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