@supabase/phoenix 0.1.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.
Files changed (49) hide show
  1. package/LICENSE.md +22 -0
  2. package/README.md +122 -0
  3. package/assets/js/phoenix/ajax.js +116 -0
  4. package/assets/js/phoenix/channel.js +331 -0
  5. package/assets/js/phoenix/constants.js +35 -0
  6. package/assets/js/phoenix/index.js +212 -0
  7. package/assets/js/phoenix/longpoll.js +192 -0
  8. package/assets/js/phoenix/presence.js +208 -0
  9. package/assets/js/phoenix/push.js +134 -0
  10. package/assets/js/phoenix/serializer.js +133 -0
  11. package/assets/js/phoenix/socket.js +747 -0
  12. package/assets/js/phoenix/timer.js +48 -0
  13. package/assets/js/phoenix/types.js +184 -0
  14. package/assets/js/phoenix/utils.js +16 -0
  15. package/package.json +58 -0
  16. package/priv/static/favicon.ico +0 -0
  17. package/priv/static/phoenix-orange.png +0 -0
  18. package/priv/static/phoenix.cjs.js +1812 -0
  19. package/priv/static/phoenix.cjs.js.map +7 -0
  20. package/priv/static/phoenix.js +1834 -0
  21. package/priv/static/phoenix.min.js +2 -0
  22. package/priv/static/phoenix.mjs +1789 -0
  23. package/priv/static/phoenix.mjs.map +7 -0
  24. package/priv/static/phoenix.png +0 -0
  25. package/priv/static/types/ajax.d.ts +10 -0
  26. package/priv/static/types/ajax.d.ts.map +1 -0
  27. package/priv/static/types/channel.d.ts +167 -0
  28. package/priv/static/types/channel.d.ts.map +1 -0
  29. package/priv/static/types/constants.d.ts +36 -0
  30. package/priv/static/types/constants.d.ts.map +1 -0
  31. package/priv/static/types/index.d.ts +10 -0
  32. package/priv/static/types/index.d.ts.map +1 -0
  33. package/priv/static/types/longpoll.d.ts +29 -0
  34. package/priv/static/types/longpoll.d.ts.map +1 -0
  35. package/priv/static/types/presence.d.ts +107 -0
  36. package/priv/static/types/presence.d.ts.map +1 -0
  37. package/priv/static/types/push.d.ts +70 -0
  38. package/priv/static/types/push.d.ts.map +1 -0
  39. package/priv/static/types/serializer.d.ts +74 -0
  40. package/priv/static/types/serializer.d.ts.map +1 -0
  41. package/priv/static/types/socket.d.ts +284 -0
  42. package/priv/static/types/socket.d.ts.map +1 -0
  43. package/priv/static/types/timer.d.ts +36 -0
  44. package/priv/static/types/timer.d.ts.map +1 -0
  45. package/priv/static/types/types.d.ts +280 -0
  46. package/priv/static/types/types.d.ts.map +1 -0
  47. package/priv/static/types/utils.d.ts +2 -0
  48. package/priv/static/types/utils.d.ts.map +1 -0
  49. package/tsconfig.json +20 -0
@@ -0,0 +1,747 @@
1
+ import {
2
+ global,
3
+ phxWindow,
4
+ CHANNEL_EVENTS,
5
+ DEFAULT_TIMEOUT,
6
+ DEFAULT_VSN,
7
+ SOCKET_STATES,
8
+ TRANSPORTS,
9
+ WS_CLOSE_NORMAL,
10
+ AUTH_TOKEN_PREFIX
11
+ } from "./constants"
12
+
13
+ import {
14
+ closure
15
+ } from "./utils"
16
+
17
+ import Ajax from "./ajax"
18
+ import Channel from "./channel"
19
+ import LongPoll from "./longpoll"
20
+ import Serializer from "./serializer"
21
+ import Timer from "./timer"
22
+
23
+ /**
24
+ * @import { Encode, Decode, Message, Vsn, SocketTransport, Params, SocketOnOpen, SocketOnClose, SocketOnError, SocketOnMessage, SocketOptions, SocketStateChangeCallbacks, HeartbeatCallback } from "./types"
25
+ */
26
+
27
+ export default class Socket {
28
+ /** Initializes the Socket *
29
+ *
30
+ * For IE8 support use an ES5-shim (https://github.com/es-shims/es5-shim)
31
+ *
32
+ * @constructor
33
+ * @param {string} endPoint - The string WebSocket endpoint, ie, `"ws://example.com/socket"`,
34
+ * `"wss://example.com"`
35
+ * `"/socket"` (inherited host & protocol)
36
+ * @param {SocketOptions} [opts] - Optional configuration
37
+ */
38
+ constructor(endPoint, opts = {}){
39
+ /** @type{SocketStateChangeCallbacks} */
40
+ this.stateChangeCallbacks = {open: [], close: [], error: [], message: []}
41
+ /** @type{Channel[]} */
42
+ this.channels = []
43
+ /** @type{(() => void)[]} */
44
+ this.sendBuffer = []
45
+ /** @type{number} */
46
+ this.ref = 0
47
+ /** @type{?string} */
48
+ this.fallbackRef = null
49
+ /** @type{number} */
50
+ this.timeout = opts.timeout || DEFAULT_TIMEOUT
51
+ /** @type{SocketTransport} */
52
+ this.transport = opts.transport || global.WebSocket || LongPoll
53
+ /** @type{InstanceType<SocketTransport> | undefined | null} */
54
+ this.conn = undefined
55
+ /** @type{boolean} */
56
+ this.primaryPassedHealthCheck = false
57
+ /** @type{number | undefined} */
58
+ this.longPollFallbackMs = opts.longPollFallbackMs
59
+ /** @type{ReturnType<typeof setTimeout>} */
60
+ this.fallbackTimer = null
61
+ /** @type{Storage} */
62
+ this.sessionStore = opts.sessionStorage || (global && global.sessionStorage)
63
+ /** @type{number} */
64
+ this.establishedConnections = 0
65
+ /** @type{Encode<void>} */
66
+ this.defaultEncoder = Serializer.encode.bind(Serializer)
67
+ /** @type{Decode<void>} */
68
+ this.defaultDecoder = Serializer.decode.bind(Serializer)
69
+ /** @type{boolean} */
70
+ this.closeWasClean = false
71
+ /** @type{boolean} */
72
+ this.disconnecting = false
73
+ /** @type{BinaryType} */
74
+ this.binaryType = opts.binaryType || "arraybuffer"
75
+ /** @type{number} */
76
+ this.connectClock = 1
77
+ /** @type{boolean} */
78
+ this.pageHidden = false
79
+ /** @type{Encode<void>} */
80
+ this.encode = undefined
81
+ /** @type{Decode<void>} */
82
+ this.decode = undefined
83
+ if(this.transport !== LongPoll){
84
+ this.encode = opts.encode || this.defaultEncoder
85
+ this.decode = opts.decode || this.defaultDecoder
86
+ } else {
87
+ this.encode = this.defaultEncoder
88
+ this.decode = this.defaultDecoder
89
+ }
90
+ /** @type{number | null} */
91
+ let awaitingConnectionOnPageShow = null
92
+ if(phxWindow && phxWindow.addEventListener){
93
+ phxWindow.addEventListener("pagehide", _e => {
94
+ if(this.conn){
95
+ this.disconnect()
96
+ awaitingConnectionOnPageShow = this.connectClock
97
+ }
98
+ })
99
+ phxWindow.addEventListener("pageshow", _e => {
100
+ if(awaitingConnectionOnPageShow === this.connectClock){
101
+ awaitingConnectionOnPageShow = null
102
+ this.connect()
103
+ }
104
+ })
105
+ phxWindow.addEventListener("visibilitychange", () => {
106
+ if(document.visibilityState === "hidden"){
107
+ this.pageHidden = true
108
+ } else {
109
+ this.pageHidden = false
110
+ // reconnect immediately
111
+ if(!this.isConnected() && !this.closeWasClean){
112
+ this.teardown(() => this.connect())
113
+ }
114
+ }
115
+ })
116
+ }
117
+ /** @type{number} */
118
+ this.heartbeatIntervalMs = opts.heartbeatIntervalMs || 30000
119
+ /** @type{boolean} */
120
+ this.autoSendHeartbeat = opts.autoSendHeartbeat ?? true
121
+ /** @type{HeartbeatCallback} */
122
+ this.heartbeatCallback = opts.heartbeatCallback ?? (() => {})
123
+ /** @type{(tries: number) => number} */
124
+ this.rejoinAfterMs = (tries) => {
125
+ if(opts.rejoinAfterMs){
126
+ return opts.rejoinAfterMs(tries)
127
+ } else {
128
+ return [1000, 2000, 5000][tries - 1] || 10000
129
+ }
130
+ }
131
+ /** @type{(tries: number) => number} */
132
+ this.reconnectAfterMs = (tries) => {
133
+ if(opts.reconnectAfterMs){
134
+ return opts.reconnectAfterMs(tries)
135
+ } else {
136
+ return [10, 50, 100, 150, 200, 250, 500, 1000, 2000][tries - 1] || 5000
137
+ }
138
+ }
139
+ /** @type{((kind: string, msg: string, data: any) => void) | null} */
140
+ this.logger = opts.logger || null
141
+ if(!this.logger && opts.debug){
142
+ this.logger = (kind, msg, data) => { console.log(`${kind}: ${msg}`, data) }
143
+ }
144
+ /** @type{number} */
145
+ this.longpollerTimeout = opts.longpollerTimeout || 20000
146
+ /** @type{() => Params} */
147
+ this.params = closure(opts.params || {})
148
+ /** @type{string} */
149
+ this.endPoint = `${endPoint}/${TRANSPORTS.websocket}`
150
+ /** @type{Vsn} */
151
+ this.vsn = opts.vsn || DEFAULT_VSN
152
+ /** @type{ReturnType<typeof setTimeout>} */
153
+ this.heartbeatTimeoutTimer = null
154
+ /** @type{ReturnType<typeof setTimeout>} */
155
+ this.heartbeatTimer = null
156
+ /** @type{number | null} */
157
+ this.heartbeatSentAt = null
158
+ /** @type{?string} */
159
+ this.pendingHeartbeatRef = null
160
+ /** @type{Timer} */
161
+ this.reconnectTimer = new Timer( () => {
162
+ if(this.pageHidden){
163
+ this.log("Not reconnecting as page is hidden!")
164
+ this.teardown()
165
+ return
166
+ }
167
+
168
+ this.teardown(async () => {
169
+ if(opts.beforeReconnect) await opts.beforeReconnect()
170
+ this.connect()
171
+ })
172
+ }, this.reconnectAfterMs)
173
+ /** @type{string | undefined} */
174
+ this.authToken = opts.authToken
175
+ }
176
+
177
+ /**
178
+ * Returns the LongPoll transport reference
179
+ */
180
+ getLongPollTransport(){ return LongPoll }
181
+
182
+ /**
183
+ * Disconnects and replaces the active transport
184
+ *
185
+ * @param {SocketTransport} newTransport - The new transport class to instantiate
186
+ *
187
+ */
188
+ replaceTransport(newTransport){
189
+ this.connectClock++
190
+ this.closeWasClean = true
191
+ clearTimeout(this.fallbackTimer)
192
+ this.reconnectTimer.reset()
193
+ if(this.conn){
194
+ this.conn.close()
195
+ this.conn = null
196
+ }
197
+ this.transport = newTransport
198
+ }
199
+
200
+ /**
201
+ * Returns the socket protocol
202
+ *
203
+ * @returns {"wss" | "ws"}
204
+ */
205
+ protocol(){ return location.protocol.match(/^https/) ? "wss" : "ws" }
206
+
207
+ /**
208
+ * The fully qualified socket url
209
+ *
210
+ * @returns {string}
211
+ */
212
+ endPointURL(){
213
+ let uri = Ajax.appendParams(
214
+ Ajax.appendParams(this.endPoint, this.params()), {vsn: this.vsn})
215
+ if(uri.charAt(0) !== "/"){ return uri }
216
+ if(uri.charAt(1) === "/"){ return `${this.protocol()}:${uri}` }
217
+
218
+ return `${this.protocol()}://${location.host}${uri}`
219
+ }
220
+
221
+ /**
222
+ * Disconnects the socket
223
+ *
224
+ * See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent#Status_codes for valid status codes.
225
+ *
226
+ * @param {() => void} [callback] - Optional callback which is called after socket is disconnected.
227
+ * @param {number} [code] - A status code for disconnection (Optional).
228
+ * @param {string} [reason] - A textual description of the reason to disconnect. (Optional)
229
+ */
230
+ disconnect(callback, code, reason){
231
+ this.connectClock++
232
+ this.disconnecting = true
233
+ this.closeWasClean = true
234
+ clearTimeout(this.fallbackTimer)
235
+ this.reconnectTimer.reset()
236
+ this.teardown(() => {
237
+ this.disconnecting = false
238
+ callback && callback()
239
+ }, code, reason)
240
+ }
241
+
242
+ /**
243
+ * @param {Params} [params] - [DEPRECATED] The params to send when connecting, for example `{user_id: userToken}`
244
+ *
245
+ * Passing params to connect is deprecated; pass them in the Socket constructor instead:
246
+ * `new Socket("/socket", {params: {user_id: userToken}})`.
247
+ */
248
+ connect(params){
249
+ if(params){
250
+ console && console.log("passing params to connect is deprecated. Instead pass :params to the Socket constructor")
251
+ this.params = closure(params)
252
+ }
253
+ if(this.conn && !this.disconnecting){ return }
254
+ if(this.longPollFallbackMs && this.transport !== LongPoll){
255
+ this.connectWithFallback(LongPoll, this.longPollFallbackMs)
256
+ } else {
257
+ this.transportConnect()
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Logs the message. Override `this.logger` for specialized logging. noops by default
263
+ * @param {string} kind
264
+ * @param {string} msg
265
+ * @param {Object} data
266
+ */
267
+ log(kind, msg, data){ this.logger && this.logger(kind, msg, data) }
268
+
269
+ /**
270
+ * Returns true if a logger has been set on this socket.
271
+ */
272
+ hasLogger(){ return this.logger !== null }
273
+
274
+ /**
275
+ * Registers callbacks for connection open events
276
+ *
277
+ * @example socket.onOpen(function(){ console.info("the socket was opened") })
278
+ *
279
+ * @param {SocketOnOpen} callback
280
+ */
281
+ onOpen(callback){
282
+ let ref = this.makeRef()
283
+ this.stateChangeCallbacks.open.push([ref, callback])
284
+ return ref
285
+ }
286
+
287
+ /**
288
+ * Registers callbacks for connection close events
289
+ * @param {SocketOnClose} callback
290
+ * @returns {string}
291
+ */
292
+ onClose(callback){
293
+ let ref = this.makeRef()
294
+ this.stateChangeCallbacks.close.push([ref, callback])
295
+ return ref
296
+ }
297
+
298
+ /**
299
+ * Registers callbacks for connection error events
300
+ *
301
+ * @example socket.onError(function(error){ alert("An error occurred") })
302
+ *
303
+ * @param {SocketOnError} callback
304
+ * @returns {string}
305
+ */
306
+ onError(callback){
307
+ let ref = this.makeRef()
308
+ this.stateChangeCallbacks.error.push([ref, callback])
309
+ return ref
310
+ }
311
+
312
+ /**
313
+ * Registers callbacks for connection message events
314
+ * @param {SocketOnMessage} callback
315
+ * @returns {string}
316
+ */
317
+ onMessage(callback){
318
+ let ref = this.makeRef()
319
+ this.stateChangeCallbacks.message.push([ref, callback])
320
+ return ref
321
+ }
322
+
323
+ /**
324
+ * Sets a callback that receives lifecycle events for internal heartbeat messages.
325
+ * Useful for instrumenting connection health (e.g. sent/ok/timeout/disconnected).
326
+ * @param {HeartbeatCallback} callback
327
+ */
328
+ onHeartbeat(callback){
329
+ this.heartbeatCallback = callback
330
+ }
331
+
332
+ /**
333
+ * Pings the server and invokes the callback with the RTT in milliseconds
334
+ * @param {(timeDelta: number) => void} callback
335
+ *
336
+ * Returns true if the ping was pushed or false if unable to be pushed.
337
+ */
338
+ ping(callback){
339
+ if(!this.isConnected()){ return false }
340
+ let ref = this.makeRef()
341
+ let startTime = Date.now()
342
+ this.push({topic: "phoenix", event: "heartbeat", payload: {}, ref: ref})
343
+ let onMsgRef = this.onMessage(msg => {
344
+ if(msg.ref === ref){
345
+ this.off([onMsgRef])
346
+ callback(Date.now() - startTime)
347
+ }
348
+ })
349
+ return true
350
+ }
351
+
352
+ /**
353
+ * @private
354
+ *
355
+ * @param {Function}
356
+ */
357
+ transportName(transport){
358
+ // JavaScript minification, enabled by default in production in Phoenix
359
+ // projects, renames symbols to reduce code size.
360
+ // See https://esbuild.github.io/api/#keep-names.
361
+ // This helper ensures we return the correct name for the LongPoll transport
362
+ // even after minification. The other common transport is WebSocket, which
363
+ // is native to browsers and does not need special handling.
364
+ switch(transport){
365
+ case LongPoll: return "LongPoll"
366
+ default: return transport.name
367
+ }
368
+ }
369
+
370
+ /**
371
+ * @private
372
+ */
373
+ transportConnect(){
374
+ this.connectClock++
375
+ this.closeWasClean = false
376
+ let protocols = undefined
377
+ // Sec-WebSocket-Protocol based token
378
+ // (longpoll uses Authorization header instead)
379
+ if(this.authToken){
380
+ protocols = ["phoenix", `${AUTH_TOKEN_PREFIX}${btoa(this.authToken).replace(/=/g, "")}`]
381
+ }
382
+ this.conn = new this.transport(this.endPointURL(), protocols)
383
+ this.conn.binaryType = this.binaryType
384
+ this.conn.timeout = this.longpollerTimeout
385
+ this.conn.onopen = () => this.onConnOpen()
386
+ this.conn.onerror = error => this.onConnError(error)
387
+ this.conn.onmessage = event => this.onConnMessage(event)
388
+ this.conn.onclose = event => this.onConnClose(event)
389
+ }
390
+
391
+ getSession(key){ return this.sessionStore && this.sessionStore.getItem(key) }
392
+
393
+ storeSession(key, val){ this.sessionStore && this.sessionStore.setItem(key, val) }
394
+
395
+ connectWithFallback(fallbackTransport, fallbackThreshold = 2500){
396
+ clearTimeout(this.fallbackTimer)
397
+ let established = false
398
+ let primaryTransport = true
399
+ let openRef, errorRef
400
+ let fallbackTransportName = this.transportName(fallbackTransport)
401
+ let fallback = (reason) => {
402
+ this.log("transport", `falling back to ${fallbackTransportName}...`, reason)
403
+ this.off([openRef, errorRef])
404
+ primaryTransport = false
405
+ this.replaceTransport(fallbackTransport)
406
+ this.transportConnect()
407
+ }
408
+ if(this.getSession(`phx:fallback:${fallbackTransportName}`)){ return fallback("memorized") }
409
+
410
+ this.fallbackTimer = setTimeout(fallback, fallbackThreshold)
411
+
412
+ errorRef = this.onError(reason => {
413
+ this.log("transport", "error", reason)
414
+ if(primaryTransport && !established){
415
+ clearTimeout(this.fallbackTimer)
416
+ fallback(reason)
417
+ }
418
+ })
419
+ if(this.fallbackRef){
420
+ this.off([this.fallbackRef])
421
+ }
422
+ this.fallbackRef = this.onOpen(() => {
423
+ established = true
424
+ if(!primaryTransport){
425
+ let fallbackTransportName = this.transportName(fallbackTransport)
426
+ // only memorize LP if we never connected to primary
427
+ if(!this.primaryPassedHealthCheck){ this.storeSession(`phx:fallback:${fallbackTransportName}`, "true") }
428
+ return this.log("transport", `established ${fallbackTransportName} fallback`)
429
+ }
430
+ // if we've established primary, give the fallback a new period to attempt ping
431
+ clearTimeout(this.fallbackTimer)
432
+ this.fallbackTimer = setTimeout(fallback, fallbackThreshold)
433
+ this.ping(rtt => {
434
+ this.log("transport", "connected to primary after", rtt)
435
+ this.primaryPassedHealthCheck = true
436
+ clearTimeout(this.fallbackTimer)
437
+ })
438
+ })
439
+ this.transportConnect()
440
+ }
441
+
442
+ clearHeartbeats(){
443
+ clearTimeout(this.heartbeatTimer)
444
+ clearTimeout(this.heartbeatTimeoutTimer)
445
+ }
446
+
447
+ onConnOpen(){
448
+ if(this.hasLogger()) this.log("transport", `connected to ${this.endPointURL()}`)
449
+ this.closeWasClean = false
450
+ this.disconnecting = false
451
+ this.establishedConnections++
452
+ this.flushSendBuffer()
453
+ this.reconnectTimer.reset()
454
+ if(this.autoSendHeartbeat){
455
+ this.resetHeartbeat()
456
+ }
457
+ this.triggerStateCallbacks("open")
458
+ }
459
+
460
+ /**
461
+ * @private
462
+ */
463
+
464
+ heartbeatTimeout(){
465
+ if(this.pendingHeartbeatRef){
466
+ this.pendingHeartbeatRef = null
467
+ this.heartbeatSentAt = null
468
+ if(this.hasLogger()){ this.log("transport", "heartbeat timeout. Attempting to re-establish connection") }
469
+ try {
470
+ this.heartbeatCallback("timeout")
471
+ } catch (e){
472
+ this.log("error", "error in heartbeat callback", e)
473
+ }
474
+ this.triggerChanError()
475
+ this.closeWasClean = false
476
+ this.teardown(() => this.reconnectTimer.scheduleTimeout(), WS_CLOSE_NORMAL, "heartbeat timeout")
477
+ }
478
+ }
479
+
480
+ resetHeartbeat(){
481
+ if(this.conn && this.conn.skipHeartbeat){ return }
482
+ this.pendingHeartbeatRef = null
483
+ this.clearHeartbeats()
484
+ this.heartbeatTimer = setTimeout(() => this.sendHeartbeat(), this.heartbeatIntervalMs)
485
+ }
486
+
487
+ teardown(callback, code, reason){
488
+ if(!this.conn){
489
+ return callback && callback()
490
+ }
491
+ let connectClock = this.connectClock
492
+
493
+ this.waitForBufferDone(() => {
494
+ if(connectClock !== this.connectClock){ return }
495
+ if(this.conn){
496
+ if(code){ this.conn.close(code, reason || "") } else { this.conn.close() }
497
+ }
498
+
499
+ this.waitForSocketClosed(() => {
500
+ if(connectClock !== this.connectClock){ return }
501
+ if(this.conn){
502
+ this.conn.onopen = function (){ } // noop
503
+ this.conn.onerror = function (){ } // noop
504
+ this.conn.onmessage = function (){ } // noop
505
+ this.conn.onclose = function (){ } // noop
506
+ this.conn = null
507
+ }
508
+
509
+ callback && callback()
510
+ })
511
+ })
512
+ }
513
+
514
+ waitForBufferDone(callback, tries = 1){
515
+ if(tries === 5 || !this.conn || !this.conn.bufferedAmount){
516
+ callback()
517
+ return
518
+ }
519
+
520
+ setTimeout(() => {
521
+ this.waitForBufferDone(callback, tries + 1)
522
+ }, 150 * tries)
523
+ }
524
+
525
+ waitForSocketClosed(callback, tries = 1){
526
+ if(tries === 5 || !this.conn || this.conn.readyState === SOCKET_STATES.closed){
527
+ callback()
528
+ return
529
+ }
530
+
531
+ setTimeout(() => {
532
+ this.waitForSocketClosed(callback, tries + 1)
533
+ }, 150 * tries)
534
+ }
535
+
536
+ /**
537
+ * @param {CloseEvent} event
538
+ */
539
+ onConnClose(event){
540
+ if(this.conn) this.conn.onclose = () => {} // noop to prevent recursive calls in teardown
541
+ if(this.hasLogger()) this.log("transport", "close", event)
542
+ this.triggerChanError()
543
+ this.clearHeartbeats()
544
+ if(!this.closeWasClean){
545
+ this.reconnectTimer.scheduleTimeout()
546
+ }
547
+ this.triggerStateCallbacks("close", event)
548
+ }
549
+
550
+ /**
551
+ * @private
552
+ * @param {Event} error
553
+ */
554
+ onConnError(error){
555
+ if(this.hasLogger()) this.log("transport", error)
556
+ let transportBefore = this.transport
557
+ let establishedBefore = this.establishedConnections
558
+ this.triggerStateCallbacks("error", error, transportBefore, establishedBefore)
559
+ if(transportBefore === this.transport || establishedBefore > 0){
560
+ this.triggerChanError()
561
+ }
562
+ }
563
+
564
+ /**
565
+ * @private
566
+ */
567
+ triggerChanError(){
568
+ this.channels.forEach(channel => {
569
+ if(!(channel.isErrored() || channel.isLeaving() || channel.isClosed())){
570
+ channel.trigger(CHANNEL_EVENTS.error)
571
+ }
572
+ })
573
+ }
574
+
575
+ /**
576
+ * @returns {string}
577
+ */
578
+ connectionState(){
579
+ switch(this.conn && this.conn.readyState){
580
+ case SOCKET_STATES.connecting: return "connecting"
581
+ case SOCKET_STATES.open: return "open"
582
+ case SOCKET_STATES.closing: return "closing"
583
+ default: return "closed"
584
+ }
585
+ }
586
+
587
+ /**
588
+ * @returns {boolean}
589
+ */
590
+ isConnected(){ return this.connectionState() === "open" }
591
+
592
+ /**
593
+ *
594
+ * @param {Channel} channel
595
+ */
596
+ remove(channel){
597
+ this.off(channel.stateChangeRefs)
598
+ this.channels = this.channels.filter(c => c !== channel)
599
+ }
600
+
601
+ /**
602
+ * Removes `onOpen`, `onClose`, `onError,` and `onMessage` registrations.
603
+ *
604
+ * @param {string[]} refs - list of refs returned by calls to
605
+ * `onOpen`, `onClose`, `onError,` and `onMessage`
606
+ */
607
+ off(refs){
608
+ for(let key in this.stateChangeCallbacks){
609
+ this.stateChangeCallbacks[key] = this.stateChangeCallbacks[key].filter(([ref]) => {
610
+ return refs.indexOf(ref) === -1
611
+ })
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Initiates a new channel for the given topic
617
+ *
618
+ * @param {string} topic
619
+ * @param {Params | (() => Params)} [chanParams]- Parameters for the channel
620
+ * @returns {Channel}
621
+ */
622
+ channel(topic, chanParams = {}){
623
+ let chan = new Channel(topic, chanParams, this)
624
+ this.channels.push(chan)
625
+ return chan
626
+ }
627
+
628
+ /**
629
+ * @param {Message<Record<string, any>>} data
630
+ */
631
+ push(data){
632
+ if(this.hasLogger()){
633
+ let {topic, event, payload, ref, join_ref} = data
634
+ this.log("push", `${topic} ${event} (${join_ref}, ${ref})`, payload)
635
+ }
636
+
637
+ if(this.isConnected()){
638
+ this.encode(data, result => this.conn.send(result))
639
+ } else {
640
+ this.sendBuffer.push(() => this.encode(data, result => this.conn.send(result)))
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Return the next message ref, accounting for overflows
646
+ * @returns {string}
647
+ */
648
+ makeRef(){
649
+ let newRef = this.ref + 1
650
+ if(newRef === this.ref){ this.ref = 0 } else { this.ref = newRef }
651
+
652
+ return this.ref.toString()
653
+ }
654
+
655
+ sendHeartbeat(){
656
+ if(!this.isConnected()){
657
+ try {
658
+ this.heartbeatCallback("disconnected")
659
+ } catch (e){
660
+ this.log("error", "error in heartbeat callback", e)
661
+ }
662
+ return
663
+ }
664
+ if(this.pendingHeartbeatRef){
665
+ this.heartbeatTimeout()
666
+ return
667
+ }
668
+ this.pendingHeartbeatRef = this.makeRef()
669
+ this.heartbeatSentAt = Date.now()
670
+ this.push({topic: "phoenix", event: "heartbeat", payload: {}, ref: this.pendingHeartbeatRef})
671
+ try {
672
+ this.heartbeatCallback("sent")
673
+ } catch (e){
674
+ this.log("error", "error in heartbeat callback", e)
675
+ }
676
+ this.heartbeatTimeoutTimer = setTimeout(() => this.heartbeatTimeout(), this.heartbeatIntervalMs)
677
+ }
678
+
679
+ flushSendBuffer(){
680
+ if(this.isConnected() && this.sendBuffer.length > 0){
681
+ this.sendBuffer.forEach(callback => callback())
682
+ this.sendBuffer = []
683
+ }
684
+ }
685
+
686
+ /**
687
+ * @param {MessageEvent<any>} rawMessage
688
+ */
689
+ onConnMessage(rawMessage){
690
+ this.decode(rawMessage.data, msg => {
691
+ let {topic, event, payload, ref, join_ref} = msg
692
+ if(ref && ref === this.pendingHeartbeatRef){
693
+ const latency = this.heartbeatSentAt ? Date.now() - this.heartbeatSentAt : undefined
694
+ this.clearHeartbeats()
695
+ try {
696
+ this.heartbeatCallback(payload.status === "ok" ? "ok" : "error", latency)
697
+ } catch (e){
698
+ this.log("error", "error in heartbeat callback", e)
699
+ }
700
+ this.pendingHeartbeatRef = null
701
+ this.heartbeatSentAt = null
702
+ if(this.autoSendHeartbeat){
703
+ this.heartbeatTimer = setTimeout(() => this.sendHeartbeat(), this.heartbeatIntervalMs)
704
+ }
705
+ }
706
+
707
+ if(this.hasLogger()) this.log("receive", `${payload.status || ""} ${topic} ${event} ${ref && "(" + ref + ")" || ""}`.trim(), payload)
708
+
709
+ for(let i = 0; i < this.channels.length; i++){
710
+ const channel = this.channels[i]
711
+ if(!channel.isMember(topic, event, payload, join_ref)){ continue }
712
+ channel.trigger(event, payload, ref, join_ref)
713
+ }
714
+
715
+ this.triggerStateCallbacks("message", msg)
716
+ })
717
+ }
718
+
719
+ /**
720
+ * @private
721
+ * @template {keyof SocketStateChangeCallbacks} K
722
+ * @param {K} event
723
+ * @param {...Parameters<SocketStateChangeCallbacks[K][number][1]>} args
724
+ * @returns {void}
725
+ */
726
+ triggerStateCallbacks(event, ...args){
727
+ try {
728
+ this.stateChangeCallbacks[event].forEach(([_, callback]) => {
729
+ try {
730
+ callback(...args)
731
+ } catch (e){
732
+ this.log("error", `error in ${event} callback`, e)
733
+ }
734
+ })
735
+ } catch (e){
736
+ this.log("error", `error triggering ${event} callbacks`, e)
737
+ }
738
+ }
739
+
740
+ leaveOpenTopic(topic){
741
+ let dupChannel = this.channels.find(c => c.topic === topic && (c.isJoined() || c.isJoining()))
742
+ if(dupChannel){
743
+ if(this.hasLogger()) this.log("transport", `leaving duplicate topic "${topic}"`)
744
+ dupChannel.leave()
745
+ }
746
+ }
747
+ }