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 CHANGED
@@ -1,201 +1,524 @@
1
- import { ClientWebWorker } from './worker.js'
2
-
3
- const isFunction = v => typeof v === 'function'
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
- #callbacks
9
- #listeners
10
- #messages
11
- #postMessage
78
+ #connected = false
12
79
 
13
80
  /**
14
- * Creates a Socket Client Instance
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
- * @param options {string | {url:string, params?:Function,
17
- * noConnect?:boolean }}
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
- if (typeof options === 'string') {
21
- options = {
22
- url: options,
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
- this.#callbacks = [null]
29
- this.#listeners = {
30
- '': [this.#oncallback],
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
- this.connected = false
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
- // to force disconnect
37
- this.on('disconnect', this.disconnect)
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 workerURL = URL.createObjectURL(
40
- new Blob(['(' + ClientWebWorker + ')()']),
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
- io.onmessage = this.#onworkermessage
46
- this.#postMessage = io.postMessage.bind(io)
142
+ const { searchParams } = url
143
+ const p = this.options.params
144
+ const params = (isFunction(p) ? p() : p) || {}
47
145
 
48
- options.noConnect === undefined && this.connect()
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
- /** Connect to server */
52
- connect() {
53
- this.#postMessage([
54
- 'connect',
55
- {
56
- url: this.options.url,
57
- params:
58
- (isFunction(this.options.params)
59
- ? this.options.params()
60
- : this.options.params) || {},
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
- * Disconnect from server
243
+ * Close the socket.
67
244
  *
68
- * @param {boolean} noReconnect - To prevent reconnections
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 = noReconnect => {
71
- this.#postMessage(['disconnect', noReconnect])
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
- * Listen for data sent by the server
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
- * @param {string} key
78
- * @param {any | Function} [valueOrCallback]
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} When callback is provided it
81
- * returns a promise
288
+ * @returns {Promise<any> | undefined}
82
289
  */
83
- emit(key, valueOrCallback, callback) {
84
- // empty `key` is reserved
85
- if (key == '') {
86
- error('`key` cannot be empty')
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
- }
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 data sent by the server
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
- * @returns () => void | undefined - `off` function to remove the
113
- * listener
359
+ * @param {ListenOptions} [options]
360
+ * @returns {() => void}
114
361
  */
115
- on(key, value) {
116
- // empty `key` is reserved
117
- if (key == '') {
118
- error('`key` cannot be empty')
119
- } else {
120
- this.#listeners[key] = this.#listeners[key] || []
121
- this.#listeners[key].push(value)
122
- return () => this.#off(key, value)
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
- #off(k, v) {
129
- const i = this.#listeners[k].indexOf(v)
130
- if (i !== -1) {
131
- this.#listeners[k].splice(i, 1)
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
- let resolve
138
- const promise = new Promise(r => (resolve = r))
139
- const i = this.#callbacks.length
140
- this.#callbacks[i] = args => resolve(c(...args))
141
- return [i, promise]
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
- #oncallback = d => {
144
- this.#callbacks[d[0]](d[1])
145
- this.#callbacks[d[0]] = null
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
- if (this.#listeners[e[0]]) {
151
- for (const fn of this.#listeners[e[0]]) {
152
- fn(
153
- e[1],
154
- e[2]
155
- ? (...d) => {
156
- this.emit('', [e[2], d])
157
- }
158
- : undefined,
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
- if (this.#messages.length) {
167
- const messages = this.#messages
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
- this.#postMessage(['emit', messages])
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
  }