Skip to content

Commit eb271e3

Browse files
committed
Support unbound durable objects
1 parent 336a75d commit eb271e3

File tree

10 files changed

+242
-32
lines changed

10 files changed

+242
-32
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "@fixture/unbound-durable-object",
3+
"private": true,
4+
"scripts": {
5+
"deploy": "wrangler deploy",
6+
"start": "wrangler dev",
7+
"test:ci": "vitest"
8+
},
9+
"devDependencies": {
10+
"@cloudflare/workers-tsconfig": "workspace:*",
11+
"@cloudflare/workers-types": "catalog:default",
12+
"vitest": "catalog:default",
13+
"wrangler": "workspace:*"
14+
},
15+
"volta": {
16+
"extends": "../../package.json"
17+
}
18+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { DurableObject } from "cloudflare:workers";
2+
3+
export class Counter extends DurableObject {
4+
async getCounterValue() {
5+
let value = (await this.ctx.storage.get("value")) || 0;
6+
return value;
7+
}
8+
9+
async increment(amount = 1) {
10+
let value = (await this.ctx.storage.get<number>("value")) || 0;
11+
value += amount;
12+
await this.ctx.storage.put("value", value);
13+
return value;
14+
}
15+
16+
async decrement(amount = 1) {
17+
let value = (await this.ctx.storage.get<number>("value")) || 0;
18+
value -= amount;
19+
await this.ctx.storage.put("value", value);
20+
return value;
21+
}
22+
}
23+
24+
export default {
25+
async fetch(
26+
request: Request,
27+
_env: never,
28+
ctx: ExecutionContext
29+
): Promise<Response> {
30+
let url = new URL(request.url);
31+
let name = url.searchParams.get("name");
32+
if (!name) {
33+
return new Response(
34+
"Select a Durable Object to contact by using" +
35+
" the `name` URL query string parameter, for example, ?name=A"
36+
);
37+
}
38+
39+
let stub = ctx.exports.Counter.getByName(name);
40+
41+
// Send a request to the Durable Object using RPC methods, then await its response.
42+
let count = null;
43+
switch (url.pathname) {
44+
case "/increment":
45+
count = await stub.increment();
46+
break;
47+
case "/decrement":
48+
count = await stub.decrement();
49+
break;
50+
case "/":
51+
// Serves the current value.
52+
count = await stub.getCounterValue();
53+
break;
54+
default:
55+
return new Response("Not found", { status: 404 });
56+
}
57+
58+
return new Response(`count: ${count}`);
59+
},
60+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { join, resolve } from "path";
2+
import { afterAll, beforeAll, describe, it } from "vitest";
3+
import { unstable_startWorker } from "wrangler";
4+
5+
const basePath = resolve(__dirname, "..");
6+
7+
describe("Unbound DO is available through `ctx.exports`", () => {
8+
let worker: Awaited<ReturnType<typeof unstable_startWorker>>;
9+
10+
beforeAll(async () => {
11+
worker = await unstable_startWorker({
12+
config: join(basePath, "wrangler.jsonc"),
13+
});
14+
});
15+
16+
afterAll(async () => {
17+
await worker.dispose();
18+
});
19+
20+
it("storage operations don't throw", async ({ expect }) => {
21+
const doName = crypto.randomUUID();
22+
let response = await worker.fetch(`http://example.com?name=${doName}`);
23+
let content = await response.text();
24+
expect(content).toMatchInlineSnapshot(`"count: 0"`);
25+
26+
response = await worker.fetch(
27+
`http://example.com/increment?name=${doName}`
28+
);
29+
content = await response.text();
30+
expect(content).toMatchInlineSnapshot(`"count: 1"`);
31+
});
32+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"extends": "@cloudflare/workers-tsconfig/tsconfig.json",
3+
"compilerOptions": {
4+
"types": ["node"]
5+
},
6+
"include": ["**/*.ts"]
7+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"compilerOptions": {
3+
"target": "es2021",
4+
"lib": ["es2021"],
5+
"module": "es2022",
6+
"types": ["@cloudflare/workers-types/experimental"],
7+
"noEmit": true,
8+
"isolatedModules": true,
9+
"forceConsistentCasingInFileNames": true,
10+
"strict": true,
11+
"skipLibCheck": true
12+
}
13+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "unbound-durable-object",
3+
"main": "src/index.ts",
4+
"compatibility_date": "2025-01-01",
5+
"compatibility_flags": ["experimental"],
6+
// This DO intentionally doesn't have a binding
7+
// "durable_objects": {
8+
// "bindings": [
9+
// {
10+
// "name": "COUNTER",
11+
// "class_name": "Counter",
12+
// },
13+
// ],
14+
// },
15+
"migrations": [
16+
{
17+
"tag": "v1",
18+
"new_sqlite_classes": ["Counter"],
19+
},
20+
],
21+
}

packages/miniflare/src/index.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ function getDurableObjectClassNames(
338338
.flatMap((workerOpts) => {
339339
const workerServiceName = getUserServiceName(workerOpts.core.name);
340340

341-
return Object.values(workerOpts.do.durableObjects ?? {}).map(
341+
const boundDOs = Object.values(workerOpts.do.durableObjects ?? {}).map(
342342
(workerDODesignator) => {
343343
const doInfo = normaliseDurableObject(workerDODesignator);
344344
if (doInfo.serviceName === undefined) {
@@ -351,6 +351,22 @@ function getDurableObjectClassNames(
351351
};
352352
}
353353
);
354+
355+
const unboundDOs = (
356+
workerOpts.do.additionalUnboundDurableObjects ?? []
357+
).map((workerDODesignator) => {
358+
const doInfo = normaliseDurableObject(workerDODesignator);
359+
if (doInfo.serviceName === undefined) {
360+
// Fallback to current worker service if name not defined
361+
doInfo.serviceName = workerServiceName;
362+
}
363+
return {
364+
doInfo,
365+
workerRawName: workerOpts.core.name,
366+
};
367+
});
368+
369+
return [...boundDOs, ...unboundDOs];
354370
})
355371
// We sort the list of durable objects because we want the durable objects without a scriptName or a scriptName
356372
// that matches the raw worker's name (meaning that they are defined within their worker) to be processed first

packages/miniflare/src/plugins/do/index.ts

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,32 +18,30 @@ export const DOContainerOptionsSchema = z.object({
1818
});
1919
export type DOContainerOptions = z.infer<typeof DOContainerOptionsSchema>;
2020

21-
export const DurableObjectsOptionsSchema = z.object({
22-
durableObjects: z
23-
.record(
24-
z.union([
25-
z.string(),
26-
z.object({
27-
className: z.string(),
28-
scriptName: z.string().optional(),
29-
useSQLite: z.boolean().optional(),
30-
// Allow `uniqueKey` to be customised. We use in Wrangler when setting
31-
// up stub Durable Objects that proxy requests to Durable Objects in
32-
// another `workerd` process, to ensure the IDs created by the stub
33-
// object can be used by the real object too.
34-
unsafeUniqueKey: z
35-
.union([z.string(), z.literal(kUnsafeEphemeralUniqueKey)])
36-
.optional(),
37-
// Prevents the Durable Object being evicted.
38-
unsafePreventEviction: z.boolean().optional(),
39-
remoteProxyConnectionString: z
40-
.custom<RemoteProxyConnectionString>()
41-
.optional(),
42-
container: z.custom<DOContainerOptions>().optional(),
43-
}),
44-
])
45-
)
21+
const DurableObject = z.object({
22+
className: z.string(),
23+
scriptName: z.string().optional(),
24+
useSQLite: z.boolean().optional(),
25+
// Allow `uniqueKey` to be customised. We use in Wrangler when setting
26+
// up stub Durable Objects that proxy requests to Durable Objects in
27+
// another `workerd` process, to ensure the IDs created by the stub
28+
// object can be used by the real object too.
29+
unsafeUniqueKey: z
30+
.union([z.string(), z.literal(kUnsafeEphemeralUniqueKey)])
31+
.optional(),
32+
// Prevents the Durable Object being evicted.
33+
unsafePreventEviction: z.boolean().optional(),
34+
remoteProxyConnectionString: z
35+
.custom<RemoteProxyConnectionString>()
4636
.optional(),
37+
container: z.custom<DOContainerOptions>().optional(),
38+
});
39+
40+
export const DurableObjectsOptionsSchema = z.object({
41+
durableObjects: z.record(z.union([z.string(), DurableObject])).optional(),
42+
// Not all DOs are configured as bindings! Include these in a different key
43+
// These might just be configured via migrations, but should still be allocated storage for e..g ctx.exports support
44+
additionalUnboundDurableObjects: z.array(DurableObject).optional(),
4745
});
4846
export const DurableObjectsSharedOptionsSchema = z.object({
4947
durableObjectsPersist: PersistenceSchema,

packages/wrangler/src/dev/miniflare/index.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ type WorkerOptionsBindings = Pick<
425425
| "helloWorld"
426426
| "workerLoaders"
427427
| "unsafeBindings"
428+
| "additionalUnboundDurableObjects"
428429
>;
429430

430431
type MiniflareBindingsConfig = Pick<
@@ -647,6 +648,37 @@ export function buildMiniflareBindingOptions(
647648
}
648649
}
649650

651+
/**
652+
* The `durableObjects` variable contains all DO bindings. However, this
653+
* may not represent all DOs defined in the app, because DOs can be defined
654+
* without being bound (accessible via ctx.exports).
655+
* To get a list of all configured DOS, we need all DOs provisioned via migrations,
656+
* wich we already have in the form of `classNameToUseSQLite`
657+
* As such, this code extends the list of bound DOs with configured DOs that
658+
* aren't already referenced. The outcome is that `additionalUnboundDurableObjects` will
659+
* contain DOs configured via migrations that are not bound.
660+
*/
661+
const additionalUnboundDurableObjects: WorkerOptionsBindings["additionalUnboundDurableObjects"] =
662+
[];
663+
664+
for (const [className, useSQLite] of classNameToUseSQLite) {
665+
if (!durableObjects.find((d) => d.class_name === className)) {
666+
additionalUnboundDurableObjects.push({
667+
className,
668+
scriptName: undefined,
669+
useSQLite,
670+
container:
671+
config.containerDOClassNames?.size && config.enableContainers
672+
? getImageNameFromDOClassName({
673+
doClassName: className,
674+
containerDOClassNames: config.containerDOClassNames,
675+
containerBuildId: config.containerBuildId,
676+
})
677+
: undefined,
678+
});
679+
}
680+
}
681+
650682
const bindingOptions: WorkerOptionsBindings = {
651683
bindings: {
652684
...bindings.vars,
@@ -822,6 +854,7 @@ export function buildMiniflareBindingOptions(
822854
}
823855
)
824856
),
857+
additionalUnboundDurableObjects,
825858

826859
ratelimits: Object.fromEntries(
827860
bindings.unsafe?.bindings

pnpm-lock.yaml

Lines changed: 18 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)