angelia.io 4.0.91 → 5.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/client/index.js +479 -137
- package/client/transport.js +164 -0
- package/package.json +33 -11
- package/readme.md +318 -123
- package/server/Emitter.js +38 -32
- package/server/Room.js +44 -14
- package/server/Rooms.js +43 -35
- package/server/Server.js +527 -160
- package/server/Socket.js +344 -138
- package/server/constants.js +18 -0
- package/server/index.js +1 -1
- package/server/utils.js +48 -36
- package/shared/protocol.js +39 -0
- package/types/client/index.d.ts +252 -33
- package/types/client/transport.d.ts +46 -0
- package/types/server/Emitter.d.ts +41 -11
- package/types/server/Room.d.ts +33 -18
- package/types/server/Rooms.d.ts +46 -14
- package/types/server/Server.d.ts +145 -57
- package/types/server/Socket.d.ts +136 -41
- package/types/server/constants.d.ts +5 -3
- package/types/server/index.d.ts +50 -3
- package/types/server/utils.d.ts +30 -34
- package/types/shared/protocol.d.ts +20 -0
- package/.github/FUNDING.yml +0 -13
- package/client/worker.js +0 -227
- package/index.js +0 -9
- package/tsconfig.json +0 -23
- package/types/client/worker.d.ts +0 -1
- package/types/index.d.ts +0 -1
package/client/index.js
CHANGED
|
@@ -1,201 +1,543 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { workerTransport } from './transport.js'
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_PATH,
|
|
4
|
+
NO_RECONNECT,
|
|
5
|
+
PACKED_PARAM,
|
|
6
|
+
PROTOCOL_KEY,
|
|
7
|
+
assertCloseReason,
|
|
8
|
+
} from '../shared/protocol.js'
|
|
4
9
|
|
|
5
10
|
const error = console.error
|
|
6
11
|
|
|
12
|
+
// Rejection messages for replies that can no longer arrive.
|
|
13
|
+
const RECONNECTING =
|
|
14
|
+
'reconnecting: the connection dropped before the server replied'
|
|
15
|
+
const DISCONNECTED =
|
|
16
|
+
'disconnected: disconnect(true) was called, so nothing more is sent or answered'
|
|
17
|
+
const EMPTY_KEY = "[angelia.io] the empty key '' is reserved for replies"
|
|
18
|
+
const stringify = JSON.stringify
|
|
19
|
+
const parse = JSON.parse
|
|
20
|
+
const isArray = Array.isArray
|
|
21
|
+
|
|
22
|
+
/** @returns {v is Function} */
|
|
23
|
+
const isFunction = (/** @type {any} */ v) => typeof v === 'function'
|
|
24
|
+
|
|
25
|
+
// Pack queued messages into the connect URL while the handshake's
|
|
26
|
+
// request line stays within nginx's default `large_client_header_buffers`
|
|
27
|
+
// (8k): a longer line gets a 414.
|
|
28
|
+
const REQUEST_LINE_LIMIT = 8192
|
|
29
|
+
|
|
30
|
+
/** Length of `text` once form-encoded into a query param value. */
|
|
31
|
+
const encodedLength = text =>
|
|
32
|
+
new URLSearchParams({ [PACKED_PARAM]: text }).toString().length -
|
|
33
|
+
PACKED_PARAM.length -
|
|
34
|
+
1
|
|
35
|
+
|
|
36
|
+
// "Going away": a server shutting down, or the page unloading. Firefox
|
|
37
|
+
// closes the page's sockets with it during a reload, before `pagehide`.
|
|
38
|
+
const GOING_AWAY = 1001
|
|
39
|
+
|
|
40
|
+
// Retries wait RETRY_MIN_MS plus up to RETRY_JITTER_MS, so a server
|
|
41
|
+
// bounce doesn't get every client back at the same instant.
|
|
42
|
+
const RETRY_MIN_MS = 2000
|
|
43
|
+
const RETRY_JITTER_MS = 2000
|
|
44
|
+
|
|
45
|
+
/** `request()`'s callback: resolve with the reply as it is. */
|
|
46
|
+
const settle = reply => reply
|
|
47
|
+
|
|
48
|
+
const noop = () => {}
|
|
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
|
+
|
|
62
|
+
const newConnid = () =>
|
|
63
|
+
crypto.getRandomValues(new BigUint64Array(1))[0].toString(36)
|
|
64
|
+
|
|
65
|
+
/** @import { ClientEvents, ClientReplies, EventKey, EventValue, ServerEvents, ServerReplies } from '../server/index.js' */
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {object} ClientOptions
|
|
69
|
+
* @property {string} url Server URL, `ws://` or `wss://`. Without a path
|
|
70
|
+
* the client connects to the default one; give it to match the
|
|
71
|
+
* server's `path` option.
|
|
72
|
+
* @property {Record<string, any> | (() => Record<string, any>)} [params]
|
|
73
|
+
* Query params sent on connect; a function is called on every connect.
|
|
74
|
+
* @property {boolean} [noConnect] Don't connect until `connect()`.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Events the client fires itself, with no payload.
|
|
79
|
+
*
|
|
80
|
+
* @typedef {'connect' | 'reconnect' | 'disconnect'} ClientPredefinedEvent
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/** @typedef {{ signal?: AbortSignal }} ListenOptions */
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A connection to an angelia.io server, run from a Web Worker. Connects
|
|
87
|
+
* on construction and reconnects on its own.
|
|
88
|
+
*/
|
|
7
89
|
export default class Client {
|
|
8
|
-
#
|
|
9
|
-
#listeners
|
|
10
|
-
#messages
|
|
11
|
-
#postMessage
|
|
90
|
+
#connected = false
|
|
12
91
|
|
|
13
92
|
/**
|
|
14
|
-
*
|
|
93
|
+
* Replies the server still owes, by callback id. `fire(args)` runs
|
|
94
|
+
* the user's callback and resolves their promise; `cancel(err)`
|
|
95
|
+
* rejects it (on reconnect, when the reply can no longer arrive).
|
|
15
96
|
*
|
|
16
|
-
* @
|
|
17
|
-
*
|
|
97
|
+
* @type {Map<
|
|
98
|
+
* number,
|
|
99
|
+
* { fire: (args: any[]) => void; cancel: (err: Error) => void }
|
|
100
|
+
* >}
|
|
18
101
|
*/
|
|
102
|
+
#callbacks = new Map()
|
|
103
|
+
/** Last callback id handed out; ids start at 1, as 0 means "none". */
|
|
104
|
+
#lastCallbackId = 0
|
|
105
|
+
// No prototype, so only registered keys are found ('constructor',
|
|
106
|
+
// '__proto__', … are ordinary event names).
|
|
107
|
+
/** @type {Record<string, Function[]>} */
|
|
108
|
+
#listeners = Object.create(null)
|
|
109
|
+
/** @type {any[]} */
|
|
110
|
+
#messages = []
|
|
111
|
+
/** @type {import('./transport.js').Connection | null} */
|
|
112
|
+
#io = null
|
|
113
|
+
/** @type {import('./transport.js').Transport} */
|
|
114
|
+
#transport
|
|
115
|
+
#connid = newConnid()
|
|
116
|
+
#isReconnect = false
|
|
117
|
+
#reconnect = true
|
|
118
|
+
#packFailed = false
|
|
119
|
+
#online = () => this.connect()
|
|
120
|
+
|
|
121
|
+
/** @param {ClientOptions} options */
|
|
19
122
|
constructor(options) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
123
|
+
const { protocol } = new URL(options.url)
|
|
124
|
+
if (protocol !== 'ws:' && protocol !== 'wss:') {
|
|
125
|
+
throw new TypeError(
|
|
126
|
+
`Client url must be ws:// or wss://, got ${protocol}//. Give the server's WebSocket address, e.g. ws://localhost:3001.`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (typeof Worker === 'undefined') {
|
|
131
|
+
throw new TypeError(
|
|
132
|
+
'angelia.io/client runs its socket in a Web Worker; this environment has none. The client needs a browser.',
|
|
133
|
+
)
|
|
24
134
|
}
|
|
25
135
|
|
|
26
136
|
this.options = options
|
|
137
|
+
this.#transport = workerTransport()
|
|
27
138
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
this.#messages = []
|
|
139
|
+
// Protocol messages (replies, commands) go through the same
|
|
140
|
+
// dispatch loop as events, under the reserved key.
|
|
141
|
+
this.#listeners[PROTOCOL_KEY] = [this.#onprotocol]
|
|
33
142
|
|
|
34
|
-
|
|
143
|
+
// Deferred so emits made right after construction are packed
|
|
144
|
+
// into the connect URL.
|
|
145
|
+
if (!options.noConnect) queueMicrotask(() => this.connect())
|
|
146
|
+
}
|
|
35
147
|
|
|
36
|
-
|
|
37
|
-
|
|
148
|
+
/** Open the socket; no-op while open or after `disconnect(true)`. */
|
|
149
|
+
connect() {
|
|
150
|
+
if (this.#io || !this.#reconnect) return
|
|
151
|
+
// The same function again is no second listener.
|
|
152
|
+
addEventListener('online', this.#online)
|
|
38
153
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
)
|
|
42
|
-
const io = new Worker(workerURL)
|
|
43
|
-
URL.revokeObjectURL(workerURL)
|
|
154
|
+
const url = new URL(this.options.url)
|
|
155
|
+
if (url.pathname === '/') url.pathname = DEFAULT_PATH
|
|
44
156
|
|
|
45
|
-
|
|
46
|
-
|
|
157
|
+
const { searchParams } = url
|
|
158
|
+
const p = this.options.params
|
|
159
|
+
const params = (isFunction(p) ? p() : p) || {}
|
|
47
160
|
|
|
48
|
-
|
|
49
|
-
|
|
161
|
+
for (const [k, v] of Object.entries(params)) {
|
|
162
|
+
searchParams.append(k, v)
|
|
163
|
+
}
|
|
164
|
+
searchParams.append('connid', this.#connid)
|
|
50
165
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
this.#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
166
|
+
/** @type {any[]} */
|
|
167
|
+
let packed = []
|
|
168
|
+
if (this.#isReconnect) {
|
|
169
|
+
this.#messages = []
|
|
170
|
+
} else {
|
|
171
|
+
packed = this.#pack(url)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** @type {number | null} When this connection opened. */
|
|
175
|
+
let openedAt = null
|
|
176
|
+
// Every handler checks it still belongs to the live connection,
|
|
177
|
+
// so a late event can't clobber a newer one.
|
|
178
|
+
const io = this.#transport.open(url, {
|
|
179
|
+
onopen: () => {
|
|
180
|
+
if (this.#io !== io) return
|
|
181
|
+
openedAt = Date.now()
|
|
182
|
+
this.#connected = true
|
|
183
|
+
if (this.#isReconnect) {
|
|
184
|
+
this.#dispatch([['reconnect', undefined]])
|
|
185
|
+
} else {
|
|
186
|
+
this.#isReconnect = true
|
|
187
|
+
this.#dispatch([['connect', undefined]])
|
|
188
|
+
}
|
|
189
|
+
this.#send()
|
|
190
|
+
},
|
|
191
|
+
onmessage: text => {
|
|
192
|
+
if (this.#io === io) this.#dispatch(parse(text))
|
|
193
|
+
},
|
|
194
|
+
onclose: code => {
|
|
195
|
+
if (this.#io !== io) return
|
|
196
|
+
// The URL never reached a server that accepted it: put the
|
|
197
|
+
// packed messages back in front, and stop packing, since the
|
|
198
|
+
// URL itself may be what got rejected.
|
|
199
|
+
if (openedAt === null && packed.length) {
|
|
200
|
+
this.#messages = [...packed, ...this.#messages]
|
|
201
|
+
this.#packFailed = true
|
|
202
|
+
}
|
|
203
|
+
this.#handleClosed(code, openedAt)
|
|
61
204
|
},
|
|
62
|
-
|
|
205
|
+
onerror: err =>
|
|
206
|
+
error('[angelia.io] client socket error. The client keeps going:', err),
|
|
207
|
+
})
|
|
208
|
+
this.#io = io
|
|
63
209
|
}
|
|
64
210
|
|
|
65
211
|
/**
|
|
66
|
-
*
|
|
212
|
+
* @param {number} code Close code
|
|
213
|
+
* @param {number | null} openedAt When the connection opened, if it did
|
|
214
|
+
*/
|
|
215
|
+
#handleClosed(code, openedAt) {
|
|
216
|
+
this.#io = null
|
|
217
|
+
const uptime = openedAt === null ? 0 : Date.now() - openedAt
|
|
218
|
+
// A reply only travels on the connection that asked, and a reconnect
|
|
219
|
+
// drops the queue, so none can come now. A connection that never
|
|
220
|
+
// opened sent nothing: its messages and callbacks wait for the next
|
|
221
|
+
// attempt.
|
|
222
|
+
if (openedAt !== null && this.#reconnect) this.#cancelPending(RECONNECTING)
|
|
223
|
+
if (this.#connected) {
|
|
224
|
+
this.#connected = false
|
|
225
|
+
this.#dispatch([['disconnect', undefined]])
|
|
226
|
+
}
|
|
227
|
+
if (!this.#reconnect) {
|
|
228
|
+
this.#shutdown()
|
|
229
|
+
} else if (uptime >= RETRY_MIN_MS && code !== GOING_AWAY) {
|
|
230
|
+
// A working connection dropped: get back right away.
|
|
231
|
+
this.connect()
|
|
232
|
+
} else {
|
|
233
|
+
// A failed attempt, a connection dropped as soon as it opened
|
|
234
|
+
// (which would otherwise loop as fast as it can), or "going
|
|
235
|
+
// away": an unloading page is gone before the delay ends, so it
|
|
236
|
+
// doesn't open a second connection, while a server that went
|
|
237
|
+
// away still gets a retry.
|
|
238
|
+
// `unref?.()` keeps a leaked Client from pinning a Node
|
|
239
|
+
// process alive; browsers return a number and skip it.
|
|
240
|
+
setTimeout(
|
|
241
|
+
() => this.connect(),
|
|
242
|
+
RETRY_MIN_MS + Math.random() * RETRY_JITTER_MS,
|
|
243
|
+
)?.unref?.()
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Whether the socket is open. */
|
|
248
|
+
get connected() {
|
|
249
|
+
return this.#connected
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** `using client = new Client(…)` disconnects for good at scope end. */
|
|
253
|
+
[Symbol.dispose]() {
|
|
254
|
+
this.disconnect(true)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Close the socket.
|
|
67
259
|
*
|
|
68
|
-
* @param {boolean} noReconnect
|
|
260
|
+
* @param {boolean} [noReconnect] `true` stops the auto-reconnect.
|
|
261
|
+
* @param {string} [reason] Close reason sent to the server, up to 123
|
|
262
|
+
* bytes; longer throws a `RangeError` before anything closes.
|
|
69
263
|
*/
|
|
70
|
-
disconnect =
|
|
71
|
-
|
|
264
|
+
disconnect(noReconnect, reason = 'client disconnect') {
|
|
265
|
+
assertCloseReason(reason)
|
|
266
|
+
if (noReconnect) this.#reconnect = false
|
|
267
|
+
if (this.#io) this.#io.close(1000, reason)
|
|
268
|
+
// Between retries there's no connection whose close would clean up.
|
|
269
|
+
else if (noReconnect) this.#shutdown()
|
|
72
270
|
}
|
|
73
271
|
|
|
74
272
|
/**
|
|
75
|
-
*
|
|
273
|
+
* Emit to the server. With a callback, returns a promise for the
|
|
274
|
+
* callback's result, rejected with a `reconnecting:` Error if the
|
|
275
|
+
* connection drops before the reply arrives, or a `disconnected:` Error
|
|
276
|
+
* after `disconnect(true)`.
|
|
76
277
|
*
|
|
77
|
-
* @
|
|
78
|
-
* @
|
|
278
|
+
* @template {EventKey<ServerEvents>} K
|
|
279
|
+
* @overload
|
|
280
|
+
* @param {K} key
|
|
281
|
+
* @returns {undefined}
|
|
282
|
+
*/
|
|
283
|
+
/**
|
|
284
|
+
* @template {EventKey<ServerEvents>} K
|
|
285
|
+
* @overload
|
|
286
|
+
* @param {K} key
|
|
287
|
+
* @param {EventValue<ServerEvents, K>} value
|
|
288
|
+
* @returns {undefined}
|
|
289
|
+
*/
|
|
290
|
+
/**
|
|
291
|
+
* @template {EventKey<ServerEvents>} K
|
|
292
|
+
* @template R
|
|
293
|
+
* @overload
|
|
294
|
+
* @param {K} key
|
|
295
|
+
* @param {EventValue<ServerEvents, K>} value
|
|
296
|
+
* @param {(reply: EventValue<ServerReplies, K>) => R} callback
|
|
297
|
+
* @returns {Promise<R>}
|
|
298
|
+
*/
|
|
299
|
+
/**
|
|
300
|
+
* @param {any} key
|
|
301
|
+
* @param {any} [value]
|
|
79
302
|
* @param {Function} [callback]
|
|
80
|
-
* @returns {Promise<any> | undefined}
|
|
81
|
-
* returns a promise
|
|
303
|
+
* @returns {Promise<any> | undefined}
|
|
82
304
|
*/
|
|
83
|
-
emit(key,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
} else {
|
|
88
|
-
if (!this.#messages.length) {
|
|
89
|
-
queueMicrotask(() => queueMicrotask(this.#send))
|
|
90
|
-
}
|
|
91
|
-
if (callback) {
|
|
92
|
-
const [i, promise] = this.#callback(callback)
|
|
93
|
-
this.#messages.push([key, valueOrCallback, i])
|
|
94
|
-
return promise
|
|
95
|
-
} else if (isFunction(valueOrCallback)) {
|
|
96
|
-
const [i, promise] = this.#callback(valueOrCallback)
|
|
97
|
-
this.#messages.push([key, 0, i])
|
|
98
|
-
return promise
|
|
99
|
-
} else if (valueOrCallback !== undefined) {
|
|
100
|
-
this.#messages.push([key, valueOrCallback])
|
|
101
|
-
} else {
|
|
102
|
-
this.#messages.push([key])
|
|
103
|
-
}
|
|
305
|
+
emit(key, value, callback) {
|
|
306
|
+
if (key === PROTOCOL_KEY) {
|
|
307
|
+
error(`${EMPTY_KEY}; this message was not sent.`)
|
|
308
|
+
return
|
|
104
309
|
}
|
|
310
|
+
// After `disconnect(true)` nothing will be sent: don't queue it.
|
|
311
|
+
if (!this.#reconnect) {
|
|
312
|
+
return callback ? handled(Promise.reject(new Error(DISCONNECTED))) : undefined
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (callback) {
|
|
316
|
+
const [id, promise] = this.#callback(callback)
|
|
317
|
+
this.#queue([key, value, id])
|
|
318
|
+
return promise
|
|
319
|
+
}
|
|
320
|
+
this.#queue(value === undefined ? [key] : [key, value])
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Emit and resolve with the server's reply.
|
|
325
|
+
*
|
|
326
|
+
* @template {EventKey<ServerEvents>} K
|
|
327
|
+
* @overload
|
|
328
|
+
* @param {K} key
|
|
329
|
+
* @returns {Promise<EventValue<ServerReplies, K>>}
|
|
330
|
+
*/
|
|
331
|
+
/**
|
|
332
|
+
* @template {EventKey<ServerEvents>} K
|
|
333
|
+
* @overload
|
|
334
|
+
* @param {K} key
|
|
335
|
+
* @param {EventValue<ServerEvents, K>} value
|
|
336
|
+
* @returns {Promise<EventValue<ServerReplies, K>>}
|
|
337
|
+
*/
|
|
338
|
+
/**
|
|
339
|
+
* @param {any} key
|
|
340
|
+
* @param {any} [value]
|
|
341
|
+
* @returns {Promise<any>}
|
|
342
|
+
*/
|
|
343
|
+
request(key, value) {
|
|
344
|
+
return this.emit(key, value, settle)
|
|
105
345
|
}
|
|
106
346
|
|
|
107
347
|
/**
|
|
108
|
-
* Listen for
|
|
348
|
+
* Listen for a predefined event. Returns a function that stops
|
|
349
|
+
* listening; aborting `options.signal` does the same.
|
|
109
350
|
*
|
|
351
|
+
* @overload
|
|
352
|
+
* @param {ClientPredefinedEvent} key
|
|
353
|
+
* @param {() => void} value
|
|
354
|
+
* @param {ListenOptions} [options]
|
|
355
|
+
* @returns {() => void}
|
|
356
|
+
*/
|
|
357
|
+
/**
|
|
358
|
+
* Listen for a server event. Returns a function that stops
|
|
359
|
+
* listening; aborting `options.signal` does the same.
|
|
360
|
+
*
|
|
361
|
+
* @template {EventKey<ClientEvents>} K
|
|
362
|
+
* @overload
|
|
363
|
+
* @param {K} key
|
|
364
|
+
* @param {(
|
|
365
|
+
* v: EventValue<ClientEvents, K>,
|
|
366
|
+
* reply?: (v: EventValue<ClientReplies, K>) => void,
|
|
367
|
+
* ) => void} value
|
|
368
|
+
* @param {ListenOptions} [options]
|
|
369
|
+
* @returns {() => void}
|
|
370
|
+
*/
|
|
371
|
+
/**
|
|
110
372
|
* @param {string} key
|
|
111
373
|
* @param {Function} value
|
|
112
|
-
* @
|
|
113
|
-
*
|
|
374
|
+
* @param {ListenOptions} [options]
|
|
375
|
+
* @returns {() => void}
|
|
114
376
|
*/
|
|
115
|
-
on(key, value) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
377
|
+
on(key, value, options) {
|
|
378
|
+
if (key === PROTOCOL_KEY) {
|
|
379
|
+
error(`${EMPTY_KEY}; this listener was not added.`)
|
|
380
|
+
return noop
|
|
381
|
+
}
|
|
382
|
+
const signal = options?.signal
|
|
383
|
+
if (signal?.aborted) return noop
|
|
384
|
+
// Copy on write: a dispatch already walking the old array is unaffected.
|
|
385
|
+
this.#listeners[key] = [...(this.#listeners[key] || []), value]
|
|
386
|
+
const off = () => {
|
|
387
|
+
this.#off(key, value)
|
|
388
|
+
signal?.removeEventListener('abort', off)
|
|
123
389
|
}
|
|
390
|
+
signal?.addEventListener('abort', off, { once: true })
|
|
391
|
+
return off
|
|
124
392
|
}
|
|
125
393
|
|
|
126
394
|
// PRIVATE API
|
|
127
395
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
396
|
+
/**
|
|
397
|
+
* Move the longest prefix of the queue that fits the request-line
|
|
398
|
+
* budget into the connect URL, so it dispatches on connect; the rest
|
|
399
|
+
* is sent once the socket opens. Stops at the first message that
|
|
400
|
+
* doesn't fit, to keep order. Off after a failed attempt that carried
|
|
401
|
+
* packed messages. Returns the packed messages.
|
|
402
|
+
*
|
|
403
|
+
* @param {URL} url
|
|
404
|
+
*/
|
|
405
|
+
#pack(url) {
|
|
406
|
+
if (this.#packFailed) return []
|
|
407
|
+
const messages = this.#messages
|
|
408
|
+
// Form-encoding works per character and yields ASCII, so encoded
|
|
409
|
+
// lengths add up and equal bytes. `search` always holds `connid`.
|
|
410
|
+
let length =
|
|
411
|
+
'GET '.length +
|
|
412
|
+
url.pathname.length +
|
|
413
|
+
url.search.length +
|
|
414
|
+
' HTTP/1.1\r\n'.length +
|
|
415
|
+
`&${PACKED_PARAM}=`.length +
|
|
416
|
+
encodedLength('[]')
|
|
417
|
+
const separator = encodedLength(',')
|
|
418
|
+
/** @type {string[]} */
|
|
419
|
+
const parts = []
|
|
420
|
+
for (const message of messages) {
|
|
421
|
+
const part = stringify(message)
|
|
422
|
+
const added =
|
|
423
|
+
encodedLength(part) + (parts.length ? separator : 0)
|
|
424
|
+
if (length + added > REQUEST_LINE_LIMIT) break
|
|
425
|
+
length += added
|
|
426
|
+
parts.push(part)
|
|
132
427
|
}
|
|
428
|
+
if (!parts.length) return []
|
|
429
|
+
|
|
430
|
+
url.searchParams.append(PACKED_PARAM, '[' + parts.join(',') + ']')
|
|
431
|
+
this.#messages = messages.slice(parts.length)
|
|
432
|
+
return messages.slice(0, parts.length)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** After `disconnect(true)`: nothing will be sent or answered again. */
|
|
436
|
+
#shutdown() {
|
|
437
|
+
removeEventListener('online', this.#online)
|
|
438
|
+
this.#transport.terminate()
|
|
439
|
+
this.#messages = []
|
|
440
|
+
this.#cancelPending(DISCONNECTED)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** @param {string} message */
|
|
444
|
+
#cancelPending(message) {
|
|
445
|
+
if (!this.#callbacks.size) return
|
|
446
|
+
const error = new Error(message)
|
|
447
|
+
for (const pending of this.#callbacks.values()) pending.cancel(error)
|
|
448
|
+
this.#callbacks.clear()
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Push a message, scheduling a flush when the queue was empty. */
|
|
452
|
+
#queue(message) {
|
|
453
|
+
// After `disconnect(true)` nothing will be sent (replies included).
|
|
454
|
+
if (!this.#reconnect) return
|
|
455
|
+
if (!this.#messages.length) queueMicrotask(this.#flushSoon)
|
|
456
|
+
this.#messages.push(message)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Two microtasks deep, so emits made by code reacting to this one
|
|
460
|
+
// (e.g. in a `.then`) join the same batch. Built once, not per flush.
|
|
461
|
+
#flushSoon = () => queueMicrotask(this.#send)
|
|
462
|
+
|
|
463
|
+
#off(k, v) {
|
|
464
|
+
// Copy on write, like `on`: removing a listener mid-dispatch must not
|
|
465
|
+
// shift the array the dispatch loop is walking.
|
|
466
|
+
this.#listeners[k] = this.#listeners[k].filter(fn => fn !== v)
|
|
133
467
|
}
|
|
134
468
|
|
|
135
469
|
/** @returns {[number, Promise<any>]} */
|
|
136
470
|
#callback(c) {
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
471
|
+
const { promise, resolve, reject } = Promise.withResolvers()
|
|
472
|
+
const id = ++this.#lastCallbackId
|
|
473
|
+
this.#callbacks.set(id, {
|
|
474
|
+
fire: args => {
|
|
475
|
+
// A throwing callback rejects its promise instead of leaving it pending.
|
|
476
|
+
try {
|
|
477
|
+
resolve(c(...args))
|
|
478
|
+
} catch (err) {
|
|
479
|
+
reject(err)
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
cancel: err => {
|
|
483
|
+
handled(promise)
|
|
484
|
+
reject(err)
|
|
485
|
+
},
|
|
486
|
+
})
|
|
487
|
+
return [id, promise]
|
|
142
488
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
489
|
+
|
|
490
|
+
/** A reply `[id, args]` or a command from the server. */
|
|
491
|
+
#onprotocol = d => {
|
|
492
|
+
if (d === NO_RECONNECT) {
|
|
493
|
+
this.disconnect(true)
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
const pending = isArray(d) && this.#callbacks.get(d[0])
|
|
497
|
+
if (pending) {
|
|
498
|
+
this.#callbacks.delete(d[0])
|
|
499
|
+
pending.fire(d[1])
|
|
500
|
+
}
|
|
146
501
|
}
|
|
147
502
|
|
|
148
503
|
#dispatch(d) {
|
|
149
504
|
for (const e of d) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
|
|
505
|
+
const listeners = this.#listeners[e[0]]
|
|
506
|
+
if (!listeners) continue
|
|
507
|
+
let replied = false
|
|
508
|
+
const reply = e[2]
|
|
509
|
+
? (...args) => {
|
|
510
|
+
if (replied) {
|
|
511
|
+
return error(
|
|
512
|
+
`[angelia.io] reply() already called for "${e[0]}": a message gets one reply, so this one was dropped.`,
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
replied = true
|
|
516
|
+
|
|
517
|
+
this.#queue([PROTOCOL_KEY, [e[2], args]])
|
|
518
|
+
}
|
|
519
|
+
: undefined
|
|
520
|
+
for (const fn of listeners) {
|
|
521
|
+
try {
|
|
522
|
+
fn(e[1], reply)
|
|
523
|
+
} catch (err) {
|
|
524
|
+
// Rethrown on its own so it still surfaces as uncaught,
|
|
525
|
+
// without skipping the rest of the batch.
|
|
526
|
+
queueMicrotask(() => {
|
|
527
|
+
throw err
|
|
528
|
+
})
|
|
160
529
|
}
|
|
161
530
|
}
|
|
162
531
|
}
|
|
163
532
|
}
|
|
164
533
|
|
|
165
534
|
#send = () => {
|
|
166
|
-
|
|
167
|
-
|
|
535
|
+
const io = this.#io
|
|
536
|
+
if (io && this.#connected && this.#messages.length) {
|
|
537
|
+
const batch = this.#messages
|
|
538
|
+
// A fresh array is cheaper for V8 than clearing this one.
|
|
168
539
|
this.#messages = []
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
#onworkermessage = e => {
|
|
174
|
-
switch (e.data[0]) {
|
|
175
|
-
case 'messages': {
|
|
176
|
-
this.#dispatch(e.data[1])
|
|
177
|
-
break
|
|
178
|
-
}
|
|
179
|
-
case 'dispatch': {
|
|
180
|
-
this.#dispatch([[e.data[1], e.data[2]]])
|
|
181
|
-
break
|
|
182
|
-
}
|
|
183
|
-
case 'connect': {
|
|
184
|
-
this.connect()
|
|
185
|
-
break
|
|
186
|
-
}
|
|
187
|
-
case 'connected': {
|
|
188
|
-
this.connected = e.data[1]
|
|
189
|
-
break
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
decode(s) {
|
|
195
|
-
if (s.b) {
|
|
196
|
-
return fetch(s.b).then(r => r.blob())
|
|
197
|
-
} else if (s.a) {
|
|
198
|
-
return fetch(s.a).then(r => r.arrayBuffer())
|
|
540
|
+
io.send(stringify(batch))
|
|
199
541
|
}
|
|
200
542
|
}
|
|
201
543
|
}
|