@oselvar/sveltekit-add-worker-exports 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@ A Vite plugin that makes Durable Objects and Workflows work with SvelteKit on Cl
4
4
 
5
5
  **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.
6
6
 
7
- **Dev mode:** `getPlatformProxy` (used by `adapter-cloudflare` in dev) can't run internal Durable Objects. This plugin starts a separate wrangler dev server that runs the real DO worker with hot-reload. Clients connect directly to it via WebSocket on a separate port.
7
+ **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).
8
8
 
9
9
  ## Install
10
10
 
@@ -87,7 +87,7 @@ export default defineConfig({
87
87
  });
88
88
  ```
89
89
 
90
- Point `adapter-cloudflare`'s platform proxy at the generated `.platform-proxy-wrangler.jsonc`. This is a copy of your wrangler config with internal Durable Object bindings, Workflows, and migrations stripped — those are served by the dev sidecar on a separate port, and `getPlatformProxy` would otherwise log warnings about classes it can't load:
90
+ 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 can't survive `getPlatformProxy` (wrangler unconditionally deletes them), so the plugin replaces them with a service binding pointing at the sidecar; a small SvelteKit hook synthesizes the missing `Workflow` API on top of that bridge — see [Dev mode: calling Workflows](#dev-mode-calling-workflows) below. Without this config path, `getPlatformProxy` would try to run the DO classes itself and warn that it can't:
91
91
 
92
92
  ```javascript
93
93
  // svelte.config.js
@@ -129,7 +129,41 @@ declare global {
129
129
  }
130
130
  ```
131
131
 
132
- 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.
132
+ The plugin auto-discovers your `wrangler.jsonc` (or `wrangler.toml`) and reads DO bindings, workflows, migrations, and compatibility settings from it. It overrides only the `main` entry point to use a generated wrapper (in `node_modules/.cache/sveltekit-add-worker-exports/`) that re-exports your classes and adds the workflow bridge routes.
133
+
134
+ ### Dev mode: calling Workflows
135
+
136
+ Workflow bindings can't be passed through `getPlatformProxy` — wrangler unconditionally strips them ([source](https://github.com/cloudflare/workers-sdk/blob/main/packages/wrangler/src/api/integrations/platform/index.ts)). The plugin works around this by adding a service binding to the sidecar and shipping a SvelteKit `Handle` that synthesizes `Workflow`-shaped objects on `platform.env`.
137
+
138
+ Wire it up in `src/hooks.server.ts`:
139
+
140
+ ```typescript
141
+ export { handle } from '@oselvar/sveltekit-add-worker-exports/hooks';
142
+ ```
143
+
144
+ If you already have a `handle`, compose with `sequence`:
145
+
146
+ ```typescript
147
+ import { sequence } from '@sveltejs/kit/hooks';
148
+ import { handle as sweHandle } from '@oselvar/sveltekit-add-worker-exports/hooks';
149
+ import { handle as myHandle } from './my-handle';
150
+
151
+ export const handle = sequence(sweHandle, myHandle);
152
+ ```
153
+
154
+ After that, `+server.ts` can call workflows the same way it does in production:
155
+
156
+ ```typescript
157
+ export const POST: RequestHandler = async ({ params, request, platform }) => {
158
+ const userMessage = await request.text();
159
+ const instance = await platform!.env.MY_WORKFLOW.create({
160
+ params: { ... }
161
+ });
162
+ return new Response(instance.id);
163
+ };
164
+ ```
165
+
166
+ In production the hook is a no-op — it reads its config from runtime globals that only the dev plugin sets, and `platform.env.MY_WORKFLOW` is the real Cloudflare binding.
133
167
 
134
168
  ### Testing the production build locally
135
169
 
@@ -191,10 +225,10 @@ The plugin reads your wrangler config, creates a temporary config with `main` po
191
225
 
192
226
  ### Generating typed bindings
193
227
 
194
- 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:
228
+ The dev plugin creates a temporary `.types-worker-wrangler.jsonc` with `main` pointing to your source entry point. Use it to generate fully generic Cloudflare types:
195
229
 
196
230
  ```bash
197
- wrangler types --config .dev-worker-wrangler.jsonc
231
+ wrangler types --config .types-worker-wrangler.jsonc
198
232
  ```
199
233
 
200
234
  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).
@@ -204,12 +238,12 @@ Add this to your `package.json` scripts for convenience:
204
238
  ```json
205
239
  {
206
240
  "scripts": {
207
- "types": "wrangler types --config .dev-worker-wrangler.jsonc"
241
+ "types": "wrangler types --config .types-worker-wrangler.jsonc"
208
242
  }
209
243
  }
210
244
  ```
211
245
 
212
- Note: the `.dev-worker-wrangler.jsonc` file is generated when the dev server starts. Run `pnpm dev` at least once before running `wrangler types`.
246
+ Note: the `.types-worker-wrangler.jsonc` file is generated when the dev server starts. Run `pnpm dev` at least once before running `wrangler types`. (The plugin also writes `.dev-worker-wrangler.jsonc` for the runtime sidecar, but its `main` points at a generated bridge wrapper in `node_modules/.cache/`, which can't be used for type generation — wrangler doesn't follow `export *` re-exports out of node_modules.)
213
247
 
214
248
  ## Why this exists
215
249
 
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Workerd-side bridge handler. Bundled into the dev sidecar by wrangler via
3
+ * the cache wrapper at `node_modules/.cache/sveltekit-add-worker-exports/`.
4
+ *
5
+ * The wrapper imports `createBridgeFetch` from this file and uses it as the
6
+ * default fetch handler. Routes `/__swe/wf/*` are handled here; anything else
7
+ * delegates to the user's own default fetch handler.
8
+ *
9
+ * This file is part of the workflow-bridge fallback that exists because
10
+ * `getPlatformProxy()` in unpatched wrangler/miniflare strips workflow
11
+ * bindings. Once the upstream patch lands (cloudflare/workers-sdk#7459),
12
+ * the entire `src/bridge/` directory can be deleted.
13
+ */
14
+ type CtxLike = {
15
+ waitUntil(promise: Promise<unknown>): void;
16
+ };
17
+ type UserFetch = (request: Request, env: unknown, ctx: CtxLike) => Promise<Response> | Response;
18
+ export interface BridgeOptions {
19
+ workflowBindings: readonly string[];
20
+ userFetch?: UserFetch;
21
+ }
22
+ /**
23
+ * Returns a `fetch` handler that intercepts `/__swe/wf/*` and forwards
24
+ * everything else to `userFetch`.
25
+ */
26
+ export declare function createBridgeFetch(options: BridgeOptions): (request: Request, env: unknown, ctx: CtxLike) => Promise<Response>;
27
+ export {};
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Workerd-side bridge handler. Bundled into the dev sidecar by wrangler via
3
+ * the cache wrapper at `node_modules/.cache/sveltekit-add-worker-exports/`.
4
+ *
5
+ * The wrapper imports `createBridgeFetch` from this file and uses it as the
6
+ * default fetch handler. Routes `/__swe/wf/*` are handled here; anything else
7
+ * delegates to the user's own default fetch handler.
8
+ *
9
+ * This file is part of the workflow-bridge fallback that exists because
10
+ * `getPlatformProxy()` in unpatched wrangler/miniflare strips workflow
11
+ * bindings. Once the upstream patch lands (cloudflare/workers-sdk#7459),
12
+ * the entire `src/bridge/` directory can be deleted.
13
+ */
14
+ /**
15
+ * Returns a `fetch` handler that intercepts `/__swe/wf/*` and forwards
16
+ * everything else to `userFetch`.
17
+ */
18
+ export function createBridgeFetch(options) {
19
+ const bindings = new Set(options.workflowBindings);
20
+ return async (request, env, ctx) => {
21
+ const url = new URL(request.url);
22
+ if (url.pathname.startsWith('/__swe/wf/')) {
23
+ return handleWf(url.pathname.slice('/__swe/wf/'.length), request, env, bindings);
24
+ }
25
+ if (options.userFetch) {
26
+ return options.userFetch(request, env, ctx);
27
+ }
28
+ return new Response('Not found', { status: 404 });
29
+ };
30
+ }
31
+ async function handleWf(rest, request, env, bindings) {
32
+ const parts = rest.split('/').map(decodeURIComponent);
33
+ const [binding, op, ...tail] = parts;
34
+ if (!binding || !bindings.has(binding)) {
35
+ return new Response('unknown workflow binding: ' + binding, { status: 404 });
36
+ }
37
+ const wf = env[binding];
38
+ if (!wf) {
39
+ return new Response('binding ' + binding + ' missing on sidecar', { status: 500 });
40
+ }
41
+ try {
42
+ if (op === 'create' && request.method === 'POST') {
43
+ const opts = await request.json();
44
+ const inst = await wf.create(opts);
45
+ return Response.json({ id: inst.id });
46
+ }
47
+ if (op === 'createBatch' && request.method === 'POST') {
48
+ const batch = (await request.json());
49
+ const insts = await wf.createBatch(batch);
50
+ return Response.json(insts.map((i) => ({ id: i.id })));
51
+ }
52
+ if (op === 'get' && tail.length === 1) {
53
+ const inst = await wf.get(tail[0]);
54
+ return Response.json({ id: inst.id });
55
+ }
56
+ if (op === 'instance' && tail.length === 2) {
57
+ const [id, method] = tail;
58
+ const inst = await wf.get(id);
59
+ switch (method) {
60
+ case 'pause':
61
+ await inst.pause();
62
+ return new Response(null, { status: 204 });
63
+ case 'resume':
64
+ await inst.resume();
65
+ return new Response(null, { status: 204 });
66
+ case 'terminate':
67
+ await inst.terminate();
68
+ return new Response(null, { status: 204 });
69
+ case 'restart':
70
+ await inst.restart();
71
+ return new Response(null, { status: 204 });
72
+ case 'status':
73
+ return Response.json(await inst.status());
74
+ case 'sendEvent': {
75
+ const body = (await request.json());
76
+ await inst.sendEvent(body);
77
+ return new Response(null, { status: 204 });
78
+ }
79
+ }
80
+ return new Response('unknown method: ' + method, { status: 404 });
81
+ }
82
+ return new Response('unknown op: ' + op, { status: 404 });
83
+ }
84
+ catch (err) {
85
+ const message = err instanceof Error ? err.message : String(err);
86
+ return new Response('error: ' + message, { status: 500 });
87
+ }
88
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * SvelteKit `Handle` that injects fake Workflow bindings into `event.platform.env`
3
+ * during `vite dev`. In production this is a no-op.
4
+ *
5
+ * Why: wrangler's `getPlatformProxy` (used by `adapter-cloudflare` in dev) deletes
6
+ * workflow bindings before they reach miniflare, so `+server.ts` can't call
7
+ * `platform.env.MY_WORKFLOW.create(...)` directly. The companion vite plugin
8
+ * exposes a service binding (`__SWE_BRIDGE`) that fetches into a sidecar wrangler
9
+ * dev worker which has the real workflow binding. This hook synthesizes a
10
+ * `Workflow`-shaped object per declared workflow that proxies API calls through
11
+ * that bridge.
12
+ *
13
+ * Usage in `src/hooks.server.ts`:
14
+ *
15
+ * export { handle } from '@oselvar/sveltekit-add-worker-exports/hooks';
16
+ *
17
+ * If you already have a `handle`, compose with `sequence`:
18
+ *
19
+ * import { sequence } from '@sveltejs/kit/hooks';
20
+ * import { handle as sweHandle } from '@oselvar/sveltekit-add-worker-exports/hooks';
21
+ * export const handle = sequence(sweHandle, myHandle);
22
+ */
23
+ import type { Handle } from '@sveltejs/kit';
24
+ /**
25
+ * Reads bridge config from `globalThis`, set by the vite dev plugin at startup.
26
+ *
27
+ * We can't rely on vite `define` / compile-time substitution here because this
28
+ * file ships in node_modules and vite doesn't transform external modules in
29
+ * SSR by default. Plain runtime globals dodge that entirely and stay
30
+ * `undefined` in production (workerd), making this hook a no-op there.
31
+ */
32
+ export declare const handle: Handle;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * SvelteKit `Handle` that injects fake Workflow bindings into `event.platform.env`
3
+ * during `vite dev`. In production this is a no-op.
4
+ *
5
+ * Why: wrangler's `getPlatformProxy` (used by `adapter-cloudflare` in dev) deletes
6
+ * workflow bindings before they reach miniflare, so `+server.ts` can't call
7
+ * `platform.env.MY_WORKFLOW.create(...)` directly. The companion vite plugin
8
+ * exposes a service binding (`__SWE_BRIDGE`) that fetches into a sidecar wrangler
9
+ * dev worker which has the real workflow binding. This hook synthesizes a
10
+ * `Workflow`-shaped object per declared workflow that proxies API calls through
11
+ * that bridge.
12
+ *
13
+ * Usage in `src/hooks.server.ts`:
14
+ *
15
+ * export { handle } from '@oselvar/sveltekit-add-worker-exports/hooks';
16
+ *
17
+ * If you already have a `handle`, compose with `sequence`:
18
+ *
19
+ * import { sequence } from '@sveltejs/kit/hooks';
20
+ * import { handle as sweHandle } from '@oselvar/sveltekit-add-worker-exports/hooks';
21
+ * export const handle = sequence(sweHandle, myHandle);
22
+ */
23
+ /**
24
+ * Reads bridge config from `globalThis`, set by the vite dev plugin at startup.
25
+ *
26
+ * We can't rely on vite `define` / compile-time substitution here because this
27
+ * file ships in node_modules and vite doesn't transform external modules in
28
+ * SSR by default. Plain runtime globals dodge that entirely and stay
29
+ * `undefined` in production (workerd), making this hook a no-op there.
30
+ */
31
+ export const handle = async ({ event, resolve }) => {
32
+ const g = globalThis;
33
+ const bridgeName = g.__SWE_BRIDGE_NAME__;
34
+ const workflows = g.__SWE_WORKFLOWS__;
35
+ if (!bridgeName || !workflows || workflows.length === 0)
36
+ return resolve(event);
37
+ const env = event.platform?.env;
38
+ if (!env)
39
+ return resolve(event);
40
+ const bridge = env[bridgeName];
41
+ if (!bridge)
42
+ return resolve(event);
43
+ for (const name of workflows) {
44
+ if (env[name])
45
+ continue;
46
+ env[name] = makeFakeWorkflow(bridge, name);
47
+ }
48
+ return resolve(event);
49
+ };
50
+ function makeFakeWorkflow(bridge, binding) {
51
+ const base = `http://swe/__swe/wf/${encodeURIComponent(binding)}`;
52
+ return {
53
+ async create(opts) {
54
+ const { id } = await postJson(bridge, `${base}/create`, opts ?? {});
55
+ return makeFakeInstance(bridge, binding, id);
56
+ },
57
+ async createBatch(batch) {
58
+ const ids = await postJson(bridge, `${base}/createBatch`, batch);
59
+ return ids.map(({ id }) => makeFakeInstance(bridge, binding, id));
60
+ },
61
+ async get(id) {
62
+ await getOk(bridge, `${base}/get/${encodeURIComponent(id)}`);
63
+ return makeFakeInstance(bridge, binding, id);
64
+ }
65
+ };
66
+ }
67
+ function makeFakeInstance(bridge, binding, id) {
68
+ const base = `http://swe/__swe/wf/${encodeURIComponent(binding)}/instance/${encodeURIComponent(id)}`;
69
+ const post = async (method, body) => {
70
+ const res = await bridge.fetch(`${base}/${method}`, {
71
+ method: 'POST',
72
+ body: body === undefined ? undefined : JSON.stringify(body)
73
+ });
74
+ if (!res.ok) {
75
+ throw new Error(`workflow ${method} failed: ${res.status} ${await res.text()}`);
76
+ }
77
+ const text = await res.text();
78
+ return text ? JSON.parse(text) : undefined;
79
+ };
80
+ return {
81
+ id,
82
+ pause: () => post('pause').then(() => undefined),
83
+ resume: () => post('resume').then(() => undefined),
84
+ terminate: () => post('terminate').then(() => undefined),
85
+ restart: () => post('restart').then(() => undefined),
86
+ status: () => post('status'),
87
+ sendEvent: (opts) => post('sendEvent', opts).then(() => undefined)
88
+ };
89
+ }
90
+ // Pass URL string + init (not a pre-built Request): miniflare's internal undici
91
+ // won't accept a Request from a different realm — it stringifies it and tries
92
+ // to parse `[object Request]` as a URL.
93
+ async function postJson(bridge, url, body) {
94
+ const res = await bridge.fetch(url, { method: 'POST', body: JSON.stringify(body) });
95
+ if (!res.ok) {
96
+ throw new Error(`bridge POST ${url} failed: ${res.status} ${await res.text()}`);
97
+ }
98
+ return (await res.json());
99
+ }
100
+ async function getOk(bridge, url) {
101
+ const res = await bridge.fetch(url);
102
+ if (!res.ok) {
103
+ throw new Error(`bridge GET ${url} failed: ${res.status} ${await res.text()}`);
104
+ }
105
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Vite-plugin-side bridge setup. Called from `src/index.ts` while the workflow
3
+ * bridge exists. Encapsulates everything bridge-specific:
4
+ *
5
+ * - generates the cache wrapper at `node_modules/.cache/...` so the dev
6
+ * sidecar serves the `/__swe/wf/*` routes used by the runtime hook
7
+ * - publishes config on `globalThis` for the SvelteKit `handle` to read
8
+ * - returns the proxy-config service binding to add and a disposer
9
+ *
10
+ * Once miniflare ships cross-worker workflow routing
11
+ * (cloudflare/workers-sdk#7459), delete `src/bridge/` and the call to
12
+ * `setupBridge` in `src/index.ts`.
13
+ */
14
+ export declare const BRIDGE_BINDING = "__SWE_BRIDGE";
15
+ export interface BridgeContext {
16
+ /** Path to the generated sidecar wrapper. Use as the dev config's `main`. */
17
+ wrapperPath: string;
18
+ /** Service binding entry to merge into the platform-proxy wrangler config. */
19
+ serviceBinding: {
20
+ binding: string;
21
+ service: string;
22
+ };
23
+ /** Best-effort cleanup of generated artifacts. */
24
+ dispose(): Promise<void>;
25
+ }
26
+ export interface SetupBridgeOptions {
27
+ userEntryAbs: string;
28
+ workflowBindings: readonly string[];
29
+ sidecarName: string;
30
+ }
31
+ export declare function setupBridge(options: SetupBridgeOptions): Promise<BridgeContext>;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Vite-plugin-side bridge setup. Called from `src/index.ts` while the workflow
3
+ * bridge exists. Encapsulates everything bridge-specific:
4
+ *
5
+ * - generates the cache wrapper at `node_modules/.cache/...` so the dev
6
+ * sidecar serves the `/__swe/wf/*` routes used by the runtime hook
7
+ * - publishes config on `globalThis` for the SvelteKit `handle` to read
8
+ * - returns the proxy-config service binding to add and a disposer
9
+ *
10
+ * Once miniflare ships cross-worker workflow routing
11
+ * (cloudflare/workers-sdk#7459), delete `src/bridge/` and the call to
12
+ * `setupBridge` in `src/index.ts`.
13
+ */
14
+ import { mkdir, unlink, writeFile } from 'node:fs/promises';
15
+ import { dirname, join, relative, resolve } from 'node:path';
16
+ export const BRIDGE_BINDING = '__SWE_BRIDGE';
17
+ const CACHE_DIR_REL = 'node_modules/.cache/sveltekit-add-worker-exports';
18
+ const WRAPPER_FILENAME = 'dev-worker-entry.ts';
19
+ export async function setupBridge(options) {
20
+ const cacheDir = resolve(CACHE_DIR_REL);
21
+ await mkdir(cacheDir, { recursive: true });
22
+ const wrapperPath = join(cacheDir, WRAPPER_FILENAME);
23
+ const userEntryRel = relative(dirname(wrapperPath), options.userEntryAbs)
24
+ .replace(/\\/g, '/')
25
+ .replace(/\.(ts|tsx|js|jsx|mjs|mts)$/, '');
26
+ await writeFile(wrapperPath, generateWrapperSource(userEntryRel, options.workflowBindings));
27
+ // The runtime hook reads these from globalThis. We can't use vite's
28
+ // `define` because dist/hooks.js lives in node_modules and vite doesn't
29
+ // transform external modules in SSR by default — substitutions wouldn't
30
+ // reach the file.
31
+ globalThis.__SWE_BRIDGE_NAME__ = BRIDGE_BINDING;
32
+ globalThis.__SWE_WORKFLOWS__ = options.workflowBindings;
33
+ return {
34
+ wrapperPath,
35
+ serviceBinding: { binding: BRIDGE_BINDING, service: options.sidecarName },
36
+ dispose: async () => {
37
+ await unlink(wrapperPath).catch(() => { });
38
+ }
39
+ };
40
+ }
41
+ /**
42
+ * Tiny glue file that wrangler bundles into the dev sidecar. The substantive
43
+ * bridge logic lives in `./dev-handler.ts`, which the wrapper imports as a
44
+ * package subpath. Keeping the template this small makes it unlikely to
45
+ * harbour TypeScript errors that wouldn't be caught by tsc on the rest of
46
+ * the package.
47
+ */
48
+ function generateWrapperSource(userEntryRel, workflowBindings) {
49
+ const importPath = JSON.stringify(userEntryRel);
50
+ const bindingList = JSON.stringify(workflowBindings);
51
+ return `// Auto-generated by @oselvar/sveltekit-add-worker-exports.
52
+ // Bridges Workflow bindings to SvelteKit dev. Do not edit.
53
+ import * as user from ${importPath};
54
+ export * from ${importPath};
55
+ import { createBridgeFetch } from '@oselvar/sveltekit-add-worker-exports/dev-bridge';
56
+
57
+ const userDefault = (user as { default?: { fetch?: ExportedHandlerFetchHandler } }).default;
58
+
59
+ export default {
60
+ \tfetch: createBridgeFetch({
61
+ \t\tworkflowBindings: ${bindingList},
62
+ \t\tuserFetch: userDefault?.fetch?.bind(userDefault)
63
+ \t})
64
+ } satisfies ExportedHandler;
65
+ `;
66
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * SvelteKit `Handle` re-exported for users.
3
+ *
4
+ * Implementation lives in `./bridge/runtime-hook` while the workflow bridge
5
+ * exists. Once miniflare ships cross-worker workflow routing
6
+ * (cloudflare/workers-sdk#7459), replace this file with a no-op handle:
7
+ *
8
+ * import type { Handle } from '@sveltejs/kit';
9
+ * export const handle: Handle = ({ event, resolve }) => resolve(event);
10
+ *
11
+ * and delete `src/bridge/`.
12
+ */
13
+ export { handle } from './bridge/runtime-hook.js';
package/dist/hooks.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * SvelteKit `Handle` re-exported for users.
3
+ *
4
+ * Implementation lives in `./bridge/runtime-hook` while the workflow bridge
5
+ * exists. Once miniflare ships cross-worker workflow routing
6
+ * (cloudflare/workers-sdk#7459), replace this file with a no-op handle:
7
+ *
8
+ * import type { Handle } from '@sveltejs/kit';
9
+ * export const handle: Handle = ({ event, resolve }) => resolve(event);
10
+ *
11
+ * and delete `src/bridge/`.
12
+ */
13
+ export { handle } from './bridge/runtime-hook.js';
package/dist/index.d.ts CHANGED
@@ -11,6 +11,15 @@
11
11
  * server via `unstable_startWorker` that runs the real DO worker. Clients
12
12
  * connect directly to it via WebSocket on a separate port.
13
13
  *
14
+ * Workflows in dev are best-effort: if the installed wrangler/miniflare
15
+ * supports cross-worker workflow routing (cloudflare/workers-sdk#7459),
16
+ * `platform.env.MY_WORKFLOW.create(...)` works directly. Otherwise the bridge
17
+ * in `src/bridge/` synthesises the binding via an HTTP fallback. Once the
18
+ * upstream patch lands, delete `src/bridge/`, drop the `setupBridge` import
19
+ * and call below, replace `wrapperPath` with `options.entryPoint`, remove
20
+ * the `serviceBinding` insertion, and replace `src/hooks.ts` with a no-op
21
+ * handle.
22
+ *
14
23
  * Usage in vite.config.ts:
15
24
  * addWorkerExports({ entryPoint: 'src/lib/server/index.ts' })
16
25
  *
package/dist/index.js CHANGED
@@ -11,16 +11,57 @@
11
11
  * server via `unstable_startWorker` that runs the real DO worker. Clients
12
12
  * connect directly to it via WebSocket on a separate port.
13
13
  *
14
+ * Workflows in dev are best-effort: if the installed wrangler/miniflare
15
+ * supports cross-worker workflow routing (cloudflare/workers-sdk#7459),
16
+ * `platform.env.MY_WORKFLOW.create(...)` works directly. Otherwise the bridge
17
+ * in `src/bridge/` synthesises the binding via an HTTP fallback. Once the
18
+ * upstream patch lands, delete `src/bridge/`, drop the `setupBridge` import
19
+ * and call below, replace `wrapperPath` with `options.entryPoint`, remove
20
+ * the `serviceBinding` insertion, and replace `src/hooks.ts` with a no-op
21
+ * handle.
22
+ *
14
23
  * Usage in vite.config.ts:
15
24
  * addWorkerExports({ entryPoint: 'src/lib/server/index.ts' })
16
25
  *
17
26
  * @see https://github.com/sveltejs/kit/issues/1712
18
27
  */
19
28
  import { build } from 'esbuild';
29
+ import { existsSync } from 'node:fs';
20
30
  import { access, readFile, rename, writeFile, unlink } from 'node:fs/promises';
21
- import { resolve } from 'node:path';
31
+ import { homedir } from 'node:os';
32
+ import { join, resolve } from 'node:path';
22
33
  import { parse as parseJsonc } from 'jsonc-parser';
34
+ import { setupBridge } from './bridge/setup.js';
23
35
  const DEFAULT_DEV_PORT = 8787;
36
+ const NODE_BUILTINS = [
37
+ 'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console',
38
+ 'constants', 'crypto', 'dgram', 'diagnostics_channel', 'dns', 'domain',
39
+ 'events', 'fs', 'fs/promises', 'http', 'http2', 'https', 'inspector',
40
+ 'module', 'net', 'os', 'path', 'path/posix', 'path/win32', 'perf_hooks',
41
+ 'process', 'punycode', 'querystring', 'readline', 'repl', 'stream',
42
+ 'stream/consumers', 'stream/promises', 'stream/web', 'string_decoder',
43
+ 'sys', 'timers', 'timers/promises', 'tls', 'trace_events', 'tty', 'url',
44
+ 'util', 'util/types', 'v8', 'vm', 'wasi', 'worker_threads', 'zlib'
45
+ ];
46
+ /**
47
+ * Mirrors wrangler's internal getRegistryPath() — wrangler doesn't export it.
48
+ * Workers register themselves under this path so cross-worker `script_name`
49
+ * bindings can find each other.
50
+ */
51
+ function getWranglerRegistryPath() {
52
+ if (process.env.WRANGLER_REGISTRY_PATH)
53
+ return process.env.WRANGLER_REGISTRY_PATH;
54
+ const legacy = join(homedir(), '.wrangler');
55
+ if (existsSync(legacy))
56
+ return join(legacy, 'registry');
57
+ if (process.platform === 'darwin') {
58
+ return join(homedir(), 'Library', 'Preferences', '.wrangler', 'registry');
59
+ }
60
+ if (process.platform === 'win32') {
61
+ return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), '.wrangler', 'registry');
62
+ }
63
+ return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), '.wrangler', 'registry');
64
+ }
24
65
  /**
25
66
  * Returns two Vite plugins: one for build (patches _worker.js) and one for
26
67
  * dev (starts a wrangler dev server for Durable Objects).
@@ -56,14 +97,30 @@ function buildPlugin(options) {
56
97
  if (!(await exists(workerPath))) {
57
98
  return;
58
99
  }
59
- // Bundle the named exports
100
+ // Bundle the named exports.
101
+ //
102
+ // `conditions: ['workerd', 'worker', 'browser']` is the Cloudflare-
103
+ // recommended set for bundlers targeting Workers — it picks the
104
+ // workerd-specific or browser-shimmed variants of packages over
105
+ // their Node variants (e.g. nanoid's webcrypto-via-`globalThis.crypto`
106
+ // browser build instead of the `node:crypto`-importing main build).
107
+ //
108
+ // Node built-ins are externalised so transitive deps that do
109
+ // `require('path')` or `import 'node:async_hooks'` survive bundling;
110
+ // the Workers runtime resolves them when `nodejs_compat` is enabled
111
+ // in wrangler.jsonc.
60
112
  await build({
61
113
  entryPoints: [options.entryPoint],
62
114
  bundle: true,
63
115
  format: 'esm',
64
116
  sourcemap: true,
65
117
  target: 'esnext',
66
- external: ['cloudflare:*'],
118
+ conditions: ['workerd', 'worker', 'browser'],
119
+ external: [
120
+ 'cloudflare:*',
121
+ ...NODE_BUILTINS,
122
+ ...NODE_BUILTINS.map((m) => `node:${m}`)
123
+ ],
67
124
  outfile: exportsPath
68
125
  });
69
126
  // Rename original worker and create merged entry point
@@ -77,6 +134,19 @@ function devPlugin(options) {
77
134
  let worker = null;
78
135
  let tempConfigPath = null;
79
136
  let proxyConfigPath = null;
137
+ let typesConfigPath = null;
138
+ let bridgeDispose = null;
139
+ let cached = null;
140
+ async function readConfig() {
141
+ if (cached)
142
+ return cached;
143
+ const { unstable_readConfig } = await import('wrangler');
144
+ const parsed = unstable_readConfig(options.wranglerConfig ? { config: options.wranglerConfig } : {});
145
+ const rawJson = await readFile(parsed.configPath, 'utf-8');
146
+ const workflowBindings = (parsed.workflows ?? []).map((w) => w.binding);
147
+ cached = { rawJson, workflowBindings };
148
+ return cached;
149
+ }
80
150
  return {
81
151
  name: 'add-worker-exports-dev',
82
152
  apply: 'serve',
@@ -88,34 +158,75 @@ function devPlugin(options) {
88
158
  };
89
159
  },
90
160
  async configureServer(server) {
91
- const { unstable_readConfig, unstable_startWorker } = await import('wrangler');
92
- // Read the project's wrangler config (auto-discovers wrangler.jsonc/toml)
93
- const wranglerConfig = unstable_readConfig(options.wranglerConfig ? { config: options.wranglerConfig } : {});
161
+ const { unstable_startWorker } = await import('wrangler');
162
+ const { rawJson, workflowBindings } = await readConfig();
163
+ const sidecarName = `${parseJsonc(rawJson).name ?? 'sveltekit'}-dev-worker`;
164
+ // Set up the workflow HTTP bridge (see src/bridge/). This is the
165
+ // fallback used when the installed wrangler/miniflare strips
166
+ // workflow bindings from getPlatformProxy. When the upstream
167
+ // patch lands (cloudflare/workers-sdk#7459), delete src/bridge,
168
+ // remove this call, and use options.entryPoint as the dev
169
+ // config's `main` directly (no service binding either).
170
+ const bridge = await setupBridge({
171
+ userEntryAbs: resolve(options.entryPoint),
172
+ workflowBindings,
173
+ sidecarName
174
+ });
175
+ bridgeDispose = bridge.dispose;
94
176
  // Copy the raw config and override main, name, and dev port
95
- const rawJson = await readFile(wranglerConfig.configPath, 'utf-8');
96
177
  const devConfig = parseJsonc(rawJson);
97
- devConfig.name = `${devConfig.name ?? 'sveltekit'}-dev-worker`;
98
- devConfig.main = options.entryPoint;
178
+ devConfig.name = sidecarName;
179
+ devConfig.main = bridge.wrapperPath;
99
180
  devConfig.dev = { ...devConfig.dev, port: devPort };
100
181
  delete devConfig.assets;
101
182
  tempConfigPath = resolve('.dev-worker-wrangler.jsonc');
102
183
  await writeFile(tempConfigPath, JSON.stringify(devConfig, null, '\t'));
103
- // Also write a wrangler config for adapter-cloudflare's getPlatformProxy
184
+ // Write a wrangler config for adapter-cloudflare's getPlatformProxy
104
185
  // (used by vite dev for platform.env). It can't run internal DOs or
105
- // Workflows -- those are served by the sidecar above. Strip them so it
106
- // doesn't emit warnings about classes it can't load.
186
+ // Workflows itself -- those are served by the sidecar above. Rewrite
187
+ // internal DO bindings as cross-worker bindings (script_name pointing
188
+ // at the sidecar) so platform.env.MY_DO calls in +server.ts reach the
189
+ // sidecar via the wrangler dev registry. Workflows get the same
190
+ // rewrite; on patched wrangler/miniflare miniflare routes them via
191
+ // the dev-registry-proxy. On unpatched, wrangler strips them and the
192
+ // bridge service binding (added below) drives the SvelteKit hook
193
+ // fallback.
107
194
  const proxyConfig = parseJsonc(rawJson);
108
195
  if (proxyConfig.durable_objects?.bindings) {
109
- proxyConfig.durable_objects.bindings = proxyConfig.durable_objects.bindings.filter((b) => b.script_name);
110
- if (proxyConfig.durable_objects.bindings.length === 0) {
111
- delete proxyConfig.durable_objects;
112
- }
196
+ proxyConfig.durable_objects.bindings = proxyConfig.durable_objects.bindings.map((b) => b.script_name ? b : { ...b, script_name: sidecarName });
197
+ }
198
+ if (Array.isArray(proxyConfig.workflows)) {
199
+ proxyConfig.workflows = proxyConfig.workflows.map((w) => w.script_name ? w : { ...w, script_name: sidecarName });
200
+ }
201
+ if (workflowBindings.length > 0) {
202
+ proxyConfig.services = [
203
+ ...(proxyConfig.services ?? []),
204
+ bridge.serviceBinding
205
+ ];
113
206
  }
114
- delete proxyConfig.workflows;
115
207
  delete proxyConfig.migrations;
116
208
  proxyConfigPath = resolve('.platform-proxy-wrangler.jsonc');
117
209
  await writeFile(proxyConfigPath, JSON.stringify(proxyConfig, null, '\t'));
118
- worker = await unstable_startWorker({ config: tempConfigPath });
210
+ // A third config used only by `wrangler types`. Identical to the dev
211
+ // config except `main` points at the user's source entry, so wrangler
212
+ // can resolve typed bindings (DurableObjectNamespace<EchoDO>, etc.).
213
+ // Pointing types at our generated wrapper produces untyped bindings
214
+ // because wrangler's type resolver doesn't follow `export *` re-exports
215
+ // out of node_modules.
216
+ const typesConfig = parseJsonc(rawJson);
217
+ typesConfig.name = sidecarName;
218
+ typesConfig.main = options.entryPoint;
219
+ typesConfig.dev = { ...typesConfig.dev, port: devPort };
220
+ delete typesConfig.assets;
221
+ typesConfigPath = resolve('.types-worker-wrangler.jsonc');
222
+ await writeFile(typesConfigPath, JSON.stringify(typesConfig, null, '\t'));
223
+ worker = await unstable_startWorker({
224
+ config: tempConfigPath,
225
+ dev: { registry: getWranglerRegistryPath() },
226
+ build: {
227
+ nodejsCompatMode: (parsedConfig) => getNodejsCompatMode(parsedConfig.compatibility_date, parsedConfig.compatibility_flags)
228
+ }
229
+ });
119
230
  server.httpServer?.on('close', async () => {
120
231
  if (worker) {
121
232
  await worker.dispose();
@@ -127,6 +238,13 @@ function devPlugin(options) {
127
238
  if (proxyConfigPath) {
128
239
  await unlink(proxyConfigPath).catch(() => { });
129
240
  }
241
+ if (typesConfigPath) {
242
+ await unlink(typesConfigPath).catch(() => { });
243
+ }
244
+ if (bridgeDispose) {
245
+ await bridgeDispose();
246
+ bridgeDispose = null;
247
+ }
130
248
  });
131
249
  }
132
250
  };
@@ -140,3 +258,24 @@ async function exists(path) {
140
258
  return false;
141
259
  }
142
260
  }
261
+ /**
262
+ * Mirrors miniflare's `getNodeCompat` mode resolution. wrangler's
263
+ * `unstable_startWorker` does not derive this from the config flags
264
+ * automatically — without it, wrangler falls back to the legacy nodejs
265
+ * compat plugin and warns about `node:*` imports even when
266
+ * `nodejs_compat` is enabled.
267
+ */
268
+ function getNodejsCompatMode(compatibilityDate, compatibilityFlags = []) {
269
+ const hasCompat = compatibilityFlags.includes('nodejs_compat');
270
+ const hasCompatV2 = compatibilityFlags.includes('nodejs_compat_v2');
271
+ const hasNoCompatV2 = compatibilityFlags.includes('no_nodejs_compat_v2');
272
+ const hasAls = compatibilityFlags.includes('nodejs_als');
273
+ const date = compatibilityDate ?? '2000-01-01';
274
+ if (hasCompatV2 || (hasCompat && date >= '2024-09-23' && !hasNoCompatV2))
275
+ return 'v2';
276
+ if (hasCompat)
277
+ return 'v1';
278
+ if (hasAls)
279
+ return 'als';
280
+ return null;
281
+ }
package/package.json CHANGED
@@ -1,51 +1,64 @@
1
1
  {
2
- "name": "@oselvar/sveltekit-add-worker-exports",
3
- "version": "0.2.0",
4
- "description": "Vite plugin that adds Durable Object and Workflow exports to SvelteKit Cloudflare workers",
5
- "type": "module",
6
- "publishConfig": {
7
- "access": "public"
8
- },
9
- "exports": {
10
- ".": {
11
- "types": "./dist/index.d.ts",
12
- "import": "./dist/index.js"
13
- }
14
- },
15
- "files": [
16
- "dist"
17
- ],
18
- "license": "MIT",
19
- "repository": {
20
- "type": "git",
21
- "url": "git+https://github.com/oselvar/sveltekit-add-worker-exports.git"
22
- },
23
- "keywords": [
24
- "sveltekit",
25
- "cloudflare",
26
- "workers",
27
- "durable-objects",
28
- "workflows",
29
- "vite-plugin"
30
- ],
31
- "peerDependencies": {
32
- "esbuild": ">=0.17",
33
- "vite": ">=5",
34
- "wrangler": ">=4"
35
- },
36
- "devDependencies": {
37
- "@types/node": "^22.0.0",
38
- "esbuild": "^0.25.0",
39
- "np": "^11.2.0",
40
- "typescript": "^5.0.0",
41
- "vite": "^6.0.0",
42
- "wrangler": "^4.84.0"
43
- },
44
- "dependencies": {
45
- "jsonc-parser": "^3.3.1"
46
- },
47
- "scripts": {
48
- "build": "tsc",
49
- "test": "tsc --noEmit"
50
- }
51
- }
2
+ "name": "@oselvar/sveltekit-add-worker-exports",
3
+ "version": "0.3.1",
4
+ "description": "Vite plugin that adds Durable Object and Workflow exports to SvelteKit Cloudflare workers",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./hooks": {
15
+ "types": "./dist/hooks.d.ts",
16
+ "import": "./dist/hooks.js"
17
+ },
18
+ "./dev-bridge": {
19
+ "types": "./dist/bridge/dev-handler.d.ts",
20
+ "import": "./dist/bridge/dev-handler.js"
21
+ }
22
+ },
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/oselvar/sveltekit-add-worker-exports.git"
30
+ },
31
+ "keywords": [
32
+ "sveltekit",
33
+ "cloudflare",
34
+ "workers",
35
+ "durable-objects",
36
+ "workflows",
37
+ "vite-plugin"
38
+ ],
39
+ "peerDependencies": {
40
+ "@sveltejs/kit": ">=2",
41
+ "esbuild": ">=0.17",
42
+ "miniflare": ">=4",
43
+ "vite": ">=5",
44
+ "wrangler": ">=4"
45
+ },
46
+ "devDependencies": {
47
+ "@sveltejs/kit": "^2.61.0",
48
+ "@types/node": "^22.19.19",
49
+ "esbuild": "^0.25.12",
50
+ "miniflare": "^4.20260521.0",
51
+ "np": "^11.2.1",
52
+ "typescript": "^5.9.3",
53
+ "vite": "^6.4.2",
54
+ "wrangler": "^4.94.0"
55
+ },
56
+ "scripts": {
57
+ "build": "tsc",
58
+ "test": "tsc --noEmit",
59
+ "prepublishOnly": "tsc"
60
+ },
61
+ "dependencies": {
62
+ "jsonc-parser": "^3.3.1"
63
+ }
64
+ }