Changeset 0.5.0 (#35)
This commit is contained in:
327
src/js/events.js
Normal file
327
src/js/events.js
Normal file
@@ -0,0 +1,327 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – EventBus (v0.5.3)
|
||||
// ==========================================
|
||||
// Labeled event bus with WebSocket bridge.
|
||||
// Components subscribe by label (supports * wildcard).
|
||||
// Events route: local-only, FE→BE, BE→FE, or both.
|
||||
//
|
||||
// Usage:
|
||||
// Events.on('chat.message.*', (payload, meta) => { ... });
|
||||
// Events.emit('chat.typing.abc123', { user: 'jeff' });
|
||||
// Events.connect('/ws');
|
||||
// ==========================================
|
||||
|
||||
const Events = {
|
||||
|
||||
_handlers: new Map(), // label → Set<{fn, once}>
|
||||
_ws: null,
|
||||
_wsUrl: null,
|
||||
_wsReconnectMs: 1000,
|
||||
_wsMaxReconnectMs: 30000,
|
||||
_wsReconnectTimer: null,
|
||||
_wsConnected: false,
|
||||
_wsQueue: [], // buffered while disconnected
|
||||
|
||||
// Labels that should NOT cross the wire
|
||||
_localOnly: new Set([
|
||||
'ui.modal.open', 'ui.modal.close',
|
||||
'ui.sidebar.toggle', 'ui.sidebar.collapse',
|
||||
'ui.toast',
|
||||
]),
|
||||
|
||||
// Labels the FE should never receive from BE
|
||||
// (enforced server-side, this is defense-in-depth)
|
||||
_serverOnly: new Set([
|
||||
'plugin.hook.pre_completion',
|
||||
'plugin.hook.post_completion',
|
||||
'internal.',
|
||||
'db.',
|
||||
]),
|
||||
|
||||
// ── Subscribe ────────────────────────────
|
||||
|
||||
/**
|
||||
* Subscribe to events matching a label pattern.
|
||||
* Supports exact match and trailing wildcard:
|
||||
* 'chat.message.abc123' — exact
|
||||
* 'chat.message.*' — any chat.message.{id}
|
||||
* 'chat.*' — any chat.{anything}
|
||||
*
|
||||
* @param {string} label - Event label or pattern
|
||||
* @param {Function} fn - Handler(payload, meta)
|
||||
* @returns {Function} unsubscribe function
|
||||
*/
|
||||
on(label, fn) {
|
||||
if (!this._handlers.has(label)) this._handlers.set(label, new Set());
|
||||
const entry = { fn, once: false };
|
||||
this._handlers.get(label).add(entry);
|
||||
return () => this._handlers.get(label)?.delete(entry);
|
||||
},
|
||||
|
||||
/**
|
||||
* Subscribe for a single event, then auto-unsubscribe.
|
||||
*/
|
||||
once(label, fn) {
|
||||
if (!this._handlers.has(label)) this._handlers.set(label, new Set());
|
||||
const entry = { fn, once: true };
|
||||
this._handlers.get(label).add(entry);
|
||||
return () => this._handlers.get(label)?.delete(entry);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove all handlers for a label, or a specific handler.
|
||||
*/
|
||||
off(label, fn) {
|
||||
if (!fn) {
|
||||
this._handlers.delete(label);
|
||||
} else {
|
||||
const set = this._handlers.get(label);
|
||||
if (set) {
|
||||
for (const entry of set) {
|
||||
if (entry.fn === fn) { set.delete(entry); break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Emit ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emit an event. Dispatches locally and sends via WebSocket
|
||||
* unless the label is in _localOnly.
|
||||
*
|
||||
* @param {string} label - Event label
|
||||
* @param {*} payload - Event data
|
||||
* @param {object} opts - { localOnly: bool, room: string }
|
||||
*/
|
||||
emit(label, payload, opts = {}) {
|
||||
const meta = {
|
||||
event: label,
|
||||
room: opts.room || null,
|
||||
ts: Date.now(),
|
||||
local: true,
|
||||
};
|
||||
|
||||
// Local dispatch
|
||||
this._dispatch(label, payload, meta);
|
||||
|
||||
// Send over WebSocket unless local-only
|
||||
if (!opts.localOnly && !this._isLocalOnly(label)) {
|
||||
this._send({ event: label, room: meta.room, payload, ts: meta.ts });
|
||||
}
|
||||
},
|
||||
|
||||
// ── Internal dispatch ────────────────────
|
||||
|
||||
_dispatch(label, payload, meta) {
|
||||
// Check every registered pattern against this label
|
||||
for (const [pattern, entries] of this._handlers) {
|
||||
if (this._match(label, pattern)) {
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
entry.fn(payload, meta);
|
||||
} catch (e) {
|
||||
console.error(`[EventBus] Handler error for ${pattern}:`, e);
|
||||
}
|
||||
if (entry.once) entries.delete(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Match a concrete label against a subscription pattern.
|
||||
* 'chat.message.abc' matches:
|
||||
* 'chat.message.abc' (exact)
|
||||
* 'chat.message.*' (wildcard last segment)
|
||||
* 'chat.*' (wildcard all after chat.)
|
||||
* '*' (match everything)
|
||||
*/
|
||||
_match(label, pattern) {
|
||||
if (pattern === '*') return true;
|
||||
if (pattern === label) return true;
|
||||
if (!pattern.endsWith('*')) return false;
|
||||
const prefix = pattern.slice(0, -1); // 'chat.message.' from 'chat.message.*'
|
||||
return label.startsWith(prefix);
|
||||
},
|
||||
|
||||
_isLocalOnly(label) {
|
||||
if (this._localOnly.has(label)) return true;
|
||||
for (const prefix of this._localOnly) {
|
||||
if (prefix.endsWith('.') && label.startsWith(prefix)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// ── WebSocket Bridge ─────────────────────
|
||||
|
||||
/**
|
||||
* Connect to WebSocket endpoint. Auto-reconnects on drop.
|
||||
* @param {string} url - WebSocket URL (e.g. '/ws' or 'wss://host/ws')
|
||||
*/
|
||||
connect(url) {
|
||||
this._wsUrl = url;
|
||||
this._wsReconnectMs = 1000;
|
||||
this._wsRetries = 0;
|
||||
this._wsMaxRetries = 5;
|
||||
this._doConnect();
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
if (this._wsReconnectTimer) clearTimeout(this._wsReconnectTimer);
|
||||
this._wsReconnectTimer = null;
|
||||
this._wsRetries = 0;
|
||||
if (this._ws) {
|
||||
this._ws.onclose = null; // prevent reconnect
|
||||
this._ws.close();
|
||||
this._ws = null;
|
||||
}
|
||||
this._wsConnected = false;
|
||||
this._dispatch('ws.disconnected', {}, { event: 'ws.disconnected', ts: Date.now(), local: true });
|
||||
},
|
||||
|
||||
_doConnect() {
|
||||
if (!this._wsUrl) return;
|
||||
|
||||
// Build full URL
|
||||
let url = this._wsUrl;
|
||||
if (url.startsWith('/')) {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
url = `${proto}//${location.host}${url}`;
|
||||
}
|
||||
|
||||
// Add auth token as query param (WebSocket doesn't support headers)
|
||||
const tokens = JSON.parse(localStorage.getItem('sb_auth') || '{}');
|
||||
if (tokens.access) {
|
||||
url += (url.includes('?') ? '&' : '?') + `token=${encodeURIComponent(tokens.access)}`;
|
||||
} else {
|
||||
console.warn('[EventBus] No auth token — skipping WebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._ws = new WebSocket(url);
|
||||
} catch (e) {
|
||||
console.warn('[EventBus] WebSocket creation failed:', e);
|
||||
this._scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this._ws.onopen = () => {
|
||||
console.log('[EventBus] WebSocket connected');
|
||||
this._wsConnected = true;
|
||||
this._wsReconnectMs = 1000;
|
||||
this._wsRetries = 0;
|
||||
|
||||
// Flush queued events
|
||||
while (this._wsQueue.length > 0) {
|
||||
const msg = this._wsQueue.shift();
|
||||
this._ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
this._dispatch('ws.connected', {}, { event: 'ws.connected', ts: Date.now(), local: true });
|
||||
};
|
||||
|
||||
this._ws.onmessage = (evt) => {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data);
|
||||
|
||||
// Server pong
|
||||
if (msg.event === 'pong') return;
|
||||
|
||||
// Drop events that shouldn't reach the frontend
|
||||
for (const prefix of this._serverOnly) {
|
||||
if (msg.event.startsWith(prefix)) return;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
event: msg.event,
|
||||
room: msg.room || null,
|
||||
ts: msg.ts || Date.now(),
|
||||
local: false,
|
||||
};
|
||||
|
||||
this._dispatch(msg.event, msg.payload, meta);
|
||||
} catch (e) {
|
||||
console.warn('[EventBus] Bad message:', evt.data);
|
||||
}
|
||||
};
|
||||
|
||||
this._ws.onclose = (evt) => {
|
||||
this._wsConnected = false;
|
||||
this._ws = null;
|
||||
if (evt.code !== 1000) {
|
||||
// Abnormal close — log with context
|
||||
console.log(`[EventBus] WebSocket closed (code=${evt.code}, reason=${evt.reason || 'none'})`);
|
||||
}
|
||||
this._dispatch('ws.disconnected', { code: evt.code }, { event: 'ws.disconnected', ts: Date.now(), local: true });
|
||||
this._scheduleReconnect();
|
||||
};
|
||||
|
||||
this._ws.onerror = () => {
|
||||
// onclose will fire after this — don't double-log
|
||||
};
|
||||
|
||||
// Heartbeat
|
||||
this._startHeartbeat();
|
||||
},
|
||||
|
||||
_scheduleReconnect() {
|
||||
if (!this._wsUrl) return;
|
||||
if (this._wsReconnectTimer) return;
|
||||
|
||||
this._wsRetries++;
|
||||
if (this._wsRetries > this._wsMaxRetries) {
|
||||
console.warn(`[EventBus] WebSocket failed after ${this._wsMaxRetries} attempts — giving up. Real-time features unavailable.`);
|
||||
this._dispatch('ws.failed', { retries: this._wsRetries }, { event: 'ws.failed', ts: Date.now(), local: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[EventBus] Reconnecting in ${this._wsReconnectMs}ms (attempt ${this._wsRetries}/${this._wsMaxRetries})`);
|
||||
this._wsReconnectTimer = setTimeout(() => {
|
||||
this._wsReconnectTimer = null;
|
||||
this._doConnect();
|
||||
}, this._wsReconnectMs);
|
||||
|
||||
// Exponential backoff with cap
|
||||
this._wsReconnectMs = Math.min(this._wsReconnectMs * 2, this._wsMaxReconnectMs);
|
||||
},
|
||||
|
||||
_send(msg) {
|
||||
if (this._wsConnected && this._ws?.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify(msg));
|
||||
} else {
|
||||
// Buffer up to 100 events while disconnected
|
||||
if (this._wsQueue.length < 100) this._wsQueue.push(msg);
|
||||
}
|
||||
},
|
||||
|
||||
_heartbeatInterval: null,
|
||||
_startHeartbeat() {
|
||||
if (this._heartbeatInterval) clearInterval(this._heartbeatInterval);
|
||||
this._heartbeatInterval = setInterval(() => {
|
||||
if (this._wsConnected) {
|
||||
this._send({ event: 'ping', payload: {}, ts: Date.now() });
|
||||
}
|
||||
}, 30000);
|
||||
},
|
||||
|
||||
// ── Utility ──────────────────────────────
|
||||
|
||||
/** Check if WebSocket is connected */
|
||||
get connected() { return this._wsConnected; },
|
||||
|
||||
/** Remove all handlers (for cleanup / logout) */
|
||||
clear() {
|
||||
this._handlers.clear();
|
||||
},
|
||||
|
||||
/** Debug: list all subscriptions */
|
||||
debug() {
|
||||
const subs = {};
|
||||
for (const [label, entries] of this._handlers) {
|
||||
subs[label] = entries.size;
|
||||
}
|
||||
return subs;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user