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 CHANGED
@@ -1,201 +1,543 @@
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
+ /**
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
- #callbacks
9
- #listeners
10
- #messages
11
- #postMessage
90
+ #connected = false
12
91
 
13
92
  /**
14
- * Creates a Socket Client Instance
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
- * @param options {string | {url:string, params?:Function,
17
- * noConnect?:boolean }}
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
- if (typeof options === 'string') {
21
- options = {
22
- url: options,
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
- this.#callbacks = [null]
29
- this.#listeners = {
30
- '': [this.#oncallback],
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
- this.connected = false
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
- // to force disconnect
37
- this.on('disconnect', this.disconnect)
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 workerURL = URL.createObjectURL(
40
- new Blob(['(' + ClientWebWorker + ')()']),
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
- io.onmessage = this.#onworkermessage
46
- this.#postMessage = io.postMessage.bind(io)
157
+ const { searchParams } = url
158
+ const p = this.options.params
159
+ const params = (isFunction(p) ? p() : p) || {}
47
160
 
48
- options.noConnect === undefined && this.connect()
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
- /** 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) || {},
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
- * Disconnect from server
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 - To prevent reconnections
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 = noReconnect => {
71
- this.#postMessage(['disconnect', noReconnect])
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
- * Listen for data sent by the server
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
- * @param {string} key
78
- * @param {any | Function} [valueOrCallback]
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} When callback is provided it
81
- * returns a promise
303
+ * @returns {Promise<any> | undefined}
82
304
  */
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
- }
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 data sent by the server
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
- * @returns () => void | undefined - `off` function to remove the
113
- * listener
374
+ * @param {ListenOptions} [options]
375
+ * @returns {() => void}
114
376
  */
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)
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
- #off(k, v) {
129
- const i = this.#listeners[k].indexOf(v)
130
- if (i !== -1) {
131
- this.#listeners[k].splice(i, 1)
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
- 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]
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
- #oncallback = d => {
144
- this.#callbacks[d[0]](d[1])
145
- this.#callbacks[d[0]] = null
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
- 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
- )
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
- if (this.#messages.length) {
167
- const messages = this.#messages
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
- 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())
540
+ io.send(stringify(batch))
199
541
  }
200
542
  }
201
543
  }