| 1 | /** |
| 2 | * `@alchemy.run/frontend-frameworks/sveltekit/node` — the Node container |
| 3 | * deploy target for `@alchemy.run/frontend-frameworks/sveltekit`. |
| 4 | * |
| 5 | * SvelteKit's Node story mirrors adapter-node: kit's server graph |
| 6 | * (`.svelte-kit/output/server`) is Node-flavored ESM. The finishing pass |
| 7 | * re-bundles that graph with rolldown into `dist/server`, then writes a |
| 8 | * Node HTTP program that serves `clientDirectory` first, then falls |
| 9 | * through to kit's `Server.respond` on `PORT` (default 3000), and answers |
| 10 | * `GET /health`. |
| 11 | * |
| 12 | * - **`adapter(context)`** — an in-memory kit `Adapter` that writes client |
| 13 | * assets + prerendered pages and an unbundled fetch-handler entry. |
| 14 | * - **`finish(output, context)`** — re-bundles the entry for Node, writes |
| 15 | * the HTTP serve entry, pins it as `serverModules[0]`. |
| 16 | * - **`bundle`** — Node resolve conditions (no `workerd`, no `@aws-sdk/`). |
| 17 | */ |
| 18 | import type { Builder } from "@sveltejs/kit"; |
| 19 | import * as Effect from "effect/Effect"; |
| 20 | import * as FileSystem from "effect/FileSystem"; |
| 21 | import * as Path from "effect/Path"; |
| 22 | import * as NodeFs from "node:fs"; |
| 23 | import * as NodePath from "node:path"; |
| 24 | import { rolldown } from "rolldown"; |
| 25 | import { runBuildChild } from "../core/BuildChild.ts"; |
| 26 | import * as FrameworkCore from "../core/index.ts"; |
| 27 | import { DeployTargetError, makeDeployTarget } from "../core/index.ts"; |
| 28 | import { |
| 29 | NODE_BUNDLE_CONDITIONS, |
| 30 | NODE_SERVE_ENTRY_FILE_NAME, |
| 31 | relativeClientDirExpression, |
| 32 | writeNodeServeEntry, |
| 33 | } from "../core/NodeServe.ts"; |
| 34 | import { |
| 35 | make, |
| 36 | type SvelteKitAdapter, |
| 37 | type SvelteKitAdapterResult, |
| 38 | type SvelteKitTarget, |
| 39 | type SvelteKitTargetConfig, |
| 40 | } from "./SvelteKit.ts"; |
| 41 | |
| 42 | const fail = (message: string, cause?: unknown) => |
| 43 | new DeployTargetError({ platform: "node", message, cause }); |
| 44 | |
| 45 | export interface SvelteKitNodeTargetConfig extends SvelteKitTargetConfig {} |
| 46 | |
| 47 | /** The bundled fetch-handler module the finishing pass writes. */ |
| 48 | export const SERVER_ENTRY_NAME = NodePath.join("server", "index.mjs"); |
| 49 | |
| 50 | const generateFetchEntry = (options: { |
| 51 | readonly serverImport: string; |
| 52 | }): string => |
| 53 | /* js */ ` |
| 54 | import { server } from ${JSON.stringify(options.serverImport)}; |
| 55 | |
| 56 | const initialized = server.init({ env: process.env }); |
| 57 | |
| 58 | export const handler = async (request) => { |
| 59 | await initialized; |
| 60 | return await server.respond(request, { |
| 61 | getClientAddress: () => |
| 62 | request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? '127.0.0.1', |
| 63 | }); |
| 64 | }; |
| 65 | `.trimStart(); |
| 66 | |
| 67 | export const makeNodeAdapter = (): SvelteKitAdapter => { |
| 68 | const result: { current?: SvelteKitAdapterResult | undefined } = {}; |
| 69 | return { |
| 70 | name: "@alchemy.run/frontend-frameworks/sveltekit/node", |
| 71 | result, |
| 72 | async adapt(builder: Builder) { |
| 73 | const dest = builder.getBuildDirectory("node"); |
| 74 | const tmp = builder.getBuildDirectory("node-tmp"); |
| 75 | |
| 76 | builder.rimraf(dest); |
| 77 | builder.rimraf(tmp); |
| 78 | builder.mkdirp(dest); |
| 79 | builder.mkdirp(tmp); |
| 80 | |
| 81 | const assetsDest = dest + builder.config.paths.base; |
| 82 | builder.mkdirp(assetsDest); |
| 83 | builder.writeClient(assetsDest); |
| 84 | builder.writePrerendered(assetsDest); |
| 85 | |
| 86 | // pre-built server instance: kit 3.0 no longer exposes the internal |
| 87 | // SSR manifest (`generateManifest` throws), so `generateServerInstance` |
| 88 | // writes `export const server = new Server(manifest)` straight to |
| 89 | // disk itself instead of returning a manifest string to embed. The |
| 90 | // Node serve entry checks the filesystem for static assets, so unlike |
| 91 | // the Cloudflare worker shim there's no route manifest to build. |
| 92 | builder.generateServerInstance(NodePath.join(tmp, "server.js")); |
| 93 | |
| 94 | const workerEntry = NodePath.join(tmp, "entry.js"); |
| 95 | NodeFs.writeFileSync( |
| 96 | workerEntry, |
| 97 | generateFetchEntry({ |
| 98 | serverImport: "./server.js", |
| 99 | }), |
| 100 | ); |
| 101 | if ( |
| 102 | typeof builder.hasServerInstrumentationFile === "function" && |
| 103 | builder.hasServerInstrumentationFile() |
| 104 | ) { |
| 105 | // kit 3.0 requires an explicit initializer module that populates |
| 106 | // `$env/dynamic/private` before instrumentation runs; the default |
| 107 | // (`process.env`) is correct for the Node runtime. |
| 108 | const initializer = builder.createInstrumentationInitializer({ |
| 109 | outputDirectory: tmp, |
| 110 | }); |
| 111 | builder.instrument({ |
| 112 | entrypoint: workerEntry, |
| 113 | instrumentation: NodePath.join( |
| 114 | builder.getServerDirectory(), |
| 115 | "instrumentation.server.js", |
| 116 | ), |
| 117 | initializer, |
| 118 | }); |
| 119 | } |
| 120 | |
| 121 | result.current = { dest, workerEntry }; |
| 122 | }, |
| 123 | dispose: async () => {}, |
| 124 | }; |
| 125 | }; |
| 126 | |
| 127 | const makeNodeAdapterTarget = ( |
| 128 | config: SvelteKitNodeTargetConfig = {}, |
| 129 | ): SvelteKitTarget => |
| 130 | makeDeployTarget({ |
| 131 | platform: "node", |
| 132 | config, |
| 133 | bundle: { |
| 134 | conditions: [...NODE_BUNDLE_CONDITIONS], |
| 135 | }, |
| 136 | adapter: (_context) => makeNodeAdapter(), |
| 137 | finish: (output, context) => |
| 138 | Effect.gen(function* () { |
| 139 | const fs = yield* FileSystem.FileSystem; |
| 140 | const path = yield* Path.Path; |
| 141 | const entry = context.entry; |
| 142 | if (entry === undefined) { |
| 143 | return yield* Effect.fail( |
| 144 | fail( |
| 145 | "The SvelteKit build produced no on-disk server entry for the finishing pass " + |
| 146 | "(context.entry is missing)", |
| 147 | ), |
| 148 | ); |
| 149 | } |
| 150 | const root = context.root; |
| 151 | const distDirectory = |
| 152 | output.distDirectory ?? path.resolve(root, "dist"); |
| 153 | if (output.clientDirectory === undefined) { |
| 154 | return yield* Effect.fail( |
| 155 | fail( |
| 156 | "The SvelteKit build produced no client directory for the Node serve entry", |
| 157 | ), |
| 158 | ); |
| 159 | } |
| 160 | // Container hosts package only distDirectory. Keep the assets beside |
| 161 | // the server so the output remains runnable after it is relocated. |
| 162 | const clientDirectory = path.join(distDirectory, "client"); |
| 163 | yield* fs |
| 164 | .remove(clientDirectory, { recursive: true, force: true }) |
| 165 | .pipe( |
| 166 | Effect.andThen(fs.copy(output.clientDirectory, clientDirectory)), |
| 167 | Effect.mapError((error) => |
| 168 | fail("Failed to package the SvelteKit client assets", error), |
| 169 | ), |
| 170 | ); |
| 171 | const serverOutDir = path.join(distDirectory, "server"); |
| 172 | yield* fs |
| 173 | .remove(serverOutDir, { recursive: true, force: true }) |
| 174 | .pipe( |
| 175 | Effect.mapError((error) => |
| 176 | fail("Failed to clean dist/server", error), |
| 177 | ), |
| 178 | ); |
| 179 | |
| 180 | yield* Effect.tryPromise({ |
| 181 | try: async () => { |
| 182 | const bundle = await rolldown({ |
| 183 | cwd: root, |
| 184 | input: entry, |
| 185 | platform: "node", |
| 186 | resolve: { |
| 187 | conditionNames: [...NODE_BUNDLE_CONDITIONS], |
| 188 | }, |
| 189 | external: [/^node:/], |
| 190 | }); |
| 191 | try { |
| 192 | await bundle.write({ |
| 193 | dir: serverOutDir, |
| 194 | format: "esm", |
| 195 | entryFileNames: "index.mjs", |
| 196 | chunkFileNames: "chunks/[name].mjs", |
| 197 | sourcemap: false, |
| 198 | }); |
| 199 | } finally { |
| 200 | await bundle.close(); |
| 201 | } |
| 202 | }, |
| 203 | catch: (error) => fail("Failed to bundle the Node server", error), |
| 204 | }); |
| 205 | |
| 206 | const modules = yield* FrameworkCore.readServerModulesFromDisk({ |
| 207 | directory: serverOutDir, |
| 208 | prefix: "server", |
| 209 | }).pipe(Effect.mapError((error) => fail(error.message, error.cause))); |
| 210 | const bundled: FrameworkCore.BuildOutput = { |
| 211 | ...output, |
| 212 | distDirectory, |
| 213 | clientDirectory, |
| 214 | serverModules: FrameworkCore.sortServerModules( |
| 215 | modules, |
| 216 | SERVER_ENTRY_NAME, |
| 217 | ), |
| 218 | }; |
| 219 | const servePath = path.join(serverOutDir, NODE_SERVE_ENTRY_FILE_NAME); |
| 220 | return yield* writeNodeServeEntry({ |
| 221 | output: bundled, |
| 222 | servePath, |
| 223 | serveModuleName: path |
| 224 | .join("server", NODE_SERVE_ENTRY_FILE_NAME) |
| 225 | .replaceAll("\\", "/"), |
| 226 | clientDirExpression: relativeClientDirExpression( |
| 227 | servePath, |
| 228 | clientDirectory, |
| 229 | ), |
| 230 | htmlHandling: "drop-trailing-slash", |
| 231 | handler: { |
| 232 | kind: "fetch", |
| 233 | imports: `import { handler } from "./index.mjs";`, |
| 234 | expr: "handler", |
| 235 | }, |
| 236 | notFoundHandling: |
| 237 | config.adapter?.notFoundHandling === "single-page-application" |
| 238 | ? "spa" |
| 239 | : config.adapter?.notFoundHandling === "404-page" |
| 240 | ? "404-page" |
| 241 | : "none", |
| 242 | platform: "node", |
| 243 | }); |
| 244 | }), |
| 245 | }); |
| 246 | |
| 247 | export interface SvelteKitNodeBuildChildConfig { |
| 248 | readonly rootDir: string; |
| 249 | readonly config: SvelteKitNodeTargetConfig; |
| 250 | } |
| 251 | |
| 252 | export const buildInChild = (config: SvelteKitNodeBuildChildConfig) => |
| 253 | Effect.gen(function* () { |
| 254 | const framework = yield* make({ |
| 255 | root: config.rootDir, |
| 256 | target: makeNodeAdapterTarget(config.config), |
| 257 | compatibilityDate: config.config.compatibilityDate, |
| 258 | compatibilityFlags: |
| 259 | config.config.compatibilityFlags !== undefined |
| 260 | ? [...config.config.compatibilityFlags] |
| 261 | : undefined, |
| 262 | kit: config.config.kit, |
| 263 | adapter: config.config.adapter, |
| 264 | }); |
| 265 | return yield* framework.build({ root: config.rootDir }); |
| 266 | }); |
| 267 | |
| 268 | export const makeNodeTarget = ( |
| 269 | config: SvelteKitNodeTargetConfig = {}, |
| 270 | ): SvelteKitTarget => ({ |
| 271 | ...makeNodeAdapterTarget(config), |
| 272 | build: (context) => |
| 273 | runBuildChild({ |
| 274 | module: import.meta.url, |
| 275 | rootDir: context.root, |
| 276 | env: context.env, |
| 277 | framework: "sveltekit", |
| 278 | config: { |
| 279 | rootDir: context.root, |
| 280 | config, |
| 281 | } satisfies SvelteKitNodeBuildChildConfig, |
| 282 | }).pipe(Effect.mapError((error) => fail(error.message, error.cause))), |
| 283 | }); |
| 284 | |
| 285 | export const target = makeNodeTarget; |
| 286 | |
| 287 | export default makeNodeTarget; |