@oh-just-another/collab 0.57.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +57 -0
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/awareness.d.ts +60 -0
  6. package/dist/awareness.d.ts.map +1 -0
  7. package/dist/awareness.js +89 -0
  8. package/dist/awareness.js.map +1 -0
  9. package/dist/bind-awareness.d.ts +33 -0
  10. package/dist/bind-awareness.d.ts.map +1 -0
  11. package/dist/bind-awareness.js +125 -0
  12. package/dist/bind-awareness.js.map +1 -0
  13. package/dist/bind-editor.d.ts +34 -0
  14. package/dist/bind-editor.d.ts.map +1 -0
  15. package/dist/bind-editor.js +87 -0
  16. package/dist/bind-editor.js.map +1 -0
  17. package/dist/branch-doc.d.ts +53 -0
  18. package/dist/branch-doc.d.ts.map +1 -0
  19. package/dist/branch-doc.js +188 -0
  20. package/dist/branch-doc.js.map +1 -0
  21. package/dist/constants.d.ts +26 -0
  22. package/dist/constants.d.ts.map +1 -0
  23. package/dist/constants.js +26 -0
  24. package/dist/constants.js.map +1 -0
  25. package/dist/encryption.d.ts +74 -0
  26. package/dist/encryption.d.ts.map +1 -0
  27. package/dist/encryption.js +124 -0
  28. package/dist/encryption.js.map +1 -0
  29. package/dist/index.d.ts +12 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +13 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/mentions.d.ts +33 -0
  34. package/dist/mentions.d.ts.map +1 -0
  35. package/dist/mentions.js +57 -0
  36. package/dist/mentions.js.map +1 -0
  37. package/dist/merge.d.ts +59 -0
  38. package/dist/merge.d.ts.map +1 -0
  39. package/dist/merge.js +2 -0
  40. package/dist/merge.js.map +1 -0
  41. package/dist/scene-doc.d.ts +41 -0
  42. package/dist/scene-doc.d.ts.map +1 -0
  43. package/dist/scene-doc.js +111 -0
  44. package/dist/scene-doc.js.map +1 -0
  45. package/dist/transport-provider.d.ts +21 -0
  46. package/dist/transport-provider.d.ts.map +1 -0
  47. package/dist/transport-provider.js +105 -0
  48. package/dist/transport-provider.js.map +1 -0
  49. package/dist/yjs-history.d.ts +68 -0
  50. package/dist/yjs-history.d.ts.map +1 -0
  51. package/dist/yjs-history.js +215 -0
  52. package/dist/yjs-history.js.map +1 -0
  53. package/package.json +59 -0
@@ -0,0 +1,125 @@
1
+ import { getElement, getElementWorldBounds } from "@oh-just-another/scene";
2
+ import { PEER_CURSOR_BROADCAST_INTERVAL_MS, } from "@oh-just-another/state";
3
+ /**
4
+ * Wire an `Editor` to a `CollabAwareness`:
5
+ *
6
+ * - publishes the local user payload + cursor + selection into
7
+ * awareness, throttled so a fast drag does not flood the room;
8
+ * - pushes the room's other-peer cursors / selections back into the
9
+ * editor so the overlay paints them.
10
+ *
11
+ * Returns an unbind function. Clearing the editor selection or cursor
12
+ * leaves awareness in a sane state.
13
+ *
14
+ * The editor exposes `onCursorMove` / `setPeerCursors` /
15
+ * `setPeerSelections` as the extension points; `@state` knows nothing
16
+ * about Yjs / awareness.
17
+ */
18
+ export const bindAwareness = (editor, awareness, options) => {
19
+ const throttle = options.cursorThrottleMs ?? PEER_CURSOR_BROADCAST_INTERVAL_MS;
20
+ let lastBroadcastAt = 0;
21
+ let pendingCursor = null;
22
+ let flushTimer = null;
23
+ // Initial identity push so peers see us before our first move.
24
+ awareness.updateLocal({ user: options.user });
25
+ const broadcastNow = () => {
26
+ if (!pendingCursor)
27
+ return;
28
+ awareness.updateLocal({ cursor: pendingCursor });
29
+ lastBroadcastAt = Date.now();
30
+ pendingCursor = null;
31
+ flushTimer = null;
32
+ };
33
+ const unsubscribeCursor = editor.onCursorMove((point) => {
34
+ pendingCursor = point;
35
+ const elapsed = Date.now() - lastBroadcastAt;
36
+ if (elapsed >= throttle) {
37
+ broadcastNow();
38
+ }
39
+ else {
40
+ flushTimer ??= setTimeout(broadcastNow, throttle - elapsed);
41
+ }
42
+ });
43
+ // Local selection → awareness, with a side effect: any editor change
44
+ // (selection OR scene mutation) re-resolves peer selection bounds so
45
+ // a peer's halo follows their selected shapes as they get edited.
46
+ let lastSelectionKey = null;
47
+ const unsubscribeSelection = editor.subscribe(() => {
48
+ const ids = [...editor.selection].sort();
49
+ const key = ids.join(",");
50
+ if (key !== lastSelectionKey) {
51
+ lastSelectionKey = key;
52
+ awareness.updateLocal({ selection: ids });
53
+ }
54
+ // Re-resolve peer selection bounds from the current scene.
55
+ refreshPeerSelections();
56
+ });
57
+ // Awareness → editor. Each change recomputes both cursors and
58
+ // selections from the room state and pushes the materialised arrays
59
+ // to the editor (which paints them on the overlay).
60
+ let lastPeers = awareness.getPeers();
61
+ const applyPeers = (peers) => {
62
+ lastPeers = peers;
63
+ const cursors = [];
64
+ const selections = [];
65
+ for (const p of peers) {
66
+ if (p.clientId === awareness.clientId)
67
+ continue;
68
+ if (p.cursor) {
69
+ cursors.push({
70
+ position: p.cursor,
71
+ color: p.user.color,
72
+ name: p.user.name,
73
+ });
74
+ }
75
+ if (p.selection && p.selection.length > 0) {
76
+ const bounds = resolveSelectionBounds(editor, p.selection);
77
+ if (bounds.length > 0) {
78
+ selections.push({ color: p.user.color, bounds });
79
+ }
80
+ }
81
+ }
82
+ editor.setPeerCursors(cursors);
83
+ editor.setPeerSelections(selections);
84
+ };
85
+ // Re-resolve only the selections (cursors don't depend on scene state).
86
+ // Called when the editor scene changes — peer halos should track shape
87
+ // movement / resize made by anyone.
88
+ const refreshPeerSelections = () => {
89
+ const selections = [];
90
+ for (const p of lastPeers) {
91
+ if (p.clientId === awareness.clientId)
92
+ continue;
93
+ if (!p.selection || p.selection.length === 0)
94
+ continue;
95
+ const bounds = resolveSelectionBounds(editor, p.selection);
96
+ if (bounds.length > 0)
97
+ selections.push({ color: p.user.color, bounds });
98
+ }
99
+ editor.setPeerSelections(selections);
100
+ };
101
+ // Push current snapshot immediately so a late-binding sees existing peers.
102
+ applyPeers(lastPeers);
103
+ const unsubscribeAwareness = awareness.onPeers(applyPeers);
104
+ return () => {
105
+ unsubscribeCursor();
106
+ unsubscribeSelection();
107
+ unsubscribeAwareness();
108
+ if (flushTimer !== null)
109
+ clearTimeout(flushTimer);
110
+ awareness.updateLocal({ cursor: null, selection: null });
111
+ editor.setPeerCursors([]);
112
+ editor.setPeerSelections([]);
113
+ };
114
+ };
115
+ const resolveSelectionBounds = (editor, ids) => {
116
+ const out = [];
117
+ for (const id of ids) {
118
+ const shape = getElement(editor.scene, id);
119
+ if (!shape)
120
+ continue;
121
+ out.push(getElementWorldBounds(shape));
122
+ }
123
+ return out;
124
+ };
125
+ //# sourceMappingURL=bind-awareness.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bind-awareness.js","sourceRoot":"","sources":["../src/bind-awareness.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ import type * as Y from "yjs";
2
+ import type { Editor } from "@oh-just-another/state";
3
+ import { SceneDoc } from "./scene-doc.js";
4
+ export interface BindEditorOptions {
5
+ /**
6
+ * Milliseconds to wait for a remote sync response before deciding to
7
+ * seed the CRDT from the local scene. Defaults to 0 (no wait — the
8
+ * connecting tab seeds immediately if the doc looks empty).
9
+ *
10
+ * Set this to a small value (e.g. 200 ms) in transports where peers
11
+ * may answer asynchronously (BroadcastChannel, WebSocket). Joiners
12
+ * will then adopt the room's existing state instead of clobbering it
13
+ * with their own.
14
+ */
15
+ readonly waitForSyncMs?: number;
16
+ }
17
+ /**
18
+ * Wire an `Editor` to a `SceneDoc` (or raw `Y.Doc`) so that local
19
+ * changes propagate to the CRDT and remote CRDT changes feed back into
20
+ * the editor.
21
+ *
22
+ * Sync handshake: when `waitForSyncMs > 0` and the CRDT looks empty
23
+ * at bind-time, we wait that long for the first inbound update. If it
24
+ * arrives, the editor adopts the room's state (`loadScene(snapshot)`).
25
+ * If the timeout elapses (we're the only peer), we seed the room with
26
+ * the editor's current scene.
27
+ *
28
+ * Self-origin transactions are tagged so the local subscriber doesn't
29
+ * apply its own change twice.
30
+ *
31
+ * Returns an unbind function.
32
+ */
33
+ export declare const bindEditor: (editor: Editor, source: SceneDoc | Y.Doc, options?: BindEditorOptions) => (() => void);
34
+ //# sourceMappingURL=bind-editor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bind-editor.d.ts","sourceRoot":"","sources":["../src/bind-editor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,CAAC,MAAM,KAAK,CAAC;AAC9B,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAErD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE1C,MAAM,WAAW,iBAAiB;IAChC;;;;;;;;;OASG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;CACjC;AAED;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,UAAU,GACrB,QAAQ,MAAM,EACd,QAAQ,QAAQ,GAAG,CAAC,CAAC,GAAG,EACxB,UAAS,iBAAsB,KAC9B,CAAC,MAAM,IAAI,CAqEb,CAAC"}
@@ -0,0 +1,87 @@
1
+ import { SceneDoc } from "./scene-doc.js";
2
+ /**
3
+ * Wire an `Editor` to a `SceneDoc` (or raw `Y.Doc`) so that local
4
+ * changes propagate to the CRDT and remote CRDT changes feed back into
5
+ * the editor.
6
+ *
7
+ * Sync handshake: when `waitForSyncMs > 0` and the CRDT looks empty
8
+ * at bind-time, we wait that long for the first inbound update. If it
9
+ * arrives, the editor adopts the room's state (`loadScene(snapshot)`).
10
+ * If the timeout elapses (we're the only peer), we seed the room with
11
+ * the editor's current scene.
12
+ *
13
+ * Self-origin transactions are tagged so the local subscriber doesn't
14
+ * apply its own change twice.
15
+ *
16
+ * Returns an unbind function.
17
+ */
18
+ export const bindEditor = (editor, source, options = {}) => {
19
+ const sceneDoc = source instanceof SceneDoc ? source : new SceneDoc(source);
20
+ const origin = Symbol("editor-binding");
21
+ const waitMs = options.waitForSyncMs ?? 0;
22
+ let lastSyncedScene = editor.scene;
23
+ let disposed = false;
24
+ // Local change → CRDT.
25
+ const unsubscribeLocal = editor.subscribe(() => {
26
+ if (disposed)
27
+ return;
28
+ if (editor.scene === lastSyncedScene)
29
+ return;
30
+ sceneDoc.applyDelta(lastSyncedScene, editor.scene, origin);
31
+ lastSyncedScene = editor.scene;
32
+ });
33
+ // CRDT change → local. Filter out self-origin updates by transaction tag.
34
+ const onUpdate = (_update, originOfUpdate) => {
35
+ if (disposed || originOfUpdate === origin)
36
+ return;
37
+ const snapshot = sceneDoc.snapshot();
38
+ editor.loadScene(snapshot, { preserveHistory: true });
39
+ lastSyncedScene = editor.scene;
40
+ };
41
+ sceneDoc.doc.on("update", onUpdate);
42
+ // Initial seed vs adopt.
43
+ const isCurrentlyEmpty = () => sceneDoc.elements.size === 0 && sceneDoc.links.size === 0 && sceneDoc.layers.size === 0;
44
+ const seedFromEditor = () => {
45
+ if (disposed)
46
+ return;
47
+ sceneDoc.replace(editor.scene, origin);
48
+ lastSyncedScene = editor.scene;
49
+ };
50
+ const adoptFromCrdt = () => {
51
+ if (disposed)
52
+ return;
53
+ const snapshot = sceneDoc.snapshot();
54
+ editor.loadScene(snapshot);
55
+ lastSyncedScene = editor.scene;
56
+ };
57
+ if (!isCurrentlyEmpty()) {
58
+ adoptFromCrdt();
59
+ }
60
+ else if (waitMs <= 0) {
61
+ seedFromEditor();
62
+ }
63
+ else {
64
+ // Wait briefly for a peer to answer the implicit sync request that the
65
+ // TransportProvider sent on connect. If anything lands in the doc
66
+ // during that window, adopt; otherwise seed.
67
+ const timer = setTimeout(() => {
68
+ if (disposed)
69
+ return;
70
+ if (isCurrentlyEmpty())
71
+ seedFromEditor();
72
+ // Else: an update arrived → onUpdate already called loadScene.
73
+ }, waitMs);
74
+ return () => {
75
+ disposed = true;
76
+ clearTimeout(timer);
77
+ unsubscribeLocal();
78
+ sceneDoc.doc.off("update", onUpdate);
79
+ };
80
+ }
81
+ return () => {
82
+ disposed = true;
83
+ unsubscribeLocal();
84
+ sceneDoc.doc.off("update", onUpdate);
85
+ };
86
+ };
87
+ //# sourceMappingURL=bind-editor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bind-editor.js","sourceRoot":"","sources":["../src/bind-editor.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,53 @@
1
+ import * as Y from "yjs";
2
+ import type { Scene } from "@oh-just-another/scene";
3
+ import { SceneDoc } from "./scene-doc.js";
4
+ import type { BranchId, BranchMergeAPI, ConflictResolution, MergeReport } from "./merge.js";
5
+ export declare class BranchDoc implements BranchMergeAPI {
6
+ readonly doc: Y.Doc;
7
+ private readonly branches;
8
+ private readonly subdocs;
9
+ private readonly sceneDocs;
10
+ constructor(doc?: Y.Doc);
11
+ /**
12
+ * Create the root branch from an initial scene. Idempotent —
13
+ * if `id` already exists, returns the existing metadata
14
+ * unchanged.
15
+ */
16
+ ensureRoot(id: string, name: string, initialScene: Scene): BranchId;
17
+ /**
18
+ * Fork a new branch from an existing one. The new subdoc starts
19
+ * with a copy of the parent's current scene; that scene is also
20
+ * remembered as the common ancestor for future merges back into
21
+ * any other branch.
22
+ */
23
+ createBranch(id: string, name: string, parentBranchId: string): BranchId;
24
+ /** SceneDoc for the named branch (throws when unknown). */
25
+ sceneDocFor(branchId: string): SceneDoc;
26
+ /** Y.Doc subdoc passthrough — required by `BranchMergeAPI`. */
27
+ branchToDoc(branchId: BranchId): Y.Doc;
28
+ /**
29
+ * Merge `source` into `target`. Uses the source branch's stored
30
+ * ancestor as the three-way merge base. Auto-applied changes
31
+ * land in the returned `autoMerged` scene; conflicts wait for
32
+ * `applyConflictResolution`.
33
+ */
34
+ mergeBranch(source: BranchId, target: BranchId): Promise<MergeReport>;
35
+ /**
36
+ * Resolve a merge report and return the final scene. Pure —
37
+ * does NOT push the result back into any branch's subdoc; the
38
+ * host commits it by calling `sceneDocFor(targetId).replace(
39
+ * finalScene)` when it wants to materialise the merge.
40
+ */
41
+ applyConflictResolution(report: MergeReport, resolutions: readonly ConflictResolution[]): Promise<Scene>;
42
+ /**
43
+ * Commit `scene` into `targetBranchId`'s subdoc and re-baseline
44
+ * the source branch's ancestor so a follow-up merge starts from
45
+ * the post-merge state. Call after `applyConflictResolution`
46
+ * when the host is happy with the result.
47
+ */
48
+ commitMerge(sourceBranchId: string, targetBranchId: string, mergedScene: Scene): void;
49
+ private requireBranch;
50
+ private requireSceneDoc;
51
+ private requireSubdoc;
52
+ }
53
+ //# sourceMappingURL=branch-doc.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"branch-doc.d.ts","sourceRoot":"","sources":["../src/branch-doc.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC;AACzB,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,wBAAwB,CAAC;AAKpD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE1C,OAAO,KAAK,EACV,QAAQ,EACR,cAAc,EACd,kBAAkB,EAElB,WAAW,EACZ,MAAM,YAAY,CAAC;AAwCpB,qBAAa,SAAU,YAAW,cAAc;IAC9C,QAAQ,CAAC,GAAG,EAAE,CAAC,CAAC,GAAG,CAAC;IACpB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAwB;IACjD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA4B;IACpD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA+B;gBAE7C,GAAG,GAAE,CAAC,CAAC,GAAiB;IAKpC;;;;OAIG;IACH,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,YAAY,EAAE,KAAK,GAAG,QAAQ;IAyBnE;;;;;OAKG;IACH,YAAY,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,QAAQ;IAoBxE,2DAA2D;IAC3D,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,QAAQ;IAIvC,+DAA+D;IAC/D,WAAW,CAAC,QAAQ,EAAE,QAAQ,GAAG,CAAC,CAAC,GAAG;IAItC;;;;;OAKG;IACH,WAAW,CAAC,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,GAAG,OAAO,CAAC,WAAW,CAAC;IA6BrE;;;;;OAKG;IACH,uBAAuB,CACrB,MAAM,EAAE,WAAW,EACnB,WAAW,EAAE,SAAS,kBAAkB,EAAE,GACzC,OAAO,CAAC,KAAK,CAAC;IAoBjB;;;;;OAKG;IACH,WAAW,CAAC,cAAc,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,WAAW,EAAE,KAAK,GAAG,IAAI;IAUrF,OAAO,CAAC,aAAa;IAMrB,OAAO,CAAC,eAAe;IAUvB,OAAO,CAAC,aAAa;CAUtB"}
@@ -0,0 +1,188 @@
1
+ import * as Y from "yjs";
2
+ import { applyConflictResolutions, mergeScenesThreeWay, } from "@oh-just-another/scene";
3
+ import { SceneDoc } from "./scene-doc.js";
4
+ export class BranchDoc {
5
+ doc;
6
+ branches;
7
+ subdocs = new Map();
8
+ sceneDocs = new Map();
9
+ constructor(doc = new Y.Doc()) {
10
+ this.doc = doc;
11
+ this.branches = doc.getMap("branches");
12
+ }
13
+ /**
14
+ * Create the root branch from an initial scene. Idempotent —
15
+ * if `id` already exists, returns the existing metadata
16
+ * unchanged.
17
+ */
18
+ ensureRoot(id, name, initialScene) {
19
+ const existing = this.branches.get(id);
20
+ if (existing) {
21
+ return {
22
+ id: existing.id,
23
+ name: existing.name,
24
+ parentVersionId: existing.parentBranchId,
25
+ };
26
+ }
27
+ const subdoc = new Y.Doc();
28
+ const sceneDoc = new SceneDoc(subdoc);
29
+ sceneDoc.replace(initialScene, "branch-init");
30
+ const meta = {
31
+ id,
32
+ name,
33
+ parentBranchId: null,
34
+ subdocGuid: subdoc.guid,
35
+ ancestorScene: cloneScene(initialScene),
36
+ };
37
+ this.branches.set(id, meta);
38
+ this.subdocs.set(id, subdoc);
39
+ this.sceneDocs.set(id, sceneDoc);
40
+ return { id, name, parentVersionId: null };
41
+ }
42
+ /**
43
+ * Fork a new branch from an existing one. The new subdoc starts
44
+ * with a copy of the parent's current scene; that scene is also
45
+ * remembered as the common ancestor for future merges back into
46
+ * any other branch.
47
+ */
48
+ createBranch(id, name, parentBranchId) {
49
+ const parent = this.requireBranch(parentBranchId);
50
+ const parentDoc = this.requireSceneDoc(parentBranchId);
51
+ const snapshot = parentDoc.snapshot();
52
+ const subdoc = new Y.Doc();
53
+ const sceneDoc = new SceneDoc(subdoc);
54
+ sceneDoc.replace(snapshot, "branch-fork");
55
+ const meta = {
56
+ id,
57
+ name,
58
+ parentBranchId: parent.id,
59
+ subdocGuid: subdoc.guid,
60
+ ancestorScene: cloneScene(snapshot),
61
+ };
62
+ this.branches.set(id, meta);
63
+ this.subdocs.set(id, subdoc);
64
+ this.sceneDocs.set(id, sceneDoc);
65
+ return { id, name, parentVersionId: parent.id };
66
+ }
67
+ /** SceneDoc for the named branch (throws when unknown). */
68
+ sceneDocFor(branchId) {
69
+ return this.requireSceneDoc(branchId);
70
+ }
71
+ /** Y.Doc subdoc passthrough — required by `BranchMergeAPI`. */
72
+ branchToDoc(branchId) {
73
+ return this.requireSubdoc(branchId.id);
74
+ }
75
+ /**
76
+ * Merge `source` into `target`. Uses the source branch's stored
77
+ * ancestor as the three-way merge base. Auto-applied changes
78
+ * land in the returned `autoMerged` scene; conflicts wait for
79
+ * `applyConflictResolution`.
80
+ */
81
+ mergeBranch(source, target) {
82
+ const sMeta = this.requireBranch(source.id);
83
+ const sourceScene = this.requireSceneDoc(source.id).snapshot();
84
+ const targetScene = this.requireSceneDoc(target.id).snapshot();
85
+ // Yjs snapshot allocates fresh shape objects every call — reference
86
+ // equality (the algorithm's default) can't tell "unchanged" from
87
+ // "edited" across two snapshots. JSON.stringify is good enough for
88
+ // plain-object shapes, which is what the kernel ships.
89
+ const report = mergeScenesThreeWay(sMeta.ancestorScene, sourceScene, targetScene, {
90
+ compareElements: (a, b) => JSON.stringify(a) === JSON.stringify(b),
91
+ });
92
+ const applied = [];
93
+ for (const [id, shape] of report.autoMerged.elements) {
94
+ const prev = targetScene.elements.get(id);
95
+ if (prev !== shape)
96
+ applied.push(id);
97
+ }
98
+ const conflicts = report.conflicts.map((c) => ({
99
+ elementId: c.elementId,
100
+ base: c.base,
101
+ source: c.source,
102
+ target: c.target,
103
+ }));
104
+ return Promise.resolve({
105
+ applied,
106
+ conflicts,
107
+ autoMerged: report.autoMerged,
108
+ });
109
+ }
110
+ /**
111
+ * Resolve a merge report and return the final scene. Pure —
112
+ * does NOT push the result back into any branch's subdoc; the
113
+ * host commits it by calling `sceneDocFor(targetId).replace(
114
+ * finalScene)` when it wants to materialise the merge.
115
+ */
116
+ applyConflictResolution(report, resolutions) {
117
+ return Promise.resolve(applyConflictResolutions({
118
+ autoMerged: report.autoMerged,
119
+ conflicts: report.conflicts.map((c) => ({
120
+ elementId: c.elementId,
121
+ base: c.base,
122
+ source: c.source,
123
+ target: c.target,
124
+ })),
125
+ }, resolutions.map((r) => ({
126
+ elementId: r.elementId,
127
+ choice: r.choice === "ours" ? "ours" : r.choice === "theirs" ? "theirs" : "both",
128
+ }))));
129
+ }
130
+ /**
131
+ * Commit `scene` into `targetBranchId`'s subdoc and re-baseline
132
+ * the source branch's ancestor so a follow-up merge starts from
133
+ * the post-merge state. Call after `applyConflictResolution`
134
+ * when the host is happy with the result.
135
+ */
136
+ commitMerge(sourceBranchId, targetBranchId, mergedScene) {
137
+ const targetDoc = this.requireSceneDoc(targetBranchId);
138
+ targetDoc.replace(mergedScene, "branch-merge");
139
+ const sMeta = this.requireBranch(sourceBranchId);
140
+ this.branches.set(sourceBranchId, {
141
+ ...sMeta,
142
+ ancestorScene: cloneScene(mergedScene),
143
+ });
144
+ }
145
+ requireBranch(id) {
146
+ const m = this.branches.get(id);
147
+ if (!m)
148
+ throw new Error(`BranchDoc: unknown branch "${id}"`);
149
+ return m;
150
+ }
151
+ requireSceneDoc(id) {
152
+ const cached = this.sceneDocs.get(id);
153
+ if (cached)
154
+ return cached;
155
+ // Subdoc was created in a different session — rehydrate.
156
+ const subdoc = this.requireSubdoc(id);
157
+ const sceneDoc = new SceneDoc(subdoc);
158
+ this.sceneDocs.set(id, sceneDoc);
159
+ return sceneDoc;
160
+ }
161
+ requireSubdoc(id) {
162
+ const cached = this.subdocs.get(id);
163
+ if (cached)
164
+ return cached;
165
+ const meta = this.requireBranch(id);
166
+ // Create the subdoc shell with the recorded guid so peers
167
+ // converge on the same doc id across sessions.
168
+ const subdoc = new Y.Doc({ guid: meta.subdocGuid });
169
+ this.subdocs.set(id, subdoc);
170
+ return subdoc;
171
+ }
172
+ }
173
+ /**
174
+ * Deep clone a scene. The Yjs subdoc owns its own copies of
175
+ * shapes / edges / layers, so the ancestor snapshot has to be a
176
+ * detached deep clone — otherwise editor mutations would silently
177
+ * mutate the ancestor and the three-way merge would always see
178
+ * "no changes" on the source side.
179
+ */
180
+ const cloneScene = (scene) => ({
181
+ elements: new Map([...scene.elements].map(([id, shape]) => [id, structuredClone(shape)])),
182
+ links: new Map([...scene.links].map(([id, edge]) => [id, structuredClone(edge)])),
183
+ layers: new Map([...scene.layers].map(([id, layer]) => [id, structuredClone(layer)])),
184
+ annotations: new Map([...scene.annotations].map(([id, ann]) => [id, structuredClone(ann)])),
185
+ files: new Map([...scene.files].map(([id, file]) => [id, structuredClone(file)])),
186
+ viewport: structuredClone(scene.viewport),
187
+ });
188
+ //# sourceMappingURL=branch-doc.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"branch-doc.js","sourceRoot":"","sources":["../src/branch-doc.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Tunable thresholds for the collab package.
3
+ */
4
+ /**
5
+ * Length of the public room identifier in bytes (hex string =
6
+ * 2× chars). 10 bytes = 80 bits = ~1 in 2^80 collision odds at
7
+ * realistic concurrency; matches standard's 20-hex format.
8
+ */
9
+ export declare const ROOM_ID_BYTES = 10;
10
+ /**
11
+ * AES key length in bits. AES-128 is the modern default for
12
+ * symmetric session keys — fast on every platform and
13
+ * cryptographically equivalent to AES-256 against any practical
14
+ * attacker (the 256-bit version's marginal extra security
15
+ * matters only for offline attacks against a long-lived key,
16
+ * not for ephemeral session keys).
17
+ */
18
+ export declare const ENCRYPTION_KEY_BITS = 128;
19
+ /**
20
+ * Initialisation-vector length for AES-GCM. NIST SP 800-38D
21
+ * recommends 96 bits (12 bytes) — the construction is most
22
+ * efficient at that length, longer IVs go through an extra
23
+ * hashing step.
24
+ */
25
+ export declare const ENCRYPTION_IV_BYTES = 12;
26
+ //# sourceMappingURL=constants.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;;;GAIG;AACH,eAAO,MAAM,aAAa,KAAK,CAAC;AAEhC;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,MAAM,CAAC;AAEvC;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,KAAK,CAAC"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Tunable thresholds for the collab package.
3
+ */
4
+ /**
5
+ * Length of the public room identifier in bytes (hex string =
6
+ * 2× chars). 10 bytes = 80 bits = ~1 in 2^80 collision odds at
7
+ * realistic concurrency; matches standard's 20-hex format.
8
+ */
9
+ export const ROOM_ID_BYTES = 10;
10
+ /**
11
+ * AES key length in bits. AES-128 is the modern default for
12
+ * symmetric session keys — fast on every platform and
13
+ * cryptographically equivalent to AES-256 against any practical
14
+ * attacker (the 256-bit version's marginal extra security
15
+ * matters only for offline attacks against a long-lived key,
16
+ * not for ephemeral session keys).
17
+ */
18
+ export const ENCRYPTION_KEY_BITS = 128;
19
+ /**
20
+ * Initialisation-vector length for AES-GCM. NIST SP 800-38D
21
+ * recommends 96 bits (12 bytes) — the construction is most
22
+ * efficient at that length, longer IVs go through an extra
23
+ * hashing step.
24
+ */
25
+ export const ENCRYPTION_IV_BYTES = 12;
26
+ //# sourceMappingURL=constants.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constants.js","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,EAAE,CAAC;AAEhC;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,GAAG,CAAC;AAEvC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,EAAE,CAAC"}
@@ -0,0 +1,74 @@
1
+ import type { Transport } from "@oh-just-another/network";
2
+ /**
3
+ * Client-side AES-GCM encryption for collab transports. Mirrors
4
+ * standard's "URL fragment carries the key" model: the secret
5
+ * never reaches the relay, so a blind fan-out server can route
6
+ * payloads without ever decrypting them.
7
+ *
8
+ * URL convention (set by the host app, parsed in `apps/diagram/src/collab.ts`):
9
+ *
10
+ * /#room=<roomId>,<keyBase64>
11
+ *
12
+ * `roomId` goes in the WebSocket pathname (`/<roomId>`) so the
13
+ * relay can route by room. `keyBase64` stays in the URL fragment
14
+ * which the browser does not transmit on any request — including
15
+ * the WebSocket upgrade — so the server never sees it.
16
+ *
17
+ * Wire format per frame:
18
+ *
19
+ * [ 12-byte IV ][ AES-GCM ciphertext + 16-byte auth tag ]
20
+ *
21
+ * IV is random per frame (WebCrypto / NIST guidance — never reuse
22
+ * an IV with the same key). The auth tag is part of the ciphertext
23
+ * tail produced by AES-GCM; tampered frames throw on decrypt.
24
+ */
25
+ /** Strongly-typed result of {@link generateRoomKey}. */
26
+ export interface RoomCredentials {
27
+ /** Public room identifier — appears in the WS pathname, visible to relay. */
28
+ readonly roomId: string;
29
+ /**
30
+ * Base64url-encoded raw AES key. Goes into the URL fragment; share
31
+ * the full URL to invite peers. Never log this value.
32
+ */
33
+ readonly keyBase64: string;
34
+ /** The imported `CryptoKey` ready for AES-GCM operations. */
35
+ readonly key: CryptoKey;
36
+ }
37
+ /**
38
+ * Mint a fresh credentials pair for a new collab session. Uses
39
+ * `crypto.getRandomValues` for the roomId and `crypto.subtle` for
40
+ * the AES key.
41
+ */
42
+ export declare const generateRoomKey: () => Promise<RoomCredentials>;
43
+ /**
44
+ * Re-import a key the second peer parsed out of the URL fragment.
45
+ * Throws on malformed base64 / wrong length so the caller can
46
+ * surface "broken invite link" cleanly.
47
+ */
48
+ export declare const importRoomKey: (keyBase64: string) => Promise<CryptoKey>;
49
+ /**
50
+ * Wrap any {@link Transport} so every outgoing payload is AES-GCM
51
+ * encrypted and every incoming payload is decrypted before reaching
52
+ * subscribers. The relay sees nothing but binary blobs.
53
+ *
54
+ * Decryption errors are silently dropped — a tampered or
55
+ * wrong-key frame should not surface as a noisy console error
56
+ * (that would let a malicious peer flood the log). Callers can opt
57
+ * into observability by passing `onDecryptError`.
58
+ */
59
+ export declare class EncryptedTransport implements Transport {
60
+ private readonly inner;
61
+ private readonly key;
62
+ private readonly options;
63
+ private readonly handlers;
64
+ private unsubscribe;
65
+ constructor(inner: Transport, key: CryptoKey, options?: {
66
+ readonly onDecryptError?: (err: unknown) => void;
67
+ });
68
+ send(payload: Uint8Array): void;
69
+ onMessage(handler: (payload: Uint8Array) => void): () => void;
70
+ close(): void;
71
+ private encryptAndSend;
72
+ private decryptAndDispatch;
73
+ }
74
+ //# sourceMappingURL=encryption.d.ts.map