@automerge/automerge-repo-network-websocket 1.1.0-alpha.1 → 1.1.0-alpha.13

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,63 +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",
83
- storageId: undefined,
84
- isEphemeral: true,
63
+ senderId: browserPeerId,
64
+ peerMetadata: { storageId: undefined, isEphemeral: true },
85
65
  supportedProtocolVersions: ["1"],
86
66
  })
87
67
  })
88
68
 
89
- it.skip("should announce disconnections", async () => {
69
+ it("should announce disconnections", async () => {
90
70
  const {
91
- server,
92
- socket,
71
+ serverAdapter,
93
72
  clients: [browserAdapter],
94
73
  } = await setup()
95
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
+
96
101
  const _browserRepo = new Repo({
97
102
  network: [browserAdapter],
98
- peerId: "browser" as PeerId,
103
+ peerId: browserPeerId,
99
104
  })
100
105
 
101
- const serverAdapter = new NodeWSServerAdapter(socket)
102
- const _serverRepo = new Repo({
106
+ await pause(500)
107
+
108
+ const { serverAdapter } = await setupServer({ port, retryInterval })
109
+ const serverRepo = new Repo({
103
110
  network: [serverAdapter],
104
- peerId: "server" as PeerId,
111
+ peerId: serverPeerId,
105
112
  })
106
113
 
107
- const disconnectPromise = new Promise<void>(resolve => {
108
- 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,
109
190
  })
110
191
 
111
- server.close()
112
- 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)
113
218
  })
114
219
 
115
220
  it("should correctly clear event handlers on reconnect", async () => {
@@ -131,12 +236,12 @@ describe("Websocket adapters", () => {
131
236
  } = await setup()
132
237
 
133
238
  const peerId = "testclient" as PeerId
134
- browser.connect(peerId, undefined, true)
239
+ browser.connect(peerId)
135
240
 
136
241
  // simulate the reconnect timer firing before the other end has responded
137
242
  // (which works here because we haven't yielded to the event loop yet so
138
243
  // the server, which is on the same event loop as us, can't respond)
139
- browser.connect(peerId, undefined, true)
244
+ browser.connect(peerId)
140
245
 
141
246
  // Now yield, so the server responds on the first socket, if the listeners
142
247
  // are cleaned up correctly we shouldn't throw
@@ -146,11 +251,13 @@ describe("Websocket adapters", () => {
146
251
 
147
252
  describe("NodeWSServerAdapter", () => {
148
253
  const serverResponse = async (clientHello: Object) => {
149
- const { socket, serverUrl } = await setup(0)
150
- const server = new NodeWSServerAdapter(socket)
254
+ const { serverSocket, serverUrl } = await setup({
255
+ clientCount: 0,
256
+ })
257
+ const server = new NodeWSServerAdapter(serverSocket)
151
258
  const _serverRepo = new Repo({
152
259
  network: [server],
153
- peerId: "server" as PeerId,
260
+ peerId: serverPeerId,
154
261
  })
155
262
 
156
263
  const clientSocket = new WebSocket(serverUrl)
@@ -164,7 +271,7 @@ describe("Websocket adapters", () => {
164
271
  return message
165
272
  }
166
273
 
167
- async function recvOrTimeout(socket: WebSocket): Promise<Buffer | null> {
274
+ async function messageOrTimeout(socket: WebSocket): Promise<Buffer | null> {
168
275
  return new Promise(resolve => {
169
276
  const timer = setTimeout(() => {
170
277
  resolve(null)
@@ -176,17 +283,75 @@ describe("Websocket adapters", () => {
176
283
  })
177
284
  }
178
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
+
179
345
  it("should send the negotiated protocol version in its hello message", async () => {
180
346
  const response = await serverResponse({
181
347
  type: "join",
182
- senderId: "browser",
348
+ senderId: browserPeerId,
183
349
  supportedProtocolVersions: ["1"],
184
350
  })
185
351
  assert.deepEqual(response, {
186
352
  type: "peer",
187
353
  senderId: "server",
188
- storageId: undefined,
189
- isEphemeral: true,
354
+ peerMetadata: { storageId: undefined, isEphemeral: true },
190
355
  targetId: "browser",
191
356
  selectedProtocolVersion: "1",
192
357
  })
@@ -195,7 +360,7 @@ describe("Websocket adapters", () => {
195
360
  it("should return an error message if the protocol version is not supported", async () => {
196
361
  const response = await serverResponse({
197
362
  type: "join",
198
- senderId: "browser",
363
+ senderId: browserPeerId,
199
364
  supportedProtocolVersions: ["fake"],
200
365
  })
201
366
  assert.deepEqual(response, {
@@ -209,77 +374,17 @@ describe("Websocket adapters", () => {
209
374
  it("should respond with protocol v1 if no protocol version is specified", async () => {
210
375
  const response = await serverResponse({
211
376
  type: "join",
212
- senderId: "browser",
377
+ senderId: browserPeerId,
213
378
  })
214
379
  assert.deepEqual(response, {
215
380
  type: "peer",
216
381
  senderId: "server",
217
- storageId: undefined,
218
- isEphemeral: true,
382
+ peerMetadata: { storageId: undefined, isEphemeral: true },
219
383
  targetId: "browser",
220
384
  selectedProtocolVersion: "1",
221
385
  })
222
386
  })
223
387
 
224
- /**
225
- * Create a new document, initialized with the given contents and return a
226
- * storage containign that document as well as the URL and a fork of the
227
- * document
228
- *
229
- * @param contents - The contents to initialize the document with
230
- */
231
- async function initDocAndStorage<T extends Record<string, unknown>>(
232
- contents: T
233
- ): Promise<{
234
- storage: DummyStorageAdapter
235
- url: AutomergeUrl
236
- doc: A.Doc<T>
237
- documentId: DocumentId
238
- }> {
239
- const storage = new DummyStorageAdapter()
240
- const silentRepo = new Repo({ storage, network: [] })
241
- const doc = A.from<T>(contents)
242
- const handle = silentRepo.create()
243
- handle.update(() => A.clone(doc))
244
- const { documentId } = parseAutomergeUrl(handle.url)
245
- await pause(150)
246
- return {
247
- url: handle.url,
248
- doc,
249
- documentId,
250
- storage,
251
- }
252
- }
253
-
254
- function assertIsPeerMessage(msg: Buffer | null) {
255
- if (msg == null) {
256
- throw new Error("expected a peer message, got null")
257
- }
258
- let decoded = CBOR.decode(msg)
259
- if (decoded.type !== "peer") {
260
- throw new Error(`expected a peer message, got type: ${decoded.type}`)
261
- }
262
- }
263
-
264
- function assertIsSyncMessage(
265
- forDocument: DocumentId,
266
- msg: Buffer | null
267
- ): SyncMessage {
268
- if (msg == null) {
269
- throw new Error("expected a peer message, got null")
270
- }
271
- let decoded = CBOR.decode(msg)
272
- if (decoded.type !== "sync") {
273
- throw new Error(`expected a peer message, got type: ${decoded.type}`)
274
- }
275
- if (decoded.documentId !== forDocument) {
276
- throw new Error(
277
- `expected a sync message for ${forDocument}, not for ${decoded.documentId}`
278
- )
279
- }
280
- return decoded
281
- }
282
-
283
388
  it("should disconnect existing peers on reconnect before announcing them", async () => {
284
389
  // This test exercises a sync loop which is exposed in the following
285
390
  // sequence of events:
@@ -300,7 +405,67 @@ describe("Websocket adapters", () => {
300
405
  // 6 and 7 continue in an infinite loop. The root cause is the servers
301
406
  // failure to clear the sync state associated with the given peer when
302
407
  // it receives a new connection from the same peer ID.
303
- 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()
304
469
 
305
470
  // Create a doc, populate a DummyStorageAdapter with that doc
306
471
  const { storage, url, doc, documentId } = await initDocAndStorage({
@@ -316,7 +481,7 @@ describe("Websocket adapters", () => {
316
481
  const repo = new Repo({
317
482
  network: [adapter],
318
483
  storage,
319
- peerId: "server" as PeerId,
484
+ peerId: serverPeerId,
320
485
  })
321
486
 
322
487
  // make a change to the handle on the sync server
@@ -340,7 +505,7 @@ describe("Websocket adapters", () => {
340
505
  })
341
506
  )
342
507
 
343
- let response = await recvOrTimeout(clientSocket)
508
+ let response = await messageOrTimeout(clientSocket)
344
509
  assertIsPeerMessage(response)
345
510
 
346
511
  // Okay now we start syncing
@@ -363,7 +528,7 @@ describe("Websocket adapters", () => {
363
528
  })
364
529
  )
365
530
 
366
- response = await recvOrTimeout(clientSocket)
531
+ response = await messageOrTimeout(clientSocket)
367
532
  assertIsSyncMessage(documentId, response)
368
533
 
369
534
  // Now, assume either the network or the server is going slow, so the
@@ -388,7 +553,7 @@ describe("Websocket adapters", () => {
388
553
  })
389
554
  )
390
555
 
391
- response = await recvOrTimeout(clientSocket)
556
+ response = await messageOrTimeout(clientSocket)
392
557
  assertIsPeerMessage(response)
393
558
 
394
559
  // Now, we start syncing. If we're not buggy, this loop should terminate.
@@ -405,7 +570,7 @@ describe("Websocket adapters", () => {
405
570
  })
406
571
  )
407
572
  }
408
- const response = await recvOrTimeout(clientSocket)
573
+ const response = await messageOrTimeout(clientSocket)
409
574
  if (response) {
410
575
  const decoded = assertIsSyncMessage(documentId, response)
411
576
  ;[clientDoc, clientState] = A.receiveSyncMessage(
@@ -430,5 +595,62 @@ describe("Websocket adapters", () => {
430
595
  })
431
596
  })
432
597
 
433
- 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) =>
434
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
+ }