@jxsuite/collab 0.9.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/README.md CHANGED
@@ -24,7 +24,7 @@ The CRDT granularity deliberately equals Studio's op-log granularity: mutators e
24
24
  - `applyDocOpsToY(doc, ops, origin)` replays Studio's recorded forward `JxDocOp`s onto the Y tree in one origin-tagged transaction (`LOCAL_ORIGIN` for user edits, `MIRROR_ORIGIN` for derived writes, `SEED_ORIGIN` for bootstraps).
25
25
  - `yEventsToDocOps(events)` converts a remote transaction's deep events back into `JxDocOp`s. Call it **inside** the `observeDeep` callback. It returns `null` for shapes it cannot convert safely; reconcile by diffing instead.
26
26
  - `diffDocs(a, b)` produces ops transforming `a` into `b` (invariant: `apply(clone(a), diff(a,b)) ≡ b`); returns `null` past `maxOps`, in which case `replaceYStructure()` hard-replaces and bumps `meta.canonicalRev` so stale mirror writes are discarded.
27
- - `applyDocOpToDoc` (in `./ops`, yjs-free) is the canonical plain-JSON applier shared with Studio's history replay and canvas shadow doc.
27
+ - `applyDocOpToDoc`, `inverseOf` and `applyDocOpsWithInverse` (in `./ops`, yjs-free) re-export the canonical plain-JSON op vocabulary from `@jxsuite/schema/doc-ops`, shared with Studio's history replay and the canvas shadow doc, so a host without yjs can apply the same ops.
28
28
 
29
29
  ## The wire envelope (`./envelope`)
30
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/collab",
3
- "version": "0.9.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": {
@@ -34,9 +35,9 @@
34
35
  "test:coverage": "bun test --isolate --coverage"
35
36
  },
36
37
  "dependencies": {
37
- "@jxsuite/schema": "^2.2.0",
38
- "lib0": "^0.2.117",
38
+ "@jxsuite/schema": "^2.3.0",
39
+ "lib0": "^0.2.119",
39
40
  "y-protocols": "^1.0.7",
40
- "yjs": "13.6.32"
41
+ "yjs": "13.6.33"
41
42
  }
42
43
  }
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) {
package/src/ops.ts CHANGED
@@ -1,128 +1,16 @@
1
1
  /**
2
- * The canonical Jx document-op vocabulary and its pure applier. A `JxDocOp` is a value-carrying,
3
- * replayable mutation of a plain-JSON Jx document tree, addressed by `JxPath` (alternating
4
- * "children" / index segments for structure, plus node keys). Studio's transaction pipeline records
5
- * forward/inverse pairs of these for history replay; the collab bridge mirrors them into a shared
6
- * Y.Doc.
2
+ * The Jx document-op vocabulary, re-exported from its home in `@jxsuite/schema/doc-ops`.
7
3
  *
8
- * This module is deliberately yjs-free and dependency-light: the slim canvas-iframe bundle imports
9
- * the applier for its non-reactive shadow doc, and it MUST stay byte-identical in behavior to the
10
- * parent's history replay — both sides import this one implementation.
4
+ * It moved so a host with no yjs (a Durable Object, a headless tool) can edit a document the way
5
+ * Studio does. This subpath stays so every existing `@jxsuite/collab/ops` import keeps working.
11
6
  */
12
7
 
13
- import type { JxMutableNode, JxPath } from "@jxsuite/schema/types";
14
-
15
- /** Value-carrying document mutation, replayable in either direction. */
16
- export type JxDocOp =
17
- /** Set (or delete, when value is undefined) a key on the node at path. */
18
- | { op: "set-key"; path: JxPath; key: string; value?: unknown }
19
- /** Insert a node at parentPath.children[index]. */
20
- | { op: "insert-child"; parentPath: JxPath; index: number; node: unknown }
21
- /** Remove parentPath.children[index]. */
22
- | { op: "remove-child"; parentPath: JxPath; index: number }
23
- /** Replace parentPath.children[index] with node. */
24
- | { op: "set-child"; parentPath: JxPath; index: number; node: unknown }
25
- /** Raw two-splice move: remove fromParent.children[fromIndex], insert at toParent[toIndex]. */
26
- | {
27
- op: "move-child";
28
- fromParentPath: JxPath;
29
- fromIndex: number;
30
- toParentPath: JxPath;
31
- toIndex: number;
32
- };
33
-
34
- export interface JxDocOpPair {
35
- forward: JxDocOp;
36
- inverse: JxDocOp;
37
- }
38
-
39
- /** JSON round-trip clone — also normalizes away reactive proxies / functions / undefined. */
40
- function jsonClone<T>(value: T): T {
41
- // oxlint-disable-next-line unicorn/prefer-structured-clone -- structuredClone throws on reactive proxies; JSON normalization is the point
42
- return JSON.parse(JSON.stringify(value)) as T;
43
- }
44
-
45
- /** Deep-clone a recorded value (undefined/null pass through; reactive proxies are read through). */
46
- export function cloneValue<T>(v: T): T {
47
- return v === undefined || v === null ? v : (jsonClone(v as object) as T);
48
- }
49
-
50
- /**
51
- * Walk a plain document tree by path. Paths address nodes by construction; missing segments and
52
- * non-node leaves surface as undefined (typed as node for caller ergonomics, matching Studio's
53
- * historical helper).
54
- */
55
- export function getNodeAtPath(doc: JxMutableNode, path: JxPath): JxMutableNode {
56
- let node = doc;
57
- for (const key of path) {
58
- if (node == null) {
59
- return undefined as unknown as JxMutableNode;
60
- }
61
- node = node[key] as JxMutableNode;
62
- }
63
- return node;
64
- }
65
-
66
- /** The node's children array, lazily created; throws if `node` is itself a children array or mapped. */
67
- export function childArray(node: JxMutableNode): (JxMutableNode | string)[] {
68
- // Defense-in-depth: a path that resolves to a children array (rather than a node) would
69
- // Otherwise get a bogus `.children` property tacked on here, silently storing the insert where
70
- // Nothing renders. Callers must pass a node; fail loudly if they don't.
71
- if (Array.isArray(node)) {
72
- throw new TypeError("Cannot insert into a children array; parentPath must point at a node");
73
- }
74
- if (!node.children) {
75
- node.children = [];
76
- }
77
- if (!Array.isArray(node.children)) {
78
- throw new TypeError("Cannot insert into mapped-array children; edit the map template instead");
79
- }
80
- return node.children;
81
- }
82
-
83
- /**
84
- * Apply a replayable doc op to a bare document tree. Values/nodes are cloned in, so the tree never
85
- * aliases the op object. Throws (with a machine-readable reason) when a target path is missing.
86
- */
87
- export function applyDocOpToDoc(doc: JxMutableNode, op: JxDocOp): void {
88
- switch (op.op) {
89
- case "set-key": {
90
- const node = getNodeAtPath(doc, op.path);
91
- if (!node) {
92
- throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
93
- }
94
- const target = node as Record<string, unknown>;
95
- if (op.value === undefined) {
96
- delete target[op.key];
97
- } else {
98
- target[op.key] = cloneValue(op.value);
99
- }
100
- return;
101
- }
102
- case "insert-child": {
103
- const parent = getNodeAtPath(doc, op.parentPath);
104
- childArray(parent).splice(op.index, 0, cloneValue(op.node) as JxMutableNode);
105
- return;
106
- }
107
- case "remove-child": {
108
- const parent = getNodeAtPath(doc, op.parentPath);
109
- childArray(parent).splice(op.index, 1);
110
- return;
111
- }
112
- case "set-child": {
113
- const parent = getNodeAtPath(doc, op.parentPath);
114
- childArray(parent).splice(op.index, 1, cloneValue(op.node) as JxMutableNode);
115
- return;
116
- }
117
- case "move-child": {
118
- const fromParent = getNodeAtPath(doc, op.fromParentPath);
119
- const toParent = getNodeAtPath(doc, op.toParentPath);
120
- const [node] = childArray(fromParent).splice(op.fromIndex, 1);
121
- childArray(toParent).splice(op.toIndex, 0, node!);
122
- return;
123
- }
124
- default: {
125
- throw new Error(`unknown-doc-op:${(op as JxDocOp).op}`);
126
- }
127
- }
128
- }
8
+ export {
9
+ applyDocOpToDoc,
10
+ applyDocOpsWithInverse,
11
+ childArray,
12
+ cloneValue,
13
+ getNodeAtPath,
14
+ inverseOf,
15
+ } from "@jxsuite/schema/doc-ops";
16
+ export type { JxDocOp, JxDocOpPair } from "@jxsuite/schema/doc-ops";
@@ -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();