diff --git a/deno.json b/deno.json index d4af675..f9d40d3 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@moonlight-protocol/network-dashboard-platform", - "version": "0.1.13", + "version": "0.1.14", "license": "MIT", "exports": "./src/main.ts", "tasks": { diff --git a/src/config/env.ts b/src/config/env.ts index d8a7235..23df82a 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -21,6 +21,9 @@ * populated `.env`. */ import { loadSync } from "@std/dotenv"; +import { StructuredError } from "@/error/structured-error.ts"; + +const SOURCE = "network-dashboard-platform/config/env"; let fileEnvCache: Record | null = null; function fileEnv(): Record { @@ -45,7 +48,11 @@ function get(key: string): string | undefined { function requireEnv(key: string): string { const value = get(key); if (value === undefined) { - throw new Error(`${key} is required but was not set`); + throw new StructuredError({ + code: "ENV_MISSING", + source: SOURCE, + message: `${key} is required but was not set`, + }); } return value; } @@ -55,9 +62,11 @@ export function getNetwork(): string { if (networkCache !== undefined) return networkCache; const v = requireEnv("NETWORK"); if (v !== "testnet" && v !== "mainnet" && v !== "local") { - throw new Error( - `NETWORK must be 'testnet' | 'mainnet' | 'local' (got: ${v})`, - ); + throw new StructuredError({ + code: "ENV_INVALID_NETWORK", + source: SOURCE, + message: `NETWORK must be 'testnet' | 'mainnet' | 'local' (got: ${v})`, + }); } networkCache = v; return v; diff --git a/src/core/events/bus.ts b/src/core/events/bus.ts index 7f12adf..9d6fa5a 100644 --- a/src/core/events/bus.ts +++ b/src/core/events/bus.ts @@ -1,7 +1,9 @@ import type { Logger } from "@/utils/logger/index.ts"; +import type { StructuredErrorShape } from "@/error/structured-error.ts"; import type { NetworkEvent } from "./types.ts"; type Listener = (event: NetworkEvent) => void; +type ErrorListener = (error: StructuredErrorShape) => void; /** * In-process pub/sub for the public network-dashboard event stream. @@ -16,6 +18,7 @@ type Listener = (event: NetworkEvent) => void; */ export class NetworkEventBus { private listeners = new Set(); + private errorListeners = new Set(); private log: Logger; constructor(deps: { log: Logger }) { @@ -32,6 +35,21 @@ export class NetworkEventBus { }; } + /** + * Subscribe to structured error broadcasts (topology refresh failures and + * the like) — a separate channel from the event stream so a subscriber can + * surface a degraded-data banner without it landing in the activity feed. + */ + subscribeErrors(listener: ErrorListener): () => void { + this.log.info("subscribeErrors"); + this.errorListeners.add(listener); + this.log.debug("errorListenerCount", this.errorListeners.size); + return () => { + this.log.info("unsubscribeErrors"); + this.errorListeners.delete(listener); + }; + } + publish(event: NetworkEvent): void { this.log.info("publish"); this.log.debug("eventKind", event.kind); @@ -45,7 +63,29 @@ export class NetworkEventBus { } } + /** + * Broadcast a client-safe structured error to error subscribers. Same + * listener-isolation guarantee as `publish`: one bad listener never breaks + * the fan-out. + */ + publishError(error: StructuredErrorShape): void { + this.log.info("publishError"); + this.log.debug("errorCode", error.code); + this.log.debug("errorListenerCount", this.errorListeners.size); + for (const listener of this.errorListeners) { + try { + listener(error); + } catch (err) { + this.log.error(err, "error listener threw during publishError"); + } + } + } + listenerCount(): number { return this.listeners.size; } + + errorListenerCount(): number { + return this.errorListeners.size; + } } diff --git a/src/core/events/types.ts b/src/core/events/types.ts index 5717455..d6fbd44 100644 --- a/src/core/events/types.ts +++ b/src/core/events/types.ts @@ -5,6 +5,7 @@ * The SPA renders each kind with its own activity-card colour and pulse * colour. Adding a kind requires SPA changes — keep this union narrow. */ +import type { StructuredErrorShape } from "@/error/structured-error.ts"; export const NETWORK_EVENT_KINDS = [ "council_formed", @@ -120,7 +121,18 @@ export type LiveFrame = { counters: Counters; }; -export type ServerFrame = SnapshotFrame | LiveFrame; +/** + * Client-safe structured error surfaced to WS subscribers (e.g. a + * council-platform topology refresh failed, so the live view may be stale). + * Matches the error-bubbling standard's `{ code, source, message }` shape; + * carries no internal cause chain or stack. See `@/error/structured-error.ts`. + */ +export type ErrorFrame = { + type: "error"; + error: StructuredErrorShape; +}; + +export type ServerFrame = SnapshotFrame | LiveFrame | ErrorFrame; /** * Subprotocol echoed back to clients. Bump the suffix on a wire-incompatible @@ -128,5 +140,10 @@ export type ServerFrame = SnapshotFrame | LiveFrame; * * v1 → v2: snapshot gained sparklines, asset breakdown, per-council * rolling metrics. Counters gained throughputPerMin + latencyMs. + * + * The additive `error` frame does NOT bump the suffix: it is a new frame + * TYPE, not a shape change to an existing frame, so older SPAs simply drop + * it via `parseServerFrame` rather than mis-rendering — which keeps a + * platform-first deploy from disconnecting not-yet-updated clients. */ export const NETWORK_WS_SUBPROTOCOL = "moonlight.network.v2"; diff --git a/src/core/sync/council-fetch.ts b/src/core/sync/council-fetch.ts index 27b1871..8759c44 100644 --- a/src/core/sync/council-fetch.ts +++ b/src/core/sync/council-fetch.ts @@ -1,6 +1,9 @@ import type { Logger } from "@/utils/logger/index.ts"; import { getCouncilPlatformUrl } from "@/config/env.ts"; import type { CouncilTopologyEntry } from "@/core/events/types.ts"; +import { StructuredError } from "@/error/structured-error.ts"; + +const SOURCE = "network-dashboard-platform/council-fetch"; /** * Shape of the upstream `GET /api/v1/public/councils` response. Mirrors @@ -34,9 +37,24 @@ export async function fetchCouncilTopology( try { log.event("requesting council list"); - const res = await fetch(url, { signal: controller.signal }); + let res: Response; + try { + res = await fetch(url, { signal: controller.signal }); + } catch (err) { + // Network failure / timeout abort — wrap with context, preserving the + // underlying cause so the log chain shows the transport error. + throw StructuredError.from(err, { + code: "COUNCIL_PLATFORM_UNREACHABLE", + source: SOURCE, + message: "council-platform request failed", + }); + } if (!res.ok) { - throw new Error(`council-platform returned HTTP ${res.status}`); + throw new StructuredError({ + code: "COUNCIL_PLATFORM_HTTP_ERROR", + source: SOURCE, + message: `council-platform returned HTTP ${res.status}`, + }); } log.event("council list received"); const body = (await res.json()) as PublicCouncilsResponse; diff --git a/src/core/sync/topology-refresh.ts b/src/core/sync/topology-refresh.ts index 7bc563e..9973699 100644 --- a/src/core/sync/topology-refresh.ts +++ b/src/core/sync/topology-refresh.ts @@ -1,6 +1,7 @@ import type { Logger } from "@/utils/logger/index.ts"; import { networkState } from "@/core/state/store.ts"; import type { NetworkEventBus } from "@/core/events/bus.ts"; +import { StructuredError } from "@/error/structured-error.ts"; import { fetchCouncilTopology } from "./council-fetch.ts"; /** @@ -53,6 +54,18 @@ async function run( log.debug("assets", networkState.countAssetsRegistered()); log.event("topology refreshed"); } catch (err) { - log.error(err, "topology refresh failed"); + // Add this layer's context as a NEW outer error (not the idempotent + // `from`, which would pass an inner StructuredError through unchanged and + // drop the topology-level framing) while keeping the council-fetch error + // as the cause so the log shows the full chain. Broadcast the client-safe + // frame so connected dashboards can flag that the live view may be stale. + const structured = new StructuredError({ + code: "TOPOLOGY_REFRESH_FAILED", + source: "network-dashboard-platform/topology-refresh", + message: "Failed to refresh network topology from council-platform", + cause: err, + }); + log.error(structured, "topology refresh failed"); + deps.bus.publishError(structured.toWire()); } } diff --git a/src/core/sync/topology-refresh_test.ts b/src/core/sync/topology-refresh_test.ts new file mode 100644 index 0000000..f72ba49 --- /dev/null +++ b/src/core/sync/topology-refresh_test.ts @@ -0,0 +1,67 @@ +import { assertEquals } from "@std/assert"; +import { newNoop } from "@/utils/logger/index.ts"; +import { NetworkEventBus } from "@/core/events/bus.ts"; +import type { StructuredErrorShape } from "@/error/structured-error.ts"; +import { __resetEnvCacheForTests } from "@/config/env.ts"; +import { refreshTopology } from "./topology-refresh.ts"; + +/** + * Exercises the council-fetch → topology-refresh error path end to end: an + * upstream failure must be wrapped with context and broadcast as a + * client-safe structured error frame on the bus (the signal a connected + * dashboard turns into a "data may be stale" banner). + */ + +function withStubbedFetch( + impl: typeof fetch, + run: () => Promise, +): Promise { + const original = globalThis.fetch; + globalThis.fetch = impl; + return run().finally(() => { + globalThis.fetch = original; + }); +} + +function captureErrors(bus: NetworkEventBus): StructuredErrorShape[] { + const errors: StructuredErrorShape[] = []; + bus.subscribeErrors((e) => errors.push(e)); + return errors; +} + +Deno.test("refreshTopology broadcasts TOPOLOGY_REFRESH_FAILED when council-platform returns non-OK", async () => { + Deno.env.set("COUNCIL_PLATFORM_URL", "http://council.test"); + __resetEnvCacheForTests(); + + const bus = new NetworkEventBus({ log: newNoop() }); + const errors = captureErrors(bus); + + await withStubbedFetch( + () => Promise.resolve(new Response("nope", { status: 503 })), + () => refreshTopology("test", { log: newNoop(), bus }), + ); + + assertEquals(errors.length, 1); + assertEquals(errors[0].code, "TOPOLOGY_REFRESH_FAILED"); + assertEquals(errors[0].source, "network-dashboard-platform/topology-refresh"); + + __resetEnvCacheForTests(); +}); + +Deno.test("refreshTopology broadcasts TOPOLOGY_REFRESH_FAILED when the request itself throws", async () => { + Deno.env.set("COUNCIL_PLATFORM_URL", "http://council.test"); + __resetEnvCacheForTests(); + + const bus = new NetworkEventBus({ log: newNoop() }); + const errors = captureErrors(bus); + + await withStubbedFetch( + () => Promise.reject(new TypeError("error sending request")), + () => refreshTopology("test", { log: newNoop(), bus }), + ); + + assertEquals(errors.length, 1); + assertEquals(errors[0].code, "TOPOLOGY_REFRESH_FAILED"); + + __resetEnvCacheForTests(); +}); diff --git a/src/error/structured-error.ts b/src/error/structured-error.ts new file mode 100644 index 0000000..6a1bb6f --- /dev/null +++ b/src/error/structured-error.ts @@ -0,0 +1,73 @@ +/** + * StructuredError — the local error-bubbling primitive for + * network-dashboard-platform. + * + * Mirrors the error-bubbling standard already live on provider-platform / + * pay-platform: internal errors are wrapped WITH CONTEXT preserving the + * cause chain (so logs get the full `A <- B <- C` story), and a small, + * client-safe `{ code, source, message }` shape is what leaves the edge + * (here: the network WebSocket). The SDK does not export a `StructuredError` + * type, so the shape is defined locally. + * + * The cause is carried on the native `Error.cause` field so the logger's + * `flattenCauses` walk (see utils/logger) can reconstruct the chain, and so + * `console`/stack traces show it too. + */ + +/** + * The client-safe wire shape. This is what rides the WebSocket error frame + * and what any HTTP error body should collapse to — never the internal + * cause chain or stack. + */ +export type StructuredErrorShape = { + code: string; + source: string; + message: string; +}; + +export class StructuredError extends Error { + readonly code: string; + readonly source: string; + + constructor(opts: { + code: string; + source: string; + message: string; + cause?: unknown; + }) { + // Only pass the `cause` option when defined so we don't set an explicit + // `cause: undefined` (which would still register as an own property). + super( + opts.message, + opts.cause !== undefined ? { cause: opts.cause } : undefined, + ); + this.name = "StructuredError"; + this.code = opts.code; + this.source = opts.source; + } + + /** The client-safe projection — no cause chain, no stack. */ + toWire(): StructuredErrorShape { + return { code: this.code, source: this.source, message: this.message }; + } + + /** + * Wrap an arbitrary caught value with context, preserving it as the cause. + * Idempotent: a value that is already a `StructuredError` is returned + * untouched so re-wrapping up the stack never buries the original code. + */ + static from( + err: unknown, + ctx: { code: string; source: string; message?: string }, + ): StructuredError { + if (err instanceof StructuredError) return err; + const message = ctx.message ?? + (err instanceof Error ? err.message : String(err)); + return new StructuredError({ + code: ctx.code, + source: ctx.source, + message, + cause: err, + }); + } +} diff --git a/src/error/structured-error_test.ts b/src/error/structured-error_test.ts new file mode 100644 index 0000000..c0549db --- /dev/null +++ b/src/error/structured-error_test.ts @@ -0,0 +1,64 @@ +import { assert, assertEquals } from "@std/assert"; +import { StructuredError } from "./structured-error.ts"; + +Deno.test("toWire projects only the client-safe { code, source, message }", () => { + const e = new StructuredError({ + code: "COUNCIL_PLATFORM_HTTP_ERROR", + source: "network-dashboard-platform/council-fetch", + message: "council-platform returned HTTP 503", + cause: new Error("connection reset"), + }); + assertEquals(e.toWire(), { + code: "COUNCIL_PLATFORM_HTTP_ERROR", + source: "network-dashboard-platform/council-fetch", + message: "council-platform returned HTTP 503", + }); + // The cause is preserved on the instance but never leaks through the wire. + assert(e.cause instanceof Error); + assertEquals((e.cause as Error).message, "connection reset"); +}); + +Deno.test("from wraps an arbitrary error, preserving it as the cause", () => { + const root = new Error("getaddrinfo ENOTFOUND council"); + const wrapped = StructuredError.from(root, { + code: "COUNCIL_PLATFORM_UNREACHABLE", + source: "network-dashboard-platform/council-fetch", + message: "council-platform request failed", + }); + assertEquals(wrapped.code, "COUNCIL_PLATFORM_UNREACHABLE"); + assertEquals(wrapped.message, "council-platform request failed"); + assertEquals(wrapped.cause, root); +}); + +Deno.test("from is idempotent — an existing StructuredError passes through untouched", () => { + const original = new StructuredError({ + code: "COUNCIL_PLATFORM_HTTP_ERROR", + source: "network-dashboard-platform/council-fetch", + message: "council-platform returned HTTP 500", + }); + const rewrapped = StructuredError.from(original, { + code: "TOPOLOGY_REFRESH_FAILED", + source: "network-dashboard-platform/topology-refresh", + }); + // Same instance, original code retained — re-wrapping up the stack must not + // bury the inner code. + assert(rewrapped === original); + assertEquals(rewrapped.code, "COUNCIL_PLATFORM_HTTP_ERROR"); +}); + +Deno.test("from defaults message to the wrapped error's message when none given", () => { + const wrapped = StructuredError.from(new Error("boom"), { + code: "TOPOLOGY_REFRESH_FAILED", + source: "network-dashboard-platform/topology-refresh", + }); + assertEquals(wrapped.message, "boom"); +}); + +Deno.test("from stringifies non-Error causes", () => { + const wrapped = StructuredError.from("plain string failure", { + code: "TOPOLOGY_REFRESH_FAILED", + source: "network-dashboard-platform/topology-refresh", + }); + assertEquals(wrapped.message, "plain string failure"); + assertEquals(wrapped.cause, "plain string failure"); +}); diff --git a/src/http/v1/network-ws.ts b/src/http/v1/network-ws.ts index 60c6eeb..26aea74 100644 --- a/src/http/v1/network-ws.ts +++ b/src/http/v1/network-ws.ts @@ -39,6 +39,10 @@ const IDLE_TIMEOUT_SECONDS = 300; * SPA paint. * { type: "event", event: NetworkEvent } * — sent for each live event after the snapshot. + * { type: "error", error: { code, source, message } } + * — sent when the backend hits a structured error worth surfacing (e.g. + * a council-platform topology refresh failed, so the live view may be + * stale). Client-safe shape only — no cause chain / stack. * * No client → server frames. Clients reconnect rather than keep-alive. */ @@ -52,7 +56,13 @@ export function handleNetworkWs( if (!ctx.isUpgradable) { ctx.response.status = 426; - ctx.response.body = { error: "WebSocket upgrade required" }; + ctx.response.body = { + error: { + code: "WS_UPGRADE_REQUIRED", + source: "network-dashboard-platform/network-ws", + message: "WebSocket upgrade required", + }, + }; return; } @@ -62,6 +72,7 @@ export function handleNetworkWs( }); let unsubscribe: (() => void) | null = null; + let unsubscribeErrors: (() => void) | null = null; let closed = false; const cleanup = () => { @@ -71,6 +82,10 @@ export function handleNetworkWs( unsubscribe(); unsubscribe = null; } + if (unsubscribeErrors) { + unsubscribeErrors(); + unsubscribeErrors = null; + } }; const sendFrame = (frame: ServerFrame): void => { @@ -92,6 +107,9 @@ export function handleNetworkWs( counters: buildSnapshotFrame().counters, }); }); + unsubscribeErrors = deps.bus.subscribeErrors((error) => { + sendFrame({ type: "error", error }); + }); log.debug("subscribers", deps.bus.listenerCount()); log.event("network WS opened"); }; diff --git a/src/http/v1/network-ws_test.ts b/src/http/v1/network-ws_test.ts index 49474f8..85644f6 100644 --- a/src/http/v1/network-ws_test.ts +++ b/src/http/v1/network-ws_test.ts @@ -88,12 +88,55 @@ const deps = () => ({ bus: new NetworkEventBus({ log: newNoop() }), }); -Deno.test("rejects non-upgradable request with 426", () => { +Deno.test("rejects non-upgradable request with 426 and a structured error body", () => { const d = deps(); const m = mockCtx({ upgradable: false }); handleNetworkWs(d)(m.ctx as never); assertEquals(m.res.status, 426); assertEquals(m.upgradeArgs, null); + // Body is the client-safe { code, source, message } shape, not a bare string. + assertEquals(m.res.body, { + error: { + code: "WS_UPGRADE_REQUIRED", + source: "network-dashboard-platform/network-ws", + message: "WebSocket upgrade required", + }, + }); +}); + +Deno.test("delivers structured error frames published on the bus, and unsubscribes on close", () => { + const d = deps(); + const socket = new MockSocket(); + const m = mockCtx({ socket }); + + handleNetworkWs(d)(m.ctx as never); + socket.triggerOpen(); + // snapshot on open + the error subscription is registered + assertEquals(socket.sent.length, 1); + assertEquals(d.bus.errorListenerCount(), 1); + + d.bus.publishError({ + code: "TOPOLOGY_REFRESH_FAILED", + source: "network-dashboard-platform/topology-refresh", + message: "Failed to refresh network topology from council-platform", + }); + assertEquals(socket.sent.length, 2); + const frame = JSON.parse(socket.sent[1]) as { + type: string; + error: { code: string; source: string; message: string }; + }; + assertEquals(frame.type, "error"); + assertEquals(frame.error.code, "TOPOLOGY_REFRESH_FAILED"); + assertEquals( + frame.error.source, + "network-dashboard-platform/topology-refresh", + ); + + // unsubscribe on close: no delivery afterwards, error listener removed + socket.triggerClose(); + assertEquals(d.bus.errorListenerCount(), 0); + d.bus.publishError({ code: "X", source: "y", message: "z" }); + assertEquals(socket.sent.length, 2); }); Deno.test("upgrades with subprotocol + idle-timeout, sends snapshot, delivers live events, drops when not OPEN, unsubscribes on close", () => { diff --git a/src/utils/logger/index.ts b/src/utils/logger/index.ts index 5623024..a822062 100644 --- a/src/utils/logger/index.ts +++ b/src/utils/logger/index.ts @@ -89,6 +89,27 @@ function stringify(v: unknown): string { } } +/** + * Flatten a native `Error.cause` chain into a single `A <- B <- C` string so + * an error logged at the edge carries the full context of how it bubbled up, + * not just the outermost message. Mirrors the error-bubbling standard's + * logger on provider-platform. Cycle-guarded and depth-capped. + */ +function flattenCauses(err: unknown): string { + const parts: string[] = []; + const seen = new Set(); + let current: unknown = err; + for (let depth = 0; depth < 16 && current != null; depth++) { + if (seen.has(current)) break; + seen.add(current); + parts.push(current instanceof Error ? current.message : String(current)); + current = current instanceof Error + ? (current as { cause?: unknown }).cause + : undefined; + } + return parts.join(" <- "); +} + function humanFormat(colored: boolean): Format { const grayLb = colored ? chalk.gray : (s: string) => s; const greenLb = colored ? chalk.green : (s: string) => s; @@ -174,7 +195,9 @@ class LoggerImpl implements Logger { error(err: unknown, msg: string): void { // ERR always emits regardless of level (matches go-logger / zerolog). - const detail = err instanceof Error ? err.message : String(err); + // Flatten the native cause chain so a wrapped error logs the full + // `outer <- inner <- root` context, not just the outermost message. + const detail = flattenCauses(err); this.emit({ ts: now(), level: "error", diff --git a/src/utils/logger/logger_test.ts b/src/utils/logger/logger_test.ts new file mode 100644 index 0000000..cf7b801 --- /dev/null +++ b/src/utils/logger/logger_test.ts @@ -0,0 +1,55 @@ +import { assert, assertEquals } from "@std/assert"; +import { Level, newLogger, type Writer } from "./index.ts"; +import { StructuredError } from "@/error/structured-error.ts"; + +function captureWriter(): { writer: Writer; lines: string[] } { + const lines: string[] = []; + return { writer: { write: (line) => lines.push(line) }, lines }; +} + +Deno.test("error() flattens the native cause chain into an A <- B <- C string", () => { + const { writer, lines } = captureWriter(); + const log = newLogger(Level.Info, { writer }); + + // Mirrors the real layering: council-fetch wraps the raw transport error, + // then topology-refresh adds its own outer layer keeping that as the cause. + const root = new Error("getaddrinfo ENOTFOUND council"); + const mid = StructuredError.from(root, { + code: "COUNCIL_PLATFORM_UNREACHABLE", + source: "council-fetch", + message: "council-platform request failed", + }); + const outer = new StructuredError({ + code: "TOPOLOGY_REFRESH_FAILED", + source: "topology-refresh", + message: "Failed to refresh network topology from council-platform", + cause: mid, + }); + + log.error(outer, "topology refresh failed"); + + assertEquals(lines.length, 1); + const line = lines[0]; + assert( + line.includes( + "Failed to refresh network topology from council-platform <- " + + "council-platform request failed <- getaddrinfo ENOTFOUND council", + ), + `expected flattened cause chain, got: ${line}`, + ); +}); + +Deno.test("error() handles a single (unwrapped) error without a trailing arrow", () => { + const { writer, lines } = captureWriter(); + const log = newLogger(Level.Info, { writer }); + log.error(new Error("solo failure"), "boom"); + assert(lines[0].includes('error="solo failure"')); + assert(!lines[0].includes("<-")); +}); + +Deno.test("error() always emits even when the level would suppress it", () => { + const { writer, lines } = captureWriter(); + const log = newLogger(Level.Disabled, { writer }); + log.error(new Error("still logged"), "boom"); + assertEquals(lines.length, 1); +});