feat(playground): WS multiplayer sidecar (scripts/playground-ws.mjs) + dual transport in client hook (BroadcastChannel + WS)
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"smoke:managed": "node scripts/managed-companion-smoke.mjs",
|
||||
"playground:ws": "node scripts/playground-ws.mjs",
|
||||
"lint": "eslint",
|
||||
"format": "prettier",
|
||||
"check": "prettier --write . && eslint --fix",
|
||||
|
||||
102
scripts/playground-ws.mjs
Normal file
102
scripts/playground-ws.mjs
Normal 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`)
|
||||
})
|
||||
@@ -79,13 +79,69 @@ export function usePlaygroundMultiplayer({
|
||||
const myName = name && name.trim().length > 0 ? name.trim() : `Builder-${selfId.slice(2, 6)}`
|
||||
|
||||
const channelRef = useRef<BroadcastChannel | null>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const wsOpenRef = useRef(false)
|
||||
const [remotePlayers, setRemotePlayers] = useState<Record<string, RemotePlayer>>({})
|
||||
const [online, setOnline] = useState(false)
|
||||
const [transport, setTransport] = useState<'broadcast' | 'ws' | 'both'>('broadcast')
|
||||
|
||||
// Stable refs to avoid re-subscribing
|
||||
const onChatRef = useRef(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
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof BroadcastChannel === 'undefined') return
|
||||
@@ -146,6 +202,9 @@ export function usePlaygroundMultiplayer({
|
||||
ts: Date.now(),
|
||||
}
|
||||
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
|
||||
setRemotePlayers((prev) => {
|
||||
let dirty = false
|
||||
@@ -173,6 +232,9 @@ export function usePlaygroundMultiplayer({
|
||||
ts: Date.now(),
|
||||
}
|
||||
try { ch.postMessage(wire) } catch {}
|
||||
if (wsOpenRef.current && wsRef.current) {
|
||||
try { wsRef.current.send(JSON.stringify(wire)) } catch {}
|
||||
}
|
||||
}, [selfId, myName, myColor, world])
|
||||
|
||||
return {
|
||||
@@ -180,6 +242,7 @@ export function usePlaygroundMultiplayer({
|
||||
myName,
|
||||
myColor,
|
||||
online,
|
||||
transport,
|
||||
remotePlayers,
|
||||
sendChat,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user