@jxsuite/collab 0.11.1 → 0.11.3
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 +2 -2
- package/src/diff-core.ts +88 -88
- package/src/diff.ts +5 -6
- package/src/envelope.ts +14 -3
- package/src/negotiate.ts +16 -12
- package/src/op-bridge.ts +43 -30
- package/src/reconnect.ts +3 -7
- package/src/schema.ts +90 -46
- package/src/source-lock.ts +10 -2
- package/src/ws-client.ts +33 -10
- package/src/ws-room.ts +29 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/collab",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.3",
|
|
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": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"test:coverage": "bun test --isolate --coverage"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@jxsuite/schema": "^
|
|
38
|
+
"@jxsuite/schema": "^4.1.0",
|
|
39
39
|
"lib0": "^0.2.119",
|
|
40
40
|
"y-protocols": "^1.0.7",
|
|
41
41
|
"yjs": "13.6.33"
|
package/src/diff-core.ts
CHANGED
|
@@ -13,11 +13,12 @@
|
|
|
13
13
|
* identical siblings therefore degrade to remove+insert — exactly what the Y move mapping does, so
|
|
14
14
|
* no fidelity is lost relative to the wire.
|
|
15
15
|
*
|
|
16
|
-
* **This file
|
|
17
|
-
*
|
|
18
|
-
* imports
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* **This file imports nothing that reaches yjs, and that is its reason for existing.** Its only
|
|
17
|
+
* runtime import is the dependency-free `@jxsuite/schema/guards`. It was the top of `./diff.ts`,
|
|
18
|
+
* which value-imports `./schema.ts` for `replaceYStructure` alone — and `schema.ts` imports yjs. So
|
|
19
|
+
* every consumer of the alignment logic paid for yjs, which put the studio's diff highlighting
|
|
20
|
+
* (`canvas/diff-marks.ts`) behind a lazy import for a computation that never touches a Y type.
|
|
21
|
+
* `diff.ts` re-exports everything here, so nothing that already imported it changed.
|
|
21
22
|
*
|
|
22
23
|
* {@link matchChildren} is exported for that studio caller: `diffDocs` emits a REPLAY SCRIPT whose
|
|
23
24
|
* paths are post-splice coordinates valid only in `b`, and a side-by-side comparison needs both
|
|
@@ -25,7 +26,8 @@
|
|
|
25
26
|
* it would mean a second definition of "the same node".
|
|
26
27
|
*/
|
|
27
28
|
|
|
28
|
-
import
|
|
29
|
+
import { isJsonObject, isString } from "@jxsuite/schema/guards";
|
|
30
|
+
import type { JxDocumentValue, JxMutableNode, JxPath } from "@jxsuite/schema/types";
|
|
29
31
|
import type { JxDocOp } from "./ops.ts";
|
|
30
32
|
|
|
31
33
|
export interface DiffOptions {
|
|
@@ -36,22 +38,21 @@ export interface DiffOptions {
|
|
|
36
38
|
const DEFAULT_MAX_OPS = 500;
|
|
37
39
|
|
|
38
40
|
class MaxOpsExceededError extends Error {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
41
|
+
constructor() {
|
|
42
|
+
super();
|
|
43
|
+
this.name = "MaxOpsExceededError";
|
|
44
|
+
}
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
/** Order-insensitive for object keys, order-sensitive for arrays. */
|
|
47
|
-
export function deepEqual(a:
|
|
48
|
+
export function deepEqual(a: JxDocumentValue | undefined, b: JxDocumentValue | undefined): boolean {
|
|
48
49
|
if (a === b) {
|
|
49
50
|
return true;
|
|
50
51
|
}
|
|
51
52
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
52
53
|
return a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));
|
|
53
54
|
}
|
|
54
|
-
if (
|
|
55
|
+
if (isJsonObject(a) && isJsonObject(b)) {
|
|
55
56
|
const keysA = Object.keys(a);
|
|
56
57
|
const keysB = Object.keys(b);
|
|
57
58
|
return (
|
|
@@ -70,23 +71,22 @@ function contentHash(text: string): string {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
/** Full-content identity: equal strong keys mean "treat as the same unchanged child". */
|
|
73
|
-
function strongKey(item:
|
|
74
|
-
return
|
|
74
|
+
function strongKey(item: JxMutableNode | string): string {
|
|
75
|
+
return isString(item) ? `s:${item}` : `n:${contentHash(JSON.stringify(item))}`;
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
/** Alignment identity: equal weak keys mean "same logical child, diff its contents". */
|
|
78
|
-
function weakKey(item:
|
|
79
|
-
if (
|
|
79
|
+
function weakKey(item: JxMutableNode | string): string {
|
|
80
|
+
if (isString(item)) {
|
|
80
81
|
return "s";
|
|
81
82
|
}
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const props = node["$props"];
|
|
83
|
+
const attrs = item["attributes"];
|
|
84
|
+
const props = item["$props"];
|
|
85
85
|
const id =
|
|
86
|
-
(
|
|
87
|
-
(
|
|
86
|
+
(isJsonObject(attrs) ? attrs["id"] : undefined) ??
|
|
87
|
+
(isJsonObject(props) ? props["key"] : undefined) ??
|
|
88
88
|
"";
|
|
89
|
-
return `w:${String(
|
|
89
|
+
return `w:${String(item["tagName"] ?? "")}:${String(id)}`;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
@@ -160,8 +160,8 @@ export interface MatchOptions {
|
|
|
160
160
|
* exists to prevent.
|
|
161
161
|
*/
|
|
162
162
|
export function matchChildren(
|
|
163
|
-
a: readonly
|
|
164
|
-
b: readonly
|
|
163
|
+
a: readonly (JxMutableNode | string)[],
|
|
164
|
+
b: readonly (JxMutableNode | string)[],
|
|
165
165
|
opts: MatchOptions = {},
|
|
166
166
|
): ChildMatch[] {
|
|
167
167
|
const maxCells = opts.maxCells ?? Number.POSITIVE_INFINITY;
|
|
@@ -195,75 +195,75 @@ export function matchChildren(
|
|
|
195
195
|
return matches.toSorted((x, y) => x.aIndex - y.aIndex);
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
result(): JxDocOp[] {
|
|
207
|
-
return this.ops;
|
|
208
|
-
}
|
|
198
|
+
/**
|
|
199
|
+
* The ops emitted so far and the ceiling on them. A plain record threaded through the recursion
|
|
200
|
+
* rather than a class: it carries no behaviour of its own.
|
|
201
|
+
*/
|
|
202
|
+
interface DiffScript {
|
|
203
|
+
readonly ops: JxDocOp[];
|
|
204
|
+
readonly maxOps: number;
|
|
205
|
+
}
|
|
209
206
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
this.ops.push(op);
|
|
207
|
+
function pushOp(script: DiffScript, op: JxDocOp): void {
|
|
208
|
+
if (script.ops.length >= script.maxOps) {
|
|
209
|
+
throw new MaxOpsExceededError();
|
|
215
210
|
}
|
|
211
|
+
script.ops.push(op);
|
|
212
|
+
}
|
|
216
213
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
if (!(key in b)) {
|
|
223
|
-
this.push({ key, op: "set-key", path });
|
|
224
|
-
} else if (!deepEqual(a[key], b[key])) {
|
|
225
|
-
this.push({ key, op: "set-key", path, value: b[key] });
|
|
226
|
-
}
|
|
214
|
+
function diffNode(script: DiffScript, path: JxPath, a: JxMutableNode, b: JxMutableNode): void {
|
|
215
|
+
for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
216
|
+
if (key === "children") {
|
|
217
|
+
continue;
|
|
227
218
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
} else if (!deepEqual(ca, cb)) {
|
|
233
|
-
// Mapped-array children (or one side missing entirely) replace whole, like the mutators do.
|
|
234
|
-
this.push({ key: "children", op: "set-key", path, value: cb });
|
|
219
|
+
if (!(key in b)) {
|
|
220
|
+
pushOp(script, { key, op: "set-key", path });
|
|
221
|
+
} else if (!deepEqual(a[key], b[key])) {
|
|
222
|
+
pushOp(script, { key, op: "set-key", path, value: b[key] });
|
|
235
223
|
}
|
|
236
224
|
}
|
|
225
|
+
const ca = a["children"];
|
|
226
|
+
const cb = b["children"];
|
|
227
|
+
if (Array.isArray(ca) && Array.isArray(cb)) {
|
|
228
|
+
diffChildren(script, path, ca, cb);
|
|
229
|
+
} else if (!deepEqual(ca, cb)) {
|
|
230
|
+
// Mapped-array children (or one side missing entirely) replace whole, like the mutators do.
|
|
231
|
+
pushOp(script, { key: "children", op: "set-key", path, value: cb });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
237
234
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
235
|
+
function diffChildren(
|
|
236
|
+
script: DiffScript,
|
|
237
|
+
path: JxPath,
|
|
238
|
+
a: readonly (JxMutableNode | string)[],
|
|
239
|
+
b: readonly (JxMutableNode | string)[],
|
|
240
|
+
): void {
|
|
241
|
+
const matches = matchChildren(a, b);
|
|
242
|
+
const matchedA = new Set(matches.map((match) => match.aIndex));
|
|
243
|
+
const byB = new Map(matches.map((match) => [match.bIndex, match]));
|
|
242
244
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
245
|
+
// Removals first, in descending original index — each splice leaves lower indices intact.
|
|
246
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
247
|
+
if (!matchedA.has(i)) {
|
|
248
|
+
pushOp(script, { index: i, op: "remove-child", parentPath: path });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// Then walk b ascending: inserts land at their final index; matched pairs already sit there
|
|
252
|
+
// (matches never cross), so recursion paths are valid as emitted.
|
|
253
|
+
for (const [i, bItem] of b.entries()) {
|
|
254
|
+
const match = byB.get(i);
|
|
255
|
+
if (!match) {
|
|
256
|
+
pushOp(script, { index: i, node: bItem, op: "insert-child", parentPath: path });
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (match.identical) {
|
|
260
|
+
continue;
|
|
248
261
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
this.push({ index: i, node: b[i], op: "insert-child", parentPath: path });
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
if (match.identical) {
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
const aItem = a[match.aIndex];
|
|
261
|
-
const bItem = b[i];
|
|
262
|
-
if (isPlainObject(aItem) && isPlainObject(bItem)) {
|
|
263
|
-
this.diffNode([...path, "children", i], aItem, bItem);
|
|
264
|
-
} else if (!deepEqual(aItem, bItem)) {
|
|
265
|
-
this.push({ index: i, node: bItem, op: "set-child", parentPath: path });
|
|
266
|
-
}
|
|
262
|
+
const aItem = a[match.aIndex];
|
|
263
|
+
if (isJsonObject(aItem) && isJsonObject(bItem)) {
|
|
264
|
+
diffNode(script, [...path, "children", i], aItem, bItem);
|
|
265
|
+
} else if (!deepEqual(aItem, bItem)) {
|
|
266
|
+
pushOp(script, { index: i, node: bItem, op: "set-child", parentPath: path });
|
|
267
267
|
}
|
|
268
268
|
}
|
|
269
269
|
}
|
|
@@ -277,14 +277,14 @@ export function diffDocs(
|
|
|
277
277
|
b: JxMutableNode,
|
|
278
278
|
opts: DiffOptions = {},
|
|
279
279
|
): JxDocOp[] | null {
|
|
280
|
-
const
|
|
280
|
+
const script: DiffScript = { maxOps: opts.maxOps ?? DEFAULT_MAX_OPS, ops: [] };
|
|
281
281
|
try {
|
|
282
|
-
|
|
282
|
+
diffNode(script, [], a, b);
|
|
283
283
|
} catch (error) {
|
|
284
284
|
if (error instanceof MaxOpsExceededError) {
|
|
285
285
|
return null;
|
|
286
286
|
}
|
|
287
287
|
throw error;
|
|
288
288
|
}
|
|
289
|
-
return
|
|
289
|
+
return script.ops;
|
|
290
290
|
}
|
package/src/diff.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* a yjs-sized chunk with them.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { cloneValue } from "@jxsuite/schema/doc-ops";
|
|
12
13
|
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
13
14
|
import { metaMap, structureMap, toYChildren } from "./schema.ts";
|
|
14
15
|
import type * as Y from "yjs";
|
|
@@ -19,26 +20,24 @@ export * from "./diff-core.ts";
|
|
|
19
20
|
* Hard-replace the structure tree with `document` (the diff-overflow escape hatch) and bump the
|
|
20
21
|
* in-doc canonicalRev so stale mirror writes computed against the old tree are discarded.
|
|
21
22
|
*/
|
|
22
|
-
export function replaceYStructure(doc: Y.Doc, document: JxMutableNode, origin:
|
|
23
|
+
export function replaceYStructure(doc: Y.Doc, document: JxMutableNode, origin: string): void {
|
|
23
24
|
doc.transact(() => {
|
|
24
25
|
const structure = structureMap(doc);
|
|
25
|
-
const next = document as Record<string, unknown>;
|
|
26
26
|
// Detached copy: keys are deleted while iterating.
|
|
27
27
|
const existingKeys = [...structure.keys()];
|
|
28
28
|
for (const key of existingKeys) {
|
|
29
|
-
if (!(key in
|
|
29
|
+
if (!(key in document) || document[key] === undefined) {
|
|
30
30
|
structure.delete(key);
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
-
for (const [key, value] of Object.entries(
|
|
33
|
+
for (const [key, value] of Object.entries(document)) {
|
|
34
34
|
if (value === undefined) {
|
|
35
35
|
continue;
|
|
36
36
|
}
|
|
37
37
|
if (key === "children" && Array.isArray(value)) {
|
|
38
38
|
structure.set(key, toYChildren(value));
|
|
39
39
|
} else {
|
|
40
|
-
|
|
41
|
-
structure.set(key, JSON.parse(JSON.stringify(value)));
|
|
40
|
+
structure.set(key, cloneValue(value));
|
|
42
41
|
}
|
|
43
42
|
}
|
|
44
43
|
const meta = metaMap(doc);
|
package/src/envelope.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import * as decoding from "lib0/decoding";
|
|
24
24
|
import * as encoding from "lib0/encoding";
|
|
25
|
+
import { isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
25
26
|
|
|
26
27
|
export { COLLAB_SUBPROTOCOL } from "./negotiate.ts";
|
|
27
28
|
|
|
@@ -79,8 +80,18 @@ export class EnvelopeError extends Error {
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/**
|
|
84
|
+
* A parsed control payload: an object whose `type` is a string. That is all the envelope checks;
|
|
85
|
+
* each receiver dispatches on `type` and skips a kind it does not know.
|
|
86
|
+
*/
|
|
87
|
+
function isControlMessage(value: unknown): value is ControlMessage {
|
|
88
|
+
return isNonNullObject(value) && "type" in value && isString(value.type);
|
|
89
|
+
}
|
|
90
|
+
|
|
82
91
|
export function encodeFrame(frame: CollabFrame): Uint8Array {
|
|
83
92
|
const encoder = encoding.createEncoder();
|
|
93
|
+
// Read before the switch: a caller outside the type system may pass a kind the union does not name.
|
|
94
|
+
const { type: kind } = frame;
|
|
84
95
|
switch (frame.type) {
|
|
85
96
|
case "doc-sync": {
|
|
86
97
|
encoding.writeVarUint(encoder, FRAME_DOC_SYNC);
|
|
@@ -105,7 +116,7 @@ export function encodeFrame(frame: CollabFrame): Uint8Array {
|
|
|
105
116
|
break;
|
|
106
117
|
}
|
|
107
118
|
default: {
|
|
108
|
-
throw new EnvelopeError(`unknown-frame:${
|
|
119
|
+
throw new EnvelopeError(`unknown-frame:${kind}`);
|
|
109
120
|
}
|
|
110
121
|
}
|
|
111
122
|
return encoding.toUint8Array(encoder);
|
|
@@ -129,8 +140,8 @@ export function decodeFrame(data: Uint8Array): CollabFrame {
|
|
|
129
140
|
return { path: decoding.readVarString(decoder), type: "doc-close" };
|
|
130
141
|
}
|
|
131
142
|
case FRAME_CONTROL: {
|
|
132
|
-
const message = JSON.parse(decoding.readVarString(decoder))
|
|
133
|
-
if (
|
|
143
|
+
const message: unknown = JSON.parse(decoding.readVarString(decoder));
|
|
144
|
+
if (!isControlMessage(message)) {
|
|
134
145
|
throw new EnvelopeError("malformed-control");
|
|
135
146
|
}
|
|
136
147
|
return { message, type: "control" };
|
package/src/negotiate.ts
CHANGED
|
@@ -21,10 +21,14 @@
|
|
|
21
21
|
* merge `schema.ts` warns about. Negotiation has to conclude before a byte of document state
|
|
22
22
|
* moves.
|
|
23
23
|
*
|
|
24
|
-
* This module imports nothing
|
|
25
|
-
* the dynamic import that pulls in Yjs, so it must
|
|
24
|
+
* This module imports nothing but the dependency-free `@jxsuite/schema/guards`, deliberately: the
|
|
25
|
+
* Studio platform adapters read it eagerly, before the dynamic import that pulls in Yjs, so it must
|
|
26
|
+
* not drag the CRDT into the initial bundle.
|
|
26
27
|
*/
|
|
27
28
|
|
|
29
|
+
import { isJsonObject, isString } from "@jxsuite/schema/guards";
|
|
30
|
+
import type { JsonValue } from "@jxsuite/schema/types";
|
|
31
|
+
|
|
28
32
|
/** The subprotocol token for the envelope in `envelope.ts`. One token per envelope major. */
|
|
29
33
|
export const COLLAB_SUBPROTOCOL = "jx.collab.v1";
|
|
30
34
|
|
|
@@ -47,22 +51,22 @@ export interface CollabNegotiation {
|
|
|
47
51
|
/**
|
|
48
52
|
* Decide what to offer, from the body of a `GET /__studio/collab` capability probe.
|
|
49
53
|
*
|
|
50
|
-
* @param {
|
|
54
|
+
* @param {JsonValue} probe The parsed probe body, or null if it did not parse as JSON.
|
|
51
55
|
* @returns {CollabNegotiation}
|
|
52
56
|
*/
|
|
53
|
-
export function negotiateCollab(probe:
|
|
54
|
-
if (probe
|
|
55
|
-
|
|
57
|
+
export function negotiateCollab(probe: JsonValue): CollabNegotiation {
|
|
58
|
+
if (!isJsonObject(probe)) {
|
|
59
|
+
/* A 200 that is not a JSON object: pre-negotiation server. Connect as before, offering nothing.
|
|
60
|
+
(A JSON array lands here too; it carries no `protocols`, so it always meant the same.) */
|
|
56
61
|
return { offer: [], refused: null };
|
|
57
62
|
}
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
63
|
+
const { collab, detail, protocols } = probe;
|
|
64
|
+
if (collab === false) {
|
|
60
65
|
/* The server's own sentence wins when it sends one. "Disabled" is true of a flag that is off,
|
|
61
66
|
and false of a room an action would open: that server knows which it is and says so. */
|
|
62
|
-
const
|
|
63
|
-
return { offer: [], refused:
|
|
67
|
+
const sentence = isString(detail) ? detail.trim() : "";
|
|
68
|
+
return { offer: [], refused: sentence || "This server has collaboration disabled." };
|
|
64
69
|
}
|
|
65
|
-
const { protocols } = body;
|
|
66
70
|
if (!Array.isArray(protocols) || protocols.length === 0) {
|
|
67
71
|
// A server that predates negotiation. It would echo nothing, so we must offer nothing.
|
|
68
72
|
return { offer: [], refused: null };
|
|
@@ -70,7 +74,7 @@ export function negotiateCollab(probe: unknown): CollabNegotiation {
|
|
|
70
74
|
if (protocols.includes(COLLAB_SUBPROTOCOL)) {
|
|
71
75
|
return { offer: [COLLAB_SUBPROTOCOL], refused: null };
|
|
72
76
|
}
|
|
73
|
-
const theirs = protocols.filter((p) =>
|
|
77
|
+
const theirs = protocols.filter((p) => isString(p)).join(", ") || "none";
|
|
74
78
|
return {
|
|
75
79
|
offer: [],
|
|
76
80
|
refused: `This server speaks collab envelope ${theirs}; this editor speaks ${COLLAB_SUBPROTOCOL}.`,
|
package/src/op-bridge.ts
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import * as Y from "yjs";
|
|
15
|
-
import
|
|
15
|
+
import { cloneValue } from "@jxsuite/schema/doc-ops";
|
|
16
|
+
import { isJsonObject, isNumber, isString } from "@jxsuite/schema/guards";
|
|
17
|
+
import type { JxDocumentValue, JxPath } from "@jxsuite/schema/types";
|
|
16
18
|
import type { JxDocOp } from "./ops.ts";
|
|
17
19
|
import {
|
|
18
20
|
applyTextEdit,
|
|
@@ -23,8 +25,10 @@ import {
|
|
|
23
25
|
toYChild,
|
|
24
26
|
toYChildren,
|
|
25
27
|
toYObject,
|
|
28
|
+
yChildToJson,
|
|
26
29
|
yValueToJson,
|
|
27
30
|
} from "./schema.ts";
|
|
31
|
+
import type { YStructureValue } from "./schema.ts";
|
|
28
32
|
|
|
29
33
|
/** Origin of Y transactions produced by mirroring the local tab's ops. */
|
|
30
34
|
export const LOCAL_ORIGIN = "jx-local";
|
|
@@ -41,7 +45,7 @@ export class CollabPathError extends Error {
|
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
function requireNode(doc: Y.Doc, path: JxPath): Y.Map<
|
|
48
|
+
function requireNode(doc: Y.Doc, path: JxPath): Y.Map<YStructureValue> {
|
|
45
49
|
const target = resolveYPath(doc, path);
|
|
46
50
|
if (!(target instanceof Y.Map)) {
|
|
47
51
|
throw new CollabPathError(path);
|
|
@@ -49,30 +53,20 @@ function requireNode(doc: Y.Doc, path: JxPath): Y.Map<unknown> {
|
|
|
49
53
|
return target;
|
|
50
54
|
}
|
|
51
55
|
|
|
52
|
-
function childrenOf(doc: Y.Doc, parentPath: JxPath, create: boolean): Y.Array<
|
|
56
|
+
function childrenOf(doc: Y.Doc, parentPath: JxPath, create: boolean): Y.Array<YStructureValue> {
|
|
53
57
|
const parent = requireNode(doc, parentPath);
|
|
54
58
|
const children = parent.get("children");
|
|
55
59
|
if (children instanceof Y.Array) {
|
|
56
60
|
return children;
|
|
57
61
|
}
|
|
58
62
|
if (create && children === undefined) {
|
|
59
|
-
const fresh = new Y.Array<
|
|
63
|
+
const fresh = new Y.Array<YStructureValue>();
|
|
60
64
|
parent.set("children", fresh);
|
|
61
65
|
return fresh;
|
|
62
66
|
}
|
|
63
67
|
throw new CollabPathError([...parentPath, "children"]);
|
|
64
68
|
}
|
|
65
69
|
|
|
66
|
-
/** JSON round-trip clone (matches the ops applier's normalization). */
|
|
67
|
-
function cloneJson<T>(value: T): T {
|
|
68
|
-
// oxlint-disable-next-line unicorn/prefer-structured-clone -- JSON normalization is the point
|
|
69
|
-
return JSON.parse(JSON.stringify(value)) as T;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
73
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
70
|
/**
|
|
77
71
|
* Write a node key, preserving CRDT identity wherever the schema stores it granularly.
|
|
78
72
|
*
|
|
@@ -87,13 +81,13 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
87
81
|
* change — string text becoming a `$ref`, an object becoming a scalar — falls back to replacement,
|
|
88
82
|
* because then there is no shared structure left to preserve.
|
|
89
83
|
*/
|
|
90
|
-
function setNodeKey(node: Y.Map<
|
|
84
|
+
function setNodeKey(node: Y.Map<YStructureValue>, key: string, value: JxDocumentValue): void {
|
|
91
85
|
const existing = node.get(key);
|
|
92
86
|
if (key === "children" && Array.isArray(value)) {
|
|
93
87
|
node.set(key, toYChildren(value));
|
|
94
88
|
return;
|
|
95
89
|
}
|
|
96
|
-
if (TEXT_KEYS.has(key) &&
|
|
90
|
+
if (TEXT_KEYS.has(key) && isString(value)) {
|
|
97
91
|
if (existing instanceof Y.Text) {
|
|
98
92
|
applyTextEdit(existing, value);
|
|
99
93
|
} else {
|
|
@@ -101,7 +95,7 @@ function setNodeKey(node: Y.Map<unknown>, key: string, value: unknown): void {
|
|
|
101
95
|
}
|
|
102
96
|
return;
|
|
103
97
|
}
|
|
104
|
-
if (GRANULAR_OBJECT_KEYS.has(key) &&
|
|
98
|
+
if (GRANULAR_OBJECT_KEYS.has(key) && isJsonObject(value)) {
|
|
105
99
|
if (existing instanceof Y.Map) {
|
|
106
100
|
mergeYObject(existing, value);
|
|
107
101
|
} else {
|
|
@@ -109,10 +103,12 @@ function setNodeKey(node: Y.Map<unknown>, key: string, value: unknown): void {
|
|
|
109
103
|
}
|
|
110
104
|
return;
|
|
111
105
|
}
|
|
112
|
-
node.set(key,
|
|
106
|
+
node.set(key, cloneValue(value));
|
|
113
107
|
}
|
|
114
108
|
|
|
115
109
|
function applyOneOp(doc: Y.Doc, op: JxDocOp): void {
|
|
110
|
+
// Read before the switch: an op from outside may carry a kind the union does not name.
|
|
111
|
+
const { op: kind } = op;
|
|
116
112
|
switch (op.op) {
|
|
117
113
|
case "set-key": {
|
|
118
114
|
const node = requireNode(doc, op.path);
|
|
@@ -148,7 +144,7 @@ function applyOneOp(doc: Y.Doc, op: JxDocOp): void {
|
|
|
148
144
|
// Whole element and discard a peer's concurrent characters, which is the same clobber
|
|
149
145
|
// `setNodeKey` avoids for `textContent` — bare-string children are the other half of prose.
|
|
150
146
|
const existing = children.get(op.index);
|
|
151
|
-
if (existing instanceof Y.Text &&
|
|
147
|
+
if (existing instanceof Y.Text && isString(op.node)) {
|
|
152
148
|
applyTextEdit(existing, op.node);
|
|
153
149
|
return;
|
|
154
150
|
}
|
|
@@ -176,7 +172,7 @@ function applyOneOp(doc: Y.Doc, op: JxDocOp): void {
|
|
|
176
172
|
return;
|
|
177
173
|
}
|
|
178
174
|
default: {
|
|
179
|
-
throw new Error(`unknown-doc-op:${
|
|
175
|
+
throw new Error(`unknown-doc-op:${kind}`);
|
|
180
176
|
}
|
|
181
177
|
}
|
|
182
178
|
}
|
|
@@ -186,7 +182,7 @@ function applyOneOp(doc: Y.Doc, op: JxDocOp): void {
|
|
|
186
182
|
* when any op's path does not resolve — the transaction still commits the ops applied before the
|
|
187
183
|
* failure, so callers catching it must reconcile by diff (which corrects any partial state).
|
|
188
184
|
*/
|
|
189
|
-
export function applyDocOpsToY(doc: Y.Doc, ops: readonly JxDocOp[], origin:
|
|
185
|
+
export function applyDocOpsToY(doc: Y.Doc, ops: readonly JxDocOp[], origin: string): void {
|
|
190
186
|
doc.transact(() => {
|
|
191
187
|
for (const op of ops) {
|
|
192
188
|
applyOneOp(doc, op);
|
|
@@ -209,7 +205,7 @@ export function applyDocOpsToY(doc: Y.Doc, ops: readonly JxDocOp[], origin: unkn
|
|
|
209
205
|
function granularOwner(path: JxPath): { nodePath: JxPath; key: string } | null {
|
|
210
206
|
for (let i = 0; i < path.length; i++) {
|
|
211
207
|
const segment = path[i];
|
|
212
|
-
if (
|
|
208
|
+
if (!isString(segment)) {
|
|
213
209
|
continue;
|
|
214
210
|
}
|
|
215
211
|
if (GRANULAR_OBJECT_KEYS.has(segment)) {
|
|
@@ -222,8 +218,17 @@ function granularOwner(path: JxPath): { nodePath: JxPath; key: string } | null {
|
|
|
222
218
|
return null;
|
|
223
219
|
}
|
|
224
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Whether a remote children-array item has a shape this package writes: a node Y.Map or a Y.Text
|
|
223
|
+
* (`toYChild`), or a plain string from a peer still on schema version 1. Anything else came from an
|
|
224
|
+
* off-contract peer, so the inbound fast path bails and the caller reconciles by diffing.
|
|
225
|
+
*/
|
|
226
|
+
function isChildItem(item: unknown): item is Y.Text | Y.Map<YStructureValue> | string {
|
|
227
|
+
return item instanceof Y.Text || item instanceof Y.Map || isString(item);
|
|
228
|
+
}
|
|
229
|
+
|
|
225
230
|
function isStructurePath(segments: readonly (string | number)[]): segments is JxPath {
|
|
226
|
-
return segments.every((seg) =>
|
|
231
|
+
return segments.every((seg) => isString(seg) || isNumber(seg));
|
|
227
232
|
}
|
|
228
233
|
|
|
229
234
|
/**
|
|
@@ -232,7 +237,9 @@ function isStructurePath(segments: readonly (string | number)[]): segments is Jx
|
|
|
232
237
|
* overlapping events, more than one array event, or non-contiguous delete runs — and the caller
|
|
233
238
|
* must reconcile by diffing instead.
|
|
234
239
|
*/
|
|
235
|
-
export function yEventsToDocOps(
|
|
240
|
+
export function yEventsToDocOps(
|
|
241
|
+
events: readonly Y.YEvent<Y.AbstractType<unknown>>[],
|
|
242
|
+
): JxDocOp[] | null {
|
|
236
243
|
if (events.length === 0) {
|
|
237
244
|
return [];
|
|
238
245
|
}
|
|
@@ -261,7 +268,7 @@ export function yEventsToDocOps(events: readonly Y.YEvent<never>[]): JxDocOp[] |
|
|
|
261
268
|
|
|
262
269
|
const ops: JxDocOp[] = [];
|
|
263
270
|
for (const event of events) {
|
|
264
|
-
const path = event
|
|
271
|
+
const { path } = event;
|
|
265
272
|
/*
|
|
266
273
|
* A remote edit inside a granular container (a `Y.Text` for prose, a nested `Y.Map` for
|
|
267
274
|
* style/attributes/$props) reports its event at the CONTAINER's path, not the node's. Studio's op
|
|
@@ -271,7 +278,7 @@ export function yEventsToDocOps(events: readonly Y.YEvent<never>[]): JxDocOp[] |
|
|
|
271
278
|
*/
|
|
272
279
|
const granular = granularOwner(path);
|
|
273
280
|
if (granular) {
|
|
274
|
-
const { doc } = event.target
|
|
281
|
+
const { doc } = event.target;
|
|
275
282
|
if (!doc) {
|
|
276
283
|
return null;
|
|
277
284
|
}
|
|
@@ -290,10 +297,10 @@ export function yEventsToDocOps(events: readonly Y.YEvent<never>[]): JxDocOp[] |
|
|
|
290
297
|
if (event instanceof Y.YTextEvent) {
|
|
291
298
|
// A bare-string child: the text IS the child, so it round-trips as a child replacement.
|
|
292
299
|
const index = path.at(-1);
|
|
293
|
-
if (path.at(-2) === "children" &&
|
|
300
|
+
if (path.at(-2) === "children" && isNumber(index)) {
|
|
294
301
|
ops.push({
|
|
295
302
|
index,
|
|
296
|
-
node:
|
|
303
|
+
node: event.target.toString(),
|
|
297
304
|
op: "set-child",
|
|
298
305
|
parentPath: path.slice(0, -2),
|
|
299
306
|
});
|
|
@@ -302,7 +309,10 @@ export function yEventsToDocOps(events: readonly Y.YEvent<never>[]): JxDocOp[] |
|
|
|
302
309
|
return null;
|
|
303
310
|
}
|
|
304
311
|
if (event instanceof Y.YMapEvent) {
|
|
305
|
-
|
|
312
|
+
/* SAFETY: these events come from observing the structure map, and a map event that reached
|
|
313
|
+
here is not inside a granular container (handled above), so its target is a node Y.Map,
|
|
314
|
+
which the writers in ./schema.ts fill only with YStructureValue entries. */
|
|
315
|
+
const target = event.target as Y.Map<YStructureValue>;
|
|
306
316
|
for (const [key, change] of event.changes.keys) {
|
|
307
317
|
if (change.action === "delete") {
|
|
308
318
|
ops.push({ key, op: "set-key", path });
|
|
@@ -327,7 +337,10 @@ export function yEventsToDocOps(events: readonly Y.YEvent<never>[]): JxDocOp[] |
|
|
|
327
337
|
} else if (delta.insert !== undefined) {
|
|
328
338
|
const items = Array.isArray(delta.insert) ? delta.insert : [delta.insert];
|
|
329
339
|
for (const item of items) {
|
|
330
|
-
|
|
340
|
+
if (!isChildItem(item)) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
ops.push({ index, node: yChildToJson(item), op: "insert-child", parentPath });
|
|
331
344
|
index += 1;
|
|
332
345
|
}
|
|
333
346
|
}
|
package/src/reconnect.ts
CHANGED
|
@@ -89,11 +89,7 @@ export function createReconnectScheduler(
|
|
|
89
89
|
connect: () => void,
|
|
90
90
|
opts: ReconnectPolicy & ReconnectWakeSources = {},
|
|
91
91
|
): ReconnectScheduler {
|
|
92
|
-
const win =
|
|
93
|
-
opts.win ??
|
|
94
|
-
(typeof window === "undefined"
|
|
95
|
-
? undefined
|
|
96
|
-
: (window as Pick<Window, "addEventListener" | "removeEventListener">));
|
|
92
|
+
const win = opts.win ?? (typeof window === "undefined" ? undefined : window);
|
|
97
93
|
const doc = opts.doc ?? (typeof document === "undefined" ? undefined : document);
|
|
98
94
|
let attempt = 0;
|
|
99
95
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -118,9 +114,9 @@ export function createReconnectScheduler(
|
|
|
118
114
|
fire();
|
|
119
115
|
};
|
|
120
116
|
|
|
121
|
-
const onPageShow = (event:
|
|
117
|
+
const onPageShow = (event: PageTransitionEvent) => {
|
|
122
118
|
// A fresh load is not a wake: only a page restored from the back/forward cache was frozen.
|
|
123
|
-
if (
|
|
119
|
+
if (event.persisted) {
|
|
124
120
|
wake();
|
|
125
121
|
}
|
|
126
122
|
};
|
package/src/schema.ts
CHANGED
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import * as Y from "yjs";
|
|
32
|
-
import
|
|
32
|
+
import { cloneValue } from "@jxsuite/schema/doc-ops";
|
|
33
|
+
import { isJsonObject, isString } from "@jxsuite/schema/guards";
|
|
34
|
+
import type { JxDocumentValue, JxMutableNode, JxPath } from "@jxsuite/schema/types";
|
|
33
35
|
|
|
34
36
|
export const META_KEY = "meta";
|
|
35
37
|
export const FRONTMATTER_KEY = "frontmatter";
|
|
@@ -47,6 +49,33 @@ export const SOURCE_KEY = "source";
|
|
|
47
49
|
*/
|
|
48
50
|
export const COLLAB_SCHEMA_VERSION = 2;
|
|
49
51
|
|
|
52
|
+
/**
|
|
53
|
+
* What one slot of the structure tree holds: a node or granular-object `Y.Map`, a children
|
|
54
|
+
* `Y.Array`, a prose `Y.Text`, or a whole-JSON LWW leaf. Every writer in this package goes through
|
|
55
|
+
* {@link toYNode}, {@link toYChildren}, {@link toYObject} or a JSON clone, so nothing else is ever
|
|
56
|
+
* stored there.
|
|
57
|
+
*/
|
|
58
|
+
export type YStructureValue =
|
|
59
|
+
| Y.Map<YStructureValue>
|
|
60
|
+
| Y.Array<YStructureValue>
|
|
61
|
+
| Y.Text
|
|
62
|
+
| JxDocumentValue;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A plain object read out of, or written into, the structure tree: a node, a style block, an
|
|
66
|
+
* attribute map. A key an editor cleared may still hold `undefined`, which every writer skips.
|
|
67
|
+
*/
|
|
68
|
+
export type DocumentRecord = Record<string, JxDocumentValue | undefined>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A plain object (not null, not an array). Only the shape is checked: its values are whatever the
|
|
72
|
+
* document holds, including the `undefined` a cleared key leaves behind, which is why this narrows
|
|
73
|
+
* to {@link DocumentRecord} rather than a fully-validated JSON object.
|
|
74
|
+
*/
|
|
75
|
+
function isDocumentRecord(value: JxDocumentValue | undefined): value is DocumentRecord {
|
|
76
|
+
return isJsonObject(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
50
79
|
export function metaMap(doc: Y.Doc): Y.Map<unknown> {
|
|
51
80
|
return doc.getMap(META_KEY);
|
|
52
81
|
}
|
|
@@ -55,24 +84,18 @@ export function frontmatterMap(doc: Y.Doc): Y.Map<unknown> {
|
|
|
55
84
|
return doc.getMap(FRONTMATTER_KEY);
|
|
56
85
|
}
|
|
57
86
|
|
|
58
|
-
|
|
59
|
-
|
|
87
|
+
/**
|
|
88
|
+
* The root node's `Y.Map`. Its type argument is the {@link YStructureValue} invariant the writers in
|
|
89
|
+
* this module maintain; Yjs itself does not check what a map holds.
|
|
90
|
+
*/
|
|
91
|
+
export function structureMap(doc: Y.Doc): Y.Map<YStructureValue> {
|
|
92
|
+
return doc.getMap<YStructureValue>(STRUCTURE_KEY);
|
|
60
93
|
}
|
|
61
94
|
|
|
62
95
|
export function sourceText(doc: Y.Doc): Y.Text {
|
|
63
96
|
return doc.getText(SOURCE_KEY);
|
|
64
97
|
}
|
|
65
98
|
|
|
66
|
-
/** JSON round-trip clone (normalizes away proxies/undefined; matches the ops applier). */
|
|
67
|
-
function cloneJson<T>(value: T): T {
|
|
68
|
-
// oxlint-disable-next-line unicorn/prefer-structured-clone -- JSON normalization is the point
|
|
69
|
-
return JSON.parse(JSON.stringify(value)) as T;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
73
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
99
|
/**
|
|
77
100
|
* Node keys whose STRING value is stored as a `Y.Text` rather than an opaque string.
|
|
78
101
|
*
|
|
@@ -132,13 +155,13 @@ export function applyTextEdit(text: Y.Text, next: string): boolean {
|
|
|
132
155
|
}
|
|
133
156
|
|
|
134
157
|
/** A plain object becomes a Y.Map, recursing into nested objects; other values stay plain JSON. */
|
|
135
|
-
export function toYObject(value:
|
|
136
|
-
const map = new Y.Map<
|
|
158
|
+
export function toYObject(value: DocumentRecord): Y.Map<YStructureValue> {
|
|
159
|
+
const map = new Y.Map<YStructureValue>();
|
|
137
160
|
for (const [key, entry] of Object.entries(value)) {
|
|
138
161
|
if (entry === undefined) {
|
|
139
162
|
continue;
|
|
140
163
|
}
|
|
141
|
-
map.set(key,
|
|
164
|
+
map.set(key, isDocumentRecord(entry) ? toYObject(entry) : cloneValue(entry));
|
|
142
165
|
}
|
|
143
166
|
return map;
|
|
144
167
|
}
|
|
@@ -148,7 +171,7 @@ export function toYObject(value: Record<string, unknown>): Y.Map<unknown> {
|
|
|
148
171
|
* recurse into nested maps. Whole-value replacement would mint a new Y.Map and discard a peer's
|
|
149
172
|
* concurrent edit to an untouched sibling property — the very thing granularity exists to prevent.
|
|
150
173
|
*/
|
|
151
|
-
export function mergeYObject(map: Y.Map<
|
|
174
|
+
export function mergeYObject(map: Y.Map<YStructureValue>, value: DocumentRecord): void {
|
|
152
175
|
// Detached key list: deleting from a Y.Map while iterating its live keys is unsafe.
|
|
153
176
|
// oxlint-disable-next-line unicorn/no-useless-spread -- the copy is the point (mutation during iteration)
|
|
154
177
|
for (const key of [...map.keys()]) {
|
|
@@ -161,20 +184,20 @@ export function mergeYObject(map: Y.Map<unknown>, value: Record<string, unknown>
|
|
|
161
184
|
continue;
|
|
162
185
|
}
|
|
163
186
|
const existing = map.get(key);
|
|
164
|
-
if (
|
|
187
|
+
if (isDocumentRecord(entry)) {
|
|
165
188
|
if (existing instanceof Y.Map) {
|
|
166
189
|
mergeYObject(existing, entry);
|
|
167
190
|
} else {
|
|
168
191
|
map.set(key, toYObject(entry));
|
|
169
192
|
}
|
|
170
193
|
} else if (!deepEqualJson(existing, entry)) {
|
|
171
|
-
map.set(key,
|
|
194
|
+
map.set(key, cloneValue(entry));
|
|
172
195
|
}
|
|
173
196
|
}
|
|
174
197
|
}
|
|
175
198
|
|
|
176
199
|
/** Structural equality over plain JSON, used to skip no-op key writes. */
|
|
177
|
-
function deepEqualJson(a:
|
|
200
|
+
function deepEqualJson(a: YStructureValue | undefined, b: JxDocumentValue): boolean {
|
|
178
201
|
if (a === b) {
|
|
179
202
|
return true;
|
|
180
203
|
}
|
|
@@ -184,14 +207,17 @@ function deepEqualJson(a: unknown, b: unknown): boolean {
|
|
|
184
207
|
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
185
208
|
}
|
|
186
209
|
|
|
187
|
-
/**
|
|
188
|
-
|
|
189
|
-
|
|
210
|
+
/**
|
|
211
|
+
* A child value: a bare string becomes a Y.Text (per-character merge), a node becomes a Y.Map.
|
|
212
|
+
* Anything else is not a child, and {@link toYNode} throws for it.
|
|
213
|
+
*/
|
|
214
|
+
export function toYChild(child: JxDocumentValue | undefined): Y.Text | Y.Map<YStructureValue> {
|
|
215
|
+
return isString(child) ? new Y.Text(child) : toYNode(child);
|
|
190
216
|
}
|
|
191
217
|
|
|
192
218
|
/** A children array becomes a Y.Array of node Y.Maps and Y.Texts. */
|
|
193
|
-
export function toYChildren(children: readonly
|
|
194
|
-
const arr = new Y.Array<
|
|
219
|
+
export function toYChildren(children: readonly JxDocumentValue[]): Y.Array<YStructureValue> {
|
|
220
|
+
const arr = new Y.Array<YStructureValue>();
|
|
195
221
|
arr.insert(
|
|
196
222
|
0,
|
|
197
223
|
children.map((child) => toYChild(child)),
|
|
@@ -204,36 +230,54 @@ export function toYChildren(children: readonly unknown[]): Y.Array<unknown> {
|
|
|
204
230
|
* `Y.Text` and {@link GRANULAR_OBJECT_KEYS} become nested `Y.Map`s, so the CRDT merges at the
|
|
205
231
|
* granularity edits actually happen at. Everything else stays a whole-JSON LWW value.
|
|
206
232
|
*/
|
|
207
|
-
export function toYNode(node:
|
|
208
|
-
if (!
|
|
233
|
+
export function toYNode(node: JxDocumentValue | undefined): Y.Map<YStructureValue> {
|
|
234
|
+
if (!isDocumentRecord(node)) {
|
|
209
235
|
throw new TypeError("collab-schema: a document node must be a plain object");
|
|
210
236
|
}
|
|
211
|
-
const map = new Y.Map<
|
|
237
|
+
const map = new Y.Map<YStructureValue>();
|
|
212
238
|
for (const [key, value] of Object.entries(node)) {
|
|
213
239
|
if (value === undefined) {
|
|
214
240
|
continue;
|
|
215
241
|
}
|
|
216
242
|
if (key === "children" && Array.isArray(value)) {
|
|
217
243
|
map.set(key, toYChildren(value));
|
|
218
|
-
} else if (TEXT_KEYS.has(key) &&
|
|
244
|
+
} else if (TEXT_KEYS.has(key) && isString(value)) {
|
|
219
245
|
map.set(key, new Y.Text(value));
|
|
220
|
-
} else if (GRANULAR_OBJECT_KEYS.has(key) &&
|
|
246
|
+
} else if (GRANULAR_OBJECT_KEYS.has(key) && isDocumentRecord(value)) {
|
|
221
247
|
map.set(key, toYObject(value));
|
|
222
248
|
} else {
|
|
223
|
-
map.set(key,
|
|
249
|
+
map.set(key, cloneValue(value));
|
|
224
250
|
}
|
|
225
251
|
}
|
|
226
252
|
return map;
|
|
227
253
|
}
|
|
228
254
|
|
|
255
|
+
/** A node or granular-object Y.Map back to its plain object (deep, detached copy). */
|
|
256
|
+
function yMapToJson(map: Y.Map<YStructureValue>): DocumentRecord {
|
|
257
|
+
return Object.fromEntries(
|
|
258
|
+
Array.from(map.entries(), ([key, entry]) => [key, yValueToJson(entry)]),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* A children-array member back to JSON: a Y.Text is a bare-string child, a Y.Map a node. A plain
|
|
264
|
+
* string is a bare-string child written under schema version 1, before prose moved to Y.Text.
|
|
265
|
+
*/
|
|
266
|
+
export function yChildToJson(
|
|
267
|
+
child: Y.Text | Y.Map<YStructureValue> | string,
|
|
268
|
+
): JxMutableNode | string {
|
|
269
|
+
if (isString(child)) {
|
|
270
|
+
return child;
|
|
271
|
+
}
|
|
272
|
+
return child instanceof Y.Text ? child.toString() : yMapToJson(child);
|
|
273
|
+
}
|
|
274
|
+
|
|
229
275
|
/** Convert any structure value (Y.Map, Y.Array, Y.Text, or plain leaf) back to JSON. */
|
|
230
|
-
export function yValueToJson(value:
|
|
276
|
+
export function yValueToJson(value: YStructureValue): JxDocumentValue;
|
|
277
|
+
export function yValueToJson(value: YStructureValue | undefined): JxDocumentValue | undefined;
|
|
278
|
+
export function yValueToJson(value: YStructureValue | undefined): JxDocumentValue | undefined {
|
|
231
279
|
if (value instanceof Y.Map) {
|
|
232
|
-
|
|
233
|
-
for (const [key, entry] of value.entries()) {
|
|
234
|
-
out[key] = yValueToJson(entry);
|
|
235
|
-
}
|
|
236
|
-
return out;
|
|
280
|
+
return yMapToJson(value);
|
|
237
281
|
}
|
|
238
282
|
if (value instanceof Y.Array) {
|
|
239
283
|
return value.toArray().map((entry) => yValueToJson(entry));
|
|
@@ -246,20 +290,20 @@ export function yValueToJson(value: unknown): unknown {
|
|
|
246
290
|
|
|
247
291
|
/** The whole structure tree as a plain document (deep, detached copy). */
|
|
248
292
|
export function yDocToJson(doc: Y.Doc): JxMutableNode {
|
|
249
|
-
return
|
|
293
|
+
return yMapToJson(structureMap(doc));
|
|
250
294
|
}
|
|
251
295
|
|
|
252
296
|
/**
|
|
253
297
|
* Resolve a JxPath against the structure tree. Returns the Y value at the path (a node Y.Map, a
|
|
254
298
|
* children Y.Array, a string child, or a plain leaf) or undefined when any segment is missing.
|
|
255
299
|
*/
|
|
256
|
-
export function resolveYPath(doc: Y.Doc, path: JxPath):
|
|
257
|
-
let current:
|
|
300
|
+
export function resolveYPath(doc: Y.Doc, path: JxPath): YStructureValue | undefined {
|
|
301
|
+
let current: YStructureValue | undefined = structureMap(doc);
|
|
258
302
|
for (const segment of path) {
|
|
259
303
|
if (current instanceof Y.Map) {
|
|
260
304
|
current = current.get(String(segment));
|
|
261
305
|
} else if (current instanceof Y.Array) {
|
|
262
|
-
const index =
|
|
306
|
+
const index = Number(segment);
|
|
263
307
|
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
|
|
264
308
|
return undefined;
|
|
265
309
|
}
|
|
@@ -275,7 +319,7 @@ export function resolveYPath(doc: Y.Doc, path: JxPath): unknown {
|
|
|
275
319
|
* Update the source Y.Text to `next` with a minimal edit (common prefix/suffix trimmed), so mirror
|
|
276
320
|
* refreshes don't ship the whole document over the wire. Returns false when already identical.
|
|
277
321
|
*/
|
|
278
|
-
export function updateSourceText(doc: Y.Doc, next: string, origin?:
|
|
322
|
+
export function updateSourceText(doc: Y.Doc, next: string, origin?: string): boolean {
|
|
279
323
|
const text = sourceText(doc);
|
|
280
324
|
if (text.toString() === next) {
|
|
281
325
|
return false;
|
|
@@ -296,9 +340,9 @@ export function seedStructure(
|
|
|
296
340
|
doc: Y.Doc,
|
|
297
341
|
document: JxMutableNode,
|
|
298
342
|
opts: {
|
|
299
|
-
frontmatter?:
|
|
343
|
+
frontmatter?: DocumentRecord;
|
|
300
344
|
sourceFormat?: string | null;
|
|
301
|
-
origin?:
|
|
345
|
+
origin?: string;
|
|
302
346
|
} = {},
|
|
303
347
|
): boolean {
|
|
304
348
|
const meta = metaMap(doc);
|
|
@@ -314,13 +358,13 @@ export function seedStructure(
|
|
|
314
358
|
if (key === "children" && Array.isArray(value)) {
|
|
315
359
|
structure.set(key, toYChildren(value));
|
|
316
360
|
} else {
|
|
317
|
-
structure.set(key,
|
|
361
|
+
structure.set(key, cloneValue(value));
|
|
318
362
|
}
|
|
319
363
|
}
|
|
320
364
|
const frontmatter = frontmatterMap(doc);
|
|
321
365
|
for (const [key, value] of Object.entries(opts.frontmatter ?? {})) {
|
|
322
366
|
if (value !== undefined) {
|
|
323
|
-
frontmatter.set(key,
|
|
367
|
+
frontmatter.set(key, cloneValue(value));
|
|
324
368
|
}
|
|
325
369
|
}
|
|
326
370
|
meta.set("schemaVersion", COLLAB_SCHEMA_VERSION);
|
package/src/source-lock.ts
CHANGED
|
@@ -30,7 +30,7 @@ export function canonicalRevOf(doc: Doc): number {
|
|
|
30
30
|
* Flip canonical to "source", seeding the text from `serialized` (the flipper's serialization of
|
|
31
31
|
* the current structure). No-op (false) when source already holds the lock.
|
|
32
32
|
*/
|
|
33
|
-
export function acquireSourceCanonical(doc: Doc, serialized: string, origin:
|
|
33
|
+
export function acquireSourceCanonical(doc: Doc, serialized: string, origin: string): boolean {
|
|
34
34
|
if (canonicalOf(doc) === "source") {
|
|
35
35
|
return false;
|
|
36
36
|
}
|
|
@@ -44,7 +44,7 @@ export function acquireSourceCanonical(doc: Doc, serialized: string, origin: unk
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/** Flip canonical back to "structure" (the mirror is the caller's responsibility beforehand). */
|
|
47
|
-
export function releaseSourceCanonical(doc: Doc, origin:
|
|
47
|
+
export function releaseSourceCanonical(doc: Doc, origin: string): boolean {
|
|
48
48
|
if (canonicalOf(doc) !== "source") {
|
|
49
49
|
return false;
|
|
50
50
|
}
|
|
@@ -64,6 +64,9 @@ export function otherSourceEditors(
|
|
|
64
64
|
): number[] {
|
|
65
65
|
const editors: number[] = [];
|
|
66
66
|
for (const [clientId, raw] of awareness.getStates()) {
|
|
67
|
+
/* SAFETY: a peer publishes its own awareness state, so nothing is guaranteed about it, and
|
|
68
|
+
`Partial` keeps every field optional. The fields are only compared with `===` against a
|
|
69
|
+
literal or `path`, so a malformed state fails the match instead of being misread. */
|
|
67
70
|
const state = raw as Partial<CollabAwarenessState>;
|
|
68
71
|
if (clientId !== selfClientId && state.mode === "source" && state.focusedPath === path) {
|
|
69
72
|
editors.push(clientId);
|
|
@@ -78,12 +81,17 @@ export function otherSourceEditors(
|
|
|
78
81
|
*/
|
|
79
82
|
export function isSourceReconciler(awareness: Awareness, path: string): boolean {
|
|
80
83
|
const self = awareness.clientID;
|
|
84
|
+
/* SAFETY: every awareness state is peer-published and unchecked (this one is our own, but read
|
|
85
|
+
back through the same untyped map). `Partial` keeps every field optional, and each is only
|
|
86
|
+
compared with `===`/`!==` against a literal or `path`, so a malformed state fails the match. */
|
|
81
87
|
const selfState = awareness.getStates().get(self) as Partial<CollabAwarenessState> | undefined;
|
|
82
88
|
if (selfState?.mode !== "source" || selfState.focusedPath !== path) {
|
|
83
89
|
return false;
|
|
84
90
|
}
|
|
85
91
|
let lowest = self;
|
|
86
92
|
for (const [clientId, raw] of awareness.getStates()) {
|
|
93
|
+
/* SAFETY: peer-published and unchecked; `mode`, `focusedPath` and `canWrite` are only compared
|
|
94
|
+
with `===`/`!==` against a literal or `path`, so a malformed state just loses the election. */
|
|
87
95
|
const state = raw as Partial<CollabAwarenessState>;
|
|
88
96
|
if (
|
|
89
97
|
state.mode === "source" &&
|
package/src/ws-client.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import * as syncProtocol from "y-protocols/sync";
|
|
19
19
|
import { decodeFrame, encodeFrame } from "./envelope.ts";
|
|
20
20
|
import { createReconnectScheduler } from "./reconnect.ts";
|
|
21
|
+
import type { ReconnectPolicy } from "./reconnect.ts";
|
|
21
22
|
import type { CollabFrame, ControlMessage } from "./envelope.ts";
|
|
22
23
|
import type { CollabHandle, CollabIdentity, CollabStatus } from "./provider.ts";
|
|
23
24
|
|
|
@@ -71,6 +72,13 @@ export interface WsCollabConnection {
|
|
|
71
72
|
|
|
72
73
|
const WS_OPEN = 1;
|
|
73
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The origin a transaction on a handle's Y.Doc carries: a Jx writer's string tag (`"remote"` for an
|
|
77
|
+
* update this connection applied off the wire), the `Y.UndoManager` replaying an undo or redo, or
|
|
78
|
+
* `null` for an untagged transaction.
|
|
79
|
+
*/
|
|
80
|
+
type DocTransactionOrigin = string | Y.UndoManager | null;
|
|
81
|
+
|
|
74
82
|
interface DocEntry {
|
|
75
83
|
doc: Y.Doc;
|
|
76
84
|
epoch: number;
|
|
@@ -89,9 +97,13 @@ interface DocEntry {
|
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
export function createWsCollabConnection(options: WsCollabConnectionOptions): WsCollabConnection {
|
|
100
|
+
/* SAFETY: a browser WebSocket does everything WsLike promises. The connection only sets
|
|
101
|
+
`binaryType`, reads `readyState`, sends a Uint8Array, closes, and assigns its own handlers,
|
|
102
|
+
which ignore the Event they are given and read only `data` off a MessageEvent. The declared
|
|
103
|
+
types differ only in the DOM handler signatures' `this` and event parameters. */
|
|
92
104
|
const WebSocketCtor =
|
|
93
105
|
options.webSocketImpl ??
|
|
94
|
-
(globalThis.WebSocket as
|
|
106
|
+
(globalThis.WebSocket as new (url: string, protocols?: string[]) => WsLike);
|
|
95
107
|
const openTimeoutMs = options.openTimeoutMs ?? 10_000;
|
|
96
108
|
const docs = new Map<string, DocEntry>();
|
|
97
109
|
const statusCbs = new Set<(status: CollabStatus) => void>();
|
|
@@ -135,7 +147,9 @@ export function createWsCollabConnection(options: WsCollabConnectionOptions): Ws
|
|
|
135
147
|
// Reconnect via the full-state re-publish below).
|
|
136
148
|
awareness.on(
|
|
137
149
|
"update",
|
|
138
|
-
|
|
150
|
+
/* Every awareness origin is a string: "remote" for what came off the wire, "local" for
|
|
151
|
+
`setLocalState`, "destroy" for the teardown removal, and "timeout" for y-protocols' expiry. */
|
|
152
|
+
(changes: { added: number[]; updated: number[]; removed: number[] }, origin: string) => {
|
|
139
153
|
if (origin === "remote") {
|
|
140
154
|
return;
|
|
141
155
|
}
|
|
@@ -147,13 +161,18 @@ export function createWsCollabConnection(options: WsCollabConnectionOptions): Ws
|
|
|
147
161
|
const handleControl = (message: ControlMessage) => {
|
|
148
162
|
switch (message.type) {
|
|
149
163
|
case "hello": {
|
|
150
|
-
|
|
164
|
+
const next: CollabIdentity = {
|
|
151
165
|
color: message.color,
|
|
152
166
|
login: message.login,
|
|
153
167
|
permission: message.permission,
|
|
154
|
-
...(message.name === undefined ? {} : { name: message.name }),
|
|
155
|
-
...(message.avatarUrl === undefined ? {} : { avatarUrl: message.avatarUrl }),
|
|
156
168
|
};
|
|
169
|
+
if (message.name !== undefined) {
|
|
170
|
+
next.name = message.name;
|
|
171
|
+
}
|
|
172
|
+
if (message.avatarUrl !== undefined) {
|
|
173
|
+
next.avatarUrl = message.avatarUrl;
|
|
174
|
+
}
|
|
175
|
+
identity = next;
|
|
157
176
|
return;
|
|
158
177
|
}
|
|
159
178
|
case "opened": {
|
|
@@ -346,10 +365,14 @@ export function createWsCollabConnection(options: WsCollabConnectionOptions): Ws
|
|
|
346
365
|
// The close handler follows and owns retry.
|
|
347
366
|
};
|
|
348
367
|
};
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
}
|
|
368
|
+
const policy: ReconnectPolicy = {};
|
|
369
|
+
if (options.reconnectDelayMs !== undefined) {
|
|
370
|
+
policy.baseMs = options.reconnectDelayMs;
|
|
371
|
+
}
|
|
372
|
+
if (options.reconnectRandom !== undefined) {
|
|
373
|
+
policy.random = options.reconnectRandom;
|
|
374
|
+
}
|
|
375
|
+
const reconnect = createReconnectScheduler(connect, policy);
|
|
353
376
|
connect();
|
|
354
377
|
|
|
355
378
|
return {
|
|
@@ -400,7 +423,7 @@ export function createWsCollabConnection(options: WsCollabConnectionOptions): Ws
|
|
|
400
423
|
docs.set(path, entry);
|
|
401
424
|
|
|
402
425
|
// Local doc updates go to the wire (unless they came FROM the wire).
|
|
403
|
-
entry.doc.on("update", (update: Uint8Array, origin:
|
|
426
|
+
entry.doc.on("update", (update: Uint8Array, origin: DocTransactionOrigin) => {
|
|
404
427
|
if (origin === "remote" || !entry.opened) {
|
|
405
428
|
return;
|
|
406
429
|
}
|
package/src/ws-room.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
removeAwarenessStates,
|
|
17
17
|
} from "y-protocols/awareness";
|
|
18
18
|
import * as syncProtocol from "y-protocols/sync";
|
|
19
|
+
import { isString } from "@jxsuite/schema/guards";
|
|
19
20
|
import { decodeFrame, encodeFrame } from "./envelope.ts";
|
|
20
21
|
import type { CollabFrame, ControlMessage } from "./envelope.ts";
|
|
21
22
|
import type { CollabIdentity } from "./provider.ts";
|
|
@@ -42,7 +43,7 @@ export interface CollabHostOptions {
|
|
|
42
43
|
interface Room {
|
|
43
44
|
doc: Y.Doc;
|
|
44
45
|
epoch: number;
|
|
45
|
-
subscribers: Set<
|
|
46
|
+
subscribers: Set<InternalConnection>;
|
|
46
47
|
ready: Promise<void>;
|
|
47
48
|
/** The source text as of the last persist (seed or markPersisted) — the room-dirty baseline. */
|
|
48
49
|
baseline: string;
|
|
@@ -55,6 +56,17 @@ export interface HostConnection {
|
|
|
55
56
|
close: () => void;
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
/** The host's own view of a connection: the embedder only ever sees the {@link HostConnection}. */
|
|
60
|
+
interface InternalConnection extends HostConnection {
|
|
61
|
+
socket: RoomSocket;
|
|
62
|
+
identity: CollabIdentity;
|
|
63
|
+
subscribed: Set<string>;
|
|
64
|
+
/** Awareness clientIDs this socket controls (removed on close). */
|
|
65
|
+
controlledIds: Set<number>;
|
|
66
|
+
warnedReadOnly: boolean;
|
|
67
|
+
closed: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
58
70
|
export interface CollabHost {
|
|
59
71
|
connect: (socket: RoomSocket, identity: CollabIdentity) => HostConnection;
|
|
60
72
|
/**
|
|
@@ -87,16 +99,6 @@ export function createCollabHost(options: CollabHostOptions): CollabHost {
|
|
|
87
99
|
// The host's own (empty) local state must not leak into rosters.
|
|
88
100
|
awareness.setLocalState(null);
|
|
89
101
|
|
|
90
|
-
interface InternalConnection extends HostConnection {
|
|
91
|
-
socket: RoomSocket;
|
|
92
|
-
identity: CollabIdentity;
|
|
93
|
-
subscribed: Set<string>;
|
|
94
|
-
/** Awareness clientIDs this socket controls (removed on close). */
|
|
95
|
-
controlledIds: Set<number>;
|
|
96
|
-
warnedReadOnly: boolean;
|
|
97
|
-
closed: boolean;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
102
|
const send = (conn: InternalConnection, frame: CollabFrame) => {
|
|
101
103
|
try {
|
|
102
104
|
conn.socket.send(encodeFrame(frame));
|
|
@@ -111,8 +113,7 @@ export function createCollabHost(options: CollabHostOptions): CollabHost {
|
|
|
111
113
|
|
|
112
114
|
const broadcastToRoom = (room: Room, frame: CollabFrame, except?: InternalConnection) => {
|
|
113
115
|
const data = encodeFrame(frame);
|
|
114
|
-
for (const
|
|
115
|
-
const target = subscriber as InternalConnection;
|
|
116
|
+
for (const target of room.subscribers) {
|
|
116
117
|
if (target !== except && !target.closed) {
|
|
117
118
|
try {
|
|
118
119
|
target.socket.send(data);
|
|
@@ -150,7 +151,9 @@ export function createCollabHost(options: CollabHostOptions): CollabHost {
|
|
|
150
151
|
sourceText(doc).insert(0, source);
|
|
151
152
|
}, "seed");
|
|
152
153
|
})();
|
|
153
|
-
doc
|
|
154
|
+
/* A room doc is written only here: the "seed" transaction above, and the sync-protocol reads in
|
|
155
|
+
`handleDocSync`, which tag the update with the connection it came from. */
|
|
156
|
+
doc.on("update", (update: Uint8Array, origin: InternalConnection | string) => {
|
|
154
157
|
const current = rooms.get(path);
|
|
155
158
|
if (!current || current.doc !== doc) {
|
|
156
159
|
return;
|
|
@@ -160,7 +163,8 @@ export function createCollabHost(options: CollabHostOptions): CollabHost {
|
|
|
160
163
|
broadcastToRoom(
|
|
161
164
|
current,
|
|
162
165
|
{ body: encoding.toUint8Array(body), epoch: current.epoch, path, type: "doc-sync" },
|
|
163
|
-
|
|
166
|
+
// The sender already has the update, so it is the one subscriber the relay skips.
|
|
167
|
+
isString(origin) ? undefined : origin,
|
|
164
168
|
);
|
|
165
169
|
options.onSourceChange?.(path);
|
|
166
170
|
// Room-level dirty: broadcast on transition so every peer's Save affordance stays in sync.
|
|
@@ -379,14 +383,19 @@ export function createCollabHost(options: CollabHostOptions): CollabHost {
|
|
|
379
383
|
warnedReadOnly: false,
|
|
380
384
|
};
|
|
381
385
|
connections.add(conn);
|
|
382
|
-
|
|
386
|
+
const hello: Extract<ControlMessage, { type: "hello" }> = {
|
|
383
387
|
color: identity.color,
|
|
384
388
|
login: identity.login,
|
|
385
389
|
permission: identity.permission,
|
|
386
390
|
type: "hello",
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
391
|
+
};
|
|
392
|
+
if (identity.name !== undefined) {
|
|
393
|
+
hello.name = identity.name;
|
|
394
|
+
}
|
|
395
|
+
if (identity.avatarUrl !== undefined) {
|
|
396
|
+
hello.avatarUrl = identity.avatarUrl;
|
|
397
|
+
}
|
|
398
|
+
sendControl(conn, hello);
|
|
390
399
|
// Late joiners receive the current presence roster immediately.
|
|
391
400
|
const states = awareness.getStates();
|
|
392
401
|
if (states.size > 0) {
|
|
@@ -447,7 +456,7 @@ export function createCollabHost(options: CollabHostOptions): CollabHost {
|
|
|
447
456
|
type: "control",
|
|
448
457
|
});
|
|
449
458
|
for (const subscriber of room.subscribers) {
|
|
450
|
-
|
|
459
|
+
subscriber.subscribed.delete(path);
|
|
451
460
|
}
|
|
452
461
|
room.doc.destroy();
|
|
453
462
|
rooms.delete(path);
|