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