UNPKG

@gftdcojp/gftd-orm

Version:

Enterprise-grade real-time data platform with ksqlDB, inspired by Supabase architecture

352 lines 10.8 kB
/** * クライアントサイド用リアルタイム通信(ブラウザ環境) */ import { EventEmitter } from 'events'; import { isBrowser } from './utils/env'; /** * ブラウザ環境用WebSocketクライアント */ class BrowserWebSocket { constructor(url, protocols) { this.ws = null; this.url = url; this.protocols = protocols; } connect() { return new Promise((resolve, reject) => { try { if (typeof globalThis.WebSocket === 'undefined') { reject(new Error('WebSocket is not available in this environment')); return; } this.ws = new globalThis.WebSocket(this.url, this.protocols); this.ws.onopen = () => resolve(); this.ws.onerror = (error) => reject(error); } catch (error) { reject(error); } }); } send(data) { if (this.ws && this.ws.readyState === 1) { // WebSocket.OPEN = 1 this.ws.send(data); } else { throw new Error('WebSocket is not connected'); } } close() { if (this.ws) { this.ws.close(); } } get readyState() { return this.ws?.readyState || 3; // WebSocket.CLOSED = 3 } set onopen(handler) { if (this.ws) { this.ws.onopen = handler; } } set onmessage(handler) { if (this.ws) { this.ws.onmessage = handler; } } set onclose(handler) { if (this.ws) { this.ws.onclose = handler; } } set onerror(handler) { if (this.ws) { this.ws.onerror = handler; } } } /** * クライアントサイド用リアルタイムチャンネル */ export class RealtimeChannel extends EventEmitter { constructor(topic, config, realtime) { super(); this.topic = topic; this.config = config; this.realtime = realtime; this.ws = null; this.subscriptions = new Map(); this.isConnected = false; this.reconnectAttempts = 0; /** * プレゼンス機能(ユーザーのオンライン状態管理) */ this.presence = { track: (state) => { return this.send({ type: 'presence', event: 'track', payload: state, }); }, untrack: () => { return this.send({ type: 'presence', event: 'untrack', payload: {}, }); }, onChange: (callback) => { this.on('presence:change', callback); }, }; } /** * データベースの変更を監視 */ on(event, callback) { return super.on(event, callback); } /** * テーブルの変更を監視 */ onTable(table, event, callback, options) { const subscriptionId = `table:${table}:${event}`; this.subscriptions.set(subscriptionId, { event, schema: options?.schema || 'public', table, filter: options?.filter, }); this.on(subscriptionId, callback); if (this.isConnected) { this.sendSubscription(subscriptionId); } return this; } /** * ストリームイベントを監視 */ onStream(stream, callback, options) { const subscriptionId = `stream:${stream}`; this.subscriptions.set(subscriptionId, { event: 'STREAM', table: stream, filter: options?.filter, }); this.on(subscriptionId, callback); if (this.isConnected) { this.sendSubscription(subscriptionId); } return this; } /** * ブロードキャストイベントを監視 */ onBroadcast(event, callback) { const subscriptionId = `broadcast:${event}`; this.subscriptions.set(subscriptionId, { event: 'BROADCAST', }); this.on(subscriptionId, callback); if (this.isConnected) { this.sendSubscription(subscriptionId); } return this; } /** * メッセージをブロードキャスト */ async broadcast(event, payload) { return this.send({ type: 'broadcast', event, payload, }); } /** * チャンネルに接続 */ async connect() { if (!isBrowser()) { throw new Error('RealtimeChannel (client) can only be used in browser environment'); } return new Promise((resolve, reject) => { try { const wsUrl = this.buildWebSocketUrl(); this.ws = new BrowserWebSocket(wsUrl); this.ws.onopen = () => { this.isConnected = true; this.reconnectAttempts = 0; // 既存のサブスクリプションを送信 for (const [id] of this.subscriptions) { this.sendSubscription(id); } this.emit('connected'); resolve(); }; this.ws.onmessage = (event) => { this.handleMessage(event.data); }; this.ws.onclose = () => { this.isConnected = false; this.emit('disconnected'); if (this.config.autoReconnect !== false) { this.attemptReconnect(); } }; this.ws.onerror = (error) => { this.emit('error', new Error(`WebSocket error: ${error}`)); reject(error); }; this.ws.connect(); } catch (error) { reject(error); } }); } /** * チャンネルから切断 */ disconnect() { if (this.ws) { this.ws.close(); this.ws = null; } this.isConnected = false; } /** * サブスクリプションを解除 */ unsubscribe(subscriptionId) { if (subscriptionId) { this.subscriptions.delete(subscriptionId); this.removeAllListeners(subscriptionId); } else { this.subscriptions.clear(); this.removeAllListeners(); } } buildWebSocketUrl() { const url = new URL(this.config.url.replace('http', 'ws')); url.pathname = `/realtime/v1/websocket`; url.searchParams.set('topic', this.topic); if (this.config.apiKey) { url.searchParams.set('apikey', this.config.apiKey); } return url.toString(); } async send(message) { if (!this.ws || this.ws.readyState !== 1) { // WebSocket.OPEN = 1 throw new Error('WebSocket is not connected'); } this.ws.send(JSON.stringify({ topic: this.topic, ref: Date.now().toString(), ...message, })); } sendSubscription(subscriptionId) { const subscription = this.subscriptions.get(subscriptionId); if (!subscription) return; this.send({ type: 'subscribe', event: subscription.event, payload: { schema: subscription.schema, table: subscription.table, filter: subscription.filter, }, }).catch(error => { this.emit('error', error); }); } handleMessage(data) { try { const message = JSON.parse(data); // イベントタイプに基づいて適切なリスナーに配信 if (message.event === 'INSERT' || message.event === 'UPDATE' || message.event === 'DELETE') { const subscriptionId = `table:${message.payload.table}:${message.event}`; this.emit(subscriptionId, message.payload); } else if (message.event === 'STREAM') { const subscriptionId = `stream:${message.payload.stream}`; this.emit(subscriptionId, message.payload); } else if (message.event === 'BROADCAST') { const subscriptionId = `broadcast:${message.payload.event}`; this.emit(subscriptionId, message.payload); } else if (message.event.startsWith('presence:')) { this.emit(message.event, message.payload); } // 汎用イベント this.emit(message.event, message.payload); } catch (error) { this.emit('error', new Error(`Failed to parse message: ${error}`)); } } attemptReconnect() { const maxAttempts = this.config.maxReconnectAttempts || 10; const interval = this.config.reconnectInterval || 5000; if (this.reconnectAttempts >= maxAttempts) { this.emit('error', new Error('Max reconnection attempts reached')); return; } this.reconnectAttempts++; setTimeout(() => { this.connect().catch(error => { this.emit('error', error); }); }, interval); } } /** * クライアントサイド用リアルタイムクライアント */ export class RealtimeClient { constructor(config) { this.config = config; this.channels = new Map(); } /** * チャンネルを作成または取得 */ channel(topic) { if (!this.channels.has(topic)) { const channel = new RealtimeChannel(topic, this.config, this); this.channels.set(topic, channel); } return this.channels.get(topic); } /** * すべてのチャンネルを切断 */ disconnect() { for (const channel of this.channels.values()) { channel.disconnect(); } this.channels.clear(); } /** * 接続状態を取得 */ getConnectionStatus() { const status = {}; for (const [topic, channel] of this.channels) { status[topic] = channel.isConnected; } return status; } } /** * クライアントサイド用Realtimeインスタンスを作成 */ export function createRealtimeClient(config) { return new RealtimeClient(config); } //# sourceMappingURL=realtime-client.js.map