1/**
2 * `@alchemy.run/frontend-frameworks/react-router/node` — the Node container
3 * deploy target for the React Router v7 integration.
4 *
5 * React Router builds through Vite: `reactRouter()` in the project's
6 * `vite.config.ts` builds a `client` environment into
7 * `<buildDirectory>/client` and a server environment into
8 * `<buildDirectory>/server`. Its server output is a `ServerBuild` MANIFEST
9 * with no default export, so the framework half makes the server build's
10 * rollup input a wrapper that turns the manifest into a fetch handler with
11 * `createRequestHandler` from `react-router` (see `serverEntrySource` in
12 * `ReactRouter.ts`). What this target owns is the Node packaging on top of
13 * that fetch handler:
14 *
15 * - **`serverEntryFileName`** — `index.js`, the chunk the server pass emits
16 * (React Router's `serverBuildFile`); the finishing pass wraps it.
17 * - **`finish`** — emits the Node deployment surface next to the entry: a
18 * `package.json` (`type: "module"` so Node classifies the `.js` server
19 * build as ESM) and a Node HTTP program (`serve-node.mjs`) that serves
20 * `clientDirectory` first, then falls through to that fetch handler on
21 * `PORT` (default 3000), and answers `GET /health`.
22 * - **`bundle`** — Node resolve conditions (no `workerd`, no `@aws-sdk/`).
23 *
24 * No Lambda adapter: the serve entry talks to the fetch handler directly.
25 */
26import * as Effect from "effect/Effect";
27import * as FileSystem from "effect/FileSystem";
28import * as Path from "effect/Path";
29import { runBuildChild } from "../core/BuildChild.ts";
30import {
31 NODE_BUNDLE_CONDITIONS,
32 NODE_SERVE_ENTRY_FILE_NAME,
33 relativeClientDirExpression,
34 writeNodeServeEntry,
35} from "../core/NodeServe.ts";
36import { DeployTargetError, makeDeployTarget } from "../core/index.ts";
37import {
38 DEFAULT_SERVER_BUILD_FILE,
39 make,
40 type ReactRouterTarget,
41 type ReactRouterTargetConfig,
42} from "./ReactRouter.ts";
43
44/** The server entry file name React Router's server pass emits. */
45export const SERVER_ENTRY_FILE_NAME = DEFAULT_SERVER_BUILD_FILE;
46
47/** Node-specific knobs carried on the shared {@link ReactRouterTargetConfig}. */
48export interface ReactRouterNodeTargetConfig extends ReactRouterTargetConfig {}
49
50/** The Node {@link ReactRouterTarget}, carrying the Node-specific config. */
51export interface ReactRouterNodeTarget extends ReactRouterTarget {
52 readonly config: ReactRouterNodeTargetConfig;
53}
54
55const fail = (message: string, cause?: unknown) =>
56 new DeployTargetError({ platform: "node", message, cause });
57
58/**
59 * Import the server build's fetch handler. The entry module default-exports
60 * `{ fetch }` (the shape the integration's virtual server entry produces). A
61 * project whose own entry default-exports the bare fetch function is
62 * accepted too.
63 */
64const fetchHandlerImports = (serverEntryFileName: string): string =>
65 `import * as serverEntry from ${JSON.stringify(`./${serverEntryFileName}`)};
66
67const entry = serverEntry.default ?? serverEntry;
68const fetchHandler =
69 typeof entry === "function"
70 ? entry
71 : (request) => entry.fetch(request);`;
72
73const makeNodeFinishTarget = (
74 config: ReactRouterNodeTargetConfig = {},
75): ReactRouterNodeTarget =>
76 makeDeployTarget({
77 platform: "node",
78 config,
79 bundle: {
80 conditions: [...NODE_BUNDLE_CONDITIONS],
81 },
82 serverEntryFileName: SERVER_ENTRY_FILE_NAME,
83 finish: (output, context) =>
84 Effect.gen(function* () {
85 const fs = yield* FileSystem.FileSystem;
86 const path = yield* Path.Path;
87 if (context.entry === undefined) {
88 return yield* Effect.fail(
89 fail(
90 "The React Router build produced no on-disk server entry to finish",
91 ),
92 );
93 }
94 if (output.clientDirectory === undefined) {
95 return yield* Effect.fail(
96 fail(
97 "The React Router build produced no client directory for the Node serve entry",
98 ),
99 );
100 }
101 const serverDir = path.dirname(context.entry);
102 const serverEntryFileName = path.basename(context.entry);
103 yield* fs
104 .writeFileString(
105 path.join(serverDir, "package.json"),
106 '{"type":"module"}\n',
107 )
108 .pipe(
109 Effect.mapError((error) =>
110 fail("Failed to write the server package.json", error),
111 ),
112 );
113 const servePath = path.join(serverDir, NODE_SERVE_ENTRY_FILE_NAME);
114 return yield* writeNodeServeEntry({
115 output,
116 servePath,
117 serveModuleName: path
118 .join("server", NODE_SERVE_ENTRY_FILE_NAME)
119 .replaceAll("\\", "/"),
120 clientDirExpression: relativeClientDirExpression(
121 servePath,
122 output.clientDirectory,
123 ),
124 handler: {
125 kind: "fetch",
126 imports: fetchHandlerImports(serverEntryFileName),
127 expr: "fetchHandler",
128 },
129 platform: "node",
130 });
131 }),
132 });
133
134/**
135 * A React Router build loads and executes the project's `vite.config.*` and
136 * `react-router.config.ts` (user plugins may mutate `process.env` or
137 * `process.chdir`, and React Router's config loader spins its own vite-node
138 * server) — none of which may touch the engine process, where many deploys
139 * share one event loop. This target's wholesale `build` therefore runs the
140 * framework in a disposable child process whose working directory IS the
141 * project root (see `core/BuildChild.ts`). The shared
142 * `core/BuildChildRunner` entry imports this module in the child and calls
143 * the exported {@link buildInChild}.
144 */
145export interface ReactRouterNodeBuildChildConfig {
146 readonly rootDir: string;
147 /** The (JSON-serializable) target config the parent was created with. */
148 readonly config: ReactRouterNodeTargetConfig;
149}
150
151export const buildInChild = (config: ReactRouterNodeBuildChildConfig) =>
152 Effect.gen(function* () {
153 const framework = yield* make({
154 root: config.rootDir,
155 // The finish-only target: no wholesale `build` hook, so the child
156 // runs the regular vite build + finish pipeline (no recursion).
157 target: makeNodeFinishTarget(config.config),
158 buildDirectory: config.config.buildDirectory,
159 });
160 return yield* framework.build({ root: config.rootDir });
161 });
162
163/**
164 * Create the Node {@link ReactRouterTarget}. See the module doc for the
165 * seams.
166 */
167export const makeNodeTarget = (
168 config: ReactRouterNodeTargetConfig = {},
169): ReactRouterNodeTarget => ({
170 ...makeNodeFinishTarget(config),
171 build: (context) =>
172 runBuildChild({
173 module: import.meta.url,
174 rootDir: context.root,
175 env: context.env,
176 framework: "react-router",
177 config: {
178 rootDir: context.root,
179 config,
180 } satisfies ReactRouterNodeBuildChildConfig,
181 }).pipe(Effect.mapError((error) => fail(error.message, error.cause))),
182});
183
184/**
185 * The deploy-target module contract (`resolveDeployTarget` accepts the
186 * default export — or the named `target` export — as a value or factory).
187 */
188export const target = makeNodeTarget;
189
190export default makeNodeTarget;