@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 +3 -2
- package/src/awareness-types.ts +8 -2
- package/src/envelope.ts +6 -0
- package/src/negotiate.ts +131 -0
- package/src/op-bridge.ts +128 -6
- package/src/schema.ts +158 -30
- package/src/ws-client.ts +19 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/collab",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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"
|
package/src/awareness-types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
25
|
-
|
|
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;
|
package/src/negotiate.ts
ADDED
|
@@ -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 {
|
|
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
|
|
63
|
-
return typeof
|
|
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
|
|
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.
|
|
8
|
-
* Y.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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) => (
|
|
197
|
+
children.map((child) => toYChild(child)),
|
|
64
198
|
);
|
|
65
199
|
return arr;
|
|
66
200
|
}
|
|
67
201
|
|
|
68
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|