@pylonsync/loro 0.22.35 → 0.22.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pylonsync/loro",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.39",
|
|
4
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
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -12,14 +12,15 @@
|
|
|
12
12
|
".": "./src/index.ts",
|
|
13
13
|
"./wire": "./src/wire.ts",
|
|
14
14
|
"./registry": "./src/registry.ts",
|
|
15
|
-
"./prosemirror": "./src/prosemirror.ts"
|
|
15
|
+
"./prosemirror": "./src/prosemirror/index.ts",
|
|
16
|
+
"./prosemirror/react": "./src/prosemirror/react.ts"
|
|
16
17
|
},
|
|
17
18
|
"scripts": {
|
|
18
19
|
"check": "tsc -p tsconfig.json --noEmit"
|
|
19
20
|
},
|
|
20
21
|
"dependencies": {
|
|
21
|
-
"@pylonsync/react": "0.22.
|
|
22
|
-
"@pylonsync/sync": "0.22.
|
|
22
|
+
"@pylonsync/react": "0.22.39",
|
|
23
|
+
"@pylonsync/sync": "0.22.39",
|
|
23
24
|
"loro-crdt": "^1.10.6"
|
|
24
25
|
},
|
|
25
26
|
"peerDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { db, getBaseUrl, getReactStorage, storageKey } from "@pylonsync/react";
|
|
28
28
|
import { pylonFetch, type SyncEngine } from "@pylonsync/sync";
|
|
29
29
|
import { globalRegistry, LoroRegistry } from "./registry";
|
|
30
|
+
import { CrdtOutbox, type OutboxState } from "./outbox";
|
|
30
31
|
|
|
31
32
|
export { LoroRegistry, globalRegistry } from "./registry";
|
|
32
33
|
export {
|
|
@@ -36,6 +37,8 @@ export {
|
|
|
36
37
|
CRDT_FRAME_UPDATE,
|
|
37
38
|
} from "./wire";
|
|
38
39
|
export type { CrdtFrame } from "./wire";
|
|
40
|
+
export { CrdtOutbox, isRetryable } from "./outbox";
|
|
41
|
+
export type { OutboxSend, OutboxState, OutboxOptions } from "./outbox";
|
|
39
42
|
|
|
40
43
|
// ---------------------------------------------------------------------------
|
|
41
44
|
// Sync engine ↔ registry wiring
|
|
@@ -73,12 +76,61 @@ function ensureAttached(): void {
|
|
|
73
76
|
// lingering with stale state until the tab closes.
|
|
74
77
|
unsubscribeRowEviction = sync.addRowEvictionListener(
|
|
75
78
|
(entity: string, rowId: string) => {
|
|
79
|
+
disposeOutbox(entity, rowId);
|
|
76
80
|
globalRegistry.evict(entity, rowId);
|
|
77
81
|
},
|
|
78
82
|
);
|
|
83
|
+
attachPageListeners();
|
|
79
84
|
attachedSync = sync;
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Outboxes — one per row doc, sending its local edits (see ./outbox.ts)
|
|
89
|
+
//
|
|
90
|
+
// Created the first time a component reads the row's doc and kept as long
|
|
91
|
+
// as the doc: an editor that unmounts must not drop edits still in flight.
|
|
92
|
+
// A row eviction (policy revoked) disposes both.
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
const outboxes = new Map<string, { doc: LoroDoc; outbox: CrdtOutbox }>();
|
|
96
|
+
|
|
97
|
+
function outboxKey(entity: string, rowId: string): string {
|
|
98
|
+
return `${entity}:${rowId}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function outboxFor(entity: string, rowId: string, doc: LoroDoc): CrdtOutbox {
|
|
102
|
+
const key = outboxKey(entity, rowId);
|
|
103
|
+
const existing = outboxes.get(key);
|
|
104
|
+
if (existing && existing.doc === doc) return existing.outbox;
|
|
105
|
+
existing?.outbox.dispose();
|
|
106
|
+
const outbox = new CrdtOutbox(doc, (update, { keepalive }) =>
|
|
107
|
+
pushCrdtUpdate(entity, rowId, update, keepalive),
|
|
108
|
+
);
|
|
109
|
+
// A state change re-renders the row's readers (useLoroSendState).
|
|
110
|
+
outbox.subscribe(() => globalRegistry.touch(entity, rowId));
|
|
111
|
+
outboxes.set(key, { doc, outbox });
|
|
112
|
+
return outbox;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function disposeOutbox(entity: string, rowId: string): void {
|
|
116
|
+
const key = outboxKey(entity, rowId);
|
|
117
|
+
outboxes.get(key)?.outbox.dispose();
|
|
118
|
+
outboxes.delete(key);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let pageListenersAttached = false;
|
|
122
|
+
|
|
123
|
+
function attachPageListeners(): void {
|
|
124
|
+
if (pageListenersAttached || typeof window === "undefined") return;
|
|
125
|
+
pageListenersAttached = true;
|
|
126
|
+
window.addEventListener("online", () => {
|
|
127
|
+
for (const { outbox } of outboxes.values()) outbox.retryNow();
|
|
128
|
+
});
|
|
129
|
+
window.addEventListener("pagehide", () => {
|
|
130
|
+
for (const { outbox } of outboxes.values()) outbox.flushOnHide();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
82
134
|
// ---------------------------------------------------------------------------
|
|
83
135
|
// React hooks
|
|
84
136
|
// ---------------------------------------------------------------------------
|
|
@@ -133,7 +185,24 @@ export function useLoroDoc(entity: string, id: string): LoroDoc {
|
|
|
133
185
|
globalRegistry.subscribe(entity, id, notify);
|
|
134
186
|
const getVersion = () => globalRegistry.version(entity, id);
|
|
135
187
|
useSyncExternalStore(subscribe, getVersion, getVersion);
|
|
136
|
-
|
|
188
|
+
const doc = globalRegistry.doc(entity, id);
|
|
189
|
+
// Local edits (an editor binding's included) reach the server through
|
|
190
|
+
// the row's outbox.
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
outboxFor(entity, id, doc);
|
|
193
|
+
}, [entity, id, doc]);
|
|
194
|
+
return doc;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Whether this tab's edits to a row have reached the server, for a
|
|
199
|
+
* "Saving" indicator or an error notice. See {@link OutboxState}.
|
|
200
|
+
* `rejected` means the server refused them (for example the person lost
|
|
201
|
+
* edit access); they stay in this tab only.
|
|
202
|
+
*/
|
|
203
|
+
export function useLoroSendState(entity: string, id: string): OutboxState {
|
|
204
|
+
useLoroDoc(entity, id);
|
|
205
|
+
return outboxes.get(outboxKey(entity, id))?.outbox.state ?? "idle";
|
|
137
206
|
}
|
|
138
207
|
|
|
139
208
|
/**
|
|
@@ -235,11 +304,6 @@ export function useCollabText(
|
|
|
235
304
|
const value = resolveLoroText(doc, field)?.toString() ?? "";
|
|
236
305
|
const setValue = (next: string): void => {
|
|
237
306
|
const text = getLoroText(doc, field);
|
|
238
|
-
// Capture the version vector BEFORE the mutation so we can ship
|
|
239
|
-
// exactly the new ops to the server (incremental delta, not the
|
|
240
|
-
// whole snapshot). Loro's `export({mode: "update", from: vv})`
|
|
241
|
-
// returns the bytes the server hasn't seen.
|
|
242
|
-
const beforeVv = doc.oplogVersion();
|
|
243
307
|
const len = text.length;
|
|
244
308
|
if (len > 0) {
|
|
245
309
|
text.delete(0, len);
|
|
@@ -248,14 +312,9 @@ export function useCollabText(
|
|
|
248
312
|
text.insert(0, next);
|
|
249
313
|
}
|
|
250
314
|
doc.commit();
|
|
251
|
-
// Optimistic render: notify every consumer of this row NOW.
|
|
315
|
+
// Optimistic render: notify every consumer of this row NOW. The
|
|
316
|
+
// row's outbox sends the edit.
|
|
252
317
|
globalRegistry.touch(entity, id);
|
|
253
|
-
|
|
254
|
-
const update = doc.export({ mode: "update", from: beforeVv });
|
|
255
|
-
if (update.length === 0) {
|
|
256
|
-
return; // No-op (e.g. setValue called with the same value)
|
|
257
|
-
}
|
|
258
|
-
void pushCrdtUpdate(entity, id, update);
|
|
259
318
|
};
|
|
260
319
|
return [value, setValue];
|
|
261
320
|
}
|
|
@@ -343,20 +402,16 @@ export function useCollabTextarea<
|
|
|
343
402
|
const prev = domValue.current;
|
|
344
403
|
if (next === prev) return;
|
|
345
404
|
const { index, deleteCount, insert } = spliceDiff(prev, next);
|
|
346
|
-
// Resolve the container per keystroke (see resolveLoroText)
|
|
347
|
-
// capture the version vector BEFORE mutating so we ship exactly the
|
|
348
|
-
// new ops.
|
|
405
|
+
// Resolve the container per keystroke (see resolveLoroText).
|
|
349
406
|
const text = getLoroText(doc, field);
|
|
350
|
-
const beforeVv = doc.oplogVersion();
|
|
351
407
|
if (deleteCount > 0) text.delete(index, deleteCount);
|
|
352
408
|
if (insert.length > 0) text.insert(index, insert);
|
|
353
409
|
doc.commit();
|
|
354
410
|
domValue.current = next;
|
|
355
411
|
// Optimistic render: the preview (and any other consumer of this
|
|
356
|
-
// row) updates on the local commit, not on the server echo.
|
|
412
|
+
// row) updates on the local commit, not on the server echo. The row's
|
|
413
|
+
// outbox sends the edit.
|
|
357
414
|
globalRegistry.touch(entity, id);
|
|
358
|
-
const update = doc.export({ mode: "update", from: beforeVv });
|
|
359
|
-
if (update.length > 0) void pushCrdtUpdate(entity, id, update);
|
|
360
415
|
};
|
|
361
416
|
|
|
362
417
|
return { ref, value, onInput };
|
|
@@ -416,29 +471,27 @@ async function pushCrdtUpdate(
|
|
|
416
471
|
entity: string,
|
|
417
472
|
id: string,
|
|
418
473
|
update: Uint8Array,
|
|
474
|
+
keepalive: boolean,
|
|
419
475
|
): Promise<void> {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
} catch (err) {
|
|
440
|
-
console.warn(`[loro] CRDT push failed for ${entity}/${id}:`, err);
|
|
441
|
-
}
|
|
476
|
+
// Server acknowledges with {ok: true}. The merged-state broadcast
|
|
477
|
+
// arrives over the WS, applied automatically by the registry. A
|
|
478
|
+
// failure throws (PylonHttpError carries the status); the outbox
|
|
479
|
+
// decides whether to retry.
|
|
480
|
+
await pylonFetch(
|
|
481
|
+
{
|
|
482
|
+
baseUrl: getBaseUrl(),
|
|
483
|
+
getToken: () =>
|
|
484
|
+
(getReactStorage().get(storageKey("token")) ?? undefined) as
|
|
485
|
+
| string
|
|
486
|
+
| undefined,
|
|
487
|
+
},
|
|
488
|
+
`/api/crdt/${encodeURIComponent(entity)}/${encodeURIComponent(id)}`,
|
|
489
|
+
{
|
|
490
|
+
method: "POST",
|
|
491
|
+
json: { update: bytesToHex(update) },
|
|
492
|
+
keepalive,
|
|
493
|
+
},
|
|
494
|
+
);
|
|
442
495
|
}
|
|
443
496
|
|
|
444
497
|
function bytesToHex(bytes: Uint8Array): string {
|
|
@@ -455,6 +508,8 @@ function bytesToHex(bytes: Uint8Array): string {
|
|
|
455
508
|
* need to call it.
|
|
456
509
|
*/
|
|
457
510
|
export function detachLoro(): void {
|
|
511
|
+
for (const { outbox } of outboxes.values()) outbox.dispose();
|
|
512
|
+
outboxes.clear();
|
|
458
513
|
if (unsubscribeBinaryHandler) {
|
|
459
514
|
unsubscribeBinaryHandler();
|
|
460
515
|
unsubscribeBinaryHandler = null;
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// CrdtOutbox against real LoroDocs. A fake server holds its own doc, merges
|
|
2
|
+
// what clients send, and relays each merge to the other clients, the way
|
|
3
|
+
// POST /api/crdt and the binary broadcast do.
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import { LoroDoc, LoroMap } from "loro-crdt";
|
|
6
|
+
import { Schema } from "prosemirror-model";
|
|
7
|
+
import { schema as basic } from "prosemirror-schema-basic";
|
|
8
|
+
import { CrdtOutbox, isRetryable, type OutboxSend } from "./outbox";
|
|
9
|
+
import { readProseMirror, writeProseMirror } from "./prosemirror/index";
|
|
10
|
+
|
|
11
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
12
|
+
|
|
13
|
+
async function settle(): Promise<void> {
|
|
14
|
+
for (let i = 0; i < 20; i++) await tick();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function httpError(status: number): Error & { status: number } {
|
|
18
|
+
return Object.assign(new Error(`status ${status}`), { status });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class FakeServer {
|
|
22
|
+
readonly doc = new LoroDoc();
|
|
23
|
+
readonly received: Uint8Array[] = [];
|
|
24
|
+
private readonly clients = new Set<LoroDoc>();
|
|
25
|
+
|
|
26
|
+
connect(client: LoroDoc): void {
|
|
27
|
+
client.import(this.doc.export({ mode: "snapshot" }));
|
|
28
|
+
this.clients.add(client);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A send that merges into the server doc and relays to every client. */
|
|
32
|
+
sender(): OutboxSend {
|
|
33
|
+
return async (update) => {
|
|
34
|
+
this.received.push(update);
|
|
35
|
+
this.doc.import(update);
|
|
36
|
+
const merged = this.doc.export({ mode: "snapshot" });
|
|
37
|
+
for (const c of this.clients) c.import(merged);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function text(doc: LoroDoc): string {
|
|
43
|
+
return doc.getText("t").toString();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("CrdtOutbox", () => {
|
|
47
|
+
test("a local edit reaches the server and the other client", async () => {
|
|
48
|
+
const server = new FakeServer();
|
|
49
|
+
const a = new LoroDoc();
|
|
50
|
+
const b = new LoroDoc();
|
|
51
|
+
server.connect(a);
|
|
52
|
+
server.connect(b);
|
|
53
|
+
const outbox = new CrdtOutbox(a, server.sender());
|
|
54
|
+
a.getText("t").insert(0, "hello");
|
|
55
|
+
a.commit();
|
|
56
|
+
await settle();
|
|
57
|
+
expect(text(server.doc)).toBe("hello");
|
|
58
|
+
expect(text(b)).toBe("hello");
|
|
59
|
+
expect(outbox.state).toBe("idle");
|
|
60
|
+
expect(outbox.hasUnsent()).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("two clients editing at once converge", async () => {
|
|
64
|
+
const server = new FakeServer();
|
|
65
|
+
const a = new LoroDoc();
|
|
66
|
+
const b = new LoroDoc();
|
|
67
|
+
server.connect(a);
|
|
68
|
+
server.connect(b);
|
|
69
|
+
new CrdtOutbox(a, server.sender());
|
|
70
|
+
new CrdtOutbox(b, server.sender());
|
|
71
|
+
a.getText("t").insert(0, "left ");
|
|
72
|
+
a.commit();
|
|
73
|
+
b.getText("t").insert(0, "right");
|
|
74
|
+
b.commit();
|
|
75
|
+
await settle();
|
|
76
|
+
expect(text(a)).toBe(text(b));
|
|
77
|
+
expect(text(server.doc)).toBe(text(a));
|
|
78
|
+
expect(text(a)).toContain("left");
|
|
79
|
+
expect(text(a)).toContain("right");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("commits made during a request go out together in the next one", async () => {
|
|
83
|
+
const server = new FakeServer();
|
|
84
|
+
const a = new LoroDoc();
|
|
85
|
+
server.connect(a);
|
|
86
|
+
let release: () => void = () => {};
|
|
87
|
+
let calls = 0;
|
|
88
|
+
const send: OutboxSend = async (update, opts) => {
|
|
89
|
+
calls += 1;
|
|
90
|
+
if (calls === 1) await new Promise<void>((r) => (release = r));
|
|
91
|
+
await server.sender()(update, opts);
|
|
92
|
+
};
|
|
93
|
+
const outbox = new CrdtOutbox(a, send);
|
|
94
|
+
a.getText("t").insert(0, "a");
|
|
95
|
+
a.commit();
|
|
96
|
+
await tick();
|
|
97
|
+
expect(outbox.state).toBe("sending");
|
|
98
|
+
for (const ch of ["b", "c", "d"]) {
|
|
99
|
+
a.getText("t").insert(a.getText("t").length, ch);
|
|
100
|
+
a.commit();
|
|
101
|
+
}
|
|
102
|
+
await tick();
|
|
103
|
+
expect(calls).toBe(1);
|
|
104
|
+
release();
|
|
105
|
+
await settle();
|
|
106
|
+
expect(calls).toBe(2);
|
|
107
|
+
expect(text(server.doc)).toBe("abcd");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a failed request is sent again, with the edits made since", async () => {
|
|
111
|
+
const server = new FakeServer();
|
|
112
|
+
const a = new LoroDoc();
|
|
113
|
+
server.connect(a);
|
|
114
|
+
const timers: Array<() => void> = [];
|
|
115
|
+
let fail = true;
|
|
116
|
+
const send: OutboxSend = async (update, opts) => {
|
|
117
|
+
if (fail) throw new Error("network down");
|
|
118
|
+
await server.sender()(update, opts);
|
|
119
|
+
};
|
|
120
|
+
const outbox = new CrdtOutbox(a, send, {
|
|
121
|
+
setTimer: (fn) => timers.push(fn),
|
|
122
|
+
clearTimer: () => {},
|
|
123
|
+
});
|
|
124
|
+
a.getText("t").insert(0, "one ");
|
|
125
|
+
a.commit();
|
|
126
|
+
await settle();
|
|
127
|
+
expect(outbox.state).toBe("retrying");
|
|
128
|
+
expect(text(server.doc)).toBe("");
|
|
129
|
+
a.getText("t").insert(4, "two");
|
|
130
|
+
a.commit();
|
|
131
|
+
await settle();
|
|
132
|
+
expect(timers.length).toBe(1); // no second request while waiting
|
|
133
|
+
fail = false;
|
|
134
|
+
timers.shift()!();
|
|
135
|
+
await settle();
|
|
136
|
+
expect(text(server.doc)).toBe("one two");
|
|
137
|
+
expect(outbox.state).toBe("idle");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("retry delays double up to the limit", async () => {
|
|
141
|
+
const a = new LoroDoc();
|
|
142
|
+
const delays: number[] = [];
|
|
143
|
+
const timers: Array<() => void> = [];
|
|
144
|
+
const outbox = new CrdtOutbox(a, async () => Promise.reject(httpError(503)), {
|
|
145
|
+
retryBaseMs: 100,
|
|
146
|
+
retryMaxMs: 300,
|
|
147
|
+
setTimer: (fn, ms) => {
|
|
148
|
+
delays.push(ms);
|
|
149
|
+
timers.push(fn);
|
|
150
|
+
},
|
|
151
|
+
clearTimer: () => {},
|
|
152
|
+
});
|
|
153
|
+
a.getText("t").insert(0, "x");
|
|
154
|
+
a.commit();
|
|
155
|
+
for (let i = 0; i < 4; i++) {
|
|
156
|
+
await settle();
|
|
157
|
+
timers.shift()!();
|
|
158
|
+
}
|
|
159
|
+
await settle();
|
|
160
|
+
expect(delays).toEqual([100, 200, 300, 300, 300]);
|
|
161
|
+
expect(outbox.state).toBe("retrying");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("retryNow skips the wait", async () => {
|
|
165
|
+
const server = new FakeServer();
|
|
166
|
+
const a = new LoroDoc();
|
|
167
|
+
server.connect(a);
|
|
168
|
+
let fail = true;
|
|
169
|
+
const outbox = new CrdtOutbox(
|
|
170
|
+
a,
|
|
171
|
+
async (u, o) => {
|
|
172
|
+
if (fail) throw new Error("offline");
|
|
173
|
+
await server.sender()(u, o);
|
|
174
|
+
},
|
|
175
|
+
{ setTimer: () => "handle", clearTimer: () => {} },
|
|
176
|
+
);
|
|
177
|
+
a.getText("t").insert(0, "x");
|
|
178
|
+
a.commit();
|
|
179
|
+
await settle();
|
|
180
|
+
fail = false;
|
|
181
|
+
outbox.retryNow();
|
|
182
|
+
await settle();
|
|
183
|
+
expect(text(server.doc)).toBe("x");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a refused edit stops the outbox", async () => {
|
|
187
|
+
const a = new LoroDoc();
|
|
188
|
+
let calls = 0;
|
|
189
|
+
const outbox = new CrdtOutbox(a, async () => {
|
|
190
|
+
calls += 1;
|
|
191
|
+
throw httpError(403);
|
|
192
|
+
});
|
|
193
|
+
a.getText("t").insert(0, "x");
|
|
194
|
+
a.commit();
|
|
195
|
+
await settle();
|
|
196
|
+
expect(outbox.state).toBe("rejected");
|
|
197
|
+
expect((outbox.error as { status: number }).status).toBe(403);
|
|
198
|
+
a.getText("t").insert(1, "y");
|
|
199
|
+
a.commit();
|
|
200
|
+
await settle();
|
|
201
|
+
expect(calls).toBe(1);
|
|
202
|
+
expect(outbox.hasUnsent()).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("edits that came from the server are not sent back", async () => {
|
|
206
|
+
const server = new FakeServer();
|
|
207
|
+
const a = new LoroDoc();
|
|
208
|
+
const b = new LoroDoc();
|
|
209
|
+
server.connect(a);
|
|
210
|
+
server.connect(b);
|
|
211
|
+
new CrdtOutbox(b, server.sender());
|
|
212
|
+
// A large edit from b reaches a through the server.
|
|
213
|
+
b.getText("t").insert(0, "x".repeat(5000));
|
|
214
|
+
b.commit();
|
|
215
|
+
await settle();
|
|
216
|
+
const sends: Uint8Array[] = [];
|
|
217
|
+
const send = server.sender();
|
|
218
|
+
new CrdtOutbox(a, async (u, o) => {
|
|
219
|
+
sends.push(u);
|
|
220
|
+
await send(u, o);
|
|
221
|
+
});
|
|
222
|
+
a.getText("t").insert(0, "!");
|
|
223
|
+
a.commit();
|
|
224
|
+
await settle();
|
|
225
|
+
expect(sends.length).toBe(1);
|
|
226
|
+
expect(sends[0].length).toBeLessThan(200);
|
|
227
|
+
expect(text(b).startsWith("!")).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("edits made before the outbox existed are sent", async () => {
|
|
231
|
+
const server = new FakeServer();
|
|
232
|
+
const a = new LoroDoc();
|
|
233
|
+
server.connect(a);
|
|
234
|
+
a.getText("t").insert(0, "early");
|
|
235
|
+
a.commit();
|
|
236
|
+
new CrdtOutbox(a, server.sender());
|
|
237
|
+
await settle();
|
|
238
|
+
expect(text(server.doc)).toBe("early");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("flushOnHide sends with keepalive while a request is in flight", async () => {
|
|
242
|
+
const a = new LoroDoc();
|
|
243
|
+
const seen: boolean[] = [];
|
|
244
|
+
let release: () => void = () => {};
|
|
245
|
+
const outbox = new CrdtOutbox(a, async (_u, { keepalive }) => {
|
|
246
|
+
seen.push(keepalive);
|
|
247
|
+
if (!keepalive) await new Promise<void>((r) => (release = r));
|
|
248
|
+
});
|
|
249
|
+
a.getText("t").insert(0, "x");
|
|
250
|
+
a.commit();
|
|
251
|
+
await tick();
|
|
252
|
+
outbox.flushOnHide();
|
|
253
|
+
await settle();
|
|
254
|
+
expect(seen).toEqual([false, true]);
|
|
255
|
+
expect(outbox.hasUnsent()).toBe(false);
|
|
256
|
+
release();
|
|
257
|
+
await settle();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("dispose stops sending", async () => {
|
|
261
|
+
const a = new LoroDoc();
|
|
262
|
+
let calls = 0;
|
|
263
|
+
const outbox = new CrdtOutbox(a, async () => {
|
|
264
|
+
calls += 1;
|
|
265
|
+
});
|
|
266
|
+
outbox.dispose();
|
|
267
|
+
a.getText("t").insert(0, "x");
|
|
268
|
+
a.commit();
|
|
269
|
+
await settle();
|
|
270
|
+
expect(calls).toBe(0);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("a ProseMirror document edited in two copies converges", async () => {
|
|
274
|
+
const schema = new Schema({ nodes: basic.spec.nodes, marks: basic.spec.marks });
|
|
275
|
+
const server = new FakeServer();
|
|
276
|
+
// The server creates the field's container on insert.
|
|
277
|
+
server.doc.getMap("row").setContainer("body", new LoroMap());
|
|
278
|
+
server.doc.commit();
|
|
279
|
+
const a = new LoroDoc();
|
|
280
|
+
const b = new LoroDoc();
|
|
281
|
+
server.connect(a);
|
|
282
|
+
server.connect(b);
|
|
283
|
+
new CrdtOutbox(a, server.sender());
|
|
284
|
+
new CrdtOutbox(b, server.sender());
|
|
285
|
+
const para = (s: string) => schema.node("paragraph", null, [schema.text(s)]);
|
|
286
|
+
writeProseMirror(a, "body", schema, schema.node("doc", null, [para("from a")]));
|
|
287
|
+
await settle();
|
|
288
|
+
const current = readProseMirror(b, "body", schema)!;
|
|
289
|
+
writeProseMirror(b, "body", schema, current.copy(current.content.append(schema.node("doc", null, [para("from b")]).content)));
|
|
290
|
+
await settle();
|
|
291
|
+
const expected = ["from a", "from b"];
|
|
292
|
+
for (const d of [a, b, server.doc]) {
|
|
293
|
+
const node = readProseMirror(d, "body", schema)!;
|
|
294
|
+
const paragraphs: string[] = [];
|
|
295
|
+
node.forEach((n) => paragraphs.push(n.textContent));
|
|
296
|
+
expect(paragraphs).toEqual(expected);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("isRetryable", () => {
|
|
302
|
+
test("network errors, 5xx, 408 and 429 retry; other 4xx do not", () => {
|
|
303
|
+
expect(isRetryable(new Error("x"))).toBe(true);
|
|
304
|
+
expect(isRetryable(httpError(500))).toBe(true);
|
|
305
|
+
expect(isRetryable(httpError(503))).toBe(true);
|
|
306
|
+
expect(isRetryable(httpError(408))).toBe(true);
|
|
307
|
+
expect(isRetryable(httpError(429))).toBe(true);
|
|
308
|
+
expect(isRetryable(httpError(400))).toBe(false);
|
|
309
|
+
expect(isRetryable(httpError(403))).toBe(false);
|
|
310
|
+
expect(isRetryable(httpError(404))).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
});
|
package/src/outbox.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// CrdtOutbox — sends a doc's local edits to the server
|
|
3
|
+
//
|
|
4
|
+
// Every edit made on this copy of a row's doc (by an editor binding, a
|
|
5
|
+
// hook, or app code) has to reach `POST /api/crdt/<entity>/<id>`. The
|
|
6
|
+
// outbox listens for local commits and sends the edits the server has not
|
|
7
|
+
// confirmed:
|
|
8
|
+
//
|
|
9
|
+
// - One request at a time, so edits arrive in order. Commits made while
|
|
10
|
+
// a request is in flight go out together in the next one.
|
|
11
|
+
// - What it sends is every op of this peer after the last confirmed one,
|
|
12
|
+
// so a failed request loses nothing: the next send carries it again.
|
|
13
|
+
// Ops of other peers are never sent back; they came from the server.
|
|
14
|
+
// - A network error or a 5xx, 408 or 429 retries with backoff. Any other
|
|
15
|
+
// 4xx (policy denied, row gone, bad update) stops the outbox: sending
|
|
16
|
+
// the same edits again would fail the same way.
|
|
17
|
+
//
|
|
18
|
+
// The server merges idempotently, so an edit sent twice (a response lost
|
|
19
|
+
// after the server applied it) does no harm.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
import { VersionVector, type LoroDoc } from "loro-crdt";
|
|
23
|
+
|
|
24
|
+
/** Send one update. Resolve when the server accepted it; reject otherwise.
|
|
25
|
+
* A rejection with a numeric `status` is treated as the HTTP status. */
|
|
26
|
+
export type OutboxSend = (update: Uint8Array, options: { keepalive: boolean }) => Promise<void>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* - `idle`: the server has every local edit.
|
|
30
|
+
* - `sending`: a request is in flight.
|
|
31
|
+
* - `retrying`: the last request failed and a retry is scheduled.
|
|
32
|
+
* - `rejected`: the server refused the edits; nothing more is sent.
|
|
33
|
+
*/
|
|
34
|
+
export type OutboxState = "idle" | "sending" | "retrying" | "rejected";
|
|
35
|
+
|
|
36
|
+
export interface OutboxOptions {
|
|
37
|
+
/** First retry delay. Doubles per failure up to `retryMaxMs`. */
|
|
38
|
+
retryBaseMs?: number;
|
|
39
|
+
retryMaxMs?: number;
|
|
40
|
+
/** Timer functions, replaceable in tests. */
|
|
41
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
42
|
+
clearTimer?: (handle: unknown) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True for an error the same request could get past by trying again. */
|
|
46
|
+
export function isRetryable(err: unknown): boolean {
|
|
47
|
+
const status = (err as { status?: unknown } | null)?.status;
|
|
48
|
+
if (typeof status !== "number") return true; // network error, abort, timeout
|
|
49
|
+
return status >= 500 || status === 408 || status === 429;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class CrdtOutbox {
|
|
53
|
+
private readonly doc: LoroDoc;
|
|
54
|
+
private readonly send: OutboxSend;
|
|
55
|
+
private readonly retryBaseMs: number;
|
|
56
|
+
private readonly retryMaxMs: number;
|
|
57
|
+
private readonly setTimer: (fn: () => void, ms: number) => unknown;
|
|
58
|
+
private readonly clearTimer: (handle: unknown) => void;
|
|
59
|
+
private readonly stopListening: () => void;
|
|
60
|
+
private readonly listeners = new Set<() => void>();
|
|
61
|
+
|
|
62
|
+
/** This peer's op counter the server has confirmed (exclusive end). */
|
|
63
|
+
private confirmed = 0;
|
|
64
|
+
private inFlight = false;
|
|
65
|
+
private retryHandle: unknown = null;
|
|
66
|
+
private failures = 0;
|
|
67
|
+
private disposed = false;
|
|
68
|
+
private _state: OutboxState = "idle";
|
|
69
|
+
private _error: unknown = null;
|
|
70
|
+
|
|
71
|
+
constructor(doc: LoroDoc, send: OutboxSend, options: OutboxOptions = {}) {
|
|
72
|
+
this.doc = doc;
|
|
73
|
+
this.send = send;
|
|
74
|
+
this.retryBaseMs = options.retryBaseMs ?? 500;
|
|
75
|
+
this.retryMaxMs = options.retryMaxMs ?? 10_000;
|
|
76
|
+
this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
77
|
+
this.clearTimer = options.clearTimer ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
|
|
78
|
+
this.stopListening = doc.subscribeLocalUpdates(() => this.schedule());
|
|
79
|
+
// Edits made before the outbox existed go out now.
|
|
80
|
+
this.schedule();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get state(): OutboxState {
|
|
84
|
+
return this._state;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The error that made the outbox `rejected` or `retrying`. */
|
|
88
|
+
get error(): unknown {
|
|
89
|
+
return this._error;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** True while some local edit has not been confirmed by the server. */
|
|
93
|
+
hasUnsent(): boolean {
|
|
94
|
+
return this.ownCounter() > this.confirmed;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Call `listener` on every state change. Returns the unsubscribe. */
|
|
98
|
+
subscribe(listener: () => void): () => void {
|
|
99
|
+
this.listeners.add(listener);
|
|
100
|
+
return () => this.listeners.delete(listener);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Send unconfirmed edits now, unless a request is already in flight. */
|
|
104
|
+
schedule(): void {
|
|
105
|
+
if (this.disposed || this._state === "rejected") return;
|
|
106
|
+
if (this.inFlight || this.retryHandle !== null) return; // picked up when that settles
|
|
107
|
+
void this.flush(false);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Skip the backoff wait (the browser came back online). */
|
|
111
|
+
retryNow(): void {
|
|
112
|
+
if (this.retryHandle === null) return;
|
|
113
|
+
this.clearTimer(this.retryHandle);
|
|
114
|
+
this.retryHandle = null;
|
|
115
|
+
this.schedule();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Last chance before the page goes away: send what is unconfirmed with
|
|
120
|
+
* `keepalive`, so the browser finishes the request after the page is gone.
|
|
121
|
+
* Runs even while a request is in flight; the server merges duplicates.
|
|
122
|
+
*/
|
|
123
|
+
flushOnHide(): void {
|
|
124
|
+
if (this.disposed || this._state === "rejected") return;
|
|
125
|
+
const update = this.unsent();
|
|
126
|
+
if (!update) return;
|
|
127
|
+
const target = this.ownCounter();
|
|
128
|
+
this.send(update, { keepalive: true }).then(
|
|
129
|
+
() => this.confirm(target),
|
|
130
|
+
() => {
|
|
131
|
+
/* the regular path retries if the page stays */
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Stop listening and cancel a pending retry. Edits not sent are dropped. */
|
|
137
|
+
dispose(): void {
|
|
138
|
+
if (this.disposed) return;
|
|
139
|
+
this.disposed = true;
|
|
140
|
+
this.stopListening();
|
|
141
|
+
if (this.retryHandle !== null) {
|
|
142
|
+
this.clearTimer(this.retryHandle);
|
|
143
|
+
this.retryHandle = null;
|
|
144
|
+
}
|
|
145
|
+
this.listeners.clear();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private ownCounter(): number {
|
|
149
|
+
return this.doc.oplogVersion().get(this.doc.peerIdStr) ?? 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** This peer's ops after `confirmed`, or null when there are none. */
|
|
153
|
+
private unsent(): Uint8Array | null {
|
|
154
|
+
if (!this.hasUnsent()) return null;
|
|
155
|
+
// Start from everything the doc has, then roll this peer back to what
|
|
156
|
+
// the server confirmed: the export is exactly this peer's new ops.
|
|
157
|
+
const from = this.doc.oplogVersion().toJSON();
|
|
158
|
+
if (this.confirmed > 0) from.set(this.doc.peerIdStr, this.confirmed);
|
|
159
|
+
else from.delete(this.doc.peerIdStr);
|
|
160
|
+
return this.doc.export({ mode: "update", from: VersionVector.parseJSON(from) });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private confirm(counter: number): void {
|
|
164
|
+
if (counter > this.confirmed) this.confirmed = counter;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private async flush(keepalive: boolean): Promise<void> {
|
|
168
|
+
const update = this.unsent();
|
|
169
|
+
if (!update) {
|
|
170
|
+
this.setState("idle", null);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const target = this.ownCounter();
|
|
174
|
+
this.inFlight = true;
|
|
175
|
+
this.setState("sending", null);
|
|
176
|
+
try {
|
|
177
|
+
await this.send(update, { keepalive });
|
|
178
|
+
this.confirm(target);
|
|
179
|
+
this.failures = 0;
|
|
180
|
+
} catch (err) {
|
|
181
|
+
this.inFlight = false;
|
|
182
|
+
if (this.disposed) return;
|
|
183
|
+
if (!isRetryable(err)) {
|
|
184
|
+
this.setState("rejected", err);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
this.failures += 1;
|
|
188
|
+
const delay = Math.min(this.retryMaxMs, this.retryBaseMs * 2 ** (this.failures - 1));
|
|
189
|
+
this.setState("retrying", err);
|
|
190
|
+
this.retryHandle = this.setTimer(() => {
|
|
191
|
+
this.retryHandle = null;
|
|
192
|
+
this.schedule();
|
|
193
|
+
}, delay);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
this.inFlight = false;
|
|
197
|
+
if (this.disposed) return;
|
|
198
|
+
if (this.hasUnsent()) void this.flush(false);
|
|
199
|
+
else this.setState("idle", null);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private setState(state: OutboxState, error: unknown): void {
|
|
203
|
+
if (this._state === state && this._error === error) return;
|
|
204
|
+
this._state = state;
|
|
205
|
+
this._error = error;
|
|
206
|
+
for (const listener of this.listeners) {
|
|
207
|
+
try {
|
|
208
|
+
listener();
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.warn("[loro] outbox listener threw:", err);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React binding for a ProseMirror (Tiptap) field.
|
|
3
|
+
*
|
|
4
|
+
* const { doc, containerId } = useProseMirrorDoc("Doc", id, "body", schema);
|
|
5
|
+
* if (!containerId) return <Loading />;
|
|
6
|
+
* const plugins = [LoroSyncPlugin({ doc, containerId }), LoroUndoPlugin({ doc })];
|
|
7
|
+
*
|
|
8
|
+
* `containerId` is null until the server's snapshot brings the field's
|
|
9
|
+
* container; mount the editor after that (see ./index.ts for why a client
|
|
10
|
+
* never creates it). Edits the editor makes reach the server through the
|
|
11
|
+
* row's outbox, like every edit on a `useLoroDoc` doc.
|
|
12
|
+
*/
|
|
13
|
+
import type { ContainerID, LoroDoc } from "loro-crdt";
|
|
14
|
+
import type { Schema } from "prosemirror-model";
|
|
15
|
+
import { useMemo } from "react";
|
|
16
|
+
import { useLoroDoc } from "../index";
|
|
17
|
+
import { configureProseMirrorMarks, findProseMirrorContainer } from "./index";
|
|
18
|
+
|
|
19
|
+
export function useProseMirrorDoc(
|
|
20
|
+
entity: string,
|
|
21
|
+
id: string,
|
|
22
|
+
field: string,
|
|
23
|
+
schema: Schema,
|
|
24
|
+
): { doc: LoroDoc; containerId: ContainerID | null } {
|
|
25
|
+
const doc = useLoroDoc(entity, id);
|
|
26
|
+
// Before any binding writes a mark. Idempotent, so once per doc is enough.
|
|
27
|
+
useMemo(() => configureProseMirrorMarks(doc, schema), [doc, schema]);
|
|
28
|
+
// useLoroDoc re-renders on every applied frame, so this picks up the
|
|
29
|
+
// container as soon as the snapshot lands.
|
|
30
|
+
return { doc, containerId: findProseMirrorContainer(doc, field) };
|
|
31
|
+
}
|
|
File without changes
|