| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as FileSystem from "effect/FileSystem"; |
| 3 | import * as Path from "effect/Path"; |
| 4 | import { createRequire } from "node:module"; |
| 5 | import { toOutputFile, type BuildOutput } from "./BuildOutput.ts"; |
| 6 | import { DeployTargetError, type DeployTarget } from "./DeployTarget.ts"; |
| 7 | import { pinNodeServeModule, type NodeServeEntryOptions } from "./NodeServe.ts"; |
| 8 | |
| 9 | /** Fetch entry emitted for Neon Functions; no listening socket is created. */ |
| 10 | export const NEON_SERVE_ENTRY_FILE_NAME = "serve-neon.mjs"; |
| 11 | |
| 12 | /** Generate a Node 24 Fetch module from a framework's portable handler description. */ |
| 13 | export const makeNeonServeEntrySource = ( |
| 14 | options: NodeServeEntryOptions, |
| 15 | ): string => { |
| 16 | const handler = options.handler; |
| 17 | return `import * as fs from "node:fs"; |
| 18 | import * as path from "node:path"; |
| 19 | import { fileURLToPath } from "node:url"; |
| 20 | import { Readable } from "node:stream"; |
| 21 | ${handler?.kind === "node" ? 'import { toFetchHandler } from "srvx/node";' : ""} |
| 22 | ${handler?.imports ?? ""} |
| 23 | const handle = ${handler === undefined ? "undefined" : handler.kind === "node" ? `toFetchHandler((request, response) => { Promise.resolve((${handler.expr})(request, response)).catch(error => response.destroy(error)); })` : handler.expr}; |
| 24 | const client = ${options.clientDirExpression ?? "undefined"}; |
| 25 | const 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" }; |
| 26 | const within = (root, file) => file === root || file.startsWith(root + path.sep); |
| 27 | const 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 | }; |
| 46 | const 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 | ); |
| 50 | export 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. */ |
| 85 | export 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. */ |
| 160 | export 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 | }); |