| 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 | */ |
| 31 | import type { BindingHooks } from "@alchemy.run/cloudflare-runtime/core"; |
| 32 | import type { Adapter, Builder, Emulator } from "@sveltejs/kit"; |
| 33 | import * as NodeFs from "node:fs"; |
| 34 | import * as NodePath from "node:path"; |
| 35 | import { pathToFileURL } from "node:url"; |
| 36 | import { generateWorkerShim } from "./WorkerShim.ts"; |
| 37 | |
| 38 | export 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 | |
| 49 | export 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 | |
| 90 | export 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 | */ |
| 114 | const 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 | |
| 168 | export 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 | |
| 302 | const readOptionalFile = (path: string): string | undefined => |
| 303 | NodeFs.existsSync(path) ? NodeFs.readFileSync(path, "utf8") : undefined; |
| 304 | |
| 305 | const 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 | */ |
| 311 | export 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 | */ |
| 338 | export 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 | */ |
| 356 | export 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. */ |
| 384 | export 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()`. */ |
| 397 | export 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. */ |
| 433 | export 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 | |
| 441 | export 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 | |
| 450 | export type OpenDevPlatformProxy = ( |
| 451 | options: OpenDevPlatformProxyOptions, |
| 452 | ) => Promise<DevPlatformProxy>; |
| 453 | |
| 454 | /** A kit `Emulator` that owns a disposable platform proxy. */ |
| 455 | export 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 | */ |
| 466 | const 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 | */ |
| 493 | const 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 | */ |
| 529 | export 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 | */ |
| 566 | const 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 | }; |