@supabase/realtime-js 2.99.3-canary.0 → 2.99.3

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 +28 -35
  2. package/dist/main/RealtimeChannel.d.ts.map +1 -1
  3. package/dist/main/RealtimeChannel.js +301 -140
  4. package/dist/main/RealtimeChannel.js.map +1 -1
  5. package/dist/main/RealtimeClient.d.ts +57 -38
  6. package/dist/main/RealtimeClient.d.ts.map +1 -1
  7. package/dist/main/RealtimeClient.js +520 -232
  8. package/dist/main/RealtimeClient.js.map +1 -1
  9. package/dist/main/RealtimePresence.d.ts +24 -8
  10. package/dist/main/RealtimePresence.d.ts.map +1 -1
  11. package/dist/main/RealtimePresence.js +202 -6
  12. package/dist/main/RealtimePresence.js.map +1 -1
  13. package/dist/main/lib/constants.d.ts +35 -39
  14. package/dist/main/lib/constants.d.ts.map +1 -1
  15. package/dist/main/lib/constants.js +35 -30
  16. package/dist/main/lib/constants.js.map +1 -1
  17. package/dist/main/lib/push.d.ts +48 -0
  18. package/dist/main/lib/push.d.ts.map +1 -0
  19. package/dist/main/lib/push.js +102 -0
  20. package/dist/main/lib/push.js.map +1 -0
  21. package/dist/main/lib/timer.d.ts +22 -0
  22. package/dist/main/lib/timer.d.ts.map +1 -0
  23. package/dist/main/lib/timer.js +39 -0
  24. package/dist/main/lib/timer.js.map +1 -0
  25. package/dist/main/lib/version.d.ts +1 -1
  26. package/dist/main/lib/version.d.ts.map +1 -1
  27. package/dist/main/lib/version.js +1 -1
  28. package/dist/main/lib/version.js.map +1 -1
  29. package/dist/main/lib/websocket-factory.d.ts +9 -0
  30. package/dist/main/lib/websocket-factory.d.ts.map +1 -1
  31. package/dist/main/lib/websocket-factory.js +12 -0
  32. package/dist/main/lib/websocket-factory.js.map +1 -1
  33. package/dist/module/RealtimeChannel.d.ts +28 -35
  34. package/dist/module/RealtimeChannel.d.ts.map +1 -1
  35. package/dist/module/RealtimeChannel.js +302 -141
  36. package/dist/module/RealtimeChannel.js.map +1 -1
  37. package/dist/module/RealtimeClient.d.ts +57 -38
  38. package/dist/module/RealtimeClient.d.ts.map +1 -1
  39. package/dist/module/RealtimeClient.js +521 -233
  40. package/dist/module/RealtimeClient.js.map +1 -1
  41. package/dist/module/RealtimePresence.d.ts +24 -8
  42. package/dist/module/RealtimePresence.d.ts.map +1 -1
  43. package/dist/module/RealtimePresence.js +202 -5
  44. package/dist/module/RealtimePresence.js.map +1 -1
  45. package/dist/module/lib/constants.d.ts +35 -39
  46. package/dist/module/lib/constants.d.ts.map +1 -1
  47. package/dist/module/lib/constants.js +35 -30
  48. package/dist/module/lib/constants.js.map +1 -1
  49. package/dist/module/lib/push.d.ts +48 -0
  50. package/dist/module/lib/push.d.ts.map +1 -0
  51. package/dist/module/lib/push.js +99 -0
  52. package/dist/module/lib/push.js.map +1 -0
  53. package/dist/module/lib/timer.d.ts +22 -0
  54. package/dist/module/lib/timer.d.ts.map +1 -0
  55. package/dist/module/lib/timer.js +36 -0
  56. package/dist/module/lib/timer.js.map +1 -0
  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 +9 -0
  62. package/dist/module/lib/websocket-factory.d.ts.map +1 -1
  63. package/dist/module/lib/websocket-factory.js +12 -0
  64. package/dist/module/lib/websocket-factory.js.map +1 -1
  65. package/dist/tsconfig.module.tsbuildinfo +1 -1
  66. package/dist/tsconfig.tsbuildinfo +1 -1
  67. package/package.json +3 -3
  68. package/src/RealtimeChannel.ts +364 -201
  69. package/src/RealtimeClient.ts +583 -296
  70. package/src/RealtimePresence.ts +287 -10
  71. package/src/lib/constants.ts +37 -50
  72. package/src/lib/push.ts +121 -0
  73. package/src/lib/timer.ts +43 -0
  74. package/src/lib/version.ts +1 -1
  75. package/src/lib/websocket-factory.ts +13 -0
  76. package/dist/main/phoenix/channelAdapter.d.ts +0 -32
  77. package/dist/main/phoenix/channelAdapter.d.ts.map +0 -1
  78. package/dist/main/phoenix/channelAdapter.js +0 -103
  79. package/dist/main/phoenix/channelAdapter.js.map +0 -1
  80. package/dist/main/phoenix/presenceAdapter.d.ts +0 -53
  81. package/dist/main/phoenix/presenceAdapter.d.ts.map +0 -1
  82. package/dist/main/phoenix/presenceAdapter.js +0 -93
  83. package/dist/main/phoenix/presenceAdapter.js.map +0 -1
  84. package/dist/main/phoenix/socketAdapter.d.ts +0 -38
  85. package/dist/main/phoenix/socketAdapter.d.ts.map +0 -1
  86. package/dist/main/phoenix/socketAdapter.js +0 -114
  87. package/dist/main/phoenix/socketAdapter.js.map +0 -1
  88. package/dist/main/phoenix/types.d.ts +0 -5
  89. package/dist/main/phoenix/types.d.ts.map +0 -1
  90. package/dist/main/phoenix/types.js +0 -3
  91. package/dist/main/phoenix/types.js.map +0 -1
  92. package/dist/module/phoenix/channelAdapter.d.ts +0 -32
  93. package/dist/module/phoenix/channelAdapter.d.ts.map +0 -1
  94. package/dist/module/phoenix/channelAdapter.js +0 -100
  95. package/dist/module/phoenix/channelAdapter.js.map +0 -1
  96. package/dist/module/phoenix/presenceAdapter.d.ts +0 -53
  97. package/dist/module/phoenix/presenceAdapter.d.ts.map +0 -1
  98. package/dist/module/phoenix/presenceAdapter.js +0 -90
  99. package/dist/module/phoenix/presenceAdapter.js.map +0 -1
  100. package/dist/module/phoenix/socketAdapter.d.ts +0 -38
  101. package/dist/module/phoenix/socketAdapter.d.ts.map +0 -1
  102. package/dist/module/phoenix/socketAdapter.js +0 -111
  103. package/dist/module/phoenix/socketAdapter.js.map +0 -1
  104. package/dist/module/phoenix/types.d.ts +0 -5
  105. package/dist/module/phoenix/types.d.ts.map +0 -1
  106. package/dist/module/phoenix/types.js +0 -2
  107. package/dist/module/phoenix/types.js.map +0 -1
  108. package/src/phoenix/channelAdapter.ts +0 -147
  109. package/src/phoenix/presenceAdapter.ts +0 -116
  110. package/src/phoenix/socketAdapter.ts +0 -168
  111. package/src/phoenix/types.ts +0 -32
@@ -5,27 +5,29 @@ import {
5
5
  CONNECTION_STATE,
6
6
  DEFAULT_VERSION,
7
7
  DEFAULT_TIMEOUT,
8
+ SOCKET_STATES,
9
+ TRANSPORTS,
8
10
  DEFAULT_VSN,
9
11
  VSN_1_0_0,
10
12
  VSN_2_0_0,
13
+ WS_CLOSE_NORMAL,
11
14
  } from './lib/constants'
12
15
 
13
16
  import Serializer from './lib/serializer'
17
+ import Timer from './lib/timer'
18
+
14
19
  import { httpEndpointURL } from './lib/transformers'
15
20
  import RealtimeChannel from './RealtimeChannel'
16
21
  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'
26
22
 
27
23
  type Fetch = typeof fetch
28
24
 
25
+ export type Channel = {
26
+ name: string
27
+ inserted_at: string
28
+ updated_at: string
29
+ id: number
30
+ }
29
31
  export type LogLevel = 'info' | 'warn' | 'error'
30
32
 
31
33
  export type RealtimeMessage = {
@@ -38,7 +40,10 @@ export type RealtimeMessage = {
38
40
 
39
41
  export type RealtimeRemoveChannelResponse = 'ok' | 'timed out' | 'error'
40
42
  export type HeartbeatStatus = 'sent' | 'ok' | 'error' | 'timeout' | 'disconnected'
41
- export type HeartbeatTimer = ReturnType<typeof setTimeout> | undefined
43
+
44
+ const noop = () => {}
45
+
46
+ type RealtimeClientState = 'connecting' | 'connected' | 'disconnecting' | 'disconnected'
42
47
 
43
48
  // Connection-related constants
44
49
  const CONNECTION_TIMEOUTS = {
@@ -60,16 +65,22 @@ export interface WebSocketLikeConstructor {
60
65
  [key: string]: any
61
66
  }
62
67
 
68
+ export interface WebSocketLikeError {
69
+ error: any
70
+ message: string
71
+ type: string
72
+ }
73
+
63
74
  export type RealtimeClientOptions = {
64
75
  transport?: WebSocketLikeConstructor
65
76
  timeout?: number
66
77
  heartbeatIntervalMs?: number
67
78
  heartbeatCallback?: (status: HeartbeatStatus, latency?: number) => void
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
79
+ vsn?: string
80
+ logger?: Function
81
+ encode?: Function
82
+ decode?: Function
83
+ reconnectAfterMs?: Function
73
84
  headers?: { [key: string]: string }
74
85
  params?: { [key: string]: any }
75
86
  //Deprecated: Use it in favour of correct casing `logLevel`
@@ -89,101 +100,52 @@ const WORKER_SCRIPT = `
89
100
  });`
90
101
 
91
102
  export default class RealtimeClient {
92
- /** @internal */
93
- socketAdapter: SocketAdapter
94
- channels: RealtimeChannel[] = new Array()
95
-
96
103
  accessTokenValue: string | null = null
97
- accessToken: (() => Promise<string | null>) | null = null
98
104
  apiKey: string | null = null
99
-
105
+ private _manuallySetToken: boolean = false
106
+ channels: RealtimeChannel[] = new Array()
107
+ endPoint: string = ''
100
108
  httpEndpoint: string = ''
101
109
  /** @deprecated headers cannot be set on websocket connections */
102
110
  headers?: { [key: string]: string } = {}
103
111
  params?: { [key: string]: string } = {}
104
-
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
105
118
  ref: number = 0
106
-
119
+ reconnectTimer: Timer | null = null
120
+ vsn: string = DEFAULT_VSN
121
+ logger: Function = noop
107
122
  logLevel?: LogLevel
108
-
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
+ }
109
140
  fetch: Fetch
141
+ accessToken: (() => Promise<string | null>) | null = null
110
142
  worker?: boolean
111
143
  workerUrl?: string
112
144
  workerRef?: Worker
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
145
+ private _connectionState: RealtimeClientState = 'disconnected'
146
+ private _wasManualDisconnect: boolean = false
184
147
  private _authPromise: Promise<void> | null = null
185
- private _workerHeartbeatTimer: HeartbeatTimer = undefined
186
- private _pendingWorkerHeartbeatRef: string | null = null
148
+ private _heartbeatSentAt: number | null = null
187
149
 
188
150
  /**
189
151
  * Initializes the Socket.
@@ -221,11 +183,12 @@ export default class RealtimeClient {
221
183
  }
222
184
  this.apiKey = options.params.apikey
223
185
 
224
- const socketAdapterOptions = this._initializeOptions(options)
225
-
226
- this.socketAdapter = new SocketAdapter(endPoint, socketAdapterOptions)
186
+ // Initialize endpoint URLs
187
+ this.endPoint = `${endPoint}/${TRANSPORTS.websocket}`
227
188
  this.httpEndpoint = httpEndpointURL(endPoint)
228
189
 
190
+ this._initializeOptions(options)
191
+ this._setupReconnectionTimer()
229
192
  this.fetch = this._resolveFetch(options?.fetch)
230
193
  }
231
194
 
@@ -234,10 +197,16 @@ export default class RealtimeClient {
234
197
  */
235
198
  connect(): void {
236
199
  // Skip if already connecting, disconnecting, or connected
237
- if (this.isConnecting() || this.isDisconnecting() || this.isConnected()) {
200
+ if (
201
+ this.isConnecting() ||
202
+ this.isDisconnecting() ||
203
+ (this.conn !== null && this.isConnected())
204
+ ) {
238
205
  return
239
206
  }
240
207
 
208
+ this._setConnectionState('connecting')
209
+
241
210
  // Trigger auth if needed and not already in progress
242
211
  // This ensures auth is called for standalone RealtimeClient usage
243
212
  // while avoiding race conditions with SupabaseClient's immediate setAuth call
@@ -245,32 +214,37 @@ export default class RealtimeClient {
245
214
  this._setAuthSafely('connect')
246
215
  }
247
216
 
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
- )
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}`)
269
245
  }
270
- throw new Error(`WebSocket not available: ${errorMessage}`)
271
246
  }
272
-
273
- this._handleNodeJsRaceCondition()
247
+ this._setupConnectionHandlers()
274
248
  }
275
249
 
276
250
  /**
@@ -278,7 +252,7 @@ export default class RealtimeClient {
278
252
  * @returns string The URL of the websocket.
279
253
  */
280
254
  endpointURL(): string {
281
- return this.socketAdapter.endPointURL()
255
+ return this._appendParams(this.endPoint, Object.assign({}, this.params, { vsn: this.vsn }))
282
256
  }
283
257
 
284
258
  /**
@@ -287,18 +261,37 @@ export default class RealtimeClient {
287
261
  * @param code A numeric status code to send on disconnect.
288
262
  * @param reason A custom reason for the disconnect.
289
263
  */
290
- async disconnect(code?: number, reason?: string) {
264
+ disconnect(code?: number, reason?: string): void {
291
265
  if (this.isDisconnecting()) {
292
- return 'ok'
293
- }
294
- return await this.socketAdapter.disconnect(
295
- () => {
296
- clearInterval(this._workerHeartbeatTimer)
297
- this._terminateWorker()
298
- },
299
- code,
300
- reason
301
- )
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
+ }
302
295
  }
303
296
 
304
297
  /**
@@ -309,16 +302,12 @@ export default class RealtimeClient {
309
302
  }
310
303
 
311
304
  /**
312
- * Unsubscribes, removes and tears down a single channel
305
+ * Unsubscribes and removes a single channel
313
306
  * @param channel A RealtimeChannel instance
314
307
  */
315
308
  async removeChannel(channel: RealtimeChannel): Promise<RealtimeRemoveChannelResponse> {
316
309
  const status = await channel.unsubscribe()
317
310
 
318
- if (status === 'ok') {
319
- channel.teardown()
320
- }
321
-
322
311
  if (this.channels.length === 0) {
323
312
  this.disconnect()
324
313
  }
@@ -327,55 +316,59 @@ export default class RealtimeClient {
327
316
  }
328
317
 
329
318
  /**
330
- * Unsubscribes, removes and tears down all channels
319
+ * Unsubscribes and removes all channels
331
320
  */
332
321
  async removeAllChannels(): Promise<RealtimeRemoveChannelResponse[]> {
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)
322
+ const values_1 = await Promise.all(this.channels.map((channel) => channel.unsubscribe()))
323
+ this.channels = []
340
324
  this.disconnect()
341
- return result
325
+ return values_1
342
326
  }
343
327
 
344
328
  /**
345
329
  * Logs the message.
346
330
  *
347
- * For customized logging, `this.logger` can be overridden in Client constructor.
331
+ * For customized logging, `this.logger` can be overridden.
348
332
  */
349
333
  log(kind: string, msg: string, data?: any) {
350
- this.socketAdapter.log(kind, msg, data)
334
+ this.logger(kind, msg, data)
351
335
  }
352
336
 
353
337
  /**
354
338
  * Returns the current state of the socket.
355
339
  */
356
- connectionState() {
357
- return this.socketAdapter.connectionState() || CONNECTION_STATE.closed
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
+ }
358
351
  }
359
352
 
360
353
  /**
361
354
  * Returns `true` is the connection is open.
362
355
  */
363
356
  isConnected(): boolean {
364
- return this.socketAdapter.isConnected()
357
+ return this.connectionState() === CONNECTION_STATE.Open
365
358
  }
366
359
 
367
360
  /**
368
361
  * Returns `true` if the connection is currently connecting.
369
362
  */
370
363
  isConnecting(): boolean {
371
- return this.socketAdapter.isConnecting()
364
+ return this._connectionState === 'connecting'
372
365
  }
373
366
 
374
367
  /**
375
368
  * Returns `true` if the connection is currently disconnecting.
376
369
  */
377
370
  isDisconnecting(): boolean {
378
- return this.socketAdapter.isDisconnecting()
371
+ return this._connectionState === 'disconnecting'
379
372
  }
380
373
 
381
374
  /**
@@ -405,7 +398,18 @@ export default class RealtimeClient {
405
398
  * If the socket is not connected, the message gets enqueued within a local buffer, and sent out when a connection is next established.
406
399
  */
407
400
  push(data: RealtimeMessage): void {
408
- this.socketAdapter.push(data)
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
+ }
409
413
  }
410
414
 
411
415
  /**
@@ -450,15 +454,71 @@ export default class RealtimeClient {
450
454
  * Sends a heartbeat message if the socket is connected.
451
455
  */
452
456
  async sendHeartbeat() {
453
- this.socketAdapter.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')
454
505
  }
455
506
 
456
507
  /**
457
508
  * Sets a callback that receives lifecycle events for internal heartbeat messages.
458
509
  * Useful for instrumenting connection health (e.g. sent/ok/timeout/disconnected).
459
510
  */
460
- onHeartbeat(callback: HeartbeatCallback) {
461
- this.socketAdapter.heartbeatCallback = this._wrapHeartbeatCallback(callback)
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
+ }
462
522
  }
463
523
 
464
524
  /**
@@ -479,11 +539,33 @@ export default class RealtimeClient {
479
539
  * @internal
480
540
  */
481
541
  _makeRef(): string {
482
- return this.socketAdapter.makeRef()
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()
483
550
  }
484
551
 
485
552
  /**
486
- * Removes a channel from RealtimeClient
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.
487
569
  *
488
570
  * @param channel An open subscription.
489
571
  *
@@ -493,6 +575,283 @@ export default class RealtimeClient {
493
575
  this.channels = this.channels.filter((c) => c.topic !== channel.topic)
494
576
  }
495
577
 
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
+
496
855
  /**
497
856
  * Perform the actual auth operation
498
857
  * @internal
@@ -536,8 +895,8 @@ export default class RealtimeClient {
536
895
 
537
896
  tokenToSend && channel.updateJoinPayload(payload)
538
897
 
539
- if (channel.joinedOnce && channel.channelAdapter.isJoined()) {
540
- channel.channelAdapter.push(CHANNEL_EVENTS.access_token, {
898
+ if (channel.joinedOnce && channel._isJoined()) {
899
+ channel._push(CHANNEL_EVENTS.access_token, {
541
900
  access_token: tokenToSend,
542
901
  })
543
902
  }
@@ -568,150 +927,89 @@ export default class RealtimeClient {
568
927
  }
569
928
  }
570
929
 
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)
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
+ }
580
942
  })
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)
943
+ } catch (e) {
944
+ this.log('error', `error triggering ${event} callbacks`, e)
611
945
  }
612
946
  }
613
947
 
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
-
639
948
  /**
640
- * Terminate the Web Worker and clear the reference
949
+ * Setup reconnection timer with proper configuration
641
950
  * @internal
642
951
  */
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
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)
661
961
  }
662
962
 
663
963
  /**
664
- * Initialize socket options with defaults
964
+ * Initialize client options with defaults
665
965
  * @internal
666
966
  */
667
- private _initializeOptions(options?: RealtimeClientOptions): SocketOptions {
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
668
973
  this.worker = options?.worker ?? false
669
974
  this.accessToken = options?.accessToken ?? null
975
+ this.heartbeatCallback = options?.heartbeatCallback ?? noop
976
+ this.vsn = options?.vsn ?? DEFAULT_VSN
670
977
 
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 =
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 =
682
988
  options?.reconnectAfterMs ??
683
989
  ((tries: number) => {
684
990
  return RECONNECT_INTERVALS[tries - 1] || DEFAULT_RECONNECT_FALLBACK
685
991
  })
686
992
 
687
- let defaultEncode: Encode<void>
688
- let defaultDecode: Decode<void>
689
-
690
- switch (result.vsn) {
993
+ switch (this.vsn) {
691
994
  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
- }
995
+ this.encode =
996
+ options?.encode ??
997
+ ((payload: JSON, callback: Function) => {
998
+ return callback(JSON.stringify(payload))
999
+ })
1000
+
1001
+ this.decode =
1002
+ options?.decode ??
1003
+ ((payload: string, callback: Function) => {
1004
+ return callback(JSON.parse(payload))
1005
+ })
698
1006
  break
699
1007
  case VSN_2_0_0:
700
- defaultEncode = this.serializer.encode.bind(this.serializer)
701
- defaultDecode = this.serializer.decode.bind(this.serializer)
1008
+ this.encode = options?.encode ?? this.serializer.encode.bind(this.serializer)
1009
+ this.decode = options?.decode ?? this.serializer.decode.bind(this.serializer)
702
1010
  break
703
1011
  default:
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 }
1012
+ throw new Error(`Unsupported serializer version: ${this.vsn}`)
715
1013
  }
716
1014
 
717
1015
  // Handle worker setup
@@ -720,17 +1018,6 @@ export default class RealtimeClient {
720
1018
  throw new Error('Web Worker is not supported')
721
1019
  }
722
1020
  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()
734
1021
  }
735
1022
  }
736
1023
  }