Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -395,6 +419,7 @@ export const LocalWorkerProvider = () =>
bindings: worker.workerBindings,
durableObjectNamespaces: toRuntimeDurableObjectNamespaces(
worker.durableObjectNamespaces,
worker.containerImages,
),
hyperdrives: worker.hyperdrives,
queueConsumers: yield* getQueueConsumers(worker.name),
Expand Down Expand Up @@ -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<string, string> => {
const containerImages: Record<string, string> = {};
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<string, string>,
containerImages: Record<string, string> = {},
): RuntimeDurableObjectNamespace[] => {
return Object.entries(namespaces).map(([className, namespaceId]) => ({
className,
uniqueKey: namespaceId,
sql: true,
...(containerImages[className]
? { container: { imageName: containerImages[className] } }
: {}),
}));
};

Expand Down
2 changes: 1 addition & 1 deletion packages/alchemy/src/Cloudflare/Workers/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ export type Worker<Bindings extends WorkerBindings = any> = Resource<
},
{
bindings?: WorkerBinding[];
containers?: { className: string }[];
containers?: { className: string; imageName?: string }[];
crons?: string[];
hyperdrives?: Record<string, Required<HyperdriveDevOrigin>>;
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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 },
]);
});
});