UNPKG

solobase-js

Version:

A 100% drop-in replacement for the Supabase JavaScript client. Self-hosted Supabase alternative with complete API compatibility.

318 lines 11.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SolobaseRealtimeClient = exports.SolobaseRealtimeChannel = void 0; class SolobaseRealtimeChannel { constructor(topic, endpointURL, params = {}, timeout, heartbeatIntervalMs, logger, encode, decode, reconnectAfterMs) { this.socket = null; this.state = 'closed'; this.callbacks = {}; this.joinRef = null; this.ref = 0; this.heartbeatTimer = null; this.reconnectTimer = null; this.timeout = 10000; this.heartbeatIntervalMs = 30000; this.reconnectAfterMs = (tries) => { var _a; return (_a = [10, 50, 100, 150, 200, 250, 500, 1000, 2000][tries - 1]) !== null && _a !== void 0 ? _a : 5000; }; this.logger = (level, message, data) => { console.log(`[Realtime ${level}] ${message}`, data); }; this.encode = (payload, callback) => callback(JSON.stringify(payload)); this.decode = (payload, callback) => { try { callback(JSON.parse(payload)); } catch (e) { callback(payload); } }; this.accessToken = null; this.topic = topic; this.params = params; this.endpointURL = endpointURL; if (timeout !== undefined) this.timeout = timeout; if (heartbeatIntervalMs !== undefined) this.heartbeatIntervalMs = heartbeatIntervalMs; if (logger) this.logger = logger; if (encode) this.encode = encode; if (decode) this.decode = decode; if (reconnectAfterMs) this.reconnectAfterMs = reconnectAfterMs; } setAuth(token) { this.accessToken = token; } subscribe(callback) { if (this.state === 'joined') { callback === null || callback === void 0 ? void 0 : callback('SUBSCRIBED'); return this; } this.joinRef = this.makeRef(); this.state = 'joining'; this.connect().then(() => { const joinPayload = { topic: this.topic, event: 'phx_join', payload: this.params, ref: this.joinRef, join_ref: this.joinRef, }; this.push(joinPayload); const timeoutTimer = setTimeout(() => { if (this.state === 'joining') { this.state = 'closed'; callback === null || callback === void 0 ? void 0 : callback('TIMED_OUT', new Error('Join timeout')); } }, this.timeout); this.on('phx_reply', (payload) => { var _a, _b, _c; if (payload.ref === this.joinRef) { clearTimeout(timeoutTimer); if (((_a = payload.payload) === null || _a === void 0 ? void 0 : _a.status) === 'ok') { this.state = 'joined'; callback === null || callback === void 0 ? void 0 : callback('SUBSCRIBED'); this.startHeartbeat(); } else { this.state = 'closed'; callback === null || callback === void 0 ? void 0 : callback('TIMED_OUT', new Error(((_c = (_b = payload.payload) === null || _b === void 0 ? void 0 : _b.response) === null || _c === void 0 ? void 0 : _c.reason) || 'Join failed')); } } }); }).catch((err) => { callback === null || callback === void 0 ? void 0 : callback('TIMED_OUT', err); }); return this; } async unsubscribe() { return new Promise((resolve) => { if (this.state !== 'joined') { resolve('ok'); return; } this.state = 'leaving'; const ref = this.makeRef(); const leavePayload = { topic: this.topic, event: 'phx_leave', payload: {}, ref, join_ref: this.joinRef, }; this.push(leavePayload); const timeoutTimer = setTimeout(() => { resolve('timed_out'); }, this.timeout); this.on('phx_reply', (payload) => { if (payload.ref === ref) { clearTimeout(timeoutTimer); this.state = 'closed'; this.stopHeartbeat(); resolve('ok'); } }); }); } on(event, callback) { if (!this.callbacks[event]) { this.callbacks[event] = []; } this.callbacks[event].push(callback); return this; } send(event, payload = {}, ref, joinRef) { if (this.state !== 'joined') { return 'timed_out'; } const message = { topic: this.topic, event, payload, ref: ref || this.makeRef(), join_ref: joinRef || this.joinRef, }; this.push(message); return 'ok'; } async connect() { return new Promise((resolve, reject) => { try { const wsUrl = this.endpointURL .replace(/^http/, 'ws') .replace(/\/$/, '') + `/socket/websocket?token=${this.accessToken || ''}`; this.socket = new WebSocket(wsUrl); this.socket.onopen = () => { this.logger('info', `Connected to ${wsUrl}`); resolve(); }; this.socket.onclose = (event) => { this.logger('info', 'Connection closed', { code: event.code, reason: event.reason }); this.state = 'closed'; this.stopHeartbeat(); this.scheduleReconnect(); }; this.socket.onerror = (error) => { this.logger('error', 'WebSocket error', error); reject(error); }; this.socket.onmessage = (event) => { this.decode(event.data, (decoded) => { this.onMessage(decoded); }); }; } catch (error) { reject(error); } }); } onMessage(message) { var _a; const { topic, event, payload, ref, join_ref } = message; if (topic === this.topic) { this.trigger(event, { ...payload, ref, join_ref }); } // Handle heartbeat if (event === 'phx_reply' && ((_a = payload === null || payload === void 0 ? void 0 : payload.response) === null || _a === void 0 ? void 0 : _a.heartbeat)) { // Heartbeat acknowledged } } trigger(event, payload) { const callbacks = this.callbacks[event] || []; callbacks.forEach(callback => { try { callback(payload); } catch (error) { this.logger('error', `Error in ${event} callback`, error); } }); } push(message) { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { return; } this.encode(message, (encoded) => { this.socket.send(encoded); }); } makeRef() { return String(++this.ref); } startHeartbeat() { if (this.heartbeatTimer) return; this.heartbeatTimer = setInterval(() => { const heartbeatPayload = { topic: 'phoenix', event: 'heartbeat', payload: {}, ref: this.makeRef(), join_ref: null, }; this.push(heartbeatPayload); }, this.heartbeatIntervalMs); } stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } } scheduleReconnect() { if (this.reconnectTimer) return; // Simple reconnect logic - could be enhanced with exponential backoff this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; if (this.state === 'closed') { this.subscribe(); } }, this.reconnectAfterMs(1)); } } exports.SolobaseRealtimeChannel = SolobaseRealtimeChannel; class SolobaseRealtimeClient { constructor(endpointURL, options = {}) { this.channels = new Map(); this.accessToken = null; this.timeout = 10000; this.heartbeatIntervalMs = 30000; this.reconnectAfterMs = (tries) => { var _a; return (_a = [10, 50, 100, 150, 200, 250, 500, 1000, 2000][tries - 1]) !== null && _a !== void 0 ? _a : 5000; }; this.logger = (level, message, data) => { console.log(`[Realtime ${level}] ${message}`, data); }; this.encode = (payload, callback) => callback(JSON.stringify(payload)); this.decode = (payload, callback) => { try { callback(JSON.parse(payload)); } catch (e) { callback(payload); } }; this.endpointURL = endpointURL; this.params = options.params || {}; if (options.timeout !== undefined) this.timeout = options.timeout; if (options.heartbeatIntervalMs !== undefined) this.heartbeatIntervalMs = options.heartbeatIntervalMs; if (options.reconnectAfterMs) this.reconnectAfterMs = options.reconnectAfterMs; if (options.logger) this.logger = options.logger; if (options.encode) this.encode = options.encode; if (options.decode) this.decode = options.decode; } setAuth(token) { this.accessToken = token; // Update auth for existing channels this.channels.forEach(channel => { if (channel instanceof SolobaseRealtimeChannel) { channel.setAuth(token); } }); } channel(topic, chanParams = {}) { const channel = new SolobaseRealtimeChannel(topic, this.endpointURL, { ...this.params, ...chanParams }, this.timeout, this.heartbeatIntervalMs, this.logger, this.encode, this.decode, this.reconnectAfterMs); channel.setAuth(this.accessToken); this.channels.set(topic, channel); return channel; } getChannels() { return Array.from(this.channels.values()); } async removeChannel(channel) { const result = await channel.unsubscribe(); // Remove from channels map for (const [topic, ch] of this.channels.entries()) { if (ch === channel) { this.channels.delete(topic); break; } } return result; } async removeAllChannels() { const promises = Array.from(this.channels.values()).map(channel => this.removeChannel(channel)); return Promise.all(promises); } connect() { // Connection is handled per-channel in this implementation this.logger('info', 'Realtime client ready'); } disconnect() { this.removeAllChannels(); } isConnected() { return Array.from(this.channels.values()).some(channel => channel.state === 'joined'); } } exports.SolobaseRealtimeClient = SolobaseRealtimeClient; //# sourceMappingURL=SolobaseRealtimeClient.js.map