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 }
|
||||
187
server/src/utils/shardIngest.js
Normal file
187
server/src/utils/shardIngest.js
Normal file
@@ -0,0 +1,187 @@
|
||||
// ── Shard event ingest dispatcher ──────────────────────────────────────────
|
||||
//
|
||||
// The single entry point for every event that arrives on the uo-link WebSocket
|
||||
// feed (and for backfilled /history events on reconnect). It routes by kind:
|
||||
// • state-changing kinds update shard_online / shard_economy / shard_houses,
|
||||
// • notable kinds are appended to the append-only shard_events log,
|
||||
// • every kind is fanned out to the SSE broadcaster (which decides public vs
|
||||
// admin visibility).
|
||||
// High-frequency kinds (char.vitals, economy.supply) are deliberately NOT logged
|
||||
// to shard_events — they only update state — keeping the event log lean.
|
||||
//
|
||||
// Dependencies are injected (defaulting to the real models) so the routing can
|
||||
// be unit-tested with mocked writes.
|
||||
|
||||
const shardEventsModel = require('../model/shardEvents/shardEvents.model')
|
||||
const shardStateModel = require('../model/shardState/shardState.model')
|
||||
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const broadcaster = require('./shardBroadcast')
|
||||
const defaultLog = require('./logger')('shard-ingest')
|
||||
|
||||
// Notable kinds appended to the shard_events log. High-frequency/session kinds
|
||||
// (char.vitals, economy.supply, mob.login/logout, account.login.attempt,
|
||||
// gold.change, vendor.buy/sell) are excluded on purpose. house.decay is handled
|
||||
// specially — logged only on the transition INTO IDOC.
|
||||
const LOGGED_KINDS = new Set([
|
||||
'vendor.sale',
|
||||
'player.death',
|
||||
'player.murdered',
|
||||
'mob.killed',
|
||||
'quest.complete',
|
||||
'skill.gain',
|
||||
'fame.change',
|
||||
'karma.change',
|
||||
'audit.set',
|
||||
'audit.command',
|
||||
'cheat.fastwalk',
|
||||
'link.request',
|
||||
'server.hello',
|
||||
'server.shutdown',
|
||||
'server.crashed',
|
||||
])
|
||||
|
||||
// Tracks the current shard boot id so a restart (changed bootId on server.hello)
|
||||
// can be detected and stale online state dropped. Module-level so it survives
|
||||
// across events within a process; reset() is exposed for tests.
|
||||
const state = { bootId: null }
|
||||
function reset() {
|
||||
state.bootId = null
|
||||
}
|
||||
|
||||
// Should this event be written to the append-only log?
|
||||
function shouldLog(event) {
|
||||
if (event.kind === 'house.decay') return String(event.to).toUpperCase() === 'IDOC'
|
||||
return LOGGED_KINDS.has(event.kind)
|
||||
}
|
||||
|
||||
// Apply the state-change side effect for a kind (if any). Returns a promise.
|
||||
async function applyStateChange(event, deps) {
|
||||
const { shardState, uoLinkConfig, log } = deps
|
||||
switch (event.kind) {
|
||||
case 'server.hello': {
|
||||
const incoming = event.bootId || null
|
||||
if (incoming && state.bootId && incoming !== state.bootId) {
|
||||
log.warn('shard restarted (bootId changed) — clearing online roster', {
|
||||
from: state.bootId,
|
||||
to: incoming,
|
||||
})
|
||||
await shardState.clearOnline()
|
||||
}
|
||||
if (incoming) state.bootId = incoming
|
||||
await uoLinkConfig.recordStatus({ pluginConnected: true, bootId: incoming, lastEventAt: event.t })
|
||||
return
|
||||
}
|
||||
case 'server.shutdown':
|
||||
case 'server.crashed':
|
||||
// Shard is going away — nobody is online anymore.
|
||||
await shardState.clearOnline()
|
||||
await uoLinkConfig.recordStatus({ pluginConnected: false })
|
||||
return
|
||||
case 'mob.login': {
|
||||
const who = event.who || {}
|
||||
await shardState.upsertOnline({
|
||||
serial: who.serial,
|
||||
name: who.name,
|
||||
acct: who.acct,
|
||||
webId: event.webId,
|
||||
map: event.map,
|
||||
x: event.x,
|
||||
y: event.y,
|
||||
z: event.z,
|
||||
})
|
||||
return
|
||||
}
|
||||
case 'mob.logout': {
|
||||
const who = event.who || {}
|
||||
if (who.serial) await shardState.setOffline(who.serial)
|
||||
return
|
||||
}
|
||||
case 'char.vitals':
|
||||
await shardState.upsertOnline({
|
||||
serial: event.serial,
|
||||
hits: event.hits,
|
||||
hitsMax: event.hitsMax,
|
||||
mana: event.mana,
|
||||
manaMax: event.manaMax,
|
||||
stam: event.stam,
|
||||
stamMax: event.stamMax,
|
||||
str: event.str,
|
||||
dex: event.dex,
|
||||
int: event.int,
|
||||
map: event.map,
|
||||
x: event.x,
|
||||
y: event.y,
|
||||
})
|
||||
return
|
||||
case 'economy.supply':
|
||||
await shardState.addEconomySample({ accounts: event.accounts, gold: event.gold, t: event.t })
|
||||
return
|
||||
case 'house.decay':
|
||||
await shardState.upsertHouse({
|
||||
serial: event.serial,
|
||||
stage: event.to,
|
||||
map: event.map,
|
||||
x: event.x,
|
||||
y: event.y,
|
||||
z: event.z,
|
||||
region: event.region,
|
||||
name: event.name,
|
||||
ownerSerial: event.ownerSerial,
|
||||
ownerAcct: event.ownerAcct,
|
||||
builtOn: event.builtOn,
|
||||
lastRefreshed: event.lastRefreshed,
|
||||
})
|
||||
return
|
||||
default:
|
||||
// No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and
|
||||
// broadcasting still happen in ingest().
|
||||
}
|
||||
}
|
||||
|
||||
// Ingest one event. Returns { logged, stored } for tests/stats. `fromBackfill`
|
||||
// suppresses the SSE broadcast (a reconnect replay shouldn't re-animate the
|
||||
// live ticker). Never throws — a bad single event must not kill the feed.
|
||||
async function ingest(event, deps = {}) {
|
||||
const d = {
|
||||
shardEvents: deps.shardEvents || shardEventsModel,
|
||||
shardState: deps.shardState || shardStateModel,
|
||||
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
|
||||
broadcast: deps.broadcast || broadcaster.broadcast,
|
||||
log: deps.log || defaultLog,
|
||||
}
|
||||
|
||||
if (!event || typeof event.kind !== 'string') return { logged: false, stored: false }
|
||||
// ws.hello / pong are transport frames, not game events.
|
||||
if (event.kind === 'ws.hello' || event.kind === 'pong') return { logged: false, stored: false }
|
||||
|
||||
const t = Number.isFinite(event.t) ? event.t : Date.now()
|
||||
let stored = false
|
||||
let logged = false
|
||||
|
||||
try {
|
||||
await applyStateChange(event, d)
|
||||
} catch (err) {
|
||||
d.log.warn('state-change write failed', { kind: event.kind, message: err.message })
|
||||
}
|
||||
|
||||
if (shouldLog(event)) {
|
||||
logged = true
|
||||
try {
|
||||
stored = await d.shardEvents.append({ kind: event.kind, t, bootId: state.bootId, payload: event })
|
||||
} catch (err) {
|
||||
d.log.warn('event log write failed', { kind: event.kind, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
if (!deps.fromBackfill) {
|
||||
try {
|
||||
d.broadcast(event)
|
||||
} catch (err) {
|
||||
d.log.warn('broadcast failed', { kind: event.kind, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
return { logged, stored }
|
||||
}
|
||||
|
||||
module.exports = { ingest, shouldLog, reset, LOGGED_KINDS, state }
|
||||
195
server/src/utils/uoLinkSocket.js
Normal file
195
server/src/utils/uoLinkSocket.js
Normal file
@@ -0,0 +1,195 @@
|
||||
// ── uo-link WebSocket ingest client ────────────────────────────────────────
|
||||
//
|
||||
// Long-lived client that connects to the sidecar's push-only WS feed and pumps
|
||||
// every frame through the ingest dispatcher. This is the server's first
|
||||
// outbound WebSocket. Lifecycle:
|
||||
// • start() — connect if the config is enabled and has a token; verify the
|
||||
// ws.hello protocol; backfill missed events via /history on every
|
||||
// (re)connect (INSERT IGNORE dedupes the overlap); reconnect with
|
||||
// capped backoff.
|
||||
// • stop() — close the socket and stop reconnecting (graceful shutdown).
|
||||
// Connection state is mirrored into uo_link_config (plugin_connected / status /
|
||||
// last_event_at) so the admin panel and public status endpoint have live data.
|
||||
|
||||
const WebSocket = require('ws')
|
||||
|
||||
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const uoLinkClient = require('./uoLinkClient')
|
||||
const shardIngest = require('./shardIngest')
|
||||
const log = require('./logger')('uo-link-socket')
|
||||
|
||||
const BACKOFF_MIN_MS = 1000
|
||||
const BACKOFF_MAX_MS = 30000
|
||||
const BACKFILL_LIMIT = 500
|
||||
|
||||
let ws = null
|
||||
let reconnectTimer = null
|
||||
let backoff = BACKOFF_MIN_MS
|
||||
let running = false // set by start()/stop(); guards auto-reconnect
|
||||
let helloSeen = false
|
||||
|
||||
const state = {
|
||||
connected: false,
|
||||
lastEventAt: null,
|
||||
lastConnectedAt: null,
|
||||
reconnects: 0,
|
||||
protocol: null,
|
||||
}
|
||||
|
||||
function buildUrl(wsUrl, token) {
|
||||
const sep = wsUrl.includes('?') ? '&' : '?'
|
||||
return token ? `${wsUrl}${sep}token=${encodeURIComponent(token)}` : wsUrl
|
||||
}
|
||||
|
||||
// Pull recent events from the sidecar's own store and replay them through the
|
||||
// dispatcher (fromBackfill = no SSE re-broadcast). dedupe_key + INSERT IGNORE
|
||||
// make this idempotent, so overlap with what we already stored is harmless.
|
||||
async function backfill() {
|
||||
try {
|
||||
const hist = await uoLinkClient.getHistory({ limit: BACKFILL_LIMIT })
|
||||
if (hist.ok && hist.data && Array.isArray(hist.data.events)) {
|
||||
// History is newest-first; replay oldest-first so latest-wins state (e.g.
|
||||
// house.decay stage) settles correctly.
|
||||
const events = [...hist.data.events].reverse()
|
||||
for (const ev of events) await shardIngest.ingest(ev, { fromBackfill: true })
|
||||
log.info('backfilled events from /history', { count: events.length })
|
||||
}
|
||||
const eco = await uoLinkClient.getEconomy(200)
|
||||
if (eco.ok && eco.data && Array.isArray(eco.data.series)) {
|
||||
const series = [...eco.data.series].reverse()
|
||||
for (const ev of series) await shardIngest.ingest(ev, { fromBackfill: true })
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('backfill failed (continuing on live feed)', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (!running) return
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = setTimeout(connect, backoff)
|
||||
log.info(`reconnecting in ${backoff}ms`)
|
||||
backoff = Math.min(backoff * 2, BACKOFF_MAX_MS)
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (!running) return
|
||||
let config
|
||||
try {
|
||||
config = await uoLinkConfig.getWithToken()
|
||||
} catch (err) {
|
||||
log.error('could not read uo-link config', err)
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
if (!config || !config.enabled || !config.wsUrl || !config.token) {
|
||||
log.info('uo-link WS not started (disabled or missing url/token)')
|
||||
running = false
|
||||
return
|
||||
}
|
||||
|
||||
state.protocol = config.protocol || 1
|
||||
helloSeen = false
|
||||
const url = buildUrl(config.wsUrl, config.token)
|
||||
|
||||
try {
|
||||
ws = new WebSocket(url)
|
||||
} catch (err) {
|
||||
log.error('failed to open WS', err)
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
ws.on('open', async () => {
|
||||
log.info('uo-link WS connected')
|
||||
state.connected = true
|
||||
state.lastConnectedAt = Date.now()
|
||||
backoff = BACKOFF_MIN_MS
|
||||
await uoLinkConfig.recordStatus({ status: 'connected', statusDetail: null, pluginConnected: true }).catch(() => {})
|
||||
await backfill()
|
||||
})
|
||||
|
||||
ws.on('message', async (raw) => {
|
||||
let event
|
||||
try {
|
||||
event = JSON.parse(raw.toString())
|
||||
} catch {
|
||||
log.warn('dropping non-JSON WS frame')
|
||||
return
|
||||
}
|
||||
|
||||
if (event.kind === 'ws.hello') {
|
||||
helloSeen = true
|
||||
if (event.protocol && event.protocol !== state.protocol) {
|
||||
log.error('uo-link protocol mismatch on ws.hello — closing', {
|
||||
expected: state.protocol,
|
||||
got: event.protocol,
|
||||
})
|
||||
await uoLinkConfig
|
||||
.recordStatus({ status: 'error', statusDetail: `protocol mismatch: expected ${state.protocol}, got ${event.protocol}` })
|
||||
.catch(() => {})
|
||||
running = false
|
||||
try {
|
||||
ws.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.kind === 'pong') return // sidecar heartbeat — ignore
|
||||
|
||||
state.lastEventAt = Number.isFinite(event.t) ? event.t : Date.now()
|
||||
await shardIngest.ingest(event)
|
||||
})
|
||||
|
||||
ws.on('close', async () => {
|
||||
state.connected = false
|
||||
if (running) state.reconnects += 1
|
||||
log.warn('uo-link WS closed')
|
||||
await uoLinkConfig
|
||||
.recordStatus({ status: running ? 'reconnecting' : 'disconnected', pluginConnected: false })
|
||||
.catch(() => {})
|
||||
ws = null
|
||||
scheduleReconnect()
|
||||
})
|
||||
|
||||
ws.on('error', (err) => {
|
||||
log.warn('uo-link WS error', { message: err.message })
|
||||
// 'close' fires after 'error'; reconnect is scheduled there.
|
||||
})
|
||||
}
|
||||
|
||||
// Begin (or restart) the WS client. Idempotent — a running client is stopped
|
||||
// first so a config save can re-point it at a new URL/token.
|
||||
async function start() {
|
||||
stop()
|
||||
running = true
|
||||
backoff = BACKOFF_MIN_MS
|
||||
await connect()
|
||||
}
|
||||
|
||||
// Stop the client and cancel any pending reconnect. Called on shutdown and
|
||||
// before a restart.
|
||||
function stop() {
|
||||
running = false
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
if (ws) {
|
||||
try {
|
||||
ws.removeAllListeners()
|
||||
ws.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
ws = null
|
||||
}
|
||||
state.connected = false
|
||||
}
|
||||
|
||||
// Ingestion stats for the admin panel.
|
||||
function getState() {
|
||||
return { ...state, running }
|
||||
}
|
||||
|
||||
module.exports = { start, stop, getState }
|
||||
Reference in New Issue
Block a user