@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 +31 -0
- package/src/index.ts +398 -0
- package/src/registry.test.ts +152 -0
- package/src/registry.ts +138 -0
- package/src/wire.test.ts +104 -0
- package/src/wire.ts +108 -0
- package/tsconfig.json +12 -0
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
|
+
});
|
package/src/registry.ts
ADDED
|
@@ -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();
|
package/src/wire.test.ts
ADDED
|
@@ -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
|
+
}
|