@automerge/automerge-repo-network-websocket 1.0.10 → 1.0.11

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 +1 @@
1
- {"version":3,"file":"NodeWSServerAdapter.d.ts","sourceRoot":"","sources":["../src/NodeWSServerAdapter.ts"],"names":[],"mappings":";AAAA,OAAO,EAAE,SAAS,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA;AAK/D,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;CAuDtD"}
1
+ {"version":3,"file":"NodeWSServerAdapter.d.ts","sourceRoot":"","sources":["../src/NodeWSServerAdapter.ts"],"names":[],"mappings":";AAAA,OAAO,EAAE,SAAS,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA;AAK/D,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.10",
3
+ "version": "1.0.11",
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.10",
19
+ "@automerge/automerge-repo": "^1.0.11",
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": "a79cfa659547b7f4a12987bbbee0f4f2f7f219f4"
36
+ "gitHead": "aaead1625f7b1b79d239bef5bce9b3cf3d725332"
37
37
  }
@@ -123,6 +123,14 @@ export class NodeWSServerAdapter extends NetworkAdapter {
123
123
  )
124
124
  switch (type) {
125
125
  case "join":
126
+ const existingSocket = this.sockets[senderId]
127
+ if (existingSocket) {
128
+ if (existingSocket.readyState === WebSocket.OPEN) {
129
+ existingSocket.close()
130
+ }
131
+ this.emit("peer-disconnected", {peerId: senderId})
132
+ }
133
+
126
134
  // Let the rest of the system know that we have a new connection.
127
135
  this.emit("peer-candidate", { peerId: senderId })
128
136
  this.sockets[senderId] = socket
@@ -1,4 +1,5 @@
1
- import { PeerId, Repo } from "@automerge/automerge-repo"
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