@pylonsync/loro 0.3.281

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 ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@pylonsync/loro",
3
+ "version": "0.3.281",
4
+ "description": "Pylon's local-first React layer — wraps loro-crdt with a registry, useLoroDoc hook, and the binary WS wire-format decoder that mirrors crates/router/encode_crdt_frame.",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "type": "module",
9
+ "main": "src/index.ts",
10
+ "types": "src/index.ts",
11
+ "exports": {
12
+ ".": "./src/index.ts",
13
+ "./wire": "./src/wire.ts",
14
+ "./registry": "./src/registry.ts"
15
+ },
16
+ "scripts": {
17
+ "check": "tsc -p tsconfig.json --noEmit"
18
+ },
19
+ "dependencies": {
20
+ "@pylonsync/react": "0.3.281",
21
+ "@pylonsync/sync": "0.3.281",
22
+ "loro-crdt": "^1.10.6"
23
+ },
24
+ "peerDependencies": {
25
+ "react": ">=19.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/react": "^19.0.0",
29
+ "typescript": "^5.5"
30
+ }
31
+ }
package/src/index.ts ADDED
@@ -0,0 +1,398 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @pylonsync/loro
3
+ //
4
+ // Local-first React layer on top of Pylon's CRDT broadcast.
5
+ //
6
+ // import { useLoroDoc, getLoroText } from "@pylonsync/loro";
7
+ //
8
+ // function MessageBody({ messageId }: { messageId: string }) {
9
+ // const doc = useLoroDoc("Message", messageId);
10
+ // const text = getLoroText(doc, "body").toString();
11
+ // return <p>{text}</p>;
12
+ // }
13
+ //
14
+ // The hook subscribes to binary CRDT frames the server broadcasts on
15
+ // every CRDT-mode write. Two browser tabs editing the same row
16
+ // converge through Loro's CRDT merge — concurrent same-field writes
17
+ // don't lose data the way LWW would.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ import { useSyncExternalStore, useEffect, useRef } from "react";
21
+ import type { FormEvent, RefObject } from "react";
22
+ import type { LoroDoc, LoroText, LoroMap } from "loro-crdt";
23
+ import { db, getBaseUrl, getReactStorage, storageKey } from "@pylonsync/react";
24
+ import { pylonFetch, type SyncEngine } from "@pylonsync/sync";
25
+ import { globalRegistry, LoroRegistry } from "./registry";
26
+
27
+ export { LoroRegistry, globalRegistry } from "./registry";
28
+ export {
29
+ decodeCrdtFrame,
30
+ encodeCrdtFrame,
31
+ CRDT_FRAME_SNAPSHOT,
32
+ CRDT_FRAME_UPDATE,
33
+ } from "./wire";
34
+ export type { CrdtFrame } from "./wire";
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Sync engine ↔ registry wiring
38
+ //
39
+ // Connect once, lazily — the first useLoroDoc call sets up the binary
40
+ // handler. Subsequent calls reuse the same registration. Re-registers
41
+ // transparently on hot module reload (the registry's Set semantics
42
+ // dedup the handler).
43
+ // ---------------------------------------------------------------------------
44
+
45
+ let attachedSync: SyncEngine | null = null;
46
+ let unsubscribeBinaryHandler: (() => void) | null = null;
47
+ let unsubscribeRowEviction: (() => void) | null = null;
48
+
49
+ function ensureAttached(): void {
50
+ const sync = db.sync;
51
+ if (attachedSync === sync) return;
52
+
53
+ // Tear down the previous engine's handlers if init() swapped it
54
+ // (test harness or re-init at runtime).
55
+ if (unsubscribeBinaryHandler) {
56
+ unsubscribeBinaryHandler();
57
+ unsubscribeBinaryHandler = null;
58
+ }
59
+ if (unsubscribeRowEviction) {
60
+ unsubscribeRowEviction();
61
+ unsubscribeRowEviction = null;
62
+ }
63
+ unsubscribeBinaryHandler = sync.onBinaryFrame((bytes: Uint8Array) => {
64
+ globalRegistry.applyBinaryFrame(bytes);
65
+ });
66
+ // The server's `row-revoked` envelope signals that the subscriber's
67
+ // policy was revoked mid-session for a specific row. Drop the
68
+ // cached LoroDoc so the collaborative handle unmounts instead of
69
+ // lingering with stale state until the tab closes.
70
+ unsubscribeRowEviction = sync.addRowEvictionListener(
71
+ (entity: string, rowId: string) => {
72
+ globalRegistry.evict(entity, rowId);
73
+ },
74
+ );
75
+ attachedSync = sync;
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // React hooks
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Subscribe to the LoroDoc for a row. Returns the same doc instance
84
+ * across renders for the same `(entity, id)` pair. The doc updates
85
+ * in place as binary CRDT frames arrive from the server; React
86
+ * re-renders the calling component on every applied frame via
87
+ * `useSyncExternalStore`.
88
+ *
89
+ * The doc is the *source of truth* for CRDT-mode entities — local
90
+ * mutations should go through doc operations (`getText().insert(...)`,
91
+ * `getMap().set(...)`) rather than `db.update`. The server's
92
+ * SQLite-projected row catches up via the next binary frame.
93
+ *
94
+ * For `crdt: false` entities the LoroDoc stays empty (the server
95
+ * never broadcasts a frame for them). Use `db.useQueryOne(entity, id)`
96
+ * for those instead.
97
+ */
98
+ export function useLoroDoc(entity: string, id: string): LoroDoc {
99
+ ensureAttached();
100
+
101
+ // Tell the server we want binary CRDT frames for this row. Refcounted
102
+ // inside the sync engine, so two components watching the same row
103
+ // don't fight over the subscription. Without this the server never
104
+ // sends a binary frame and the LoroDoc stays empty forever — the
105
+ // notifier filters by subscriber set rather than fanning out to
106
+ // every WS client.
107
+ //
108
+ // We use `useEffect` (not `useSyncExternalStore`'s subscribe) because
109
+ // the subscribe call is a side effect on the network, not a React
110
+ // store subscription. The store subscription stays registry-local
111
+ // and fires on every applied frame.
112
+ useEffect(() => {
113
+ const sync = db.sync;
114
+ sync.subscribeCrdt(entity, id);
115
+ return () => {
116
+ sync.unsubscribeCrdt(entity, id);
117
+ };
118
+ }, [entity, id]);
119
+
120
+ // useSyncExternalStore drives re-renders. The snapshot is the doc
121
+ // itself (referentially stable across calls — same instance from
122
+ // the registry), so React's bail-out keeps re-renders bounded to
123
+ // when the registry's listener actually fires.
124
+ const subscribe = (notify: () => void) =>
125
+ globalRegistry.subscribe(entity, id, notify);
126
+ const getSnapshot = () => globalRegistry.doc(entity, id);
127
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
128
+ }
129
+
130
+ /**
131
+ * Convenience: get a `LoroText` container at the given top-level key,
132
+ * creating it if absent. Wraps `doc.getText(key)` so callers don't
133
+ * have to import loro-crdt directly for the common case.
134
+ */
135
+ export function getLoroText(doc: LoroDoc, key: string): LoroText {
136
+ return doc.getText(key);
137
+ }
138
+
139
+ /**
140
+ * Convenience: get a `LoroMap` container at the given top-level key.
141
+ */
142
+ export function getLoroMap(doc: LoroDoc, key: string): LoroMap {
143
+ return doc.getMap(key);
144
+ }
145
+
146
+ /**
147
+ * High-level hook for the most common CRDT case: a single text field
148
+ * shared across clients. Returns `[value, setValue]` matching React's
149
+ * useState shape so existing controlled-input components drop in.
150
+ *
151
+ * Two tabs editing the same `(entity, id, field)` converge through
152
+ * Loro's text CRDT — concurrent same-position writes interleave
153
+ * deterministically rather than one stomping the other.
154
+ *
155
+ * `setValue` performs a whole-text replace on every call. Loro's
156
+ * text-CRDT update path treats this as a delete-then-insert against
157
+ * the prior version vector, so concurrent edits to disjoint regions
158
+ * still merge correctly. After applying locally, the hook ships the
159
+ * incremental update to the server (POST /api/crdt/<entity>/<id>),
160
+ * which re-projects to SQLite and broadcasts the merged snapshot
161
+ * back to every connected tab. A future variant could expose lower-
162
+ * level insert/delete ops for IME-friendly diff-aware editing.
163
+ *
164
+ * For boring CRUD use `db.useQueryOne` instead — this hook only
165
+ * lights up for entities marked `crdt: true` (the default) AND
166
+ * fields with `crdt: "text"` (or `richtext` type, which defaults
167
+ * to LoroText).
168
+ */
169
+ export function useCollabText(
170
+ entity: string,
171
+ id: string,
172
+ field: string,
173
+ ): [string, (next: string) => void] {
174
+ const doc = useLoroDoc(entity, id);
175
+ const text = doc.getText(field);
176
+ const value = text.toString();
177
+ const setValue = (next: string): void => {
178
+ // Capture the version vector BEFORE the mutation so we can ship
179
+ // exactly the new ops to the server (incremental delta, not the
180
+ // whole snapshot). Loro's `export({mode: "update", from: vv})`
181
+ // returns the bytes the server hasn't seen.
182
+ const beforeVv = doc.oplogVersion();
183
+ const len = text.length;
184
+ if (len > 0) {
185
+ text.delete(0, len);
186
+ }
187
+ if (next.length > 0) {
188
+ text.insert(0, next);
189
+ }
190
+ doc.commit();
191
+
192
+ const update = doc.export({ mode: "update", from: beforeVv });
193
+ if (update.length === 0) {
194
+ return; // No-op (e.g. setValue called with the same value)
195
+ }
196
+ void pushCrdtUpdate(entity, id, update);
197
+ };
198
+ return [value, setValue];
199
+ }
200
+
201
+ /**
202
+ * Diff-aware collaborative binding for a `<textarea>` / `<input>` — the
203
+ * editor-grade counterpart to {@link useCollabText}.
204
+ *
205
+ * `useCollabText`'s `setValue` deletes the whole field and re-inserts it on
206
+ * every keystroke, which converges but moves the caret — fine for a one-line
207
+ * topic, jarring in a document editor. This hook instead computes the MINIMAL
208
+ * single splice between the old and new value (shared prefix + shared suffix)
209
+ * and applies just that `insert`/`delete` to the `LoroText`, shipping only the
210
+ * incremental delta. Remote frames are merged back into the element with the
211
+ * local caret remapped across the change, so two people typing in the same
212
+ * document don't fight over the cursor.
213
+ *
214
+ * LoroText indices are UTF-16 (matching JS string offsets and
215
+ * `selectionStart`/`selectionEnd`), so the prefix/suffix diff computed on the
216
+ * raw element value lines up with Loro exactly — no codepoint conversion, and
217
+ * astral characters (emoji) splice correctly.
218
+ *
219
+ * Spread the returned `ref` + `onInput` onto the element, and use
220
+ * `defaultValue` (NOT `value`) so the element stays uncontrolled and React
221
+ * re-renders never clobber the caret. `value` is the live text, handy for a
222
+ * side-by-side preview:
223
+ *
224
+ * ```tsx
225
+ * const { ref, value, onInput } = useCollabTextarea("Document", id, "content");
226
+ * return (
227
+ * <>
228
+ * <textarea ref={ref} defaultValue={value} onInput={onInput} />
229
+ * <MarkdownPreview source={value} />
230
+ * </>
231
+ * );
232
+ * ```
233
+ */
234
+ export function useCollabTextarea<
235
+ T extends HTMLTextAreaElement | HTMLInputElement = HTMLTextAreaElement,
236
+ >(
237
+ entity: string,
238
+ id: string,
239
+ field: string,
240
+ ): {
241
+ ref: RefObject<T | null>;
242
+ value: string;
243
+ onInput: (e: FormEvent<T>) => void;
244
+ } {
245
+ const doc = useLoroDoc(entity, id);
246
+ const text = doc.getText(field);
247
+ const value = text.toString(); // re-renders on every applied frame
248
+ const ref = useRef<T | null>(null);
249
+ // The value we last reconciled into the DOM element. Lets us tell our own
250
+ // edits (element already correct — skip) from remote frames (patch the DOM).
251
+ const domValue = useRef<string>(value);
252
+
253
+ // Apply REMOTE changes to the element, preserving the caret. Runs after each
254
+ // render whose doc text differs from what the element currently shows.
255
+ useEffect(() => {
256
+ const el = ref.current;
257
+ if (!el) return;
258
+ if (el.value === value) {
259
+ domValue.current = value;
260
+ return;
261
+ }
262
+ const prev = el.value;
263
+ const selStart = el.selectionStart ?? prev.length;
264
+ const selEnd = el.selectionEnd ?? prev.length;
265
+ el.value = value;
266
+ try {
267
+ el.setSelectionRange(
268
+ remapCaret(prev, value, selStart),
269
+ remapCaret(prev, value, selEnd),
270
+ );
271
+ } catch {
272
+ // Some element types don't support setSelectionRange — ignore.
273
+ }
274
+ domValue.current = value;
275
+ }, [value]);
276
+
277
+ const onInput = (e: FormEvent<T>): void => {
278
+ const next = (e.target as T).value;
279
+ const prev = domValue.current;
280
+ if (next === prev) return;
281
+ const { index, deleteCount, insert } = spliceDiff(prev, next);
282
+ // Capture the version vector BEFORE mutating so we ship exactly the new ops.
283
+ const beforeVv = doc.oplogVersion();
284
+ if (deleteCount > 0) text.delete(index, deleteCount);
285
+ if (insert.length > 0) text.insert(index, insert);
286
+ doc.commit();
287
+ domValue.current = next;
288
+ const update = doc.export({ mode: "update", from: beforeVv });
289
+ if (update.length > 0) void pushCrdtUpdate(entity, id, update);
290
+ };
291
+
292
+ return { ref, value, onInput };
293
+ }
294
+
295
+ /**
296
+ * The minimal single splice that turns `prev` into `next`: shared prefix of
297
+ * length `index`, shared suffix, with the differing middle of `prev`
298
+ * (`deleteCount` chars) replaced by `insert`. UTF-16 units throughout to match
299
+ * LoroText.
300
+ */
301
+ function spliceDiff(
302
+ prev: string,
303
+ next: string,
304
+ ): { index: number; deleteCount: number; insert: string } {
305
+ const max = Math.min(prev.length, next.length);
306
+ let p = 0;
307
+ while (p < max && prev.charCodeAt(p) === next.charCodeAt(p)) p++;
308
+ let s = 0;
309
+ while (
310
+ s < max - p &&
311
+ prev.charCodeAt(prev.length - 1 - s) === next.charCodeAt(next.length - 1 - s)
312
+ ) {
313
+ s++;
314
+ }
315
+ return {
316
+ index: p,
317
+ deleteCount: prev.length - p - s,
318
+ insert: next.slice(p, next.length - s),
319
+ };
320
+ }
321
+
322
+ /**
323
+ * Map a caret offset in `prev` to the equivalent offset in `next` across the
324
+ * single splice between them. Before the change → unchanged; after → shifted
325
+ * by the length delta; inside the replaced region → clamped to the splice end.
326
+ */
327
+ function remapCaret(prev: string, next: string, caret: number): number {
328
+ const { index, deleteCount, insert } = spliceDiff(prev, next);
329
+ if (caret <= index) return caret;
330
+ if (caret >= index + deleteCount) {
331
+ return caret + (insert.length - deleteCount);
332
+ }
333
+ return index + insert.length;
334
+ }
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Upstream push — POST /api/crdt/<entity>/<row_id>
338
+ //
339
+ // Wraps the binary Loro update in a JSON envelope ({update: hex}) so it
340
+ // flows through Pylon's existing UTF-8-only HTTP body channel. Hex
341
+ // (vs base64) keeps the encoder zero-dep on both sides; bandwidth
342
+ // overhead is 2x, fine for the typical sub-1KB CRDT delta.
343
+ // ---------------------------------------------------------------------------
344
+
345
+ async function pushCrdtUpdate(
346
+ entity: string,
347
+ id: string,
348
+ update: Uint8Array,
349
+ ): Promise<void> {
350
+ try {
351
+ // Server acknowledges with {ok: true}. The merged-state broadcast
352
+ // arrives over the WS, applied automatically by the registry — no
353
+ // need to read the response body here, but pylonFetch surfaces
354
+ // structured errors so failed pushes log instead of vanishing.
355
+ await pylonFetch(
356
+ {
357
+ baseUrl: getBaseUrl(),
358
+ getToken: () =>
359
+ (getReactStorage().get(storageKey("token")) ?? undefined) as
360
+ | string
361
+ | undefined,
362
+ },
363
+ `/api/crdt/${encodeURIComponent(entity)}/${encodeURIComponent(id)}`,
364
+ {
365
+ method: "POST",
366
+ json: { update: bytesToHex(update) },
367
+ },
368
+ );
369
+ } catch (err) {
370
+ console.warn(`[loro] CRDT push failed for ${entity}/${id}:`, err);
371
+ }
372
+ }
373
+
374
+ function bytesToHex(bytes: Uint8Array): string {
375
+ const out = new Array<string>(bytes.length);
376
+ for (let i = 0; i < bytes.length; i++) {
377
+ out[i] = bytes[i].toString(16).padStart(2, "0");
378
+ }
379
+ return out.join("");
380
+ }
381
+
382
+ /**
383
+ * Manual teardown. Tests use this to drop the binary handler when
384
+ * spinning up multiple engines in sequence; production apps don't
385
+ * need to call it.
386
+ */
387
+ export function detachLoro(): void {
388
+ if (unsubscribeBinaryHandler) {
389
+ unsubscribeBinaryHandler();
390
+ unsubscribeBinaryHandler = null;
391
+ }
392
+ if (unsubscribeRowEviction) {
393
+ unsubscribeRowEviction();
394
+ unsubscribeRowEviction = null;
395
+ }
396
+ attachedSync = null;
397
+ }
398
+
@@ -0,0 +1,152 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Registry tests. Exercise the doc cache + binary frame routing
3
+ // in isolation from the WebSocket / SyncEngine plumbing.
4
+ // ---------------------------------------------------------------------------
5
+
6
+ import { test, expect } from "bun:test";
7
+ import { LoroDoc } from "loro-crdt";
8
+ import { LoroRegistry } from "./registry";
9
+ import { CRDT_FRAME_SNAPSHOT, encodeCrdtFrame } from "./wire";
10
+
11
+ test("doc() returns the same instance for the same row across calls", () => {
12
+ const reg = new LoroRegistry();
13
+ const a = reg.doc("Note", "n1");
14
+ const b = reg.doc("Note", "n1");
15
+ expect(a).toBe(b); // referential identity, not just equal
16
+ });
17
+
18
+ test("distinct (entity, rowId) pairs get distinct docs", () => {
19
+ const reg = new LoroRegistry();
20
+ const a = reg.doc("Note", "n1");
21
+ const b = reg.doc("Note", "n2");
22
+ const c = reg.doc("Other", "n1");
23
+ expect(a).not.toBe(b);
24
+ expect(a).not.toBe(c);
25
+ expect(b).not.toBe(c);
26
+ expect(reg.cachedRows()).toBe(3);
27
+ });
28
+
29
+ test("applyBinaryFrame imports a server snapshot into the right doc", () => {
30
+ // Build a Loro snapshot the way the server would.
31
+ const upstream = new LoroDoc();
32
+ upstream.getText("body").insert(0, "hello");
33
+ upstream.commit();
34
+ const snap = upstream.export({ mode: "snapshot" });
35
+ const frame = encodeCrdtFrame(CRDT_FRAME_SNAPSHOT, "Note", "n1", snap);
36
+
37
+ const reg = new LoroRegistry();
38
+ const ok = reg.applyBinaryFrame(frame);
39
+ expect(ok).toBe(true);
40
+
41
+ const local = reg.doc("Note", "n1");
42
+ expect(local.getText("body").toString()).toBe("hello");
43
+ });
44
+
45
+ test("subscribe() fires on every applied frame", () => {
46
+ const reg = new LoroRegistry();
47
+ let calls = 0;
48
+ const unsub = reg.subscribe("Note", "n1", () => {
49
+ calls += 1;
50
+ });
51
+
52
+ const upstream = new LoroDoc();
53
+ upstream.getText("body").insert(0, "first");
54
+ upstream.commit();
55
+ reg.applyBinaryFrame(
56
+ encodeCrdtFrame(
57
+ CRDT_FRAME_SNAPSHOT,
58
+ "Note",
59
+ "n1",
60
+ upstream.export({ mode: "snapshot" }),
61
+ ),
62
+ );
63
+
64
+ upstream.getText("body").insert(5, " update");
65
+ upstream.commit();
66
+ reg.applyBinaryFrame(
67
+ encodeCrdtFrame(
68
+ CRDT_FRAME_SNAPSHOT,
69
+ "Note",
70
+ "n1",
71
+ upstream.export({ mode: "snapshot" }),
72
+ ),
73
+ );
74
+
75
+ expect(calls).toBe(2);
76
+ unsub();
77
+
78
+ // After unsub, future frames don't fire the listener.
79
+ upstream.getText("body").insert(0, "post-unsub ");
80
+ upstream.commit();
81
+ reg.applyBinaryFrame(
82
+ encodeCrdtFrame(
83
+ CRDT_FRAME_SNAPSHOT,
84
+ "Note",
85
+ "n1",
86
+ upstream.export({ mode: "snapshot" }),
87
+ ),
88
+ );
89
+ expect(calls).toBe(2);
90
+ });
91
+
92
+ test("subscribe() listener is per-row — sibling rows don't fire it", () => {
93
+ const reg = new LoroRegistry();
94
+ let n1Calls = 0;
95
+ let n2Calls = 0;
96
+ reg.subscribe("Note", "n1", () => (n1Calls += 1));
97
+ reg.subscribe("Note", "n2", () => (n2Calls += 1));
98
+
99
+ const doc = new LoroDoc();
100
+ doc.getText("body").insert(0, "x");
101
+ doc.commit();
102
+ const snap = doc.export({ mode: "snapshot" });
103
+
104
+ reg.applyBinaryFrame(encodeCrdtFrame(CRDT_FRAME_SNAPSHOT, "Note", "n1", snap));
105
+ expect(n1Calls).toBe(1);
106
+ expect(n2Calls).toBe(0);
107
+
108
+ reg.applyBinaryFrame(encodeCrdtFrame(CRDT_FRAME_SNAPSHOT, "Note", "n2", snap));
109
+ expect(n1Calls).toBe(1);
110
+ expect(n2Calls).toBe(1);
111
+ });
112
+
113
+ test("malformed binary frame returns false without crashing", () => {
114
+ const reg = new LoroRegistry();
115
+ expect(reg.applyBinaryFrame(new Uint8Array([0x10, 0x00]))).toBe(false);
116
+ });
117
+
118
+ test("unknown frame type returns false", () => {
119
+ const reg = new LoroRegistry();
120
+ // Type byte 0xFF isn't a recognized snapshot/update marker.
121
+ const frame = encodeCrdtFrame(0xff, "Note", "n1", new Uint8Array(0));
122
+ expect(reg.applyBinaryFrame(frame)).toBe(false);
123
+ });
124
+
125
+ test("two registries hydrated from snapshots converge after exchange", () => {
126
+ // End-to-end "two browser tabs" simulation. Each tab has its own
127
+ // registry; they exchange snapshots and converge to the same state.
128
+ const a = new LoroRegistry();
129
+ const b = new LoroRegistry();
130
+
131
+ // Tab A locally writes "from-a".
132
+ a.doc("Note", "n1").getText("body").insert(0, "from-a");
133
+ a.doc("Note", "n1").commit();
134
+ // Tab B locally writes "from-b".
135
+ b.doc("Note", "n1").getText("body").insert(0, "from-b");
136
+ b.doc("Note", "n1").commit();
137
+
138
+ // Each tab broadcasts its snapshot to the other.
139
+ const snapA = a.doc("Note", "n1").export({ mode: "snapshot" });
140
+ const snapB = b.doc("Note", "n1").export({ mode: "snapshot" });
141
+
142
+ a.applyBinaryFrame(encodeCrdtFrame(CRDT_FRAME_SNAPSHOT, "Note", "n1", snapB));
143
+ b.applyBinaryFrame(encodeCrdtFrame(CRDT_FRAME_SNAPSHOT, "Note", "n1", snapA));
144
+
145
+ // Both registries' docs converge — the result contains BOTH writes'
146
+ // characters in some deterministic Loro-merged order. The exact
147
+ // output isn't pinned (Loro picks one) but both replicas agree.
148
+ const aText = a.doc("Note", "n1").getText("body").toString();
149
+ const bText = b.doc("Note", "n1").getText("body").toString();
150
+ expect(aText).toBe(bText);
151
+ expect(aText.length).toBeGreaterThan(0);
152
+ });
@@ -0,0 +1,138 @@
1
+ // ---------------------------------------------------------------------------
2
+ // LoroRegistry — per-row LoroDoc cache + binary-frame router
3
+ //
4
+ // One process-wide registry. The SyncEngine's binary handler routes
5
+ // every incoming CRDT frame here; this class:
6
+ //
7
+ // 1. Decodes the frame
8
+ // 2. Looks up (or creates) the LoroDoc for (entity, row_id)
9
+ // 3. Imports the snapshot/update into the doc
10
+ // 4. Notifies subscribers so React re-renders
11
+ //
12
+ // Architectural note: the server-side broadcast is currently
13
+ // "send every CRDT update to every connected client" (see
14
+ // docs/RUNTIME.md / loro_store.rs). The registry honors the same
15
+ // shape — it doesn't filter incoming frames by interest. Apps that
16
+ // don't subscribe to a row still create+update its LoroDoc as
17
+ // frames arrive; harmless extra work, kept here so that when the
18
+ // server eventually adds per-client subscriptions the client side
19
+ // is ready to consume them.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ import { LoroDoc } from "loro-crdt";
23
+ import {
24
+ decodeCrdtFrame,
25
+ CRDT_FRAME_SNAPSHOT,
26
+ CRDT_FRAME_UPDATE,
27
+ } from "./wire";
28
+
29
+ type Listener = () => void;
30
+
31
+ interface DocEntry {
32
+ doc: LoroDoc;
33
+ listeners: Set<Listener>;
34
+ }
35
+
36
+ export class LoroRegistry {
37
+ /** (entity, row_id) → cached LoroDoc + subscribers. Map key is
38
+ * joined `entity:row_id` to keep the hash one-dimensional. */
39
+ private docs: Map<string, DocEntry> = new Map();
40
+
41
+ /** Get-or-create the doc for a row. The returned doc is the same
42
+ * instance across calls so subscribers and consumers all see the
43
+ * same CRDT state. Loro's per-doc peer_id is generated on first
44
+ * construction; re-fetching the same row never produces a new
45
+ * peer_id (which would fragment the merge graph). */
46
+ doc(entity: string, rowId: string): LoroDoc {
47
+ return this.entry(entity, rowId).doc;
48
+ }
49
+
50
+ /** Subscribe to changes on a row's doc. Returns an unsubscribe fn.
51
+ * Calls the listener after every applied frame (snapshot or
52
+ * update) and after every local mutation that triggers Loro's
53
+ * internal subscribe events. */
54
+ subscribe(entity: string, rowId: string, listener: Listener): () => void {
55
+ const entry = this.entry(entity, rowId);
56
+ entry.listeners.add(listener);
57
+ return () => {
58
+ entry.listeners.delete(listener);
59
+ // Don't drop the doc when listeners hit 0 — a future hook
60
+ // remount on the same row should see the same state. Eviction
61
+ // policy comes alongside the per-row subscribe protocol.
62
+ };
63
+ }
64
+
65
+ /** Apply a binary frame from the WebSocket. Decodes then routes to
66
+ * the matching doc; notifies subscribers. Returns true when the
67
+ * frame parsed and an entry was updated, false on decode failure
68
+ * or unknown frame type. */
69
+ applyBinaryFrame(bytes: Uint8Array): boolean {
70
+ const frame = decodeCrdtFrame(bytes);
71
+ if (!frame) return false;
72
+ if (frame.type !== CRDT_FRAME_SNAPSHOT && frame.type !== CRDT_FRAME_UPDATE) {
73
+ return false;
74
+ }
75
+ const entry = this.entry(frame.entity, frame.rowId);
76
+ try {
77
+ entry.doc.import(frame.payload);
78
+ } catch (err) {
79
+ console.warn(
80
+ `[loro] import failed for ${frame.entity}/${frame.rowId}:`,
81
+ err,
82
+ );
83
+ return false;
84
+ }
85
+ for (const listener of entry.listeners) {
86
+ try {
87
+ listener();
88
+ } catch (err) {
89
+ console.warn("[loro] listener threw:", err);
90
+ }
91
+ }
92
+ return true;
93
+ }
94
+
95
+ /** Drop the cached doc for a row. Tests + the eventual eviction
96
+ * policy. Subscribers receive a final notify before the entry
97
+ * is removed so they can detect the drop and re-create their
98
+ * view if needed. */
99
+ evict(entity: string, rowId: string): void {
100
+ const key = this.key(entity, rowId);
101
+ const entry = this.docs.get(key);
102
+ if (!entry) return;
103
+ for (const listener of entry.listeners) {
104
+ try {
105
+ listener();
106
+ } catch {
107
+ /* swallow — we're tearing down anyway */
108
+ }
109
+ }
110
+ this.docs.delete(key);
111
+ }
112
+
113
+ /** Number of cached docs. Diagnostic. */
114
+ cachedRows(): number {
115
+ return this.docs.size;
116
+ }
117
+
118
+ private entry(entity: string, rowId: string): DocEntry {
119
+ const key = this.key(entity, rowId);
120
+ let entry = this.docs.get(key);
121
+ if (!entry) {
122
+ entry = { doc: new LoroDoc(), listeners: new Set() };
123
+ this.docs.set(key, entry);
124
+ }
125
+ return entry;
126
+ }
127
+
128
+ private key(entity: string, rowId: string): string {
129
+ return `${entity}:${rowId}`;
130
+ }
131
+ }
132
+
133
+ /** Process-wide singleton. The React hook reaches for this rather than
134
+ * threading the registry through context — every Pylon app is
135
+ * single-tenant per process, so a global registry is the simpler
136
+ * shape and matches how `db.useQuery` (singleton SyncEngine) already
137
+ * works. */
138
+ export const globalRegistry = new LoroRegistry();
@@ -0,0 +1,104 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Wire-format tests. Mirror the Rust-side tests in
3
+ // `crates/router/src/lib.rs::crdt_frame_tests` so any divergence
4
+ // between encoder and decoder fails loud here before it ships.
5
+ // Run via: `bun test packages/loro/src/wire.test.ts`
6
+ // ---------------------------------------------------------------------------
7
+
8
+ import { test, expect } from "bun:test";
9
+ import {
10
+ CRDT_FRAME_SNAPSHOT,
11
+ CRDT_FRAME_UPDATE,
12
+ decodeCrdtFrame,
13
+ encodeCrdtFrame,
14
+ } from "./wire";
15
+
16
+ test("encode + decode round-trips a snapshot frame", () => {
17
+ const payload = new Uint8Array([0xab, 0xcd, 0xef]);
18
+ const frame = encodeCrdtFrame(CRDT_FRAME_SNAPSHOT, "Message", "msg_123", payload);
19
+ // Header: 1 + 2 + 7 ("Message") + 2 + 7 ("msg_123") + 3 = 22 bytes.
20
+ expect(frame.length).toBe(22);
21
+
22
+ const decoded = decodeCrdtFrame(frame);
23
+ expect(decoded).not.toBeNull();
24
+ expect(decoded!.type).toBe(CRDT_FRAME_SNAPSHOT);
25
+ expect(decoded!.entity).toBe("Message");
26
+ expect(decoded!.rowId).toBe("msg_123");
27
+ expect(Array.from(decoded!.payload)).toEqual([0xab, 0xcd, 0xef]);
28
+ });
29
+
30
+ test("matches Rust encoder byte-for-byte for the same input", () => {
31
+ // Identical to the `roundtrip_header_layout` test in
32
+ // crates/router/src/lib.rs::crdt_frame_tests. If this fails the
33
+ // wire formats have drifted between Rust and TS.
34
+ const frame = encodeCrdtFrame(
35
+ CRDT_FRAME_SNAPSHOT,
36
+ "Message",
37
+ "msg_123",
38
+ new Uint8Array([0xab, 0xcd, 0xef]),
39
+ );
40
+ expect(frame[0]).toBe(0x10);
41
+ expect(Array.from(frame.subarray(1, 3))).toEqual([0, 7]);
42
+ expect(new TextDecoder().decode(frame.subarray(3, 10))).toBe("Message");
43
+ expect(Array.from(frame.subarray(10, 12))).toEqual([0, 7]);
44
+ expect(new TextDecoder().decode(frame.subarray(12, 19))).toBe("msg_123");
45
+ expect(Array.from(frame.subarray(19, 22))).toEqual([0xab, 0xcd, 0xef]);
46
+ });
47
+
48
+ test("empty payload still carries headers", () => {
49
+ const frame = encodeCrdtFrame(CRDT_FRAME_UPDATE, "X", "y", new Uint8Array(0));
50
+ expect(frame.length).toBe(7);
51
+ expect(frame[0]).toBe(0x11);
52
+
53
+ const decoded = decodeCrdtFrame(frame);
54
+ expect(decoded!.entity).toBe("X");
55
+ expect(decoded!.rowId).toBe("y");
56
+ expect(decoded!.payload.length).toBe(0);
57
+ });
58
+
59
+ test("entity > u16 max throws", () => {
60
+ const huge = "x".repeat(0x10000);
61
+ expect(() =>
62
+ encodeCrdtFrame(CRDT_FRAME_SNAPSHOT, huge, "y", new Uint8Array(0)),
63
+ ).toThrow(/exceeds u16/);
64
+ });
65
+
66
+ test("row_id > u16 max throws", () => {
67
+ const huge = "x".repeat(0x10000);
68
+ expect(() =>
69
+ encodeCrdtFrame(CRDT_FRAME_SNAPSHOT, "X", huge, new Uint8Array(0)),
70
+ ).toThrow(/exceeds u16/);
71
+ });
72
+
73
+ test("decodes a truncated frame as null instead of throwing", () => {
74
+ // A frame that claims entity_len=10 but only carries 3 bytes of entity.
75
+ const truncated = new Uint8Array([
76
+ CRDT_FRAME_SNAPSHOT,
77
+ 0x00,
78
+ 0x0a, // entity_len = 10
79
+ 0x41,
80
+ 0x42,
81
+ 0x43, // only "ABC" — 3 bytes, not 10
82
+ ]);
83
+ expect(decodeCrdtFrame(truncated)).toBeNull();
84
+ });
85
+
86
+ test("decodes a too-short header (<5 bytes) as null", () => {
87
+ expect(decodeCrdtFrame(new Uint8Array([0x10, 0x00]))).toBeNull();
88
+ expect(decodeCrdtFrame(new Uint8Array(0))).toBeNull();
89
+ });
90
+
91
+ test("UTF-8 multi-byte chars in entity / row_id round-trip", () => {
92
+ // 文書 = 6 bytes UTF-8, not 2 chars × 1 byte. Entity-name length
93
+ // should be the BYTE length the encoder writes — exercising the
94
+ // u16-as-bytes-not-chars contract on both sides.
95
+ const frame = encodeCrdtFrame(
96
+ CRDT_FRAME_SNAPSHOT,
97
+ "文書",
98
+ "行_42",
99
+ new Uint8Array(0),
100
+ );
101
+ const decoded = decodeCrdtFrame(frame);
102
+ expect(decoded!.entity).toBe("文書");
103
+ expect(decoded!.rowId).toBe("行_42");
104
+ });
package/src/wire.ts ADDED
@@ -0,0 +1,108 @@
1
+ // ---------------------------------------------------------------------------
2
+ // CRDT WebSocket wire format
3
+ //
4
+ // Mirror of `crates/router/src/lib.rs::encode_crdt_frame`. Frame layout:
5
+ //
6
+ // [type: u8] [entity_len: u16 BE] [entity utf8]
7
+ // [row_id_len: u16 BE] [row_id utf8] [payload bytes]
8
+ //
9
+ // Type bytes:
10
+ // 0x10 = full Loro snapshot
11
+ // 0x11 = incremental Loro update
12
+ //
13
+ // Keep this in sync with the Rust encoder. A change to either side
14
+ // without a matching change to the other corrupts every CRDT message
15
+ // in flight.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export const CRDT_FRAME_SNAPSHOT = 0x10;
19
+ export const CRDT_FRAME_UPDATE = 0x11;
20
+
21
+ export interface CrdtFrame {
22
+ /** 0x10 (snapshot) or 0x11 (incremental update). */
23
+ type: number;
24
+ /** Entity name as declared in the manifest. */
25
+ entity: string;
26
+ /** Row ID — 40-char hex for Pylon-generated rows. */
27
+ rowId: string;
28
+ /** Loro binary payload. Snapshot or update bytes depending on `type`. */
29
+ payload: Uint8Array;
30
+ }
31
+
32
+ /**
33
+ * Decode a binary CRDT frame received over the WebSocket. Returns
34
+ * `null` on any parse failure (truncated frame, length-header overrun)
35
+ * — caller logs and drops; the next valid frame is independent.
36
+ *
37
+ * The router encoder bails on entity / row_id strings >65 KiB, so the
38
+ * decoder's malformed-input path is genuinely unreachable for frames
39
+ * the server emits. The defensive checks exist for cases where the
40
+ * client receives bytes from a custom proxy / test fixture / future
41
+ * protocol extension.
42
+ */
43
+ export function decodeCrdtFrame(bytes: Uint8Array): CrdtFrame | null {
44
+ // Header is at minimum: type(1) + entity_len(2) + row_id_len(2) = 5 bytes.
45
+ if (bytes.length < 5) return null;
46
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
47
+ const type = view.getUint8(0);
48
+ const entityLen = view.getUint16(1, false /* big-endian */);
49
+ const entityStart = 3;
50
+ const entityEnd = entityStart + entityLen;
51
+ if (entityEnd + 2 > bytes.length) return null;
52
+
53
+ const rowIdLen = view.getUint16(entityEnd, false);
54
+ const rowIdStart = entityEnd + 2;
55
+ const rowIdEnd = rowIdStart + rowIdLen;
56
+ if (rowIdEnd > bytes.length) return null;
57
+
58
+ const decoder = new TextDecoder();
59
+ const entity = decoder.decode(bytes.subarray(entityStart, entityEnd));
60
+ const rowId = decoder.decode(bytes.subarray(rowIdStart, rowIdEnd));
61
+ const payload = bytes.subarray(rowIdEnd);
62
+
63
+ return { type, entity, rowId, payload };
64
+ }
65
+
66
+ /**
67
+ * Encode a frame in the same format. Useful for tests / for any
68
+ * eventual client-to-server CRDT push (not yet wired). Throws on
69
+ * length-header overrun rather than truncating, matching the Rust
70
+ * encoder's failure mode.
71
+ */
72
+ export function encodeCrdtFrame(
73
+ type: number,
74
+ entity: string,
75
+ rowId: string,
76
+ payload: Uint8Array,
77
+ ): Uint8Array {
78
+ const encoder = new TextEncoder();
79
+ const entityBytes = encoder.encode(entity);
80
+ const rowIdBytes = encoder.encode(rowId);
81
+ if (entityBytes.length > 0xffff) {
82
+ throw new Error(
83
+ `CRDT frame: entity name ${entityBytes.length} bytes exceeds u16 length limit (65535)`,
84
+ );
85
+ }
86
+ if (rowIdBytes.length > 0xffff) {
87
+ throw new Error(
88
+ `CRDT frame: row_id ${rowIdBytes.length} bytes exceeds u16 length limit (65535)`,
89
+ );
90
+ }
91
+ const out = new Uint8Array(
92
+ 1 + 2 + entityBytes.length + 2 + rowIdBytes.length + payload.length,
93
+ );
94
+ const view = new DataView(out.buffer);
95
+ let offset = 0;
96
+ view.setUint8(offset, type);
97
+ offset += 1;
98
+ view.setUint16(offset, entityBytes.length, false);
99
+ offset += 2;
100
+ out.set(entityBytes, offset);
101
+ offset += entityBytes.length;
102
+ view.setUint16(offset, rowIdBytes.length, false);
103
+ offset += 2;
104
+ out.set(rowIdBytes, offset);
105
+ offset += rowIdBytes.length;
106
+ out.set(payload, offset);
107
+ return out;
108
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "jsx": "react-jsx",
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "moduleResolution": "Bundler",
8
+ "target": "ES2022"
9
+ },
10
+ "include": ["src"],
11
+ "exclude": ["src/**/*.test.ts"]
12
+ }