@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 +1 -1
- package/package.json +2 -1
- package/src/negotiate.ts +5 -2
- package/src/reconnect.ts +164 -0
- package/src/ws-client.ts +20 -15
package/LICENSE
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/collab",
|
|
3
|
-
"version": "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
|
-
|
|
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) {
|
package/src/reconnect.ts
ADDED
|
@@ -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 (
|
|
4
|
-
* live docs on reconnect), the shared project-level Awareness,
|
|
5
|
-
* `openDoc(path)` hands out {@link CollabHandle}s for
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
clearTimeout(retryTimer);
|
|
356
|
-
}
|
|
361
|
+
reconnect.dispose();
|
|
357
362
|
removeAwarenessStates(awareness, [awareness.clientID], "destroy");
|
|
358
363
|
awareness.destroy();
|
|
359
364
|
awarenessDoc.destroy();
|