solobase-js
Version: 
A 100% drop-in replacement for the Supabase JavaScript client. Self-hosted Supabase alternative with complete API compatibility.
311 lines • 10.8 kB
JavaScript
export 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) => [10, 50, 100, 150, 200, 250, 500, 1000, 2000][tries - 1] ?? 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?.('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?.('TIMED_OUT', new Error('Join timeout'));
                }
            }, this.timeout);
            this.on('phx_reply', (payload) => {
                if (payload.ref === this.joinRef) {
                    clearTimeout(timeoutTimer);
                    if (payload.payload?.status === 'ok') {
                        this.state = 'joined';
                        callback?.('SUBSCRIBED');
                        this.startHeartbeat();
                    }
                    else {
                        this.state = 'closed';
                        callback?.('TIMED_OUT', new Error(payload.payload?.response?.reason || 'Join failed'));
                    }
                }
            });
        }).catch((err) => {
            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) {
        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' && payload?.response?.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));
    }
}
export class SolobaseRealtimeClient {
    constructor(endpointURL, options = {}) {
        this.channels = new Map();
        this.accessToken = null;
        this.timeout = 10000;
        this.heartbeatIntervalMs = 30000;
        this.reconnectAfterMs = (tries) => [10, 50, 100, 150, 200, 250, 500, 1000, 2000][tries - 1] ?? 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');
    }
}
//# sourceMappingURL=SolobaseRealtimeClient.js.map