1/**
2 * Shared Node HTTP serve-entry generator for container deploy targets.
3 *
4 * The finishing pass of each SSR node target writes one of these files and
5 * pins it as `serverModules[0]`. The program is a complete bun/node entry
6 * (`isExternal: true` on the platform Service): it listens on `PORT`
7 * (default 3000), answers `GET /health`, serves `clientDirectory` on GET
8 * first, then falls through to the framework fetch/Node handler.
9 */
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Path from "effect/Path";
13import * as NodePath from "node:path";
14import {
15 toOutputFile,
16 type BuildOutput,
17 type OutputFile,
18} from "./BuildOutput.ts";
19import { DeployTargetError } from "./DeployTarget.ts";
20
21/** Node resolve conditions for server code (no `workerd`). */
22export const NODE_BUNDLE_CONDITIONS = [
23 "node",
24 "import",
25 "module",
26 "default",
27] as const;
28
29/** The file name the finishing pass writes next to the framework server entry. */
30export const NODE_SERVE_ENTRY_FILE_NAME = "serve-node.mjs";
31
32/** Default listen port (Hetzner `deployUnit` curls `/health` whenever `PORT` is set). */
33export const NODE_DEFAULT_PORT = 3000;
34
35export type NodeServeHtmlHandling = "none" | "drop-trailing-slash";
36export type NodeServeNotFoundHandling = "none" | "spa" | "404-page";
37export type NodeServeHandlerKind = "fetch" | "node";
38
39export interface NodeServeHandler {
40 /** `"fetch"` is `(request) => Response`; `"node"` is `(req, res) => void`. */
41 readonly kind: NodeServeHandlerKind;
42 /** ESM import statements for the handler module. */
43 readonly imports: string;
44 /** Expression that evaluates to the fetch handler or Node listener. */
45 readonly expr: string;
46}
47
48export interface NodeServeEntryOptions {
49 /**
50 * JS expression resolving the on-disk client-assets directory
51 * (typically `fileURLToPath(new URL("../client/", import.meta.url))`).
52 * Omit to skip static-file serving (Next.js handles its own assets).
53 */
54 readonly clientDirExpression?: string | undefined;
55 /**
56 * Framework request handler. Omit for assets-only sites (Vite SPA,
57 * `vite build` output): unmatched GETs 404 after static lookup / SPA
58 * fallback.
59 */
60 readonly handler?: NodeServeHandler | undefined;
61 /**
62 * Vocs/Waku: serve `about/index.html` at `/about` (CF
63 * `htmlHandling: "drop-trailing-slash"`).
64 * @default "none"
65 */
66 readonly htmlHandling?: NodeServeHtmlHandling | undefined;
67 /**
68 * Fallback when no static file matches: `"spa"` serves `index.html`,
69 * `"404-page"` serves `404.html` with status 404. A handler still runs
70 * when the fallback file is missing.
71 * @default "none"
72 */
73 readonly notFoundHandling?: NodeServeNotFoundHandling | undefined;
74 /** HTML file served for a 404-page fallback, relative to the client directory. @default "404.html" */
75 readonly errorPage?: string | undefined;
76 /** @default 3000 */
77 readonly defaultPort?: number | undefined;
78 /**
79 * Log `http://host:port` once the listener is bound. Local `alchemy dev`
80 * StaticSite uses this so `Command.Dev` can parse the URL (`PORT=0`).
81 * @default false
82 */
83 readonly printUrl?: boolean | undefined;
84}
85
86/**
87 * JS expression that resolves `clientDirectory` relative to a serve-entry
88 * file via `import.meta.url`. Falls back to an absolute JSON string when
89 * the two paths do not share a relative root (e.g. different Windows drives).
90 */
91export const relativeClientDirExpression = (
92 fromFile: string,
93 clientDirectory: string,
94): string => {
95 const fromDir = NodePath.posix.dirname(fromFile.replaceAll("\\", "/"));
96 const client = clientDirectory.replaceAll("\\", "/");
97 let relative = NodePath.posix.relative(fromDir, client);
98 if (relative === "") {
99 relative = ".";
100 }
101 if (NodePath.posix.isAbsolute(relative)) {
102 return JSON.stringify(clientDirectory);
103 }
104 if (!relative.startsWith(".")) {
105 relative = `./${relative}`;
106 }
107 if (!relative.endsWith("/")) {
108 relative = `${relative}/`;
109 }
110 return `fileURLToPath(new URL(${JSON.stringify(relative)}, import.meta.url))`;
111};
112
113/** Pin `serveModule` as `serverModules[0]`, keeping the rest of the bundle. */
114export const pinNodeServeModule = (
115 output: BuildOutput,
116 serveModule: OutputFile,
117): BuildOutput => ({
118 ...output,
119 serverModules: [
120 serveModule,
121 ...(output.serverModules ?? []).filter(
122 (module_) => module_.name !== serveModule.name,
123 ),
124 ],
125});
126
127/**
128 * Generate a complete Node/Bun HTTP program: `/health`, GET static assets,
129 * then the framework handler. No package imports other than `node:*` and
130 * the handler module the caller passes in.
131 */
132export const makeNodeServeEntrySource = (
133 options: NodeServeEntryOptions,
134): string => {
135 const port = options.defaultPort ?? NODE_DEFAULT_PORT;
136 const htmlHandling = options.htmlHandling ?? "none";
137 const notFoundHandling = options.notFoundHandling ?? "none";
138 const hasStatic = options.clientDirExpression !== undefined;
139 const dropSlash = htmlHandling === "drop-trailing-slash";
140 const spa = notFoundHandling === "spa";
141 const notFoundPage = notFoundHandling === "404-page";
142 const handler = options.handler;
143 const handlerIsFetch = handler?.kind === "fetch";
144
145 const staticBlock = hasStatic
146 ? `
147const CLIENT_DIR = ${options.clientDirExpression};
148
149const MIME = {
150 ".html": "text/html; charset=utf-8",
151 ".js": "text/javascript; charset=utf-8",
152 ".mjs": "text/javascript; charset=utf-8",
153 ".cjs": "text/javascript; charset=utf-8",
154 ".css": "text/css; charset=utf-8",
155 ".json": "application/json",
156 ".svg": "image/svg+xml",
157 ".png": "image/png",
158 ".jpg": "image/jpeg",
159 ".jpeg": "image/jpeg",
160 ".gif": "image/gif",
161 ".ico": "image/x-icon",
162 ".webp": "image/webp",
163 ".woff": "font/woff",
164 ".woff2": "font/woff2",
165 ".txt": "text/plain; charset=utf-8",
166 ".xml": "application/xml",
167 ".wasm": "application/wasm",
168 ".map": "application/json",
169};
170
171const existingFile = (filePath, dirIndex = true) => {
172 try {
173 const st = fs.statSync(filePath);
174 if (st.isFile()) return filePath;
175 // Do not map "/" onto public/index.html — that steals SSR home
176 // routes (Nuxt) and serves an unrendered shell. Nested routes
177 // like /prerendered still resolve dir/index.html.
178 if (dirIndex && st.isDirectory()) {
179 const index = path.join(filePath, "index.html");
180 try {
181 if (fs.statSync(index).isFile()) return index;
182 } catch {}
183 }
184 } catch {}
185 return undefined;
186};
187
188const safeJoin = (urlPath) => {
189 const relative = urlPath.replace(/^\\/+/, "");
190 const resolved = path.resolve(CLIENT_DIR, relative);
191 const root = path.resolve(CLIENT_DIR);
192 if (resolved !== root && !resolved.startsWith(root + path.sep)) {
193 return undefined;
194 }
195 return resolved;
196};
197
198const lookupStatic = (urlPath) => {
199 const base = safeJoin(urlPath);
200 if (base === undefined) return undefined;
201 const isRoot = ${handler === undefined ? "false" : `(urlPath === "/" || urlPath === "")`};
202 const direct = existingFile(base, !isRoot);
203 if (direct) return direct;
204${
205 dropSlash
206 ? ` if (!path.extname(base)) {
207 const html = existingFile(base + ".html");
208 if (html) return html;
209 }
210`
211 : ""
212} return undefined;
213};
214
215// Never mark files immutable: a service worker, web manifest, or any
216// other unhashed file served with \`immutable\` pins the old app version
217// in the browser until the user clears storage, and every deploy must be
218// able to bust what it ships.
219const sendFile = (res, filePath, status) => {
220 const ext = path.extname(filePath).toLowerCase();
221 res.writeHead(status, {
222 "content-type": MIME[ext] ?? "application/octet-stream",
223 "cache-control": "no-cache",
224 });
225 fs.createReadStream(filePath).pipe(res);
226};
227`
228 : "";
229
230 const staticDispatch = hasStatic
231 ? `
232 if (req.method === "GET" || req.method === "HEAD") {
233 const file = lookupStatic(urlPath);
234 if (file !== undefined) {
235 if (req.method === "HEAD") {
236 res.writeHead(200);
237 res.end();
238 return;
239 }
240 sendFile(res, file, 200);
241 return;
242 }
243${
244 spa
245 ? ` const spaIndex = lookupStatic("/index.html");
246 if (spaIndex !== undefined) {
247 if (req.method === "HEAD") {
248 res.writeHead(200);
249 res.end();
250 return;
251 }
252 sendFile(res, spaIndex, 200);
253 return;
254 }
255`
256 : ""
257}${
258 notFoundPage
259 ? ` const notFound = lookupStatic(${JSON.stringify(`/${options.errorPage ?? "404.html"}`)});
260 if (notFound !== undefined) {
261 if (req.method === "HEAD") {
262 res.writeHead(404);
263 res.end();
264 return;
265 }
266 sendFile(res, notFound, 404);
267 return;
268 }
269`
270 : ""
271 } }
272`
273 : "";
274
275 const handlerDispatch =
276 handler === undefined
277 ? `
278 res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
279 res.end("Not Found");
280`
281 : handlerIsFetch
282 ? `
283 const request = await toRequest(req);
284 const response = await (${handler.expr})(request);
285 await writeResponse(res, response);
286`
287 : `
288 await (${handler.expr})(endedGet(req), res);
289`;
290
291 const fetchHelpers = handlerIsFetch
292 ? `
293const header = (req, name) => {
294 const value = req.headers[name];
295 if (value === undefined) return undefined;
296 return Array.isArray(value) ? value[0] : value;
297};
298
299const toRequest = (req) => {
300 const proto = header(req, "x-forwarded-proto") ?? "http";
301 const host = header(req, "host") ?? "localhost";
302 const url = proto + "://" + host + (req.url ?? "/");
303 const method = req.method ?? "GET";
304 const headers = new Headers();
305 for (const [key, value] of Object.entries(req.headers)) {
306 if (value === undefined) continue;
307 headers.set(key, Array.isArray(value) ? value.join(", ") : value);
308 }
309 const body =
310 method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(req);
311 return new Request(url, { method, headers, body, duplex: "half" });
312};
313
314const writeResponse = async (res, response) => {
315 const headers = {};
316 response.headers.forEach((value, key) => {
317 headers[key] = value;
318 });
319 res.writeHead(response.status, headers);
320 if (response.body) {
321 Readable.fromWeb(response.body).pipe(res);
322 } else {
323 res.end();
324 }
325};
326`
327 : "";
328
329 // Bun's node:http IncomingMessage often never emits "end" for GET, so
330 // h3/nitro waits forever. A plain Readable.from([]) is not an
331 // IncomingMessage; h3 still hangs. Construct a real IncomingMessage
332 // that is already complete.
333 const nodeHelpers =
334 handler === undefined || handlerIsFetch
335 ? ""
336 : `
337const endedGet = (req) => {
338 const method = (req.method ?? "GET").toUpperCase();
339 if (method !== "GET" && method !== "HEAD") return req;
340 const headers = { ...req.headers };
341 delete headers["content-length"];
342 delete headers["transfer-encoding"];
343 const fake = new http.IncomingMessage(req.socket);
344 fake.method = req.method;
345 fake.url = req.url;
346 fake.headers = headers;
347 fake.httpVersion = req.httpVersion ?? "1.1";
348 fake.httpVersionMajor = req.httpVersionMajor ?? 1;
349 fake.httpVersionMinor = req.httpVersionMinor ?? 1;
350 fake.complete = true;
351 fake.push(null);
352 return fake;
353};
354`;
355
356 const listen = options.printUrl
357 ? `server.listen(PORT, HOST, () => {
358 const addr = server.address();
359 const bound = typeof addr === "object" && addr !== null ? addr.port : PORT;
360 const printed = HOST === "0.0.0.0" || HOST === "::" ? "localhost" : HOST;
361 console.log("http://" + printed + ":" + bound);
362});
363`
364 : `server.listen(PORT, HOST);
365`;
366
367 return `// Generated by @alchemy.run/frontend-frameworks — Node container serve entry.
368import * as fs from "node:fs";
369import * as http from "node:http";
370import * as path from "node:path";
371${handler !== undefined ? `import { Readable } from "node:stream";\n` : ""}import { fileURLToPath } from "node:url";
372${handler?.imports ?? ""}
373
374const PORT = Number.parseInt(process.env.PORT ?? "${String(port)}", 10);
375const HOST = process.env.HOST ?? "0.0.0.0";
376${staticBlock}${fetchHelpers}${nodeHelpers}
377const pathnameOf = (url) => {
378 try {
379 return decodeURIComponent(new URL(url, "http://127.0.0.1").pathname);
380 } catch {
381 return "/";
382 }
383};
384
385const server = http.createServer(async (req, res) => {
386 try {
387 const urlPath = pathnameOf(req.url ?? "/");
388 if (
389 (req.method === "GET" || req.method === "HEAD") &&
390 urlPath === "/health"
391 ) {
392 res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
393 res.end("ok");
394 return;
395 }
396${staticDispatch}${handlerDispatch} } catch (error) {
397 if (!res.headersSent) {
398 res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
399 }
400 res.end(error instanceof Error ? (error.stack ?? error.message) : String(error));
401 }
402});
403
404${listen}`;
405};
406
407export interface WriteNodeServeEntryOptions extends NodeServeEntryOptions {
408 readonly output: BuildOutput;
409 /** Absolute path of the serve-entry file to write. */
410 readonly servePath: string;
411 /**
412 * Module name relative to `distDirectory` (POSIX), e.g.
413 * `server/serve-node.mjs`. Becomes `serverModules[0]`.
414 */
415 readonly serveModuleName: string;
416 readonly platform?: string | undefined;
417}
418
419/** Write the serve entry to disk and pin it as `serverModules[0]`. */
420export const writeNodeServeEntry = (
421 options: WriteNodeServeEntryOptions,
422): Effect.Effect<
423 BuildOutput,
424 DeployTargetError,
425 FileSystem.FileSystem | Path.Path
426> =>
427 Effect.gen(function* () {
428 const fs = yield* FileSystem.FileSystem;
429 const path = yield* Path.Path;
430 const fail = (message: string) => (cause?: unknown) =>
431 new DeployTargetError({
432 platform: options.platform ?? "node",
433 message,
434 cause,
435 });
436 const source = makeNodeServeEntrySource(options);
437 yield* fs
438 .makeDirectory(path.dirname(options.servePath), { recursive: true })
439 .pipe(Effect.mapError(fail("Failed to create the Node serve directory")));
440 yield* fs
441 .writeFileString(options.servePath, source)
442 .pipe(Effect.mapError(fail("Failed to write the Node serve entry")));
443 const serveModule = yield* toOutputFile(options.serveModuleName, source);
444 return pinNodeServeModule(
445 {
446 ...options.output,
447 nodeServe: {
448 clientDirExpression: options.clientDirExpression,
449 handler: options.handler,
450 htmlHandling: options.htmlHandling,
451 notFoundHandling: options.notFoundHandling,
452 },
453 },
454 serveModule,
455 );
456 });