// ── 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') /** * 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) from the file the // operator converted out of their own client. Same contract as the atlas: // 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 })) } 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. uoLinkSocket.stop() // close the uo-link WS ingest client shardBroadcast.closeAll() // end any open shard live-feed SSE streams } module.exports = { onBoot, onShutdown, checkUoLink }