Files
Module-uo/server/boot.js
wtclaude f335531538
All checks were successful
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Successful in 41s
PR Checks / server-tests (pull_request) Successful in 8m23s
feat(assets): item pictures on the marketplace and the character sheet (Phase 5)
Both places this site already knew an item's (ItemID, hue) and could only print
it as text now show the picture, hued the way the client would draw it. The
shard does the hueing: whether a hue repaints every pixel or only the grey ones
is a flag in `tiledata.mul`, which a browser has no way to read.

**Ingest warms; the route only serves** (org lead, 2026-09-11). A page never
waits on the shard and never causes a fetch -- it renders what is stored and
leaves out what is not, which is the state every install was in before this
phase. Fetching happens behind that, on a timer, from the keys the site's own
rows name. The alternative, fetching on first request, was rejected on one
number: the shard's asset plane serves ONE request at a time, so a URL that
fetched would let any visitor walk 49,152 ids times 3,000 hues through that slot
and park an operator's own import behind it.

The wanted set is DERIVED (`SELECT DISTINCT item_id, hue`) rather than queued, so
it is self-healing: a restart loses nothing, and a key stops being wanted the
moment the vendor row naming it is deleted. The in-memory hint set on top is only
for the character sheet, which is fetched live from the shard and stored nowhere
-- nothing on disk would ever name those keys.

Staleness without a manifest (§7): every row records the shard's `catalog` id, a
hash of the files that decide its bytes. A client patch changes it and a restart
does not, so "is this out of date?" is a per-row question -- and pictures nobody
looks at any more are simply never re-fetched, which is why this is lazy rather
than a sweep. `shard_asset_meta` is deliberately NOT written here: it is the body
catalogue's singleton, and a warm pass touching it would tell the body import
that a client it never looked at is unchanged.

A key the shard has no art for writes no row at all. An empty row would make the
key held and it would never be asked again -- including after the operator
patches in the graphic that was missing.

`assets.sources` now reports which families an overlay serves, so an overlay
older than phase 5 is one reported state with a sentence naming the fix, instead
of a refusal per pass forever with no picture ever appearing.

688 server tests pass (14 new); client builds; the frozen manifest regenerates
with one added route, all documented, no core URL moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-11 06:13:08 -05:00

140 lines
7.0 KiB
JavaScript

// ── onBoot / onShutdown ────────────────────────────────────────────────────
//
// The eight UO call sites that used to sit in core's `server.js`. `register()`
// runs with no database (MODULE_API.md §2.2); everything here runs with one.
//
// Core dispatches `onBoot` after `ensureSchema` and the schema-fragment replay,
// and **before the HTTP listener binds** — so the tables these functions touch
// exist, and nothing is served until the warm-up finishes. That ordering is the
// contract's promise rather than an accident, and it is why `onBoot` has no
// timeout: a module that must not serve traffic until a cache is warm only gets
// that guarantee if the listener is still closed.
//
// **One behavioural change, and it is deliberate.** In core, `uoLinkSocket.start()`
// and the sidecar health probe ran AFTER the listener bound; here they run before
// it. `start()` returns as soon as the reconnecting client is armed, so that part
// is free — but the probe is a real HTTP call to the sidecar, and an unreachable
// sidecar must not hold the site closed. It is therefore fired and NOT awaited,
// with its own catch. Reporting whether the bridge is up is diagnostics; being up
// is not a precondition for serving a page, and the site is required to degrade
// gracefully when the shard is down.
//
// Everything here is best-effort by the same rule. A module whose `onBoot`
// throws is marked `startup_failed` and its routes answer 503 (§4.4), which is
// the right outcome for a broken module — but "the operator has not configured a
// ServUO path" is not a broken module, and neither is "the shard is offline".
const core = require('./core')
const uoLinkSocket = require('./utils/uoLinkSocket')
const uoLinkClient = require('./utils/uoLinkClient')
const uoLinkConfig = require('./model/uoLinkConfig/uoLinkConfig.model')
const shardBroadcast = require('./utils/shardBroadcast')
const shardAtlas = require('./model/shardAtlas/shardAtlas.model')
const shardClilocs = require('./model/shardClilocs/shardClilocs.model')
const shardMarket = require('./model/shardMarket/shardMarket.model')
const shardItemArt = require('./model/shardAssets/shardItemArt.model')
/**
* Best-effort startup probe of the uo-link sidecar.
*
* Logs whether it is reachable and warns loudly on a protocol mismatch —
* fail-fast visibility rather than silently mis-parsing a newer wire format.
* Never throws, and is never awaited by `onBoot`.
*/
async function checkUoLink() {
const log = core.logger('boot')
const config = await uoLinkConfig.getSafe()
if (!config.enabled) return
const health = await uoLinkClient.health()
if (!health.ok) {
log.warn('uo-link is enabled but the sidecar is unreachable at startup', {
baseUrl: config.baseUrl,
error: health.error || `status ${health.status}`,
})
return
}
if (health.data && health.data.protocol && health.data.protocol !== config.protocol) {
log.error('uo-link PROTOCOL MISMATCH — pinned vs sidecar', {
pinned: config.protocol,
sidecar: health.data.protocol,
})
} else {
log.info('uo-link sidecar reachable', {
pluginConnected: health.data && health.data.plugin_connected,
protocol: health.data && health.data.protocol,
})
}
}
async function onBoot() {
const log = core.logger('boot')
// Re-derive the spawn atlas from the shard's own ServUO tree. The shard's maps
// change over its lifetime — facets get added, replaced or renamed — so the
// atlas is rebuilt on every boot rather than shipped as a snapshot that would
// silently go stale. Hash-gated, so an unchanged tree costs one read pass and
// no database write.
//
// Best-effort by contract: no configured path, an unreadable mount or a
// malformed file must never stop the site coming up. A refresh that would
// REMOVE a facet is staged for admin approval instead of being applied.
await shardAtlas.refreshOnBoot()
// Refresh the cliloc table (UO's id → display-string map).
//
// **On an install with uo-link configured this imports nothing** — protocol 8
// moved the base table to the shard, and asking for it would put a sidecar
// round trip in the boot sequence to answer a question whose answer is "no"
// except after a client patch. That is an operator action, so importing is an
// operator action: Admin → Shard (docs/link/v8.md §9).
//
// Without a shard link it is the old file pipeline, unchanged: hash-gated so an
// unchanged file costs one read, and best-effort so a missing or wrong-format
// file never stops the site coming up — it just means item names render as ids,
// which is what they did before the table existed.
const clilocResult = await shardClilocs.refreshOnBoot()
// A cliloc import changes what item names RESOLVE to, and the marketplace
// stores those names denormalized (shard_vendor_items.display_name) so it can
// index and search them. The shard's market sweep will not re-send an unchanged
// shop just because the site learned what its items are called, so the backfill
// has to be pulled rather than waited for. Only after an actual import — the
// common boot is hash-gated to a no-op and must stay one.
if (clilocResult && clilocResult.status === 'imported') await shardMarket.refreshDisplayNames()
// Start the uo-link WebSocket ingest client. Self-guards: it only actually
// connects when the admin has enabled the integration and saved a token, so
// this is a no-op on shards that haven't configured the sidecar.
try {
await uoLinkSocket.start()
} catch (err) {
log.warn('uo-link socket failed to start (continuing)', { error: err.message })
}
// Deliberately not awaited — see the header. An unreachable sidecar would
// otherwise hold the listener closed for the length of an HTTP timeout.
checkUoLink().catch((err) => log.warn('uo-link startup probe failed', { error: err.message }))
// Item and land pictures for the keys this site's own rows name (§11, phase 5).
//
// A timer rather than a boot pass, and it is the same rule §9.2 set for clilocs:
// **boot does not call the shard.** The first pass is one interval away, so an
// unreachable sidecar costs a log line rather than a startup delay, and an
// operator who has just configured the bridge does not have to restart to get
// pictures. `unref`ed, so it never holds shutdown open.
shardItemArt.startWarming()
}
async function onShutdown() {
// Core runs this FIRST in its signal handler, while everything it handed over
// still works — the pool is open, the push dispatcher is up, the SSE fan-out
// is live. It is the only chance to close cleanly, and it is budgeted, so a
// hook that will not let go costs five seconds rather than the whole shutdown.
shardItemArt.stopWarming() // stop the item-art warm pass
uoLinkSocket.stop() // close the uo-link WS ingest client
shardBroadcast.closeAll() // end any open shard live-feed SSE streams
}
module.exports = { onBoot, onShutdown, checkUoLink }