Skip to content

Commit 8a3bc02

Browse files
committed
Document facet bootstrap pattern, no runtime change
Adds README + setName() docstring guidance pointing facet users at the explicit FacetStartupOptions.id pattern, plus an end-to-end test fixture (FacetParent / FacetChild) that pins both the implicit-id workerd contract and the recommended explicit-id behavior. Per the Cloudflare facet docs, passing `id: someBoundDoNamespace.idFromName(facetName)` to ctx.facets.get() gives the facet its own ctx.id.name, so partyserver's existing name getter does the right thing without any setName/__ps_name override mechanism. The runtime is byte-identical to 0.5.2. Also bumps sibling packages' devDependency on partyserver from ^0.5.1 to ^0.5.2 for consistency, and tidies CHANGELOG formatting. Made-with: Cursor
1 parent 2684ca8 commit 8a3bc02

13 files changed

Lines changed: 408 additions & 39 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"partyserver": patch
3+
---
4+
5+
Document and test the supported pattern for using PartyServer with [Durable Object Facets](https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/). No runtime behavior change.
6+
7+
**Background.** Facets spawned via `ctx.facets.get(name, factory)` _without_ an explicit `id` in `FacetStartupOptions` inherit the parent DO's `ctx.id` — including `ctx.id.name`. PartyServer's `name` getter reads `ctx.id.name` straight through, so on an implicit-id facet `this.name` returns the _parent's_ name rather than the facet's logical name. This is a faithful reflection of the workerd contract, but it's almost never what framework authors expect.
8+
9+
The fix is at the call site, not in PartyServer: pass `id: someBoundDONamespace.idFromName(facetName)` to `ctx.facets.get(...)`. The facet then gets its own native `ctx.id.name === facetName` and PartyServer's `name` getter does the right thing automatically. No `setName()` is required, no `__ps_name` storage record is written, and cold-wake recovery happens for free because the factory re-runs and `idFromName` is deterministic.
10+
11+
This release adds:
12+
13+
- **A "Using PartyServer with Durable Object Facets" section in the README** that walks through the recommended pattern with a code example, calls out the implicit-id footgun explicitly, and documents that plain-string `id` values are not a substitute for `idFromName(facetName)` (workerd treats string ids as `idFromString`-like, so the resulting facet has no `ctx.id.name`).
14+
- **`setName()` docstring updated** to clarify that facets are NOT a `setName()` use case — point to the explicit-`id` pattern instead. The original `setName()` `ctx.id.name` mismatch throw is preserved as a typo guard for the `idFromName` happy path.
15+
- **End-to-end facet test coverage** against the real workerd `ctx.facets.get(...)` API. A `FacetParent` / `FacetChild` fixture exercises both the implicit-id path (pinning the runtime contract that `this.name` returns the parent's name in that flow — i.e., behavior-as-documentation so framework authors are unsurprised) and the explicit-id path (recommended; verifies that all reasonable id-construction strategies work and that cold wake recovers without any storage record). Plain-string `id` is also tested; the test asserts it does NOT carry a name, pinning the contract so callers don't get tempted by the type signature.
16+
17+
The runtime behavior of `Server` (the `name` getter, `setName()`, the legacy `__ps_name` hydrate inside `#ensureInitialized()`) is unchanged from 0.5.2.

package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/hono-party/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,6 @@
3737
"devDependencies": {
3838
"@cloudflare/workers-types": "^4.20260424.1",
3939
"hono": "^4.12.15",
40-
"partyserver": "^0.5.1"
40+
"partyserver": "^0.5.2"
4141
}
4242
}

packages/partyserver/CHANGELOG.md

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
```
2323

2424
Backward compatible:
25-
2625
- For DOs addressed via `idFromName()` / `getByName()` (the happy path), `setName()` continues to NOT write storage — `ctx.id.name` is the source of truth and `setName()` is just a no-op-plus-onStart.
2726
- The pre-existing direct-storage-write pattern keeps working — the storage write becomes idempotent with what `setName()` would do.
2827

@@ -37,7 +36,6 @@
3736
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()`.
3837

3938
Changes:
40-
4139
- 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).
4240
- `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.
4341
- `Server.alarm()` is simplified — it no longer needs its own hydrate call since `#ensureInitialized()` handles it.
@@ -52,7 +50,6 @@
5250
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.
5351

5452
Changes in `partyserver`:
55-
5653
- `this.name` resolves from `this.ctx.id.name`. The apologetic `workerd#2240` error message is gone.
5754
- `this.name` is now available **inside the constructor** and from class field initializers, not just after `setName()`/`fetch()` has run.
5855
- `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.
@@ -64,7 +61,6 @@
6461
- 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.
6562

6663
Changes in all affected packages (`partyserver`, `partysub`, `partysync`, `y-partyserver`, `hono-party`):
67-
6864
- `@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.
6965

7066
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.
@@ -385,14 +381,12 @@
385381
### Patch Changes
386382

387383
- [`528adea`](https://github.com/threepointone/partyserver/commit/528adeaced6dce6e888d2f54cc75c3569bf2c277) Thanks [@threepointone](https://github.com/threepointone)! - some fixes and tweaks
388-
389384
- getServerByName was throwing on all requests
390385
- `Env` is now an optional arg when defining `Server`
391386
- `y-partyserver/provider` can now take an optional `prefix` arg to use a custom url to connect
392387
- `routePartyKitRequest`/`getServerByName` now accepts `jurisdiction`
393388

394389
bonus:
395-
396390
- added a bunch of fixtures
397391
- added stubs for docs
398392

packages/partyserver/README.md

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,9 @@ These methods can be optionally `async`:
129129

130130
## Properties
131131

132-
- `.name` - (readonly) the server's name, as provided to `getServerByName()` or `routePartykitRequest()`. Resolves from the underlying Durable Object's `ctx.id.name`, so it is available inside every entry point — including the constructor, `onStart()`, `onAlarm()`, and hibernating websocket handlers. This assumes the DO was addressed via `idFromName()`/`getByName()` (which is the only supported way to address PartyServer DOs).
132+
- `.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. (For the narrow case of legacy DOs bootstrapped via `setName()` — e.g. a `__ps_name` storage record from an older version — that override is recovered automatically. PartyServer no longer writes that record itself for new DOs.)
133+
134+
DOs addressed via `idFromString()` or `newUniqueId()` without a `setName()` bootstrap are not supported and `.name` will throw.
133135

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

@@ -176,6 +178,47 @@ export class MyServer extends Server {
176178
- If it returns `undefined` or `null`, the request will be passed to the server as normal.
177179
- `onBeforeRequest` - A function that can modify the request before it's passed to the server. This is exactly the same as `onBeforeConnect`, but for HTTP requests.
178180

181+
### Using PartyServer with Durable Object Facets
182+
183+
[Durable Object Facets](https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/) let you spawn a child DO from inside a parent's isolate via `ctx.facets.get(name, factory)`. This is useful for frameworks (e.g. Cloudflare Agents sub-agents) that want isolated SQLite storage per facet without exposing a separate Durable Object namespace.
184+
185+
Facets that extend `Server` work, but **you must pass an explicit `id` in `FacetStartupOptions`**. Otherwise the facet inherits the parent DO's `ctx.id` (including `ctx.id.name`), and `this.name` on the facet will return the _parent's_ name — almost never what you want.
186+
187+
```ts
188+
import { Server } from "partyserver";
189+
190+
export class FacetChild extends Server {
191+
// `this.name` here will report `facetName` (NOT the parent's name)
192+
// because we passed an explicit `id` at spawn time below.
193+
onStart() {
194+
console.log("facet started:", this.name);
195+
}
196+
}
197+
198+
export class ParentServer extends Server {
199+
async fetch(request: Request) {
200+
const facetName = "child-foo";
201+
202+
// Recommended: construct the id via `ctx.exports[BoundDOClass]`,
203+
// which is also a `DurableObjectNamespace`. Any bound DO class
204+
// works — the id is opaque + a name; nothing routes through the
205+
// namespace at runtime for facets.
206+
const id = this.ctx.exports.ParentServer.idFromName(facetName);
207+
208+
const facet = this.ctx.facets.get(facetName, () => ({
209+
class: this.ctx.exports.FacetChild,
210+
id // <-- the critical bit
211+
}));
212+
213+
return facet.fetch(request);
214+
}
215+
}
216+
```
217+
218+
Without the explicit `id`, the facet's `ctx.id.name` is the parent's name, `this.name` returns the parent's name, and `setName(facetName)` throws (it would mismatch `ctx.id.name`). With the explicit `id`, the facet has its own native `ctx.id.name`, no `setName()` is needed, no storage write is involved, and cold wakes recover the name automatically (the factory re-runs and `idFromName(facetName)` is deterministic).
219+
220+
Plain strings (`id: "child-foo"`) are NOT a substitute for `idFromName(facetName)` — workerd treats a string `id` as `idFromString`-like and the resulting facet has no `ctx.id.name`, so `this.name` will throw.
221+
179222
## Comparison to Erlang/Elixir
180223

181224
"Wait", I hear you say, "this looks a lot like Erlang/Elixir's actor model!" And you'd be right! Durable Objects are inspired by the actor model/[GenServer](https://hexdocs.pm/elixir/1.12/GenServer.html) and aims to provide a similar experience for developers building applications on Cloudflare Workers. It's implemented fully in the infrastructure layer, so you don't have to maintain your own infrastructure.

packages/partyserver/src/index.ts

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -551,15 +551,19 @@ export class Server<
551551
* 1. Pre-2026-03-15 alarms, which fire without `ctx.id.name`
552552
* populated on the alarm handler (see the Durable Objects
553553
* 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`.
554+
* 2. Legacy framework-level bootstrap patterns that write
555+
* `__ps_name` directly (or call `setName()`) before triggering
556+
* `__unsafe_ensureInitialized()` — typically DOs addressed via
557+
* `idFromString()` / `newUniqueId()` plus a name override.
559558
*
560559
* PartyServer no longer writes this record itself. Everything that
561560
* reads it is reading something written by an older version of
562561
* PartyServer or by a framework that embeds it.
562+
*
563+
* Not relevant to Cloudflare Agents facets — the recommended
564+
* facet pattern passes an explicit `id` in `FacetStartupOptions`,
565+
* so the facet has its own `ctx.id.name` and never hits this
566+
* fallback. See the README for the full pattern.
563567
*/
564568
async #hydrateNameFromLegacyStorage(): Promise<void> {
565569
if (this.#_name) return;
@@ -672,25 +676,43 @@ export class Server<
672676
/**
673677
* Establish this server's name and trigger `onStart()`.
674678
*
675-
* Two distinct use cases:
679+
* Use cases:
676680
*
677-
* 1. **Framework-level bootstrap of non-`idFromName` DOs** where
678-
* `ctx.id.name` is undefined — for example, Cloudflare Agents
679-
* facets (spawned via `ctx.facets.get(...)`). `setName()` is the
680-
* sanctioned bootstrap primitive: it stashes the name in memory
681-
* AND persists it to storage (under `__ps_name`) so the name
682-
* survives DO eviction and is recovered on cold wake by
683-
* `#ensureInitialized()`.
684-
* 2. **Delivering initial `props` to `onStart()`** via the optional
685-
* second argument.
681+
* 1. **Framework-level bootstrap of DOs where `ctx.id.name` is
682+
* undefined** — e.g. DOs addressed via `idFromString()` /
683+
* `newUniqueId()`. `setName()` stashes the name in memory and
684+
* persists it under `__ps_name` so cold-wake invocations
685+
* recover it via `#ensureInitialized()`'s legacy fallback.
686+
* 2. **Delivering initial `props` to `onStart()`** via the
687+
* optional second argument.
686688
*
687689
* For DOs addressed via `idFromName()` / `getByName()`, calling
688690
* `setName()` is redundant — `this.name` is available automatically
689691
* from `ctx.id.name`. Throws if `name` does not match `ctx.id.name`.
690692
*
693+
* **Not appropriate for facets.** Cloudflare Agents and any other
694+
* framework using `ctx.facets.get(...)` should pass an explicit
695+
* `id` in `FacetStartupOptions` so the facet has its own
696+
* `ctx.id.name`:
697+
*
698+
* ```ts
699+
* const stub = ctx.facets.get(facetKey, () => ({
700+
* class: ChildClass,
701+
* id: ctx.exports.SomeBoundDOClass.idFromName(facetName),
702+
* }));
703+
* ```
704+
*
705+
* Without an explicit `id`, the facet inherits the parent DO's
706+
* `ctx.id` (including `ctx.id.name`), and `setName()` will throw
707+
* the ctx.id.name-mismatch error because the facet's intended
708+
* name differs from the parent's. See
709+
* https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/
710+
* for the `FacetStartupOptions.id` semantics.
711+
*
691712
* @deprecated for callers that address DOs via `idFromName()` /
692713
* `getByName()`. Still the supported API for framework-level
693-
* bootstrap and props delivery.
714+
* bootstrap of header/`newUniqueId`-addressed DOs and for
715+
* delivering initial `props` to `onStart()`.
694716
*/
695717
async setName(name: string, props?: Props) {
696718
if (!name) {

0 commit comments

Comments
 (0)