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
35 changes: 35 additions & 0 deletions .changeset/config-export-container-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
"@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",
}),
},
});
```

Containers are only supported on the SQLite storage engine, so `container` is only offered alongside `storage: "sqlite"`. Passing it with `storage: "legacy-kv"` is a type error rather than something only caught on deploy:

```typescript
exports.durableObject({
storage: "legacy-kv",
// Object literal may only specify known properties,
// and 'container' does not exist in type '{ storage: "legacy-kv" }'
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.
Comment thread
petebacondarwin marked this conversation as resolved.
39 changes: 39 additions & 0 deletions .changeset/containers-attached-via-exports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
"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",
},
},
}
```

The existing `containers[].class_name` direction keeps working and either direction may be used, but the two must agree: a container that names its Durable Object cannot also be claimed by a different one.

`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
- two Durable Object exports claim the same container
- a container and a Durable Object export disagree about which one they are linked to
- a container ends up linked to no Durable Object at all
- two containers share a `name`
- two containers are attached to the same Durable Object

That last case was previously accepted but could never work: workerd attaches a single container per Durable Object namespace, and in local development every container for a class builds into the same image tag, so one silently overwrote the other. If you have two containers on one `class_name`, give each its own Durable Object class.
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
141 changes: 141 additions & 0 deletions packages/config/src/__tests__/schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { describe, it } from "vitest";
import { exports as exportConfig } from "../exports";
import {
ConfigExportsSchema,
InputWorkerSchema,
OutputWorkerSchema,
SettingsSchema,
} from "../schema";
import type { ParsedInputWorkerConfig } from "../schema";

const baseConfig = {
type: "worker",
Expand Down Expand Up @@ -724,3 +726,142 @@ 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);
});

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

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

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

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

it("still accepts a legacy-kv export without a container", ({ expect }) => {
const result = parseExports({
MyDO: { type: "durable-object", storage: "legacy-kv" },
});

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

// Containers require the SQLite storage engine. The check below is the type
// half of that rule: `tsc` checks this body (it is never called), so a missing
// error fails `check:type` via the unused `@ts-expect-error` directives.
it("forbids `container` on a legacy-kv export at the type level", ({
expect,
}) => {
function typeAssertions() {
exportConfig.durableObject({
storage: "legacy-kv",
// @ts-expect-error `container` requires `storage: "sqlite"`
container: "my-container",
});

exportConfig.durableObject({
state: "expecting-transfer",
storage: "legacy-kv",
transferFrom: "source-worker",
// @ts-expect-error `container` requires `storage: "sqlite"`
container: "my-container",
});

const _exports: NonNullable<ParsedInputWorkerConfig["exports"]> = {
MyDO: {
type: "durable-object",
storage: "legacy-kv",
// @ts-expect-error `container` requires `storage: "sqlite"`
container: "my-container",
},
};

// The permitted combinations must still compile.
exportConfig.durableObject({ storage: "sqlite", container: "my-do" });
exportConfig.durableObject({ storage: "legacy-kv" });
exportConfig.durableObject({
state: "expecting-transfer",
storage: "sqlite",
transferFrom: "source-worker",
container: "my-do",
});
exportConfig.durableObject({
state: "expecting-transfer",
storage: "legacy-kv",
transferFrom: "source-worker",
});

return _exports;
}

expect(typeAssertions).toBeTypeOf("function");
});
});
4 changes: 4 additions & 0 deletions packages/config/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,8 @@ function convertExports(
converted[exportName] = {
type: "durable-object",
storage: value.storage,
...(value.storage === "sqlite" &&
value.container !== undefined && { container: value.container }),
};
break;
}
Expand Down Expand Up @@ -743,6 +745,8 @@ function convertExports(
state: "expecting-transfer",
storage: value.storage,
transfer_from: value.transferFrom,
...(value.storage === "sqlite" &&
value.container !== undefined && { container: value.container }),
};
break;
}
Expand Down
Loading
Loading