@automerge/automerge-repo-network-websocket 1.0.10 → 1.0.12
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.
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/// <reference types="ws" />
|
|
2
|
-
import
|
|
2
|
+
import WebSocket from "isomorphic-ws";
|
|
3
|
+
import { type WebSocketServer } from "isomorphic-ws";
|
|
3
4
|
import { NetworkAdapter, type PeerId } from "@automerge/automerge-repo";
|
|
4
5
|
import { FromServerMessage } from "./messages.js";
|
|
5
6
|
export declare class NodeWSServerAdapter extends NetworkAdapter {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"NodeWSServerAdapter.d.ts","sourceRoot":"","sources":["../src/NodeWSServerAdapter.ts"],"names":[],"mappings":";AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"NodeWSServerAdapter.d.ts","sourceRoot":"","sources":["../src/NodeWSServerAdapter.ts"],"names":[],"mappings":";AAAA,OAAO,SAAS,MAAM,eAAe,CAAA;AACrC,OAAO,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA;AAKpD,OAAO,EAEL,cAAc,EACd,KAAK,MAAM,EACZ,MAAM,2BAA2B,CAAA;AAClC,OAAO,EAAqB,iBAAiB,EAAE,MAAM,eAAe,CAAA;AASpE,qBAAa,mBAAoB,SAAQ,cAAc;IACrD,MAAM,EAAE,eAAe,CAAA;IACvB,OAAO,EAAE;QAAE,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,CAAK;gBAEjC,MAAM,EAAE,eAAe;IAKnC,OAAO,CAAC,MAAM,EAAE,MAAM;IAoDtB,UAAU;IAIV,IAAI,CAAC,OAAO,EAAE,iBAAiB;IAyB/B,cAAc,CAAC,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS;CA+DtD"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import WebSocket from "isomorphic-ws";
|
|
1
2
|
import debug from "debug";
|
|
2
3
|
const log = debug("WebsocketServer");
|
|
3
4
|
import { cbor as cborHelpers, NetworkAdapter, } from "@automerge/automerge-repo";
|
|
@@ -83,6 +84,13 @@ export class NodeWSServerAdapter extends NetworkAdapter {
|
|
|
83
84
|
log(`[${senderId}->${myPeerId}${"documentId" in cbor ? "@" + cbor.documentId : ""}] ${type} | ${message.byteLength} bytes`);
|
|
84
85
|
switch (type) {
|
|
85
86
|
case "join":
|
|
87
|
+
const existingSocket = this.sockets[senderId];
|
|
88
|
+
if (existingSocket) {
|
|
89
|
+
if (existingSocket.readyState === WebSocket.OPEN) {
|
|
90
|
+
existingSocket.close();
|
|
91
|
+
}
|
|
92
|
+
this.emit("peer-disconnected", { peerId: senderId });
|
|
93
|
+
}
|
|
86
94
|
// Let the rest of the system know that we have a new connection.
|
|
87
95
|
this.emit("peer-candidate", { peerId: senderId });
|
|
88
96
|
this.sockets[senderId] = socket;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automerge/automerge-repo-network-websocket",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
4
4
|
"description": "isomorphic node/browser Websocket network adapter for Automerge Repo",
|
|
5
5
|
"peerDependencies": {
|
|
6
6
|
"@automerge/automerge": "^2.1.0"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"test": "vitest"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@automerge/automerge-repo": "^1.0.
|
|
19
|
+
"@automerge/automerge-repo": "^1.0.12",
|
|
20
20
|
"cbor-x": "^1.3.0",
|
|
21
21
|
"eventemitter3": "^5.0.1",
|
|
22
22
|
"isomorphic-ws": "^5.0.0",
|
|
@@ -33,5 +33,5 @@
|
|
|
33
33
|
"publishConfig": {
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
|
-
"gitHead": "
|
|
36
|
+
"gitHead": "254bad1c774fa2a881265aaad5283af231bf72eb"
|
|
37
37
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import WebSocket from "isomorphic-ws"
|
|
2
|
+
import { type WebSocketServer } from "isomorphic-ws"
|
|
2
3
|
|
|
3
4
|
import debug from "debug"
|
|
4
5
|
const log = debug("WebsocketServer")
|
|
@@ -123,6 +124,14 @@ export class NodeWSServerAdapter extends NetworkAdapter {
|
|
|
123
124
|
)
|
|
124
125
|
switch (type) {
|
|
125
126
|
case "join":
|
|
127
|
+
const existingSocket = this.sockets[senderId]
|
|
128
|
+
if (existingSocket) {
|
|
129
|
+
if (existingSocket.readyState === WebSocket.OPEN) {
|
|
130
|
+
existingSocket.close()
|
|
131
|
+
}
|
|
132
|
+
this.emit("peer-disconnected", {peerId: senderId})
|
|
133
|
+
}
|
|
134
|
+
|
|
126
135
|
// Let the rest of the system know that we have a new connection.
|
|
127
136
|
this.emit("peer-candidate", { peerId: senderId })
|
|
128
137
|
this.sockets[senderId] = socket
|
package/test/Websocket.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { next as A } from "@automerge/automerge"
|
|
2
|
+
import { AutomergeUrl, DocumentId, PeerId, Repo, SyncMessage, parseAutomergeUrl } from "@automerge/automerge-repo"
|
|
2
3
|
import assert from "assert"
|
|
3
4
|
import * as CBOR from "cbor-x"
|
|
4
5
|
import { once } from "events"
|
|
@@ -6,8 +7,10 @@ import http from "http"
|
|
|
6
7
|
import { describe, it } from "vitest"
|
|
7
8
|
import WebSocket, { AddressInfo } from "ws"
|
|
8
9
|
import { runAdapterTests } from "../../automerge-repo/src/helpers/tests/network-adapter-tests.js"
|
|
10
|
+
import { DummyStorageAdapter } from "../../automerge-repo/test/helpers/DummyStorageAdapter.js"
|
|
9
11
|
import { BrowserWebSocketClientAdapter } from "../src/BrowserWebSocketClientAdapter.js"
|
|
10
12
|
import { NodeWSServerAdapter } from "../src/NodeWSServerAdapter.js"
|
|
13
|
+
import {headsAreSame} from "@automerge/automerge-repo/src/helpers/headsAreSame.js"
|
|
11
14
|
|
|
12
15
|
describe("Websocket adapters", () => {
|
|
13
16
|
const setup = async (clientCount = 1) => {
|
|
@@ -152,6 +155,18 @@ describe("Websocket adapters", () => {
|
|
|
152
155
|
return message
|
|
153
156
|
}
|
|
154
157
|
|
|
158
|
+
async function recvOrTimeout(socket: WebSocket): Promise<Buffer | null> {
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
const timer = setTimeout(() => {
|
|
161
|
+
resolve(null)
|
|
162
|
+
}, 1000)
|
|
163
|
+
socket.once("message", (msg) => {
|
|
164
|
+
clearTimeout(timer)
|
|
165
|
+
resolve(msg as Buffer)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
155
170
|
it("should send the negotiated protocol version in its hello message", async () => {
|
|
156
171
|
const response = await serverResponse({
|
|
157
172
|
type: "join",
|
|
@@ -192,6 +207,185 @@ describe("Websocket adapters", () => {
|
|
|
192
207
|
selectedProtocolVersion: "1",
|
|
193
208
|
})
|
|
194
209
|
})
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Create a new document, initialized with the given contents and return a
|
|
213
|
+
* storage containign that document as well as the URL and a fork of the
|
|
214
|
+
* document
|
|
215
|
+
*
|
|
216
|
+
* @param contents - The contents to initialize the document with
|
|
217
|
+
*/
|
|
218
|
+
async function initDocAndStorage<T extends Record<string, unknown>>(contents: T): Promise<{
|
|
219
|
+
storage: DummyStorageAdapter,
|
|
220
|
+
url: AutomergeUrl,
|
|
221
|
+
doc: A.Doc<T>,
|
|
222
|
+
documentId: DocumentId
|
|
223
|
+
}> {
|
|
224
|
+
const storage = new DummyStorageAdapter()
|
|
225
|
+
const silentRepo = new Repo({storage, network: []})
|
|
226
|
+
const doc = A.from<T>(contents)
|
|
227
|
+
const handle = silentRepo.create()
|
|
228
|
+
handle.update(() => A.clone(doc))
|
|
229
|
+
const { documentId } = parseAutomergeUrl(handle.url)
|
|
230
|
+
await pause(150)
|
|
231
|
+
return {
|
|
232
|
+
url: handle.url,
|
|
233
|
+
doc,
|
|
234
|
+
documentId,
|
|
235
|
+
storage,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function assertIsPeerMessage(msg: Buffer | null) {
|
|
240
|
+
if (msg == null) {
|
|
241
|
+
throw new Error("expected a peer message, got null")
|
|
242
|
+
}
|
|
243
|
+
let decoded = CBOR.decode(msg)
|
|
244
|
+
if (decoded.type !== "peer") {
|
|
245
|
+
throw new Error(`expected a peer message, got type: ${decoded.type}`)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function assertIsSyncMessage(forDocument: DocumentId, msg: Buffer | null): SyncMessage {
|
|
250
|
+
if (msg == null) {
|
|
251
|
+
throw new Error("expected a peer message, got null")
|
|
252
|
+
}
|
|
253
|
+
let decoded = CBOR.decode(msg)
|
|
254
|
+
if (decoded.type !== "sync") {
|
|
255
|
+
throw new Error(`expected a peer message, got type: ${decoded.type}`)
|
|
256
|
+
}
|
|
257
|
+
if (decoded.documentId !== forDocument) {
|
|
258
|
+
throw new Error(`expected a sync message for ${forDocument}, not for ${decoded.documentId}`)
|
|
259
|
+
}
|
|
260
|
+
return decoded
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
it("should disconnect existing peers on reconnect before announcing them", async () => {
|
|
264
|
+
// This test exercises a sync loop which is exposed in the following
|
|
265
|
+
// sequence of events:
|
|
266
|
+
//
|
|
267
|
+
// 1. A document exists on both the server and the client with divergent
|
|
268
|
+
// heads (both sides have changes the other does not have)
|
|
269
|
+
// 2. The client sends a sync message to the server
|
|
270
|
+
// 3. The server responds, but for some reason the server response is
|
|
271
|
+
// dropped
|
|
272
|
+
// 4. The client reconnects due to not receiving a response or a ping
|
|
273
|
+
// 5. The peers exchange sync messages, but the server thinks it has
|
|
274
|
+
// already sent its changes to the client, so it doesn't sent them.
|
|
275
|
+
// 6. The client notices that it doesn't have the servers changes so it
|
|
276
|
+
// asks for them
|
|
277
|
+
// 7. The server responds with an empty sync message because it thinks it
|
|
278
|
+
// has already sent the changes
|
|
279
|
+
//
|
|
280
|
+
// 6 and 7 continue in an infinite loop. The root cause is the servers
|
|
281
|
+
// failure to clear the sync state associated with the given peer when
|
|
282
|
+
// it receives a new connection from the same peer ID.
|
|
283
|
+
const { socket, serverUrl } = await setup(0)
|
|
284
|
+
|
|
285
|
+
// Create a doc, populate a DummyStorageAdapter with that doc
|
|
286
|
+
const {storage, url, doc, documentId} = await initDocAndStorage({foo: "bar"})
|
|
287
|
+
|
|
288
|
+
// Create a copy of the document to represent the client state
|
|
289
|
+
let clientDoc = A.clone<{foo: string}>(doc)
|
|
290
|
+
clientDoc = A.change(clientDoc, d => d.foo = "qux")
|
|
291
|
+
|
|
292
|
+
// Now create a websocket sync server with the original document in it's storage
|
|
293
|
+
const adapter = new NodeWSServerAdapter(socket)
|
|
294
|
+
const repo = new Repo({ network: [adapter], storage, peerId: "server" as PeerId })
|
|
295
|
+
|
|
296
|
+
// make a change to the handle on the sync server
|
|
297
|
+
const handle = repo.find<{foo: string}>(url)
|
|
298
|
+
await handle.whenReady()
|
|
299
|
+
handle.change(d => d.foo = "baz")
|
|
300
|
+
|
|
301
|
+
// Okay, so now there is a document on both the client and the server
|
|
302
|
+
// which has concurrent changes on each peer.
|
|
303
|
+
|
|
304
|
+
// Simulate the initial websocket connection
|
|
305
|
+
let clientSocket = new WebSocket(serverUrl)
|
|
306
|
+
await once(clientSocket, "open")
|
|
307
|
+
|
|
308
|
+
// Run through the client/server hello
|
|
309
|
+
clientSocket.send(CBOR.encode({
|
|
310
|
+
type: "join",
|
|
311
|
+
senderId: "client",
|
|
312
|
+
supportedProtocolVersions: ["1"],
|
|
313
|
+
}))
|
|
314
|
+
|
|
315
|
+
let response = await recvOrTimeout(clientSocket)
|
|
316
|
+
assertIsPeerMessage(response)
|
|
317
|
+
|
|
318
|
+
// Okay now we start syncing
|
|
319
|
+
|
|
320
|
+
let clientState = A.initSyncState()
|
|
321
|
+
let [newSyncState, message] = A.generateSyncMessage(clientDoc, clientState)
|
|
322
|
+
clientState = newSyncState
|
|
323
|
+
|
|
324
|
+
// Send the initial sync state
|
|
325
|
+
clientSocket.send(CBOR.encode({
|
|
326
|
+
type: "request",
|
|
327
|
+
documentId,
|
|
328
|
+
targetId: "server",
|
|
329
|
+
senderId: "client",
|
|
330
|
+
data: message
|
|
331
|
+
}))
|
|
332
|
+
|
|
333
|
+
response = await recvOrTimeout(clientSocket)
|
|
334
|
+
assertIsSyncMessage(documentId, response)
|
|
335
|
+
|
|
336
|
+
// Now, assume either the network or the server is going slow, so the
|
|
337
|
+
// server thinks it has sent the response above, but for whatever reason
|
|
338
|
+
// it never gets to the client. In that case the reconnect timer in the
|
|
339
|
+
// BrowserWebSocketClientAdapter will fire and we'll create a new
|
|
340
|
+
// websocket and connect it. To simulate this we drop the above response
|
|
341
|
+
// on the floor and start connecting again.
|
|
342
|
+
|
|
343
|
+
clientSocket = new WebSocket(serverUrl)
|
|
344
|
+
await once(clientSocket, "open")
|
|
345
|
+
|
|
346
|
+
// and we also make a change to the client doc
|
|
347
|
+
clientDoc = A.change(clientDoc, d => d.foo = "quoxen")
|
|
348
|
+
|
|
349
|
+
// Run through the whole client/server hello dance again
|
|
350
|
+
clientSocket.send(CBOR.encode({
|
|
351
|
+
type: "join",
|
|
352
|
+
senderId: "client",
|
|
353
|
+
supportedProtocolVersions: ["1"],
|
|
354
|
+
}))
|
|
355
|
+
|
|
356
|
+
response = await recvOrTimeout(clientSocket)
|
|
357
|
+
assertIsPeerMessage(response)
|
|
358
|
+
|
|
359
|
+
// Now, we start syncing. If we're not buggy, this loop should terminate.
|
|
360
|
+
while(true) {
|
|
361
|
+
;[clientState, message] = A.generateSyncMessage(clientDoc, clientState)
|
|
362
|
+
if (message) {
|
|
363
|
+
clientSocket.send(CBOR.encode({
|
|
364
|
+
type: "sync",
|
|
365
|
+
documentId,
|
|
366
|
+
targetId: "server",
|
|
367
|
+
senderId: "client",
|
|
368
|
+
data: message
|
|
369
|
+
}))
|
|
370
|
+
}
|
|
371
|
+
const response = await recvOrTimeout(clientSocket)
|
|
372
|
+
if (response) {
|
|
373
|
+
const decoded = assertIsSyncMessage(documentId, response)
|
|
374
|
+
;[clientDoc, clientState] = A.receiveSyncMessage(clientDoc, clientState, decoded.data)
|
|
375
|
+
}
|
|
376
|
+
if (response == null && message == null) {
|
|
377
|
+
break
|
|
378
|
+
}
|
|
379
|
+
// Make sure shit has time to happen
|
|
380
|
+
await pause(50)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let localHeads = A.getHeads(clientDoc)
|
|
384
|
+
let remoteHeads = A.getHeads(handle.docSync())
|
|
385
|
+
if (!headsAreSame(localHeads, remoteHeads)) {
|
|
386
|
+
throw new Error("heads not equal")
|
|
387
|
+
}
|
|
388
|
+
})
|
|
195
389
|
})
|
|
196
390
|
})
|
|
197
391
|
|