@jxsuite/collab 0.11.1 → 0.11.2

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.11.1",
3
+ "version": "0.11.2",
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": "^3.0.0",
38
+ "@jxsuite/schema": "^4.0.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 has no runtime imports, and that is its reason for existing.** It was the top of
17
- * `./diff.ts`, which value-imports `./schema.ts` for `replaceYStructure` alone — and `schema.ts`
18
- * imports yjs. So every consumer of the alignment logic paid for yjs, which put the studio's diff
19
- * highlighting (`canvas/diff-marks.ts`) behind a lazy import for a computation that never touches a
20
- * Y type. `diff.ts` re-exports everything here, so nothing that already imported it changed.
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 type { JxMutableNode, JxPath } from "@jxsuite/schema/types";
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
- override name = "MaxOpsExceededError";
40
- }
41
-
42
- function isPlainObject(value: unknown): value is Record<string, unknown> {
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: unknown, b: unknown): boolean {
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 (isPlainObject(a) && isPlainObject(b)) {
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: unknown): string {
74
- return typeof item === "string" ? `s:${item}` : `n:${contentHash(JSON.stringify(item))}`;
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: unknown): string {
79
- if (typeof item === "string") {
79
+ function weakKey(item: JxMutableNode | string): string {
80
+ if (isString(item)) {
80
81
  return "s";
81
82
  }
82
- const node = item as Record<string, unknown>;
83
- const attrs = node["attributes"];
84
- const props = node["$props"];
83
+ const attrs = item["attributes"];
84
+ const props = item["$props"];
85
85
  const id =
86
- (isPlainObject(attrs) ? attrs["id"] : undefined) ??
87
- (isPlainObject(props) ? props["key"] : undefined) ??
86
+ (isJsonObject(attrs) ? attrs["id"] : undefined) ??
87
+ (isJsonObject(props) ? props["key"] : undefined) ??
88
88
  "";
89
- return `w:${String(node["tagName"] ?? "")}:${String(id)}`;
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 unknown[],
164
- b: readonly unknown[],
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
- class Differ {
199
- private readonly ops: JxDocOp[] = [];
200
- private readonly maxOps: number;
201
-
202
- constructor(maxOps: number) {
203
- this.maxOps = maxOps;
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
- private push(op: JxDocOp): void {
211
- if (this.ops.length >= this.maxOps) {
212
- throw new MaxOpsExceededError();
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
- diffNode(path: JxPath, a: Record<string, unknown>, b: Record<string, unknown>): void {
218
- for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
219
- if (key === "children") {
220
- continue;
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
- const ca = a["children"];
229
- const cb = b["children"];
230
- if (Array.isArray(ca) && Array.isArray(cb)) {
231
- this.diffChildren(path, ca, cb);
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
- private diffChildren(path: JxPath, a: readonly unknown[], b: readonly unknown[]): void {
239
- const matches = matchChildren(a, b);
240
- const matchedA = new Set(matches.map((match) => match.aIndex));
241
- const byB = new Map(matches.map((match) => [match.bIndex, match]));
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
- // Removals first, in descending original index — each splice leaves lower indices intact.
244
- for (let i = a.length - 1; i >= 0; i--) {
245
- if (!matchedA.has(i)) {
246
- this.push({ index: i, op: "remove-child", parentPath: path });
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
- // Then walk b ascending: inserts land at their final index; matched pairs already sit there
250
- // (matches never cross), so recursion paths are valid as emitted.
251
- for (let i = 0; i < b.length; i++) {
252
- const match = byB.get(i);
253
- if (!match) {
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 differ = new Differ(opts.maxOps ?? DEFAULT_MAX_OPS);
280
+ const script: DiffScript = { maxOps: opts.maxOps ?? DEFAULT_MAX_OPS, ops: [] };
281
281
  try {
282
- differ.diffNode([], a as Record<string, unknown>, b as Record<string, unknown>);
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 differ.result();
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: unknown): void {
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 next) || next[key] === undefined) {
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(next)) {
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
- // oxlint-disable-next-line unicorn/prefer-structured-clone -- JSON normalization is the point
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:${(frame as CollabFrame).type}`);
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)) as ControlMessage;
133
- if (typeof message !== "object" || typeof message.type !== "string") {
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, 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.
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 {unknown} probe The parsed probe body, or null if it did not parse as JSON.
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: 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.
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 body = probe as { collab?: unknown; detail?: unknown; protocols?: unknown };
59
- if (body.collab === false) {
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 detail = typeof body.detail === "string" ? body.detail.trim() : "";
63
- return { offer: [], refused: detail || "This server has collaboration disabled." };
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) => typeof p === "string").join(", ") || "none";
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 type { JxPath } from "@jxsuite/schema/types";
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<unknown> {
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<unknown> {
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<unknown>();
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<unknown>, key: string, value: unknown): void {
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) && typeof value === "string") {
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) && isPlainObject(value)) {
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, cloneJson(value));
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 && typeof op.node === "string") {
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:${(op as JxDocOp).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: unknown): void {
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 (typeof segment !== "string") {
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) => typeof seg === "string" || typeof seg === "number");
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(events: readonly Y.YEvent<never>[]): JxDocOp[] | null {
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.path as JxPath;
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 as { doc?: Y.Doc | null };
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" && typeof index === "number") {
300
+ if (path.at(-2) === "children" && isNumber(index)) {
294
301
  ops.push({
295
302
  index,
296
- node: (event.target as Y.Text).toString(),
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
- const target = event.target as Y.Map<unknown>;
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
- ops.push({ index, node: yValueToJson(item), op: "insert-child", parentPath });
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: 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 ((event as PageTransitionEvent).persisted) {
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 type { JxMutableNode, JxPath } from "@jxsuite/schema/types";
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
- export function structureMap(doc: Y.Doc): Y.Map<unknown> {
59
- return doc.getMap(STRUCTURE_KEY);
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: Record<string, unknown>): Y.Map<unknown> {
136
- const map = new Y.Map<unknown>();
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, isPlainObject(entry) ? toYObject(entry) : cloneJson(entry));
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<unknown>, value: Record<string, unknown>): void {
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 (isPlainObject(entry)) {
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, cloneJson(entry));
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: unknown, b: unknown): boolean {
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
- /** 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);
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 unknown[]): Y.Array<unknown> {
194
- const arr = new Y.Array<unknown>();
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: unknown): Y.Map<unknown> {
208
- if (!isPlainObject(node)) {
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<unknown>();
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) && typeof value === "string") {
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) && isPlainObject(value)) {
246
+ } else if (GRANULAR_OBJECT_KEYS.has(key) && isDocumentRecord(value)) {
221
247
  map.set(key, toYObject(value));
222
248
  } else {
223
- map.set(key, cloneJson(value));
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: unknown): unknown {
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
- const out: Record<string, unknown> = {};
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 yValueToJson(structureMap(doc)) as JxMutableNode;
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): unknown {
257
- let current: unknown = structureMap(doc);
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 = typeof segment === "number" ? segment : Number(segment);
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?: unknown): boolean {
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?: Record<string, unknown>;
343
+ frontmatter?: DocumentRecord;
300
344
  sourceFormat?: string | null;
301
- origin?: unknown;
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, cloneJson(value));
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, cloneJson(value));
367
+ frontmatter.set(key, cloneValue(value));
324
368
  }
325
369
  }
326
370
  meta.set("schemaVersion", COLLAB_SCHEMA_VERSION);
@@ -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: unknown): boolean {
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: unknown): boolean {
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 unknown as new (url: string, protocols?: string[]) => WsLike);
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
- (changes: { added: number[]; updated: number[]; removed: number[] }, origin: unknown) => {
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
- identity = {
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 reconnect = createReconnectScheduler(connect, {
350
- ...(options.reconnectDelayMs === undefined ? {} : { baseMs: options.reconnectDelayMs }),
351
- ...(options.reconnectRandom === undefined ? {} : { random: options.reconnectRandom }),
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: unknown) => {
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<HostConnection>;
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 subscriber of room.subscribers) {
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.on("update", (update: Uint8Array, origin: unknown) => {
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
- origin instanceof Object && "socket" in origin ? (origin as InternalConnection) : undefined,
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
- sendControl(conn, {
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
- ...(identity.name === undefined ? {} : { name: identity.name }),
388
- ...(identity.avatarUrl === undefined ? {} : { avatarUrl: identity.avatarUrl }),
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
- (subscriber as InternalConnection).subscribed.delete(path);
459
+ subscriber.subscribed.delete(path);
451
460
  }
452
461
  room.doc.destroy();
453
462
  rooms.delete(path);