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
14 changes: 14 additions & 0 deletions .changeset/spotty-lions-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"partyserver": minor
---

Stop persisting the `__ps_name` self-heal fallback record on named-access initialization; deprecate the `setName()` name-bootstrap role and the `x-partykit-room` header fallback; document the native `ctx.id.name` availability matrix

`ctx.id.name` has been populated natively inside Durable Objects addressed via `idFromName()`/`getByName()` since 2026-03-15 (https://developers.cloudflare.com/changelog/post/2026-03-15-durable-object-id-name/), and production verification on a worker pinned to `compatibility_date` 2024-06-01 (i.e. the behavior is not compat-date gated) confirmed the name is present in the constructor, on hibernating-WebSocket message wakeups, and in alarm handlers firing on cold instances after eviction. PartyServer therefore no longer writes a `__ps_name` fallback copy of the native name during initialization.

What stays:

- the legacy `__ps_name` **read** — records written by older partyserver versions (covering e.g. pre-2026-03-15 alarm records) or by the `setName()` bootstrap are still honored when `ctx.id.name` is undefined;
- the `setName()` bootstrap **write** for raw-ID DOs (`idFromString()`/`newUniqueId()`) — now deprecated, to become an error in a future major version;
- the `x-partykit-room` header fallback read — now deprecated;
- `setName()` itself — it remains the onStart-synchronization + `props`-delivery channel used by `getServerByName()` on every resolution.
15 changes: 14 additions & 1 deletion fixtures/alarm-restart-e2e/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
# `alarm-restart-e2e`

Reproducer for the runtime contract that motivates partyserver's
Reproducer for the runtime contract that motivated partyserver's
`__ps_name` fallback record. Pins down behavior reported in
[cloudflare/partykit#390](https://github.com/cloudflare/partykit/issues/390)
across three Durable Objects in the same Worker:

> **Status (historical):** the workspace partyserver no longer WRITES
> the `__ps_name` record during named-access initialization — the
> local-dev alarm-name gap this fixture reproduces was fixed in workerd
> (the alarm scheduler persists the actor name since
> `workerd@1.20260703.1` / wrangler 4.108.0, see
> [cloudflare/workerd#6850](https://github.com/cloudflare/workerd/issues/6850)),
> and production behavior was verified on a worker pinned to
> `compatibility_date: "2024-06-01"`. The legacy `__ps_name` READ
> remains, so the `FixedAlarm` recovery described below now requires a
> pre-existing record (seeded manually, or written by an earlier
> partyserver release that still had the self-heal write). On current
> tooling all three DOs see `ctx.id.name` in `alarm()` natively.

| DO | Class | Extends |
| ------------ | --------------------------------- | -------------------------------------------------------------------------- |
| `RawAlarm` | `RawAlarm` | `DurableObject` (no PartyServer) |
Expand Down
4 changes: 2 additions & 2 deletions packages/partyserver/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,9 @@ These methods can be optionally `async`:

## Properties

- `.name` - (readonly) the server's name. Resolves from the underlying Durable Object's `ctx.id.name`, populated whenever the DO is addressed via `idFromName()` / `getByName()` — which is the supported way to address PartyServer DOs. Available inside every entry point including the constructor, `onStart()`, `onAlarm()`, and hibernating websocket handlers. PartyServer also persists this name to a small `__ps_name` fallback record during initialization so that alarm handlers firing on stale on-disk alarm records (scheduled by older workerd versions that didn't persist `name`) can still recover the name. Legacy DOs bootstrapped via `setName()` use the same fallback.
- `.name` - (readonly) the server's name. Resolves from the underlying Durable Object's `ctx.id.name`, which the Cloudflare runtime populates natively (since 2026-03-15, see the [changelog](https://developers.cloudflare.com/changelog/post/2026-03-15-durable-object-id-name/) and the [DO id docs](https://developers.cloudflare.com/durable-objects/api/id/#name)) whenever the DO is addressed via `idFromName()` / `getByName()` — which is the supported way to address PartyServer DOs. Available inside the constructor, class field initializers, `onStart()`, and `onAlarm()` (subject to the alarm-scheduling caveats below). Constructor availability isn't spelled out in the docs — it's pinned by this package's test suite and workerd's own tests ([cloudflare/workerd#6421](https://github.com/cloudflare/workerd/pull/6421)). Availability on hibernating-websocket wakeups isn't documented either — it's pinned by production verification: a hibernated instance woken by a WebSocket message sees `ctx.id.name` in both the constructor and `webSocketMessage`, on a worker pinned to `compatibility_date` 2024-06-01 (i.e. the behavior isn't compat-date gated). PartyServer no longer persists a `__ps_name` fallback copy of the native name during initialization; the legacy `__ps_name` record is still read whenever `ctx.id.name` is undefined, covering records written by older partyserver versions and DOs bootstrapped via `setName()`.

DOs addressed via `idFromString()` or `newUniqueId()` without a `setName()` bootstrap are not supported and `.name` will throw.
`ctx.id.name` is undefined — by design, per the [DO id docs](https://developers.cloudflare.com/durable-objects/api/id/#name) — for DOs addressed via `idFromString()` (even if the ID was originally created with `idFromName()`) or `newUniqueId()`, for names longer than 1,024 bytes, and inside alarm handlers for alarms scheduled before 2026-03-15. Alarms only carry the name when they were scheduled from a context where `ctx.id.name` was itself available — an alarm rescheduled from a nameless handler also fires without a name (a legacy `__ps_name` record — written by an older partyserver version or a `setName()` bootstrap — covers these alarm cases where one exists; otherwise reschedule the alarm from a fetch/RPC handler where the name is available). DOs addressed via `idFromString()` / `newUniqueId()` without a `setName()` bootstrap are not supported and `.name` will throw.

- `.ctx` - the context object for the Durable Object, containing references to [`storage`](https://developers.cloudflare.com/durable-objects/api/transactional-storage-api/)

Expand Down
144 changes: 95 additions & 49 deletions packages/partyserver/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,15 @@ export class Server<
// regardless of how it was supplied. `#ensureInitialized()` will
// fall back to reading storage when neither ctx.id.name nor the
// header has provided one.
//
// DEPRECATED: the `x-partykit-room` header fallback is a legacy
// name source for raw `stub.fetch()` callers and old clients, and
// will be removed in a future major version. Address servers via
// `idFromName()`/`getByName()` (routePartykitRequest and
// getServerByName already do) — the runtime populates
// `ctx.id.name` natively inside the DO since 2026-03-15:
// https://developers.cloudflare.com/changelog/post/2026-03-15-durable-object-id-name/
// https://developers.cloudflare.com/durable-objects/api/id/#name
if (!this.ctx.id.name && !this.#_name) {
const room = request.headers.get("x-partykit-room");
if (room) this.#_name = room;
Expand All @@ -667,7 +676,7 @@ export class Server<
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.`);
3. You called stub.fetch() directly without going through routePartykitRequest()/getServerByName(). Prefer those; the x-partykit-room header fallback is deprecated.`);
}

const url = new URL(request.url);
Expand Down Expand Up @@ -841,6 +850,14 @@ export class Server<
* `__ps_name` directly (or call `setName()`) before triggering
* `__unsafe_ensureInitialized()` — typically DOs addressed via
* `idFromString()` / `newUniqueId()` plus a name override.
*
* PartyServer no longer WRITES this record during named-access
* initialization: `ctx.id.name` is populated natively across cold
* starts, hibernating-WebSocket wakeups, and alarm wakeups after
* eviction (verified in production on a worker pinned to
* `compatibility_date` 2024-06-01, so the behavior is not compat-date
* gated). Existing records — written by older partyserver versions or
* by the `setName()` bootstrap — are honored on read.
*/
async #hydrateNameFromLegacyStorage(): Promise<void> {
if (this.#_name) return;
Expand All @@ -850,17 +867,6 @@ export class Server<
}
}

async #persistNameFallbackFromCtxId(): Promise<void> {
const ctxName = this.ctx.id.name;
if (ctxName === undefined || this.#_name) return;

const stored = await this.ctx.storage.get<string>(NAME_STORAGE_KEY);
if (stored !== ctxName) {
await this.ctx.storage.put(NAME_STORAGE_KEY, ctxName);
}
this.#_name = ctxName;
}

/**
* @internal — Do not use directly. This is an escape hatch for frameworks
* (like Agents) that receive calls via native DO RPC, bypassing the
Expand All @@ -875,15 +881,18 @@ export class Server<
async #ensureInitialized(): Promise<void> {
if (this.#status === "started") return;

// Persist a fallback record for name-based DOs before user startup
// code can schedule alarms. Current workerd populates `ctx.id.name`
// in alarm handlers, but stale on-disk alarm records scheduled by
// older workerd versions do not, and we want recovery from those
// without requiring users to wipe `.wrangler/state` or to reschedule
// alarms from a fetch handler. See cloudflare/partykit#390.
if (this.ctx.id.name !== undefined) {
await this.#persistNameFallbackFromCtxId();
} else if (!this.#_name) {
// When the runtime provides no native name (raw-ID addressing, or
// an alarm record scheduled before 2026-03-15), fall back to the
// legacy `__ps_name` storage record. When `ctx.id.name` IS defined
// there is nothing to do: PartyServer no longer persists a
// `__ps_name` fallback copy of the native name — production
// verification on a worker pinned to `compatibility_date`
// 2024-06-01 (i.e. ungated by compat date) confirmed `ctx.id.name`
// is populated in the constructor, on hibernating-WebSocket
// wakeups, and in alarm handlers firing on cold instances after
// eviction. Records written by older partyserver versions (or by
// the `setName()` bootstrap) are still honored on read.
if (this.ctx.id.name === undefined && !this.#_name) {
await this.#hydrateNameFromLegacyStorage();
}

Expand Down Expand Up @@ -973,46 +982,73 @@ export class Server<
/**
* The name for this server.
*
* Resolves from `this.ctx.id.name` — the native DO id name, populated
* whenever the stub was created via `idFromName()` or `getByName()`.
* This is available inside every entry point (including the constructor,
* alarms, and hibernating websocket handlers).
* Resolves from `this.ctx.id.name` — the native DO id name, which the
* runtime populates since 2026-03-15
* (https://developers.cloudflare.com/changelog/post/2026-03-15-durable-object-id-name/).
* Availability matrix:
*
* For alarm handlers firing on stale on-disk alarm records from
* older workerd versions that didn't persist `name` into the alarm
* record, the name is recovered from a storage fallback record.
* - `idFromName()` / `getByName()`: POPULATED. Availability inside
* the constructor and class field initializers isn't spelled out
* in the docs — it is pinned by workerd's own tests
* (https://github.com/cloudflare/workerd/pull/6421) and this
* repo's `NameInConstructorServer` / "Raw runtime contract"
* tests. Availability on hibernating-WebSocket wakeups is not
* documented either — it is pinned by production verification
* (a hibernated instance woken by a WebSocket message sees
* `ctx.id.name` in both the constructor and `webSocketMessage`,
* on a worker pinned to `compatibility_date` 2024-06-01).
* - `idFromString()`: UNDEFINED, permanently, by design (per
* https://developers.cloudflare.com/durable-objects/api/id/#name)
* — even if the ID was originally created with `idFromName()`.
* - `newUniqueId()`: UNDEFINED.
* - Names longer than 1,024 bytes: UNDEFINED (not passed through
* to `ctx.id`).
* - Alarms: POPULATED only for alarms scheduled on or after
* 2026-03-15 from a context where `ctx.id.name` was itself
* available. Alarms scheduled before 2026-03-15 fire with it
* UNDEFINED (the on-disk alarm record carries no name), and per
* the DO id docs an alarm rescheduled from such a nameless
* handler also fires without a name. Where a legacy `__ps_name`
* record exists (written by an older partyserver version or a
* `setName()` bootstrap), PartyServer recovers the name from it
* in both cases; otherwise, per the DO id docs, reschedule the
* alarm from a fetch/RPC handler where the name is available.
*
* Throws if neither source is available — typically this means the DO
* was addressed via `idFromString()` or `newUniqueId()`, which is not
* supported by PartyServer.
* When `ctx.id.name` is undefined, falls back to the in-memory /
* stored name (`setName()` bootstrap or `__ps_name` record). Throws
* if neither source is available — typically this means the DO was
* addressed via `idFromString()` or `newUniqueId()` without a
* bootstrap, which is not supported by PartyServer.
*/
get name(): string {
const ctxName = this.ctx.id.name;
if (ctxName !== undefined) return ctxName;
if (this.#_name) return this.#_name;
throw new Error(
`Attempting to read .name on ${this.#ParentClass.name}, but this.ctx.id.name is not set and no ${NAME_STORAGE_KEY} fallback record is available. PartyServer requires DOs to be addressed via idFromName()/getByName(), or explicitly bootstrapped with setName() when using idFromString()/newUniqueId(). If this happens in an alarm handler firing on a stale alarm record, initialize the DO from a fetch/RPC entry point first so PartyServer can persist the fallback name.`
`Attempting to read .name on ${this.#ParentClass.name}, but this.ctx.id.name is not set and no ${NAME_STORAGE_KEY} fallback record is available. PartyServer requires DOs to be addressed via idFromName()/getByName(), or explicitly bootstrapped with setName() when using idFromString()/newUniqueId(). If this happens in an alarm handler firing on a stale (pre-2026-03-15) alarm record, reschedule the alarm from a fetch or RPC handler where ctx.id.name is available.`
);
}

/**
* Establish this server's name and trigger `onStart()`.
*
* Use cases:
* Two roles:
*
* 1. **Framework-level bootstrap of DOs where `ctx.id.name` is
* undefined** — e.g. DOs addressed via `idFromString()` /
* `newUniqueId()`. `setName()` stashes the name in memory and
* persists it under `__ps_name` so cold-wake invocations
* recover it via `#ensureInitialized()`'s legacy fallback.
* 2. **Delivering initial `props` to `onStart()`** via the
* optional second argument.
* 1. **onStart synchronization + `props` delivery** — `getServerByName()`
* calls this on every resolution so that `onStart()` has completed
* (and received `props`) before user-defined RPC methods run.
* This role is NOT deprecated; the method stays.
* 2. **Legacy name establishment for raw-ID DOs** — DOs addressed
* via `idFromString()` / `newUniqueId()` have no `ctx.id.name`,
* so `setName()` stashes the name in memory and persists it
* under `__ps_name` so cold-wake invocations recover it via
* `#ensureInitialized()`'s legacy fallback. This role is
* DEPRECATED — see the `@deprecated` note below.
*
* For DOs addressed via `idFromName()` / `getByName()`, calling
* `setName()` is redundant — `this.name` is available automatically
* from `ctx.id.name`. The normal initialization path also persists
* a fallback record so old-compat alarm handlers can recover the name.
* Throws if `name` does not match `ctx.id.name`.
* For DOs addressed via `idFromName()` / `getByName()`, `this.name`
* is available automatically from `ctx.id.name`, so the name argument
* is purely a consistency check — nothing is persisted. Throws if
* `name` does not match `ctx.id.name`.
*
* **Not appropriate for facets.** Cloudflare Agents and any other
* framework using `ctx.facets.get(...)` should pass an explicit
Expand All @@ -1033,10 +1069,20 @@ export class Server<
* https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/
* for the `FacetStartupOptions.id` semantics.
*
* @deprecated for callers that address DOs via `idFromName()` /
* `getByName()`. Still the supported API for framework-level
* bootstrap of header/`newUniqueId`-addressed DOs and for
* delivering initial `props` to `onStart()`.
* @deprecated Using `setName()` to ESTABLISH a name — the bootstrap
* for DOs addressed via `idFromString()` / `newUniqueId()` — is
* deprecated and will become an error in a future major version.
* Address servers via `idFromName()` / `getByName()` instead: the
* runtime populates `ctx.id.name` natively inside the DO since
* 2026-03-15, so no name needs to be passed through arguments or
* persisted to storage. See
* https://developers.cloudflare.com/changelog/post/2026-03-15-durable-object-id-name/
* and
* https://developers.cloudflare.com/durable-objects/api/id/#name.
*
* The onStart-synchronization + `props`-delivery role (used by
* `getServerByName()` on every resolution) is NOT deprecated; the
* method itself stays.
*/
async setName(name: string, props?: Props) {
if (!name) {
Expand Down
Loading
Loading