@jxsuite/collab 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Kevin Shenk
3
+ Copyright (c) 2026 Avunu LLC and Jx Suite contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/collab",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Realtime co-editing for Jx Studio — Y.Doc schema, op bridge, structural differ, wire envelope, and provider contracts shared by every collab backend",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,6 +24,7 @@
24
24
  "./awareness-types": "./src/awareness-types.ts",
25
25
  "./provider": "./src/provider.ts",
26
26
  "./client": "./src/ws-client.ts",
27
+ "./reconnect": "./src/reconnect.ts",
27
28
  "./room": "./src/ws-room.ts"
28
29
  },
29
30
  "publishConfig": {
package/src/negotiate.ts CHANGED
@@ -55,9 +55,12 @@ export function negotiateCollab(probe: unknown): CollabNegotiation {
55
55
  // A 200 that is not a JSON object: pre-negotiation server. Connect as before, offering nothing.
56
56
  return { offer: [], refused: null };
57
57
  }
58
- const body = probe as { collab?: unknown; protocols?: unknown };
58
+ const body = probe as { collab?: unknown; detail?: unknown; protocols?: unknown };
59
59
  if (body.collab === false) {
60
- return { offer: [], refused: "This server has collaboration disabled." };
60
+ /* The server's own sentence wins when it sends one. "Disabled" is true of a flag that is off,
61
+ and false of a room an action would open: that server knows which it is and says so. */
62
+ const detail = typeof body.detail === "string" ? body.detail.trim() : "";
63
+ return { offer: [], refused: detail || "This server has collaboration disabled." };
61
64
  }
62
65
  const { protocols } = body;
63
66
  if (!Array.isArray(protocols) || protocols.length === 0) {
@@ -0,0 +1,164 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Reconnect timing shared by every long-lived socket Studio holds: the collab socket
4
+ * (`ws-client.ts`) and the cloud session's `/events` stream
5
+ * (`packages/studio/src/platforms/cloud.ts`).
6
+ *
7
+ * **Jitter, because every client loses the socket at the same instant.** A gateway deploy or a
8
+ * relay restart drops every connected Studio together, and a plain doubling backoff brings them all
9
+ * back together too — at 1s, 2s, 4s, … in lockstep, so each retry wave lands on the server as one
10
+ * spike. Equal jitter ({@link backoffDelay}) keeps the doubling but spreads each wave across the
11
+ * upper half of its window, which keeps a floor under the delay (a retry never fires immediately on
12
+ * a server that is still down) while decorrelating the herd.
13
+ *
14
+ * **Wake triggers, because a timer is the wrong clock for a laptop.** A socket that dropped while
15
+ * the machine slept or the network was gone can be sitting in a 30-second wait when the network
16
+ * returns. The browser says so — `online`, a `pageshow` restored from the back/forward cache, a tab
17
+ * becoming visible — and {@link ReconnectScheduler.wake} turns any of them into a retry now. It
18
+ * only ever SHORTENS a pending wait: a socket that is open or mid-handshake is left alone, so a tab
19
+ * switch never tears down a healthy connection.
20
+ *
21
+ * This module imports nothing, deliberately: the cloud adapter reads it eagerly, before the dynamic
22
+ * import that pulls in Yjs, so it must not drag the CRDT into the initial bundle (the same
23
+ * constraint, and the same reason, as `negotiate.ts`).
24
+ */
25
+
26
+ /** Backoff tuning. Every field is optional; the defaults are what production uses. */
27
+ export interface ReconnectPolicy {
28
+ /** The first retry's window, in ms. Doubles per failed attempt. Default 1000. */
29
+ baseMs?: number;
30
+ /** The ceiling on any one window, in ms. Default 30 000. */
31
+ maxMs?: number;
32
+ /** Source of jitter in [0, 1]. Default `Math.random`; tests pin it. */
33
+ random?: () => number;
34
+ }
35
+
36
+ const DEFAULT_BASE_MS = 1000;
37
+ const DEFAULT_MAX_MS = 30_000;
38
+
39
+ /**
40
+ * The delay before retry number `attempt` (0-based), with **equal jitter**: half the capped window
41
+ * is fixed and the other half is random, so the result lies in `[cap / 2, cap]` where `cap =
42
+ * min(maxMs, baseMs × 2^attempt)`.
43
+ *
44
+ * Full jitter (`random × cap`) spreads the herd wider but can retry at ~0ms, which against a server
45
+ * that is still restarting is a wasted handshake per client; equal jitter keeps the floor.
46
+ */
47
+ export function backoffDelay(attempt: number, policy: ReconnectPolicy = {}): number {
48
+ const base = policy.baseMs ?? DEFAULT_BASE_MS;
49
+ const max = policy.maxMs ?? DEFAULT_MAX_MS;
50
+ const random = policy.random ?? Math.random;
51
+ // `2 ** attempt` reaches Infinity long before an attempt counter overflows; `min` absorbs it.
52
+ const cap = Math.min(max, base * 2 ** Math.max(0, attempt));
53
+ return cap / 2 + (random() * cap) / 2;
54
+ }
55
+
56
+ /** The DOM surfaces the scheduler listens on (injectable for tests and non-browser runtimes). */
57
+ export interface ReconnectWakeSources {
58
+ win?: Pick<Window, "addEventListener" | "removeEventListener">;
59
+ doc?: Pick<Document, "visibilityState" | "addEventListener" | "removeEventListener">;
60
+ }
61
+
62
+ export interface ReconnectScheduler {
63
+ /**
64
+ * The socket closed: arm one retry after the next backoff delay. A second call while a retry is
65
+ * already pending is a no-op, so a close that arrives twice cannot double the attempt count.
66
+ */
67
+ scheduleRetry: () => void;
68
+ /** The socket opened: the next failure starts the backoff from the first window again. */
69
+ opened: () => void;
70
+ /**
71
+ * Conditions changed (network back, page shown): if a retry is pending, run it NOW. A no-op when
72
+ * nothing is pending — the socket is open or a handshake is already in flight, and either way
73
+ * there is nothing to hurry.
74
+ */
75
+ wake: () => void;
76
+ /** Cancel any pending retry and remove every wake listener. Idempotent. */
77
+ dispose: () => void;
78
+ }
79
+
80
+ /**
81
+ * Drive `connect` through jittered backoff and the browser's wake signals.
82
+ *
83
+ * The caller owns the socket; this owns only WHEN `connect` runs again. The caller reports the two
84
+ * edges it sees — {@link ReconnectScheduler.opened} on `open`,
85
+ * {@link ReconnectScheduler.scheduleRetry} on `close` — and calls {@link ReconnectScheduler.dispose}
86
+ * when it is done with the socket for good.
87
+ */
88
+ export function createReconnectScheduler(
89
+ connect: () => void,
90
+ opts: ReconnectPolicy & ReconnectWakeSources = {},
91
+ ): ReconnectScheduler {
92
+ const win =
93
+ opts.win ??
94
+ (typeof window === "undefined"
95
+ ? undefined
96
+ : (window as Pick<Window, "addEventListener" | "removeEventListener">));
97
+ const doc = opts.doc ?? (typeof document === "undefined" ? undefined : document);
98
+ let attempt = 0;
99
+ let timer: ReturnType<typeof setTimeout> | null = null;
100
+ let disposed = false;
101
+
102
+ const fire = () => {
103
+ timer = null;
104
+ if (!disposed) {
105
+ connect();
106
+ }
107
+ };
108
+
109
+ const wake = () => {
110
+ if (disposed || timer === null) {
111
+ return;
112
+ }
113
+ clearTimeout(timer);
114
+ /* The backoff that accumulated was measured against the conditions that just ended — an
115
+ offline laptop fails every attempt for as long as it is offline. Carrying it forward would
116
+ make the first failure after the network returns wait a full 30 seconds. */
117
+ attempt = 0;
118
+ fire();
119
+ };
120
+
121
+ const onPageShow = (event: Event) => {
122
+ // A fresh load is not a wake: only a page restored from the back/forward cache was frozen.
123
+ if ((event as PageTransitionEvent).persisted) {
124
+ wake();
125
+ }
126
+ };
127
+ const onVisibility = () => {
128
+ if (doc?.visibilityState === "visible") {
129
+ wake();
130
+ }
131
+ };
132
+
133
+ win?.addEventListener("online", wake);
134
+ win?.addEventListener("pageshow", onPageShow);
135
+ doc?.addEventListener("visibilitychange", onVisibility);
136
+
137
+ return {
138
+ dispose() {
139
+ if (disposed) {
140
+ return;
141
+ }
142
+ disposed = true;
143
+ if (timer !== null) {
144
+ clearTimeout(timer);
145
+ timer = null;
146
+ }
147
+ win?.removeEventListener("online", wake);
148
+ win?.removeEventListener("pageshow", onPageShow);
149
+ doc?.removeEventListener("visibilitychange", onVisibility);
150
+ },
151
+ opened() {
152
+ attempt = 0;
153
+ },
154
+ scheduleRetry() {
155
+ if (disposed || timer !== null) {
156
+ return;
157
+ }
158
+ const delay = backoffDelay(attempt, opts);
159
+ attempt += 1;
160
+ timer = setTimeout(fire, delay);
161
+ },
162
+ wake,
163
+ };
164
+ }
package/src/ws-client.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * The browser half of the wire envelope: one WebSocket per project, multiplexing every open
3
- * document. `createWsCollabConnection` owns the socket (exponential-backoff reconnect, re-open of
4
- * live docs on reconnect), the shared project-level Awareness, and per-doc sync state;
5
- * `openDoc(path)` hands out {@link CollabHandle}s for `StudioPlatform.collab`.
3
+ * document. `createWsCollabConnection` owns the socket (jittered-backoff reconnect with wake
4
+ * triggers, `reconnect.ts`; re-open of live docs on reconnect), the shared project-level Awareness,
5
+ * and per-doc sync state; `openDoc(path)` hands out {@link CollabHandle}s for
6
+ * `StudioPlatform.collab`.
6
7
  */
7
8
 
8
9
  import * as Y from "yjs";
@@ -16,6 +17,7 @@ import {
16
17
  } from "y-protocols/awareness";
17
18
  import * as syncProtocol from "y-protocols/sync";
18
19
  import { decodeFrame, encodeFrame } from "./envelope.ts";
20
+ import { createReconnectScheduler } from "./reconnect.ts";
19
21
  import type { CollabFrame, ControlMessage } from "./envelope.ts";
20
22
  import type { CollabHandle, CollabIdentity, CollabStatus } from "./provider.ts";
21
23
 
@@ -52,8 +54,13 @@ export interface WsCollabConnectionOptions {
52
54
  hydratePath?: (path: string) => Promise<void>;
53
55
  /** Give up on an openDoc after this long (falls back to solo editing). */
54
56
  openTimeoutMs?: number;
55
- /** Initial reconnect backoff (doubles to 30s). */
57
+ /** Initial reconnect backoff window (`ReconnectPolicy.baseMs`: doubles to 30s, equal jitter). */
56
58
  reconnectDelayMs?: number;
59
+ /**
60
+ * Jitter source for the reconnect backoff (`ReconnectPolicy.random`). Tests pin it so a retry's
61
+ * delay is exact; production leaves it to `Math.random`.
62
+ */
63
+ reconnectRandom?: () => number;
57
64
  }
58
65
 
59
66
  export interface WsCollabConnection {
@@ -99,8 +106,6 @@ export function createWsCollabConnection(options: WsCollabConnectionOptions): Ws
99
106
  let identity: CollabIdentity | null = null;
100
107
  let status: CollabStatus = "connecting";
101
108
  let destroyed = false;
102
- let retryMs = options.reconnectDelayMs ?? 1000;
103
- let retryTimer: ReturnType<typeof setTimeout> | null = null;
104
109
 
105
110
  const setStatus = (next: CollabStatus) => {
106
111
  if (status === next) {
@@ -262,10 +267,9 @@ export function createWsCollabConnection(options: WsCollabConnectionOptions): Ws
262
267
  entry.doc.destroy();
263
268
  };
264
269
 
270
+ /* No `destroyed` guard here: the only callers are the first connect below and the scheduler, and
271
+ `destroy()` disposes the scheduler, whose timer re-checks that before it calls back. */
265
272
  const connect = () => {
266
- if (destroyed) {
267
- return;
268
- }
269
273
  setStatus("connecting");
270
274
  /* Re-offered on every reconnect: a socket that comes back after the server was restarted must
271
275
  negotiate again, and the offer is a property of this client's envelope, not of one socket. */
@@ -280,7 +284,7 @@ export function createWsCollabConnection(options: WsCollabConnectionOptions): Ws
280
284
  the injectable WsLike test surface minimal). */
281
285
  // oxlint-disable-next-line unicorn/prefer-add-event-listener
282
286
  ws.onopen = () => {
283
- retryMs = options.reconnectDelayMs ?? 1000;
287
+ reconnect.opened();
284
288
  setStatus("connected");
285
289
  // Re-open every live doc and re-publish our awareness state.
286
290
  for (const path of docs.keys()) {
@@ -335,14 +339,17 @@ export function createWsCollabConnection(options: WsCollabConnectionOptions): Ws
335
339
  for (const entry of docs.values()) {
336
340
  entry.opened = false;
337
341
  }
338
- retryTimer = setTimeout(connect, retryMs);
339
- retryMs = Math.min(retryMs * 2, 30_000);
342
+ reconnect.scheduleRetry();
340
343
  };
341
344
  // oxlint-disable-next-line unicorn/prefer-add-event-listener
342
345
  ws.onerror = () => {
343
346
  // The close handler follows and owns retry.
344
347
  };
345
348
  };
349
+ const reconnect = createReconnectScheduler(connect, {
350
+ ...(options.reconnectDelayMs === undefined ? {} : { baseMs: options.reconnectDelayMs }),
351
+ ...(options.reconnectRandom === undefined ? {} : { random: options.reconnectRandom }),
352
+ });
346
353
  connect();
347
354
 
348
355
  return {
@@ -351,9 +358,7 @@ export function createWsCollabConnection(options: WsCollabConnectionOptions): Ws
351
358
  return;
352
359
  }
353
360
  destroyed = true;
354
- if (retryTimer) {
355
- clearTimeout(retryTimer);
356
- }
361
+ reconnect.dispose();
357
362
  removeAwarenessStates(awareness, [awareness.clientID], "destroy");
358
363
  awareness.destroy();
359
364
  awarenessDoc.destroy();