@supabase/realtime-js 2.99.3 → 2.100.0-canary.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.
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 +37 -56
  6. package/dist/main/RealtimeClient.d.ts.map +1 -1
  7. package/dist/main/RealtimeClient.js +233 -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 +37 -56
  46. package/dist/module/RealtimeClient.d.ts.map +1 -1
  47. package/dist/module/RealtimeClient.js +234 -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 +290 -581
  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,20 @@ 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 { Message, SocketOptions, HeartbeatCallback, Encode, Decode } from './phoenix/types'
22
19
 
23
20
  type Fetch = typeof fetch
24
21
 
25
- export type Channel = {
26
- name: string
27
- inserted_at: string
28
- updated_at: string
29
- id: number
30
- }
31
22
  export type LogLevel = 'info' | 'warn' | 'error'
32
23
 
33
24
  export type RealtimeMessage = {
@@ -40,10 +31,7 @@ export type RealtimeMessage = {
40
31
 
41
32
  export type RealtimeRemoveChannelResponse = 'ok' | 'timed out' | 'error'
42
33
  export type HeartbeatStatus = 'sent' | 'ok' | 'error' | 'timeout' | 'disconnected'
43
-
44
- const noop = () => {}
45
-
46
- type RealtimeClientState = 'connecting' | 'connected' | 'disconnecting' | 'disconnected'
34
+ export type HeartbeatTimer = ReturnType<typeof setTimeout> | undefined
47
35
 
48
36
  // Connection-related constants
49
37
  const CONNECTION_TIMEOUTS = {
@@ -65,22 +53,16 @@ export interface WebSocketLikeConstructor {
65
53
  [key: string]: any
66
54
  }
67
55
 
68
- export interface WebSocketLikeError {
69
- error: any
70
- message: string
71
- type: string
72
- }
73
-
74
56
  export type RealtimeClientOptions = {
75
57
  transport?: WebSocketLikeConstructor
76
58
  timeout?: number
77
59
  heartbeatIntervalMs?: number
78
60
  heartbeatCallback?: (status: HeartbeatStatus, latency?: number) => void
79
61
  vsn?: string
80
- logger?: Function
81
- encode?: Function
82
- decode?: Function
83
- reconnectAfterMs?: Function
62
+ logger?: (kind: string, msg: string, data?: any) => void
63
+ encode?: Encode<void>
64
+ decode?: Decode<void>
65
+ reconnectAfterMs?: (tries: number) => number
84
66
  headers?: { [key: string]: string }
85
67
  params?: { [key: string]: any }
86
68
  //Deprecated: Use it in favour of correct casing `logLevel`
@@ -100,52 +82,101 @@ const WORKER_SCRIPT = `
100
82
  });`
101
83
 
102
84
  export default class RealtimeClient {
85
+ /** @internal */
86
+ socketAdapter: SocketAdapter
87
+ channels: RealtimeChannel[] = new Array()
88
+
103
89
  accessTokenValue: string | null = null
90
+ accessToken: (() => Promise<string | null>) | null = null
104
91
  apiKey: string | null = null
105
- private _manuallySetToken: boolean = false
106
- channels: RealtimeChannel[] = new Array()
107
- endPoint: string = ''
92
+
108
93
  httpEndpoint: string = ''
109
94
  /** @deprecated headers cannot be set on websocket connections */
110
95
  headers?: { [key: string]: string } = {}
111
96
  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
97
+
118
98
  ref: number = 0
119
- reconnectTimer: Timer | null = null
120
- vsn: string = DEFAULT_VSN
121
- logger: Function = noop
99
+
122
100
  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
- }
101
+
140
102
  fetch: Fetch
141
- accessToken: (() => Promise<string | null>) | null = null
142
103
  worker?: boolean
143
104
  workerUrl?: string
144
105
  workerRef?: Worker
145
- private _connectionState: RealtimeClientState = 'disconnected'
146
- private _wasManualDisconnect: boolean = false
106
+
107
+ serializer: Serializer = new Serializer()
108
+
109
+ get endPoint() {
110
+ return this.socketAdapter.endPoint
111
+ }
112
+
113
+ get timeout() {
114
+ return this.socketAdapter.timeout
115
+ }
116
+
117
+ get transport() {
118
+ return this.socketAdapter.transport
119
+ }
120
+
121
+ get heartbeatCallback() {
122
+ return this.socketAdapter.heartbeatCallback
123
+ }
124
+
125
+ get heartbeatIntervalMs() {
126
+ return this.socketAdapter.heartbeatIntervalMs
127
+ }
128
+
129
+ get heartbeatTimer() {
130
+ if (this.worker) {
131
+ return this._workerHeartbeatTimer
132
+ }
133
+ return this.socketAdapter.heartbeatTimer
134
+ }
135
+
136
+ get pendingHeartbeatRef() {
137
+ if (this.worker) {
138
+ return this._pendingWorkerHeartbeatRef
139
+ }
140
+ return this.socketAdapter.pendingHeartbeatRef
141
+ }
142
+
143
+ get reconnectTimer() {
144
+ return this.socketAdapter.reconnectTimer
145
+ }
146
+
147
+ get vsn() {
148
+ return this.socketAdapter.vsn
149
+ }
150
+
151
+ get encode() {
152
+ return this.socketAdapter.encode
153
+ }
154
+
155
+ get decode() {
156
+ return this.socketAdapter.decode
157
+ }
158
+
159
+ get reconnectAfterMs() {
160
+ return this.socketAdapter.reconnectAfterMs
161
+ }
162
+
163
+ get sendBuffer() {
164
+ return this.socketAdapter.sendBuffer
165
+ }
166
+
167
+ get stateChangeCallbacks(): {
168
+ open: [string, Function][]
169
+ close: [string, Function][]
170
+ error: [string, Function][]
171
+ message: [string, Function][]
172
+ } {
173
+ return this.socketAdapter.stateChangeCallbacks
174
+ }
175
+
176
+ private _manuallySetToken: boolean = false
147
177
  private _authPromise: Promise<void> | null = null
148
- private _heartbeatSentAt: number | null = null
178
+ private _workerHeartbeatTimer: HeartbeatTimer = undefined
179
+ private _pendingWorkerHeartbeatRef: string | null = null
149
180
 
150
181
  /**
151
182
  * Initializes the Socket.
@@ -183,12 +214,11 @@ export default class RealtimeClient {
183
214
  }
184
215
  this.apiKey = options.params.apikey
185
216
 
186
- // Initialize endpoint URLs
187
- this.endPoint = `${endPoint}/${TRANSPORTS.websocket}`
217
+ const socketAdapterOptions = this._initializeOptions(options)
218
+
219
+ this.socketAdapter = new SocketAdapter(endPoint, socketAdapterOptions)
188
220
  this.httpEndpoint = httpEndpointURL(endPoint)
189
221
 
190
- this._initializeOptions(options)
191
- this._setupReconnectionTimer()
192
222
  this.fetch = this._resolveFetch(options?.fetch)
193
223
  }
194
224
 
@@ -197,16 +227,10 @@ export default class RealtimeClient {
197
227
  */
198
228
  connect(): void {
199
229
  // Skip if already connecting, disconnecting, or connected
200
- if (
201
- this.isConnecting() ||
202
- this.isDisconnecting() ||
203
- (this.conn !== null && this.isConnected())
204
- ) {
230
+ if (this.isConnecting() || this.isDisconnecting() || this.isConnected()) {
205
231
  return
206
232
  }
207
233
 
208
- this._setConnectionState('connecting')
209
-
210
234
  // Trigger auth if needed and not already in progress
211
235
  // This ensures auth is called for standalone RealtimeClient usage
212
236
  // while avoiding race conditions with SupabaseClient's immediate setAuth call
@@ -214,37 +238,32 @@ export default class RealtimeClient {
214
238
  this._setAuthSafely('connect')
215
239
  }
216
240
 
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}`)
241
+ this._setupConnectionHandlers()
242
+
243
+ try {
244
+ this.socketAdapter.connect()
245
+ } catch (error) {
246
+ const errorMessage = (error as Error).message
247
+
248
+ // Provide helpful error message based on environment
249
+ if (errorMessage.includes('Node.js')) {
250
+ throw new Error(
251
+ `${errorMessage}\n\n` +
252
+ 'To use Realtime in Node.js, you need to provide a WebSocket implementation:\n\n' +
253
+ 'Option 1: Use Node.js 22+ which has native WebSocket support\n' +
254
+ 'Option 2: Install and provide the "ws" package:\n\n' +
255
+ ' npm install ws\n\n' +
256
+ ' import ws from "ws"\n' +
257
+ ' const client = new RealtimeClient(url, {\n' +
258
+ ' ...options,\n' +
259
+ ' transport: ws\n' +
260
+ ' })'
261
+ )
245
262
  }
263
+ throw new Error(`WebSocket not available: ${errorMessage}`)
246
264
  }
247
- this._setupConnectionHandlers()
265
+
266
+ this._handleNodeJsRaceCondition()
248
267
  }
249
268
 
250
269
  /**
@@ -252,7 +271,7 @@ export default class RealtimeClient {
252
271
  * @returns string The URL of the websocket.
253
272
  */
254
273
  endpointURL(): string {
255
- return this._appendParams(this.endPoint, Object.assign({}, this.params, { vsn: this.vsn }))
274
+ return this.socketAdapter.endPointURL()
256
275
  }
257
276
 
258
277
  /**
@@ -261,37 +280,18 @@ export default class RealtimeClient {
261
280
  * @param code A numeric status code to send on disconnect.
262
281
  * @param reason A custom reason for the disconnect.
263
282
  */
264
- disconnect(code?: number, reason?: string): void {
283
+ async disconnect(code?: number, reason?: string) {
265
284
  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
- }
285
+ return 'ok'
286
+ }
287
+ return await this.socketAdapter.disconnect(
288
+ () => {
289
+ clearInterval(this._workerHeartbeatTimer)
290
+ this._terminateWorker()
291
+ },
292
+ code,
293
+ reason
294
+ )
295
295
  }
296
296
 
297
297
  /**
@@ -302,12 +302,16 @@ export default class RealtimeClient {
302
302
  }
303
303
 
304
304
  /**
305
- * Unsubscribes and removes a single channel
305
+ * Unsubscribes, removes and tears down a single channel
306
306
  * @param channel A RealtimeChannel instance
307
307
  */
308
308
  async removeChannel(channel: RealtimeChannel): Promise<RealtimeRemoveChannelResponse> {
309
309
  const status = await channel.unsubscribe()
310
310
 
311
+ if (status === 'ok') {
312
+ channel.teardown()
313
+ }
314
+
311
315
  if (this.channels.length === 0) {
312
316
  this.disconnect()
313
317
  }
@@ -316,59 +320,55 @@ export default class RealtimeClient {
316
320
  }
317
321
 
318
322
  /**
319
- * Unsubscribes and removes all channels
323
+ * Unsubscribes, removes and tears down all channels
320
324
  */
321
325
  async removeAllChannels(): Promise<RealtimeRemoveChannelResponse[]> {
322
- const values_1 = await Promise.all(this.channels.map((channel) => channel.unsubscribe()))
323
- this.channels = []
326
+ const promises = this.channels.map(async (channel) => {
327
+ const result = await channel.unsubscribe()
328
+ channel.teardown()
329
+ return result
330
+ })
331
+
332
+ const result = await Promise.all(promises)
324
333
  this.disconnect()
325
- return values_1
334
+ return result
326
335
  }
327
336
 
328
337
  /**
329
338
  * Logs the message.
330
339
  *
331
- * For customized logging, `this.logger` can be overridden.
340
+ * For customized logging, `this.logger` can be overridden in Client constructor.
332
341
  */
333
342
  log(kind: string, msg: string, data?: any) {
334
- this.logger(kind, msg, data)
343
+ this.socketAdapter.log(kind, msg, data)
335
344
  }
336
345
 
337
346
  /**
338
347
  * Returns the current state of the socket.
339
348
  */
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
- }
349
+ connectionState() {
350
+ return this.socketAdapter.connectionState() || CONNECTION_STATE.closed
351
351
  }
352
352
 
353
353
  /**
354
354
  * Returns `true` is the connection is open.
355
355
  */
356
356
  isConnected(): boolean {
357
- return this.connectionState() === CONNECTION_STATE.Open
357
+ return this.socketAdapter.isConnected()
358
358
  }
359
359
 
360
360
  /**
361
361
  * Returns `true` if the connection is currently connecting.
362
362
  */
363
363
  isConnecting(): boolean {
364
- return this._connectionState === 'connecting'
364
+ return this.socketAdapter.isConnecting()
365
365
  }
366
366
 
367
367
  /**
368
368
  * Returns `true` if the connection is currently disconnecting.
369
369
  */
370
370
  isDisconnecting(): boolean {
371
- return this._connectionState === 'disconnecting'
371
+ return this.socketAdapter.isDisconnecting()
372
372
  }
373
373
 
374
374
  /**
@@ -398,18 +398,7 @@ export default class RealtimeClient {
398
398
  * If the socket is not connected, the message gets enqueued within a local buffer, and sent out when a connection is next established.
399
399
  */
400
400
  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
- }
401
+ this.socketAdapter.push(data)
413
402
  }
414
403
 
415
404
  /**
@@ -454,71 +443,15 @@ export default class RealtimeClient {
454
443
  * Sends a heartbeat message if the socket is connected.
455
444
  */
456
445
  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')
446
+ this.socketAdapter.sendHeartbeat()
505
447
  }
506
448
 
507
449
  /**
508
450
  * Sets a callback that receives lifecycle events for internal heartbeat messages.
509
451
  * Useful for instrumenting connection health (e.g. sent/ok/timeout/disconnected).
510
452
  */
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
- }
453
+ onHeartbeat(callback: HeartbeatCallback) {
454
+ this.socketAdapter.heartbeatCallback = this._wrapHeartbeatCallback(callback)
522
455
  }
523
456
 
524
457
  /**
@@ -539,33 +472,11 @@ export default class RealtimeClient {
539
472
  * @internal
540
473
  */
541
474
  _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()
475
+ return this.socketAdapter.makeRef()
550
476
  }
551
477
 
552
478
  /**
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.
479
+ * Removes a channel from RealtimeClient
569
480
  *
570
481
  * @param channel An open subscription.
571
482
  *
@@ -575,283 +486,6 @@ export default class RealtimeClient {
575
486
  this.channels = this.channels.filter((c) => c.topic !== channel.topic)
576
487
  }
577
488
 
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
489
  /**
856
490
  * Perform the actual auth operation
857
491
  * @internal
@@ -895,8 +529,8 @@ export default class RealtimeClient {
895
529
 
896
530
  tokenToSend && channel.updateJoinPayload(payload)
897
531
 
898
- if (channel.joinedOnce && channel._isJoined()) {
899
- channel._push(CHANNEL_EVENTS.access_token, {
532
+ if (channel.joinedOnce && channel.channelAdapter.isJoined()) {
533
+ channel.channelAdapter.push(CHANNEL_EVENTS.access_token, {
900
534
  access_token: tokenToSend,
901
535
  })
902
536
  }
@@ -927,89 +561,153 @@ export default class RealtimeClient {
927
561
  }
928
562
  }
929
563
 
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
- }
564
+ /** @internal */
565
+ private _setupConnectionHandlers(): void {
566
+ this.socketAdapter.onOpen(() => {
567
+ const authPromise =
568
+ this._authPromise ||
569
+ (this.accessToken && !this.accessTokenValue ? this.setAuth() : Promise.resolve())
570
+
571
+ authPromise.catch((e) => {
572
+ this.log('error', 'error waiting for auth on connect', e)
942
573
  })
943
- } catch (e) {
944
- this.log('error', `error triggering ${event} callbacks`, e)
574
+
575
+ if (this.worker && !this.workerRef) {
576
+ this._startWorkerHeartbeat()
577
+ }
578
+ })
579
+ this.socketAdapter.onClose(() => {
580
+ if (this.worker && this.workerRef) {
581
+ this._terminateWorker()
582
+ }
583
+ })
584
+ this.socketAdapter.onMessage((message: Message<any>) => {
585
+ if (message.ref && message.ref === this._pendingWorkerHeartbeatRef) {
586
+ this._pendingWorkerHeartbeatRef = null
587
+ }
588
+ })
589
+ }
590
+
591
+ /** @internal */
592
+ private _handleNodeJsRaceCondition() {
593
+ if (this.socketAdapter.isConnected()) {
594
+ // hack: ensure onConnOpen is called
595
+ this.socketAdapter.getSocket().onConnOpen()
596
+ }
597
+ }
598
+
599
+ /** @internal */
600
+ private _wrapHeartbeatCallback(heartbeatCallback?: HeartbeatCallback): HeartbeatCallback {
601
+ return (status, latency) => {
602
+ if (status == 'sent') this._setAuthSafely()
603
+ if (heartbeatCallback) heartbeatCallback(status, latency)
945
604
  }
946
605
  }
947
606
 
607
+ /** @internal */
608
+ private _startWorkerHeartbeat() {
609
+ if (this.workerUrl) {
610
+ this.log('worker', `starting worker for from ${this.workerUrl}`)
611
+ } else {
612
+ this.log('worker', `starting default worker`)
613
+ }
614
+ const objectUrl = this._workerObjectUrl(this.workerUrl!)
615
+ this.workerRef = new Worker(objectUrl)
616
+ this.workerRef.onerror = (error) => {
617
+ this.log('worker', 'worker error', (error as ErrorEvent).message)
618
+ this._terminateWorker()
619
+ this.disconnect()
620
+ }
621
+ this.workerRef.onmessage = (event) => {
622
+ if (event.data.event === 'keepAlive') {
623
+ this.sendHeartbeat()
624
+ }
625
+ }
626
+ this.workerRef.postMessage({
627
+ event: 'start',
628
+ interval: this.heartbeatIntervalMs,
629
+ })
630
+ }
631
+
948
632
  /**
949
- * Setup reconnection timer with proper configuration
633
+ * Terminate the Web Worker and clear the reference
950
634
  * @internal
951
635
  */
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)
636
+ private _terminateWorker(): void {
637
+ if (this.workerRef) {
638
+ this.log('worker', 'terminating worker')
639
+ this.workerRef.terminate()
640
+ this.workerRef = undefined
641
+ }
642
+ }
643
+
644
+ /** @internal */
645
+ private _workerObjectUrl(url: string | undefined): string {
646
+ let result_url: string
647
+ if (url) {
648
+ result_url = url
649
+ } else {
650
+ const blob = new Blob([WORKER_SCRIPT], { type: 'application/javascript' })
651
+ result_url = URL.createObjectURL(blob)
652
+ }
653
+ return result_url
961
654
  }
962
655
 
963
656
  /**
964
- * Initialize client options with defaults
657
+ * Initialize socket options with defaults
965
658
  * @internal
966
659
  */
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
660
+ private _initializeOptions(options?: RealtimeClientOptions): SocketOptions {
973
661
  this.worker = options?.worker ?? false
974
662
  this.accessToken = options?.accessToken ?? null
975
- this.heartbeatCallback = options?.heartbeatCallback ?? noop
976
- this.vsn = options?.vsn ?? DEFAULT_VSN
977
663
 
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
- }
664
+ const result: SocketOptions = {}
665
+ result.timeout = options?.timeout ?? DEFAULT_TIMEOUT
666
+ result.heartbeatIntervalMs =
667
+ options?.heartbeatIntervalMs ?? CONNECTION_TIMEOUTS.HEARTBEAT_INTERVAL
985
668
 
986
- // Set up functions with defaults
987
- this.reconnectAfterMs =
669
+ // @ts-ignore - mismatch between phoenix and supabase
670
+ result.transport = options?.transport ?? WebSocketFactory.getWebSocketConstructor()
671
+ result.params = options?.params
672
+ result.logger = options?.logger
673
+ result.heartbeatCallback = this._wrapHeartbeatCallback(options?.heartbeatCallback)
674
+ result.reconnectAfterMs =
988
675
  options?.reconnectAfterMs ??
989
676
  ((tries: number) => {
990
677
  return RECONNECT_INTERVALS[tries - 1] || DEFAULT_RECONNECT_FALLBACK
991
678
  })
992
679
 
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
- })
680
+ let defaultEncode: Encode<void>
681
+ let defaultDecode: Decode<void>
1000
682
 
1001
- this.decode =
1002
- options?.decode ??
1003
- ((payload: string, callback: Function) => {
1004
- return callback(JSON.parse(payload))
1005
- })
683
+ const vsn = options?.vsn ?? DEFAULT_VSN
684
+
685
+ switch (vsn) {
686
+ case VSN_1_0_0:
687
+ defaultEncode = (payload, callback) => {
688
+ return callback(JSON.stringify(payload))
689
+ }
690
+ defaultDecode = (payload, callback) => {
691
+ return callback(JSON.parse(payload as string))
692
+ }
1006
693
  break
1007
694
  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)
695
+ defaultEncode = this.serializer.encode.bind(this.serializer)
696
+ defaultDecode = this.serializer.decode.bind(this.serializer)
1010
697
  break
1011
698
  default:
1012
- throw new Error(`Unsupported serializer version: ${this.vsn}`)
699
+ throw new Error(`Unsupported serializer version: ${result.vsn}`)
700
+ }
701
+
702
+ result.vsn = vsn
703
+ result.encode = options?.encode ?? defaultEncode
704
+ result.decode = options?.decode ?? defaultDecode
705
+
706
+ result.beforeReconnect = this._reconnectAuth.bind(this)
707
+
708
+ if (options?.logLevel || options?.log_level) {
709
+ this.logLevel = options.logLevel || options.log_level
710
+ result.params = { ...result.params, log_level: this.logLevel as string }
1013
711
  }
1014
712
 
1015
713
  // Handle worker setup
@@ -1018,6 +716,17 @@ export default class RealtimeClient {
1018
716
  throw new Error('Web Worker is not supported')
1019
717
  }
1020
718
  this.workerUrl = options?.workerUrl
719
+ result.autoSendHeartbeat = !this.worker
720
+ }
721
+
722
+ return result
723
+ }
724
+
725
+ /** @internal */
726
+ private async _reconnectAuth() {
727
+ await this._waitForAuthIfNeeded()
728
+ if (!this.isConnected()) {
729
+ this.connect()
1021
730
  }
1022
731
  }
1023
732
  }