@automerge/automerge-repo-network-websocket 1.1.0-alpha.7 → 1.1.0

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.
@@ -7,43 +7,32 @@ import {
7
7
  SyncMessage,
8
8
  parseAutomergeUrl,
9
9
  } from "@automerge/automerge-repo"
10
+ import { generateAutomergeUrl } from "@automerge/automerge-repo/dist/AutomergeUrl"
11
+ import { eventPromise } from "@automerge/automerge-repo/src/helpers/eventPromise"
12
+ import { headsAreSame } from "@automerge/automerge-repo/src/helpers/headsAreSame.js"
13
+ import { runAdapterTests } from "@automerge/automerge-repo/src/helpers/tests/network-adapter-tests.js"
14
+ import { DummyStorageAdapter } from "@automerge/automerge-repo/test/helpers/DummyStorageAdapter.js"
10
15
  import assert from "assert"
11
16
  import * as CBOR from "cbor-x"
12
17
  import { once } from "events"
13
18
  import http from "http"
19
+ import { getPortPromise as getAvailablePort } from "portfinder"
14
20
  import { describe, it } from "vitest"
15
- import WebSocket, { AddressInfo } from "ws"
16
- import { runAdapterTests } from "../../automerge-repo/src/helpers/tests/network-adapter-tests.js"
17
- import { DummyStorageAdapter } from "../../automerge-repo/test/helpers/DummyStorageAdapter.js"
21
+ import WebSocket from "ws"
18
22
  import { BrowserWebSocketClientAdapter } from "../src/BrowserWebSocketClientAdapter.js"
19
23
  import { NodeWSServerAdapter } from "../src/NodeWSServerAdapter.js"
20
- import { headsAreSame } from "@automerge/automerge-repo/src/helpers/headsAreSame.js"
21
24
 
22
25
  describe("Websocket adapters", () => {
23
- const setup = async (clientCount = 1) => {
24
- const server = http.createServer()
25
- const socket = new WebSocket.Server({ server })
26
-
27
- await new Promise<void>(resolve => server.listen(0, resolve))
28
- const { port } = server.address() as AddressInfo
29
- const serverUrl = `ws://localhost:${port}`
30
-
31
- const clients = [] as BrowserWebSocketClientAdapter[]
32
- for (let i = 0; i < clientCount; i++) {
33
- clients.push(new BrowserWebSocketClientAdapter(serverUrl))
34
- }
26
+ const browserPeerId = "browser" as PeerId
27
+ const serverPeerId = "server" as PeerId
28
+ const documentId = parseAutomergeUrl(generateAutomergeUrl()).documentId
35
29
 
36
- return { socket, server, port, serverUrl, clients }
37
- }
38
-
39
- // run adapter acceptance tests
40
30
  runAdapterTests(async () => {
41
31
  const {
42
32
  clients: [aliceAdapter, bobAdapter],
43
- socket,
44
33
  server,
45
- } = await setup(2)
46
- const serverAdapter = new NodeWSServerAdapter(socket)
34
+ serverAdapter,
35
+ } = await setup({ clientCount: 2 })
47
36
 
48
37
  const teardown = () => {
49
38
  server.close()
@@ -53,62 +42,179 @@ describe("Websocket adapters", () => {
53
42
  })
54
43
 
55
44
  describe("BrowserWebSocketClientAdapter", () => {
56
- const firstMessage = async (socket: WebSocket.Server<any>) =>
57
- new Promise((resolve, reject) => {
58
- socket.once("connection", ws => {
59
- ws.once("message", (message: any) => resolve(message))
60
- ws.once("error", (error: any) => reject(error))
61
- })
62
- socket.once("error", error => reject(error))
63
- })
64
-
65
45
  it("should advertise the protocol versions it supports in its join message", async () => {
66
46
  const {
67
- socket,
47
+ serverSocket: socket,
68
48
  clients: [browser],
69
49
  } = await setup()
70
50
 
71
- const helloPromise = firstMessage(socket)
72
-
73
- const _repo = new Repo({
74
- network: [browser],
75
- peerId: "browser" as PeerId,
51
+ const helloPromise = new Promise((resolve, reject) => {
52
+ socket.once("connection", ws => {
53
+ ws.once("message", (message: any) => resolve(message))
54
+ })
76
55
  })
77
56
 
57
+ const _repo = new Repo({ network: [browser], peerId: browserPeerId })
58
+
78
59
  const encodedMessage = await helloPromise
79
60
  const message = CBOR.decode(encodedMessage as Uint8Array)
80
61
  assert.deepEqual(message, {
81
62
  type: "join",
82
- senderId: "browser",
63
+ senderId: browserPeerId,
83
64
  peerMetadata: { storageId: undefined, isEphemeral: true },
84
65
  supportedProtocolVersions: ["1"],
85
66
  })
86
67
  })
87
68
 
88
- it.skip("should announce disconnections", async () => {
69
+ it("should announce disconnections", async () => {
89
70
  const {
90
- server,
91
- socket,
71
+ serverAdapter,
92
72
  clients: [browserAdapter],
93
73
  } = await setup()
94
74
 
75
+ const browserRepo = new Repo({
76
+ network: [browserAdapter],
77
+ peerId: browserPeerId,
78
+ })
79
+
80
+ const serverRepo = new Repo({
81
+ network: [serverAdapter],
82
+ peerId: serverPeerId,
83
+ })
84
+
85
+ await eventPromise(serverRepo.networkSubsystem, "peer")
86
+
87
+ browserAdapter.disconnect()
88
+
89
+ await Promise.all([
90
+ eventPromise(browserAdapter, "peer-disconnected"),
91
+ eventPromise(serverAdapter, "peer-disconnected"),
92
+ ])
93
+ })
94
+
95
+ it("should connect even when server is not initially available", async () => {
96
+ const port = await getPort() //?
97
+ const retryInterval = 100
98
+
99
+ const browserAdapter = await setupClient({ port, retryInterval })
100
+
95
101
  const _browserRepo = new Repo({
96
102
  network: [browserAdapter],
97
- peerId: "browser" as PeerId,
103
+ peerId: browserPeerId,
98
104
  })
99
105
 
100
- const serverAdapter = new NodeWSServerAdapter(socket)
101
- const _serverRepo = new Repo({
106
+ await pause(500)
107
+
108
+ const { serverAdapter } = await setupServer({ port, retryInterval })
109
+ const serverRepo = new Repo({
102
110
  network: [serverAdapter],
103
- peerId: "server" as PeerId,
111
+ peerId: serverPeerId,
104
112
  })
105
113
 
106
- const disconnectPromise = new Promise<void>(resolve => {
107
- browserAdapter.on("peer-disconnected", () => resolve())
114
+ await eventPromise(browserAdapter, "peer-candidate")
115
+ })
116
+
117
+ it("should reconnect after being disconnected", async () => {
118
+ const port = await getPort()
119
+ const retryInterval = 100
120
+
121
+ const browser = await setupClient({ port, retryInterval })
122
+
123
+ {
124
+ const { server, serverSocket, serverAdapter } = await setupServer({
125
+ port,
126
+ retryInterval,
127
+ })
128
+
129
+ const _browserRepo = new Repo({
130
+ network: [browser],
131
+ peerId: browserPeerId,
132
+ })
133
+
134
+ const serverRepo = new Repo({
135
+ network: [serverAdapter],
136
+ peerId: serverPeerId,
137
+ })
138
+
139
+ await eventPromise(browser, "peer-candidate")
140
+
141
+ // Stop the server
142
+ serverAdapter.disconnect()
143
+ server.close()
144
+ serverSocket.close()
145
+
146
+ await eventPromise(browser, "peer-disconnected")
147
+ }
148
+
149
+ {
150
+ // Restart the server (on the same port)
151
+ const { serverAdapter } = await setupServer({ port, retryInterval })
152
+
153
+ const serverRepo = new Repo({
154
+ network: [serverAdapter],
155
+ peerId: serverPeerId,
156
+ })
157
+
158
+ // The browserAdapter reconnects on its own
159
+ await eventPromise(browser, "peer-candidate")
160
+ }
161
+ })
162
+
163
+ it("should throw an error if asked to send a zero-length message", async () => {
164
+ const {
165
+ clients: [browser],
166
+ } = await setup()
167
+ const sendNoData = () => {
168
+ browser.send({
169
+ type: "sync",
170
+ data: new Uint8Array(), // <- empty
171
+ documentId,
172
+ senderId: browserPeerId,
173
+ targetId: serverPeerId,
174
+ })
175
+ }
176
+ assert.throws(sendNoData, /zero/)
177
+ })
178
+
179
+ it("should throw an error if asked to send before ready", async () => {
180
+ const port = await getPort()
181
+
182
+ const serverUrl = `ws://localhost:${port}`
183
+
184
+ const retry = 100
185
+ const browser = new BrowserWebSocketClientAdapter(serverUrl, retry)
186
+
187
+ const _browserRepo = new Repo({
188
+ network: [browser],
189
+ peerId: browserPeerId,
108
190
  })
109
191
 
110
- server.close()
111
- await disconnectPromise
192
+ const server = http.createServer()
193
+ const serverSocket = new WebSocket.Server({ server })
194
+
195
+ await new Promise<void>(resolve => server.listen(port, resolve))
196
+ const serverAdapter = new NodeWSServerAdapter(serverSocket, retry)
197
+
198
+ const serverRepo = new Repo({
199
+ network: [serverAdapter],
200
+ peerId: serverPeerId,
201
+ })
202
+
203
+ const sendMessage = () => {
204
+ browser.send({
205
+ // @ts-ignore
206
+ type: "foo",
207
+ data: new Uint8Array([1, 2, 3]),
208
+ documentId,
209
+ senderId: browserPeerId,
210
+ targetId: serverPeerId,
211
+ })
212
+ }
213
+ assert.throws(sendMessage, /not ready/)
214
+
215
+ // once the server is ready, we can send
216
+ await eventPromise(browser, "peer-candidate")
217
+ assert.doesNotThrow(sendMessage)
112
218
  })
113
219
 
114
220
  it("should correctly clear event handlers on reconnect", async () => {
@@ -130,12 +236,12 @@ describe("Websocket adapters", () => {
130
236
  } = await setup()
131
237
 
132
238
  const peerId = "testclient" as PeerId
133
- browser.connect(peerId, undefined, true)
239
+ browser.connect(peerId)
134
240
 
135
241
  // simulate the reconnect timer firing before the other end has responded
136
242
  // (which works here because we haven't yielded to the event loop yet so
137
243
  // the server, which is on the same event loop as us, can't respond)
138
- browser.connect(peerId, undefined, true)
244
+ browser.connect(peerId)
139
245
 
140
246
  // Now yield, so the server responds on the first socket, if the listeners
141
247
  // are cleaned up correctly we shouldn't throw
@@ -145,11 +251,13 @@ describe("Websocket adapters", () => {
145
251
 
146
252
  describe("NodeWSServerAdapter", () => {
147
253
  const serverResponse = async (clientHello: Object) => {
148
- const { socket, serverUrl } = await setup(0)
149
- const server = new NodeWSServerAdapter(socket)
254
+ const { serverSocket, serverUrl } = await setup({
255
+ clientCount: 0,
256
+ })
257
+ const server = new NodeWSServerAdapter(serverSocket)
150
258
  const _serverRepo = new Repo({
151
259
  network: [server],
152
- peerId: "server" as PeerId,
260
+ peerId: serverPeerId,
153
261
  })
154
262
 
155
263
  const clientSocket = new WebSocket(serverUrl)
@@ -163,7 +271,7 @@ describe("Websocket adapters", () => {
163
271
  return message
164
272
  }
165
273
 
166
- async function recvOrTimeout(socket: WebSocket): Promise<Buffer | null> {
274
+ async function messageOrTimeout(socket: WebSocket): Promise<Buffer | null> {
167
275
  return new Promise(resolve => {
168
276
  const timer = setTimeout(() => {
169
277
  resolve(null)
@@ -175,10 +283,69 @@ describe("Websocket adapters", () => {
175
283
  })
176
284
  }
177
285
 
286
+ it("should disconnect from a closed client", async () => {
287
+ const {
288
+ serverAdapter,
289
+ clients: [browserAdapter],
290
+ } = await setup()
291
+
292
+ const _browserRepo = new Repo({
293
+ network: [browserAdapter],
294
+ peerId: browserPeerId,
295
+ })
296
+
297
+ const serverRepo = new Repo({
298
+ network: [serverAdapter],
299
+ peerId: serverPeerId,
300
+ })
301
+
302
+ await eventPromise(serverRepo.networkSubsystem, "peer")
303
+
304
+ const disconnectPromise = new Promise<void>(resolve => {
305
+ serverAdapter.on("peer-disconnected", () => resolve())
306
+ })
307
+
308
+ browserAdapter.socket!.close()
309
+
310
+ await disconnectPromise
311
+ })
312
+
313
+ it("should disconnect from a client that doesn't respond to pings", async () => {
314
+ const port = await getPort()
315
+
316
+ const serverUrl = `ws://localhost:${port}`
317
+
318
+ const retry = 100
319
+ const browserAdapter = new BrowserWebSocketClientAdapter(serverUrl, retry)
320
+
321
+ const server = http.createServer()
322
+ const serverSocket = new WebSocket.Server({ server })
323
+
324
+ await new Promise<void>(resolve => server.listen(port, resolve))
325
+ const serverAdapter = new NodeWSServerAdapter(serverSocket, retry)
326
+
327
+ const _browserRepo = new Repo({
328
+ network: [browserAdapter],
329
+ peerId: browserPeerId,
330
+ })
331
+
332
+ const serverRepo = new Repo({
333
+ network: [serverAdapter],
334
+ peerId: serverPeerId,
335
+ })
336
+
337
+ await eventPromise(serverAdapter, "peer-candidate")
338
+
339
+ // Simulate the client not responding to pings
340
+ browserAdapter.socket!.pong = () => {}
341
+
342
+ await eventPromise(serverAdapter, "peer-disconnected")
343
+ })
344
+
178
345
  it("should send the negotiated protocol version in its hello message", async () => {
179
346
  const response = await serverResponse({
180
347
  type: "join",
181
- senderId: "browser",
348
+ senderId: browserPeerId,
182
349
  supportedProtocolVersions: ["1"],
183
350
  })
184
351
  assert.deepEqual(response, {
@@ -193,7 +360,7 @@ describe("Websocket adapters", () => {
193
360
  it("should return an error message if the protocol version is not supported", async () => {
194
361
  const response = await serverResponse({
195
362
  type: "join",
196
- senderId: "browser",
363
+ senderId: browserPeerId,
197
364
  supportedProtocolVersions: ["fake"],
198
365
  })
199
366
  assert.deepEqual(response, {
@@ -207,7 +374,7 @@ describe("Websocket adapters", () => {
207
374
  it("should respond with protocol v1 if no protocol version is specified", async () => {
208
375
  const response = await serverResponse({
209
376
  type: "join",
210
- senderId: "browser",
377
+ senderId: browserPeerId,
211
378
  })
212
379
  assert.deepEqual(response, {
213
380
  type: "peer",
@@ -218,65 +385,6 @@ describe("Websocket adapters", () => {
218
385
  })
219
386
  })
220
387
 
221
- /**
222
- * Create a new document, initialized with the given contents and return a
223
- * storage containign that document as well as the URL and a fork of the
224
- * document
225
- *
226
- * @param contents - The contents to initialize the document with
227
- */
228
- async function initDocAndStorage<T extends Record<string, unknown>>(
229
- contents: T
230
- ): Promise<{
231
- storage: DummyStorageAdapter
232
- url: AutomergeUrl
233
- doc: A.Doc<T>
234
- documentId: DocumentId
235
- }> {
236
- const storage = new DummyStorageAdapter()
237
- const silentRepo = new Repo({ storage, network: [] })
238
- const doc = A.from<T>(contents)
239
- const handle = silentRepo.create()
240
- handle.update(() => A.clone(doc))
241
- const { documentId } = parseAutomergeUrl(handle.url)
242
- await pause(150)
243
- return {
244
- url: handle.url,
245
- doc,
246
- documentId,
247
- storage,
248
- }
249
- }
250
-
251
- function assertIsPeerMessage(msg: Buffer | null) {
252
- if (msg == null) {
253
- throw new Error("expected a peer message, got null")
254
- }
255
- let decoded = CBOR.decode(msg)
256
- if (decoded.type !== "peer") {
257
- throw new Error(`expected a peer message, got type: ${decoded.type}`)
258
- }
259
- }
260
-
261
- function assertIsSyncMessage(
262
- forDocument: DocumentId,
263
- msg: Buffer | null
264
- ): SyncMessage {
265
- if (msg == null) {
266
- throw new Error("expected a peer message, got null")
267
- }
268
- let decoded = CBOR.decode(msg)
269
- if (decoded.type !== "sync") {
270
- throw new Error(`expected a peer message, got type: ${decoded.type}`)
271
- }
272
- if (decoded.documentId !== forDocument) {
273
- throw new Error(
274
- `expected a sync message for ${forDocument}, not for ${decoded.documentId}`
275
- )
276
- }
277
- return decoded
278
- }
279
-
280
388
  it("should disconnect existing peers on reconnect before announcing them", async () => {
281
389
  // This test exercises a sync loop which is exposed in the following
282
390
  // sequence of events:
@@ -297,7 +405,67 @@ describe("Websocket adapters", () => {
297
405
  // 6 and 7 continue in an infinite loop. The root cause is the servers
298
406
  // failure to clear the sync state associated with the given peer when
299
407
  // it receives a new connection from the same peer ID.
300
- const { socket, serverUrl } = await setup(0)
408
+
409
+ /**
410
+ * Create a new document, initialized with the given contents and return a
411
+ * storage containing that document as well as the URL and a fork of the
412
+ * document
413
+ *
414
+ * @param contents - The contents to initialize the document with
415
+ */
416
+ async function initDocAndStorage<T extends Record<string, unknown>>(
417
+ contents: T
418
+ ): Promise<{
419
+ storage: DummyStorageAdapter
420
+ url: AutomergeUrl
421
+ doc: A.Doc<T>
422
+ documentId: DocumentId
423
+ }> {
424
+ const storage = new DummyStorageAdapter()
425
+ const silentRepo = new Repo({ storage, network: [] })
426
+ const doc = A.from<T>(contents)
427
+ const handle = silentRepo.create()
428
+ handle.update(() => A.clone(doc))
429
+ const { documentId } = parseAutomergeUrl(handle.url)
430
+ await pause(150)
431
+ return {
432
+ url: handle.url,
433
+ doc,
434
+ documentId,
435
+ storage,
436
+ }
437
+ }
438
+
439
+ function assertIsPeerMessage(msg: Buffer | null) {
440
+ if (msg == null) {
441
+ throw new Error("expected a peer message, got null")
442
+ }
443
+ let decoded = CBOR.decode(msg)
444
+ if (decoded.type !== "peer") {
445
+ throw new Error(`expected a peer message, got type: ${decoded.type}`)
446
+ }
447
+ }
448
+
449
+ function assertIsSyncMessage(
450
+ forDocument: DocumentId,
451
+ msg: Buffer | null
452
+ ): SyncMessage {
453
+ if (msg == null) {
454
+ throw new Error("expected a peer message, got null")
455
+ }
456
+ let decoded = CBOR.decode(msg)
457
+ if (decoded.type !== "sync") {
458
+ throw new Error(`expected a peer message, got type: ${decoded.type}`)
459
+ }
460
+ if (decoded.documentId !== forDocument) {
461
+ throw new Error(
462
+ `expected a sync message for ${forDocument}, not for ${decoded.documentId}`
463
+ )
464
+ }
465
+ return decoded
466
+ }
467
+
468
+ const { serverSocket: socket, serverUrl } = await setupServer()
301
469
 
302
470
  // Create a doc, populate a DummyStorageAdapter with that doc
303
471
  const { storage, url, doc, documentId } = await initDocAndStorage({
@@ -313,7 +481,7 @@ describe("Websocket adapters", () => {
313
481
  const repo = new Repo({
314
482
  network: [adapter],
315
483
  storage,
316
- peerId: "server" as PeerId,
484
+ peerId: serverPeerId,
317
485
  })
318
486
 
319
487
  // make a change to the handle on the sync server
@@ -337,7 +505,7 @@ describe("Websocket adapters", () => {
337
505
  })
338
506
  )
339
507
 
340
- let response = await recvOrTimeout(clientSocket)
508
+ let response = await messageOrTimeout(clientSocket)
341
509
  assertIsPeerMessage(response)
342
510
 
343
511
  // Okay now we start syncing
@@ -360,7 +528,7 @@ describe("Websocket adapters", () => {
360
528
  })
361
529
  )
362
530
 
363
- response = await recvOrTimeout(clientSocket)
531
+ response = await messageOrTimeout(clientSocket)
364
532
  assertIsSyncMessage(documentId, response)
365
533
 
366
534
  // Now, assume either the network or the server is going slow, so the
@@ -385,7 +553,7 @@ describe("Websocket adapters", () => {
385
553
  })
386
554
  )
387
555
 
388
- response = await recvOrTimeout(clientSocket)
556
+ response = await messageOrTimeout(clientSocket)
389
557
  assertIsPeerMessage(response)
390
558
 
391
559
  // Now, we start syncing. If we're not buggy, this loop should terminate.
@@ -402,7 +570,7 @@ describe("Websocket adapters", () => {
402
570
  })
403
571
  )
404
572
  }
405
- const response = await recvOrTimeout(clientSocket)
573
+ const response = await messageOrTimeout(clientSocket)
406
574
  if (response) {
407
575
  const decoded = assertIsSyncMessage(documentId, response)
408
576
  ;[clientDoc, clientState] = A.receiveSyncMessage(
@@ -427,5 +595,62 @@ describe("Websocket adapters", () => {
427
595
  })
428
596
  })
429
597
 
430
- export const pause = (t = 0) =>
598
+ // HELPERS
599
+
600
+ const setup = async (options: SetupOptions = {}) => {
601
+ const {
602
+ clientCount = 1,
603
+ retryInterval = 1000,
604
+ port = await getPort(),
605
+ } = options
606
+
607
+ const { server, serverAdapter, serverSocket, serverUrl } = await setupServer(
608
+ options
609
+ )
610
+ const clients = await Promise.all(
611
+ Array.from({ length: clientCount }).map(() =>
612
+ setupClient({ retryInterval, port })
613
+ )
614
+ )
615
+ return { serverSocket, server, port, serverUrl, clients, serverAdapter }
616
+ }
617
+
618
+ const setupServer = async (options: SetupOptions = {}) => {
619
+ const {
620
+ clientCount = 1,
621
+ retryInterval = 1000,
622
+ port = await getPort(),
623
+ } = options
624
+ const serverUrl = `ws://localhost:${port}`
625
+ const server = http.createServer()
626
+ const serverSocket = new WebSocket.Server({ server })
627
+ await new Promise<void>(resolve => server.listen(port, resolve))
628
+ const serverAdapter = new NodeWSServerAdapter(serverSocket, retryInterval)
629
+ return { server, serverAdapter, serverSocket, serverUrl }
630
+ }
631
+
632
+ const setupClient = async (options: SetupOptions = {}) => {
633
+ const {
634
+ clientCount = 1,
635
+ retryInterval = 1000,
636
+ port = await getPort(),
637
+ } = options
638
+ const serverUrl = `ws://localhost:${port}`
639
+ return new BrowserWebSocketClientAdapter(serverUrl, retryInterval)
640
+ }
641
+
642
+ const pause = (t = 0) =>
431
643
  new Promise<void>(resolve => setTimeout(() => resolve(), t))
644
+
645
+ const getPort = () => {
646
+ const base = 3010
647
+ return getAvailablePort({ port: base })
648
+ }
649
+
650
+ // TYPES
651
+
652
+ type SetupOptions = {
653
+ clientCount?: number
654
+ retryInterval?: number
655
+ port?: number
656
+ }