A Vite plugin that makes Durable Objects and Workflows work with SvelteKit on Cloudflare, in both dev and production.
Build mode: SvelteKit's adapter-cloudflare generates _worker.js with only a default export (the fetch handler). Cloudflare Workers requires Durable Object and Workflow classes to be named exports. This plugin post-processes the build output to merge your named exports with SvelteKit's default export.
Dev mode: getPlatformProxy (used by adapter-cloudflare in dev) can't run internal Durable Objects or Workflows. This plugin starts a separate wrangler dev server that runs the real DO/Workflow worker with hot-reload. SvelteKit +server.ts handlers call DOs through platform.env.MY_DO.<rpc>() as usual — the plugin rewrites those bindings to point at the sidecar via wrangler's dev registry, so cross-worker calls Just Work. Clients can also connect directly to the sidecar via WebSocket on a separate port (see below).
pnpm add -D @oselvar/sveltekit-add-worker-exports
esbuild, vite, and wrangler are peer dependencies -- your SvelteKit project already has them.
Create a worker entry point that exports your Durable Object classes and a default fetch handler. The fetch handler is only used by the wrangler dev server — in production, SvelteKit's route handlers handle all requests.
// src/lib/server/index.ts
export { MyDurableObject } from './MyDurableObject';
export { MyWorkflow } from './MyWorkflow';
export { default } from './devHandler';
Workflow classes (extending WorkflowEntrypoint) are exported the same way as Durable Objects — the plugin merges them into _worker.js as named exports. Declare them in wrangler.jsonc under workflows, and they become available as bindings (e.g. env.MY_WORKFLOW.create({ params })) in both dev and production.
Both the dev handler and the production SvelteKit route need to do the same thing: validate the upgrade header and forward the request to a Durable Object. Extract that into a small helper so the two callers stay in sync:
// src/lib/server/forwardWebSocket.ts
export async function forwardWebSocket<T extends Rpc.DurableObjectBranded | undefined>(
request: Request,
namespace: DurableObjectNamespace<T>,
name: string
): Promise<Response> {
if (request.headers.get('upgrade') !== 'websocket') {
return new Response('Expected WebSocket', { status: 426 });
}
const id = namespace.idFromName(name);
return namespace.get(id).fetch(request);
}
The dev handler parses the URL and delegates. It's only used by the wrangler dev sidecar:
// src/lib/server/devHandler.ts
import { forwardWebSocket } from './forwardWebSocket';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const match = new URL(request.url).pathname.match(/^\/ws\/(.+)$/);
if (!match) return new Response('Not found', { status: 404 });
return forwardWebSocket(request, env.MY_DO, match[1]);
}
};
In production, the dev handler is not used — SvelteKit serves the same path through a +server.ts route, which delegates to the same helper:
// src/routes/ws/[id]/+server.ts
import { forwardWebSocket } from '$lib/server/forwardWebSocket';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = ({ params, request, platform }) =>
forwardWebSocket(request, platform!.env.MY_DO, params.id);
Now any change to the upgrade-and-forward logic (auth, rate-limiting, response shape) lives in one place and applies to both dev and production.
Add the plugin to your vite.config.ts after sveltekit():
import { sveltekit } from '@sveltejs/kit/vite';
import { addWorkerExports } from '@oselvar/sveltekit-add-worker-exports';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
sveltekit(),
addWorkerExports({ entryPoint: 'src/lib/server/index.ts' })
]
});
Point adapter-cloudflare's platform proxy at the generated .platform-proxy-wrangler.jsonc. The plugin writes this file with internal Durable Object bindings rewritten to cross-worker form (each gets a script_name pointing at the sidecar). Workflows and migrations are stripped — calling a Workflow from platform.env in vite dev isn't supported, but it works in production. Without this config path, getPlatformProxy would try to run the classes itself and warn that it can't:
// svelte.config.js
import adapter from '@sveltejs/adapter-cloudflare';
export default {
kit: {
adapter: adapter({
platformProxy: {
configPath: '.platform-proxy-wrangler.jsonc'
}
})
}
};
In dev mode, the plugin starts a wrangler dev server on a separate port and injects __DEV_WORKER_PORT__ as a compile-time constant. Use it to connect your client:
import { dev } from '$app/environment';
let wsUrl: string;
if (dev) {
wsUrl = `ws://${window.location.hostname}:${__DEV_WORKER_PORT__}/ws/${id}`;
} else {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/ws/${id}`;
}
const ws = new WebSocket(wsUrl);
Add the type declaration to your src/app.d.ts:
declare global {
const __DEV_WORKER_PORT__: number;
}
The plugin auto-discovers your wrangler.jsonc (or wrangler.toml) and reads DO bindings, migrations, and compatibility settings from it. It overrides only the main entry point to use your source file instead of the SvelteKit build output.
vite dev exercises the dev handler via the wrangler-dev sidecar; it does not exercise your +server.ts route or the merged _worker.js. To verify the production wiring (named DO exports + SvelteKit routes in the same worker), run wrangler against the build output:
pnpm build # produces .svelte-kit/cloudflare/_worker.js with merged exports
pnpm wrangler dev # uses wrangler.jsonc → main: .svelte-kit/cloudflare/_worker.js
This serves the exact bundle that gets deployed, with local Durable Object storage. Connect a WebSocket client to ws://localhost:8787/ws/<id> and confirm you get a 101 Switching Protocols response — that proves the request flowed through the SvelteKit +server.ts and into your DO.
A handy shortcut is to add a preview script to package.json:
{
"scripts": {
"preview": "wrangler dev"
}
}
Note:
vite previewis not suitable here — it only serves static assets and cannot run Durable Objects. Always usewrangler devto preview the production worker.
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
entryPoint |
string |
Yes | -- | Path to the file that exports your DO/Workflow classes |
outputDir |
string |
No | .svelte-kit/cloudflare |
Directory containing the SvelteKit-generated _worker.js |
wranglerConfig |
string |
No | auto-discovered | Path to wrangler config file |
devPort |
number |
No | 8787 |
Port for the dev worker server |
The plugin runs in the closeBundle hook (after SvelteKit's adapter has generated _worker.js):
entryPoint with esbuild into _extra_exports.js_worker.js to _sveltekit_worker.js_worker.js that re-exports both:export { default } from './_sveltekit_worker.js';
export * from './_extra_exports.js';
The operation is idempotent -- if _sveltekit_worker.js already exists, the plugin skips.
The plugin reads your wrangler config, creates a temporary config with main pointing to your entryPoint, and starts a wrangler dev server via unstable_startWorker. This gives you:
The dev plugin creates a temporary .dev-worker-wrangler.jsonc with main pointing to your source entry point. You can use this to generate fully generic Cloudflare types:
wrangler types --config .dev-worker-wrangler.jsonc
This produces typed DO bindings like DurableObjectNamespace<MyDurableObject> instead of the untyped DurableObjectNamespace you get from the default wrangler.jsonc (whose main points to the SvelteKit build output, which doesn't exist during dev).
Add this to your package.json scripts for convenience:
{
"scripts": {
"types": "wrangler types --config .dev-worker-wrangler.jsonc"
}
}
Note: the .dev-worker-wrangler.jsonc file is generated when the dev server starts. Run pnpm dev at least once before running wrangler types.
SvelteKit's adapter-cloudflare does not support named exports from the worker entry point (sveltejs/kit#1712). Additionally, getPlatformProxy (used for local dev) cannot run internal Durable Objects because it uses an empty worker script.
MIT