diff --git a/.changeset/config.json b/.changeset/config.json index 84f06421..17919024 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -10,5 +10,8 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["@partyserver/fixture-*"] + "ignore": ["@partyserver/fixture-*"], + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true + } } diff --git a/.changeset/persist-server-name.md b/.changeset/persist-server-name.md new file mode 100644 index 00000000..ebc12388 --- /dev/null +++ b/.changeset/persist-server-name.md @@ -0,0 +1,5 @@ +--- +"partyserver": minor +--- + +Persist `Server.name` to durable storage so it survives cold starts without an HTTP request. Fixes `this.name` throwing inside `onAlarm()` and scheduled callbacks (cloudflare/agents#933). diff --git a/package-lock.json b/package-lock.json index cc82e597..6ed4a219 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12235,12 +12235,12 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20251218.0", "hono": "^4.11.1", - "partyserver": "^0.2.0" + "partyserver": ">=0.2.0 <1.0.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20240729.0", "hono": "^4.6.17", - "partyserver": "^0.2.0" + "partyserver": ">=0.2.0 <1.0.0" } }, "packages/partyagent": { @@ -12260,7 +12260,7 @@ "license": "ISC", "dependencies": { "nanoid": "^5.1.6", - "partysocket": "^1.1.13" + "partysocket": "^1.1.14" } }, "packages/partyhard": { @@ -12327,12 +12327,12 @@ "license": "ISC", "devDependencies": { "@cloudflare/workers-types": "^4.20251218.0", - "partyserver": "^0.2.0", + "partyserver": ">=0.2.0 <1.0.0", "partysocket": "^1.1.14" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20240729.0", - "partyserver": "^0.2.0", + "partyserver": ">=0.2.0 <1.0.0", "partysocket": "^1.1.14" } }, @@ -12345,11 +12345,11 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20251218.0", - "partyserver": "^0.2.0" + "partyserver": ">=0.2.0 <1.0.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20240729.0", - "partyserver": "^0.2.0" + "partyserver": ">=0.2.0 <1.0.0" } }, "packages/partytracks": { @@ -12367,7 +12367,7 @@ "license": "ISC", "dependencies": { "cron-parser": "^5.4.0", - "partyserver": "^0.2.0" + "partyserver": ">=0.2.0 <1.0.0" } }, "packages/y-partyserver": { @@ -12382,13 +12382,13 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20251218.0", "@types/lodash.debounce": "^4.0.9", - "partyserver": "^0.2.0", + "partyserver": ">=0.2.0 <1.0.0", "ws": "^8.18.3", "yjs": "^13.6.28" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20240729.0", - "partyserver": "^0.2.0", + "partyserver": ">=0.2.0 <1.0.0", "yjs": "^13.6.14" } }, diff --git a/packages/hono-party/package.json b/packages/hono-party/package.json index e22aef33..70e124f0 100644 --- a/packages/hono-party/package.json +++ b/packages/hono-party/package.json @@ -32,11 +32,11 @@ "peerDependencies": { "@cloudflare/workers-types": "^4.20240729.0", "hono": "^4.6.17", - "partyserver": "^0.2.0" + "partyserver": ">=0.2.0 <1.0.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20251218.0", "hono": "^4.11.1", - "partyserver": "^0.2.0" + "partyserver": ">=0.2.0 <1.0.0" } } diff --git a/packages/partyfn/package.json b/packages/partyfn/package.json index 52ce169e..bef838cd 100644 --- a/packages/partyfn/package.json +++ b/packages/partyfn/package.json @@ -19,7 +19,7 @@ ], "dependencies": { "nanoid": "^5.1.6", - "partysocket": "^1.1.13" + "partysocket": "^1.1.14" }, "scripts": { "build": "tsx scripts/build.ts" diff --git a/packages/partyserver/src/index.ts b/packages/partyserver/src/index.ts index aa3ea328..b854e9e9 100644 --- a/packages/partyserver/src/index.ts +++ b/packages/partyserver/src/index.ts @@ -24,6 +24,8 @@ export * from "./types"; export type WSMessage = ArrayBuffer | ArrayBufferView | string; +const NAME_STORAGE_KEY = "__ps_name"; + // Let's cache the server namespace map // so we don't call it on every request const serverMapCache = new WeakMap< @@ -390,31 +392,28 @@ export class Server< this.#_props = JSON.parse(props); } if (!this.#_name) { - // This is temporary while we solve https://github.com/cloudflare/workerd/issues/2240 - - // get namespace and room from headers - // const namespace = request.headers.get("x-partykit-namespace"); - const room = request.headers.get("x-partykit-room"); - if ( - // !namespace || - !room - ) { - throw new Error(`Missing namespace or room headers when connecting to ${this.#ParentClass.name}. + // Try hydrating from storage first (covers cold starts after + // a previous request already persisted the name). + const stored = this.ctx.storage.kv.get(NAME_STORAGE_KEY); + if (stored) { + this.#_name = stored; + } else { + // First-time contact: name must come from the request header + // (set by routePartykitRequest or getServerByName). + const room = request.headers.get("x-partykit-room"); + if (!room) { + throw new Error(`Missing namespace or room headers when connecting to ${this.#ParentClass.name}. Did you try connecting directly to this Durable Object? Try using getServerByName(namespace, id) instead.`); + } + await this.setName(room); } - await this.setName(room); - } else if (this.#status !== "started") { - // Name was set by a previous request but initialization failed. - // Retry initialization so the server can recover from transient - // onStart failures. - await this.#initialize(); } + + await this.#ensureInitialized(); + const url = new URL(request.url); - // TODO: this is a hack to set the server name, - // it'll be replaced with RPC later if (url.pathname === "/cdn-cgi/partyserver/set-name/") { - // we can just return a 200 for now return Response.json({ ok: true }); } @@ -496,9 +495,7 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam try { const connection = createLazyConnection(ws); - // rehydrate the server name if it's woken up - await this.setName(connection.server); - // TODO: ^ this shouldn't be async + await this.#ensureInitialized(); return this.onMessage(connection, message); } catch (e) { @@ -522,9 +519,7 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam try { const connection = createLazyConnection(ws); - // rehydrate the server name if it's woken up - await this.setName(connection.server); - // TODO: ^ this shouldn't be async + await this.#ensureInitialized(); return this.onClose(connection, code, reason, wasClean); } catch (e) { @@ -543,9 +538,7 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam try { const connection = createLazyConnection(ws); - // rehydrate the server name if it's woken up - await this.setName(connection.server); - // TODO: ^ this shouldn't be async + await this.#ensureInitialized(); return this.onError(connection, error); } catch (e) { @@ -556,7 +549,8 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam } } - async #initialize(): Promise { + async #ensureInitialized(): Promise { + if (this.#status === "started") return; let error: unknown; await this.ctx.blockConcurrencyWhile(async () => { this.#status = "starting"; @@ -607,29 +601,26 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam #_name: string | undefined; - #_longErrorAboutNameThrown = false; /** * The name for this server. Write-once-only. + * Hydrates from durable storage on first access if the name was + * previously persisted (e.g. during an alarm wake-up with no HTTP request). */ get name(): string { if (!this.#_name) { - if (!this.#_longErrorAboutNameThrown) { - this.#_longErrorAboutNameThrown = true; - throw new Error( - `Attempting to read .name on ${this.#ParentClass.name} before it was set. The name can be set by explicitly calling .setName(name) on the stub, or by using routePartyKitRequest(). This is a known issue and will be fixed soon. Follow https://github.com/cloudflare/workerd/issues/2240 for more updates.` - ); - } else { - throw new Error( - `Attempting to read .name on ${this.#ParentClass.name} before it was set.` - ); + const stored = this.ctx.storage.kv.get(NAME_STORAGE_KEY); + if (stored) { + this.#_name = stored; } } + if (!this.#_name) { + throw new Error( + `Attempting to read .name on ${this.#ParentClass.name} before it was set. The name can be set by explicitly calling .setName(name) on the stub, or by using routePartyKitRequest(). This is a known issue and will be fixed soon. Follow https://github.com/cloudflare/workerd/issues/2240 for more updates.` + ); + } return this.#_name; } - // We won't have an await inside this function - // but it will be called remotely, - // so we need to mark it as async async setName(name: string) { if (!name) { throw new Error("A name is required."); @@ -640,10 +631,9 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam ); } this.#_name = name; + this.ctx.storage.kv.put(NAME_STORAGE_KEY, name); - if (this.#status !== "started") { - await this.#initialize(); - } + await this.#ensureInitialized(); } #sendMessageToConnection(connection: Connection, message: WSMessage): void { @@ -793,11 +783,7 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam } async alarm(): Promise { - if (this.#status !== "started") { - // This means the server "woke up" after hibernation - // so we need to hydrate it again - await this.#initialize(); - } + await this.#ensureInitialized(); await this.onAlarm(); } } diff --git a/packages/partyserver/src/tests/index.test.ts b/packages/partyserver/src/tests/index.test.ts index 141e041a..72441b56 100644 --- a/packages/partyserver/src/tests/index.test.ts +++ b/packages/partyserver/src/tests/index.test.ts @@ -532,6 +532,172 @@ describe("Alarm (initialize without redundant blockConcurrencyWhile)", () => { }); }); +describe("Name persistence via storage", () => { + it("persists name to storage when setName is called", async () => { + const id = env.Stateful.idFromName("storage-persist-test"); + const stub = env.Stateful.get(id); + + const res = await stub.fetch( + new Request("http://example.com/parties/stateful/storage-persist-test", { + headers: { "x-partykit-room": "storage-persist-test" } + }) + ); + const data = (await res.json()) as { name: string }; + expect(data.name).toBe("storage-persist-test"); + }); + + it("this.name is available inside onAlarm after normal setup", async () => { + const id = env.AlarmNameServer.idFromName("alarm-name-normal"); + const stub = env.AlarmNameServer.get(id); + + const setupRes = await stub.fetch( + new Request( + "http://example.com/parties/alarm-name-server/alarm-name-normal?setAlarm=1", + { headers: { "x-partykit-room": "alarm-name-normal" } } + ) + ); + expect(await setupRes.text()).toBe("alarm set"); + + const ran = await runDurableObjectAlarm(stub); + expect(ran).toBe(true); + + const stateRes = await stub.fetch( + new Request("http://example.com/", { + headers: { "x-partykit-room": "alarm-name-normal" } + }) + ); + const state = (await stateRes.json()) as { + name: string; + alarmName: string | null; + onStartName: string | null; + }; + expect(state.name).toBe("alarm-name-normal"); + expect(state.alarmName).toBe("alarm-name-normal"); + }); + + it("hydrates name from storage on cold alarm wake (bypassing setName)", async () => { + // Seed storage directly without calling setName — simulates a DO + // that was previously named, went to sleep, and wakes cold. + const id = env.AlarmNameServer.idFromName("alarm-cold-wake"); + const stub = env.AlarmNameServer.get(id); + + const seedRes = await stub.fetch( + new Request("http://example.com/?seed=1&name=alarm-cold-wake") + ); + expect(await seedRes.text()).toBe("seeded"); + + const ran = await runDurableObjectAlarm(stub); + expect(ran).toBe(true); + + const stateRes = await stub.fetch( + new Request("http://example.com/", { + headers: { "x-partykit-room": "alarm-cold-wake" } + }) + ); + const state = (await stateRes.json()) as { + alarmName: string | null; + onStartName: string | null; + }; + expect(state.alarmName).toBe("alarm-cold-wake"); + }); + + it("this.name is available inside onStart during alarm wake", async () => { + const id = env.AlarmNameServer.idFromName("alarm-onstart-name"); + const stub = env.AlarmNameServer.get(id); + + const seedRes = await stub.fetch( + new Request("http://example.com/?seed=1&name=alarm-onstart-name") + ); + expect(await seedRes.text()).toBe("seeded"); + + const ran = await runDurableObjectAlarm(stub); + expect(ran).toBe(true); + + const stateRes = await stub.fetch( + new Request("http://example.com/", { + headers: { "x-partykit-room": "alarm-onstart-name" } + }) + ); + const state = (await stateRes.json()) as { + onStartName: string | null; + }; + expect(state.onStartName).toBe("alarm-onstart-name"); + }); + + it("fetch() hydrates name from storage without requiring the header", async () => { + const id = env.AlarmNameServer.idFromName("fetch-no-header"); + const stub = env.AlarmNameServer.get(id); + + // First request sets the name via the header (normal path) + await stub.fetch( + new Request( + "http://example.com/parties/alarm-name-server/fetch-no-header", + { headers: { "x-partykit-room": "fetch-no-header" } } + ) + ); + + // Second request: no x-partykit-room header. + // Should still work because the name is in storage. + const res = await stub.fetch(new Request("http://example.com/")); + const data = (await res.json()) as { name: string }; + expect(data.name).toBe("fetch-no-header"); + }); + + it("setName is idempotent for the same value", async () => { + const id = env.AlarmNameServer.idFromName("idempotent-test"); + const stub = env.AlarmNameServer.get(id); + + // First call + const res1 = await stub.fetch( + new Request( + "http://example.com/parties/alarm-name-server/idempotent-test", + { headers: { "x-partykit-room": "idempotent-test" } } + ) + ); + expect(res1.status).toBe(200); + + // Second call with same name — should not throw + const res2 = await stub.fetch( + new Request("http://example.com/", { + headers: { "x-partykit-room": "idempotent-test" } + }) + ); + const data = (await res2.json()) as { name: string }; + expect(data.name).toBe("idempotent-test"); + }); + + it("throws when name was never set and is not in storage", async () => { + const id = env.NoNameServer.idFromName("no-name-test"); + const stub = env.NoNameServer.get(id); + + // Direct fetch without the header — name was never persisted + const res = await stub.fetch(new Request("http://example.com/")); + expect(res.status).toBe(500); + const body = await res.text(); + expect(body).toContain("Missing namespace or room headers"); + }); + + it("getServerByName persists the name for future access", async () => { + const ctx = createExecutionContext(); + + // Use routePartykitRequest to hit the DO and set its name + const request = new Request( + "http://example.com/parties/alarm-name-server/gsbn-test" + ); + const response = await worker.fetch(request, env, ctx); + expect(response.status).toBe(200); + const data = (await response.json()) as { name: string }; + expect(data.name).toBe("gsbn-test"); + + // Now fetch the same DO directly without the header + const id = env.AlarmNameServer.idFromName("gsbn-test"); + const stub = env.AlarmNameServer.get(id); + const directRes = await stub.fetch(new Request("http://example.com/")); + const directData = (await directRes.json()) as { name: string }; + expect(directData.name).toBe("gsbn-test"); + }); +}); + describe("CORS", () => { it("returns CORS headers on OPTIONS preflight for matched routes", async () => { const ctx = createExecutionContext(); diff --git a/packages/partyserver/src/tests/worker.ts b/packages/partyserver/src/tests/worker.ts index 1928a9bf..740edea5 100644 --- a/packages/partyserver/src/tests/worker.ts +++ b/packages/partyserver/src/tests/worker.ts @@ -13,6 +13,8 @@ export type Env = { OnStartServer: DurableObjectNamespace; HibernatingOnStartServer: DurableObjectNamespace; AlarmServer: DurableObjectNamespace; + AlarmNameServer: DurableObjectNamespace; + NoNameServer: DurableObjectNamespace; Mixed: DurableObjectNamespace; ConfigurableState: DurableObjectNamespace; ConfigurableStateInMemory: DurableObjectNamespace; @@ -142,6 +144,80 @@ export class AlarmServer extends Server { } } +/** + * Multipurpose test DO for name persistence scenarios. + * Supports seeding storage directly (bypassing setName), reading back + * what this.name returned in onStart/onAlarm, and direct fetch without + * the x-partykit-room header. + */ +export class AlarmNameServer extends Server { + static options = { + hibernate: true + }; + + alarmName: string | null = null; + onStartName: string | null = null; + nameWasCold = false; + + async fetch(request: Request): Promise { + const url = new URL(request.url); + + // Seed storage directly, bypassing Server.fetch()/setName(). + // Simulates a DO that was previously named, hibernated, and + // wakes cold — #_name is unset, only storage has the name. + if (url.searchParams.get("seed")) { + const name = url.searchParams.get("name")!; + this.ctx.storage.kv.put("__ps_name", name); + await this.ctx.storage.setAlarm(Date.now() + 60_000); + return new Response("seeded"); + } + + return super.fetch(request); + } + + async onStart() { + try { + this.onStartName = this.name; + } catch { + this.onStartName = null; + } + } + + onAlarm() { + this.alarmName = this.name; + } + + async onRequest(request: Request): Promise { + const url = new URL(request.url); + if (url.searchParams.get("setAlarm")) { + await this.ctx.storage.setAlarm(Date.now() + 60_000); + return new Response("alarm set"); + } + return Response.json({ + name: this.name, + alarmName: this.alarmName, + onStartName: this.onStartName, + nameWasCold: this.nameWasCold + }); + } +} + +/** + * Minimal DO that never has its name set. + * Used to test that the name getter throws appropriately. + */ +export class NoNameServer extends Server { + static options = { hibernate: true }; + + async onStart() { + // no-op + } + + onRequest(): Response { + return Response.json({ name: this.name }); + } +} + export class Mixed extends Server { static options = { hibernate: true diff --git a/packages/partyserver/src/tests/wrangler.jsonc b/packages/partyserver/src/tests/wrangler.jsonc index 6e1e8c41..ea5c4131 100644 --- a/packages/partyserver/src/tests/wrangler.jsonc +++ b/packages/partyserver/src/tests/wrangler.jsonc @@ -1,6 +1,6 @@ { "main": "/worker.ts", - "compatibility_date": "2024-05-12", + "compatibility_date": "2026-01-28", "compatibility_flags": [ "nodejs_compat", // test specific flags @@ -51,6 +51,14 @@ "name": "AlarmServer", "class_name": "AlarmServer" }, + { + "name": "AlarmNameServer", + "class_name": "AlarmNameServer" + }, + { + "name": "NoNameServer", + "class_name": "NoNameServer" + }, { "name": "FailingOnStartServer", "class_name": "FailingOnStartServer" @@ -72,7 +80,7 @@ "migrations": [ { "tag": "v1", - "new_classes": [ + "new_sqlite_classes": [ "Stateful", "OnStartServer", "Mixed", @@ -83,6 +91,8 @@ "CustomCorsServer", "HibernatingOnStartServer", "AlarmServer", + "AlarmNameServer", + "NoNameServer", "FailingOnStartServer", "HibernatingNameInMessage", "TagsServer", diff --git a/packages/partysub/package.json b/packages/partysub/package.json index a9f522c6..355b1ba6 100644 --- a/packages/partysub/package.json +++ b/packages/partysub/package.json @@ -41,12 +41,12 @@ "description": "", "peerDependencies": { "@cloudflare/workers-types": "^4.20240729.0", - "partyserver": "^0.2.0", + "partyserver": ">=0.2.0 <1.0.0", "partysocket": "^1.1.14" }, "devDependencies": { "@cloudflare/workers-types": "^4.20251218.0", - "partyserver": "^0.2.0", + "partyserver": ">=0.2.0 <1.0.0", "partysocket": "^1.1.14" } } diff --git a/packages/partysync/package.json b/packages/partysync/package.json index 06f1c6e7..ee22b088 100644 --- a/packages/partysync/package.json +++ b/packages/partysync/package.json @@ -50,10 +50,10 @@ }, "peerDependencies": { "@cloudflare/workers-types": "^4.20240729.0", - "partyserver": "^0.2.0" + "partyserver": ">=0.2.0 <1.0.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20251218.0", - "partyserver": "^0.2.0" + "partyserver": ">=0.2.0 <1.0.0" } } diff --git a/packages/partywhen/package.json b/packages/partywhen/package.json index 0a544db5..5388d956 100644 --- a/packages/partywhen/package.json +++ b/packages/partywhen/package.json @@ -29,6 +29,6 @@ "description": "A library for scheduling and running tasks in Cloudflare Workers", "dependencies": { "cron-parser": "^5.4.0", - "partyserver": "^0.2.0" + "partyserver": ">=0.2.0 <1.0.0" } } diff --git a/packages/y-partyserver/package.json b/packages/y-partyserver/package.json index b2ab0761..a3707f1a 100644 --- a/packages/y-partyserver/package.json +++ b/packages/y-partyserver/package.json @@ -47,13 +47,13 @@ }, "peerDependencies": { "@cloudflare/workers-types": "^4.20240729.0", - "partyserver": "^0.2.0", + "partyserver": ">=0.2.0 <1.0.0", "yjs": "^13.6.14" }, "devDependencies": { "@cloudflare/workers-types": "^4.20251218.0", "@types/lodash.debounce": "^4.0.9", - "partyserver": "^0.2.0", + "partyserver": ">=0.2.0 <1.0.0", "ws": "^8.18.3", "yjs": "^13.6.28" }