Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
17 changes: 13 additions & 4 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> | null = null;
function fileEnv(): Record<string, string> {
Expand All @@ -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;
}
Expand All @@ -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;
Expand Down
40 changes: 40 additions & 0 deletions src/core/events/bus.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -16,6 +18,7 @@ type Listener = (event: NetworkEvent) => void;
*/
export class NetworkEventBus {
private listeners = new Set<Listener>();
private errorListeners = new Set<ErrorListener>();
private log: Logger;

constructor(deps: { log: Logger }) {
Expand All @@ -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);
Expand All @@ -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;
}
}
19 changes: 18 additions & 1 deletion src/core/events/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -120,13 +121,29 @@ 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
* frame-shape change so old SPAs can't silently mis-render.
*
* 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";
22 changes: 20 additions & 2 deletions src/core/sync/council-fetch.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 14 additions & 1 deletion src/core/sync/topology-refresh.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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());
}
}
67 changes: 67 additions & 0 deletions src/core/sync/topology-refresh_test.ts
Original file line number Diff line number Diff line change
@@ -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<void>,
): Promise<void> {
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();
});
73 changes: 73 additions & 0 deletions src/error/structured-error.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
Loading
Loading