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
24 changes: 24 additions & 0 deletions .changeset/hibernation-awareness-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"y-partyserver": minor
---

Fix Yjs hibernation support and awareness propagation

**Server:**

- Replace in-memory `WSSharedDoc.conns` Map with `connection.setState()` and `getConnections()` so connection tracking survives Durable Object hibernation
- Move event handler registration from `WSSharedDoc` constructor into `onStart()` to use `getConnections()` for broadcasting
- Disable awareness protocol's built-in `_checkInterval` in `WSSharedDoc` constructor to prevent timers from defeating hibernation
- On `onStart`, send sync step 1 to all existing connections so clients re-sync the server's document after hibernation wake-up
- Simplify `send()` — no longer forcibly closes connections on failure
- Remove `closeConn()` helper; awareness cleanup now happens in `onClose` via persisted connection state
- Widen `onLoad()` return type to `Promise<YDoc | void>` to allow seeding the document from a returned YDoc

**Provider:**

- Switch awareness event listener from `"update"` to `"change"` so clock-only heartbeat renewals do not produce network traffic (allows DO hibernation during idle sessions)
- Disable awareness protocol's built-in `_checkInterval` on the client to stop 15-second clock renewals and 30-second peer timeout removal
- Remove provider's own `_checkInterval` liveness timer (was coupled to the awareness heartbeat)
- Clear stale awareness meta for remote clients on WebSocket close so reconnecting clients' awareness updates are accepted
- Bump awareness clock on reconnect to ensure remote peers accept the update
- Fix bug where `host.slice(0, -1)` result was not assigned, so trailing slashes were never stripped
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ dist


.wrangler
.wrangler-persist-*
.DS_Store
.env
.dev.vars
12 changes: 11 additions & 1 deletion fixtures/monaco-yjs/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { routePartykitRequest } from "partyserver";
import { YServer } from "y-partyserver";

export { YServer as MonacoServer };
// export { YServer as MonacoServer };

export class MonacoServer extends YServer {
static options = {
hibernate: true
};
async onStart(): Promise<void> {
console.log("onStart");
await super.onStart();
}
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 11 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,24 @@
"version": "0.0.0",
"private": true,
"description": "Everything's better with friends",
"license": "ISC",
"author": "Sunil Pai <spai@cloudflare.com>",
"workspaces": [
"packages/*",
"fixtures/*"
],
"type": "module",
"scripts": {
"build": "npm run build -w partyserver -w partysocket -w y-partyserver -w partysub -w partyfn -w partysync -w partywhen -w partytracks -w hono-party && tsx scripts/check-exports.ts",
"check": "npm run check:repo && npm run check:format && npm run check:lint && npm run check:type && npm run check:test",
"format": "prettier . --write --ignore-unknown",
"check:format": "prettier . --check --ignore-unknown",
"check:lint": "biome check",
"check:repo": "sherif",
"check:test": "npm run check:test -w partyserver -w partysocket -w partysub -w partywhen -w partytracks",
"check:test": "npm run check:test -w partyserver -w partysocket -w partysub -w partywhen -w partytracks -w y-partyserver",
"check:type": "tsx scripts/typecheck.ts",
"all": "npm i && npm run build && npm run check"
},
"author": "Sunil Pai <spai@cloudflare.com>",
"license": "ISC",
"workspaces": [
"packages/*",
"fixtures/*"
],
"devDependencies": {
"@biomejs/biome": "^2.3.10",
"@changesets/changelog-github": "^0.5.2",
Expand All @@ -46,18 +47,17 @@
"wrangler": "^4.56.0"
},
"overrides": {
"esbuild": "0.25.0",
"@types/node": "25.0.3",
"esbuild": "0.25.0",
"prosemirror-model": "1.22.2",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"packageManager": "npm@11.7.0",
"trustedDependencies": [
"@biomejs/biome",
"core-js",
"esbuild",
"workerd"
],
"packageManager": "npm@11.7.0",
"type": "module"
]
}
2 changes: 1 addition & 1 deletion packages/hono-party/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,6 @@
"devDependencies": {
"@cloudflare/workers-types": "^4.20251218.0",
"hono": "^4.11.1",
"partyserver": ">=0.3.0"
"partyserver": "^0.3.0"
}
}
2 changes: 1 addition & 1 deletion packages/partyfn/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
],
"dependencies": {
"nanoid": "^5.1.6",
"partysocket": "^1.1.14"
"partysocket": "^1.1.15"
},
"scripts": {
"build": "tsx scripts/build.ts"
Expand Down
2 changes: 1 addition & 1 deletion packages/partysub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,6 @@
"devDependencies": {
"@cloudflare/workers-types": "^4.20251218.0",
"partyserver": ">=0.2.0 <1.0.0",
"partysocket": "^1.1.14"
"partysocket": "^1.1.15"
}
}
42 changes: 23 additions & 19 deletions packages/y-partyserver/package.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
{
"name": "y-partyserver",
"version": "2.0.0",
"description": "",
"keywords": [
"collaboration",
"text-editors",
"yjs"
],
"homepage": "https://github.com/cloudflare/partykit/tree/main/packages/y-partyserver",
"license": "ISC",
"author": "Sunil Pai <spai@cloudflare.com>",
"repository": {
"type": "git",
"url": "git://github.com/cloudflare/partykit.git"
},
"homepage": "https://github.com/cloudflare/partykit/tree/main/packages/y-partyserver",
"files": [
"dist",
"README.md"
],
"type": "module",
"exports": {
".": {
Expand All @@ -25,36 +37,28 @@
}
},
"scripts": {
"build": "tsx scripts/build.ts"
"build": "tsx scripts/build.ts",
"check:test": "vitest -r src/tests --watch false",
"test": "vitest -r src/tests",
"test:integration": "vitest -r src/tests --config vitest.integration.config.ts --watch false",
"test:hibernation": "vitest -r src/tests --config vitest.hibernation.config.ts --watch false"
},
"files": [
"dist",
"README.md"
],
"keywords": [
"yjs",
"collaboration",
"text-editors"
],
"author": "Sunil Pai <spai@cloudflare.com>",
"license": "ISC",
"description": "",
"dependencies": {
"lib0": "^0.2.115",
"lodash.debounce": "^4.0.8",
"nanoid": "^5.1.6",
"y-protocols": "^1.0.7"
},
"peerDependencies": {
"@cloudflare/workers-types": "^4.20240729.0",
"partyserver": ">=0.2.0 <1.0.0",
"yjs": "^13.6.14"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20251218.0",
"@types/lodash.debounce": "^4.0.9",
"partyserver": ">=0.2.0 <1.0.0",
"ws": "^8.18.3",
"yjs": "^13.6.28"
},
"peerDependencies": {
"@cloudflare/workers-types": "^4.20240729.0",
"partyserver": ">=0.2.0 <1.0.0",
"yjs": "^13.6.14"
}
}
60 changes: 31 additions & 29 deletions packages/y-partyserver/src/provider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,6 @@ export const messageAuth = 2;
// Disable BroadcastChannel by default in Cloudflare Workers / Node
const DEFAULT_DISABLE_BC = typeof window === "undefined";

function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}

const messageHandlers: Array<
(
encoder: encoding.Encoder,
Expand Down Expand Up @@ -101,9 +95,6 @@ messageHandlers[messageAuth] = (
);
};

// @todo - this should depend on awareness.outdatedTime
const messageReconnectTimeout = 30000;

function permissionDeniedHandler(provider: WebsocketProvider, reason: string) {
console.warn(`Permission denied to access ${provider.url}.\n${reason}`);
}
Expand Down Expand Up @@ -165,13 +156,19 @@ function setupWS(provider: WebsocketProvider) {
provider.wsconnected = false;
provider.synced = false;
// update awareness (all users except local left)
const removedClients = Array.from(
provider.awareness.getStates().keys()
).filter((client) => client !== provider.doc.clientID);
awarenessProtocol.removeAwarenessStates(
provider.awareness,
Array.from(provider.awareness.getStates().keys()).filter(
(client) => client !== provider.doc.clientID
),
removedClients,
provider
);
// Clear stale meta for remote clients so their awareness
// updates are accepted on reconnect (clock check starts fresh)
for (const clientID of removedClients) {
provider.awareness.meta.delete(clientID);
}
provider.emit("status", [
{
status: "disconnected"
Expand Down Expand Up @@ -208,6 +205,9 @@ function setupWS(provider: WebsocketProvider) {
websocket.send(encoding.toUint8Array(encoder));
// broadcast local awareness state
if (provider.awareness.getLocalState() !== null) {
// Re-set local state to bump the awareness clock, ensuring
// remote clients accept the update even if they have stale meta
provider.awareness.setLocalState(provider.awareness.getLocalState());
const encoderAwarenessState = encoding.createEncoder();
encoding.writeVarUint(encoderAwarenessState, messageAwareness);
encoding.writeVarUint8Array(
Expand Down Expand Up @@ -281,7 +281,6 @@ export class WebsocketProvider extends Observable<string> {
_updateHandler: (update: Uint8Array, origin: unknown) => void;
_awarenessUpdateHandler: (update: AwarenessUpdate, origin: unknown) => void;
_unloadHandler: () => void;
_checkInterval: ReturnType<typeof setInterval> | number;

constructor(
serverUrl: string,
Expand Down Expand Up @@ -398,19 +397,23 @@ export class WebsocketProvider extends Observable<string> {
) {
process.on("exit", this._unloadHandler);
}
awareness.on("update", this._awarenessUpdateHandler);
this._checkInterval = /** @type {any} */ setInterval(() => {
if (
this.wsconnected &&
messageReconnectTimeout <
time.getUnixTime() - this.wsLastMessageReceived
) {
assert(this.ws !== null, "ws must not be null");
// no message received in a long time - not even your own awareness
// updates (which are updated every 15 seconds)
this.ws.close();
}
}, messageReconnectTimeout / 10);
// Listen on 'change' (not 'update') so that clock-only awareness
// renewals (the 15-second heartbeat) do NOT produce network traffic.
// Only actual state changes (cursor moved, name changed, etc.) are sent.
// This allows Durable Objects to hibernate when sessions are idle.
awareness.on("change", this._awarenessUpdateHandler);

// Disable the awareness protocol's built-in check interval.
// It renews the local clock every 15s (causing wire traffic that defeats
// DO hibernation) and removes remote peers after 30s of inactivity.
// We handle peer cleanup via WebSocket close events instead.
clearInterval(
(
awareness as unknown as {
_checkInterval: ReturnType<typeof setInterval>;
}
)._checkInterval
);
if (connect) {
this.connect();
}
Expand All @@ -435,7 +438,6 @@ export class WebsocketProvider extends Observable<string> {
if (this._resyncInterval !== 0) {
clearInterval(this._resyncInterval);
}
clearInterval(this._checkInterval);
this.disconnect();
if (typeof window !== "undefined") {
window.removeEventListener("unload", this._unloadHandler);
Expand All @@ -445,7 +447,7 @@ export class WebsocketProvider extends Observable<string> {
) {
process.off("exit", this._unloadHandler);
}
this.awareness.off("update", this._awarenessUpdateHandler);
this.awareness.off("change", this._awarenessUpdateHandler);
this.doc.off("update", this._updateHandler);
super.destroy();
}
Expand Down Expand Up @@ -567,7 +569,7 @@ export default class YProvider extends WebsocketProvider {

// strip trailing slash from host if any
if (host.endsWith("/")) {
host.slice(0, -1);
host = host.slice(0, -1);
}

const serverUrl = `${
Expand Down
Loading
Loading