@mocanvas/editor 4.8.3 → 4.8.6

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/SYNC.md ADDED
@@ -0,0 +1,490 @@
1
+ # Multiplayer
2
+
3
+ How two or more people end up on one document with `@mocanvas/sync`. By the end
4
+ you will know which records travel and which stay put, how to hand the client a
5
+ channel (including one that joins two browser tabs with no server at all), what
6
+ presence carries and how often, and exactly what the merge does and does not
7
+ promise when two edits race.
8
+
9
+ Everything here is public API from `@mocanvas/sync`, `@mocanvas/editor` and
10
+ `@mocanvas/store`. [ARCHITECTURE.md](ARCHITECTURE.md) explains the store and the
11
+ rendering split underneath it; [CUSTOM_SHAPES.md](CUSTOM_SHAPES.md) covers
12
+ writing the records this document is made of.
13
+
14
+ Contents:
15
+
16
+ 1. [What travels, and what does not](#1-what-travels-and-what-does-not)
17
+ 2. [Wiring a client to a transport](#2-wiring-a-client-to-a-transport)
18
+ 3. [Presence: cursors, camera, selection](#3-presence-cursors-camera-selection)
19
+ 4. [What the merge guarantees](#4-what-the-merge-guarantees)
20
+ 5. [What a server has to do](#5-what-a-server-has-to-do)
21
+ 6. [Things that bite](#6-things-that-bite)
22
+
23
+ ---
24
+
25
+ ## 1. What travels, and what does not
26
+
27
+ Every record type in the store declares a **scope**, one of three
28
+ (`RecordScope` in `@mocanvas/store`):
29
+
30
+ | Scope | Record types | Persisted to `.tldr` | Sent to peers |
31
+ | --- | --- | --- | --- |
32
+ | `document` | `document`, `page`, `shape`, `binding`, `asset`, comments | yes | yes, as stamped diffs |
33
+ | `session` | `camera`, `instance`, `instance_page_state`, `pointer` | no | no |
34
+ | `presence` | `instance_presence`, `user` | no | yes, as whole records |
35
+
36
+ A custom record declares its own scope (`CustomRecordInfo.scope`), so it travels
37
+ or does not by the same rule.
38
+
39
+ The sync client only ever listens to one scope:
40
+
41
+ ```ts
42
+ store.listen(handler, { source: "user", scope: "document" })
43
+ ```
44
+
45
+ That single filter does two jobs. `scope: "document"` keeps your camera, your
46
+ selection highlight and your hover state off the wire entirely — they are
47
+ `session` records, and nobody else's editor should be moved by your scroll
48
+ wheel. `source: "user"` keeps changes you applied *from* a peer from going
49
+ straight back out again; see §6. The snapshot a joiner receives is
50
+ `store.serialize("document")`, so it carries the same set.
51
+
52
+ ### Why a cursor is not in the document
53
+
54
+ A cursor moves about thirty times a second. Put it in a `document` record and
55
+ every one of those moves becomes a history entry you can undo, a field the CRDT
56
+ has to stamp, a byte in the saved file, and a tombstone when the person leaves.
57
+ So presence is a record type of its own, `instance_presence`, in the `presence`
58
+ scope: never written to a `.tldr` file, never in the undo stack (the editor's
59
+ history manager listens with `{ source: "user", scope: "document" }` too), never
60
+ stamped or merged. It travels as a `presence` message holding one whole record,
61
+ the newest replaces the last by id, and a peer unheard from for ten seconds is
62
+ dropped.
63
+
64
+ `session` records sit at the other extreme: yours, unsaved, unshared. A camera
65
+ is the clearest case — sharing it would mean two people fighting over one
66
+ viewport. What you want instead is *following*, which is a `presence` field
67
+ (`followingUserId`) plus `editor.startFollowingUser(userId)`, not a shared
68
+ camera record.
69
+
70
+ ---
71
+
72
+ ## 2. Wiring a client to a transport
73
+
74
+ ### The contract
75
+
76
+ A transport is a duplex channel carrying `SyncMessage`s for one room. It owns
77
+ serialisation and reconnection; the client above it only sees decoded messages.
78
+
79
+ ```ts
80
+ interface Transport<R extends UnknownRecord = UnknownRecord> {
81
+ send(message: SyncMessage<R>): void
82
+ onMessage(callback: (message: SyncMessage<R>) => void): () => void
83
+ onOpen?(callback: () => void): () => void
84
+ onClose?(callback: () => void): () => void
85
+ close(): void
86
+ }
87
+ ```
88
+
89
+ `onOpen` fires every time the channel becomes usable, including after a
90
+ reconnect; `onClose` every time it stops being. Both are optional, and a
91
+ transport with neither is treated as open the moment it is connected. Anything
92
+ satisfying this interface works — a WebSocket, a `BroadcastChannel`, a WebRTC
93
+ data channel, a shared worker, a mock in a test.
94
+
95
+ There are five messages, all JSON: `hello` (I joined), `snapshot` (the document
96
+ records plus the sender's stamps), `diff` (one squashed store diff, stamped
97
+ field by field), `presence`, `bye`. `encodeMessage` stamps
98
+ `version: PROTOCOL_VERSION` (3) onto every one and `decodeMessage` checks it,
99
+ returning `null` for anything malformed and a synthetic
100
+ `{ type: "unsupported", version }` for a peer on another protocol — reported
101
+ through `onError` rather than half-parsed.
102
+
103
+ ### The client
104
+
105
+ ```ts
106
+ import { createSyncClient, createBroadcastChannelTransport } from "@mocanvas/sync"
107
+ import type { EditorRecord } from "@mocanvas/editor"
108
+
109
+ const client = createSyncClient<EditorRecord>({
110
+ store: editor.store,
111
+ roomId: "my-doc",
112
+ transport: createBroadcastChannelTransport<EditorRecord>("my-doc"),
113
+ presence: { editor },
114
+ })
115
+
116
+ client.connect()
117
+ ```
118
+
119
+ `client.getStatus()` returns a signal of `"offline" | "connecting" | "online"`,
120
+ readable from a reactor or with `useValue`. `disconnect()` sends `bye` and stops
121
+ listening; `dispose()` disconnects and closes the transport.
122
+
123
+ In React, `useSync` does the whole lifecycle for as long as the component is
124
+ mounted:
125
+
126
+ ```tsx
127
+ import { CollaboratorCursors, createBroadcastChannelTransport, useSync } from "@mocanvas/sync"
128
+
129
+ const { status } = useSync(editor, {
130
+ roomId,
131
+ transport: () => createBroadcastChannelTransport(roomId),
132
+ })
133
+
134
+ // inside <Mocanvas onMount={setEditor}>:
135
+ {editor ? <CollaboratorCursors editor={editor} /> : null}
136
+ ```
137
+
138
+ Pass a **factory**, not a transport: it is called once per connection, so a
139
+ transport is never reused across a reconnect or a room change. `enabled: false`
140
+ keeps the editor offline (a read-only view, say).
141
+
142
+ ### A BroadcastChannel, with no server at all
143
+
144
+ `createBroadcastChannelTransport(roomId, { prefix })` is shipped, and it is the
145
+ whole of the multiplayer demo on the website: two tabs of one browser, one
146
+ origin, nothing deployed and nothing to keep running. Written out, it is small
147
+ enough to read:
148
+
149
+ ```ts
150
+ import { decodeMessage, encodeMessage, type SyncMessage, type Transport } from "@mocanvas/sync"
151
+ import type { EditorRecord } from "@mocanvas/editor"
152
+
153
+ export function createTabTransport(roomId: string): Transport<EditorRecord> {
154
+ const channel = new BroadcastChannel(`my-app:${roomId}`)
155
+ const listeners = new Set<(message: SyncMessage<EditorRecord>) => void>()
156
+ let closed = false
157
+
158
+ channel.onmessage = (event: MessageEvent) => {
159
+ const message = decodeMessage<EditorRecord>(event.data)
160
+ if (!message) return
161
+ for (const listener of Array.from(listeners)) listener(message)
162
+ }
163
+
164
+ return {
165
+ send(message) {
166
+ if (!closed) channel.postMessage(encodeMessage(message))
167
+ },
168
+ onMessage(callback) {
169
+ listeners.add(callback)
170
+ return () => void listeners.delete(callback)
171
+ },
172
+ onOpen(callback) {
173
+ // Usable already — but hand control back first, so that connect() has
174
+ // finished subscribing before the hello goes out.
175
+ let cancelled = false
176
+ queueMicrotask(() => {
177
+ if (!cancelled && !closed) callback()
178
+ })
179
+ return () => void (cancelled = true)
180
+ },
181
+ close() {
182
+ closed = true
183
+ listeners.clear()
184
+ channel.onmessage = null
185
+ channel.close()
186
+ },
187
+ }
188
+ }
189
+ ```
190
+
191
+ Two details are doing real work. The `queueMicrotask` in `onOpen` is not
192
+ decoration: `connect()` subscribes to messages and *then* to opens, and a
193
+ callback that fires synchronously would send `hello` before anything was
194
+ listening for the answer. And `BroadcastChannel` never delivers to the context
195
+ that posted, which is why there is no self-filter here — over a relay that
196
+ echoes, the client's own `clientId` check on `diff` and `presence` messages is
197
+ what saves you.
198
+
199
+ Two tabs joining the same room also need to be looking at the same page. A blank
200
+ document's first page has a fixed id (`DEFAULT_PAGE_ID`), so two editors that
201
+ each built their own store meet there by default. If one of them pins or loads a
202
+ page of its own, everything reports `online`, every message arrives, and nothing
203
+ is drawn — so the client says so once, in development, rather than leaving you
204
+ hunting a transport bug that is not there.
205
+
206
+ Tabs are *not* deduplicated by person: presence is published under
207
+ `editor.getInstancePresenceId()`, which is per editor instance, so two tabs of
208
+ one browser are two collaborators and see each other. You do not need to invent
209
+ a fake per-tab identity.
210
+
211
+ ---
212
+
213
+ ## 3. Presence: cursors, camera, selection
214
+
215
+ `presence: { editor }` turns on a `PresenceSync`, which builds one
216
+ `InstancePresence` record from the editor and pushes it out. It carries:
217
+
218
+ `cursor` from `editor.inputs.currentPagePoint` and the cursor type and rotation
219
+ on `getInstanceState()`; `camera` from `editor.getCamera()`; `selectedShapeIds`,
220
+ `currentPageId`, `brush`, `scribbles` and `followingUserId` from the editor's
221
+ own getters; `userId`, `userName` and `color` from `editor.user`; and
222
+ `lastActivityTimestamp`, which is `Date.now()` on the sender.
223
+
224
+ The cursor point is in page space; `CollaboratorCursors` converts it with
225
+ `editor.pageToViewport` when it draws. The camera is there so that somebody can
226
+ *follow* you, not so that your scrolling moves their viewport.
227
+
228
+ ### The rate
229
+
230
+ | Constant | Default | Meaning |
231
+ | --- | --- | --- |
232
+ | `DEFAULT_PRESENCE_THROTTLE_MS` | 34 ms (~30 Hz) | upper bound on the send rate |
233
+ | `DEFAULT_PRESENCE_HEARTBEAT_MS` | 3000 ms | re-send even when nothing changed |
234
+ | `DEFAULT_PRESENCE_TIMEOUT_MS` | 10 000 ms | drop a peer we have not heard from |
235
+
236
+ Camera, page, selection and instance state are signals, so a reactor covers
237
+ them; the pointer is not, so the editor's `event` is sampled as well. Both only
238
+ *ask* for a send. The request is collapsed into the throttle window, and when it
239
+ fires the record is compared with the last one sent (`isSamePresence`, which
240
+ ignores `lastActivityTimestamp`): unchanged means nothing goes out. So a still
241
+ pointer costs one message every three seconds, not thirty a second.
242
+
243
+ Three things force a send regardless: starting, the heartbeat, and a `hello`
244
+ from a newcomer. That last one is a re-announcement — what changed is who is
245
+ listening, not the record — and without it somebody who has been sitting still
246
+ stays invisible to a joiner until the next heartbeat.
247
+
248
+ Override any of it per client: `presenceThrottleMs`, `presenceHeartbeatMs`,
249
+ `presenceTimeoutMs` on `createSyncClient` (`useSync` exposes the throttle and the
250
+ timeout).
251
+
252
+ ### Reading the room
253
+
254
+ ```ts
255
+ editor.getCollaborators() // everyone else, however stale
256
+ editor.getCollaboratorsOnCurrentPage() // …narrowed to this page
257
+ editor.collaborators.getVisibleCollaboratorsOnCurrentPage() // …and actually here
258
+ editor.collaborators.isCollaboratorIdle(presence) // present, but not doing anything
259
+ ```
260
+
261
+ Draw from the *visible* list: a tab left open overnight keeps writing presence
262
+ long after the person went home. `collaboratorInactiveTimeoutMs` (60 s) decides
263
+ who is still there, `collaboratorIdleTimeoutMs` (3 s) who is merely idle —
264
+ dimmed, not removed. The flip side is that a long-running participant which is
265
+ not a human, such as an agent working through a tool call, must keep its
266
+ `lastActivityTimestamp` fresh or it will vanish mid-turn.
267
+
268
+ ---
269
+
270
+ ## 4. What the merge guarantees
271
+
272
+ The merge lives in `packages/sync/src/crdt.ts`; this is what it actually does.
273
+
274
+ **Every leaf field of every record is its own last-writer-wins register**, keyed
275
+ by a dotted path: `x`, `props.w`, `meta.notes.title`. A local change carries one
276
+ Lamport stamp, `{ lamport, client }`. A field is overwritten only when the
277
+ incoming stamp is greater than the one held for that exact path, and equal
278
+ Lamport values are broken by comparing client ids — so the order is total and
279
+ both sides of a race reach the same answer.
280
+
281
+ A stamp covers a **subtree**, not just a leaf. Writing `meta` — setting it, or
282
+ deleting the key — dominates `meta.a`, and a write that drops a subtree keeps
283
+ descendants stamped later than itself. Treating `meta` and `meta.a` as unrelated
284
+ registers is exactly how "delete the key" racing "edit its child" lands
285
+ differently on each replica.
286
+
287
+ That gives two properties, and they are the whole guarantee:
288
+
289
+ - **Idempotent** — the same message twice changes nothing the second time.
290
+ - **Commutative** — per path the result is the maximum stamp, and a maximum does
291
+ not care about arrival order.
292
+
293
+ Together: replicas converge however the transport reorders, duplicates or drops
294
+ messages, as long as everything is eventually delivered. `src/fuzz.test.ts` is
295
+ the evidence — random schedules of creates, nested edits, deletes, re-creates,
296
+ reordering, duplication, drops, disconnects, reconnects and snapshot joins over
297
+ an in-memory network, then a demand that every replica's document be identical
298
+ under a key-sorted serialisation.
299
+
300
+ In practice: two people editing **different fields of one shape** — one moves
301
+ it, one recolours it — keep both edits; two people editing the **same** field
302
+ pick the same winner everywhere; **creating** the same record id on two peers
303
+ merges field by field, like an edit. **Deleting** leaves a stamped tombstone, so
304
+ a late message cannot resurrect the record — a delete beats a concurrent edit
305
+ only when its stamp is greater, and an edit that outranks the delete keeps the
306
+ record alive, re-claimed whole under a fresh stamp so that every replica agrees
307
+ on which body survived. Work done while **disconnected** survives: local changes
308
+ are stamped by the store listener, which is wired from construction until
309
+ `dispose()`, so an edit made before `connect()` or after `disconnect()` is
310
+ stamped when it happens and merged on the next connection.
311
+
312
+ ### What it does not cover
313
+
314
+ Field-level and order-independent is a real guarantee, and it is not the same as
315
+ "nothing is ever lost". These limits are structural, not bugs waiting on a fix:
316
+
317
+ - **Two writes to one field cannot both survive.** The register holds one value;
318
+ the greater stamp takes it and the other is gone.
319
+ - **Arrays are opaque.** `props.points`, `selectedShapeIds` and every other array
320
+ is *one* register holding the whole array. Two concurrent edits to one array
321
+ pick a winner rather than merging. Per-element registers would need element
322
+ identity, which record data does not carry.
323
+ - **The fractional `index` is one register too**, so a concurrent reorder of the
324
+ same shape lands on one of the two orders deterministically. Reordering
325
+ *different* shapes is fine — different records.
326
+ - **No text merge.** Two people typing into one text field pick a winner rather
327
+ than interleaving characters. There is no operational transform and no
328
+ character-level CRDT here.
329
+ - **Tombstones are bounded**: one hour, 5000 of them, oldest first
330
+ (`tombstoneMaxAgeMs`, `tombstoneLimit`). That bound is a statement about how
331
+ long a peer may be partitioned. Within it, deletes are safe against
332
+ arbitrarily late messages; past it, a straggler's edit resurrects the record.
333
+ - **A replica that has never connected starts its clock at zero.** Its offline
334
+ edits are real writes and its own new records always survive, but an edit to a
335
+ field the room has written several times can sort below what is already there.
336
+ One that has been in the room and comes back has observed the room's clock and
337
+ does not have this problem.
338
+ - **A body that arrives without its stamps can be stale.** A put claims stamps
339
+ only for the paths it touches, so a replica that missed a record's creation
340
+ and then sees an edit to one field adopts the rest of that body without
341
+ provenance. The next snapshot exchange repairs it, and every `hello`,
342
+ `connect` and reconnect triggers one.
343
+
344
+ ---
345
+
346
+ ## 5. What a server has to do
347
+
348
+ Two tabs need no server. More than two, or two on different machines, need
349
+ something in the middle — and the minimum is genuinely small.
350
+
351
+ ### Relay diffs
352
+
353
+ The relay forwards every message it receives on a socket to the other sockets in
354
+ the same room, verbatim. It keeps no document state at all: peers hand each
355
+ other the document with the `hello`/`snapshot` exchange, so restarting the relay
356
+ is harmless. `packages/sync/scripts/relay.mjs` is that, in about fifty lines; the
357
+ room is the URL path.
358
+
359
+ ```sh
360
+ pnpm --filter @mocanvas/sync relay # ws://localhost:5858/<room>
361
+ ```
362
+
363
+ ```ts
364
+ createWebSocketTransport(`ws://localhost:5858/${roomId}`, { reconnect: true })
365
+ ```
366
+
367
+ `createWebSocketTransport` reconnects with exponential backoff and full jitter,
368
+ so a relay restart does not get a thundering herd from every open tab. Messages
369
+ sent while the socket is down are dropped rather than queued — the next
370
+ `hello`/`snapshot` re-establishes the document anyway, and presence is re-sent
371
+ on the heartbeat. The script ships in the package but `ws` does not: it is a
372
+ devDependency, because the relay is a development tool rather than part of the
373
+ client. Install `ws` yourself to run it elsewhere.
374
+
375
+ ### Keep the authoritative document
376
+
377
+ A pure relay means the document lives only in the connected peers. When the last
378
+ one leaves, nothing holds it. If you want a copy that outlives the room, the
379
+ cheapest correct answer is to run **an ordinary peer on the server**: a store, a
380
+ sync client, and no presence.
381
+
382
+ ```ts
383
+ import { createStore, type EditorRecord } from "@mocanvas/editor"
384
+ import { createSyncClient } from "@mocanvas/sync"
385
+
386
+ const store = createStore()
387
+ createSyncClient<EditorRecord>({ store, roomId, transport }).connect()
388
+
389
+ const snapshot = store.getStoreSnapshot("document") // persist this
390
+ ```
391
+
392
+ It merges like everyone else, and `store.loadStoreSnapshot(snapshot)` brings it
393
+ back after a restart. Nothing on the wire privileges it: "authoritative" here
394
+ means "the peer that is always in the room", not a different role.
395
+
396
+ ### Where `mergeRemoteChanges` fits
397
+
398
+ Inside the client, and you normally never call it. When a `diff` arrives, the
399
+ client asks the CRDT which fields won and applies only those:
400
+
401
+ ```ts
402
+ store.mergeRemoteChanges(() => store.applyDiff(winningFields))
403
+ ```
404
+
405
+ `mergeRemoteChanges` marks everything done inside it `source: "remote"`, which
406
+ is what stops the applied change from entering the local undo stack and from
407
+ being picked up by the outgoing listener and broadcast straight back.
408
+
409
+ If you are writing your own merging peer rather than using `createSyncClient` —
410
+ a server that wants the records without the client's lifecycle, say — that pair
411
+ is the whole shape of it: `createCrdt({ clientId, getRecord })`, then
412
+ `crdt.mergeRemote(diff)` for the winning fields, then
413
+ `store.mergeRemoteChanges(() => store.applyDiff(...))` to apply them. Anything
414
+ you write to the store *outside* `mergeRemoteChanges` counts as a local user
415
+ edit and will be stamped and broadcast.
416
+
417
+ ---
418
+
419
+ ## 6. Things that bite
420
+
421
+ ### Echoing your own changes back
422
+
423
+ Two guards stop the obvious loop. Outgoing changes come from
424
+ `store.listen(cb, { source: "user", scope: "document" })`, and incoming ones are
425
+ applied inside `mergeRemoteChanges`, which reports them as `remote` — so they
426
+ never reach that listener. On top of that the client ignores `diff` and
427
+ `presence` messages carrying its own `clientId`, which matters on any transport
428
+ that echoes to the sender.
429
+
430
+ You meet the loop by writing to the store yourself on behalf of a peer —
431
+ restoring from your own persistence layer, or applying a message from a channel
432
+ of your own. Wrap it:
433
+
434
+ ```ts
435
+ store.mergeRemoteChanges(() => store.applyDiff(diff))
436
+ ```
437
+
438
+ Without the wrapper the change goes out as though you had drawn it, the peer
439
+ merges it and rewrites, and you have a round trip per edit.
440
+
441
+ ### Undo across clients
442
+
443
+ Undo is local, and it is not a retraction. The history manager listens with
444
+ `{ source: "user", scope: "document" }`, so nothing a peer did ever enters your
445
+ stack — you cannot undo somebody else's work, which is what you want.
446
+
447
+ What surprises people is the other direction. `editor.undo()` applies the
448
+ reverse diff as an ordinary local change, so it is stamped with a *new*, greater
449
+ stamp and broadcast like any edit. Undo therefore means "write the old value
450
+ back, now", not "remove my edit from history everywhere". So undoing a move
451
+ after a peer has moved the same shape wins, because your stamp is newer — that
452
+ is a later write, not a merge conflict; and undoing a *create* deletes the
453
+ record for everyone, unless a peer's edit to it outranks the tombstone.
454
+
455
+ ### Applying a remote change inside a side effect
456
+
457
+ Every side-effect handler is given the change source as its last argument:
458
+
459
+ ```ts
460
+ editor.sideEffects.registerAfterChangeHandler("shape", (prev, next, source) => {
461
+ if (source !== "user") return
462
+ // …runs once, on the replica where the person actually did it
463
+ })
464
+ ```
465
+
466
+ A handler that does not check `source` runs on every replica, for every merged
467
+ change. Sometimes that is right — a validator clamping a value should clamp
468
+ everywhere. Sometimes it is a duplicated side effect, a second network call, or
469
+ a second record created per peer.
470
+
471
+ The client copes with the honest case. It compares what it asked the store to do
472
+ with what the store actually did, and any difference — a validator rewriting a
473
+ merged record, a side effect touching another one — is stamped as a local write
474
+ and broadcast; otherwise that rewrite would exist on one replica only, since
475
+ changes made inside `mergeRemoteChanges` never leave. A rewrite that is a
476
+ function of the record and the document then settles in one round: the peers
477
+ apply a value they already hold and produce nothing further.
478
+
479
+ A side effect that is **not** a function of its inputs — one that reads the wall
480
+ clock, a random number, or per-user state — has no fixed point. It rewrites
481
+ differently on each replica, each rewrite is broadcast, and the replicas chase
482
+ each other. No design converges on that. Keep non-deterministic work out of side
483
+ effects on `document`-scope records, or gate it on `source === "user"`.
484
+
485
+ ### A peer on another protocol version
486
+
487
+ A version 2 peer merges the same messages to a different answer, so the two must
488
+ not share a room. Such a message is reported once through `onError`; surface it
489
+ rather than swallowing it, because the symptom otherwise is a room where
490
+ everything looks connected and nothing arrives.