@jxsuite/collab 0.5.1 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/collab",
3
- "version": "0.5.1",
3
+ "version": "0.7.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": {
@@ -19,6 +19,7 @@
19
19
  "./op-bridge": "./src/op-bridge.ts",
20
20
  "./diff": "./src/diff.ts",
21
21
  "./envelope": "./src/envelope.ts",
22
+ "./negotiate": "./src/negotiate.ts",
22
23
  "./awareness-types": "./src/awareness-types.ts",
23
24
  "./provider": "./src/provider.ts",
24
25
  "./client": "./src/ws-client.ts",
@@ -32,7 +33,7 @@
32
33
  "test:coverage": "bun test --isolate --coverage"
33
34
  },
34
35
  "dependencies": {
35
- "@jxsuite/schema": "^1.4.0",
36
+ "@jxsuite/schema": "^1.6.0",
36
37
  "lib0": "^0.2.99",
37
38
  "y-protocols": "^1.0.6",
38
39
  "yjs": "13.6.27"
@@ -21,8 +21,14 @@ export interface CollabAwarenessState {
21
21
  focusedPath: string | null;
22
22
  /** Which representation the client is editing (drives the Phase-B canonical lock). */
23
23
  mode?: "structure" | "source";
24
- /** The client's structural (canvas) selection in the focused document. */
25
- structuralSelection?: JxPath | null;
24
+ /**
25
+ * The client's structural (canvas) selection in the focused document — the whole SET.
26
+ *
27
+ * A list because `session.selection` is one (studio §6.5): a peer who has selected six cards
28
+ * publishes six paths, and the remote canvas draws a presence box for each. A peer with one node
29
+ * selected publishes a one-entry list, which draws exactly the one box it always did.
30
+ */
31
+ structuralSelection?: JxPath[] | null;
26
32
  /**
27
33
  * RESERVED for y-monaco: the in-buffer text cursor as {anchor, head} Y.RelativePosition JSON.
28
34
  * MonacoBinding owns this field via setLocalStateField("selection", …) while a code view is
package/src/envelope.ts CHANGED
@@ -14,11 +14,17 @@
14
14
  * are dropped and answered with a `doc-reset` control message, upon which the client destroys its
15
15
  * Y.Doc and re-opens. The invariant "Y history is never deleted or replaced without an epoch bump"
16
16
  * is what prevents divergent-history duplicate-content merges.
17
+ *
18
+ * The layout above is what the `jx.collab.v1` WebSocket subprotocol names. A change that would make
19
+ * a peer mis-parse a frame is a new token, not a silent edit here — see `negotiate.ts` for the bump
20
+ * rule and for why the capability probe, not the socket, is where the two sides agree.
17
21
  */
18
22
 
19
23
  import * as decoding from "lib0/decoding";
20
24
  import * as encoding from "lib0/encoding";
21
25
 
26
+ export { COLLAB_SUBPROTOCOL } from "./negotiate.ts";
27
+
22
28
  export const FRAME_DOC_SYNC = 0;
23
29
  export const FRAME_AWARENESS = 1;
24
30
  export const FRAME_DOC_CLOSE = 2;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * WebSocket subprotocol negotiation for the collab socket (RFC 6455 §1.9, §4.2.2).
3
+ *
4
+ * **The token names the wire envelope, not the package.** `jx.collab.v1` means "I speak the frame
5
+ * layout `envelope.ts` documents". There is one token per envelope **major**, and the rule for
6
+ * bumping it is narrow on purpose: bump when {@link decodeFrame} would _mis-parse_ a peer's frame —
7
+ * a field reordered, a type widened, a length prefix changed. **Do not bump for a new frame type.**
8
+ * Both halves already skip a frame type they do not know, so a v1 peer and a v1-plus-one-frame peer
9
+ * interoperate; bumping there would refuse a room that would have worked.
10
+ *
11
+ * **Why the capability probe does the negotiating.** RFC 6455 §4.1 requires a client that offered
12
+ * subprotocols and received no echo to _fail the connection_, and browsers enforce it. So a new
13
+ * Studio that unconditionally offered `jx.collab.v1` would lose collab entirely against an older
14
+ * dev server — a regression caused by the fix. The probe already exists, already returns a version
15
+ * the client discards, and runs before the socket: it is the one place the client can learn what
16
+ * the server speaks without risking the handshake. Hence {@link negotiateCollab}, which turns a
17
+ * probe body into the list to offer — empty against a server that advertises nothing.
18
+ *
19
+ * **Nothing here may go in the `hello` control message.** That arrives after the socket is up and
20
+ * after frames may already have been exchanged, which is too late to prevent the divergent-history
21
+ * merge `schema.ts` warns about. Negotiation has to conclude before a byte of document state
22
+ * moves.
23
+ *
24
+ * This module imports nothing, deliberately: the Studio platform adapters read it eagerly, before
25
+ * the dynamic import that pulls in Yjs, so it must not drag the CRDT into the initial bundle.
26
+ */
27
+
28
+ /** The subprotocol token for the envelope in `envelope.ts`. One token per envelope major. */
29
+ export const COLLAB_SUBPROTOCOL = "jx.collab.v1";
30
+
31
+ /** What the client should do with the socket, given what the server advertised. */
32
+ export interface CollabNegotiation {
33
+ /**
34
+ * The subprotocols to offer as `Sec-WebSocket-Protocol`. **Empty means offer none**, which is the
35
+ * correct answer against a server that advertises none: an offer it cannot echo would fail the
36
+ * handshake outright, where offering nothing connects exactly as it does today.
37
+ */
38
+ offer: string[];
39
+ /**
40
+ * Non-null when the peers cannot interoperate: the server advertised subprotocols and none of
41
+ * them is one we speak. The client must not connect — the string says which side speaks what, so
42
+ * the degradation is legible rather than a silent solo session.
43
+ */
44
+ refused: string | null;
45
+ }
46
+
47
+ /**
48
+ * Decide what to offer, from the body of a `GET /__studio/collab` capability probe.
49
+ *
50
+ * @param {unknown} probe The parsed probe body, or null if it did not parse as JSON.
51
+ * @returns {CollabNegotiation}
52
+ */
53
+ export function negotiateCollab(probe: unknown): CollabNegotiation {
54
+ if (probe === null || typeof probe !== "object") {
55
+ // A 200 that is not a JSON object: pre-negotiation server. Connect as before, offering nothing.
56
+ return { offer: [], refused: null };
57
+ }
58
+ const body = probe as { collab?: unknown; protocols?: unknown };
59
+ if (body.collab === false) {
60
+ return { offer: [], refused: "This server has collaboration disabled." };
61
+ }
62
+ const { protocols } = body;
63
+ if (!Array.isArray(protocols) || protocols.length === 0) {
64
+ // A server that predates negotiation. It would echo nothing, so we must offer nothing.
65
+ return { offer: [], refused: null };
66
+ }
67
+ if (protocols.includes(COLLAB_SUBPROTOCOL)) {
68
+ return { offer: [COLLAB_SUBPROTOCOL], refused: null };
69
+ }
70
+ const theirs = protocols.filter((p) => typeof p === "string").join(", ") || "none";
71
+ return {
72
+ offer: [],
73
+ refused: `This server speaks collab envelope ${theirs}; this editor speaks ${COLLAB_SUBPROTOCOL}.`,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * The subprotocols a client offered, from its `Sec-WebSocket-Protocol` request header.
79
+ *
80
+ * RFC 6455 §4.1 allows the field to be split across several header lines and comma-separated within
81
+ * one; `Headers.get` already joins repeats with `, `, so one split covers both spellings.
82
+ *
83
+ * @param {string | null} header
84
+ * @returns {string[]}
85
+ */
86
+ export function offeredSubprotocols(header: string | null): string[] {
87
+ if (!header) {
88
+ return [];
89
+ }
90
+ return header
91
+ .split(",")
92
+ .map((token) => token.trim())
93
+ .filter((token) => token !== "");
94
+ }
95
+
96
+ /** What the server should do with an upgrade, given the client's offer. */
97
+ export interface SubprotocolAnswer {
98
+ /** The token to echo in the 101 response, or null to echo nothing. */
99
+ echo: string | null;
100
+ /** Non-null when the handshake must be refused instead of upgraded. */
101
+ reject: string | null;
102
+ }
103
+
104
+ /**
105
+ * Choose the subprotocol to echo (RFC 6455 §4.2.2 step 4).
106
+ *
107
+ * Three cases, and the middle one is the one that is easy to get wrong:
108
+ *
109
+ * - **No offer** — an older client. Upgrade and echo nothing; it is not expecting an echo, and
110
+ * refusing it would break every Studio built before this shipped.
111
+ * - **An offer we speak** — echo exactly that token. Echoing a token the client did not offer is
112
+ * itself a protocol violation, so the echo is always a member of the offer.
113
+ * - **An offer we do not speak** — refuse. This is the whole point: two peers whose envelopes
114
+ * disagree about merge granularity (see `collab.md` §3.1) must not end up in one room, and a
115
+ * refused handshake is a far better outcome than a divergent-history merge.
116
+ *
117
+ * @param {string[]} offered
118
+ * @returns {SubprotocolAnswer}
119
+ */
120
+ export function selectSubprotocol(offered: string[]): SubprotocolAnswer {
121
+ if (offered.length === 0) {
122
+ return { echo: null, reject: null };
123
+ }
124
+ if (offered.includes(COLLAB_SUBPROTOCOL)) {
125
+ return { echo: COLLAB_SUBPROTOCOL, reject: null };
126
+ }
127
+ return {
128
+ echo: null,
129
+ reject: `No supported collab subprotocol offered (this server speaks ${COLLAB_SUBPROTOCOL}).`,
130
+ };
131
+ }
package/src/op-bridge.ts CHANGED
@@ -14,7 +14,17 @@
14
14
  import * as Y from "yjs";
15
15
  import type { JxPath } from "@jxsuite/schema/types";
16
16
  import type { JxDocOp } from "./ops.ts";
17
- import { resolveYPath, toYChildren, toYNode, yValueToJson } from "./schema.ts";
17
+ import {
18
+ applyTextEdit,
19
+ GRANULAR_OBJECT_KEYS,
20
+ mergeYObject,
21
+ resolveYPath,
22
+ TEXT_KEYS,
23
+ toYChild,
24
+ toYChildren,
25
+ toYObject,
26
+ yValueToJson,
27
+ } from "./schema.ts";
18
28
 
19
29
  /** Origin of Y transactions produced by mirroring the local tab's ops. */
20
30
  export const LOCAL_ORIGIN = "jx-local";
@@ -59,8 +69,47 @@ function cloneJson<T>(value: T): T {
59
69
  return JSON.parse(JSON.stringify(value)) as T;
60
70
  }
61
71
 
62
- function toYChild(node: unknown): unknown {
63
- return typeof node === "string" ? node : toYNode(node);
72
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
73
+ return typeof value === "object" && value !== null && !Array.isArray(value);
74
+ }
75
+
76
+ /**
77
+ * Write a node key, preserving CRDT identity wherever the schema stores it granularly.
78
+ *
79
+ * Studio's mutators record whole-value `set-key` ops — `mutateUpdateStyle` replaces the entire
80
+ * style object, an inline-edit commit replaces the entire `textContent`. Writing those straight
81
+ * through with `Y.Map.set` mints a fresh Y type each time and throws away whatever a peer
82
+ * concurrently did to the old one, which is exactly how "two people typing in one paragraph" became
83
+ * last-writer-wins.
84
+ *
85
+ * So a whole-value op is diffed down onto the existing structure: text through {@link applyTextEdit}
86
+ * (character-level), granular objects through {@link mergeYObject} (per-property). Only a TYPE
87
+ * change — string text becoming a `$ref`, an object becoming a scalar — falls back to replacement,
88
+ * because then there is no shared structure left to preserve.
89
+ */
90
+ function setNodeKey(node: Y.Map<unknown>, key: string, value: unknown): void {
91
+ const existing = node.get(key);
92
+ if (key === "children" && Array.isArray(value)) {
93
+ node.set(key, toYChildren(value));
94
+ return;
95
+ }
96
+ if (TEXT_KEYS.has(key) && typeof value === "string") {
97
+ if (existing instanceof Y.Text) {
98
+ applyTextEdit(existing, value);
99
+ } else {
100
+ node.set(key, new Y.Text(value));
101
+ }
102
+ return;
103
+ }
104
+ if (GRANULAR_OBJECT_KEYS.has(key) && isPlainObject(value)) {
105
+ if (existing instanceof Y.Map) {
106
+ mergeYObject(existing, value);
107
+ } else {
108
+ node.set(key, toYObject(value));
109
+ }
110
+ return;
111
+ }
112
+ node.set(key, cloneJson(value));
64
113
  }
65
114
 
66
115
  function applyOneOp(doc: Y.Doc, op: JxDocOp): void {
@@ -69,10 +118,8 @@ function applyOneOp(doc: Y.Doc, op: JxDocOp): void {
69
118
  const node = requireNode(doc, op.path);
70
119
  if (op.value === undefined) {
71
120
  node.delete(op.key);
72
- } else if (op.key === "children" && Array.isArray(op.value)) {
73
- node.set(op.key, toYChildren(op.value));
74
121
  } else {
75
- node.set(op.key, cloneJson(op.value));
122
+ setNodeKey(node, op.key, op.value);
76
123
  }
77
124
  return;
78
125
  }
@@ -97,6 +144,14 @@ function applyOneOp(doc: Y.Doc, op: JxDocOp): void {
97
144
  if (op.index < 0 || op.index >= children.length) {
98
145
  throw new CollabPathError([...op.parentPath, "children", op.index]);
99
146
  }
147
+ // Text child → text child: edit the existing Y.Text in place. A delete+insert would replace the
148
+ // Whole element and discard a peer's concurrent characters, which is the same clobber
149
+ // `setNodeKey` avoids for `textContent` — bare-string children are the other half of prose.
150
+ const existing = children.get(op.index);
151
+ if (existing instanceof Y.Text && typeof op.node === "string") {
152
+ applyTextEdit(existing, op.node);
153
+ return;
154
+ }
100
155
  children.delete(op.index, 1);
101
156
  children.insert(op.index, [toYChild(op.node)]);
102
157
  return;
@@ -139,6 +194,34 @@ export function applyDocOpsToY(doc: Y.Doc, ops: readonly JxDocOp[], origin: unkn
139
194
  }, origin);
140
195
  }
141
196
 
197
+ /**
198
+ * When `path` addresses a granular container (or something nested inside one), the node that owns
199
+ * it and the key it sits under; otherwise null.
200
+ *
201
+ * The event path is relative to the structure map, so the first segment matching a granular key
202
+ * marks the boundary: everything before it is the node path, and anything after it is interior
203
+ * detail the op log does not model.
204
+ *
205
+ * `children`/index segments need no special handling: they are not granular keys, so the scan
206
+ * passes over them and the returned node path keeps the full prefix — `["children", 0, "style"]`
207
+ * resolves to the style of the node at `children/0`, not of the root.
208
+ */
209
+ function granularOwner(path: JxPath): { nodePath: JxPath; key: string } | null {
210
+ for (let i = 0; i < path.length; i++) {
211
+ const segment = path[i];
212
+ if (typeof segment !== "string") {
213
+ continue;
214
+ }
215
+ if (GRANULAR_OBJECT_KEYS.has(segment)) {
216
+ return { key: segment, nodePath: path.slice(0, i) };
217
+ }
218
+ if (TEXT_KEYS.has(segment)) {
219
+ return { key: segment, nodePath: path.slice(0, i) };
220
+ }
221
+ }
222
+ return null;
223
+ }
224
+
142
225
  function isStructurePath(segments: readonly (string | number)[]): segments is JxPath {
143
226
  return segments.every((seg) => typeof seg === "string" || typeof seg === "number");
144
227
  }
@@ -179,6 +262,45 @@ export function yEventsToDocOps(events: readonly Y.YEvent<never>[]): JxDocOp[] |
179
262
  const ops: JxDocOp[] = [];
180
263
  for (const event of events) {
181
264
  const path = event.path as JxPath;
265
+ /*
266
+ * A remote edit inside a granular container (a `Y.Text` for prose, a nested `Y.Map` for
267
+ * style/attributes/$props) reports its event at the CONTAINER's path, not the node's. Studio's op
268
+ * vocabulary addresses nodes, so those collapse back to one whole-value op for the owning key —
269
+ * the same shape a local mutator would have produced. Granularity buys merge; it deliberately does
270
+ * not leak into the op log, the canvas patcher, or the history ring.
271
+ */
272
+ const granular = granularOwner(path);
273
+ if (granular) {
274
+ const { doc } = event.target as { doc?: Y.Doc | null };
275
+ if (!doc) {
276
+ return null;
277
+ }
278
+ const container = resolveYPath(doc, [...granular.nodePath, granular.key]);
279
+ if (container === undefined) {
280
+ return null;
281
+ }
282
+ ops.push({
283
+ key: granular.key,
284
+ op: "set-key",
285
+ path: granular.nodePath,
286
+ value: yValueToJson(container),
287
+ });
288
+ continue;
289
+ }
290
+ if (event instanceof Y.YTextEvent) {
291
+ // A bare-string child: the text IS the child, so it round-trips as a child replacement.
292
+ const index = path.at(-1);
293
+ if (path.at(-2) === "children" && typeof index === "number") {
294
+ ops.push({
295
+ index,
296
+ node: (event.target as Y.Text).toString(),
297
+ op: "set-child",
298
+ parentPath: path.slice(0, -2),
299
+ });
300
+ continue;
301
+ }
302
+ return null;
303
+ }
182
304
  if (event instanceof Y.YMapEvent) {
183
305
  const target = event.target as Y.Map<unknown>;
184
306
  for (const [key, change] of event.changes.keys) {
package/src/schema.ts CHANGED
@@ -4,15 +4,24 @@
4
4
  * Layout (all top-level types, created on demand): - `meta: Y.Map` — schemaVersion,
5
5
  * structureSeeded, canonical ("structure" | "source"), canonicalRev (representation-flip counter —
6
6
  * distinct from the provider's wire docEpoch), sourceFormat. - `frontmatter: Y.Map` — per-field
7
- * plain-JSON values (LWW per field). - `structure: Y.Map` — the root JxMutableNode. Every node is a
8
- * Y.Map whose ONLY nested Y container is "children" (a Y.Array of node Y.Maps and plain strings);
9
- * every other key holds a plain deep-JSON value merged whole (LWW). The CRDT granularity
10
- * deliberately equals Studio's op-log granularity — mutators emit whole-value set-keys and children
11
- * splices, so deeper Y types would add merge surface nothing can target. - `source: Y.Text` — the
12
- * serialized source. THE authoritative file content: providers persist
7
+ * plain-JSON values (LWW per field). - `structure: Y.Map` — the root JxMutableNode. - `source:
8
+ * Y.Text` — the serialized source. THE authoritative file content: providers persist
13
9
  * `getText("source").toString()` and never parse or serialize Jx documents themselves; the
14
10
  * structure tree is a Studio-side projection kept in sync by the bridge.
15
11
  *
12
+ * **CRDT granularity is deliberately FINER than Studio's op-log granularity.** Every node is a
13
+ * Y.Map; within it, "children" is a Y.Array, {@link TEXT_KEYS} are `Y.Text`,
14
+ * {@link GRANULAR_OBJECT_KEYS} are nested Y.Maps, and everything else is a whole-JSON LWW value.
15
+ *
16
+ * The two granularities used to match, on the reasoning that mutators emit whole-value set-keys so
17
+ * deeper Y types would add merge surface nothing targets. That reasoning was wrong: it conflated
18
+ * what an EDIT addresses with what a MERGE must resolve. Two authors typing in one paragraph, or
19
+ * setting `color` and `padding` on one element, produce whole-value ops that a whole-value CRDT can
20
+ * only resolve by discarding one of them. The bridge therefore diffs a whole-value op down onto the
21
+ * granular representation ({@link applyTextEdit}, {@link mergeYObject}) and {@link yValueToJson}
22
+ * reassembles whole values on the way out — so `JxDocOp`, the canvas patcher and the history ring
23
+ * are unchanged, while concurrent edits actually merge.
24
+ *
16
25
  * Seeding contract: providers seed only `source` (from file bytes). Clients derive `structure` on
17
26
  * first sync via {@link seedStructure}, which is convergence-safe under concurrent seeders because
18
27
  * it only performs whole-key Y.Map sets (per-key LWW picks one seeder's subtrees wholesale) — never
@@ -27,7 +36,16 @@ export const FRONTMATTER_KEY = "frontmatter";
27
36
  export const STRUCTURE_KEY = "structure";
28
37
  export const SOURCE_KEY = "source";
29
38
 
30
- export const COLLAB_SCHEMA_VERSION = 1;
39
+ /**
40
+ * Bumped to 2 when prose moved to `Y.Text` and style/attributes/$props to nested `Y.Map`s.
41
+ *
42
+ * Written into `meta` at seed time and not currently validated on load. A room persisted under 1
43
+ * still READS correctly ({@link yValueToJson} passes plain values through) and self-heals on the
44
+ * first edit to a given key, since the bridge replaces a non-granular container. Two clients
45
+ * running DIFFERENT versions in one room would disagree about merge granularity — see §5 of
46
+ * specs/collab.md on version skew, which this does not attempt to solve.
47
+ */
48
+ export const COLLAB_SCHEMA_VERSION = 2;
31
49
 
32
50
  export function metaMap(doc: Y.Doc): Y.Map<unknown> {
33
51
  return doc.getMap(META_KEY);
@@ -55,17 +73,137 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
55
73
  return typeof value === "object" && value !== null && !Array.isArray(value);
56
74
  }
57
75
 
58
- /** A children array becomes a Y.Array of node Y.Maps and plain strings. */
76
+ /**
77
+ * Node keys whose STRING value is stored as a `Y.Text` rather than an opaque string.
78
+ *
79
+ * This is the character-level-collaboration decision. Whole-paragraph last-writer-wins was never a
80
+ * Yjs limitation — it was this schema storing prose as a plain LWW value, so two people typing in
81
+ * one paragraph clobbered each other and only `source` (which the canvas does not edit) merged per
82
+ * character.
83
+ *
84
+ * `innerHTML` is deliberately absent: it is an opaque markup blob, never caret-edited.
85
+ */
86
+ export const TEXT_KEYS: ReadonlySet<string> = new Set(["textContent"]);
87
+
88
+ /**
89
+ * Node keys stored as a nested `Y.Map` so concurrent edits to DIFFERENT properties both survive.
90
+ *
91
+ * `mutateUpdateStyle` and friends record a whole-object `set-key`, so writing the object as one LWW
92
+ * value meant A's `color` and B's `padding` raced and one lost. Per-property merge fixes that
93
+ * without touching the op vocabulary: {@link yValueToJson} reassembles the whole object on the way
94
+ * out, so `JxDocOp`, the canvas patcher and the history ring keep seeing whole values.
95
+ *
96
+ * Declaration VALUES stay plain (a CSS value is never partially edited); only the key space is
97
+ * granular. Nesting recurses, which covers `style`'s media (`@--sm`) and pseudo (`&:hover`)
98
+ * blocks.
99
+ */
100
+ export const GRANULAR_OBJECT_KEYS: ReadonlySet<string> = new Set(["style", "attributes", "$props"]);
101
+
102
+ /**
103
+ * Rewrite a `Y.Text` to `next` with a minimal edit (common prefix/suffix trimmed).
104
+ *
105
+ * Minimal rather than replace-all for two reasons: the wire carries only the delta, and — the
106
+ * load-bearing one — the surviving characters keep their identity, so a concurrent remote insert
107
+ * inside the untouched region merges instead of being clobbered. Returns false when already equal.
108
+ */
109
+ export function applyTextEdit(text: Y.Text, next: string): boolean {
110
+ const current = text.toString();
111
+ if (current === next) {
112
+ return false;
113
+ }
114
+ let start = 0;
115
+ const minLen = Math.min(current.length, next.length);
116
+ while (start < minLen && current[start] === next[start]) {
117
+ start += 1;
118
+ }
119
+ let endCurrent = current.length;
120
+ let endNext = next.length;
121
+ while (endCurrent > start && endNext > start && current[endCurrent - 1] === next[endNext - 1]) {
122
+ endCurrent -= 1;
123
+ endNext -= 1;
124
+ }
125
+ if (endCurrent > start) {
126
+ text.delete(start, endCurrent - start);
127
+ }
128
+ if (endNext > start) {
129
+ text.insert(start, next.slice(start, endNext));
130
+ }
131
+ return true;
132
+ }
133
+
134
+ /** A plain object becomes a Y.Map, recursing into nested objects; other values stay plain JSON. */
135
+ export function toYObject(value: Record<string, unknown>): Y.Map<unknown> {
136
+ const map = new Y.Map<unknown>();
137
+ for (const [key, entry] of Object.entries(value)) {
138
+ if (entry === undefined) {
139
+ continue;
140
+ }
141
+ map.set(key, isPlainObject(entry) ? toYObject(entry) : cloneJson(entry));
142
+ }
143
+ return map;
144
+ }
145
+
146
+ /**
147
+ * Merge a plain object into an existing Y.Map per key: set changed keys, delete removed ones,
148
+ * recurse into nested maps. Whole-value replacement would mint a new Y.Map and discard a peer's
149
+ * concurrent edit to an untouched sibling property — the very thing granularity exists to prevent.
150
+ */
151
+ export function mergeYObject(map: Y.Map<unknown>, value: Record<string, unknown>): void {
152
+ // Detached key list: deleting from a Y.Map while iterating its live keys is unsafe.
153
+ // oxlint-disable-next-line unicorn/no-useless-spread -- the copy is the point (mutation during iteration)
154
+ for (const key of [...map.keys()]) {
155
+ if (!(key in value) || value[key] === undefined) {
156
+ map.delete(key);
157
+ }
158
+ }
159
+ for (const [key, entry] of Object.entries(value)) {
160
+ if (entry === undefined) {
161
+ continue;
162
+ }
163
+ const existing = map.get(key);
164
+ if (isPlainObject(entry)) {
165
+ if (existing instanceof Y.Map) {
166
+ mergeYObject(existing, entry);
167
+ } else {
168
+ map.set(key, toYObject(entry));
169
+ }
170
+ } else if (!deepEqualJson(existing, entry)) {
171
+ map.set(key, cloneJson(entry));
172
+ }
173
+ }
174
+ }
175
+
176
+ /** Structural equality over plain JSON, used to skip no-op key writes. */
177
+ function deepEqualJson(a: unknown, b: unknown): boolean {
178
+ if (a === b) {
179
+ return true;
180
+ }
181
+ if (a instanceof Y.Map || a instanceof Y.Array || a instanceof Y.Text) {
182
+ return false;
183
+ }
184
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
185
+ }
186
+
187
+ /** A child value: a bare string becomes a Y.Text (per-character merge), a node becomes a Y.Map. */
188
+ export function toYChild(child: unknown): unknown {
189
+ return typeof child === "string" ? new Y.Text(child) : toYNode(child);
190
+ }
191
+
192
+ /** A children array becomes a Y.Array of node Y.Maps and Y.Texts. */
59
193
  export function toYChildren(children: readonly unknown[]): Y.Array<unknown> {
60
194
  const arr = new Y.Array<unknown>();
61
195
  arr.insert(
62
196
  0,
63
- children.map((child) => (typeof child === "string" ? child : toYNode(child))),
197
+ children.map((child) => toYChild(child)),
64
198
  );
65
199
  return arr;
66
200
  }
67
201
 
68
- /** A node object becomes a Y.Map; only its "children" key nests further Y types. */
202
+ /**
203
+ * A node object becomes a Y.Map. Nesting is no longer limited to `children`: text keys become
204
+ * `Y.Text` and {@link GRANULAR_OBJECT_KEYS} become nested `Y.Map`s, so the CRDT merges at the
205
+ * granularity edits actually happen at. Everything else stays a whole-JSON LWW value.
206
+ */
69
207
  export function toYNode(node: unknown): Y.Map<unknown> {
70
208
  if (!isPlainObject(node)) {
71
209
  throw new TypeError("collab-schema: a document node must be a plain object");
@@ -77,6 +215,10 @@ export function toYNode(node: unknown): Y.Map<unknown> {
77
215
  }
78
216
  if (key === "children" && Array.isArray(value)) {
79
217
  map.set(key, toYChildren(value));
218
+ } else if (TEXT_KEYS.has(key) && typeof value === "string") {
219
+ map.set(key, new Y.Text(value));
220
+ } else if (GRANULAR_OBJECT_KEYS.has(key) && isPlainObject(value)) {
221
+ map.set(key, toYObject(value));
80
222
  } else {
81
223
  map.set(key, cloneJson(value));
82
224
  }
@@ -84,7 +226,7 @@ export function toYNode(node: unknown): Y.Map<unknown> {
84
226
  return map;
85
227
  }
86
228
 
87
- /** Convert any structure value (Y.Map node, Y.Array children, or plain leaf) back to JSON. */
229
+ /** Convert any structure value (Y.Map, Y.Array, Y.Text, or plain leaf) back to JSON. */
88
230
  export function yValueToJson(value: unknown): unknown {
89
231
  if (value instanceof Y.Map) {
90
232
  const out: Record<string, unknown> = {};
@@ -96,6 +238,9 @@ export function yValueToJson(value: unknown): unknown {
96
238
  if (value instanceof Y.Array) {
97
239
  return value.toArray().map((entry) => yValueToJson(entry));
98
240
  }
241
+ if (value instanceof Y.Text) {
242
+ return value.toString();
243
+ }
99
244
  return value;
100
245
  }
101
246
 
@@ -132,28 +277,11 @@ export function resolveYPath(doc: Y.Doc, path: JxPath): unknown {
132
277
  */
133
278
  export function updateSourceText(doc: Y.Doc, next: string, origin?: unknown): boolean {
134
279
  const text = sourceText(doc);
135
- const current = text.toString();
136
- if (current === next) {
280
+ if (text.toString() === next) {
137
281
  return false;
138
282
  }
139
- let start = 0;
140
- const minLen = Math.min(current.length, next.length);
141
- while (start < minLen && current[start] === next[start]) {
142
- start += 1;
143
- }
144
- let endCurrent = current.length;
145
- let endNext = next.length;
146
- while (endCurrent > start && endNext > start && current[endCurrent - 1] === next[endNext - 1]) {
147
- endCurrent -= 1;
148
- endNext -= 1;
149
- }
150
283
  doc.transact(() => {
151
- if (endCurrent > start) {
152
- text.delete(start, endCurrent - start);
153
- }
154
- if (endNext > start) {
155
- text.insert(start, next.slice(start, endNext));
156
- }
284
+ applyTextEdit(text, next);
157
285
  }, origin);
158
286
  return true;
159
287
  }
package/src/ws-client.ts CHANGED
@@ -34,8 +34,17 @@ export interface WsLike {
34
34
  export interface WsCollabConnectionOptions {
35
35
  /** The ws(s):// endpoint for this project's collab socket. */
36
36
  url: string;
37
+ /**
38
+ * Subprotocols to offer (`Sec-WebSocket-Protocol`), normally `negotiateCollab`'s `offer`.
39
+ *
40
+ * Left empty or undefined deliberately when the server advertised none: RFC 6455 §4.1 makes a
41
+ * client that offers a subprotocol and receives no echo fail the connection, so an unconditional
42
+ * offer would break collab against every server built before negotiation shipped. The caller
43
+ * decides, from the capability probe, and `negotiate.ts` holds the reasoning.
44
+ */
45
+ protocols?: string[];
37
46
  /** WebSocket constructor override (tests, non-browser runtimes). */
38
- webSocketImpl?: new (url: string) => WsLike;
47
+ webSocketImpl?: new (url: string, protocols?: string[]) => WsLike;
39
48
  /**
40
49
  * Called when the server refuses a doc with content-not-loaded: hydrate the file over HTTP (which
41
50
  * caches it server-side) so the retry can seed. One retry per open.
@@ -74,7 +83,8 @@ interface DocEntry {
74
83
 
75
84
  export function createWsCollabConnection(options: WsCollabConnectionOptions): WsCollabConnection {
76
85
  const WebSocketCtor =
77
- options.webSocketImpl ?? (globalThis.WebSocket as unknown as new (url: string) => WsLike);
86
+ options.webSocketImpl ??
87
+ (globalThis.WebSocket as unknown as new (url: string, protocols?: string[]) => WsLike);
78
88
  const openTimeoutMs = options.openTimeoutMs ?? 10_000;
79
89
  const docs = new Map<string, DocEntry>();
80
90
  const statusCbs = new Set<(status: CollabStatus) => void>();
@@ -257,7 +267,13 @@ export function createWsCollabConnection(options: WsCollabConnectionOptions): Ws
257
267
  return;
258
268
  }
259
269
  setStatus("connecting");
260
- const ws = new WebSocketCtor(options.url);
270
+ /* Re-offered on every reconnect: a socket that comes back after the server was restarted must
271
+ negotiate again, and the offer is a property of this client's envelope, not of one socket. */
272
+ const offer = options.protocols;
273
+ const ws =
274
+ offer === undefined || offer.length === 0
275
+ ? new WebSocketCtor(options.url)
276
+ : new WebSocketCtor(options.url, offer);
261
277
  socket = ws;
262
278
  ws.binaryType = "arraybuffer";
263
279
  /* Each connect() builds a FRESH socket; single-assignment handlers are the point (and keep