@gftdcojp/gftd-orm
Version:
Enterprise-grade real-time data platform with ksqlDB, inspired by Supabase architecture
352 lines • 10.8 kB
JavaScript
/**
* クライアントサイド用リアルタイム通信(ブラウザ環境)
*/
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