@automerge/automerge-repo-network-websocket 0.0.1
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/.mocharc.json +5 -0
- package/README.md +5 -0
- package/dist/BrowserWebSocketClientAdapter.d.ts +21 -0
- package/dist/BrowserWebSocketClientAdapter.d.ts.map +1 -0
- package/dist/BrowserWebSocketClientAdapter.js +124 -0
- package/dist/NodeWSServerAdapter.d.ts +16 -0
- package/dist/NodeWSServerAdapter.d.ts.map +1 -0
- package/dist/NodeWSServerAdapter.js +97 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/package.json +34 -0
- package/src/BrowserWebSocketClientAdapter.ts +178 -0
- package/src/NodeWSServerAdapter.ts +143 -0
- package/src/index.ts +2 -0
- package/test/Websocket.test.ts +25 -0
- package/test/utilities/CreateWebSocketServer.ts +8 -0
- package/test/utilities/WebSockets.ts +34 -0
- package/tsconfig.json +16 -0
package/.mocharc.json
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Automerge-Repo Network: Websocket
|
|
2
|
+
|
|
3
|
+
Includes two implementations, a Websocket client and a Websocket server. These are used by the example sync-server.
|
|
4
|
+
|
|
5
|
+
The package uses isomorphic-ws to share code between node and the browser, but the server code is node only due to lack of browser support.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/// <reference types="ws" />
|
|
3
|
+
import { ChannelId, NetworkAdapter, PeerId } from "@automerge/automerge-repo";
|
|
4
|
+
import WebSocket from "isomorphic-ws";
|
|
5
|
+
declare abstract class WebSocketNetworkAdapter extends NetworkAdapter {
|
|
6
|
+
socket?: WebSocket;
|
|
7
|
+
}
|
|
8
|
+
export declare class BrowserWebSocketClientAdapter extends WebSocketNetworkAdapter {
|
|
9
|
+
timerId?: NodeJS.Timer;
|
|
10
|
+
url: string;
|
|
11
|
+
channels: ChannelId[];
|
|
12
|
+
constructor(url: string);
|
|
13
|
+
connect(peerId: PeerId): void;
|
|
14
|
+
join(channelId: ChannelId): void;
|
|
15
|
+
leave(channelId: ChannelId): void;
|
|
16
|
+
sendMessage(targetId: PeerId, channelId: ChannelId, message: Uint8Array, broadcast: boolean): void;
|
|
17
|
+
announceConnection(channelId: ChannelId, peerId: PeerId): void;
|
|
18
|
+
receiveMessage(message: Uint8Array): void;
|
|
19
|
+
}
|
|
20
|
+
export {};
|
|
21
|
+
//# sourceMappingURL=BrowserWebSocketClientAdapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BrowserWebSocketClientAdapter.d.ts","sourceRoot":"","sources":["../src/BrowserWebSocketClientAdapter.ts"],"names":[],"mappings":";;AAAA,OAAO,EACL,SAAS,EAET,cAAc,EACd,MAAM,EACP,MAAM,2BAA2B,CAAA;AAElC,OAAO,SAAS,MAAM,eAAe,CAAA;AAKrC,uBAAe,uBAAwB,SAAQ,cAAc;IAC3D,MAAM,CAAC,EAAE,SAAS,CAAA;CACnB;AAED,qBAAa,6BAA8B,SAAQ,uBAAuB;IACxE,OAAO,CAAC,EAAE,MAAM,CAAC,KAAK,CAAA;IACtB,GAAG,EAAE,MAAM,CAAA;IACX,QAAQ,EAAE,SAAS,EAAE,CAAK;gBAEd,GAAG,EAAE,MAAM;IAKvB,OAAO,CAAC,MAAM,EAAE,MAAM;IA8BtB,IAAI,CAAC,SAAS,EAAE,SAAS;IA6BzB,KAAK,CAAC,SAAS,EAAE,SAAS;IAU1B,WAAW,CACT,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,SAAS,EACpB,OAAO,EAAE,UAAU,EACnB,SAAS,EAAE,OAAO;IAgCpB,kBAAkB,CAAC,SAAS,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM;IAUvD,cAAc,CAAC,OAAO,EAAE,UAAU;CAoCnC"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { NetworkAdapter, } from "@automerge/automerge-repo";
|
|
2
|
+
import * as CBOR from "cbor-x";
|
|
3
|
+
import WebSocket from "isomorphic-ws";
|
|
4
|
+
import debug from "debug";
|
|
5
|
+
const log = debug("WebsocketClient");
|
|
6
|
+
class WebSocketNetworkAdapter extends NetworkAdapter {
|
|
7
|
+
socket;
|
|
8
|
+
}
|
|
9
|
+
export class BrowserWebSocketClientAdapter extends WebSocketNetworkAdapter {
|
|
10
|
+
timerId;
|
|
11
|
+
url;
|
|
12
|
+
channels = [];
|
|
13
|
+
constructor(url) {
|
|
14
|
+
super();
|
|
15
|
+
this.url = url;
|
|
16
|
+
}
|
|
17
|
+
connect(peerId) {
|
|
18
|
+
if (!this.timerId) {
|
|
19
|
+
this.timerId = setInterval(() => this.connect(peerId), 5000);
|
|
20
|
+
}
|
|
21
|
+
this.peerId = peerId;
|
|
22
|
+
this.socket = new WebSocket(this.url);
|
|
23
|
+
this.socket.binaryType = "arraybuffer";
|
|
24
|
+
this.socket.addEventListener("open", () => {
|
|
25
|
+
log(`@ ${this.url}: open`);
|
|
26
|
+
clearInterval(this.timerId);
|
|
27
|
+
this.timerId = undefined;
|
|
28
|
+
this.channels.forEach(c => this.join(c));
|
|
29
|
+
});
|
|
30
|
+
// When a socket closes, or disconnects, remove it from the array.
|
|
31
|
+
this.socket.addEventListener("close", () => {
|
|
32
|
+
log(`${this.url}: close`);
|
|
33
|
+
if (!this.timerId) {
|
|
34
|
+
this.connect(peerId);
|
|
35
|
+
}
|
|
36
|
+
// log("Disconnected from server")
|
|
37
|
+
});
|
|
38
|
+
this.socket.addEventListener("message", (event) => this.receiveMessage(event.data));
|
|
39
|
+
}
|
|
40
|
+
join(channelId) {
|
|
41
|
+
// TODO: the network subsystem should manage this
|
|
42
|
+
if (!this.channels.includes(channelId)) {
|
|
43
|
+
this.channels.push(channelId);
|
|
44
|
+
}
|
|
45
|
+
if (!this.socket) {
|
|
46
|
+
throw new Error("WTF, get a socket");
|
|
47
|
+
}
|
|
48
|
+
if (this.socket.readyState === WebSocket.OPEN) {
|
|
49
|
+
this.socket.send(CBOR.encode({ type: "join", channelId, senderId: this.peerId }));
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
this.socket.addEventListener("open", () => {
|
|
53
|
+
if (!this.socket) {
|
|
54
|
+
throw new Error("WTF, get a socket");
|
|
55
|
+
}
|
|
56
|
+
this.socket.send(CBOR.encode({ type: "join", channelId, senderId: this.peerId }));
|
|
57
|
+
}, { once: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
leave(channelId) {
|
|
61
|
+
this.channels = this.channels.filter(c => c !== channelId);
|
|
62
|
+
if (!this.socket) {
|
|
63
|
+
throw new Error("WTF, get a socket");
|
|
64
|
+
}
|
|
65
|
+
this.socket.send(CBOR.encode({ type: "leave", channelId, senderId: this.peerId }));
|
|
66
|
+
}
|
|
67
|
+
sendMessage(targetId, channelId, message, broadcast) {
|
|
68
|
+
if (message.byteLength === 0) {
|
|
69
|
+
throw new Error("tried to send a zero-length message");
|
|
70
|
+
}
|
|
71
|
+
if (!this.peerId) {
|
|
72
|
+
throw new Error("Why don't we have a PeerID?");
|
|
73
|
+
}
|
|
74
|
+
const decoded = {
|
|
75
|
+
senderId: this.peerId,
|
|
76
|
+
targetId,
|
|
77
|
+
channelId,
|
|
78
|
+
type: "message",
|
|
79
|
+
message,
|
|
80
|
+
broadcast,
|
|
81
|
+
};
|
|
82
|
+
const encoded = CBOR.encode(decoded);
|
|
83
|
+
// This incantation deals with websocket sending the whole
|
|
84
|
+
// underlying buffer even if we just have a uint8array view on it
|
|
85
|
+
const arrayBuf = encoded.buffer.slice(encoded.byteOffset, encoded.byteOffset + encoded.byteLength);
|
|
86
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
87
|
+
throw new Error("Websocket Socket not ready!");
|
|
88
|
+
}
|
|
89
|
+
this.socket.send(arrayBuf);
|
|
90
|
+
}
|
|
91
|
+
announceConnection(channelId, peerId) {
|
|
92
|
+
// return a peer object
|
|
93
|
+
const myPeerId = this.peerId;
|
|
94
|
+
if (!myPeerId) {
|
|
95
|
+
throw new Error("we should have a peer ID by now");
|
|
96
|
+
}
|
|
97
|
+
this.emit("peer-candidate", { peerId, channelId });
|
|
98
|
+
}
|
|
99
|
+
receiveMessage(message) {
|
|
100
|
+
const decoded = CBOR.decode(new Uint8Array(message));
|
|
101
|
+
const { type, senderId, targetId, channelId, message: messageData, broadcast, } = decoded;
|
|
102
|
+
const socket = this.socket;
|
|
103
|
+
if (!socket) {
|
|
104
|
+
throw new Error("Missing socket at receiveMessage");
|
|
105
|
+
}
|
|
106
|
+
if (message.byteLength === 0) {
|
|
107
|
+
throw new Error("received a zero-length message");
|
|
108
|
+
}
|
|
109
|
+
switch (type) {
|
|
110
|
+
case "peer":
|
|
111
|
+
log(`peer: ${senderId}, ${channelId}`);
|
|
112
|
+
this.announceConnection(channelId, senderId);
|
|
113
|
+
break;
|
|
114
|
+
default:
|
|
115
|
+
this.emit("message", {
|
|
116
|
+
channelId,
|
|
117
|
+
senderId,
|
|
118
|
+
targetId,
|
|
119
|
+
message: new Uint8Array(messageData),
|
|
120
|
+
broadcast,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/// <reference types="ws" />
|
|
2
|
+
import { WebSocket, type WebSocketServer } from "isomorphic-ws";
|
|
3
|
+
import { ChannelId, NetworkAdapter, PeerId } from "@automerge/automerge-repo";
|
|
4
|
+
export declare class NodeWSServerAdapter extends NetworkAdapter {
|
|
5
|
+
server: WebSocketServer;
|
|
6
|
+
sockets: {
|
|
7
|
+
[peerId: PeerId]: WebSocket;
|
|
8
|
+
};
|
|
9
|
+
constructor(server: WebSocketServer);
|
|
10
|
+
connect(peerId: PeerId): void;
|
|
11
|
+
join(docId: ChannelId): void;
|
|
12
|
+
leave(docId: ChannelId): void;
|
|
13
|
+
sendMessage(targetId: PeerId, channelId: ChannelId, message: Uint8Array, broadcast: boolean): void;
|
|
14
|
+
receiveMessage(message: Uint8Array, socket: WebSocket): void;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=NodeWSServerAdapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"NodeWSServerAdapter.d.ts","sourceRoot":"","sources":["../src/NodeWSServerAdapter.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA;AAK/D,OAAO,EACL,SAAS,EAET,cAAc,EACd,MAAM,EACP,MAAM,2BAA2B,CAAA;AAElC,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;IAmBtB,IAAI,CAAC,KAAK,EAAE,SAAS;IAIrB,KAAK,CAAC,KAAK,EAAE,SAAS;IAItB,WAAW,CACT,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,SAAS,EACpB,OAAO,EAAE,UAAU,EACnB,SAAS,EAAE,OAAO;IAsCpB,cAAc,CAAC,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS;CAmDtD"}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as CBOR from "cbor-x";
|
|
2
|
+
import debug from "debug";
|
|
3
|
+
const log = debug("WebsocketServer");
|
|
4
|
+
import { NetworkAdapter, } from "@automerge/automerge-repo";
|
|
5
|
+
export class NodeWSServerAdapter extends NetworkAdapter {
|
|
6
|
+
server;
|
|
7
|
+
sockets = {};
|
|
8
|
+
constructor(server) {
|
|
9
|
+
super();
|
|
10
|
+
this.server = server;
|
|
11
|
+
}
|
|
12
|
+
connect(peerId) {
|
|
13
|
+
this.peerId = peerId;
|
|
14
|
+
this.server.on("connection", socket => {
|
|
15
|
+
// When a socket closes, or disconnects, remove it from our list
|
|
16
|
+
socket.on("close", () => {
|
|
17
|
+
for (const [otherPeerId, otherSocket] of Object.entries(this.sockets)) {
|
|
18
|
+
if (socket === otherSocket) {
|
|
19
|
+
this.emit("peer-disconnected", { peerId: otherPeerId });
|
|
20
|
+
delete this.sockets[otherPeerId];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
socket.on("message", message => this.receiveMessage(message, socket));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
join(docId) {
|
|
28
|
+
// throw new Error("The server doesn't join channels.")
|
|
29
|
+
}
|
|
30
|
+
leave(docId) {
|
|
31
|
+
// throw new Error("The server doesn't join channels.")
|
|
32
|
+
}
|
|
33
|
+
sendMessage(targetId, channelId, message, broadcast) {
|
|
34
|
+
if (message.byteLength === 0) {
|
|
35
|
+
throw new Error("tried to send a zero-length message");
|
|
36
|
+
}
|
|
37
|
+
const senderId = this.peerId;
|
|
38
|
+
if (!senderId) {
|
|
39
|
+
throw new Error("No peerId set for the websocket server network adapter.");
|
|
40
|
+
}
|
|
41
|
+
if (this.sockets[targetId] === undefined) {
|
|
42
|
+
log(`Tried to send message to disconnected peer: ${targetId}`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const decoded = {
|
|
46
|
+
senderId,
|
|
47
|
+
targetId,
|
|
48
|
+
channelId,
|
|
49
|
+
type: "sync",
|
|
50
|
+
message,
|
|
51
|
+
broadcast,
|
|
52
|
+
};
|
|
53
|
+
const encoded = CBOR.encode(decoded);
|
|
54
|
+
// This incantation deals with websocket sending the whole
|
|
55
|
+
// underlying buffer even if we just have a uint8array view on it
|
|
56
|
+
const arrayBuf = encoded.buffer.slice(encoded.byteOffset, encoded.byteOffset + encoded.byteLength);
|
|
57
|
+
log(`[${senderId}->${targetId}@${channelId}] "sync" | ${arrayBuf.byteLength} bytes`);
|
|
58
|
+
this.sockets[targetId].send(arrayBuf);
|
|
59
|
+
}
|
|
60
|
+
receiveMessage(message, socket) {
|
|
61
|
+
const cbor = CBOR.decode(message);
|
|
62
|
+
const { type, channelId, senderId, targetId, message: data, broadcast, } = cbor;
|
|
63
|
+
const myPeerId = this.peerId;
|
|
64
|
+
if (!myPeerId) {
|
|
65
|
+
throw new Error("Missing my peer ID.");
|
|
66
|
+
}
|
|
67
|
+
log(`[${senderId}->${myPeerId}@${channelId}] ${type} | ${message.byteLength} bytes`);
|
|
68
|
+
switch (type) {
|
|
69
|
+
case "join":
|
|
70
|
+
// Let the rest of the system know that we have a new connection.
|
|
71
|
+
this.emit("peer-candidate", { peerId: senderId, channelId });
|
|
72
|
+
this.sockets[senderId] = socket;
|
|
73
|
+
// In this client-server connection, there's only ever one peer: us!
|
|
74
|
+
// (and we pretend to be joined to every channel)
|
|
75
|
+
socket.send(CBOR.encode({ type: "peer", senderId: this.peerId, channelId }));
|
|
76
|
+
break;
|
|
77
|
+
case "leave":
|
|
78
|
+
// It doesn't seem like this gets called;
|
|
79
|
+
// we handle leaving in the socket close logic
|
|
80
|
+
// TODO: confirm this
|
|
81
|
+
// ?
|
|
82
|
+
break;
|
|
83
|
+
case "message":
|
|
84
|
+
this.emit("message", {
|
|
85
|
+
senderId,
|
|
86
|
+
targetId,
|
|
87
|
+
channelId,
|
|
88
|
+
message: new Uint8Array(data),
|
|
89
|
+
broadcast,
|
|
90
|
+
});
|
|
91
|
+
break;
|
|
92
|
+
default:
|
|
93
|
+
// log("unrecognized message type")
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,6BAA6B,EAAE,MAAM,oCAAoC,CAAA;AAClF,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA"}
|
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@automerge/automerge-repo-network-websocket",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "isomorphic node/browser Websocket network adapter for Automerge Repo",
|
|
5
|
+
"repository": "https://github.com/pvh/automerge-repo",
|
|
6
|
+
"author": "Peter van Hardenberg <pvh@pvh.ca>",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"private": false,
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "dist/index.js",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"watch": "npm-watch",
|
|
14
|
+
"test": "mocha --no-warnings --experimental-specifier-resolution=node --exit"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@automerge/automerge-repo": "^0.0.1",
|
|
18
|
+
"cbor-x": "^1.3.0",
|
|
19
|
+
"eventemitter3": "^4.0.7",
|
|
20
|
+
"isomorphic-ws": "^5.0.0"
|
|
21
|
+
},
|
|
22
|
+
"watch": {
|
|
23
|
+
"build": {
|
|
24
|
+
"patterns": "./src/**/*",
|
|
25
|
+
"extensions": [
|
|
26
|
+
".ts"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"gitHead": "e572f26ae416140b025c3ba557d9f781abbdada1"
|
|
34
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChannelId,
|
|
3
|
+
InboundMessagePayload,
|
|
4
|
+
NetworkAdapter,
|
|
5
|
+
PeerId,
|
|
6
|
+
} from "@automerge/automerge-repo"
|
|
7
|
+
import * as CBOR from "cbor-x"
|
|
8
|
+
import WebSocket from "isomorphic-ws"
|
|
9
|
+
|
|
10
|
+
import debug from "debug"
|
|
11
|
+
const log = debug("WebsocketClient")
|
|
12
|
+
|
|
13
|
+
abstract class WebSocketNetworkAdapter extends NetworkAdapter {
|
|
14
|
+
socket?: WebSocket
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class BrowserWebSocketClientAdapter extends WebSocketNetworkAdapter {
|
|
18
|
+
timerId?: NodeJS.Timer
|
|
19
|
+
url: string
|
|
20
|
+
channels: ChannelId[] = []
|
|
21
|
+
|
|
22
|
+
constructor(url: string) {
|
|
23
|
+
super()
|
|
24
|
+
this.url = url
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
connect(peerId: PeerId) {
|
|
28
|
+
if (!this.timerId) {
|
|
29
|
+
this.timerId = setInterval(() => this.connect(peerId), 5000)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
this.peerId = peerId
|
|
33
|
+
this.socket = new WebSocket(this.url)
|
|
34
|
+
this.socket.binaryType = "arraybuffer"
|
|
35
|
+
|
|
36
|
+
this.socket.addEventListener("open", () => {
|
|
37
|
+
log(`@ ${this.url}: open`)
|
|
38
|
+
clearInterval(this.timerId)
|
|
39
|
+
this.timerId = undefined
|
|
40
|
+
this.channels.forEach(c => this.join(c))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// When a socket closes, or disconnects, remove it from the array.
|
|
44
|
+
this.socket.addEventListener("close", () => {
|
|
45
|
+
log(`${this.url}: close`)
|
|
46
|
+
if (!this.timerId) {
|
|
47
|
+
this.connect(peerId)
|
|
48
|
+
}
|
|
49
|
+
// log("Disconnected from server")
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
this.socket.addEventListener("message", (event: WebSocket.MessageEvent) =>
|
|
53
|
+
this.receiveMessage(event.data as Uint8Array)
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
join(channelId: ChannelId) {
|
|
58
|
+
// TODO: the network subsystem should manage this
|
|
59
|
+
if (!this.channels.includes(channelId)) {
|
|
60
|
+
this.channels.push(channelId)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!this.socket) {
|
|
64
|
+
throw new Error("WTF, get a socket")
|
|
65
|
+
}
|
|
66
|
+
if (this.socket.readyState === WebSocket.OPEN) {
|
|
67
|
+
this.socket.send(
|
|
68
|
+
CBOR.encode({ type: "join", channelId, senderId: this.peerId })
|
|
69
|
+
)
|
|
70
|
+
} else {
|
|
71
|
+
this.socket.addEventListener(
|
|
72
|
+
"open",
|
|
73
|
+
() => {
|
|
74
|
+
if (!this.socket) {
|
|
75
|
+
throw new Error("WTF, get a socket")
|
|
76
|
+
}
|
|
77
|
+
this.socket.send(
|
|
78
|
+
CBOR.encode({ type: "join", channelId, senderId: this.peerId })
|
|
79
|
+
)
|
|
80
|
+
},
|
|
81
|
+
{ once: true }
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
leave(channelId: ChannelId) {
|
|
87
|
+
this.channels = this.channels.filter(c => c !== channelId)
|
|
88
|
+
if (!this.socket) {
|
|
89
|
+
throw new Error("WTF, get a socket")
|
|
90
|
+
}
|
|
91
|
+
this.socket.send(
|
|
92
|
+
CBOR.encode({ type: "leave", channelId, senderId: this.peerId })
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
sendMessage(
|
|
97
|
+
targetId: PeerId,
|
|
98
|
+
channelId: ChannelId,
|
|
99
|
+
message: Uint8Array,
|
|
100
|
+
broadcast: boolean
|
|
101
|
+
) {
|
|
102
|
+
if (message.byteLength === 0) {
|
|
103
|
+
throw new Error("tried to send a zero-length message")
|
|
104
|
+
}
|
|
105
|
+
if (!this.peerId) {
|
|
106
|
+
throw new Error("Why don't we have a PeerID?")
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const decoded: InboundMessagePayload = {
|
|
110
|
+
senderId: this.peerId,
|
|
111
|
+
targetId,
|
|
112
|
+
channelId,
|
|
113
|
+
type: "message",
|
|
114
|
+
message,
|
|
115
|
+
broadcast,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const encoded = CBOR.encode(decoded)
|
|
119
|
+
|
|
120
|
+
// This incantation deals with websocket sending the whole
|
|
121
|
+
// underlying buffer even if we just have a uint8array view on it
|
|
122
|
+
const arrayBuf = encoded.buffer.slice(
|
|
123
|
+
encoded.byteOffset,
|
|
124
|
+
encoded.byteOffset + encoded.byteLength
|
|
125
|
+
)
|
|
126
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
127
|
+
throw new Error("Websocket Socket not ready!")
|
|
128
|
+
}
|
|
129
|
+
this.socket.send(arrayBuf)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
announceConnection(channelId: ChannelId, peerId: PeerId) {
|
|
133
|
+
// return a peer object
|
|
134
|
+
const myPeerId = this.peerId
|
|
135
|
+
if (!myPeerId) {
|
|
136
|
+
throw new Error("we should have a peer ID by now")
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.emit("peer-candidate", { peerId, channelId })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
receiveMessage(message: Uint8Array) {
|
|
143
|
+
const decoded: InboundMessagePayload = CBOR.decode(new Uint8Array(message))
|
|
144
|
+
|
|
145
|
+
const {
|
|
146
|
+
type,
|
|
147
|
+
senderId,
|
|
148
|
+
targetId,
|
|
149
|
+
channelId,
|
|
150
|
+
message: messageData,
|
|
151
|
+
broadcast,
|
|
152
|
+
} = decoded
|
|
153
|
+
|
|
154
|
+
const socket = this.socket
|
|
155
|
+
if (!socket) {
|
|
156
|
+
throw new Error("Missing socket at receiveMessage")
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (message.byteLength === 0) {
|
|
160
|
+
throw new Error("received a zero-length message")
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
switch (type) {
|
|
164
|
+
case "peer":
|
|
165
|
+
log(`peer: ${senderId}, ${channelId}`)
|
|
166
|
+
this.announceConnection(channelId, senderId)
|
|
167
|
+
break
|
|
168
|
+
default:
|
|
169
|
+
this.emit("message", {
|
|
170
|
+
channelId,
|
|
171
|
+
senderId,
|
|
172
|
+
targetId,
|
|
173
|
+
message: new Uint8Array(messageData),
|
|
174
|
+
broadcast,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import * as CBOR from "cbor-x"
|
|
2
|
+
import { WebSocket, type WebSocketServer } from "isomorphic-ws"
|
|
3
|
+
|
|
4
|
+
import debug from "debug"
|
|
5
|
+
const log = debug("WebsocketServer")
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
ChannelId,
|
|
9
|
+
InboundMessagePayload,
|
|
10
|
+
NetworkAdapter,
|
|
11
|
+
PeerId,
|
|
12
|
+
} from "@automerge/automerge-repo"
|
|
13
|
+
|
|
14
|
+
export class NodeWSServerAdapter extends NetworkAdapter {
|
|
15
|
+
server: WebSocketServer
|
|
16
|
+
sockets: { [peerId: PeerId]: WebSocket } = {}
|
|
17
|
+
|
|
18
|
+
constructor(server: WebSocketServer) {
|
|
19
|
+
super()
|
|
20
|
+
this.server = server
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
connect(peerId: PeerId) {
|
|
24
|
+
this.peerId = peerId
|
|
25
|
+
this.server.on("connection", socket => {
|
|
26
|
+
// When a socket closes, or disconnects, remove it from our list
|
|
27
|
+
socket.on("close", () => {
|
|
28
|
+
for (const [otherPeerId, otherSocket] of Object.entries(this.sockets)) {
|
|
29
|
+
if (socket === otherSocket) {
|
|
30
|
+
this.emit("peer-disconnected", { peerId: otherPeerId as PeerId })
|
|
31
|
+
delete this.sockets[otherPeerId as PeerId]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
socket.on("message", message =>
|
|
37
|
+
this.receiveMessage(message as Uint8Array, socket)
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
join(docId: ChannelId) {
|
|
43
|
+
// throw new Error("The server doesn't join channels.")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
leave(docId: ChannelId) {
|
|
47
|
+
// throw new Error("The server doesn't join channels.")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
sendMessage(
|
|
51
|
+
targetId: PeerId,
|
|
52
|
+
channelId: ChannelId,
|
|
53
|
+
message: Uint8Array,
|
|
54
|
+
broadcast: boolean
|
|
55
|
+
) {
|
|
56
|
+
if (message.byteLength === 0) {
|
|
57
|
+
throw new Error("tried to send a zero-length message")
|
|
58
|
+
}
|
|
59
|
+
const senderId = this.peerId
|
|
60
|
+
if (!senderId) {
|
|
61
|
+
throw new Error("No peerId set for the websocket server network adapter.")
|
|
62
|
+
}
|
|
63
|
+
if (this.sockets[targetId] === undefined) {
|
|
64
|
+
log(`Tried to send message to disconnected peer: ${targetId}`)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const decoded: InboundMessagePayload = {
|
|
69
|
+
senderId,
|
|
70
|
+
targetId,
|
|
71
|
+
channelId,
|
|
72
|
+
type: "sync",
|
|
73
|
+
message,
|
|
74
|
+
broadcast,
|
|
75
|
+
}
|
|
76
|
+
const encoded = CBOR.encode(decoded)
|
|
77
|
+
|
|
78
|
+
// This incantation deals with websocket sending the whole
|
|
79
|
+
// underlying buffer even if we just have a uint8array view on it
|
|
80
|
+
const arrayBuf = encoded.buffer.slice(
|
|
81
|
+
encoded.byteOffset,
|
|
82
|
+
encoded.byteOffset + encoded.byteLength
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
log(
|
|
86
|
+
`[${senderId}->${targetId}@${channelId}] "sync" | ${arrayBuf.byteLength} bytes`
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
this.sockets[targetId].send(arrayBuf)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
receiveMessage(message: Uint8Array, socket: WebSocket) {
|
|
93
|
+
const cbor: InboundMessagePayload = CBOR.decode(message)
|
|
94
|
+
|
|
95
|
+
const {
|
|
96
|
+
type,
|
|
97
|
+
channelId,
|
|
98
|
+
senderId,
|
|
99
|
+
targetId,
|
|
100
|
+
message: data,
|
|
101
|
+
broadcast,
|
|
102
|
+
} = cbor
|
|
103
|
+
|
|
104
|
+
const myPeerId = this.peerId
|
|
105
|
+
if (!myPeerId) {
|
|
106
|
+
throw new Error("Missing my peer ID.")
|
|
107
|
+
}
|
|
108
|
+
log(
|
|
109
|
+
`[${senderId}->${myPeerId}@${channelId}] ${type} | ${message.byteLength} bytes`
|
|
110
|
+
)
|
|
111
|
+
switch (type) {
|
|
112
|
+
case "join":
|
|
113
|
+
// Let the rest of the system know that we have a new connection.
|
|
114
|
+
this.emit("peer-candidate", { peerId: senderId, channelId })
|
|
115
|
+
this.sockets[senderId] = socket
|
|
116
|
+
|
|
117
|
+
// In this client-server connection, there's only ever one peer: us!
|
|
118
|
+
// (and we pretend to be joined to every channel)
|
|
119
|
+
socket.send(
|
|
120
|
+
CBOR.encode({ type: "peer", senderId: this.peerId, channelId })
|
|
121
|
+
)
|
|
122
|
+
break
|
|
123
|
+
case "leave":
|
|
124
|
+
// It doesn't seem like this gets called;
|
|
125
|
+
// we handle leaving in the socket close logic
|
|
126
|
+
// TODO: confirm this
|
|
127
|
+
// ?
|
|
128
|
+
break
|
|
129
|
+
case "message":
|
|
130
|
+
this.emit("message", {
|
|
131
|
+
senderId,
|
|
132
|
+
targetId,
|
|
133
|
+
channelId,
|
|
134
|
+
message: new Uint8Array(data),
|
|
135
|
+
broadcast,
|
|
136
|
+
})
|
|
137
|
+
break
|
|
138
|
+
default:
|
|
139
|
+
// log("unrecognized message type")
|
|
140
|
+
break
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { runAdapterTests } from "@automerge/automerge-repo"
|
|
2
|
+
import { BrowserWebSocketClientAdapter } from "../src/BrowserWebSocketClientAdapter"
|
|
3
|
+
import { NodeWSServerAdapter } from "../src/NodeWSServerAdapter"
|
|
4
|
+
import { startServer } from "./utilities/WebSockets"
|
|
5
|
+
|
|
6
|
+
describe("Websocket adapters", async () => {
|
|
7
|
+
let port = 8080
|
|
8
|
+
|
|
9
|
+
runAdapterTests(async () => {
|
|
10
|
+
port += 1 // Increment port to avoid conflicts
|
|
11
|
+
const { socket, server } = await startServer(port)
|
|
12
|
+
const serverAdapter = new NodeWSServerAdapter(socket)
|
|
13
|
+
|
|
14
|
+
const serverUrl = `ws://localhost:${port}`
|
|
15
|
+
|
|
16
|
+
const aliceAdapter = new BrowserWebSocketClientAdapter(serverUrl)
|
|
17
|
+
const bobAdapter = new BrowserWebSocketClientAdapter(serverUrl)
|
|
18
|
+
|
|
19
|
+
const teardown = () => {
|
|
20
|
+
server.close()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return { adapters: [serverAdapter, aliceAdapter, bobAdapter], teardown }
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import http from "http"
|
|
2
|
+
import { createWebSocketServer } from "./CreateWebSocketServer"
|
|
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 }
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"jsx": "react",
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "node",
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"declarationMap": true,
|
|
9
|
+
"outDir": "./dist",
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"strict": true,
|
|
13
|
+
"skipLibCheck": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*.ts"]
|
|
16
|
+
}
|