@adonis-agora/collaboration 0.19.0 → 0.19.2
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/CHANGELOG.md +18 -0
- package/dist/src/drivers/automerge/automerge_driver.d.ts +51 -2
- package/dist/src/drivers/automerge/automerge_driver.d.ts.map +1 -1
- package/dist/src/drivers/automerge/automerge_driver.js +250 -87
- package/dist/src/drivers/automerge/automerge_driver.js.map +1 -1
- package/dist/src/drivers/lazy_deps.d.ts +10 -0
- package/dist/src/drivers/lazy_deps.d.ts.map +1 -0
- package/dist/src/drivers/lazy_deps.js +28 -0
- package/dist/src/drivers/lazy_deps.js.map +1 -0
- package/dist/src/drivers/partykit/partykit_driver.d.ts.map +1 -1
- package/dist/src/drivers/partykit/partykit_driver.js +2 -1
- package/dist/src/drivers/partykit/partykit_driver.js.map +1 -1
- package/dist/src/drivers/yjs/yjs_driver.d.ts +13 -1
- package/dist/src/drivers/yjs/yjs_driver.d.ts.map +1 -1
- package/dist/src/drivers/yjs/yjs_driver.js +63 -15
- package/dist/src/drivers/yjs/yjs_driver.js.map +1 -1
- package/dist/src/services/presence_service.d.ts +18 -3
- package/dist/src/services/presence_service.d.ts.map +1 -1
- package/dist/src/services/presence_service.js +104 -11
- package/dist/src/services/presence_service.js.map +1 -1
- package/package.json +5 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @adonis-agora/collaboration
|
|
2
2
|
|
|
3
|
+
## 0.19.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#138](https://github.com/DavideCarvalho/adonis-agora-collaboration/pull/138) [`894997d`](https://github.com/DavideCarvalho/adonis-agora-collaboration/commit/894997d002cd20a9d9fd19107812005c77eb24a1) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Load heavy engine dependencies on first use instead of at import time. `@automerge/automerge` now loads when an automerge driver first opens a document, `@hocuspocus/transformer` (Tiptap + ProseMirror) when a document's text or a diff is first computed, and `@hocuspocus/server` when the yjs driver first attaches to the HTTP server. Importing the package root or its provider drops from ~10.5 MB to ~3.9 MB of heap (and ~100 ms of module evaluation) in every process that never opens a document — ace commands, queue workers, and web pods until the first collaboration request. No API change.
|
|
8
|
+
|
|
9
|
+
## 0.19.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [#135](https://github.com/DavideCarvalho/adonis-agora-collaboration/pull/135) [`718d1cc`](https://github.com/DavideCarvalho/adonis-agora-collaboration/commit/718d1cce9bfd30f2c1f3bc77603a9bd6d230915e) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Automerge: clients and server exchange change chunks instead of whole documents. `useAutomergeDoc` asks for them with `?sync=changes`; the server acknowledges with a text frame and from then on both sides send only the changes the other lacks. Sockets that do not ask (older clients) keep sending and receiving whole documents, and a new client against an older server keeps sending whole documents, so mixed versions keep working in the same room. The server applies both shapes with `A.loadIncremental` and never forwards a payload that adds nothing.
|
|
14
|
+
|
|
15
|
+
Automerge rooms are now saved and dropped from memory when their last client leaves, `close()` writes pending saves instead of cancelling them (and no longer hangs while sockets are open), and a document edited without pause is saved at least every 10 seconds.
|
|
16
|
+
|
|
17
|
+
A socket whose presence join fails, or that closes while the join is in flight, is now taken out of its room (which can then be dropped) and its possibly half-written roster entry is removed, unless the user still has another tab on the document.
|
|
18
|
+
|
|
19
|
+
- [#135](https://github.com/DavideCarvalho/adonis-agora-collaboration/pull/135) [`718d1cc`](https://github.com/DavideCarvalho/adonis-agora-collaboration/commit/718d1cce9bfd30f2c1f3bc77603a9bd6d230915e) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Presence: each roster entry now expires on its own 60-second TTL, and every live instance re-stamps the members it holds every 20 seconds, so the users of a crashed instance drop out even on a document other instances keep busy. The stored member format is unchanged. The Yjs driver no longer scans every open socket on each disconnect, and the Automerge driver no longer scans the room's sockets when one closes: both keep a socket count per user per document.
|
|
20
|
+
|
|
3
21
|
## 0.19.0
|
|
4
22
|
|
|
5
23
|
### Minor Changes
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import type { CollaborationDriver, SelfHostedDriverOptions } from '../../driver.js';
|
|
2
2
|
import type { CollabDiffSummary, CollabVersion, ListPageOptions } from '../../types.js';
|
|
3
|
+
/** Text frame confirming that this socket exchanges change chunks. */
|
|
4
|
+
export declare const CHANGES_ACK = "automerge-sync:changes";
|
|
3
5
|
export declare class AutomergeDriver implements CollaborationDriver {
|
|
4
6
|
readonly engine = "automerge";
|
|
5
7
|
private rooms;
|
|
6
8
|
private wss?;
|
|
7
9
|
private readonly options;
|
|
8
10
|
private readonly storage;
|
|
9
|
-
private
|
|
11
|
+
private pendingSaves;
|
|
12
|
+
/** Saves being written, by document name; a room is not dropped under one. */
|
|
13
|
+
private savesInFlight;
|
|
10
14
|
/**
|
|
11
15
|
* Rooms being opened, by document name.
|
|
12
16
|
*
|
|
@@ -17,6 +21,14 @@ export declare class AutomergeDriver implements CollaborationDriver {
|
|
|
17
21
|
* duplicated content, so the window has to close.
|
|
18
22
|
*/
|
|
19
23
|
private roomLoads;
|
|
24
|
+
/**
|
|
25
|
+
* `@automerge/automerge`, loaded by the first call that opens a room or reads
|
|
26
|
+
* a version (see `lazy_deps.ts`). Every synchronous use runs on a room, and a
|
|
27
|
+
* room only exists after `openRoom` awaited the module, so it is set there.
|
|
28
|
+
*/
|
|
29
|
+
private am;
|
|
30
|
+
/** Loads Automerge (once per process) and makes it available as `this.am`. */
|
|
31
|
+
private automerge;
|
|
20
32
|
constructor(options: SelfHostedDriverOptions);
|
|
21
33
|
private loadRoom;
|
|
22
34
|
private openRoom;
|
|
@@ -32,10 +44,46 @@ export declare class AutomergeDriver implements CollaborationDriver {
|
|
|
32
44
|
* than a document worth guessing at.
|
|
33
45
|
*/
|
|
34
46
|
private seedDocument;
|
|
47
|
+
/**
|
|
48
|
+
* Sends everything the room gained since `since` to every client but the
|
|
49
|
+
* sender: the change chunk to sockets that speak chunks, a whole document
|
|
50
|
+
* (`wholeDoc`, else `A.save` of the room) to the rest. Each shape is
|
|
51
|
+
* encoded at most once per call.
|
|
52
|
+
*/
|
|
35
53
|
private broadcast;
|
|
54
|
+
/** Runs `fn` on the room, dropping it afterwards if nobody else uses it. */
|
|
55
|
+
private withRoom;
|
|
56
|
+
/**
|
|
57
|
+
* Writes the room's pending save and drops it from memory once nothing
|
|
58
|
+
* uses it: no client, no driver call, no save waiting or being written.
|
|
59
|
+
* A client that joins meanwhile finds the same room in the map.
|
|
60
|
+
*/
|
|
61
|
+
private releaseRoom;
|
|
36
62
|
attach(server: import('node:http').Server): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Takes `ws` out of the room — and, when `userId` is given, out of that
|
|
65
|
+
* user's socket count, leaving the roster with their last one. Safe to call
|
|
66
|
+
* twice for one socket.
|
|
67
|
+
*/
|
|
68
|
+
private removeSocket;
|
|
69
|
+
/** Removes `userId` from the roster unless another of their sockets is still in the room. */
|
|
70
|
+
private leaveIfLast;
|
|
71
|
+
/**
|
|
72
|
+
* Applies a client's whole document or change chunk and forwards only what
|
|
73
|
+
* was new to the room. A payload that adds nothing — a duplicate, or bytes
|
|
74
|
+
* that are not Automerge at all — is neither forwarded nor saved.
|
|
75
|
+
*/
|
|
37
76
|
private handleClientMessage;
|
|
38
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Debounced save: each edit postpones it by `debounce`, but never past
|
|
79
|
+
* {@link MAX_PERSIST_WAIT_MS} (or `debounce`, if longer) after the first
|
|
80
|
+
* unsaved edit, so a document edited without pause is still written.
|
|
81
|
+
*/
|
|
82
|
+
private schedulePersist;
|
|
83
|
+
/** Writes the pending save of `docName` now, if there is one. */
|
|
84
|
+
private persistNow;
|
|
85
|
+
/** Saves `state`, tracked so the room is not dropped while it is written. */
|
|
86
|
+
private save;
|
|
39
87
|
getDocumentState(docName: string): Promise<Uint8Array>;
|
|
40
88
|
getDocumentText(docName: string): Promise<string>;
|
|
41
89
|
/** Versions = markers over the native history (heads hash). */
|
|
@@ -48,6 +96,7 @@ export declare class AutomergeDriver implements CollaborationDriver {
|
|
|
48
96
|
* that leaves no entry in it is a restore nobody can undo.
|
|
49
97
|
*/
|
|
50
98
|
restoreVersion(docName: string, versionId: string, restoredBy: string | null): Promise<void>;
|
|
99
|
+
private restoreRoom;
|
|
51
100
|
/** Binary state stored for a version (`A.save` of the doc at that point). */
|
|
52
101
|
getVersionState(docName: string, versionId: string): Promise<Uint8Array>;
|
|
53
102
|
diffVersions(docName: string, aId: string, bId: string): Promise<CollabDiffSummary>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"automerge_driver.d.ts","sourceRoot":"","sources":["../../../../src/drivers/automerge/automerge_driver.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"automerge_driver.d.ts","sourceRoot":"","sources":["../../../../src/drivers/automerge/automerge_driver.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,MAAM,iBAAiB,CAAC;AAEpF,OAAO,KAAK,EACV,iBAAiB,EAGjB,aAAa,EACb,eAAe,EAChB,MAAM,gBAAgB,CAAC;AA4BxB,sEAAsE;AACtE,eAAO,MAAM,WAAW,2BAA2B,CAAC;AAgCpD,qBAAa,eAAgB,YAAW,mBAAmB;IACzD,QAAQ,CAAC,MAAM,eAAe;IAE9B,OAAO,CAAC,KAAK,CAA2B;IACxC,OAAO,CAAC,GAAG,CAAC,CAAkB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAClD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuB;IAC/C,OAAO,CAAC,YAAY,CAAkC;IACtD,8EAA8E;IAC9E,OAAO,CAAC,aAAa,CAAoC;IACzD;;;;;;;;OAQG;IACH,OAAO,CAAC,SAAS,CAAoC;IAErD;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAmB;IAE7B,8EAA8E;YAChE,SAAS;IAKvB,YAAY,OAAO,EAAE,uBAAuB,EAG3C;IAED,OAAO,CAAC,QAAQ;YAYF,QAAQ;IAiBtB;;;;;;;;;;OAUG;YAEW,YAAY;IA0C1B;;;;;OAKG;IACH,OAAO,CAAC,SAAS;IAoBjB,4EAA4E;YAC9D,QAAQ;IAWtB;;;;OAIG;YACW,WAAW;IAQnB,MAAM,CAAC,MAAM,EAAE,OAAO,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAsH9D;IAED;;;;OAIG;IACH,OAAO,CAAC,YAAY;IAkBpB,6FAA6F;IAC7F,OAAO,CAAC,WAAW;IAOnB;;;;OAIG;IACH,OAAO,CAAC,mBAAmB;IAoB3B;;;;OAIG;IACH,OAAO,CAAC,eAAe;IAcvB,iEAAiE;YACnD,UAAU;IAQxB,6EAA6E;YAC/D,IAAI;IAmBZ,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAE3D;IAEK,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAKtD;IAED,+DAA+D;IACzD,aAAa,CACjB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,KAAK,EAAE,MAAM,GAAG,IAAI,GACnB,OAAO,CAAC,aAAa,CAAC,CAexB;IAEK,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAEpF;IAED;;;;;OAKG;IACG,cAAc,CAClB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GAAG,IAAI,GACxB,OAAO,CAAC,IAAI,CAAC,CAOf;YAEa,WAAW;IA0CzB,6EAA6E;IACvE,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAI7E;IAEK,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAaxF;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAa3B;CACF"}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import * as A from '@automerge/automerge';
|
|
2
|
-
import { next as ANext } from '@automerge/automerge';
|
|
3
1
|
import { WebSocketServer } from 'ws';
|
|
4
2
|
import { verifySelfHostedToken } from '../../auth/token.js';
|
|
5
3
|
import { reportCollaborationError, reportCollaborationWarning } from '../../observability.js';
|
|
6
4
|
import { createVersionMetadata, restoredFromLabel, seqVersions } from '../../versioning.js';
|
|
5
|
+
import { loadAutomerge } from '../lazy_deps.js';
|
|
7
6
|
import { lineDiff, resolveStorage } from '../shared.js';
|
|
8
7
|
/**
|
|
9
8
|
* Automerge driver.
|
|
@@ -14,22 +13,39 @@ import { lineDiff, resolveStorage } from '../shared.js';
|
|
|
14
13
|
* between versions is already in the doc. `restoreVersion` brings the doc
|
|
15
14
|
* back to the version's heads via a compensating change (non-destructive).
|
|
16
15
|
*
|
|
17
|
-
* Transport:
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* Transport: one in-memory doc per room and a per-room broadcast. Every
|
|
17
|
+
* client gets the whole `A.save(doc)` on connect. After that, a socket opened
|
|
18
|
+
* with `?sync=changes` is answered with the {@link CHANGES_ACK} text frame and
|
|
19
|
+
* from then on exchanges change chunks (`A.saveSince`) both ways; any other
|
|
20
|
+
* socket sends and receives whole saved documents. The server applies both
|
|
21
|
+
* shapes with `A.loadIncremental`, so one room can hold both kinds of client.
|
|
22
|
+
*
|
|
23
|
+
* A room lives while it has clients: when the last one leaves, its pending
|
|
24
|
+
* save is written and the room is dropped from memory.
|
|
21
25
|
*/
|
|
26
|
+
/** Query parameter (and value) a client sets to ask for change chunks. */
|
|
27
|
+
const CHANGES_PARAM = 'sync';
|
|
28
|
+
const CHANGES_PARAM_VALUE = 'changes';
|
|
29
|
+
/** Text frame confirming that this socket exchanges change chunks. */
|
|
30
|
+
export const CHANGES_ACK = 'automerge-sync:changes';
|
|
31
|
+
/** Longest a burst of edits can keep postponing its save. */
|
|
32
|
+
const MAX_PERSIST_WAIT_MS = 10_000;
|
|
22
33
|
/** Connections whose permission denied canWrite — their updates are dropped. */
|
|
23
34
|
const readOnlyClients = new WeakSet();
|
|
24
|
-
/**
|
|
25
|
-
const
|
|
35
|
+
/** Sockets that exchange change chunks instead of whole documents. */
|
|
36
|
+
const changeClients = new WeakSet();
|
|
37
|
+
function sameHeads(a, b) {
|
|
38
|
+
return a.length === b.length && a.every((hash, index) => hash === b[index]);
|
|
39
|
+
}
|
|
26
40
|
export class AutomergeDriver {
|
|
27
41
|
engine = 'automerge';
|
|
28
42
|
rooms = new Map();
|
|
29
43
|
wss;
|
|
30
44
|
options;
|
|
31
45
|
storage;
|
|
32
|
-
|
|
46
|
+
pendingSaves = new Map();
|
|
47
|
+
/** Saves being written, by document name; a room is not dropped under one. */
|
|
48
|
+
savesInFlight = new Map();
|
|
33
49
|
/**
|
|
34
50
|
* Rooms being opened, by document name.
|
|
35
51
|
*
|
|
@@ -40,6 +56,17 @@ export class AutomergeDriver {
|
|
|
40
56
|
* duplicated content, so the window has to close.
|
|
41
57
|
*/
|
|
42
58
|
roomLoads = new Map();
|
|
59
|
+
/**
|
|
60
|
+
* `@automerge/automerge`, loaded by the first call that opens a room or reads
|
|
61
|
+
* a version (see `lazy_deps.ts`). Every synchronous use runs on a room, and a
|
|
62
|
+
* room only exists after `openRoom` awaited the module, so it is set there.
|
|
63
|
+
*/
|
|
64
|
+
am;
|
|
65
|
+
/** Loads Automerge (once per process) and makes it available as `this.am`. */
|
|
66
|
+
async automerge() {
|
|
67
|
+
this.am ??= await loadAutomerge();
|
|
68
|
+
return this.am;
|
|
69
|
+
}
|
|
43
70
|
constructor(options) {
|
|
44
71
|
this.options = options;
|
|
45
72
|
this.storage = resolveStorage(options.storage);
|
|
@@ -56,11 +83,17 @@ export class AutomergeDriver {
|
|
|
56
83
|
return opening.finally(() => this.roomLoads.delete(docName));
|
|
57
84
|
}
|
|
58
85
|
async openRoom(docName) {
|
|
86
|
+
await this.automerge();
|
|
59
87
|
const stored = await this.storage.loadDocument(docName);
|
|
60
88
|
const doc = stored
|
|
61
|
-
?
|
|
89
|
+
? this.am.load(stored.state)
|
|
62
90
|
: await this.seedDocument(docName);
|
|
63
|
-
const room = {
|
|
91
|
+
const room = {
|
|
92
|
+
text: doc,
|
|
93
|
+
clients: new Set(),
|
|
94
|
+
users: new Map(),
|
|
95
|
+
holds: 0,
|
|
96
|
+
};
|
|
64
97
|
this.rooms.set(docName, room);
|
|
65
98
|
return room;
|
|
66
99
|
}
|
|
@@ -77,7 +110,7 @@ export class AutomergeDriver {
|
|
|
77
110
|
*/
|
|
78
111
|
// biome-ignore lint/suspicious/noExplicitAny: automerge's typing is unstable between minors
|
|
79
112
|
async seedDocument(docName) {
|
|
80
|
-
const empty = () =>
|
|
113
|
+
const empty = () => this.am.from({ content: '' });
|
|
81
114
|
if (!this.options.seedDocument)
|
|
82
115
|
return empty();
|
|
83
116
|
let seed;
|
|
@@ -99,7 +132,7 @@ export class AutomergeDriver {
|
|
|
99
132
|
}
|
|
100
133
|
let doc;
|
|
101
134
|
try {
|
|
102
|
-
doc =
|
|
135
|
+
doc = this.am.load(seed);
|
|
103
136
|
}
|
|
104
137
|
catch (error) {
|
|
105
138
|
reportCollaborationError({ scope: 'storage', operation: 'loadDocument', docName, error });
|
|
@@ -115,12 +148,52 @@ export class AutomergeDriver {
|
|
|
115
148
|
}
|
|
116
149
|
return doc;
|
|
117
150
|
}
|
|
118
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Sends everything the room gained since `since` to every client but the
|
|
153
|
+
* sender: the change chunk to sockets that speak chunks, a whole document
|
|
154
|
+
* (`wholeDoc`, else `A.save` of the room) to the rest. Each shape is
|
|
155
|
+
* encoded at most once per call.
|
|
156
|
+
*/
|
|
157
|
+
broadcast(room, since, sender, wholeDoc) {
|
|
158
|
+
let changes;
|
|
159
|
+
let whole = wholeDoc;
|
|
119
160
|
for (const client of room.clients) {
|
|
120
|
-
if (client
|
|
121
|
-
|
|
161
|
+
if (client === sender || client.readyState !== 1)
|
|
162
|
+
continue;
|
|
163
|
+
if (changeClients.has(client)) {
|
|
164
|
+
changes ??= this.am.next.saveSince(room.text, since);
|
|
165
|
+
client.send(changes);
|
|
122
166
|
}
|
|
167
|
+
else {
|
|
168
|
+
whole ??= this.am.save(room.text);
|
|
169
|
+
client.send(whole);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** Runs `fn` on the room, dropping it afterwards if nobody else uses it. */
|
|
174
|
+
async withRoom(docName, fn) {
|
|
175
|
+
const room = await this.loadRoom(docName);
|
|
176
|
+
room.holds += 1;
|
|
177
|
+
try {
|
|
178
|
+
return await fn(room);
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
room.holds -= 1;
|
|
182
|
+
void this.releaseRoom(docName, room);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Writes the room's pending save and drops it from memory once nothing
|
|
187
|
+
* uses it: no client, no driver call, no save waiting or being written.
|
|
188
|
+
* A client that joins meanwhile finds the same room in the map.
|
|
189
|
+
*/
|
|
190
|
+
async releaseRoom(docName, room) {
|
|
191
|
+
const idle = () => room.clients.size === 0 && room.holds === 0;
|
|
192
|
+
while (idle() && (this.pendingSaves.has(docName) || this.savesInFlight.has(docName))) {
|
|
193
|
+
await this.persistNow(docName);
|
|
123
194
|
}
|
|
195
|
+
if (idle() && this.rooms.get(docName) === room)
|
|
196
|
+
this.rooms.delete(docName);
|
|
124
197
|
}
|
|
125
198
|
async attach(server) {
|
|
126
199
|
const path = this.options.path ?? '/collaboration';
|
|
@@ -185,102 +258,176 @@ export class AutomergeDriver {
|
|
|
185
258
|
ws.close(4003, 'no access');
|
|
186
259
|
return;
|
|
187
260
|
}
|
|
261
|
+
let room;
|
|
188
262
|
try {
|
|
189
|
-
|
|
263
|
+
room = await this.loadRoom(docName);
|
|
264
|
+
const joinedRoom = room;
|
|
190
265
|
room.clients.add(ws);
|
|
191
266
|
if (!permission.canWrite)
|
|
192
267
|
readOnlyClients.add(ws);
|
|
268
|
+
const sendsChanges = url.searchParams.get(CHANGES_PARAM) === CHANGES_PARAM_VALUE;
|
|
269
|
+
if (sendsChanges)
|
|
270
|
+
changeClients.add(ws);
|
|
271
|
+
// Registered before the join, so a socket that closes while the join
|
|
272
|
+
// is in flight still leaves the room.
|
|
273
|
+
let joined = false;
|
|
274
|
+
ws.on('close', () => {
|
|
275
|
+
this.removeSocket(docName, joinedRoom, ws, joined ? ctx.userId : undefined);
|
|
276
|
+
});
|
|
193
277
|
await this.options.presence?.join(docName, {
|
|
194
278
|
userId: ctx.userId,
|
|
195
279
|
...(ctx.user?.name ? { name: ctx.user.name } : {}),
|
|
196
280
|
});
|
|
197
|
-
|
|
198
|
-
|
|
281
|
+
if (ws.readyState !== ws.OPEN) {
|
|
282
|
+
// Closed during the join: the entry it wrote is owed a leave.
|
|
283
|
+
this.leaveIfLast(docName, joinedRoom, ctx.userId);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
joined = true;
|
|
287
|
+
room.users.set(ctx.userId, (room.users.get(ctx.userId) ?? 0) + 1);
|
|
288
|
+
// The acknowledgement goes before the state, so the client knows
|
|
289
|
+
// the shape of its first send before it makes it.
|
|
290
|
+
if (sendsChanges)
|
|
291
|
+
ws.send(CHANGES_ACK);
|
|
292
|
+
ws.send(this.am.save(room.text));
|
|
199
293
|
ws.on('message', (data) => {
|
|
200
294
|
// A read-only connection still receives every update; its own are
|
|
201
295
|
// dropped. Enforced here because the client cannot be trusted to
|
|
202
296
|
// respect a permission it was merely told about.
|
|
203
297
|
if (readOnlyClients.has(ws))
|
|
204
298
|
return;
|
|
205
|
-
|
|
206
|
-
});
|
|
207
|
-
ws.on('close', () => {
|
|
208
|
-
room.clients.delete(ws);
|
|
209
|
-
readOnlyClients.delete(ws);
|
|
210
|
-
// Only the user's LAST socket on this document leaves the roster.
|
|
211
|
-
const stillConnected = [...room.clients].some((client) => socketUsers.get(client) === ctx.userId);
|
|
212
|
-
socketUsers.delete(ws);
|
|
213
|
-
if (!stillConnected) {
|
|
214
|
-
void this.options.presence?.leave(docName, ctx.userId);
|
|
215
|
-
}
|
|
299
|
+
this.handleClientMessage(docName, joinedRoom, data, ws);
|
|
216
300
|
});
|
|
217
|
-
socketUsers.set(ws, ctx.userId);
|
|
218
301
|
}
|
|
219
302
|
catch (error) {
|
|
303
|
+
if (room) {
|
|
304
|
+
// A join that failed may still have written the entry.
|
|
305
|
+
this.removeSocket(docName, room, ws, undefined);
|
|
306
|
+
this.leaveIfLast(docName, room, ctx.userId);
|
|
307
|
+
}
|
|
220
308
|
reportCollaborationError({ scope: 'storage', operation: 'loadDocument', docName, error });
|
|
221
309
|
ws.close(4500, 'could not open the document');
|
|
222
310
|
}
|
|
223
311
|
});
|
|
224
312
|
});
|
|
225
313
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Takes `ws` out of the room — and, when `userId` is given, out of that
|
|
316
|
+
* user's socket count, leaving the roster with their last one. Safe to call
|
|
317
|
+
* twice for one socket.
|
|
318
|
+
*/
|
|
319
|
+
removeSocket(docName, room, ws, userId) {
|
|
320
|
+
if (!room.clients.delete(ws))
|
|
321
|
+
return;
|
|
322
|
+
readOnlyClients.delete(ws);
|
|
323
|
+
changeClients.delete(ws);
|
|
324
|
+
if (userId !== undefined) {
|
|
325
|
+
const count = (room.users.get(userId) ?? 1) - 1;
|
|
326
|
+
if (count > 0)
|
|
327
|
+
room.users.set(userId, count);
|
|
328
|
+
else
|
|
329
|
+
room.users.delete(userId);
|
|
330
|
+
this.leaveIfLast(docName, room, userId);
|
|
331
|
+
}
|
|
332
|
+
void this.releaseRoom(docName, room);
|
|
333
|
+
}
|
|
334
|
+
/** Removes `userId` from the roster unless another of their sockets is still in the room. */
|
|
335
|
+
leaveIfLast(docName, room, userId) {
|
|
336
|
+
if (room.users.has(userId))
|
|
337
|
+
return;
|
|
338
|
+
this.options.presence?.leave(docName, userId).catch((error) => {
|
|
339
|
+
reportCollaborationError({ scope: 'transport', operation: 'presence.leave', docName, error });
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Applies a client's whole document or change chunk and forwards only what
|
|
344
|
+
* was new to the room. A payload that adds nothing — a duplicate, or bytes
|
|
345
|
+
* that are not Automerge at all — is neither forwarded nor saved.
|
|
346
|
+
*/
|
|
347
|
+
handleClientMessage(docName, room, data, sender) {
|
|
229
348
|
try {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
//
|
|
236
|
-
|
|
349
|
+
const before = this.am.next.getHeads(room.text);
|
|
350
|
+
room.text = this.am.loadIncremental(room.text, data);
|
|
351
|
+
if (sameHeads(before, this.am.next.getHeads(room.text)))
|
|
352
|
+
return;
|
|
353
|
+
// A whole document from a whole-document client is forwarded as it came
|
|
354
|
+
// to the other whole-document clients: it is already the payload they need.
|
|
355
|
+
this.broadcast(room, before, sender, changeClients.has(sender) ? undefined : data);
|
|
356
|
+
this.schedulePersist(docName, room);
|
|
237
357
|
}
|
|
238
358
|
catch {
|
|
239
359
|
// Invalid payload: ignore (a badly-behaved client doesn't take down the room).
|
|
240
360
|
}
|
|
241
361
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
362
|
+
/**
|
|
363
|
+
* Debounced save: each edit postpones it by `debounce`, but never past
|
|
364
|
+
* {@link MAX_PERSIST_WAIT_MS} (or `debounce`, if longer) after the first
|
|
365
|
+
* unsaved edit, so a document edited without pause is still written.
|
|
366
|
+
*/
|
|
367
|
+
schedulePersist(docName, room) {
|
|
368
|
+
const debounce = this.options.debounce ?? 2000;
|
|
369
|
+
const now = Date.now();
|
|
370
|
+
const pending = this.pendingSaves.get(docName);
|
|
371
|
+
if (pending)
|
|
372
|
+
clearTimeout(pending.timer);
|
|
373
|
+
const deadline = pending?.deadline ?? now + Math.max(MAX_PERSIST_WAIT_MS, debounce);
|
|
374
|
+
const timer = setTimeout(() => void this.persistNow(docName), Math.max(0, Math.min(debounce, deadline - now)));
|
|
375
|
+
this.pendingSaves.set(docName, { timer, deadline, room });
|
|
376
|
+
}
|
|
377
|
+
/** Writes the pending save of `docName` now, if there is one. */
|
|
378
|
+
async persistNow(docName) {
|
|
379
|
+
const pending = this.pendingSaves.get(docName);
|
|
380
|
+
if (!pending)
|
|
381
|
+
return this.savesInFlight.get(docName);
|
|
382
|
+
clearTimeout(pending.timer);
|
|
383
|
+
this.pendingSaves.delete(docName);
|
|
384
|
+
await this.save(docName, this.am.save(pending.room.text));
|
|
385
|
+
}
|
|
386
|
+
/** Saves `state`, tracked so the room is not dropped while it is written. */
|
|
387
|
+
async save(docName, state) {
|
|
388
|
+
const previous = this.savesInFlight.get(docName);
|
|
389
|
+
const write = (async () => {
|
|
390
|
+
// Writes of one document stay in order, so an older state never lands last.
|
|
391
|
+
await previous;
|
|
392
|
+
try {
|
|
393
|
+
await this.storage.saveDocument(docName, state);
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
reportCollaborationError({ scope: 'storage', operation: 'saveDocument', docName, error });
|
|
397
|
+
}
|
|
398
|
+
})();
|
|
399
|
+
this.savesInFlight.set(docName, write);
|
|
400
|
+
try {
|
|
401
|
+
await write;
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
if (this.savesInFlight.get(docName) === write)
|
|
405
|
+
this.savesInFlight.delete(docName);
|
|
406
|
+
}
|
|
261
407
|
}
|
|
262
408
|
async getDocumentState(docName) {
|
|
263
|
-
|
|
264
|
-
return A.save(room.text);
|
|
409
|
+
return this.withRoom(docName, (room) => this.am.save(room.text));
|
|
265
410
|
}
|
|
266
411
|
async getDocumentText(docName) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
412
|
+
return this.withRoom(docName, (room) => {
|
|
413
|
+
const content = room.text.content;
|
|
414
|
+
return content ? content.toString() : '';
|
|
415
|
+
});
|
|
270
416
|
}
|
|
271
417
|
/** Versions = markers over the native history (heads hash). */
|
|
272
418
|
async createVersion(docName, createdBy, label) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
419
|
+
return this.withRoom(docName, async (room) => {
|
|
420
|
+
const heads = this.am.next.getHeads(room.text);
|
|
421
|
+
const existing = await this.storage.listVersions(docName);
|
|
422
|
+
const version = {
|
|
423
|
+
...createVersionMetadata(createdBy, label, seqVersions(existing)),
|
|
424
|
+
id: heads.join(','),
|
|
425
|
+
};
|
|
426
|
+
// Snapshot = binary save of the doc up to those heads (Automerge compresses
|
|
427
|
+
// well; storing the whole doc as the "snapshot" keeps restore O(1)).
|
|
428
|
+
await this.storage.saveVersion(docName, version, this.am.save(room.text));
|
|
429
|
+
return version;
|
|
430
|
+
});
|
|
284
431
|
}
|
|
285
432
|
async listVersions(docName, page) {
|
|
286
433
|
return this.storage.listVersions(docName, page);
|
|
@@ -293,25 +440,36 @@ export class AutomergeDriver {
|
|
|
293
440
|
*/
|
|
294
441
|
async restoreVersion(docName, versionId, restoredBy) {
|
|
295
442
|
const bytes = await this.getVersionState(docName, versionId);
|
|
296
|
-
|
|
297
|
-
const
|
|
443
|
+
await this.automerge();
|
|
444
|
+
const snapshotDoc = this.am.load(bytes);
|
|
445
|
+
await this.withRoom(docName, (room) => this.restoreRoom(docName, room, versionId, snapshotDoc, restoredBy));
|
|
446
|
+
}
|
|
447
|
+
async restoreRoom(docName, room, versionId,
|
|
448
|
+
// biome-ignore lint/suspicious/noExplicitAny: automerge's typing is unstable between minors
|
|
449
|
+
snapshotDoc, restoredBy) {
|
|
298
450
|
const existing = await this.storage.listVersions(docName);
|
|
299
451
|
const target = existing.find((version) => version.id === versionId);
|
|
300
452
|
await this.storage.saveVersion(docName, {
|
|
301
453
|
...createVersionMetadata(restoredBy, restoredFromLabel(target, versionId), seqVersions(existing)),
|
|
302
|
-
id:
|
|
303
|
-
},
|
|
304
|
-
const
|
|
454
|
+
id: this.am.next.getHeads(room.text).join(','),
|
|
455
|
+
}, this.am.save(room.text));
|
|
456
|
+
const before = this.am.next.getHeads(room.text);
|
|
457
|
+
const merged = this.am.merge(room.text, snapshotDoc);
|
|
305
458
|
// Automerge merge converges to the most recent state; to "restore",
|
|
306
459
|
// we overwrite the content field with the value from the version.
|
|
307
460
|
const restoredContent = snapshotDoc
|
|
308
461
|
.content;
|
|
309
|
-
const finalDoc =
|
|
462
|
+
const finalDoc = this.am.change(merged, (doc) => {
|
|
310
463
|
doc.content = restoredContent?.toString() ?? '';
|
|
311
464
|
});
|
|
312
465
|
room.text = finalDoc;
|
|
313
|
-
this.broadcast(room,
|
|
314
|
-
|
|
466
|
+
this.broadcast(room, before);
|
|
467
|
+
// The restore supersedes any debounced save of the state it replaced.
|
|
468
|
+
const pending = this.pendingSaves.get(docName);
|
|
469
|
+
if (pending)
|
|
470
|
+
clearTimeout(pending.timer);
|
|
471
|
+
this.pendingSaves.delete(docName);
|
|
472
|
+
await this.save(docName, this.am.save(finalDoc));
|
|
315
473
|
}
|
|
316
474
|
/** Binary state stored for a version (`A.save` of the doc at that point). */
|
|
317
475
|
async getVersionState(docName, versionId) {
|
|
@@ -325,20 +483,25 @@ export class AutomergeDriver {
|
|
|
325
483
|
this.getVersionState(docName, aId),
|
|
326
484
|
this.getVersionState(docName, bId),
|
|
327
485
|
]);
|
|
486
|
+
await this.automerge();
|
|
328
487
|
const textOf = (bytes) => {
|
|
329
|
-
const doc =
|
|
488
|
+
const doc = this.am.load(bytes);
|
|
330
489
|
return doc.content?.toString() ?? '';
|
|
331
490
|
};
|
|
332
491
|
return lineDiff(textOf(aBytes), textOf(bBytes));
|
|
333
492
|
}
|
|
334
493
|
async close() {
|
|
335
|
-
|
|
336
|
-
|
|
494
|
+
// Edits still inside their debounce are written, not dropped.
|
|
495
|
+
await Promise.all([...this.pendingSaves.keys()].map((docName) => this.persistNow(docName)));
|
|
496
|
+
await Promise.all(this.savesInFlight.values());
|
|
337
497
|
// `this.wss?.close(cb)` never runs cb when there is no server, so this
|
|
338
498
|
// promise used to hang forever on a driver that was never attached —
|
|
339
499
|
// which is every driver outside the web process.
|
|
340
500
|
if (!this.wss)
|
|
341
501
|
return;
|
|
502
|
+
// `wss.close` only calls back once every client is gone.
|
|
503
|
+
for (const client of this.wss.clients)
|
|
504
|
+
client.terminate();
|
|
342
505
|
return new Promise((resolve) => {
|
|
343
506
|
this.wss?.close(() => resolve());
|
|
344
507
|
});
|