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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,49 +7,44 @@ const log = debug("WebsocketServer")
7
7
  import {
8
8
  cbor as cborHelpers,
9
9
  NetworkAdapter,
10
+ type PeerMetadata,
10
11
  type PeerId,
11
- type StorageId,
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(
32
- peerId: PeerId,
33
- storageId: StorageId | undefined,
34
- isEphemeral: boolean
35
- ) {
35
+ connect(peerId: PeerId, peerMetadata: PeerMetadata) {
36
36
  this.peerId = peerId
37
- this.storageId = storageId
38
- this.isEphemeral = isEphemeral
37
+ this.peerMetadata = peerMetadata
39
38
 
40
- this.server.on("close", function close() {
41
- clearInterval(interval)
39
+ this.server.on("close", () => {
40
+ clearInterval(keepAliveId)
41
+ this.disconnect()
42
42
  })
43
43
 
44
44
  this.server.on("connection", (socket: WebSocketWithIsAlive) => {
45
45
  // When a socket closes, or disconnects, remove it from our list
46
46
  socket.on("close", () => {
47
- for (const [otherPeerId, otherSocket] of Object.entries(this.sockets)) {
48
- if (socket === otherSocket) {
49
- this.emit("peer-disconnected", { peerId: otherPeerId as PeerId })
50
- delete this.sockets[otherPeerId as PeerId]
51
- }
52
- }
47
+ this.#removeSocket(socket)
53
48
  })
54
49
 
55
50
  socket.on("message", message =>
@@ -63,138 +58,132 @@ export class NodeWSServerAdapter extends NetworkAdapter {
63
58
  this.emit("ready", { network: this })
64
59
  })
65
60
 
66
- // Every interval, terminate connections to lost clients,
67
- // then mark all clients as potentially dead and then ping them.
68
- const interval = setInterval(() => {
69
- ;(this.server.clients as Set<WebSocketWithIsAlive>).forEach(socket => {
70
- if (socket.isAlive === false) {
71
- // Make sure we clean up this socket even though we're terminating.
72
- // This might be unnecessary but I have read reports of the close() not happening for 30s.
73
- for (const [otherPeerId, otherSocket] of Object.entries(
74
- this.sockets
75
- )) {
76
- if (socket === otherSocket) {
77
- this.emit("peer-disconnected", { peerId: otherPeerId as PeerId })
78
- delete this.sockets[otherPeerId as PeerId]
79
- }
80
- }
81
- 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)
82
71
  }
83
- socket.isAlive = false
84
- socket.ping()
85
72
  })
86
- }, 5000)
73
+ }, this.keepAliveInterval)
87
74
  }
88
75
 
89
76
  disconnect() {
90
- // 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
+ })
91
82
  }
92
83
 
93
84
  send(message: FromServerMessage) {
94
- if ("data" in message && message.data.byteLength === 0) {
95
- throw new Error("tried to send a zero-length message")
96
- }
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
+
97
89
  const senderId = this.peerId
98
- if (!senderId) {
99
- throw new Error("No peerId set for the websocket server network adapter.")
100
- }
90
+ assert(senderId, "No peerId set for the websocket server network adapter.")
91
+
92
+ const socket = this.sockets[message.targetId]
101
93
 
102
- if (this.sockets[message.targetId] === undefined) {
103
- log(`Tried to send message to disconnected peer: ${message.targetId}`)
94
+ if (!socket) {
95
+ log(`Tried to send to disconnected peer: ${message.targetId}`)
104
96
  return
105
97
  }
106
98
 
107
99
  const encoded = encode(message)
108
- // This incantation deals with websocket sending the whole
109
- // underlying buffer even if we just have a uint8array view on it
110
- const arrayBuf = encoded.buffer.slice(
111
- encoded.byteOffset,
112
- encoded.byteOffset + encoded.byteLength
113
- )
114
-
115
- this.sockets[message.targetId]?.send(arrayBuf)
100
+ const arrayBuf = toArrayBuffer(encoded)
101
+
102
+ socket.send(arrayBuf)
116
103
  }
117
104
 
118
- receiveMessage(message: Uint8Array, socket: WebSocket) {
119
- const cbor: FromClientMessage = decode(message)
105
+ receiveMessage(messageBytes: Uint8Array, socket: WebSocket) {
106
+ const message: FromClientMessage = decode(messageBytes)
120
107
 
121
- const { type, senderId } = cbor
108
+ const { type, senderId } = message
122
109
 
123
110
  const myPeerId = this.peerId
124
- if (!myPeerId) {
125
- throw new Error("Missing my peer ID.")
126
- }
127
- log(
128
- `[${senderId}->${myPeerId}${
129
- "documentId" in cbor ? "@" + cbor.documentId : ""
130
- }] ${type} | ${message.byteLength} bytes`
131
- )
132
- switch (type) {
133
- case "join":
134
- {
135
- const existingSocket = this.sockets[senderId]
136
- if (existingSocket) {
137
- if (existingSocket.readyState === WebSocket.OPEN) {
138
- existingSocket.close()
139
- }
140
- this.emit("peer-disconnected", { peerId: senderId })
141
- }
142
-
143
- const { storageId, isEphemeral } = cbor
144
- // Let the rest of the system know that we have a new connection.
145
- this.emit("peer-candidate", {
146
- peerId: senderId,
147
- storageId,
148
- isEphemeral,
149
- })
150
- this.sockets[senderId] = socket
151
-
152
- // In this client-server connection, there's only ever one peer: us!
153
- // (and we pretend to be joined to every channel)
154
- const selectedProtocolVersion = selectProtocol(
155
- cbor.supportedProtocolVersions
156
- )
157
- if (selectedProtocolVersion === null) {
158
- this.send({
159
- type: "error",
160
- senderId: this.peerId!,
161
- message: "unsupported protocol version",
162
- targetId: senderId,
163
- })
164
- this.sockets[senderId].close()
165
- delete this.sockets[senderId]
166
- } else {
167
- this.send({
168
- type: "peer",
169
- senderId: this.peerId!,
170
- storageId: this.storageId,
171
- isEphemeral: this.isEphemeral,
172
- selectedProtocolVersion: ProtocolV1,
173
- targetId: senderId,
174
- })
175
- }
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()
176
123
  }
177
- break
178
- case "leave":
179
- // It doesn't seem like this gets called;
180
- // we handle leaving in the socket close logic
181
- // TODO: confirm this
182
- // ?
183
- break
184
-
185
- default:
186
- this.emit("message", cbor)
187
- 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)
188
158
  }
189
159
  }
190
- }
191
160
 
192
- function selectProtocol(versions?: ProtocolVersion[]): ProtocolVersion | null {
193
- if (versions === undefined) {
194
- return ProtocolV1
161
+ #terminate(socket: WebSocketWithIsAlive) {
162
+ this.#removeSocket(socket)
163
+ socket.terminate()
195
164
  }
196
- if (versions.includes(ProtocolV1)) {
197
- return ProtocolV1
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]
171
+ }
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
198
178
  }
179
+ }
180
+
181
+ const selectProtocol = (versions?: ProtocolVersion[]) => {
182
+ if (versions === undefined) return ProtocolV1
183
+ if (versions.includes(ProtocolV1)) return ProtocolV1
199
184
  return null
200
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
@@ -1,4 +1,4 @@
1
- import type { Message, PeerId, StorageId } from "@automerge/automerge-repo"
1
+ import type { Message, PeerId, PeerMetadata } from "@automerge/automerge-repo"
2
2
  import type { ProtocolVersion } from "./protocolVersion.js"
3
3
 
4
4
  /** The sender is disconnecting */
@@ -13,12 +13,8 @@ export type JoinMessage = {
13
13
  /** The PeerID of the client */
14
14
  senderId: PeerId
15
15
 
16
- /** Unique ID of the storage that the sender peer is using, is persistent across sessions */
17
- storageId?: StorageId
18
-
19
- /** Indicates whether other peers should persist the sync state of the sender peer.
20
- * Sync state is only persisted for non-ephemeral peers */
21
- isEphemeral: boolean
16
+ /** Metadata presented by the peer */
17
+ peerMetadata: PeerMetadata
22
18
 
23
19
  /** The protocol version the client supports */
24
20
  supportedProtocolVersions: ProtocolVersion[]
@@ -30,12 +26,8 @@ export type PeerMessage = {
30
26
  /** The PeerID of the server */
31
27
  senderId: PeerId
32
28
 
33
- /** Unique ID of the storage that the sender peer is using, is persistent across sessions */
34
- storageId?: StorageId
35
-
36
- /** Indicates whether other peers should persist the sync state of the sender peer.
37
- * Sync state is only persisted for non-ephemeral peers */
38
- isEphemeral: boolean
29
+ /** Metadata presented by the peer */
30
+ peerMetadata: PeerMetadata
39
31
 
40
32
  /** The protocol version the server selected for this connection */
41
33
  selectedProtocolVersion: ProtocolVersion
@@ -54,11 +46,26 @@ export type ErrorMessage = {
54
46
  targetId: PeerId
55
47
  }
56
48
 
57
- // This adapter doesn't use the network adapter Message types, it has its own idea of how to handle
58
- // join/leave
59
-
60
49
  /** A message from the client to the server */
61
50
  export type FromClientMessage = JoinMessage | LeaveMessage | Message
62
51
 
63
52
  /** A message from the server to the client */
64
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
+ }