Frontend (React 18 + TS + Vite): - Landing: glitching hero, live /api/stats veil ticker, mode cards, featured spirits - Séance: three.js shader ghost (hue/form per entity, mood + audio-reactive), Ouija planchette board spelling utterances, transcript with TTS replay, entity dossier, direct contact streaming, passive/active listening - Modes: Wire Ghost telemetry panel, EVP mic anomaly detection, WebUSB RTL-SDR sweep + waterfall (hardware pass pending), Ouija/Direct Contact - Codex: public registry + entity dossiers, rarity tiers, i18n EN/ES complete - State: VeilSocket (reconnect/backoff), seance reducer, auth context - 72 vitest tests green; served by FastAPI at :7777 Docs: README + as-built plans 3-7
172 lines
4.8 KiB
TypeScript
172 lines
4.8 KiB
TypeScript
// Reconnecting WebSocket client for /ws/session with a typed event emitter.
|
|
// Exponential backoff on unexpected close; surfaces connection status so the
|
|
// UI can show "the connection to the other side is unstable".
|
|
|
|
import type { ClientFrame, ServerFrame } from './types'
|
|
|
|
export type VeilConnectionState =
|
|
| 'connecting'
|
|
| 'open'
|
|
| 'unstable' // reconnecting after a drop
|
|
| 'closed'
|
|
|
|
type FrameHandler = (frame: ServerFrame) => void
|
|
type StateHandler = (state: VeilConnectionState) => void
|
|
|
|
export type VeilSocketOptions = {
|
|
url?: string
|
|
/** Base backoff delay (ms); doubles each retry up to maxBackoffMs. */
|
|
baseBackoffMs?: number
|
|
maxBackoffMs?: number
|
|
/** Ping interval to keep the veil warm (ms). 0 disables. */
|
|
pingIntervalMs?: number
|
|
/** Injectable WebSocket constructor (tests). */
|
|
socketFactory?: (url: string) => WebSocket
|
|
}
|
|
|
|
export function defaultSessionUrl(): string {
|
|
const proto = location.protocol === 'https:' ? 'wss://' : 'ws://'
|
|
return `${proto}${location.host}/ws/session`
|
|
}
|
|
|
|
export class VeilSocket {
|
|
private ws: WebSocket | null = null
|
|
private frameHandlers = new Set<FrameHandler>()
|
|
private stateHandlers = new Set<StateHandler>()
|
|
private readonly url: string
|
|
private readonly baseBackoffMs: number
|
|
private readonly maxBackoffMs: number
|
|
private readonly pingIntervalMs: number
|
|
private readonly socketFactory: (url: string) => WebSocket
|
|
private attempts = 0
|
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
private pingTimer: ReturnType<typeof setInterval> | null = null
|
|
private deliberatelyClosed = false
|
|
private outbox: string[] = []
|
|
private _state: VeilConnectionState = 'closed'
|
|
|
|
constructor(opts: VeilSocketOptions = {}) {
|
|
this.url = opts.url ?? defaultSessionUrl()
|
|
this.baseBackoffMs = opts.baseBackoffMs ?? 800
|
|
this.maxBackoffMs = opts.maxBackoffMs ?? 15000
|
|
this.pingIntervalMs = opts.pingIntervalMs ?? 25000
|
|
this.socketFactory = opts.socketFactory ?? ((url: string) => new WebSocket(url))
|
|
}
|
|
|
|
get state(): VeilConnectionState {
|
|
return this._state
|
|
}
|
|
|
|
onFrame(handler: FrameHandler): () => void {
|
|
this.frameHandlers.add(handler)
|
|
return () => this.frameHandlers.delete(handler)
|
|
}
|
|
|
|
onState(handler: StateHandler): () => void {
|
|
this.stateHandlers.add(handler)
|
|
return () => this.stateHandlers.delete(handler)
|
|
}
|
|
|
|
connect(): void {
|
|
this.deliberatelyClosed = false
|
|
this.openSocket()
|
|
}
|
|
|
|
close(): void {
|
|
this.deliberatelyClosed = true
|
|
this.clearTimers()
|
|
this.ws?.close()
|
|
this.ws = null
|
|
this.setState('closed')
|
|
}
|
|
|
|
/** Send a frame; queued while connecting, dropped while closed/unstable. */
|
|
send(frame: ClientFrame): void {
|
|
const raw = JSON.stringify(frame)
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
this.ws.send(raw)
|
|
} else if (this._state === 'connecting') {
|
|
if (this.outbox.length < 64) this.outbox.push(raw)
|
|
}
|
|
}
|
|
|
|
private setState(state: VeilConnectionState): void {
|
|
if (this._state === state) return
|
|
this._state = state
|
|
this.stateHandlers.forEach((h) => h(state))
|
|
}
|
|
|
|
private clearTimers(): void {
|
|
if (this.reconnectTimer !== null) {
|
|
clearTimeout(this.reconnectTimer)
|
|
this.reconnectTimer = null
|
|
}
|
|
if (this.pingTimer !== null) {
|
|
clearInterval(this.pingTimer)
|
|
this.pingTimer = null
|
|
}
|
|
}
|
|
|
|
private openSocket(): void {
|
|
this.clearTimers()
|
|
this.setState('connecting')
|
|
let ws: WebSocket
|
|
try {
|
|
ws = this.socketFactory(this.url)
|
|
} catch {
|
|
this.scheduleReconnect()
|
|
return
|
|
}
|
|
this.ws = ws
|
|
|
|
ws.onopen = () => {
|
|
this.attempts = 0
|
|
this.setState('open')
|
|
// Flush frames queued while connecting.
|
|
for (const raw of this.outbox) ws.send(raw)
|
|
this.outbox = []
|
|
if (this.pingIntervalMs > 0) {
|
|
this.pingTimer = setInterval(() => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'ping' }))
|
|
}
|
|
}, this.pingIntervalMs)
|
|
}
|
|
}
|
|
|
|
ws.onmessage = (ev: MessageEvent) => {
|
|
if (typeof ev.data !== 'string') return
|
|
let frame: ServerFrame
|
|
try {
|
|
frame = JSON.parse(ev.data) as ServerFrame
|
|
} catch {
|
|
return // ignore malformed whispers
|
|
}
|
|
this.frameHandlers.forEach((h) => h(frame))
|
|
}
|
|
|
|
ws.onclose = () => {
|
|
this.clearTimers()
|
|
this.ws = null
|
|
if (!this.deliberatelyClosed) this.scheduleReconnect()
|
|
else this.setState('closed')
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
// onclose follows onerror; nothing extra to do here.
|
|
}
|
|
}
|
|
|
|
private scheduleReconnect(): void {
|
|
this.setState('unstable')
|
|
const delay = Math.min(
|
|
this.maxBackoffMs,
|
|
this.baseBackoffMs * 2 ** this.attempts,
|
|
)
|
|
this.attempts++
|
|
this.reconnectTimer = setTimeout(() => {
|
|
if (!this.deliberatelyClosed) this.openSocket()
|
|
}, delay)
|
|
}
|
|
}
|