diff --git a/.changeset/persist-room-name-alarm.md b/.changeset/persist-room-name-alarm.md new file mode 100644 index 00000000..c9b04b09 --- /dev/null +++ b/.changeset/persist-room-name-alarm.md @@ -0,0 +1,5 @@ +--- +"partyserver": patch +--- + +`this.name` now works in `onAlarm()` for servers that have been fetched at least once. diff --git a/packages/partyserver/src/index.ts b/packages/partyserver/src/index.ts index aa3ea328..119a7d5c 100644 --- a/packages/partyserver/src/index.ts +++ b/packages/partyserver/src/index.ts @@ -403,11 +403,19 @@ export class Server< Did you try connecting directly to this Durable Object? Try using getServerByName(namespace, id) instead.`); } 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(); + } else { + const room = request.headers.get("x-partykit-room"); + if (room && room !== this.#_name) { + throw new Error( + `Room name mismatch: this server is "${this.#_name}" but request has room "${room}"` + ); + } + 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(); + } } const url = new URL(request.url); @@ -559,6 +567,11 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam async #initialize(): Promise { let error: unknown; await this.ctx.blockConcurrencyWhile(async () => { + if (!this.#_name) { + const stored = + await this.ctx.storage.get("__partyserver_name"); + if (stored) this.#_name = stored; + } this.#status = "starting"; try { await this.onStart(this.#_props); @@ -627,9 +640,6 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam 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,6 +650,7 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam ); } this.#_name = name; + await this.ctx.storage.put("__partyserver_name", name); if (this.#status !== "started") { await this.#initialize(); @@ -798,6 +809,12 @@ Did you try connecting directly to this Durable Object? Try using getServerByNam // so we need to hydrate it again await this.#initialize(); } + if (!this.#_name) { + console.warn( + `${this.#ParentClass.name} alarm fired but this.name is not available. ` + + `The server must be fetched at least once (via routePartykitRequest or getServerByName) before this.name can be used in onAlarm.` + ); + } await this.onAlarm(); } } diff --git a/packages/partyserver/src/tests/index.test.ts b/packages/partyserver/src/tests/index.test.ts index 141e041a..3b901030 100644 --- a/packages/partyserver/src/tests/index.test.ts +++ b/packages/partyserver/src/tests/index.test.ts @@ -1,7 +1,8 @@ import { createExecutionContext, env, - runDurableObjectAlarm + runDurableObjectAlarm, + runInDurableObject // waitOnExecutionContext } from "cloudflare:test"; import { describe, expect, it } from "vitest"; @@ -532,6 +533,42 @@ describe("Alarm (initialize without redundant blockConcurrencyWhile)", () => { }); }); +describe("Alarm cold start (name persisted to storage)", () => { + it("setName persists the room name to storage", async () => { + const id = env.AlarmNameServer.idFromName("persist-write-test"); + const stub = env.AlarmNameServer.get(id); + + await stub.fetch( + new Request("http://example.com/", { + headers: { "x-partykit-room": "persist-write-test" } + }) + ); + + const stored = await runInDurableObject(stub, async (_instance, state) => { + return state.storage.get("__partyserver_name"); + }); + expect(stored).toEqual("persist-write-test"); + }); + + it("this.name is available in onAlarm after a cold start", async () => { + const id = env.AlarmNameServer.idFromName("alarm-name-test"); + const stub = env.AlarmNameServer.get(id); + + // Pre-seed storage directly — no fetch, so #_name is never set in memory. + // This simulates a DO that was previously initialized (name persisted) + // but has since been evicted and is now cold-starting via alarm. + await runInDurableObject(stub, async (_instance, state) => { + await state.storage.put("__partyserver_name", "alarm-name-test"); + await state.storage.setAlarm(Date.now() + 60_000); + }); + + await runDurableObjectAlarm(stub); + + const alarmName = await runInDurableObject(stub, (i) => i.alarmName); + expect(alarmName).toEqual("alarm-name-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..3f8c10a7 100644 --- a/packages/partyserver/src/tests/worker.ts +++ b/packages/partyserver/src/tests/worker.ts @@ -13,6 +13,7 @@ export type Env = { OnStartServer: DurableObjectNamespace; HibernatingOnStartServer: DurableObjectNamespace; AlarmServer: DurableObjectNamespace; + AlarmNameServer: DurableObjectNamespace; Mixed: DurableObjectNamespace; ConfigurableState: DurableObjectNamespace; ConfigurableStateInMemory: DurableObjectNamespace; @@ -352,6 +353,22 @@ export class TagsServerInMemory extends Server { } } +/** + * Tests that this.name is available in onAlarm after an alarm-triggered + * cold start (no prior fetch). + */ +export class AlarmNameServer extends Server { + static options = { + hibernate: true + }; + + alarmName: string | null = null; + + onAlarm() { + this.alarmName = this.name; + } +} + export class CorsServer extends Server { onRequest(): Response | Promise { return Response.json({ cors: true }); diff --git a/packages/partyserver/src/tests/wrangler.jsonc b/packages/partyserver/src/tests/wrangler.jsonc index 6e1e8c41..073d27bf 100644 --- a/packages/partyserver/src/tests/wrangler.jsonc +++ b/packages/partyserver/src/tests/wrangler.jsonc @@ -55,6 +55,10 @@ "name": "FailingOnStartServer", "class_name": "FailingOnStartServer" }, + { + "name": "AlarmNameServer", + "class_name": "AlarmNameServer" + }, { "name": "HibernatingNameInMessage", "class_name": "HibernatingNameInMessage" @@ -84,6 +88,7 @@ "HibernatingOnStartServer", "AlarmServer", "FailingOnStartServer", + "AlarmNameServer", "HibernatingNameInMessage", "TagsServer", "TagsServerInMemory"