@supabase/realtime-js 2.99.2 → 2.100.0-rc.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 (111) hide show
  1. package/dist/main/RealtimeChannel.d.ts +35 -28
  2. package/dist/main/RealtimeChannel.d.ts.map +1 -1
  3. package/dist/main/RealtimeChannel.js +140 -301
  4. package/dist/main/RealtimeChannel.js.map +1 -1
  5. package/dist/main/RealtimeClient.d.ts +38 -57
  6. package/dist/main/RealtimeClient.d.ts.map +1 -1
  7. package/dist/main/RealtimeClient.js +232 -520
  8. package/dist/main/RealtimeClient.js.map +1 -1
  9. package/dist/main/RealtimePresence.d.ts +8 -24
  10. package/dist/main/RealtimePresence.d.ts.map +1 -1
  11. package/dist/main/RealtimePresence.js +6 -202
  12. package/dist/main/RealtimePresence.js.map +1 -1
  13. package/dist/main/lib/constants.d.ts +39 -35
  14. package/dist/main/lib/constants.d.ts.map +1 -1
  15. package/dist/main/lib/constants.js +30 -35
  16. package/dist/main/lib/constants.js.map +1 -1
  17. package/dist/main/lib/version.d.ts +1 -1
  18. package/dist/main/lib/version.d.ts.map +1 -1
  19. package/dist/main/lib/version.js +1 -1
  20. package/dist/main/lib/version.js.map +1 -1
  21. package/dist/main/lib/websocket-factory.d.ts +0 -9
  22. package/dist/main/lib/websocket-factory.d.ts.map +1 -1
  23. package/dist/main/lib/websocket-factory.js +0 -12
  24. package/dist/main/lib/websocket-factory.js.map +1 -1
  25. package/dist/main/phoenix/channelAdapter.d.ts +32 -0
  26. package/dist/main/phoenix/channelAdapter.d.ts.map +1 -0
  27. package/dist/main/phoenix/channelAdapter.js +103 -0
  28. package/dist/main/phoenix/channelAdapter.js.map +1 -0
  29. package/dist/main/phoenix/presenceAdapter.d.ts +53 -0
  30. package/dist/main/phoenix/presenceAdapter.d.ts.map +1 -0
  31. package/dist/main/phoenix/presenceAdapter.js +93 -0
  32. package/dist/main/phoenix/presenceAdapter.js.map +1 -0
  33. package/dist/main/phoenix/socketAdapter.d.ts +38 -0
  34. package/dist/main/phoenix/socketAdapter.d.ts.map +1 -0
  35. package/dist/main/phoenix/socketAdapter.js +114 -0
  36. package/dist/main/phoenix/socketAdapter.js.map +1 -0
  37. package/dist/main/phoenix/types.d.ts +5 -0
  38. package/dist/main/phoenix/types.d.ts.map +1 -0
  39. package/dist/main/phoenix/types.js +3 -0
  40. package/dist/main/phoenix/types.js.map +1 -0
  41. package/dist/module/RealtimeChannel.d.ts +35 -28
  42. package/dist/module/RealtimeChannel.d.ts.map +1 -1
  43. package/dist/module/RealtimeChannel.js +141 -302
  44. package/dist/module/RealtimeChannel.js.map +1 -1
  45. package/dist/module/RealtimeClient.d.ts +38 -57
  46. package/dist/module/RealtimeClient.d.ts.map +1 -1
  47. package/dist/module/RealtimeClient.js +233 -521
  48. package/dist/module/RealtimeClient.js.map +1 -1
  49. package/dist/module/RealtimePresence.d.ts +8 -24
  50. package/dist/module/RealtimePresence.d.ts.map +1 -1
  51. package/dist/module/RealtimePresence.js +5 -202
  52. package/dist/module/RealtimePresence.js.map +1 -1
  53. package/dist/module/lib/constants.d.ts +39 -35
  54. package/dist/module/lib/constants.d.ts.map +1 -1
  55. package/dist/module/lib/constants.js +30 -35
  56. package/dist/module/lib/constants.js.map +1 -1
  57. package/dist/module/lib/version.d.ts +1 -1
  58. package/dist/module/lib/version.d.ts.map +1 -1
  59. package/dist/module/lib/version.js +1 -1
  60. package/dist/module/lib/version.js.map +1 -1
  61. package/dist/module/lib/websocket-factory.d.ts +0 -9
  62. package/dist/module/lib/websocket-factory.d.ts.map +1 -1
  63. package/dist/module/lib/websocket-factory.js +0 -12
  64. package/dist/module/lib/websocket-factory.js.map +1 -1
  65. package/dist/module/phoenix/channelAdapter.d.ts +32 -0
  66. package/dist/module/phoenix/channelAdapter.d.ts.map +1 -0
  67. package/dist/module/phoenix/channelAdapter.js +100 -0
  68. package/dist/module/phoenix/channelAdapter.js.map +1 -0
  69. package/dist/module/phoenix/presenceAdapter.d.ts +53 -0
  70. package/dist/module/phoenix/presenceAdapter.d.ts.map +1 -0
  71. package/dist/module/phoenix/presenceAdapter.js +90 -0
  72. package/dist/module/phoenix/presenceAdapter.js.map +1 -0
  73. package/dist/module/phoenix/socketAdapter.d.ts +38 -0
  74. package/dist/module/phoenix/socketAdapter.d.ts.map +1 -0
  75. package/dist/module/phoenix/socketAdapter.js +111 -0
  76. package/dist/module/phoenix/socketAdapter.js.map +1 -0
  77. package/dist/module/phoenix/types.d.ts +5 -0
  78. package/dist/module/phoenix/types.d.ts.map +1 -0
  79. package/dist/module/phoenix/types.js +2 -0
  80. package/dist/module/phoenix/types.js.map +1 -0
  81. package/dist/tsconfig.module.tsbuildinfo +1 -1
  82. package/dist/tsconfig.tsbuildinfo +1 -1
  83. package/package.json +2 -2
  84. package/src/RealtimeChannel.ts +201 -364
  85. package/src/RealtimeClient.ts +296 -583
  86. package/src/RealtimePresence.ts +10 -287
  87. package/src/lib/constants.ts +50 -37
  88. package/src/lib/version.ts +1 -1
  89. package/src/lib/websocket-factory.ts +0 -13
  90. package/src/phoenix/channelAdapter.ts +147 -0
  91. package/src/phoenix/presenceAdapter.ts +116 -0
  92. package/src/phoenix/socketAdapter.ts +168 -0
  93. package/src/phoenix/types.ts +32 -0
  94. package/dist/main/lib/push.d.ts +0 -48
  95. package/dist/main/lib/push.d.ts.map +0 -1
  96. package/dist/main/lib/push.js +0 -102
  97. package/dist/main/lib/push.js.map +0 -1
  98. package/dist/main/lib/timer.d.ts +0 -22
  99. package/dist/main/lib/timer.d.ts.map +0 -1
  100. package/dist/main/lib/timer.js +0 -39
  101. package/dist/main/lib/timer.js.map +0 -1
  102. package/dist/module/lib/push.d.ts +0 -48
  103. package/dist/module/lib/push.d.ts.map +0 -1
  104. package/dist/module/lib/push.js +0 -99
  105. package/dist/module/lib/push.js.map +0 -1
  106. package/dist/module/lib/timer.d.ts +0 -22
  107. package/dist/module/lib/timer.d.ts.map +0 -1
  108. package/dist/module/lib/timer.js +0 -36
  109. package/dist/module/lib/timer.js.map +0 -1
  110. package/src/lib/push.ts +0 -121
  111. package/src/lib/timer.ts +0 -43
@@ -5,29 +5,27 @@ import {
5
5
  CONNECTION_STATE,
6
6
  DEFAULT_VERSION,
7
7
  DEFAULT_TIMEOUT,
8
- SOCKET_STATES,
9
- TRANSPORTS,
10
8
  DEFAULT_VSN,
11
9
  VSN_1_0_0,
12
10
  VSN_2_0_0,
13
- WS_CLOSE_NORMAL,
14
11
  } from './lib/constants'
15
12
 
16
13
  import Serializer from './lib/serializer'
17
- import Timer from './lib/timer'
18
-
19
14
  import { httpEndpointURL } from './lib/transformers'
20
15
  import RealtimeChannel from './RealtimeChannel'
21
16
  import type { RealtimeChannelOptions } from './RealtimeChannel'
17
+ import SocketAdapter from './phoenix/socketAdapter'
18
+ import type {
19
+ Message,
20
+ SocketOptions,
21
+ HeartbeatCallback,
22
+ Encode,
23
+ Decode,
24
+ Vsn,
25
+ } from './phoenix/types'
22
26
 
23
27
  type Fetch = typeof fetch
24
28
 
25
- export type Channel = {
26
- name: string
27
- inserted_at: string
28
- updated_at: string
29
- id: number
30
- }
31
29
  export type LogLevel = 'info' | 'warn' | 'error'
32
30
 
33
31
  export type RealtimeMessage = {
@@ -40,10 +38,7 @@ export type RealtimeMessage = {
40
38
 
41
39
  export type RealtimeRemoveChannelResponse = 'ok' | 'timed out' | 'error'
42
40
  export type HeartbeatStatus = 'sent' | 'ok' | 'error' | 'timeout' | 'disconnected'
43
-
44
- const noop = () => {}
45
-
46
- type RealtimeClientState = 'connecting' | 'connected' | 'disconnecting' | 'disconnected'
41
+ export type HeartbeatTimer = ReturnType<typeof setTimeout> | undefined
47
42
 
48
43
  // Connection-related constants
49
44
  const CONNECTION_TIMEOUTS = {
@@ -65,22 +60,16 @@ export interface WebSocketLikeConstructor {
65
60
  [key: string]: any
66
61
  }
67
62
 
68
- export interface WebSocketLikeError {
69
- error: any
70
- message: string
71
- type: string
72
- }
73
-
74
63
  export type RealtimeClientOptions = {
75
64
  transport?: WebSocketLikeConstructor
76
65
  timeout?: number
77
66
  heartbeatIntervalMs?: number
78
67
  heartbeatCallback?: (status: HeartbeatStatus, latency?: number) => void
79
- vsn?: string
80
- logger?: Function
81
- encode?: Function
82
- decode?: Function
83
- reconnectAfterMs?: Function
68
+ vsn?: Vsn
69
+ logger?: (kind: string, msg: string, data?: any) => void
70
+ encode?: Encode<void>
71
+ decode?: Decode<void>
72
+ reconnectAfterMs?: (tries: number) => number
84
73
  headers?: { [key: string]: string }
85
74
  params?: { [key: string]: any }
86
75
  //Deprecated: Use it in favour of correct casing `logLevel`
@@ -100,52 +89,101 @@ const WORKER_SCRIPT = `
100
89
  });`
101
90
 
102
91
  export default class RealtimeClient {
92
+ /** @internal */
93
+ socketAdapter: SocketAdapter
94
+ channels: RealtimeChannel[] = new Array()
95
+
103
96
  accessTokenValue: string | null = null
97
+ accessToken: (() => Promise<string | null>) | null = null
104
98
  apiKey: string | null = null
105
- private _manuallySetToken: boolean = false
106
- channels: RealtimeChannel[] = new Array()
107
- endPoint: string = ''
99
+
108
100
  httpEndpoint: string = ''
109
101
  /** @deprecated headers cannot be set on websocket connections */
110
102
  headers?: { [key: string]: string } = {}
111
103
  params?: { [key: string]: string } = {}
112
- timeout: number = DEFAULT_TIMEOUT
113
- transport: WebSocketLikeConstructor | null = null
114
- heartbeatIntervalMs: number = CONNECTION_TIMEOUTS.HEARTBEAT_INTERVAL
115
- heartbeatTimer: ReturnType<typeof setInterval> | undefined = undefined
116
- pendingHeartbeatRef: string | null = null
117
- heartbeatCallback: (status: HeartbeatStatus, latency?: number) => void = noop
104
+
118
105
  ref: number = 0
119
- reconnectTimer: Timer | null = null
120
- vsn: string = DEFAULT_VSN
121
- logger: Function = noop
106
+
122
107
  logLevel?: LogLevel
123
- encode!: Function
124
- decode!: Function
125
- reconnectAfterMs!: Function
126
- conn: WebSocketLike | null = null
127
- sendBuffer: Function[] = []
128
- serializer: Serializer = new Serializer()
129
- stateChangeCallbacks: {
130
- open: Function[]
131
- close: Function[]
132
- error: Function[]
133
- message: Function[]
134
- } = {
135
- open: [],
136
- close: [],
137
- error: [],
138
- message: [],
139
- }
108
+
140
109
  fetch: Fetch
141
- accessToken: (() => Promise<string | null>) | null = null
142
110
  worker?: boolean
143
111
  workerUrl?: string
144
112
  workerRef?: Worker
145
- private _connectionState: RealtimeClientState = 'disconnected'
146
- private _wasManualDisconnect: boolean = false
113
+
114
+ serializer: Serializer = new Serializer()
115
+
116
+ get endPoint() {
117
+ return this.socketAdapter.endPoint
118
+ }
119
+
120
+ get timeout() {
121
+ return this.socketAdapter.timeout
122
+ }
123
+
124
+ get transport() {
125
+ return this.socketAdapter.transport
126
+ }
127
+
128
+ get heartbeatCallback() {
129
+ return this.socketAdapter.heartbeatCallback
130
+ }
131
+
132
+ get heartbeatIntervalMs() {
133
+ return this.socketAdapter.heartbeatIntervalMs
134
+ }
135
+
136
+ get heartbeatTimer() {
137
+ if (this.worker) {
138
+ return this._workerHeartbeatTimer
139
+ }
140
+ return this.socketAdapter.heartbeatTimer
141
+ }
142
+
143
+ get pendingHeartbeatRef() {
144
+ if (this.worker) {
145
+ return this._pendingWorkerHeartbeatRef
146
+ }
147
+ return this.socketAdapter.pendingHeartbeatRef
148
+ }
149
+
150
+ get reconnectTimer() {
151
+ return this.socketAdapter.reconnectTimer
152
+ }
153
+
154
+ get vsn() {
155
+ return this.socketAdapter.vsn
156
+ }
157
+
158
+ get encode() {
159
+ return this.socketAdapter.encode
160
+ }
161
+
162
+ get decode() {
163
+ return this.socketAdapter.decode
164
+ }
165
+
166
+ get reconnectAfterMs() {
167
+ return this.socketAdapter.reconnectAfterMs
168
+ }
169
+
170
+ get sendBuffer() {
171
+ return this.socketAdapter.sendBuffer
172
+ }
173
+
174
+ get stateChangeCallbacks(): {
175
+ open: [string, Function][]
176
+ close: [string, Function][]
177
+ error: [string, Function][]
178
+ message: [string, Function][]
179
+ } {
180
+ return this.socketAdapter.stateChangeCallbacks
181
+ }
182
+
183
+ private _manuallySetToken: boolean = false
147
184
  private _authPromise: Promise<void> | null = null
148
- private _heartbeatSentAt: number | null = null
185
+ private _workerHeartbeatTimer: HeartbeatTimer = undefined
186
+ private _pendingWorkerHeartbeatRef: string | null = null
149
187
 
150
188
  /**
151
189
  * Initializes the Socket.
@@ -183,12 +221,11 @@ export default class RealtimeClient {
183
221
  }
184
222
  this.apiKey = options.params.apikey
185
223
 
186
- // Initialize endpoint URLs
187
- this.endPoint = `${endPoint}/${TRANSPORTS.websocket}`
224
+ const socketAdapterOptions = this._initializeOptions(options)
225
+
226
+ this.socketAdapter = new SocketAdapter(endPoint, socketAdapterOptions)
188
227
  this.httpEndpoint = httpEndpointURL(endPoint)
189
228
 
190
- this._initializeOptions(options)
191
- this._setupReconnectionTimer()
192
229
  this.fetch = this._resolveFetch(options?.fetch)
193
230
  }
194
231
 
@@ -197,16 +234,10 @@ export default class RealtimeClient {
197
234
  */
198
235
  connect(): void {
199
236
  // Skip if already connecting, disconnecting, or connected
200
- if (
201
- this.isConnecting() ||
202
- this.isDisconnecting() ||
203
- (this.conn !== null && this.isConnected())
204
- ) {
237
+ if (this.isConnecting() || this.isDisconnecting() || this.isConnected()) {
205
238
  return
206
239
  }
207
240
 
208
- this._setConnectionState('connecting')
209
-
210
241
  // Trigger auth if needed and not already in progress
211
242
  // This ensures auth is called for standalone RealtimeClient usage
212
243
  // while avoiding race conditions with SupabaseClient's immediate setAuth call
@@ -214,37 +245,32 @@ export default class RealtimeClient {
214
245
  this._setAuthSafely('connect')
215
246
  }
216
247
 
217
- // Establish WebSocket connection
218
- if (this.transport) {
219
- // Use custom transport if provided
220
- this.conn = new this.transport(this.endpointURL()) as WebSocketLike
221
- } else {
222
- // Try to use native WebSocket
223
- try {
224
- this.conn = WebSocketFactory.createWebSocket(this.endpointURL())
225
- } catch (error) {
226
- this._setConnectionState('disconnected')
227
- const errorMessage = (error as Error).message
228
-
229
- // Provide helpful error message based on environment
230
- if (errorMessage.includes('Node.js')) {
231
- throw new Error(
232
- `${errorMessage}\n\n` +
233
- 'To use Realtime in Node.js, you need to provide a WebSocket implementation:\n\n' +
234
- 'Option 1: Use Node.js 22+ which has native WebSocket support\n' +
235
- 'Option 2: Install and provide the "ws" package:\n\n' +
236
- ' npm install ws\n\n' +
237
- ' import ws from "ws"\n' +
238
- ' const client = new RealtimeClient(url, {\n' +
239
- ' ...options,\n' +
240
- ' transport: ws\n' +
241
- ' })'
242
- )
243
- }
244
- throw new Error(`WebSocket not available: ${errorMessage}`)
248
+ this._setupConnectionHandlers()
249
+
250
+ try {
251
+ this.socketAdapter.connect()
252
+ } catch (error) {
253
+ const errorMessage = (error as Error).message
254
+
255
+ // Provide helpful error message based on environment
256
+ if (errorMessage.includes('Node.js')) {
257
+ throw new Error(
258
+ `${errorMessage}\n\n` +
259
+ 'To use Realtime in Node.js, you need to provide a WebSocket implementation:\n\n' +
260
+ 'Option 1: Use Node.js 22+ which has native WebSocket support\n' +
261
+ 'Option 2: Install and provide the "ws" package:\n\n' +
262
+ ' npm install ws\n\n' +
263
+ ' import ws from "ws"\n' +
264
+ ' const client = new RealtimeClient(url, {\n' +
265
+ ' ...options,\n' +
266
+ ' transport: ws\n' +
267
+ ' })'
268
+ )
245
269
  }
270
+ throw new Error(`WebSocket not available: ${errorMessage}`)
246
271
  }
247
- this._setupConnectionHandlers()
272
+
273
+ this._handleNodeJsRaceCondition()
248
274
  }
249
275
 
250
276
  /**
@@ -252,7 +278,7 @@ export default class RealtimeClient {
252
278
  * @returns string The URL of the websocket.
253
279
  */
254
280
  endpointURL(): string {
255
- return this._appendParams(this.endPoint, Object.assign({}, this.params, { vsn: this.vsn }))
281
+ return this.socketAdapter.endPointURL()
256
282
  }
257
283
 
258
284
  /**
@@ -261,37 +287,18 @@ export default class RealtimeClient {
261
287
  * @param code A numeric status code to send on disconnect.
262
288
  * @param reason A custom reason for the disconnect.
263
289
  */
264
- disconnect(code?: number, reason?: string): void {
290
+ async disconnect(code?: number, reason?: string) {
265
291
  if (this.isDisconnecting()) {
266
- return
267
- }
268
-
269
- this._setConnectionState('disconnecting', true)
270
-
271
- if (this.conn) {
272
- // Setup fallback timer to prevent hanging in disconnecting state
273
- const fallbackTimer = setTimeout(() => {
274
- this._setConnectionState('disconnected')
275
- }, 100)
276
-
277
- this.conn.onclose = () => {
278
- clearTimeout(fallbackTimer)
279
- this._setConnectionState('disconnected')
280
- }
281
-
282
- // Close the WebSocket connection if close method exists
283
- if (typeof this.conn.close === 'function') {
284
- if (code) {
285
- this.conn.close(code, reason ?? '')
286
- } else {
287
- this.conn.close()
288
- }
289
- }
290
-
291
- this._teardownConnection()
292
- } else {
293
- this._setConnectionState('disconnected')
294
- }
292
+ return 'ok'
293
+ }
294
+ return await this.socketAdapter.disconnect(
295
+ () => {
296
+ clearInterval(this._workerHeartbeatTimer)
297
+ this._terminateWorker()
298
+ },
299
+ code,
300
+ reason
301
+ )
295
302
  }
296
303
 
297
304
  /**
@@ -302,12 +309,16 @@ export default class RealtimeClient {
302
309
  }
303
310
 
304
311
  /**
305
- * Unsubscribes and removes a single channel
312
+ * Unsubscribes, removes and tears down a single channel
306
313
  * @param channel A RealtimeChannel instance
307
314
  */
308
315
  async removeChannel(channel: RealtimeChannel): Promise<RealtimeRemoveChannelResponse> {
309
316
  const status = await channel.unsubscribe()
310
317
 
318
+ if (status === 'ok') {
319
+ channel.teardown()
320
+ }
321
+
311
322
  if (this.channels.length === 0) {
312
323
  this.disconnect()
313
324
  }
@@ -316,59 +327,55 @@ export default class RealtimeClient {
316
327
  }
317
328
 
318
329
  /**
319
- * Unsubscribes and removes all channels
330
+ * Unsubscribes, removes and tears down all channels
320
331
  */
321
332
  async removeAllChannels(): Promise<RealtimeRemoveChannelResponse[]> {
322
- const values_1 = await Promise.all(this.channels.map((channel) => channel.unsubscribe()))
323
- this.channels = []
333
+ const promises = this.channels.map(async (channel) => {
334
+ const result = await channel.unsubscribe()
335
+ channel.teardown()
336
+ return result
337
+ })
338
+
339
+ const result = await Promise.all(promises)
324
340
  this.disconnect()
325
- return values_1
341
+ return result
326
342
  }
327
343
 
328
344
  /**
329
345
  * Logs the message.
330
346
  *
331
- * For customized logging, `this.logger` can be overridden.
347
+ * For customized logging, `this.logger` can be overridden in Client constructor.
332
348
  */
333
349
  log(kind: string, msg: string, data?: any) {
334
- this.logger(kind, msg, data)
350
+ this.socketAdapter.log(kind, msg, data)
335
351
  }
336
352
 
337
353
  /**
338
354
  * Returns the current state of the socket.
339
355
  */
340
- connectionState(): CONNECTION_STATE {
341
- switch (this.conn && this.conn.readyState) {
342
- case SOCKET_STATES.connecting:
343
- return CONNECTION_STATE.Connecting
344
- case SOCKET_STATES.open:
345
- return CONNECTION_STATE.Open
346
- case SOCKET_STATES.closing:
347
- return CONNECTION_STATE.Closing
348
- default:
349
- return CONNECTION_STATE.Closed
350
- }
356
+ connectionState() {
357
+ return this.socketAdapter.connectionState() || CONNECTION_STATE.closed
351
358
  }
352
359
 
353
360
  /**
354
361
  * Returns `true` is the connection is open.
355
362
  */
356
363
  isConnected(): boolean {
357
- return this.connectionState() === CONNECTION_STATE.Open
364
+ return this.socketAdapter.isConnected()
358
365
  }
359
366
 
360
367
  /**
361
368
  * Returns `true` if the connection is currently connecting.
362
369
  */
363
370
  isConnecting(): boolean {
364
- return this._connectionState === 'connecting'
371
+ return this.socketAdapter.isConnecting()
365
372
  }
366
373
 
367
374
  /**
368
375
  * Returns `true` if the connection is currently disconnecting.
369
376
  */
370
377
  isDisconnecting(): boolean {
371
- return this._connectionState === 'disconnecting'
378
+ return this.socketAdapter.isDisconnecting()
372
379
  }
373
380
 
374
381
  /**
@@ -398,18 +405,7 @@ export default class RealtimeClient {
398
405
  * If the socket is not connected, the message gets enqueued within a local buffer, and sent out when a connection is next established.
399
406
  */
400
407
  push(data: RealtimeMessage): void {
401
- const { topic, event, payload, ref } = data
402
- const callback = () => {
403
- this.encode(data, (result: any) => {
404
- this.conn?.send(result)
405
- })
406
- }
407
- this.log('push', `${topic} ${event} (${ref})`, payload)
408
- if (this.isConnected()) {
409
- callback()
410
- } else {
411
- this.sendBuffer.push(callback)
412
- }
408
+ this.socketAdapter.push(data)
413
409
  }
414
410
 
415
411
  /**
@@ -454,71 +450,15 @@ export default class RealtimeClient {
454
450
  * Sends a heartbeat message if the socket is connected.
455
451
  */
456
452
  async sendHeartbeat() {
457
- if (!this.isConnected()) {
458
- try {
459
- this.heartbeatCallback('disconnected')
460
- } catch (e) {
461
- this.log('error', 'error in heartbeat callback', e)
462
- }
463
- return
464
- }
465
-
466
- // Handle heartbeat timeout and force reconnection if needed
467
- if (this.pendingHeartbeatRef) {
468
- this.pendingHeartbeatRef = null
469
- this._heartbeatSentAt = null
470
- this.log('transport', 'heartbeat timeout. Attempting to re-establish connection')
471
- try {
472
- this.heartbeatCallback('timeout')
473
- } catch (e) {
474
- this.log('error', 'error in heartbeat callback', e)
475
- }
476
-
477
- // Force reconnection after heartbeat timeout
478
- this._wasManualDisconnect = false
479
- this.conn?.close(WS_CLOSE_NORMAL, 'heartbeat timeout')
480
-
481
- setTimeout(() => {
482
- if (!this.isConnected()) {
483
- this.reconnectTimer?.scheduleTimeout()
484
- }
485
- }, CONNECTION_TIMEOUTS.HEARTBEAT_TIMEOUT_FALLBACK)
486
- return
487
- }
488
-
489
- // Send heartbeat message to server
490
- this._heartbeatSentAt = Date.now()
491
- this.pendingHeartbeatRef = this._makeRef()
492
- this.push({
493
- topic: 'phoenix',
494
- event: 'heartbeat',
495
- payload: {},
496
- ref: this.pendingHeartbeatRef,
497
- })
498
- try {
499
- this.heartbeatCallback('sent')
500
- } catch (e) {
501
- this.log('error', 'error in heartbeat callback', e)
502
- }
503
-
504
- this._setAuthSafely('heartbeat')
453
+ this.socketAdapter.sendHeartbeat()
505
454
  }
506
455
 
507
456
  /**
508
457
  * Sets a callback that receives lifecycle events for internal heartbeat messages.
509
458
  * Useful for instrumenting connection health (e.g. sent/ok/timeout/disconnected).
510
459
  */
511
- onHeartbeat(callback: (status: HeartbeatStatus, latency?: number) => void): void {
512
- this.heartbeatCallback = callback
513
- }
514
- /**
515
- * Flushes send buffer
516
- */
517
- flushSendBuffer() {
518
- if (this.isConnected() && this.sendBuffer.length > 0) {
519
- this.sendBuffer.forEach((callback) => callback())
520
- this.sendBuffer = []
521
- }
460
+ onHeartbeat(callback: HeartbeatCallback) {
461
+ this.socketAdapter.heartbeatCallback = this._wrapHeartbeatCallback(callback)
522
462
  }
523
463
 
524
464
  /**
@@ -539,33 +479,11 @@ export default class RealtimeClient {
539
479
  * @internal
540
480
  */
541
481
  _makeRef(): string {
542
- let newRef = this.ref + 1
543
- if (newRef === this.ref) {
544
- this.ref = 0
545
- } else {
546
- this.ref = newRef
547
- }
548
-
549
- return this.ref.toString()
482
+ return this.socketAdapter.makeRef()
550
483
  }
551
484
 
552
485
  /**
553
- * Unsubscribe from channels with the specified topic.
554
- *
555
- * @internal
556
- */
557
- _leaveOpenTopic(topic: string): void {
558
- let dupChannel = this.channels.find(
559
- (c) => c.topic === topic && (c._isJoined() || c._isJoining())
560
- )
561
- if (dupChannel) {
562
- this.log('transport', `leaving duplicate topic "${topic}"`)
563
- dupChannel.unsubscribe()
564
- }
565
- }
566
-
567
- /**
568
- * Removes a subscription from the socket.
486
+ * Removes a channel from RealtimeClient
569
487
  *
570
488
  * @param channel An open subscription.
571
489
  *
@@ -575,283 +493,6 @@ export default class RealtimeClient {
575
493
  this.channels = this.channels.filter((c) => c.topic !== channel.topic)
576
494
  }
577
495
 
578
- /** @internal */
579
- private _onConnMessage(rawMessage: { data: any }) {
580
- this.decode(rawMessage.data, (msg: RealtimeMessage) => {
581
- // Handle heartbeat responses
582
- if (
583
- msg.topic === 'phoenix' &&
584
- msg.event === 'phx_reply' &&
585
- msg.ref &&
586
- msg.ref === this.pendingHeartbeatRef
587
- ) {
588
- const latency = this._heartbeatSentAt ? Date.now() - this._heartbeatSentAt : undefined
589
- try {
590
- this.heartbeatCallback(msg.payload.status === 'ok' ? 'ok' : 'error', latency)
591
- } catch (e) {
592
- this.log('error', 'error in heartbeat callback', e)
593
- }
594
- this._heartbeatSentAt = null
595
- this.pendingHeartbeatRef = null
596
- }
597
-
598
- // Log incoming message
599
- const { topic, event, payload, ref } = msg
600
- const refString = ref ? `(${ref})` : ''
601
- const status = payload.status || ''
602
- this.log('receive', `${status} ${topic} ${event} ${refString}`.trim(), payload)
603
-
604
- // Route message to appropriate channels
605
- this.channels
606
- .filter((channel: RealtimeChannel) => channel._isMember(topic))
607
- .forEach((channel: RealtimeChannel) => channel._trigger(event, payload, ref))
608
-
609
- this._triggerStateCallbacks('message', msg)
610
- })
611
- }
612
-
613
- /**
614
- * Clear specific timer
615
- * @internal
616
- */
617
- private _clearTimer(timer: 'heartbeat' | 'reconnect'): void {
618
- if (timer === 'heartbeat' && this.heartbeatTimer) {
619
- clearInterval(this.heartbeatTimer)
620
- this.heartbeatTimer = undefined
621
- } else if (timer === 'reconnect') {
622
- this.reconnectTimer?.reset()
623
- }
624
- }
625
-
626
- /**
627
- * Clear all timers
628
- * @internal
629
- */
630
- private _clearAllTimers(): void {
631
- this._clearTimer('heartbeat')
632
- this._clearTimer('reconnect')
633
- }
634
-
635
- /**
636
- * Setup connection handlers for WebSocket events
637
- * @internal
638
- */
639
- private _setupConnectionHandlers(): void {
640
- if (!this.conn) return
641
-
642
- // Set binary type if supported (browsers and most WebSocket implementations)
643
- if ('binaryType' in this.conn) {
644
- ;(this.conn as any).binaryType = 'arraybuffer'
645
- }
646
-
647
- this.conn.onopen = () => this._onConnOpen()
648
- this.conn.onerror = (error: Event) => this._onConnError(error)
649
- this.conn.onmessage = (event: any) => this._onConnMessage(event)
650
- this.conn.onclose = (event: any) => this._onConnClose(event)
651
-
652
- if (this.conn.readyState === SOCKET_STATES.open) {
653
- this._onConnOpen()
654
- }
655
- }
656
-
657
- /**
658
- * Teardown connection and cleanup resources
659
- * @internal
660
- */
661
- private _teardownConnection(): void {
662
- if (this.conn) {
663
- if (
664
- this.conn.readyState === SOCKET_STATES.open ||
665
- this.conn.readyState === SOCKET_STATES.connecting
666
- ) {
667
- try {
668
- this.conn.close()
669
- } catch (e) {
670
- this.log('error', 'Error closing connection', e)
671
- }
672
- }
673
-
674
- this.conn.onopen = null
675
- this.conn.onerror = null
676
- this.conn.onmessage = null
677
- this.conn.onclose = null
678
- this.conn = null
679
- }
680
- this._clearAllTimers()
681
- this._terminateWorker()
682
- this.channels.forEach((channel) => channel.teardown())
683
- }
684
-
685
- /** @internal */
686
- private _onConnOpen() {
687
- this._setConnectionState('connected')
688
- this.log('transport', `connected to ${this.endpointURL()}`)
689
-
690
- // Wait for any pending auth operations before flushing send buffer
691
- // This ensures channel join messages include the correct access token
692
- const authPromise =
693
- this._authPromise ||
694
- (this.accessToken && !this.accessTokenValue ? this.setAuth() : Promise.resolve())
695
-
696
- authPromise
697
- .then(() => {
698
- // When subscribe() is called before the accessToken callback has
699
- // resolved (common on React Native / Expo where token storage is
700
- // async), the phx_join payload captured at subscribe()-time will
701
- // have no access_token. By this point auth has settled and
702
- // this.accessTokenValue holds the real JWT.
703
- //
704
- // The stale join messages sitting in sendBuffer captured the old
705
- // (token-less) payload in a closure, so we cannot simply flush
706
- // them. Instead we:
707
- // 1. Patch each channel's joinPush payload with the real token
708
- // 2. Drop the stale buffered messages
709
- // 3. Re-send the join for any channel still in "joining" state
710
- //
711
- // On browsers this is a harmless no-op: accessTokenValue was
712
- // already set synchronously before subscribe() ran, so the join
713
- // payload already had the correct token.
714
- if (this.accessTokenValue) {
715
- this.channels.forEach((channel) => {
716
- channel.updateJoinPayload({ access_token: this.accessTokenValue })
717
- })
718
- this.sendBuffer = []
719
- this.channels.forEach((channel) => {
720
- if (channel._isJoining()) {
721
- channel.joinPush.sent = false
722
- channel.joinPush.send()
723
- }
724
- })
725
- }
726
- this.flushSendBuffer()
727
- })
728
- .catch((e) => {
729
- this.log('error', 'error waiting for auth on connect', e)
730
- // Proceed anyway to avoid hanging connections
731
- this.flushSendBuffer()
732
- })
733
-
734
- this._clearTimer('reconnect')
735
-
736
- if (!this.worker) {
737
- this._startHeartbeat()
738
- } else {
739
- if (!this.workerRef) {
740
- this._startWorkerHeartbeat()
741
- }
742
- }
743
-
744
- this._triggerStateCallbacks('open')
745
- }
746
- /** @internal */
747
- private _startHeartbeat() {
748
- this.heartbeatTimer && clearInterval(this.heartbeatTimer)
749
- this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.heartbeatIntervalMs)
750
- }
751
-
752
- /** @internal */
753
- private _startWorkerHeartbeat() {
754
- if (this.workerUrl) {
755
- this.log('worker', `starting worker for from ${this.workerUrl}`)
756
- } else {
757
- this.log('worker', `starting default worker`)
758
- }
759
- const objectUrl = this._workerObjectUrl(this.workerUrl!)
760
- this.workerRef = new Worker(objectUrl)
761
- this.workerRef.onerror = (error) => {
762
- this.log('worker', 'worker error', (error as ErrorEvent).message)
763
- this._terminateWorker()
764
- }
765
- this.workerRef.onmessage = (event) => {
766
- if (event.data.event === 'keepAlive') {
767
- this.sendHeartbeat()
768
- }
769
- }
770
- this.workerRef.postMessage({
771
- event: 'start',
772
- interval: this.heartbeatIntervalMs,
773
- })
774
- }
775
-
776
- /**
777
- * Terminate the Web Worker and clear the reference
778
- * @internal
779
- */
780
- private _terminateWorker(): void {
781
- if (this.workerRef) {
782
- this.log('worker', 'terminating worker')
783
- this.workerRef.terminate()
784
- this.workerRef = undefined
785
- }
786
- }
787
- /** @internal */
788
- private _onConnClose(event: any) {
789
- this._setConnectionState('disconnected')
790
- this.log('transport', 'close', event)
791
- this._triggerChanError()
792
- this._clearTimer('heartbeat')
793
-
794
- // Only schedule reconnection if it wasn't a manual disconnect
795
- if (!this._wasManualDisconnect) {
796
- this.reconnectTimer?.scheduleTimeout()
797
- }
798
-
799
- this._triggerStateCallbacks('close', event)
800
- }
801
-
802
- /** @internal */
803
- private _onConnError(error: Event) {
804
- this._setConnectionState('disconnected')
805
- this.log('transport', `${error}`)
806
- this._triggerChanError()
807
- this._triggerStateCallbacks('error', error)
808
- try {
809
- this.heartbeatCallback('error')
810
- } catch (e) {
811
- this.log('error', 'error in heartbeat callback', e)
812
- }
813
- }
814
-
815
- /** @internal */
816
- private _triggerChanError() {
817
- this.channels.forEach((channel: RealtimeChannel) => channel._trigger(CHANNEL_EVENTS.error))
818
- }
819
-
820
- /** @internal */
821
- private _appendParams(url: string, params: { [key: string]: string }): string {
822
- if (Object.keys(params).length === 0) {
823
- return url
824
- }
825
- const prefix = url.match(/\?/) ? '&' : '?'
826
- const query = new URLSearchParams(params)
827
- return `${url}${prefix}${query}`
828
- }
829
-
830
- private _workerObjectUrl(url: string | undefined): string {
831
- let result_url: string
832
- if (url) {
833
- result_url = url
834
- } else {
835
- const blob = new Blob([WORKER_SCRIPT], { type: 'application/javascript' })
836
- result_url = URL.createObjectURL(blob)
837
- }
838
- return result_url
839
- }
840
-
841
- /**
842
- * Set connection state with proper state management
843
- * @internal
844
- */
845
- private _setConnectionState(state: RealtimeClientState, manual = false): void {
846
- this._connectionState = state
847
-
848
- if (state === 'connecting') {
849
- this._wasManualDisconnect = false
850
- } else if (state === 'disconnecting') {
851
- this._wasManualDisconnect = manual
852
- }
853
- }
854
-
855
496
  /**
856
497
  * Perform the actual auth operation
857
498
  * @internal
@@ -895,8 +536,8 @@ export default class RealtimeClient {
895
536
 
896
537
  tokenToSend && channel.updateJoinPayload(payload)
897
538
 
898
- if (channel.joinedOnce && channel._isJoined()) {
899
- channel._push(CHANNEL_EVENTS.access_token, {
539
+ if (channel.joinedOnce && channel.channelAdapter.isJoined()) {
540
+ channel.channelAdapter.push(CHANNEL_EVENTS.access_token, {
900
541
  access_token: tokenToSend,
901
542
  })
902
543
  }
@@ -927,89 +568,150 @@ export default class RealtimeClient {
927
568
  }
928
569
  }
929
570
 
930
- /**
931
- * Trigger state change callbacks with proper error handling
932
- * @internal
933
- */
934
- private _triggerStateCallbacks(event: keyof typeof this.stateChangeCallbacks, data?: any): void {
935
- try {
936
- this.stateChangeCallbacks[event].forEach((callback) => {
937
- try {
938
- callback(data)
939
- } catch (e) {
940
- this.log('error', `error in ${event} callback`, e)
941
- }
571
+ /** @internal */
572
+ private _setupConnectionHandlers(): void {
573
+ this.socketAdapter.onOpen(() => {
574
+ const authPromise =
575
+ this._authPromise ||
576
+ (this.accessToken && !this.accessTokenValue ? this.setAuth() : Promise.resolve())
577
+
578
+ authPromise.catch((e) => {
579
+ this.log('error', 'error waiting for auth on connect', e)
942
580
  })
943
- } catch (e) {
944
- this.log('error', `error triggering ${event} callbacks`, e)
581
+
582
+ if (this.worker && !this.workerRef) {
583
+ this._startWorkerHeartbeat()
584
+ }
585
+ })
586
+ this.socketAdapter.onClose(() => {
587
+ if (this.worker && this.workerRef) {
588
+ this._terminateWorker()
589
+ }
590
+ })
591
+ this.socketAdapter.onMessage((message: Message<any>) => {
592
+ if (message.ref && message.ref === this._pendingWorkerHeartbeatRef) {
593
+ this._pendingWorkerHeartbeatRef = null
594
+ }
595
+ })
596
+ }
597
+
598
+ /** @internal */
599
+ private _handleNodeJsRaceCondition() {
600
+ if (this.socketAdapter.isConnected()) {
601
+ // hack: ensure onConnOpen is called
602
+ this.socketAdapter.getSocket().onConnOpen()
603
+ }
604
+ }
605
+
606
+ /** @internal */
607
+ private _wrapHeartbeatCallback(heartbeatCallback?: HeartbeatCallback): HeartbeatCallback {
608
+ return (status, latency) => {
609
+ if (status == 'sent') this._setAuthSafely()
610
+ if (heartbeatCallback) heartbeatCallback(status, latency)
945
611
  }
946
612
  }
947
613
 
614
+ /** @internal */
615
+ private _startWorkerHeartbeat() {
616
+ if (this.workerUrl) {
617
+ this.log('worker', `starting worker for from ${this.workerUrl}`)
618
+ } else {
619
+ this.log('worker', `starting default worker`)
620
+ }
621
+ const objectUrl = this._workerObjectUrl(this.workerUrl!)
622
+ this.workerRef = new Worker(objectUrl)
623
+ this.workerRef.onerror = (error) => {
624
+ this.log('worker', 'worker error', (error as ErrorEvent).message)
625
+ this._terminateWorker()
626
+ this.disconnect()
627
+ }
628
+ this.workerRef.onmessage = (event) => {
629
+ if (event.data.event === 'keepAlive') {
630
+ this.sendHeartbeat()
631
+ }
632
+ }
633
+ this.workerRef.postMessage({
634
+ event: 'start',
635
+ interval: this.heartbeatIntervalMs,
636
+ })
637
+ }
638
+
948
639
  /**
949
- * Setup reconnection timer with proper configuration
640
+ * Terminate the Web Worker and clear the reference
950
641
  * @internal
951
642
  */
952
- private _setupReconnectionTimer(): void {
953
- this.reconnectTimer = new Timer(async () => {
954
- setTimeout(async () => {
955
- await this._waitForAuthIfNeeded()
956
- if (!this.isConnected()) {
957
- this.connect()
958
- }
959
- }, CONNECTION_TIMEOUTS.RECONNECT_DELAY)
960
- }, this.reconnectAfterMs)
643
+ private _terminateWorker(): void {
644
+ if (this.workerRef) {
645
+ this.log('worker', 'terminating worker')
646
+ this.workerRef.terminate()
647
+ this.workerRef = undefined
648
+ }
649
+ }
650
+
651
+ /** @internal */
652
+ private _workerObjectUrl(url: string | undefined): string {
653
+ let result_url: string
654
+ if (url) {
655
+ result_url = url
656
+ } else {
657
+ const blob = new Blob([WORKER_SCRIPT], { type: 'application/javascript' })
658
+ result_url = URL.createObjectURL(blob)
659
+ }
660
+ return result_url
961
661
  }
962
662
 
963
663
  /**
964
- * Initialize client options with defaults
664
+ * Initialize socket options with defaults
965
665
  * @internal
966
666
  */
967
- private _initializeOptions(options?: RealtimeClientOptions): void {
968
- // Set defaults
969
- this.transport = options?.transport ?? null
970
- this.timeout = options?.timeout ?? DEFAULT_TIMEOUT
971
- this.heartbeatIntervalMs =
972
- options?.heartbeatIntervalMs ?? CONNECTION_TIMEOUTS.HEARTBEAT_INTERVAL
667
+ private _initializeOptions(options?: RealtimeClientOptions): SocketOptions {
973
668
  this.worker = options?.worker ?? false
974
669
  this.accessToken = options?.accessToken ?? null
975
- this.heartbeatCallback = options?.heartbeatCallback ?? noop
976
- this.vsn = options?.vsn ?? DEFAULT_VSN
977
670
 
978
- // Handle special cases
979
- if (options?.params) this.params = options.params
980
- if (options?.logger) this.logger = options.logger
981
- if (options?.logLevel || options?.log_level) {
982
- this.logLevel = options.logLevel || options.log_level
983
- this.params = { ...this.params, log_level: this.logLevel as string }
984
- }
985
-
986
- // Set up functions with defaults
987
- this.reconnectAfterMs =
671
+ const result: SocketOptions = {}
672
+ result.timeout = options?.timeout ?? DEFAULT_TIMEOUT
673
+ result.heartbeatIntervalMs =
674
+ options?.heartbeatIntervalMs ?? CONNECTION_TIMEOUTS.HEARTBEAT_INTERVAL
675
+ result.vsn = options?.vsn ?? DEFAULT_VSN
676
+ // @ts-ignore - mismatch between phoenix and supabase
677
+ result.transport = options?.transport ?? WebSocketFactory.getWebSocketConstructor()
678
+ result.params = options?.params
679
+ result.logger = options?.logger
680
+ result.heartbeatCallback = this._wrapHeartbeatCallback(options?.heartbeatCallback)
681
+ result.reconnectAfterMs =
988
682
  options?.reconnectAfterMs ??
989
683
  ((tries: number) => {
990
684
  return RECONNECT_INTERVALS[tries - 1] || DEFAULT_RECONNECT_FALLBACK
991
685
  })
992
686
 
993
- switch (this.vsn) {
994
- case VSN_1_0_0:
995
- this.encode =
996
- options?.encode ??
997
- ((payload: JSON, callback: Function) => {
998
- return callback(JSON.stringify(payload))
999
- })
687
+ let defaultEncode: Encode<void>
688
+ let defaultDecode: Decode<void>
1000
689
 
1001
- this.decode =
1002
- options?.decode ??
1003
- ((payload: string, callback: Function) => {
1004
- return callback(JSON.parse(payload))
1005
- })
690
+ switch (result.vsn) {
691
+ case VSN_1_0_0:
692
+ defaultEncode = (payload, callback) => {
693
+ return callback(JSON.stringify(payload))
694
+ }
695
+ defaultDecode = (payload, callback) => {
696
+ return callback(JSON.parse(payload as string))
697
+ }
1006
698
  break
1007
699
  case VSN_2_0_0:
1008
- this.encode = options?.encode ?? this.serializer.encode.bind(this.serializer)
1009
- this.decode = options?.decode ?? this.serializer.decode.bind(this.serializer)
700
+ defaultEncode = this.serializer.encode.bind(this.serializer)
701
+ defaultDecode = this.serializer.decode.bind(this.serializer)
1010
702
  break
1011
703
  default:
1012
- throw new Error(`Unsupported serializer version: ${this.vsn}`)
704
+ throw new Error(`Unsupported serializer version: ${result.vsn}`)
705
+ }
706
+
707
+ result.encode = options?.encode ?? defaultEncode
708
+ result.decode = options?.decode ?? defaultDecode
709
+
710
+ result.beforeReconnect = this._reconnectAuth.bind(this)
711
+
712
+ if (options?.logLevel || options?.log_level) {
713
+ this.logLevel = options.logLevel || options.log_level
714
+ result.params = { ...result.params, log_level: this.logLevel as string }
1013
715
  }
1014
716
 
1015
717
  // Handle worker setup
@@ -1018,6 +720,17 @@ export default class RealtimeClient {
1018
720
  throw new Error('Web Worker is not supported')
1019
721
  }
1020
722
  this.workerUrl = options?.workerUrl
723
+ result.autoSendHeartbeat = !this.worker
724
+ }
725
+
726
+ return result
727
+ }
728
+
729
+ /** @internal */
730
+ private async _reconnectAuth() {
731
+ await this._waitForAuthIfNeeded()
732
+ if (!this.isConnected()) {
733
+ this.connect()
1021
734
  }
1022
735
  }
1023
736
  }