1// Alchemy modifications are licensed under Apache-2.0.
2// This file includes third-party code; see /THIRD_PARTY_LICENSES.md.
3/**
4 * In-memory SvelteKit `Adapter` — a wrangler-free fork of
5 * `@sveltejs/adapter-cloudflare`'s `adapt()`.
6 *
7 * Differences from upstream:
8 *
9 * - No `unstable_readConfig` / wrangler.json: always Workers mode with plain
10 * options (`dest` fixed to kit's `cloudflare` build directory, assets
11 * binding defaults to `ASSETS`).
12 * - No Pages mode, no `_routes.json`.
13 * - The worker shim is generated directly with real relative import paths
14 * (see `WorkerShim.ts`); upstream ships a prebuilt `files/worker.js` and
15 * string-replaces `SERVER`/`MANIFEST` placeholders.
16 * - `emulate()` supplies the dev `platform` from `cloudflare-runtime`'s
17 * platform proxy (`getPlatformProxy`) instead of wrangler's: real bindings
18 * round-trip through a workerd instance, `caches` actually stores/matches
19 * (wrangler's is a no-op), `cf`/`ctx` mocks match wrangler's.
20 *
21 * The adapter does **not** bundle the app: `adapt()` records the assets
22 * directory and the unbundled worker entry on `result`, and the `SvelteKit`
23 * Framework service runs the rolldown pass afterwards (replacing the bundling
24 * `wrangler deploy` performs for the upstream adapter).
25 *
26 * Note: this module runs as a SvelteKit build callback (kit calls `adapt()`
27 * inside Vite's `buildApp`), so it uses kit's synchronous `Builder` API and
28 * `node:fs` directly like upstream — it is framework-callback code, not an
29 * Effect service.
30 */
31import type { BindingHooks } from "@alchemy.run/cloudflare-runtime/core";
32import type { Adapter, Builder, Emulator } from "@sveltejs/kit";
33import * as NodeFs from "node:fs";
34import * as NodePath from "node:path";
35import { pathToFileURL } from "node:url";
36import { generateWorkerShim } from "./WorkerShim.ts";
37
38export interface CloudflareAdapterResult {
39 /**
40 * The static-assets directory (client build + prerendered pages +
41 * `_headers`/`_redirects`/`.assetsignore`) — upload wholesale as the
42 * Worker's assets directory; becomes `BuildOutput.clientDirectory`.
43 */
44 readonly dest: string;
45 /** The generated (unbundled) worker entry — input for the rolldown pass. */
46 readonly workerEntry: string;
47}
48
49export interface CloudflareAdapterOptions {
50 /**
51 * Name of the static-assets binding the worker shim serves files through.
52 * @default "ASSETS"
53 */
54 readonly assetsBinding?: string | undefined;
55 /**
56 * Mirror of Workers static assets `not_found_handling`, driving fallback
57 * page generation: `"404-page"` writes `404.html`,
58 * `"single-page-application"` writes `index.html` AND makes the worker
59 * shim defer router-level not-founds on navigation-shaped requests to the
60 * assets layer so the fallback governs (see `WorkerShim.ts`).
61 * @default "none"
62 */
63 readonly notFoundHandling?:
64 | "none"
65 | "404-page"
66 | "single-page-application"
67 | undefined;
68 /**
69 * With `notFoundHandling: "404-page"`: `"spa"` renders the app shell as the
70 * fallback, `"plaintext"` writes a plain `Not Found` page.
71 * @default "plaintext"
72 */
73 readonly fallback?: "spa" | "plaintext" | undefined;
74 /**
75 * Project root used to locate user-authored `_headers` / `_redirects`
76 * files.
77 * @default process.cwd()
78 */
79 readonly root?: string | undefined;
80 /**
81 * Inputs for the dev platform returned from `emulate()` (see
82 * {@link CloudflareDevPlatformOptions}). When present, `emulate()` serves
83 * `platform = { env, ctx, caches, cf }` through `cloudflare-runtime`'s
84 * platform proxy; when absent (production builds), `emulate()` returns an
85 * inert empty platform.
86 */
87 readonly platform?: CloudflareDevPlatformOptions | undefined;
88}
89
90export interface CloudflareAdapter extends Adapter {
91 /** Populated by `adapt()`; consumed by the SvelteKit Framework service. */
92 readonly result: { current?: CloudflareAdapterResult };
93 /**
94 * Tear down dev resources held by the adapter (the platform-proxy workerd
95 * instance, when one was opened). Safe to call multiple times; a no-op
96 * when the proxy was never opened.
97 */
98 readonly dispose: () => Promise<void>;
99}
100
101/**
102 * Render kit's fallback page (the SPA / 404 app shell) in-process.
103 *
104 * `builder.generateFallback` delegates to a `worker_threads` fork whose
105 * ready/args handshake matches messages on the module's `import.meta.url`
106 * string. Under bun on Windows the parent and child spell that URL
107 * differently, the handshake never completes, and no fallback is written —
108 * the deployed site then 404s every client route (the assets layer has no
109 * shell to fall back to). These are kit's own `core/postbuild/fallback.js`
110 * steps, minus the fork; the build process is short-lived in every context
111 * that runs `adapt` (harness build, alchemy source build), so the fork's
112 * dangling-handle isolation buys nothing here.
113 */
114const generateFallbackInProcess = async (
115 builder: Builder,
116 dest: string,
117): Promise<void> => {
118 const kit = builder.config;
119 const serverRoot = NodePath.join(kit.outDir, "output", "server");
120 const load = async (file: string): Promise<Record<string, any>> =>
121 await import(
122 /* @vite-ignore */ pathToFileURL(NodePath.join(serverRoot, file)).href
123 );
124 const { set_building } = await load("internal.js");
125 const { Server } = await load("index.js");
126 const { manifest } = await load("manifest-full.js");
127
128 // kit's builder loads .env files through vite's `loadEnv`; the adapter
129 // only runs inside a build, where vite is importable. Fall back to the
130 // process env when it isn't (the shell render rarely reads env at all).
131 const env: Record<string, string> = await import("vite").then(
132 (vite) => vite.loadEnv("production", kit.env.dir, ""),
133 () =>
134 Object.fromEntries(
135 Object.entries(process.env).filter(
136 (entry): entry is [string, string] => typeof entry[1] === "string",
137 ),
138 ),
139 );
140
141 set_building();
142 const server = new Server(manifest);
143 await server.init({ env });
144 const origin = kit.paths.origin || "http://sveltekit-prerender";
145 const response: Response = await server.respond(
146 new Request(`${origin}/[fallback]`),
147 {
148 getClientAddress: () => {
149 throw new Error("Cannot read clientAddress during prerendering");
150 },
151 prerendering: {
152 fallback: true,
153 dependencies: new Map(),
154 remote_responses: new Map(),
155 },
156 read: (file: string) =>
157 NodeFs.readFileSync(NodePath.join(kit.files.assets, file)),
158 },
159 );
160 if (!response.ok) {
161 throw new Error(
162 `Could not create a fallback page — failed with status ${response.status}`,
163 );
164 }
165 NodeFs.writeFileSync(dest, await response.text());
166};
167
168export const makeCloudflareAdapter = (
169 options: CloudflareAdapterOptions = {},
170): CloudflareAdapter => {
171 const result: { current?: CloudflareAdapterResult } = {};
172 let emulator: CloudflareEmulator | undefined;
173 return {
174 name: "@alchemy.run/frontend-frameworks/sveltekit",
175 result,
176 async adapt(builder: Builder) {
177 const root = options.root ?? process.cwd();
178 const assetsBinding = options.assetsBinding ?? "ASSETS";
179 const dest = builder.getBuildDirectory("cloudflare");
180 const tmp = builder.getBuildDirectory("cloudflare-tmp");
181
182 builder.rimraf(dest);
183 builder.rimraf(tmp);
184 builder.mkdirp(dest);
185 builder.mkdirp(tmp);
186
187 // client assets and prerendered pages
188 const assetsDest = dest + builder.config.paths.base;
189 builder.mkdirp(assetsDest);
190 if (options.notFoundHandling === "404-page") {
191 // generate plaintext 404.html first, which can then be overridden by
192 // prerendering if the user defined such a page
193 const fallback = NodePath.join(assetsDest, "404.html");
194 if (options.fallback === "spa") {
195 await generateFallbackInProcess(builder, fallback);
196 } else {
197 NodeFs.writeFileSync(fallback, "Not Found");
198 }
199 }
200 builder.writeClient(assetsDest);
201 builder.writePrerendered(assetsDest);
202 if (options.notFoundHandling === "single-page-application") {
203 await generateFallbackInProcess(
204 builder,
205 NodePath.join(assetsDest, "index.html"),
206 );
207 }
208
209 // pre-built server instance: kit 3.0 no longer exposes the internal
210 // SSR manifest (`generateManifest` throws), so `generateServerInstance`
211 // writes `export const server = new Server(manifest)` straight to
212 // disk itself instead of returning a manifest string to embed.
213 builder.generateServerInstance(NodePath.join(tmp, "server.js"));
214
215 // route manifest module — built from kit's public Builder surface
216 // (`builder.manifest`, `builder.routes`, `builder.getAppPath()`) since
217 // the internal SSR manifest is no longer reachable outside the
218 // generated server module above.
219 NodeFs.writeFileSync(
220 NodePath.join(tmp, "manifest.js"),
221 `export const assets = new Set(${JSON.stringify(builder.manifest.assets.map((asset) => asset.path))});\n\n` +
222 `export const app_path = ${JSON.stringify(builder.getAppPath())};\n\n` +
223 `export const prerendered = new Set(${JSON.stringify(builder.prerendered.paths)});\n\n` +
224 `export const base_path = ${JSON.stringify(builder.config.paths.base)};\n\n` +
225 `export const routes = [${builder.routes.map((route) => `{ pattern: ${route.pattern} }`).join(", ")}];\n`,
226 );
227
228 // worker entry (unbundled shim; relative imports into `output/server`)
229 const workerEntry = NodePath.join(dest, "_worker.js");
230 NodeFs.writeFileSync(
231 workerEntry,
232 generateWorkerShim({
233 serverImport: `./${posixify(NodePath.relative(dest, tmp))}/server.js`,
234 manifestImport: `./${posixify(NodePath.relative(dest, tmp))}/manifest.js`,
235 assetsBinding,
236 notFoundHandling: options.notFoundHandling,
237 }),
238 );
239 if (
240 typeof builder.hasServerInstrumentationFile === "function" &&
241 builder.hasServerInstrumentationFile()
242 ) {
243 // kit 3.0 requires an explicit initializer module that populates
244 // `$env/dynamic/private` before instrumentation runs. Source it from
245 // `cloudflare:workers` (the same binding source the worker shim
246 // uses) rather than the `process.env` default, which workerd doesn't
247 // populate with real bindings.
248 const initializer = builder.createInstrumentationInitializer({
249 outputDirectory: tmp,
250 environment: "export { env as default } from 'cloudflare:workers';",
251 });
252 builder.instrument({
253 entrypoint: workerEntry,
254 instrumentation: NodePath.join(
255 builder.getServerDirectory(),
256 "instrumentation.server.js",
257 ),
258 initializer,
259 });
260 }
261
262 // _headers
263 const userHeaders = readOptionalFile(NodePath.join(root, "_headers"));
264 NodeFs.writeFileSync(
265 NodePath.join(dest, "_headers"),
266 generateHeaders(builder.getAppPath(), userHeaders),
267 );
268
269 // _redirects
270 const userRedirects = readOptionalFile(NodePath.join(root, "_redirects"));
271 const redirects = generateRedirects(
272 builder.prerendered.redirects,
273 userRedirects,
274 );
275 if (redirects !== undefined) {
276 NodeFs.writeFileSync(NodePath.join(dest, "_redirects"), redirects);
277 }
278
279 // Workers-mode assets ignore file
280 NodeFs.writeFileSync(
281 NodePath.join(dest, ".assetsignore"),
282 generateAssetsIgnore(),
283 );
284
285 result.current = { dest, workerEntry };
286 },
287 emulate: () =>
288 (emulator ??=
289 options.platform !== undefined
290 ? makeDevPlatformEmulator(options.platform)
291 : makeBuildEmulator()),
292 dispose: async () => {
293 await emulator?.dispose();
294 },
295 supports: {
296 read: () => true,
297 instrumentation: () => true,
298 },
299 };
300};
301
302const readOptionalFile = (path: string): string | undefined =>
303 NodeFs.existsSync(path) ? NodeFs.readFileSync(path, "utf8") : undefined;
304
305const posixify = (str: string): string => str.replace(/\\/g, "/");
306
307/**
308 * Add a rule block for `url` to a `_headers` file, merging into an existing
309 * block for the same URL if present (upstream `append_headers`).
310 */
311export const appendHeaders = (
312 url: string,
313 rules: Array<string>,
314 content: string,
315): string => {
316 const regex = new RegExp(`^(${url.replaceAll("*", "\\*")})$`, "m");
317 const formattedHeaders = rules.map((rule) => ` ${rule}`).join("\n");
318
319 // if the URL already exists, just add header rules to it
320 if (regex.test(content)) {
321 return content.replace(regex, `$1\n${formattedHeaders}`);
322 }
323
324 // otherwise, we add the url and header rules
325 return `
326${content}
327# === START AUTOGENERATED SVELTE IMMUTABLE HEADERS ===
328${url}
329${formattedHeaders}
330# === END AUTOGENERATED SVELTE IMMUTABLE HEADERS ===
331`.trim();
332};
333
334/**
335 * Merge the user's `_headers` content with the generated kit rules
336 * (`noindex` for the app dir, immutable caching for hashed assets).
337 */
338export const generateHeaders = (appDir: string, content = ""): string => {
339 content = appendHeaders(
340 `/${appDir}/*`,
341 ["X-Robots-Tag: noindex", "Cache-Control: no-cache"],
342 content,
343 );
344 content = appendHeaders(
345 `/${appDir}/immutable/*`,
346 ["! Cache-Control", "Cache-Control: public, immutable, max-age=31536000"],
347 content,
348 );
349 return content;
350};
351
352/**
353 * Merge the user's `_redirects` content with rules for kit's prerendered
354 * redirects. Returns `undefined` when there is nothing to write.
355 */
356export const generateRedirects = (
357 redirects: Map<string, { status: number; location: string }>,
358 content?: string,
359): string | undefined => {
360 const parts: Array<string> = [];
361 if (content !== undefined) {
362 parts.push(content);
363 }
364 if (redirects.size > 0) {
365 const rules = Array.from(
366 redirects.entries(),
367 ([path, redirect]) => `${path} ${redirect.location} ${redirect.status}`,
368 ).join("\n");
369 parts.push(
370 `
371# === START AUTOGENERATED SVELTE PRERENDERED REDIRECTS ===
372${rules}
373# === END AUTOGENERATED SVELTE PRERENDERED REDIRECTS ===
374`.trimEnd(),
375 );
376 }
377 if (parts.length === 0) {
378 return undefined;
379 }
380 return parts.join("\n");
381};
382
383/** Workers-mode `.assetsignore`: files in `dest` that are not static assets. */
384export const generateAssetsIgnore = (): string =>
385 `
386_worker.js
387_routes.json
388_headers
389_redirects
390`.trimStart();
391
392// ---------------------------------------------------------------------------
393// Dev platform emulation (cloudflare-runtime platform proxy)
394// ---------------------------------------------------------------------------
395
396/** Inputs for the proxy-backed dev platform served from `emulate()`. */
397export interface CloudflareDevPlatformOptions {
398 /**
399 * Literal `platform.env` overrides. Applied on top of the proxied binding
400 * values — a same-named literal always wins (back-compat with the phase-1
401 * stub platform, whose env was literals only).
402 */
403 readonly env?: Record<string, unknown> | undefined;
404 /**
405 * Bindings to expose on `platform.env` — the same hook shapes
406 * `cloudflare-runtime`'s `Runtime.start` accepts (`Text.local`,
407 * `KvNamespace.local`, `D1.local`, remote bindings, …). Typed opaquely
408 * because the specs travel through the target-agnostic framework half;
409 * the values must be `cloudflare-runtime` `BindingHooks`.
410 */
411 readonly bindings?: ReadonlyArray<unknown> | undefined;
412 /**
413 * Pre-built `Context<RuntimeServices>` to host the proxy in (opaque —
414 * travels through the target-agnostic framework half). Enables
415 * remote()-lowered bindings, which the proxy's internal local-only layer
416 * cannot serve.
417 */
418 readonly services?: unknown;
419 /** Compatibility date for the binding-proxy workerd instance. */
420 readonly compatibilityDate?: string | undefined;
421 /** Compatibility flags for the binding-proxy workerd instance. */
422 readonly compatibilityFlags?: ReadonlyArray<string> | undefined;
423 /** Name of the proxy workerd service. @default "sveltekit-dev-platform-proxy" */
424 readonly name?: string | undefined;
425 /**
426 * Override how the platform proxy is opened. A test seam — the default
427 * dynamically imports `cloudflare-runtime`'s `getPlatformProxy`.
428 */
429 readonly openProxy?: OpenDevPlatformProxy | undefined;
430}
431
432/** The subset of `cloudflare-runtime`'s `PlatformProxy` the emulator serves. */
433export interface DevPlatformProxy {
434 readonly env: Record<string, unknown>;
435 readonly cf: Record<string, unknown>;
436 readonly ctx: unknown;
437 readonly caches: unknown;
438 readonly dispose: () => Promise<void>;
439}
440
441export interface OpenDevPlatformProxyOptions {
442 readonly name: string;
443 readonly compatibilityDate?: string | undefined;
444 readonly compatibilityFlags?: ReadonlyArray<string> | undefined;
445 readonly bindings: ReadonlyArray<unknown>;
446 /** See {@link CloudflareDevPlatformOptions.services}. */
447 readonly services?: unknown;
448}
449
450export type OpenDevPlatformProxy = (
451 options: OpenDevPlatformProxyOptions,
452) => Promise<DevPlatformProxy>;
453
454/** A kit `Emulator` that owns a disposable platform proxy. */
455export interface CloudflareEmulator extends Emulator {
456 /** Tear down the proxy. No-op when it was never opened. */
457 readonly dispose: () => Promise<void>;
458}
459
460/**
461 * The default proxy opener: `cloudflare-runtime`'s `getPlatformProxy`
462 * (workerd hosting the bindings behind the internal proxy worker, local-only
463 * layer, state in a temp directory). Imported lazily so production builds
464 * never load the runtime machinery.
465 */
466const openPlatformProxy: OpenDevPlatformProxy = async (options) => {
467 const { getPlatformProxy } =
468 await import("@alchemy.run/cloudflare-runtime/core/platform-proxy");
469 return await getPlatformProxy({
470 name: options.name,
471 ...(options.compatibilityDate !== undefined
472 ? { compatibilityDate: options.compatibilityDate }
473 : undefined),
474 ...(options.compatibilityFlags !== undefined
475 ? { compatibilityFlags: [...options.compatibilityFlags] }
476 : undefined),
477 bindings: options.bindings as BindingHooks,
478 ...(options.services !== undefined
479 ? {
480 services: options.services as Parameters<
481 typeof getPlatformProxy
482 >[0]["services"],
483 }
484 : undefined),
485 });
486};
487
488/**
489 * `platform.env` for prerenderable routes: any key access throws (mirroring
490 * upstream), because prerendered pages must not depend on request-time
491 * bindings.
492 */
493const guardPrerenderEnv = (
494 env: Record<string, unknown>,
495): Record<string, unknown> => {
496 const guarded: Record<string, unknown> = {};
497 for (const key of Object.keys(env)) {
498 Object.defineProperty(guarded, key, {
499 get: () => {
500 throw new Error(
501 `Cannot access platform.env.${key} in a prerenderable route`,
502 );
503 },
504 });
505 }
506 return guarded;
507};
508
509/**
510 * The dev-platform emulator: serves `platform = { env, ctx, caches, cf }`
511 * from `cloudflare-runtime`'s platform proxy — our wrangler-free equivalent
512 * of upstream `adapter-cloudflare`'s `getPlatformProxy`-based `emulate()`.
513 *
514 * - The proxy (a workerd instance hosting the bindings) is opened lazily on
515 * the first `platform()` call and shared across requests; dispose it with
516 * the dev server via {@link CloudflareEmulator.dispose}.
517 * - `env` carries every configured binding, callable from Node
518 * (`env.KV.get("key")`, `env.DB.prepare("...").all()`); literal
519 * `options.env` values override same-named proxied values.
520 * - `caches` actually round-trips (`put`/`match`/`delete` hit an in-memory
521 * store in the proxy worker — unlike wrangler, whose dev `caches` is a
522 * no-op); `cf` is the same mock object miniflare falls back to; `ctx` is
523 * wrangler's no-op `ExecutionContext` mock.
524 * - Inherited proxy limitations: binding methods returning rich class
525 * instances (e.g. `R2Object`) are unsupported, and intermediate values
526 * (e.g. `DurableObjectId`) need an explicit `await` before synchronous use
527 * — see `cloudflare-runtime/platform-proxy`.
528 */
529export const makeDevPlatformEmulator = (
530 options: CloudflareDevPlatformOptions = {},
531): CloudflareEmulator => {
532 const openProxy = options.openProxy ?? openPlatformProxy;
533 let opened: Promise<DevPlatformProxy> | undefined;
534 const open = () =>
535 (opened ??= openProxy({
536 name: options.name ?? "sveltekit-dev-platform-proxy",
537 compatibilityDate: options.compatibilityDate,
538 compatibilityFlags: options.compatibilityFlags,
539 bindings: options.bindings ?? [],
540 services: options.services,
541 }));
542 return {
543 platform: async ({ prerender }) => {
544 const proxy = await open();
545 const env = { ...proxy.env, ...options.env };
546 return {
547 env: prerender ? guardPrerenderEnv(env) : env,
548 ctx: proxy.ctx,
549 caches: proxy.caches,
550 cf: proxy.cf,
551 } as unknown as App.Platform;
552 },
553 dispose: async () => {
554 if (opened === undefined) return;
555 const proxy = await opened.catch(() => undefined);
556 await proxy?.dispose();
557 },
558 };
559};
560
561/**
562 * The inert platform used when the adapter is constructed without dev
563 * platform options (production builds): empty env, no-op `ctx`/`caches`,
564 * empty `cf`. Never opens a proxy.
565 */
566const makeBuildEmulator = (): CloudflareEmulator => {
567 const noopCache = {
568 match: async () => undefined,
569 put: async () => {},
570 delete: async () => false,
571 };
572 const platform = {
573 env: {},
574 ctx: {
575 waitUntil: (_promise: Promise<unknown>) => {},
576 passThroughOnException: () => {},
577 },
578 caches: {
579 default: noopCache,
580 open: async () => noopCache,
581 },
582 cf: {},
583 };
584 return {
585 platform: () => platform as unknown as App.Platform,
586 dispose: async () => {},
587 };
588};