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",
|
"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
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 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,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user