@mocanvas/sync 1.0.0 → 4.0.1
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/ARCHITECTURE.md +421 -0
- package/BENCHMARK.md +519 -0
- package/CLEAN_ROOM.md +50 -0
- package/COMPAT.md +282 -0
- package/CUSTOM_SHAPES.md +880 -0
- package/LICENSE +110 -16
- package/MIGRATION.md +807 -0
- package/README.md +162 -35
- package/UI.md +256 -0
- package/dist/index.d.ts +215 -19
- package/dist/index.js +614 -39
- package/dist/index.js.map +1 -1
- package/package.json +14 -16
package/dist/index.js
CHANGED
|
@@ -1,11 +1,499 @@
|
|
|
1
1
|
import { react, atom } from '@mocanvas/state';
|
|
2
|
-
import { uniqueId, isRecordsDiffEmpty } from '@mocanvas/store';
|
|
2
|
+
import { uniqueId, isRecordsDiffEmpty, createEmptyRecordsDiff } from '@mocanvas/store';
|
|
3
3
|
import { InstancePresenceRecordType } from '@mocanvas/editor';
|
|
4
4
|
import { track, useValue } from '@mocanvas/state/react';
|
|
5
5
|
import { useRef, useState, useEffect } from 'react';
|
|
6
6
|
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
7
7
|
|
|
8
8
|
// src/SyncClient.ts
|
|
9
|
+
var CRDT_STATE_VERSION = 1;
|
|
10
|
+
var DEFAULT_TOMBSTONE_LIMIT = 5e3;
|
|
11
|
+
var DEFAULT_TOMBSTONE_MAX_AGE_MS = 60 * 60 * 1e3;
|
|
12
|
+
function compareStamps(a, b) {
|
|
13
|
+
if (a.lamport !== b.lamport) return a.lamport < b.lamport ? -1 : 1;
|
|
14
|
+
if (a.client === b.client) return 0;
|
|
15
|
+
return a.client < b.client ? -1 : 1;
|
|
16
|
+
}
|
|
17
|
+
function isStamp(value) {
|
|
18
|
+
if (typeof value !== "object" || value === null) return false;
|
|
19
|
+
const stamp = value;
|
|
20
|
+
return typeof stamp.lamport === "number" && Number.isFinite(stamp.lamport) && typeof stamp.client === "string";
|
|
21
|
+
}
|
|
22
|
+
function createLamportClock(start = 0) {
|
|
23
|
+
let value = start;
|
|
24
|
+
return {
|
|
25
|
+
get: () => value,
|
|
26
|
+
tick: () => value += 1,
|
|
27
|
+
observe(remote) {
|
|
28
|
+
if (!Number.isFinite(remote)) return;
|
|
29
|
+
value = Math.max(value, remote) + 1;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function createEmptyStampedDiff() {
|
|
34
|
+
return { puts: [], removes: [] };
|
|
35
|
+
}
|
|
36
|
+
function isStampedDiffEmpty(diff) {
|
|
37
|
+
return diff.puts.length === 0 && diff.removes.length === 0;
|
|
38
|
+
}
|
|
39
|
+
function isStampedDiffLike(value) {
|
|
40
|
+
if (typeof value !== "object" || value === null) return false;
|
|
41
|
+
const diff = value;
|
|
42
|
+
if (!Array.isArray(diff.puts) || !Array.isArray(diff.removes)) return false;
|
|
43
|
+
for (const put of diff.puts) {
|
|
44
|
+
if (typeof put !== "object" || put === null) return false;
|
|
45
|
+
const { record, fields, base } = put;
|
|
46
|
+
if (typeof record !== "object" || record === null) return false;
|
|
47
|
+
if (typeof record.id !== "string") return false;
|
|
48
|
+
if (typeof record.typeName !== "string") return false;
|
|
49
|
+
if (!isStampMap(fields)) return false;
|
|
50
|
+
if (base !== void 0 && !isStamp(base)) return false;
|
|
51
|
+
}
|
|
52
|
+
for (const remove of diff.removes) {
|
|
53
|
+
if (typeof remove !== "object" || remove === null) return false;
|
|
54
|
+
const { id, stamp } = remove;
|
|
55
|
+
if (typeof id !== "string" || !isStamp(stamp)) return false;
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
function createEmptyCrdtState() {
|
|
60
|
+
return { version: CRDT_STATE_VERSION, clock: 0, records: {} };
|
|
61
|
+
}
|
|
62
|
+
function isCrdtStateLike(value) {
|
|
63
|
+
if (typeof value !== "object" || value === null) return false;
|
|
64
|
+
const state = value;
|
|
65
|
+
if (typeof state.version !== "number") return false;
|
|
66
|
+
if (typeof state.clock !== "number") return false;
|
|
67
|
+
if (typeof state.records !== "object" || state.records === null || Array.isArray(state.records)) return false;
|
|
68
|
+
for (const key of Object.keys(state.records)) {
|
|
69
|
+
const record = state.records[key];
|
|
70
|
+
if (typeof record !== "object" || record === null) return false;
|
|
71
|
+
const { fields, deleted, base } = record;
|
|
72
|
+
if (!isStampMap(fields)) return false;
|
|
73
|
+
if (deleted !== void 0 && !isStamp(deleted)) return false;
|
|
74
|
+
if (base !== void 0 && !isStamp(base)) return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
function isStampMap(value) {
|
|
79
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
80
|
+
for (const key of Object.keys(value)) {
|
|
81
|
+
if (!isStamp(value[key])) return false;
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
function maxStamp(a, b) {
|
|
86
|
+
if (!a) return b;
|
|
87
|
+
if (!b) return a;
|
|
88
|
+
return compareStamps(a, b) >= 0 ? a : b;
|
|
89
|
+
}
|
|
90
|
+
function greatestStamp(fields) {
|
|
91
|
+
let best;
|
|
92
|
+
for (const path of Object.keys(fields)) best = maxStamp(best, fields[path]);
|
|
93
|
+
return best;
|
|
94
|
+
}
|
|
95
|
+
function ancestorPaths(path) {
|
|
96
|
+
const out = [];
|
|
97
|
+
let index = path.indexOf(".");
|
|
98
|
+
while (index !== -1) {
|
|
99
|
+
out.push(path.slice(0, index));
|
|
100
|
+
index = path.indexOf(".", index + 1);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
function claimPath(state, path, stamp) {
|
|
105
|
+
const own = state.fields.get(path);
|
|
106
|
+
if (own && compareStamps(stamp, own) <= 0) return null;
|
|
107
|
+
for (const ancestor of ancestorPaths(path)) {
|
|
108
|
+
const above = state.fields.get(ancestor);
|
|
109
|
+
if (above && compareStamps(stamp, above) <= 0) return null;
|
|
110
|
+
}
|
|
111
|
+
const survivors = [];
|
|
112
|
+
const prefix = `${path}.`;
|
|
113
|
+
for (const [other, existing] of Array.from(state.fields)) {
|
|
114
|
+
if (!other.startsWith(prefix)) continue;
|
|
115
|
+
if (compareStamps(existing, stamp) <= 0) state.fields.delete(other);
|
|
116
|
+
else survivors.push(other);
|
|
117
|
+
}
|
|
118
|
+
state.fields.set(path, stamp);
|
|
119
|
+
return survivors;
|
|
120
|
+
}
|
|
121
|
+
function isStamped(state, path) {
|
|
122
|
+
if (state.fields.has(path)) return true;
|
|
123
|
+
for (const ancestor of ancestorPaths(path)) {
|
|
124
|
+
if (state.fields.has(ancestor)) return true;
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
function highestAbsent(body, state, path) {
|
|
129
|
+
for (const ancestor of ancestorPaths(path)) {
|
|
130
|
+
if (readPath(body, ancestor) !== ABSENT) continue;
|
|
131
|
+
if (isStamped(state, ancestor) || hasStampedDescendant(state, ancestor)) continue;
|
|
132
|
+
return ancestor;
|
|
133
|
+
}
|
|
134
|
+
return path;
|
|
135
|
+
}
|
|
136
|
+
function hasStampedDescendant(state, path) {
|
|
137
|
+
const prefix = `${path}.`;
|
|
138
|
+
for (const other of state.fields.keys()) {
|
|
139
|
+
if (other.startsWith(prefix)) return true;
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
function createCrdt(options) {
|
|
144
|
+
const { clientId } = options;
|
|
145
|
+
const getRecord = options.getRecord ?? (() => void 0);
|
|
146
|
+
const tombstoneLimit = options.tombstoneLimit ?? DEFAULT_TOMBSTONE_LIMIT;
|
|
147
|
+
const tombstoneMaxAgeMs = options.tombstoneMaxAgeMs ?? DEFAULT_TOMBSTONE_MAX_AGE_MS;
|
|
148
|
+
const now = options.now ?? (() => Date.now());
|
|
149
|
+
const clock = createLamportClock();
|
|
150
|
+
const states = /* @__PURE__ */ new Map();
|
|
151
|
+
let outgoing = createEmptyStampedDiff();
|
|
152
|
+
const stateFor = (id) => {
|
|
153
|
+
let state = states.get(id);
|
|
154
|
+
if (!state) {
|
|
155
|
+
state = { fields: /* @__PURE__ */ new Map() };
|
|
156
|
+
states.set(id, state);
|
|
157
|
+
}
|
|
158
|
+
return state;
|
|
159
|
+
};
|
|
160
|
+
const startPass = () => ({ before: /* @__PURE__ */ new Map(), after: /* @__PURE__ */ new Map() });
|
|
161
|
+
const readBefore = (pass, id) => {
|
|
162
|
+
if (!pass.before.has(id)) pass.before.set(id, getRecord(id));
|
|
163
|
+
return pass.before.get(id);
|
|
164
|
+
};
|
|
165
|
+
const readCurrent = (pass, id) => pass.after.has(id) ? pass.after.get(id) : readBefore(pass, id);
|
|
166
|
+
const write = (pass, id, value) => {
|
|
167
|
+
readBefore(pass, id);
|
|
168
|
+
pass.after.set(id, value);
|
|
169
|
+
};
|
|
170
|
+
const finishPass = (pass) => {
|
|
171
|
+
const result = createEmptyRecordsDiff();
|
|
172
|
+
for (const [id, next] of pass.after) {
|
|
173
|
+
const prev = pass.before.get(id);
|
|
174
|
+
if (prev === next) continue;
|
|
175
|
+
const key = id;
|
|
176
|
+
if (prev === void 0) {
|
|
177
|
+
if (next !== void 0) result.added[key] = next;
|
|
178
|
+
} else if (next === void 0) {
|
|
179
|
+
result.removed[key] = prev;
|
|
180
|
+
} else {
|
|
181
|
+
result.updated[key] = [prev, next];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
};
|
|
186
|
+
const applyRemove = (pass, id, stamp) => {
|
|
187
|
+
const state = stateFor(id);
|
|
188
|
+
if (state.deleted && compareStamps(stamp, state.deleted) <= 0) return;
|
|
189
|
+
state.deleted = stamp;
|
|
190
|
+
state.deletedAt = now();
|
|
191
|
+
state.base = maxStamp(state.base, stamp);
|
|
192
|
+
let alive = false;
|
|
193
|
+
for (const [path, existing] of Array.from(state.fields)) {
|
|
194
|
+
if (compareStamps(existing, stamp) > 0) alive = true;
|
|
195
|
+
else state.fields.delete(path);
|
|
196
|
+
}
|
|
197
|
+
if (!alive) {
|
|
198
|
+
if (readCurrent(pass, id) !== void 0) write(pass, id, void 0);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const record = readCurrent(pass, id);
|
|
202
|
+
if (!record) return;
|
|
203
|
+
const revival = { lamport: clock.tick(), client: clientId };
|
|
204
|
+
const fields = {};
|
|
205
|
+
for (const path of leafPaths(record)) {
|
|
206
|
+
fields[path] = revival;
|
|
207
|
+
claimPath(state, path, revival);
|
|
208
|
+
}
|
|
209
|
+
state.base = revival;
|
|
210
|
+
outgoing.puts.push({ record, fields, base: revival });
|
|
211
|
+
};
|
|
212
|
+
const adoptBody = (record, body, state) => {
|
|
213
|
+
const paths = /* @__PURE__ */ new Set([...leafPaths(body), ...leafPaths(record)]);
|
|
214
|
+
let next = record;
|
|
215
|
+
for (const path of Array.from(paths).sort()) {
|
|
216
|
+
if (path === "id" || path === "typeName") continue;
|
|
217
|
+
if (isStamped(state, path) || hasStampedDescendant(state, path)) continue;
|
|
218
|
+
const value = readPath(body, path);
|
|
219
|
+
next = writePath(next, value === ABSENT ? highestAbsent(body, state, path) : path, value);
|
|
220
|
+
}
|
|
221
|
+
return next;
|
|
222
|
+
};
|
|
223
|
+
const applyPut = (pass, put) => {
|
|
224
|
+
const id = put.record.id;
|
|
225
|
+
const state = stateFor(id);
|
|
226
|
+
const claims = [];
|
|
227
|
+
for (const path of Object.keys(put.fields)) {
|
|
228
|
+
const stamp = put.fields[path];
|
|
229
|
+
if (!stamp) continue;
|
|
230
|
+
if (state.deleted && compareStamps(stamp, state.deleted) <= 0) continue;
|
|
231
|
+
const survivors = claimPath(state, path, stamp);
|
|
232
|
+
if (survivors === null) continue;
|
|
233
|
+
claims.push({ path, survivors });
|
|
234
|
+
}
|
|
235
|
+
const base = put.base ?? greatestStamp(put.fields);
|
|
236
|
+
const bodyWins = base !== void 0 && (state.base === void 0 || compareStamps(base, state.base) > 0);
|
|
237
|
+
if (claims.length === 0 && !bodyWins) return;
|
|
238
|
+
const current = readCurrent(pass, id);
|
|
239
|
+
if (current === void 0) {
|
|
240
|
+
if (claims.length === 0) return;
|
|
241
|
+
write(pass, id, put.record);
|
|
242
|
+
state.base = maxStamp(state.base, base);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
let next = current;
|
|
246
|
+
for (const { path, survivors } of claims) {
|
|
247
|
+
if (path === "id") continue;
|
|
248
|
+
const before = next;
|
|
249
|
+
next = writePath(next, path, readPath(put.record, path));
|
|
250
|
+
for (const survivor of survivors) next = writePath(next, survivor, readPath(before, survivor));
|
|
251
|
+
}
|
|
252
|
+
if (bodyWins) {
|
|
253
|
+
next = adoptBody(next, put.record, state);
|
|
254
|
+
state.base = base;
|
|
255
|
+
}
|
|
256
|
+
if (next !== current) write(pass, id, next);
|
|
257
|
+
};
|
|
258
|
+
const observeStamps = (stamped) => {
|
|
259
|
+
let max = -1;
|
|
260
|
+
for (const put of stamped.puts) {
|
|
261
|
+
for (const path of Object.keys(put.fields)) {
|
|
262
|
+
const stamp = put.fields[path];
|
|
263
|
+
if (stamp && stamp.lamport > max) max = stamp.lamport;
|
|
264
|
+
}
|
|
265
|
+
if (put.base && put.base.lamport > max) max = put.base.lamport;
|
|
266
|
+
}
|
|
267
|
+
for (const remove of stamped.removes) {
|
|
268
|
+
if (remove.stamp.lamport > max) max = remove.stamp.lamport;
|
|
269
|
+
}
|
|
270
|
+
if (max >= 0) clock.observe(max);
|
|
271
|
+
};
|
|
272
|
+
const collectTombstones = () => {
|
|
273
|
+
const gone = [];
|
|
274
|
+
for (const [id, state] of states) {
|
|
275
|
+
if (state.deleted && state.fields.size === 0) gone.push({ id, at: state.deletedAt ?? 0 });
|
|
276
|
+
}
|
|
277
|
+
if (gone.length === 0) return;
|
|
278
|
+
const cutoff = now() - tombstoneMaxAgeMs;
|
|
279
|
+
let overflow = gone.length - tombstoneLimit;
|
|
280
|
+
if (overflow <= 0 && gone.every((entry) => entry.at >= cutoff)) return;
|
|
281
|
+
gone.sort((a, b) => a.at - b.at);
|
|
282
|
+
for (const entry of gone) {
|
|
283
|
+
if (overflow > 0) {
|
|
284
|
+
states.delete(entry.id);
|
|
285
|
+
overflow--;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (entry.at < cutoff) states.delete(entry.id);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
return {
|
|
292
|
+
clientId,
|
|
293
|
+
clock,
|
|
294
|
+
stampLocal(diff) {
|
|
295
|
+
const stamp = { lamport: clock.tick(), client: clientId };
|
|
296
|
+
const result = createEmptyStampedDiff();
|
|
297
|
+
for (const id in diff.added) {
|
|
298
|
+
const record = diff.added[id];
|
|
299
|
+
if (!record) continue;
|
|
300
|
+
const fields = {};
|
|
301
|
+
const state = stateFor(id);
|
|
302
|
+
for (const path of leafPaths(record)) {
|
|
303
|
+
fields[path] = stamp;
|
|
304
|
+
claimPath(state, path, stamp);
|
|
305
|
+
}
|
|
306
|
+
state.base = stamp;
|
|
307
|
+
result.puts.push({ record, fields, base: stamp });
|
|
308
|
+
}
|
|
309
|
+
for (const id in diff.updated) {
|
|
310
|
+
const pair = diff.updated[id];
|
|
311
|
+
if (!pair) continue;
|
|
312
|
+
const [from, to] = pair;
|
|
313
|
+
const paths = changedPaths(from, to);
|
|
314
|
+
if (paths.length === 0) continue;
|
|
315
|
+
const fields = {};
|
|
316
|
+
const state = stateFor(id);
|
|
317
|
+
for (const path of paths) {
|
|
318
|
+
fields[path] = stamp;
|
|
319
|
+
claimPath(state, path, stamp);
|
|
320
|
+
}
|
|
321
|
+
state.base = stamp;
|
|
322
|
+
result.puts.push({ record: to, fields, base: stamp });
|
|
323
|
+
}
|
|
324
|
+
for (const id in diff.removed) {
|
|
325
|
+
const state = stateFor(id);
|
|
326
|
+
state.deleted = stamp;
|
|
327
|
+
state.deletedAt = now();
|
|
328
|
+
state.fields.clear();
|
|
329
|
+
state.base = stamp;
|
|
330
|
+
result.removes.push({ id, stamp });
|
|
331
|
+
}
|
|
332
|
+
collectTombstones();
|
|
333
|
+
return result;
|
|
334
|
+
},
|
|
335
|
+
mergeRemote(stamped) {
|
|
336
|
+
observeStamps(stamped);
|
|
337
|
+
const pass = startPass();
|
|
338
|
+
for (const remove of stamped.removes) applyRemove(pass, remove.id, remove.stamp);
|
|
339
|
+
for (const put of stamped.puts) applyPut(pass, put);
|
|
340
|
+
collectTombstones();
|
|
341
|
+
return finishPass(pass);
|
|
342
|
+
},
|
|
343
|
+
getState() {
|
|
344
|
+
const records = {};
|
|
345
|
+
for (const [id, state] of states) {
|
|
346
|
+
const fields = {};
|
|
347
|
+
for (const [path, stamp] of state.fields) fields[path] = stamp;
|
|
348
|
+
records[id] = {
|
|
349
|
+
fields,
|
|
350
|
+
...state.base ? { base: state.base } : {},
|
|
351
|
+
...state.deleted ? { deleted: state.deleted, deletedAt: state.deletedAt ?? 0 } : {}
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
return { version: CRDT_STATE_VERSION, clock: clock.get(), records };
|
|
355
|
+
},
|
|
356
|
+
applyState(state) {
|
|
357
|
+
const pass = startPass();
|
|
358
|
+
if (state.version !== CRDT_STATE_VERSION) return finishPass(pass);
|
|
359
|
+
clock.observe(state.clock);
|
|
360
|
+
for (const id of Object.keys(state.records)) {
|
|
361
|
+
const incoming = state.records[id];
|
|
362
|
+
if (!incoming) continue;
|
|
363
|
+
if (incoming.deleted) applyRemove(pass, id, incoming.deleted);
|
|
364
|
+
const local = stateFor(id);
|
|
365
|
+
for (const path of Object.keys(incoming.fields)) {
|
|
366
|
+
const stamp = incoming.fields[path];
|
|
367
|
+
if (!stamp) continue;
|
|
368
|
+
clock.observe(stamp.lamport);
|
|
369
|
+
if (local.deleted && compareStamps(stamp, local.deleted) <= 0) continue;
|
|
370
|
+
claimPath(local, path, stamp);
|
|
371
|
+
}
|
|
372
|
+
if (incoming.base) {
|
|
373
|
+
clock.observe(incoming.base.lamport);
|
|
374
|
+
local.base = maxStamp(local.base, incoming.base);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
collectTombstones();
|
|
378
|
+
return finishPass(pass);
|
|
379
|
+
},
|
|
380
|
+
takeOutgoing() {
|
|
381
|
+
if (isStampedDiffEmpty(outgoing)) return null;
|
|
382
|
+
const result = outgoing;
|
|
383
|
+
outgoing = createEmptyStampedDiff();
|
|
384
|
+
return result;
|
|
385
|
+
},
|
|
386
|
+
size: () => states.size,
|
|
387
|
+
collectTombstones
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function stampedDiffFromSnapshot(records, state) {
|
|
391
|
+
const result = createEmptyStampedDiff();
|
|
392
|
+
for (const record of records) {
|
|
393
|
+
const entry = state.records[record.id];
|
|
394
|
+
const fields = entry?.fields ?? {};
|
|
395
|
+
const base = entry?.base ?? greatestStamp(fields);
|
|
396
|
+
result.puts.push(base ? { record, fields, base } : { record, fields });
|
|
397
|
+
}
|
|
398
|
+
for (const id of Object.keys(state.records)) {
|
|
399
|
+
const deleted = state.records[id]?.deleted;
|
|
400
|
+
if (deleted) result.removes.push({ id, stamp: deleted });
|
|
401
|
+
}
|
|
402
|
+
return result;
|
|
403
|
+
}
|
|
404
|
+
var ABSENT = /* @__PURE__ */ Symbol("absent");
|
|
405
|
+
function isPlainObject(value) {
|
|
406
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
407
|
+
const proto = Object.getPrototypeOf(value);
|
|
408
|
+
return proto === Object.prototype || proto === null;
|
|
409
|
+
}
|
|
410
|
+
function leafPaths(value, prefix = "") {
|
|
411
|
+
const out = [];
|
|
412
|
+
collectLeaves(value, prefix, out);
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
function collectLeaves(value, prefix, out) {
|
|
416
|
+
if (isPlainObject(value)) {
|
|
417
|
+
const keys = Object.keys(value);
|
|
418
|
+
if (keys.length > 0) {
|
|
419
|
+
for (const key of keys) collectLeaves(value[key], prefix ? `${prefix}.${key}` : key, out);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (prefix !== "") out.push(prefix);
|
|
424
|
+
}
|
|
425
|
+
function changedPaths(from, to) {
|
|
426
|
+
const out = [];
|
|
427
|
+
collectChanges(from, to, "", out);
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
function collectChanges(from, to, prefix, out) {
|
|
431
|
+
if (isPlainObject(from) && isPlainObject(to)) {
|
|
432
|
+
for (const key of Object.keys(from)) {
|
|
433
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
434
|
+
if (!(key in to)) out.push(path);
|
|
435
|
+
else collectChanges(from[key], to[key], path, out);
|
|
436
|
+
}
|
|
437
|
+
for (const key of Object.keys(to)) {
|
|
438
|
+
if (key in from) continue;
|
|
439
|
+
collectLeaves(to[key], prefix ? `${prefix}.${key}` : key, out);
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (!deepEqual(from, to) && prefix !== "") out.push(prefix);
|
|
444
|
+
}
|
|
445
|
+
function readPath(source, path) {
|
|
446
|
+
let current = source;
|
|
447
|
+
for (const segment of path.split(".")) {
|
|
448
|
+
if (!isPlainObject(current) || !(segment in current)) return ABSENT;
|
|
449
|
+
current = current[segment];
|
|
450
|
+
}
|
|
451
|
+
return current;
|
|
452
|
+
}
|
|
453
|
+
function writePath(record, path, value) {
|
|
454
|
+
const current = readPath(record, path);
|
|
455
|
+
if (value === ABSENT) {
|
|
456
|
+
if (current === ABSENT) return record;
|
|
457
|
+
} else if (current !== ABSENT && deepEqual(current, value)) {
|
|
458
|
+
return record;
|
|
459
|
+
}
|
|
460
|
+
const segments = path.split(".");
|
|
461
|
+
return setIn(record, segments, 0, value);
|
|
462
|
+
}
|
|
463
|
+
function setIn(target, segments, index, value) {
|
|
464
|
+
const key = segments[index];
|
|
465
|
+
if (key === void 0) return target;
|
|
466
|
+
const next = { ...target };
|
|
467
|
+
if (index === segments.length - 1) {
|
|
468
|
+
if (value === ABSENT) delete next[key];
|
|
469
|
+
else next[key] = value;
|
|
470
|
+
return next;
|
|
471
|
+
}
|
|
472
|
+
const child = next[key];
|
|
473
|
+
next[key] = setIn(isPlainObject(child) ? child : {}, segments, index + 1, value);
|
|
474
|
+
return next;
|
|
475
|
+
}
|
|
476
|
+
function deepEqual(a, b) {
|
|
477
|
+
if (Object.is(a, b)) return true;
|
|
478
|
+
if (Array.isArray(a)) {
|
|
479
|
+
if (!Array.isArray(b) || a.length !== b.length) return false;
|
|
480
|
+
for (let i = 0; i < a.length; i++) {
|
|
481
|
+
if (!deepEqual(a[i], b[i])) return false;
|
|
482
|
+
}
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
486
|
+
const keysA = Object.keys(a);
|
|
487
|
+
const keysB = Object.keys(b);
|
|
488
|
+
if (keysA.length !== keysB.length) return false;
|
|
489
|
+
for (const key of keysA) {
|
|
490
|
+
if (!(key in b)) return false;
|
|
491
|
+
if (!deepEqual(a[key], b[key])) return false;
|
|
492
|
+
}
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
9
497
|
var DEFAULT_PRESENCE_THROTTLE_MS = Math.ceil(1e3 / 30);
|
|
10
498
|
var DEFAULT_PRESENCE_HEARTBEAT_MS = 3e3;
|
|
11
499
|
var DEFAULT_PRESENCE_TIMEOUT_MS = 1e4;
|
|
@@ -98,7 +586,15 @@ function createPresenceSync(options) {
|
|
|
98
586
|
};
|
|
99
587
|
}
|
|
100
588
|
function isSamePresence(a, b) {
|
|
101
|
-
return a.userId === b.userId && a.userName === b.userName && a.color === b.color && a.currentPageId === b.currentPageId && a.chatMessage === b.chatMessage && a.followingUserId === b.followingUserId && a.cursor
|
|
589
|
+
return a.userId === b.userId && a.userName === b.userName && a.color === b.color && a.currentPageId === b.currentPageId && a.chatMessage === b.chatMessage && a.followingUserId === b.followingUserId && sameCursor(a.cursor, b.cursor) && sameCamera(a.camera, b.camera) && sameIds(a.selectedShapeIds, b.selectedShapeIds) && sameBrush(a.brush, b.brush) && a.scribbles.length === b.scribbles.length && a.scribbles.every((s, i) => s === b.scribbles[i]);
|
|
590
|
+
}
|
|
591
|
+
function sameCamera(a, b) {
|
|
592
|
+
if (a === null || b === null) return a === b;
|
|
593
|
+
return a.x === b.x && a.y === b.y && a.z === b.z;
|
|
594
|
+
}
|
|
595
|
+
function sameCursor(a, b) {
|
|
596
|
+
if (a === null || b === null) return a === b;
|
|
597
|
+
return a.x === b.x && a.y === b.y && a.type === b.type && a.rotation === b.rotation;
|
|
102
598
|
}
|
|
103
599
|
function sameIds(a, b) {
|
|
104
600
|
return a.length === b.length && a.every((id, i) => id === b[i]);
|
|
@@ -166,9 +662,9 @@ var PresenceRoom = class {
|
|
|
166
662
|
};
|
|
167
663
|
|
|
168
664
|
// src/protocol.ts
|
|
169
|
-
var PROTOCOL_VERSION =
|
|
665
|
+
var PROTOCOL_VERSION = 3;
|
|
170
666
|
function encodeMessage(message) {
|
|
171
|
-
return JSON.stringify(message);
|
|
667
|
+
return JSON.stringify({ ...message, version: PROTOCOL_VERSION });
|
|
172
668
|
}
|
|
173
669
|
function decodeMessage(data) {
|
|
174
670
|
let value = data;
|
|
@@ -181,6 +677,11 @@ function decodeMessage(data) {
|
|
|
181
677
|
}
|
|
182
678
|
if (typeof value !== "object" || value === null) return null;
|
|
183
679
|
const message = value;
|
|
680
|
+
const version = message["version"];
|
|
681
|
+
if (typeof version === "number" && version !== PROTOCOL_VERSION) {
|
|
682
|
+
const clientId = message["clientId"];
|
|
683
|
+
return typeof clientId === "string" ? { type: "unsupported", version, clientId } : { type: "unsupported", version };
|
|
684
|
+
}
|
|
184
685
|
switch (message["type"]) {
|
|
185
686
|
case "hello":
|
|
186
687
|
if (typeof message["clientId"] !== "string") return null;
|
|
@@ -190,12 +691,14 @@ function decodeMessage(data) {
|
|
|
190
691
|
const records = message["records"];
|
|
191
692
|
if (!Array.isArray(records)) return null;
|
|
192
693
|
if (!records.every(isRecordLike)) return null;
|
|
193
|
-
|
|
694
|
+
const state = message["state"];
|
|
695
|
+
if (!isCrdtStateLike(state)) return null;
|
|
696
|
+
return { type: "snapshot", records, state };
|
|
194
697
|
}
|
|
195
698
|
case "diff": {
|
|
196
699
|
if (typeof message["clientId"] !== "string") return null;
|
|
197
700
|
if (typeof message["seq"] !== "number") return null;
|
|
198
|
-
if (!
|
|
701
|
+
if (!isStampedDiffLike(message["diff"])) return null;
|
|
199
702
|
return {
|
|
200
703
|
type: "diff",
|
|
201
704
|
clientId: message["clientId"],
|
|
@@ -218,15 +721,6 @@ function decodeMessage(data) {
|
|
|
218
721
|
function isRecordLike(value) {
|
|
219
722
|
return typeof value === "object" && value !== null && typeof value.id === "string" && typeof value.typeName === "string";
|
|
220
723
|
}
|
|
221
|
-
function isDiffLike(value) {
|
|
222
|
-
if (typeof value !== "object" || value === null) return false;
|
|
223
|
-
const diff = value;
|
|
224
|
-
for (const key of ["added", "updated", "removed"]) {
|
|
225
|
-
const part = diff[key];
|
|
226
|
-
if (typeof part !== "object" || part === null || Array.isArray(part)) return false;
|
|
227
|
-
}
|
|
228
|
-
return true;
|
|
229
|
-
}
|
|
230
724
|
|
|
231
725
|
// src/SyncClient.ts
|
|
232
726
|
function createSyncClient(options) {
|
|
@@ -234,7 +728,14 @@ function createSyncClient(options) {
|
|
|
234
728
|
const clientId = options.clientId ?? uniqueId(12);
|
|
235
729
|
const status = atom(`sync.status:${roomId}`, "offline");
|
|
236
730
|
const room = new PresenceRoom(store, options.presenceTimeoutMs ?? DEFAULT_PRESENCE_TIMEOUT_MS);
|
|
731
|
+
const crdt = createCrdt({
|
|
732
|
+
clientId,
|
|
733
|
+
getRecord: (id) => store.unsafeGetWithoutCapture(id),
|
|
734
|
+
...options.tombstoneLimit !== void 0 ? { tombstoneLimit: options.tombstoneLimit } : {},
|
|
735
|
+
...options.tombstoneMaxAgeMs !== void 0 ? { tombstoneMaxAgeMs: options.tombstoneMaxAgeMs } : {}
|
|
736
|
+
});
|
|
237
737
|
const unsubscribes = [];
|
|
738
|
+
const reportedVersions = /* @__PURE__ */ new Set();
|
|
238
739
|
let presence = null;
|
|
239
740
|
let seq = 0;
|
|
240
741
|
let awaitingSnapshot = false;
|
|
@@ -247,9 +748,78 @@ function createSyncClient(options) {
|
|
|
247
748
|
options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
248
749
|
}
|
|
249
750
|
};
|
|
751
|
+
const broadcast = (stamped) => {
|
|
752
|
+
if (isStampedDiffEmpty(stamped)) return;
|
|
753
|
+
send({ type: "diff", clientId, seq: seq++, diff: stamped });
|
|
754
|
+
};
|
|
755
|
+
const flushCrdt = () => {
|
|
756
|
+
const outgoing = crdt.takeOutgoing();
|
|
757
|
+
if (outgoing) broadcast(outgoing);
|
|
758
|
+
};
|
|
759
|
+
const sendSnapshot = () => {
|
|
760
|
+
send({
|
|
761
|
+
type: "snapshot",
|
|
762
|
+
records: Object.values(store.serialize("document")),
|
|
763
|
+
state: crdt.getState()
|
|
764
|
+
});
|
|
765
|
+
};
|
|
766
|
+
const applyRemote = (diff) => {
|
|
767
|
+
if (isRecordsDiffEmpty(diff)) return;
|
|
768
|
+
const actual = store.extractingChanges(() => {
|
|
769
|
+
store.mergeRemoteChanges(() => {
|
|
770
|
+
store.applyDiff(diff);
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
reportRewrites(diff, actual);
|
|
774
|
+
};
|
|
775
|
+
const reportRewrites = (asked, actual) => {
|
|
776
|
+
const rewrite = createEmptyRecordsDiff();
|
|
777
|
+
const intended = (id) => asked.added[id] ?? asked.updated[id]?.[1];
|
|
778
|
+
const isDocument = (record) => store.getScope(record.typeName) === "document";
|
|
779
|
+
for (const key in actual.added) {
|
|
780
|
+
const id = key;
|
|
781
|
+
const after = actual.added[id];
|
|
782
|
+
if (!after || !isDocument(after)) continue;
|
|
783
|
+
const want = intended(id);
|
|
784
|
+
if (!want) rewrite.added[id] = after;
|
|
785
|
+
else if (!deepEqual(want, after)) rewrite.updated[id] = [want, after];
|
|
786
|
+
}
|
|
787
|
+
for (const key in actual.updated) {
|
|
788
|
+
const id = key;
|
|
789
|
+
const pair = actual.updated[id];
|
|
790
|
+
if (!pair || !isDocument(pair[1])) continue;
|
|
791
|
+
const want = intended(id);
|
|
792
|
+
if (!want) rewrite.updated[id] = pair;
|
|
793
|
+
else if (!deepEqual(want, pair[1])) rewrite.updated[id] = [want, pair[1]];
|
|
794
|
+
}
|
|
795
|
+
for (const key in actual.removed) {
|
|
796
|
+
const id = key;
|
|
797
|
+
const before = actual.removed[id];
|
|
798
|
+
if (!before || !isDocument(before)) continue;
|
|
799
|
+
if (!asked.removed[id]) rewrite.removed[id] = before;
|
|
800
|
+
}
|
|
801
|
+
if (isRecordsDiffEmpty(rewrite)) return;
|
|
802
|
+
broadcast(crdt.stampLocal(rewrite));
|
|
803
|
+
};
|
|
804
|
+
const stopListening = store.listen(
|
|
805
|
+
(entry) => {
|
|
806
|
+
if (isRecordsDiffEmpty(entry.changes)) return;
|
|
807
|
+
const stamped = crdt.stampLocal(entry.changes);
|
|
808
|
+
if (connected) broadcast(stamped);
|
|
809
|
+
},
|
|
810
|
+
{ source: "user", scope: "document" }
|
|
811
|
+
);
|
|
250
812
|
const handle = (message) => {
|
|
251
813
|
if (disposed) return;
|
|
252
814
|
switch (message.type) {
|
|
815
|
+
case "unsupported": {
|
|
816
|
+
if (reportedVersions.has(message.version)) return;
|
|
817
|
+
reportedVersions.add(message.version);
|
|
818
|
+
options.onError?.(
|
|
819
|
+
new Error(`A peer speaks protocol ${message.version}, we speak ${PROTOCOL_VERSION}`)
|
|
820
|
+
);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
253
823
|
case "hello": {
|
|
254
824
|
if (message.clientId === clientId) return;
|
|
255
825
|
if (message.version !== PROTOCOL_VERSION) {
|
|
@@ -259,24 +829,36 @@ function createSyncClient(options) {
|
|
|
259
829
|
return;
|
|
260
830
|
}
|
|
261
831
|
awaitingSnapshot = false;
|
|
262
|
-
|
|
832
|
+
sendSnapshot();
|
|
263
833
|
presence?.poke();
|
|
264
834
|
return;
|
|
265
835
|
}
|
|
266
836
|
case "snapshot": {
|
|
267
|
-
if (
|
|
837
|
+
if (awaitingSnapshot && crdt.size() === 0) {
|
|
838
|
+
awaitingSnapshot = false;
|
|
839
|
+
const removals = crdt.applyState(message.state);
|
|
840
|
+
const actual = store.extractingChanges(() => {
|
|
841
|
+
store.mergeRemoteChanges(() => {
|
|
842
|
+
store.put(message.records);
|
|
843
|
+
store.applyDiff(removals);
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
const asked = createEmptyRecordsDiff();
|
|
847
|
+
for (const record of message.records) asked.added[record.id] = record;
|
|
848
|
+
reportRewrites(asked, actual);
|
|
849
|
+
flushCrdt();
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
268
852
|
awaitingSnapshot = false;
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
});
|
|
853
|
+
applyRemote(crdt.mergeRemote(stampedDiffFromSnapshot(message.records, message.state)));
|
|
854
|
+
flushCrdt();
|
|
272
855
|
return;
|
|
273
856
|
}
|
|
274
857
|
case "diff": {
|
|
275
858
|
if (message.clientId === clientId) return;
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
});
|
|
859
|
+
if (isStampedDiffEmpty(message.diff)) return;
|
|
860
|
+
applyRemote(crdt.mergeRemote(message.diff));
|
|
861
|
+
flushCrdt();
|
|
280
862
|
return;
|
|
281
863
|
}
|
|
282
864
|
case "presence": {
|
|
@@ -295,6 +877,7 @@ function createSyncClient(options) {
|
|
|
295
877
|
if (disposed || !connected) return;
|
|
296
878
|
status.set("online");
|
|
297
879
|
send({ type: "hello", clientId, version: PROTOCOL_VERSION });
|
|
880
|
+
if (!awaitingSnapshot) sendSnapshot();
|
|
298
881
|
presence?.poke();
|
|
299
882
|
};
|
|
300
883
|
const onClose = () => {
|
|
@@ -307,20 +890,11 @@ function createSyncClient(options) {
|
|
|
307
890
|
connect() {
|
|
308
891
|
if (disposed || connected) return;
|
|
309
892
|
connected = true;
|
|
310
|
-
awaitingSnapshot =
|
|
893
|
+
awaitingSnapshot = crdt.size() === 0;
|
|
311
894
|
status.set("connecting");
|
|
312
895
|
unsubscribes.push(transport.onMessage(handle));
|
|
313
896
|
if (transport.onOpen) unsubscribes.push(transport.onOpen(onOpen));
|
|
314
897
|
if (transport.onClose) unsubscribes.push(transport.onClose(onClose));
|
|
315
|
-
unsubscribes.push(
|
|
316
|
-
store.listen(
|
|
317
|
-
(entry) => {
|
|
318
|
-
if (isRecordsDiffEmpty(entry.changes)) return;
|
|
319
|
-
send({ type: "diff", clientId, seq: seq++, diff: entry.changes });
|
|
320
|
-
},
|
|
321
|
-
{ source: "user", scope: "document" }
|
|
322
|
-
)
|
|
323
|
-
);
|
|
324
898
|
if (options.presence) {
|
|
325
899
|
presence = createPresenceSync({
|
|
326
900
|
editor: options.presence.editor,
|
|
@@ -343,7 +917,6 @@ function createSyncClient(options) {
|
|
|
343
917
|
room.stopSweeping();
|
|
344
918
|
room.clear();
|
|
345
919
|
for (const off of unsubscribes.splice(0)) off();
|
|
346
|
-
awaitingSnapshot = false;
|
|
347
920
|
status.set("offline");
|
|
348
921
|
},
|
|
349
922
|
getStatus() {
|
|
@@ -353,6 +926,7 @@ function createSyncClient(options) {
|
|
|
353
926
|
if (disposed) return;
|
|
354
927
|
this.disconnect();
|
|
355
928
|
disposed = true;
|
|
929
|
+
stopListening();
|
|
356
930
|
transport.close();
|
|
357
931
|
}
|
|
358
932
|
};
|
|
@@ -576,7 +1150,8 @@ var CollaboratorCursor = track(function CollaboratorCursor2({
|
|
|
576
1150
|
presence,
|
|
577
1151
|
showName
|
|
578
1152
|
}) {
|
|
579
|
-
|
|
1153
|
+
if (presence.cursor === null) return null;
|
|
1154
|
+
const point = editor.pageToViewport(presence.cursor);
|
|
580
1155
|
const name = presence.userName || "Anonymous";
|
|
581
1156
|
return /* @__PURE__ */ jsxs("g", { transform: `translate(${point.x}, ${point.y}) rotate(${presence.cursor.rotation})`, children: [
|
|
582
1157
|
/* @__PURE__ */ jsx(
|
|
@@ -613,8 +1188,8 @@ var CollaboratorSelection = track(function CollaboratorSelection2({
|
|
|
613
1188
|
[bounds.x, bounds.maxY]
|
|
614
1189
|
];
|
|
615
1190
|
const points = corners.map(([x, y]) => {
|
|
616
|
-
const
|
|
617
|
-
return `${
|
|
1191
|
+
const p = editor.pageToViewport({ x: m.a * x + m.c * y + m.e, y: m.b * x + m.d * y + m.f });
|
|
1192
|
+
return `${p.x},${p.y}`;
|
|
618
1193
|
});
|
|
619
1194
|
outlines.push(points.join(" "));
|
|
620
1195
|
}
|
|
@@ -655,6 +1230,6 @@ function useSync(editor, options) {
|
|
|
655
1230
|
return { status, client };
|
|
656
1231
|
}
|
|
657
1232
|
|
|
658
|
-
export { CollaboratorCursors, DEFAULT_PRESENCE_HEARTBEAT_MS, DEFAULT_PRESENCE_THROTTLE_MS, DEFAULT_PRESENCE_TIMEOUT_MS, PROTOCOL_VERSION, PresenceRoom, createBroadcastChannelTransport, createMemoryHub, createMemoryTransportPair, createPresenceSync, createSyncClient, createWebSocketTransport, decodeMessage, encodeMessage, isSamePresence, presenceIdForClient, useSync };
|
|
1233
|
+
export { CRDT_STATE_VERSION, CollaboratorCursors, DEFAULT_PRESENCE_HEARTBEAT_MS, DEFAULT_PRESENCE_THROTTLE_MS, DEFAULT_PRESENCE_TIMEOUT_MS, DEFAULT_TOMBSTONE_LIMIT, DEFAULT_TOMBSTONE_MAX_AGE_MS, PROTOCOL_VERSION, PresenceRoom, compareStamps, createBroadcastChannelTransport, createCrdt, createEmptyCrdtState, createEmptyStampedDiff, createLamportClock, createMemoryHub, createMemoryTransportPair, createPresenceSync, createSyncClient, createWebSocketTransport, decodeMessage, encodeMessage, isSamePresence, isStampedDiffEmpty, presenceIdForClient, stampedDiffFromSnapshot, useSync };
|
|
659
1234
|
//# sourceMappingURL=index.js.map
|
|
660
1235
|
//# sourceMappingURL=index.js.map
|