@automerge/automerge-repo-network-websocket 1.1.0-alpha.7 → 1.1.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.
@@ -10,41 +10,41 @@ import {
10
10
  type PeerMetadata,
11
11
  type PeerId,
12
12
  } from "@automerge/automerge-repo"
13
- import { FromClientMessage, FromServerMessage } from "./messages.js"
13
+ import {
14
+ FromClientMessage,
15
+ FromServerMessage,
16
+ isJoinMessage,
17
+ isLeaveMessage,
18
+ } from "./messages.js"
14
19
  import { ProtocolV1, ProtocolVersion } from "./protocolVersion.js"
20
+ import assert from "assert"
21
+ import { toArrayBuffer } from "./toArrayBuffer.js"
15
22
 
16
23
  const { encode, decode } = cborHelpers
17
24
 
18
- interface WebSocketWithIsAlive extends WebSocket {
19
- isAlive: boolean
20
- }
21
-
22
25
  export class NodeWSServerAdapter extends NetworkAdapter {
23
- server: WebSocketServer
24
26
  sockets: { [peerId: PeerId]: WebSocket } = {}
25
27
 
26
- constructor(server: WebSocketServer) {
28
+ constructor(
29
+ private server: WebSocketServer,
30
+ private keepAliveInterval = 5000
31
+ ) {
27
32
  super()
28
- this.server = server
29
33
  }
30
34
 
31
- connect(peerId: PeerId, peerMetadata: PeerMetadata) {
35
+ connect(peerId: PeerId, peerMetadata?: PeerMetadata) {
32
36
  this.peerId = peerId
33
37
  this.peerMetadata = peerMetadata
34
38
 
35
- this.server.on("close", function close() {
36
- clearInterval(interval)
39
+ this.server.on("close", () => {
40
+ clearInterval(keepAliveId)
41
+ this.disconnect()
37
42
  })
38
43
 
39
44
  this.server.on("connection", (socket: WebSocketWithIsAlive) => {
40
45
  // When a socket closes, or disconnects, remove it from our list
41
46
  socket.on("close", () => {
42
- for (const [otherPeerId, otherSocket] of Object.entries(this.sockets)) {
43
- if (socket === otherSocket) {
44
- this.emit("peer-disconnected", { peerId: otherPeerId as PeerId })
45
- delete this.sockets[otherPeerId as PeerId]
46
- }
47
- }
47
+ this.#removeSocket(socket)
48
48
  })
49
49
 
50
50
  socket.on("message", message =>
@@ -58,136 +58,132 @@ export class NodeWSServerAdapter extends NetworkAdapter {
58
58
  this.emit("ready", { network: this })
59
59
  })
60
60
 
61
- // Every interval, terminate connections to lost clients,
62
- // then mark all clients as potentially dead and then ping them.
63
- const interval = setInterval(() => {
64
- ;(this.server.clients as Set<WebSocketWithIsAlive>).forEach(socket => {
65
- if (socket.isAlive === false) {
66
- // Make sure we clean up this socket even though we're terminating.
67
- // This might be unnecessary but I have read reports of the close() not happening for 30s.
68
- for (const [otherPeerId, otherSocket] of Object.entries(
69
- this.sockets
70
- )) {
71
- if (socket === otherSocket) {
72
- this.emit("peer-disconnected", { peerId: otherPeerId as PeerId })
73
- delete this.sockets[otherPeerId as PeerId]
74
- }
75
- }
76
- return socket.terminate()
61
+ const keepAliveId = setInterval(() => {
62
+ // Terminate connections to lost clients
63
+ const clients = this.server.clients as Set<WebSocketWithIsAlive>
64
+ clients.forEach(socket => {
65
+ if (socket.isAlive) {
66
+ // Mark all clients as potentially dead until we hear from them
67
+ socket.isAlive = false
68
+ socket.ping()
69
+ } else {
70
+ this.#terminate(socket)
77
71
  }
78
- socket.isAlive = false
79
- socket.ping()
80
72
  })
81
- }, 5000)
73
+ }, this.keepAliveInterval)
82
74
  }
83
75
 
84
76
  disconnect() {
85
- // throw new Error("The server doesn't join channels.")
77
+ const clients = this.server.clients as Set<WebSocketWithIsAlive>
78
+ clients.forEach(socket => {
79
+ this.#terminate(socket)
80
+ this.#removeSocket(socket)
81
+ })
86
82
  }
87
83
 
88
84
  send(message: FromServerMessage) {
89
- if ("data" in message && message.data.byteLength === 0) {
90
- throw new Error("tried to send a zero-length message")
91
- }
85
+ assert("targetId" in message && message.targetId !== undefined)
86
+ if ("data" in message && message.data?.byteLength === 0)
87
+ throw new Error("Tried to send a zero-length message")
88
+
92
89
  const senderId = this.peerId
93
- if (!senderId) {
94
- throw new Error("No peerId set for the websocket server network adapter.")
95
- }
90
+ assert(senderId, "No peerId set for the websocket server network adapter.")
91
+
92
+ const socket = this.sockets[message.targetId]
96
93
 
97
- if (this.sockets[message.targetId] === undefined) {
98
- log(`Tried to send message to disconnected peer: ${message.targetId}`)
94
+ if (!socket) {
95
+ log(`Tried to send to disconnected peer: ${message.targetId}`)
99
96
  return
100
97
  }
101
98
 
102
99
  const encoded = encode(message)
103
- // This incantation deals with websocket sending the whole
104
- // underlying buffer even if we just have a uint8array view on it
105
- const arrayBuf = encoded.buffer.slice(
106
- encoded.byteOffset,
107
- encoded.byteOffset + encoded.byteLength
108
- )
109
-
110
- this.sockets[message.targetId]?.send(arrayBuf)
100
+ const arrayBuf = toArrayBuffer(encoded)
101
+
102
+ socket.send(arrayBuf)
111
103
  }
112
104
 
113
- receiveMessage(message: Uint8Array, socket: WebSocket) {
114
- const cbor: FromClientMessage = decode(message)
105
+ receiveMessage(messageBytes: Uint8Array, socket: WebSocket) {
106
+ const message: FromClientMessage = decode(messageBytes)
115
107
 
116
- const { type, senderId } = cbor
108
+ const { type, senderId } = message
117
109
 
118
110
  const myPeerId = this.peerId
119
- if (!myPeerId) {
120
- throw new Error("Missing my peer ID.")
121
- }
122
- log(
123
- `[${senderId}->${myPeerId}${
124
- "documentId" in cbor ? "@" + cbor.documentId : ""
125
- }] ${type} | ${message.byteLength} bytes`
126
- )
127
- switch (type) {
128
- case "join":
129
- {
130
- const existingSocket = this.sockets[senderId]
131
- if (existingSocket) {
132
- if (existingSocket.readyState === WebSocket.OPEN) {
133
- existingSocket.close()
134
- }
135
- this.emit("peer-disconnected", { peerId: senderId })
136
- }
137
-
138
- const { peerMetadata } = cbor
139
- // Let the rest of the system know that we have a new connection.
140
- this.emit("peer-candidate", {
141
- peerId: senderId,
142
- peerMetadata,
143
- })
144
- this.sockets[senderId] = socket
145
-
146
- // In this client-server connection, there's only ever one peer: us!
147
- // (and we pretend to be joined to every channel)
148
- const selectedProtocolVersion = selectProtocol(
149
- cbor.supportedProtocolVersions
150
- )
151
- if (selectedProtocolVersion === null) {
152
- this.send({
153
- type: "error",
154
- senderId: this.peerId!,
155
- message: "unsupported protocol version",
156
- targetId: senderId,
157
- })
158
- this.sockets[senderId].close()
159
- delete this.sockets[senderId]
160
- } else {
161
- this.send({
162
- type: "peer",
163
- senderId: this.peerId!,
164
- peerMetadata: this.peerMetadata!,
165
- selectedProtocolVersion: ProtocolV1,
166
- targetId: senderId,
167
- })
168
- }
111
+ assert(myPeerId)
112
+
113
+ const documentId = "documentId" in message ? "@" + message.documentId : ""
114
+ const { byteLength } = messageBytes
115
+ log(`[${senderId}->${myPeerId}${documentId}] ${type} | ${byteLength} bytes`)
116
+
117
+ if (isJoinMessage(message)) {
118
+ const { peerMetadata, supportedProtocolVersions } = message
119
+ const existingSocket = this.sockets[senderId]
120
+ if (existingSocket) {
121
+ if (existingSocket.readyState === WebSocket.OPEN) {
122
+ existingSocket.close()
169
123
  }
170
- break
171
- case "leave":
172
- // It doesn't seem like this gets called;
173
- // we handle leaving in the socket close logic
174
- // TODO: confirm this
175
- // ?
176
- break
177
-
178
- default:
179
- this.emit("message", cbor)
180
- break
124
+ this.emit("peer-disconnected", { peerId: senderId })
125
+ }
126
+
127
+ // Let the repo know that we have a new connection.
128
+ this.emit("peer-candidate", { peerId: senderId, peerMetadata })
129
+ this.sockets[senderId] = socket
130
+
131
+ const selectedProtocolVersion = selectProtocol(supportedProtocolVersions)
132
+ if (selectedProtocolVersion === null) {
133
+ this.send({
134
+ type: "error",
135
+ senderId: this.peerId!,
136
+ message: "unsupported protocol version",
137
+ targetId: senderId,
138
+ })
139
+ this.sockets[senderId].close()
140
+ delete this.sockets[senderId]
141
+ } else {
142
+ this.send({
143
+ type: "peer",
144
+ senderId: this.peerId!,
145
+ peerMetadata: this.peerMetadata!,
146
+ selectedProtocolVersion: ProtocolV1,
147
+ targetId: senderId,
148
+ })
149
+ }
150
+ } else if (isLeaveMessage(message)) {
151
+ const { senderId } = message
152
+ const socket = this.sockets[senderId]
153
+ /* c8 ignore next */
154
+ if (!socket) return
155
+ this.#terminate(socket as WebSocketWithIsAlive)
156
+ } else {
157
+ this.emit("message", message)
181
158
  }
182
159
  }
183
- }
184
160
 
185
- function selectProtocol(versions?: ProtocolVersion[]): ProtocolVersion | null {
186
- if (versions === undefined) {
187
- return ProtocolV1
161
+ #terminate(socket: WebSocketWithIsAlive) {
162
+ this.#removeSocket(socket)
163
+ socket.terminate()
164
+ }
165
+
166
+ #removeSocket(socket: WebSocketWithIsAlive) {
167
+ const peerId = this.#peerIdBySocket(socket)
168
+ if (!peerId) return
169
+ this.emit("peer-disconnected", { peerId })
170
+ delete this.sockets[peerId as PeerId]
188
171
  }
189
- if (versions.includes(ProtocolV1)) {
190
- return ProtocolV1
172
+
173
+ #peerIdBySocket = (socket: WebSocket) => {
174
+ const isThisSocket = (peerId: string) =>
175
+ this.sockets[peerId as PeerId] === socket
176
+ const result = Object.keys(this.sockets).find(isThisSocket) as PeerId
177
+ return result ?? null
191
178
  }
179
+ }
180
+
181
+ const selectProtocol = (versions?: ProtocolVersion[]) => {
182
+ if (versions === undefined) return ProtocolV1
183
+ if (versions.includes(ProtocolV1)) return ProtocolV1
192
184
  return null
193
185
  }
186
+
187
+ interface WebSocketWithIsAlive extends WebSocket {
188
+ isAlive: boolean
189
+ }
package/src/assert.ts ADDED
@@ -0,0 +1,28 @@
1
+ /* c8 ignore start */
2
+
3
+ export function assert(value: boolean, message?: string): asserts value
4
+ export function assert<T>(
5
+ value: T | undefined,
6
+ message?: string
7
+ ): asserts value is T
8
+ export function assert(value: any, message = "Assertion failed") {
9
+ if (value === false || value === null || value === undefined) {
10
+ const error = new Error(trimLines(message))
11
+ error.stack = removeLine(error.stack, "assert.ts")
12
+ throw error
13
+ }
14
+ }
15
+
16
+ const trimLines = (s: string) =>
17
+ s
18
+ .split("\n")
19
+ .map(s => s.trim())
20
+ .join("\n")
21
+
22
+ const removeLine = (s = "", targetText: string) =>
23
+ s
24
+ .split("\n")
25
+ .filter(line => !line.includes(targetText))
26
+ .join("\n")
27
+
28
+ /* c8 ignore end */
package/src/messages.ts CHANGED
@@ -46,11 +46,26 @@ export type ErrorMessage = {
46
46
  targetId: PeerId
47
47
  }
48
48
 
49
- // This adapter doesn't use the network adapter Message types, it has its own idea of how to handle
50
- // join/leave
51
-
52
49
  /** A message from the client to the server */
53
50
  export type FromClientMessage = JoinMessage | LeaveMessage | Message
54
51
 
55
52
  /** A message from the server to the client */
56
53
  export type FromServerMessage = PeerMessage | ErrorMessage | Message
54
+
55
+ // TYPE GUARDS
56
+
57
+ export const isJoinMessage = (
58
+ message: FromClientMessage
59
+ ): message is JoinMessage => message.type === "join"
60
+
61
+ export const isLeaveMessage = (
62
+ message: FromClientMessage
63
+ ): message is LeaveMessage => message.type === "leave"
64
+
65
+ export const isPeerMessage = (
66
+ message: FromServerMessage
67
+ ): message is PeerMessage => message.type === "peer"
68
+
69
+ export const isErrorMessage = (
70
+ message: FromServerMessage
71
+ ): message is ErrorMessage => message.type === "error"
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This incantation deals with websocket sending the whole underlying buffer even if we just have a
3
+ * uint8array view on it
4
+ */
5
+ export const toArrayBuffer = (bytes: Uint8Array) => {
6
+ const { buffer, byteOffset, byteLength } = bytes
7
+ return buffer.slice(byteOffset, byteOffset + byteLength)
8
+ }