@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.
- package/dist/BrowserWebSocketClientAdapter.d.ts +1 -0
- package/dist/BrowserWebSocketClientAdapter.d.ts.map +1 -1
- package/dist/BrowserWebSocketClientAdapter.js +5 -0
- package/dist/NodeWSServerAdapter.d.ts.map +1 -1
- package/dist/NodeWSServerAdapter.js +9 -1
- package/package.json +3 -3
- package/src/BrowserWebSocketClientAdapter.ts +7 -0
- package/src/NodeWSServerAdapter.ts +9 -1
- package/test/Websocket.test.ts +356 -143
- package/test/utilities/CreateWebSocketServer.ts +0 -8
- package/test/utilities/WebSockets.ts +0 -34
|
@@ -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;
|
|
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;
|
|
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
|
-
},
|
|
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.
|
|
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.
|
|
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": "
|
|
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
|
-
},
|
|
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
|
package/test/Websocket.test.ts
CHANGED
|
@@ -1,39 +1,42 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
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",
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
24
|
+
const clients = [] as BrowserWebSocketClientAdapter[]
|
|
25
|
+
for (let i = 0; i < clientCount; i++) {
|
|
26
|
+
clients.push(new BrowserWebSocketClientAdapter(serverUrl))
|
|
27
|
+
}
|
|
32
28
|
|
|
33
|
-
|
|
29
|
+
return { socket, server, port, serverUrl, clients }
|
|
30
|
+
}
|
|
34
31
|
|
|
35
|
-
|
|
36
|
-
|
|
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("
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
64
|
+
const helloPromise = firstMessage(socket)
|
|
57
65
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
supportedProtocolVersions: ["1"],
|
|
63
|
-
})
|
|
64
|
-
})
|
|
65
|
-
})
|
|
66
|
+
const _repo = new Repo({
|
|
67
|
+
network: [browser],
|
|
68
|
+
peerId: "browser" as PeerId,
|
|
69
|
+
})
|
|
66
70
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
170
|
-
|
|
203
|
+
assert.deepEqual(response, {
|
|
204
|
+
type: "peer",
|
|
205
|
+
senderId: "server",
|
|
206
|
+
targetId: "browser",
|
|
207
|
+
selectedProtocolVersion: "1",
|
|
171
208
|
})
|
|
172
209
|
})
|
|
173
|
-
|
|
174
|
-
|
|
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,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 }
|