From 05347c07c64fd61b3e86e6f2123b545160076047 Mon Sep 17 00:00:00 2001 From: Sunil Pai <18808+threepointone@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:35:03 +0100 Subject: [PATCH 1/2] Add terminal close reconnect predicate Co-authored-by: Cursor --- .changeset/partysocket-terminal-reconnect.md | 5 + .../src/tests/reconnecting.test.ts | 107 ++++++++++++++++++ packages/partysocket/src/ws.ts | 8 ++ 3 files changed, 120 insertions(+) create mode 100644 .changeset/partysocket-terminal-reconnect.md diff --git a/.changeset/partysocket-terminal-reconnect.md b/.changeset/partysocket-terminal-reconnect.md new file mode 100644 index 00000000..bca114f2 --- /dev/null +++ b/.changeset/partysocket-terminal-reconnect.md @@ -0,0 +1,5 @@ +--- +"partysocket": minor +--- + +Add `shouldReconnectOnClose`, allowing callers to stop automatic reconnects for terminal close events while preserving the existing reconnect-by-default behavior. diff --git a/packages/partysocket/src/tests/reconnecting.test.ts b/packages/partysocket/src/tests/reconnecting.test.ts index 33101474..7a3c728e 100644 --- a/packages/partysocket/src/tests/reconnecting.test.ts +++ b/packages/partysocket/src/tests/reconnecting.test.ts @@ -848,6 +848,113 @@ testDone( } ); +testDone( + "shouldReconnectOnClose=false stops reconnecting before close listeners run", + (done, fail) => { + const ws = new ReconnectingWebSocket(URL, undefined, { + minReconnectionDelay: 50, + maxReconnectionDelay: 100, + shouldReconnectOnClose: (event) => event.code !== 1008 + }); + + let connections = 0; + function onConnection(client: NodeWebSocket) { + connections++; + if (connections === 1) { + client.close(1008, "policy"); + return; + } + fail(new Error("unexpected reconnect")); + } + wss.on("connection", onConnection); + + ws.addEventListener("close", (event) => { + try { + expect(event.code).toBe(1008); + expect(event.reason).toBe("policy"); + expect(ws.shouldReconnect).toBe(false); + } catch (error) { + wss.off("connection", onConnection); + fail(error); + return; + } + + setTimeout(() => { + try { + expect(connections).toBe(1); + wss.off("connection", onConnection); + done(); + } catch (error) { + wss.off("connection", onConnection); + fail(error); + } + }, 200); + }); + } +); + +testDone( + "shouldReconnectOnClose preserves default reconnect behavior when omitted", + (done, fail) => { + const ws = new ReconnectingWebSocket(URL, undefined, { + minReconnectionDelay: 50, + maxReconnectionDelay: 100 + }); + + let connections = 0; + function onConnection(client: NodeWebSocket) { + connections++; + if (connections === 1) { + client.close(1008, "policy"); + return; + } + if (connections === 2) { + ws.close(); + wss.off("connection", onConnection); + done(); + return; + } + fail(new Error("unexpected reconnect")); + } + wss.on("connection", onConnection); + } +); + +testDone( + "reconnect() re-enables a socket stopped by shouldReconnectOnClose", + (done, fail) => { + const ws = new ReconnectingWebSocket(URL, undefined, { + minReconnectionDelay: 50, + maxReconnectionDelay: 100, + shouldReconnectOnClose: (event) => event.code !== 1008 + }); + + let connections = 0; + function onConnection(client: NodeWebSocket) { + connections++; + if (connections === 1) { + client.close(1008, "policy"); + return; + } + if (connections === 2) { + ws.close(); + wss.off("connection", onConnection); + done(); + return; + } + fail(new Error("unexpected reconnect")); + } + wss.on("connection", onConnection); + + ws.addEventListener("close", (event) => { + if (event.code === 1008) { + expect(ws.shouldReconnect).toBe(false); + ws.reconnect(); + } + }); + } +); + testDone("reconnection delay grow factor", (done) => { const ws = new ReconnectingWebSocket(ERROR_URL, [], { minReconnectionDelay: 50, diff --git a/packages/partysocket/src/ws.ts b/packages/partysocket/src/ws.ts index c74eafc5..7df3bcbc 100644 --- a/packages/partysocket/src/ws.ts +++ b/packages/partysocket/src/ws.ts @@ -117,6 +117,7 @@ export type Options = { maxRetries?: number; maxEnqueuedMessages?: number; startClosed?: boolean; + shouldReconnectOnClose?: (event: CloseEvent) => boolean; debug?: boolean; // oxlint-disable-next-line no-explicit-any debugLogger?: (...args: any[]) => void; @@ -668,6 +669,13 @@ const partysocket = new PartySocket({ this._debug("close event"); this._clearTimeouts(); + if ( + this._options.shouldReconnectOnClose && + !this._options.shouldReconnectOnClose(event) + ) { + this._shouldReconnect = false; + } + if (this._shouldReconnect) { this._connect(); } From 3b100059583963455f52862f197be5fe7965088c Mon Sep 17 00:00:00 2001 From: Sunil Pai <18808+threepointone@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:40:53 +0100 Subject: [PATCH 2/2] fix ci --- package-lock.json | 10 +++++----- packages/hono-party/package.json | 2 +- packages/partyserver/CHANGELOG.md | 8 -------- packages/partysub/package.json | 2 +- packages/partysync/package.json | 2 +- packages/partywhen/package.json | 2 +- packages/y-partyserver/package.json | 2 +- 7 files changed, 10 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 106e8061..aa4aeb0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11632,7 +11632,7 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20260426.1", "hono": "^4.12.15", - "partyserver": "^0.5.7" + "partyserver": "^0.5.8" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1", @@ -11765,7 +11765,7 @@ "license": "ISC", "devDependencies": { "@cloudflare/workers-types": "^4.20260426.1", - "partyserver": "^0.5.7", + "partyserver": "^0.5.8", "partysocket": "^1.2.0" }, "peerDependencies": { @@ -11784,7 +11784,7 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20260426.1", "partyfn": "^0.1.0", - "partyserver": "^0.5.7", + "partyserver": "^0.5.8", "partysocket": "^1.2.0" }, "peerDependencies": { @@ -11825,7 +11825,7 @@ "license": "ISC", "dependencies": { "cron-parser": "^5.5.0", - "partyserver": "^0.5.7" + "partyserver": "^0.5.8" } }, "packages/y-partyserver": { @@ -11841,7 +11841,7 @@ "@cloudflare/workers-types": "^4.20260426.1", "@types/lodash.debounce": "^4.0.9", "@types/node": "25.6.0", - "partyserver": "^0.5.7", + "partyserver": "^0.5.8", "ws": "^8.20.0", "yjs": "^13.6.30" }, diff --git a/packages/hono-party/package.json b/packages/hono-party/package.json index 92670a57..80188a75 100644 --- a/packages/hono-party/package.json +++ b/packages/hono-party/package.json @@ -37,6 +37,6 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20260426.1", "hono": "^4.12.15", - "partyserver": "^0.5.7" + "partyserver": "^0.5.8" } } diff --git a/packages/partyserver/CHANGELOG.md b/packages/partyserver/CHANGELOG.md index b9833095..027096f7 100644 --- a/packages/partyserver/CHANGELOG.md +++ b/packages/partyserver/CHANGELOG.md @@ -15,7 +15,6 @@ On compatibility dates `>= 2026-04-07` the `web_socket_auto_reply_to_close` flag makes the runtime send a reciprocal Close frame and tear the socket down automatically. For a non-hibernating PartyServer (`hibernate: false`), the Durable Object sits on the server end of a connection that the runtime tunnels back to the client, so that auto-teardown could fire through an already-severed tunnel — surfacing as a spurious retryable `Network connection lost.` rejection (for example when a Durable Object is reset while a connection is still open). Half-open mode keeps PartyServer's existing close handling in control; it already reciprocates the peer's Close frame on every compatibility date, so client behavior is unchanged. Also in this release, two related WebSocket fixes that keep behavior consistent across all compatibility dates: - - **Pin `binaryType` to `"arraybuffer"` for non-hibernating connections.** On compatibility dates `>= 2026-03-17` the `websocket_standard_binary_type` flag flips the default server-side `binaryType` from `"arraybuffer"` to `"blob"`, so binary frames arrived as `Blob` instead of `ArrayBuffer` on the in-memory path. PartyServer (and frameworks built on it, e.g. Cloudflare Agents) have always received `ArrayBuffer`, so it is now pinned back in `accept()`. This is a no-op on older dates and corrective on newer ones; the Hibernation API is unaffected (it always delivers `ArrayBuffer`). - **Stop reporting transport-teardown errors as `onError`.** A retryable `Network connection lost.` / `WebSocket peer disconnected` error that fires on an already closing/closed connection is the socket going away during the close handshake, not an application error. It is now suppressed when the connection is `CLOSING`/`CLOSED` (detected via the structured `retryable` flag, with a message fallback), so it no longer spams logs on abrupt client disconnects. Genuine mid-connection (`OPEN`) errors still reach `onError`. @@ -50,7 +49,6 @@ 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. This release adds: - - **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`). - **`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. - **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. @@ -79,7 +77,6 @@ ``` Backward compatible: - - 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. - The pre-existing direct-storage-write pattern keeps working — the storage write becomes idempotent with what `setName()` would do. @@ -94,7 +91,6 @@ 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. @@ -109,7 +105,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. @@ -121,7 +116,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. @@ -442,14 +436,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 diff --git a/packages/partysub/package.json b/packages/partysub/package.json index 0d9a3d1a..ce988ccf 100644 --- a/packages/partysub/package.json +++ b/packages/partysub/package.json @@ -46,7 +46,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20260426.1", - "partyserver": "^0.5.7", + "partyserver": "^0.5.8", "partysocket": "^1.2.0" } } diff --git a/packages/partysync/package.json b/packages/partysync/package.json index e0cd05f1..869a002a 100644 --- a/packages/partysync/package.json +++ b/packages/partysync/package.json @@ -51,7 +51,7 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20260426.1", "partyfn": "^0.1.0", - "partyserver": "^0.5.7", + "partyserver": "^0.5.8", "partysocket": "^1.2.0" }, "peerDependencies": { diff --git a/packages/partywhen/package.json b/packages/partywhen/package.json index e5c21d34..cfbb3a19 100644 --- a/packages/partywhen/package.json +++ b/packages/partywhen/package.json @@ -29,6 +29,6 @@ "description": "A library for scheduling and running tasks in Cloudflare Workers", "dependencies": { "cron-parser": "^5.5.0", - "partyserver": "^0.5.7" + "partyserver": "^0.5.8" } } diff --git a/packages/y-partyserver/package.json b/packages/y-partyserver/package.json index 8c485ae6..ac65e2d3 100644 --- a/packages/y-partyserver/package.json +++ b/packages/y-partyserver/package.json @@ -65,7 +65,7 @@ "@cloudflare/workers-types": "^4.20260426.1", "@types/lodash.debounce": "^4.0.9", "@types/node": "25.6.0", - "partyserver": "^0.5.7", + "partyserver": "^0.5.8", "ws": "^8.20.0", "yjs": "^13.6.30" },