diff --git a/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts b/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts index c0fdd07da..f34c1b274 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,16 @@ export const LocalWorkerProvider = () => } } } + // Container-backed DO classes: className -> local image name from + // binding data (see `collectContainerImages`). + const containerImages = collectContainerImages(bindings); return { id, name, compatibility, workerBindings, durableObjectNamespaces, + containerImages, hyperdrives, env: props.env, bundleOptions: { @@ -395,6 +419,7 @@ export const LocalWorkerProvider = () => bindings: worker.workerBindings, durableObjectNamespaces: toRuntimeDurableObjectNamespaces( worker.durableObjectNamespaces, + worker.containerImages, ), hyperdrives: worker.hyperdrives, queueConsumers: yield* getQueueConsumers(worker.name), @@ -672,13 +697,42 @@ 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[] => { 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 356e26c54..7786eec31 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>; }, 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 000000000..7d21d1193 --- /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 }, + ]); + }); +});