| 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 | */ |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as FileSystem from "effect/FileSystem"; |
| 12 | import * as Path from "effect/Path"; |
| 13 | import * as NodePath from "node:path"; |
| 14 | import { |
| 15 | toOutputFile, |
| 16 | type BuildOutput, |
| 17 | type OutputFile, |
| 18 | } from "./BuildOutput.ts"; |
| 19 | import { DeployTargetError } from "./DeployTarget.ts"; |
| 20 | |
| 21 | /** Node resolve conditions for server code (no `workerd`). */ |
| 22 | export 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. */ |
| 30 | export const NODE_SERVE_ENTRY_FILE_NAME = "serve-node.mjs"; |
| 31 | |
| 32 | /** Default listen port (Hetzner `deployUnit` curls `/health` whenever `PORT` is set). */ |
| 33 | export const NODE_DEFAULT_PORT = 3000; |
| 34 | |
| 35 | export type NodeServeHtmlHandling = "none" | "drop-trailing-slash"; |
| 36 | export type NodeServeNotFoundHandling = "none" | "spa" | "404-page"; |
| 37 | export type NodeServeHandlerKind = "fetch" | "node"; |
| 38 | |
| 39 | export 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 | |
| 48 | export 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 | */ |
| 91 | export 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. */ |
| 114 | export 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 | */ |
| 132 | export 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 | ? ` |
| 147 | const CLIENT_DIR = ${options.clientDirExpression}; |
| 148 | |
| 149 | const 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 | |
| 171 | const 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 | |
| 188 | const 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 | |
| 198 | const 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. |
| 219 | const 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 | ? ` |
| 293 | const 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 | |
| 299 | const 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 | |
| 314 | const 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 | : ` |
| 337 | const 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. |
| 368 | import * as fs from "node:fs"; |
| 369 | import * as http from "node:http"; |
| 370 | import * as path from "node:path"; |
| 371 | ${handler !== undefined ? `import { Readable } from "node:stream";\n` : ""}import { fileURLToPath } from "node:url"; |
| 372 | ${handler?.imports ?? ""} |
| 373 | |
| 374 | const PORT = Number.parseInt(process.env.PORT ?? "${String(port)}", 10); |
| 375 | const HOST = process.env.HOST ?? "0.0.0.0"; |
| 376 | ${staticBlock}${fetchHelpers}${nodeHelpers} |
| 377 | const pathnameOf = (url) => { |
| 378 | try { |
| 379 | return decodeURIComponent(new URL(url, "http://127.0.0.1").pathname); |
| 380 | } catch { |
| 381 | return "/"; |
| 382 | } |
| 383 | }; |
| 384 | |
| 385 | const 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 | |
| 407 | export 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]`. */ |
| 420 | export 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 | }); |