diff --git a/assets/personas/hermodr-avatar.png b/assets/personas/hermodr-avatar.png deleted file mode 100644 index 2576914..0000000 Binary files a/assets/personas/hermodr-avatar.png and /dev/null differ diff --git a/docs/hermodr-extraction-plan.md b/docs/hermodr-extraction-plan.md index 39a0ad9..41eea8d 100644 --- a/docs/hermodr-extraction-plan.md +++ b/docs/hermodr-extraction-plan.md @@ -200,16 +200,26 @@ replaces the private store behind the existing port, the ingress and its options are gone, and `src/odin/` is down to twelve modules. Verified by unit tests; behaviour against a live provider over RUDP remains an operator step. -**Phase 3 — Publish the document contract.** `documents.cjs` becomes a package -consumed by both Odin and Hermodr. +**Phase 3 — Publish the document contract. Done.** The 45 definitions are +`cultcache-ts`'s `defineSwarmDocuments`, verified byte-identical to what +`documents.cjs` produced. Odin keeps a 26-line adapter under the name its +consumers already call. `utils.cjs` left with Hermodr, which used one of its six +functions. -**Phase 4 — Extract.** Move the daemon, state stream, tests, lifecycle scripts, -and the Hermodr-only modules to `GameCult/Hermodr`. MIT, matching the rest of the -chain, so a self-hosting creator may actually run it. +**Phase 4 — Extract. Done.** `GameCult/Hermodr`, MIT, sixteen files. The three +dependencies on Odin were resolved rather than carried: the catalog comes from +CultLib, Idunn's health contract is declared locally over the CultLib transport +with `sourceRuntimeId` `hermodr`, and `parseArgs` is seventeen lines of +`args.cjs`. `src/odin/` is down from fourteen modules to eleven, and Odin's +CommonJS surface from sixteen files to twelve. -**Phase 5 — Static lowering.** Library plus CLI in the new repository, runnable -without starting the daemon. Requiring the daemon reintroduces the dependency -static lowering exists to remove. +**Phase 5 — Static lowering. Not started, and no longer this repository's +business.** Library plus CLI in `GameCult/Hermodr`, runnable without starting +the daemon. Requiring the daemon reintroduces the dependency static lowering +exists to remove. + +This document stays here as the record of what left Odin and why. The work it +describes now happens in Hermodr. ## Verification diff --git a/package.json b/package.json index 43ec5d6..15f9be7 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "sleipnir:start": "cargo run -p sleipnir-daemon --", "muninn:build": "cargo build -p muninn-daemon", "muninn:start": "cargo run -p muninn-daemon --", - "hermodr:start": "node src/hermodr-daemon.cjs", "rust:test": "cargo test --workspace", "check": "node scripts/check-syntax.cjs", "test": "node --test test/*.test.cjs", diff --git a/scripts/health-hermodr.ps1 b/scripts/health-hermodr.ps1 deleted file mode 100644 index 1025954..0000000 --- a/scripts/health-hermodr.ps1 +++ /dev/null @@ -1,14 +0,0 @@ -param( - [string] $BaseUrl = $(if ($env:HERMODR_BASE_URL) { $env:HERMODR_BASE_URL } else { "http://127.0.0.1:8798" }) -) - -$ErrorActionPreference = "Stop" - -$healthUrl = $BaseUrl.TrimEnd("/") + "/health" -$response = Invoke-RestMethod -Uri $healthUrl -Method Get -TimeoutSec 3 - -if ($response.authority -ne "lowering-only") { - throw "Hermodr responded, but did not report lowering-only authority." -} - -Write-Host "Hermodr browser lowering responds at $BaseUrl; authority=$($response.authority). This is not daemon health or Verse discovery authority." diff --git a/scripts/restart-hermodr.ps1 b/scripts/restart-hermodr.ps1 deleted file mode 100644 index 85c84d4..0000000 --- a/scripts/restart-hermodr.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -param( - [string] $HostAddress = $(if ($env:HERMODR_BIND_HOST) { $env:HERMODR_BIND_HOST } else { "127.0.0.1" }), - [int] $Port = $(if ($env:HERMODR_PORT) { [int]$env:HERMODR_PORT } else { 8798 }), - [string] $OdinCultMeshUri = $(if ($env:HERMODR_ODIN_CULTMESH_URI) { $env:HERMODR_ODIN_CULTMESH_URI } elseif ($env:ODIN_CULTMESH_URI) { $env:ODIN_CULTMESH_URI } else { "cultmesh://odin/rendezvous/provider-catalog" }), - [string] $IdunnRudpHealth = $(if ($env:HERMODR_IDUNN_RUDP_HEALTH) { $env:HERMODR_IDUNN_RUDP_HEALTH } else { $env:IDUNN_RUDP_HEALTH }), - [string] $IdunnDaemon = "hermodr", - [string] $IdunnHealthContract = "hermodr.cultnet-rudp-browser-lowering-health" -) - -$ErrorActionPreference = "Stop" - -if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daemon") { - throw "restart-hermodr.ps1 is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path." -} - -$stop = Join-Path $PSScriptRoot "stop-hermodr.ps1" -$start = Join-Path $PSScriptRoot "start-hermodr.ps1" - -if (Test-Path -LiteralPath $stop) { - & $stop -} - -& $start -HostAddress $HostAddress -Port $Port -OdinCultMeshUri $OdinCultMeshUri -IdunnRudpHealth $IdunnRudpHealth -IdunnDaemon $IdunnDaemon -IdunnHealthContract $IdunnHealthContract -NoWindow diff --git a/scripts/start-hermodr.ps1 b/scripts/start-hermodr.ps1 deleted file mode 100644 index fcff881..0000000 --- a/scripts/start-hermodr.ps1 +++ /dev/null @@ -1,57 +0,0 @@ -param( - [string] $HostAddress = $(if ($env:HERMODR_BIND_HOST) { $env:HERMODR_BIND_HOST } else { "127.0.0.1" }), - [int] $Port = $(if ($env:HERMODR_PORT) { [int]$env:HERMODR_PORT } else { 8798 }), - [string] $OdinCultMeshUri = $(if ($env:HERMODR_ODIN_CULTMESH_URI) { $env:HERMODR_ODIN_CULTMESH_URI } elseif ($env:ODIN_CULTMESH_URI) { $env:ODIN_CULTMESH_URI } else { "cultmesh://odin/rendezvous/provider-catalog" }), - [string] $IdunnRudpHealth = $(if ($env:HERMODR_IDUNN_RUDP_HEALTH) { $env:HERMODR_IDUNN_RUDP_HEALTH } else { $env:IDUNN_RUDP_HEALTH }), - [string] $IdunnDaemon = "hermodr", - [string] $IdunnHealthContract = "hermodr.cultnet-rudp-browser-lowering-health", - [string] $StateDir = $(Join-Path $PSScriptRoot "..\scratch\Hermodr"), - [switch] $NoWindow -) - -$ErrorActionPreference = "Stop" - -foreach ($name in @( - "HERMODR_ODIN_HTTP_BASE", - "HERMODR_ODIN_WS_BASE", - "HERMODR_ODIN_STATE_PATH", - "HERMODR_SLEIPNIR_COMMAND_RUDP", - "HERMODR_SLEIPNIR_COMMAND_ENDPOINT", - "HERMODR_MUNINN_RUDP", - "HERMODR_AETHERIA_COMMAND_RUDP", - "HERMODR_ODIN_CULTMESH_STORE", - "ODIN_CULTMESH_STORE" -)) { - if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { - throw "$name was removed. Hermodr lowers Odin/CultMesh state for browsers and may only publish typed CultMesh command documents." - } -} -if (-not [string]::IsNullOrWhiteSpace($env:HERMODR_COMMAND_STORE)) { - throw "HERMODR_COMMAND_STORE was removed. Hermodr publishes typed commands through provider-advertised CultMesh routes and records visible witnesses in Odin's CultMesh cache." -} - -$node = "node" -$Hermodr = Join-Path $PSScriptRoot "..\src\hermodr-daemon.cjs" -$arguments = @( - $Hermodr, - "--host", $HostAddress, - "--port", $Port, - "--odin-cultmesh-uri", $OdinCultMeshUri -) -if (-not [string]::IsNullOrWhiteSpace($IdunnRudpHealth)) { - $arguments += @( - "--idunn-rudp-health", $IdunnRudpHealth, - "--idunn-daemon", $IdunnDaemon, - "--idunn-health-contract", $IdunnHealthContract - ) -} - -if ($NoWindow) { - New-Item -ItemType Directory -Force -Path $StateDir | Out-Null - $proc = Start-Process -FilePath $node -ArgumentList $arguments -WindowStyle Hidden -PassThru - $proc.Id | Set-Content -Encoding ASCII -LiteralPath (Join-Path $StateDir "hermodr.pid") - Write-Host "Started Hermodr browser lowering at http://${HostAddress}:${Port}/" - return -} - -& $node @arguments diff --git a/scripts/stop-hermodr.ps1 b/scripts/stop-hermodr.ps1 deleted file mode 100644 index 849e71e..0000000 --- a/scripts/stop-hermodr.ps1 +++ /dev/null @@ -1,22 +0,0 @@ -param( - [string] $StateDir = (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\Hermodr") -) - -$ErrorActionPreference = "Stop" - -$pidPath = Join-Path $StateDir "hermodr.pid" -if (-not (Test-Path $pidPath)) { - Write-Host "No Hermodr PID file found at $pidPath." - exit 0 -} - -$HermodrPid = (Get-Content -Raw $pidPath).Trim() -if ($HermodrPid -match "^\d+$") { - $process = Get-Process -Id ([int] $HermodrPid) -ErrorAction SilentlyContinue - if ($process) { - Stop-Process -Id $process.Id - Write-Host "Stopped Hermodr PID $HermodrPid." - } -} - -Remove-Item -LiteralPath $pidPath -Force diff --git a/src/hermodr-daemon.cjs b/src/hermodr-daemon.cjs deleted file mode 100644 index 3efed86..0000000 --- a/src/hermodr-daemon.cjs +++ /dev/null @@ -1,1534 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("fs"); -const http = require("http"); -const { createRequire } = require("module"); -const path = require("path"); - -const { defineOdinDocuments } = require("./odin/documents.cjs"); -const { createIdunnRudpHealthPublisher, publishIdunnRudpHealth } = require("./odin/idunn-rudp.cjs"); -const { parseArgs } = require("./odin/utils.cjs"); -const { ProviderSubscriptionSource } = require("./hermodr-provider-subscription.cjs"); -const { HermodrStateStreamRegistry } = require("./hermodr-state-stream.cjs"); - -const repoRoot = path.resolve(__dirname, ".."); -const projectsRoot = path.resolve(repoRoot, ".."); -const defaultEveRepoRoot = path.resolve(projectsRoot, "Eve"); -const defaultEveWebRoot = path.resolve(defaultEveRepoRoot, "web"); -const defaultOdinCultMeshUri = "cultmesh://odin/rendezvous/provider-catalog"; -const defaultOdinRudpEndpoint = "rudp://127.0.0.1:17871"; -const odinRudpConnectionId = 0x0d1d0002; -const providerRudpConnectionId = 0x43554c54; -const providerRudpPeers = new Map(); -const defaultProviderCatalogKeys = []; - -const cultCacheRequire = createRequire(resolveCultCachePackagePath()); -const cultMeshRequire = createRequire(resolveCultMeshPackagePath()); -const { defineDocumentType } = cultCacheRequire(resolveCultCacheRuntimePath()); -const cultMeshRuntime = cultMeshRequire(resolveCultMeshRuntimePath()); -const { CultMesh } = cultMeshRuntime; -const { decode: decodeMessagePack } = cultMeshRequire("@msgpack/msgpack"); - -const documents = defineOdinDocuments(defineDocumentType); -const odinDocumentDefinitions = Object.values(documents).filter(Boolean); -const HermodrCommandDefinition = defineDocumentType({ - type: "gamecult.eve.command", - schemaName: "gamecult.eve.command", - schemaId: "gamecult.eve.command.v1", - schemaVersion: "gamecult.eve.command.v1", - global: false, - name: (value) => value?.commandId || value?.command_id || "hermodr-command", - schema: requireObject("Hermodr command document"), -}); -const EveCommandReceiptDefinition = defineDocumentType({ - type: "gamecult.eve.command_receipt", - schemaName: "gamecult.eve.command_receipt", - schemaId: "gamecult.eve.command_receipt.v1", - schemaVersion: "gamecult.eve.command_receipt.v1", - global: false, - name: (value) => value?.commandId || value?.receiptId || "command-receipt", - schema: requireObject("Eve command receipt document"), -}); - -async function main() { - const options = parseOptions(process.argv.slice(2)); - rejectRemovedOptions(options); - if (options.help) { - printUsage(); - return; - } - - // Providers own their state. Odin says where it lives. Hermodr resolves the - // provider through the catalog and reads that provider directly, the same way - // the command path already routes intent to a provider-advertised endpoint. - const livePublicationSource = new ProviderSubscriptionSource( - (providerId, schemaId, recordKey) => readProviderStateDocument(options, providerId, schemaId, recordKey), - { - pollIntervalMs: options.providerPollIntervalMs, - onError: (error, selection) => console.error( - `Hermodr provider read failed for ${selection.providerId} ${selection.schemaId}:${selection.recordKey}: ${error.message}`, - ), - }, - ); - const bridge = createHermodrBridge(options, livePublicationSource); - const server = http.createServer((request, response) => { - bridge.handle(request, response).catch((error) => { - writeJson(response, error?.statusCode || 500, { - ok: false, - error: error instanceof Error ? error.message : String(error), - }); - }); - }); - - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(options.port, options.host, () => { - server.off("error", reject); - resolve(); - }); - }); - - const address = server.address(); - const port = typeof address === "object" && address ? address.port : options.port; - console.log(`Hermodr browser lowering listening on http://${options.host}:${port}`); - console.log(`Hermodr Odin CultMesh URI: ${options.odinCultMeshUri}`); - - const idunnHealthPublisher = createIdunnRudpHealthPublisher(options.idunnRudpHealth); - const publishHealth = () => { - if (!idunnHealthPublisher) return; - publishIdunnRudpHealth(idunnHealthPublisher, { - state: "healthy", - detail: `Hermodr browser lowering accepts requests on http://${options.host}:${port}.`, - observedAt: new Date().toISOString(), - }).catch((error) => console.error(`Hermodr Idunn health publication failed: ${error.message}`)); - }; - publishHealth(); - const healthTimer = idunnHealthPublisher - ? setInterval(publishHealth, options.idunnHealthIntervalMs) - : null; - - const shutdown = () => { - if (healthTimer) clearInterval(healthTimer); - server.close(() => {}); - closeProviderRudpPeers(); - }; - process.once("SIGINT", shutdown); - process.once("SIGTERM", shutdown); -} - -function parseOptions(argv) { - const parsed = parseArgs(argv); - return { - help: Boolean(parsed.help), - host: stringOption(parsed.host, process.env.HERMODR_BIND_HOST || "127.0.0.1"), - port: numberOption(parsed.port, process.env.HERMODR_PORT || 8798), - odinCultMeshUri: stringOption( - parsed.odinCultMeshUri || parsed.odinUri || parsed["odin-cultmesh-uri"], - process.env.HERMODR_ODIN_CULTMESH_URI || process.env.ODIN_CULTMESH_URI || defaultOdinCultMeshUri, - ), - odinRudpEndpoint: stringOption( - parsed.odinRudpEndpoint || parsed["odin-rudp-endpoint"], - process.env.HERMODR_ODIN_RUDP || process.env.CULTMESH_URI_ODIN_RUDP || defaultOdinRudpEndpoint, - ), - odinSurfaceKey: stringOption(parsed.odinSurfaceKey, process.env.HERMODR_ODIN_SURFACE_KEY || "surface:gamecult.network.status"), - eveWebRoot: path.resolve(stringOption(parsed.eveWebRoot, process.env.HERMODR_EVE_WEB_ROOT || defaultEveWebRoot)), - eveRepoRoot: path.resolve(stringOption(parsed.eveRepoRoot, process.env.HERMODR_EVE_REPO_ROOT || defaultEveRepoRoot)), - providerCatalogKeys: stringListOption( - parsed.providerCatalogKeys || parsed["provider-catalog-keys"], - process.env.HERMODR_PROVIDER_CATALOG_KEYS, - defaultProviderCatalogKeys, - ), - providerPollIntervalMs: Math.max(250, numberOption( - parsed.providerPollIntervalMs || parsed["provider-poll-interval-ms"], - process.env.HERMODR_PROVIDER_POLL_INTERVAL_MS || 2_000, - )), - sleipnirProviderId: stringOption(parsed.sleipnirProviderId, process.env.HERMODR_SLEIPNIR_PROVIDER_ID || "sleipnir.input-mirror.starfire"), - idunnRudpHealth: idunnRudpHealthOptions(parsed), - idunnHealthIntervalMs: Math.max(1_000, numberOption( - parsed.idunnHealthIntervalMs || parsed["idunn-health-interval-ms"], - process.env.HERMODR_IDUNN_HEALTH_INTERVAL_MS || 15_000, - )), - raw: parsed, - }; -} - -function idunnRudpHealthOptions(parsed) { - const endpoint = stringOption( - parsed.idunnRudpHealth || parsed["idunn-rudp-health"], - process.env.HERMODR_IDUNN_RUDP_HEALTH || process.env.IDUNN_RUDP_HEALTH || "", - ); - if (!endpoint) return null; - return { - endpoint, - daemonId: stringOption(parsed.idunnDaemon || parsed["idunn-daemon"], process.env.HERMODR_IDUNN_DAEMON || "hermodr"), - healthContract: stringOption( - parsed.idunnHealthContract || parsed["idunn-health-contract"], - process.env.HERMODR_IDUNN_HEALTH_CONTRACT || "hermodr.cultnet-rudp-browser-lowering-health", - ), - }; -} - -function rejectRemovedOptions(options) { - const removed = [ - "odinHttpBase", - "odinWsBase", - "odinStatePath", - "sleipnirMappingPath", - "sleipnirStorePath", - "sleipnirCommandEndpoint", - "sleipnirCommandRudp", - "muninnRudp", - "aetheriaCommandRudp", - "hermodrCommandStorePath", - "odinCachePath", - // parseArgs keys options exactly as typed, so both spellings are reachable - // and both must fail. Hermodr no longer accepts provider publications; it - // reads providers through the routes Odin advertises. - "providerSessionBind", - "provider-session-bind", - "providerSessionToken", - "provider-session-token", - ]; - for (const option of removed) { - if (Object.hasOwn(options.raw, option)) { - throw new Error(`--${option} was removed. Hermodr is a browser lowering over Odin/CultMesh state, not a fallback discovery or raw command transport.`); - } - } - - const removedEnv = [ - "HERMODR_ODIN_HTTP_BASE", - "HERMODR_ODIN_WS_BASE", - "HERMODR_ODIN_STATE_PATH", - "HERMODR_SLEIPNIR_MAPPING_PATH", - "HERMODR_SLEIPNIR_STORE_PATH", - "HERMODR_SLEIPNIR_COMMAND_ENDPOINT", - "HERMODR_SLEIPNIR_COMMAND_RUDP", - "HERMODR_MUNINN_RUDP", - "HERMODR_AETHERIA_COMMAND_RUDP", - "HERMODR_ODIN_CULTMESH_STORE", - "ODIN_CULTMESH_STORE", - "HERMODR_PROVIDER_SESSION_BIND", - "HERMODR_PROVIDER_SESSION_TOKEN", - ]; - for (const name of removedEnv) { - if (process.env[name]) { - throw new Error(`${name} was removed. Hermodr must lower Odin/CultMesh state and publish typed CultMesh command documents.`); - } - } -} - -function createHermodrBridge(options, livePublicationSource) { - const stateStreams = livePublicationSource ? new HermodrStateStreamRegistry(livePublicationSource) : null; - return { - async handle(request, response) { - const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`); - if (request.method === "OPTIONS") { - writeCors(response, 204); - response.end(); - return; - } - - if (request.method === "GET" && url.pathname === "/health") { - writeJson(response, 200, { - ok: true, - service: "hermodr-browser-lowering", - authority: "lowering-only", - visibleAuthority: "Odin sees provider-owned CultMesh documents; Odin does not own provider daemons.", - odinCultMeshUri: options.odinCultMeshUri, - eveWebRoot: options.eveWebRoot, - assetBytes: "provider-cultmesh-cdn", - }); - return; - } - - if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) { - await serveStaticFile(response, path.join(options.eveWebRoot, "index.html"), "text/html; charset=utf-8"); - return; - } - - if (request.method === "GET" && url.pathname === "/cultmesh-cdn/catalog") { - writeJson(response, 200, await readAssetCatalog(options)); - return; - } - - if ((request.method === "GET" || request.method === "HEAD") && url.pathname.startsWith("/cultmesh-cdn/")) { - await serveCultMeshAsset( - options, - response, - decodeURIComponent(url.pathname.slice("/cultmesh-cdn/".length)), - request.method === "HEAD", - ); - return; - } - - if (request.method === "GET" && url.pathname === "/hermodr/verse/catalog") { - writeJson(response, 200, await readCatalog(options)); - return; - } - - if (request.method === "GET" && url.pathname.startsWith("/hermodr/surface/")) { - const providerId = decodeURIComponent(url.pathname.slice("/hermodr/surface/".length)); - const surfaceId = stringOption(url.searchParams.get("surfaceId"), ""); - const catalog = await readCatalog(options); - const surface = await readProviderSurface(options, catalog, providerId, surfaceId); - if (!surface) { - writeJson(response, 404, { ok: false, error: `No surface found for provider '${providerId}'.` }); - return; - } - writeJson(response, 200, surface); - return; - } - - if (request.method === "GET" && url.pathname.startsWith("/hermodr/document/")) { - const providerId = decodeURIComponent(url.pathname.slice("/hermodr/document/".length)); - const documentId = stringOption(url.searchParams.get("documentId"), ""); - const schemaId = stringOption(url.searchParams.get("schemaId"), ""); - const resolved = await readVisibleDocument(options, { providerId, documentId, schemaId }); - if (!resolved) { - writeJson(response, 404, { ok: false, error: `No visible document '${documentId}' (${schemaId || "any schema"}) for provider '${providerId}'.` }); - return; - } - writeJson(response, 200, resolved); - return; - } - - if (request.method === "GET" && url.pathname.startsWith("/hermodr/state/")) { - const selection = normalizeStateBindingSource({ - providerId: decodeURIComponent(url.pathname.slice("/hermodr/state/".length)), - sourceId: stringOption(url.searchParams.get("sourceId"), ""), - schemaId: stringOption(url.searchParams.get("schemaId"), ""), - }); - if (!selection) { - writeJson(response, 400, { ok: false, error: "providerId, sourceId, and schemaId are required for a typed state stream." }); - return; - } - if (!stateStreams) { - writeJson(response, 503, { ok: false, error: "Provider-session live publication ingress is unavailable." }); - return; - } - openStateEventStream(request, response, stateStreams, selection); - return; - } - - if (request.method === "POST" && url.pathname === "/hermodr/commands/sleipnir/input-mapping") { - const body = await readJsonBody(request); - writeJson(response, 202, await publishSleipnirMapping(options, body)); - return; - } - - if (request.method === "POST" && url.pathname === "/hermodr/commands/eve") { - const body = await readJsonBody(request); - writeJson(response, 200, await publishEveCommand(options, body)); - return; - } - - if (request.method === "GET" && url.pathname.startsWith("/packages/")) { - await serveStaticFile(response, safeJoin(path.join(options.eveRepoRoot, "packages"), url.pathname.slice("/packages/".length))); - return; - } - - if (request.method === "GET") { - await serveStaticFile(response, safeJoin(options.eveWebRoot, url.pathname.slice(1))); - return; - } - - writeJson(response, 404, { ok: false, error: "not found" }); - }, - }; -} - -function normalizeStateBindingSource(value) { - const providerId = String(value?.providerId || "").trim(); - const sourceId = String(value?.sourceId || "").trim(); - const schemaId = String(value?.schemaId || "").trim(); - if (!providerId || !sourceId || !schemaId) return null; - const prefix = `${schemaId.replace(/\.v\d+$/, "")}:`; - const recordKey = sourceId.startsWith(prefix) ? sourceId.slice(prefix.length) : sourceId; - return recordKey ? { providerId, sourceId, schemaId, recordKey } : null; -} - -function openStateEventStream(request, response, registry, selection) { - writeCors(response, 200); - response.setHeader("Content-Type", "text/event-stream; charset=utf-8"); - response.setHeader("Cache-Control", "no-cache, no-transform"); - response.setHeader("Connection", "keep-alive"); - response.flushHeaders?.(); - const release = registry.acquire(selection, event => { - response.write(`id: ${event.sequence}\n`); - response.write(`event: ${event.type}\n`); - response.write(`data: ${JSON.stringify(event)}\n\n`); - }); - request.once("close", release); - response.once("close", release); -} - -async function readCatalog(options) { - const providerSnapshots = []; - for (let attempt = 0; attempt < 3; attempt += 1) { - providerSnapshots.push(...await readOdinProviderAdvertisements(options)); - } - const providerAdvertisements = mergeProviderAdvertisements([ - ...await readOdinSurfaceProviderCatalog(options), - ...providerSnapshots, - ]); - return createBrowserCatalog(providerAdvertisements, options); -} - -function createBrowserCatalog(providerAdvertisements, options = {}) { - const surfaces = providerAdvertisements.flatMap((provider) => { - const advertised = provider.surfaces?.length ? provider.surfaces : [{}]; - return advertised.map((surface) => ({ - providerId: provider.id, - surfaceId: surface.surfaceId || provider.id, - surfaceKind: surface.surfaceKind || "", - title: surface.title || provider.title || surface.surfaceId || provider.id, - version: 0, - updatedAt: provider.updatedAt || null, - surface: { advertised: true }, - })); - }); - - return { - schema: "gamecult.hermodr.browser_lowering_catalog.v1", - service: "hermodr-browser-lowering", - generatedAt: new Date().toISOString(), - authority: { - owner: "Odin/CultMesh", - ownerNote: "Odin sees provider-owned daemon publications; it does not own provider daemon state.", - hermodrRole: "browser lowering adapter", - forbidden: ["provider catalog synthesis", "provider state ownership", "renderer-owned discovery"], - }, - source: { - odinCultMeshUri: options.odinCultMeshUri || "", - odinSurfaceKey: options.odinSurfaceKey || "", - }, - surface: null, - surfaces, - providers: providerAdvertisements, - }; -} - -async function readOdinSurfaceProviderCatalog(options) { - const peer = await CultMesh.createRudpPeer( - "hermodr-browser-lowering-odin-surface", - odinRudpConnectionId, - resolveHermodrRudpEndpoint(options.odinCultMeshUri, options), - hermodrRudpPeerOptions({ - resolveCultMeshRudpEndpoint: (uri) => resolveHermodrRudpEndpoint(uri, options), - }), - ); - try { - const keys = [ - options.odinSurfaceKey, - String(options.odinSurfaceKey || "").startsWith("surface:") - ? String(options.odinSurfaceKey || "").slice("surface:".length) - : `surface:${options.odinSurfaceKey}`, - ]; - const document = await requestCultNetRawSnapshotFirstDocument( - peer, - "gamecult.eve.surface_state.v1", - keys, - { timeoutMs: 4_000, messageIdPrefix: "hermodr-odin-provider-catalog" }, - ); - const decoded = decodeMessagePack(bufferFromPayload(document.payload)); - const catalog = Array.isArray(decoded?.providerCatalog) ? decoded.providerCatalog : []; - return catalog.map(normalizeCatalogProviderAdvertisement).filter(Boolean); - } finally { - if (typeof peer.close === "function") { - peer.close(); - } - } -} - -async function readOdinProviderAdvertisements(options) { - const peer = await CultMesh.createRudpPeer( - "hermodr-browser-lowering-odin", - odinRudpConnectionId, - resolveHermodrRudpEndpoint(options.odinCultMeshUri, options), - hermodrRudpPeerOptions({ - resolveCultMeshRudpEndpoint: (uri) => resolveHermodrRudpEndpoint(uri, options), - }), - ); - try { - const response = await requestCultNetRawSnapshot( - peer, - options.providerCatalogKeys, - { - schemaIds: ["gamecult.eve.provider_advertisement.v1"], - timeoutMs: 4_000, - messageIdPrefix: "hermodr-odin-providers", - }, - ); - return response.documents - .filter((document) => document.schemaId === "gamecult.eve.provider_advertisement.v1") - .map((document) => normalizeProviderAdvertisement( - decodeMessagePack(bufferFromPayload(document.payload)), - document.recordKey, - )) - .filter(Boolean); - } finally { - if (typeof peer.close === "function") { - peer.close(); - } - } -} - -function normalizeCatalogProviderAdvertisement(value) { - const providerId = value?.id || value?.providerId || value?.provider?.id; - if (!providerId) return null; - return { - id: String(providerId), - title: value.title || String(providerId), - description: value.description || "", - endpoint: value.endpoint || value.cultMeshAddress || value.provider?.endpoint || null, - cultMeshAddress: value.cultMeshAddress || value.endpoint || value.provider?.endpoint || null, - endpoints: arrayOfObjects(value.endpoints), - routes: arrayOfObjects(value.routes), - commandSurface: value.commandSurface || null, - surfaces: normalizeAdvertisedSurfaces(value.surfaces), - status: value.status || "unknown", - updatedAt: value.updatedAt || null, - }; -} - -function mergeProviderAdvertisements(providers) { - const merged = new Map(); - for (const provider of providers) { - if (!provider?.id) continue; - const existing = merged.get(provider.id) || {}; - merged.set(provider.id, { - ...existing, - ...provider, - endpoints: mergeAdvertisedRoutes(existing.endpoints, provider.endpoints), - routes: mergeAdvertisedRoutes(existing.routes, provider.routes), - surfaces: provider.surfaces?.length ? provider.surfaces : existing.surfaces || [], - cultMeshAddress: provider.cultMeshAddress || existing.cultMeshAddress || null, - endpoint: provider.endpoint || existing.endpoint || null, - }); - } - return [...merged.values()].sort((left, right) => String(left.id).localeCompare(String(right.id))); -} - -function mergeAdvertisedRoutes(left, right) { - const merged = new Map(); - for (const route of [...arrayOfObjects(left), ...arrayOfObjects(right)]) { - const key = [route.id, route.role, route.uri, route.endpoint, route.address, route.transport] - .map((value) => String(value || "")) - .join("\u001f"); - merged.set(key, route); - } - return [...merged.values()]; -} - -function normalizeAdvertisedSurfaces(surfaces) { - return arrayOfObjects(surfaces) - .map((surface) => ({ - ...surface, - surfaceId: String(surface.surfaceId || surface.id || "").trim(), - surfaceKind: String(surface.surfaceKind || surface.kind || "").trim(), - recordRef: String(surface.recordRef || surface.key || "").trim(), - })) - .filter((surface) => surface.surfaceId); -} - -function surfaceRecordKeys(catalog, providerId, surfaceId) { - const provider = (catalog.providers || []).find((candidate) => candidate.id === providerId); - const surface = (provider?.surfaces || []).find((candidate) => candidate.surfaceId === surfaceId); - return [...new Set([ - surface?.recordRef, - surfaceId, - `surface:${surfaceId}`, - `eve:surface:${surfaceId}`, - providerId, - `surface:${providerId}`, - ].filter(Boolean))]; -} - -function resolveHermodrRudpEndpoint(uri, options) { - const text = String(uri || "").trim(); - if (text === defaultOdinCultMeshUri || text.startsWith("cultmesh://odin/")) { - return normalizeRudpEndpoint(options.odinRudpEndpoint); - } - return undefined; -} - -function normalizeRudpEndpoint(endpoint) { - const text = String(endpoint || "").trim(); - if (!text) return text; - return text.startsWith("rudp://") ? text : `rudp://${text}`; -} - -function hermodrRudpPeerOptions(overrides = {}) { - return { - bindHost: "0.0.0.0", - connectTimeoutMs: 2_000, - maxFragmentBytes: 1200, - maxPendingReliablePackets: 512, - ...overrides, - }; -} - -async function readProviderSurface(options, catalog, providerId, surfaceId = "") { - const selectedSurfaceId = surfaceId || providerId; - const recordKeys = surfaceRecordKeys(catalog, providerId, selectedSurfaceId); - const odinSurface = await readOdinProviderSurface(options, providerId, selectedSurfaceId, recordKeys); - if (odinSurface) { - return odinSurface; - } - - const route = findProviderSnapshotRoute(catalog, providerId); - if (!route) return null; - try { - const document = await requestProviderSnapshotFirstDocumentWithReconnect( - route.endpoint, - "gamecult.eve.surface_state.v1", - recordKeys, - { timeoutMs: 4_000, messageIdPrefix: "hermodr-surface-state" }, - ); - const state = normalizeSurfaceState(decodeMessagePack(bufferFromPayload(document.payload)), document.recordKey); - if (state?.surface?.root) { - return { - providerId, - documentProviderId: state.providerId, - providerKind: "provider-cultmesh-rudp", - title: state.title || providerId, - version: state.version ?? 0, - updatedAt: state.updatedAt || null, - surface: normalizeEveSurfaceTree(state.surface), - commands: [], - }; - } - } catch { - // Older providers may still expose gamecult.eve.surface.v1 directly. - } - const document = await requestProviderSnapshotFirstDocumentWithReconnect( - route.endpoint, - "gamecult.eve.surface.v1", - recordKeys, - { timeoutMs: 4_000, messageIdPrefix: "hermodr-surface" }, - ); - return normalizeEveSurfaceDocument(decodeMessagePack(bufferFromPayload(document.payload)), providerId); -} - -async function readOdinProviderSurface(options, providerId, surfaceId = providerId, recordKeys = []) { - const peer = await CultMesh.createRudpPeer( - "hermodr-browser-lowering-odin-provider-surface", - odinRudpConnectionId, - resolveHermodrRudpEndpoint(options.odinCultMeshUri, options), - hermodrRudpPeerOptions({ - resolveCultMeshRudpEndpoint: (uri) => resolveHermodrRudpEndpoint(uri, options), - }), - ); - try { - const candidates = recordKeys.length ? recordKeys : [surfaceId, `surface:${surfaceId}`, providerId, `surface:${providerId}`]; - for (const recordKey of candidates) { - try { - const document = await requestCultNetRawSnapshotFirstDocument( - peer, - "gamecult.eve.surface_state.v1", - [recordKey], - { timeoutMs: 4_000, messageIdPrefix: "hermodr-odin-provider-surface" }, - ); - const state = normalizeSurfaceState(decodeMessagePack(bufferFromPayload(document.payload)), document.recordKey); - if (!state?.surface?.root) continue; - return { - providerId, - documentProviderId: state.providerId, - providerKind: "odin-cultmesh-surface-state", - title: state.title || providerId, - version: state.version ?? 0, - updatedAt: state.updatedAt || null, - surface: normalizeEveSurfaceTree(state.surface), - commands: [], - }; - } catch { - // Try the next provider-advertised alias. - } - } - return null; - } catch { - return null; - } finally { - if (typeof peer.close === "function") { - peer.close(); - } - } -} - -function normalizeEveSurfaceDocument(value, fallbackProviderId) { - if (Array.isArray(value)) { - if (Array.isArray(value[5])) { - return { - providerId: String(fallbackProviderId || value[0] || ""), - documentProviderId: String(value[0] || ""), - providerKind: String(value[1] || ""), - title: String(value[2] || value[0] || fallbackProviderId || ""), - version: value[3] ?? 0, - updatedAt: value[4] || null, - surface: normalizeEveSurfaceTree(value[5]), - commands: Array.isArray(value[6]) ? normalizeEveCommands(value[6]) : [], - }; - } - return { - providerId: String(value[2] || fallbackProviderId || ""), - providerKind: String(value[3] || ""), - title: String(value[4] || value[2] || fallbackProviderId || ""), - version: value[5] ?? 0, - updatedAt: value[6] || null, - surface: normalizeEveSurfaceTree(value[7]), - commands: Array.isArray(value[8]) ? normalizeEveCommands(value[8]) : [], - }; - } - return { - providerId: String(value?.providerId || fallbackProviderId || ""), - providerKind: String(value?.providerKind || ""), - title: String(value?.title || value?.surface?.title || value?.providerId || fallbackProviderId || ""), - version: value?.version ?? 0, - updatedAt: value?.updatedAtUtc || value?.updatedAt || null, - surface: normalizeEveSurfaceTree(value?.surface), - commands: Array.isArray(value?.commands) ? normalizeEveCommands(value.commands) : [], - }; -} - -function normalizeEveSurfaceTree(value) { - if (Array.isArray(value)) { - return { - id: String(value[0] || ""), - root: normalizeEveSurfaceComponent(value[1]), - styles: Array.isArray(value[2]) ? value[2] : [], - }; - } - return { - id: String(value?.id || ""), - title: value?.title, - root: normalizeEveSurfaceComponent(value?.root), - styles: Array.isArray(value?.styles) ? value.styles : [], - }; -} - -function normalizeEveSurfaceComponent(value) { - if (Array.isArray(value)) { - const component = { - id: String(value[0] || ""), - kind: String(value[1] || ""), - props: value[2] && typeof value[2] === "object" && !Array.isArray(value[2]) ? value[2] : {}, - children: Array.isArray(value[3]) ? value[3].map(normalizeEveSurfaceComponent) : [], - }; - if (Array.isArray(value[5])) component.embeddedDocuments = normalizeEmbeddedDocuments(value[5]); - if (value[6] && typeof value[6] === "object" && !Array.isArray(value[6])) component.layout = value[6]; - if (value[7] && typeof value[7] === "object" && !Array.isArray(value[7])) component.style = value[7]; - return component; - } - if (!value || typeof value !== "object") { - return { id: "", kind: "surface", props: {}, children: [] }; - } - return { - ...value, - props: value.props && typeof value.props === "object" && !Array.isArray(value.props) ? value.props : {}, - children: Array.isArray(value.children) ? value.children.map(normalizeEveSurfaceComponent) : [], - }; -} - -function normalizeEveCommands(commands) { - return commands.map((command) => { - if (!Array.isArray(command)) return command; - return { - commandId: String(command[0] || ""), - label: String(command[1] || ""), - description: String(command[2] || ""), - authority: String(command[3] || ""), - transport: String(command[4] || ""), - }; - }); -} - -function normalizeEmbeddedDocuments(documents) { - return documents.map((document) => { - if (!Array.isArray(document)) return document; - return { - slotId: String(document[0] || ""), - documentId: String(document[1] || ""), - schemaId: String(document[2] || ""), - presentationKind: String(document[3] || ""), - bindingMode: String(document[4] || ""), - query: String(document[5] || ""), - }; - }); -} - -async function publishSleipnirMapping(options, body) { - if (!body || typeof body !== "object" || Array.isArray(body)) { - throw new Error("Sleipnir input mapping command body must be an object."); - } - - const catalog = await readCatalog(options); - const route = findSleipnirCommandRoute(catalog, options.sleipnirProviderId); - if (!route) { - const error = new Error(`Odin does not advertise a cultmesh:// Sleipnir input mapping command route for ${options.sleipnirProviderId}.`); - error.statusCode = 409; - throw error; - } - - const commandId = body.commandId || body.command_id || `hermodr:sleipnir:${Date.now()}:${Math.random().toString(16).slice(2)}`; - const mapping = { - ...body, - schema: "sleipnir.input_mapping.v1", - providerId: options.sleipnirProviderId, - commandId, - commandRoute: route.uri, - commandRouteResolver: "odin-cultmesh", - publishedBy: "hermodr-browser-lowering", - publishedAt: new Date().toISOString(), - }; - delete mapping.muninnRudp; - delete mapping.muninn_rudp; - const publication = await publishCommandDocument(options, documents.SleipnirInputMappingDefinition, options.sleipnirProviderId, mapping, route.uri); - if (publication.routeError) { - const error = new Error(`CultMesh route publish failed for ${route.uri}: ${publication.routeError}`); - error.statusCode = 502; - throw error; - } - return { - ok: true, - schema: "sleipnir.input_mapping.v1", - commandId, - providerId: options.sleipnirProviderId, - commandRoute: route.uri, - route: route.uri, - routeError: publication.routeError || undefined, - }; -} - -async function publishEveCommand(options, body) { - if (!body || typeof body !== "object" || Array.isArray(body)) { - throw new Error("Eve command body must be an object."); - } - - const target = typeof body.target === "string" ? body.target.trim() : ""; - const catalog = await readCatalog(options); - const route = target.startsWith("cultmesh://") - ? { uri: target, publishEndpoint: target } - : findProviderCommandRoute(catalog, body.providerId || body.provider_id || ""); - if (!route) { - const error = new Error("No provider-advertised cultmesh:// command route was visible."); - error.statusCode = 409; - throw error; - } - - const commandId = body.commandId || body.command_id || `hermodr:eve:${Date.now()}:${Math.random().toString(16).slice(2)}`; - const command = { - ...body, - schema: "gamecult.eve.command.v1", - commandId, - target: route.uri, - publishedBy: "hermodr-browser-lowering", - publishedAt: new Date().toISOString(), - }; - const publication = await publishCommandDocument( - options, - HermodrCommandDefinition, - commandId, - command, - route.publishEndpoint || route.uri, - route.connectionId, - EveCommandReceiptDefinition, - ); - if (publication.routeError) { - const error = new Error(`CultMesh route publish failed for ${route.uri}: ${publication.routeError}`); - error.statusCode = 502; - throw error; - } - return { ok: true, ...publication.receipt, target: route.uri }; -} - -async function publishCommandDocument(options, definition, key, value, route, connectionId = 0xe1e00001, receiptDefinition = null) { - let routeError = null; - let receipt = null; - if (route && (route.startsWith("cultmesh://") || route.startsWith("rudp://"))) { - try { - const publishOptions = { - bindHost: "0.0.0.0", - sourceRuntimeId: "hermodr-browser-lowering", - sourceRole: "browser-lowering", - tags: ["hermodr", "browser", "command"], - receiptTimeoutMs: 5_000, - }; - receipt = receiptDefinition - ? await CultMesh.publishRudpDocumentAndWaitForReceipt( - "hermodr-browser-lowering", Number(connectionId), route, { definition }, key, value, - { definition: receiptDefinition }, publishOptions, - ) - : await CultMesh.publishRudpDocumentOnce( - "hermodr-browser-lowering", Number(connectionId), route, { definition }, key, value, publishOptions, - ); - } catch (error) { - routeError = error instanceof Error ? error.message : String(error); - } - } - return { routeError, receipt }; -} - -async function readVisibleDocument(options, request) { - const catalog = await readCatalog(options); - const resolved = await fetchProviderDocument(catalog, request); - if (!resolved) return null; - return { - ok: true, - schema: "gamecult.hermodr.resolved_document.v1", - providerId: request.providerId, - documentId: request.documentId, - schemaId: resolved.schemaId, - recordKey: resolved.recordKey, - document: resolved.document, - source: { - visibility: "provider-cultmesh-rudp-snapshot", - endpoint: resolved.endpoint, - odinCultMeshUri: options.odinCultMeshUri, - }, - }; -} - -function findSleipnirCommandRoute(catalog, providerId) { - const providers = catalog.providers.filter((provider) => provider.id === providerId || String(provider.id).includes("sleipnir")); - for (const provider of providers) { - const endpoints = [...(provider.endpoints || []), ...(provider.routes || [])]; - const route = endpoints.find((endpoint) => { - const uri = endpoint.uri || endpoint.endpoint || endpoint.address || ""; - const role = [endpoint.id, endpoint.role, ...(endpoint.tags || [])].join(" ").toLowerCase(); - return uri.startsWith("cultmesh://") && role.includes("command") && role.includes("sleipnir"); - }) || endpoints.find((endpoint) => { - const uri = endpoint.uri || endpoint.endpoint || endpoint.address || ""; - return uri.startsWith("cultmesh://") && uri.includes("sleipnir") && uri.includes("input_mapping"); - }); - if (route) { - return { - ...route, - uri: route.uri || route.endpoint || route.address, - }; - } - } - return null; -} - -function findProviderCommandRoute(catalog, providerId) { - const normalized = String(providerId || "").trim(); - const providers = catalog.providers.filter((provider) => { - if (!normalized) return true; - return provider.id === normalized || provider.id.includes(normalized) || normalized.includes(provider.id); - }); - for (const provider of providers) { - const endpoints = [...(provider.endpoints || []), ...(provider.routes || [])]; - const route = endpoints.find((endpoint) => { - const uri = endpoint.uri || endpoint.endpoint || endpoint.address || ""; - const role = [endpoint.id, endpoint.role, ...(endpoint.tags || [])].join(" ").toLowerCase(); - return uri.startsWith("cultmesh://") && role.includes("command"); - }); - if (route) { - const uri = route.uri || route.endpoint || route.address; - const address = String(route.address || "").trim(); - return { - ...route, - uri, - publishEndpoint: address ? `rudp://${address}` : uri, - }; - } - } - return ""; -} - -// Provider state routes change far more slowly than state does, and resolving -// one costs a round of Odin catalog reads. Cache the route, not the state. -const providerStateRoutes = new Map(); -const PROVIDER_ROUTE_TTL_MS = 30_000; - -async function resolveProviderStateEndpoint(options, providerId) { - const cached = providerStateRoutes.get(providerId); - if (cached && cached.expiresAt > Date.now()) return cached.endpoint; - - const catalog = await readCatalog(options); - const endpoint = findProviderStateEndpoint(catalog, providerId); - if (!endpoint) { - throw new Error(`Odin advertises no reachable state route for provider ${providerId}.`); - } - providerStateRoutes.set(providerId, { endpoint, expiresAt: Date.now() + PROVIDER_ROUTE_TTL_MS }); - return endpoint; -} - -function findProviderStateEndpoint(catalog, providerId) { - const normalized = String(providerId || "").trim(); - const provider = (catalog.providers || []).find((candidate) => candidate.id === normalized) - || (catalog.providers || []).find((candidate) => candidate.id.includes(normalized) || normalized.includes(candidate.id)); - if (!provider) return ""; - - const endpoints = [...(provider.endpoints || []), ...(provider.routes || [])]; - const stateEndpoint = endpoints.find((endpoint) => { - const role = [endpoint.id, endpoint.role, ...(endpoint.tags || [])].join(" ").toLowerCase(); - return Boolean(endpoint.address) && /state|document|surface/.test(role); - }); - const anyEndpoint = endpoints.find((endpoint) => Boolean(endpoint.address)); - const address = stateEndpoint?.address || anyEndpoint?.address || provider.cultMeshAddress; - if (!address) return ""; - return String(address).startsWith("rudp://") ? String(address) : `rudp://${address}`; -} - -// The read behind the provider subscription. Odin says where; the provider -// answers with what. Hermodr does not cache the answer as truth: the -// subscription source owns lifecycle, and this returns null for absence rather -// than throwing, so a missing document reads as withdrawal while a transport -// failure propagates as an error and is not mistaken for one. -async function readProviderStateDocument(options, providerId, schemaId, recordKey) { - const endpoint = await resolveProviderStateEndpoint(options, providerId); - const peer = await getProviderRudpPeer(endpoint); - try { - const document = await requestCultNetRawSnapshotDocument(peer, schemaId, recordKey, { - timeoutMs: 4_000, - messageIdPrefix: "hermodr-provider-state", - }); - if (!document) return null; - return decodeMessagePack(bufferFromPayload(document.payload)); - } catch (error) { - if (/did not return/.test(String(error?.message || ""))) { - // The provider answered and does not hold this document. That is absence, - // not failure. - return null; - } - // The peer may be wedged. Drop it so the next poll reconnects, and let the - // subscription source treat this as a failed read rather than a withdrawal. - await dropProviderRudpPeer(endpoint); - providerStateRoutes.delete(providerId); - throw error; - } -} - -function normalizeProviderAdvertisement(value, recordKey) { - const providerId = value?.providerId || value?.provider?.id || value?.id || recordKey; - if (!providerId) { - return null; - } - const endpoints = arrayOfObjects(value?.endpoints); - const routes = arrayOfObjects(value?.routes); - return { - id: String(providerId), - title: value.title || value.provider?.title || String(providerId), - description: value.description || "", - cultMeshAddress: value.cultMeshAddress || value.provider?.endpoint || null, - endpoints, - routes, - commandSurface: value.commandSurface || null, - surfaces: normalizeAdvertisedSurfaces(value.surfaces), - status: value.status || "unknown", - updatedAt: value.updatedAt || value.generatedAt || null, - }; -} - -function normalizeSurfaceState(value, recordKey) { - if (Array.isArray(value)) { - value = { - providerId: value[0], - title: value[1], - version: value[2], - updatedAt: value[3], - surface: value[4], - }; - } - const providerId = value?.providerId || value?.provider_id || recordKey?.replace(/^surface:/, ""); - if (!providerId) { - return null; - } - return { - providerId: String(providerId), - title: value.title || String(providerId), - version: value.version || 0, - updatedAt: value.updatedAt || value.updated_at || null, - surface: value.surface ? normalizeEveSurfaceTree(value.surface) : null, - }; -} - -function decodeCultMeshDocumentId(documentId) { - const text = String(documentId || ""); - if (!text.startsWith("cultmesh://")) return text; - const parsed = new URL(text); - return decodeURIComponent(parsed.pathname.replace(/^\/+/, "")); -} - -function arrayOfObjects(value) { - return Array.isArray(value) ? value.filter((item) => item && typeof item === "object" && !Array.isArray(item)) : []; -} - -function requireObject(label) { - return { - parse(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error(`${label} must be an object.`); - } - return value; - }, - }; -} - -function readJsonBody(request) { - return new Promise((resolve, reject) => { - const chunks = []; - request.on("data", (chunk) => chunks.push(chunk)); - request.on("error", reject); - request.on("end", () => { - try { - const text = Buffer.concat(chunks).toString("utf8").trim(); - resolve(text ? JSON.parse(text) : {}); - } catch (error) { - reject(new Error(`Invalid JSON body: ${error.message}`)); - } - }); - }); -} - -function writeJson(response, statusCode, body) { - writeCors(response, statusCode); - response.setHeader("Content-Type", "application/json; charset=utf-8"); - response.end(`${JSON.stringify(body, null, 2)}\n`); -} - -function writeCors(response, statusCode) { - response.statusCode = statusCode; - response.setHeader("Access-Control-Allow-Origin", "*"); - response.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); - response.setHeader("Access-Control-Allow-Headers", "content-type"); -} - -async function serveStaticFile(response, filePath, contentType, headOnly = false) { - if (!filePath || !fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) { - writeJson(response, 404, { ok: false, error: "not found" }); - return; - } - writeCors(response, 200); - response.setHeader("Content-Type", contentType || contentTypeForPath(filePath)); - response.setHeader("Content-Length", fs.statSync(filePath).size); - if (headOnly) { - response.end(); - return; - } - response.end(await fs.promises.readFile(filePath)); -} - -function safeJoin(root, relativePath) { - const resolved = path.resolve(root, relativePath || ""); - const normalizedRoot = path.resolve(root); - if (resolved !== normalizedRoot && !resolved.startsWith(`${normalizedRoot}${path.sep}`)) { - return ""; - } - return resolved; -} - -async function readAssetCatalog(options) { - const catalog = await readCatalog(options); - return { - ok: true, - schema: "gamecult.hermodr.asset_catalog.v1", - source: "odin-cultmesh-provider-routes", - providers: catalog.providers, - manifests: [], - }; -} - -async function serveCultMeshAsset(options, response, uri, headOnly = false) { - const assetUri = normalizeCultMeshAssetUri(uri); - try { - const asset = await fetchCultMeshCdnAsset(options, assetUri); - writeCors(response, 200); - response.setHeader("Content-Type", asset.contentType || contentTypeForPath(assetUri)); - response.setHeader("Content-Length", asset.bytes.length); - response.setHeader("X-CultMesh-CDN-Provider", asset.providerId); - response.setHeader("X-CultMesh-CDN-Endpoint", asset.endpoint); - if (headOnly) { - response.end(); - return; - } - response.end(asset.bytes); - return; - } catch (error) { - writeJson(response, 502, { - ok: false, - error: `Provider-owned CultMesh CDN fetch failed for ${assetUri}: ${error instanceof Error ? error.message : String(error)}`, - note: "Hermodr resolves provider-advertised CultMesh CDN routes and never reads product filesystems.", - }); - return; - } -} - -async function fetchCultMeshCdnAsset(options, assetUri) { - const catalog = await readCatalog(options); - const route = findProviderCdnRoute(catalog, assetUri); - if (!route) { - throw new Error(`No provider advertised a CultMesh CDN route for '${assetUri}'.`); - } - - const peer = await getProviderRudpPeer(route.endpoint); - const document = await requestCultNetRawSnapshotDocument( - peer, - "gamecult.cultmesh.cdn.asset_blob.v1", - assetUri, - { timeoutMs: 4_000, messageIdPrefix: "hermodr-cdn" }, - ); - return { - providerId: route.providerId, - endpoint: route.endpoint, - contentType: contentTypeFromTags(document.tags) || contentTypeForPath(assetUri), - bytes: bufferFromMessagePackedBytes(document.payload), - }; -} - -async function fetchProviderDocument(catalog, request) { - const route = findProviderSnapshotRoute(catalog, request.providerId); - if (!route) return null; - const recordKey = decodeCultMeshDocumentId(request.documentId); - const document = await requestProviderSnapshotDocumentWithReconnect( - route.endpoint, - request.schemaId, - recordKey, - { timeoutMs: 4_000, messageIdPrefix: "hermodr-document" }, - ); - return { - endpoint: route.endpoint, - schemaId: document.schemaId, - recordKey: document.recordKey, - document: decodeMessagePack(bufferFromPayload(document.payload)), - }; -} - -async function requestProviderSnapshotDocumentWithReconnect(endpoint, schemaId, recordKey, options = {}) { - try { - const peer = await getProviderRudpPeer(endpoint); - return await requestCultNetRawSnapshotDocument(peer, schemaId, recordKey, options); - } catch (firstError) { - await dropProviderRudpPeer(endpoint); - const peer = await getProviderRudpPeer(endpoint); - try { - return await requestCultNetRawSnapshotDocument(peer, schemaId, recordKey, options); - } catch (secondError) { - secondError.message = `${secondError.message} Provider route ${endpoint} was retried after dropping a stale Hermodr RUDP peer. First error: ${ - firstError instanceof Error ? firstError.message : String(firstError) - }`; - throw secondError; - } - } -} - -async function requestProviderSnapshotFirstDocumentWithReconnect(endpoint, schemaId, recordKeys, options = {}) { - try { - const peer = await getProviderRudpPeer(endpoint); - return await requestCultNetRawSnapshotFirstDocument(peer, schemaId, recordKeys, options); - } catch (firstError) { - await dropProviderRudpPeer(endpoint); - const peer = await getProviderRudpPeer(endpoint); - try { - return await requestCultNetRawSnapshotFirstDocument(peer, schemaId, recordKeys, options); - } catch (secondError) { - secondError.message = `${secondError.message} Provider route ${endpoint} was retried after dropping a stale Hermodr RUDP peer. First error: ${ - firstError instanceof Error ? firstError.message : String(firstError) - }`; - throw secondError; - } - } -} - -async function getProviderRudpPeer(endpoint) { - const key = String(endpoint || "").trim(); - if (!key) throw new Error("Provider RUDP endpoint is empty."); - const existing = providerRudpPeers.get(key); - if (existing) return existing; - - const pending = CultMesh.createRudpPeer( - "hermodr-browser-lowering-provider", - providerRudpConnectionId, - key, - hermodrRudpPeerOptions(), - ).catch((error) => { - providerRudpPeers.delete(key); - throw error; - }); - providerRudpPeers.set(key, pending); - return pending; -} - -async function dropProviderRudpPeer(endpoint) { - const key = String(endpoint || "").trim(); - const peerOrPromise = providerRudpPeers.get(key); - providerRudpPeers.delete(key); - if (!peerOrPromise) return; - try { - const peer = await peerOrPromise; - if (typeof peer?.close === "function") peer.close(); - } catch { - // The peer is already unusable; removing it is the repair. - } -} - -function closeProviderRudpPeers() { - for (const peerOrPromise of providerRudpPeers.values()) { - Promise.resolve(peerOrPromise) - .then((peer) => { - if (typeof peer?.close === "function") peer.close(); - }) - .catch(() => {}); - } - providerRudpPeers.clear(); -} - -function findProviderSnapshotRoute(catalog, providerId) { - const normalized = String(providerId || "").trim(); - const providers = (catalog.providers || []).filter((provider) => { - if (!normalized) return true; - return provider.id === normalized || - provider.id.includes(normalized) || - normalized.includes(provider.id); - }); - const candidates = normalized ? providers : (catalog.providers || []); - for (const provider of candidates) { - const endpoints = [...(provider.endpoints || []), ...(provider.routes || [])]; - const route = endpoints.find((endpoint) => { - const address = String(endpoint.uri || endpoint.endpoint || endpoint.address || ""); - const role = [endpoint.id, endpoint.role, endpoint.resolver, ...(endpoint.tags || [])].join(" ").toLowerCase(); - return address.startsWith("rudp://") && (role.includes("snapshot") || role.includes("provider-cultmesh-rudp")); - }) || endpoints.find((endpoint) => { - const address = String(endpoint.uri || endpoint.endpoint || endpoint.address || ""); - return address.startsWith("rudp://"); - }); - if (route) { - return { - providerId: provider.id, - endpoint: route.uri || route.endpoint || route.address, - }; - } - } - return null; -} - -function findProviderCdnRoute(catalog, assetUri = "") { - const providerHint = providerIdFromCultMeshUri(assetUri); - const providers = (catalog.providers || []).filter((provider) => - !providerHint || provider.id === providerHint || provider.id.startsWith(`${providerHint}.`) || providerHint.startsWith(`${provider.id}.`)); - for (const provider of providers) { - const endpoints = [...(provider.endpoints || []), ...(provider.routes || [])]; - const route = endpoints.find((endpoint) => { - const address = String(endpoint.uri || endpoint.endpoint || endpoint.address || ""); - const role = [endpoint.id, endpoint.role, endpoint.resolver, endpoint.schemaId, ...(endpoint.tags || [])].join(" ").toLowerCase(); - return (address.startsWith("cultmesh://") || address.startsWith("rudp://")) && - role.includes("cultmesh-cdn") && - (role.includes("asset_blob") || role.includes("cdn")); - }) || endpoints.find((endpoint) => { - const address = String(endpoint.uri || endpoint.endpoint || endpoint.address || ""); - const role = [endpoint.id, endpoint.role, endpoint.resolver, ...(endpoint.tags || [])].join(" ").toLowerCase(); - return (address.startsWith("cultmesh://") || address.startsWith("rudp://")) && role.includes("cdn"); - }); - if (route) { - return { - providerId: provider.id, - endpoint: route.uri || route.endpoint || route.address, - }; - } - } - return null; -} - -async function requestCultNetRawSnapshotDocument(peer, schemaId, recordKey, options = {}) { - const messageId = `${options.messageIdPrefix || "hermodr-snapshot"}:${Date.now()}:${Math.random().toString(16).slice(2)}`; - const response = await requestCultNetRawSnapshot(peer, [recordKey], { - schemaIds: [schemaId], - messageId, - timeoutMs: options.timeoutMs, - }); - const candidates = response.documents.filter((candidate) => candidate.recordKey === recordKey); - const document = candidates.find((candidate) => candidate.schemaId === schemaId) || candidates[0]; - if (!document) { - throw new Error(`CultMesh peer snapshot did not return ${schemaId} at ${recordKey}.`); - } - return document; -} - -async function requestCultNetRawSnapshotFirstDocument(peer, schemaId, recordKeys, options = {}) { - const keys = [...new Set(recordKeys.map((key) => String(key || "").trim()).filter(Boolean))]; - const messageId = `${options.messageIdPrefix || "hermodr-snapshot"}:${Date.now()}:${Math.random().toString(16).slice(2)}`; - const response = await requestCultNetRawSnapshot(peer, keys, { - schemaIds: [schemaId], - messageId, - timeoutMs: options.timeoutMs, - }); - const document = response.documents.find((candidate) => candidate.schemaId === schemaId) || response.documents[0]; - if (!document) { - throw new Error(`CultMesh peer snapshot did not return ${schemaId} at ${keys.join(", ")}.`); - } - return document; -} - -async function requestCultNetRawSnapshot(peer, recordKeys, options) { - const messageId = options.messageId || `${options.messageIdPrefix || "hermodr-snapshot"}:${Date.now()}:${Math.random().toString(16).slice(2)}`; - return new Promise((resolve, reject) => { - const cleanup = () => { - clearTimeout(timer); - peer.off("message", onMessage); - peer.off("invalidMessage", onInvalidMessage); - peer.off("error", onError); - peer.off("close", onClose); - }; - const rejectWith = (error) => { - cleanup(); - reject(error); - }; - const onMessage = (message) => { - if (message?.schemaVersion === "cultnet.error.v0") { - rejectWith(new Error(message.error || "CultNet peer returned an error.")); - return; - } - if (message?.schemaVersion !== "cultnet.snapshot_response_raw.v0" || - message.messageId !== messageId) { - return; - } - cleanup(); - resolve(message); - }; - const onInvalidMessage = (error) => rejectWith(error); - const onError = (error) => rejectWith(error); - const onClose = () => rejectWith(new Error("CultNet peer closed before snapshot response.")); - const timer = setTimeout( - () => rejectWith(new Error(`Timed out waiting for CultNet snapshot ${messageId}.`)), - options.timeoutMs || 4_000, - ); - - peer.on("message", onMessage); - peer.on("invalidMessage", onInvalidMessage); - peer.on("error", onError); - peer.on("close", onClose); - peer.sendSnapshotRequest({ - schemaVersion: "cultnet.snapshot_request.v0", - messageId, - ...(recordKeys.length > 0 ? { recordKeys: [...recordKeys] } : {}), - schemaIds: Array.isArray(options.schemaIds) ? [...options.schemaIds] : undefined, - }); - }); -} - -function bufferFromMessagePackedBytes(payload) { - const decoded = decodeMessagePack(bufferFromPayload(payload)); - return bufferFromPayload(decoded); -} - -function bufferFromPayload(payload) { - if (Buffer.isBuffer(payload)) return payload; - if (payload instanceof Uint8Array) return Buffer.from(payload); - if (Array.isArray(payload)) return Buffer.from(payload); - if (payload && typeof payload === "object") { - const values = Object.keys(payload) - .sort((left, right) => Number(left) - Number(right)) - .map((key) => payload[key]); - if (values.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)) { - return Buffer.from(values); - } - } - throw new Error("CultMesh CDN asset payload was not binary."); -} - -function contentTypeFromTags(tags) { - const tag = (Array.isArray(tags) ? tags : []).find((candidate) => String(candidate).startsWith("mime:")); - return tag ? String(tag).slice("mime:".length) : ""; -} - -function normalizeCultMeshAssetUri(uri) { - const text = String(uri || "").trim(); - if (!text) return ""; - if (text.startsWith("cultmesh://") || text.startsWith("resources://")) return text; - throw new Error(`Asset URI '${text}' is not a provider-owned CultMesh URI.`); -} - -function providerIdFromCultMeshUri(uri) { - const match = /^cultmesh:\/\/([^/]+)/i.exec(String(uri || "").trim()); - return match ? decodeURIComponent(match[1]) : ""; -} - -function contentTypeForPath(filePath) { - const ext = path.extname(filePath).toLowerCase(); - return { - ".css": "text/css; charset=utf-8", - ".html": "text/html; charset=utf-8", - ".js": "text/javascript; charset=utf-8", - ".mjs": "text/javascript; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".svg": "image/svg+xml", - }[ext] || "application/octet-stream"; -} - -function stringOption(value, fallback) { - return typeof value === "string" && value.trim() ? value.trim() : fallback; -} - -function numberOption(value, fallback) { - const parsed = Number.parseInt(String(value ?? ""), 10); - return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback; -} - -function stringListOption(value, fallback, defaultValue = []) { - const text = String(value ?? fallback ?? "").trim(); - if (!text) return [...defaultValue]; - return text.split(",").map((entry) => entry.trim()).filter(Boolean); -} - -function resolveCultCachePackagePath() { - const candidate = path.resolve(projectsRoot, "CultLib", "packages", "cultcache-ts", "package.json"); - if (!fs.existsSync(candidate)) { - throw new Error(`CultCache TypeScript runtime is unavailable at ${candidate}`); - } - return candidate; -} - -function resolveCultMeshPackagePath() { - const candidate = path.resolve(process.env.CULTLIB_ROOT || path.resolve(projectsRoot, "CultLib"), "packages", "cultmesh-ts", "package.json"); - if (!fs.existsSync(candidate)) { - throw new Error(`CultMesh TypeScript runtime is unavailable at ${candidate}`); - } - return candidate; -} - -function resolveCultCacheRuntimePath() { - return path.resolve(projectsRoot, "CultLib", "packages", "cultcache-ts", "dist", "index.js"); -} - -function resolveCultMeshRuntimePath() { - return path.resolve(process.env.CULTLIB_ROOT || path.resolve(projectsRoot, "CultLib"), "packages", "cultmesh-ts", "dist", "index.js"); -} - -function printUsage() { - console.log(`Usage: - node src/hermodr-daemon.cjs [--host 127.0.0.1] [--port 8798] [--odin-cultmesh-uri cultmesh://odin/rendezvous/provider-catalog] - -Hermodr is a browser lowering adapter. It owns no provider discovery and no -daemon state. Odin advertises where a provider's state lives; Hermodr resolves -that route and reads the provider directly, then lowers the surface to browsers -and relays operator intent back as typed CultMesh command documents. - -Live Eve state needs a reachable Odin catalog and a CULTLIB_ROOT pointing at the -CultLib reliability branch. Poll cadence is --provider-poll-interval-ms -(default 2000). -`); -} - -if (require.main === module) { - main().catch((error) => { - const statusCode = error?.statusCode || 1; - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = statusCode === 409 ? 1 : statusCode; - }); -} - -module.exports = { - createBrowserCatalog, - createHermodrBridge, - findProviderCdnRoute, - hermodrRudpPeerOptions, - normalizeStateBindingSource, - normalizeProviderAdvertisement, - normalizeSurfaceState, - surfaceRecordKeys, -}; diff --git a/src/hermodr-provider-subscription.cjs b/src/hermodr-provider-subscription.cjs deleted file mode 100644 index 9fe43f7..0000000 --- a/src/hermodr-provider-subscription.cjs +++ /dev/null @@ -1,154 +0,0 @@ -"use strict"; - -// Provider state subscription for the browser lowering. -// -// Hermodr previously ran its own provider-session ingress and accepted -// publications into a private store, which made it a required ingress rather -// than a bridge: a surface reached a browser only if its provider bound to -// Hermodr specifically. Providers own their state. Odin publishes where to find -// it. This source resolves a provider through the catalog and reads that -// provider directly, exactly as the command path already routes intent. -// -// It presents the same narrow port the previous store did - -// forProvider(id) -> { latest, watch, watchLifecycle } - so -// HermodrStateStreamRegistry, its sequence numbers, and its stale/reconnected -// transitions are untouched. -// -// readDocument is injected. That keeps the polling and lifecycle semantics -// testable without a live Verse, and keeps CultMesh transport out of this file. - -const DEFAULT_POLL_INTERVAL_MS = 2_000; - -class ProviderSubscriptionSource { - constructor(readDocument, options = {}) { - if (typeof readDocument !== "function") { - throw new Error("Provider subscription source requires a readDocument(providerId, schemaId, recordKey) function."); - } - this.readDocument = readDocument; - this.pollIntervalMs = Number.isFinite(options.pollIntervalMs) && options.pollIntervalMs > 0 - ? options.pollIntervalMs - : DEFAULT_POLL_INTERVAL_MS; - this.setInterval = options.setInterval || setInterval; - this.clearInterval = options.clearInterval || clearInterval; - this.onError = options.onError || (() => {}); - this.entries = new Map(); - this.sequence = 0; - } - - forProvider(providerId) { - return { - latest: (schemaId, recordKey) => this.latest(providerId, schemaId, recordKey), - watch: (schemaId, recordKey, callback) => this.watch(providerId, schemaId, recordKey, callback), - watchLifecycle: (schemaId, recordKey, callback) => this.watchLifecycle(providerId, schemaId, recordKey, callback), - }; - } - - async latest(providerId, schemaId, recordKey) { - const value = await this.readDocument(providerId, schemaId, recordKey); - if (value === undefined || value === null) { - throw new Error(`Provider ${providerId} published no ${schemaId}:${recordKey}.`); - } - return value; - } - - watch(providerId, schemaId, recordKey, callback) { - return this.watchLifecycle(providerId, schemaId, recordKey, (event) => { - if (event.kind !== "withdrawn") callback(event.value); - }); - } - - watchLifecycle(providerId, schemaId, recordKey, callback) { - const key = keyOf(providerId, schemaId, recordKey); - let entry = this.entries.get(key); - if (!entry) { - entry = { - providerId, - schemaId, - recordKey, - listeners: new Set(), - value: undefined, - present: false, - timer: null, - polling: false, - }; - this.entries.set(key, entry); - entry.timer = this.setInterval(() => { this.poll(key); }, this.pollIntervalMs); - if (typeof entry.timer?.unref === "function") entry.timer.unref(); - } - - entry.listeners.add(callback); - if (entry.present) { - callback({ kind: "snapshot", sequence: ++this.sequence, value: entry.value }); - } - // First listener on a cold entry still needs a value without waiting a full - // interval. Subsequent listeners piggyback on the shared poll. - this.poll(key); - - let released = false; - return () => { - if (released) return; - released = true; - entry.listeners.delete(callback); - if (entry.listeners.size === 0) { - this.clearInterval(entry.timer); - this.entries.delete(key); - } - }; - } - - async poll(key) { - const entry = this.entries.get(key); - if (!entry || entry.polling) return; - entry.polling = true; - try { - let value; - try { - value = await this.readDocument(entry.providerId, entry.schemaId, entry.recordKey); - } catch (error) { - // A read failure is not a withdrawal. The provider may be restarting, - // and claiming withdrawal would let a transport hiccup look like the - // provider retracting state it still holds. - this.onError(error, { providerId: entry.providerId, schemaId: entry.schemaId, recordKey: entry.recordKey }); - return; - } - - if (!this.entries.has(key)) return; - - const missing = value === undefined || value === null; - if (missing) { - if (entry.present) { - entry.present = false; - entry.value = undefined; - this.emit(entry, { kind: "withdrawn", sequence: ++this.sequence, reason: "withdrawn" }); - } - return; - } - - if (entry.present && equivalent(entry.value, value)) return; - - const kind = entry.present ? "update" : "snapshot"; - entry.present = true; - entry.value = value; - this.emit(entry, { kind, sequence: ++this.sequence, value }); - } finally { - entry.polling = false; - } - } - - emit(entry, event) { - for (const listener of entry.listeners) listener(event); - } -} - -function keyOf(providerId, schemaId, recordKey) { - if (!String(providerId || "").trim() || !String(schemaId || "").trim() || !String(recordKey || "").trim()) { - throw new Error("Provider subscription requires providerId, schemaId, and recordKey."); - } - return `${providerId}\u001f${schemaId}\u001f${recordKey}`; -} - -function equivalent(left, right) { - return JSON.stringify(left) === JSON.stringify(right); -} - -module.exports = { ProviderSubscriptionSource, DEFAULT_POLL_INTERVAL_MS }; diff --git a/src/hermodr-state-stream.cjs b/src/hermodr-state-stream.cjs deleted file mode 100644 index b5db48b..0000000 --- a/src/hermodr-state-stream.cjs +++ /dev/null @@ -1,32 +0,0 @@ -"use strict"; - -class HermodrStateStreamRegistry { - constructor(source) { this.source = source; this.entries = new Map(); } - acquire(selection, listener) { - const key = `${selection.schemaId}\u001f${selection.recordKey}`; - let entry = this.entries.get(key); - if (!entry) { - entry = { listeners: new Set([listener]), sequence: 0, stale: false, unsubscribe: null }; - entry.unsubscribe = this.source.forProvider(selection.providerId).watchLifecycle(selection.schemaId, selection.recordKey, event => { - const type = event.kind === "withdrawn" ? "stale" : entry.stale ? "reconnected" : event.kind; - entry.stale = type === "stale"; - const lowered = { type, sequence: ++entry.sequence, source: selection, - ...(event.value === undefined ? {} : { value: event.value }), - ...(event.reason ? { reason: event.reason } : {}) }; - for (const subscriber of entry.listeners) subscriber(lowered); - }); - this.entries.set(key, entry); - } else { - entry.listeners.add(listener); - } - let released = false; - return () => { - if (released) return; - released = true; - entry.listeners.delete(listener); - if (entry.listeners.size === 0) { entry.unsubscribe(); this.entries.delete(key); } - }; - } -} - -module.exports = { HermodrStateStreamRegistry }; diff --git a/src/odin/utils.cjs b/src/odin/utils.cjs deleted file mode 100644 index c4aff92..0000000 --- a/src/odin/utils.cjs +++ /dev/null @@ -1,68 +0,0 @@ -"use strict"; - -const fs = require("fs"); - -function parseArgs(argv) { - const parsed = {}; - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (!arg.startsWith("--")) continue; - const key = arg.slice(2); - const next = argv[index + 1]; - if (!next || next.startsWith("--")) { - parsed[key] = true; - } else { - parsed[key] = next; - index += 1; - } - } - return parsed; -} - -function parseObjectDocument(label) { - return { - parse(value) { - if (!value || typeof value !== "object") { - throw new Error(`${label} must be an object`); - } - return value; - }, - }; -} - -function positiveNumber(value, fallback) { - const number = Number(value); - return Number.isFinite(number) && number > 0 ? number : fallback; -} - -function stableId(value) { - return String(value).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); -} - -function clampNumber(value, min, max) { - if (!Number.isFinite(value)) return min; - return Math.max(min, Math.min(max, Math.round(value))); -} - -function tailTextFile(filePath, maxBytes) { - const stat = fs.statSync(filePath); - const length = Math.min(stat.size, maxBytes); - const buffer = Buffer.alloc(length); - const fd = fs.openSync(filePath, "r"); - try { - fs.readSync(fd, buffer, 0, length, stat.size - length); - } finally { - fs.closeSync(fd); - } - - return buffer.toString("utf8"); -} - -module.exports = { - clampNumber, - parseArgs, - parseObjectDocument, - positiveNumber, - stableId, - tailTextFile, -}; diff --git a/test/hermodr-catalog.test.cjs b/test/hermodr-catalog.test.cjs deleted file mode 100644 index 7a4a2c2..0000000 --- a/test/hermodr-catalog.test.cjs +++ /dev/null @@ -1,70 +0,0 @@ -"use strict"; - -const assert = require("node:assert/strict"); -const test = require("node:test"); - -const { - createBrowserCatalog, - findProviderCdnRoute, - hermodrRudpPeerOptions, - normalizeProviderAdvertisement, - normalizeSurfaceState, - surfaceRecordKeys, -} = require("../src/hermodr-daemon.cjs"); - -test("Hermodr outbound RUDP peers bind beyond loopback", () => { - const options = hermodrRudpPeerOptions(); - assert.equal(options.bindHost, "0.0.0.0"); - assert.equal(options.connectTimeoutMs, 2_000); -}); - -const provider = normalizeProviderAdvertisement({ - providerId: "fixture.game", - title: "Fixture game", - surfaces: [ - { surfaceId: "fixture.menu", surfaceKind: "menu", recordRef: "cultmesh://fixture/surfaces/menu" }, - { surfaceId: "fixture.world", surfaceKind: "interactive-world", recordRef: "cultmesh://fixture/surfaces/world" }, - ], - routes: [{ - id: "fixture-cdn", - uri: "rudp://127.0.0.1:3000", - tags: ["cultmesh-cdn", "asset_blob"], - }], -}); - -test("Hermodr preserves every advertised surface and its semantic kind", () => { - const catalog = createBrowserCatalog([provider], { odinCultMeshUri: "cultmesh://odin/providers" }); - assert.deepEqual(catalog.surfaces.map(surface => surface.surfaceId), ["fixture.menu", "fixture.world"]); - assert.equal(catalog.surfaces[1].surfaceKind, "interactive-world"); - assert.equal(catalog.providers[0].surfaces[1].recordRef, "cultmesh://fixture/surfaces/world"); -}); - -test("surface reads prefer the provider-advertised record reference", () => { - const catalog = createBrowserCatalog([provider]); - assert.equal(surfaceRecordKeys(catalog, "fixture.game", "fixture.world")[0], "cultmesh://fixture/surfaces/world"); -}); - -test("surface-state normalization lowers MessagePack array records", () => { - const state = normalizeSurfaceState([ - "gjallar.overview", - "Gjallar", - 7, - "2026-08-25T00:00:00Z", - [ - "gjallar.overview.surface", - ["root", "dashboard", { title: "Gjallar" }, [], [], [], { mode: "weighted-bisect" }, {}], - [], - ], - ], "gjallar.overview"); - - assert.equal(state.surface.id, "gjallar.overview.surface"); - assert.equal(state.surface.root.kind, "dashboard"); - assert.equal(state.surface.root.layout.mode, "weighted-bisect"); -}); - -test("CDN routing follows the asset URI provider instead of a product name", () => { - const catalog = createBrowserCatalog([provider]); - const route = findProviderCdnRoute(catalog, "cultmesh://fixture.game/assets/player"); - assert.equal(route.providerId, "fixture.game"); - assert.equal(route.endpoint, "rudp://127.0.0.1:3000"); -}); diff --git a/test/hermodr-provider-subscription.test.cjs b/test/hermodr-provider-subscription.test.cjs deleted file mode 100644 index 99e4c27..0000000 --- a/test/hermodr-provider-subscription.test.cjs +++ /dev/null @@ -1,188 +0,0 @@ -"use strict"; - -const test = require("node:test"); -const assert = require("node:assert/strict"); - -const { ProviderSubscriptionSource } = require("../src/hermodr-provider-subscription.cjs"); - -function harness(reads) { - const state = { value: reads.shift(), calls: 0, cleared: 0 }; - let tick = null; - const source = new ProviderSubscriptionSource( - async () => { - state.calls += 1; - if (state.value instanceof Error) throw state.value; - return state.value; - }, - { - setInterval: (fn) => { tick = fn; return { unref() {} }; }, - clearInterval: () => { state.cleared += 1; tick = null; }, - onError: () => {}, - }, - ); - return { - source, - state, - set: (value) => { state.value = value; }, - poll: async () => { if (tick) tick(); await flush(); }, - hasTimer: () => tick !== null, - }; -} - -async function flush() { - for (let i = 0; i < 4; i += 1) await new Promise((resolve) => setImmediate(resolve)); -} - -function collect(source, providerId = "erycina", schemaId = "gamecult.eve.surface_state.v1", recordKey = "erycina") { - const events = []; - const release = source.forProvider(providerId).watchLifecycle(schemaId, recordKey, (event) => events.push(event)); - return { events, release }; -} - -test("first watch emits a snapshot of the provider's document", async () => { - const h = harness([{ title: "Erycina" }]); - const { events } = collect(h.source); - await flush(); - - assert.equal(events.length, 1); - assert.equal(events[0].kind, "snapshot"); - assert.deepEqual(events[0].value, { title: "Erycina" }); -}); - -test("an unchanged document emits nothing further", async () => { - const h = harness([{ title: "Erycina" }]); - const { events } = collect(h.source); - await flush(); - await h.poll(); - await h.poll(); - - assert.equal(events.length, 1); -}); - -test("a changed document emits an update", async () => { - const h = harness([{ version: 1 }]); - const { events } = collect(h.source); - await flush(); - h.set({ version: 2 }); - await h.poll(); - - assert.equal(events.length, 2); - assert.equal(events[1].kind, "update"); - assert.deepEqual(events[1].value, { version: 2 }); - assert.ok(events[1].sequence > events[0].sequence); -}); - -test("a document that disappears is withdrawn", async () => { - const h = harness([{ version: 1 }]); - const { events } = collect(h.source); - await flush(); - h.set(null); - await h.poll(); - - assert.equal(events.at(-1).kind, "withdrawn"); - assert.equal(events.at(-1).reason, "withdrawn"); -}); - -test("a document that returns after withdrawal emits a fresh snapshot", async () => { - const h = harness([{ version: 1 }]); - const { events } = collect(h.source); - await flush(); - h.set(null); - await h.poll(); - h.set({ version: 3 }); - await h.poll(); - - assert.equal(events.at(-1).kind, "snapshot"); - assert.deepEqual(events.at(-1).value, { version: 3 }); -}); - -test("a read failure is not a withdrawal", async () => { - // A provider restart or a dropped packet must not look like the provider - // retracting state it still holds. This is the invariant that stops a - // transport hiccup from being rendered as authoritative absence. - const h = harness([{ version: 1 }]); - const { events } = collect(h.source); - await flush(); - const before = events.length; - - h.set(new Error("rudp timeout")); - await h.poll(); - await h.poll(); - - assert.equal(events.length, before); - assert.equal(events.at(-1).kind, "snapshot"); -}); - -test("state survives the failure and updates once reads recover", async () => { - const h = harness([{ version: 1 }]); - const { events } = collect(h.source); - await flush(); - h.set(new Error("rudp timeout")); - await h.poll(); - h.set({ version: 9 }); - await h.poll(); - - assert.equal(events.at(-1).kind, "update"); - assert.deepEqual(events.at(-1).value, { version: 9 }); -}); - -test("releasing the last listener stops polling", async () => { - const h = harness([{ version: 1 }]); - const { release } = collect(h.source); - await flush(); - assert.ok(h.hasTimer()); - - release(); - - assert.equal(h.state.cleared, 1); - assert.equal(h.hasTimer(), false); -}); - -test("a second listener shares one poll and receives the current snapshot", async () => { - const h = harness([{ version: 1 }]); - const first = collect(h.source); - await flush(); - const callsAfterFirst = h.state.calls; - - const second = collect(h.source); - await flush(); - - assert.equal(second.events[0].kind, "snapshot"); - assert.deepEqual(second.events[0].value, { version: 1 }); - // The shared entry polls once per interval regardless of listener count. - await h.poll(); - assert.ok(h.state.calls <= callsAfterFirst + 2); - - first.release(); - assert.equal(h.state.cleared, 0, "the entry survives while one listener remains"); - second.release(); - assert.equal(h.state.cleared, 1); -}); - -test("watch skips withdrawal events and yields values only", async () => { - const h = harness([{ version: 1 }]); - const values = []; - h.source.forProvider("erycina").watch("s", "k", (value) => values.push(value)); - await flush(); - h.set(null); - await h.poll(); - h.set({ version: 2 }); - await h.poll(); - - assert.deepEqual(values, [{ version: 1 }, { version: 2 }]); -}); - -test("latest refuses to invent a document the provider does not publish", async () => { - const h = harness([null]); - await assert.rejects( - () => h.source.forProvider("erycina").latest("gamecult.eve.surface_state.v1", "erycina"), - /published no/, - ); -}); - -test("a subscription requires provider, schema, and record key", () => { - const h = harness([{}]); - assert.throws(() => h.source.forProvider("").watchLifecycle("s", "k", () => {}), /requires providerId/); - assert.throws(() => h.source.forProvider("p").watchLifecycle("", "k", () => {}), /requires providerId/); - assert.throws(() => h.source.forProvider("p").watchLifecycle("s", "", () => {}), /requires providerId/); -});