angelia.io 5.0.0 → 5.0.2
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/client/index.js +21 -2
- package/client/transport.js +51 -12
- package/package.json +3 -2
- package/readme.md +91 -7
- package/server/Emitter.js +50 -13
- package/server/Message.js +27 -0
- package/server/Room.js +24 -4
- package/server/Rooms.js +21 -10
- package/server/Server.js +42 -74
- package/server/Socket.js +16 -12
- package/server/constants.js +3 -3
- package/server/index.js +3 -0
- package/shared/protocol.js +3 -0
- package/types/client/transport.d.ts +1 -1
- package/types/server/Emitter.d.ts +20 -11
- package/types/server/Message.d.ts +22 -0
- package/types/server/Room.d.ts +12 -5
- package/types/server/Rooms.d.ts +8 -0
- package/types/server/Server.d.ts +7 -7
- package/types/server/Socket.d.ts +9 -2
- package/types/server/constants.d.ts +2 -2
- package/types/server/index.d.ts +10 -1
- package/types/shared/protocol.d.ts +2 -0
package/client/index.js
CHANGED
|
@@ -47,6 +47,18 @@ const settle = reply => reply
|
|
|
47
47
|
|
|
48
48
|
const noop = () => {}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* A reply that can no longer arrive isn't an error of the app's: only a caller
|
|
52
|
+
* reading the promise hears of it. A throwing callback still surfaces as unhandled.
|
|
53
|
+
*
|
|
54
|
+
* @template T
|
|
55
|
+
* @param {Promise<T>} promise
|
|
56
|
+
*/
|
|
57
|
+
const handled = promise => {
|
|
58
|
+
promise.catch(noop)
|
|
59
|
+
return promise
|
|
60
|
+
}
|
|
61
|
+
|
|
50
62
|
const newConnid = () =>
|
|
51
63
|
crypto.getRandomValues(new BigUint64Array(1))[0].toString(36)
|
|
52
64
|
|
|
@@ -104,6 +116,7 @@ export default class Client {
|
|
|
104
116
|
#isReconnect = false
|
|
105
117
|
#reconnect = true
|
|
106
118
|
#packFailed = false
|
|
119
|
+
#online = () => this.connect()
|
|
107
120
|
|
|
108
121
|
/** @param {ClientOptions} options */
|
|
109
122
|
constructor(options) {
|
|
@@ -135,6 +148,8 @@ export default class Client {
|
|
|
135
148
|
/** Open the socket; no-op while open or after `disconnect(true)`. */
|
|
136
149
|
connect() {
|
|
137
150
|
if (this.#io || !this.#reconnect) return
|
|
151
|
+
// The same function again is no second listener.
|
|
152
|
+
addEventListener('online', this.#online)
|
|
138
153
|
|
|
139
154
|
const url = new URL(this.options.url)
|
|
140
155
|
if (url.pathname === '/') url.pathname = DEFAULT_PATH
|
|
@@ -294,7 +309,7 @@ export default class Client {
|
|
|
294
309
|
}
|
|
295
310
|
// After `disconnect(true)` nothing will be sent: don't queue it.
|
|
296
311
|
if (!this.#reconnect) {
|
|
297
|
-
return callback ? Promise.reject(new Error(DISCONNECTED)) : undefined
|
|
312
|
+
return callback ? handled(Promise.reject(new Error(DISCONNECTED))) : undefined
|
|
298
313
|
}
|
|
299
314
|
|
|
300
315
|
if (callback) {
|
|
@@ -419,6 +434,7 @@ export default class Client {
|
|
|
419
434
|
|
|
420
435
|
/** After `disconnect(true)`: nothing will be sent or answered again. */
|
|
421
436
|
#shutdown() {
|
|
437
|
+
removeEventListener('online', this.#online)
|
|
422
438
|
this.#transport.terminate()
|
|
423
439
|
this.#messages = []
|
|
424
440
|
this.#cancelPending(DISCONNECTED)
|
|
@@ -463,7 +479,10 @@ export default class Client {
|
|
|
463
479
|
reject(err)
|
|
464
480
|
}
|
|
465
481
|
},
|
|
466
|
-
cancel:
|
|
482
|
+
cancel: err => {
|
|
483
|
+
handled(promise)
|
|
484
|
+
reject(err)
|
|
485
|
+
},
|
|
467
486
|
})
|
|
468
487
|
return [id, promise]
|
|
469
488
|
}
|
package/client/transport.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Client's socket lives in a Web Worker, so the browser's own
|
|
3
|
-
* send/receive work (expensive in Chromium) and the heartbeat
|
|
3
|
+
* send/receive work (expensive in Chromium) and the heartbeat stay
|
|
4
4
|
* off the main thread (see `bench/browser`). Only strings cross
|
|
5
5
|
* `postMessage`; everything else stays in Client.
|
|
6
6
|
*
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* }} Transport
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import { TIMEOUT_MS } from '../shared/protocol.js'
|
|
26
|
+
|
|
25
27
|
/**
|
|
26
28
|
* The Client only opens a connection after the previous one closed, so
|
|
27
29
|
* the worker holds at most one socket and no ids are needed. Data frames
|
|
@@ -71,7 +73,7 @@ export function workerTransport() {
|
|
|
71
73
|
open(url, handlers) {
|
|
72
74
|
const live = (worker ??= spawn())
|
|
73
75
|
current = handlers
|
|
74
|
-
live.postMessage(['connect', url.toString()])
|
|
76
|
+
live.postMessage(['connect', url.toString(), TIMEOUT_MS])
|
|
75
77
|
return {
|
|
76
78
|
send: text => live.postMessage(text),
|
|
77
79
|
close: (code, reason) => live.postMessage(['close', code, reason]),
|
|
@@ -97,26 +99,63 @@ export function workerTransport() {
|
|
|
97
99
|
function workerMain(scope) {
|
|
98
100
|
/** @type {WebSocket | null} */
|
|
99
101
|
let ws = null
|
|
102
|
+
/** @type {ReturnType<typeof setInterval> | undefined} */
|
|
103
|
+
let heartbeat
|
|
104
|
+
let received = 0
|
|
105
|
+
let sent = 0
|
|
106
|
+
|
|
107
|
+
/** @param {number} code */
|
|
108
|
+
const closed = code => {
|
|
109
|
+
ws = null
|
|
110
|
+
clearInterval(heartbeat)
|
|
111
|
+
scope.postMessage(['close', code])
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @param {number} timeout */
|
|
115
|
+
const watch = timeout => {
|
|
116
|
+
heartbeat = setInterval(() => {
|
|
117
|
+
const socket = /** @type {WebSocket} */ (ws)
|
|
118
|
+
const now = Date.now()
|
|
119
|
+
if (now - received > timeout) {
|
|
120
|
+
// A dead connection never finishes the closing handshake: don't wait for it.
|
|
121
|
+
socket.onmessage = socket.onclose = null
|
|
122
|
+
closed(1006)
|
|
123
|
+
socket.close()
|
|
124
|
+
} else if (now - Math.min(received, sent) > timeout / 2 - 5000) {
|
|
125
|
+
// Either way: the server times out a client that only listens.
|
|
126
|
+
sent = now
|
|
127
|
+
socket.send('')
|
|
128
|
+
}
|
|
129
|
+
}, timeout / 2)
|
|
130
|
+
}
|
|
100
131
|
|
|
101
132
|
scope.onmessage = ({ data }) => {
|
|
102
133
|
if (typeof data === 'string') {
|
|
103
|
-
if (ws?.readyState === WebSocket.OPEN)
|
|
134
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
135
|
+
sent = Date.now()
|
|
136
|
+
ws.send(data)
|
|
137
|
+
}
|
|
104
138
|
return
|
|
105
139
|
}
|
|
106
|
-
const [type
|
|
140
|
+
const [type] = data
|
|
107
141
|
if (type === 'connect') {
|
|
108
|
-
const
|
|
142
|
+
const [, url, timeout] = data
|
|
143
|
+
const socket = new WebSocket(url)
|
|
109
144
|
ws = socket
|
|
110
|
-
socket.onopen = () =>
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
scope.postMessage(['
|
|
145
|
+
socket.onopen = () => {
|
|
146
|
+
received = sent = Date.now()
|
|
147
|
+
watch(timeout)
|
|
148
|
+
scope.postMessage(['open'])
|
|
149
|
+
}
|
|
150
|
+
socket.onclose = e => closed(e.code)
|
|
151
|
+
socket.onmessage = e => {
|
|
152
|
+
received = Date.now()
|
|
153
|
+
if (e.data !== '') scope.postMessage(e.data)
|
|
114
154
|
}
|
|
115
|
-
socket.onmessage = e =>
|
|
116
|
-
e.data === '' ? socket.send('') : scope.postMessage(e.data)
|
|
117
155
|
} else if (type === 'close') {
|
|
156
|
+
const [, code, reason] = data
|
|
118
157
|
try {
|
|
119
|
-
ws?.close(
|
|
158
|
+
ws?.close(code, reason)
|
|
120
159
|
} catch (err) {
|
|
121
160
|
scope.postMessage(['error', String(err)])
|
|
122
161
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "angelia.io",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.2",
|
|
4
4
|
"description": "WebSockets Server and Client API for node.js and the browser, with rooms support.",
|
|
5
5
|
"author": "@titoBouzout @boredofnames",
|
|
6
6
|
"license": "GPL",
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
"demo": "node tests/demo/server.js",
|
|
44
44
|
"bench:worker": "node bench/browser/run.js",
|
|
45
45
|
"bench:server": "node bench/node-receive.js",
|
|
46
|
-
"bench:fanout": "node bench/fanout.js"
|
|
46
|
+
"bench:fanout": "node bench/fanout.js",
|
|
47
|
+
"bench:message": "node bench/message.js"
|
|
47
48
|
},
|
|
48
49
|
"homepage": "https://github.com/titoBouzout/angelia.io",
|
|
49
50
|
"repository": {
|
package/readme.md
CHANGED
|
@@ -74,7 +74,6 @@ socket.on('gotIt', message => {
|
|
|
74
74
|
| `hostname` | String | `127.0.0.1` | the hostname (interface) to bind to |
|
|
75
75
|
| `port` | Number | 3001 | the TCP port to listen on; `0` lets the OS pick one, read it from `Server.port` after `listen()` |
|
|
76
76
|
| `path` | String | `/angelia` | the WebSocket path; clients connect to it by putting it in their `url` |
|
|
77
|
-
| `timeout` | Number | 60000 | time in milliseconds after a socket is considered gone, minimum value is 10000 |
|
|
78
77
|
| `maxMessageSize` | Number | 5 | max size of a single incoming message, in megabytes |
|
|
79
78
|
| `skipUTF8Validation` | Boolean | true | skip UTF-8 validation of incoming frames; pass `false` to close connections that send invalid UTF-8 |
|
|
80
79
|
| `cert` | String | undefined | path to a PEM-encoded TLS certificate file. With `privKey`, the server speaks `wss://`; otherwise `ws://` |
|
|
@@ -115,7 +114,6 @@ them with the exported class: `import { Server } from
|
|
|
115
114
|
| `port` | Number | port used by this server |
|
|
116
115
|
| `hostname` | String | the interface the server listens on |
|
|
117
116
|
| `path` | String | the WebSocket path clients connect to |
|
|
118
|
-
| `timeout` | Number | after how long the socket is considered gone, in ms |
|
|
119
117
|
| `served` | Number | count of sockets ever connected |
|
|
120
118
|
| `bytesReceived` | Number | sum of bytes the server has ever received |
|
|
121
119
|
| `bytesSent` | Number | sum of bytes sent by the server |
|
|
@@ -131,7 +129,10 @@ them with the exported class: `import { Server } from
|
|
|
131
129
|
| `on(Class)` | Function | attaches all methods of a `Class` as listeners |
|
|
132
130
|
| `emit(key, [value])` | Function | emits to all connected sockets |
|
|
133
131
|
| `broadcast(sender, key, [value])` | Function | emits to all connected sockets except sender |
|
|
132
|
+
| `send(message)` | Function | sends a `Message` to all connected sockets |
|
|
133
|
+
| `sendExcept(sender, message)` | Function | sends a `Message` to all connected sockets except sender |
|
|
134
134
|
| `sockets` | Set | every currently connected socket |
|
|
135
|
+
| `SocketClass` | Class | class of the sockets it creates, `Socket` unless set before `listen` |
|
|
135
136
|
| `reloadCerts()` | Function | re-read `cert`/`privKey` from disk and swap them in. Open connections are untouched. Safe to call manually (e.g. on `SIGHUP`); the watcher uses it internally |
|
|
136
137
|
| `stop()` | Function | stops listening and drops every connection; `await using server = new Server()` calls it when the scope ends |
|
|
137
138
|
|
|
@@ -149,7 +150,6 @@ The `socket` object is given to you by a listener
|
|
|
149
150
|
| `params` | Object | the params sent via the client constructor (parsed from the WebSocket URL query); includes `connid`, a per-client id stable across reconnects. The reserved `m` param (URL-packed messages) is parsed off and not exposed here |
|
|
150
151
|
| `since` | Number | timestamp of first seen |
|
|
151
152
|
| `seen` | Number | timestamp of last received message |
|
|
152
|
-
| `ping` | Number | delay with the socket in milliseconds (full round trip) |
|
|
153
153
|
| `closed` | Boolean | whether the socket has disconnected |
|
|
154
154
|
| `timedout` | Boolean | whether we lost connection with this socket |
|
|
155
155
|
| `bytesSent` | Number | sum of bytes sent to this socket |
|
|
@@ -159,8 +159,53 @@ The `socket` object is given to you by a listener
|
|
|
159
159
|
| `rooms` | Set | the rooms this socket is in |
|
|
160
160
|
| `request(key, [value])` | Function | emits and returns a promise for the client's reply, rejected with an Error whose message starts with `disconnected:` if the socket closes first; without a value the listener gets `null` |
|
|
161
161
|
| `emit(key, [value, callback])` | Function | emits to client; with a callback, returns a promise for its result, rejected with an Error whose message starts with `disconnected:` if the socket closes first |
|
|
162
|
+
| `send(message)` | Function | sends a `Message` to the client |
|
|
162
163
|
| `disconnect([noReconnect, reason])` | Function | closes the WebSocket, pass `true` to prevent re-connections; `reason` (up to 123 bytes) is sent to the client |
|
|
163
164
|
|
|
165
|
+
### `Message`
|
|
166
|
+
|
|
167
|
+
`emit` encodes once per fan-out, in the tick it sends. A `Message` is
|
|
168
|
+
encoded once as it is made, and keeps its encoding for as long as you keep
|
|
169
|
+
it, for a payload sent to sockets one at a time or again later, as a list
|
|
170
|
+
sent to whoever arrives.
|
|
171
|
+
|
|
172
|
+
```js
|
|
173
|
+
import { Message } from 'angelia.io/server'
|
|
174
|
+
|
|
175
|
+
const rooms = new Message('rooms', list)
|
|
176
|
+
|
|
177
|
+
socket.send(rooms)
|
|
178
|
+
room.send(rooms)
|
|
179
|
+
room.sendExcept(socket, rooms)
|
|
180
|
+
|
|
181
|
+
// equal text means an equal message
|
|
182
|
+
if (rooms.json !== last.json) Server.send(rooms)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Later changes to the value never reach the wire, and `toJSON` runs as it
|
|
186
|
+
is made rather than as it sends. A message carries no reply callback, as
|
|
187
|
+
one belongs to a single socket: use `socket.emit` or `socket.request`.
|
|
188
|
+
|
|
189
|
+
### Your own `Socket`
|
|
190
|
+
|
|
191
|
+
Set `Server.SocketClass` before `listen` and each connection is your subclass, so what belongs to a user lives on the socket itself. `toJSON` says what sending a socket sends; by default it sends nothing of it.
|
|
192
|
+
|
|
193
|
+
```js
|
|
194
|
+
import Server, { Socket } from 'angelia.io/server'
|
|
195
|
+
|
|
196
|
+
class User extends Socket {
|
|
197
|
+
username = ''
|
|
198
|
+
toJSON() {
|
|
199
|
+
return { username: this.username }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
Server.SocketClass = User
|
|
204
|
+
await Server.listen()
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Listeners, hooks and every `sockets` then give you `User`s. Its constructor gets what `Socket`'s does, so pass them on with `super(...args)` if it has one.
|
|
208
|
+
|
|
164
209
|
### Server Listeners
|
|
165
210
|
|
|
166
211
|
Listen to an event by creating a class with any name, and give to
|
|
@@ -236,11 +281,10 @@ the right one by name.
|
|
|
236
281
|
| `listen()` | when the server has started listening |
|
|
237
282
|
| `connect(socket, request)` | when a socket connects; `request` is `{ headers, url }` from the upgrade — read cookies / auth / custom headers here, it's not kept on the socket |
|
|
238
283
|
| `disconnect(socket, code, reason)` | when a socket gets disconnected, with the WebSocket close code and reason (1006 and `''` when the connection just dropped) |
|
|
239
|
-
| `ping(socket)` | when we got an update of the ping for a socket |
|
|
240
284
|
| `timeout(socket, delay)` | when we are about to disconnect a timedout socket, gives the delay in milliseconds |
|
|
241
285
|
| `garbage(socket, data)` | when the client sends a message with no listener, a malformed one, or one named after a predefined event |
|
|
242
286
|
| `incoming(socket, messages)` | for debugging: ref to array of incoming messages received before dispatching to listeners |
|
|
243
|
-
| `outgoing(socket, messages)` | for debugging: ref to array of outgoing messages before sending to socket
|
|
287
|
+
| `outgoing(socket, messages)` | for debugging: ref to array of outgoing messages before sending to socket; changing a `Message`'s tuple here never changes what is sent |
|
|
244
288
|
|
|
245
289
|
## Client API (Browser)
|
|
246
290
|
|
|
@@ -267,7 +311,7 @@ to `/angelia`, the server's default; include the path
|
|
|
267
311
|
|
|
268
312
|
The WebSocket runs in a Web Worker: messages are still stringified and
|
|
269
313
|
parsed on the main thread, but the browser's own send and receive work
|
|
270
|
-
and the heartbeat
|
|
314
|
+
and the heartbeat happen off it. In Chromium that work is the
|
|
271
315
|
expensive part of moving large payloads; Firefox still routes a
|
|
272
316
|
worker's socket events through the main thread, so it gains less.
|
|
273
317
|
`npm run bench:worker` measures it.
|
|
@@ -322,10 +366,25 @@ down, or the page itself unloading) are retried after a random 2–4s
|
|
|
322
366
|
delay, so clients don't all come back at once and a page being
|
|
323
367
|
reloaded doesn't open a second connection on its way out.
|
|
324
368
|
|
|
369
|
+
The client keeps the heartbeat: every 30s it sends an empty
|
|
370
|
+
message to a server it hasn't heard from or written to for
|
|
371
|
+
25s, which the server answers, and it takes a
|
|
372
|
+
connection silent for over 60s for dead and reconnects. The server drops
|
|
373
|
+
a socket it hasn't heard from for as long. These times are fixed.
|
|
374
|
+
|
|
375
|
+
Going `online` reconnects at once if the client isn't connected, and does
|
|
376
|
+
nothing if it is.
|
|
377
|
+
|
|
378
|
+
A page Chromium keeps in its back/forward cache holds its connection
|
|
379
|
+
open, unanswering, until the server times it out or the page is
|
|
380
|
+
restored; either way the client reconnects as it is restored.
|
|
381
|
+
|
|
325
382
|
On reconnect, any messages still queued for send are dropped, and
|
|
326
383
|
pending reply promises (from `request`, or `emit` with a callback)
|
|
327
384
|
reject with an Error whose message starts with `reconnecting:` as soon as the connection drops;
|
|
328
385
|
after `disconnect(true)` they reject with an Error whose message starts with `disconnected:`.
|
|
386
|
+
A caller that ignores the promise hears nothing of a lost reply, while a
|
|
387
|
+
callback that throws still surfaces as an unhandled rejection.
|
|
329
388
|
Messages queued before the first connection opened wait for it.
|
|
330
389
|
The library can't tell which queued messages are safe to replay (a
|
|
331
390
|
typing indicator: yes; a "transfer $100": maybe not), so recovery is
|
|
@@ -398,10 +457,13 @@ await Server.listen()
|
|
|
398
457
|
| `get(id)` | method | gets room with such an `id` |
|
|
399
458
|
| `join(socket, [id])` | method | adds socket to room `id` |
|
|
400
459
|
| `leave(socket, [id])` | method | removes socket from room `id` |
|
|
460
|
+
| `of(socket)` | method | iterates the rooms of this list `socket` is in, walking the socket's rooms, not the list's |
|
|
401
461
|
| `sockets` | Set | sockets across every room in this list, each once |
|
|
402
462
|
| `rooms` | Map | every room in this list, by id |
|
|
403
463
|
| `emit(key, [value])` | method | emits to all sockets in this room list |
|
|
404
464
|
| `broadcast(socket, key, [value])` | method | emits to other sockets in this room list |
|
|
465
|
+
| `send(message)` | method | sends a `Message` to all sockets in this room list |
|
|
466
|
+
| `sendExcept(socket, message)` | method | sends a `Message` to other sockets in this room list |
|
|
405
467
|
|
|
406
468
|
### Via a `Room`
|
|
407
469
|
|
|
@@ -451,9 +513,31 @@ await Server.listen()
|
|
|
451
513
|
| `sockets` | Set | sockets in the room |
|
|
452
514
|
| `emit(key, [value])` | method | emits to all sockets in the room |
|
|
453
515
|
| `broadcast(socket, key, [value])` | method | emits to other sockets in the room |
|
|
516
|
+
| `send(message)` | method | sends a `Message` to all sockets in the room |
|
|
517
|
+
| `sendExcept(socket, message)` | method | sends a `Message` to other sockets in the room |
|
|
454
518
|
| `id` | any | the room id |
|
|
455
519
|
| `join(socket)` | method | adds a socket to the room |
|
|
456
|
-
| `leave(socket)` | method | removes a socket from the room
|
|
520
|
+
| `leave(socket)` | method | removes a socket from the room; in a `Rooms` list, an emptied room is deleted unless `persistent` |
|
|
521
|
+
|
|
522
|
+
### Your own `Emitter`
|
|
523
|
+
|
|
524
|
+
`Server`, `Room` and `Rooms` send through `Emitter`, which only reads a
|
|
525
|
+
`sockets` field. Extend it for something that sends to sockets it doesn't
|
|
526
|
+
own, as what a room is doing right now:
|
|
527
|
+
|
|
528
|
+
```js
|
|
529
|
+
import { Emitter } from 'angelia.io/server'
|
|
530
|
+
|
|
531
|
+
class Match extends Emitter {
|
|
532
|
+
constructor(room) {
|
|
533
|
+
super()
|
|
534
|
+
this.room = room
|
|
535
|
+
}
|
|
536
|
+
get sockets() {
|
|
537
|
+
return this.room.sockets
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
```
|
|
457
541
|
|
|
458
542
|
### `sockets` in `Server`, `Room` and `Rooms`
|
|
459
543
|
|
package/server/Emitter.js
CHANGED
|
@@ -1,19 +1,41 @@
|
|
|
1
1
|
/** @import { ClientEvents, EventKey, EventValue } from './index.js' */
|
|
2
|
+
/** @import { Message } from './Message.js' */
|
|
2
3
|
/** @import { Socket } from './Socket.js' */
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* @typedef {{
|
|
6
7
|
* sockets: Iterable<Socket>
|
|
7
|
-
* broadcast(me: Socket | undefined, k: any, v?: any): void
|
|
8
8
|
* }} HasSockets
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* @param {Iterable<Socket>} sockets
|
|
13
|
+
* @param {any[]} tuple
|
|
14
|
+
*/
|
|
15
|
+
function queueAll(sockets, tuple) {
|
|
16
|
+
for (const socket of sockets) {
|
|
17
|
+
socket.queue(tuple)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {Iterable<Socket>} sockets
|
|
23
|
+
* @param {Socket | undefined} me
|
|
24
|
+
* @param {any[]} tuple
|
|
25
|
+
*/
|
|
26
|
+
function queueExcept(sockets, me, tuple) {
|
|
27
|
+
for (const socket of sockets) {
|
|
28
|
+
if (me !== socket) {
|
|
29
|
+
socket.queue(tuple)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
/**
|
|
12
35
|
* Common emit / broadcast logic. Subclasses own the `sockets` field
|
|
13
|
-
* (a Set on Server, Room and Rooms). Each fan-out
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* all sockets share a single encoding.
|
|
36
|
+
* (a Set on Server, Room and Rooms). Each fan-out queues **one** wire
|
|
37
|
+
* tuple on every recipient, and `Server.cacheMessages` keeps the encoded
|
|
38
|
+
* form on it, so all sockets share a single encoding.
|
|
17
39
|
*/
|
|
18
40
|
export class Emitter {
|
|
19
41
|
/**
|
|
@@ -25,7 +47,7 @@ export class Emitter {
|
|
|
25
47
|
* @param {EventValue<ClientEvents, K>} v
|
|
26
48
|
*/
|
|
27
49
|
emit(k, v) {
|
|
28
|
-
this.
|
|
50
|
+
queueAll(this.sockets, [k, v])
|
|
29
51
|
}
|
|
30
52
|
|
|
31
53
|
/**
|
|
@@ -38,12 +60,27 @@ export class Emitter {
|
|
|
38
60
|
* @param {EventValue<ClientEvents, K>} v
|
|
39
61
|
*/
|
|
40
62
|
broadcast(me, k, v) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
63
|
+
queueExcept(this.sockets, me, [k, v])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Send an encoded message to every socket in scope.
|
|
68
|
+
*
|
|
69
|
+
* @this {HasSockets}
|
|
70
|
+
* @param {Message<any>} message
|
|
71
|
+
*/
|
|
72
|
+
send(message) {
|
|
73
|
+
queueAll(this.sockets, message.tuple)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Send an encoded message to every socket in scope except `me`.
|
|
78
|
+
*
|
|
79
|
+
* @this {HasSockets}
|
|
80
|
+
* @param {Socket | undefined} me
|
|
81
|
+
* @param {Message<any>} message
|
|
82
|
+
*/
|
|
83
|
+
sendExcept(me, message) {
|
|
84
|
+
queueExcept(this.sockets, me, message.tuple)
|
|
48
85
|
}
|
|
49
86
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { encoded } from './constants.js'
|
|
2
|
+
import { stringify } from './utils.js'
|
|
3
|
+
|
|
4
|
+
/** @import { ClientEvents, EventKey, EventValue } from './index.js' */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A message encoded once, here, to send to any socket at any time with
|
|
8
|
+
* `send` / `sendExcept`. Later changes to `v` don't reach the wire.
|
|
9
|
+
*
|
|
10
|
+
* @template {EventKey<ClientEvents>} K
|
|
11
|
+
*/
|
|
12
|
+
export class Message {
|
|
13
|
+
/**
|
|
14
|
+
* @param {K} k
|
|
15
|
+
* @param {EventValue<ClientEvents, K>} v
|
|
16
|
+
*/
|
|
17
|
+
constructor(k, v) {
|
|
18
|
+
/**
|
|
19
|
+
* @internal The wire tuple every send queues.
|
|
20
|
+
* @type {any[]}
|
|
21
|
+
*/
|
|
22
|
+
this.tuple = [k, v]
|
|
23
|
+
/** The encoded text; equal text means an equal message. */
|
|
24
|
+
this.json = stringify(this.tuple)
|
|
25
|
+
this.tuple[encoded] = this.json
|
|
26
|
+
}
|
|
27
|
+
}
|
package/server/Room.js
CHANGED
|
@@ -7,14 +7,20 @@ import { parent } from './constants.js'
|
|
|
7
7
|
/** A group of sockets to emit to; subclass it to hook its lifecycle. */
|
|
8
8
|
export class Room extends Emitter {
|
|
9
9
|
/** @type {Set<Socket>} */
|
|
10
|
-
sockets = new Set()
|
|
10
|
+
#sockets = new Set()
|
|
11
11
|
/** @type {any} Set by `Rooms` for its rooms; yours to set otherwise. */
|
|
12
12
|
id
|
|
13
|
-
/** Keep the room in its `Rooms` list when its last socket leaves. */
|
|
14
|
-
persistent = false
|
|
15
13
|
/** @type {Rooms<any> | undefined} The list the room belongs to, if any. */
|
|
16
14
|
;[parent]
|
|
17
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Off the room's own fields, as a subclass may spread its fields into
|
|
18
|
+
* what it sends.
|
|
19
|
+
*/
|
|
20
|
+
get sockets() {
|
|
21
|
+
return this.#sockets
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
/**
|
|
19
25
|
* Called when a `Rooms` list creates the room for `socket`.
|
|
20
26
|
*
|
|
@@ -41,16 +47,30 @@ export class Room extends Emitter {
|
|
|
41
47
|
this[parent]?.sockets.add(socket)
|
|
42
48
|
this.onJoin(socket)
|
|
43
49
|
}
|
|
44
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Leave the room; a room in a `Rooms` list that this empties is deleted
|
|
52
|
+
* unless it's `persistent`.
|
|
53
|
+
*
|
|
54
|
+
* @param {Socket} socket
|
|
55
|
+
*/
|
|
45
56
|
leave(socket) {
|
|
46
57
|
if (!this.sockets.delete(socket)) return
|
|
47
58
|
socket.rooms.delete(this)
|
|
48
59
|
const list = this[parent]
|
|
49
60
|
if (list && !inList(socket, list)) list.sockets.delete(socket)
|
|
50
61
|
this.onLeave(socket)
|
|
62
|
+
if (list && !this.persistent && this.sockets.size === 0) {
|
|
63
|
+
list.rooms.delete(this.id)
|
|
64
|
+
this.onDelete(socket)
|
|
65
|
+
// Joins through a kept reference no longer reach this list.
|
|
66
|
+
this[parent] = undefined
|
|
67
|
+
}
|
|
51
68
|
}
|
|
52
69
|
}
|
|
53
70
|
|
|
71
|
+
/** Keep the room in its `Rooms` list when its last socket leaves. */
|
|
72
|
+
Room.prototype.persistent = false
|
|
73
|
+
|
|
54
74
|
/**
|
|
55
75
|
* Whether `socket` is still in another room of `list`. Walks the
|
|
56
76
|
* socket's own rooms, which stay few, not the list's members.
|
package/server/Rooms.js
CHANGED
|
@@ -51,7 +51,14 @@ export class Rooms extends Emitter {
|
|
|
51
51
|
room.id = id
|
|
52
52
|
this.rooms.set(id, room)
|
|
53
53
|
|
|
54
|
-
room.
|
|
54
|
+
// In the list while it runs, for it to find itself; a failed one is no room.
|
|
55
|
+
try {
|
|
56
|
+
room.onCreate(socket)
|
|
57
|
+
} catch (error) {
|
|
58
|
+
this.rooms.delete(id)
|
|
59
|
+
room[parent] = undefined
|
|
60
|
+
throw error
|
|
61
|
+
}
|
|
55
62
|
}
|
|
56
63
|
room.join(socket)
|
|
57
64
|
}
|
|
@@ -64,15 +71,19 @@ export class Rooms extends Emitter {
|
|
|
64
71
|
* @param {any} [id]
|
|
65
72
|
*/
|
|
66
73
|
leave(socket, id) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
this.rooms.get(id)?.leave(socket)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The rooms of this list `socket` is in. Walks the socket's own rooms,
|
|
79
|
+
* which stay few, not the list's. Leaving one while iterating is fine.
|
|
80
|
+
*
|
|
81
|
+
* @param {Socket} socket
|
|
82
|
+
* @returns {Generator<R>}
|
|
83
|
+
*/
|
|
84
|
+
*of(socket) {
|
|
85
|
+
for (const room of socket.rooms) {
|
|
86
|
+
if (room[parent] === this) yield /** @type {R} */ (room)
|
|
76
87
|
}
|
|
77
88
|
}
|
|
78
89
|
|
package/server/Server.js
CHANGED
|
@@ -12,12 +12,16 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
DEFAULT_MAX_MESSAGE_SIZE_MB,
|
|
14
14
|
DEFAULT_PORT,
|
|
15
|
-
DEFAULT_TIMEOUT_MS,
|
|
16
15
|
LIFECYCLE_EVENTS,
|
|
17
16
|
MAX_PORT,
|
|
18
|
-
|
|
17
|
+
encoded,
|
|
18
|
+
framed,
|
|
19
19
|
} from './constants.js'
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_PATH,
|
|
22
|
+
PACKED_PARAM,
|
|
23
|
+
TIMEOUT_MS,
|
|
24
|
+
} from '../shared/protocol.js'
|
|
21
25
|
|
|
22
26
|
import { Socket } from './Socket.js'
|
|
23
27
|
import { Emitter } from './Emitter.js'
|
|
@@ -31,9 +35,6 @@ import { createServer as createHttpsServer } from 'node:https'
|
|
|
31
35
|
|
|
32
36
|
import { WebSocketServer } from 'ws'
|
|
33
37
|
|
|
34
|
-
// The heartbeat is a constant empty message — frame once, reuse forever.
|
|
35
|
-
const PING = frame('')
|
|
36
|
-
|
|
37
38
|
// Close code for a connection that ended without a close frame, which is
|
|
38
39
|
// what `terminate()` does.
|
|
39
40
|
const ABNORMAL_CLOSURE = 1006
|
|
@@ -50,8 +51,6 @@ const DEFAULT_WATCH_DEBOUNCE_MS = 1000
|
|
|
50
51
|
* @property {number} [port] TCP port; `0` lets the OS pick one, readable
|
|
51
52
|
* from `port` after `listen()`.
|
|
52
53
|
* @property {string} [path] WebSocket path clients connect to.
|
|
53
|
-
* @property {number} [timeout] Milliseconds of silence after which a
|
|
54
|
-
* socket is considered gone.
|
|
55
54
|
* @property {number} [maxMessageSize] Max size of a single incoming
|
|
56
55
|
* message, in megabytes.
|
|
57
56
|
* @property {boolean} [skipUTF8Validation] Skip `ws`'s UTF-8 validation
|
|
@@ -83,7 +82,6 @@ const DEFAULT_WATCH_DEBOUNCE_MS = 1000
|
|
|
83
82
|
* @property {() => void} [listen]
|
|
84
83
|
* @property {(socket: Socket, request: ConnectRequest) => void} [connect]
|
|
85
84
|
* @property {(socket: Socket, code: number, reason: string) => void} [disconnect]
|
|
86
|
-
* @property {(socket: Socket) => void} [ping]
|
|
87
85
|
* @property {(socket: Socket, delay: number) => void} [timeout]
|
|
88
86
|
* @property {(socket: Socket, data: any) => void} [garbage]
|
|
89
87
|
* @property {(socket: Socket, messages: any[]) => void} [incoming]
|
|
@@ -160,6 +158,13 @@ export class Server extends Emitter {
|
|
|
160
158
|
* @type {Socket[]}
|
|
161
159
|
*/
|
|
162
160
|
drained = []
|
|
161
|
+
/**
|
|
162
|
+
* Class of the sockets it creates; set it before `listen` to have each
|
|
163
|
+
* connection be a subclass of `Socket`.
|
|
164
|
+
*
|
|
165
|
+
* @type {typeof Socket}
|
|
166
|
+
*/
|
|
167
|
+
SocketClass = Socket
|
|
163
168
|
/** @internal */
|
|
164
169
|
cacheSymbol = Symbol('cache')
|
|
165
170
|
/** @internal */
|
|
@@ -180,7 +185,6 @@ export class Server extends Emitter {
|
|
|
180
185
|
this.port = DEFAULT_PORT
|
|
181
186
|
this.path = DEFAULT_PATH
|
|
182
187
|
|
|
183
|
-
this.timeout = DEFAULT_TIMEOUT_MS
|
|
184
188
|
|
|
185
189
|
this.maxMessageSize = DEFAULT_MAX_MESSAGE_SIZE_MB
|
|
186
190
|
|
|
@@ -264,10 +268,6 @@ export class Server extends Emitter {
|
|
|
264
268
|
|
|
265
269
|
this.path = options.path || this.path
|
|
266
270
|
|
|
267
|
-
const timeout = +(options.timeout ?? 0)
|
|
268
|
-
this.timeout =
|
|
269
|
-
timeout >= MIN_TIMEOUT_MS ? timeout : DEFAULT_TIMEOUT_MS
|
|
270
|
-
|
|
271
271
|
const maxMessageSize = +(options.maxMessageSize ?? 0)
|
|
272
272
|
this.maxMessageSize =
|
|
273
273
|
maxMessageSize > 0 ? maxMessageSize : DEFAULT_MAX_MESSAGE_SIZE_MB
|
|
@@ -323,7 +323,7 @@ export class Server extends Emitter {
|
|
|
323
323
|
this.since = this.now
|
|
324
324
|
|
|
325
325
|
// `unref` so a stopped server doesn't hold the process alive.
|
|
326
|
-
this.#pingTimer = setInterval(this.
|
|
326
|
+
this.#pingTimer = setInterval(this.sweep, TIMEOUT_MS / 2)
|
|
327
327
|
this.#pingTimer.unref()
|
|
328
328
|
|
|
329
329
|
this.#nowTimer = setInterval(this.#updateNow, 500)
|
|
@@ -488,7 +488,7 @@ export class Server extends Emitter {
|
|
|
488
488
|
#onconnection = (io, request) => {
|
|
489
489
|
// URL-packed messages come off the params before `connect` sees them.
|
|
490
490
|
const [query, m] = params(request.url, PACKED_PARAM)
|
|
491
|
-
const socket = new
|
|
491
|
+
const socket = new this.SocketClass(io, request, this, query)
|
|
492
492
|
|
|
493
493
|
socket.listen()
|
|
494
494
|
|
|
@@ -511,8 +511,6 @@ export class Server extends Emitter {
|
|
|
511
511
|
else socket.garbage(m)
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
-
// ping on connect is usually high
|
|
515
|
-
setTimeout(() => this.#pingSocket(socket), 200).unref()
|
|
516
514
|
}
|
|
517
515
|
|
|
518
516
|
#onerror = err => {
|
|
@@ -550,91 +548,62 @@ export class Server extends Emitter {
|
|
|
550
548
|
}
|
|
551
549
|
|
|
552
550
|
/**
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
* bumped in `Socket.write`.
|
|
551
|
+
* The frame of `messages`. Each message stringifies once however many
|
|
552
|
+
* batches it lands in, and a batch frames once per tick. Byte counters
|
|
553
|
+
* are bumped in `Socket.write`.
|
|
556
554
|
*
|
|
557
555
|
* @internal Called from Socket.processQueue.
|
|
558
556
|
* @param {any[]} messages
|
|
559
557
|
* @returns {Buffer[]}
|
|
560
558
|
*/
|
|
561
559
|
cacheMessages(messages) {
|
|
562
|
-
// A fan-out is usually one message:
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
}
|
|
569
|
-
|
|
560
|
+
// A fan-out is usually one message: its frame lives on it, for as long as it does.
|
|
561
|
+
if (messages.length === 1) {
|
|
562
|
+
const m = messages[0]
|
|
563
|
+
if (m[framed]) {
|
|
564
|
+
this.messagesSentCacheHit++
|
|
565
|
+
} else {
|
|
566
|
+
m[framed] = frame(`[${m[encoded] ??= stringify(m)}]`)
|
|
567
|
+
}
|
|
568
|
+
return m[framed]
|
|
569
|
+
}
|
|
570
|
+
let key = ''
|
|
571
|
+
for (const m of messages) {
|
|
572
|
+
if (!m[this.cacheSymbol]) {
|
|
573
|
+
m[this.cacheSymbol] = this.cached++
|
|
570
574
|
}
|
|
575
|
+
key += m[this.cacheSymbol] + ','
|
|
571
576
|
}
|
|
572
|
-
let
|
|
573
|
-
if (
|
|
577
|
+
let batch = this.cache.get(key)
|
|
578
|
+
if (batch) {
|
|
574
579
|
this.messagesSentCacheHit++
|
|
575
580
|
} else {
|
|
576
|
-
|
|
577
|
-
this.cache.set(key,
|
|
581
|
+
batch = frame(`[${messages.map(m => (m[encoded] ??= stringify(m))).join()}]`)
|
|
582
|
+
this.cache.set(key, batch)
|
|
578
583
|
}
|
|
579
|
-
return
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
// ping. Only `ping` is public so tests can drive the heartbeat
|
|
583
|
-
// sweep manually; the rest is internal.
|
|
584
|
-
|
|
585
|
-
/**
|
|
586
|
-
* @internal Sockets silent for longer than this get pinged. The sweep runs every
|
|
587
|
-
* `timeout / 2`; the margin makes sure a socket seen just after one
|
|
588
|
-
* sweep is still pinged before the next one times it out.
|
|
589
|
-
*/
|
|
590
|
-
get timeoutCheck() {
|
|
591
|
-
return this.timeout / 2 - 5000
|
|
584
|
+
return batch
|
|
592
585
|
}
|
|
593
586
|
|
|
594
587
|
#updateNow = () => {
|
|
595
588
|
this.now = now()
|
|
596
589
|
}
|
|
597
590
|
|
|
598
|
-
/** @internal
|
|
599
|
-
|
|
591
|
+
/** @internal Drops sockets gone silent; public so tests can run it. */
|
|
592
|
+
sweep = () => {
|
|
600
593
|
this.#updateNow()
|
|
601
|
-
const check = this.timeoutCheck
|
|
602
594
|
for (const socket of this.sockets) {
|
|
603
595
|
const delay = this.now - socket.seen
|
|
604
596
|
|
|
605
|
-
if (delay >
|
|
597
|
+
if (delay > TIMEOUT_MS) {
|
|
606
598
|
socket.timedout = true
|
|
607
599
|
this.hooks.timeout?.(socket, delay)
|
|
608
600
|
socket.io.terminate()
|
|
609
601
|
// Clean up now, as `disconnect()` does, not on the close event.
|
|
610
602
|
socket.onclose(ABNORMAL_CLOSURE, '')
|
|
611
|
-
} else if (delay > check) {
|
|
612
|
-
// `now` was just updated for the whole sweep.
|
|
613
|
-
socket.contacted = this.now
|
|
614
|
-
socket.write(PING)
|
|
615
603
|
}
|
|
616
604
|
}
|
|
617
605
|
}
|
|
618
606
|
|
|
619
|
-
/** @param {Socket} socket */
|
|
620
|
-
#pingSocket(socket) {
|
|
621
|
-
this.#updateNow()
|
|
622
|
-
socket.contacted = this.now
|
|
623
|
-
socket.write(PING)
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
/**
|
|
627
|
-
* @internal Called from Socket.onmessage on heartbeat receipt.
|
|
628
|
-
* @param {Socket} socket
|
|
629
|
-
*/
|
|
630
|
-
pong(socket) {
|
|
631
|
-
this.#updateNow()
|
|
632
|
-
socket.seen = this.now
|
|
633
|
-
socket.ping = this.now - socket.contacted
|
|
634
|
-
|
|
635
|
-
this.hooks.ping?.(socket)
|
|
636
|
-
}
|
|
637
|
-
|
|
638
607
|
/** `await using server = …` stops the server when the scope ends. */
|
|
639
608
|
[Symbol.asyncDispose]() {
|
|
640
609
|
return this.stop()
|
|
@@ -688,7 +657,6 @@ export class Server extends Emitter {
|
|
|
688
657
|
sockets: this.sockets.size,
|
|
689
658
|
served: this.served,
|
|
690
659
|
since: this.since,
|
|
691
|
-
timeout: this.timeout,
|
|
692
660
|
bytesSent: this.bytesSent,
|
|
693
661
|
bytesReceived: this.bytesReceived,
|
|
694
662
|
messagesSent: this.messagesSent,
|
package/server/Socket.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { parent } from './constants.js'
|
|
2
1
|
import {
|
|
3
2
|
NO_RECONNECT,
|
|
4
3
|
PROTOCOL_KEY,
|
|
@@ -16,10 +15,13 @@ import {
|
|
|
16
15
|
import { WebSocket } from 'ws'
|
|
17
16
|
|
|
18
17
|
/** @import { ClientEvents, ClientReplies, EventKey, EventValue } from './index.js' */
|
|
18
|
+
/** @import { Message } from './Message.js' */
|
|
19
19
|
/** @import { Room } from './Room.js' */
|
|
20
20
|
/** @import { Server } from './Server.js' */
|
|
21
21
|
|
|
22
22
|
const DISCONNECT = frame(stringify([[PROTOCOL_KEY, NO_RECONNECT]]))
|
|
23
|
+
// The answer to a client's heartbeat, an empty message: frame once, reuse forever.
|
|
24
|
+
const PONG = frame('')
|
|
23
25
|
|
|
24
26
|
// Rejection message for replies the client can no longer send.
|
|
25
27
|
const DISCONNECTED = 'disconnected: the socket closed before the client replied'
|
|
@@ -35,9 +37,6 @@ export class Socket {
|
|
|
35
37
|
/** Last callback id handed out; ids start at 1, as 0 means "none". */
|
|
36
38
|
#lastCallbackId = 0
|
|
37
39
|
|
|
38
|
-
/** @internal When the last ping was sent, to time the pong. */
|
|
39
|
-
contacted = 0
|
|
40
|
-
|
|
41
40
|
/**
|
|
42
41
|
* @internal Messages waiting for the next batch.
|
|
43
42
|
* @type {any[]}
|
|
@@ -78,9 +77,7 @@ export class Socket {
|
|
|
78
77
|
|
|
79
78
|
this.since = server.now
|
|
80
79
|
this.seen = server.now
|
|
81
|
-
this.contacted = server.now
|
|
82
80
|
|
|
83
|
-
this.ping = 0
|
|
84
81
|
this.timedout = false
|
|
85
82
|
this.closed = false
|
|
86
83
|
|
|
@@ -123,6 +120,15 @@ export class Socket {
|
|
|
123
120
|
this.queue([k, v])
|
|
124
121
|
}
|
|
125
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Queue an encoded message for the next batch.
|
|
125
|
+
*
|
|
126
|
+
* @param {Message<any>} message
|
|
127
|
+
*/
|
|
128
|
+
send(message) {
|
|
129
|
+
this.queue(message.tuple)
|
|
130
|
+
}
|
|
131
|
+
|
|
126
132
|
/**
|
|
127
133
|
* @internal Queue a wire tuple; fan-outs pass one tuple to every
|
|
128
134
|
* recipient so `Server.cacheMessages` encodes it once.
|
|
@@ -190,11 +196,11 @@ export class Socket {
|
|
|
190
196
|
|
|
191
197
|
/** @internal @param {Buffer} raw */
|
|
192
198
|
onmessage = raw => {
|
|
199
|
+
this.seen = this.server.now
|
|
193
200
|
if (raw.length === 0) {
|
|
194
|
-
this.
|
|
201
|
+
this.write(PONG)
|
|
195
202
|
return
|
|
196
203
|
}
|
|
197
|
-
this.seen = this.server.now
|
|
198
204
|
|
|
199
205
|
this.server.bytesReceived += raw.length
|
|
200
206
|
this.bytesReceived += raw.length
|
|
@@ -227,9 +233,7 @@ export class Socket {
|
|
|
227
233
|
|
|
228
234
|
// Leaving deletes the room from this.rooms; Set iteration allows that.
|
|
229
235
|
for (const room of this.rooms) {
|
|
230
|
-
room
|
|
231
|
-
? room[parent].leave(this, room.id)
|
|
232
|
-
: room.leave(this)
|
|
236
|
+
room.leave(this)
|
|
233
237
|
}
|
|
234
238
|
|
|
235
239
|
this.server.hooks.disconnect?.(this, code, reason.toString())
|
|
@@ -379,6 +383,7 @@ export class Socket {
|
|
|
379
383
|
this.io._sender.sendFrame(chunks)
|
|
380
384
|
}
|
|
381
385
|
|
|
386
|
+
/** @returns {any} What sending a socket sends; a subclass says what. */
|
|
382
387
|
toJSON() {
|
|
383
388
|
return '[content of socket object omitted for toJSON]'
|
|
384
389
|
}
|
|
@@ -397,7 +402,6 @@ export class Socket {
|
|
|
397
402
|
params: this.params,
|
|
398
403
|
closed: this.closed,
|
|
399
404
|
timedout: this.timedout,
|
|
400
|
-
ping: this.ping,
|
|
401
405
|
since: this.since,
|
|
402
406
|
seen: this.seen,
|
|
403
407
|
rooms: [...this.rooms].map(room => room.id),
|
package/server/constants.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
export const parent = Symbol('parent')
|
|
2
|
+
// A wire tuple's JSON, and its frame as a batch of one.
|
|
3
|
+
export const encoded = Symbol('encoded')
|
|
4
|
+
export const framed = Symbol('framed')
|
|
2
5
|
|
|
3
6
|
// Server events fired by the library itself; clients can't trigger them.
|
|
4
7
|
export const LIFECYCLE_EVENTS = new Set([
|
|
5
8
|
'listen',
|
|
6
9
|
'connect',
|
|
7
10
|
'disconnect',
|
|
8
|
-
'ping',
|
|
9
11
|
'timeout',
|
|
10
12
|
'garbage',
|
|
11
13
|
'incoming',
|
|
@@ -14,9 +16,7 @@ export const LIFECYCLE_EVENTS = new Set([
|
|
|
14
16
|
|
|
15
17
|
// Server defaults
|
|
16
18
|
export const DEFAULT_PORT = 3001
|
|
17
|
-
export const DEFAULT_TIMEOUT_MS = 60_000
|
|
18
19
|
export const DEFAULT_MAX_MESSAGE_SIZE_MB = 5
|
|
19
20
|
|
|
20
21
|
// Server bounds
|
|
21
|
-
export const MIN_TIMEOUT_MS = 10_000
|
|
22
22
|
export const MAX_PORT = 65535
|
package/server/index.js
CHANGED
package/shared/protocol.js
CHANGED
|
@@ -15,6 +15,9 @@ export const PROTOCOL_KEY = ''
|
|
|
15
15
|
/** Command telling the client to close and not reconnect. */
|
|
16
16
|
export const NO_RECONNECT = 'disconnect'
|
|
17
17
|
|
|
18
|
+
/** Milliseconds of silence after which a connection counts as gone. */
|
|
19
|
+
export const TIMEOUT_MS = 60_000
|
|
20
|
+
|
|
18
21
|
/** WebSocket close frames carry at most this many bytes of reason. */
|
|
19
22
|
const MAX_CLOSE_REASON_BYTES = 123
|
|
20
23
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Client's socket lives in a Web Worker, so the browser's own
|
|
3
|
-
* send/receive work (expensive in Chromium) and the heartbeat
|
|
3
|
+
* send/receive work (expensive in Chromium) and the heartbeat stay
|
|
4
4
|
* off the main thread (see `bench/browser`). Only strings cross
|
|
5
5
|
* `postMessage`; everything else stays in Client.
|
|
6
6
|
*
|
|
@@ -1,23 +1,17 @@
|
|
|
1
1
|
/** @import { ClientEvents, EventKey, EventValue } from './index.js' */
|
|
2
|
+
/** @import { Message } from './Message.js' */
|
|
2
3
|
/** @import { Socket } from './Socket.js' */
|
|
3
4
|
import type { ClientEvents, EventKey, EventValue } from './index.js';
|
|
5
|
+
import type { Message } from './Message.js';
|
|
4
6
|
import type { Socket } from './Socket.js';
|
|
5
7
|
export type HasSockets = {
|
|
6
8
|
sockets: Iterable<Socket>;
|
|
7
|
-
broadcast(me: Socket | undefined, k: any, v?: any): void;
|
|
8
9
|
};
|
|
9
|
-
/**
|
|
10
|
-
* @typedef {{
|
|
11
|
-
* sockets: Iterable<Socket>
|
|
12
|
-
* broadcast(me: Socket | undefined, k: any, v?: any): void
|
|
13
|
-
* }} HasSockets
|
|
14
|
-
*/
|
|
15
10
|
/**
|
|
16
11
|
* Common emit / broadcast logic. Subclasses own the `sockets` field
|
|
17
|
-
* (a Set on Server, Room and Rooms). Each fan-out
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* all sockets share a single encoding.
|
|
12
|
+
* (a Set on Server, Room and Rooms). Each fan-out queues **one** wire
|
|
13
|
+
* tuple on every recipient, and `Server.cacheMessages` keeps the encoded
|
|
14
|
+
* form on it, so all sockets share a single encoding.
|
|
21
15
|
*/
|
|
22
16
|
export declare class Emitter {
|
|
23
17
|
/**
|
|
@@ -39,4 +33,19 @@ export declare class Emitter {
|
|
|
39
33
|
* @param {EventValue<ClientEvents, K>} v
|
|
40
34
|
*/
|
|
41
35
|
broadcast<K extends EventKey<ClientEvents>>(this: HasSockets, me: Socket | undefined, k: K, v: EventValue<ClientEvents, K>): void;
|
|
36
|
+
/**
|
|
37
|
+
* Send an encoded message to every socket in scope.
|
|
38
|
+
*
|
|
39
|
+
* @this {HasSockets}
|
|
40
|
+
* @param {Message<any>} message
|
|
41
|
+
*/
|
|
42
|
+
send(this: HasSockets, message: Message<any>): void;
|
|
43
|
+
/**
|
|
44
|
+
* Send an encoded message to every socket in scope except `me`.
|
|
45
|
+
*
|
|
46
|
+
* @this {HasSockets}
|
|
47
|
+
* @param {Socket | undefined} me
|
|
48
|
+
* @param {Message<any>} message
|
|
49
|
+
*/
|
|
50
|
+
sendExcept(this: HasSockets, me: Socket | undefined, message: Message<any>): void;
|
|
42
51
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ClientEvents, EventKey, EventValue } from './index.js';
|
|
2
|
+
/** @import { ClientEvents, EventKey, EventValue } from './index.js' */
|
|
3
|
+
/**
|
|
4
|
+
* A message encoded once, here, to send to any socket at any time with
|
|
5
|
+
* `send` / `sendExcept`. Later changes to `v` don't reach the wire.
|
|
6
|
+
*
|
|
7
|
+
* @template {EventKey<ClientEvents>} K
|
|
8
|
+
*/
|
|
9
|
+
export declare class Message<K extends EventKey<ClientEvents>> {
|
|
10
|
+
/**
|
|
11
|
+
* @internal The wire tuple every send queues.
|
|
12
|
+
* @type {any[]}
|
|
13
|
+
*/
|
|
14
|
+
tuple: any[];
|
|
15
|
+
/** The encoded text; equal text means an equal message. */
|
|
16
|
+
json: string;
|
|
17
|
+
/**
|
|
18
|
+
* @param {K} k
|
|
19
|
+
* @param {EventValue<ClientEvents, K>} v
|
|
20
|
+
*/
|
|
21
|
+
constructor(k: K, v: EventValue<ClientEvents, K>);
|
|
22
|
+
}
|
package/types/server/Room.d.ts
CHANGED
|
@@ -5,13 +5,15 @@ import type { Socket } from './Socket.js';
|
|
|
5
5
|
/** @import { Rooms } from './Rooms.js' */
|
|
6
6
|
/** A group of sockets to emit to; subclass it to hook its lifecycle. */
|
|
7
7
|
export declare class Room extends Emitter {
|
|
8
|
-
|
|
9
|
-
sockets: Set<Socket>;
|
|
8
|
+
#private;
|
|
10
9
|
/** @type {any} Set by `Rooms` for its rooms; yours to set otherwise. */
|
|
11
10
|
id: any;
|
|
12
|
-
/** Keep the room in its `Rooms` list when its last socket leaves. */
|
|
13
|
-
persistent: boolean;
|
|
14
11
|
[parent]: any;
|
|
12
|
+
/**
|
|
13
|
+
* Off the room's own fields, as a subclass may spread its fields into
|
|
14
|
+
* what it sends.
|
|
15
|
+
*/
|
|
16
|
+
get sockets(): Set<Socket>;
|
|
15
17
|
/**
|
|
16
18
|
* Called when a `Rooms` list creates the room for `socket`.
|
|
17
19
|
*
|
|
@@ -30,6 +32,11 @@ export declare class Room extends Emitter {
|
|
|
30
32
|
onLeave(socket: Socket): void;
|
|
31
33
|
/** @param {Socket} socket */
|
|
32
34
|
join(socket: Socket): void;
|
|
33
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Leave the room; a room in a `Rooms` list that this empties is deleted
|
|
37
|
+
* unless it's `persistent`.
|
|
38
|
+
*
|
|
39
|
+
* @param {Socket} socket
|
|
40
|
+
*/
|
|
34
41
|
leave(socket: Socket): void;
|
|
35
42
|
}
|
package/types/server/Rooms.d.ts
CHANGED
|
@@ -42,6 +42,14 @@ export declare class Rooms<R extends Room = Room> extends Emitter {
|
|
|
42
42
|
* @param {any} [id]
|
|
43
43
|
*/
|
|
44
44
|
leave(socket: Socket, id?: any): void;
|
|
45
|
+
/**
|
|
46
|
+
* The rooms of this list `socket` is in. Walks the socket's own rooms,
|
|
47
|
+
* which stay few, not the list's. Leaving one while iterating is fine.
|
|
48
|
+
*
|
|
49
|
+
* @param {Socket} socket
|
|
50
|
+
* @returns {Generator<R>}
|
|
51
|
+
*/
|
|
52
|
+
of(socket: Socket): Generator<R>;
|
|
45
53
|
/** @returns {R[]} */
|
|
46
54
|
toJSON(): R[];
|
|
47
55
|
}
|
package/types/server/Server.d.ts
CHANGED
|
@@ -17,11 +17,6 @@ export type ServerListenOptions = {
|
|
|
17
17
|
* WebSocket path clients connect to.
|
|
18
18
|
*/
|
|
19
19
|
path?: string;
|
|
20
|
-
/**
|
|
21
|
-
* Milliseconds of silence after which a
|
|
22
|
-
* socket is considered gone.
|
|
23
|
-
*/
|
|
24
|
-
timeout?: number;
|
|
25
20
|
/**
|
|
26
21
|
* Max size of a single incoming
|
|
27
22
|
* message, in megabytes.
|
|
@@ -66,7 +61,6 @@ export type PredefinedServerHandlers = {
|
|
|
66
61
|
listen?: () => void;
|
|
67
62
|
connect?: (socket: Socket, request: ConnectRequest) => void;
|
|
68
63
|
disconnect?: (socket: Socket, code: number, reason: string) => void;
|
|
69
|
-
ping?: (socket: Socket) => void;
|
|
70
64
|
timeout?: (socket: Socket, delay: number) => void;
|
|
71
65
|
garbage?: (socket: Socket, data: any) => void;
|
|
72
66
|
incoming?: (socket: Socket, messages: any[]) => void;
|
|
@@ -87,7 +81,6 @@ export declare class Server extends Emitter {
|
|
|
87
81
|
hostname: string;
|
|
88
82
|
port: number;
|
|
89
83
|
path: string;
|
|
90
|
-
timeout: number;
|
|
91
84
|
maxMessageSize: number;
|
|
92
85
|
/**
|
|
93
86
|
* When true, read `x-forwarded-for` from the upgrade headers for
|
|
@@ -122,6 +115,13 @@ export declare class Server extends Emitter {
|
|
|
122
115
|
io: WebSocketServer | null;
|
|
123
116
|
/** @type {import('node:http').Server | null} */
|
|
124
117
|
httpServer: import('node:http').Server | null;
|
|
118
|
+
/**
|
|
119
|
+
* Class of the sockets it creates; set it before `listen` to have each
|
|
120
|
+
* connection be a subclass of `Socket`.
|
|
121
|
+
*
|
|
122
|
+
* @type {typeof Socket}
|
|
123
|
+
*/
|
|
124
|
+
SocketClass: typeof Socket;
|
|
125
125
|
constructor();
|
|
126
126
|
/**
|
|
127
127
|
* Start listening for WebSocket connections. Serves `wss://` when
|
package/types/server/Socket.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { inspect } from './utils.js';
|
|
2
2
|
import { WebSocket } from 'ws';
|
|
3
3
|
import type { ClientEvents, ClientReplies, EventKey, EventValue } from './index.js';
|
|
4
|
+
import type { Message } from './Message.js';
|
|
4
5
|
import type { Room } from './Room.js';
|
|
5
6
|
import type { Server } from './Server.js';
|
|
6
7
|
/** One client connection, handed to every event handler and hook. */
|
|
@@ -20,7 +21,6 @@ export declare class Socket {
|
|
|
20
21
|
messagesReceived: number;
|
|
21
22
|
since: number;
|
|
22
23
|
seen: number;
|
|
23
|
-
ping: number;
|
|
24
24
|
timedout: boolean;
|
|
25
25
|
closed: boolean;
|
|
26
26
|
/** @type {Set<Room>} The rooms this socket is in. */
|
|
@@ -79,6 +79,12 @@ export declare class Socket {
|
|
|
79
79
|
* @returns {Promise<any> | void}
|
|
80
80
|
*/
|
|
81
81
|
emit<K extends EventKey<ClientEvents>, R>(k: K, v: EventValue<ClientEvents, K>, callback: (reply: EventValue<ClientReplies, K>) => R): Promise<R>;
|
|
82
|
+
/**
|
|
83
|
+
* Queue an encoded message for the next batch.
|
|
84
|
+
*
|
|
85
|
+
* @param {Message<any>} message
|
|
86
|
+
*/
|
|
87
|
+
send(message: Message<any>): void;
|
|
82
88
|
/**
|
|
83
89
|
* Emit and resolve with the client's reply; rejected with
|
|
84
90
|
* a `disconnected:` Error if the socket closes first.
|
|
@@ -132,7 +138,8 @@ export declare class Socket {
|
|
|
132
138
|
* 123 bytes; longer throws a `RangeError` before anything closes.
|
|
133
139
|
*/
|
|
134
140
|
disconnect(noReconnect?: boolean, reason?: string): void;
|
|
135
|
-
|
|
141
|
+
/** @returns {any} What sending a socket sends; a subclass says what. */
|
|
142
|
+
toJSON(): any;
|
|
136
143
|
/**
|
|
137
144
|
* `console.log(socket)` shows who it is and how it's doing, not internals.
|
|
138
145
|
*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export declare const parent: unique symbol;
|
|
2
|
+
export declare const encoded: unique symbol;
|
|
3
|
+
export declare const framed: unique symbol;
|
|
2
4
|
export declare const LIFECYCLE_EVENTS: Set<string>;
|
|
3
5
|
export declare const DEFAULT_PORT = 3001;
|
|
4
|
-
export declare const DEFAULT_TIMEOUT_MS = 60000;
|
|
5
6
|
export declare const DEFAULT_MAX_MESSAGE_SIZE_MB = 5;
|
|
6
|
-
export declare const MIN_TIMEOUT_MS = 10000;
|
|
7
7
|
export declare const MAX_PORT = 65535;
|
package/types/server/index.d.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
export { default, Server } from './Server.js'
|
|
2
|
+
export { Emitter } from './Emitter.js'
|
|
3
|
+
export { Message } from './Message.js'
|
|
2
4
|
export { Room } from './Room.js'
|
|
3
5
|
export { Rooms } from './Rooms.js'
|
|
4
|
-
export
|
|
6
|
+
export { Socket } from './Socket.js'
|
|
7
|
+
|
|
8
|
+
declare module './Room.js' {
|
|
9
|
+
interface Room {
|
|
10
|
+
/** Keep the room in its `Rooms` list when its last socket leaves. */
|
|
11
|
+
persistent: boolean
|
|
12
|
+
}
|
|
13
|
+
}
|
|
5
14
|
|
|
6
15
|
/**
|
|
7
16
|
* Event-map interfaces. Augment these from your app to get end-to-end
|
|
@@ -9,6 +9,8 @@ export declare const PACKED_PARAM = "m";
|
|
|
9
9
|
export declare const PROTOCOL_KEY = "";
|
|
10
10
|
/** Command telling the client to close and not reconnect. */
|
|
11
11
|
export declare const NO_RECONNECT = "disconnect";
|
|
12
|
+
/** Milliseconds of silence after which a connection counts as gone. */
|
|
13
|
+
export declare const TIMEOUT_MS = 60000;
|
|
12
14
|
/**
|
|
13
15
|
* Throw before closing with a reason the WebSocket would reject, so a
|
|
14
16
|
* bad reason fails with nothing half done.
|