@jxsuite/collab 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -0
- package/package.json +40 -0
- package/src/awareness-types.ts +54 -0
- package/src/diff.ts +277 -0
- package/src/envelope.ts +137 -0
- package/src/index.ts +45 -0
- package/src/op-bridge.ts +218 -0
- package/src/ops.ts +128 -0
- package/src/provider.ts +45 -0
- package/src/schema.ts +205 -0
- package/src/ws-client.ts +436 -0
- package/src/ws-room.ts +424 -0
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @jxsuite/collab
|
|
2
|
+
|
|
3
|
+
Realtime co-editing for Jx Studio: the shared Y.Doc schema for a Jx document, the bridge between
|
|
4
|
+
Studio's transactional op-log and Yjs, a structural differ, the binary wire envelope every collab
|
|
5
|
+
backend speaks, and the provider contract Studio consumes through its Platform Abstraction Layer.
|
|
6
|
+
|
|
7
|
+
Everything here is environment-agnostic TypeScript (no DOM, no Node/Bun APIs): the same modules run
|
|
8
|
+
in the browser, in the `@jxsuite/server` dev server, and on Cloudflare Workers.
|
|
9
|
+
|
|
10
|
+
## The document model
|
|
11
|
+
|
|
12
|
+
One `Y.Doc` per open file:
|
|
13
|
+
|
|
14
|
+
| Root type | Purpose |
|
|
15
|
+
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
16
|
+
| `source: Y.Text` | **The authoritative file content.** Providers persist `getText("source").toString()` — nothing else. |
|
|
17
|
+
| `structure: Y.Map` | The `JxMutableNode` tree, projected by Studio. Only `children` nests (`Y.Array`); every other key is a plain-JSON value merged whole (LWW). |
|
|
18
|
+
| `frontmatter: Y.Map` | Per-field plain-JSON values. |
|
|
19
|
+
| `meta: Y.Map` | `schemaVersion`, `structureSeeded`, `canonical`, `canonicalRev`, `sourceFormat`. |
|
|
20
|
+
|
|
21
|
+
The CRDT granularity deliberately equals Studio's op-log granularity: mutators emit whole-value
|
|
22
|
+
`set-key`s and children splices, so those are the only merge points.
|
|
23
|
+
|
|
24
|
+
**Seeding contract.** Providers seed only `source` from file bytes; they never parse or serialize
|
|
25
|
+
Jx documents. Clients derive `structure` on first sync via `seedStructure()`, which is safe under
|
|
26
|
+
concurrent seeders because it only performs whole-key `Y.Map` sets (per-key LWW keeps one seeder's
|
|
27
|
+
subtrees wholesale — never array inserts, which would duplicate). A client's `Y.Doc` must start
|
|
28
|
+
empty; never seed locally before the provider's sync completes.
|
|
29
|
+
|
|
30
|
+
## The op bridge
|
|
31
|
+
|
|
32
|
+
- `applyDocOpsToY(doc, ops, origin)` replays Studio's recorded forward `JxDocOp`s onto the Y tree
|
|
33
|
+
in one origin-tagged transaction (`LOCAL_ORIGIN` for user edits, `MIRROR_ORIGIN` for derived
|
|
34
|
+
writes, `SEED_ORIGIN` for bootstraps).
|
|
35
|
+
- `yEventsToDocOps(events)` converts a remote transaction's deep events back into `JxDocOp`s —
|
|
36
|
+
call it **inside** the `observeDeep` callback. It returns `null` for shapes it cannot convert
|
|
37
|
+
safely; reconcile by diffing instead.
|
|
38
|
+
- `diffDocs(a, b)` produces ops transforming `a` into `b` (invariant: `apply(clone(a), diff(a,b))
|
|
39
|
+
≡ b`); returns `null` past `maxOps`, in which case `replaceYStructure()` hard-replaces and bumps
|
|
40
|
+
`meta.canonicalRev` so stale mirror writes are discarded.
|
|
41
|
+
- `applyDocOpToDoc` (in `./ops`, yjs-free) is the canonical plain-JSON applier shared with
|
|
42
|
+
Studio's history replay and canvas shadow doc.
|
|
43
|
+
|
|
44
|
+
## The wire envelope (`./envelope`)
|
|
45
|
+
|
|
46
|
+
One WebSocket per project, multiplexing every open document (lib0 binary frames):
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
frame := varUint frameType, body
|
|
50
|
+
0 DOC_SYNC := varString path, varUint docEpoch, varUint8Array y-protocols sync body
|
|
51
|
+
1 AWARENESS := varUint8Array y-protocols awareness update (project-scoped)
|
|
52
|
+
2 DOC_CLOSE := varString path
|
|
53
|
+
3 CONTROL := varString json — hello | open | opened | doc-reset | flush | flush-ack | error
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`docEpoch` is the server-owned lifetime counter of a doc's Y history. **Y history is never deleted
|
|
57
|
+
or replaced without an epoch bump**: out-of-band content replacement (git discard/pull/rename,
|
|
58
|
+
non-collab writes) bumps it and broadcasts `doc-reset`; clients destroy their `Y.Doc` and re-open.
|
|
59
|
+
Stale-epoch frames are dropped. This invariant is what prevents divergent-history duplicate-content
|
|
60
|
+
merges.
|
|
61
|
+
|
|
62
|
+
Session flow: connect → server sends `hello{login, name?, avatarUrl?, color, permission}` → client
|
|
63
|
+
sends `open{path}` per document → server ensures the room (loads persisted Y state or seeds
|
|
64
|
+
`source` from the file) → `opened{path, epoch}` → y-protocols sync handshake → updates flow.
|
|
65
|
+
Read-only sockets receive sync and may publish awareness, but their doc updates are dropped with
|
|
66
|
+
one `error{read-only}`.
|
|
67
|
+
|
|
68
|
+
## Awareness (`./awareness-types`)
|
|
69
|
+
|
|
70
|
+
One project-level `Awareness` per connection. State shape:
|
|
71
|
+
`{ user: {login, name?, avatarUrl?, color}, focusedPath, mode?, selection? }` — file-tree presence
|
|
72
|
+
and per-document cursor overlays both filter the same states. Identity comes from the server
|
|
73
|
+
`hello`; colors are deterministic (`colorForKey`).
|
|
74
|
+
|
|
75
|
+
## Provider contract (`./provider`, type-only)
|
|
76
|
+
|
|
77
|
+
`StudioPlatform.collab?: (docPath) => Promise<CollabHandle | null>` where a handle carries
|
|
78
|
+
`{doc, awareness, whenSynced, identity(), flush(), onStatus(), onReset(), destroy()}`. `onReset`
|
|
79
|
+
fires on a `doc-reset`: the handle is dead — destroy it and re-acquire.
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jxsuite/collab",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/jxsuite/jx.git",
|
|
9
|
+
"directory": "packages/collab"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src/"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.ts",
|
|
17
|
+
"./ops": "./src/ops.ts",
|
|
18
|
+
"./schema": "./src/schema.ts",
|
|
19
|
+
"./op-bridge": "./src/op-bridge.ts",
|
|
20
|
+
"./diff": "./src/diff.ts",
|
|
21
|
+
"./envelope": "./src/envelope.ts",
|
|
22
|
+
"./awareness-types": "./src/awareness-types.ts",
|
|
23
|
+
"./provider": "./src/provider.ts",
|
|
24
|
+
"./client": "./src/ws-client.ts",
|
|
25
|
+
"./room": "./src/ws-room.ts"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"provenance": false
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "bun test --isolate",
|
|
32
|
+
"test:coverage": "bun test --isolate --coverage"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@jxsuite/schema": "workspace:^",
|
|
36
|
+
"lib0": "^0.2.99",
|
|
37
|
+
"y-protocols": "^1.0.6",
|
|
38
|
+
"yjs": "13.6.27"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project-level awareness contract. One y-protocols Awareness instance rides each provider
|
|
3
|
+
* connection (not one per document): every client publishes a single state describing who they are
|
|
4
|
+
* and where they're working, which gives file-tree presence for free; per-document cursor overlays
|
|
5
|
+
* filter the same states by focusedPath.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { JxPath } from "@jxsuite/schema/types";
|
|
9
|
+
|
|
10
|
+
export interface CollabUser {
|
|
11
|
+
login: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
avatarUrl?: string;
|
|
14
|
+
/** Presence color, assigned deterministically by the server (hello) or colorForKey. */
|
|
15
|
+
color: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CollabAwarenessState {
|
|
19
|
+
user: CollabUser;
|
|
20
|
+
/** Project-relative path of the document the client is focused on, or null. */
|
|
21
|
+
focusedPath: string | null;
|
|
22
|
+
/** Which representation the client is editing (drives the Phase-B canonical lock). */
|
|
23
|
+
mode?: "structure" | "source";
|
|
24
|
+
/** The client's structural selection in the focused document. */
|
|
25
|
+
selection?: JxPath | null;
|
|
26
|
+
/**
|
|
27
|
+
* Whether this client may write (from the server hello). Drives reconciler election — the lowest
|
|
28
|
+
* write-capable clientID mirrors derived representations. Advisory only: the server drops updates
|
|
29
|
+
* from read-only sockets regardless.
|
|
30
|
+
*/
|
|
31
|
+
canWrite?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Eight distinguishable presence hues (dark-theme friendly). */
|
|
35
|
+
export const PRESENCE_PALETTE = [
|
|
36
|
+
"#4f9cf9",
|
|
37
|
+
"#e5484d",
|
|
38
|
+
"#30a46c",
|
|
39
|
+
"#f5a524",
|
|
40
|
+
"#8e4ec6",
|
|
41
|
+
"#00a2c7",
|
|
42
|
+
"#e93d82",
|
|
43
|
+
"#a8845c",
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
/** Deterministic palette pick — the same key always maps to the same color. */
|
|
47
|
+
export function colorForKey(key: string): string {
|
|
48
|
+
let hash = 5381;
|
|
49
|
+
for (let i = 0; i < key.length; i++) {
|
|
50
|
+
hash = Math.imul(hash, 33) + (key.codePointAt(i) ?? 0);
|
|
51
|
+
}
|
|
52
|
+
const index = Math.abs(hash) % PRESENCE_PALETTE.length;
|
|
53
|
+
return PRESENCE_PALETTE[index]!;
|
|
54
|
+
}
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural differ: `diffDocs(a, b)` produces JxDocOps that transform document `a` into `b` when
|
|
3
|
+
* replayed sequentially (property invariant: `apply(clone(a), diffDocs(a, b)) ≡ b`). The collab
|
|
4
|
+
* bridge uses it wherever no op-log exists — whole-document bypass writes (Monaco parse-flush,
|
|
5
|
+
* navigation, reload), un-instrumented transactions, and inbound Y transactions too gnarly for the
|
|
6
|
+
* fast event path.
|
|
7
|
+
*
|
|
8
|
+
* Children alignment: an LCS over strong keys (full-content hashes) pins unchanged nodes as
|
|
9
|
+
* anchors; the gaps between anchors align by a second LCS over weak signatures (tagName +
|
|
10
|
+
* id/$props.key) whose pairs diff recursively. Unmatched items become splices. Reorders of
|
|
11
|
+
* identical siblings therefore degrade to remove+insert — exactly what the Y move mapping does, so
|
|
12
|
+
* no fidelity is lost relative to the wire.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { JxMutableNode, JxPath } from "@jxsuite/schema/types";
|
|
16
|
+
import type { JxDocOp } from "./ops.ts";
|
|
17
|
+
import { metaMap, structureMap, toYChildren } from "./schema.ts";
|
|
18
|
+
import type * as Y from "yjs";
|
|
19
|
+
|
|
20
|
+
export interface DiffOptions {
|
|
21
|
+
/** Abort (return null) when the diff would exceed this many ops; caller hard-replaces instead. */
|
|
22
|
+
maxOps?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_MAX_OPS = 500;
|
|
26
|
+
|
|
27
|
+
class MaxOpsExceededError extends Error {
|
|
28
|
+
override name = "MaxOpsExceededError";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
32
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Order-insensitive for object keys, order-sensitive for arrays. */
|
|
36
|
+
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
37
|
+
if (a === b) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
41
|
+
return a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));
|
|
42
|
+
}
|
|
43
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
44
|
+
const keysA = Object.keys(a);
|
|
45
|
+
const keysB = Object.keys(b);
|
|
46
|
+
return (
|
|
47
|
+
keysA.length === keysB.length && keysA.every((key) => key in b && deepEqual(a[key], b[key]))
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function contentHash(text: string): string {
|
|
54
|
+
let hash = 5381;
|
|
55
|
+
for (let i = 0; i < text.length; i++) {
|
|
56
|
+
hash = Math.imul(hash, 33) + (text.codePointAt(i) ?? 0);
|
|
57
|
+
}
|
|
58
|
+
return hash.toString(36);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Full-content identity: equal strong keys mean "treat as the same unchanged child". */
|
|
62
|
+
function strongKey(item: unknown): string {
|
|
63
|
+
return typeof item === "string" ? `s:${item}` : `n:${contentHash(JSON.stringify(item))}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Alignment identity: equal weak keys mean "same logical child, diff its contents". */
|
|
67
|
+
function weakKey(item: unknown): string {
|
|
68
|
+
if (typeof item === "string") {
|
|
69
|
+
return "s";
|
|
70
|
+
}
|
|
71
|
+
const node = item as Record<string, unknown>;
|
|
72
|
+
const attrs = node["attributes"];
|
|
73
|
+
const props = node["$props"];
|
|
74
|
+
const id =
|
|
75
|
+
(isPlainObject(attrs) ? attrs["id"] : undefined) ??
|
|
76
|
+
(isPlainObject(props) ? props["key"] : undefined) ??
|
|
77
|
+
"";
|
|
78
|
+
return `w:${String(node["tagName"] ?? "")}:${String(id)}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Longest common subsequence over key arrays; returns strictly-increasing index pairs. */
|
|
82
|
+
function lcsPairs(aKeys: readonly string[], bKeys: readonly string[]): [number, number][] {
|
|
83
|
+
const n = aKeys.length;
|
|
84
|
+
const m = bKeys.length;
|
|
85
|
+
if (n === 0 || m === 0) {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
// Classic DP table; children arrays are small (bounded by document size, typically < 200).
|
|
89
|
+
const table: number[][] = Array.from({ length: n + 1 }, () =>
|
|
90
|
+
Array.from({ length: m + 1 }, () => 0),
|
|
91
|
+
);
|
|
92
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
93
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
94
|
+
table[i]![j] =
|
|
95
|
+
aKeys[i] === bKeys[j]
|
|
96
|
+
? table[i + 1]![j + 1]! + 1
|
|
97
|
+
: Math.max(table[i + 1]![j]!, table[i]![j + 1]!);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const pairs: [number, number][] = [];
|
|
101
|
+
let i = 0;
|
|
102
|
+
let j = 0;
|
|
103
|
+
while (i < n && j < m) {
|
|
104
|
+
if (aKeys[i] === bKeys[j]) {
|
|
105
|
+
pairs.push([i, j]);
|
|
106
|
+
i += 1;
|
|
107
|
+
j += 1;
|
|
108
|
+
} else if (table[i + 1]![j]! >= table[i]![j + 1]!) {
|
|
109
|
+
i += 1;
|
|
110
|
+
} else {
|
|
111
|
+
j += 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return pairs;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface ChildMatch {
|
|
118
|
+
aIndex: number;
|
|
119
|
+
bIndex: number;
|
|
120
|
+
/** Strong matches are byte-identical — no recursion needed. */
|
|
121
|
+
identical: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Anchor by strong LCS, then align each gap by weak LCS. Matches never cross. */
|
|
125
|
+
function matchChildren(a: readonly unknown[], b: readonly unknown[]): ChildMatch[] {
|
|
126
|
+
const anchors = lcsPairs(
|
|
127
|
+
a.map((item) => strongKey(item)),
|
|
128
|
+
b.map((item) => strongKey(item)),
|
|
129
|
+
);
|
|
130
|
+
const matches: ChildMatch[] = [];
|
|
131
|
+
let prevA = -1;
|
|
132
|
+
let prevB = -1;
|
|
133
|
+
const gaps: [number, number, number, number][] = [];
|
|
134
|
+
for (const [ai, bi] of anchors) {
|
|
135
|
+
gaps.push([prevA + 1, ai, prevB + 1, bi]);
|
|
136
|
+
matches.push({ aIndex: ai, bIndex: bi, identical: true });
|
|
137
|
+
prevA = ai;
|
|
138
|
+
prevB = bi;
|
|
139
|
+
}
|
|
140
|
+
gaps.push([prevA + 1, a.length, prevB + 1, b.length]);
|
|
141
|
+
for (const [aStart, aEnd, bStart, bEnd] of gaps) {
|
|
142
|
+
const aGap = a.slice(aStart, aEnd);
|
|
143
|
+
const bGap = b.slice(bStart, bEnd);
|
|
144
|
+
for (const [gi, gj] of lcsPairs(
|
|
145
|
+
aGap.map((item) => weakKey(item)),
|
|
146
|
+
bGap.map((item) => weakKey(item)),
|
|
147
|
+
)) {
|
|
148
|
+
matches.push({ aIndex: aStart + gi, bIndex: bStart + gj, identical: false });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return matches.toSorted((x, y) => x.aIndex - y.aIndex);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
class Differ {
|
|
155
|
+
private readonly ops: JxDocOp[] = [];
|
|
156
|
+
private readonly maxOps: number;
|
|
157
|
+
|
|
158
|
+
constructor(maxOps: number) {
|
|
159
|
+
this.maxOps = maxOps;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
result(): JxDocOp[] {
|
|
163
|
+
return this.ops;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private push(op: JxDocOp): void {
|
|
167
|
+
if (this.ops.length >= this.maxOps) {
|
|
168
|
+
throw new MaxOpsExceededError();
|
|
169
|
+
}
|
|
170
|
+
this.ops.push(op);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
diffNode(path: JxPath, a: Record<string, unknown>, b: Record<string, unknown>): void {
|
|
174
|
+
for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
175
|
+
if (key === "children") {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (!(key in b)) {
|
|
179
|
+
this.push({ key, op: "set-key", path });
|
|
180
|
+
} else if (!deepEqual(a[key], b[key])) {
|
|
181
|
+
this.push({ key, op: "set-key", path, value: b[key] });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const ca = a["children"];
|
|
185
|
+
const cb = b["children"];
|
|
186
|
+
if (Array.isArray(ca) && Array.isArray(cb)) {
|
|
187
|
+
this.diffChildren(path, ca, cb);
|
|
188
|
+
} else if (!deepEqual(ca, cb)) {
|
|
189
|
+
// Mapped-array children (or one side missing entirely) replace whole, like the mutators do.
|
|
190
|
+
this.push({ key: "children", op: "set-key", path, value: cb });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private diffChildren(path: JxPath, a: readonly unknown[], b: readonly unknown[]): void {
|
|
195
|
+
const matches = matchChildren(a, b);
|
|
196
|
+
const matchedA = new Set(matches.map((match) => match.aIndex));
|
|
197
|
+
const byB = new Map(matches.map((match) => [match.bIndex, match]));
|
|
198
|
+
|
|
199
|
+
// Removals first, in descending original index — each splice leaves lower indices intact.
|
|
200
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
201
|
+
if (!matchedA.has(i)) {
|
|
202
|
+
this.push({ index: i, op: "remove-child", parentPath: path });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Then walk b ascending: inserts land at their final index; matched pairs already sit there
|
|
206
|
+
// (matches never cross), so recursion paths are valid as emitted.
|
|
207
|
+
for (let i = 0; i < b.length; i++) {
|
|
208
|
+
const match = byB.get(i);
|
|
209
|
+
if (!match) {
|
|
210
|
+
this.push({ index: i, node: b[i], op: "insert-child", parentPath: path });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (match.identical) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const aItem = a[match.aIndex];
|
|
217
|
+
const bItem = b[i];
|
|
218
|
+
if (isPlainObject(aItem) && isPlainObject(bItem)) {
|
|
219
|
+
this.diffNode([...path, "children", i], aItem, bItem);
|
|
220
|
+
} else if (!deepEqual(aItem, bItem)) {
|
|
221
|
+
this.push({ index: i, node: bItem, op: "set-child", parentPath: path });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Ops that transform `a` into `b` when replayed sequentially, or null when the change is too large
|
|
229
|
+
* to express surgically (caller should hard-replace and reset).
|
|
230
|
+
*/
|
|
231
|
+
export function diffDocs(
|
|
232
|
+
a: JxMutableNode,
|
|
233
|
+
b: JxMutableNode,
|
|
234
|
+
opts: DiffOptions = {},
|
|
235
|
+
): JxDocOp[] | null {
|
|
236
|
+
const differ = new Differ(opts.maxOps ?? DEFAULT_MAX_OPS);
|
|
237
|
+
try {
|
|
238
|
+
differ.diffNode([], a as Record<string, unknown>, b as Record<string, unknown>);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error instanceof MaxOpsExceededError) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
return differ.result();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Hard-replace the structure tree with `document` (the diff-overflow escape hatch) and bump the
|
|
250
|
+
* in-doc canonicalRev so stale mirror writes computed against the old tree are discarded.
|
|
251
|
+
*/
|
|
252
|
+
export function replaceYStructure(doc: Y.Doc, document: JxMutableNode, origin: unknown): void {
|
|
253
|
+
doc.transact(() => {
|
|
254
|
+
const structure = structureMap(doc);
|
|
255
|
+
const next = document as Record<string, unknown>;
|
|
256
|
+
// Detached copy: keys are deleted while iterating.
|
|
257
|
+
const existingKeys = [...structure.keys()];
|
|
258
|
+
for (const key of existingKeys) {
|
|
259
|
+
if (!(key in next) || next[key] === undefined) {
|
|
260
|
+
structure.delete(key);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
for (const [key, value] of Object.entries(next)) {
|
|
264
|
+
if (value === undefined) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (key === "children" && Array.isArray(value)) {
|
|
268
|
+
structure.set(key, toYChildren(value));
|
|
269
|
+
} else {
|
|
270
|
+
// oxlint-disable-next-line unicorn/prefer-structured-clone -- JSON normalization is the point
|
|
271
|
+
structure.set(key, JSON.parse(JSON.stringify(value)));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const meta = metaMap(doc);
|
|
275
|
+
meta.set("canonicalRev", Number(meta.get("canonicalRev") ?? 0) + 1);
|
|
276
|
+
}, origin);
|
|
277
|
+
}
|
package/src/envelope.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The binary wire envelope every Jx collab provider speaks: the Cloudflare platform DO, the
|
|
3
|
+
* devserver endpoint in the server package, and the browser client. One WebSocket per project
|
|
4
|
+
* carries every open document, multiplexed by path:
|
|
5
|
+
*
|
|
6
|
+
* frame := varUint frameType, body
|
|
7
|
+
* 0 DOC_SYNC := varString path, varUint docEpoch, varUint8Array y-protocols sync body
|
|
8
|
+
* 1 AWARENESS := varUint8Array y-protocols awareness update (project-scoped)
|
|
9
|
+
* 2 DOC_CLOSE := varString path (client → server unsubscribe)
|
|
10
|
+
* 3 CONTROL := varString json (see ControlMessage)
|
|
11
|
+
*
|
|
12
|
+
* `docEpoch` is the server-owned lifetime counter of a document's Y history. Out-of-band content
|
|
13
|
+
* replacement (git discard/pull/rename, non-collab writes) bumps it; frames carrying a stale epoch
|
|
14
|
+
* are dropped and answered with a `doc-reset` control message, upon which the client destroys its
|
|
15
|
+
* Y.Doc and re-opens. The invariant "Y history is never deleted or replaced without an epoch bump"
|
|
16
|
+
* is what prevents divergent-history duplicate-content merges.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as decoding from "lib0/decoding";
|
|
20
|
+
import * as encoding from "lib0/encoding";
|
|
21
|
+
|
|
22
|
+
export const FRAME_DOC_SYNC = 0;
|
|
23
|
+
export const FRAME_AWARENESS = 1;
|
|
24
|
+
export const FRAME_DOC_CLOSE = 2;
|
|
25
|
+
export const FRAME_CONTROL = 3;
|
|
26
|
+
|
|
27
|
+
export type CollabPermission = "admin" | "write" | "read";
|
|
28
|
+
|
|
29
|
+
/** JSON payloads of CONTROL frames. */
|
|
30
|
+
export type ControlMessage =
|
|
31
|
+
/** Server → client on accept: the socket's identity and effective permission. */
|
|
32
|
+
| {
|
|
33
|
+
type: "hello";
|
|
34
|
+
login: string;
|
|
35
|
+
name?: string;
|
|
36
|
+
avatarUrl?: string;
|
|
37
|
+
color: string;
|
|
38
|
+
permission: CollabPermission;
|
|
39
|
+
}
|
|
40
|
+
/** Client → server: subscribe to a document (the client's Y.Doc must start EMPTY). */
|
|
41
|
+
| { type: "open"; path: string }
|
|
42
|
+
/** Server → client: the room is ready at this epoch; sync follows. */
|
|
43
|
+
| { type: "opened"; path: string; epoch: number }
|
|
44
|
+
/** Server → client: the doc's history was replaced; destroy the local Y.Doc and re-open. */
|
|
45
|
+
| { type: "doc-reset"; path: string; epoch: number }
|
|
46
|
+
/** Client → server: persist this doc to durable storage now (Cmd+S). */
|
|
47
|
+
| { type: "flush"; path: string }
|
|
48
|
+
/** Server → client: the flush landed. */
|
|
49
|
+
| { type: "flush-ack"; path: string }
|
|
50
|
+
/**
|
|
51
|
+
* Server → client: a request was refused (codes: read-only, content-not-loaded, too-large,
|
|
52
|
+
* rate-limited, binary-file, unknown-frame).
|
|
53
|
+
*/
|
|
54
|
+
| { type: "error"; path?: string; code: string; message: string };
|
|
55
|
+
|
|
56
|
+
export type CollabFrame =
|
|
57
|
+
| { type: "doc-sync"; path: string; epoch: number; body: Uint8Array }
|
|
58
|
+
| { type: "awareness"; body: Uint8Array }
|
|
59
|
+
| { type: "doc-close"; path: string }
|
|
60
|
+
| { type: "control"; message: ControlMessage };
|
|
61
|
+
|
|
62
|
+
export class EnvelopeError extends Error {
|
|
63
|
+
constructor(message: string) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = "EnvelopeError";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function encodeFrame(frame: CollabFrame): Uint8Array {
|
|
70
|
+
const encoder = encoding.createEncoder();
|
|
71
|
+
switch (frame.type) {
|
|
72
|
+
case "doc-sync": {
|
|
73
|
+
encoding.writeVarUint(encoder, FRAME_DOC_SYNC);
|
|
74
|
+
encoding.writeVarString(encoder, frame.path);
|
|
75
|
+
encoding.writeVarUint(encoder, frame.epoch);
|
|
76
|
+
encoding.writeVarUint8Array(encoder, frame.body);
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case "awareness": {
|
|
80
|
+
encoding.writeVarUint(encoder, FRAME_AWARENESS);
|
|
81
|
+
encoding.writeVarUint8Array(encoder, frame.body);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case "doc-close": {
|
|
85
|
+
encoding.writeVarUint(encoder, FRAME_DOC_CLOSE);
|
|
86
|
+
encoding.writeVarString(encoder, frame.path);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case "control": {
|
|
90
|
+
encoding.writeVarUint(encoder, FRAME_CONTROL);
|
|
91
|
+
encoding.writeVarString(encoder, JSON.stringify(frame.message));
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
default: {
|
|
95
|
+
throw new EnvelopeError(`unknown-frame:${(frame as CollabFrame).type}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return encoding.toUint8Array(encoder);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function decodeFrame(data: Uint8Array): CollabFrame {
|
|
102
|
+
try {
|
|
103
|
+
const decoder = decoding.createDecoder(data);
|
|
104
|
+
const frameType = decoding.readVarUint(decoder);
|
|
105
|
+
switch (frameType) {
|
|
106
|
+
case FRAME_DOC_SYNC: {
|
|
107
|
+
const path = decoding.readVarString(decoder);
|
|
108
|
+
const epoch = decoding.readVarUint(decoder);
|
|
109
|
+
const body = decoding.readVarUint8Array(decoder);
|
|
110
|
+
return { body, epoch, path, type: "doc-sync" };
|
|
111
|
+
}
|
|
112
|
+
case FRAME_AWARENESS: {
|
|
113
|
+
return { body: decoding.readVarUint8Array(decoder), type: "awareness" };
|
|
114
|
+
}
|
|
115
|
+
case FRAME_DOC_CLOSE: {
|
|
116
|
+
return { path: decoding.readVarString(decoder), type: "doc-close" };
|
|
117
|
+
}
|
|
118
|
+
case FRAME_CONTROL: {
|
|
119
|
+
const message = JSON.parse(decoding.readVarString(decoder)) as ControlMessage;
|
|
120
|
+
if (typeof message !== "object" || typeof message.type !== "string") {
|
|
121
|
+
throw new EnvelopeError("malformed-control");
|
|
122
|
+
}
|
|
123
|
+
return { message, type: "control" };
|
|
124
|
+
}
|
|
125
|
+
default: {
|
|
126
|
+
throw new EnvelopeError(`unknown-frame-type:${frameType}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (error instanceof EnvelopeError) {
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
throw new EnvelopeError(
|
|
134
|
+
`malformed-frame: ${error instanceof Error ? error.message : String(error)}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type { JxDocOp, JxDocOpPair } from "./ops.ts";
|
|
2
|
+
export { applyDocOpToDoc, childArray, cloneValue, getNodeAtPath } from "./ops.ts";
|
|
3
|
+
export {
|
|
4
|
+
COLLAB_SCHEMA_VERSION,
|
|
5
|
+
FRONTMATTER_KEY,
|
|
6
|
+
frontmatterMap,
|
|
7
|
+
META_KEY,
|
|
8
|
+
metaMap,
|
|
9
|
+
resolveYPath,
|
|
10
|
+
seedStructure,
|
|
11
|
+
SOURCE_KEY,
|
|
12
|
+
sourceText,
|
|
13
|
+
STRUCTURE_KEY,
|
|
14
|
+
structureMap,
|
|
15
|
+
toYChildren,
|
|
16
|
+
toYNode,
|
|
17
|
+
updateSourceText,
|
|
18
|
+
yDocToJson,
|
|
19
|
+
yValueToJson,
|
|
20
|
+
} from "./schema.ts";
|
|
21
|
+
// Re-exported so consumers keep a single yjs import point (avoids dual-instance hazards).
|
|
22
|
+
export { Doc as YDoc, UndoManager } from "yjs";
|
|
23
|
+
export {
|
|
24
|
+
applyDocOpsToY,
|
|
25
|
+
CollabPathError,
|
|
26
|
+
LOCAL_ORIGIN,
|
|
27
|
+
MIRROR_ORIGIN,
|
|
28
|
+
SEED_ORIGIN,
|
|
29
|
+
yEventsToDocOps,
|
|
30
|
+
} from "./op-bridge.ts";
|
|
31
|
+
export { deepEqual, diffDocs, replaceYStructure } from "./diff.ts";
|
|
32
|
+
export type { DiffOptions } from "./diff.ts";
|
|
33
|
+
export {
|
|
34
|
+
decodeFrame,
|
|
35
|
+
encodeFrame,
|
|
36
|
+
EnvelopeError,
|
|
37
|
+
FRAME_AWARENESS,
|
|
38
|
+
FRAME_CONTROL,
|
|
39
|
+
FRAME_DOC_CLOSE,
|
|
40
|
+
FRAME_DOC_SYNC,
|
|
41
|
+
} from "./envelope.ts";
|
|
42
|
+
export type { CollabFrame, CollabPermission, ControlMessage } from "./envelope.ts";
|
|
43
|
+
export { colorForKey, PRESENCE_PALETTE } from "./awareness-types.ts";
|
|
44
|
+
export type { CollabAwarenessState, CollabUser } from "./awareness-types.ts";
|
|
45
|
+
export type { CollabCapability, CollabHandle, CollabIdentity, CollabStatus } from "./provider.ts";
|