@automerge/automerge-repo-network-websocket 1.0.9 → 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.
@@ -8,6 +8,7 @@ declare abstract class WebSocketNetworkAdapter extends NetworkAdapter {
8
8
  export declare class BrowserWebSocketClientAdapter extends WebSocketNetworkAdapter {
9
9
  #private;
10
10
  timerId?: ReturnType<typeof setTimeout>;
11
+ remotePeerId?: PeerId;
11
12
  url: string;
12
13
  constructor(url: string);
13
14
  connect(peerId: PeerId): void;
@@ -1 +1 @@
1
- {"version":3,"file":"BrowserWebSocketClientAdapter.d.ts","sourceRoot":"","sources":["../src/BrowserWebSocketClientAdapter.ts"],"names":[],"mappings":";AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,EAAQ,MAAM,2BAA2B,CAAA;AACxE,OAAO,SAAS,MAAM,eAAe,CAAA;AAIrC,OAAO,EACL,iBAAiB,EAGlB,MAAM,eAAe,CAAA;AAMtB,uBAAe,uBAAwB,SAAQ,cAAc;IAC3D,MAAM,CAAC,EAAE,SAAS,CAAA;CACnB;AAED,qBAAa,6BAA8B,SAAQ,uBAAuB;;IAGxE,OAAO,CAAC,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,CAAA;IAGvC,GAAG,EAAE,MAAM,CAAA;gBAEC,GAAG,EAAE,MAAM;IAKvB,OAAO,CAAC,MAAM,EAAE,MAAM;IAkCtB,MAAM,aAKL;IAGD,OAAO,aAON;IAED,SAAS,UAAW,sBAAsB,UAEzC;IAED,IAAI;IAWJ,UAAU;IAOV,IAAI,CAAC,OAAO,EAAE,iBAAiB;IAwB/B,kBAAkB,CAAC,MAAM,EAAE,MAAM;IAajC,cAAc,CAAC,OAAO,EAAE,UAAU;CA6BnC"}
1
+ {"version":3,"file":"BrowserWebSocketClientAdapter.d.ts","sourceRoot":"","sources":["../src/BrowserWebSocketClientAdapter.ts"],"names":[],"mappings":";AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,EAAQ,MAAM,2BAA2B,CAAA;AACxE,OAAO,SAAS,MAAM,eAAe,CAAA;AAIrC,OAAO,EACL,iBAAiB,EAGlB,MAAM,eAAe,CAAA;AAMtB,uBAAe,uBAAwB,SAAQ,cAAc;IAC3D,MAAM,CAAC,EAAE,SAAS,CAAA;CACnB;AAED,qBAAa,6BAA8B,SAAQ,uBAAuB;;IAGxE,OAAO,CAAC,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,CAAA;IACvC,YAAY,CAAC,EAAE,MAAM,CAAA;IAGrB,GAAG,EAAE,MAAM,CAAA;gBAEC,GAAG,EAAE,MAAM;IAKvB,OAAO,CAAC,MAAM,EAAE,MAAM;IAkCtB,MAAM,aAKL;IAGD,OAAO,aAYN;IAED,SAAS,UAAW,sBAAsB,UAEzC;IAED,IAAI;IAWJ,UAAU;IAOV,IAAI,CAAC,OAAO,EAAE,iBAAiB;IAwB/B,kBAAkB,CAAC,MAAM,EAAE,MAAM;IAcjC,cAAc,CAAC,OAAO,EAAE,UAAU;CA6BnC"}
@@ -11,6 +11,7 @@ export class BrowserWebSocketClientAdapter extends WebSocketNetworkAdapter {
11
11
  // Type trickery required for platform independence,
12
12
  // see https://stackoverflow.com/questions/45802988/typescript-use-correct-version-of-settimeout-node-vs-window
13
13
  timerId;
14
+ remotePeerId; // this adapter only connects to one remote client at a time
14
15
  #startupComplete = false;
15
16
  url;
16
17
  constructor(url) {
@@ -54,6 +55,9 @@ export class BrowserWebSocketClientAdapter extends WebSocketNetworkAdapter {
54
55
  // When a socket closes, or disconnects, remove it from the array.
55
56
  onClose = () => {
56
57
  log(`${this.url}: close`);
58
+ if (this.remotePeerId) {
59
+ this.emit("peer-disconnected", { peerId: this.remotePeerId });
60
+ }
57
61
  if (!this.timerId) {
58
62
  if (this.peerId) {
59
63
  this.connect(this.peerId);
@@ -106,6 +110,7 @@ export class BrowserWebSocketClientAdapter extends WebSocketNetworkAdapter {
106
110
  this.#startupComplete = true;
107
111
  this.emit("ready", { network: this });
108
112
  }
113
+ this.remotePeerId = peerId;
109
114
  this.emit("peer-candidate", { peerId });
110
115
  }
111
116
  receiveMessage(message) {
@@ -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";
@@ -50,7 +51,7 @@ export class NodeWSServerAdapter extends NetworkAdapter {
50
51
  socket.isAlive = false;
51
52
  socket.ping();
52
53
  });
53
- }, 30000);
54
+ }, 5000);
54
55
  }
55
56
  disconnect() {
56
57
  // throw new Error("The server doesn't join channels.")
@@ -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.9",
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.9",
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": "c277a6e8546cdd14236070d033ca62db68dfdeb3"
36
+ "gitHead": "aaead1625f7b1b79d239bef5bce9b3cf3d725332"
37
37
  }
@@ -21,6 +21,7 @@ export class BrowserWebSocketClientAdapter extends WebSocketNetworkAdapter {
21
21
  // Type trickery required for platform independence,
22
22
  // see https://stackoverflow.com/questions/45802988/typescript-use-correct-version-of-settimeout-node-vs-window
23
23
  timerId?: ReturnType<typeof setTimeout>
24
+ remotePeerId?: PeerId // this adapter only connects to one remote client at a time
24
25
  #startupComplete: boolean = false
25
26
 
26
27
  url: string
@@ -74,6 +75,11 @@ export class BrowserWebSocketClientAdapter extends WebSocketNetworkAdapter {
74
75
  // When a socket closes, or disconnects, remove it from the array.
75
76
  onClose = () => {
76
77
  log(`${this.url}: close`)
78
+
79
+ if (this.remotePeerId) {
80
+ this.emit("peer-disconnected", { peerId: this.remotePeerId })
81
+ }
82
+
77
83
  if (!this.timerId) {
78
84
  if (this.peerId) {
79
85
  this.connect(this.peerId)
@@ -137,6 +143,7 @@ export class BrowserWebSocketClientAdapter extends WebSocketNetworkAdapter {
137
143
  this.#startupComplete = true
138
144
  this.emit("ready", { network: this })
139
145
  }
146
+ this.remotePeerId = peerId
140
147
  this.emit("peer-candidate", { peerId })
141
148
  }
142
149
 
@@ -75,7 +75,7 @@ export class NodeWSServerAdapter extends NetworkAdapter {
75
75
  socket.isAlive = false
76
76
  socket.ping()
77
77
  })
78
- }, 30000)
78
+ }, 5000)
79
79
  }
80
80
 
81
81
  disconnect() {
@@ -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,39 +1,42 @@
1
- import { runAdapterTests } from "../../automerge-repo/src/helpers/tests/network-adapter-tests.js"
2
- import { BrowserWebSocketClientAdapter } from "../src/BrowserWebSocketClientAdapter.js"
3
- import { NodeWSServerAdapter } from "../src/NodeWSServerAdapter.js"
4
- import { startServer } from "./utilities/WebSockets.js"
5
- import * as CBOR from "cbor-x"
6
- import WebSocket, { AddressInfo } from "ws"
1
+ import { next as A } from "@automerge/automerge"
2
+ import { AutomergeUrl, DocumentId, PeerId, Repo, SyncMessage, parseAutomergeUrl } from "@automerge/automerge-repo"
7
3
  import assert from "assert"
8
- import { PeerId, Repo } from "@automerge/automerge-repo"
4
+ import * as CBOR from "cbor-x"
9
5
  import { once } from "events"
6
+ import http from "http"
10
7
  import { describe, it } from "vitest"
8
+ import WebSocket, { AddressInfo } from "ws"
9
+ import { runAdapterTests } from "../../automerge-repo/src/helpers/tests/network-adapter-tests.js"
10
+ import { DummyStorageAdapter } from "../../automerge-repo/test/helpers/DummyStorageAdapter.js"
11
+ import { BrowserWebSocketClientAdapter } from "../src/BrowserWebSocketClientAdapter.js"
12
+ import { NodeWSServerAdapter } from "../src/NodeWSServerAdapter.js"
13
+ import {headsAreSame} from "@automerge/automerge-repo/src/helpers/headsAreSame.js"
11
14
 
12
- describe("Websocket adapters", async () => {
13
- let port = 8080
15
+ describe("Websocket adapters", () => {
16
+ const setup = async (clientCount = 1) => {
17
+ const server = http.createServer()
18
+ const socket = new WebSocket.Server({ server })
14
19
 
15
- runAdapterTests(async () => {
16
- let socket: WebSocket.Server | undefined = undefined
17
- let server: any
18
-
19
- while (socket === undefined) {
20
- try {
21
- ;({ socket, server } = await startServer(port))
22
- } catch (e: any) {
23
- if (e.code === "EADDRINUSE") {
24
- port++
25
- } else {
26
- throw e
27
- }
28
- }
29
- }
20
+ await new Promise<void>(resolve => server.listen(0, resolve))
21
+ const { port } = server.address() as AddressInfo
22
+ const serverUrl = `ws://localhost:${port}`
30
23
 
31
- const serverAdapter = new NodeWSServerAdapter(socket)
24
+ const clients = [] as BrowserWebSocketClientAdapter[]
25
+ for (let i = 0; i < clientCount; i++) {
26
+ clients.push(new BrowserWebSocketClientAdapter(serverUrl))
27
+ }
32
28
 
33
- const serverUrl = `ws://localhost:${port}`
29
+ return { socket, server, port, serverUrl, clients }
30
+ }
34
31
 
35
- const aliceAdapter = new BrowserWebSocketClientAdapter(serverUrl)
36
- const bobAdapter = new BrowserWebSocketClientAdapter(serverUrl)
32
+ // run adapter acceptance tests
33
+ runAdapterTests(async () => {
34
+ const {
35
+ clients: [aliceAdapter, bobAdapter],
36
+ socket,
37
+ server,
38
+ } = await setup(2)
39
+ const serverAdapter = new NodeWSServerAdapter(socket)
37
40
 
38
41
  const teardown = () => {
39
42
  server.close()
@@ -41,140 +44,350 @@ describe("Websocket adapters", async () => {
41
44
 
42
45
  return { adapters: [aliceAdapter, serverAdapter, bobAdapter], teardown }
43
46
  })
44
- })
45
47
 
46
- describe("The BrowserWebSocketClientAdapter", () => {
47
- it("should advertise the protocol versions it supports in its join message", async () => {
48
- const { socket, server } = await startServer(0)
49
- let port = (server.address()!! as AddressInfo).port
50
- const serverUrl = `ws://localhost:${port}`
51
- const helloPromise = firstMessage(socket)
48
+ describe("BrowserWebSocketClientAdapter", () => {
49
+ const firstMessage = async (socket: WebSocket.Server<any>) =>
50
+ new Promise((resolve, reject) => {
51
+ socket.once("connection", ws => {
52
+ ws.once("message", (message: any) => resolve(message))
53
+ ws.once("error", (error: any) => reject(error))
54
+ })
55
+ socket.once("error", error => reject(error))
56
+ })
52
57
 
53
- const client = new BrowserWebSocketClientAdapter(serverUrl)
54
- const repo = new Repo({ network: [client], peerId: "browser" as PeerId })
58
+ it("should advertise the protocol versions it supports in its join message", async () => {
59
+ const {
60
+ socket,
61
+ clients: [browser],
62
+ } = await setup()
55
63
 
56
- const hello = await helloPromise
64
+ const helloPromise = firstMessage(socket)
57
65
 
58
- const message = CBOR.decode(hello as Uint8Array)
59
- assert.deepEqual(message, {
60
- type: "join",
61
- senderId: "browser",
62
- supportedProtocolVersions: ["1"],
63
- })
64
- })
65
- })
66
+ const _repo = new Repo({
67
+ network: [browser],
68
+ peerId: "browser" as PeerId,
69
+ })
66
70
 
67
- describe("The NodeWSServerAdapter", () => {
68
- it("should send the negotiated protocol version in its hello message", async () => {
69
- const response = await serverHelloGivenClientHello({
70
- type: "join",
71
- senderId: "browser",
72
- supportedProtocolVersions: ["1"],
73
- })
74
- assert.deepEqual<any>(response, {
75
- type: "peer",
76
- senderId: "server",
77
- targetId: "browser",
78
- selectedProtocolVersion: "1",
71
+ const encodedMessage = await helloPromise
72
+ const message = CBOR.decode(encodedMessage as Uint8Array)
73
+ assert.deepEqual(message, {
74
+ type: "join",
75
+ senderId: "browser",
76
+ supportedProtocolVersions: ["1"],
77
+ })
79
78
  })
80
- })
81
79
 
82
- it("should return an error message if the protocol version is not supported", async () => {
83
- const response = await serverHelloGivenClientHello({
84
- type: "join",
85
- senderId: "browser",
86
- supportedProtocolVersions: ["fake"],
80
+ it.skip("should announce disconnections", async () => {
81
+ const {
82
+ server,
83
+ socket,
84
+ clients: [browserAdapter],
85
+ } = await setup()
86
+
87
+ const _browserRepo = new Repo({
88
+ network: [browserAdapter],
89
+ peerId: "browser" as PeerId,
90
+ })
91
+
92
+ const serverAdapter = new NodeWSServerAdapter(socket)
93
+ const _serverRepo = new Repo({
94
+ network: [serverAdapter],
95
+ peerId: "server" as PeerId,
96
+ })
97
+
98
+ const disconnectPromise = new Promise<void>(resolve => {
99
+ browserAdapter.on("peer-disconnected", () => resolve())
100
+ })
101
+
102
+ server.close()
103
+ await disconnectPromise
87
104
  })
88
- assert.deepEqual<any>(response, {
89
- type: "error",
90
- senderId: "server",
91
- targetId: "browser",
92
- message: "unsupported protocol version",
105
+
106
+ it("should correctly clear event handlers on reconnect", async () => {
107
+ // This reproduces an issue where the BrowserWebSocketClientAdapter.connect
108
+ // call registered event handlers on the websocket but didn't clear them
109
+ // up again on a second call to connect. This combined with the reconnect
110
+ // timer to produce the following sequence of events:
111
+ //
112
+ // - first call to connect creates a socket and registers an "open"
113
+ // handler. The "open" handler will attempt to send a join message
114
+ // - The connection is slow, so the reconnect timer fires before "open",
115
+ // the reconnect timer calls connect again. this.socket is now a new socket
116
+ // - The other end replies to the first socket, "open"
117
+ // - The original "open" handler attempts to send a message, but on the new
118
+ // socket (because it uses `this.socket`), which isn't open yet, so we get an
119
+ // error that the socket is not ready
120
+ const {
121
+ clients: [browser],
122
+ } = await setup()
123
+
124
+ const peerId = "testclient" as PeerId
125
+ browser.connect(peerId)
126
+
127
+ // simulate the reconnect timer firing before the other end has responded
128
+ // (which works here because we haven't yielded to the event loop yet so
129
+ // the server, which is on the same event loop as us, can't respond)
130
+ browser.connect(peerId)
131
+
132
+ // Now yield, so the server responds on the first socket, if the listeners
133
+ // are cleaned up correctly we shouldn't throw
134
+ await pause(1)
93
135
  })
94
136
  })
95
137
 
96
- it("should respond with protocol v1 if no protocol version is specified", async () => {
97
- const response = await serverHelloGivenClientHello({
98
- type: "join",
99
- senderId: "browser",
138
+ describe("NodeWSServerAdapter", () => {
139
+ const serverResponse = async (clientHello: Object) => {
140
+ const { socket, serverUrl } = await setup(0)
141
+ const server = new NodeWSServerAdapter(socket)
142
+ const _serverRepo = new Repo({
143
+ network: [server],
144
+ peerId: "server" as PeerId,
145
+ })
146
+
147
+ const clientSocket = new WebSocket(serverUrl)
148
+ await once(clientSocket, "open")
149
+ const serverHelloPromise = once(clientSocket, "message")
150
+
151
+ clientSocket.send(CBOR.encode(clientHello))
152
+
153
+ const serverHello = await serverHelloPromise
154
+ const message = CBOR.decode(serverHello[0] as Uint8Array)
155
+ return message
156
+ }
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
+
170
+ it("should send the negotiated protocol version in its hello message", async () => {
171
+ const response = await serverResponse({
172
+ type: "join",
173
+ senderId: "browser",
174
+ supportedProtocolVersions: ["1"],
175
+ })
176
+ assert.deepEqual(response, {
177
+ type: "peer",
178
+ senderId: "server",
179
+ targetId: "browser",
180
+ selectedProtocolVersion: "1",
181
+ })
100
182
  })
101
- assert.deepEqual<any>(response, {
102
- type: "peer",
103
- senderId: "server",
104
- targetId: "browser",
105
- selectedProtocolVersion: "1",
183
+
184
+ it("should return an error message if the protocol version is not supported", async () => {
185
+ const response = await serverResponse({
186
+ type: "join",
187
+ senderId: "browser",
188
+ supportedProtocolVersions: ["fake"],
189
+ })
190
+ assert.deepEqual(response, {
191
+ type: "error",
192
+ senderId: "server",
193
+ targetId: "browser",
194
+ message: "unsupported protocol version",
195
+ })
106
196
  })
107
- })
108
-
109
- it("should correctly clear event handlers on reconnect", async () => {
110
- // This reproduces an issue where the BrowserWebSocketClientAdapter.connect
111
- // call registered event handlers on the websocket but didn't clear them
112
- // up again on a second call to connect. This combined with the reconnect
113
- // timer to produce the following sequence of events:
114
- //
115
- // * first call to connect creates a socket and registers an "open"
116
- // handler. The "open" handler will attempt to send a join message
117
- // * The connection is slow, so the reconnect timer fires before "open",
118
- // the reconnect timer calls connect again. this.socket is now a new socket
119
- // * The other end replies to the first socket, "open"
120
- // * The original "open" handler attempts to send a message, but on the new
121
- // socket (because it uses this.socket), which isn't open yet, so we get an
122
- // error that the socket is not ready
123
- const { socket, server } = await startServer(0)
124
- let port = (server.address()!! as AddressInfo).port
125
- const serverUrl = `ws://localhost:${port}`
126
- const serverAdapter = new NodeWSServerAdapter(socket)
127
- const browserAdapter = new BrowserWebSocketClientAdapter(serverUrl)
128
-
129
- const peerId = "testclient" as PeerId
130
- browserAdapter.connect(peerId)
131
- // simulate the reconnect timer firing before the other end has responded
132
- // (which works here because we haven't yielded to the event loop yet so
133
- // the server, which is on the same event loop as us, can't respond)
134
- browserAdapter.connect(peerId)
135
- // Now await, so the server responds on the first socket, if the listeners
136
- // are cleaned up correctly we shouldn't throw
137
- await pause(1)
138
- })
139
- })
140
197
 
141
- async function serverHelloGivenClientHello(
142
- clientHello: Object
143
- ): Promise<Object | null> {
144
- const { socket, server } = await startServer(0)
145
- let port = (server.address()!! as AddressInfo).port
146
- const serverUrl = `ws://localhost:${port}`
147
- const adapter = new NodeWSServerAdapter(socket)
148
- const repo = new Repo({ network: [adapter], peerId: "server" as PeerId })
149
-
150
- const clientSocket = new WebSocket(serverUrl)
151
- await once(clientSocket, "open")
152
- const serverHelloPromise = once(clientSocket, "message")
153
-
154
- clientSocket.send(CBOR.encode(clientHello))
155
-
156
- const serverHello = await serverHelloPromise
157
- const message = CBOR.decode(serverHello[0] as Uint8Array)
158
- return message
159
- }
160
-
161
- async function firstMessage(
162
- socket: WebSocket.Server<any>
163
- ): Promise<Object | null> {
164
- return new Promise((resolve, reject) => {
165
- socket.once("connection", ws => {
166
- ws.once("message", (message: any) => {
167
- resolve(message)
198
+ it("should respond with protocol v1 if no protocol version is specified", async () => {
199
+ const response = await serverResponse({
200
+ type: "join",
201
+ senderId: "browser",
168
202
  })
169
- ws.once("error", (error: any) => {
170
- reject(error)
203
+ assert.deepEqual(response, {
204
+ type: "peer",
205
+ senderId: "server",
206
+ targetId: "browser",
207
+ selectedProtocolVersion: "1",
171
208
  })
172
209
  })
173
- socket.once("error", error => {
174
- reject(error)
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
+ }
175
388
  })
176
389
  })
177
- }
390
+ })
178
391
 
179
392
  export const pause = (t = 0) =>
180
393
  new Promise<void>(resolve => setTimeout(() => resolve(), t))
@@ -1,8 +0,0 @@
1
- import http from "http"
2
- import WebSocket from "ws"
3
-
4
- function createWebSocketServer(server: http.Server) {
5
- return new WebSocket.Server({ server })
6
- }
7
-
8
- export { createWebSocketServer }
@@ -1,34 +0,0 @@
1
- import http from "http"
2
- import { createWebSocketServer } from "./CreateWebSocketServer.js"
3
- import WebSocket from "ws"
4
-
5
- function startServer(port: number) {
6
- const server = http.createServer()
7
- const socket = createWebSocketServer(server)
8
- return new Promise<{
9
- socket: WebSocket.Server
10
- server: http.Server
11
- }>(resolve => {
12
- server.listen(port, () => resolve({ socket, server }))
13
- })
14
- }
15
-
16
- type WebSocketState =
17
- | typeof WebSocket.CONNECTING
18
- | typeof WebSocket.OPEN
19
- | typeof WebSocket.CLOSING
20
- | typeof WebSocket.CLOSED
21
-
22
- function waitForSocketState(socket: WebSocket, state: WebSocketState) {
23
- return new Promise<void>(function (resolve) {
24
- setTimeout(function () {
25
- if (socket.readyState === state) {
26
- resolve()
27
- } else {
28
- waitForSocketState(socket, state).then(resolve)
29
- }
30
- }, 5)
31
- })
32
- }
33
-
34
- export { startServer, waitForSocketState }