Add uo-link WS ingest, storage tables and SSE broadcaster (phase 1)
The site now ingests the sidecar's live WebSocket feed and persists it to its own MariaDB, and re-broadcasts curated events to browsers over SSE. - schema: shard_events (append-only notable-kind log, sha1 dedupe_key + INSERT IGNORE for idempotent reconnect backfill), shard_online (current players, upsert/refresh/remove), shard_economy (gold-supply series), shard_houses (per-house decay stage + derived is_idoc). - model/shardEvents + model/shardState: the .db.js/.model.js split; writes take camelCase event data, reads are shaped; online upsert uses COALESCE so a partial char.vitals refresh never blanks login fields. - utils/shardIngest: single dispatcher routing each kind to state writes and/or the event log, then the broadcaster. High-frequency kinds (char.vitals, economy.supply) update state only. A changed server.hello bootId clears the stale online roster. Deps are injected for unit testing. - utils/uoLinkSocket: the server's first outbound WS client (ws dep). Verifies the ws.hello protocol, backfills via /history + /economy on every (re)connect (dedupe handles overlap), reconnects with capped backoff, and mirrors connection state into uo_link_config. Self-guards: only connects when the integration is enabled with a token. - utils/shardBroadcast: SSE fan-out with public (safe kinds only) and admin (all) channels, keepalive pings, per-client cleanup. - server.js: start the ingest socket on boot (no-op until configured) and stop it + close SSE streams on graceful shutdown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
This commit is contained in:
116
server/src/utils/shardBroadcast.js
Normal file
116
server/src/utils/shardBroadcast.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// ── Shard live-feed SSE broadcaster ────────────────────────────────────────
|
||||
//
|
||||
// The browser can't talk to the sidecar's WebSocket directly (the token must
|
||||
// never reach it, and the WS may be on another host). Instead the server ingests
|
||||
// the WS feed and re-broadcasts curated events to browsers over Server-Sent
|
||||
// Events (plain HTTP — works through any reverse proxy).
|
||||
//
|
||||
// Two channels:
|
||||
// • public — safe kinds only (sales, deaths, IDOC, logins, economy). No IPs,
|
||||
// no account-login attempts, no staff audit / cheat events.
|
||||
// • admin — everything, including the sensitive kinds above.
|
||||
//
|
||||
// shardIngest calls broadcast(event) for each ingested event; the public/admin
|
||||
// SSE route handlers call subscribe(req, res, channel).
|
||||
|
||||
const log = require('./logger')('shard-broadcast')
|
||||
|
||||
// Kinds safe to expose to unauthenticated browsers.
|
||||
const PUBLIC_KINDS = new Set([
|
||||
'vendor.sale',
|
||||
'player.death',
|
||||
'player.murdered',
|
||||
'mob.killed',
|
||||
'house.decay',
|
||||
'quest.complete',
|
||||
'skill.gain',
|
||||
'fame.change',
|
||||
'karma.change',
|
||||
'mob.login',
|
||||
'mob.logout',
|
||||
'economy.supply',
|
||||
'server.hello',
|
||||
'server.shutdown',
|
||||
'server.crashed',
|
||||
])
|
||||
|
||||
// Open response streams per channel.
|
||||
const clients = { public: new Set(), admin: new Set() }
|
||||
|
||||
const KEEPALIVE_MS = 25000
|
||||
|
||||
// Register an SSE stream on a channel. Sets the SSE headers, sends an initial
|
||||
// comment, keeps the connection warm with periodic pings, and cleans up on close.
|
||||
function subscribe(req, res, channel) {
|
||||
const bucket = clients[channel]
|
||||
if (!bucket) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no', // disable proxy buffering so events flush immediately
|
||||
})
|
||||
res.write('retry: 5000\n\n') // tell EventSource to reconnect after 5s if dropped
|
||||
res.write(': connected\n\n')
|
||||
|
||||
bucket.add(res)
|
||||
|
||||
const ping = setInterval(() => {
|
||||
try {
|
||||
res.write(': ping\n\n')
|
||||
} catch {
|
||||
/* write after close — cleanup below handles it */
|
||||
}
|
||||
}, KEEPALIVE_MS)
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(ping)
|
||||
bucket.delete(res)
|
||||
}
|
||||
req.on('close', cleanup)
|
||||
res.on('error', cleanup)
|
||||
}
|
||||
|
||||
function writeTo(bucket, payload) {
|
||||
for (const res of bucket) {
|
||||
try {
|
||||
res.write(payload)
|
||||
} catch (err) {
|
||||
log.warn('sse write failed; dropping client', { message: err.message })
|
||||
bucket.delete(res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fan an ingested event out to the admin channel (always) and the public
|
||||
// channel (safe kinds only). A no-op when nobody is subscribed.
|
||||
function broadcast(event) {
|
||||
if (!event || !event.kind) return
|
||||
const frame = `data: ${JSON.stringify(event)}\n\n`
|
||||
if (clients.admin.size) writeTo(clients.admin, frame)
|
||||
if (clients.public.size && PUBLIC_KINDS.has(event.kind)) writeTo(clients.public, frame)
|
||||
}
|
||||
|
||||
// Close every open stream (graceful shutdown).
|
||||
function closeAll() {
|
||||
for (const channel of Object.values(clients)) {
|
||||
for (const res of channel) {
|
||||
try {
|
||||
res.end()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
channel.clear()
|
||||
}
|
||||
}
|
||||
|
||||
function stats() {
|
||||
return { publicClients: clients.public.size, adminClients: clients.admin.size }
|
||||
}
|
||||
|
||||
module.exports = { subscribe, broadcast, closeAll, stats, PUBLIC_KINDS }
|
||||
Reference in New Issue
Block a user