diff --git a/packages/server/__tests__/hub.test.ts b/packages/server/__tests__/hub.test.ts new file mode 100644 index 0000000..1d414be --- /dev/null +++ b/packages/server/__tests__/hub.test.ts @@ -0,0 +1,84 @@ +import { + fanout, + OPEN_READY_STATE, + selectRevokedSockets, + sweepLiveness, + type HubSocket +} from '../src/hub'; + +function fakeSocket(overrides: Partial = {}): HubSocket & { sent: string[]; pings: number } { + const socket = { + readyState: OPEN_READY_STATE, + sent: [] as string[], + pings: 0, + send(payload: string) { + this.sent.push(payload); + }, + close() {}, + terminate() {}, + ping() { + this.pings++; + }, + ...overrides + }; + return socket; +} + +describe('WebSocket hub helpers', () => { + it('isolates fanout failures and skips non-open sockets', () => { + const throwing = fakeSocket({ + send() { + throw new Error('gone'); + } + }); + const closed = fakeSocket({ readyState: 3 }); + const healthy = fakeSocket(); + const failed: HubSocket[] = []; + + const delivered = fanout([throwing, closed, healthy], 'payload', (socket) => failed.push(socket)); + + expect(delivered).toBe(1); + expect(failed).toEqual([throwing]); + expect(healthy.sent).toEqual(['payload']); + }); + + it('terminates missed peers and pings peers that answered', () => { + const missed = fakeSocket(); + const responsive = fakeSocket(); + const terminated: HubSocket[] = []; + const liveness = new Map([ + [missed, { alive: false }], + [responsive, { alive: true }] + ]); + missed.terminate = () => terminated.push(missed); + + sweepLiveness(liveness, (socket) => { + socket.terminate(); + liveness.delete(socket); + }, () => { + throw new Error('unexpected ping failure'); + }); + + expect(terminated).toEqual([missed]); + expect(liveness.has(missed)).toBe(false); + expect(responsive.pings).toBe(1); + expect(liveness.get(responsive)?.alive).toBe(false); + }); + + it('selects only sockets whose session ids are no longer live', () => { + const live = fakeSocket(); + const revoked = fakeSocket(); + const receiver = fakeSocket(); + + expect( + selectRevokedSockets( + [ + [live, { sid: 'live' }], + [revoked, { sid: 'revoked' }], + [receiver, {}] + ], + (sid) => sid === 'live' + ) + ).toEqual([revoked]); + }); +}); diff --git a/packages/server/__tests__/ws-resilience.test.ts b/packages/server/__tests__/ws-resilience.test.ts new file mode 100644 index 0000000..9dd72e7 --- /dev/null +++ b/packages/server/__tests__/ws-resilience.test.ts @@ -0,0 +1,136 @@ +import { loadWavegridConfig } from '@wavegrid/layout'; +import { openStore } from '@wavegrid/settings'; +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { WebSocket } from 'ws'; + +import { signJwt } from '../src/jwt'; +import { startServer, type ServerHandle } from '../src/server'; + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function connect(port: number, token: string): Promise<{ ws: WebSocket; states: number }> { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`); + let states = 0; + ws.on('message', (raw) => { + try { + if ((JSON.parse(raw.toString()) as { type?: string }).type === 'state') states++; + } catch { + // Ignore malformed test traffic. + } + }); + ws.once('open', () => resolve({ ws, get states() { return states; } })); + ws.once('error', reject); + }); +} + +describe('WebSocket hub resilience', () => { + const saved = { ...process.env }; + let handle: ServerHandle; + let port: number; + + beforeAll(async () => { + const storeDir = mkdtempSync(join(tmpdir(), 'wg-ws-store-')); + const stateDir = mkdtempSync(join(tmpdir(), 'wg-ws-state-')); + process.env.APPSTASH_BASE_DIR = storeDir; + process.env.WAVEGRID_PROJECT = 'demo'; + process.env.WG_STATE_DIR = stateDir; + process.env.WG_JWT_SECRET = 'test-secret'; + process.env.WG_HEARTBEAT_MS = '40'; + delete process.env.WAVEGRID_LAYOUT; + delete process.env.WAVEGRID_MODE; + + const store = openStore(); + store.createProject('demo', { layout: { preset: 'grid-7x7' }, server: { host: '127.0.0.1', port: 0 } }); + store.setActiveProject('demo'); + + handle = startServer(loadWavegridConfig(), { uiDir: null, advertise: false }); + await handle.ready; + const address = handle.server.address(); + port = typeof address === 'object' && address ? address.port : 0; + }); + + afterAll(() => { + handle.stop(); + process.env = { ...saved }; + }); + + function tokenFor(sid: string, username: string): string { + return signJwt(username, { sid, role: 'operator', ttlSec: 3600 }); + } + + function createSession(username: string) { + return openStore().createSession('demo', { + username, + role: 'operator', + ttlMs: 60_000 + }); + } + + it('continues the fanout after one server-side send fails', async () => { + const firstSession = createSession('first'); + const secondSession = createSession('second'); + const first = await connect(port, tokenFor(firstSession.id, firstSession.username)); + const second = await connect(port, tokenFor(secondSession.id, secondSession.username)); + await wait(30); + + const originalSend = WebSocket.prototype.send; + const uncaught: unknown[] = []; + const onUncaught = (error: unknown) => uncaught.push(error); + let victim: WebSocket | null = null; + process.on('uncaughtException', onUncaught); + Object.defineProperty(WebSocket.prototype, 'send', { + configurable: true, + writable: true, + value: function(this: WebSocket, ...args: Parameters) { + victim ??= this; + if (this === victim) throw new Error('simulated broken peer'); + return Reflect.apply(originalSend, this, args); + } + }); + try { + await wait(100); + expect(second.states).toBeGreaterThan(0); + expect(uncaught).toEqual([]); + } finally { + Object.defineProperty(WebSocket.prototype, 'send', { + configurable: true, + writable: true, + value: originalSend + }); + process.removeListener('uncaughtException', onUncaught); + first.ws.close(); + second.ws.close(); + } + }); + + it('closes only a revoked session while other clients keep receiving state', async () => { + const revoked = createSession('revoked'); + const survivor = createSession('survivor'); + const revokedClient = await connect(port, tokenFor(revoked.id, revoked.username)); + const survivorClient = await connect(port, tokenFor(survivor.id, survivor.username)); + const closed = new Promise((resolve) => revokedClient.ws.once('close', (code) => resolve(code))); + + openStore().revokeSession('demo', revoked.id); + expect(await closed).toBe(4001); + await wait(80); + expect(survivorClient.states).toBeGreaterThan(0); + survivorClient.ws.close(); + }); + + it('rejects a token whose session was revoked before reconnecting', async () => { + const session = createSession('reconnect'); + const token = tokenFor(session.id, session.username); + openStore().revokeSession('demo', session.id); + + const ws = new WebSocket(`ws://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`); + const closed = new Promise((resolve) => ws.once('close', (code) => resolve(code))); + ws.on('error', () => { + // Browsers surface the rejected upgrade as a generic socket error. + }); + + expect(await closed).toBe(1006); + }); +}); diff --git a/packages/server/src/hub.ts b/packages/server/src/hub.ts new file mode 100644 index 0000000..aed6aee --- /dev/null +++ b/packages/server/src/hub.ts @@ -0,0 +1,67 @@ +export const OPEN_READY_STATE = 1; + +export interface HubSocket { + readyState: number; + send(payload: string): void; + close(code?: number, reason?: string): void; + terminate(): void; + ping(): void; +} + +export interface LivenessState { + alive: boolean; +} + +/** Send to every open socket without letting one broken peer stop the fanout. */ +export function fanout( + sockets: Iterable, + payload: string, + onFailure: (socket: T, error: unknown) => void +): number { + let delivered = 0; + for (const socket of sockets) { + if (socket.readyState !== OPEN_READY_STATE) continue; + try { + socket.send(payload); + delivered++; + } catch (error) { + onFailure(socket, error); + } + } + return delivered; +} + +/** + * Mark responsive sockets for the next sweep and terminate peers that missed + * the previous ping. + */ +export function sweepLiveness( + sockets: Map, + onDead: (socket: T) => void, + onPingFailure: (socket: T, error: unknown) => void +): void { + for (const [socket, state] of sockets) { + if (!state.alive) { + onDead(socket); + continue; + } + state.alive = false; + try { + socket.ping(); + } catch (error) { + onPingFailure(socket, error); + } + } +} + +/** Select authenticated sockets whose server-side sessions are no longer live. */ +export function selectRevokedSockets( + clients: Iterable<[T, C]>, + isSessionLive: (sid: string) => boolean +): T[] { + const revoked: T[] = []; + for (const [socket, info] of clients) { + if (info.sid && !isSessionLive(info.sid)) revoked.push(socket); + } + return revoked; +} diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index df9e3d8..374b260 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -35,6 +35,8 @@ export interface ClientInfo { remote: string; connectedAt: number; lastSeen: number; + sid?: string; + username?: string; hello?: Omit; } diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 0048484..b3d3da1 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -5,13 +5,15 @@ import * as fs from 'fs'; import http from 'http'; import { resolve } from 'path'; import { URL } from 'url'; -import { WebSocket,WebSocketServer } from 'ws'; +import { WebSocket, WebSocketServer } from 'ws'; import { animations } from './animations'; import { computeCoverage } from './coverage'; import type { BlendMode, CannonState, Orientation, Rotation } from './grid'; import {compositeLayer, createGrid, DEFAULT_ALPHA, defaultOrientation, mapUiToGrid, remapGridForUi, resetGrid, setAllTargets, setCannonTarget, shiftGrid, tickGrid } from './grid'; import { createHttpApp, lanVisitors, resolveUiDir } from './http-app'; +import { fanout, type LivenessState,selectRevokedSockets, sweepLiveness } from './hub'; +import type { JwtPayload } from './jwt'; import { verifyJwt } from './jwt'; import { ServerPatternEngine } from './pattern-engine'; import { compilePlaylist, type PlaylistDef, type PlaylistStep } from './playlist-compiler'; @@ -195,6 +197,8 @@ const server = http.createServer((req, res) => { }); const wss = new WebSocketServer({ noServer: true }); +const verifiedPayloads = new WeakMap(); +const liveness = new Map(); const RECEIVER_KEY = process.env.WG_RECEIVER_KEY || ''; @@ -212,6 +216,15 @@ server.on('upgrade', (req, socket, head) => { socket.destroy(); return; } + if (payload.sid) { + const target = resolveSyncTarget(); + if (target && !target.store.getSession(target.project, payload.sid)) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + } + verifiedPayloads.set(req, payload); } else if (key && RECEIVER_KEY && key === RECEIVER_KEY) { // valid receiver key — allow } else { @@ -225,6 +238,34 @@ server.on('upgrade', (req, socket, head) => { }); }); +function dropClient(ws: WebSocket, error?: unknown): void { + const wasTracked = clients.delete(ws); + liveness.delete(ws); + if (!wasTracked) return; + if (error) { + console.error(' ◈ WebSocket client error:', error instanceof Error ? error.message : String(error)); + } + try { + ws.terminate(); + } catch { + // The peer is already gone. + } +} + +function sendToClient(ws: WebSocket, payload: string): boolean { + return fanout([ws], payload, dropClient) === 1; +} + +function revokeClient(ws: WebSocket): void { + clients.delete(ws); + liveness.delete(ws); + try { + ws.close(4001, 'session revoked'); + } catch { + ws.terminate(); + } +} + // Broadcast the current grid snapshot to all UI clients. // Used for calibration, orientation changes, and paint/clear — so the // browser UI preview stays up-to-date. Receivers ignore these messages @@ -242,11 +283,7 @@ function broadcastState() { GRID_COLUMNS, GRID_ROWS, orientation ); const payload = JSON.stringify({ type: 'state', grid: output }); - wss.clients.forEach(client => { - if (client.readyState === WebSocket.OPEN) { - client.send(payload); - } - }); + fanout(wss.clients, payload, dropClient); } function getCalibrationOutput(): CannonState[] { @@ -285,20 +322,12 @@ function loadPhysicalLightMap(): number[] { function broadcastOrientation() { const payload = JSON.stringify({ type: 'orientation', ...orientation }); - wss.clients.forEach(client => { - if (client.readyState === WebSocket.OPEN) { - client.send(payload); - } - }); + fanout(wss.clients, payload, dropClient); } function broadcastCommand(cmd: Record) { const payload = JSON.stringify({ type: 'command', ...cmd }); - wss.clients.forEach(client => { - if (client.readyState === WebSocket.OPEN) { - client.send(payload); - } - }); + fanout(wss.clients, payload, dropClient); } function broadcastPlaylistState() { @@ -308,11 +337,7 @@ function broadcastPlaylistState() { playlist: activePlaylist, currentStep: playlistCurrentStep }); - wss.clients.forEach(client => { - if (client.readyState === WebSocket.OPEN) { - client.send(payload); - } - }); + fanout(wss.clients, payload, dropClient); } /** Cancel any active playlist when another visual command arrives. */ @@ -383,9 +408,7 @@ function isSecretScope(scope: string): boolean { /** Broadcast an accepted config revision to every connected client. */ function broadcastSync(update: SyncUpdateMessage): void { const payload = JSON.stringify(update); - wss.clients.forEach(client => { - if (client.readyState === WebSocket.OPEN) client.send(payload); - }); + fanout(wss.clients, payload, dropClient); } /** Serialize + persist a client's config push, then broadcast the revision. */ @@ -416,7 +439,7 @@ function handleSyncPush(msg: SyncPushMessage): void { function sendSyncState(ws: WebSocket, msg: SyncRequestMessage): void { const target = resolveSyncTarget(); if (!target) { - ws.send(JSON.stringify({ type: 'sync_state', revision: 0, entries: {} })); + sendToClient(ws, JSON.stringify({ type: 'sync_state', revision: 0, entries: {} })); return; } const state = target.store.getSyncState(target.project); @@ -428,7 +451,7 @@ function sendSyncState(ws: WebSocket, msg: SyncRequestMessage): void { /* ignore */ } } - ws.send(JSON.stringify({ type: 'sync_state', revision: state.revision, entries: state.entries })); + sendToClient(ws, JSON.stringify({ type: 'sync_state', revision: state.revision, entries: state.entries })); } /** Record a client's acknowledgement of the revision it applied. */ @@ -480,9 +503,7 @@ function filterSecretScopes(entries: Record): Record { /** Broadcast the full replicated document to every client (post-merge convergence). */ function broadcastSyncStateTo(state: { revision: number; entries: unknown }): void { const payload = JSON.stringify({ type: 'sync_state', revision: state.revision, entries: state.entries }); - wss.clients.forEach(client => { - if (client.readyState === WebSocket.OPEN) client.send(payload); - }); + fanout(wss.clients, payload, dropClient); } /** Sync summary for `system_status` (revision + devices that lag it). */ @@ -530,26 +551,43 @@ function buildSystemStatus(): SystemStatus { wss.on('connection', (ws, req: http.IncomingMessage) => { const remote = req.socket.remoteAddress ?? 'unknown'; - const isUi = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`).searchParams.has('token'); + const payload = verifiedPayloads.get(req); + const isUi = payload !== undefined; const now = Date.now(); - clients.set(ws, { role: isUi ? 'ui' : 'unknown', remote, connectedAt: now, lastSeen: now }); - ws.on('close', () => clients.delete(ws)); + clients.set(ws, { + role: isUi ? 'ui' : 'unknown', + remote, + connectedAt: now, + lastSeen: now, + sid: payload?.sid, + username: payload?.sub + }); + liveness.set(ws, { alive: true }); + ws.on('close', () => { + clients.delete(ws); + liveness.delete(ws); + }); + ws.on('error', (error) => dropClient(ws, error)); + ws.on('pong', () => { + const state = liveness.get(ws); + if (state) state.alive = true; + }); // Send the resolved layout first — the single source of truth for geometry — // so UI and receiver render/route from the same fixtures the server uses. - ws.send(JSON.stringify({ type: 'layout', layout, runMode: RUN_MODE })); + sendToClient(ws, JSON.stringify({ type: 'layout', layout, runMode: RUN_MODE })); // Send initial state + orientation const initGrid = remapGridForUi( grid.map(c => ({ h: c.h, s: c.s, b: c.b })), GRID_COLUMNS, GRID_ROWS, orientation ); - ws.send(JSON.stringify({ type: 'state', grid: initGrid })); - ws.send(JSON.stringify({ type: 'orientation', ...orientation })); - ws.send(JSON.stringify({ type: 'command', action: 'setOrientation', rotation: orientation.rotation, flipH: orientation.flipH, flipV: orientation.flipV })); - ws.send(JSON.stringify({ type: 'command', action: 'setSmoothness', value: currentAlpha })); - ws.send(JSON.stringify({ type: 'command', action: 'setAttack', value: currentAttack })); - ws.send(JSON.stringify({ type: 'command', action: 'setSpeed', value: animSpeed })); - ws.send(JSON.stringify({ + sendToClient(ws, JSON.stringify({ type: 'state', grid: initGrid })); + sendToClient(ws, JSON.stringify({ type: 'orientation', ...orientation })); + sendToClient(ws, JSON.stringify({ type: 'command', action: 'setOrientation', rotation: orientation.rotation, flipH: orientation.flipH, flipV: orientation.flipV })); + sendToClient(ws, JSON.stringify({ type: 'command', action: 'setSmoothness', value: currentAlpha })); + sendToClient(ws, JSON.stringify({ type: 'command', action: 'setAttack', value: currentAttack })); + sendToClient(ws, JSON.stringify({ type: 'command', action: 'setSpeed', value: animSpeed })); + sendToClient(ws, JSON.stringify({ type: 'settings', alpha: currentAlpha, attack: currentAttack, @@ -557,16 +595,16 @@ wss.on('connection', (ws, req: http.IncomingMessage) => { animation: currentAnimation })); if (currentAnimation) { - ws.send(JSON.stringify({ type: 'command', action: 'setAnimation', name: currentAnimation, speed: animSpeed })); + sendToClient(ws, JSON.stringify({ type: 'command', action: 'setAnimation', name: currentAnimation, speed: animSpeed })); } if (activePlaylist) { - ws.send(JSON.stringify({ type: 'playlist_state', active: true, playlist: activePlaylist })); + sendToClient(ws, JSON.stringify({ type: 'playlist_state', active: true, playlist: activePlaylist })); // Re-send compiled playlist to receiver on reconnect const compiled = compilePlaylist(activePlaylist); - ws.send(JSON.stringify({ type: 'command', action: 'evalPattern', code: compiled, params: {} })); + sendToClient(ws, JSON.stringify({ type: 'command', action: 'evalPattern', code: compiled, params: {} })); } if (shiftVx !== 0 || shiftVy !== 0) { - ws.send(JSON.stringify({ type: 'command', action: 'setShift', vx: shiftVx, vy: shiftVy })); + sendToClient(ws, JSON.stringify({ type: 'command', action: 'setShift', vx: shiftVx, vy: shiftVy })); } ws.on('message', (raw) => { @@ -597,7 +635,7 @@ wss.on('connection', (ws, req: http.IncomingMessage) => { return; } if (msg.type === 'system_status') { - ws.send(JSON.stringify(buildSystemStatus())); + sendToClient(ws, JSON.stringify(buildSystemStatus())); return; } // Config synchronization (Phase D): the socket is already authenticated @@ -937,6 +975,26 @@ const tickTimer = setInterval(() => { } }, TICK_MS); +const heartbeatMs = Number(process.env.WG_HEARTBEAT_MS); +const heartbeatIntervalMs = Number.isFinite(heartbeatMs) && heartbeatMs > 0 ? heartbeatMs : 15_000; +const heartbeatTimer = setInterval(() => { + sweepLiveness( + liveness, + dropClient, + dropClient + ); + + const target = resolveSyncTarget(); + if (!target) return; + try { + const liveSessionIds = new Set(target.store.listSessions(target.project).map((session) => session.id)); + const revoked = selectRevokedSockets(clients, (sid) => liveSessionIds.has(sid)); + for (const ws of revoked) revokeClient(ws); + } catch { + // Session checks are best-effort when the project store is unavailable. + } +}, heartbeatIntervalMs); + let advertiseHandle: AdvertiseHandle | null = null; const ready = new Promise((resolveReady, rejectReady) => { @@ -975,6 +1033,7 @@ server.listen(PORT, resolved.config.server.host, () => { const stop = () => { clearInterval(tickTimer); + clearInterval(heartbeatTimer); if (saveTimer) clearTimeout(saveTimer); if (advertiseHandle) advertiseHandle.stop(); wss.close(); diff --git a/packages/ui/__tests__/socket-state.test.ts b/packages/ui/__tests__/socket-state.test.ts new file mode 100644 index 0000000..9e95a45 --- /dev/null +++ b/packages/ui/__tests__/socket-state.test.ts @@ -0,0 +1,64 @@ +import { + applySocketMessage, + beginConnection, + createSocketSnapshot, + isFeedStale, + isSyncConfigMessage +} from '../src/lib/socket-state'; + +describe('socket state snapshots', () => { + it('drops the old connection state and applies a complete fresh burst', () => { + const old = applySocketMessage( + applySocketMessage( + applySocketMessage( + applySocketMessage(createSocketSnapshot(100), { type: 'state', grid: [{ h: 10, s: 20, b: 30 }] }, 110), + { type: 'settings', alpha: 0.2, attack: 0.4, speed: 2, animation: 'pulse' }, + 120 + ), + { type: 'orientation', rotation: 90, flipH: true, flipV: false }, + 130 + ), + { type: 'playlist_state', active: true, currentStep: 1, playlist: null }, + 140 + ); + const fresh = beginConnection(old, 200); + expect(fresh.epoch).toBe(old.epoch + 1); + expect(fresh.grid).toEqual([]); + expect(fresh.settings).toBeNull(); + expect(fresh.playlistState).toBeNull(); + + const synced = applySocketMessage( + applySocketMessage( + applySocketMessage( + applySocketMessage(fresh, { type: 'state', grid: [{ h: 1, s: 2, b: 3 }] }, 210), + { type: 'orientation', rotation: 180, flipH: false, flipV: true }, + 211 + ), + { type: 'settings', alpha: 0.06, attack: 1, speed: 1, animation: null }, + 212 + ), + { type: 'playlist_state', active: false, currentStep: 0, playlist: null }, + 213 + ); + expect(synced.grid).toEqual([{ h: 1, s: 2, b: 3 }]); + expect(synced.orientation).toEqual({ rotation: 180, flipH: false, flipV: true }); + expect(synced.settings).toEqual({ alpha: 0.06, attack: 1, speed: 1, animation: null }); + expect(synced.playlistState).toEqual({ active: false, currentStep: 0, playlist: null }); + }); + + it('signals sync messages and detects a silent feed', () => { + const snapshot = beginConnection(createSocketSnapshot(0), 1); + expect(isSyncConfigMessage({ type: 'sync_update' })).toBe(true); + expect(isSyncConfigMessage({ type: 'sync_state' })).toBe(true); + expect(isSyncConfigMessage({ type: 'state' })).toBe(false); + expect(isSyncConfigMessage(null)).toBe(false); + expect(isSyncConfigMessage('sync_state')).toBe(false); + + const updated = applySocketMessage(snapshot, { type: 'sync_state', revision: 1, entries: {} }, 10); + + expect(updated.lastMessageAt).toBe(10); + expect(applySocketMessage(updated, { type: 'sync_update' }, 20).lastMessageAt).toBe(20); + expect(isFeedStale(updated, 8_011)).toBe(true); + expect(isFeedStale(updated, 8_009)).toBe(false); + }); +}); diff --git a/packages/ui/src/app.tsx b/packages/ui/src/app.tsx index 58ad141..9d67b22 100644 --- a/packages/ui/src/app.tsx +++ b/packages/ui/src/app.tsx @@ -460,7 +460,7 @@ export default function Home() { const [configRev, setConfigRev] = useState(0); const config = useConfig(configRev); const { user, token, checked, endedSession, lastUser, login, sessionEnded } = useAuth(); - const { connection, grid, orientation, playlistState, settings, send } = useSocket( + const { connection, grid, orientation, playlistState, settings, epoch, send } = useSocket( config?.simulatorUrl ?? null, token, useCallback(() => setConfigRev((n) => n + 1), []) @@ -515,6 +515,9 @@ export default function Home() { // Sync slider state from server on initial connect const settingsSyncedRef = useRef(false); + useEffect(() => { + settingsSyncedRef.current = false; + }, [epoch]); useEffect(() => { if (!settings || settingsSyncedRef.current) return; settingsSyncedRef.current = true; diff --git a/packages/ui/src/lib/socket-state.ts b/packages/ui/src/lib/socket-state.ts new file mode 100644 index 0000000..f06a053 --- /dev/null +++ b/packages/ui/src/lib/socket-state.ts @@ -0,0 +1,127 @@ +export interface CannonColor { + h: number; + s: number; + b: number; +} + +export interface Orientation { + rotation: 0 | 90 | 180 | 270; + flipH: boolean; + flipV: boolean; +} + +export interface PlaylistState { + active: boolean; + currentStep: number; + playlist: { + steps: Array<{ type: string; name?: string; code?: string; duration: number }>; + loop: boolean; + transition: 'cut' | 'fade'; + transitionDuration: number; + } | null; +} + +export interface Settings { + alpha: number; + attack: number; + speed: number; + animation: string | null; +} + +export interface SocketSnapshot { + grid: CannonColor[]; + orientation: Orientation; + playlistState: PlaylistState | null; + settings: Settings | null; + epoch: number; + lastMessageAt: number; +} + +// State is broadcast every frame by the brain, so 8 seconds allows temporary +// event-loop stalls without masking a genuinely dead socket. +export const SOCKET_FEED_STALE_MS = 8_000; + +export function createSocketSnapshot(now = 0): SocketSnapshot { + return { + grid: [], + orientation: { rotation: 0, flipH: false, flipV: false }, + playlistState: null, + settings: null, + epoch: 0, + lastMessageAt: now + }; +} + +/** Start a fresh connection epoch instead of carrying forward stale state. */ +export function beginConnection(snapshot: SocketSnapshot, now = Date.now()): SocketSnapshot { + return { + ...createSocketSnapshot(now), + epoch: snapshot.epoch + 1 + }; +} + +/** True for messages that mean the replicated config document moved on. */ +export function isSyncConfigMessage(msg: unknown): boolean { + if (!msg || typeof msg !== 'object') return false; + const type = (msg as Record).type; + return type === 'sync_update' || type === 'sync_state'; +} + +/** Apply one server message without mutating the previous connection snapshot. */ +export function applySocketMessage( + snapshot: SocketSnapshot, + msg: unknown, + now = Date.now() +): SocketSnapshot { + if (!msg || typeof msg !== 'object') return snapshot; + const message = msg as Record; + switch (message.type) { + case 'state': + if (!Array.isArray(message.grid)) return snapshot; + return { ...snapshot, grid: message.grid as CannonColor[], lastMessageAt: now }; + case 'orientation': + return { + ...snapshot, + orientation: { + rotation: message.rotation === 90 || message.rotation === 180 || message.rotation === 270 ? message.rotation : 0, + flipH: !!message.flipH, + flipV: !!message.flipV + }, + lastMessageAt: now + }; + case 'playlist_state': + return { + ...snapshot, + playlistState: { + active: !!message.active, + currentStep: typeof message.currentStep === 'number' ? message.currentStep : 0, + playlist: (message.playlist as PlaylistState['playlist']) ?? null + }, + lastMessageAt: now + }; + case 'settings': + return { + ...snapshot, + settings: { + alpha: typeof message.alpha === 'number' ? message.alpha : 0.06, + attack: typeof message.attack === 'number' ? message.attack : 1.0, + speed: typeof message.speed === 'number' ? message.speed : 1.0, + animation: typeof message.animation === 'string' ? message.animation : null + }, + lastMessageAt: now + }; + case 'sync_update': + case 'sync_state': + return { ...snapshot, lastMessageAt: now }; + default: + return snapshot; + } +} + +export function isFeedStale( + snapshot: SocketSnapshot, + now = Date.now(), + thresholdMs = SOCKET_FEED_STALE_MS +): boolean { + return now - snapshot.lastMessageAt > thresholdMs; +} diff --git a/packages/ui/src/lib/use-socket.ts b/packages/ui/src/lib/use-socket.ts index 44473bf..d8b4cd4 100644 --- a/packages/ui/src/lib/use-socket.ts +++ b/packages/ui/src/lib/use-socket.ts @@ -6,36 +6,16 @@ import { OPEN_CONNECTION, retryDelay } from '@/lib/connection'; +import { + applySocketMessage, + beginConnection, + createSocketSnapshot, + isSyncConfigMessage, + SOCKET_FEED_STALE_MS, + type SocketSnapshot +} from '@/lib/socket-state'; -export interface CannonColor { - h: number; - s: number; - b: number; -} - -export interface Orientation { - rotation: 0 | 90 | 180 | 270; - flipH: boolean; - flipV: boolean; -} - -export interface PlaylistState { - active: boolean; - currentStep: number; - playlist: { - steps: Array<{ type: string; name?: string; code?: string; duration: number }>; - loop: boolean; - transition: 'cut' | 'fade'; - transitionDuration: number; - } | null; -} - -export interface Settings { - alpha: number; - attack: number; - speed: number; - animation: string | null; -} +export type { CannonColor, Orientation, PlaylistState, Settings } from '@/lib/socket-state'; export function useSocket( url: string | null, @@ -46,6 +26,7 @@ export function useSocket( // Keep the latest callback without re-subscribing the socket on every render. const onSyncConfigRef = useRef(onSyncConfig); onSyncConfigRef.current = onSyncConfig; + const lastMessageAtRef = useRef(0); const [connection, setConnection] = useState({ state: 'connecting', cause: 'unknown', @@ -53,10 +34,7 @@ export function useSocket( code: null, attempts: 0 }); - const [grid, setGrid] = useState([]); - const [orientation, setOrientation] = useState({ rotation: 0, flipH: false, flipV: false }); - const [playlistState, setPlaylistState] = useState(null); - const [settings, setSettings] = useState(null); + const [snapshot, setSnapshot] = useState(() => createSocketSnapshot()); useEffect(() => { if (!token || !url) return; @@ -64,6 +42,7 @@ export function useSocket( let disposed = false; let attempts = 0; let retry: ReturnType | null = null; + let watchdog: ReturnType | null = null; const probe = async (path: string) => { const res = await fetch(path, { headers: { Authorization: `Bearer ${token}` } }); @@ -79,6 +58,8 @@ export function useSocket( ws.onopen = () => { attempts = 0; + lastMessageAtRef.current = Date.now(); + setSnapshot((prev) => beginConnection(prev, lastMessageAtRef.current)); setConnection(OPEN_CONNECTION); }; @@ -99,24 +80,10 @@ export function useSocket( ws.onmessage = (e) => { try { const msg = JSON.parse(e.data); - if (msg.type === 'state' && Array.isArray(msg.grid)) { - setGrid(msg.grid); - } else if (msg.type === 'orientation') { - setOrientation({ rotation: msg.rotation ?? 0, flipH: !!msg.flipH, flipV: !!msg.flipV }); - } else if (msg.type === 'playlist_state') { - setPlaylistState({ active: !!msg.active, currentStep: msg.currentStep ?? 0, playlist: msg.playlist ?? null }); - } else if (msg.type === 'settings') { - setSettings({ - alpha: msg.alpha ?? 0.06, - attack: msg.attack ?? 1.0, - speed: msg.speed ?? 1.0, - animation: msg.animation ?? null - }); - } else if (msg.type === 'sync_update' || msg.type === 'sync_state') { - // A config change was replicated from another device — refetch it so - // the browser reflects the new layout/light-map without a reload. - onSyncConfigRef.current?.(); - } + const now = Date.now(); + lastMessageAtRef.current = now; + setSnapshot((prev) => applySocketMessage(prev, msg, now)); + if (isSyncConfigMessage(msg)) onSyncConfigRef.current?.(); } catch { // ignore } @@ -124,10 +91,18 @@ export function useSocket( }; connect(); + watchdog = setInterval(() => { + const ws = wsRef.current; + if ( + ws?.readyState === WebSocket.OPEN && + Date.now() - lastMessageAtRef.current > SOCKET_FEED_STALE_MS + ) ws.close(); + }, Math.min(1_000, SOCKET_FEED_STALE_MS)); return () => { disposed = true; if (retry) clearTimeout(retry); + if (watchdog) clearInterval(watchdog); wsRef.current?.close(); wsRef.current = null; }; @@ -142,10 +117,11 @@ export function useSocket( return { connected: connection.state === 'open', connection, - grid, - orientation, - playlistState, - settings, + grid: snapshot.grid, + orientation: snapshot.orientation, + playlistState: snapshot.playlistState, + settings: snapshot.settings, + epoch: snapshot.epoch, send }; }