1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import { createRequire } from "node:module";
5import { toOutputFile, type BuildOutput } from "./BuildOutput.ts";
6import { DeployTargetError, type DeployTarget } from "./DeployTarget.ts";
7import { pinNodeServeModule, type NodeServeEntryOptions } from "./NodeServe.ts";
8
9/** Fetch entry emitted for Neon Functions; no listening socket is created. */
10export const NEON_SERVE_ENTRY_FILE_NAME = "serve-neon.mjs";
11
12/** Generate a Node 24 Fetch module from a framework's portable handler description. */
13export const makeNeonServeEntrySource = (
14 options: NodeServeEntryOptions,
15): string => {
16 const handler = options.handler;
17 return `import * as fs from "node:fs";
18import * as path from "node:path";
19import { fileURLToPath } from "node:url";
20import { Readable } from "node:stream";
21${handler?.kind === "node" ? 'import { toFetchHandler } from "srvx/node";' : ""}
22${handler?.imports ?? ""}
23const handle = ${handler === undefined ? "undefined" : handler.kind === "node" ? `toFetchHandler((request, response) => { Promise.resolve((${handler.expr})(request, response)).catch(error => response.destroy(error)); })` : handler.expr};
24const client = ${options.clientDirExpression ?? "undefined"};
25const mime = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".ico": "image/x-icon", ".webp": "image/webp", ".avif": "image/avif", ".woff": "font/woff", ".woff2": "font/woff2", ".txt": "text/plain; charset=utf-8", ".xml": "application/xml", ".wasm": "application/wasm", ".pdf": "application/pdf" };
26const within = (root, file) => file === root || file.startsWith(root + path.sep);
27const lookup = (pathname, directoryIndex = true) => {
28 if (client === undefined) return;
29 if (pathname.split("/").some(part => part.startsWith(".") || part === "serve-node.mjs" || part === "serve-neon.mjs")) return;
30 const root = path.resolve(client);
31 let file = path.resolve(root, pathname.replace(/^\\/+/, ""));
32 if (!within(root, file)) return;
33 try {
34 let stat = fs.statSync(file);
35 if (stat.isDirectory() && directoryIndex) {
36 file = path.join(file, "index.html");
37 stat = fs.statSync(file);
38 }
39 if (!stat.isFile() || !within(fs.realpathSync(root), fs.realpathSync(file))) return;
40 return { file, size: stat.size };
41 } catch (error) {
42 if (error.code === "ENOENT" || error.code === "ENOTDIR") return;
43 throw error;
44 }
45};
46const serve = (found, request, status = 200) => new Response(
47 request.method === "HEAD" ? null : Readable.toWeb(fs.createReadStream(found.file)),
48 { status, headers: { "content-type": mime[path.extname(found.file).toLowerCase()] ?? "application/octet-stream", "content-length": String(found.size), "cache-control": "no-cache" } },
49);
50export default {
51 async fetch(request) {
52 try {
53 const url = new URL(request.url);
54 const forwarded = request.headers.get("x-forwarded-host");
55 if (forwarded && /^[a-zA-Z0-9.\\-]+(?::[0-9]+)?$/.test(forwarded)) {
56 url.host = forwarded;
57 request = new Request(url, request);
58 }
59 let pathname;
60 try { pathname = decodeURIComponent(url.pathname); } catch { return new Response("Bad Request", { status: 400 }); }
61 if (pathname.includes("\\0") || pathname.includes("\\\\")) return new Response("Bad Request", { status: 400 });
62 if (request.method === "GET" || request.method === "HEAD") {
63 const found = lookup(pathname, handle === undefined || pathname !== "/")${options.htmlHandling === "drop-trailing-slash" ? ' ?? (!path.extname(pathname) ? lookup(pathname + ".html") : undefined)' : ""};
64 if (found) return serve(found, request);
65${options.notFoundHandling === "spa" ? ' const fallback = lookup("/index.html");\n if (fallback) return serve(fallback, request);' : ""}
66 }
67 if (handle) {
68 const response = await handle(request);
69 if (request.method !== "HEAD" || response.body === null) return response;
70 await response.body.cancel();
71 return new Response(null, { status: response.status, statusText: response.statusText, headers: response.headers });
72 }
73${options.notFoundHandling === "404-page" ? ' if (request.method === "GET" || request.method === "HEAD") {\n const missing = lookup("/404.html");\n if (missing) return serve(missing, request, 404);\n }' : ""}
74 return new Response(request.method === "HEAD" ? null : "Not Found", { status: 404 });
75 } catch (error) {
76 console.error("Neon website request failed", error);
77 return new Response("Internal Server Error", { status: 500 });
78 }
79 },
80};
81`;
82};
83
84/** Replace a Node target's listening wrapper with a Fetch wrapper, preserving its actual framework build. */
85export const finishNeonOutput = (output: BuildOutput) =>
86 Effect.gen(function* () {
87 const fs = yield* FileSystem.FileSystem;
88 const path = yield* Path.Path;
89 const entry = output.serverModules?.[0];
90 if (entry?.name.endsWith(NEON_SERVE_ENTRY_FILE_NAME)) return output;
91 if (!output.distDirectory || !entry || !output.nodeServe) {
92 return yield* Effect.fail(
93 new DeployTargetError({
94 platform: "neon",
95 message:
96 "The framework did not provide a portable Node handler description for Neon.",
97 }),
98 );
99 }
100 const name = path.join(
101 path.dirname(entry.name),
102 NEON_SERVE_ENTRY_FILE_NAME,
103 );
104 let source = makeNeonServeEntrySource(output.nodeServe);
105 if (output.nodeServe.handler?.kind === "node") {
106 const adapter = yield* Effect.try(() =>
107 createRequire(import.meta.url).resolve("srvx/node"),
108 );
109 const bundle = yield* Effect.tryPromise(() =>
110 import("esbuild").then((esbuild) =>
111 esbuild.build({
112 entryPoints: [adapter],
113 bundle: true,
114 platform: "node",
115 target: "node24",
116 format: "esm",
117 write: false,
118 }),
119 ),
120 );
121 const adapterPath = path.join(
122 output.distDirectory,
123 path.dirname(name),
124 "neon-node-adapter.mjs",
125 );
126 yield* fs.writeFile(adapterPath, bundle.outputFiles[0]!.contents);
127 source = source.replace(
128 'from "srvx/node"',
129 'from "./neon-node-adapter.mjs"',
130 );
131 }
132 yield* fs.writeFileString(path.join(output.distDirectory, name), source);
133 const module = yield* toOutputFile(name, source);
134 const result = pinNodeServeModule(
135 {
136 ...output,
137 serverModules: output.serverModules?.filter(
138 (item) => item.name !== entry.name,
139 ),
140 },
141 module,
142 );
143 yield* fs.remove(path.join(output.distDirectory, entry.name), {
144 force: true,
145 });
146 return result;
147 }).pipe(
148 Effect.mapError((cause) =>
149 cause instanceof DeployTargetError
150 ? cause
151 : new DeployTargetError({
152 platform: "neon",
153 message: "Failed to emit the Neon Fetch entry.",
154 cause,
155 }),
156 ),
157 );
158
159/** Reuse framework build/configuration hooks while selecting Neon's Fetch runtime. */
160export const makeNeonTarget = <T extends DeployTarget>(node: T): T => ({
161 ...node,
162 platform: "neon",
163 ...(node.build
164 ? {
165 build: (context) =>
166 node.build!(context).pipe(Effect.flatMap(finishNeonOutput)),
167 }
168 : {}),
169 ...(node.finish
170 ? {
171 finish: (output, context) =>
172 node.finish!(output, context).pipe(Effect.flatMap(finishNeonOutput)),
173 }
174 : {}),
175});