UNPKG

@supabase/realtime-js

Version:
723 lines 27.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = require("tslib"); const websocket_factory_1 = tslib_1.__importDefault(require("./lib/websocket-factory")); const constants_1 = require("./lib/constants"); const serializer_1 = tslib_1.__importDefault(require("./lib/serializer")); const timer_1 = tslib_1.__importDefault(require("./lib/timer")); const transformers_1 = require("./lib/transformers"); const RealtimeChannel_1 = tslib_1.__importDefault(require("./RealtimeChannel")); const noop = () => { }; // Connection-related constants const CONNECTION_TIMEOUTS = { HEARTBEAT_INTERVAL: 25000, RECONNECT_DELAY: 10, HEARTBEAT_TIMEOUT_FALLBACK: 100, }; const RECONNECT_INTERVALS = [1000, 2000, 5000, 10000]; const DEFAULT_RECONNECT_FALLBACK = 10000; const WORKER_SCRIPT = ` addEventListener("message", (e) => { if (e.data.event === "start") { setInterval(() => postMessage({ event: "keepAlive" }), e.data.interval); } });`; class RealtimeClient { /** * 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, options) { var _a; this.accessTokenValue = null; this.apiKey = null; this.channels = new Array(); this.endPoint = ''; this.httpEndpoint = ''; /** @deprecated headers cannot be set on websocket connections */ this.headers = {}; this.params = {}; this.timeout = constants_1.DEFAULT_TIMEOUT; this.transport = null; this.heartbeatIntervalMs = CONNECTION_TIMEOUTS.HEARTBEAT_INTERVAL; this.heartbeatTimer = undefined; this.pendingHeartbeatRef = null; this.heartbeatCallback = noop; this.ref = 0; this.reconnectTimer = null; this.logger = noop; this.conn = null; this.sendBuffer = []; this.serializer = new serializer_1.default(); this.stateChangeCallbacks = { open: [], close: [], error: [], message: [], }; this.accessToken = null; this._connectionState = 'disconnected'; this._wasManualDisconnect = false; this._authPromise = null; /** * Use either custom fetch, if provided, or default fetch to make HTTP requests * * @internal */ this._resolveFetch = (customFetch) => { let _fetch; if (customFetch) { _fetch = customFetch; } else if (typeof fetch === 'undefined') { // Node.js environment without native fetch _fetch = (...args) => Promise.resolve(`${'@supabase/node-fetch'}`).then(s => tslib_1.__importStar(require(s))).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); }; // Validate required parameters if (!((_a = options === null || options === void 0 ? void 0 : options.params) === null || _a === void 0 ? void 0 : _a.apikey)) { throw new Error('API key is required to connect to Realtime'); } this.apiKey = options.params.apikey; // Initialize endpoint URLs this.endPoint = `${endPoint}/${constants_1.TRANSPORTS.websocket}`; this.httpEndpoint = (0, transformers_1.httpEndpointURL)(endPoint); this._initializeOptions(options); this._setupReconnectionTimer(); this.fetch = this._resolveFetch(options === null || options === void 0 ? void 0 : options.fetch); } /** * Connects the socket, unless already connected. */ connect() { // 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()); } else { // Try to use native WebSocket try { this.conn = websocket_factory_1.default.createWebSocket(this.endpointURL()); } catch (error) { this._setConnectionState('disconnected'); const errorMessage = 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() { return this._appendParams(this.endPoint, Object.assign({}, this.params, { vsn: constants_1.VSN })); } /** * Disconnects the socket. * * @param code A numeric status code to send on disconnect. * @param reason A custom reason for the disconnect. */ disconnect(code, reason) { 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 !== null && reason !== void 0 ? reason : ''); } else { this.conn.close(); } this._teardownConnection(); } else { this._setConnectionState('disconnected'); } } /** * Returns all created channels */ getChannels() { return this.channels; } /** * Unsubscribes and removes a single channel * @param channel A RealtimeChannel instance */ async removeChannel(channel) { const status = await channel.unsubscribe(); if (this.channels.length === 0) { this.disconnect(); } return status; } /** * Unsubscribes and removes all channels */ async removeAllChannels() { 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, msg, data) { this.logger(kind, msg, data); } /** * Returns the current state of the socket. */ connectionState() { switch (this.conn && this.conn.readyState) { case constants_1.SOCKET_STATES.connecting: return constants_1.CONNECTION_STATE.Connecting; case constants_1.SOCKET_STATES.open: return constants_1.CONNECTION_STATE.Open; case constants_1.SOCKET_STATES.closing: return constants_1.CONNECTION_STATE.Closing; default: return constants_1.CONNECTION_STATE.Closed; } } /** * Returns `true` is the connection is open. */ isConnected() { return this.connectionState() === constants_1.CONNECTION_STATE.Open; } /** * Returns `true` if the connection is currently connecting. */ isConnecting() { return this._connectionState === 'connecting'; } /** * Returns `true` if the connection is currently disconnecting. */ isDisconnecting() { return this._connectionState === 'disconnecting'; } channel(topic, params = { config: {} }) { const realtimeTopic = `realtime:${topic}`; const exists = this.getChannels().find((c) => c.topic === realtimeTopic); if (!exists) { const chan = new RealtimeChannel_1.default(`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) { const { topic, event, payload, ref } = data; const callback = () => { this.encode(data, (result) => { var _a; (_a = this.conn) === null || _a === void 0 ? void 0 : _a.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 = null) { this._authPromise = this._performAuth(token); try { await this._authPromise; } finally { this._authPromise = null; } } /** * Sends a heartbeat message if the socket is connected. */ async sendHeartbeat() { var _a; 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; (_a = this.conn) === null || _a === void 0 ? void 0 : _a.close(constants_1.WS_CLOSE_NORMAL, 'heartbeat timeout'); setTimeout(() => { var _a; if (!this.isConnected()) { (_a = this.reconnectTimer) === null || _a === void 0 ? void 0 : _a.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) { this.heartbeatCallback = callback; } /** * Flushes send buffer */ flushSendBuffer() { if (this.isConnected() && this.sendBuffer.length > 0) { this.sendBuffer.forEach((callback) => callback()); this.sendBuffer = []; } } /** * Return the next message ref, accounting for overflows * * @internal */ _makeRef() { 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) { 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) { this.channels = this.channels.filter((c) => c.topic !== channel.topic); } /** @internal */ _onConnMessage(rawMessage) { this.decode(rawMessage.data, (msg) => { // 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) => channel._isMember(topic)) .forEach((channel) => channel._trigger(event, payload, ref)); this._triggerStateCallbacks('message', msg); }); } /** * Clear specific timer * @internal */ _clearTimer(timer) { var _a; if (timer === 'heartbeat' && this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; } else if (timer === 'reconnect') { (_a = this.reconnectTimer) === null || _a === void 0 ? void 0 : _a.reset(); } } /** * Clear all timers * @internal */ _clearAllTimers() { this._clearTimer('heartbeat'); this._clearTimer('reconnect'); } /** * Setup connection handlers for WebSocket events * @internal */ _setupConnectionHandlers() { if (!this.conn) return; // Set binary type if supported (browsers and most WebSocket implementations) if ('binaryType' in this.conn) { ; this.conn.binaryType = 'arraybuffer'; } this.conn.onopen = () => this._onConnOpen(); this.conn.onerror = (error) => this._onConnError(error); this.conn.onmessage = (event) => this._onConnMessage(event); this.conn.onclose = (event) => this._onConnClose(event); } /** * Teardown connection and cleanup resources * @internal */ _teardownConnection() { 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 */ _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 */ _startHeartbeat() { this.heartbeatTimer && clearInterval(this.heartbeatTimer); this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.heartbeatIntervalMs); } /** @internal */ _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.message); this.workerRef.terminate(); }; this.workerRef.onmessage = (event) => { if (event.data.event === 'keepAlive') { this.sendHeartbeat(); } }; this.workerRef.postMessage({ event: 'start', interval: this.heartbeatIntervalMs, }); } /** @internal */ _onConnClose(event) { var _a; 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) { (_a = this.reconnectTimer) === null || _a === void 0 ? void 0 : _a.scheduleTimeout(); } this._triggerStateCallbacks('close', event); } /** @internal */ _onConnError(error) { this._setConnectionState('disconnected'); this.log('transport', `${error}`); this._triggerChanError(); this._triggerStateCallbacks('error', error); } /** @internal */ _triggerChanError() { this.channels.forEach((channel) => channel._trigger(constants_1.CHANNEL_EVENTS.error)); } /** @internal */ _appendParams(url, params) { if (Object.keys(params).length === 0) { return url; } const prefix = url.match(/\?/) ? '&' : '?'; const query = new URLSearchParams(params); return `${url}${prefix}${query}`; } _workerObjectUrl(url) { let result_url; 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 */ _setConnectionState(state, manual = false) { this._connectionState = state; if (state === 'connecting') { this._wasManualDisconnect = false; } else if (state === 'disconnecting') { this._wasManualDisconnect = manual; } } /** * Perform the actual auth operation * @internal */ async _performAuth(token = null) { let tokenToSend; 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: constants_1.DEFAULT_VERSION, }; tokenToSend && channel.updateJoinPayload(payload); if (channel.joinedOnce && channel._isJoined()) { channel._push(constants_1.CHANNEL_EVENTS.access_token, { access_token: tokenToSend, }); } }); } } /** * Wait for any in-flight auth operations to complete * @internal */ async _waitForAuthIfNeeded() { if (this._authPromise) { await this._authPromise; } } /** * Safely call setAuth with standardized error handling * @internal */ _setAuthSafely(context = 'general') { this.setAuth().catch((e) => { this.log('error', `error setting auth in ${context}`, e); }); } /** * Trigger state change callbacks with proper error handling * @internal */ _triggerStateCallbacks(event, data) { 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 */ _setupReconnectionTimer() { this.reconnectTimer = new timer_1.default(async () => { setTimeout(async () => { await this._waitForAuthIfNeeded(); if (!this.isConnected()) { this.connect(); } }, CONNECTION_TIMEOUTS.RECONNECT_DELAY); }, this.reconnectAfterMs); } /** * Initialize client options with defaults * @internal */ _initializeOptions(options) { var _a, _b, _c, _d, _e, _f, _g, _h, _j; // Set defaults this.transport = (_a = options === null || options === void 0 ? void 0 : options.transport) !== null && _a !== void 0 ? _a : null; this.timeout = (_b = options === null || options === void 0 ? void 0 : options.timeout) !== null && _b !== void 0 ? _b : constants_1.DEFAULT_TIMEOUT; this.heartbeatIntervalMs = (_c = options === null || options === void 0 ? void 0 : options.heartbeatIntervalMs) !== null && _c !== void 0 ? _c : CONNECTION_TIMEOUTS.HEARTBEAT_INTERVAL; this.worker = (_d = options === null || options === void 0 ? void 0 : options.worker) !== null && _d !== void 0 ? _d : false; this.accessToken = (_e = options === null || options === void 0 ? void 0 : options.accessToken) !== null && _e !== void 0 ? _e : null; this.heartbeatCallback = (_f = options === null || options === void 0 ? void 0 : options.heartbeatCallback) !== null && _f !== void 0 ? _f : noop; // Handle special cases if (options === null || options === void 0 ? void 0 : options.params) this.params = options.params; if (options === null || options === void 0 ? void 0 : options.logger) this.logger = options.logger; if ((options === null || options === void 0 ? void 0 : options.logLevel) || (options === null || options === void 0 ? void 0 : options.log_level)) { this.logLevel = options.logLevel || options.log_level; this.params = Object.assign(Object.assign({}, this.params), { log_level: this.logLevel }); } // Set up functions with defaults this.reconnectAfterMs = (_g = options === null || options === void 0 ? void 0 : options.reconnectAfterMs) !== null && _g !== void 0 ? _g : ((tries) => { return RECONNECT_INTERVALS[tries - 1] || DEFAULT_RECONNECT_FALLBACK; }); this.encode = (_h = options === null || options === void 0 ? void 0 : options.encode) !== null && _h !== void 0 ? _h : ((payload, callback) => { return callback(JSON.stringify(payload)); }); this.decode = (_j = options === null || options === void 0 ? void 0 : options.decode) !== null && _j !== void 0 ? _j : 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 === null || options === void 0 ? void 0 : options.workerUrl; } } } exports.default = RealtimeClient; //# sourceMappingURL=RealtimeClient.js.map