feat(playground): WS multiplayer sidecar (scripts/playground-ws.mjs) + dual transport in client hook (BroadcastChannel + WS)

This commit is contained in:
Aurora release bot
2026-05-03 07:32:15 -04:00
parent 5fec57b466
commit abfe56cc7c
3 changed files with 166 additions and 0 deletions

View File

@@ -16,6 +16,7 @@
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",
"smoke:managed": "node scripts/managed-companion-smoke.mjs", "smoke:managed": "node scripts/managed-companion-smoke.mjs",
"playground:ws": "node scripts/playground-ws.mjs",
"lint": "eslint", "lint": "eslint",
"format": "prettier", "format": "prettier",
"check": "prettier --write . && eslint --fix", "check": "prettier --write . && eslint --fix",

102
scripts/playground-ws.mjs Normal file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* Hermes Playground WebSocket presence hub.
*
* Tiny stateless relay: every client publishes a presence/chat envelope,
* the server fans it out to every other client. The server keeps an
* in-memory map of last-seen presence to quickly bootstrap newcomers.
*
* Wire format mirrors `usePlaygroundMultiplayer` so we can swap the
* client transport without changing protocol.
*
* Run:
* node scripts/playground-ws.mjs # default port 8787
* PORT=9000 node scripts/playground-ws.mjs
*
* For client config:
* VITE_PLAYGROUND_WS_URL=ws://localhost:8787 pnpm dev
*
* Deploy:
* This is a 70-line ws relay. Drop in any Node host (Fly.io, Render,
* Railway, Cloudflare Workers w/ Durable Objects, EC2, etc.). No DB
* required for v0. Add Redis if you want multi-instance fanout.
*/
import http from 'node:http'
import { WebSocketServer } from 'ws'
const PORT = Number(process.env.PORT || 8787)
const STALE_AFTER_MS = 6000
const presence = new Map() // id -> last presence wire
const chatRing = []
const CHAT_RING_MAX = 50
const server = http.createServer((req, res) => {
if (req.url === '/' || req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, players: presence.size, ts: Date.now() }))
return
}
res.writeHead(404)
res.end()
})
const wss = new WebSocketServer({ server, path: '/playground' })
function broadcast(originSocket, data) {
const payload = typeof data === 'string' ? data : JSON.stringify(data)
for (const client of wss.clients) {
if (client !== originSocket && client.readyState === 1) {
try { client.send(payload) } catch {}
}
}
}
function pruneStale() {
const cutoff = Date.now() - STALE_AFTER_MS
for (const [id, p] of presence) {
if (p.ts < cutoff) {
presence.delete(id)
broadcast(null, { kind: 'leave', id })
}
}
}
setInterval(pruneStale, 1000)
wss.on('connection', (socket, req) => {
socket.id = `c_${Math.random().toString(36).slice(2, 10)}`
socket.send(JSON.stringify({ kind: 'hello', server: 'hermes.playground.v0', ts: Date.now() }))
// Snapshot existing presence for the newcomer
for (const p of presence.values()) {
try { socket.send(JSON.stringify(p)) } catch {}
}
for (const c of chatRing) {
try { socket.send(JSON.stringify(c)) } catch {}
}
socket.on('message', (raw) => {
let msg
try { msg = JSON.parse(raw.toString()) } catch { return }
if (!msg || typeof msg.kind !== 'string') return
if (msg.kind === 'presence' && msg.id) {
presence.set(msg.id, msg)
broadcast(socket, msg)
} else if (msg.kind === 'chat' && msg.id) {
chatRing.push(msg)
if (chatRing.length > CHAT_RING_MAX) chatRing.shift()
broadcast(socket, msg)
} else if (msg.kind === 'leave' && msg.id) {
presence.delete(msg.id)
broadcast(socket, msg)
}
})
socket.on('close', () => {
// The client should send 'leave', but if it doesn't we'll reap on staleness
})
})
server.listen(PORT, () => {
console.log(`[hermes-playground-ws] listening on :${PORT} path=/playground`)
})

View File

@@ -79,13 +79,69 @@ export function usePlaygroundMultiplayer({
const myName = name && name.trim().length > 0 ? name.trim() : `Builder-${selfId.slice(2, 6)}` const myName = name && name.trim().length > 0 ? name.trim() : `Builder-${selfId.slice(2, 6)}`
const channelRef = useRef<BroadcastChannel | null>(null) const channelRef = useRef<BroadcastChannel | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const wsOpenRef = useRef(false)
const [remotePlayers, setRemotePlayers] = useState<Record<string, RemotePlayer>>({}) const [remotePlayers, setRemotePlayers] = useState<Record<string, RemotePlayer>>({})
const [online, setOnline] = useState(false) const [online, setOnline] = useState(false)
const [transport, setTransport] = useState<'broadcast' | 'ws' | 'both'>('broadcast')
// Stable refs to avoid re-subscribing // Stable refs to avoid re-subscribing
const onChatRef = useRef(onChat) const onChatRef = useRef(onChat)
useEffect(() => { onChatRef.current = onChat }, [onChat]) useEffect(() => { onChatRef.current = onChat }, [onChat])
// Open WebSocket transport (optional, controlled by VITE_PLAYGROUND_WS_URL)
useEffect(() => {
if (typeof window === 'undefined') return
const url = (import.meta as any).env?.VITE_PLAYGROUND_WS_URL as string | undefined
if (!url) return
let ws: WebSocket | null = null
let stop = false
let retry = 0
const open = () => {
if (stop) return
try {
ws = new WebSocket(url + (url.endsWith('/playground') ? '' : '/playground'))
} catch {
return
}
wsRef.current = ws
ws.addEventListener('open', () => {
wsOpenRef.current = true
retry = 0
setTransport((t) => (t === 'broadcast' ? 'both' : 'ws'))
})
ws.addEventListener('message', (ev) => {
let msg: Wire | { kind: 'hello' }
try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : '') } catch { return }
if (!msg || !('kind' in msg)) return
if (msg.kind === 'hello') return
if (msg.kind === 'presence' && msg.id !== selfId) {
setRemotePlayers((prev) => ({ ...prev, [msg.id]: msg as RemotePlayer }))
} else if (msg.kind === 'leave' && msg.id !== selfId) {
setRemotePlayers((prev) => { const { [msg.id]: _, ...rest } = prev; return rest })
} else if (msg.kind === 'chat' && msg.id !== selfId) {
onChatRef.current?.(msg as ChatWire)
}
})
ws.addEventListener('close', () => {
wsOpenRef.current = false
wsRef.current = null
setTransport((t) => (t === 'both' ? 'broadcast' : t === 'ws' ? 'broadcast' : t))
if (!stop) {
retry = Math.min(8, retry + 1)
window.setTimeout(open, retry * 500)
}
})
ws.addEventListener('error', () => { try { ws?.close() } catch {} })
}
open()
return () => {
stop = true
try { ws?.close() } catch {}
wsRef.current = null
}
}, [selfId])
// Open channel // Open channel
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined' || typeof BroadcastChannel === 'undefined') return if (typeof window === 'undefined' || typeof BroadcastChannel === 'undefined') return
@@ -146,6 +202,9 @@ export function usePlaygroundMultiplayer({
ts: Date.now(), ts: Date.now(),
} }
try { ch.postMessage(wire) } catch {} try { ch.postMessage(wire) } catch {}
if (wsOpenRef.current && wsRef.current) {
try { wsRef.current.send(JSON.stringify(wire)) } catch {}
}
const cutoff = Date.now() - STALE_AFTER_MS const cutoff = Date.now() - STALE_AFTER_MS
setRemotePlayers((prev) => { setRemotePlayers((prev) => {
let dirty = false let dirty = false
@@ -173,6 +232,9 @@ export function usePlaygroundMultiplayer({
ts: Date.now(), ts: Date.now(),
} }
try { ch.postMessage(wire) } catch {} try { ch.postMessage(wire) } catch {}
if (wsOpenRef.current && wsRef.current) {
try { wsRef.current.send(JSON.stringify(wire)) } catch {}
}
}, [selfId, myName, myColor, world]) }, [selfId, myName, myColor, world])
return { return {
@@ -180,6 +242,7 @@ export function usePlaygroundMultiplayer({
myName, myName,
myColor, myColor,
online, online,
transport,
remotePlayers, remotePlayers,
sendChat, sendChat,
} }