UNPKG

@supabase/realtime-js

Version:

Listen to realtime updates to your PostgreSQL database

908 lines (814 loc) 25.5 kB
import WebSocketFactory, { WebSocketLike } from './lib/websocket-factory' import { CHANNEL_EVENTS, CONNECTION_STATE, DEFAULT_VERSION, DEFAULT_TIMEOUT, SOCKET_STATES, TRANSPORTS, VSN, WS_CLOSE_NORMAL, } from './lib/constants' import Serializer from './lib/serializer' import Timer from './lib/timer' import { httpEndpointURL } from './lib/transformers' import RealtimeChannel from './RealtimeChannel' import type { RealtimeChannelOptions } from './RealtimeChannel' type Fetch = typeof fetch export type Channel = { name: string inserted_at: string updated_at: string id: number } export type LogLevel = 'info' | 'warn' | 'error' export type RealtimeMessage = { topic: string event: string payload: any ref: string join_ref?: string } export type RealtimeRemoveChannelResponse = 'ok' | 'timed out' | 'error' export type HeartbeatStatus = | 'sent' | 'ok' | 'error' | 'timeout' | 'disconnected' const noop = () => {} type RealtimeClientState = | 'connecting' | 'connected' | 'disconnecting' | 'disconnected' // Connection-related constants const CONNECTION_TIMEOUTS = { HEARTBEAT_INTERVAL: 25000, RECONNECT_DELAY: 10, HEARTBEAT_TIMEOUT_FALLBACK: 100, } as const const RECONNECT_INTERVALS = [1000, 2000, 5000, 10000] as const const DEFAULT_RECONNECT_FALLBACK = 10000 export interface WebSocketLikeConstructor { new ( address: string | URL, subprotocols?: string | string[] | undefined ): WebSocketLike // Allow additional properties that may exist on WebSocket constructors [key: string]: any } export interface WebSocketLikeError { error: any message: string type: string } export type RealtimeClientOptions = { transport?: WebSocketLikeConstructor timeout?: number heartbeatIntervalMs?: number heartbeatCallback?: (status: HeartbeatStatus) => void logger?: Function encode?: Function decode?: Function reconnectAfterMs?: Function headers?: { [key: string]: string } params?: { [key: string]: any } //Deprecated: Use it in favour of correct casing `logLevel` log_level?: LogLevel logLevel?: LogLevel fetch?: Fetch worker?: boolean workerUrl?: string accessToken?: () => Promise<string | null> } const WORKER_SCRIPT = ` addEventListener("message", (e) => { if (e.data.event === "start") { setInterval(() => postMessage({ event: "keepAlive" }), e.data.interval); } });` export default class RealtimeClient { accessTokenValue: string | null = null apiKey: string | null = null channels: RealtimeChannel[] = new Array() endPoint: string = '' httpEndpoint: string = '' /** @deprecated headers cannot be set on websocket connections */ headers?: { [key: string]: string } = {} params?: { [key: string]: string } = {} timeout: number = DEFAULT_TIMEOUT transport: WebSocketLikeConstructor | null = null heartbeatIntervalMs: number = CONNECTION_TIMEOUTS.HEARTBEAT_INTERVAL heartbeatTimer: ReturnType<typeof setInterval> | undefined = undefined pendingHeartbeatRef: string | null = null heartbeatCallback: (status: HeartbeatStatus) => void = noop ref: number = 0 reconnectTimer: Timer | null = null logger: Function = noop logLevel?: LogLevel encode!: Function decode!: Function reconnectAfterMs!: Function conn: WebSocketLike | null = null sendBuffer: Function[] = [] serializer: Serializer = new Serializer() stateChangeCallbacks: { open: Function[] close: Function[] error: Function[] message: Function[] } = { open: [], close: [], error: [], message: [], } fetch: Fetch accessToken: (() => Promise<string | null>) | null = null worker?: boolean workerUrl?: string workerRef?: Worker private _connectionState: RealtimeClientState = 'disconnected' private _wasManualDisconnect: boolean = false private _authPromise: Promise<void> | null = null /** * Initializes the Socket. * * @param endPoint The string WebSocket endpoint, ie, "ws://example.com/socket", "wss://example.com", "/socket" (inherited host & protocol) * @param httpEndpoint The string HTTP endpoint, ie, "https://example.com", "/" (inherited host & protocol) * @param options.transport The Websocket Transport, for example WebSocket. This can be a custom implementation * @param options.timeout The default timeout in milliseconds to trigger push timeouts. * @param options.params The optional params to pass when connecting. * @param options.headers Deprecated: headers cannot be set on websocket connections and this option will be removed in the future. * @param options.heartbeatIntervalMs The millisec interval to send a heartbeat message. * @param options.heartbeatCallback The optional function to handle heartbeat status. * @param options.logger The optional function for specialized logging, ie: logger: (kind, msg, data) => { console.log(`${kind}: ${msg}`, data) } * @param options.logLevel Sets the log level for Realtime * @param options.encode The function to encode outgoing messages. Defaults to JSON: (payload, callback) => callback(JSON.stringify(payload)) * @param options.decode The function to decode incoming messages. Defaults to Serializer's decode. * @param options.reconnectAfterMs he optional function that returns the millsec reconnect interval. Defaults to stepped backoff off. * @param options.worker Use Web Worker to set a side flow. Defaults to false. * @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. */ constructor(endPoint: string, options?: RealtimeClientOptions) { // Validate required parameters if (!options?.params?.apikey) { throw new Error('API key is required to connect to Realtime') } this.apiKey = options.params.apikey // Initialize endpoint URLs this.endPoint = `${endPoint}/${TRANSPORTS.websocket}` this.httpEndpoint = httpEndpointURL(endPoint) this._initializeOptions(options) this._setupReconnectionTimer() this.fetch = this._resolveFetch(options?.fetch) } /** * Connects the socket, unless already connected. */ connect(): void { // Skip if already connecting, disconnecting, or connected if ( this.isConnecting() || this.isDisconnecting() || (this.conn !== null && this.isConnected()) ) { return } this._setConnectionState('connecting') this._setAuthSafely('connect') // Establish WebSocket connection if (this.transport) { // Use custom transport if provided this.conn = new this.transport(this.endpointURL()) as WebSocketLike } else { // Try to use native WebSocket try { this.conn = WebSocketFactory.createWebSocket(this.endpointURL()) } catch (error) { this._setConnectionState('disconnected') const errorMessage = (error as Error).message // Provide helpful error message based on environment if (errorMessage.includes('Node.js')) { throw new Error( `${errorMessage}\n\n` + 'To use Realtime in Node.js, you need to provide a WebSocket implementation:\n\n' + 'Option 1: Use Node.js 22+ which has native WebSocket support\n' + 'Option 2: Install and provide the "ws" package:\n\n' + ' npm install ws\n\n' + ' import ws from "ws"\n' + ' const client = new RealtimeClient(url, {\n' + ' ...options,\n' + ' transport: ws\n' + ' })' ) } throw new Error(`WebSocket not available: ${errorMessage}`) } } this._setupConnectionHandlers() } /** * Returns the URL of the websocket. * @returns string The URL of the websocket. */ endpointURL(): string { return this._appendParams( this.endPoint, Object.assign({}, this.params, { vsn: VSN }) ) } /** * Disconnects the socket. * * @param code A numeric status code to send on disconnect. * @param reason A custom reason for the disconnect. */ disconnect(code?: number, reason?: string): void { if (this.isDisconnecting()) { return } this._setConnectionState('disconnecting', true) if (this.conn) { // Setup fallback timer to prevent hanging in disconnecting state const fallbackTimer = setTimeout(() => { this._setConnectionState('disconnected') }, 100) this.conn.onclose = () => { clearTimeout(fallbackTimer) this._setConnectionState('disconnected') } // Close the WebSocket connection if (code) { this.conn.close(code, reason ?? '') } else { this.conn.close() } this._teardownConnection() } else { this._setConnectionState('disconnected') } } /** * Returns all created channels */ getChannels(): RealtimeChannel[] { return this.channels } /** * Unsubscribes and removes a single channel * @param channel A RealtimeChannel instance */ async removeChannel( channel: RealtimeChannel ): Promise<RealtimeRemoveChannelResponse> { const status = await channel.unsubscribe() if (this.channels.length === 0) { this.disconnect() } return status } /** * Unsubscribes and removes all channels */ async removeAllChannels(): Promise<RealtimeRemoveChannelResponse[]> { const values_1 = await Promise.all( this.channels.map((channel) => channel.unsubscribe()) ) this.channels = [] this.disconnect() return values_1 } /** * Logs the message. * * For customized logging, `this.logger` can be overridden. */ log(kind: string, msg: string, data?: any) { this.logger(kind, msg, data) } /** * Returns the current state of the socket. */ connectionState(): CONNECTION_STATE { switch (this.conn && this.conn.readyState) { case SOCKET_STATES.connecting: return CONNECTION_STATE.Connecting case SOCKET_STATES.open: return CONNECTION_STATE.Open case SOCKET_STATES.closing: return CONNECTION_STATE.Closing default: return CONNECTION_STATE.Closed } } /** * Returns `true` is the connection is open. */ isConnected(): boolean { return this.connectionState() === CONNECTION_STATE.Open } /** * Returns `true` if the connection is currently connecting. */ isConnecting(): boolean { return this._connectionState === 'connecting' } /** * Returns `true` if the connection is currently disconnecting. */ isDisconnecting(): boolean { return this._connectionState === 'disconnecting' } channel( topic: string, params: RealtimeChannelOptions = { config: {} } ): RealtimeChannel { const realtimeTopic = `realtime:${topic}` const exists = this.getChannels().find( (c: RealtimeChannel) => c.topic === realtimeTopic ) if (!exists) { const chan = new RealtimeChannel(`realtime:${topic}`, params, this) this.channels.push(chan) return chan } else { return exists } } /** * Push out a message if the socket is connected. * * If the socket is not connected, the message gets enqueued within a local buffer, and sent out when a connection is next established. */ push(data: RealtimeMessage): void { const { topic, event, payload, ref } = data const callback = () => { this.encode(data, (result: any) => { this.conn?.send(result) }) } this.log('push', `${topic} ${event} (${ref})`, payload) if (this.isConnected()) { callback() } else { this.sendBuffer.push(callback) } } /** * Sets the JWT access token used for channel subscription authorization and Realtime RLS. * * If param is null it will use the `accessToken` callback function or the token set on the client. * * On callback used, it will set the value of the token internal to the client. * * @param token A JWT string to override the token set on the client. */ async setAuth(token: string | null = null): Promise<void> { this._authPromise = this._performAuth(token) try { await this._authPromise } finally { this._authPromise = null } } /** * Sends a heartbeat message if the socket is connected. */ async sendHeartbeat() { if (!this.isConnected()) { try { this.heartbeatCallback('disconnected') } catch (e) { this.log('error', 'error in heartbeat callback', e) } return } // Handle heartbeat timeout and force reconnection if needed if (this.pendingHeartbeatRef) { this.pendingHeartbeatRef = null this.log( 'transport', 'heartbeat timeout. Attempting to re-establish connection' ) try { this.heartbeatCallback('timeout') } catch (e) { this.log('error', 'error in heartbeat callback', e) } // Force reconnection after heartbeat timeout this._wasManualDisconnect = false this.conn?.close(WS_CLOSE_NORMAL, 'heartbeat timeout') setTimeout(() => { if (!this.isConnected()) { this.reconnectTimer?.scheduleTimeout() } }, CONNECTION_TIMEOUTS.HEARTBEAT_TIMEOUT_FALLBACK) return } // Send heartbeat message to server this.pendingHeartbeatRef = this._makeRef() this.push({ topic: 'phoenix', event: 'heartbeat', payload: {}, ref: this.pendingHeartbeatRef, }) try { this.heartbeatCallback('sent') } catch (e) { this.log('error', 'error in heartbeat callback', e) } this._setAuthSafely('heartbeat') } onHeartbeat(callback: (status: HeartbeatStatus) => void): void { this.heartbeatCallback = callback } /** * Flushes send buffer */ flushSendBuffer() { if (this.isConnected() && this.sendBuffer.length > 0) { this.sendBuffer.forEach((callback) => callback()) this.sendBuffer = [] } } /** * Use either custom fetch, if provided, or default fetch to make HTTP requests * * @internal */ _resolveFetch = (customFetch?: Fetch): Fetch => { let _fetch: Fetch if (customFetch) { _fetch = customFetch } else if (typeof fetch === 'undefined') { // Node.js environment without native fetch _fetch = (...args) => import('@supabase/node-fetch' as any) .then(({ default: fetch }) => fetch(...args)) .catch((error) => { throw new Error( `Failed to load @supabase/node-fetch: ${error.message}. ` + `This is required for HTTP requests in Node.js environments without native fetch.` ) }) } else { _fetch = fetch } return (...args) => _fetch(...args) } /** * Return the next message ref, accounting for overflows * * @internal */ _makeRef(): string { let newRef = this.ref + 1 if (newRef === this.ref) { this.ref = 0 } else { this.ref = newRef } return this.ref.toString() } /** * Unsubscribe from channels with the specified topic. * * @internal */ _leaveOpenTopic(topic: string): void { let dupChannel = this.channels.find( (c) => c.topic === topic && (c._isJoined() || c._isJoining()) ) if (dupChannel) { this.log('transport', `leaving duplicate topic "${topic}"`) dupChannel.unsubscribe() } } /** * Removes a subscription from the socket. * * @param channel An open subscription. * * @internal */ _remove(channel: RealtimeChannel) { this.channels = this.channels.filter((c) => c.topic !== channel.topic) } /** @internal */ private _onConnMessage(rawMessage: { data: any }) { this.decode(rawMessage.data, (msg: RealtimeMessage) => { // Handle heartbeat responses if (msg.topic === 'phoenix' && msg.event === 'phx_reply') { try { this.heartbeatCallback(msg.payload.status === 'ok' ? 'ok' : 'error') } catch (e) { this.log('error', 'error in heartbeat callback', e) } } // Handle pending heartbeat reference cleanup if (msg.ref && msg.ref === this.pendingHeartbeatRef) { this.pendingHeartbeatRef = null } // Log incoming message const { topic, event, payload, ref } = msg const refString = ref ? `(${ref})` : '' const status = payload.status || '' this.log( 'receive', `${status} ${topic} ${event} ${refString}`.trim(), payload ) // Route message to appropriate channels this.channels .filter((channel: RealtimeChannel) => channel._isMember(topic)) .forEach((channel: RealtimeChannel) => channel._trigger(event, payload, ref) ) this._triggerStateCallbacks('message', msg) }) } /** * Clear specific timer * @internal */ private _clearTimer(timer: 'heartbeat' | 'reconnect'): void { if (timer === 'heartbeat' && this.heartbeatTimer) { clearInterval(this.heartbeatTimer) this.heartbeatTimer = undefined } else if (timer === 'reconnect') { this.reconnectTimer?.reset() } } /** * Clear all timers * @internal */ private _clearAllTimers(): void { this._clearTimer('heartbeat') this._clearTimer('reconnect') } /** * Setup connection handlers for WebSocket events * @internal */ private _setupConnectionHandlers(): void { if (!this.conn) return // Set binary type if supported (browsers and most WebSocket implementations) if ('binaryType' in this.conn) { ;(this.conn as any).binaryType = 'arraybuffer' } this.conn.onopen = () => this._onConnOpen() this.conn.onerror = (error: Event) => this._onConnError(error) this.conn.onmessage = (event: any) => this._onConnMessage(event) this.conn.onclose = (event: any) => this._onConnClose(event) } /** * Teardown connection and cleanup resources * @internal */ private _teardownConnection(): void { if (this.conn) { this.conn.onopen = null this.conn.onerror = null this.conn.onmessage = null this.conn.onclose = null this.conn = null } this._clearAllTimers() this.channels.forEach((channel) => channel.teardown()) } /** @internal */ private _onConnOpen() { this._setConnectionState('connected') this.log('transport', `connected to ${this.endpointURL()}`) this.flushSendBuffer() this._clearTimer('reconnect') if (!this.worker) { this._startHeartbeat() } else { if (!this.workerRef) { this._startWorkerHeartbeat() } } this._triggerStateCallbacks('open') } /** @internal */ private _startHeartbeat() { this.heartbeatTimer && clearInterval(this.heartbeatTimer) this.heartbeatTimer = setInterval( () => this.sendHeartbeat(), this.heartbeatIntervalMs ) } /** @internal */ private _startWorkerHeartbeat() { if (this.workerUrl) { this.log('worker', `starting worker for from ${this.workerUrl}`) } else { this.log('worker', `starting default worker`) } const objectUrl = this._workerObjectUrl(this.workerUrl!) this.workerRef = new Worker(objectUrl) this.workerRef.onerror = (error) => { this.log('worker', 'worker error', (error as ErrorEvent).message) this.workerRef!.terminate() } this.workerRef.onmessage = (event) => { if (event.data.event === 'keepAlive') { this.sendHeartbeat() } } this.workerRef.postMessage({ event: 'start', interval: this.heartbeatIntervalMs, }) } /** @internal */ private _onConnClose(event: any) { this._setConnectionState('disconnected') this.log('transport', 'close', event) this._triggerChanError() this._clearTimer('heartbeat') // Only schedule reconnection if it wasn't a manual disconnect if (!this._wasManualDisconnect) { this.reconnectTimer?.scheduleTimeout() } this._triggerStateCallbacks('close', event) } /** @internal */ private _onConnError(error: Event) { this._setConnectionState('disconnected') this.log('transport', `${error}`) this._triggerChanError() this._triggerStateCallbacks('error', error) } /** @internal */ private _triggerChanError() { this.channels.forEach((channel: RealtimeChannel) => channel._trigger(CHANNEL_EVENTS.error) ) } /** @internal */ private _appendParams( url: string, params: { [key: string]: string } ): string { if (Object.keys(params).length === 0) { return url } const prefix = url.match(/\?/) ? '&' : '?' const query = new URLSearchParams(params) return `${url}${prefix}${query}` } private _workerObjectUrl(url: string | undefined): string { let result_url: string if (url) { result_url = url } else { const blob = new Blob([WORKER_SCRIPT], { type: 'application/javascript' }) result_url = URL.createObjectURL(blob) } return result_url } /** * Set connection state with proper state management * @internal */ private _setConnectionState( state: RealtimeClientState, manual = false ): void { this._connectionState = state if (state === 'connecting') { this._wasManualDisconnect = false } else if (state === 'disconnecting') { this._wasManualDisconnect = manual } } /** * Perform the actual auth operation * @internal */ private async _performAuth(token: string | null = null): Promise<void> { let tokenToSend: string | null if (token) { tokenToSend = token } else if (this.accessToken) { // Always call the accessToken callback to get fresh token tokenToSend = await this.accessToken() } else { tokenToSend = this.accessTokenValue } if (this.accessTokenValue != tokenToSend) { this.accessTokenValue = tokenToSend this.channels.forEach((channel) => { const payload = { access_token: tokenToSend, version: DEFAULT_VERSION, } tokenToSend && channel.updateJoinPayload(payload) if (channel.joinedOnce && channel._isJoined()) { channel._push(CHANNEL_EVENTS.access_token, { access_token: tokenToSend, }) } }) } } /** * Wait for any in-flight auth operations to complete * @internal */ private async _waitForAuthIfNeeded(): Promise<void> { if (this._authPromise) { await this._authPromise } } /** * Safely call setAuth with standardized error handling * @internal */ private _setAuthSafely(context = 'general'): void { this.setAuth().catch((e) => { this.log('error', `error setting auth in ${context}`, e) }) } /** * Trigger state change callbacks with proper error handling * @internal */ private _triggerStateCallbacks( event: keyof typeof this.stateChangeCallbacks, data?: any ): void { try { this.stateChangeCallbacks[event].forEach((callback) => { try { callback(data) } catch (e) { this.log('error', `error in ${event} callback`, e) } }) } catch (e) { this.log('error', `error triggering ${event} callbacks`, e) } } /** * Setup reconnection timer with proper configuration * @internal */ private _setupReconnectionTimer(): void { this.reconnectTimer = new Timer(async () => { setTimeout(async () => { await this._waitForAuthIfNeeded() if (!this.isConnected()) { this.connect() } }, CONNECTION_TIMEOUTS.RECONNECT_DELAY) }, this.reconnectAfterMs) } /** * Initialize client options with defaults * @internal */ private _initializeOptions(options?: RealtimeClientOptions): void { // Set defaults this.transport = options?.transport ?? null this.timeout = options?.timeout ?? DEFAULT_TIMEOUT this.heartbeatIntervalMs = options?.heartbeatIntervalMs ?? CONNECTION_TIMEOUTS.HEARTBEAT_INTERVAL this.worker = options?.worker ?? false this.accessToken = options?.accessToken ?? null this.heartbeatCallback = options?.heartbeatCallback ?? noop // Handle special cases if (options?.params) this.params = options.params if (options?.logger) this.logger = options.logger if (options?.logLevel || options?.log_level) { this.logLevel = options.logLevel || options.log_level this.params = { ...this.params, log_level: this.logLevel as string } } // Set up functions with defaults this.reconnectAfterMs = options?.reconnectAfterMs ?? ((tries: number) => { return RECONNECT_INTERVALS[tries - 1] || DEFAULT_RECONNECT_FALLBACK }) this.encode = options?.encode ?? ((payload: JSON, callback: Function) => { return callback(JSON.stringify(payload)) }) this.decode = options?.decode ?? this.serializer.decode.bind(this.serializer) // Handle worker setup if (this.worker) { if (typeof window !== 'undefined' && !window.Worker) { throw new Error('Web Worker is not supported') } this.workerUrl = options?.workerUrl } } }