@supabase/realtime-js 2.12.2 → 2.13.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.
@@ -46,6 +46,22 @@ export type HeartbeatStatus =
46
46
 
47
47
  const noop = () => {}
48
48
 
49
+ type RealtimeClientState =
50
+ | 'connecting'
51
+ | 'connected'
52
+ | 'disconnecting'
53
+ | 'disconnected'
54
+
55
+ // Connection-related constants
56
+ const CONNECTION_TIMEOUTS = {
57
+ HEARTBEAT_INTERVAL: 25000,
58
+ RECONNECT_DELAY: 10,
59
+ HEARTBEAT_TIMEOUT_FALLBACK: 100,
60
+ } as const
61
+
62
+ const RECONNECT_INTERVALS = [1000, 2000, 5000, 10000] as const
63
+ const DEFAULT_RECONNECT_FALLBACK = 10000
64
+
49
65
  export interface WebSocketLikeConstructor {
50
66
  new (
51
67
  address: string | URL,
@@ -97,18 +113,18 @@ export default class RealtimeClient {
97
113
  headers?: { [key: string]: string } = {}
98
114
  params?: { [key: string]: string } = {}
99
115
  timeout: number = DEFAULT_TIMEOUT
100
- transport: WebSocketLikeConstructor | null
101
- heartbeatIntervalMs: number = 25000
116
+ transport: WebSocketLikeConstructor | null = null
117
+ heartbeatIntervalMs: number = CONNECTION_TIMEOUTS.HEARTBEAT_INTERVAL
102
118
  heartbeatTimer: ReturnType<typeof setInterval> | undefined = undefined
103
119
  pendingHeartbeatRef: string | null = null
104
120
  heartbeatCallback: (status: HeartbeatStatus) => void = noop
105
121
  ref: number = 0
106
- reconnectTimer: Timer
122
+ reconnectTimer: Timer | null = null
107
123
  logger: Function = noop
108
124
  logLevel?: LogLevel
109
- encode: Function
110
- decode: Function
111
- reconnectAfterMs: Function
125
+ encode!: Function
126
+ decode!: Function
127
+ reconnectAfterMs!: Function
112
128
  conn: WebSocketLike | null = null
113
129
  sendBuffer: Function[] = []
114
130
  serializer: Serializer = new Serializer()
@@ -128,6 +144,9 @@ export default class RealtimeClient {
128
144
  worker?: boolean
129
145
  workerUrl?: string
130
146
  workerRef?: Worker
147
+ private _connectionState: RealtimeClientState = 'disconnected'
148
+ private _wasManualDisconnect: boolean = false
149
+ private _authPromise: Promise<void> | null = null
131
150
 
132
151
  /**
133
152
  * Initializes the Socket.
@@ -148,80 +167,48 @@ export default class RealtimeClient {
148
167
  * @param options.workerUrl The URL of the worker script. Defaults to https://realtime.supabase.com/worker.js that includes a heartbeat event call to keep the connection alive.
149
168
  */
150
169
  constructor(endPoint: string, options?: RealtimeClientOptions) {
151
- this.endPoint = `${endPoint}/${TRANSPORTS.websocket}`
152
- this.httpEndpoint = httpEndpointURL(endPoint)
153
- if (options?.transport) {
154
- this.transport = options.transport
155
- } else {
156
- this.transport = null
157
- }
158
- if (options?.params) this.params = options.params
159
- if (options?.timeout) this.timeout = options.timeout
160
- if (options?.logger) this.logger = options.logger
161
- if (options?.logLevel || options?.log_level) {
162
- this.logLevel = options.logLevel || options.log_level
163
- this.params = { ...this.params, log_level: this.logLevel as string }
164
- }
165
-
166
- if (options?.heartbeatIntervalMs)
167
- this.heartbeatIntervalMs = options.heartbeatIntervalMs
168
-
169
- const accessTokenValue = options?.params?.apikey
170
- if (accessTokenValue) {
171
- this.accessTokenValue = accessTokenValue
172
- this.apiKey = accessTokenValue
170
+ // Validate required parameters
171
+ if (!options?.params?.apikey) {
172
+ throw new Error('API key is required to connect to Realtime')
173
173
  }
174
+ this.apiKey = options.params.apikey
174
175
 
175
- this.reconnectAfterMs = options?.reconnectAfterMs
176
- ? options.reconnectAfterMs
177
- : (tries: number) => {
178
- return [1000, 2000, 5000, 10000][tries - 1] || 10000
179
- }
180
- this.encode = options?.encode
181
- ? options.encode
182
- : (payload: JSON, callback: Function) => {
183
- return callback(JSON.stringify(payload))
184
- }
185
- this.decode = options?.decode
186
- ? options.decode
187
- : this.serializer.decode.bind(this.serializer)
188
- this.reconnectTimer = new Timer(async () => {
189
- this.disconnect()
190
- this.connect()
191
- }, this.reconnectAfterMs)
176
+ // Initialize endpoint URLs
177
+ this.endPoint = `${endPoint}/${TRANSPORTS.websocket}`
178
+ this.httpEndpoint = httpEndpointURL(endPoint)
192
179
 
180
+ this._initializeOptions(options)
181
+ this._setupReconnectionTimer()
193
182
  this.fetch = this._resolveFetch(options?.fetch)
194
- if (options?.worker) {
195
- if (typeof window !== 'undefined' && !window.Worker) {
196
- throw new Error('Web Worker is not supported')
197
- }
198
- this.worker = options?.worker || false
199
- this.workerUrl = options?.workerUrl
200
- }
201
- this.accessToken = options?.accessToken || null
202
183
  }
203
184
 
204
185
  /**
205
186
  * Connects the socket, unless already connected.
206
187
  */
207
188
  connect(): void {
208
- // Set the token on connect
209
- setTimeout(() => {
210
- this.setAuth().catch((e) => {
211
- this.log('error', 'error setting auth', e)
212
- })
213
- }, 0)
214
- if (this.conn) {
189
+ // Skip if already connecting, disconnecting, or connected
190
+ if (
191
+ this.isConnecting() ||
192
+ this.isDisconnecting() ||
193
+ (this.conn !== null && this.isConnected())
194
+ ) {
215
195
  return
216
196
  }
197
+
198
+ this._setConnectionState('connecting')
199
+ this._setAuthSafely('connect')
200
+
201
+ // Establish WebSocket connection
217
202
  if (!this.transport) {
218
203
  this.transport = WebSocket
219
204
  }
220
205
  if (!this.transport) {
206
+ this._setConnectionState('disconnected')
221
207
  throw new Error('No transport provided')
222
208
  }
223
- this.conn = new this.transport(this.endpointURL()) as WebSocketLike
224
- this.setupConnection()
209
+
210
+ this.conn = new this.transport!(this.endpointURL()) as WebSocketLike
211
+ this._setupConnectionHandlers()
225
212
  }
226
213
 
227
214
  /**
@@ -242,19 +229,33 @@ export default class RealtimeClient {
242
229
  * @param reason A custom reason for the disconnect.
243
230
  */
244
231
  disconnect(code?: number, reason?: string): void {
232
+ if (this.isDisconnecting()) {
233
+ return
234
+ }
235
+
236
+ this._setConnectionState('disconnecting', true)
237
+
245
238
  if (this.conn) {
246
- this.conn.onclose = function () {} // noop
239
+ // Setup fallback timer to prevent hanging in disconnecting state
240
+ const fallbackTimer = setTimeout(() => {
241
+ this._setConnectionState('disconnected')
242
+ }, 100)
243
+
244
+ this.conn.onclose = () => {
245
+ clearTimeout(fallbackTimer)
246
+ this._setConnectionState('disconnected')
247
+ }
248
+
249
+ // Close the WebSocket connection
247
250
  if (code) {
248
251
  this.conn.close(code, reason ?? '')
249
252
  } else {
250
253
  this.conn.close()
251
254
  }
252
- this.conn = null
253
255
 
254
- // remove open handles
255
- this.heartbeatTimer && clearInterval(this.heartbeatTimer)
256
- this.reconnectTimer.reset()
257
- this.channels.forEach((channel) => channel.teardown())
256
+ this._teardownConnection()
257
+ } else {
258
+ this._setConnectionState('disconnected')
258
259
  }
259
260
  }
260
261
 
@@ -325,6 +326,20 @@ export default class RealtimeClient {
325
326
  return this.connectionState() === CONNECTION_STATE.Open
326
327
  }
327
328
 
329
+ /**
330
+ * Returns `true` if the connection is currently connecting.
331
+ */
332
+ isConnecting(): boolean {
333
+ return this._connectionState === 'connecting'
334
+ }
335
+
336
+ /**
337
+ * Returns `true` if the connection is currently disconnecting.
338
+ */
339
+ isDisconnecting(): boolean {
340
+ return this._connectionState === 'disconnecting'
341
+ }
342
+
328
343
  channel(
329
344
  topic: string,
330
345
  params: RealtimeChannelOptions = { config: {} }
@@ -374,27 +389,11 @@ export default class RealtimeClient {
374
389
  * @param token A JWT string to override the token set on the client.
375
390
  */
376
391
  async setAuth(token: string | null = null): Promise<void> {
377
- let tokenToSend =
378
- token ||
379
- (this.accessToken && (await this.accessToken())) ||
380
- this.accessTokenValue
381
-
382
- if (this.accessTokenValue != tokenToSend) {
383
- this.accessTokenValue = tokenToSend
384
- this.channels.forEach((channel) => {
385
- const payload = {
386
- access_token: tokenToSend,
387
- version: DEFAULT_VERSION,
388
- }
389
-
390
- tokenToSend && channel.updateJoinPayload(payload)
391
-
392
- if (channel.joinedOnce && channel._isJoined()) {
393
- channel._push(CHANNEL_EVENTS.access_token, {
394
- access_token: tokenToSend,
395
- })
396
- }
397
- })
392
+ this._authPromise = this._performAuth(token)
393
+ try {
394
+ await this._authPromise
395
+ } finally {
396
+ this._authPromise = null
398
397
  }
399
398
  }
400
399
  /**
@@ -405,6 +404,8 @@ export default class RealtimeClient {
405
404
  this.heartbeatCallback('disconnected')
406
405
  return
407
406
  }
407
+
408
+ // Handle heartbeat timeout and force reconnection if needed
408
409
  if (this.pendingHeartbeatRef) {
409
410
  this.pendingHeartbeatRef = null
410
411
  this.log(
@@ -412,9 +413,20 @@ export default class RealtimeClient {
412
413
  'heartbeat timeout. Attempting to re-establish connection'
413
414
  )
414
415
  this.heartbeatCallback('timeout')
415
- this.conn?.close(WS_CLOSE_NORMAL, 'hearbeat timeout')
416
+
417
+ // Force reconnection after heartbeat timeout
418
+ this._wasManualDisconnect = false
419
+ this.conn?.close(WS_CLOSE_NORMAL, 'heartbeat timeout')
420
+
421
+ setTimeout(() => {
422
+ if (!this.isConnected()) {
423
+ this.reconnectTimer?.scheduleTimeout()
424
+ }
425
+ }, CONNECTION_TIMEOUTS.HEARTBEAT_TIMEOUT_FALLBACK)
416
426
  return
417
427
  }
428
+
429
+ // Send heartbeat message to server
418
430
  this.pendingHeartbeatRef = this._makeRef()
419
431
  this.push({
420
432
  topic: 'phoenix',
@@ -423,7 +435,8 @@ export default class RealtimeClient {
423
435
  ref: this.pendingHeartbeatRef,
424
436
  })
425
437
  this.heartbeatCallback('sent')
426
- await this.setAuth()
438
+
439
+ this._setAuthSafely('heartbeat')
427
440
  }
428
441
 
429
442
  onHeartbeat(callback: (status: HeartbeatStatus) => void): void {
@@ -501,57 +514,99 @@ export default class RealtimeClient {
501
514
  this.channels = this.channels.filter((c) => c.topic !== channel.topic)
502
515
  }
503
516
 
504
- /**
505
- * Sets up connection handlers.
506
- *
507
- * @internal
508
- */
509
- private setupConnection(): void {
510
- if (this.conn) {
511
- this.conn.binaryType = 'arraybuffer'
512
- this.conn.onopen = () => this._onConnOpen()
513
- this.conn.onerror = (error: Event) => this._onConnError(error)
514
- this.conn.onmessage = (event: any) => this._onConnMessage(event)
515
- this.conn.onclose = (event: any) => this._onConnClose(event)
516
- }
517
- }
518
-
519
517
  /** @internal */
520
518
  private _onConnMessage(rawMessage: { data: any }) {
521
519
  this.decode(rawMessage.data, (msg: RealtimeMessage) => {
522
- let { topic, event, payload, ref } = msg
523
-
524
- if (topic === 'phoenix' && event === 'phx_reply') {
525
- this.heartbeatCallback(msg.payload.status == 'ok' ? 'ok' : 'error')
520
+ // Handle heartbeat responses
521
+ if (msg.topic === 'phoenix' && msg.event === 'phx_reply') {
522
+ this.heartbeatCallback(msg.payload.status === 'ok' ? 'ok' : 'error')
526
523
  }
527
524
 
528
- if (ref && ref === this.pendingHeartbeatRef) {
525
+ // Handle pending heartbeat reference cleanup
526
+ if (msg.ref && msg.ref === this.pendingHeartbeatRef) {
529
527
  this.pendingHeartbeatRef = null
530
528
  }
531
529
 
530
+ // Log incoming message
531
+ const { topic, event, payload, ref } = msg
532
+ const refString = ref ? `(${ref})` : ''
533
+ const status = payload.status || ''
532
534
  this.log(
533
535
  'receive',
534
- `${payload.status || ''} ${topic} ${event} ${
535
- (ref && '(' + ref + ')') || ''
536
- }`,
536
+ `${status} ${topic} ${event} ${refString}`.trim(),
537
537
  payload
538
538
  )
539
539
 
540
- Array.from(this.channels)
540
+ // Route message to appropriate channels
541
+ this.channels
541
542
  .filter((channel: RealtimeChannel) => channel._isMember(topic))
542
543
  .forEach((channel: RealtimeChannel) =>
543
544
  channel._trigger(event, payload, ref)
544
545
  )
545
546
 
546
- this.stateChangeCallbacks.message.forEach((callback) => callback(msg))
547
+ this._triggerStateCallbacks('message', msg)
547
548
  })
548
549
  }
549
550
 
551
+ /**
552
+ * Clear specific timer
553
+ * @internal
554
+ */
555
+ private _clearTimer(timer: 'heartbeat' | 'reconnect'): void {
556
+ if (timer === 'heartbeat' && this.heartbeatTimer) {
557
+ clearInterval(this.heartbeatTimer)
558
+ this.heartbeatTimer = undefined
559
+ } else if (timer === 'reconnect') {
560
+ this.reconnectTimer?.reset()
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Clear all timers
566
+ * @internal
567
+ */
568
+ private _clearAllTimers(): void {
569
+ this._clearTimer('heartbeat')
570
+ this._clearTimer('reconnect')
571
+ }
572
+
573
+ /**
574
+ * Setup connection handlers for WebSocket events
575
+ * @internal
576
+ */
577
+ private _setupConnectionHandlers(): void {
578
+ if (!this.conn) return
579
+
580
+ this.conn.binaryType = 'arraybuffer'
581
+ this.conn.onopen = () => this._onConnOpen()
582
+ this.conn.onerror = (error: Event) => this._onConnError(error)
583
+ this.conn.onmessage = (event: any) => this._onConnMessage(event)
584
+ this.conn.onclose = (event: any) => this._onConnClose(event)
585
+ }
586
+
587
+ /**
588
+ * Teardown connection and cleanup resources
589
+ * @internal
590
+ */
591
+ private _teardownConnection(): void {
592
+ if (this.conn) {
593
+ this.conn.onopen = null
594
+ this.conn.onerror = null
595
+ this.conn.onmessage = null
596
+ this.conn.onclose = null
597
+ this.conn = null
598
+ }
599
+ this._clearAllTimers()
600
+ this.channels.forEach((channel) => channel.teardown())
601
+ }
602
+
550
603
  /** @internal */
551
604
  private _onConnOpen() {
605
+ this._setConnectionState('connected')
552
606
  this.log('transport', `connected to ${this.endpointURL()}`)
553
607
  this.flushSendBuffer()
554
- this.reconnectTimer.reset()
608
+ this._clearTimer('reconnect')
609
+
555
610
  if (!this.worker) {
556
611
  this._startHeartbeat()
557
612
  } else {
@@ -560,7 +615,7 @@ export default class RealtimeClient {
560
615
  }
561
616
  }
562
617
 
563
- this.stateChangeCallbacks.open.forEach((callback) => callback())
618
+ this._triggerStateCallbacks('open')
564
619
  }
565
620
  /** @internal */
566
621
  private _startHeartbeat() {
@@ -596,18 +651,25 @@ export default class RealtimeClient {
596
651
  }
597
652
  /** @internal */
598
653
  private _onConnClose(event: any) {
654
+ this._setConnectionState('disconnected')
599
655
  this.log('transport', 'close', event)
600
656
  this._triggerChanError()
601
- this.heartbeatTimer && clearInterval(this.heartbeatTimer)
602
- this.reconnectTimer.scheduleTimeout()
603
- this.stateChangeCallbacks.close.forEach((callback) => callback(event))
657
+ this._clearTimer('heartbeat')
658
+
659
+ // Only schedule reconnection if it wasn't a manual disconnect
660
+ if (!this._wasManualDisconnect) {
661
+ this.reconnectTimer?.scheduleTimeout()
662
+ }
663
+
664
+ this._triggerStateCallbacks('close', event)
604
665
  }
605
666
 
606
667
  /** @internal */
607
668
  private _onConnError(error: Event) {
669
+ this._setConnectionState('disconnected')
608
670
  this.log('transport', `${error}`)
609
671
  this._triggerChanError()
610
- this.stateChangeCallbacks.error.forEach((callback) => callback(error))
672
+ this._triggerStateCallbacks('error', error)
611
673
  }
612
674
 
613
675
  /** @internal */
@@ -640,4 +702,158 @@ export default class RealtimeClient {
640
702
  }
641
703
  return result_url
642
704
  }
705
+
706
+ /**
707
+ * Set connection state with proper state management
708
+ * @internal
709
+ */
710
+ private _setConnectionState(
711
+ state: RealtimeClientState,
712
+ manual = false
713
+ ): void {
714
+ this._connectionState = state
715
+
716
+ if (state === 'connecting') {
717
+ this._wasManualDisconnect = false
718
+ } else if (state === 'disconnecting') {
719
+ this._wasManualDisconnect = manual
720
+ }
721
+ }
722
+
723
+ /**
724
+ * Perform the actual auth operation
725
+ * @internal
726
+ */
727
+ private async _performAuth(token: string | null = null): Promise<void> {
728
+ let tokenToSend: string | null
729
+
730
+ if (token) {
731
+ tokenToSend = token
732
+ } else if (this.accessToken) {
733
+ // Always call the accessToken callback to get fresh token
734
+ tokenToSend = await this.accessToken()
735
+ } else {
736
+ tokenToSend = this.accessTokenValue
737
+ }
738
+
739
+ if (this.accessTokenValue != tokenToSend) {
740
+ this.accessTokenValue = tokenToSend
741
+ this.channels.forEach((channel) => {
742
+ const payload = {
743
+ access_token: tokenToSend,
744
+ version: DEFAULT_VERSION,
745
+ }
746
+
747
+ tokenToSend && channel.updateJoinPayload(payload)
748
+
749
+ if (channel.joinedOnce && channel._isJoined()) {
750
+ channel._push(CHANNEL_EVENTS.access_token, {
751
+ access_token: tokenToSend,
752
+ })
753
+ }
754
+ })
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Wait for any in-flight auth operations to complete
760
+ * @internal
761
+ */
762
+ private async _waitForAuthIfNeeded(): Promise<void> {
763
+ if (this._authPromise) {
764
+ await this._authPromise
765
+ }
766
+ }
767
+
768
+ /**
769
+ * Safely call setAuth with standardized error handling
770
+ * @internal
771
+ */
772
+ private _setAuthSafely(context = 'general'): void {
773
+ this.setAuth().catch((e) => {
774
+ this.log('error', `error setting auth in ${context}`, e)
775
+ })
776
+ }
777
+
778
+ /**
779
+ * Trigger state change callbacks with proper error handling
780
+ * @internal
781
+ */
782
+ private _triggerStateCallbacks(
783
+ event: keyof typeof this.stateChangeCallbacks,
784
+ data?: any
785
+ ): void {
786
+ try {
787
+ this.stateChangeCallbacks[event].forEach((callback) => {
788
+ try {
789
+ callback(data)
790
+ } catch (e) {
791
+ this.log('error', `error in ${event} callback`, e)
792
+ }
793
+ })
794
+ } catch (e) {
795
+ this.log('error', `error triggering ${event} callbacks`, e)
796
+ }
797
+ }
798
+
799
+ /**
800
+ * Setup reconnection timer with proper configuration
801
+ * @internal
802
+ */
803
+ private _setupReconnectionTimer(): void {
804
+ this.reconnectTimer = new Timer(async () => {
805
+ setTimeout(async () => {
806
+ await this._waitForAuthIfNeeded()
807
+ if (!this.isConnected()) {
808
+ this.connect()
809
+ }
810
+ }, CONNECTION_TIMEOUTS.RECONNECT_DELAY)
811
+ }, this.reconnectAfterMs)
812
+ }
813
+
814
+ /**
815
+ * Initialize client options with defaults
816
+ * @internal
817
+ */
818
+ private _initializeOptions(options?: RealtimeClientOptions): void {
819
+ // Set defaults
820
+ this.transport = options?.transport ?? null
821
+ this.timeout = options?.timeout ?? DEFAULT_TIMEOUT
822
+ this.heartbeatIntervalMs =
823
+ options?.heartbeatIntervalMs ?? CONNECTION_TIMEOUTS.HEARTBEAT_INTERVAL
824
+ this.worker = options?.worker ?? false
825
+ this.accessToken = options?.accessToken ?? null
826
+
827
+ // Handle special cases
828
+ if (options?.params) this.params = options.params
829
+ if (options?.logger) this.logger = options.logger
830
+ if (options?.logLevel || options?.log_level) {
831
+ this.logLevel = options.logLevel || options.log_level
832
+ this.params = { ...this.params, log_level: this.logLevel as string }
833
+ }
834
+
835
+ // Set up functions with defaults
836
+ this.reconnectAfterMs =
837
+ options?.reconnectAfterMs ??
838
+ ((tries: number) => {
839
+ return RECONNECT_INTERVALS[tries - 1] || DEFAULT_RECONNECT_FALLBACK
840
+ })
841
+
842
+ this.encode =
843
+ options?.encode ??
844
+ ((payload: JSON, callback: Function) => {
845
+ return callback(JSON.stringify(payload))
846
+ })
847
+
848
+ this.decode =
849
+ options?.decode ?? this.serializer.decode.bind(this.serializer)
850
+
851
+ // Handle worker setup
852
+ if (this.worker) {
853
+ if (typeof window !== 'undefined' && !window.Worker) {
854
+ throw new Error('Web Worker is not supported')
855
+ }
856
+ this.workerUrl = options?.workerUrl
857
+ }
858
+ }
643
859
  }
@@ -1 +1 @@
1
- export const version = '2.12.2'
1
+ export const version = '2.13.0'