| 1 | /** |
| 2 | * `@alchemy.run/frontend-frameworks/sveltekit/aws` — the AWS Lambda deploy |
| 3 | * target for `@alchemy.run/frontend-frameworks/sveltekit`. |
| 4 | * |
| 5 | * SvelteKit's AWS story mirrors adapter-node: kit's server graph |
| 6 | * (`.svelte-kit/output/server`) is Node-flavored ESM, so the Lambda ships |
| 7 | * a Node bundle whose entry wraps kit's `Server.respond` fetch handler with |
| 8 | * the `@alchemy.run/frontend-frameworks/aws-lambda` adapter |
| 9 | * (`awslambda.streamifyResponse` behind a Function URL with |
| 10 | * `invokeMode: RESPONSE_STREAM`). What this target owns: |
| 11 | * |
| 12 | * - **`adapter(context)`** — an in-memory kit `Adapter` that writes client |
| 13 | * assets + prerendered pages into the static-assets directory (uploaded |
| 14 | * wholesale to S3), generates the route manifest module, and emits an |
| 15 | * unbundled Lambda entry importing kit's server from |
| 16 | * `.svelte-kit/output/server`. |
| 17 | * - **`finish(output, context)`** — re-bundles the entry (and the whole |
| 18 | * server graph it imports, including the aws-lambda adapter) for Node |
| 19 | * with rolldown into `dist/server` — a self-contained deployment unit |
| 20 | * the Lambda ships as-is (`bundle: false`). |
| 21 | * - **`bundle`** — Node resolve conditions and the `@aws-sdk/` externals |
| 22 | * (the Lambda Node.js runtime ships SDK v3). |
| 23 | * |
| 24 | * No dev platform: `dev` runs kit's own Vite dev server (Node SSR), which |
| 25 | * is already the AWS Lambda programming model (plain Node, `process.env`). |
| 26 | */ |
| 27 | import * as FrameworkCore from "../core/index.ts"; |
| 28 | import { DeployTargetError, makeDeployTarget } from "../core/index.ts"; |
| 29 | import type { Builder } from "@sveltejs/kit"; |
| 30 | import * as Effect from "effect/Effect"; |
| 31 | import * as FileSystem from "effect/FileSystem"; |
| 32 | import * as NodeFs from "node:fs"; |
| 33 | import * as NodePath from "node:path"; |
| 34 | import * as Path from "effect/Path"; |
| 35 | import { rolldown } from "rolldown"; |
| 36 | import { runBuildChild } from "../core/BuildChild.ts"; |
| 37 | import { |
| 38 | make, |
| 39 | type SvelteKitAdapter, |
| 40 | type SvelteKitAdapterResult, |
| 41 | type SvelteKitTarget, |
| 42 | type SvelteKitTargetConfig, |
| 43 | } from "./SvelteKit.ts"; |
| 44 | |
| 45 | const fail = (message: string, cause?: unknown) => |
| 46 | new DeployTargetError({ platform: "aws", message, cause }); |
| 47 | |
| 48 | /** AWS-specific knobs carried on the shared {@link SvelteKitTargetConfig}. */ |
| 49 | export interface SvelteKitAwsTargetConfig extends SvelteKitTargetConfig { |
| 50 | /** |
| 51 | * Whether the emitted Lambda handler streams its response |
| 52 | * (`awslambda.streamifyResponse`, requires a Function URL with |
| 53 | * `invokeMode: RESPONSE_STREAM`). The buffered handler answers |
| 54 | * APIGW/Function-URL events with a complete response object. |
| 55 | * @default true |
| 56 | */ |
| 57 | readonly streaming?: boolean | undefined; |
| 58 | } |
| 59 | |
| 60 | /** The entry module name the finishing pass writes (`dist/server/index.mjs`). */ |
| 61 | export const SERVER_ENTRY_NAME = NodePath.join("server", "index.mjs"); |
| 62 | |
| 63 | /** |
| 64 | * The generated (unbundled) Lambda entry: kit's pre-built server instance |
| 65 | * (`generateServerInstance` output), wrapped with the aws-lambda web |
| 66 | * adapter. The `@alchemy.run/frontend-frameworks/aws-lambda` import is |
| 67 | * inlined by the finishing pass's rolldown bundle, so the shipped |
| 68 | * `dist/server` has no runtime dependency on this package. |
| 69 | */ |
| 70 | const generateLambdaEntry = (options: { |
| 71 | readonly serverImport: string; |
| 72 | readonly streaming: boolean; |
| 73 | }): string => { |
| 74 | const wrap = options.streaming |
| 75 | ? "toLambdaHandler" |
| 76 | : "toBufferedLambdaHandler"; |
| 77 | return /* js */ ` |
| 78 | import { server } from ${JSON.stringify(options.serverImport)}; |
| 79 | import { ${wrap} } from '@alchemy.run/frontend-frameworks/aws-lambda'; |
| 80 | |
| 81 | const initialized = server.init({ env: process.env }); |
| 82 | |
| 83 | const respond = async (request) => { |
| 84 | // always await initialization to prevent race condition with concurrent requests |
| 85 | await initialized; |
| 86 | return await server.respond(request, { |
| 87 | getClientAddress: () => |
| 88 | request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? '127.0.0.1', |
| 89 | }); |
| 90 | }; |
| 91 | |
| 92 | export const handler = ${wrap}(respond); |
| 93 | `.trimStart(); |
| 94 | }; |
| 95 | |
| 96 | /** |
| 97 | * The in-memory kit `Adapter` for AWS: writes the static-assets directory |
| 98 | * (client build + prerendered pages) and the unbundled Lambda entry, and |
| 99 | * records both on `result` for the framework half. Bundling happens later |
| 100 | * in the target's `finish` pass. |
| 101 | */ |
| 102 | export const makeAwsAdapter = (options: { |
| 103 | readonly streaming?: boolean | undefined; |
| 104 | }): SvelteKitAdapter => { |
| 105 | const result: { current?: SvelteKitAdapterResult | undefined } = {}; |
| 106 | return { |
| 107 | name: "@alchemy.run/frontend-frameworks/sveltekit/aws", |
| 108 | result, |
| 109 | async adapt(builder: Builder) { |
| 110 | const dest = builder.getBuildDirectory("aws"); |
| 111 | const tmp = builder.getBuildDirectory("aws-tmp"); |
| 112 | |
| 113 | builder.rimraf(dest); |
| 114 | builder.rimraf(tmp); |
| 115 | builder.mkdirp(dest); |
| 116 | builder.mkdirp(tmp); |
| 117 | |
| 118 | // client assets and prerendered pages — uploaded wholesale to S3; |
| 119 | // the CloudFront edge router serves them by exact match. |
| 120 | const assetsDest = dest + builder.config.paths.base; |
| 121 | builder.mkdirp(assetsDest); |
| 122 | builder.writeClient(assetsDest); |
| 123 | builder.writePrerendered(assetsDest); |
| 124 | |
| 125 | // pre-built server instance: kit 3.0 no longer exposes the internal |
| 126 | // SSR manifest (`generateManifest` throws), so `generateServerInstance` |
| 127 | // writes `export const server = new Server(manifest)` straight to |
| 128 | // disk itself instead of returning a manifest string to embed. Lambda |
| 129 | // routing is all-or-nothing (S3/CloudFront handle static assets), so |
| 130 | // unlike the Cloudflare worker shim there's no route manifest to build. |
| 131 | builder.generateServerInstance(NodePath.join(tmp, "server.js")); |
| 132 | |
| 133 | // Lambda entry (unbundled; relative imports into `output/server`) |
| 134 | const workerEntry = NodePath.join(tmp, "lambda.js"); |
| 135 | NodeFs.writeFileSync( |
| 136 | workerEntry, |
| 137 | generateLambdaEntry({ |
| 138 | serverImport: "./server.js", |
| 139 | streaming: options.streaming ?? true, |
| 140 | }), |
| 141 | ); |
| 142 | if ( |
| 143 | typeof builder.hasServerInstrumentationFile === "function" && |
| 144 | builder.hasServerInstrumentationFile() |
| 145 | ) { |
| 146 | // kit 3.0 requires an explicit initializer module that populates |
| 147 | // `$env/dynamic/private` before instrumentation runs; the default |
| 148 | // (`process.env`) is correct for the Lambda Node runtime. |
| 149 | const initializer = builder.createInstrumentationInitializer({ |
| 150 | outputDirectory: tmp, |
| 151 | }); |
| 152 | builder.instrument({ |
| 153 | entrypoint: workerEntry, |
| 154 | instrumentation: NodePath.join( |
| 155 | builder.getServerDirectory(), |
| 156 | "instrumentation.server.js", |
| 157 | ), |
| 158 | initializer, |
| 159 | }); |
| 160 | } |
| 161 | |
| 162 | result.current = { dest, workerEntry }; |
| 163 | }, |
| 164 | dispose: async () => {}, |
| 165 | }; |
| 166 | }; |
| 167 | |
| 168 | /** |
| 169 | * The adapter-driven target — the shape the framework's regular |
| 170 | * build/finish pipeline consumes. Used directly in the build child (where |
| 171 | * `cwd === root` holds); {@link makeAwsTarget} wraps it with the wholesale |
| 172 | * `build` hook that spawns the child. |
| 173 | */ |
| 174 | const makeAwsAdapterTarget = ( |
| 175 | config: SvelteKitAwsTargetConfig = {}, |
| 176 | ): SvelteKitTarget => |
| 177 | makeDeployTarget({ |
| 178 | platform: "aws", |
| 179 | config, |
| 180 | bundle: { |
| 181 | conditions: ["node", "import", "module"], |
| 182 | external: ["@aws-sdk/"], |
| 183 | }, |
| 184 | adapter: (_context) => |
| 185 | makeAwsAdapter({ |
| 186 | streaming: config.streaming, |
| 187 | }), |
| 188 | finish: (output, context) => |
| 189 | Effect.gen(function* () { |
| 190 | const fs = yield* FileSystem.FileSystem; |
| 191 | const path = yield* Path.Path; |
| 192 | const entry = context.entry; |
| 193 | if (entry === undefined) { |
| 194 | return yield* Effect.fail( |
| 195 | fail( |
| 196 | "The SvelteKit build produced no on-disk Lambda entry for the finishing pass " + |
| 197 | "(context.entry is missing)", |
| 198 | ), |
| 199 | ); |
| 200 | } |
| 201 | const root = context.root; |
| 202 | const distDirectory = |
| 203 | output.distDirectory ?? path.resolve(root, "dist"); |
| 204 | const serverOutDir = path.join(distDirectory, "server"); |
| 205 | yield* fs |
| 206 | .remove(serverOutDir, { recursive: true, force: true }) |
| 207 | .pipe( |
| 208 | Effect.mapError((error) => |
| 209 | fail("Failed to clean dist/server", error), |
| 210 | ), |
| 211 | ); |
| 212 | |
| 213 | // Re-bundle the entry (kit's server graph + the aws-lambda adapter) |
| 214 | // for Node. `dist/server` must be self-contained: the Lambda ships |
| 215 | // it as-is (`bundle: false`), so only Node builtins and the |
| 216 | // runtime-provided `@aws-sdk/*` stay external. |
| 217 | yield* Effect.tryPromise({ |
| 218 | try: async () => { |
| 219 | const bundle = await rolldown({ |
| 220 | cwd: root, |
| 221 | input: entry, |
| 222 | platform: "node", |
| 223 | resolve: { |
| 224 | conditionNames: ["node", "import", "module"], |
| 225 | }, |
| 226 | external: [/^@aws-sdk\//, /^node:/], |
| 227 | }); |
| 228 | try { |
| 229 | await bundle.write({ |
| 230 | dir: serverOutDir, |
| 231 | format: "esm", |
| 232 | entryFileNames: "index.mjs", |
| 233 | chunkFileNames: "chunks/[name].mjs", |
| 234 | sourcemap: false, |
| 235 | }); |
| 236 | } finally { |
| 237 | await bundle.close(); |
| 238 | } |
| 239 | }, |
| 240 | catch: (error) => |
| 241 | fail("Failed to bundle the Lambda server for Node", error), |
| 242 | }); |
| 243 | |
| 244 | const modules = yield* FrameworkCore.readServerModulesFromDisk({ |
| 245 | directory: serverOutDir, |
| 246 | prefix: "server", |
| 247 | }).pipe(Effect.mapError((error) => fail(error.message, error.cause))); |
| 248 | const serverModules = FrameworkCore.sortServerModules( |
| 249 | modules, |
| 250 | SERVER_ENTRY_NAME, |
| 251 | ); |
| 252 | |
| 253 | return { |
| 254 | ...output, |
| 255 | distDirectory, |
| 256 | serverModules, |
| 257 | } satisfies FrameworkCore.BuildOutput; |
| 258 | }), |
| 259 | }); |
| 260 | |
| 261 | /** |
| 262 | * SvelteKit resolves its config (and the postbuild analyse/prerender worker |
| 263 | * threads re-derive `outDir`) relative to `process.cwd()`, so a production |
| 264 | * build only works when `cwd === root`. The engine process can't guarantee |
| 265 | * that (many deploys share one event loop), so this target's wholesale |
| 266 | * `build` runs the framework in a disposable child process whose working |
| 267 | * directory IS the project root (see `core/BuildChild.ts`). The shared |
| 268 | * `core/BuildChildRunner` entry imports this module in the child and calls |
| 269 | * the exported {@link buildInChild}. |
| 270 | */ |
| 271 | export interface SvelteKitAwsBuildChildConfig { |
| 272 | readonly rootDir: string; |
| 273 | /** The (JSON-serializable) target config the parent was created with. */ |
| 274 | readonly config: SvelteKitAwsTargetConfig; |
| 275 | } |
| 276 | |
| 277 | export const buildInChild = (config: SvelteKitAwsBuildChildConfig) => |
| 278 | Effect.gen(function* () { |
| 279 | const framework = yield* make({ |
| 280 | root: config.rootDir, |
| 281 | // The adapter-only target: no wholesale `build` hook, so the child |
| 282 | // runs the regular vite build + finish pipeline (no recursion). |
| 283 | target: makeAwsAdapterTarget(config.config), |
| 284 | compatibilityDate: config.config.compatibilityDate, |
| 285 | compatibilityFlags: |
| 286 | config.config.compatibilityFlags !== undefined |
| 287 | ? [...config.config.compatibilityFlags] |
| 288 | : undefined, |
| 289 | kit: config.config.kit, |
| 290 | adapter: config.config.adapter, |
| 291 | }); |
| 292 | return yield* framework.build({ root: config.rootDir }); |
| 293 | }); |
| 294 | |
| 295 | /** |
| 296 | * Create the AWS Lambda {@link SvelteKitTarget}. See the module doc for |
| 297 | * the seams. |
| 298 | */ |
| 299 | export const makeAwsTarget = ( |
| 300 | config: SvelteKitAwsTargetConfig = {}, |
| 301 | ): SvelteKitTarget => ({ |
| 302 | ...makeAwsAdapterTarget(config), |
| 303 | build: (context) => |
| 304 | runBuildChild({ |
| 305 | module: import.meta.url, |
| 306 | rootDir: context.root, |
| 307 | framework: "sveltekit", |
| 308 | config: { |
| 309 | rootDir: context.root, |
| 310 | config, |
| 311 | } satisfies SvelteKitAwsBuildChildConfig, |
| 312 | }).pipe(Effect.mapError((error) => fail(error.message, error.cause))), |
| 313 | }); |
| 314 | |
| 315 | /** |
| 316 | * The deploy-target module contract (`resolveDeployTarget` accepts the |
| 317 | * default export — or the named `target` export — as a value or factory). |
| 318 | */ |
| 319 | export const target = makeAwsTarget; |
| 320 | |
| 321 | export default makeAwsTarget; |