Skip to content
Merged
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
14 changes: 14 additions & 0 deletions .changeset/fix-facet-hydrate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"partyserver": patch
---

Fix: restore legacy `__ps_name` storage fallback for framework bootstrap patterns.

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()`.

Changes:

- 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).
- `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.
- `Server.alarm()` is simplified — it no longer needs its own hydrate call since `#ensureInitialized()` handles it.
- `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.
2 changes: 0 additions & 2 deletions packages/hono-party/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
Durable Objects now expose `ctx.id.name` on every entry point (constructor, fetch, alarm, hibernating websocket handlers) when the DO is addressed via `idFromName()`/`getByName()`. PartyServer now uses this as the primary source of `this.name`, which simplifies routing, eliminates storage writes, and makes `this.name` available inside the constructor.

Changes in `partyserver`:

- `this.name` resolves from `this.ctx.id.name`. The apologetic `workerd#2240` error message is gone.
- `this.name` is now available **inside the constructor** and from class field initializers, not just after `setName()`/`fetch()` has run.
- `routePartykitRequest` no longer issues a `setName()`/`_initAndFetch()` RPC before `fetch()`. The WebSocket path goes from 2 RPCs to 1; the HTTP path remains 1 RPC. Props, when supplied, are delivered to the DO via the `x-partykit-props` request header, set after `onBeforeConnect`/`onBeforeRequest` hooks run.
Expand All @@ -21,7 +20,6 @@
- When reading `this.name` throws, it is because `ctx.id.name` is undefined and no legacy fallback has populated the name: the DO was addressed via `idFromString()` or `newUniqueId()` (both unsupported), the runtime is too old to expose `ctx.id.name`, or a pre-2026-03-15 alarm fired before the legacy storage fallback ran.

Changes in all affected packages (`partyserver`, `partysub`, `partysync`, `y-partyserver`, `hono-party`):

- `@cloudflare/workers-types` peer dependency bumped from `^4.20240729.0` to `^4.20260424.1`. The old range predates `ctx.id.name` in the type surface.

Not supported: addressing PartyServer DOs via `idFromString()` or `newUniqueId()`. These paths return `ctx.id.name === undefined` inside the DO and will surface as a clear error from `this.name`. PartyServer has always assumed name-based addressing via `getServerByName` / `routePartykitRequest`; this release makes that assumption explicit.
Expand Down
4 changes: 0 additions & 4 deletions packages/partyserver/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
Durable Objects now expose `ctx.id.name` on every entry point (constructor, fetch, alarm, hibernating websocket handlers) when the DO is addressed via `idFromName()`/`getByName()`. PartyServer now uses this as the primary source of `this.name`, which simplifies routing, eliminates storage writes, and makes `this.name` available inside the constructor.

Changes in `partyserver`:

- `this.name` resolves from `this.ctx.id.name`. The apologetic `workerd#2240` error message is gone.
- `this.name` is now available **inside the constructor** and from class field initializers, not just after `setName()`/`fetch()` has run.
- `routePartykitRequest` no longer issues a `setName()`/`_initAndFetch()` RPC before `fetch()`. The WebSocket path goes from 2 RPCs to 1; the HTTP path remains 1 RPC. Props, when supplied, are delivered to the DO via the `x-partykit-props` request header, set after `onBeforeConnect`/`onBeforeRequest` hooks run.
Expand All @@ -21,7 +20,6 @@
- When reading `this.name` throws, it is because `ctx.id.name` is undefined and no legacy fallback has populated the name: the DO was addressed via `idFromString()` or `newUniqueId()` (both unsupported), the runtime is too old to expose `ctx.id.name`, or a pre-2026-03-15 alarm fired before the legacy storage fallback ran.

Changes in all affected packages (`partyserver`, `partysub`, `partysync`, `y-partyserver`, `hono-party`):

- `@cloudflare/workers-types` peer dependency bumped from `^4.20240729.0` to `^4.20260424.1`. The old range predates `ctx.id.name` in the type surface.

Not supported: addressing PartyServer DOs via `idFromString()` or `newUniqueId()`. These paths return `ctx.id.name === undefined` inside the DO and will surface as a clear error from `this.name`. PartyServer has always assumed name-based addressing via `getServerByName` / `routePartykitRequest`; this release makes that assumption explicit.
Expand Down Expand Up @@ -342,14 +340,12 @@
### Patch Changes

- [`528adea`](https://github.com/threepointone/partyserver/commit/528adeaced6dce6e888d2f54cc75c3569bf2c277) Thanks [@threepointone](https://github.com/threepointone)! - some fixes and tweaks

- getServerByName was throwing on all requests
- `Env` is now an optional arg when defining `Server`
- `y-partyserver/provider` can now take an optional `prefix` arg to use a custom url to connect
- `routePartyKitRequest`/`getServerByName` now accepts `jurisdiction`

bonus:

- added a bunch of fixtures
- added stubs for docs

Expand Down
79 changes: 52 additions & 27 deletions packages/partyserver/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,24 +389,26 @@ export class Server<
this.#_props = JSON.parse(props);
}

// Legacy fallback for callers that bypass `routePartykitRequest`/
// `getServerByName` and invoke `stub.fetch()` directly with the
// `x-partykit-room` header. When the DO was addressed via
// `idFromName()`/`getByName()`, `ctx.id.name` provides the name and
// this branch is a no-op.
// Name resolution priority: ctx.id.name > x-partykit-room header
// > legacy __ps_name storage record. Pre-populate from the header
// BEFORE `#ensureInitialized()` so that `onStart()` sees the name
// regardless of how it was supplied. `#ensureInitialized()` will
// fall back to reading storage when neither ctx.id.name nor the
// header has provided one.
if (!this.ctx.id.name && !this.#_name) {
const room = request.headers.get("x-partykit-room");
if (!room) {
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:
if (room) this.#_name = room;
}

await this.#ensureInitialized();

if (!this.ctx.id.name && !this.#_name) {
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:
1. The stub was built via idFromString()/newUniqueId(). PartyServer requires name-based addressing (idFromName/getByName).
2. The workerd/wrangler runtime is too old to expose ctx.id.name — update to a recent wrangler release.
3. You called stub.fetch() directly without going through routePartykitRequest()/getServerByName(). Prefer those, or set the x-partykit-room header.`);
}
this.#_name = room;
}

await this.#ensureInitialized();

const url = new URL(request.url);

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

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

async #ensureInitialized(): Promise<void> {
if (this.#status === "started") return;

// Name resolution fallback. The happy path (DO addressed via
// idFromName/getByName) short-circuits here because `ctx.id.name`
// is already populated — no storage read. The slow path covers
// pre-2026-03-15 alarms and framework bootstrap patterns (e.g.
// Agents facets) that write `__ps_name` directly before the
// first `onStart()` runs.
if (!this.ctx.id.name && !this.#_name) {
await this.#hydrateNameFromLegacyStorage();
}

let error: unknown;
await this.ctx.blockConcurrencyWhile(async () => {
this.#status = "starting";
Expand Down Expand Up @@ -646,11 +670,19 @@ export class Server<
}

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

async alarm(): Promise<void> {
// Alarms scheduled before 2026-03-15 fire without `ctx.id.name`.
// Recover the name from the legacy storage record if present so that
// `this.name` / `onStart()` / `onAlarm()` keep working during the
// upgrade window while those alarms drain.
if (!this.ctx.id.name && !this.#_name) {
await this.#hydrateNameFromLegacyStorage();
}
await this.#ensureInitialized();
await this.onAlarm();
}
Expand Down
67 changes: 67 additions & 0 deletions packages/partyserver/src/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,73 @@ describe("this.name in the constructor", () => {
});
});

describe("x-partykit-room header applied before onStart", () => {
it("populates this.name from header before onStart runs", async () => {
// Regression guard: for DOs with `ctx.id.name === undefined` where
// the caller supplies the name via `x-partykit-room`, the header
// must be applied BEFORE `#ensureInitialized()` runs `onStart()`.
// Otherwise an onStart that reads `this.name` would throw.
const id = env.HeaderOnlyOnStartServer.newUniqueId();
const stub = env.HeaderOnlyOnStartServer.get(id);
const res = await stub.fetch(
new Request("http://example.com/", {
headers: { "x-partykit-room": "header-onstart-test" }
})
);
expect(res.status).toBe(200);
const data = (await res.json()) as {
name: string;
onStartName: string | null;
};
expect(data.name).toBe("header-onstart-test");
expect(data.onStartName).toBe("header-onstart-test");
});
});

describe("Framework bootstrap fallback (Agents facets etc.)", () => {
it("hydrates this.name from __ps_name storage when ctx.id.name is undefined", async () => {
// Regression guard for Cloudflare Agents facets. Facets are spawned
// via `ctx.facets.get(...)`, not `idFromName()`, so their
// `ctx.id.name` is `undefined`. Agents bootstraps the name by
// writing `__ps_name` to storage and then calling
// `__unsafe_ensureInitialized()`. PartyServer's
// `#ensureInitialized()` must pick up the storage record as a
// fallback so `onStart()` can read `this.name`.
const id = env.FacetLikeBootstrapServer.newUniqueId();
const stub = env.FacetLikeBootstrapServer.get(id);
const result = await stub.bootstrap("facet-bootstrap-test");
expect(result.onStartName).toBe("facet-bootstrap-test");

// Follow-up fetch must also see the hydrated name (the #_name
// stashed during bootstrap survives in-memory as long as the DO
// instance isn't evicted).
const res = await stub.fetch(new Request("http://example.com/"));
const data = (await res.json()) as {
name: string;
onStartName: string | null;
};
expect(data.name).toBe("facet-bootstrap-test");
expect(data.onStartName).toBe("facet-bootstrap-test");
});

it("recovers from cold-wake fetch by re-reading storage when ctx.id.name is undefined", async () => {
// After the initial bootstrap, the DO may evict and come back
// cold. `Server.fetch()` must still resolve `this.name` via the
// storage fallback inside `#ensureInitialized()`, even without an
// `x-partykit-room` header.
const id = env.FacetLikeBootstrapServer.newUniqueId();
const stub = env.FacetLikeBootstrapServer.get(id);
await stub.bootstrap("facet-coldwake-test");

// Second, otherwise-unrelated fetch — no header, no bootstrap call.
// Simulates a later request arriving at the same DO.
const res = await stub.fetch(new Request("http://example.com/"));
expect(res.status).toBe(200);
const data = (await res.json()) as { name: string };
expect(data.name).toBe("facet-coldwake-test");
});
});

describe("Legacy fallbacks", () => {
it("reads __ps_name from storage when ctx.id.name is undefined in an alarm", async () => {
// Simulates the pre-2026-03-15 alarm migration scenario: an alarm was
Expand Down
60 changes: 60 additions & 0 deletions packages/partyserver/src/tests/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export type Env = {
AlarmServer: DurableObjectNamespace<AlarmServer>;
AlarmNameServer: DurableObjectNamespace<AlarmNameServer>;
NoNameServer: DurableObjectNamespace<NoNameServer>;
HeaderOnlyOnStartServer: DurableObjectNamespace<HeaderOnlyOnStartServer>;
FacetLikeBootstrapServer: DurableObjectNamespace<FacetLikeBootstrapServer>;
NameInConstructorServer: DurableObjectNamespace<NameInConstructorServer>;
Mixed: DurableObjectNamespace<Mixed>;
ConfigurableState: DurableObjectNamespace<ConfigurableState>;
Expand Down Expand Up @@ -256,6 +258,64 @@ export class NoNameServer extends Server {
}
}

/**
* Regression guard: DO with `ctx.id.name === undefined` (because it was
* addressed via `newUniqueId()`) whose `onStart()` reads `this.name`.
* When the caller supplies the name via the `x-partykit-room` header,
* `Server.fetch()` must apply the header BEFORE running `onStart()`, so
* `this.name` is readable during `onStart()`.
*/
export class HeaderOnlyOnStartServer extends Server {
onStartName: string | null = null;

async onStart() {
// Throws if `this.name` isn't resolvable here.
this.onStartName = this.name;
}

onRequest(): Response {
return Response.json({
name: this.name,
onStartName: this.onStartName
});
}
}

/**
* Simulates a framework-level bootstrap pattern (e.g. Cloudflare Agents
* facets) where the DO is NOT addressed via `idFromName()` — so
* `ctx.id.name` is undefined — but the framework writes `__ps_name` to
* storage directly and then triggers `onStart()`. PartyServer must pick
* up the legacy storage record as a fallback so `onStart()` and
* subsequent handlers can read `this.name`.
*/
export class FacetLikeBootstrapServer extends Server {
static options = { hibernate: true };

onStartName: string | null = null;

async onStart() {
try {
this.onStartName = this.name;
} catch {
this.onStartName = null;
}
}

async bootstrap(name: string): Promise<{ onStartName: string | null }> {
await this.ctx.storage.put("__ps_name", name);
await this.__unsafe_ensureInitialized();
return { onStartName: this.onStartName };
}

onRequest(): Response {
return Response.json({
name: this.name,
onStartName: this.onStartName
});
}
}

/**
* Regression guard for the headline Phase 1 capability: `this.name` must
* be readable inside the constructor and from class field initializers,
Expand Down
10 changes: 10 additions & 0 deletions packages/partyserver/src/tests/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@
"name": "PropsServer",
"class_name": "PropsServer"
},
{
"name": "HeaderOnlyOnStartServer",
"class_name": "HeaderOnlyOnStartServer"
},
{
"name": "FacetLikeBootstrapServer",
"class_name": "FacetLikeBootstrapServer"
},
{
"name": "NameInConstructorServer",
"class_name": "NameInConstructorServer"
Expand Down Expand Up @@ -116,6 +124,8 @@
"UriServer",
"UriServerInMemory",
"PropsServer",
"HeaderOnlyOnStartServer",
"FacetLikeBootstrapServer",
"NameInConstructorServer"
]
}
Expand Down
2 changes: 0 additions & 2 deletions packages/partysub/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
Durable Objects now expose `ctx.id.name` on every entry point (constructor, fetch, alarm, hibernating websocket handlers) when the DO is addressed via `idFromName()`/`getByName()`. PartyServer now uses this as the primary source of `this.name`, which simplifies routing, eliminates storage writes, and makes `this.name` available inside the constructor.

Changes in `partyserver`:

- `this.name` resolves from `this.ctx.id.name`. The apologetic `workerd#2240` error message is gone.
- `this.name` is now available **inside the constructor** and from class field initializers, not just after `setName()`/`fetch()` has run.
- `routePartykitRequest` no longer issues a `setName()`/`_initAndFetch()` RPC before `fetch()`. The WebSocket path goes from 2 RPCs to 1; the HTTP path remains 1 RPC. Props, when supplied, are delivered to the DO via the `x-partykit-props` request header, set after `onBeforeConnect`/`onBeforeRequest` hooks run.
Expand All @@ -21,7 +20,6 @@
- When reading `this.name` throws, it is because `ctx.id.name` is undefined and no legacy fallback has populated the name: the DO was addressed via `idFromString()` or `newUniqueId()` (both unsupported), the runtime is too old to expose `ctx.id.name`, or a pre-2026-03-15 alarm fired before the legacy storage fallback ran.

Changes in all affected packages (`partyserver`, `partysub`, `partysync`, `y-partyserver`, `hono-party`):

- `@cloudflare/workers-types` peer dependency bumped from `^4.20240729.0` to `^4.20260424.1`. The old range predates `ctx.id.name` in the type surface.

Not supported: addressing PartyServer DOs via `idFromString()` or `newUniqueId()`. These paths return `ctx.id.name === undefined` inside the DO and will surface as a clear error from `this.name`. PartyServer has always assumed name-based addressing via `getServerByName` / `routePartykitRequest`; this release makes that assumption explicit.
Expand Down
Loading
Loading