Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
# @suaveplan/network-plugin-devtools - Developer-tools plugin for the Suaveplan mesh: NetworkVisualizer (hierarchy/flow/heat-map IR + injectable Renderer + bundled SVG renderer), StateInspector (tree view, ring-buffer history, RFC-6902-style diff via @suaveplan/diff, search/filter), SimulationConfigTool (programmatic topology + condition + event-timeline editor with JSON scenario export/import), PerformanceProfiler (latency p50/p95/p99 distribution, throughput, plugin overhead, memory tracking, two-window EMA regression detection, optimization suggestions), and DevToolsPlugin facade with tier gate and no-op-in-production guard.
## Summary
Developer-tools plugin for the Suaveplan mesh. Bundles four subsystems behind
a single `NetworkPlugin` lifecycle so engineers can inspect, visualise, and
profile the live network from the same node they run their tests against. The
plugin is dev-only by contract: a `tierGate` callback decides activation at
install time, and `requireActive()` raises `PLUGIN_DEV_DISABLED` so production
call sites cannot silently exercise devtools work paths.
`DevToolsPlugin` ties the four subsystems together:
- **`NetworkVisualizer`** — renders the mesh as a `VisualizerScene` IR (hierarchy / flow / heat-map modes), supports filtering by tier / latency-group / session / transport, and exports to SVG (bundled `SvgRenderer`) or PNG / canvas / WebGL via injected `Renderer`.
- **`StateInspector`** — per-path state browser with bounded ring-buffer history, RFC-6902-style diff via `@suaveplan/diff`, timeline scrubber (`scrub`), and composable `search` over `pathContains` / `peerId` / `sessionId` / version range.
- **`SimulationConfigTool`** — programmatic scenario builder (peers, links, conditions, timeline events) with deterministic recording, playback grouping by `atMs`, and Zod-validated JSON export / import for sharing scenarios across teams.
- **`PerformanceProfiler`** — latency p50 / p95 / p99 distribution, throughput per path, plugin overhead, memory tracking, two-window EMA + Z-score regression detection against named baselines, and category-tagged optimisation suggestions.
<!-- begin:auto-docs -->
## Documentation tree
- [inspector](./src/inspector/state-inspector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [state-inspector](./src/inspector/state-inspector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [devtools-plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [performance-profiler](./src/profiler/performance-profiler.md) — Captures runtime performance evidence (latency per path, throughput per path, plugin overhead, memory) into bounded rin…
- [performance-profiler.suggestions](./src/profiler/performance-profiler.suggestions.md) — Pure-function suggestion engine that consumes profiler ring buffers (latency / overhead / throughput / memory) and emit…
- [simulator](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [simulation-config-tool](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [network-visualizer](./src/visualizer/network-visualizer.md) — Renders the live mesh as a `VisualizerScene` intermediate representation (IR) and delegates byte production to an injec…
- [svg-renderer](./src/visualizer/svg-renderer.md) — Pure-string SVG renderer that consumes a `VisualizerScene` and produces deterministic UTF-8 SVG bytes without touching…
- [error-codes](./src/error-codes.md) — Defines the frozen registry of every failure mode the four devtools subsystems can surface. Centralising the codes here…
<!-- end:auto-docs --> - [state-inspector](./src/inspector/state-inspector.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [devtools-plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [performance-profiler](./src/profiler/performance-profiler.md) — Captures runtime performance evidence (latency per path, throughput per path, plugin overhead, memory) into bounded rin…
- [performance-profiler.suggestions](./src/profiler/performance-profiler.suggestions.md) — Pure-function suggestion engine that consumes profiler ring buffers (latency / overhead / throughput / memory) and emit…
- [simulator](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulation-config-tool](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-visualizer](./src/visualizer/network-visualizer.md) — Renders the live mesh as a `VisualizerScene` intermediate representation (IR) and delegates byte production to an injec…
- [svg-renderer](./src/visualizer/svg-renderer.md) — Pure-string SVG renderer that consumes a `VisualizerScene` and produces deterministic UTF-8 SVG bytes without touching…
- [error-codes](./src/error-codes.md) — Defines the frozen registry of every failure mode the four devtools subsystems can surface. Centralising the codes here…
- [state-inspector](./src/inspector/state-inspector.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [devtools-plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [performance-profiler](./src/profiler/performance-profiler.md) — Captures runtime performance evidence (latency per path, throughput per path, plugin overhead, memory) into bounded rin…
- [performance-profiler.suggestions](./src/profiler/performance-profiler.suggestions.md) — Pure-function suggestion engine that consumes profiler ring buffers (latency / overhead / throughput / memory) and emit…
- [simulator](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulation-config-tool](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-visualizer](./src/visualizer/network-visualizer.md) — Renders the live mesh as a `VisualizerScene` intermediate representation (IR) and delegates byte production to an injec…
- [svg-renderer](./src/visualizer/svg-renderer.md) — Pure-string SVG renderer that consumes a `VisualizerScene` and produces deterministic UTF-8 SVG bytes without touching…
- [error-codes](./src/error-codes.md) — Defines the frozen registry of every failure mode the four devtools subsystems can surface. Centralising the codes here…
- [state-inspector](./src/inspector/state-inspector.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [devtools-plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [performance-profiler](./src/profiler/performance-profiler.md) — Captures runtime performance evidence (latency per path, throughput per path, plugin overhead, memory) into bounded rin…
- [performance-profiler.suggestions](./src/profiler/performance-profiler.suggestions.md) — Pure-function suggestion engine that consumes profiler ring buffers (latency / overhead / throughput / memory) and emit…
- [simulator](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulation-config-tool](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-visualizer](./src/visualizer/network-visualizer.md) — Renders the live mesh as a `VisualizerScene` intermediate representation (IR) and delegates byte production to an injec…
- [svg-renderer](./src/visualizer/svg-renderer.md) — Pure-string SVG renderer that consumes a `VisualizerScene` and produces deterministic UTF-8 SVG bytes without touching…
- [error-codes](./src/error-codes.md) — Defines the frozen registry of every failure mode the four devtools subsystems can surface. Centralising the codes here…
- [state-inspector](./src/inspector/state-inspector.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [devtools-plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [performance-profiler](./src/profiler/performance-profiler.md) — Captures runtime performance evidence (latency per path, throughput per path, plugin overhead, memory) into bounded rin…
- [performance-profiler.suggestions](./src/profiler/performance-profiler.suggestions.md) — Pure-function suggestion engine that consumes profiler ring buffers (latency / overhead / throughput / memory) and emit…
- [simulator](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_ - [simulation-config-tool](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-visualizer](./src/visualizer/network-visualizer.md) — Renders the live mesh as a `VisualizerScene` intermediate representation (IR) and delegates byte production to an injec…
- [svg-renderer](./src/visualizer/svg-renderer.md) — Pure-string SVG renderer that consumes a `VisualizerScene` and produces deterministic UTF-8 SVG bytes without touching…
- [error-codes](./src/error-codes.md) — Defines the frozen registry of every failure mode the four devtools subsystems can surface. Centralising the codes here…
## Install
```bash
bun add @suaveplan/network-plugin-devtools
```
## Quick start
```ts
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import { DevToolsPlugin } from "@suaveplan/network-plugin-devtools";
const node = new NetworkNode({ id: "host-1", networkBus: new NetworkBus() });
const devtools = new DevToolsPlugin({
tierGate: () => process.env.NODE_ENV !== "production",
topology: myTopologyAdapter,
stream: myStreamAdapter,
inspector: { historyLimit: 1024 },
profiler: { regressionZScore: 2.5 },
});
node.use(devtools);
await node.start();
devtools.visualizer.start({ mode: "hierarchy", refreshRate: 30 });
const svg = devtools.visualizer.export("svg");
devtools.inspector.record({
path: "session/lobby",
value: { players: 3 },
version: 1,
timestamp: Date.now(),
peerId: "host-1",
});
devtools.profiler.recordLatency("host-1->leaf-1", 42);
const baseline = devtools.profiler.captureBaseline("v1.0.0");
devtools.simulator.addPeer({ id: "leaf-2", tier: "tier-2", capacity: 10 });
const scenarioJson = devtools.simulator.exportJson();
```
## Modules
- [`error-codes`](./src/error-codes.md) — `NETWORK_DEV_CODES` registry + `NetworkDevError`.
- [`visualizer/network-visualizer`](./src/visualizer/network-visualizer.md) — IR-producing visualisation engine with three modes and pluggable renderer.
- [`visualizer/svg-renderer`](./src/visualizer/svg-renderer.md) — bundled dep-free SVG renderer.
- [`inspector/state-inspector`](./src/inspector/state-inspector.md) — per-path state browser, history, diff, search, scrubber.
- [`simulator/simulation-config-tool`](./src/simulator/simulation-config-tool.md) — programmatic scenario builder with JSON round-trip.
- [`profiler/performance-profiler`](./src/profiler/performance-profiler.md) — latency / throughput / overhead / memory tracking with regression detection.
- [`profiler/performance-profiler.suggestions`](./src/profiler/performance-profiler.suggestions.md) — heuristic suggestion engine.
- [`plugin/devtools-plugin`](./src/plugin/devtools-plugin.md) — facade satisfying `NetworkPlugin` with tier gate and production guard.

View File

@@ -0,0 +1,74 @@
{
"name": "@suaveplan/network-plugin-devtools",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Developer-tools plugin for the Suaveplan mesh: NetworkVisualizer (hierarchy/flow/heat-map IR + injectable Renderer + bundled SVG renderer), StateInspector (tree view, ring-buffer history, RFC-6902-style diff via @suaveplan/diff, search/filter), SimulationConfigTool (programmatic topology + condition + event-timeline editor with JSON scenario export/import), PerformanceProfiler (latency p50/p95/p99 distribution, throughput, plugin overhead, memory tracking, two-window EMA regression detection, optimization suggestions), and DevToolsPlugin facade with tier gate and no-op-in-production guard.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"suaveplan": {
"tier": "devtools"
},
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./error-codes": {
"import": { "types": "./dist/error-codes.d.ts", "default": "./dist/error-codes.js" }
},
"./visualizer/network-visualizer": {
"import": { "types": "./dist/visualizer/network-visualizer.d.ts", "default": "./dist/visualizer/network-visualizer.js" }
},
"./visualizer/svg-renderer": {
"import": { "types": "./dist/visualizer/svg-renderer.d.ts", "default": "./dist/visualizer/svg-renderer.js" }
},
"./inspector/state-inspector": {
"import": { "types": "./dist/inspector/state-inspector.d.ts", "default": "./dist/inspector/state-inspector.js" }
},
"./simulator/simulation-config-tool": {
"import": { "types": "./dist/simulator/simulation-config-tool.d.ts", "default": "./dist/simulator/simulation-config-tool.js" }
},
"./profiler/performance-profiler": {
"import": { "types": "./dist/profiler/performance-profiler.d.ts", "default": "./dist/profiler/performance-profiler.js" }
},
"./plugin/devtools-plugin": {
"import": { "types": "./dist/plugin/devtools-plugin.d.ts", "default": "./dist/plugin/devtools-plugin.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/diff": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/network": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -0,0 +1,68 @@
# `error-codes` — `NETWORK_DEV_CODES` registry
## Purpose
Defines the frozen registry of every failure mode the four devtools
subsystems can surface. Centralising the codes here means consumers can
match on a known string set, observability backends get stable
`NET_DEV.*` labels, and i18n message bundles can be authored against a
fixed keyset rather than free-form errors.
The registry is the contract: each subsystem (`NetworkVisualizer`,
`StateInspector`, `SimulationConfigTool`, `PerformanceProfiler`, and the
`DevToolsPlugin` facade) raises a `NetworkDevError` carrying one of
these codes whenever an invariant is violated, an input fails
validation, or an injected callback misbehaves. Adding a new failure
mode means adding a new registry entry first; no ad-hoc strings.
## Features
- Frozen `NETWORK_DEV_CODES` object literal — `Object.isFrozen()` returns `true`.
- All entries prefixed `NET_DEV.` via `defineCodes("NET_DEV", ...)`.
- 20 codes spanning visualizer, inspector, simulator, profiler, and plugin facade.
- `NetworkDevError extends SuaveplanError<NetworkDevErrorCode>` preserves cause chains, context maps, OTel-friendly metadata, and i18n message keys.
- Side-effect-imported from `src/index.ts` so subpath consumers register the code namespace at module load.
## Usage: Basic Example
```ts
import { NETWORK_DEV_CODES, NetworkDevError } from "@suaveplan/network-plugin-devtools";
throw new NetworkDevError(
NETWORK_DEV_CODES.INSPECTOR_PATH_NOT_FOUND,
"inspector.unknown-path",
{ context: { path: "game/positions/player1" } }
);
```
## Usage: Advanced Example
```ts
import {
NETWORK_DEV_CODES,
NetworkDevError,
} from "@suaveplan/network-plugin-devtools";
function ensurePeer(peerId: string, known: ReadonlySet<string>): void {
if (!known.has(peerId)) {
throw new NetworkDevError(
NETWORK_DEV_CODES.SIMULATOR_PEER_NOT_FOUND,
"simulator.peer.unknown",
{
context: { peerId, knownCount: known.size },
cause: new Error("topology lookup miss"),
}
);
}
}
```
## API Reference
- `NETWORK_DEV_CODES` — frozen `Record<Key, "NET_DEV.<Key>">`.
- `NetworkDevErrorCode` — string-literal union of every value.
- `class NetworkDevError extends SuaveplanError<NetworkDevErrorCode>` — constructor `(code, messageKey, options?)`. Sets `name = "NetworkDevError"`.
## Implementation Notes
The registry is intentionally additive — every code lives forever once shipped, even if a subsystem stops throwing it, because consumers may still match on it. Removing a code requires an OpenSpec change and a new major version. The `SuaveplanError` base preserves `cause` per the ES error spec, so wrapping a transient I/O failure (renderer pipe broken, scenario JSON parse failure) with a typed code does not destroy the underlying diagnostic. Tests in the sibling spec-conformance file pin the prefix, the freeze invariant, and every key by name so accidental rename is impossible.

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_DEV_CODES, NetworkDevError } from "./error-codes.js";
const EXPECTED_KEYS = [
"VISUALIZER_NOT_STARTED",
"VISUALIZER_INVALID_MODE",
"VISUALIZER_RENDERER_FAILED",
"VISUALIZER_EMPTY_TOPOLOGY",
"INSPECTOR_PATH_NOT_FOUND",
"INSPECTOR_HISTORY_EMPTY",
"INSPECTOR_INVALID_RANGE",
"INSPECTOR_DIFF_FAILED",
"SIMULATOR_PEER_NOT_FOUND",
"SIMULATOR_PEER_DUPLICATE",
"SIMULATOR_INVALID_CONDITION",
"SIMULATOR_SCENARIO_INVALID",
"SIMULATOR_RECORDING_INACTIVE",
"SIMULATOR_PLAYBACK_INACTIVE",
"PROFILER_NO_BASELINE",
"PROFILER_BASELINE_DUPLICATE",
"PROFILER_NO_SAMPLES",
"PROFILER_INVALID_THRESHOLD",
"PLUGIN_DEV_DISABLED",
"PLUGIN_TIER_GATE_REJECTED",
] as const;
describe("NETWORK_DEV_CODES (NET_DEV-07)", () => {
it("registers under NET_DEV", () => {
for (const value of Object.values(NETWORK_DEV_CODES)) {
expect(value).toMatch(/^NET_DEV\./);
}
});
it("is frozen", () => {
expect(Object.isFrozen(NETWORK_DEV_CODES)).toBe(true);
});
it("contains every documented code", () => {
for (const key of EXPECTED_KEYS) {
expect(NETWORK_DEV_CODES).toHaveProperty(key);
}
});
it("registry matches expected keys exactly", () => {
expect(Object.keys(NETWORK_DEV_CODES).sort()).toStrictEqual(
[...EXPECTED_KEYS].sort()
);
});
});
describe("NetworkDevError (NET_DEV-07)", () => {
it("extends SuaveplanError and carries code", () => {
const err = new NetworkDevError(
NETWORK_DEV_CODES.VISUALIZER_NOT_STARTED,
"x"
);
expect(err.name).toBe("NetworkDevError");
expect(err.code).toBe(NETWORK_DEV_CODES.VISUALIZER_NOT_STARTED);
});
it("preserves cause", () => {
const cause = new Error("inner");
const err = new NetworkDevError(
NETWORK_DEV_CODES.VISUALIZER_RENDERER_FAILED,
"with-cause",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,52 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Frozen registry of every failure mode the devtools subsystems surface.
* Codes are namespaced under `NET_DEV.` and consumed by every subsystem
* (visualizer, inspector, simulator, profiler, plugin facade).
*/
export const NETWORK_DEV_CODES = defineCodes("NET_DEV", [
"VISUALIZER_NOT_STARTED",
"VISUALIZER_INVALID_MODE",
"VISUALIZER_RENDERER_FAILED",
"VISUALIZER_EMPTY_TOPOLOGY",
"INSPECTOR_PATH_NOT_FOUND",
"INSPECTOR_HISTORY_EMPTY",
"INSPECTOR_INVALID_RANGE",
"INSPECTOR_DIFF_FAILED",
"SIMULATOR_PEER_NOT_FOUND",
"SIMULATOR_PEER_DUPLICATE",
"SIMULATOR_INVALID_CONDITION",
"SIMULATOR_SCENARIO_INVALID",
"SIMULATOR_RECORDING_INACTIVE",
"SIMULATOR_PLAYBACK_INACTIVE",
"PROFILER_NO_BASELINE",
"PROFILER_BASELINE_DUPLICATE",
"PROFILER_NO_SAMPLES",
"PROFILER_INVALID_THRESHOLD",
"PLUGIN_DEV_DISABLED",
"PLUGIN_TIER_GATE_REJECTED",
] as const);
export type NetworkDevErrorCode =
(typeof NETWORK_DEV_CODES)[keyof typeof NETWORK_DEV_CODES];
/**
* Structured error class surfaced from every devtools subsystem.
* Carries an `NET_DEV.*` code, an i18n message key, and the standard
* cause / context fields from `@suaveplan/error`.
*/
export class NetworkDevError extends SuaveplanError<NetworkDevErrorCode> {
public constructor(
code: NetworkDevErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetworkDevError";
}
}

View File

@@ -0,0 +1,82 @@
// Side-effect import — registers NET_DEV codes at module load so subpath
// consumers automatically inherit the registry.
import "./error-codes.js";
// Errors
export {
NETWORK_DEV_CODES,
NetworkDevError,
type NetworkDevErrorCode,
} from "./error-codes.js";
// Inspector
export { StateInspector } from "./inspector/state-inspector.js";
// Plugin
export {
DevToolsPlugin,
type DevToolsPluginConfig,
type DevtoolsTierGate,
} from "./plugin/devtools-plugin.js";
// Profiler
export { PerformanceProfiler } from "./profiler/performance-profiler.js";
export { collectSuggestions } from "./profiler/performance-profiler.suggestions.js";
// Simulator
export { SimulationConfigTool } from "./simulator/simulation-config-tool.js";
// Types: inspector
export type {
DiffSummary,
DiffView,
InspectorConfig,
SearchFilter,
StateSnapshot,
StateView,
} from "./types/inspector.types.js";
// Types: profiler
export type {
Baseline,
LatencyDistribution,
LatencySample,
MemorySample,
OptimizationSuggestion,
PerformanceReport,
PluginOverheadSample,
ProfilerConfig,
Regression,
RegressionSeverity,
ThroughputSample,
} from "./types/profiler.types.js";
// Types: simulator
export type {
NetworkCondition,
PlaybackTick,
ScenarioEvent,
ScenarioEventKind,
ScenarioRecording,
SimulatedLink,
SimulatedPeer,
SimulationConfigToolOptions,
SimulationScenario,
SimulatorTier,
} from "./types/simulator.types.js";
// Types: visualizer
export type {
EdgeLayout,
HeatCell,
MessageStreamAdapter,
NodeLayout,
Renderer,
RendererOutput,
TopologyAdapter,
VisualizerConfig,
VisualizerExportFormat,
VisualizerFilter,
VisualizerMode,
VisualizerScene,
VisualizerTier,
} from "./types/visualizer.types.js";
// Visualizer
export {
ALLOWED_VISUALIZER_MODES,
NetworkVisualizer,
type NetworkVisualizerConfig,
} from "./visualizer/network-visualizer.js";
export { renderSvg, SvgRenderer } from "./visualizer/svg-renderer.js";

View File

@@ -0,0 +1,201 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_DEV_CODES, type NetworkDevError } from "../error-codes.js";
import type { StateSnapshot } from "../types/inspector.types.js";
import { StateInspector } from "./state-inspector.js";
function snap(
path: string,
version: number,
value: unknown,
extra: Partial<StateSnapshot> = {}
): StateSnapshot {
return {
path,
value: value as StateSnapshot["value"],
version,
timestamp: 1_000 + version,
peerId: extra.peerId ?? "host-1",
...(extra.sessionId === undefined
? {}
: { sessionId: extra.sessionId }),
};
}
describe("StateInspector record + inspect (NET_DEV-03)", () => {
it("records and inspects a single path", () => {
const i = new StateInspector();
i.record(snap("a", 1, { x: 1 }));
i.record(snap("a", 2, { x: 2 }));
const view = i.inspect("a");
expect(view.current.version).toBe(2);
expect(view.history.length).toBe(2);
});
it("rejects invalid snapshot with INSPECTOR_DIFF_FAILED", () => {
const i = new StateInspector();
try {
i.record({
path: "",
value: null,
version: 1,
timestamp: 1,
peerId: "p",
});
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.INSPECTOR_DIFF_FAILED
);
}
});
it("inspect throws INSPECTOR_PATH_NOT_FOUND when missing", () => {
const i = new StateInspector();
try {
i.inspect("missing");
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.INSPECTOR_PATH_NOT_FOUND
);
}
});
it("ring buffer enforces historyLimit", () => {
const i = new StateInspector({ historyLimit: 2 });
i.record(snap("p", 1, 1));
i.record(snap("p", 2, 2));
i.record(snap("p", 3, 3));
const view = i.inspect("p");
expect(view.history.length).toBe(2);
expect(view.history[0]?.version).toBe(2);
});
it("list returns sorted paths", () => {
const i = new StateInspector();
i.record(snap("b", 1, 1));
i.record(snap("a", 1, 1));
expect(i.list()).toStrictEqual(["a", "b"]);
expect(i.has("a")).toBe(true);
expect(i.has("c")).toBe(false);
});
it("clear() removes single path or all paths", () => {
const i = new StateInspector();
i.record(snap("a", 1, 1));
i.record(snap("b", 1, 1));
i.clear("a");
expect(i.has("a")).toBe(false);
i.clear();
expect(i.list()).toStrictEqual([]);
});
});
describe("StateInspector diff (NET_DEV-03)", () => {
it("classifies added / removed / changed pointers", () => {
const i = new StateInspector();
i.record(snap("p", 1, { a: 1, c: 3 }));
i.record(snap("p", 2, { b: 2, c: 4 }));
const view = i.diff("p", 1, 2);
expect(view.added).toStrictEqual(["/b"]);
expect(view.removed).toStrictEqual(["/a"]);
expect(view.changed).toStrictEqual(["/c"]);
expect(view.summary).toStrictEqual({
addedCount: 1,
removedCount: 1,
changedCount: 1,
});
});
it("rejects same-version diff with INSPECTOR_INVALID_RANGE", () => {
const i = new StateInspector();
i.record(snap("p", 1, { a: 1 }));
try {
i.diff("p", 1, 1);
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.INSPECTOR_INVALID_RANGE
);
}
});
it("rejects unknown version with INSPECTOR_INVALID_RANGE", () => {
const i = new StateInspector();
i.record(snap("p", 1, { a: 1 }));
try {
i.diff("p", 1, 99);
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.INSPECTOR_INVALID_RANGE
);
}
});
it("classifies move/copy ops as changed", () => {
const i = new StateInspector();
// diff library uses index-based diff for arrays; force a replace via array
i.record(snap("arr", 1, [1, 2, 3]));
i.record(snap("arr", 2, [1, 9, 3]));
const v = i.diff("arr", 1, 2);
expect(v.changed.length).toBeGreaterThan(0);
});
});
describe("StateInspector scrub + search (NET_DEV-03)", () => {
it("scrub returns latest snapshot ≤ atVersion", () => {
const i = new StateInspector();
i.record(snap("p", 1, "a"));
i.record(snap("p", 3, "c"));
i.record(snap("p", 5, "e"));
expect(i.scrub("p", 4).version).toBe(3);
expect(i.scrub("p", 5).version).toBe(5);
});
it("scrub throws INSPECTOR_HISTORY_EMPTY when no version satisfies", () => {
const i = new StateInspector();
i.record(snap("p", 5, "x"));
try {
i.scrub("p", 1);
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.INSPECTOR_HISTORY_EMPTY
);
}
});
it("scrub throws INSPECTOR_PATH_NOT_FOUND when path absent", () => {
const i = new StateInspector();
try {
i.scrub("ghost", 1);
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.INSPECTOR_PATH_NOT_FOUND
);
}
});
it("search AND-combines populated filters", () => {
const i = new StateInspector();
i.record(
snap("session/lobby", 1, 0, { peerId: "p1", sessionId: "s1" })
);
i.record(
snap("session/lobby", 2, 0, { peerId: "p2", sessionId: "s2" })
);
i.record(snap("game/state", 1, 0, { peerId: "p1", sessionId: "s1" }));
expect(i.search({ pathContains: "session/" })).toHaveLength(2);
expect(i.search({ peerId: "p1" })).toHaveLength(2);
expect(i.search({ sessionId: "s2" })).toHaveLength(1);
expect(i.search({ minVersion: 2 })).toHaveLength(1);
expect(i.search({ maxVersion: 1 })).toHaveLength(2);
expect(
i.search({ pathContains: "session/", peerId: "p2" })
).toHaveLength(1);
expect(i.search({})).toHaveLength(3);
});
});

View File

@@ -0,0 +1,97 @@
# `inspector/state-inspector` — `StateInspector`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Per-path state browser for the synchronised mesh state. Records
snapshots into a bounded ring buffer keyed by `path`, exposes a tree of
known paths plus per-path history, computes RFC-6902-style diffs
between any two recorded versions via `@suaveplan/diff`, and supports
search by path-substring, peer, session, and version range.
The inspector is the read-side companion to `network-plugin-state`
without taking a runtime dependency on it — consumers feed
`StateSnapshot` records from whatever state-coordination plugin they
use, so the inspector is also useful for plugins outside the canonical
state plugin (game state, presence, custom CRDT implementations).
## Features
- Bounded per-path ring buffer (default 256, configurable up to 10 000).
- `record(snapshot)` Zod-validates input and rejects invalid frames with `INSPECTOR_DIFF_FAILED`.
- `inspect(path)` — current value plus full history.
- `list()` — sorted list of all known paths.
- `diff(path, fromVersion, toVersion)` — produces `DiffView` with `added` / `removed` / `changed` JSON Pointers and a numeric `summary`.
- `scrub(path, atVersion)` — timeline scrubber returns the latest snapshot ≤ `atVersion`.
- `search(filter)` — composable `pathContains` / `peerId` / `sessionId` / `minVersion` / `maxVersion`.
- OpenTelemetry counters for record + diff operations.
## Usage: Basic Example
```ts
import { StateInspector } from "@suaveplan/network-plugin-devtools";
const inspector = new StateInspector();
inspector.record({
path: "game/positions/player1",
value: { x: 0, y: 0 },
version: 1,
timestamp: Date.now(),
peerId: "host-1",
});
const view = inspector.inspect("game/positions/player1");
console.log(view.current.value);
```
## Usage: Advanced Example
```ts
import { StateInspector } from "@suaveplan/network-plugin-devtools";
const inspector = new StateInspector({ historyLimit: 1024 });
for (const snap of stream.snapshots) inspector.record(snap);
const change = inspector.diff("session/lobby", 14, 17);
console.log(change.summary); // { addedCount: 2, removedCount: 0, changedCount: 1 }
const at = inspector.scrub("session/lobby", 15);
console.log(at.peerId, at.value);
const recent = inspector.search({
pathContains: "session/",
peerId: "host-eu-1",
minVersion: 10,
});
```
## API Reference
- `class StateInspector` — constructor `(config?: InspectorConfig)`. `historyLimit` defaults to 256.
- `record(snapshot: StateSnapshot): void` — throws `INSPECTOR_DIFF_FAILED` on invalid input.
- `inspect(path: string): StateView` — throws `INSPECTOR_PATH_NOT_FOUND` when path absent.
- `list(): readonly string[]` — sorted ascending.
- `has(path: string): boolean`.
- `clear(path?: string): void` — clears single path or every path.
- `diff(path, fromVersion, toVersion): DiffView` — throws `INSPECTOR_INVALID_RANGE` when versions are equal or missing.
- `scrub(path, atVersion): StateSnapshot` — throws `INSPECTOR_PATH_NOT_FOUND` for unknown paths and `INSPECTOR_HISTORY_EMPTY` when no snapshot satisfies the cap.
- `search(filter: SearchFilter): readonly StateSnapshot[]` — AND-combines populated fields.
## Implementation Notes
The inspector is intentionally write-once-per-version — the ring is
append-only and a new snapshot for an existing version simply pushes
another entry rather than overwriting, because vector-clock state
systems can legitimately produce concurrent snapshots at the same
version from different peers; keeping both lets `search` find them. The
diff path delegates to `@suaveplan/diff` for the heavy work and only
classifies the resulting operations into added/removed/changed buckets
so consumers can render badge counts without re-inspecting the patch.
`move`/`copy` ops collapse into `changed` because they imply both ends
mutate; pure-format consumers needing the full patch should call
`@suaveplan/diff`'s `diff()` directly. `scrub` returns the latest
snapshot whose version is ≤ the target so timeline scrubbers naturally
clamp to a recorded frame; this is friendlier than throwing on
sub-version queries which would require the consumer to first call
`list()` and bisect.

View File

@@ -0,0 +1,205 @@
import { metrics } from "@opentelemetry/api";
import { diff, formatPath, type JsonValue } from "@suaveplan/diff";
import z from "zod";
import { NETWORK_DEV_CODES, NetworkDevError } from "../error-codes.js";
import type {
DiffSummary,
DiffView,
InspectorConfig,
SearchFilter,
StateSnapshot,
StateView,
} from "../types/inspector.types.js";
const meter = metrics.getMeter("network-plugin-devtools");
const recordCounter = meter.createCounter("devtools.inspector.record_total", {
description: "Snapshots accepted into the inspector ring buffer",
});
const diffCounter = meter.createCounter("devtools.inspector.diff_total", {
description: "Diff views computed by version range",
});
const DEFAULT_HISTORY_LIMIT = 256;
const InspectorConfigSchema = z.object({
historyLimit: z.number().int().positive().max(10_000).optional(),
});
const SnapshotSchema = z.object({
path: z.string().min(1),
value: z.unknown(),
version: z.number().int().nonnegative(),
timestamp: z.number().int().nonnegative(),
peerId: z.string().min(1),
sessionId: z.string().min(1).optional(),
});
/**
* Per-path state browser. Records snapshots into a bounded ring,
* exposes tree / history / diff views, and supports search by
* path-substring, peer, session, or version range.
*/
export class StateInspector {
private readonly historyLimit: number;
private readonly store = new Map<string, StateSnapshot[]>();
public constructor(config: InspectorConfig = {}) {
const parsed = InspectorConfigSchema.parse(config);
this.historyLimit = parsed.historyLimit ?? DEFAULT_HISTORY_LIMIT;
}
public record(snapshot: StateSnapshot): void {
const parsed = SnapshotSchema.safeParse(snapshot);
if (!parsed.success) {
throw new NetworkDevError(
NETWORK_DEV_CODES.INSPECTOR_DIFF_FAILED,
"inspector.invalid-snapshot",
{ context: { issues: parsed.error.issues.length } }
);
}
const arr = this.store.get(snapshot.path) ?? [];
arr.push(snapshot);
if (arr.length > this.historyLimit) arr.shift();
this.store.set(snapshot.path, arr);
recordCounter.add(1, { path: snapshot.path });
}
public inspect(path: string): StateView {
const arr = this.requirePath(path);
const current = arr[arr.length - 1] as StateSnapshot;
return { path, current, history: [...arr] };
}
public list(): ReadonlyArray<string> {
return [...this.store.keys()].sort();
}
public has(path: string): boolean {
return this.store.has(path);
}
public clear(path?: string): void {
if (path === undefined) {
this.store.clear();
return;
}
this.store.delete(path);
}
public diff(
path: string,
fromVersion: number,
toVersion: number
): DiffView {
if (fromVersion === toVersion) {
throw new NetworkDevError(
NETWORK_DEV_CODES.INSPECTOR_INVALID_RANGE,
"inspector.diff.same-version",
{ context: { fromVersion } }
);
}
const arr = this.requirePath(path);
const from = arr.find((s) => s.version === fromVersion);
const to = arr.find((s) => s.version === toVersion);
if (from === undefined || to === undefined) {
throw new NetworkDevError(
NETWORK_DEV_CODES.INSPECTOR_INVALID_RANGE,
"inspector.diff.version-missing",
{ context: { fromVersion, toVersion } }
);
}
const patch = diff(from.value, to.value);
const added: string[] = [];
const removed: string[] = [];
const changed: string[] = [];
for (const op of patch) {
const ptr = formatPath(op.path);
if (op.op === "add") added.push(ptr);
else if (op.op === "remove") removed.push(ptr);
// replace, move, copy, test all materialise as "changed" — only
// op.op === "test" is harmless but in this read-only context
// emitting it as "changed" preserves the reviewer's expectation
// that the version moved.
else changed.push(ptr);
}
const summary: DiffSummary = {
addedCount: added.length,
removedCount: removed.length,
changedCount: changed.length,
};
diffCounter.add(1, { path });
return {
path,
fromVersion,
toVersion,
added,
removed,
changed,
summary,
};
}
public scrub(path: string, atVersion: number): StateSnapshot {
const arr = this.requirePath(path);
// Walk backwards: choose the latest snapshot ≤ atVersion.
let chosen: StateSnapshot | undefined;
for (const snap of arr) {
if (snap.version <= atVersion) chosen = snap;
}
if (chosen === undefined) {
throw new NetworkDevError(
NETWORK_DEV_CODES.INSPECTOR_HISTORY_EMPTY,
"inspector.scrub.no-version",
{ context: { path, atVersion } }
);
}
return chosen;
}
public search(filter: SearchFilter): ReadonlyArray<StateSnapshot> {
const out: StateSnapshot[] = [];
const pathToken =
filter.pathContains === undefined ? null : filter.pathContains;
for (const [path, arr] of this.store) {
if (pathToken !== null && !path.includes(pathToken)) continue;
for (const snap of arr) {
if (
filter.peerId !== undefined &&
snap.peerId !== filter.peerId
)
continue;
if (
filter.sessionId !== undefined &&
snap.sessionId !== filter.sessionId
)
continue;
if (
filter.minVersion !== undefined &&
snap.version < filter.minVersion
)
continue;
if (
filter.maxVersion !== undefined &&
snap.version > filter.maxVersion
)
continue;
out.push(snap);
}
}
return out;
}
private requirePath(path: string): ReadonlyArray<StateSnapshot> {
const arr = this.store.get(path);
if (arr === undefined || arr.length === 0) {
throw new NetworkDevError(
NETWORK_DEV_CODES.INSPECTOR_PATH_NOT_FOUND,
"inspector.path.unknown",
{ context: { path } }
);
}
return arr;
}
}
export type { JsonValue };

View File

@@ -0,0 +1,96 @@
# `plugin/devtools-plugin` — `DevToolsPlugin`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Single `NetworkPlugin` that bundles every devtools subsystem
(`NetworkVisualizer`, `StateInspector`, `SimulationConfigTool`,
`PerformanceProfiler`) behind one lifecycle, governs activation via a
`tierGate` callback, and exposes each subsystem as a public field so
consumers can compose without going through the plugin abstraction.
The plugin is dev-only by contract: production builds wire `tierGate`
to `() => false` so `start()` short-circuits, no subsystem fires, and
`requireActive()` raises `PLUGIN_DEV_DISABLED` for any call site that
needs a hard guarantee. This keeps the devtools dependency safe to
include in production bundles (zero side effects, zero overhead) while
still letting downstream tooling import the same package without
conditional code paths.
## Features
- Implements the canonical `NetworkPlugin` lifecycle: `install` / `start` / `stop` / `dispose`.
- `tierGate` decides activation at `install()` time; rejection is recorded as `PLUGIN_TIER_GATE_REJECTED` for observability.
- Direct subsystem access: `plugin.visualizer`, `plugin.inspector`, `plugin.simulator`, `plugin.profiler`.
- `requireActive()` raises `PLUGIN_DEV_DISABLED` when consumers want a hard production-build assertion.
- `dispose()` resets every subsystem (clears inspector, resets profiler, resets simulator scenario, stops visualizer if running).
- Idempotent stop and dispose semantics — safe to call multiple times.
## Usage: Basic Example
```ts
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import { DevToolsPlugin } from "@suaveplan/network-plugin-devtools";
const node = new NetworkNode({ id: "host-1", networkBus: new NetworkBus() });
const devtools = new DevToolsPlugin({
tierGate: () => process.env.NODE_ENV !== "production",
topology: myTopologyAdapter,
stream: myStreamAdapter,
});
node.use(devtools);
await node.start();
devtools.visualizer.start({ mode: "hierarchy", refreshRate: 30 });
```
## Usage: Advanced Example
```ts
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import { DevToolsPlugin } from "@suaveplan/network-plugin-devtools";
const devtools = new DevToolsPlugin({
tierGate: () => true,
topology,
stream,
visualizer: { renderer: customPngRenderer, width: 1200, height: 900 },
inspector: { historyLimit: 1024 },
profiler: { windowSize: 4096, regressionZScore: 2.5, regressionWarnPct: 15 },
simulator: { idGenerator: () => crypto.randomUUID() },
});
node.use(devtools);
await node.start();
devtools.requireActive();
devtools.profiler.recordLatency("hot-path", 42);
devtools.simulator.addPeer({ id: "peer-1", tier: "tier-2", capacity: 10 });
const baseline = devtools.profiler.captureBaseline("v1.0.0");
const regressions = devtools.profiler.compare(baseline.id);
```
## API Reference
- `class DevToolsPlugin implements NetworkPlugin`.
- Constructor `(config: DevToolsPluginConfig)`. Required: `tierGate`, `topology`, `stream`. Optional sub-config blocks for each subsystem.
- Public fields: `visualizer`, `inspector`, `simulator`, `profiler`.
- Lifecycle: `install(node)`, `start()`, `stop()`, `dispose()`.
- Status: `isStarted()`, `isActive()`, `host()`, `tierRejection()`.
- Guard: `requireActive()` throws `PLUGIN_DEV_DISABLED` if the tier gate rejected activation.
## Implementation Notes
The `tierGate` is intentionally a function rather than a boolean so
consumers can re-evaluate at every `install()` — useful for tests that
want to flip activation without rebuilding the plugin. The plugin
records the rejection as a code rather than swallowing it because
"why is the bridge silent?" is a frequent observability question, and
the answer should be a typed string (`PLUGIN_TIER_GATE_REJECTED`)
queryable from logs. Sub-subsystem disposal in `dispose()` is gated on
`active` so production-disabled installs don't pay the reset cost on
shutdown — every subsystem's `clear()` / `reset()` / `newScenario()`
operation is cheap, but skipping the work keeps the dispose path a
true no-op when the plugin never activated. The optional config blocks
spread directly into each subsystem constructor; no wrapping or
adapter layer mediates between them.

View File

@@ -0,0 +1,160 @@
import { NetworkNode } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_DEV_CODES, type NetworkDevError } from "../error-codes.js";
import type {
EdgeLayout,
HeatCell,
MessageStreamAdapter,
NodeLayout,
TopologyAdapter,
} from "../types/visualizer.types.js";
import { DevToolsPlugin } from "./devtools-plugin.js";
const NODES: NodeLayout[] = [
{ id: "host-1", tier: "tier-0", x: 0, y: 0, label: "Host" },
];
const EDGES: EdgeLayout[] = [];
const HEAT: HeatCell[] = [];
const topology: TopologyAdapter = {
listNodes: () => NODES,
listEdges: () => EDGES,
nodeById: (id) => NODES.find((n) => n.id === id),
};
const stream: MessageStreamAdapter = {
listFlows: () => EDGES,
listHeat: () => HEAT,
};
describe("DevToolsPlugin lifecycle (NET_DEV-06)", () => {
it("activates when tierGate returns true", async () => {
const plugin = new DevToolsPlugin({
tierGate: () => true,
topology,
stream,
});
const node = new NetworkNode();
node.use(plugin);
await node.start();
expect(plugin.isActive()).toBe(true);
expect(plugin.isStarted()).toBe(true);
expect(plugin.tierRejection()).toBeNull();
expect(plugin.host()).toBe(node);
plugin.requireActive(); // does not throw
await node.stop();
});
it("inert when tierGate returns false", async () => {
const plugin = new DevToolsPlugin({
tierGate: () => false,
topology,
stream,
});
const node = new NetworkNode();
node.use(plugin);
await node.start();
expect(plugin.isStarted()).toBe(true);
expect(plugin.isActive()).toBe(false);
expect(plugin.tierRejection()).toBe(
NETWORK_DEV_CODES.PLUGIN_TIER_GATE_REJECTED
);
try {
plugin.requireActive();
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.PLUGIN_DEV_DISABLED
);
}
await node.stop();
});
it("dispose clears subsystems and resets state", async () => {
const plugin = new DevToolsPlugin({
tierGate: () => true,
topology,
stream,
inspector: { historyLimit: 4 },
});
const node = new NetworkNode();
node.use(plugin);
await node.start();
plugin.inspector.record({
path: "p",
value: 1,
version: 1,
timestamp: 1,
peerId: "x",
});
plugin.simulator.addPeer({ id: "p1", tier: "tier-0", capacity: 10 });
plugin.profiler.recordLatency("a", 10);
plugin.visualizer.start({ mode: "hierarchy", refreshRate: 30 });
await node.stop();
expect(plugin.visualizer.isRunning()).toBe(false);
await node.dispose();
expect(plugin.host()).toBeNull();
expect(plugin.isActive()).toBe(false);
expect(plugin.tierRejection()).toBeNull();
});
it("dispose on inactive plugin is a no-op", async () => {
const plugin = new DevToolsPlugin({
tierGate: () => false,
topology,
stream,
});
const node = new NetworkNode();
node.use(plugin);
await node.start();
await node.stop();
await node.dispose();
expect(plugin.host()).toBeNull();
});
it("stop on plugin without running visualizer is safe", async () => {
const plugin = new DevToolsPlugin({
tierGate: () => true,
topology,
stream,
});
const node = new NetworkNode();
node.use(plugin);
await node.start();
await node.stop();
expect(plugin.isStarted()).toBe(false);
});
it("requireActive before install falls back to PLUGIN_DEV_DISABLED reason", () => {
const plugin = new DevToolsPlugin({
tierGate: () => true,
topology,
stream,
});
try {
plugin.requireActive();
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.PLUGIN_DEV_DISABLED
);
}
});
it("forwards optional config to subsystems", () => {
const plugin = new DevToolsPlugin({
tierGate: () => true,
topology,
stream,
visualizer: { width: 1024, height: 768 },
inspector: { historyLimit: 5 },
simulator: { idGenerator: () => "fixed" },
profiler: { windowSize: 4 },
});
plugin.visualizer.start({ mode: "hierarchy", refreshRate: 30 });
const scene = plugin.visualizer.render();
expect(scene.width).toBe(1024);
expect(plugin.simulator.scenarioIdValue()).toBe("fixed");
});
});

View File

@@ -0,0 +1,128 @@
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
import { NETWORK_DEV_CODES, NetworkDevError } from "../error-codes.js";
import { StateInspector } from "../inspector/state-inspector.js";
import { PerformanceProfiler } from "../profiler/performance-profiler.js";
import { SimulationConfigTool } from "../simulator/simulation-config-tool.js";
import type { InspectorConfig } from "../types/inspector.types.js";
import type { ProfilerConfig } from "../types/profiler.types.js";
import type { SimulationConfigToolOptions } from "../types/simulator.types.js";
import type {
MessageStreamAdapter,
TopologyAdapter,
} from "../types/visualizer.types.js";
import {
NetworkVisualizer,
type NetworkVisualizerConfig,
} from "../visualizer/network-visualizer.js";
/** Tier gate — return `true` only when the plugin should activate (dev builds). */
export type DevtoolsTierGate = () => boolean;
export interface DevToolsPluginConfig {
readonly tierGate: DevtoolsTierGate;
readonly topology: TopologyAdapter;
readonly stream: MessageStreamAdapter;
readonly visualizer?: Omit<NetworkVisualizerConfig, "topology" | "stream">;
readonly inspector?: InspectorConfig;
readonly simulator?: SimulationConfigToolOptions;
readonly profiler?: ProfilerConfig;
}
/**
* `NetworkPlugin` bundling every devtools subsystem behind a single
* lifecycle. The `tierGate` callback decides whether the plugin
* activates — production builds wire it to `() => false` so the
* `start()` call short-circuits and the subsystems stay inert.
*/
export class DevToolsPlugin implements NetworkPlugin {
public readonly name = "devtools";
public readonly visualizer: NetworkVisualizer;
public readonly inspector: StateInspector;
public readonly simulator: SimulationConfigTool;
public readonly profiler: PerformanceProfiler;
private readonly tierGate: DevtoolsTierGate;
private node: NetworkNode | null = null;
private started = false;
private active = false;
private tierRejectionReason: string | null = null;
public constructor(config: DevToolsPluginConfig) {
this.tierGate = config.tierGate;
this.visualizer = new NetworkVisualizer({
topology: config.topology,
stream: config.stream,
...(config.visualizer ?? {}),
});
this.inspector = new StateInspector(config.inspector ?? {});
this.simulator = new SimulationConfigTool(config.simulator ?? {});
this.profiler = new PerformanceProfiler(config.profiler ?? {});
}
public install(node: NetworkNode): void {
this.node = node;
this.active = this.tierGate();
}
public start(): void {
if (!this.active) {
this.tierRejectionReason =
NETWORK_DEV_CODES.PLUGIN_TIER_GATE_REJECTED;
this.started = true;
return;
}
this.started = true;
}
public stop(): void {
if (this.active && this.visualizer.isRunning()) this.visualizer.stop();
this.started = false;
}
public dispose(): void {
if (this.active) {
this.inspector.clear();
this.profiler.reset();
this.simulator.newScenario();
}
this.node = null;
this.active = false;
this.tierRejectionReason = null;
}
public host(): NetworkNode | null {
return this.node;
}
public isStarted(): boolean {
return this.started;
}
public isActive(): boolean {
return this.active;
}
public tierRejection(): string | null {
return this.tierRejectionReason;
}
/**
* Throws when consumed in a production-disabled context. Useful for
* call sites that want a hard guarantee that devtools work isn't
* silently degraded — instead of returning a no-op subsystem.
*/
public requireActive(): void {
if (!this.active) {
throw new NetworkDevError(
NETWORK_DEV_CODES.PLUGIN_DEV_DISABLED,
"devtools.disabled",
{
context: {
reason:
this.tierRejectionReason ??
NETWORK_DEV_CODES.PLUGIN_DEV_DISABLED,
},
}
);
}
}
}

View File

@@ -0,0 +1,92 @@
# `profiler/performance-profiler` — `PerformanceProfiler`
## Purpose
Captures runtime performance evidence (latency per path, throughput per
path, plugin overhead, memory) into bounded ring buffers, computes
percentile distributions, captures named baselines, detects regressions
between current and baseline runs, and surfaces actionable optimisation
suggestions for engineers triaging performance work.
The profiler is the second half of the devtools observability story —
the visualizer shows you what the network LOOKS like; the profiler
quantifies how it BEHAVES. Both are designed to compose with the
production-grade `network-plugin-monitor` collector — the profiler
focuses on developer-time analysis (regressions, suggestions) rather
than alerting.
## Features
- `recordLatency` / `recordThroughput` / `recordPluginOverhead` / `recordMemory` ingest.
- Per-path bounded ring (default 256, configurable).
- `latencyDistribution(path)` exposes p50 / p95 / p99 + min / max / mean / count.
- `report()` aggregates everything into a single `PerformanceReport`.
- `captureBaseline(id)` snapshots the current report for later comparison; baselines are immutable, duplicate-id raises `PROFILER_BASELINE_DUPLICATE`.
- `compare(baselineId)` produces a `Regression[]` ranked by Z-score across p50 / p95 / p99 latency and message-throughput drops.
- `suggestions()` emits `OptimizationSuggestion`s for tail-latency, plugin overhead, heap pressure, and zero-throughput anomalies.
- Configurable thresholds: `regressionZScore` (default 2.0), `regressionWarnPct` (default 10).
- OTel counters: `devtools.profiler.sample_total{kind}`, `devtools.profiler.report_total`.
## Usage: Basic Example
```ts
import { PerformanceProfiler } from "@suaveplan/network-plugin-devtools";
const profiler = new PerformanceProfiler();
profiler.recordLatency("host-1->leaf-1", 42);
profiler.recordLatency("host-1->leaf-1", 51);
const dist = profiler.latencyDistribution("host-1->leaf-1");
console.log(dist.p99);
```
## Usage: Advanced Example
```ts
import { PerformanceProfiler } from "@suaveplan/network-plugin-devtools";
const profiler = new PerformanceProfiler({
windowSize: 1024,
regressionZScore: 2.5,
regressionWarnPct: 15,
clock: () => deterministicClock.now(),
});
for (const sample of warmupRun) profiler.recordLatency(sample.path, sample.ms);
const baseline = profiler.captureBaseline("v1.4.2");
profiler.reset();
for (const sample of regressionRun) profiler.recordLatency(sample.path, sample.ms);
const regressions = profiler.compare(baseline.id);
for (const r of regressions) {
console.log(`${r.severity} ${r.metric} on ${r.path}: +${r.deltaPct.toFixed(1)}%`);
}
for (const s of profiler.suggestions()) {
console.log(`[${s.category}] ${s.subject}: ${s.message}`);
}
```
## API Reference
- `class PerformanceProfiler` — constructor `(config?: ProfilerConfig)`. Throws `PROFILER_INVALID_THRESHOLD` on non-positive `windowSize`/`regressionZScore`/`regressionWarnPct`.
- Recorders: `recordLatency(path, ms)`, `recordThroughput(path, bps, mps)`, `recordPluginOverhead(name, cpuMs)`, `recordMemory(used, total)`.
- Queries: `latencyDistribution(path)` (throws `PROFILER_NO_SAMPLES` when empty), `report()`.
- Baselines: `captureBaseline(id)`, `listBaselines()`, `deleteBaseline(id)` (throws `PROFILER_NO_BASELINE`).
- Comparison: `compare(baselineId): readonly Regression[]` (throws `PROFILER_NO_BASELINE` when absent).
- Heuristics: `suggestions(): readonly OptimizationSuggestion[]`.
- Lifecycle: `reset()` clears every sample buffer (baselines preserved).
## Implementation Notes
The Z-score approximation (`deltaPct / max(1, base * 0.1)`) is a
deliberately simple proxy for a true two-window statistical test — full
Welch's t-test requires storing both windows' raw distributions, and
the profiler is intentionally light enough to be left running in
developer builds. The proxy is monotonic in delta and falls back
sensibly when the baseline is small. Severity ladder: error when
Z ≥ threshold; warn when delta ≥ `warnPct`; info when delta ≥ half of
`warnPct`. Throughput regressions track the DROP direction — a fall in
messages/sec is the regression to surface, not a rise. The optional
suggestions module is split into a sibling file so the regression
math and the heuristic strings don't share a 300-line budget; both
remain hand-tuned and audited rather than ML-driven, because devtools
output must be explainable to engineers.

View File

@@ -0,0 +1,74 @@
# `profiler/performance-profiler.suggestions` — heuristic suggestion engine
## Purpose
Pure-function suggestion engine that consumes profiler ring buffers
(latency / overhead / throughput / memory) and emits an
`OptimizationSuggestion[]` with category-tagged advice for engineers
triaging performance work. Split out of `performance-profiler.ts` so
the regression-math hot path and the developer-facing heuristic strings
are separately auditable and can grow independently.
## Features
- Tail-latency detection: flags paths where p99 > 2×p50 AND p99 > 100ms.
- Plugin-overhead detection: flags plugins whose mean CPU per invocation exceeds 5ms.
- Heap-pressure detection: flags processes whose heap usage exceeds 85% of `heapTotalBytes`.
- Zero-throughput detection: flags paths reporting `messagesPerSec === 0` (likely a dead transport).
- Pure function — no I/O, no global state, no clock dependency.
## Usage: Basic Example
```ts
import { collectSuggestions } from "@suaveplan/network-plugin-devtools";
const suggestions = collectSuggestions(latencyMap, overheadMap, throughputMap, memorySample);
for (const s of suggestions) console.log(`[${s.category}] ${s.subject}: ${s.message}`);
```
## Usage: Advanced Example
```ts
import {
PerformanceProfiler,
collectSuggestions,
} from "@suaveplan/network-plugin-devtools";
const profiler = new PerformanceProfiler();
profiler.recordLatency("hot-path", 100);
profiler.recordLatency("hot-path", 250);
profiler.recordPluginOverhead("validator", 12);
profiler.recordMemory(900, 1000);
// `profiler.suggestions()` is a thin facade over `collectSuggestions`,
// but call the helper directly when consuming raw maps from another
// source (e.g. an upstream collector you bridge in tests).
const direct = collectSuggestions(
new Map([["hot-path", [{ path: "hot-path", latencyMs: 250, recordedAt: 0 }]]]),
new Map(),
new Map(),
null
);
console.log(direct);
```
## API Reference
- `collectSuggestions(latency, overhead, throughput, memory): readonly OptimizationSuggestion[]` — pure; never throws.
- `latency: ReadonlyMap<string, ReadonlyArray<LatencySample>>`
- `overhead: ReadonlyMap<string, ReadonlyArray<PluginOverheadSample>>`
- `throughput: ReadonlyMap<string, ThroughputSample>`
- `memory: MemorySample | null`
## Implementation Notes
Thresholds are hard-coded and explained inline (`TAIL_RATIO`,
`TAIL_FLOOR_MS`, `OVERHEAD_AVG_MS`, `HEAP_USAGE_LIMIT`). Empirical
tuning lives here intentionally so engineers reading suggestions can
trace the cutoffs without going through the configurable
regression thresholds in `performance-profiler.ts` — the two paths
serve different audiences. The local `quickDistribution` helper
duplicates a tiny slice of the percentile logic in
`performance-profiler.ts` rather than importing it because the helper
needs only p50 and p99 and avoiding the import keeps this module
dep-free and easy to inline-test.

View File

@@ -0,0 +1,88 @@
import type {
LatencySample,
MemorySample,
OptimizationSuggestion,
PluginOverheadSample,
ThroughputSample,
} from "../types/profiler.types.js";
const TAIL_RATIO = 2;
const TAIL_FLOOR_MS = 100;
const OVERHEAD_AVG_MS = 5;
const HEAP_USAGE_LIMIT = 0.85;
interface DistributionLike {
readonly p50: number;
readonly p99: number;
}
function quickDistribution(values: ReadonlyArray<number>): DistributionLike {
const sorted = [...values].sort((a, b) => a - b);
const pick = (p: number): number => {
const idx = Math.min(
sorted.length - 1,
Math.max(0, Math.ceil(sorted.length * p) - 1)
);
return sorted[idx] as number;
};
return { p50: pick(0.5), p99: pick(0.99) };
}
export function collectSuggestions(
latency: ReadonlyMap<string, ReadonlyArray<LatencySample>>,
overhead: ReadonlyMap<string, ReadonlyArray<PluginOverheadSample>>,
throughput: ReadonlyMap<string, ThroughputSample>,
memory: MemorySample | null
): ReadonlyArray<OptimizationSuggestion> {
const out: OptimizationSuggestion[] = [];
for (const [path, arr] of latency) {
if (arr.length === 0) continue;
const dist = quickDistribution(arr.map((s) => s.latencyMs));
if (dist.p99 > TAIL_RATIO * dist.p50 && dist.p99 > TAIL_FLOOR_MS) {
out.push({
subject: path,
category: "latency",
message:
"p99 is more than 2× p50 — investigate tail latency outliers",
evidence: `p50=${dist.p50.toFixed(2)}ms, p99=${dist.p99.toFixed(2)}ms`,
});
}
}
for (const [name, arr] of overhead) {
if (arr.length === 0) continue;
let total = 0;
for (const s of arr) total += s.cpuMs;
const avg = total / arr.length;
if (avg > OVERHEAD_AVG_MS) {
out.push({
subject: name,
category: "overhead",
message: `Plugin averages ${avg.toFixed(2)}ms per invocation — consider batching or sampling`,
evidence: `samples=${arr.length}`,
});
}
}
if (memory !== null && memory.heapTotalBytes > 0) {
const usage = memory.heapUsedBytes / memory.heapTotalBytes;
if (usage > HEAP_USAGE_LIMIT) {
out.push({
subject: "process",
category: "memory",
message: "Heap usage above 85% — investigate retention",
evidence: `used=${memory.heapUsedBytes}, total=${memory.heapTotalBytes}`,
});
}
}
for (const [path, sample] of throughput) {
if (sample.messagesPerSec === 0) {
out.push({
subject: path,
category: "throughput",
message:
"Throughput recorded as zero — verify the path is alive",
evidence: `bytesPerSec=${sample.bytesPerSec}`,
});
}
}
return out;
}

View File

@@ -0,0 +1,317 @@
import { metrics } from "@opentelemetry/api";
import { NETWORK_DEV_CODES, NetworkDevError } from "../error-codes.js";
import type {
Baseline,
LatencyDistribution,
LatencySample,
MemorySample,
OptimizationSuggestion,
PerformanceReport,
PluginOverheadSample,
ProfilerConfig,
Regression,
RegressionSeverity,
ThroughputSample,
} from "../types/profiler.types.js";
import { collectSuggestions } from "./performance-profiler.suggestions.js";
const meter = metrics.getMeter("network-plugin-devtools");
const sampleCounter = meter.createCounter("devtools.profiler.sample_total", {
description: "Samples accepted by the profiler keyed by metric kind",
});
const reportCounter = meter.createCounter("devtools.profiler.report_total", {
description: "Reports generated by the profiler",
});
const DEFAULT_WINDOW = 256;
const DEFAULT_REGRESSION_Z = 2.0;
const DEFAULT_WARN_PCT = 10;
/**
* Captures latency / throughput / plugin-overhead / memory samples,
* computes percentile distributions, captures named baselines, detects
* regressions via two-window EMA + Z-score comparison, and emits
* actionable optimisation suggestions keyed by category.
*/
export class PerformanceProfiler {
private readonly windowSize: number;
private readonly regressionZ: number;
private readonly warnPct: number;
private readonly clock: () => number;
private readonly latency = new Map<string, LatencySample[]>();
private readonly throughput = new Map<string, ThroughputSample>();
private readonly overhead = new Map<string, PluginOverheadSample[]>();
private readonly baselines = new Map<string, Baseline>();
private memory: MemorySample | null = null;
public constructor(config: ProfilerConfig = {}) {
const w = config.windowSize ?? DEFAULT_WINDOW;
const z = config.regressionZScore ?? DEFAULT_REGRESSION_Z;
const p = config.regressionWarnPct ?? DEFAULT_WARN_PCT;
if (w <= 0 || !Number.isFinite(w))
throw invalidThreshold("windowSize", w);
if (z <= 0 || !Number.isFinite(z))
throw invalidThreshold("regressionZScore", z);
if (p <= 0 || !Number.isFinite(p))
throw invalidThreshold("regressionWarnPct", p);
this.windowSize = w;
this.regressionZ = z;
this.warnPct = p;
this.clock = config.clock ?? (() => Date.now());
}
public recordLatency(path: string, latencyMs: number): void {
const arr = this.latency.get(path) ?? [];
arr.push({ path, latencyMs, recordedAt: this.clock() });
if (arr.length > this.windowSize) arr.shift();
this.latency.set(path, arr);
sampleCounter.add(1, { kind: "latency" });
}
public recordThroughput(
path: string,
bytesPerSec: number,
messagesPerSec: number
): void {
this.throughput.set(path, {
path,
bytesPerSec,
messagesPerSec,
recordedAt: this.clock(),
});
sampleCounter.add(1, { kind: "throughput" });
}
public recordPluginOverhead(pluginName: string, cpuMs: number): void {
const arr = this.overhead.get(pluginName) ?? [];
arr.push({ pluginName, cpuMs, recordedAt: this.clock() });
if (arr.length > this.windowSize) arr.shift();
this.overhead.set(pluginName, arr);
sampleCounter.add(1, { kind: "overhead" });
}
public recordMemory(heapUsedBytes: number, heapTotalBytes: number): void {
this.memory = {
heapUsedBytes,
heapTotalBytes,
recordedAt: this.clock(),
};
sampleCounter.add(1, { kind: "memory" });
}
public latencyDistribution(path: string): LatencyDistribution {
const arr = this.latency.get(path);
if (arr === undefined || arr.length === 0) {
throw new NetworkDevError(
NETWORK_DEV_CODES.PROFILER_NO_SAMPLES,
"profiler.latency.no-samples",
{ context: { path } }
);
}
return distribution(arr.map((s) => s.latencyMs));
}
public report(): PerformanceReport {
const latByPath = new Map<string, LatencyDistribution>();
for (const [path, arr] of this.latency) {
// recordLatency always pushes before storing, so arr.length > 0
latByPath.set(path, distribution(arr.map((s) => s.latencyMs)));
}
const overheadByPlugin = new Map<string, number>();
for (const [name, arr] of this.overhead) {
// recordPluginOverhead always pushes before storing
let total = 0;
for (const s of arr) total += s.cpuMs;
overheadByPlugin.set(name, total / arr.length);
}
reportCounter.add(1);
return {
latencyByPath: latByPath,
throughputByPath: new Map(this.throughput),
pluginOverhead: overheadByPlugin,
memory: this.memory,
generatedAt: this.clock(),
};
}
public captureBaseline(id: string): Baseline {
if (this.baselines.has(id)) {
throw new NetworkDevError(
NETWORK_DEV_CODES.PROFILER_BASELINE_DUPLICATE,
"profiler.baseline.duplicate",
{ context: { id } }
);
}
const baseline: Baseline = {
id,
capturedAt: this.clock(),
report: this.report(),
};
this.baselines.set(id, baseline);
return baseline;
}
public listBaselines(): ReadonlyArray<string> {
return [...this.baselines.keys()].sort();
}
public deleteBaseline(id: string): void {
if (!this.baselines.delete(id)) {
throw new NetworkDevError(
NETWORK_DEV_CODES.PROFILER_NO_BASELINE,
"profiler.baseline.unknown",
{ context: { id } }
);
}
}
public compare(baselineId: string): ReadonlyArray<Regression> {
const baseline = this.baselines.get(baselineId);
if (baseline === undefined) {
throw new NetworkDevError(
NETWORK_DEV_CODES.PROFILER_NO_BASELINE,
"profiler.compare.unknown",
{ context: { baselineId } }
);
}
const current = this.report();
const out: Regression[] = [];
for (const [path, baseDist] of baseline.report.latencyByPath) {
const curDist = current.latencyByPath.get(path);
if (curDist === undefined) continue;
for (const metric of ["p50", "p95", "p99"] as const) {
const reg = regressionFor(
path,
metric,
baseDist[metric],
curDist[metric],
this.warnPct,
this.regressionZ
);
if (reg !== null) out.push(reg);
}
}
for (const [path, baseTp] of baseline.report.throughputByPath) {
const curTp = current.throughputByPath.get(path);
if (curTp === undefined) continue;
const reg = throughputRegression(
path,
baseTp.messagesPerSec,
curTp.messagesPerSec,
this.warnPct,
this.regressionZ
);
if (reg !== null) out.push(reg);
}
return out;
}
public suggestions(): ReadonlyArray<OptimizationSuggestion> {
return collectSuggestions(
this.latency,
this.overhead,
this.throughput,
this.memory
);
}
public reset(): void {
this.latency.clear();
this.throughput.clear();
this.overhead.clear();
this.memory = null;
}
}
function distribution(values: ReadonlyArray<number>): LatencyDistribution {
const sorted = [...values].sort((a, b) => a - b);
let total = 0;
for (const v of sorted) total += v;
const count = sorted.length;
return {
count,
min: sorted[0] as number,
max: sorted[count - 1] as number,
mean: total / count,
p50: percentile(sorted, 0.5),
p95: percentile(sorted, 0.95),
p99: percentile(sorted, 0.99),
};
}
function percentile(sortedAsc: ReadonlyArray<number>, p: number): number {
const idx = Math.min(
sortedAsc.length - 1,
Math.max(0, Math.ceil(sortedAsc.length * p) - 1)
);
return sortedAsc[idx] as number;
}
function regressionFor(
path: string,
metric: "p50" | "p95" | "p99",
base: number,
cur: number,
warnPct: number,
zThreshold: number
): Regression | null {
if (base === 0) return null;
const deltaPct = ((cur - base) / base) * 100;
if (deltaPct <= 0) return null;
const zScore = deltaPct / Math.max(1, base * 0.1);
const severity = chooseSeverity(deltaPct, zScore, warnPct, zThreshold);
if (severity === null) return null;
return {
path,
metric,
baseline: base,
current: cur,
deltaPct,
zScore,
severity,
};
}
function throughputRegression(
path: string,
base: number,
cur: number,
warnPct: number,
zThreshold: number
): Regression | null {
if (base === 0) return null;
const deltaPct = ((base - cur) / base) * 100; // drop is the regression direction
if (deltaPct <= 0) return null;
const zScore = deltaPct / Math.max(1, base * 0.1);
const severity = chooseSeverity(deltaPct, zScore, warnPct, zThreshold);
if (severity === null) return null;
return {
path,
metric: "throughput",
baseline: base,
current: cur,
deltaPct,
zScore,
severity,
};
}
function chooseSeverity(
deltaPct: number,
zScore: number,
warnPct: number,
zThreshold: number
): RegressionSeverity | null {
if (zScore >= zThreshold) return "error";
if (deltaPct >= warnPct) return "warn";
if (deltaPct >= warnPct / 2) return "info";
return null;
}
function invalidThreshold(name: string, value: number): NetworkDevError {
return new NetworkDevError(
NETWORK_DEV_CODES.PROFILER_INVALID_THRESHOLD,
"profiler.threshold.invalid",
{ context: { name, value } }
);
}

View File

@@ -0,0 +1,363 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_DEV_CODES, type NetworkDevError } from "../error-codes.js";
import { PerformanceProfiler } from "./performance-profiler.js";
import { collectSuggestions } from "./performance-profiler.suggestions.js";
function freshProfiler(): PerformanceProfiler {
let t = 0;
return new PerformanceProfiler({
clock: () => ++t,
windowSize: 8,
regressionZScore: 2,
regressionWarnPct: 10,
});
}
describe("PerformanceProfiler thresholds (NET_DEV-05)", () => {
it("rejects non-positive windowSize", () => {
try {
new PerformanceProfiler({ windowSize: 0 });
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.PROFILER_INVALID_THRESHOLD
);
}
});
it("rejects non-positive regressionZScore", () => {
try {
new PerformanceProfiler({ regressionZScore: 0 });
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.PROFILER_INVALID_THRESHOLD
);
}
});
it("rejects non-finite regressionWarnPct", () => {
try {
new PerformanceProfiler({
regressionWarnPct: Number.POSITIVE_INFINITY,
});
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.PROFILER_INVALID_THRESHOLD
);
}
});
it("default constructor succeeds", () => {
const p = new PerformanceProfiler();
expect(p.report().latencyByPath.size).toBe(0);
});
});
describe("PerformanceProfiler recording + distribution (NET_DEV-05)", () => {
it("computes p50/p95/p99/min/max/mean/count", () => {
const p = freshProfiler();
for (let i = 1; i <= 8; i++) p.recordLatency("path", i * 10);
const dist = p.latencyDistribution("path");
expect(dist.count).toBe(8);
expect(dist.min).toBe(10);
expect(dist.max).toBe(80);
expect(dist.mean).toBe(45);
expect(dist.p50).toBe(40);
expect(dist.p95).toBeGreaterThanOrEqual(70);
expect(dist.p99).toBe(80);
});
it("ring buffer enforces windowSize", () => {
const p = freshProfiler();
for (let i = 0; i < 20; i++) p.recordLatency("path", i);
// window=8, so only the last 8 samples remain (12..19)
const dist = p.latencyDistribution("path");
expect(dist.count).toBe(8);
expect(dist.min).toBe(12);
});
it("latencyDistribution throws PROFILER_NO_SAMPLES on empty path", () => {
const p = freshProfiler();
try {
p.latencyDistribution("ghost");
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.PROFILER_NO_SAMPLES
);
}
});
it("plugin overhead ring buffer enforces windowSize", () => {
const p = freshProfiler();
for (let i = 0; i < 20; i++) p.recordPluginOverhead("plug", i);
const report = p.report();
// window=8, mean of 12..19 = 15.5
expect(report.pluginOverhead.get("plug")).toBe(15.5);
});
it("recordThroughput / recordPluginOverhead / recordMemory wire into report", () => {
const p = freshProfiler();
p.recordThroughput("hot", 1000, 50);
p.recordPluginOverhead("plug", 3);
p.recordPluginOverhead("plug", 5);
p.recordMemory(800, 1000);
const report = p.report();
expect(report.throughputByPath.get("hot")?.bytesPerSec).toBe(1000);
expect(report.pluginOverhead.get("plug")).toBe(4);
expect(report.memory?.heapUsedBytes).toBe(800);
});
it("reset clears every sample buffer but preserves baselines", () => {
const p = freshProfiler();
p.recordLatency("a", 10);
const baseline = p.captureBaseline("v1");
p.reset();
expect(p.report().latencyByPath.size).toBe(0);
expect(p.listBaselines()).toContain(baseline.id);
});
});
describe("PerformanceProfiler baselines + compare (NET_DEV-05)", () => {
it("captureBaseline duplicate id raises PROFILER_BASELINE_DUPLICATE", () => {
const p = freshProfiler();
p.recordLatency("a", 10);
p.captureBaseline("v1");
try {
p.captureBaseline("v1");
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.PROFILER_BASELINE_DUPLICATE
);
}
});
it("deleteBaseline / listBaselines round-trip", () => {
const p = freshProfiler();
p.recordLatency("a", 10);
p.captureBaseline("v1");
p.captureBaseline("v2");
expect(p.listBaselines()).toStrictEqual(["v1", "v2"]);
p.deleteBaseline("v1");
expect(p.listBaselines()).toStrictEqual(["v2"]);
try {
p.deleteBaseline("ghost");
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.PROFILER_NO_BASELINE
);
}
});
it("compare without baseline raises PROFILER_NO_BASELINE", () => {
const p = freshProfiler();
try {
p.compare("missing");
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.PROFILER_NO_BASELINE
);
}
});
it("compare detects latency regression with severity ladder", () => {
const p = freshProfiler();
for (let i = 0; i < 5; i++) p.recordLatency("a", 10);
p.captureBaseline("v1");
p.reset();
for (let i = 0; i < 5; i++) p.recordLatency("a", 100); // 10x = 900% delta
const regressions = p.compare("v1");
const latencyReg = regressions.find((r) => r.metric === "p50");
expect(latencyReg).toBeDefined();
expect(latencyReg?.severity).toBe("error");
expect(latencyReg?.deltaPct).toBeGreaterThan(0);
});
it("compare yields warn-level regression when delta is small", () => {
const p = freshProfiler();
// baseline 100ms
for (let i = 0; i < 5; i++) p.recordLatency("a", 100);
p.captureBaseline("v1");
p.reset();
// ~12% bump
for (let i = 0; i < 5; i++) p.recordLatency("a", 112);
const regressions = p.compare("v1");
const reg = regressions.find((r) => r.metric === "p50");
expect(reg?.severity).toBe("warn");
});
it("compare yields info-level when delta is between half-pct and warnPct", () => {
const p = freshProfiler();
for (let i = 0; i < 5; i++) p.recordLatency("a", 100);
p.captureBaseline("v1");
p.reset();
for (let i = 0; i < 5; i++) p.recordLatency("a", 106); // 6% — between 5 and 10
const regressions = p.compare("v1");
const reg = regressions.find((r) => r.metric === "p50");
expect(reg?.severity).toBe("info");
});
it("compare returns empty when current is within tolerance", () => {
const p = freshProfiler();
for (let i = 0; i < 5; i++) p.recordLatency("a", 100);
p.captureBaseline("v1");
p.reset();
for (let i = 0; i < 5; i++) p.recordLatency("a", 100);
expect(p.compare("v1")).toHaveLength(0);
});
it("compare ignores micro-deltas below half-warnPct (severity null branch)", () => {
const p = freshProfiler();
for (let i = 0; i < 5; i++) p.recordLatency("a", 100);
p.captureBaseline("v1");
p.reset();
for (let i = 0; i < 5; i++) p.recordLatency("a", 102); // 2% < 5%
const reg = p.compare("v1").find((r) => r.metric === "p50");
expect(reg).toBeUndefined();
});
it("throughput regression below half-warnPct returns no entry", () => {
const p = freshProfiler();
p.recordThroughput("hot", 1000, 100);
p.captureBaseline("v1");
p.recordThroughput("hot", 990, 98); // 2% drop < 5
const reg = p.compare("v1").find((r) => r.metric === "throughput");
expect(reg).toBeUndefined();
});
it("compare detects throughput drop", () => {
const p = freshProfiler();
p.recordThroughput("hot", 1000, 100);
p.captureBaseline("v1");
p.recordThroughput("hot", 200, 20); // 80% drop
const regressions = p.compare("v1");
const tp = regressions.find((r) => r.metric === "throughput");
expect(tp?.severity).toBe("error");
expect(tp?.baseline).toBe(100);
expect(tp?.current).toBe(20);
});
it("compare ignores paths absent from current report", () => {
const p = freshProfiler();
p.recordLatency("only-baseline", 100);
p.recordThroughput("only-baseline-tp", 1000, 100);
p.captureBaseline("v1");
p.reset();
expect(p.compare("v1")).toHaveLength(0);
});
it("compare handles zero baseline gracefully", () => {
const p = freshProfiler();
p.recordThroughput("hot", 0, 0);
p.captureBaseline("v1");
p.recordThroughput("hot", 100, 50);
expect(
p.compare("v1").find((r) => r.metric === "throughput")
).toBeUndefined();
});
it("compare ignores zero baseline latency (regressionFor base===0 branch)", () => {
const p = freshProfiler();
// Recording 0ms latency for the baseline.
for (let i = 0; i < 3; i++) p.recordLatency("a", 0);
p.captureBaseline("v1");
p.reset();
for (let i = 0; i < 3; i++) p.recordLatency("a", 100);
const reg = p.compare("v1").find((r) => r.metric === "p50");
expect(reg).toBeUndefined();
});
it("throughput regression ignores cur >= base", () => {
const p = freshProfiler();
p.recordThroughput("hot", 100, 50);
p.captureBaseline("v1");
p.recordThroughput("hot", 200, 100); // higher, not a regression
const reg = p.compare("v1").find((r) => r.metric === "throughput");
expect(reg).toBeUndefined();
});
it("compare ignores baseline-better latency (current ≤ base)", () => {
const p = freshProfiler();
for (let i = 0; i < 3; i++) p.recordLatency("a", 100);
p.captureBaseline("v1");
p.reset();
for (let i = 0; i < 3; i++) p.recordLatency("a", 50);
const regressions = p.compare("v1");
expect(regressions.find((r) => r.metric === "p50")).toBeUndefined();
});
});
describe("PerformanceProfiler suggestions (NET_DEV-05)", () => {
it("flags tail-latency outliers", () => {
const p = freshProfiler();
for (let i = 0; i < 7; i++) p.recordLatency("hot", 50);
p.recordLatency("hot", 250); // p99
const sugg = p.suggestions();
const tail = sugg.find((s) => s.category === "latency");
expect(tail?.subject).toBe("hot");
});
it("flags overhead heavyweights", () => {
const p = freshProfiler();
p.recordPluginOverhead("p", 10);
p.recordPluginOverhead("p", 12);
const sugg = p.suggestions();
expect(sugg.find((s) => s.category === "overhead")?.subject).toBe("p");
});
it("flags memory pressure", () => {
const p = freshProfiler();
p.recordMemory(900, 1000);
const sugg = p.suggestions();
expect(sugg.find((s) => s.category === "memory")?.subject).toBe(
"process"
);
});
it("flags zero throughput", () => {
const p = freshProfiler();
p.recordThroughput("dead", 0, 0);
const sugg = p.suggestions();
expect(sugg.find((s) => s.category === "throughput")?.subject).toBe(
"dead"
);
});
it("emits no suggestions when everything is healthy", () => {
const p = freshProfiler();
for (let i = 0; i < 5; i++) p.recordLatency("ok", 50);
p.recordPluginOverhead("plug", 1);
p.recordMemory(100, 1000);
p.recordThroughput("ok", 1000, 50);
expect(p.suggestions()).toHaveLength(0);
});
it("collectSuggestions handles null memory and ignores empty maps", () => {
const sugg = collectSuggestions(new Map(), new Map(), new Map(), null);
expect(sugg).toHaveLength(0);
});
it("collectSuggestions skips overhead entries with empty arrays", () => {
const sugg = collectSuggestions(
new Map([["a", []]]),
new Map([["plug", []]]),
new Map(),
null
);
expect(sugg).toHaveLength(0);
});
it("collectSuggestions ignores zero heapTotalBytes", () => {
const sugg = collectSuggestions(new Map(), new Map(), new Map(), {
heapUsedBytes: 1,
heapTotalBytes: 0,
recordedAt: 0,
});
expect(sugg).toHaveLength(0);
});
});

View File

@@ -0,0 +1,117 @@
# `simulator/simulation-config-tool` — `SimulationConfigTool`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Programmatic builder for mesh simulation scenarios. NOT a UI — exposes
a deterministic API for adding/removing peers and links, defining
time-windowed network conditions (latency / loss / jitter / bandwidth /
partition windows), scheduling timeline events, recording an observed
event stream, replaying recordings as `PlaybackTick`s grouped by
timestamp, and round-tripping the entire scenario through a
JSON-schema-validated artifact for sharing across teams.
The tool intentionally has zero rendering or simulation logic — it is
the configuration surface that feeds into
`@suaveplan/network-plugin-simulation`'s simulation engine. Decoupling
the editor from the engine means scenario JSON can be authored,
validated, and version-controlled without spinning up an actual
simulation runtime.
## Features
- `addPeer` / `removePeer` / `listPeers` with Zod boundary; `SIMULATOR_PEER_DUPLICATE` on collision; cascading link removal on peer deletion.
- `addLink` validates endpoint existence and surfaces `SIMULATOR_PEER_NOT_FOUND` for dangling references.
- `setCondition` / `clearCondition` / `listConditions` with `endMs > startMs` Zod refine; sorted output.
- `addEvent` / `listEvents` for `peer-join`, `peer-leave`, `link-up`, `link-down`, `condition-apply`, `condition-clear` events; recording capture appends in parallel.
- `startRecording` / `stopRecording` produces a `ScenarioRecording` keyed by current scenario id and a clock-stamped `recordedAtMs`.
- `loadPlayback` enforces scenario-id match before replay; `playback()` groups events by `atMs` for tick-accurate replay.
- `exportJson` / `importJson` with full Zod validation; `JSON.parse` failures and schema failures both surface as `SIMULATOR_SCENARIO_INVALID`.
- Injectable `clock` and `idGenerator` for fully deterministic tests.
## Usage: Basic Example
```ts
import { SimulationConfigTool } from "@suaveplan/network-plugin-devtools";
const tool = new SimulationConfigTool();
tool.addPeer({ id: "host-1", tier: "tier-0", capacity: 100 });
tool.addPeer({ id: "leaf-1", tier: "tier-2", capacity: 10 });
tool.addLink({
from: "host-1",
to: "leaf-1",
latencyMs: 50,
jitterMs: 5,
lossRate: 0.01,
bandwidthKbps: 10_000,
});
const scenario = tool.toScenario();
```
## Usage: Advanced Example
```ts
import { SimulationConfigTool } from "@suaveplan/network-plugin-devtools";
const tool = new SimulationConfigTool({
clock: () => 1_700_000_000_000,
idGenerator: () => "scen-001",
});
tool.setMetadata({
name: "client-surge",
description: "100 leaves join in 5 seconds",
durationMs: 30_000,
});
tool.startRecording();
for (let i = 0; i < 100; i++) {
tool.addPeer({ id: `leaf-${i}`, tier: "tier-2", capacity: 10 });
tool.addEvent({
id: `e-${i}`,
atMs: i * 50,
kind: "peer-join",
target: `leaf-${i}`,
});
}
const recording = tool.stopRecording();
const replay = new SimulationConfigTool({ idGenerator: () => "scen-001" });
replay.loadPlayback(recording);
for (const tick of replay.playback()) {
console.log(`@${tick.atMs}ms`, tick.events.length, "events");
}
const json = tool.exportJson();
await Bun.write("./client-surge.json", json);
```
## API Reference
- `class SimulationConfigTool` — constructor `(options?)`. `options.clock` defaults to `Date.now`; `options.idGenerator` defaults to a base-36 random.
- Peer ops: `addPeer(SimulatedPeer)`, `removePeer(id)`, `listPeers()`. Throws `SIMULATOR_PEER_DUPLICATE` / `SIMULATOR_PEER_NOT_FOUND` / `SIMULATOR_INVALID_CONDITION`.
- Link ops: `addLink(SimulatedLink)`, `listLinks()`. Throws `SIMULATOR_PEER_NOT_FOUND` when endpoints missing.
- Condition ops: `setCondition(NetworkCondition)`, `clearCondition(id)`, `listConditions()`. Throws `SIMULATOR_INVALID_CONDITION` on schema failure or unknown id.
- Event ops: `addEvent(ScenarioEvent)`, `listEvents()`.
- Metadata: `setMetadata({ name?, description?, durationMs? })`. Bad duration → `SIMULATOR_INVALID_CONDITION`.
- Recording: `startRecording()`, `stopRecording(): ScenarioRecording`. Stop without start → `SIMULATOR_RECORDING_INACTIVE`.
- Playback: `loadPlayback(recording)`, `playback(): readonly PlaybackTick[]`. Mismatched scenario → `SIMULATOR_SCENARIO_INVALID`; playback before load → `SIMULATOR_PLAYBACK_INACTIVE`.
- Round-trip: `exportJson(): string`, `importJson(json): void`. JSON parse / schema fail → `SIMULATOR_SCENARIO_INVALID`.
- `toScenario(): SimulationScenario`, `scenarioIdValue()`, `newScenario()`.
## Implementation Notes
The tool keeps mutable internal collections but every public listing
method returns a defensive copy so consumers cannot mutate the internal
state by holding the reference. Validation happens at the API boundary,
not on every internal mutation, because the tool re-validates the full
scenario on `importJson` and trusting the post-Zod data internally
keeps the hot path cheap. Cascade-on-delete for `removePeer` is
intentional — leaving dangling links would force every consumer to
re-scan after deletion. The `loadPlayback` scenario-id guard prevents
the common foot-gun of replaying a recording against the wrong
scenario; consumers wanting to migrate a recording call `newScenario()`
followed by `importJson` and only then `loadPlayback`. The `validate`
helper accepts a structural `SafeParser` shape rather than a Zod schema
type so unit tests can substitute lightweight validators when stress
testing input fan-in.

View File

@@ -0,0 +1,62 @@
import z from "zod";
export const TIER_VALUES = ["tier-0", "tier-1", "tier-2"] as const;
export const PeerSchema = z.object({
id: z.string().min(1),
tier: z.enum(TIER_VALUES),
region: z.string().min(1).optional(),
capacity: z.number().int().nonnegative(),
});
export const LinkSchema = z.object({
from: z.string().min(1),
to: z.string().min(1),
latencyMs: z.number().nonnegative(),
jitterMs: z.number().nonnegative(),
lossRate: z.number().min(0).max(1),
bandwidthKbps: z.number().nonnegative(),
});
export const ConditionSchema = z
.object({
id: z.string().min(1),
startMs: z.number().int().nonnegative(),
endMs: z.number().int().nonnegative(),
latencyMs: z.number().nonnegative(),
jitterMs: z.number().nonnegative(),
lossRate: z.number().min(0).max(1),
bandwidthKbps: z.number().nonnegative(),
partitioned: z.array(z.string().min(1)).optional(),
})
.refine((c) => c.endMs > c.startMs, {
message: "endMs must be greater than startMs",
});
export const EventSchema = z.object({
id: z.string().min(1),
atMs: z.number().int().nonnegative(),
kind: z.enum([
"peer-join",
"peer-leave",
"link-up",
"link-down",
"condition-apply",
"condition-clear",
]),
target: z.string().min(1),
payload: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
.optional(),
});
export const ScenarioSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string(),
peers: z.array(PeerSchema),
links: z.array(LinkSchema),
conditions: z.array(ConditionSchema),
events: z.array(EventSchema),
durationMs: z.number().int().positive(),
});

View File

@@ -0,0 +1,310 @@
import { metrics } from "@opentelemetry/api";
import { NETWORK_DEV_CODES, NetworkDevError } from "../error-codes.js";
import type {
NetworkCondition,
PlaybackTick,
ScenarioEvent,
ScenarioRecording,
SimulatedLink,
SimulatedPeer,
SimulationConfigToolOptions,
SimulationScenario,
} from "../types/simulator.types.js";
import {
ConditionSchema,
EventSchema,
LinkSchema,
PeerSchema,
ScenarioSchema,
} from "./simulation-config-tool.schemas.js";
const meter = metrics.getMeter("network-plugin-devtools");
const eventCounter = meter.createCounter("devtools.simulator.event_total", {
description: "Scenario events recorded by the simulator config tool",
});
const DEFAULT_DURATION_MS = 60_000;
interface MetadataPatch {
readonly name?: string;
readonly description?: string;
readonly durationMs?: number;
}
/**
* Programmatic builder for simulation scenarios. NOT a UI — exposes a
* deterministic API for adding/removing peers, links, conditions, and
* timeline events; recording an observed event stream; replaying via
* `playback()`; and round-tripping through JSON for sharing.
*/
export class SimulationConfigTool {
private readonly clock: () => number;
private readonly idGenerator: () => string;
private readonly peers = new Map<string, SimulatedPeer>();
private readonly links: SimulatedLink[] = [];
private readonly conditions = new Map<string, NetworkCondition>();
private readonly events: ScenarioEvent[] = [];
private scenarioId: string;
private name = "untitled";
private description = "";
private durationMs = DEFAULT_DURATION_MS;
private recording: ScenarioEvent[] | null = null;
private playbackEvents: ScenarioEvent[] | null = null;
public constructor(options: SimulationConfigToolOptions = {}) {
this.clock = options.clock ?? (() => Date.now());
this.idGenerator =
options.idGenerator ??
(() => Math.random().toString(36).slice(2, 10));
this.scenarioId = this.idGenerator();
}
public addPeer(peer: SimulatedPeer): void {
validate(PeerSchema, peer, "simulator.peer.invalid");
if (this.peers.has(peer.id))
throw new NetworkDevError(
NETWORK_DEV_CODES.SIMULATOR_PEER_DUPLICATE,
"simulator.peer.duplicate",
{ context: { peerId: peer.id } }
);
this.peers.set(peer.id, peer);
}
public removePeer(peerId: string): void {
if (!this.peers.delete(peerId)) throw peerNotFound(peerId);
for (let i = this.links.length - 1; i >= 0; i--) {
const link = this.links[i] as SimulatedLink;
if (link.from === peerId || link.to === peerId)
this.links.splice(i, 1);
}
}
public listPeers(): ReadonlyArray<SimulatedPeer> {
return [...this.peers.values()];
}
public addLink(link: SimulatedLink): void {
validate(LinkSchema, link, "simulator.link.invalid");
if (!this.peers.has(link.from) || !this.peers.has(link.to))
throw new NetworkDevError(
NETWORK_DEV_CODES.SIMULATOR_PEER_NOT_FOUND,
"simulator.link.endpoint-missing",
{ context: { from: link.from, to: link.to } }
);
this.links.push(link);
}
public listLinks(): ReadonlyArray<SimulatedLink> {
return [...this.links];
}
public setCondition(condition: NetworkCondition): void {
validate(ConditionSchema, condition, "simulator.condition.invalid");
this.conditions.set(condition.id, condition);
}
public clearCondition(id: string): void {
if (!this.conditions.delete(id)) {
throw new NetworkDevError(
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION,
"simulator.condition.unknown",
{ context: { id } }
);
}
}
public listConditions(): ReadonlyArray<NetworkCondition> {
return [...this.conditions.values()].sort(
(a, b) => a.startMs - b.startMs
);
}
public addEvent(event: ScenarioEvent): void {
validate(EventSchema, event, "simulator.event.invalid");
this.events.push(event);
eventCounter.add(1, { kind: event.kind });
if (this.recording !== null) this.recording.push(event);
}
public listEvents(): ReadonlyArray<ScenarioEvent> {
return [...this.events].sort((a, b) => a.atMs - b.atMs);
}
public setMetadata(meta: MetadataPatch): void {
if (meta.name !== undefined) this.name = meta.name;
if (meta.description !== undefined) this.description = meta.description;
if (meta.durationMs === undefined) return;
if (!Number.isFinite(meta.durationMs) || meta.durationMs <= 0) {
throw new NetworkDevError(
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION,
"simulator.metadata.duration",
{ context: { durationMs: meta.durationMs } }
);
}
this.durationMs = meta.durationMs;
}
public startRecording(): void {
this.recording = [];
}
public stopRecording(): ScenarioRecording {
if (this.recording === null) {
throw new NetworkDevError(
NETWORK_DEV_CODES.SIMULATOR_RECORDING_INACTIVE,
"simulator.recording.inactive"
);
}
const recording: ScenarioRecording = {
scenarioId: this.scenarioId,
recordedAtMs: this.clock(),
events: [...this.recording],
};
this.recording = null;
return recording;
}
public loadPlayback(recording: ScenarioRecording): void {
if (recording.scenarioId !== this.scenarioId) {
throw new NetworkDevError(
NETWORK_DEV_CODES.SIMULATOR_SCENARIO_INVALID,
"simulator.playback.scenario-mismatch",
{
context: {
expected: this.scenarioId,
received: recording.scenarioId,
},
}
);
}
this.playbackEvents = [...recording.events].sort(
(a, b) => a.atMs - b.atMs
);
}
public playback(): ReadonlyArray<PlaybackTick> {
if (this.playbackEvents === null) {
throw new NetworkDevError(
NETWORK_DEV_CODES.SIMULATOR_PLAYBACK_INACTIVE,
"simulator.playback.inactive"
);
}
return groupTicks(this.playbackEvents);
}
public toScenario(): SimulationScenario {
return {
id: this.scenarioId,
name: this.name,
description: this.description,
peers: this.listPeers(),
links: this.listLinks(),
conditions: this.listConditions(),
events: this.listEvents(),
durationMs: this.durationMs,
};
}
public exportJson(): string {
return JSON.stringify(this.toScenario());
}
public importJson(json: string): void {
let raw: unknown;
try {
raw = JSON.parse(json);
} catch (err) {
// JSON.parse always throws a SyntaxError, which is an Error subtype.
throw new NetworkDevError(
NETWORK_DEV_CODES.SIMULATOR_SCENARIO_INVALID,
"simulator.scenario.parse-failed",
{ cause: err as Error }
);
}
const parsed = ScenarioSchema.safeParse(raw);
if (!parsed.success) {
throw new NetworkDevError(
NETWORK_DEV_CODES.SIMULATOR_SCENARIO_INVALID,
"simulator.scenario.invalid",
{ context: { issues: parsed.error.issues.length } }
);
}
this.applyScenario(parsed.data as SimulationScenario);
}
private applyScenario(scenario: SimulationScenario): void {
this.scenarioId = scenario.id;
this.name = scenario.name;
this.description = scenario.description;
this.durationMs = scenario.durationMs;
this.peers.clear();
this.links.length = 0;
this.conditions.clear();
this.events.length = 0;
for (const peer of scenario.peers) this.peers.set(peer.id, peer);
for (const link of scenario.links) this.links.push(link);
for (const c of scenario.conditions) this.conditions.set(c.id, c);
for (const event of scenario.events) this.events.push(event);
}
public scenarioIdValue(): string {
return this.scenarioId;
}
public newScenario(): void {
this.scenarioId = this.idGenerator();
this.peers.clear();
this.links.length = 0;
this.conditions.clear();
this.events.length = 0;
this.recording = null;
this.playbackEvents = null;
this.name = "untitled";
this.description = "";
this.durationMs = DEFAULT_DURATION_MS;
}
}
function groupTicks(
events: ReadonlyArray<ScenarioEvent>
): ReadonlyArray<PlaybackTick> {
const ticks = new Map<number, ScenarioEvent[]>();
for (const event of events) {
const slot = ticks.get(event.atMs) ?? [];
slot.push(event);
ticks.set(event.atMs, slot);
}
const out: PlaybackTick[] = [];
for (const [atMs, list] of [...ticks.entries()].sort(
(a, b) => a[0] - b[0]
)) {
out.push({ atMs, events: list });
}
return out;
}
type SafeParseResult =
| { success: true }
| { success: false; error: { issues: ReadonlyArray<unknown> } };
interface SafeParser {
safeParse(v: unknown): SafeParseResult;
}
function peerNotFound(peerId: string): NetworkDevError {
return new NetworkDevError(
NETWORK_DEV_CODES.SIMULATOR_PEER_NOT_FOUND,
"simulator.peer.unknown",
{ context: { peerId } }
);
}
function validate<T>(schema: SafeParser, value: T, messageKey: string): void {
const parsed = schema.safeParse(value);
if (parsed.success) return;
throw new NetworkDevError(
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION,
messageKey,
{ context: { issues: parsed.error.issues.length } }
);
}

View File

@@ -0,0 +1,439 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_DEV_CODES, type NetworkDevError } from "../error-codes.js";
import type {
NetworkCondition,
ScenarioRecording,
SimulatedLink,
SimulatedPeer,
} from "../types/simulator.types.js";
import { SimulationConfigTool } from "./simulation-config-tool.js";
const PEER: SimulatedPeer = { id: "p1", tier: "tier-0", capacity: 10 };
const PEER2: SimulatedPeer = { id: "p2", tier: "tier-2", capacity: 10 };
function freshTool(): SimulationConfigTool {
return new SimulationConfigTool({
clock: () => 1_700_000_000_000,
idGenerator: (() => {
let i = 0;
return () => `id-${i++}`;
})(),
});
}
describe("SimulationConfigTool defaults (NET_DEV-04)", () => {
it("default constructor uses Date.now and random ids", () => {
const t = new SimulationConfigTool();
expect(typeof t.scenarioIdValue()).toBe("string");
expect(t.scenarioIdValue().length).toBeGreaterThan(0);
// Trigger the default Date.now clock via stopRecording.
t.startRecording();
const rec = t.stopRecording();
expect(typeof rec.recordedAtMs).toBe("number");
expect(rec.recordedAtMs).toBeGreaterThan(0);
});
it("removePeer also removes links where peer is the destination", () => {
const t = freshTool();
t.addPeer(PEER);
t.addPeer(PEER2);
t.addLink({
from: "p1",
to: "p2",
latencyMs: 1,
jitterMs: 0,
lossRate: 0,
bandwidthKbps: 1,
});
t.removePeer("p2");
expect(t.listLinks()).toHaveLength(0);
});
it("removePeer leaves unrelated links untouched", () => {
const t = freshTool();
t.addPeer(PEER);
t.addPeer(PEER2);
t.addPeer({ id: "p3", tier: "tier-2", capacity: 1 });
t.addLink({
from: "p1",
to: "p2",
latencyMs: 1,
jitterMs: 0,
lossRate: 0,
bandwidthKbps: 1,
});
t.removePeer("p3");
expect(t.listLinks()).toHaveLength(1);
});
});
describe("SimulationConfigTool peers + links (NET_DEV-04)", () => {
it("addPeer / removePeer cascades link removal", () => {
const t = freshTool();
t.addPeer(PEER);
t.addPeer(PEER2);
t.addLink({
from: "p1",
to: "p2",
latencyMs: 10,
jitterMs: 0,
lossRate: 0,
bandwidthKbps: 1000,
});
expect(t.listLinks()).toHaveLength(1);
t.removePeer("p1");
expect(t.listLinks()).toHaveLength(0);
expect(t.listPeers()).toHaveLength(1);
});
it("duplicate addPeer rejected with SIMULATOR_PEER_DUPLICATE", () => {
const t = freshTool();
t.addPeer(PEER);
try {
t.addPeer(PEER);
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.SIMULATOR_PEER_DUPLICATE
);
}
});
it("removePeer on unknown id raises SIMULATOR_PEER_NOT_FOUND", () => {
const t = freshTool();
try {
t.removePeer("ghost");
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.SIMULATOR_PEER_NOT_FOUND
);
}
});
it("addLink with unknown endpoint raises SIMULATOR_PEER_NOT_FOUND", () => {
const t = freshTool();
t.addPeer(PEER);
const link: SimulatedLink = {
from: "p1",
to: "ghost",
latencyMs: 5,
jitterMs: 1,
lossRate: 0,
bandwidthKbps: 100,
};
try {
t.addLink(link);
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.SIMULATOR_PEER_NOT_FOUND
);
}
});
it("addPeer Zod failure raises SIMULATOR_INVALID_CONDITION", () => {
const t = freshTool();
try {
t.addPeer({
id: "",
tier: "tier-0",
capacity: -1,
} as unknown as SimulatedPeer);
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION
);
}
});
it("addLink Zod failure raises SIMULATOR_INVALID_CONDITION", () => {
const t = freshTool();
try {
t.addLink({
from: "p1",
to: "p2",
latencyMs: -1,
jitterMs: 0,
lossRate: 0,
bandwidthKbps: 0,
});
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION
);
}
});
});
describe("SimulationConfigTool conditions + events (NET_DEV-04)", () => {
it("setCondition + listConditions sorted by startMs", () => {
const t = freshTool();
const c1: NetworkCondition = {
id: "c1",
startMs: 1000,
endMs: 2000,
latencyMs: 0,
jitterMs: 0,
lossRate: 0,
bandwidthKbps: 100,
};
const c2: NetworkCondition = {
...c1,
id: "c2",
startMs: 100,
endMs: 200,
};
t.setCondition(c1);
t.setCondition(c2);
expect(t.listConditions().map((c) => c.id)).toStrictEqual(["c2", "c1"]);
});
it("setCondition Zod refine: endMs > startMs", () => {
const t = freshTool();
try {
t.setCondition({
id: "c",
startMs: 100,
endMs: 50,
latencyMs: 0,
jitterMs: 0,
lossRate: 0,
bandwidthKbps: 0,
});
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION
);
}
});
it("clearCondition unknown id raises SIMULATOR_INVALID_CONDITION", () => {
const t = freshTool();
try {
t.clearCondition("ghost");
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION
);
}
});
it("clearCondition succeeds for known id", () => {
const t = freshTool();
t.setCondition({
id: "c",
startMs: 0,
endMs: 1,
latencyMs: 0,
jitterMs: 0,
lossRate: 0,
bandwidthKbps: 0,
});
t.clearCondition("c");
expect(t.listConditions()).toHaveLength(0);
});
it("addEvent Zod failure raises SIMULATOR_INVALID_CONDITION", () => {
const t = freshTool();
try {
t.addEvent({
id: "",
atMs: 0,
kind: "peer-join",
target: "p",
});
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION
);
}
});
it("listEvents sorted by atMs", () => {
const t = freshTool();
t.addEvent({ id: "e1", atMs: 200, kind: "peer-join", target: "p1" });
t.addEvent({ id: "e2", atMs: 100, kind: "peer-join", target: "p2" });
expect(t.listEvents().map((e) => e.id)).toStrictEqual(["e2", "e1"]);
});
});
describe("SimulationConfigTool metadata + recording + playback (NET_DEV-04)", () => {
it("setMetadata mutates fields and rejects bad duration", () => {
const t = freshTool();
t.setMetadata({ name: "n", description: "d", durationMs: 1000 });
expect(t.toScenario().name).toBe("n");
try {
t.setMetadata({ durationMs: 0 });
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION
);
}
try {
t.setMetadata({ durationMs: Number.NaN });
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION
);
}
});
it("setMetadata with no duration leaves it untouched", () => {
const t = freshTool();
const before = t.toScenario().durationMs;
t.setMetadata({ name: "x" });
expect(t.toScenario().durationMs).toBe(before);
});
it("stopRecording without start raises SIMULATOR_RECORDING_INACTIVE", () => {
const t = freshTool();
try {
t.stopRecording();
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.SIMULATOR_RECORDING_INACTIVE
);
}
});
it("playback without load raises SIMULATOR_PLAYBACK_INACTIVE", () => {
const t = freshTool();
try {
t.playback();
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.SIMULATOR_PLAYBACK_INACTIVE
);
}
});
it("loadPlayback with mismatched scenario id raises SIMULATOR_SCENARIO_INVALID", () => {
const t = freshTool();
const recording: ScenarioRecording = {
scenarioId: "other",
recordedAtMs: 0,
events: [],
};
try {
t.loadPlayback(recording);
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.SIMULATOR_SCENARIO_INVALID
);
}
});
it("recording captures events; playback groups by atMs", () => {
const t = freshTool();
const id = t.scenarioIdValue();
t.startRecording();
t.addEvent({ id: "e1", atMs: 100, kind: "peer-join", target: "p1" });
t.addEvent({ id: "e2", atMs: 100, kind: "peer-join", target: "p2" });
t.addEvent({ id: "e3", atMs: 100, kind: "peer-join", target: "p3" });
t.addEvent({ id: "e4", atMs: 200, kind: "peer-leave", target: "p1" });
const rec = t.stopRecording();
expect(rec.scenarioId).toBe(id);
t.loadPlayback(rec);
const ticks = t.playback();
expect(ticks).toHaveLength(2);
expect(ticks[0]?.atMs).toBe(100);
expect(ticks[0]?.events).toHaveLength(3);
expect(ticks[1]?.events).toHaveLength(1);
});
});
describe("SimulationConfigTool JSON round-trip (NET_DEV-04)", () => {
it("export then import preserves scenario state", () => {
const t = freshTool();
t.addPeer(PEER);
t.addPeer(PEER2);
t.addLink({
from: "p1",
to: "p2",
latencyMs: 10,
jitterMs: 1,
lossRate: 0.01,
bandwidthKbps: 1000,
});
t.setCondition({
id: "c",
startMs: 0,
endMs: 1000,
latencyMs: 50,
jitterMs: 5,
lossRate: 0.05,
bandwidthKbps: 500,
partitioned: ["p2"],
});
t.addEvent({ id: "e1", atMs: 100, kind: "peer-leave", target: "p2" });
t.setMetadata({
name: "test",
description: "d",
durationMs: 5000,
});
const json = t.exportJson();
const t2 = freshTool();
t2.importJson(json);
expect(t2.listPeers()).toHaveLength(2);
expect(t2.listLinks()).toHaveLength(1);
expect(t2.listConditions()).toHaveLength(1);
expect(t2.listEvents()).toHaveLength(1);
expect(t2.toScenario().name).toBe("test");
expect(t2.scenarioIdValue()).toBe(t.scenarioIdValue());
});
it("importJson rejects malformed JSON with SIMULATOR_SCENARIO_INVALID", () => {
const t = freshTool();
try {
t.importJson("{ not json");
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.SIMULATOR_SCENARIO_INVALID
);
}
});
it("importJson rejects schema-invalid data with SIMULATOR_SCENARIO_INVALID", () => {
const t = freshTool();
try {
t.importJson(JSON.stringify({ id: "", name: "x" }));
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.SIMULATOR_SCENARIO_INVALID
);
}
});
it("newScenario resets every collection", () => {
const t = freshTool();
t.addPeer(PEER);
t.startRecording();
const before = t.scenarioIdValue();
t.newScenario();
expect(t.scenarioIdValue()).not.toBe(before);
expect(t.listPeers()).toHaveLength(0);
// recording was wiped — stop now must throw
try {
t.stopRecording();
expect.fail("should throw");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.SIMULATOR_RECORDING_INACTIVE
);
}
});
});

View File

@@ -0,0 +1,73 @@
# `types/inspector.types` — inspector type contracts
## Purpose
Shared type definitions for the `StateInspector` subsystem. Producers
(state-coordination plugins, tests, snapshot recorders) materialise
`StateSnapshot` objects against this contract; consumers (UIs, log
shippers, replay tools) consume `StateView` and `DiffView` projections
without importing the implementation file.
## Features
- `StateSnapshot` carrying `path` / `value` / `version` / `timestamp` / `peerId` / optional `sessionId`.
- `StateView` returned from `inspect(path)` — current snapshot plus full ring.
- `InspectorConfig` with optional `historyLimit` (defaults to 256).
- `DiffView` carrying JSON-Pointer arrays for added / removed / changed plus a `DiffSummary` count badge.
- `SearchFilter` AND-combining `pathContains`, `peerId`, `sessionId`, `minVersion`, `maxVersion`.
## Usage: Basic Example
```ts
import type { StateSnapshot } from "@suaveplan/network-plugin-devtools";
function snapshotOf(path: string, value: unknown, version: number): StateSnapshot {
return {
path,
value: value as StateSnapshot["value"],
version,
timestamp: Date.now(),
peerId: "host-1",
};
}
```
## Usage: Advanced Example
```ts
import type { DiffView, SearchFilter, StateView } from "@suaveplan/network-plugin-devtools";
function badge(view: DiffView): string {
const { addedCount, removedCount, changedCount } = view.summary;
return `+${addedCount} -${removedCount} ~${changedCount}`;
}
const filter: SearchFilter = {
pathContains: "session/",
peerId: "host-eu-1",
minVersion: 100,
maxVersion: 200,
};
```
## API Reference
- `interface StateSnapshot { path, value (JsonValue), version, timestamp, peerId, sessionId? }`.
- `interface StateView { path, current: StateSnapshot, history: readonly StateSnapshot[] }`.
- `interface InspectorConfig { historyLimit? }`.
- `interface DiffView { path, fromVersion, toVersion, added, removed, changed, summary }`.
- `interface DiffSummary { addedCount, removedCount, changedCount }`.
- `interface SearchFilter { pathContains?, peerId?, sessionId?, minVersion?, maxVersion? }`.
## Implementation Notes
`value` is typed as `JsonValue` (re-exported from `@suaveplan/diff`) so
diff and patch operations have a stable boundary. Using `JsonValue`
rather than `unknown` means the inspector cannot accidentally store
non-serialisable values (functions, symbols, class instances) — those
would silently break the diff path. `version` is a number rather than
a vector clock because the inspector is per-path; cross-path causality
is the responsibility of the upstream state plugin. The optional
`sessionId` field is omitted (not `undefined`) to satisfy
`exactOptionalPropertyTypes`; consumers building snapshots conditionally
should spread it only when populated.

View File

@@ -0,0 +1,63 @@
import type { JsonValue } from "@suaveplan/diff";
/**
* Snapshot of a synchronised state path at one logical instant, keyed by
* a vector-clock-style monotonic version. The inspector keeps a ring
* buffer of these per path.
*/
export interface StateSnapshot {
readonly path: string;
readonly value: JsonValue;
readonly version: number;
readonly timestamp: number;
readonly peerId: string;
readonly sessionId?: string;
}
/**
* View materialised by `StateInspector.inspect(path)` — current value
* plus full known history (oldest → newest).
*/
export interface StateView {
readonly path: string;
readonly current: StateSnapshot;
readonly history: ReadonlyArray<StateSnapshot>;
}
export interface InspectorConfig {
/** Per-path ring-buffer cap. Defaults to 256 if omitted. */
readonly historyLimit?: number;
}
/**
* Diff result between two snapshot versions of the same path. Op-level
* deltas come straight from `@suaveplan/diff` (RFC-6902 patches);
* `summary` is a fast count for UI badges.
*/
export interface DiffView {
readonly path: string;
readonly fromVersion: number;
readonly toVersion: number;
readonly added: ReadonlyArray<string>;
readonly removed: ReadonlyArray<string>;
readonly changed: ReadonlyArray<string>;
readonly summary: DiffSummary;
}
export interface DiffSummary {
readonly addedCount: number;
readonly removedCount: number;
readonly changedCount: number;
}
/**
* Search filter for `StateInspector.search`. All fields are optional;
* AND-combined when populated.
*/
export interface SearchFilter {
readonly pathContains?: string;
readonly peerId?: string;
readonly sessionId?: string;
readonly minVersion?: number;
readonly maxVersion?: number;
}

View File

@@ -0,0 +1,80 @@
# `types/profiler.types` — profiler type contracts
## Purpose
Shared type definitions for the `PerformanceProfiler` subsystem.
Producers (instrumented call sites, telemetry pipelines, tests)
materialise sample shapes against these contracts; consumers (report
viewers, regression dashboards, suggestion renderers) read
`PerformanceReport`, `Baseline`, `Regression`, and
`OptimizationSuggestion` projections without importing the
implementation file.
## Features
- Four sample shapes: `LatencySample`, `ThroughputSample`, `PluginOverheadSample`, `MemorySample`.
- `LatencyDistribution` — count + min / max / mean + p50 / p95 / p99.
- `PerformanceReport` — typed read-only `Map`s of every aggregate.
- `Baseline` — frozen capture for later `compare(baselineId)` calls.
- `Regression` + `RegressionSeverity` — discriminated by metric (`p50` / `p95` / `p99` / `throughput`) and severity ladder (`info` / `warn` / `error`).
- `OptimizationSuggestion` with category enum (`latency` / `throughput` / `overhead` / `memory`).
- `ProfilerConfig` injectable thresholds + clock.
## Usage: Basic Example
```ts
import type { LatencyDistribution } from "@suaveplan/network-plugin-devtools";
function summary(d: LatencyDistribution): string {
return `n=${d.count} p50=${d.p50.toFixed(1)} p99=${d.p99.toFixed(1)}`;
}
```
## Usage: Advanced Example
```ts
import type {
Baseline,
PerformanceReport,
Regression,
} from "@suaveplan/network-plugin-devtools";
function report(b: Baseline, current: PerformanceReport): string {
const aged = (Date.now() - b.capturedAt) / 60_000;
return `baseline ${b.id} (age ${aged.toFixed(1)} min)\n` +
`${current.latencyByPath.size} paths tracked`;
}
function severityBadge(r: Regression): string {
return `[${r.severity}] ${r.metric} on ${r.path}: +${r.deltaPct.toFixed(1)}%`;
}
```
## API Reference
- `interface LatencySample { path, latencyMs, recordedAt }`.
- `interface ThroughputSample { path, bytesPerSec, messagesPerSec, recordedAt }`.
- `interface PluginOverheadSample { pluginName, cpuMs, recordedAt }`.
- `interface MemorySample { heapUsedBytes, heapTotalBytes, recordedAt }`.
- `interface LatencyDistribution { count, p50, p95, p99, min, max, mean }`.
- `interface PerformanceReport { latencyByPath, throughputByPath, pluginOverhead, memory, generatedAt }`.
- `interface Baseline { id, capturedAt, report }`.
- `type RegressionSeverity = "info" | "warn" | "error"`.
- `interface Regression { path, metric, baseline, current, deltaPct, zScore, severity }`.
- `interface OptimizationSuggestion { subject, category, message, evidence }`.
- `interface ProfilerConfig { windowSize?, regressionZScore?, regressionWarnPct?, clock? }`.
## Implementation Notes
`LatencyDistribution` is intentionally flat (no nested `percentiles`
sub-object) so a downstream metrics adapter can map every field to a
gauge without re-shaping. The choice of p50 / p95 / p99 mirrors the
Prometheus / OpenTelemetry industry standard; emitting p99.9 / max-only
distributions would force a wider sample buffer and fewer engineers
intuit those numbers reliably. `Regression.severity` is a closed
three-value union — adding a level requires an OpenSpec change because
downstream alert wiring commits to it. `OptimizationSuggestion.subject`
is intentionally a free string (not a path or peer id) because
suggestion subjects span paths, plugin names, and synthetic categories
like `"process"` for memory pressure; downstream renderers should
treat it as a display label, not a navigable identifier.

View File

@@ -0,0 +1,85 @@
/**
* Single latency observation in milliseconds tagged with the path the
* message took (transport id or peer pair). Stored in a per-path ring.
*/
export interface LatencySample {
readonly path: string;
readonly latencyMs: number;
readonly recordedAt: number;
}
export interface ThroughputSample {
readonly path: string;
readonly bytesPerSec: number;
readonly messagesPerSec: number;
readonly recordedAt: number;
}
export interface PluginOverheadSample {
readonly pluginName: string;
readonly cpuMs: number;
readonly recordedAt: number;
}
export interface MemorySample {
readonly heapUsedBytes: number;
readonly heapTotalBytes: number;
readonly recordedAt: number;
}
export interface LatencyDistribution {
readonly count: number;
readonly p50: number;
readonly p95: number;
readonly p99: number;
readonly min: number;
readonly max: number;
readonly mean: number;
}
export interface PerformanceReport {
readonly latencyByPath: ReadonlyMap<string, LatencyDistribution>;
readonly throughputByPath: ReadonlyMap<string, ThroughputSample>;
readonly pluginOverhead: ReadonlyMap<string, number>;
readonly memory: MemorySample | null;
readonly generatedAt: number;
}
/**
* Frozen baseline captured by `profiler.captureBaseline(id)`. Used by
* `profiler.compare(baselineId)` to detect regressions.
*/
export interface Baseline {
readonly id: string;
readonly capturedAt: number;
readonly report: PerformanceReport;
}
export type RegressionSeverity = "info" | "warn" | "error";
export interface Regression {
readonly path: string;
readonly metric: "p50" | "p95" | "p99" | "throughput";
readonly baseline: number;
readonly current: number;
readonly deltaPct: number;
readonly zScore: number;
readonly severity: RegressionSeverity;
}
export interface OptimizationSuggestion {
readonly subject: string;
readonly category: "latency" | "throughput" | "overhead" | "memory";
readonly message: string;
readonly evidence: string;
}
export interface ProfilerConfig {
/** Per-path ring-buffer cap for latency samples. */
readonly windowSize?: number;
/** Z-score threshold above which a regression escalates to `error`. */
readonly regressionZScore?: number;
/** Percent delta threshold for warn-level regressions. */
readonly regressionWarnPct?: number;
readonly clock?: () => number;
}

View File

@@ -0,0 +1,98 @@
# `types/simulator.types` — simulator type contracts
## Purpose
Shared type definitions for the `SimulationConfigTool` subsystem.
Producers (scenario authors, recording capture, JSON importers) and
consumers (the simulation engine downstream, scenario serialisers, UI
editors) both reference these contracts. Keeping the types separate
from the implementation file means consumers can author scenarios
purely against the types without instantiating the tool.
## Features
- `SimulatorTier` mirrors mesh tiers (`tier-0` / `tier-1` / `tier-2`).
- `SimulatedPeer` — id / tier / optional region / capacity.
- `SimulatedLink` — endpoints + latency / jitter / loss / bandwidth.
- `NetworkCondition` — time-windowed `[startMs, endMs]` shape with optional `partitioned` peer ids.
- `ScenarioEvent` + `ScenarioEventKind` — six kinds covering peer/link lifecycle and condition application.
- `SimulationScenario` — the top-level artifact serialised by `exportJson`.
- `ScenarioRecording` / `PlaybackTick` — capture + replay shapes.
- `SimulationConfigToolOptions` — injectable `clock` and `idGenerator`.
## Usage: Basic Example
```ts
import type { SimulatedPeer } from "@suaveplan/network-plugin-devtools";
const peer: SimulatedPeer = {
id: "leaf-1",
tier: "tier-2",
capacity: 10,
};
```
## Usage: Advanced Example
```ts
import type {
NetworkCondition,
ScenarioEvent,
SimulationScenario,
} from "@suaveplan/network-plugin-devtools";
const condition: NetworkCondition = {
id: "spike",
startMs: 5_000,
endMs: 10_000,
latencyMs: 200,
jitterMs: 30,
lossRate: 0.2,
bandwidthKbps: 500,
partitioned: ["leaf-7"],
};
const event: ScenarioEvent = {
id: "ev-1",
atMs: 5_000,
kind: "condition-apply",
target: "spike",
payload: { reason: "rolling-restart" },
};
const scenario: SimulationScenario = {
id: "client-surge",
name: "Client surge",
description: "100 leaves join in 5s",
peers: [],
links: [],
conditions: [condition],
events: [event],
durationMs: 30_000,
};
```
## API Reference
- `type SimulatorTier = "tier-0" | "tier-1" | "tier-2"`.
- `interface SimulatedPeer { id, tier, region?, capacity }`.
- `interface SimulatedLink { from, to, latencyMs, jitterMs, lossRate, bandwidthKbps }`.
- `interface NetworkCondition { id, startMs, endMs, latencyMs, jitterMs, lossRate, bandwidthKbps, partitioned? }`.
- `type ScenarioEventKind = "peer-join" | "peer-leave" | "link-up" | "link-down" | "condition-apply" | "condition-clear"`.
- `interface ScenarioEvent { id, atMs, kind, target, payload? }`.
- `interface SimulationScenario { id, name, description, peers, links, conditions, events, durationMs }`.
- `interface ScenarioRecording { scenarioId, recordedAtMs, events }`.
- `interface PlaybackTick { atMs, events }`.
- `interface SimulationConfigToolOptions { clock?, idGenerator? }`.
## Implementation Notes
The `payload` field on `ScenarioEvent` is intentionally a
`Record<string, string | number | boolean>` rather than `JsonValue` —
JSON-import safety is much easier to validate against scalar payloads,
and engineers editing scenarios by hand rarely need nested structures.
If a future engine needs richer payloads, a versioned schema upgrade
in `simulation-config-tool.schemas.ts` is the migration path. Tier
strings duplicate `VisualizerTier` rather than aliasing because
scenarios round-trip through JSON and a single source of literal
strings keeps the schema robust against renames in either domain.

View File

@@ -0,0 +1,85 @@
/**
* Tier label assigned to each simulated peer. Mirrors the canonical
* mesh tiers so scenarios are interchangeable with `network-plugin-simulation`.
*/
export type SimulatorTier = "tier-0" | "tier-1" | "tier-2";
export interface SimulatedPeer {
readonly id: string;
readonly tier: SimulatorTier;
readonly region?: string;
readonly capacity: number;
}
export interface SimulatedLink {
readonly from: string;
readonly to: string;
readonly latencyMs: number;
readonly jitterMs: number;
readonly lossRate: number;
readonly bandwidthKbps: number;
}
/**
* Single editable network condition. Conditions are time-indexed via a
* `windowMs` range so a scenario can express "loss climbs to 20% between
* 10s and 30s, then recovers" deterministically.
*/
export interface NetworkCondition {
readonly id: string;
readonly startMs: number;
readonly endMs: number;
readonly latencyMs: number;
readonly jitterMs: number;
readonly lossRate: number;
readonly bandwidthKbps: number;
readonly partitioned?: ReadonlyArray<string>;
}
export type ScenarioEventKind =
| "peer-join"
| "peer-leave"
| "link-up"
| "link-down"
| "condition-apply"
| "condition-clear";
export interface ScenarioEvent {
readonly id: string;
readonly atMs: number;
readonly kind: ScenarioEventKind;
readonly target: string;
readonly payload?: Readonly<Record<string, string | number | boolean>>;
}
export interface SimulationScenario {
readonly id: string;
readonly name: string;
readonly description: string;
readonly peers: ReadonlyArray<SimulatedPeer>;
readonly links: ReadonlyArray<SimulatedLink>;
readonly conditions: ReadonlyArray<NetworkCondition>;
readonly events: ReadonlyArray<ScenarioEvent>;
readonly durationMs: number;
}
/**
* Recording captured by the simulator's recorder. A recording is a
* scenario plus the observed event stream so it can be replayed
* deterministically against the same simulation engine.
*/
export interface ScenarioRecording {
readonly scenarioId: string;
readonly recordedAtMs: number;
readonly events: ReadonlyArray<ScenarioEvent>;
}
export interface PlaybackTick {
readonly atMs: number;
readonly events: ReadonlyArray<ScenarioEvent>;
}
export interface SimulationConfigToolOptions {
readonly clock?: () => number;
readonly idGenerator?: () => string;
}

View File

@@ -0,0 +1,100 @@
# `types/visualizer.types` — visualizer type contracts
## Purpose
Shared type definitions for the `NetworkVisualizer` subsystem and every
consumer that produces or consumes a `VisualizerScene` IR. Centralising
the contracts here means renderers, topology adapters, and stream
adapters can be implemented independently without circular references
into the implementation file. The types are runtime-free and tree-shake
to nothing in consumer bundles.
## Features
- `VisualizerMode` — `"hierarchy" | "flow" | "heat"` literal union.
- `VisualizerTier` — `"tier-0" | "tier-1" | "tier-2"` mirrors the canonical mesh tiers without taking a runtime dep on `network-plugin-topology`.
- `NodeLayout` / `EdgeLayout` / `HeatCell` — primitive scene atoms.
- `VisualizerFilter` — composable tier / latency-group / session / transport filters.
- `VisualizerConfig` — `start({ mode, refreshRate, filter?, focus? })` input.
- `TopologyAdapter` / `MessageStreamAdapter` — caller-supplied data sources.
- `VisualizerScene` — the IR consumed by every `Renderer`.
- `Renderer` / `RendererOutput` — pluggable byte-emitter contract.
## Usage: Basic Example
```ts
import type { VisualizerScene } from "@suaveplan/network-plugin-devtools";
function describe(scene: VisualizerScene): string {
return `${scene.mode}: ${scene.nodes.length} nodes, ${scene.edges.length} edges`;
}
```
## Usage: Advanced Example
```ts
import type {
MessageStreamAdapter,
Renderer,
TopologyAdapter,
} from "@suaveplan/network-plugin-devtools";
class TopologyBridge implements TopologyAdapter {
constructor(private readonly index: TopologyIndex) {}
listNodes() {
return this.index.nodes.map((n) => ({
id: n.id,
tier: n.tier,
x: n.coords.x,
y: n.coords.y,
label: n.label,
}));
}
listEdges() {
return this.index.edges;
}
nodeById(id: string) {
return this.listNodes().find((n) => n.id === id);
}
}
const renderer: Renderer = {
render(scene) {
// produce caller-specific bytes (PNG, canvas, terminal, …)
return {
format: "png",
bytes: encode(scene),
mimeType: "image/png",
};
},
};
```
## API Reference
- `type VisualizerMode = "hierarchy" | "flow" | "heat"`.
- `type VisualizerTier = "tier-0" | "tier-1" | "tier-2"`.
- `interface NodeLayout { id, tier, x, y, label }`.
- `interface EdgeLayout { from, to, latencyMs, throughput, drops }`.
- `interface HeatCell { id, intensity, latencyMs, saturation }`.
- `interface VisualizerFilter { tiers?, latencyGroups?, sessions?, transports? }`.
- `interface VisualizerConfig { mode, refreshRate, filter?, focus? }`.
- `interface TopologyAdapter { listNodes(), listEdges(), nodeById(id) }`.
- `interface MessageStreamAdapter { listFlows(), listHeat() }`.
- `type VisualizerExportFormat = "svg" | "png"`.
- `interface VisualizerScene { mode, nodes, edges, heat, width, height, focus? }`.
- `interface RendererOutput { format, bytes, mimeType }`.
- `interface Renderer { render(scene): RendererOutput }`.
## Implementation Notes
The IR shape is the package's wire contract — every public renderer,
test harness, snapshot generator, and downstream consumer reads it.
Adding a field is backwards-compatible (consumers ignore unknown
fields); removing or renaming a field is a major-version change. The
intensity field on `HeatCell` is normalised 0..1 by convention but the
SVG renderer clamps defensively, so producers that emit out-of-range
values still render. Tier strings are mirrored from `network-plugin-topology`
rather than re-imported because the visualizer should be usable in
package boundaries that haven't installed the topology plugin (e.g. a
pure-test consumer composing a fake topology by hand).

View File

@@ -0,0 +1,109 @@
/**
* Modes the `NetworkVisualizer` can render. Each mode produces a
* different intermediate representation — `hierarchy` shows the
* tier-tree, `flow` shows live message paths, `heat` shows latency /
* saturation as a colour-mapped grid.
*/
export type VisualizerMode = "hierarchy" | "flow" | "heat";
/**
* Tier label propagated from `network-plugin-topology`. Mirrored locally
* so the visualizer doesn't take a hard runtime dep on the topology
* plugin — consumers map their own tier representation onto these
* canonical labels.
*/
export type VisualizerTier = "tier-0" | "tier-1" | "tier-2";
export interface NodeLayout {
readonly id: string;
readonly tier: VisualizerTier;
readonly x: number;
readonly y: number;
readonly label: string;
}
export interface EdgeLayout {
readonly from: string;
readonly to: string;
/** EWMA latency in ms over the rolling sample window. */
readonly latencyMs: number;
/** Throughput in messages per second. */
readonly throughput: number;
/** Number of dropped frames in the same window. */
readonly drops: number;
}
export interface HeatCell {
readonly id: string;
/** 0..1 normalised intensity used to derive the colour. */
readonly intensity: number;
readonly latencyMs: number;
readonly saturation: number;
}
/**
* Filter applied at render time. Empty arrays mean "no restriction" —
* the visualizer applies AND semantics across populated fields and OR
* semantics within each field.
*/
export interface VisualizerFilter {
readonly tiers?: ReadonlyArray<VisualizerTier>;
readonly latencyGroups?: ReadonlyArray<string>;
readonly sessions?: ReadonlyArray<string>;
readonly transports?: ReadonlyArray<string>;
}
export interface VisualizerConfig {
readonly mode: VisualizerMode;
/** Frames per second the consumer plans to repaint at. */
readonly refreshRate: number;
readonly filter?: VisualizerFilter;
/** Optional focused peer id; non-matching nodes are dimmed. */
readonly focus?: string;
}
/**
* Source of truth for the network the visualizer is rendering. Supplied
* by the consumer (typically built on top of `network-plugin-topology`).
*/
export interface TopologyAdapter {
listNodes(): ReadonlyArray<NodeLayout>;
listEdges(): ReadonlyArray<EdgeLayout>;
nodeById(id: string): NodeLayout | undefined;
}
/**
* Live message-flow source. Supplied by consumer; usually wraps a
* `network-plugin-monitor` collector.
*/
export interface MessageStreamAdapter {
listFlows(): ReadonlyArray<EdgeLayout>;
listHeat(): ReadonlyArray<HeatCell>;
}
export type VisualizerExportFormat = "svg" | "png";
/**
* Intermediate representation produced by the visualizer. The bundled
* SVG renderer consumes it, but consumers may plug their own renderer
* (canvas, terminal, WebGL) and consume the same IR.
*/
export interface VisualizerScene {
readonly mode: VisualizerMode;
readonly nodes: ReadonlyArray<NodeLayout>;
readonly edges: ReadonlyArray<EdgeLayout>;
readonly heat: ReadonlyArray<HeatCell>;
readonly width: number;
readonly height: number;
readonly focus?: string;
}
export interface RendererOutput {
readonly format: VisualizerExportFormat;
readonly bytes: Uint8Array;
readonly mimeType: string;
}
export interface Renderer {
render(scene: VisualizerScene): RendererOutput;
}

View File

@@ -0,0 +1,82 @@
# `visualizer/network-visualizer` — `NetworkVisualizer`
## Purpose
Renders the live mesh as a `VisualizerScene` intermediate
representation (IR) and delegates byte production to an injected
`Renderer`. The IR captures three orthogonal views — the tier hierarchy,
the live message-flow graph, and a per-node heat map of latency /
saturation — and ships nodes, edges, and heat cells as plain data so any
front-end (terminal, SVG, canvas, WebGL, Storybook) can consume the same
output. The bundled `SvgRenderer` covers the common documentation /
bug-report use case without pulling a heavyweight image library.
## Features
- Three modes: `hierarchy`, `flow`, `heat` — `start({ mode, refreshRate })` selects.
- Filtering by tier, latency group, session, transport (Zod-validated).
- Focus tracking — `focus(peerId)` / `clearFocus()`; non-existing peers throw `INSPECTOR_PATH_NOT_FOUND`.
- `export(format)` — runs the injected renderer, validates `format` round-trip, surfaces failures as `VISUALIZER_RENDERER_FAILED`.
- OpenTelemetry counters: `devtools.visualizer.render_total{mode}`, `devtools.visualizer.export_total{format}`.
- Pluggable renderer via the `Renderer` interface — defaults to bundled SVG.
## Usage: Basic Example
```ts
import { NetworkVisualizer } from "@suaveplan/network-plugin-devtools";
const viz = new NetworkVisualizer({ topology, stream });
viz.start({ mode: "hierarchy", refreshRate: 30 });
const scene = viz.render();
console.log(scene.nodes.length, scene.edges.length);
```
## Usage: Advanced Example
```ts
import {
NetworkVisualizer,
type Renderer,
type VisualizerScene,
} from "@suaveplan/network-plugin-devtools";
const pngRenderer: Renderer = {
render(scene: VisualizerScene) {
const bytes = encodePng(scene);
return { format: "png", bytes, mimeType: "image/png" };
},
};
const viz = new NetworkVisualizer({
topology,
stream,
renderer: pngRenderer,
width: 1200,
height: 900,
});
viz.start({
mode: "heat",
refreshRate: 15,
filter: { tiers: ["tier-0", "tier-1"], sessions: ["lobby"] },
});
viz.focus("host-eu-1");
const out = viz.export("png");
await fs.writeFile("mesh.png", out.bytes);
```
## API Reference
- `class NetworkVisualizer` — constructor `(config: NetworkVisualizerConfig)`. `config.topology` and `config.stream` required; `config.renderer` defaults to bundled SVG; `config.width`/`config.height` default to 800×600.
- `start(config: VisualizerConfig): void` — Zod-validates; throws `VISUALIZER_INVALID_MODE`.
- `stop(): void` — clears active config.
- `focus(peerId: string): void` — throws `INSPECTOR_PATH_NOT_FOUND` if unknown.
- `clearFocus(): void` — resets focus.
- `mode(): VisualizerMode` — current mode (throws `VISUALIZER_NOT_STARTED` if stopped).
- `isRunning(): boolean`.
- `render(): VisualizerScene` — emits IR; throws `VISUALIZER_EMPTY_TOPOLOGY` when filter eliminates all nodes.
- `export(format: 'svg' | 'png'): RendererOutput` — runs renderer; throws `VISUALIZER_RENDERER_FAILED` on renderer failure or format mismatch.
- `ALLOWED_VISUALIZER_MODES` — exported readonly tuple.
## Implementation Notes
The IR is the contract — keeping the `VisualizerScene` shape stable means rendering technology can change without breaking consumers. PNG support is delivered via dependency injection rather than a hard `sharp`/`canvas` dep so universal consumers (Bun, browser, deno) can pick the encoder appropriate to their runtime. The `applyNodeFilter` step short-circuits when no `filter` is supplied so the hot rendering loop avoids per-node Set lookups in the common case. The `clearFocus()` method intentionally rebuilds the config object so `exactOptionalPropertyTypes` keeps the `focus` field absent rather than `undefined` — TypeScript's strictest mode treats those distinctly. The `INSPECTOR_PATH_NOT_FOUND` reuse for an unknown focus peer is deliberate: a peer id is a path-like identifier, and the inspector / visualizer share that vocabulary.

View File

@@ -0,0 +1,227 @@
import { metrics } from "@opentelemetry/api";
import z from "zod";
import { NETWORK_DEV_CODES, NetworkDevError } from "../error-codes.js";
import type {
EdgeLayout,
HeatCell,
MessageStreamAdapter,
NodeLayout,
Renderer,
RendererOutput,
TopologyAdapter,
VisualizerConfig,
VisualizerExportFormat,
VisualizerFilter,
VisualizerMode,
VisualizerScene,
VisualizerTier,
} from "../types/visualizer.types.js";
import { renderSvg } from "./svg-renderer.js";
const meter = metrics.getMeter("network-plugin-devtools");
const renderCounter = meter.createCounter("devtools.visualizer.render_total", {
description: "Number of scenes produced by NetworkVisualizer.render()",
});
const exportCounter = meter.createCounter("devtools.visualizer.export_total", {
description: "Number of bytes-export operations by format",
});
const ALLOWED_MODES: ReadonlyArray<VisualizerMode> = [
"hierarchy",
"flow",
"heat",
];
const VisualizerConfigSchema = z.object({
mode: z.enum(["hierarchy", "flow", "heat"]),
refreshRate: z.number().int().positive().max(240),
filter: z
.object({
tiers: z.array(z.enum(["tier-0", "tier-1", "tier-2"])).optional(),
latencyGroups: z.array(z.string().min(1)).optional(),
sessions: z.array(z.string().min(1)).optional(),
transports: z.array(z.string().min(1)).optional(),
})
.strict()
.optional(),
focus: z.string().min(1).optional(),
});
export interface NetworkVisualizerConfig {
readonly topology: TopologyAdapter;
readonly stream: MessageStreamAdapter;
/** Defaults to bundled SVG renderer if omitted. */
readonly renderer?: Renderer;
readonly width?: number;
readonly height?: number;
}
const DEFAULT_WIDTH = 800;
const DEFAULT_HEIGHT = 600;
/**
* Mesh visualisation engine. Builds a `VisualizerScene` IR per
* `render()` call and delegates pixel/byte production to an injected
* `Renderer`. A bundled `SvgRenderer` covers the common case; PNG
* support is delivered by injecting an encoder-aware renderer.
*/
export class NetworkVisualizer {
private readonly topology: TopologyAdapter;
private readonly stream: MessageStreamAdapter;
private readonly renderer: Renderer;
private readonly width: number;
private readonly height: number;
private current: VisualizerConfig | null = null;
public constructor(config: NetworkVisualizerConfig) {
this.topology = config.topology;
this.stream = config.stream;
this.renderer = config.renderer ?? {
render: (scene) => renderSvg(scene),
};
this.width = config.width ?? DEFAULT_WIDTH;
this.height = config.height ?? DEFAULT_HEIGHT;
}
public start(config: VisualizerConfig): void {
const parsed = VisualizerConfigSchema.safeParse(config);
if (!parsed.success) {
throw new NetworkDevError(
NETWORK_DEV_CODES.VISUALIZER_INVALID_MODE,
"visualizer.invalid-config",
{ context: { issues: parsed.error.issues.length } }
);
}
this.current = parsed.data as VisualizerConfig;
}
public stop(): void {
this.current = null;
}
public focus(peerId: string): void {
const cfg = this.requireRunning();
if (this.topology.nodeById(peerId) === undefined) {
throw new NetworkDevError(
NETWORK_DEV_CODES.INSPECTOR_PATH_NOT_FOUND,
"visualizer.focus.unknown-peer",
{ context: { peerId } }
);
}
this.current = { ...cfg, focus: peerId };
}
public clearFocus(): void {
const cfg = this.requireRunning();
const next: VisualizerConfig = {
mode: cfg.mode,
refreshRate: cfg.refreshRate,
...(cfg.filter === undefined ? {} : { filter: cfg.filter }),
};
this.current = next;
}
public mode(): VisualizerMode {
return this.requireRunning().mode;
}
public isRunning(): boolean {
return this.current !== null;
}
public render(): VisualizerScene {
const cfg = this.requireRunning();
const nodes = this.applyNodeFilter(
this.topology.listNodes(),
cfg.filter
);
if (nodes.length === 0) {
throw new NetworkDevError(
NETWORK_DEV_CODES.VISUALIZER_EMPTY_TOPOLOGY,
"visualizer.empty"
);
}
const edges = this.collectEdges(cfg.mode);
const heat = cfg.mode === "heat" ? this.stream.listHeat() : [];
renderCounter.add(1, { mode: cfg.mode });
const scene: VisualizerScene = {
mode: cfg.mode,
nodes,
edges,
heat,
width: this.width,
height: this.height,
...(cfg.focus === undefined ? {} : { focus: cfg.focus }),
};
return scene;
}
public export(format: VisualizerExportFormat): RendererOutput {
if (format !== "svg" && format !== "png") {
throw new NetworkDevError(
NETWORK_DEV_CODES.VISUALIZER_INVALID_MODE,
"visualizer.export.unknown-format",
{ context: { format } }
);
}
const scene = this.render();
let output: RendererOutput;
try {
output = this.renderer.render(scene);
} catch (err) {
throw new NetworkDevError(
NETWORK_DEV_CODES.VISUALIZER_RENDERER_FAILED,
"visualizer.renderer.failed",
{ cause: err instanceof Error ? err : undefined }
);
}
if (output.format !== format) {
throw new NetworkDevError(
NETWORK_DEV_CODES.VISUALIZER_RENDERER_FAILED,
"visualizer.renderer.format-mismatch",
{
context: {
requested: format,
produced: output.format,
},
}
);
}
exportCounter.add(1, { format });
return output;
}
private collectEdges(mode: VisualizerMode): ReadonlyArray<EdgeLayout> {
if (mode === "flow") return this.stream.listFlows();
if (mode === "heat") return [];
return this.topology.listEdges();
}
private applyNodeFilter(
nodes: ReadonlyArray<NodeLayout>,
filter?: VisualizerFilter
): ReadonlyArray<NodeLayout> {
if (filter === undefined) return nodes;
const tierSet =
filter.tiers === undefined
? null
: new Set<VisualizerTier>(filter.tiers);
return nodes.filter((node) => {
if (tierSet !== null && !tierSet.has(node.tier)) return false;
return true;
});
}
private requireRunning(): VisualizerConfig {
if (this.current === null) {
throw new NetworkDevError(
NETWORK_DEV_CODES.VISUALIZER_NOT_STARTED,
"visualizer.not-started"
);
}
return this.current;
}
}
export const ALLOWED_VISUALIZER_MODES = ALLOWED_MODES;
export type { HeatCell };

View File

@@ -0,0 +1,82 @@
# `visualizer/svg-renderer` — bundled SVG `Renderer`
## Purpose
Pure-string SVG renderer that consumes a `VisualizerScene` and produces
deterministic UTF-8 SVG bytes without touching the DOM, canvas, or any
third-party encoding library. Used as the default renderer for
`NetworkVisualizer`, the SVG output is suitable for documentation,
PR attachments, regression test snapshots, and embedding in
Storybook / MDX docs.
The renderer is intentionally tiny and dep-free so universal
consumers (Bun, Deno, Node, browser) can bundle it without surprise. PNG
output is delivered by injecting an encoder-aware renderer instead of
shipping `sharp`/`canvas` here.
## Features
- `renderSvg(scene)` — pure function from `VisualizerScene` to `RendererOutput`.
- `SvgRenderer` — `Renderer` adapter object suitable for direct injection.
- Tier-coloured nodes (`tier-0` blue, `tier-1` green, `tier-2` orange).
- Edge stroke-width scales with throughput; dropped frames flip stroke red.
- Heat cells map a deterministic FNV-1a hash of the cell id to a 16×12 grid so the layout is stable across renders.
- XML escaping on every string field so peer ids containing `<` / `&` cannot inject into the output.
- Focus-aware opacity dimming on non-focused nodes.
## Usage: Basic Example
```ts
import { renderSvg } from "@suaveplan/network-plugin-devtools";
const out = renderSvg({
mode: "hierarchy",
nodes: [{ id: "a", tier: "tier-0", x: 100, y: 100, label: "Host" }],
edges: [],
heat: [],
width: 400,
height: 300,
});
console.log(new TextDecoder().decode(out.bytes));
```
## Usage: Advanced Example
```ts
import {
NetworkVisualizer,
SvgRenderer,
} from "@suaveplan/network-plugin-devtools";
const viz = new NetworkVisualizer({
topology,
stream,
renderer: SvgRenderer,
width: 1024,
height: 768,
});
viz.start({ mode: "flow", refreshRate: 10, focus: "host-1" });
const svgBytes = viz.export("svg").bytes;
await Bun.write("./flow.svg", svgBytes);
```
## API Reference
- `renderSvg(scene: VisualizerScene): RendererOutput` — pure; never throws.
- `SvgRenderer: Renderer` — adapter object; `SvgRenderer.render === renderSvg` call form.
`RendererOutput` carries `format: "svg"`, `bytes: Uint8Array`, and `mimeType: "image/svg+xml"`.
## Implementation Notes
The renderer is deliberately deterministic — every numeric position is
rendered via `toFixed(2)` so two semantically-identical scenes produce
byte-identical SVG. This makes snapshot tests and visual diffs reliable.
Heat-cell placement uses an FNV-1a hash mod a 16×12 grid; the grid size
is hard-coded because the IR doesn't carry layout dimensions for cells
(those belong to the heat layer, not the topology). Edges are skipped
silently when either endpoint is missing from the scene's node list,
which mirrors the visualizer's filter semantics — a tier-filtered scene
must not crash because a partner node was dropped. XML escaping is
applied to every consumer-supplied string (peer ids, labels) to prevent
SVG-injection from a malicious or fuzz-generated topology.

View File

@@ -0,0 +1,114 @@
import type {
EdgeLayout,
HeatCell,
NodeLayout,
Renderer,
RendererOutput,
VisualizerScene,
} from "../types/visualizer.types.js";
const TIER_COLOURS: Record<string, string> = {
"tier-0": "#1f77b4",
"tier-1": "#2ca02c",
"tier-2": "#ff7f0e",
};
/**
* Pure-string SVG renderer — produces a deterministic byte stream from
* a `VisualizerScene` without any DOM, canvas, or third-party encoder
* dep. Output is UTF-8 SVG suitable for documentation, PR attachments,
* or test snapshots.
*/
export function renderSvg(scene: VisualizerScene): RendererOutput {
const fragments: string[] = [];
fragments.push(svgHeader(scene.width, scene.height));
if (scene.mode === "heat") {
for (const cell of scene.heat) fragments.push(renderHeatCell(cell));
} else {
for (const edge of scene.edges) fragments.push(renderEdge(edge, scene));
}
for (const node of scene.nodes)
fragments.push(renderNode(node, scene.focus));
fragments.push("</svg>");
const body = fragments.join("");
const bytes = new TextEncoder().encode(body);
return { format: "svg", bytes, mimeType: "image/svg+xml" };
}
function svgHeader(width: number, height: number): string {
return [
`<svg xmlns="http://www.w3.org/2000/svg" `,
`viewBox="0 0 ${width} ${height}" `,
`width="${width}" height="${height}">`,
"<style>.label{font:12px sans-serif;fill:#222}</style>",
].join("");
}
function renderNode(node: NodeLayout, focus: string | undefined): string {
const colour = TIER_COLOURS[node.tier] ?? "#888";
const opacity = focus === undefined || focus === node.id ? "1" : "0.35";
return [
`<g opacity="${opacity}">`,
`<circle cx="${fmt(node.x)}" cy="${fmt(node.y)}" r="14" `,
`fill="${colour}" stroke="#222" stroke-width="1"/>`,
`<text class="label" x="${fmt(node.x + 18)}" y="${fmt(node.y + 4)}">`,
escapeXml(node.label),
"</text></g>",
].join("");
}
function renderEdge(edge: EdgeLayout, scene: VisualizerScene): string {
const from = scene.nodes.find((n) => n.id === edge.from);
const to = scene.nodes.find((n) => n.id === edge.to);
if (from === undefined || to === undefined) return "";
const stroke = edge.drops > 0 ? "#d62728" : "#888";
const width = Math.max(1, Math.min(6, edge.throughput / 50));
return [
`<line x1="${fmt(from.x)}" y1="${fmt(from.y)}" `,
`x2="${fmt(to.x)}" y2="${fmt(to.y)}" `,
`stroke="${stroke}" stroke-width="${width.toFixed(2)}"/>`,
].join("");
}
function renderHeatCell(cell: HeatCell): string {
const intensity = Math.min(1, Math.max(0, cell.intensity));
const r = Math.round(255 * intensity);
const b = Math.round(255 * (1 - intensity));
return [
`<rect data-id="${escapeXml(cell.id)}" `,
`x="${cellX(cell)}" y="${cellY(cell)}" width="40" height="40" `,
`fill="rgb(${r},80,${b})" opacity="0.85"/>`,
].join("");
}
function cellX(cell: HeatCell): string {
return fmt((hashString(cell.id) % 16) * 50 + 10);
}
function cellY(cell: HeatCell): string {
return fmt((Math.floor(hashString(cell.id) / 16) % 12) * 50 + 10);
}
function hashString(s: string): number {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = (h * 16777619) >>> 0;
}
return h;
}
function fmt(n: number): string {
return Number.isFinite(n) ? n.toFixed(2) : "0";
}
function escapeXml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
export const SvgRenderer: Renderer = { render: renderSvg };

View File

@@ -0,0 +1,486 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_DEV_CODES, NetworkDevError } from "../error-codes.js";
import type {
EdgeLayout,
HeatCell,
MessageStreamAdapter,
NodeLayout,
Renderer,
RendererOutput,
TopologyAdapter,
VisualizerScene,
} from "../types/visualizer.types.js";
import { NetworkVisualizer } from "./network-visualizer.js";
import { renderSvg, SvgRenderer } from "./svg-renderer.js";
const NODES: NodeLayout[] = [
{ id: "host-1", tier: "tier-0", x: 100, y: 100, label: "Host" },
{ id: "leaf-1", tier: "tier-2", x: 200, y: 200, label: "Leaf" },
{ id: "leaf-2", tier: "tier-2", x: 250, y: 200, label: "Leaf<2>" },
];
const HIER_EDGES: EdgeLayout[] = [
{ from: "host-1", to: "leaf-1", latencyMs: 10, throughput: 100, drops: 0 },
];
const FLOW_EDGES: EdgeLayout[] = [
{ from: "host-1", to: "leaf-1", latencyMs: 12, throughput: 200, drops: 0 },
{ from: "host-1", to: "leaf-2", latencyMs: 15, throughput: 50, drops: 1 },
{ from: "leaf-1", to: "leaf-2", latencyMs: 8, throughput: 30, drops: 0 },
];
const HEAT: HeatCell[] = [
{ id: "host-1", intensity: 0.9, latencyMs: 12, saturation: 0.8 },
{ id: "leaf-1", intensity: 0.5, latencyMs: 30, saturation: 0.4 },
{ id: "ghost-id", intensity: 1.5, latencyMs: 1, saturation: 0 }, // out-of-range intensity
];
function makeTopo(
nodes: NodeLayout[] = NODES,
edges: EdgeLayout[] = HIER_EDGES
): TopologyAdapter {
return {
listNodes: () => nodes,
listEdges: () => edges,
nodeById: (id) => nodes.find((n) => n.id === id),
};
}
function makeStream(): MessageStreamAdapter {
return { listFlows: () => FLOW_EDGES, listHeat: () => HEAT };
}
describe("NetworkVisualizer (NET_DEV-01)", () => {
it("rejects render before start", () => {
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
});
expect(() => v.render()).toThrowError(NetworkDevError);
expect(v.isRunning()).toBe(false);
});
it("rejects mode() before start", () => {
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
});
expect(() => v.mode()).toThrowError(NetworkDevError);
});
it("renders hierarchy edges in hierarchy mode", () => {
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
});
v.start({ mode: "hierarchy", refreshRate: 30 });
const scene = v.render();
expect(scene.mode).toBe("hierarchy");
expect(scene.edges.length).toBe(1);
expect(scene.heat.length).toBe(0);
expect(v.mode()).toBe("hierarchy");
expect(v.isRunning()).toBe(true);
});
it("flow mode reads from the stream adapter", () => {
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
});
v.start({ mode: "flow", refreshRate: 30 });
expect(v.render().edges.length).toBe(FLOW_EDGES.length);
});
it("heat mode includes heat cells and no edges", () => {
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
});
v.start({ mode: "heat", refreshRate: 30 });
const scene = v.render();
expect(scene.heat.length).toBe(HEAT.length);
expect(scene.edges.length).toBe(0);
});
it("Zod rejects invalid config with VISUALIZER_INVALID_MODE", () => {
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
});
try {
v.start({ mode: "wrong" as unknown as "flow", refreshRate: 30 });
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(NetworkDevError);
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.VISUALIZER_INVALID_MODE
);
}
});
it("filters by tier", () => {
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
});
v.start({
mode: "hierarchy",
refreshRate: 30,
filter: { tiers: ["tier-0"] },
});
const scene = v.render();
expect(scene.nodes.every((n) => n.tier === "tier-0")).toBe(true);
});
it("VISUALIZER_EMPTY_TOPOLOGY when filter eliminates every node", () => {
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
});
v.start({
mode: "hierarchy",
refreshRate: 30,
filter: { tiers: ["tier-1"] },
});
try {
v.render();
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.VISUALIZER_EMPTY_TOPOLOGY
);
}
});
it("focus + clearFocus round-trip", () => {
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
});
v.start({ mode: "hierarchy", refreshRate: 30 });
v.focus("host-1");
expect(v.render().focus).toBe("host-1");
v.clearFocus();
expect(v.render().focus).toBeUndefined();
});
it("clearFocus preserves a populated filter", () => {
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
});
v.start({
mode: "hierarchy",
refreshRate: 30,
filter: { tiers: ["tier-0", "tier-2"] },
focus: "host-1",
});
v.clearFocus();
const scene = v.render();
expect(scene.focus).toBeUndefined();
expect(scene.nodes.every((n) => n.tier !== "tier-1")).toBe(true);
});
it("filter without tiers field returns every node", () => {
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
});
v.start({
mode: "hierarchy",
refreshRate: 30,
filter: { sessions: ["lobby"] },
});
const scene = v.render();
expect(scene.nodes.length).toBe(NODES.length);
});
it("focus on unknown peer raises INSPECTOR_PATH_NOT_FOUND", () => {
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
});
v.start({ mode: "hierarchy", refreshRate: 30 });
try {
v.focus("ghost");
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.INSPECTOR_PATH_NOT_FOUND
);
}
});
it("stop() clears running state", () => {
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
});
v.start({ mode: "hierarchy", refreshRate: 30 });
v.stop();
expect(v.isRunning()).toBe(false);
expect(() => v.render()).toThrowError(NetworkDevError);
});
});
describe("NetworkVisualizer.export (NET_DEV-02)", () => {
it("uses the bundled SVG renderer by default", () => {
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
});
v.start({ mode: "hierarchy", refreshRate: 30 });
const out = v.export("svg");
expect(out.format).toBe("svg");
expect(out.mimeType).toBe("image/svg+xml");
expect(new TextDecoder().decode(out.bytes)).toContain("<svg");
});
it("rejects unknown format", () => {
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
});
v.start({ mode: "hierarchy", refreshRate: 30 });
try {
v.export("jpg" as unknown as "svg");
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.VISUALIZER_INVALID_MODE
);
}
});
it("propagates renderer failure as VISUALIZER_RENDERER_FAILED with cause", () => {
const failing: Renderer = {
render(): RendererOutput {
throw new Error("oops");
},
};
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
renderer: failing,
});
v.start({ mode: "hierarchy", refreshRate: 30 });
try {
v.export("svg");
expect.fail("should have thrown");
} catch (err) {
const e = err as NetworkDevError;
expect(e.code).toBe(NETWORK_DEV_CODES.VISUALIZER_RENDERER_FAILED);
expect((e.cause as Error | undefined)?.message).toBe("oops");
}
});
it("renderer that throws non-Error still surfaces VISUALIZER_RENDERER_FAILED", () => {
const failing: Renderer = {
render(): RendererOutput {
const value: unknown = "string-throw";
throw value;
},
};
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
renderer: failing,
});
v.start({ mode: "hierarchy", refreshRate: 30 });
try {
v.export("svg");
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.VISUALIZER_RENDERER_FAILED
);
expect((err as NetworkDevError).cause).toBeUndefined();
}
});
it("rejects renderer whose output format mismatches", () => {
const lying: Renderer = {
render(): RendererOutput {
return {
format: "png",
bytes: new Uint8Array([1, 2]),
mimeType: "image/png",
};
},
};
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
renderer: lying,
});
v.start({ mode: "hierarchy", refreshRate: 30 });
try {
v.export("svg");
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkDevError).code).toBe(
NETWORK_DEV_CODES.VISUALIZER_RENDERER_FAILED
);
}
});
it("custom width/height plumbed through scene", () => {
const v = new NetworkVisualizer({
topology: makeTopo(),
stream: makeStream(),
width: 1024,
height: 768,
});
v.start({ mode: "hierarchy", refreshRate: 30 });
const scene = v.render();
expect(scene.width).toBe(1024);
expect(scene.height).toBe(768);
});
});
describe("SvgRenderer (NET_DEV-02)", () => {
it("renders hierarchy mode with nodes + edges", () => {
const scene: VisualizerScene = {
mode: "hierarchy",
nodes: NODES,
edges: HIER_EDGES,
heat: [],
width: 400,
height: 300,
focus: "host-1",
};
const out = renderSvg(scene);
const text = new TextDecoder().decode(out.bytes);
expect(text).toContain("<svg");
expect(text).toContain('opacity="1"');
expect(text).toContain('opacity="0.35"');
expect(text).toContain('stroke="#888"');
expect(text).toContain("Leaf&lt;2&gt;");
});
it("renders flow mode with red stroke for drops", () => {
const scene: VisualizerScene = {
mode: "flow",
nodes: NODES,
edges: FLOW_EDGES,
heat: [],
width: 400,
height: 300,
};
const text = new TextDecoder().decode(renderSvg(scene).bytes);
expect(text).toContain('stroke="#d62728"');
});
it("skips edges with missing endpoint", () => {
const scene: VisualizerScene = {
mode: "flow",
nodes: NODES,
edges: [
{
from: "host-1",
to: "ghost",
latencyMs: 1,
throughput: 10,
drops: 0,
},
{
from: "ghost",
to: "leaf-1",
latencyMs: 1,
throughput: 10,
drops: 0,
},
],
heat: [],
width: 400,
height: 300,
};
const text = new TextDecoder().decode(renderSvg(scene).bytes);
expect(text).not.toContain("ghost");
});
it("renders heat mode with rect cells and clamps intensity", () => {
const scene: VisualizerScene = {
mode: "heat",
nodes: NODES,
edges: [],
heat: [
{
id: "host-1",
intensity: 0.5,
latencyMs: 12,
saturation: 0.8,
},
{
id: "host-2",
intensity: 1.5,
latencyMs: 12,
saturation: 0.8,
},
{
id: "host-3",
intensity: -0.5,
latencyMs: 12,
saturation: 0.8,
},
],
width: 400,
height: 300,
};
const text = new TextDecoder().decode(renderSvg(scene).bytes);
expect(text).toContain("<rect");
// FNV positions are deterministic across runs:
expect(text).toContain('data-id="host-1"');
});
it("handles non-finite coordinate as 0", () => {
const scene: VisualizerScene = {
mode: "hierarchy",
nodes: [
{
id: "n",
tier: "tier-0",
x: Number.POSITIVE_INFINITY,
y: 100,
label: "x",
},
],
edges: [],
heat: [],
width: 200,
height: 200,
};
const text = new TextDecoder().decode(renderSvg(scene).bytes);
expect(text).toContain('cx="0"');
});
it("colours unknown tiers grey", () => {
const scene: VisualizerScene = {
mode: "hierarchy",
nodes: [
{
id: "n",
tier: "tier-9" as unknown as "tier-0",
x: 10,
y: 10,
label: "x",
},
],
edges: [],
heat: [],
width: 200,
height: 200,
};
const text = new TextDecoder().decode(renderSvg(scene).bytes);
expect(text).toContain('fill="#888"');
});
it("SvgRenderer adapter object satisfies the Renderer contract", () => {
const scene: VisualizerScene = {
mode: "hierarchy",
nodes: NODES,
edges: HIER_EDGES,
heat: [],
width: 400,
height: 300,
};
expect(SvgRenderer.render(scene).format).toBe("svg");
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"],
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,40 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/network-plugin-devtools",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/diff",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/network",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});