Skip to content
Open
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
24 changes: 24 additions & 0 deletions .changeset/config-export-container-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@cloudflare/config": minor
---

Add a `container` option to `exports.durableObject()`

Live Durable Object exports can now attach a container by name, matching the new `container` field in the Wrangler configuration format:

```typescript
import { defineWorker, exports } from "@cloudflare/config";

export default defineWorker({
name: "my-worker",
compatibilityDate: "2026-07-01",
exports: {
MyContainerDO: exports.durableObject({
storage: "sqlite",
container: "my-container",
}),
},
});
```

This is an experimental feature: containers themselves are not yet configurable from `cloudflare.config.ts`, so the field is only useful once they are.
30 changes: 30 additions & 0 deletions .changeset/containers-attached-via-exports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"wrangler": minor
"@cloudflare/vite-plugin": minor
---

Allow containers to be attached to a Durable Object from its `exports` entry

A container can now be linked to its Durable Object from the export side, using a new `container` field that names an entry in the `containers` array. As a result `containers[].class_name` is now optional — a container that is referenced this way only needs a `name`:

```jsonc
{
"name": "my-worker",
"main": "worker.js",
"compatibility_date": "2026-07-01",
"containers": [
{ "name": "my-container", "image": "./Dockerfile", "max_instances": 1 },
],
"exports": {
"MyContainerDO": {
"type": "durable-object",
"storage": "sqlite",
"container": "my-container",
},
},
}
```

This decouples container configuration from the Durable Object class, which is a prerequisite for configuring containers as standalone resources. The existing `containers[].class_name` direction keeps working, and either direction may be used, but a Durable Object and its container must reference each other consistently when both are set.

`container` is only valid on live `durable-object` exports (`created` and `expecting-transfer`) and requires `storage: "sqlite"`. Wrangler now also reports an error when a `container` reference names a container that does not exist, when two Durable Object exports claim the same container, when a container ends up linked to no Durable Object at all, and when two containers share a name.
15 changes: 9 additions & 6 deletions fixtures/container-app/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@
"containers": [
{
"image": "./Dockerfile",
"class_name": "FixtureTestContainer",
"name": "container",
"max_instances": 2,
},
],
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["FixtureTestContainer"],
// The container has no `class_name`; the Durable Object attaches it by name
// instead. See `wrangler.registry.jsonc` for the `class_name` + `migrations`
// equivalent.
"exports": {
"FixtureTestContainer": {
"type": "durable-object",
"storage": "sqlite",
"container": "container",
},
],
},
}
48 changes: 48 additions & 0 deletions packages/config/src/__tests__/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,54 @@ describe("convertToWranglerConfig", () => {
});
});

it("converts an attached container on a live durable-object export", ({
expect,
}) => {
const result = convertToWranglerConfig({
...baseConfig,
exports: {
MyDO: {
type: "durable-object",
storage: "sqlite",
container: "my-container",
},
},
});
expect((result as { exports?: unknown }).exports).toEqual({
MyDO: {
type: "durable-object",
storage: "sqlite",
container: "my-container",
},
});
});

it("converts an attached container on an expecting-transfer export", ({
expect,
}) => {
const result = convertToWranglerConfig({
...baseConfig,
exports: {
Incoming: {
type: "durable-object",
state: "expecting-transfer",
storage: "sqlite",
transferFrom: "source-worker",
container: "my-container",
},
},
});
expect((result as { exports?: unknown }).exports).toEqual({
Incoming: {
type: "durable-object",
state: "expecting-transfer",
storage: "sqlite",
transfer_from: "source-worker",
container: "my-container",
},
});
});

it('treats an explicit `state: "created"` like the default and omits it on the wire', ({
expect,
}) => {
Expand Down
52 changes: 52 additions & 0 deletions packages/config/src/__tests__/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,3 +724,55 @@ describe("ConfigExportsSchema", () => {
expect(result.success).toBe(true);
});
});

describe("ExportSchema", () => {
function parseExports(exports: unknown) {
return InputWorkerSchema.safeParse({ ...baseConfig, exports });
}

it("accepts `container` on a live durable-object export", ({ expect }) => {
const result = parseExports({
MyDO: {
type: "durable-object",
storage: "sqlite",
container: "my-container",
},
});

expect(result.success).toBe(true);
});

it("accepts `container` on an expecting-transfer export", ({ expect }) => {
const result = parseExports({
Incoming: {
type: "durable-object",
state: "expecting-transfer",
storage: "sqlite",
transferFrom: "source-worker",
container: "my-container",
},
});

expect(result.success).toBe(true);
});

it("rejects `container` on a tombstone", ({ expect }) => {
const result = parseExports({
OldDO: {
type: "durable-object",
state: "deleted",
container: "my-container",
},
});

expect(result.success).toBe(false);
});

it("rejects a non-string `container`", ({ expect }) => {
const result = parseExports({
MyDO: { type: "durable-object", storage: "sqlite", container: 1 },
});

expect(result.success).toBe(false);
});
});
2 changes: 2 additions & 0 deletions packages/config/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,7 @@ function convertExports(
converted[exportName] = {
type: "durable-object",
storage: value.storage,
...(value.container !== undefined && { container: value.container }),
};
break;
}
Expand Down Expand Up @@ -743,6 +744,7 @@ function convertExports(
state: "expecting-transfer",
storage: value.storage,
transfer_from: value.transferFrom,
...(value.container !== undefined && { container: value.container }),
};
break;
}
Expand Down
13 changes: 13 additions & 0 deletions packages/config/src/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export interface DurableObjectCreatedExportOptions {
* - `"legacy-kv"`: selects the legacy key-value storage engine.
*/
storage: "sqlite" | "legacy-kv";
/**
* Attach a container to this Durable Object, by container name.
*
* Requires `storage: "sqlite"`.
*/
container?: string;
}

/**
Expand Down Expand Up @@ -81,6 +87,12 @@ export interface DurableObjectExpectingTransferExportOptions {
* The source Worker for the two-phase cross-Worker transfer.
*/
transferFrom: string;
/**
* Attach a container to this Durable Object, by container name.
*
* Requires `storage: "sqlite"`.
*/
container?: string;
}

export interface DurableObjectCreatedExport extends DurableObjectCreatedExportOptions {
Expand Down Expand Up @@ -223,6 +235,7 @@ function worker(
* export default defineWorker({
* exports: {
* MyDurableObject: exports.durableObject({ storage: "sqlite" }),
* MyContainerDO: exports.durableObject({ storage: "sqlite", container: "my-container" }),
* OldClass: exports.durableObject({ state: "deleted" }),
* OldName: exports.durableObject({ state: "renamed", renamedTo: "NewName" }),
* Outgoing: exports.durableObject({ state: "transferred", transferredTo: "target-worker" }),
Expand Down
2 changes: 2 additions & 0 deletions packages/config/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ const ExportSchema = z.union([
type: z.literal("durable-object"),
state: z.literal("created").optional(),
storage: z.enum(["sqlite", "legacy-kv"]),
container: z.string().optional(),
}),
z.strictObject({
type: z.literal("durable-object"),
Expand All @@ -316,6 +317,7 @@ const ExportSchema = z.union([
state: z.literal("expecting-transfer"),
storage: z.enum(["sqlite", "legacy-kv"]),
transferFrom: z.string(),
container: z.string().optional(),
}),
z.strictObject({
type: z.literal("worker"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -869,10 +869,16 @@ export function createWorkerUploadForm(
? { main_module: main.name }
: { body_part: main.name }),
bindings: metadataBindings,
// Both directions of the container/Durable Object link are sent as
// configured: the API resolves a container's Durable Object from either this
// `class_name` or an `exports` entry naming the container by `name`.
containers:
worker.containers === undefined
? undefined
: worker.containers.map((c) => ({ class_name: c.class_name })),
: worker.containers.map((c) => ({
...(c.name !== undefined && { name: c.name }),
...(c.class_name !== undefined && { class_name: c.class_name }),
})),

...(compatibility_date && { compatibility_date }),
...(compatibility_flags && {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, test } from "vitest";
import { getContainerOptions } from "../containers";
import type { ResolvedWorkerConfig } from "../plugin-config";

type Containers = ResolvedWorkerConfig["containers"];
type Exports = ResolvedWorkerConfig["exports"];

describe("getContainerOptions", () => {
test("returns undefined when no containers are configured", ({ expect }) => {
expect(
getContainerOptions({
containersConfig: undefined,
exports: {},
containerBuildId: "build-id",
})
).toBeUndefined();
});

test("uses the container's own class_name when set", ({ expect }) => {
const containersConfig: Containers = [
{
name: "my-container",
class_name: "MyDO",
image: "registry.cloudflare.com/hello:world",
},
];

expect(
getContainerOptions({
containersConfig,
exports: {},
containerBuildId: "build-id",
})
).toEqual([
{
image_uri: "registry.cloudflare.com/hello:world",
class_name: "MyDO",
image_tag: "cloudflare-dev/mydo:build-id",
},
]);
});

test("resolves class_name from a durable object export that references the container", ({
expect,
}) => {
const containersConfig: Containers = [
{ name: "my-container", image: "registry.cloudflare.com/hello:world" },
];
const exports: Exports = {
MyContainerDO: {
type: "durable-object",
storage: "sqlite",
container: "my-container",
},
};

expect(
getContainerOptions({
containersConfig,
exports,
containerBuildId: "build-id",
})
).toEqual([
{
image_uri: "registry.cloudflare.com/hello:world",
class_name: "MyContainerDO",
image_tag: "cloudflare-dev/mycontainerdo:build-id",
},
]);
});

test("skips containers that are not linked to a durable object", ({
expect,
}) => {
const containersConfig: Containers = [
{ name: "my-container", image: "registry.cloudflare.com/hello:world" },
];

expect(
getContainerOptions({
containersConfig,
exports: {},
containerBuildId: "build-id",
})
).toEqual([]);
});
});
Loading
Loading