rspace-online/lib/demo-sync-vanilla.ts

211 lines
5.4 KiB
TypeScript

/**
* 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<string, DemoShape> }
* - "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<typeof setTimeout> | null = null;
private pingTimer: ReturnType<typeof setInterval> | null = null;
private destroyed = false;
shapes: Record<string, DemoShape> = {};
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<DemoShape>): 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<void> {
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<string, DemoShape>): Record<string, DemoShape> {
if (!this.filter || this.filter.length === 0) return allShapes;
const filtered: Record<string, DemoShape> = {};
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);
}
}