/** * DemoSync — vanilla JS class for real-time demo data via rSpace WebSocket. * * Mirrors the React useDemoSync hook but uses EventTarget for framework-free * operation. Designed for module demo pages served as server-rendered HTML. * * Events: * - "snapshot" → detail: { shapes: Record } * - "connected" → WebSocket opened * - "disconnected" → WebSocket closed * * Usage: * const sync = new DemoSync({ filter: ['demo-poll'] }); * sync.addEventListener('snapshot', (e) => renderUI(e.detail.shapes)); * sync.connect(); */ export interface DemoShape { type: string; id: string; x: number; y: number; width: number; height: number; rotation: number; [key: string]: unknown; } export interface DemoSyncOptions { /** Community slug (default: 'demo') */ slug?: string; /** Only subscribe to these shape types */ filter?: string[]; /** rSpace server URL (default: auto-detect) */ serverUrl?: string; } const DEFAULT_SLUG = "demo"; const RECONNECT_BASE_MS = 1000; const RECONNECT_MAX_MS = 30000; const PING_INTERVAL_MS = 30000; function getDefaultServerUrl(): string { if (typeof window === "undefined") return "https://rspace.online"; const host = window.location.hostname; if (host === "localhost" || host === "127.0.0.1") { return `http://${host}:3000`; } return "https://rspace.online"; } export class DemoSync extends EventTarget { private slug: string; private filter: string[] | undefined; private serverUrl: string; private ws: WebSocket | null = null; private reconnectAttempt = 0; private reconnectTimer: ReturnType | null = null; private pingTimer: ReturnType | null = null; private destroyed = false; shapes: Record = {}; connected = false; constructor(options?: DemoSyncOptions) { super(); this.slug = options?.slug ?? DEFAULT_SLUG; this.filter = options?.filter; this.serverUrl = options?.serverUrl ?? getDefaultServerUrl(); } connect(): void { if (this.destroyed) return; const wsProtocol = this.serverUrl.startsWith("https") ? "wss" : "ws"; const host = this.serverUrl.replace(/^https?:\/\//, ""); const wsUrl = `${wsProtocol}://${host}/ws/${this.slug}?mode=json`; const ws = new WebSocket(wsUrl); this.ws = ws; ws.onopen = () => { if (this.destroyed) return; this.connected = true; this.reconnectAttempt = 0; this.dispatchEvent(new Event("connected")); this.pingTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() })); } }, PING_INTERVAL_MS); }; ws.onmessage = (event) => { if (this.destroyed) return; try { const msg = JSON.parse(event.data); if (msg.type === "snapshot" && msg.shapes) { this.shapes = this.applyFilter(msg.shapes); this.dispatchEvent( new CustomEvent("snapshot", { detail: { shapes: this.shapes }, }), ); } } catch { // ignore parse errors } }; ws.onclose = () => { if (this.destroyed) return; this.connected = false; this.cleanup(); this.dispatchEvent(new Event("disconnected")); this.scheduleReconnect(); }; ws.onerror = () => { // onclose fires after onerror }; } updateShape(id: string, data: Partial): void { const ws = this.ws; if (!ws || ws.readyState !== WebSocket.OPEN) return; // Optimistic local update const existing = this.shapes[id]; if (existing) { const updated = { ...existing, ...data, id }; if (this.filter && this.filter.length > 0 && !this.filter.includes(updated.type)) return; this.shapes = { ...this.shapes, [id]: updated as DemoShape }; this.dispatchEvent( new CustomEvent("snapshot", { detail: { shapes: this.shapes }, }), ); } ws.send(JSON.stringify({ type: "update", id, data: { ...data, id } })); } deleteShape(id: string): void { const ws = this.ws; if (!ws || ws.readyState !== WebSocket.OPEN) return; const { [id]: _, ...rest } = this.shapes; this.shapes = rest; this.dispatchEvent( new CustomEvent("snapshot", { detail: { shapes: this.shapes } }), ); ws.send(JSON.stringify({ type: "delete", id })); } async resetDemo(): Promise { const res = await fetch(`${this.serverUrl}/api/communities/demo/reset`, { method: "POST", }); if (!res.ok) { const body = await res.text(); throw new Error(`Reset failed: ${res.status} ${body}`); } } destroy(): void { this.destroyed = true; this.cleanup(); if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.ws) { this.ws.onclose = null; this.ws.close(); this.ws = null; } } private applyFilter(allShapes: Record): Record { if (!this.filter || this.filter.length === 0) return allShapes; const filtered: Record = {}; for (const [id, shape] of Object.entries(allShapes)) { if (this.filter.includes(shape.type)) { filtered[id] = shape; } } return filtered; } private cleanup(): void { if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; } } private scheduleReconnect(): void { if (this.destroyed) return; const delay = Math.min( RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempt), RECONNECT_MAX_MS, ); this.reconnectAttempt++; this.reconnectTimer = setTimeout(() => { if (!this.destroyed) this.connect(); }, delay); } }