Skip to content

Commit 65eb7f0

Browse files
morinokamiclaude
andauthored
fix(void): restore Container Durable Object bindings in the dev server (#696)
Since 1.5.0, an app that uses `void/sandbox` can't reach its Sandbox in the dev server. `getSandbox()` throws: ``` env: Missing Cloudflare binding 'SANDBOX'. ``` Other Container classes lose their binding too. 1.4.0 works. Since 8b26dba, the config for `@cloudflare/vite-plugin` 2.0 comes from `migrateBuiltWranglerConfig()`, which rewrites the binding of each Container class as `unsafe:durable_object_namespace`. That form is only for the Worker upload. Miniflare skips unsafe bindings that have no dev plugin, so the class and its Container still exist in dev, but `env` has no binding for them. ## Fix In serve, `void:cloudflare-config` turns these bindings back into regular `durable-object` bindings. Builds and deploys keep the unsafe form. Only the generated binding of a Container class changes. An unsafe binding in the user's config has the same type, but it may point at another Worker, even at a class with the same name as a local Container. Turning it into a local binding would connect it to the local class instead, or stop workerd from starting if this Worker has no such class. `docs/guide/sandboxes.md` already describes local development with Docker, so the docs do not change. ## Not fixed here These also regressed in 1.5.0: - `vite preview` with a Sandbox fails with the same error. - A Container with a Dockerfile `image` outside `void/sandbox` stops the dev server from starting. - A Container in an auxiliary Worker stops the dev server from starting. ## Tests `packages/void/test/unit/plugin-cloudflare-v2.test.ts` runs the real plugin and Miniflare with a Docker stub, so it needs no Docker. In the dev server, Container classes and a regular Durable Object are bound, and unsafe bindings from the user's config are not, including one that names another Worker's class with the same name as a local Container. In the build, Container classes keep the unsafe form. The test fails without the fix, without the Container class check and without the generated-form check. ## Verification - `vp test run`: 772 files pass, 1 skipped (15,810 tests pass, 29 skipped). - `vp run check`: 33/33 pass. - By hand under `vp dev` with Docker: - `getSandbox('x')` runs `echo hi`, and the local explorer lists `SANDBOX`. - The acceptance route of the `kitchen-sink-sandbox` fixture passes all 12 checks. - A renamed binding and class, a `defineDurableObject` class with a Container, and `CLOUDFLARE_ENV` pointing at an environment with only a `name` also work. - Not run: a real Cloudflare deploy. The change only runs in serve, and the test checks the binding forms in the build output. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f10f68a commit 65eb7f0

3 files changed

Lines changed: 138 additions & 1 deletion

File tree

‎packages/void/src/plugin-cloudflare-config.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,29 @@ export function toNativeCloudflareConfig(
106106
return { ...config, worker: config.worker };
107107
}
108108

109+
/**
110+
* Give Container Durable Objects a regular binding in the dev server. Their unsafe
111+
* binding from `migrateBuiltWranglerConfig()` exists only for the Worker upload, and
112+
* Miniflare skips unsafe bindings without a dev plugin. Match only that generated form:
113+
* any other unsafe binding comes from the user's config and may name another Worker,
114+
* even one with a class of the same name, so it stays as written.
115+
*/
116+
export function restoreLocalContainerBindings(worker: ParsedInputConfigWithWorker['worker']): void {
117+
for (const [name, binding] of Object.entries(worker.env ?? {})) {
118+
if (
119+
binding.type !== 'unsafe:durable_object_namespace' ||
120+
Object.keys(binding).some((key) => !['type', 'class_name'].includes(key))
121+
) {
122+
continue;
123+
}
124+
const className = binding.class_name as string;
125+
const declaration = worker.exports?.[className];
126+
if (declaration && 'container' in declaration) {
127+
worker.env![name] = { type: 'durable-object', worker: worker.name, exportName: className };
128+
}
129+
}
130+
}
131+
109132
export function nativeAuxiliaryWorkers(
110133
workers: Array<AuxiliaryWorkerConfig> | undefined,
111134
root: string,

‎packages/void/src/plugin-cloudflare.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { readGeneratedWranglerConfigFile } from './cli/generated-wrangler-config
1212
import {
1313
mergeCloudflareConfig,
1414
nativeAuxiliaryWorkers,
15+
restoreLocalContainerBindings,
1516
toNativeCloudflareConfig,
1617
type AuxiliaryWorkerConfig,
1718
type WorkerConfig,
@@ -79,6 +80,8 @@ export function cloudflare(options: {
7980
delete declaration.container;
8081
}
8182
}
83+
} else if (environment.command === 'serve') {
84+
restoreLocalContainerBindings(pluginNative.worker);
8285
}
8386
},
8487
};

‎packages/void/test/unit/plugin-cloudflare-v2.test.ts‎

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { createRequire } from 'node:module';
1212
import { tmpdir } from 'node:os';
1313
import { dirname, join } from 'node:path';
1414
import { getWorkerAssetsDir, readBuildOutput } from '@cloudflare/build-output-utils';
15-
import { createBuilder, normalizePath, preview, resolveConfig } from 'vite';
15+
import { createBuilder, createServer, normalizePath, preview, resolveConfig } from 'vite';
1616
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';
1717
import { prepareCfBuildOutput } from '../../src/deploy.ts';
1818
import {
@@ -173,6 +173,117 @@ describe('Cloudflare v2 compatibility', () => {
173173
// Two real Vite builds take about four seconds, near the default timeout.
174174
}, 15_000);
175175

176+
// The plugin does not support local Containers on Windows.
177+
it.skipIf(process.platform === 'win32')(
178+
'binds Container Durable Objects in the dev server and keeps their upload form in builds',
179+
async () => {
180+
const root = project();
181+
writeFileSync(
182+
join(root, 'worker.js'),
183+
`export class Box {}
184+
export class Sandbox {}
185+
export class Plain {}
186+
export default {
187+
fetch: (_, env) =>
188+
Response.json(
189+
Object.fromEntries(
190+
['BOX', 'SANDBOX', 'PLAIN', 'REMOTE', 'AUTHORED'].map((name) => [
191+
name,
192+
typeof env[name]?.getByName,
193+
]),
194+
),
195+
),
196+
};
197+
`,
198+
);
199+
writeFileSync(join(root, 'Dockerfile'), 'FROM scratch\n');
200+
// The dev server prepares images at startup but starts no Container until one is
201+
// used, so a stub lets this test run the real plugin and Miniflare without Docker.
202+
const docker = join(root, 'docker-stub');
203+
writeFileSync(docker, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
204+
vi.stubEnv('WRANGLER_DOCKER_BIN', docker);
205+
const plugins = () =>
206+
cloudflare({
207+
config: () => ({
208+
name: 'container-app',
209+
main: 'worker.js',
210+
compatibility_date: '2026-02-24',
211+
durable_objects: {
212+
bindings: [
213+
{ name: 'BOX', class_name: 'Box' },
214+
{ name: 'SANDBOX', class_name: 'Sandbox' },
215+
{ name: 'PLAIN', class_name: 'Plain' },
216+
],
217+
},
218+
migrations: [{ tag: 'v1', new_sqlite_classes: ['Box', 'Sandbox', 'Plain'] }],
219+
containers: [
220+
{ name: 'box', class_name: 'Box', image: 'docker.io/example/box:1' },
221+
{
222+
name: 'sandbox',
223+
class_name: 'Sandbox',
224+
scheduling_policy: 'durable_object',
225+
images: { default: { dockerfile: join(root, 'Dockerfile') } },
226+
},
227+
],
228+
unsafe: {
229+
bindings: [
230+
// Another Worker's class, named like a local Container class.
231+
{
232+
name: 'REMOTE',
233+
type: 'durable_object_namespace',
234+
class_name: 'Box',
235+
script_name: 'another-worker',
236+
},
237+
{ name: 'AUTHORED', type: 'durable_object_namespace', class_name: 'Elsewhere' },
238+
],
239+
},
240+
}),
241+
viteEnvironment: { name: 'ssr' },
242+
});
243+
244+
const server = await createServer({
245+
root,
246+
configFile: false,
247+
logLevel: 'silent',
248+
server: { port: 0 },
249+
plugins: plugins(),
250+
});
251+
try {
252+
await server.listen();
253+
const response = await fetch(server.resolvedUrls!.local[0]!);
254+
expect(await response.json()).toEqual({
255+
BOX: 'function',
256+
SANDBOX: 'function',
257+
PLAIN: 'function',
258+
REMOTE: 'undefined',
259+
AUTHORED: 'undefined',
260+
});
261+
} finally {
262+
await server.close();
263+
}
264+
265+
const builder = await createBuilder({
266+
root,
267+
configFile: false,
268+
logLevel: 'silent',
269+
plugins: plugins(),
270+
});
271+
await builder.buildApp();
272+
expect((await readBuildOutput(root)).workers.default.config.env).toMatchObject({
273+
BOX: { type: 'unsafe:durable_object_namespace', class_name: 'Box' },
274+
SANDBOX: { type: 'unsafe:durable_object_namespace', class_name: 'Sandbox' },
275+
PLAIN: { type: 'durable-object', worker: 'container-app', exportName: 'Plain' },
276+
REMOTE: {
277+
type: 'unsafe:durable_object_namespace',
278+
class_name: 'Box',
279+
script_name: 'another-worker',
280+
},
281+
AUTHORED: { type: 'unsafe:durable_object_namespace', class_name: 'Elsewhere' },
282+
});
283+
},
284+
15_000,
285+
);
286+
176287
it('preserves resource identities, required secrets and compatibility settings', () => {
177288
const root = project();
178289
const config = toNativeCloudflareConfig(

0 commit comments

Comments
 (0)