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
JavaScript
"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