| 1 | # @alchemy.run/frontend-frameworks/sveltekit |
| 2 | |
| 3 | Wrangler-free SvelteKit integration: programmatic build and dev for SvelteKit |
| 4 | projects, with the deploy platform passed in as a **deploy target** value. |
| 5 | Cloudflare Workers is the built-in target |
| 6 | (`@alchemy.run/frontend-frameworks/sveltekit/cloudflare`); the framework half of this package |
| 7 | contains no Cloudflare code at all. |
| 8 | |
| 9 | ## Architecture: framework half × target half |
| 10 | |
| 11 | The package follows the framework × deploy-target split defined in |
| 12 | `@alchemy.run/frontend-frameworks/core` (see its README for the full `DeployTarget` |
| 13 | contract): |
| 14 | |
| 15 | | Module | Role | |
| 16 | | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |
| 17 | | `@alchemy.run/frontend-frameworks/sveltekit` (`src/SvelteKit.ts`, `src/index.ts`) | **Framework half.** Drives kit programmatically: loads the _project's_ `vite` and `@sveltejs/kit/vite`, synthesizes the in-memory kit config, runs `createBuilder().buildApp()` (build) / `createServer()` (dev), and implements framework-core's `Framework` service. Target-agnostic — zero Cloudflare imports, enforced by a unit test (`test/TargetDecoupling.test.ts`). | |
| 18 | | `@alchemy.run/frontend-frameworks/sveltekit/cloudflare` (`src/cloudflare.ts`) | **Target half.** The Cloudflare Workers `SvelteKitTarget`: the wrangler-free in-memory kit adapter (fork of `@sveltejs/adapter-cloudflare`), the generated worker shim, the proxy-backed dev platform (cloudflare-runtime's `getPlatformProxy`), and the `finish` pass that re-bundles kit's node-flavored output for workerd with rolldown + `@alchemy.run/cloudflare-runtime/rolldown`. | |
| 19 | | `@alchemy.run/frontend-frameworks/sveltekit/source` (`src/source.ts`) | Alchemy Worker **source provider** (structural `WorkerSourceModule` contract): maps `Framework.build` onto alchemy's bundle/assets/hash slots, plus a rebuild-free memo hash and kit-dev `dev()`. Cloudflare-specific by nature; it passes the cloudflare target factory to the framework directly. | |
| 20 | |
| 21 | Background on why the integration is shaped this way: |
| 22 | |
| 23 | - **SvelteKit is purely a Vite plugin.** There is no framework CLI for |
| 24 | dev/build (`svelte-kit` only implements `sync`); `vite.createServer()` / |
| 25 | `vite.createBuilder().buildApp()` are the whole programmatic API. Kit v3 |
| 26 | takes its entire config via `sveltekit(config)` — a `svelte.config.js` on |
| 27 | disk is an upstream **error** — so all kit options (adapter included) live |
| 28 | in the project's `vite.config.ts`. |
| 29 | - **A project-owned `vite.config.ts` loads natively.** The build/dev config |
| 30 | leaves Vite's `configFile` discovery alone, so the user's Vite settings, |
| 31 | plugins, and `sveltekit(...)` call (aliases, preprocess, prerender entries, |
| 32 | …) all apply as written. The deploy target's adapter is injected into the |
| 33 | _user's_ `sveltekit()` instance — never a second one — by an inline |
| 34 | `enforce: "pre"` plugin (`src/UserConfig.ts`) that mutates the validated |
| 35 | config kit exposes as `api.options` on `vite-plugin-sveltekit-setup` |
| 36 | _before_ kit's own `config` hook processes it. A user-declared `adapter` |
| 37 | is replaced **with a warning** (the deploy target owns build packaging; a |
| 38 | foreign adapter's output would never be deployed — the user's file itself |
| 39 | is untouched, so plain `vite build` still uses it). Only when no config |
| 40 | file exists does the integration fall back to a fully-programmatic config |
| 41 | (`configFile: false` + `sveltekit({ ...options.kit, adapter })`). |
| 42 | - **Build is one `buildApp()` pass** (Vite Environment API): server build → |
| 43 | route analysis → client build → prerender → (service worker) → adapter |
| 44 | `adapt()`. The adapter runs _inside_ the build as a kit callback. |
| 45 | - **The upstream cloudflare adapter's only wrangler touchpoints** are |
| 46 | `unstable_readConfig` (wrangler.json discovery) and `getPlatformProxy` |
| 47 | (dev emulation) — plus, implicitly, `wrangler deploy`'s bundling of the |
| 48 | emitted `_worker.js` (upstream does not bundle; its shim's relative imports |
| 49 | into `.svelte-kit/output/server/**` are left for wrangler). This package |
| 50 | replaces all three: plain in-memory options, cloudflare-runtime's own |
| 51 | `getPlatformProxy` (a workerd-backed platform proxy, wrangler-free), and |
| 52 | the rolldown finishing pass. |
| 53 | |
| 54 | ### The `SvelteKitTarget` contract |
| 55 | |
| 56 | The framework half declares what any deploy target must provide: |
| 57 | |
| 58 | ```ts |
| 59 | interface SvelteKitTarget extends DeployTarget<SvelteKitTargetConfig> { |
| 60 | /** Produce the kit Adapter for one build/dev invocation. */ |
| 61 | adapter(context: { |
| 62 | root: string; |
| 63 | dev?: { |
| 64 | /** Literal platform.env overrides (win over target-provided values). */ |
| 65 | env?: Record<string, unknown>; |
| 66 | /** Target-specific binding specs (opaque to the framework half). */ |
| 67 | bindings?: ReadonlyArray<unknown>; |
| 68 | }; |
| 69 | }): SvelteKitAdapter; |
| 70 | } |
| 71 | ``` |
| 72 | |
| 73 | - `adapter(context)` — a kit `Adapter` whose `adapt()` records |
| 74 | `result.current = { dest, workerEntry }` (`dest` → the static-assets |
| 75 | directory → `BuildOutput.clientDirectory`; `workerEntry` → the on-disk |
| 76 | unbundled server entry). When `context.dev` is present the adapter's |
| 77 | `emulate()` must supply the dev `platform` (serving `context.dev.bindings` |
| 78 | however the target's runtime provides them, with `context.dev.env` |
| 79 | literals overriding same-named values); the optional `dispose()` on the |
| 80 | returned adapter releases whatever the platform emulation holds and is |
| 81 | called by the framework after the dev server closes. |
| 82 | - `finish(output, context)` (generic `DeployTarget` seam) — receives |
| 83 | `context.entry = workerEntry` and turns it into |
| 84 | `BuildOutput.serverModules` for the target runtime. |
| 85 | - `build(context)` (generic seam) — optional wholesale takeover; when a |
| 86 | target defines it, the framework delegates the entire production build. |
| 87 | |
| 88 | `Framework.build` therefore runs: resolve target → (`target.build`? delegate) |
| 89 | → kit `buildApp()` with `target.adapter({ root })` → `applyDeployTargetFinish` |
| 90 | with the adapter's `workerEntry`. `Framework.dev` runs kit's own Vite dev |
| 91 | server (Node SSR, full HMR) with `target.adapter({ root, dev })` supplying the |
| 92 | platform emulation. `target.serve` is not used by this package (serving built |
| 93 | output is the harness's/deployer's concern). |
| 94 | |
| 95 | ### The Cloudflare target |
| 96 | |
| 97 | `makeCloudflareTarget(config)` (default export of |
| 98 | `@alchemy.run/frontend-frameworks/sveltekit/cloudflare`) implements the contract: |
| 99 | |
| 100 | - **Adapter** — an in-memory fork of `@sveltejs/adapter-cloudflare`'s |
| 101 | `adapt()`: `writeClient` + `writePrerendered` into kit's `cloudflare` build |
| 102 | directory, `generateManifest` → `cloudflare-tmp/manifest.js`, generated |
| 103 | `_worker.js`, merged `_headers`/`_redirects`, `.assetsignore`, optional |
| 104 | `404.html`/`index.html` fallbacks, `builder.instrument` support. |
| 105 | Differences from upstream: always **Workers** mode (upstream defaults to |
| 106 | Pages without a wrangler config — intentional divergence), no |
| 107 | `unstable_readConfig`, no `_routes.json`, and the worker shim is |
| 108 | **generated with real relative import paths** (upstream ships a prebuilt |
| 109 | `files/worker.js` and string-replaces `SERVER`/`MANIFEST` placeholders — no |
| 110 | publish-time prebundle needed here). |
| 111 | - **Worker shim** — module-level `env` from `cloudflare:workers`; static |
| 112 | assets, prerendered pages, and `version.json` served via the assets-binding |
| 113 | fetch; trailing-slash redirects for prerendered pages; everything else |
| 114 | through `server.respond` with `platform = { env, ctx, caches, cf }`. |
| 115 | Upstream's `worktop/cfw.cache` dependency is replaced with an inline |
| 116 | pragma-cache over `caches.default` (same lookup/save semantics, incl. the |
| 117 | `private=Set-Cookie` handling). |
| 118 | - **Finish pass** — rolldown over the unbundled `_worker.js` (which inlines |
| 119 | the whole `.svelte-kit/output/server/**` graph) into `dist/server/` with |
| 120 | `cloudflare({ compatibilityDate, compatibilityFlags, exports: ["default"] })`, |
| 121 | then entry-first `serverModules` via framework-core's |
| 122 | `readServerModulesFromDisk`/`sortServerModules`, plus the external-workspace |
| 123 | set for watch/memoization. The plugin's default resolve conditions |
| 124 | (`workerd, worker, module, browser`) handle kit's node22-flavored server |
| 125 | output as-is — validated end-to-end (e.g. `uuid` resolves to its browser |
| 126 | entry; kit pre-inlines `esm-env` precisely so downstream bundlers don't |
| 127 | mis-resolve it). |
| 128 | - **Dev platform (platform proxy)** — `emulate()` serves |
| 129 | `platform = { env, ctx, caches, cf }` through |
| 130 | `@alchemy.run/cloudflare-runtime/core/platform-proxy` (`getPlatformProxy`, |
| 131 | our wrangler-free reimplementation of wrangler's API): a workerd instance |
| 132 | hosts the worker's binding hooks and `platform.env` proxies real calls to |
| 133 | them (`env.FIXTURE_KV.get(...)`, `env.DB.prepare(...).all()`, …). The |
| 134 | proxy opens lazily on the first SSR request and is disposed with the dev |
| 135 | server. Literal `dev.env` values override same-named proxied values. |
| 136 | `caches` actually round-trips (in-memory store in the proxy worker — |
| 137 | wrangler's dev `caches` is a no-op); `cf` is the wrangler/miniflare mock |
| 138 | object; `ctx` is wrangler's no-op `ExecutionContext` mock. During |
| 139 | prerendering, `platform.env` access throws (prerenderable routes must not |
| 140 | depend on request-time bindings — mirrors upstream). |
| 141 | |
| 142 | ## Usage |
| 143 | |
| 144 | ### With the e2e harness (`e2e.config.ts`) |
| 145 | |
| 146 | ```ts |
| 147 | import * as Options from "@alchemy.run/cloudflare-test-tools/e2e/Options"; |
| 148 | import * as SvelteKit from "@alchemy.run/frontend-frameworks/sveltekit"; |
| 149 | |
| 150 | export default Options.make({ |
| 151 | // string form: framework: "@alchemy.run/frontend-frameworks/sveltekit" |
| 152 | framework: (options) => |
| 153 | SvelteKit.layer(SvelteKit.fromHarnessOptions(options as SvelteKit.HarnessOptions)), |
| 154 | target: { |
| 155 | cloudflare: { |
| 156 | worker: { |
| 157 | compatibilityDate: "2026-03-10", |
| 158 | compatibilityFlags: ["nodejs_compat"], |
| 159 | worker: { |
| 160 | bindings: [ |
| 161 | /* ... */ |
| 162 | ], |
| 163 | assets: { notFoundHandling: "none" }, |
| 164 | }, |
| 165 | }, |
| 166 | preview: { |
| 167 | /* miniflare options for `e2e preview` / Playwright "live" */ |
| 168 | }, |
| 169 | }, |
| 170 | }, |
| 171 | }); |
| 172 | ``` |
| 173 | |
| 174 | `fromHarnessOptions` reads the target-scoped carriage |
| 175 | (`target.cloudflare.worker`, falling back to the deprecated top-level `vite` |
| 176 | alias): compatibility date/flags, `assets.notFoundHandling`, and the declared |
| 177 | binding hooks, which pass through wholesale to the dev platform proxy — |
| 178 | resource bindings (KV, D1, …) included, not just literal values. |
| 179 | |
| 180 | ### Direct (typed) usage |
| 181 | |
| 182 | ```ts |
| 183 | import * as SvelteKit from "@alchemy.run/frontend-frameworks/sveltekit"; |
| 184 | |
| 185 | const layer = SvelteKit.layer({ |
| 186 | compatibilityDate: "2026-03-10", |
| 187 | compatibilityFlags: ["nodejs_compat"], |
| 188 | kit: { alias: { $lib: "src/lib" } }, |
| 189 | adapter: { notFoundHandling: "404-page", fallback: "spa" }, |
| 190 | dev: { |
| 191 | port: 3103, |
| 192 | // cloudflare-runtime binding hooks, served on platform.env via the proxy |
| 193 | bindings: [KvNamespace.local("MY_KV")], |
| 194 | // literal overrides — win over same-named proxied values |
| 195 | env: { MY_SECRET: "..." }, |
| 196 | }, |
| 197 | }); |
| 198 | ``` |
| 199 | |
| 200 | ### Selecting a deploy target |
| 201 | |
| 202 | `SvelteKitOptions.target` accepts a `DeployTargetInput`: |
| 203 | |
| 204 | ```ts |
| 205 | // 1. default — omitted: resolves "@alchemy.run/frontend-frameworks/sveltekit/cloudflare" |
| 206 | // from the project's node_modules |
| 207 | SvelteKit.layer({}); |
| 208 | |
| 209 | // 2. factory — applied to the config the framework assembles from options |
| 210 | import cloudflareTarget from "@alchemy.run/frontend-frameworks/sveltekit/cloudflare"; |
| 211 | SvelteKit.layer({ target: cloudflareTarget }); |
| 212 | |
| 213 | // 3. value — full control; options-level compat/adapter fields are ignored |
| 214 | SvelteKit.layer({ target: cloudflareTarget({ compatibilityDate: "2026-03-10" }) }); |
| 215 | |
| 216 | // 4. specifier — loaded from the project's dependency tree |
| 217 | SvelteKit.layer({ target: "@my-scope/sveltekit-aws" }); |
| 218 | ``` |
| 219 | |
| 220 | A future target (e.g. AWS) implements `SvelteKitTarget` in its own module — |
| 221 | no change to this package's framework half. |
| 222 | |
| 223 | ## Options |
| 224 | |
| 225 | ### `SvelteKitOptions` (framework) |
| 226 | |
| 227 | | Option | Default | Purpose | |
| 228 | | -------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | |
| 229 | | `root` | `process.cwd()` | Project root. Must also be the process cwd (see limitations). | |
| 230 | | `target` | `"@alchemy.run/frontend-frameworks/sveltekit/cloudflare"` | Deploy target: value, factory, or module specifier. | |
| 231 | | `compatibilityDate` | — | Forwarded to the target config (drives the finishing pass). | |
| 232 | | `compatibilityFlags` | `["nodejs_compat"]` (applied by the cloudflare target) | Forwarded to the target config. Kit's server graph is node-flavored, so `nodejs_compat` is effectively required on workerd. | |
| 233 | | `kit` | — | Kit config passed to `sveltekit(config)` (the `adapter` field is injected). | |
| 234 | | `vite` | — | Extra Vite `InlineConfig` merged into build/dev. | |
| 235 | | `adapter` | — | `SvelteKitAdapterOptions` forwarded to the target config. | |
| 236 | | `dev.port` | Vite default | Dev-server port (overridden by `FrameworkDevOptions.port`). | |
| 237 | | `dev.bindings` | `[]` | Binding specs the target's dev platform serves on `platform.env` (Cloudflare: cloudflare-runtime `BindingHooks`). | |
| 238 | | `dev.env` | `{}` | Literal `platform.env` overrides — a same-named literal wins over the proxied binding value. | |
| 239 | |
| 240 | ### `SvelteKitAdapterOptions` |
| 241 | |
| 242 | | Option | Default | Purpose | |
| 243 | | ------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------- | |
| 244 | | `assetsBinding` | `"ASSETS"` | Name of the static-assets binding the shim serves files through. | |
| 245 | | `notFoundHandling` | `"none"` | `"404-page"` writes `404.html`; `"single-page-application"` writes `index.html` (mirrors Workers static assets `not_found_handling`). | |
| 246 | | `fallback` | `"plaintext"` | With `"404-page"`: `"spa"` renders the app shell as the fallback. | |
| 247 | |
| 248 | ### `SvelteKitSourceOptions` (`./source`, alchemy) |
| 249 | |
| 250 | `rootDir`, `memo` (`include`/`exclude` globs + `lockfile`, default |
| 251 | gitignore-aware), `kit`, `adapter` — all JSON-serializable (they persist in |
| 252 | alchemy state and participate in the Worker's metadata hash). `build()` maps |
| 253 | `BuildOutput` onto alchemy's source contract (entry-first bundle files, |
| 254 | manifest-hashed assets honoring `.assetsignore`/`_headers`/`_redirects`, |
| 255 | project-tree `input` hash); `hash()` recomputes the input hash without |
| 256 | building; `dev()` starts kit's dev server with the proxy-backed platform: |
| 257 | the Worker's binding hooks (`ctx.worker.bindings`) serve on `platform.env` |
| 258 | through the platform proxy, while literal `props.env` values (strings; |
| 259 | `Redacted` values unwrapped) apply as overrides. |
| 260 | |
| 261 | ## Limitations & known constraints |
| 262 | |
| 263 | - **Pre-release kit.** Pinned to `@sveltejs/kit` `3.0.0-next.9` |
| 264 | (`peerDependencies: >=3.0.0-next.9`). The v3 surface this package relies on |
| 265 | (`sveltekit(config)`, the `Adapter`/`Builder` API, `buildApp` ordering) is |
| 266 | new and may shift before stable; treat kit bumps as deliberate migrations. |
| 267 | Note the repo's `minimumReleaseAge` install gate (3 days) when bumping. |
| 268 | - **Always Workers mode.** Upstream defaults to Cloudflare _Pages_ when no |
| 269 | wrangler config exists; this package intentionally has no Pages mode and |
| 270 | emits no `_routes.json`. |
| 271 | - **Dev fidelity.** Dev runs kit's Node SSR with real bindings served |
| 272 | through cloudflare-runtime's platform proxy (`platform.env` calls |
| 273 | round-trip to a workerd instance hosting the local bindings; `caches` |
| 274 | round-trips; `cf`/`ctx` match wrangler's mocks). App code itself still |
| 275 | runs in Node, not workerd — forcing kit's dev SSR into workerd is a |
| 276 | non-goal (kit's dev path is hardwired to Node `ssrLoadModule`; even |
| 277 | Cloudflare's official vite plugin doesn't do it). The proxy's documented |
| 278 | limitations apply in dev: |
| 279 | - binding methods whose results are rich class instances (e.g. R2's |
| 280 | `R2Object`) are not supported over the proxy — results must be |
| 281 | JSON-compatible values, bytes, dates, streams, or `DurableObjectId`s; |
| 282 | - no synchronous materialisation of intermediate values — |
| 283 | `env.DO.idFromName("a").toString()` needs |
| 284 | `(await env.DO.idFromName("a")).toString()`; |
| 285 | - `connect()` on sockets is unsupported, and cross-binding stub arguments |
| 286 | are rejected. |
| 287 | - **cwd sensitivity.** `sveltekit()` resolves its peers |
| 288 | (`@sveltejs/vite-plugin-svelte`, `vite`) and kit's postbuild |
| 289 | analyse/prerender worker threads re-derive `outDir` relative to |
| 290 | `process.cwd()`. Run `build` with cwd = project root. The `./source` |
| 291 | provider handles this itself (a process-level lock + temporary `chdir`); |
| 292 | the e2e harness runs from the fixture directory anyway. |
| 293 | - **Node-flavored dependencies.** Kit builds its server graph for |
| 294 | `target: 'node22'`; the finishing pass re-resolves it for workerd. The |
| 295 | plugin defaults have proven sufficient, but a dependency that only ships |
| 296 | node builtins outside `nodejs_compat`'s coverage would surface here (the |
| 297 | re-bundle's only expected externals are `cloudflare:*` and `node:*`). |
| 298 | - **`_headers` / `_redirects`** are read from the project root and merged with |
| 299 | kit's generated rules (immutable-cache + noindex for the app dir, |
| 300 | prerendered redirects). |
| 301 | - **Lightly-tested corners** (as upstream): service workers and |
| 302 | `instrumentation.server.js` entry wrapping (supported via |
| 303 | `builder.instrument`, not covered by the fixture). |
| 304 | - **`emulate()` env guard.** Accessing `platform.env` from a prerenderable |
| 305 | route throws in dev — by design, since prerendered pages cannot see |
| 306 | request-time bindings in production either. |
| 307 | |
| 308 | ## Testing |
| 309 | |
| 310 | - `bun run test` — unit tests (adapter output generation, shim generation, |
| 311 | harness-option mapping, target contract, and the import-boundary |
| 312 | enforcement test). |
| 313 | - `packages/frontend-frameworks/fixtures/sveltekit` — the end-to-end fixture (Playwright against both the |
| 314 | miniflare-served production build and the kit dev server); see its README |
| 315 | for the covered kit surface (form actions, cookies, binary endpoints, route |
| 316 | groups, prerender + SSR mix, `platform.env` with a real KV binding and |
| 317 | literal overrides, `platform.caches` round-trips, `platform.cf`). |
| 318 | |
| 319 | ## Upstream references |
| 320 | |
| 321 | - SvelteKit: [`f36b3a4cd307bd8c89e03fa191becc175e7b3077`](https://github.com/sveltejs/kit/tree/f36b3a4cd307bd8c89e03fa191becc175e7b3077) |