From 6e2164b8e7f9fe1eb8281f138160e4bc5f22e8f1 Mon Sep 17 00:00:00 2001 From: Matthew Aylward Date: Thu, 11 Jun 2026 08:07:15 +0200 Subject: [PATCH 1/2] feat(cloudflare): run DO-attached containers in alchemy dev Thread a per-class container image from binding data (containers: [{ className, imageName }]) through LocalWorkerProvider into workerd's native container support: each DO namespace with an attached image gets container: { imageName }, and the worker gets a localDocker containerEngine pointing at the host's docker socket (egress sidecar image overridable via ALCHEMY_CONTAINER_EGRESS_IMAGE). Inert until @distilled.cloud/cloudflare-runtime threads the container config through to workerd (alchemy-run/cloudflare-tools#46): the fields typecheck today via spreads and are dropped by the 0.10.12 config assembly, so behavior is unchanged until the catalog bump. Co-Authored-By: Claude Fable 5 --- .../Cloudflare/Workers/LocalWorkerProvider.ts | 39 +++++++++++++++++++ .../alchemy/src/Cloudflare/Workers/Worker.ts | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts b/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts index c0fdd07da6..7c2c08d7c6 100644 --- a/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts @@ -214,7 +214,27 @@ export const LocalWorkerProvider = () => hyperdrives: worker.hyperdrives, durableObjectNamespaces: toRuntimeDurableObjectNamespaces( worker.durableObjectNamespaces, + worker.containerImages, ), + // Local containers: hand workerd the docker socket whenever a + // namespace has an attached image, mirroring `wrangler dev`. + // The egress-interceptor default is the image miniflare pins; + // ALCHEMY_CONTAINER_EGRESS_IMAGE overrides it (e.g. to work + // around cloudflare/workerd#6792 on hosts where TPROXY + // intercepts bridge-local traffic and hangs the sidecar + // readiness probe). + ...(Object.keys(worker.containerImages).length > 0 + ? { + containerEngine: { + localDocker: { + socketPath: "unix:///var/run/docker.sock", + containerEgressInterceptorImage: + process.env.ALCHEMY_CONTAINER_EGRESS_IMAGE ?? + "cloudflare/proxy-everything:3cb1195@sha256:0ef6716c52430096900b150d84a3302057d6cd2319dae7987128c85d0733e3c8", + }, + }, + } + : {}), queueConsumers: yield* getQueueConsumers(worker.name), modules: yield* toRuntimeModules(bundle), assets: toRuntimeAssets(worker.assets), @@ -296,12 +316,26 @@ export const LocalWorkerProvider = () => } } } + // Container-backed DO classes: map className to a local image from + // binding data (`containers: [{ className, imageName }]`). Only + // entries that carry an `imageName` participate locally; a + // remote-only attachment (no image) is ignored by the local + // runtime and `ctx.container` stays absent. + const containerImages: Record = {}; + for (const { data } of bindings) { + for (const container of data.containers ?? []) { + if (container.imageName) { + containerImages[container.className] = container.imageName; + } + } + } return { id, name, compatibility, workerBindings, durableObjectNamespaces, + containerImages, hyperdrives, env: props.env, bundleOptions: { @@ -395,6 +429,7 @@ export const LocalWorkerProvider = () => bindings: worker.workerBindings, durableObjectNamespaces: toRuntimeDurableObjectNamespaces( worker.durableObjectNamespaces, + worker.containerImages, ), hyperdrives: worker.hyperdrives, queueConsumers: yield* getQueueConsumers(worker.name), @@ -674,11 +709,15 @@ const toRuntimeAssets = ( const toRuntimeDurableObjectNamespaces = ( namespaces: Record, + containerImages: Record = {}, ): RuntimeDurableObjectNamespace[] => { return Object.entries(namespaces).map(([className, namespaceId]) => ({ className, uniqueKey: namespaceId, sql: true, + ...(containerImages[className] + ? { container: { imageName: containerImages[className] } } + : {}), })); }; diff --git a/packages/alchemy/src/Cloudflare/Workers/Worker.ts b/packages/alchemy/src/Cloudflare/Workers/Worker.ts index 356e26c542..7786eec31e 100644 --- a/packages/alchemy/src/Cloudflare/Workers/Worker.ts +++ b/packages/alchemy/src/Cloudflare/Workers/Worker.ts @@ -325,7 +325,7 @@ export type Worker = Resource< }, { bindings?: WorkerBinding[]; - containers?: { className: string }[]; + containers?: { className: string; imageName?: string }[]; crons?: string[]; hyperdrives?: Record>; }, From 472532baff09618bce61d4e46d3e3c2f6305b9da Mon Sep 17 00:00:00 2001 From: Matthew Aylward Date: Thu, 11 Jun 2026 10:30:09 +0200 Subject: [PATCH 2/2] test(cloudflare): unit-test dev local-container image mapping Extract the inline className->imageName loop into an exported, documented `collectContainerImages` helper and export `toRuntimeDurableObjectNamespaces`, then cover both with unit tests (remote-only attachments skipped, className collision, container attached only to mapped namespaces). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Cloudflare/Workers/LocalWorkerProvider.ts | 43 ++++++++---- .../Workers/LocalWorkerProvider.test.ts | 68 +++++++++++++++++++ 2 files changed, 97 insertions(+), 14 deletions(-) create mode 100644 packages/alchemy/test/Cloudflare/Workers/LocalWorkerProvider.test.ts diff --git a/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts b/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts index 7c2c08d7c6..f34c1b2740 100644 --- a/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts @@ -316,19 +316,9 @@ export const LocalWorkerProvider = () => } } } - // Container-backed DO classes: map className to a local image from - // binding data (`containers: [{ className, imageName }]`). Only - // entries that carry an `imageName` participate locally; a - // remote-only attachment (no image) is ignored by the local - // runtime and `ctx.container` stays absent. - const containerImages: Record = {}; - for (const { data } of bindings) { - for (const container of data.containers ?? []) { - if (container.imageName) { - containerImages[container.className] = container.imageName; - } - } - } + // Container-backed DO classes: className -> local image name from + // binding data (see `collectContainerImages`). + const containerImages = collectContainerImages(bindings); return { id, name, @@ -707,7 +697,32 @@ const toRuntimeAssets = ( }; }; -const toRuntimeDurableObjectNamespaces = ( +/** + * Map each container-backed DO `className` to its local image name from worker + * binding data. Only `containers` entries that carry an `imageName` participate + * locally; a remote-only attachment (no image) is skipped, so `ctx.container` + * stays absent for it under `alchemy dev`. Later entries win on a className + * collision. + */ +export const collectContainerImages = ( + bindings: ReadonlyArray<{ + data: { + containers?: ReadonlyArray<{ className: string; imageName?: string }>; + }; + }>, +): Record => { + const containerImages: Record = {}; + for (const { data } of bindings) { + for (const container of data.containers ?? []) { + if (container.imageName) { + containerImages[container.className] = container.imageName; + } + } + } + return containerImages; +}; + +export const toRuntimeDurableObjectNamespaces = ( namespaces: Record, containerImages: Record = {}, ): RuntimeDurableObjectNamespace[] => { diff --git a/packages/alchemy/test/Cloudflare/Workers/LocalWorkerProvider.test.ts b/packages/alchemy/test/Cloudflare/Workers/LocalWorkerProvider.test.ts new file mode 100644 index 0000000000..7d21d11937 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Workers/LocalWorkerProvider.test.ts @@ -0,0 +1,68 @@ +import { + collectContainerImages, + toRuntimeDurableObjectNamespaces, +} from "@/Cloudflare/Workers/LocalWorkerProvider"; +import { describe, expect, test } from "vitest"; + +const binding = (containers?: { className: string; imageName?: string }[]) => ({ + data: { containers }, +}); + +describe("collectContainerImages", () => { + test("maps className to imageName for entries that carry an image", () => { + expect( + collectContainerImages([ + binding([{ className: "SheetDo", imageName: "sql:dev" }]), + ]), + ).toEqual({ SheetDo: "sql:dev" }); + }); + + test("skips remote-only attachments (no imageName)", () => { + expect( + collectContainerImages([ + binding([ + { className: "WithImage", imageName: "img:dev" }, + { className: "RemoteOnly" }, + ]), + ]), + ).toEqual({ WithImage: "img:dev" }); + }); + + test("tolerates bindings with no containers and empty input", () => { + expect(collectContainerImages([])).toEqual({}); + expect(collectContainerImages([binding(), binding([])])).toEqual({}); + }); + + test("last entry wins on a className collision", () => { + expect( + collectContainerImages([ + binding([{ className: "Dup", imageName: "first:dev" }]), + binding([{ className: "Dup", imageName: "second:dev" }]), + ]), + ).toEqual({ Dup: "second:dev" }); + }); +}); + +describe("toRuntimeDurableObjectNamespaces", () => { + test("emits sql-backed namespaces with no container by default", () => { + expect(toRuntimeDurableObjectNamespaces({ Counter: "uniq-1" })).toEqual([ + { className: "Counter", uniqueKey: "uniq-1", sql: true }, + ]); + }); + + test("attaches container only to namespaces with a mapped image", () => { + const result = toRuntimeDurableObjectNamespaces( + { SheetDo: "uniq-1", Plain: "uniq-2" }, + { SheetDo: "sql:dev" }, + ); + expect(result).toEqual([ + { + className: "SheetDo", + uniqueKey: "uniq-1", + sql: true, + container: { imageName: "sql:dev" }, + }, + { className: "Plain", uniqueKey: "uniq-2", sql: true }, + ]); + }); +});