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
5 changes: 5 additions & 0 deletions .changeset/proxy-wakes-by-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": patch
---

`WorkspaceProxy` and `WorkspaceServiceProxy` wake their host Durable Object by name, so `ctx.id.name` survives a wake caused by a container or Dynamic Worker callback. `WorkspaceRef`, `WorkspaceProxyProps` and `WorkspaceServiceProxyProps` accept an optional `name`, filled in from `ctx.id.name` when the backend runs in the host object itself.
15 changes: 13 additions & 2 deletions packages/computer/src/backends/container-legacy/container-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
// an RpcTarget so it works the same in-isolate and across RPC.

import { RpcTarget } from "cloudflare:workers";
import { withHostName } from "../../durable-object-name.js";
import { WorkspaceTransportError } from "../../transport-failure.js";
import { ContainerClientSecret } from "./container-client-secret.js";
import {
Expand Down Expand Up @@ -46,6 +47,11 @@ export interface WorkspaceRef {
binding: string;
// Stringified DurableObjectId of the specific Workspace owner.
id: string;
// Name the Workspace owner is addressed by, typically
// ctx.id.name. The proxy wakes the owner by name when this yields
// `id`, so an owner the container wakes keeps ctx.id.name. Filled
// in automatically when the container host is the owner itself.
name?: string;
}

// Driver surface LegacyContainerBackend talks to. Implemented
Expand Down Expand Up @@ -259,7 +265,9 @@ export class LegacyWorkspaceContainerAPI extends RpcTarget implements ILegacyWor
const exports = (this.#ctx as unknown as { exports: Record<string, unknown> }).exports as {
WorkspaceProxy: (opts: { props: WorkspaceRef & { egressToken: string } }) => Fetcher;
};
const proxy = exports.WorkspaceProxy({ props: { ...ref, egressToken: token } });
const proxy = exports.WorkspaceProxy({
props: { ...withHostName(ref, this.#ctx), egressToken: token },
});
await Promise.all([
this.#container.interceptAllOutboundHttp(proxy),
this.#container.interceptOutboundHttps("*", proxy),
Expand All @@ -276,7 +284,10 @@ export class LegacyWorkspaceContainerAPI extends RpcTarget implements ILegacyWor
const exports = (this.#ctx as unknown as { exports: Record<string, unknown> }).exports as {
WorkspaceProxy: (opts: { props: WorkspaceRef }) => Fetcher;
};
await this.#container.interceptOutboundHttp(host, exports.WorkspaceProxy({ props: ref }));
await this.#container.interceptOutboundHttp(
host,
exports.WorkspaceProxy({ props: withHostName(ref, this.#ctx) }),
);
}

fetchPort(port: number, input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
Expand Down
15 changes: 13 additions & 2 deletions packages/computer/src/backends/container/container-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
// an RpcTarget so it works the same in-isolate and across RPC.

import { RpcTarget } from "cloudflare:workers";
import { withHostName } from "../../durable-object-name.js";
import { WorkspaceTransportError } from "../../transport-failure.js";
import { ContainerClientSecret } from "./container-client-secret.js";
import {
Expand Down Expand Up @@ -50,6 +51,11 @@ export interface WorkspaceRef {
binding: string;
// Stringified DurableObjectId of the specific Workspace owner.
id: string;
// Name the Workspace owner is addressed by, typically
// ctx.id.name. The proxy wakes the owner by name when this yields
// `id`, so an owner the container wakes keeps ctx.id.name. Filled
// in automatically when the container host is the owner itself.
name?: string;
}

// Driver surface ContainerBackend talks to. Implemented
Expand Down Expand Up @@ -323,7 +329,9 @@ export class WorkspaceContainerAPI extends RpcTarget implements IWorkspaceContai
const exports = (this.#ctx as unknown as { exports: Record<string, unknown> }).exports as {
WorkspaceProxy: (opts: { props: WorkspaceRef & { egressToken: string } }) => Fetcher;
};
const proxy = exports.WorkspaceProxy({ props: { ...ref, egressToken: token } });
const proxy = exports.WorkspaceProxy({
props: { ...withHostName(ref, this.#ctx), egressToken: token },
});
await Promise.all([
this.#container.interceptAllOutboundHttp(proxy),
this.#container.interceptOutboundHttps("*", proxy),
Expand All @@ -340,7 +348,10 @@ export class WorkspaceContainerAPI extends RpcTarget implements IWorkspaceContai
const exports = (this.#ctx as unknown as { exports: Record<string, unknown> }).exports as {
WorkspaceProxy: (opts: { props: WorkspaceRef }) => Fetcher;
};
await this.#container.interceptOutboundHttp(host, exports.WorkspaceProxy({ props: ref }));
await this.#container.interceptOutboundHttp(
host,
exports.WorkspaceProxy({ props: withHostName(ref, this.#ctx) }),
);
}

fetchPort(port: number, input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
Expand Down
3 changes: 2 additions & 1 deletion packages/computer/src/backends/worker-shell/worker-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import type { ExecEvent, ShellRPC, SyncRPC, WorkspaceRPC } from "@cloudflare/computer-rpc";

import type { BackendHandle, WorkspaceBackend } from "../../backend.js";
import { withHostName } from "../../durable-object-name.js";
import type { WorkspaceServiceProxyProps } from "../../proxy.js";
import { dynamicWorkerEgress, type WorkspaceEgressPolicy } from "../../runtime/egress.js";
import { SHELL_RUNTIME_MODULES } from "./runtime-modules.js";
Expand Down Expand Up @@ -254,7 +255,7 @@ export class WorkerShellBackend implements WorkspaceBackend {
// The shell calls env.HOST.getWorkspace() on every exec;
// the proxy resolves env[binding].get(id).getWorkspace()
// on the host side.
HOST: ctx.exports.WorkspaceServiceProxy({ props: workspace }),
HOST: ctx.exports.WorkspaceServiceProxy({ props: withHostName(workspace, ctx) }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Automatic name propagation remains untested end-to-end

The tests cover inferred names and name-aware routing separately. No workerd test confirms a backend passes its inferred name to a proxy, leaving the wake fix's wiring unverified.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

},
...dynamicWorkerEgress(this.#egress),
}));
Expand Down
69 changes: 69 additions & 0 deletions packages/computer/src/durable-object-name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";

import { hostDurableObjectId, withHostName } from "./durable-object-name.js";

// A namespace whose ids remember how they were made, standing in for
// DurableObjectNamespace: a named id knows its name, as ctx.id.name
// does on the instance it wakes.
class FakeId {
constructor(
readonly hex: string,
readonly name?: string,
) {}
toString(): string {
return this.hex;
}
}

const ns = {
idFromName: (name: string) => new FakeId(`hex:${name}`, name),
idFromString: (id: string) => new FakeId(id),
};

describe("hostDurableObjectId", () => {
it("addresses by name when the name yields the id", () => {
const id = hostDurableObjectId(ns, "hex:main", "main");
expect(id.toString()).toBe("hex:main");
expect(id.name).toBe("main");
});

it("falls back to the id when the name maps elsewhere", () => {
const id = hostDurableObjectId(ns, "hex:main", "other");
expect(id.toString()).toBe("hex:main");
expect(id.name).toBeUndefined();
});

it("uses the id when there is no name", () => {
expect(hostDurableObjectId(ns, "hex:main", undefined).name).toBeUndefined();
expect(hostDurableObjectId(ns, "hex:main", "").name).toBeUndefined();
});
});

describe("withHostName", () => {
const ctx = { id: new FakeId("hex:main", "main") };

it("adds the name of the object that owns ctx", () => {
expect(withHostName({ binding: "Agent", id: "hex:main" }, ctx)).toEqual({
binding: "Agent",
id: "hex:main",
name: "main",
});
});

it("leaves a reference to another object alone", () => {
const ref = { binding: "Agent", id: "hex:other" };
expect(withHostName(ref, ctx)).toBe(ref);
});

it("keeps a name the caller supplied", () => {
const ref = { binding: "Agent", id: "hex:main", name: "explicit" };
expect(withHostName(ref, ctx)).toBe(ref);
});

it("leaves the reference alone when the runtime has no name", () => {
const ref = { binding: "Agent", id: "hex:main" };
expect(withHostName(ref, { id: new FakeId("hex:main") })).toBe(ref);
expect(withHostName(ref, undefined)).toBe(ref);
expect(withHostName(ref, {})).toBe(ref);
});
});
58 changes: 58 additions & 0 deletions packages/computer/src/durable-object-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Keeping a Durable Object's name across proxy wakes.
//
// The workspace proxies reach their host Durable Object from props
// that a container or a Dynamic Worker holds on to. Both outlive the
// object: after an eviction or a deploy, the proxy is often the first
// caller, and the instance it wakes is the one every later caller
// shares. An instance woken through idFromString() has no
// ctx.id.name for its whole lifetime, and anything that needs the
// name (agents' Lifecycle, for one) then refuses to start. So the
// props may carry the name too, and the proxy addresses the object
// by name whenever that name produces the same id.

// The part of DurableObjectNamespace the proxies use. Structural, so
// this module needs no runtime import.
interface DurableObjectIds<Id> {
idFromName(name: string): Id;
idFromString(id: string): Id;
}

/**
* Resolve the id a proxy should wake its host Durable Object with.
*
* Prefers `idFromName(name)` when it yields `id`, so the woken
* instance keeps `ctx.id.name`. A missing name, or one that maps to a
* different object, falls back to `idFromString(id)`: `id` stays the
* authority on which object is meant.
*/
export function hostDurableObjectId<Id extends { toString(): string }>(
ns: DurableObjectIds<Id>,
id: string,
name: string | undefined,
): Id {
if (name !== undefined && name !== "") {
const named = ns.idFromName(name);
if (named.toString() === id) return named;
}
return ns.idFromString(id);
}

/**
* Fill in `name` on a reference to the Durable Object that owns `ctx`.
*
* Callers usually build the reference from `ctx.id.toString()` alone.
* When the reference points at this very object and the runtime knows
* its name, the name is added so the proxy can wake it by name later.
* A reference to another object, or one that already names itself, is
* returned unchanged.
*/
export function withHostName<Ref extends { id: string; name?: string }>(
ref: Ref,
ctx: unknown,
): Ref {
if (ref.name !== undefined) return ref;
const id = (ctx as { id?: { name?: string; toString(): string } } | undefined)?.id;
const name = id?.name;
if (name === undefined || name === "" || id?.toString() !== ref.id) return ref;
return { ...ref, name };
}
2 changes: 2 additions & 0 deletions packages/computer/src/proxy-stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { ArtifactsCLIInput, ArtifactsCLIResult } from "./artifacts/index.js
export interface WorkspaceProxyProps {
binding: string;
id: string;
name?: string;
}

export class WorkspaceProxy {
Expand All @@ -33,6 +34,7 @@ export class WorkspaceProxy {
export interface WorkspaceServiceProxyProps {
binding: string;
id: string;
name?: string;
}

export class ArtifactsCLITarget {
Expand Down
23 changes: 18 additions & 5 deletions packages/computer/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
// binding: "COMPUTERD",
// // The DO instance the upgrade should route to.
// id: ctx.id.toString(),
// // Lets the proxy wake the DO by name, so
// // ctx.id.name survives a wake the proxy causes.
// name: ctx.id.name,
// },
// }),
// });
Expand All @@ -51,6 +54,7 @@
import { RpcTarget, WorkerEntrypoint } from "cloudflare:workers";

import type { ArtifactsCLIInput, ArtifactsCLIResult } from "./artifacts/index.js";
import { hostDurableObjectId } from "./durable-object-name.js";
import { WORKSPACE_EGRESS_TOKEN_HEADER, WORKSPACE_EGRESS_URL_HEADER } from "./runtime/egress.js";

export interface WorkspaceProxyProps {
Expand All @@ -60,6 +64,11 @@ export interface WorkspaceProxyProps {
// Stringified DurableObjectId — typically `ctx.id.toString()`
// from inside the owning DO's constructor.
id: string;
// The name the DO is addressed by — typically `ctx.id.name`. When
// `idFromName(name)` yields `id`, the proxy wakes the DO by name,
// so an instance the proxy wakes keeps `ctx.id.name`. Woken by id
// alone, it never has one.
name?: string;
egressToken?: string;
}

Expand Down Expand Up @@ -108,9 +117,9 @@ export class WorkspaceProxy extends WorkerEntrypoint<unknown, WorkspaceProxyProp
}

#hostStub(): DurableObjectStub | undefined {
const { binding, id } = this.ctx.props;
const { binding, id, name } = this.ctx.props;
const ns = (this.env as Record<string, unknown>)[binding] as DurableObjectNamespace | undefined;
return ns?.get(ns.idFromString(id));
return ns?.get(hostDurableObjectId(ns, id, name));
}

#missingBindingResponse(): Response {
Expand Down Expand Up @@ -155,7 +164,7 @@ export class ArtifactsCLITarget extends RpcTarget {
//
// // Inside the DO that owns the Workspace:
// const host = ctx.exports.WorkspaceServiceProxy({
// props: { binding: "ContainerExample", id: ctx.id.toString() },
// props: { binding: "ContainerExample", id: ctx.id.toString(), name: ctx.id.name },
// });
// env.LOADER.get(`shell:${ctx.id}`, () => ({
// env: { HOST: host },
Expand All @@ -177,6 +186,10 @@ export interface WorkspaceServiceProxyProps {
// Stringified DurableObjectId — typically ctx.id.toString()
// from inside the owning DO's constructor.
id: string;
// The name the DO is addressed by — typically ctx.id.name. See
// WorkspaceProxyProps.name: without it, a DO this proxy wakes has
// no ctx.id.name.
name?: string;
}

export class WorkspaceServiceProxy extends WorkerEntrypoint<unknown, WorkspaceServiceProxyProps> {
Expand Down Expand Up @@ -217,13 +230,13 @@ export class WorkspaceServiceProxy extends WorkerEntrypoint<unknown, WorkspaceSe
}

#hostStub<T extends object>(): Rpc.DurableObjectBranded & T {
const { binding, id } = this.ctx.props;
const { binding, id, name } = this.ctx.props;
const ns = (this.env as Record<string, unknown>)[binding] as
| DurableObjectNamespace<Rpc.DurableObjectBranded & T>
| undefined;
if (!ns) {
throw new Error(`WorkspaceServiceProxy: env.${binding} is not a DurableObjectNamespace`);
}
return ns.get(ns.idFromString(id)) as unknown as Rpc.DurableObjectBranded & T;
return ns.get(hostDurableObjectId(ns, id, name)) as unknown as Rpc.DurableObjectBranded & T;
}
}
21 changes: 18 additions & 3 deletions packages/computer/tests/proxy-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,24 @@

import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";

export { WorkspaceProxy } from "../src/proxy.js";
export { WorkspaceProxy, WorkspaceServiceProxy } from "../src/proxy.js";

export interface Env {
COMPUTERD: DurableObjectNamespace;
}

export class TestStorageDO extends DurableObject<Env> {
// What the runtime reports as this instance's name. An instance
// first woken through idFromString() never learns it.
async __getWorkspaceStub(): Promise<unknown> {
return { name: this.ctx.id.name ?? null };
}

override async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/api" && request.headers.has("x-test-report-name")) {
return Response.json({ name: this.ctx.id.name ?? null });
}
const egressToken = request.headers.get("x-workspace-egress-token");
if (url.pathname === "/api" && egressToken !== null) {
return Response.json({
Expand Down Expand Up @@ -59,9 +68,15 @@ export default class TestDriver extends WorkerEntrypoint<Env> {
const binding = request.headers.get("x-test-binding") ?? "COMPUTERD";
const id = request.headers.get("x-test-id") ?? "";
const egressToken = request.headers.get("x-test-egress-token") ?? undefined;
const name = request.headers.get("x-test-name") ?? undefined;
// oxlint-disable-next-line typescript/no-explicit-any -- ctx.exports isn't in @cloudflare/workers-types yet
const proxy = (this.ctx as any).exports.WorkspaceProxy({
props: { binding, id, egressToken },
const exports = (this.ctx as any).exports;
if (new URL(request.url).pathname === "/__service/getWorkspace") {
const service = exports.WorkspaceServiceProxy({ props: { binding, id, name } });
return Response.json(await service.getWorkspace());
}
const proxy = exports.WorkspaceProxy({
props: { binding, id, name, egressToken },
});
return proxy.fetch(request);
}
Expand Down
Loading
Loading