1# @alchemy.run/frontend-frameworks/sveltekit
2
3Wrangler-free SvelteKit integration: programmatic build and dev for SvelteKit
4projects, with the deploy platform passed in as a **deploy target** value.
5Cloudflare Workers is the built-in target
6(`@alchemy.run/frontend-frameworks/sveltekit/cloudflare`); the framework half of this package
7contains no Cloudflare code at all.
8
9## Architecture: framework half × target half
10
11The package follows the framework × deploy-target split defined in
12`@alchemy.run/frontend-frameworks/core` (see its README for the full `DeployTarget`
13contract):
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
21Background 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
56The framework half declares what any deploy target must provide:
57
58```ts
59interface 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`
90with the adapter's `workerEntry`. `Framework.dev` runs kit's own Vite dev
91server (Node SSR, full HMR) with `target.adapter({ root, dev })` supplying the
92platform emulation. `target.serve` is not used by this package (serving built
93output 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
147import * as Options from "@alchemy.run/cloudflare-test-tools/e2e/Options";
148import * as SvelteKit from "@alchemy.run/frontend-frameworks/sveltekit";
149
150export 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`
176alias): compatibility date/flags, `assets.notFoundHandling`, and the declared
177binding hooks, which pass through wholesale to the dev platform proxy —
178resource bindings (KV, D1, …) included, not just literal values.
179
180### Direct (typed) usage
181
182```ts
183import * as SvelteKit from "@alchemy.run/frontend-frameworks/sveltekit";
184
185const 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
207SvelteKit.layer({});
208
209// 2. factory — applied to the config the framework assembles from options
210import cloudflareTarget from "@alchemy.run/frontend-frameworks/sveltekit/cloudflare";
211SvelteKit.layer({ target: cloudflareTarget });
212
213// 3. value — full control; options-level compat/adapter fields are ignored
214SvelteKit.layer({ target: cloudflareTarget({ compatibilityDate: "2026-03-10" }) });
215
216// 4. specifier — loaded from the project's dependency tree
217SvelteKit.layer({ target: "@my-scope/sveltekit-aws" });
218```
219
220A future target (e.g. AWS) implements `SvelteKitTarget` in its own module —
221no 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
251gitignore-aware), `kit`, `adapter` — all JSON-serializable (they persist in
252alchemy state and participate in the Worker's metadata hash). `build()` maps
253`BuildOutput` onto alchemy's source contract (entry-first bundle files,
254manifest-hashed assets honoring `.assetsignore`/`_headers`/`_redirects`,
255project-tree `input` hash); `hash()` recomputes the input hash without
256building; `dev()` starts kit's dev server with the proxy-backed platform:
257the Worker's binding hooks (`ctx.worker.bindings`) serve on `platform.env`
258through 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)