Skip to content

Commit 0274658

Browse files
committed
Fix: restore __ps_name storage hydrate for Agents facets (0.5.1)
0.5.0 moved the legacy __ps_name storage hydrate into `alarm()` only, which broke Cloudflare Agents facets and any other framework that writes __ps_name directly before calling __unsafe_ensureInitialized(). Facet DOs are spawned via `ctx.facets.get(...)` rather than `idFromName()`, so their `ctx.id.name` is `undefined`. They were relying on PartyServer's `#ensureInitialized()` calling `#hydrateNameFromStorage()` to populate `this.name` before `onStart()`. This PR: - Moves the hydrate from `alarm()` into `#ensureInitialized()`, gated on `!ctx.id.name && !#_name` so the happy path (normal idFromName DOs) still pays zero storage reads. - Simplifies `Server.fetch()` to delegate the hydrate to `#ensureInitialized()`. The x-partykit-room header remains as a last-resort fallback when neither ctx.id.name nor storage has a value. - Simplifies `Server.alarm()` — no separate hydrate call needed. - Softens `@deprecated` on `setName()` to clarify it's still the right primitive for framework bootstrap of non-idFromName DOs (e.g. facets). - Adds FacetLikeBootstrapServer + tests covering the fetch-time and RPC-time hydrate paths. Made-with: Cursor
1 parent f661aa7 commit 0274658

5 files changed

Lines changed: 148 additions & 24 deletions

File tree

.changeset/fix-facet-hydrate.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"partyserver": patch
3+
---
4+
5+
Fix: restore legacy `__ps_name` storage fallback for framework bootstrap patterns.
6+
7+
0.5.0 moved the legacy storage hydrate into `alarm()` only, breaking Cloudflare Agents facets and any other framework that writes `__ps_name` directly before calling `__unsafe_ensureInitialized()`. Facet DOs are spawned via `ctx.facets.get(...)` rather than `idFromName()` and therefore have `ctx.id.name === undefined`; they relied on PartyServer reading the storage record back to populate `this.name` before `onStart()`.
8+
9+
Changes:
10+
11+
- Move the legacy `__ps_name` hydrate from `alarm()` into `#ensureInitialized()`, still gated on `!ctx.id.name && !#_name` so it costs nothing on the happy path (normal `idFromName()`/`getByName()` DOs skip the storage read entirely).
12+
- `Server.fetch()` now delegates to `#ensureInitialized()` for the hydrate instead of doing its own. The `x-partykit-room` header fallback remains as a last resort when neither `ctx.id.name` nor a legacy storage record is available.
13+
- `Server.alarm()` is simplified — it no longer needs its own hydrate call since `#ensureInitialized()` handles it.
14+
- `setName()`'s `@deprecated` docblock is softened to clarify that it remains appropriate for framework-level bootstrap of non-`idFromName` DOs (e.g. Agents facets), not just a deprecated compatibility shim.

packages/partyserver/src/index.ts

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -389,24 +389,26 @@ export class Server<
389389
this.#_props = JSON.parse(props);
390390
}
391391

392-
// Legacy fallback for callers that bypass `routePartykitRequest`/
393-
// `getServerByName` and invoke `stub.fetch()` directly with the
394-
// `x-partykit-room` header. When the DO was addressed via
395-
// `idFromName()`/`getByName()`, `ctx.id.name` provides the name and
396-
// this branch is a no-op.
392+
// Name resolution in `#ensureInitialized()` consults
393+
// `ctx.id.name`, then a legacy storage record, so by the time it
394+
// returns `this.name` is populated for any properly-addressed
395+
// DO or any DO bootstrapped via the legacy `__ps_name` storage
396+
// record (e.g. Cloudflare Agents facets). The only remaining
397+
// case is a caller that bypasses both — then we fall back to
398+
// the `x-partykit-room` request header.
399+
await this.#ensureInitialized();
400+
397401
if (!this.ctx.id.name && !this.#_name) {
398402
const room = request.headers.get("x-partykit-room");
399403
if (!room) {
400-
throw new Error(`Cannot determine the name for ${this.#ParentClass.name}: this.ctx.id.name is undefined and no x-partykit-room header is present. Likely causes:
404+
throw new Error(`Cannot determine the name for ${this.#ParentClass.name}: this.ctx.id.name is undefined, no legacy __ps_name storage record is present, and no x-partykit-room header was supplied. Likely causes:
401405
1. The stub was built via idFromString()/newUniqueId(). PartyServer requires name-based addressing (idFromName/getByName).
402406
2. The workerd/wrangler runtime is too old to expose ctx.id.name — update to a recent wrangler release.
403407
3. You called stub.fetch() directly without going through routePartykitRequest()/getServerByName(). Prefer those, or set the x-partykit-room header.`);
404408
}
405409
this.#_name = room;
406410
}
407411

408-
await this.#ensureInitialized();
409-
410412
const url = new URL(request.url);
411413

412414
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
@@ -543,10 +545,21 @@ export class Server<
543545
}
544546

545547
/**
546-
* Read a legacy name record from storage. Only used for alarms scheduled
547-
* before 2026-03-15, which fire without `ctx.id.name` populated on the
548-
* alarm handler. See:
549-
* https://developers.cloudflare.com/durable-objects/api/id/#name
548+
* Read the legacy `__ps_name` storage record as a fallback source of
549+
* `this.name` when `ctx.id.name` is unavailable. Covers:
550+
*
551+
* 1. Pre-2026-03-15 alarms, which fire without `ctx.id.name`
552+
* populated on the alarm handler (see the Durable Objects
553+
* ID docs: https://developers.cloudflare.com/durable-objects/api/id/#name).
554+
* 2. Framework-level bootstrap patterns that write `__ps_name`
555+
* directly before calling `__unsafe_ensureInitialized()` —
556+
* notably Cloudflare Agents facets, which are addressed via
557+
* `ctx.facets.get()` rather than `idFromName()` and therefore
558+
* do not receive a `ctx.id.name`.
559+
*
560+
* PartyServer no longer writes this record itself. Everything that
561+
* reads it is reading something written by an older version of
562+
* PartyServer or by a framework that embeds it.
550563
*/
551564
async #hydrateNameFromLegacyStorage(): Promise<void> {
552565
if (this.#_name) return;
@@ -569,6 +582,17 @@ export class Server<
569582

570583
async #ensureInitialized(): Promise<void> {
571584
if (this.#status === "started") return;
585+
586+
// Name resolution fallback. The happy path (DO addressed via
587+
// idFromName/getByName) short-circuits here because `ctx.id.name`
588+
// is already populated — no storage read. The slow path covers
589+
// pre-2026-03-15 alarms and framework bootstrap patterns (e.g.
590+
// Agents facets) that write `__ps_name` directly before the
591+
// first `onStart()` runs.
592+
if (!this.ctx.id.name && !this.#_name) {
593+
await this.#hydrateNameFromLegacyStorage();
594+
}
595+
572596
let error: unknown;
573597
await this.ctx.blockConcurrencyWhile(async () => {
574598
this.#status = "starting";
@@ -646,11 +670,19 @@ export class Server<
646670
}
647671

648672
/**
649-
* @deprecated The DO's name is available automatically via `ctx.id.name`
650-
* as long as the stub was created with `idFromName()`/`getByName()`.
651-
* Callers generally no longer need `setName()`; it is retained for
652-
* backward compatibility (including delivering initial `props` to
653-
* `onStart()`).
673+
* @deprecated for callers that address DOs via `idFromName()` /
674+
* `getByName()` — `this.name` is available automatically from
675+
* `ctx.id.name` and calling `setName()` is redundant.
676+
*
677+
* Still appropriate for two use cases:
678+
* 1. Delivering initial `props` to `onStart()` (via the optional
679+
* second argument).
680+
* 2. Framework-level bootstrap of non-`idFromName` DOs where
681+
* `ctx.id.name` is undefined — for example, Cloudflare Agents
682+
* facets. Calling `setName(name)` from inside such a DO stashes
683+
* the name in memory for the current instance; for
684+
* survival-across-eviction, write `__ps_name` to storage as
685+
* well.
654686
*/
655687
async setName(name: string, props?: Props) {
656688
if (!name) {
@@ -842,13 +874,6 @@ export class Server<
842874
}
843875

844876
async alarm(): Promise<void> {
845-
// Alarms scheduled before 2026-03-15 fire without `ctx.id.name`.
846-
// Recover the name from the legacy storage record if present so that
847-
// `this.name` / `onStart()` / `onAlarm()` keep working during the
848-
// upgrade window while those alarms drain.
849-
if (!this.ctx.id.name && !this.#_name) {
850-
await this.#hydrateNameFromLegacyStorage();
851-
}
852877
await this.#ensureInitialized();
853878
await this.onAlarm();
854879
}

packages/partyserver/src/tests/index.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,50 @@ describe("this.name in the constructor", () => {
699699
});
700700
});
701701

702+
describe("Framework bootstrap fallback (Agents facets etc.)", () => {
703+
it("hydrates this.name from __ps_name storage when ctx.id.name is undefined", async () => {
704+
// Regression guard for Cloudflare Agents facets. Facets are spawned
705+
// via `ctx.facets.get(...)`, not `idFromName()`, so their
706+
// `ctx.id.name` is `undefined`. Agents bootstraps the name by
707+
// writing `__ps_name` to storage and then calling
708+
// `__unsafe_ensureInitialized()`. PartyServer's
709+
// `#ensureInitialized()` must pick up the storage record as a
710+
// fallback so `onStart()` can read `this.name`.
711+
const id = env.FacetLikeBootstrapServer.newUniqueId();
712+
const stub = env.FacetLikeBootstrapServer.get(id);
713+
const result = await stub.bootstrap("facet-bootstrap-test");
714+
expect(result.onStartName).toBe("facet-bootstrap-test");
715+
716+
// Follow-up fetch must also see the hydrated name (the #_name
717+
// stashed during bootstrap survives in-memory as long as the DO
718+
// instance isn't evicted).
719+
const res = await stub.fetch(new Request("http://example.com/"));
720+
const data = (await res.json()) as {
721+
name: string;
722+
onStartName: string | null;
723+
};
724+
expect(data.name).toBe("facet-bootstrap-test");
725+
expect(data.onStartName).toBe("facet-bootstrap-test");
726+
});
727+
728+
it("recovers from cold-wake fetch by re-reading storage when ctx.id.name is undefined", async () => {
729+
// After the initial bootstrap, the DO may evict and come back
730+
// cold. `Server.fetch()` must still resolve `this.name` via the
731+
// storage fallback inside `#ensureInitialized()`, even without an
732+
// `x-partykit-room` header.
733+
const id = env.FacetLikeBootstrapServer.newUniqueId();
734+
const stub = env.FacetLikeBootstrapServer.get(id);
735+
await stub.bootstrap("facet-coldwake-test");
736+
737+
// Second, otherwise-unrelated fetch — no header, no bootstrap call.
738+
// Simulates a later request arriving at the same DO.
739+
const res = await stub.fetch(new Request("http://example.com/"));
740+
expect(res.status).toBe(200);
741+
const data = (await res.json()) as { name: string };
742+
expect(data.name).toBe("facet-coldwake-test");
743+
});
744+
});
745+
702746
describe("Legacy fallbacks", () => {
703747
it("reads __ps_name from storage when ctx.id.name is undefined in an alarm", async () => {
704748
// Simulates the pre-2026-03-15 alarm migration scenario: an alarm was

packages/partyserver/src/tests/worker.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type Env = {
1515
AlarmServer: DurableObjectNamespace<AlarmServer>;
1616
AlarmNameServer: DurableObjectNamespace<AlarmNameServer>;
1717
NoNameServer: DurableObjectNamespace<NoNameServer>;
18+
FacetLikeBootstrapServer: DurableObjectNamespace<FacetLikeBootstrapServer>;
1819
NameInConstructorServer: DurableObjectNamespace<NameInConstructorServer>;
1920
Mixed: DurableObjectNamespace<Mixed>;
2021
ConfigurableState: DurableObjectNamespace<ConfigurableState>;
@@ -256,6 +257,41 @@ export class NoNameServer extends Server {
256257
}
257258
}
258259

260+
/**
261+
* Simulates a framework-level bootstrap pattern (e.g. Cloudflare Agents
262+
* facets) where the DO is NOT addressed via `idFromName()` — so
263+
* `ctx.id.name` is undefined — but the framework writes `__ps_name` to
264+
* storage directly and then triggers `onStart()`. PartyServer must pick
265+
* up the legacy storage record as a fallback so `onStart()` and
266+
* subsequent handlers can read `this.name`.
267+
*/
268+
export class FacetLikeBootstrapServer extends Server {
269+
static options = { hibernate: true };
270+
271+
onStartName: string | null = null;
272+
273+
async onStart() {
274+
try {
275+
this.onStartName = this.name;
276+
} catch {
277+
this.onStartName = null;
278+
}
279+
}
280+
281+
async bootstrap(name: string): Promise<{ onStartName: string | null }> {
282+
await this.ctx.storage.put("__ps_name", name);
283+
await this.__unsafe_ensureInitialized();
284+
return { onStartName: this.onStartName };
285+
}
286+
287+
onRequest(): Response {
288+
return Response.json({
289+
name: this.name,
290+
onStartName: this.onStartName
291+
});
292+
}
293+
}
294+
259295
/**
260296
* Regression guard for the headline Phase 1 capability: `this.name` must
261297
* be readable inside the constructor and from class field initializers,

packages/partyserver/src/tests/wrangler.jsonc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@
8787
"name": "PropsServer",
8888
"class_name": "PropsServer"
8989
},
90+
{
91+
"name": "FacetLikeBootstrapServer",
92+
"class_name": "FacetLikeBootstrapServer"
93+
},
9094
{
9195
"name": "NameInConstructorServer",
9296
"class_name": "NameInConstructorServer"
@@ -116,6 +120,7 @@
116120
"UriServer",
117121
"UriServerInMemory",
118122
"PropsServer",
123+
"FacetLikeBootstrapServer",
119124
"NameInConstructorServer"
120125
]
121126
}

0 commit comments

Comments
 (0)