@depup/supabase__realtime-js 2.99.3-depup.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 (104) hide show
  1. package/README.md +32 -0
  2. package/changes.json +14 -0
  3. package/dist/main/RealtimeChannel.d.ts +370 -0
  4. package/dist/main/RealtimeChannel.d.ts.map +1 -0
  5. package/dist/main/RealtimeChannel.js +644 -0
  6. package/dist/main/RealtimeChannel.js.map +1 -0
  7. package/dist/main/RealtimeClient.d.ts +238 -0
  8. package/dist/main/RealtimeClient.d.ts.map +1 -0
  9. package/dist/main/RealtimeClient.js +870 -0
  10. package/dist/main/RealtimeClient.js.map +1 -0
  11. package/dist/main/RealtimePresence.d.ts +76 -0
  12. package/dist/main/RealtimePresence.d.ts.map +1 -0
  13. package/dist/main/RealtimePresence.js +237 -0
  14. package/dist/main/RealtimePresence.js.map +1 -0
  15. package/dist/main/index.d.ts +6 -0
  16. package/dist/main/index.d.ts.map +1 -0
  17. package/dist/main/index.js +18 -0
  18. package/dist/main/index.js.map +1 -0
  19. package/dist/main/lib/constants.d.ts +39 -0
  20. package/dist/main/lib/constants.d.ts.map +1 -0
  21. package/dist/main/lib/constants.js +48 -0
  22. package/dist/main/lib/constants.js.map +1 -0
  23. package/dist/main/lib/push.d.ts +48 -0
  24. package/dist/main/lib/push.d.ts.map +1 -0
  25. package/dist/main/lib/push.js +102 -0
  26. package/dist/main/lib/push.js.map +1 -0
  27. package/dist/main/lib/serializer.d.ts +33 -0
  28. package/dist/main/lib/serializer.d.ts.map +1 -0
  29. package/dist/main/lib/serializer.js +155 -0
  30. package/dist/main/lib/serializer.js.map +1 -0
  31. package/dist/main/lib/timer.d.ts +22 -0
  32. package/dist/main/lib/timer.d.ts.map +1 -0
  33. package/dist/main/lib/timer.js +39 -0
  34. package/dist/main/lib/timer.js.map +1 -0
  35. package/dist/main/lib/transformers.d.ts +109 -0
  36. package/dist/main/lib/transformers.d.ts.map +1 -0
  37. package/dist/main/lib/transformers.js +241 -0
  38. package/dist/main/lib/transformers.js.map +1 -0
  39. package/dist/main/lib/version.d.ts +2 -0
  40. package/dist/main/lib/version.d.ts.map +1 -0
  41. package/dist/main/lib/version.js +11 -0
  42. package/dist/main/lib/version.js.map +1 -0
  43. package/dist/main/lib/websocket-factory.d.ts +81 -0
  44. package/dist/main/lib/websocket-factory.d.ts.map +1 -0
  45. package/dist/main/lib/websocket-factory.js +131 -0
  46. package/dist/main/lib/websocket-factory.js.map +1 -0
  47. package/dist/module/RealtimeChannel.d.ts +370 -0
  48. package/dist/module/RealtimeChannel.d.ts.map +1 -0
  49. package/dist/module/RealtimeChannel.js +639 -0
  50. package/dist/module/RealtimeChannel.js.map +1 -0
  51. package/dist/module/RealtimeClient.d.ts +238 -0
  52. package/dist/module/RealtimeClient.d.ts.map +1 -0
  53. package/dist/module/RealtimeClient.js +866 -0
  54. package/dist/module/RealtimeClient.js.map +1 -0
  55. package/dist/module/RealtimePresence.d.ts +76 -0
  56. package/dist/module/RealtimePresence.d.ts.map +1 -0
  57. package/dist/module/RealtimePresence.js +233 -0
  58. package/dist/module/RealtimePresence.js.map +1 -0
  59. package/dist/module/index.d.ts +6 -0
  60. package/dist/module/index.d.ts.map +1 -0
  61. package/dist/module/index.js +6 -0
  62. package/dist/module/index.js.map +1 -0
  63. package/dist/module/lib/constants.d.ts +39 -0
  64. package/dist/module/lib/constants.d.ts.map +1 -0
  65. package/dist/module/lib/constants.js +45 -0
  66. package/dist/module/lib/constants.js.map +1 -0
  67. package/dist/module/lib/push.d.ts +48 -0
  68. package/dist/module/lib/push.d.ts.map +1 -0
  69. package/dist/module/lib/push.js +99 -0
  70. package/dist/module/lib/push.js.map +1 -0
  71. package/dist/module/lib/serializer.d.ts +33 -0
  72. package/dist/module/lib/serializer.d.ts.map +1 -0
  73. package/dist/module/lib/serializer.js +152 -0
  74. package/dist/module/lib/serializer.js.map +1 -0
  75. package/dist/module/lib/timer.d.ts +22 -0
  76. package/dist/module/lib/timer.d.ts.map +1 -0
  77. package/dist/module/lib/timer.js +36 -0
  78. package/dist/module/lib/timer.js.map +1 -0
  79. package/dist/module/lib/transformers.d.ts +109 -0
  80. package/dist/module/lib/transformers.d.ts.map +1 -0
  81. package/dist/module/lib/transformers.js +229 -0
  82. package/dist/module/lib/transformers.js.map +1 -0
  83. package/dist/module/lib/version.d.ts +2 -0
  84. package/dist/module/lib/version.d.ts.map +1 -0
  85. package/dist/module/lib/version.js +8 -0
  86. package/dist/module/lib/version.js.map +1 -0
  87. package/dist/module/lib/websocket-factory.d.ts +81 -0
  88. package/dist/module/lib/websocket-factory.d.ts.map +1 -0
  89. package/dist/module/lib/websocket-factory.js +127 -0
  90. package/dist/module/lib/websocket-factory.js.map +1 -0
  91. package/dist/tsconfig.module.tsbuildinfo +1 -0
  92. package/dist/tsconfig.tsbuildinfo +1 -0
  93. package/package.json +85 -0
  94. package/src/RealtimeChannel.ts +1039 -0
  95. package/src/RealtimeClient.ts +1023 -0
  96. package/src/RealtimePresence.ts +346 -0
  97. package/src/index.ts +53 -0
  98. package/src/lib/constants.ts +49 -0
  99. package/src/lib/push.ts +121 -0
  100. package/src/lib/serializer.ts +203 -0
  101. package/src/lib/timer.ts +43 -0
  102. package/src/lib/transformers.ts +270 -0
  103. package/src/lib/version.ts +7 -0
  104. package/src/lib/websocket-factory.ts +192 -0
@@ -0,0 +1,1023 @@
1
+ import WebSocketFactory, { WebSocketLike } from './lib/websocket-factory'
2
+
3
+ import {
4
+ CHANNEL_EVENTS,
5
+ CONNECTION_STATE,
6
+ DEFAULT_VERSION,
7
+ DEFAULT_TIMEOUT,
8
+ SOCKET_STATES,
9
+ TRANSPORTS,
10
+ DEFAULT_VSN,
11
+ VSN_1_0_0,
12
+ VSN_2_0_0,
13
+ WS_CLOSE_NORMAL,
14
+ } from './lib/constants'
15
+
16
+ import Serializer from './lib/serializer'
17
+ import Timer from './lib/timer'
18
+
19
+ import { httpEndpointURL } from './lib/transformers'
20
+ import RealtimeChannel from './RealtimeChannel'
21
+ import type { RealtimeChannelOptions } from './RealtimeChannel'
22
+
23
+ type Fetch = typeof fetch
24
+
25
+ export type Channel = {
26
+ name: string
27
+ inserted_at: string
28
+ updated_at: string
29
+ id: number
30
+ }
31
+ export type LogLevel = 'info' | 'warn' | 'error'
32
+
33
+ export type RealtimeMessage = {
34
+ topic: string
35
+ event: string
36
+ payload: any
37
+ ref: string
38
+ join_ref?: string
39
+ }
40
+
41
+ export type RealtimeRemoveChannelResponse = 'ok' | 'timed out' | 'error'
42
+ export type HeartbeatStatus = 'sent' | 'ok' | 'error' | 'timeout' | 'disconnected'
43
+
44
+ const noop = () => {}
45
+
46
+ type RealtimeClientState = 'connecting' | 'connected' | 'disconnecting' | 'disconnected'
47
+
48
+ // Connection-related constants
49
+ const CONNECTION_TIMEOUTS = {
50
+ HEARTBEAT_INTERVAL: 25000,
51
+ RECONNECT_DELAY: 10,
52
+ HEARTBEAT_TIMEOUT_FALLBACK: 100,
53
+ } as const
54
+
55
+ const RECONNECT_INTERVALS = [1000, 2000, 5000, 10000] as const
56
+ const DEFAULT_RECONNECT_FALLBACK = 10000
57
+
58
+ /**
59
+ * Minimal WebSocket constructor interface that RealtimeClient can work with.
60
+ * Supply a compatible implementation (native WebSocket, `ws`, etc) when running outside the browser.
61
+ */
62
+ export interface WebSocketLikeConstructor {
63
+ new (address: string | URL, subprotocols?: string | string[] | undefined): WebSocketLike
64
+ // Allow additional properties that may exist on WebSocket constructors
65
+ [key: string]: any
66
+ }
67
+
68
+ export interface WebSocketLikeError {
69
+ error: any
70
+ message: string
71
+ type: string
72
+ }
73
+
74
+ export type RealtimeClientOptions = {
75
+ transport?: WebSocketLikeConstructor
76
+ timeout?: number
77
+ heartbeatIntervalMs?: number
78
+ heartbeatCallback?: (status: HeartbeatStatus, latency?: number) => void
79
+ vsn?: string
80
+ logger?: Function
81
+ encode?: Function
82
+ decode?: Function
83
+ reconnectAfterMs?: Function
84
+ headers?: { [key: string]: string }
85
+ params?: { [key: string]: any }
86
+ //Deprecated: Use it in favour of correct casing `logLevel`
87
+ log_level?: LogLevel
88
+ logLevel?: LogLevel
89
+ fetch?: Fetch
90
+ worker?: boolean
91
+ workerUrl?: string
92
+ accessToken?: () => Promise<string | null>
93
+ }
94
+
95
+ const WORKER_SCRIPT = `
96
+ addEventListener("message", (e) => {
97
+ if (e.data.event === "start") {
98
+ setInterval(() => postMessage({ event: "keepAlive" }), e.data.interval);
99
+ }
100
+ });`
101
+
102
+ export default class RealtimeClient {
103
+ accessTokenValue: string | null = null
104
+ apiKey: string | null = null
105
+ private _manuallySetToken: boolean = false
106
+ channels: RealtimeChannel[] = new Array()
107
+ endPoint: string = ''
108
+ httpEndpoint: string = ''
109
+ /** @deprecated headers cannot be set on websocket connections */
110
+ headers?: { [key: string]: string } = {}
111
+ 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
118
+ ref: number = 0
119
+ reconnectTimer: Timer | null = null
120
+ vsn: string = DEFAULT_VSN
121
+ logger: Function = noop
122
+ 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
+ }
140
+ fetch: Fetch
141
+ accessToken: (() => Promise<string | null>) | null = null
142
+ worker?: boolean
143
+ workerUrl?: string
144
+ workerRef?: Worker
145
+ private _connectionState: RealtimeClientState = 'disconnected'
146
+ private _wasManualDisconnect: boolean = false
147
+ private _authPromise: Promise<void> | null = null
148
+ private _heartbeatSentAt: number | null = null
149
+
150
+ /**
151
+ * Initializes the Socket.
152
+ *
153
+ * @param endPoint The string WebSocket endpoint, ie, "ws://example.com/socket", "wss://example.com", "/socket" (inherited host & protocol)
154
+ * @param httpEndpoint The string HTTP endpoint, ie, "https://example.com", "/" (inherited host & protocol)
155
+ * @param options.transport The Websocket Transport, for example WebSocket. This can be a custom implementation
156
+ * @param options.timeout The default timeout in milliseconds to trigger push timeouts.
157
+ * @param options.params The optional params to pass when connecting.
158
+ * @param options.headers Deprecated: headers cannot be set on websocket connections and this option will be removed in the future.
159
+ * @param options.heartbeatIntervalMs The millisec interval to send a heartbeat message.
160
+ * @param options.heartbeatCallback The optional function to handle heartbeat status and latency.
161
+ * @param options.logger The optional function for specialized logging, ie: logger: (kind, msg, data) => { console.log(`${kind}: ${msg}`, data) }
162
+ * @param options.logLevel Sets the log level for Realtime
163
+ * @param options.encode The function to encode outgoing messages. Defaults to JSON: (payload, callback) => callback(JSON.stringify(payload))
164
+ * @param options.decode The function to decode incoming messages. Defaults to Serializer's decode.
165
+ * @param options.reconnectAfterMs he optional function that returns the millsec reconnect interval. Defaults to stepped backoff off.
166
+ * @param options.worker Use Web Worker to set a side flow. Defaults to false.
167
+ * @param options.workerUrl The URL of the worker script. Defaults to https://realtime.supabase.com/worker.js that includes a heartbeat event call to keep the connection alive.
168
+ * @param options.vsn The protocol version to use when connecting. Supported versions are "1.0.0" and "2.0.0". Defaults to "2.0.0".
169
+ * @example
170
+ * ```ts
171
+ * import RealtimeClient from '@supabase/realtime-js'
172
+ *
173
+ * const client = new RealtimeClient('https://xyzcompany.supabase.co/realtime/v1', {
174
+ * params: { apikey: 'public-anon-key' },
175
+ * })
176
+ * client.connect()
177
+ * ```
178
+ */
179
+ constructor(endPoint: string, options?: RealtimeClientOptions) {
180
+ // Validate required parameters
181
+ if (!options?.params?.apikey) {
182
+ throw new Error('API key is required to connect to Realtime')
183
+ }
184
+ this.apiKey = options.params.apikey
185
+
186
+ // Initialize endpoint URLs
187
+ this.endPoint = `${endPoint}/${TRANSPORTS.websocket}`
188
+ this.httpEndpoint = httpEndpointURL(endPoint)
189
+
190
+ this._initializeOptions(options)
191
+ this._setupReconnectionTimer()
192
+ this.fetch = this._resolveFetch(options?.fetch)
193
+ }
194
+
195
+ /**
196
+ * Connects the socket, unless already connected.
197
+ */
198
+ connect(): void {
199
+ // Skip if already connecting, disconnecting, or connected
200
+ if (
201
+ this.isConnecting() ||
202
+ this.isDisconnecting() ||
203
+ (this.conn !== null && this.isConnected())
204
+ ) {
205
+ return
206
+ }
207
+
208
+ this._setConnectionState('connecting')
209
+
210
+ // Trigger auth if needed and not already in progress
211
+ // This ensures auth is called for standalone RealtimeClient usage
212
+ // while avoiding race conditions with SupabaseClient's immediate setAuth call
213
+ if (this.accessToken && !this._authPromise) {
214
+ this._setAuthSafely('connect')
215
+ }
216
+
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}`)
245
+ }
246
+ }
247
+ this._setupConnectionHandlers()
248
+ }
249
+
250
+ /**
251
+ * Returns the URL of the websocket.
252
+ * @returns string The URL of the websocket.
253
+ */
254
+ endpointURL(): string {
255
+ return this._appendParams(this.endPoint, Object.assign({}, this.params, { vsn: this.vsn }))
256
+ }
257
+
258
+ /**
259
+ * Disconnects the socket.
260
+ *
261
+ * @param code A numeric status code to send on disconnect.
262
+ * @param reason A custom reason for the disconnect.
263
+ */
264
+ disconnect(code?: number, reason?: string): void {
265
+ 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
+ }
295
+ }
296
+
297
+ /**
298
+ * Returns all created channels
299
+ */
300
+ getChannels(): RealtimeChannel[] {
301
+ return this.channels
302
+ }
303
+
304
+ /**
305
+ * Unsubscribes and removes a single channel
306
+ * @param channel A RealtimeChannel instance
307
+ */
308
+ async removeChannel(channel: RealtimeChannel): Promise<RealtimeRemoveChannelResponse> {
309
+ const status = await channel.unsubscribe()
310
+
311
+ if (this.channels.length === 0) {
312
+ this.disconnect()
313
+ }
314
+
315
+ return status
316
+ }
317
+
318
+ /**
319
+ * Unsubscribes and removes all channels
320
+ */
321
+ async removeAllChannels(): Promise<RealtimeRemoveChannelResponse[]> {
322
+ const values_1 = await Promise.all(this.channels.map((channel) => channel.unsubscribe()))
323
+ this.channels = []
324
+ this.disconnect()
325
+ return values_1
326
+ }
327
+
328
+ /**
329
+ * Logs the message.
330
+ *
331
+ * For customized logging, `this.logger` can be overridden.
332
+ */
333
+ log(kind: string, msg: string, data?: any) {
334
+ this.logger(kind, msg, data)
335
+ }
336
+
337
+ /**
338
+ * Returns the current state of the socket.
339
+ */
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
+ }
351
+ }
352
+
353
+ /**
354
+ * Returns `true` is the connection is open.
355
+ */
356
+ isConnected(): boolean {
357
+ return this.connectionState() === CONNECTION_STATE.Open
358
+ }
359
+
360
+ /**
361
+ * Returns `true` if the connection is currently connecting.
362
+ */
363
+ isConnecting(): boolean {
364
+ return this._connectionState === 'connecting'
365
+ }
366
+
367
+ /**
368
+ * Returns `true` if the connection is currently disconnecting.
369
+ */
370
+ isDisconnecting(): boolean {
371
+ return this._connectionState === 'disconnecting'
372
+ }
373
+
374
+ /**
375
+ * Creates (or reuses) a {@link RealtimeChannel} for the provided topic.
376
+ *
377
+ * Topics are automatically prefixed with `realtime:` to match the Realtime service.
378
+ * If a channel with the same topic already exists it will be returned instead of creating
379
+ * a duplicate connection.
380
+ */
381
+ channel(topic: string, params: RealtimeChannelOptions = { config: {} }): RealtimeChannel {
382
+ const realtimeTopic = `realtime:${topic}`
383
+ const exists = this.getChannels().find((c: RealtimeChannel) => c.topic === realtimeTopic)
384
+
385
+ if (!exists) {
386
+ const chan = new RealtimeChannel(`realtime:${topic}`, params, this)
387
+ this.channels.push(chan)
388
+
389
+ return chan
390
+ } else {
391
+ return exists
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Push out a message if the socket is connected.
397
+ *
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
+ */
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
+ }
413
+ }
414
+
415
+ /**
416
+ * Sets the JWT access token used for channel subscription authorization and Realtime RLS.
417
+ *
418
+ * If param is null it will use the `accessToken` callback function or the token set on the client.
419
+ *
420
+ * On callback used, it will set the value of the token internal to the client.
421
+ *
422
+ * When a token is explicitly provided, it will be preserved across channel operations
423
+ * (including removeChannel and resubscribe). The `accessToken` callback will not be
424
+ * invoked until `setAuth()` is called without arguments.
425
+ *
426
+ * @param token A JWT string to override the token set on the client.
427
+ *
428
+ * @example
429
+ * // Use a manual token (preserved across resubscribes, ignores accessToken callback)
430
+ * client.realtime.setAuth('my-custom-jwt')
431
+ *
432
+ * // Switch back to using the accessToken callback
433
+ * client.realtime.setAuth()
434
+ */
435
+ async setAuth(token: string | null = null): Promise<void> {
436
+ this._authPromise = this._performAuth(token)
437
+ try {
438
+ await this._authPromise
439
+ } finally {
440
+ this._authPromise = null
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Returns true if the current access token was explicitly set via setAuth(token),
446
+ * false if it was obtained via the accessToken callback.
447
+ * @internal
448
+ */
449
+ _isManualToken(): boolean {
450
+ return this._manuallySetToken
451
+ }
452
+
453
+ /**
454
+ * Sends a heartbeat message if the socket is connected.
455
+ */
456
+ 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')
505
+ }
506
+
507
+ /**
508
+ * Sets a callback that receives lifecycle events for internal heartbeat messages.
509
+ * Useful for instrumenting connection health (e.g. sent/ok/timeout/disconnected).
510
+ */
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
+ }
522
+ }
523
+
524
+ /**
525
+ * Use either custom fetch, if provided, or default fetch to make HTTP requests
526
+ *
527
+ * @internal
528
+ */
529
+ _resolveFetch = (customFetch?: Fetch): Fetch => {
530
+ if (customFetch) {
531
+ return (...args) => customFetch(...args)
532
+ }
533
+ return (...args) => fetch(...args)
534
+ }
535
+
536
+ /**
537
+ * Return the next message ref, accounting for overflows
538
+ *
539
+ * @internal
540
+ */
541
+ _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()
550
+ }
551
+
552
+ /**
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.
569
+ *
570
+ * @param channel An open subscription.
571
+ *
572
+ * @internal
573
+ */
574
+ _remove(channel: RealtimeChannel) {
575
+ this.channels = this.channels.filter((c) => c.topic !== channel.topic)
576
+ }
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
+
855
+ /**
856
+ * Perform the actual auth operation
857
+ * @internal
858
+ */
859
+ private async _performAuth(token: string | null = null): Promise<void> {
860
+ let tokenToSend: string | null
861
+ let isManualToken = false
862
+
863
+ if (token) {
864
+ tokenToSend = token
865
+ // Track if this is a manually-provided token
866
+ isManualToken = true
867
+ } else if (this.accessToken) {
868
+ // Call the accessToken callback to get fresh token
869
+ try {
870
+ tokenToSend = await this.accessToken()
871
+ } catch (e) {
872
+ this.log('error', 'Error fetching access token from callback', e)
873
+ // Fall back to cached value if callback fails
874
+ tokenToSend = this.accessTokenValue
875
+ }
876
+ } else {
877
+ tokenToSend = this.accessTokenValue
878
+ }
879
+
880
+ // Track whether this token was manually set or fetched via callback
881
+ if (isManualToken) {
882
+ this._manuallySetToken = true
883
+ } else if (this.accessToken) {
884
+ // If we used the callback, clear the manual flag
885
+ this._manuallySetToken = false
886
+ }
887
+
888
+ if (this.accessTokenValue != tokenToSend) {
889
+ this.accessTokenValue = tokenToSend
890
+ this.channels.forEach((channel) => {
891
+ const payload = {
892
+ access_token: tokenToSend,
893
+ version: DEFAULT_VERSION,
894
+ }
895
+
896
+ tokenToSend && channel.updateJoinPayload(payload)
897
+
898
+ if (channel.joinedOnce && channel._isJoined()) {
899
+ channel._push(CHANNEL_EVENTS.access_token, {
900
+ access_token: tokenToSend,
901
+ })
902
+ }
903
+ })
904
+ }
905
+ }
906
+
907
+ /**
908
+ * Wait for any in-flight auth operations to complete
909
+ * @internal
910
+ */
911
+ private async _waitForAuthIfNeeded(): Promise<void> {
912
+ if (this._authPromise) {
913
+ await this._authPromise
914
+ }
915
+ }
916
+
917
+ /**
918
+ * Safely call setAuth with standardized error handling
919
+ * @internal
920
+ */
921
+ private _setAuthSafely(context = 'general'): void {
922
+ // Only refresh auth if using callback-based tokens
923
+ if (!this._isManualToken()) {
924
+ this.setAuth().catch((e) => {
925
+ this.log('error', `Error setting auth in ${context}`, e)
926
+ })
927
+ }
928
+ }
929
+
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
+ }
942
+ })
943
+ } catch (e) {
944
+ this.log('error', `error triggering ${event} callbacks`, e)
945
+ }
946
+ }
947
+
948
+ /**
949
+ * Setup reconnection timer with proper configuration
950
+ * @internal
951
+ */
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)
961
+ }
962
+
963
+ /**
964
+ * Initialize client options with defaults
965
+ * @internal
966
+ */
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
973
+ this.worker = options?.worker ?? false
974
+ this.accessToken = options?.accessToken ?? null
975
+ this.heartbeatCallback = options?.heartbeatCallback ?? noop
976
+ this.vsn = options?.vsn ?? DEFAULT_VSN
977
+
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 =
988
+ options?.reconnectAfterMs ??
989
+ ((tries: number) => {
990
+ return RECONNECT_INTERVALS[tries - 1] || DEFAULT_RECONNECT_FALLBACK
991
+ })
992
+
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
+ })
1000
+
1001
+ this.decode =
1002
+ options?.decode ??
1003
+ ((payload: string, callback: Function) => {
1004
+ return callback(JSON.parse(payload))
1005
+ })
1006
+ break
1007
+ 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)
1010
+ break
1011
+ default:
1012
+ throw new Error(`Unsupported serializer version: ${this.vsn}`)
1013
+ }
1014
+
1015
+ // Handle worker setup
1016
+ if (this.worker) {
1017
+ if (typeof window !== 'undefined' && !window.Worker) {
1018
+ throw new Error('Web Worker is not supported')
1019
+ }
1020
+ this.workerUrl = options?.workerUrl
1021
+ }
1022
+ }
1023
+ }