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 */
27import * as FrameworkCore from "../core/index.ts";
28import { DeployTargetError, makeDeployTarget } from "../core/index.ts";
29import type { Builder } from "@sveltejs/kit";
30import * as Effect from "effect/Effect";
31import * as FileSystem from "effect/FileSystem";
32import * as NodeFs from "node:fs";
33import * as NodePath from "node:path";
34import * as Path from "effect/Path";
35import { rolldown } from "rolldown";
36import { runBuildChild } from "../core/BuildChild.ts";
37import {
38 make,
39 type SvelteKitAdapter,
40 type SvelteKitAdapterResult,
41 type SvelteKitTarget,
42 type SvelteKitTargetConfig,
43} from "./SvelteKit.ts";
44
45const fail = (message: string, cause?: unknown) =>
46 new DeployTargetError({ platform: "aws", message, cause });
47
48/** AWS-specific knobs carried on the shared {@link SvelteKitTargetConfig}. */
49export 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`). */
61export 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 */
70const generateLambdaEntry = (options: {
71 readonly serverImport: string;
72 readonly streaming: boolean;
73}): string => {
74 const wrap = options.streaming
75 ? "toLambdaHandler"
76 : "toBufferedLambdaHandler";
77 return /* js */ `
78import { server } from ${JSON.stringify(options.serverImport)};
79import { ${wrap} } from '@alchemy.run/frontend-frameworks/aws-lambda';
80
81const initialized = server.init({ env: process.env });
82
83const 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
92export 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 */
102export 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 */
174const 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 */
271export interface SvelteKitAwsBuildChildConfig {
272 readonly rootDir: string;
273 /** The (JSON-serializable) target config the parent was created with. */
274 readonly config: SvelteKitAwsTargetConfig;
275}
276
277export 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 */
299export 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 */
319export const target = makeAwsTarget;
320
321export default makeAwsTarget;