From 13c72b91166e0e5f651b5817c1b70ca064021e54 Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Mon, 27 Jul 2026 08:36:58 +0300 Subject: [PATCH] feat(cockpit): add browser session client --- web/cockpit/deno.json | 8 + web/cockpit/package.json | 20 + .../src/session/decoders/decoders.test.ts | 54 + web/cockpit/src/session/decoders/index.ts | 29 + web/cockpit/src/session/decoders/json.ts | 30 + web/cockpit/src/session/hooks.ts | 16 + .../src/session/protocol_fixtures.test.ts | 120 +++ web/cockpit/src/session/session.test.ts | 323 ++++++ web/cockpit/src/session/session.ts | 267 +++++ web/cockpit/src/session/store.test.ts | 284 +++++ web/cockpit/src/session/store.ts | 334 ++++++ web/cockpit/src/session/transport.test.ts | 336 ++++++ web/cockpit/src/session/transport.ts | 238 +++++ web/cockpit/tsconfig.json | 28 + web/cockpit/vite.config.ts | 38 + web/deno.json | 15 +- web/deno.lock | 990 +++++++++++++++++- 17 files changed, 3124 insertions(+), 6 deletions(-) create mode 100644 web/cockpit/deno.json create mode 100644 web/cockpit/package.json create mode 100644 web/cockpit/src/session/decoders/decoders.test.ts create mode 100644 web/cockpit/src/session/decoders/index.ts create mode 100644 web/cockpit/src/session/decoders/json.ts create mode 100644 web/cockpit/src/session/hooks.ts create mode 100644 web/cockpit/src/session/protocol_fixtures.test.ts create mode 100644 web/cockpit/src/session/session.test.ts create mode 100644 web/cockpit/src/session/session.ts create mode 100644 web/cockpit/src/session/store.test.ts create mode 100644 web/cockpit/src/session/store.ts create mode 100644 web/cockpit/src/session/transport.test.ts create mode 100644 web/cockpit/src/session/transport.ts create mode 100644 web/cockpit/tsconfig.json create mode 100644 web/cockpit/vite.config.ts diff --git a/web/cockpit/deno.json b/web/cockpit/deno.json new file mode 100644 index 0000000000..49389bf4bf --- /dev/null +++ b/web/cockpit/deno.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "dev": "deno run -A npm:vite", + "build": "deno run -A npm:vite build", + "test": "deno run -A npm:vitest run", + "check": "deno run -A npm:typescript/tsc --noEmit" + } +} diff --git a/web/cockpit/package.json b/web/cockpit/package.json new file mode 100644 index 0000000000..fe1cf67ea7 --- /dev/null +++ b/web/cockpit/package.json @@ -0,0 +1,20 @@ +{ + "name": "@dimos/cockpit", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.1.0", + "happy-dom": "^20.11.1", + "typescript": "~5.9.2", + "vite": "^7.1.0", + "vitest": "^3.2.4" + } +} diff --git a/web/cockpit/src/session/decoders/decoders.test.ts b/web/cockpit/src/session/decoders/decoders.test.ts new file mode 100644 index 0000000000..8284c03967 --- /dev/null +++ b/web/cockpit/src/session/decoders/decoders.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import type { FrameHeader } from "@dimos/shared"; +import { getDecoder, registerDecoder } from "./index.ts"; +import { JSON_PREVIEW_MAX_CHARS, MAX_JSON_PAYLOAD_BYTES } from "./json.ts"; + +const HEADER: FrameHeader = { ch: "x", seq: 1, ts: 0, delivery: "latest" }; + +describe("decoder registry", () => { + it("resolves any *.json.vN encoding to the JSON decoder", () => { + const decode = getDecoder("pose.json.v1"); + expect(decode).toBeDefined(); + const payload = new TextEncoder().encode('{"x": 1.5, "yaw": -0.25}'); + expect(decode!(payload, HEADER)).toEqual({ + value: { x: 1.5, yaw: -0.25 }, + preview: '{"x": 1.5, "yaw": -0.25}', + }); + expect(getDecoder("future.json.v7")).toBeDefined(); + }); + + it("returns undefined for unknown encodings (unsupported, not an error)", () => { + expect(getDecoder("jpeg.v1")).toBeUndefined(); + expect(getDecoder("costmap.zlib.v1")).toBeUndefined(); + expect(getDecoder(undefined)).toBeUndefined(); + }); + + it("prefers an exact registration over the JSON fallback", () => { + registerDecoder("special.json.v1", () => ({ value: "exact" })); + expect(getDecoder("special.json.v1")!(new Uint8Array(), HEADER).value).toBe("exact"); + }); + + it("throws on invalid UTF-8 so the caller can count a decode error", () => { + const decode = getDecoder("pose.json.v1")!; + expect(() => decode(new Uint8Array([0xff, 0xfe, 0x22]), HEADER)).toThrow(); + }); + + it("reports oversized json instead of parsing it", () => { + const decode = getDecoder("pose.json.v1")!; + // 0x31 = "1": would be valid JSON, but must never reach the parser. + const payload = new Uint8Array(MAX_JSON_PAYLOAD_BYTES + 1).fill(0x31); + const { value, preview } = decode(payload, HEADER); + expect(value).toBeUndefined(); + expect(preview).toContain("oversized"); + expect(preview!.length).toBeLessThan(200); + }); + + it("bounds the preview of large-but-valid json", () => { + const decode = getDecoder("pose.json.v1")!; + const long = JSON.stringify({ data: "x".repeat(10_000) }); + const { value, preview } = decode(new TextEncoder().encode(long), HEADER); + expect(value).toEqual({ data: "x".repeat(10_000) }); + expect(preview).toContain("truncated"); + expect(preview!.length).toBeLessThan(JSON_PREVIEW_MAX_CHARS + 50); + }); +}); diff --git a/web/cockpit/src/session/decoders/index.ts b/web/cockpit/src/session/decoders/index.ts new file mode 100644 index 0000000000..a0de315f67 --- /dev/null +++ b/web/cockpit/src/session/decoders/index.ts @@ -0,0 +1,29 @@ +// Payload decoder registry, keyed by the manifest's encoding id. An encoding +// without a decoder is not an error: the channel renders as "unsupported" +// (forward compatibility with newer bridges). Binary decoders (jpeg.v1, +// costmap.zlib.v1, ...) arrive with their panels from T4 on. + +import type { FrameHeader } from "@dimos/shared"; +import { jsonDecoder } from "./json.ts"; + +export interface Decoded { + value: unknown; + /** Bounded text form of `value` for the raw channel UI; binary decoders leave it unset. */ + preview?: string; +} + +export type Decoder = (payload: Uint8Array, header: FrameHeader) => Decoded; + +const registry = new Map(); + +export function registerDecoder(encoding: string, decoder: Decoder): void { + registry.set(encoding, decoder); +} + +export function getDecoder(encoding: string | undefined): Decoder | undefined { + if (encoding === undefined) return undefined; + const exact = registry.get(encoding); + if (exact !== undefined) return exact; + if (/\.json\.v\d+$/.test(encoding)) return jsonDecoder; + return undefined; +} diff --git a/web/cockpit/src/session/decoders/json.ts b/web/cockpit/src/session/decoders/json.ts new file mode 100644 index 0000000000..98900a9958 --- /dev/null +++ b/web/cockpit/src/session/decoders/json.ts @@ -0,0 +1,30 @@ +import type { FrameHeader } from "@dimos/shared"; +import type { Decoded } from "./index.ts"; + +// fatal: corrupted bytes must fail decode, not U+FFFD their way onto screen. +const utf8 = new TextDecoder("utf-8", { fatal: true }); + +// Parsing and previewing run on the main thread: a payload above this cap is +// reported instead of parsed, so a huge but valid frame cannot freeze the tab +// (the outer MAX_DATA_FRAME_BYTES is 64 MiB). Reference scale: the Python +// viewer caps pose.json.v1 at 64 KiB. +export const MAX_JSON_PAYLOAD_BYTES = 256 * 1024; + +// Longest preview the raw-value UI ever mounts in the DOM. +export const JSON_PREVIEW_MAX_CHARS = 2048; + +/** Decoder for every `*.json.vN` encoding (pose.json.v1 and friends). */ +export function jsonDecoder(payload: Uint8Array, _header: FrameHeader): Decoded { + if (payload.byteLength > MAX_JSON_PAYLOAD_BYTES) { + return { + value: undefined, + preview: `(oversized json payload: ${payload.byteLength} B, cap ${MAX_JSON_PAYLOAD_BYTES} B)`, + }; + } + const text = utf8.decode(payload); + const value: unknown = JSON.parse(text); + const preview = text.length > JSON_PREVIEW_MAX_CHARS + ? `${text.slice(0, JSON_PREVIEW_MAX_CHARS)} ... (truncated, ${text.length} chars)` + : text; + return { value, preview }; +} diff --git a/web/cockpit/src/session/hooks.ts b/web/cockpit/src/session/hooks.ts new file mode 100644 index 0000000000..5f8d0609f5 --- /dev/null +++ b/web/cockpit/src/session/hooks.ts @@ -0,0 +1,16 @@ +// The React-facing edge of the session layer (the only React import under +// session/). Both hooks ride useSyncExternalStore; channel snapshots only +// change on the store's UI tick, so channel rate never sets render rate. + +import { useCallback, useSyncExternalStore } from "react"; +import type { ChannelSnapshot, ChannelStore, SessionStatus, StatusStore } from "./store.ts"; + +export function useStatus(store: StatusStore): SessionStatus { + return useSyncExternalStore(store.subscribe, store.get); +} + +export function useChannel(store: ChannelStore, ch: string): ChannelSnapshot { + const subscribe = useCallback((cb: () => void) => store.subscribeUi(ch, cb), [store, ch]); + const getSnapshot = useCallback(() => store.getUiSnapshot(ch), [store, ch]); + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/web/cockpit/src/session/protocol_fixtures.test.ts b/web/cockpit/src/session/protocol_fixtures.test.ts new file mode 100644 index 0000000000..7f2778d027 --- /dev/null +++ b/web/cockpit/src/session/protocol_fixtures.test.ts @@ -0,0 +1,120 @@ +// The cockpit consumes shared/protocol.ts through the vite/vitest pipeline +// (alias + bundler resolution) instead of Deno's. Running the golden vectors +// here proves that pipeline yields byte-identical framing. + +import { describe, expect, it } from "vitest"; +import { + ControlFrameReader, + DataFrameStreamReader, + decodeDatagram, + encodeControlFrame, + encodeDataFrame, + encodeDatagram, + type FrameHeader, + type Msg, + msgFromUnknown, +} from "@dimos/shared"; +import { ManifestError, parseManifest } from "@dimos/shared/manifest"; +import controlFrames from "../../../shared/fixtures/control_frames.json"; +import dataFrames from "../../../shared/fixtures/data_frames.json"; +import datagrams from "../../../shared/fixtures/datagrams.json"; +import manifests from "../../../shared/fixtures/manifests.json"; + +function b64ToBytes(b64: string): Uint8Array { + return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); +} + +describe("control frame golden vectors", () => { + for (const vector of controlFrames.vectors) { + it(`encodes ${vector.name} byte-exactly`, () => { + expect(encodeControlFrame(vector.message as Msg)).toEqual(b64ToBytes(vector.b64)); + }); + } + + it("decodes every vector whole and byte-at-a-time", () => { + const whole = new ControlFrameReader(); + const trickle = new ControlFrameReader(); + const expected = controlFrames.vectors.map((v) => v.message); + + const all = controlFrames.vectors.flatMap((v) => [...b64ToBytes(v.b64)]); + expect(whole.push(Uint8Array.from(all))).toEqual(expected); + + const decoded: unknown[] = []; + for (const byte of all) decoded.push(...trickle.push(Uint8Array.of(byte))); + expect(decoded).toEqual(expected); + }); +}); + +describe("datagram golden vectors", () => { + for (const vector of datagrams.vectors) { + it(`round-trips ${vector.name}`, () => { + expect(encodeDatagram(vector.message as Msg)).toEqual(b64ToBytes(vector.b64)); + expect(decodeDatagram(b64ToBytes(vector.b64))).toEqual(vector.message); + }); + } +}); + +describe("data frame golden vectors", () => { + for (const vector of dataFrames.vectors) { + it(`encodes and chunk-decodes ${vector.name}`, () => { + const payload = b64ToBytes(vector.payload_b64); + const frame = b64ToBytes(vector.frame_b64); + expect(encodeDataFrame(vector.header as FrameHeader, payload)).toEqual(frame); + + const reader = new DataFrameStreamReader(); + const decoded = []; + for (let i = 0; i < frame.length; i += 3) { + decoded.push(...reader.push(frame.subarray(i, Math.min(i + 3, frame.length)))); + } + expect(decoded).toHaveLength(1); + expect(decoded[0].header).toEqual(vector.header); + expect(decoded[0].payload).toEqual(payload); + }); + } +}); + +describe("manifest golden vectors", () => { + type Vector = { name: string; data: unknown; manifest?: unknown; error?: string }; + for (const vector of manifests.vectors as Vector[]) { + it(`handles ${vector.name}`, () => { + if (vector.error !== undefined) { + let code: string | null = null; + try { + parseManifest(vector.data); + } catch (e) { + if (e instanceof ManifestError) code = e.code; + } + expect(code).toBe(vector.error); + } else { + expect(parseManifest(vector.data)).toEqual(vector.manifest); + } + }); + } +}); + +describe("manifest/session message validation", () => { + const manifest = { + t: "manifest", + robotId: "go2", + channels: [{ ch: "odom", encoding: "pose.json.v1", delivery: "reliable", maxHz: 20 }], + }; + + it("accepts well-formed messages", () => { + expect(msgFromUnknown(manifest)).toEqual(manifest); + expect(msgFromUnknown({ t: "robots", robots: [{ id: "a", name: "A", model: "go2" }] })) + .not.toBeNull(); + }); + + it("rejects malformed or unknown messages", () => { + expect(msgFromUnknown({ t: "nope" })).toBeNull(); + expect(msgFromUnknown({ t: "toString" })).toBeNull(); + expect(msgFromUnknown({ t: "manifest", robotId: "go2" })).toBeNull(); + expect( + msgFromUnknown({ + ...manifest, + channels: [{ ch: "odom", encoding: "pose.json.v1", delivery: "sometimes", maxHz: 20 }], + }), + ).toBeNull(); + expect(msgFromUnknown({ t: "watch" })).toBeNull(); + }); +}); diff --git a/web/cockpit/src/session/session.test.ts b/web/cockpit/src/session/session.test.ts new file mode 100644 index 0000000000..f0a575df3f --- /dev/null +++ b/web/cockpit/src/session/session.test.ts @@ -0,0 +1,323 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + type ChannelSpec, + ControlFrameReader, + encodeControlFrame, + encodeDataFrame, + type Msg, + PROTOCOL_VERSION, + type RobotInfo, +} from "@dimos/shared"; +import { + manifestsEqual, + pickAutoWatch, + type SessionHandle, + startSession, + subscribableChannels, +} from "./session.ts"; +import type { RelayInfo, WebTransportLike } from "./transport.ts"; + +function spec(over: Partial = {}): ChannelSpec { + return { ch: "odom", encoding: "pose.json.v1", delivery: "reliable", maxHz: 20, ...over }; +} + +describe("manifestsEqual", () => { + it("ignores channel order", () => { + const a = [spec(), spec({ ch: "color_image", encoding: "jpeg.v1", delivery: "latest" })]; + expect(manifestsEqual(a, [...a].reverse())).toBe(true); + }); + + it("detects field changes and extra channels", () => { + expect(manifestsEqual([spec()], [spec({ maxHz: 30 })])).toBe(false); + expect(manifestsEqual([spec()], [spec({ encoding: "pose.json.v2" })])).toBe(false); + expect(manifestsEqual([spec()], [spec({ delivery: "latest" })])).toBe(false); + expect(manifestsEqual([spec()], [spec(), spec({ ch: "extra" })])).toBe(false); + expect(manifestsEqual([], [])).toBe(true); + }); +}); + +describe("pickAutoWatch", () => { + const robot = (id: string): RobotInfo => ({ id, name: id, model: "go2" }); + + it("picks the robot only when it is the only one", () => { + expect(pickAutoWatch([])).toBeNull(); + expect(pickAutoWatch([robot("a")])).toEqual(robot("a")); + expect(pickAutoWatch([robot("a"), robot("b")])).toBeNull(); + }); +}); + +describe("subscribableChannels", () => { + it("keeps only channels with a decoder (undecodable ones waste bandwidth)", () => { + const odom = spec(); + const jpeg = spec({ ch: "color_image", encoding: "jpeg.v1", delivery: "latest" }); + expect(subscribableChannels([odom, jpeg])).toEqual([odom]); + expect(subscribableChannels([jpeg])).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests: the real Session against a fake relay behind the +// WebTransportLike seam. The fake is the relay end of one connection: it +// collects viewer control messages and can push control messages and data +// frames, so robot lifecycle transitions run over the actual wire encoding +// while the relay and "viewer connection" stay up the whole time. + +const INFO: RelayInfo = { + wtUrl: "https://127.0.0.1:1/viewer", + certHash: "aGFzaA==", + v: PROTOCOL_VERSION, +}; + +const ROBOT_A: RobotInfo = { id: "a", name: "A", model: "go2" }; +const ROBOT_B: RobotInfo = { id: "b", name: "B", model: "go2" }; + +class FakeRelayEnd { + readonly sent: Msg[] = []; + readonly wt: WebTransportLike; + #control!: ReadableStreamDefaultController; + #uni!: ReadableStreamDefaultController>; + + constructor() { + const inbound = new ControlFrameReader(); + const readable = new ReadableStream({ + start: (c) => { + this.#control = c; + }, + }); + const writable = new WritableStream({ + write: (chunk) => { + this.sent.push(...inbound.push(chunk)); + }, + }); + let closeWt = () => {}; + const closed = new Promise((resolve) => { + closeWt = () => resolve({}); + }); + this.wt = { + ready: Promise.resolve(), + closed, + close: () => closeWt(), + createBidirectionalStream: () => Promise.resolve({ readable, writable }), + incomingUnidirectionalStreams: new ReadableStream>({ + start: (c) => { + this.#uni = c; + }, + }), + }; + } + + push(msg: Msg): void { + this.#control.enqueue(encodeControlFrame(msg)); + } + + /** One data frame on its own uni stream, JSON payload like the bridge's. */ + pushFrame(seq: number, value: unknown, ch = "odom"): void { + const payload = new TextEncoder().encode(JSON.stringify(value)); + const frame = encodeDataFrame({ ch, seq, ts: seq, delivery: "reliable" }, payload); + this.#uni.enqueue( + new ReadableStream({ + start: (c) => { + c.enqueue(frame); + c.close(); + }, + }), + ); + } + + watches(id: string): number { + return this.sent.filter((m) => m.t === "watch" && m.robotId === id).length; + } + + subs(): string[] { + return this.sent.flatMap((m) => (m.t === "sub" ? [m.ch] : [])); + } +} + +async function until(cond: () => boolean, what = "condition"): Promise { + const deadline = Date.now() + 2000; + while (!cond()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise((resolve) => setTimeout(resolve, 2)); + } +} + +/** Lets already-queued streams/messages drain before a negative assertion. */ +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 20)); +} + +describe("Session over a fake WebTransport", () => { + const handles: SessionHandle[] = []; + + afterEach(() => { + for (const handle of handles.splice(0)) handle.stop(); + }); + + function start(): { relay: FakeRelayEnd; handle: SessionHandle } { + const relay = new FakeRelayEnd(); + const handle = startSession({ + fetchInfo: () => Promise.resolve(INFO), + createWebTransport: () => relay.wt, + }); + handles.push(handle); + return { relay, handle }; + } + + async function goLive( + relay: FakeRelayEnd, + handle: SessionHandle, + robot = ROBOT_A, + channels = [spec()], + ): Promise { + relay.push({ t: "welcome", v: PROTOCOL_VERSION }); + relay.push({ t: "robots", robots: [robot] }); + await until(() => relay.watches(robot.id) === 1, "watch"); + relay.push({ t: "manifest", robotId: robot.id, channels }); + await until(() => handle.status.get().channels.length === channels.length, "manifest"); + } + + it("publishes connected only after the relay's welcome", async () => { + const { relay, handle } = start(); + await until(() => relay.sent.some((m) => m.t === "hello"), "hello"); + expect(handle.status.get().transport.phase).toBe("connecting"); + + relay.push({ t: "welcome", v: PROTOCOL_VERSION }); + await until(() => handle.status.get().transport.phase === "connected", "connected"); + }); + + it("watches the sole robot and subs only decodable manifest channels", async () => { + const { relay, handle } = start(); + await goLive(relay, handle, ROBOT_A, [ + spec(), + spec({ ch: "color_image", encoding: "jpeg.v1", delivery: "latest" }), + ]); + expect(relay.watches("a")).toBe(1); + expect(relay.subs()).toEqual(["odom"]); + expect(handle.status.get().robot).toEqual(ROBOT_A); + }); + + it("retries the watch when the robot reappears after unknown_robot", async () => { + const { relay, handle } = start(); + relay.push({ t: "welcome", v: PROTOCOL_VERSION }); + relay.push({ t: "robots", robots: [ROBOT_A] }); + await until(() => relay.watches("a") === 1, "first watch"); + + // The robot died before the watch arrived: the relay answered with + // unknown_robot and pushed an empty robots list. + relay.push({ t: "robots", robots: [] }); + relay.push({ t: "error", code: "unknown_robot", message: "no robot a" }); + await until(() => handle.status.get().lastError === "unknown_robot: no robot a", "error"); + + // The same id coming back must trigger a fresh watch, and its manifest + // clears the stale error. + relay.push({ t: "robots", robots: [ROBOT_A] }); + await until(() => relay.watches("a") === 2, "retried watch"); + relay.push({ t: "manifest", robotId: "a", channels: [spec()] }); + await until(() => handle.status.get().lastError === null, "error cleared"); + expect(handle.status.get().channels).toEqual([spec()]); + }); + + it("clears the stale error on the next welcome", async () => { + const { relay, handle } = start(); + relay.push({ t: "welcome", v: PROTOCOL_VERSION }); + relay.push({ t: "error", code: "no_watch", message: "watch a robot before sub/unsub" }); + await until(() => handle.status.get().lastError !== null, "error"); + + relay.push({ t: "welcome", v: PROTOCOL_VERSION }); + await until(() => handle.status.get().lastError === null, "error cleared"); + }); + + it("survives a same-relay robot restart: rewatch, reset, restarted seqs win", async () => { + const { relay, handle } = start(); + await goLive(relay, handle); + relay.pushFrame(500, { x: 500 }); + await until(() => handle.channels.get("odom")?.seq === 500, "first frame"); + + // Robot gone: channels, values, and the watch confirmation are dropped. + relay.push({ t: "robots", robots: [] }); + await until(() => handle.status.get().channels.length === 0, "cleared channels"); + expect(handle.status.get().robotCount).toBe(0); + expect(handle.status.get().epoch).toBe(1); + expect(handle.channels.get("odom")).toBeNull(); + + // A frame still draining out of the dead robot's relay queue is ignored. + relay.pushFrame(501, { x: 501 }); + await settle(); + expect(handle.channels.get("odom")).toBeNull(); + + // Same id returns: the watch must be re-sent even though the id never + // changed, and the manifest re-adopted. + relay.push({ t: "robots", robots: [ROBOT_A] }); + await until(() => relay.watches("a") === 2, "rewatch"); + relay.push({ t: "manifest", robotId: "a", channels: [spec()] }); + await until(() => handle.status.get().channels.length === 1, "manifest readopted"); + + // A late high-seq frame from the dead producer must not lock out the + // new producer's restarted counter. + relay.pushFrame(502, { x: 502 }); + await until(() => handle.channels.get("odom")?.seq === 502, "late old frame"); + relay.pushFrame(1, { x: 1 }); + await until(() => handle.channels.get("odom")?.seq === 1, "restarted seq"); + expect(handle.channels.get("odom")?.value).toEqual({ x: 1 }); + }); + + it("does not carry values into a replacement robot with an identical manifest", async () => { + const { relay, handle } = start(); + await goLive(relay, handle); + relay.pushFrame(900, { x: 900 }); + await until(() => handle.channels.get("odom")?.seq === 900, "value from A"); + + relay.push({ t: "robots", robots: [] }); + await until(() => handle.status.get().channels.length === 0, "cleared"); + + relay.push({ t: "robots", robots: [ROBOT_B] }); + await until(() => relay.watches("b") === 1, "watch B"); + relay.push({ t: "manifest", robotId: "b", channels: [spec()] }); + await until(() => handle.status.get().channels.length === 1, "manifest B"); + + // Identical manifest, different robot: A's value must not show under B. + expect(handle.channels.get("odom")).toBeNull(); + expect(handle.status.get().robot).toEqual(ROBOT_B); + relay.pushFrame(1, { x: 1 }); + await until(() => handle.channels.get("odom")?.seq === 1, "value from B"); + expect(handle.channels.get("odom")?.value).toEqual({ x: 1 }); + }); + + it("drops data and remounts when the watched robot's manifest changes", async () => { + const { relay, handle } = start(); + await goLive(relay, handle); + relay.pushFrame(900, { x: 900 }); + await until(() => handle.channels.get("odom")?.seq === 900, "value"); + + const changed = spec({ ch: "status" }); + relay.push({ t: "manifest", robotId: "a", channels: [changed] }); + await until(() => handle.status.get().channels[0]?.ch === "status", "new manifest"); + expect(handle.status.get().epoch).toBe(1); + expect(handle.channels.get("odom")).toBeNull(); + }); + + it("clears the watch on multiple robots and ignores a stale manifest reply", async () => { + const { relay, handle } = start(); + await goLive(relay, handle); + relay.pushFrame(900, { x: 900 }); + await until(() => handle.channels.get("odom")?.seq === 900, "value from A"); + + // A second robot appears: auto-watch is ambiguous, everything clears. + relay.push({ t: "robots", robots: [ROBOT_A, ROBOT_B] }); + await until(() => handle.status.get().robotCount === 2, "two robots"); + expect(handle.status.get().robot).toBeNull(); + expect(handle.status.get().channels).toEqual([]); + expect(handle.channels.get("odom")).toBeNull(); + + // A manifest reply that raced the second registration is not adopted. + relay.push({ t: "manifest", robotId: "a", channels: [spec()] }); + await settle(); + expect(handle.status.get().channels).toEqual([]); + + // A leaves; the survivor becomes the sole robot and gets watched. + relay.push({ t: "robots", robots: [ROBOT_B] }); + await until(() => relay.watches("b") === 1, "watch B"); + relay.push({ t: "manifest", robotId: "b", channels: [spec({ ch: "status" })] }); + await until(() => handle.status.get().channels[0]?.ch === "status", "manifest B"); + }); +}); diff --git a/web/cockpit/src/session/session.ts b/web/cockpit/src/session/session.ts new file mode 100644 index 0000000000..d781dc7e8b --- /dev/null +++ b/web/cockpit/src/session/session.ts @@ -0,0 +1,267 @@ +// Viewer session: drives the control stream (hello, robots, watch, manifest, +// sub) and the incoming uni-stream data plane on top of ReconnectingTransport, +// writing everything into the stores. One instance lives for the page. + +import { + type ChannelSpec, + ControlFrameReader, + type DataFrame, + DataFrameStreamError, + DataFrameStreamReader, + encodeControlFrame, + type Msg, + PROTOCOL_VERSION, + type RobotInfo, +} from "@dimos/shared"; +import { parseManifest } from "@dimos/shared/manifest"; +import { getDecoder } from "./decoders/index.ts"; +import { ChannelStore, StatusStore } from "./store.ts"; +import { ReconnectingTransport, type TransportDeps, type WebTransportLike } from "./transport.ts"; + +const UI_TICK_MS = 500; + +export interface SessionHandle { + status: StatusStore; + channels: ChannelStore; + stop(): void; +} + +/** True when both lists describe the same channels (order-insensitive). */ +export function manifestsEqual(a: ChannelSpec[], b: ChannelSpec[]): boolean { + if (a.length !== b.length) return false; + const key = (c: ChannelSpec) => c.ch; + const sortedA = [...a].sort((x, y) => key(x).localeCompare(key(y))); + const sortedB = [...b].sort((x, y) => key(x).localeCompare(key(y))); + return sortedA.every((c, i) => { + const other = sortedB[i]; + return ( + c.ch === other.ch && + c.encoding === other.encoding && + c.delivery === other.delivery && + c.maxHz === other.maxHz + ); + }); +} + +/** Local auto-select policy: watch the robot only when it is the only one. */ +export function pickAutoWatch(robots: RobotInfo[]): RobotInfo | null { + return robots.length === 1 ? robots[0] : null; +} + +/** + * Channels worth subscribing: only those with a decoder. Subscribing to + * undecodable channels wastes encode CPU and bandwidth, and a 15 Hz JPEG + * stream nobody renders overflows the relay's reliable FIFO under Firefox's + * tighter QUIC credit (the relay kicks the viewer every ~8 s). Panels take + * over subscription decisions in T7; the video channel joins in T5 with its + * decoder. + */ +export function subscribableChannels(channels: ChannelSpec[]): ChannelSpec[] { + return channels.filter((spec) => getDecoder(spec.encoding) !== undefined); +} + +class Session { + readonly status = new StatusStore(); + readonly channels = new ChannelStore(); + readonly transport: ReconnectingTransport; + + // Bumped per connection; data-plane writes from a previous connection's + // still-draining reader loops are dropped by comparing against it. + #runId = 0; + #manifest: ChannelSpec[] | null = null; + #ticker: ReturnType; + + constructor(transportDeps: TransportDeps = {}) { + this.transport = new ReconnectingTransport( + { + onPhase: (phase) => this.status.update({ transport: phase }), + onSession: (wt) => this.#runSession(wt), + }, + transportDeps, + ); + this.#ticker = setInterval(() => this.channels.publishUi(), UI_TICK_MS); + } + + stop(): void { + clearInterval(this.#ticker); + this.transport.stop(); + } + + async #runSession(wt: WebTransportLike): Promise { + const runId = ++this.#runId; + const control = await wt.createBidirectionalStream(); + const writer = control.writable.getWriter(); + const send = async (msg: Msg) => { + await writer.write(encodeControlFrame(msg)); + }; + await send({ t: "hello", v: PROTOCOL_VERSION, role: "viewer" }); + void this.#readUniStreams(wt, runId); + + const reader = control.readable.getReader(); + const frames = new ControlFrameReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + for (const msg of frames.push(value)) { + switch (msg.t) { + case "welcome": + // Session-level success: only now is the page usable, so only + // now the transport may show "connected". + this.transport.sessionReady(); + this.status.update({ lastError: null }); + break; + case "robots": { + const pick = pickAutoWatch(msg.robots); + this.status.update({ robot: pick, robotCount: msg.robots.length }); + if (pick === null) { + this.#clearProducer(); + } else { + // Unconditional (and idempotent on the relay): a same-id + // robot restart is announced with the id we already watch, + // so only a fresh watch re-confirms it, refreshes the + // manifest, and retries after an unknown_robot race. + await send({ t: "watch", robotId: pick.id }); + } + break; + } + case "manifest": { + // A reply to a watch that raced a robot change must not be + // adopted: only the currently picked robot's manifest counts. + if (msg.robotId !== this.status.get().robot?.id) break; + let channels: ChannelSpec[]; + try { + // Domain validation (duplicate/bogus ids) on top of the + // transport shape check: a duplicate id would make the + // store and the channel list disagree on the winner. + channels = parseManifest({ channels: msg.channels }).channels; + } catch (e) { + this.status.update({ lastError: `invalid manifest: ${(e as Error).message}` }); + break; + } + this.status.update({ lastError: null }); + for (const spec of subscribableChannels(channels)) { + await send({ t: "sub", ch: spec.ch }); + } + this.#applyManifest(channels); + break; + } + case "error": { + if (msg.code === "version_mismatch") { + this.transport.fail(msg.message); + } else { + this.status.update({ lastError: `${msg.code}: ${msg.message}` }); + } + break; + } + default: + // pong, robot-side messages: nothing to do + break; + } + } + } + } catch { + // control stream died with the connection; the transport reconnects + } + } + + /** + * Adopt a confirmed manifest. The producer behind it may differ from the + * previous one (viewer reconnect, robot restart or replacement), so seq + * tracking always rebaselines; a first adopt drops data left over from a + * dead producer, and a changed manifest additionally remounts. + */ + #applyManifest(channels: ChannelSpec[]): void { + const prev = this.#manifest; + this.#manifest = channels; + if (prev === null) { + this.channels.reset(); + this.status.update({ channels }); + } else if (!manifestsEqual(prev, channels)) { + this.channels.reset(); + this.status.update({ channels, epoch: this.status.get().epoch + 1 }); + } else { + this.status.update({ channels }); + } + this.channels.rebaseline(); + } + + /** + * Zero or ambiguous robots: the watch is no longer confirmed. Drop the + * manifest and all channel data and remount, so nothing stale survives + * under whatever robot is confirmed next. + */ + #clearProducer(): void { + if (this.#manifest === null) return; + this.#manifest = null; + this.channels.reset(); + this.status.update({ channels: [], epoch: this.status.get().epoch + 1 }); + } + + async #readUniStreams(wt: WebTransportLike, runId: number): Promise { + const streams = wt.incomingUnidirectionalStreams.getReader(); + try { + while (true) { + const { value, done } = await streams.read(); + if (done) break; + void this.#readStreamFrames(value, runId); + } + } catch { + // connection died; the transport reconnects + } + } + + // A latest stream carries one frame; a reliable channel's persistent stream + // carries them back to back. Frames dispatch on byte count (the relay's FIN + // can be seconds late); the stream is never cancelled - reading to its end + // costs nothing and a cancel would reset the persistent stream. + async #readStreamFrames(stream: ReadableStream, runId: number): Promise { + const reader = stream.getReader(); + const frames = new DataFrameStreamReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + for (const frame of frames.push(value)) { + if (runId === this.#runId) this.#ingest(frame); + } + } + } catch (e) { + // reset/aborted stream: a partial latest-wins frame is dropped by + // design. Framing corruption still delivers the frames decoded before + // it; only the corrupt stream is abandoned. + if (e instanceof DataFrameStreamError && runId === this.#runId) { + for (const frame of e.frames) this.#ingest(frame); + } + } + } + + #ingest(frame: DataFrame): void { + // No adopted manifest means no confirmed producer: anything arriving is + // stale drain from a dead robot session and must not re-dirty the store. + if (this.#manifest === null) return; + const spec = this.#manifest.find((c) => c.ch === frame.header.ch); + const decoder = getDecoder(spec?.encoding); + let value: unknown; + let preview: string | undefined; + let decodeOk = true; + if (decoder !== undefined) { + try { + ({ value, preview } = decoder(frame.payload, frame.header)); + } catch { + decodeOk = false; + } + } + this.channels.ingest(frame.header.ch, frame.header, value, decodeOk, preview); + } +} + +export function startSession(transportDeps: TransportDeps = {}): SessionHandle { + const session = new Session(transportDeps); + session.transport.start(); + return { + status: session.status, + channels: session.channels, + stop: () => session.stop(), + }; +} diff --git a/web/cockpit/src/session/store.test.ts b/web/cockpit/src/session/store.test.ts new file mode 100644 index 0000000000..38ed2e3171 --- /dev/null +++ b/web/cockpit/src/session/store.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it, vi } from "vitest"; +import type { FrameHeader } from "@dimos/shared"; +import { ChannelStore, REBASELINE_WINDOW_MS, StatusStore } from "./store.ts"; + +function header(seq: number, ts = seq / 10): FrameHeader { + return { ch: "odom", seq, ts, delivery: "reliable" }; +} + +describe("ChannelStore", () => { + it("keeps the latest frame by seq and counts every arrival", () => { + let now = 1000; + const store = new ChannelStore(() => now); + store.ingest("odom", header(5), { x: 5 }, true, '{"x":5}'); + now = 1010; + store.ingest("odom", header(3), { x: 3 }, true); + + expect(store.get("odom")).toMatchObject({ + value: { x: 5 }, + preview: '{"x":5}', + seq: 5, + version: 1, + }); + store.publishUi(); + expect(store.getUiSnapshot("odom").stats.frames).toBe(2); + }); + + it("notifies direct subscribers per accepted frame, not for stale seqs", () => { + const store = new ChannelStore(() => 0); + const cb = vi.fn(); + store.subscribe("odom", cb); + store.ingest("odom", header(1), 1, true); + store.ingest("odom", header(2), 2, true); + store.ingest("odom", header(1), 1, true); + // A decode failure never touches the slot, so it must not notify either. + store.ingest("odom", header(3), undefined, false); + expect(cb).toHaveBeenCalledTimes(2); + }); + + it("notifies UI subscribers only from publishUi", () => { + const store = new ChannelStore(() => 0); + const cb = vi.fn(); + store.subscribeUi("odom", cb); + store.ingest("odom", header(1), 1, true); + store.ingest("odom", header(2), 2, true); + expect(cb).not.toHaveBeenCalled(); + store.publishUi(); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("keeps snapshot identity stable between UI ticks", () => { + let now = 0; + const store = new ChannelStore(() => now); + const before = store.getUiSnapshot("odom"); + expect(store.getUiSnapshot("odom")).toBe(before); + + store.ingest("odom", header(1), 1, true); + expect(store.getUiSnapshot("odom")).toBe(before); + store.publishUi(); + const after = store.getUiSnapshot("odom"); + expect(after).not.toBe(before); + expect(store.getUiSnapshot("odom")).toBe(after); + + // Nothing arrived and the age bucket has not moved: same snapshot object. + store.publishUi(); + expect(store.getUiSnapshot("odom")).toBe(after); + + // Silence ages the channel: the next tick publishes a fresh snapshot so + // staleness keeps rising on screen. + now = 5000; + store.publishUi(); + expect(store.getUiSnapshot("odom")).not.toBe(after); + }); + + it("computes hz from header timestamps, not arrival times", () => { + let now = 0; + const store = new ChannelStore(() => now); + // The source stamps 10 Hz; delivery drains at 20 Hz (catching up on a + // backlog). Arrival rate must not leak into the figure. + for (let i = 1; i <= 40; i++) { + now = i * 50; + store.ingest("odom", header(i, i / 10), i, true); + } + store.publishUi(); + expect(store.getUiSnapshot("odom").stats.hz).toBe(10); + }); + + it("reads a burst-delivered backlog at its source rate", () => { + const now = 10_000; + const store = new ChannelStore(() => now); + // 30 frames stamped 100 ms apart at the source, all arriving at once. + for (let i = 1; i <= 30; i++) store.ingest("odom", header(i, i / 10), i, true); + store.publishUi(); + expect(store.getUiSnapshot("odom").stats.hz).toBe(10); + }); + + it("decays hz to zero and grows age while the channel is silent", () => { + let now = 1000; + const store = new ChannelStore(() => now); + store.ingest("odom", header(1, 1), { x: 1 }, true); + store.publishUi(); + expect(store.getUiSnapshot("odom").stats.hz).toBe(0.5); + expect(store.getUiSnapshot("odom").stats.ageMs).toBe(0); + + now = 7000; + store.publishUi(); + expect(store.getUiSnapshot("odom").stats.hz).toBe(0); + expect(store.getUiSnapshot("odom").stats.ageMs).toBe(6000); + }); + + it("ages a delayed frame by its header timestamp, not its arrival", () => { + let now = 1000; + const store = new ChannelStore(() => now); + // Live frame delivered instantly: the skew estimate learns 0. + store.ingest("odom", header(1, 1), { x: 1 }, true); + // 5 s later a frame stamped 4 s ago drains out of a backlog. + now = 6000; + store.ingest("odom", header(2, 2), { x: 2 }, true); + store.publishUi(); + expect(store.getUiSnapshot("odom").stats.ageMs).toBe(4000); + }); + + it("corrects age and hz for a source clock far ahead of the browser", () => { + let now = 1000; + const store = new ChannelStore(() => now); + store.ingest("odom", header(1, (now + 600_000) / 1000), 1, true); + now = 1500; + store.ingest("odom", header(2, (now + 600_000) / 1000), 2, true); + store.publishUi(); + expect(store.getUiSnapshot("odom").stats.hz).toBe(1); + expect(store.getUiSnapshot("odom").stats.ageMs).toBe(0); + + now = 3500; + store.publishUi(); + expect(store.getUiSnapshot("odom").stats.ageMs).toBe(2000); + }); + + it("keeps the slot on the last good frame and flags decode failures", () => { + const store = new ChannelStore(() => 0); + store.ingest("odom", header(1), { x: 1 }, true, '{"x":1}'); + store.ingest("odom", header(2), undefined, false); + + // The slot stays internally consistent: value, preview, seq, ts, and + // version all describe the decoded frame, not the corrupt one. + expect(store.get("odom")).toMatchObject({ + value: { x: 1 }, + preview: '{"x":1}', + seq: 1, + ts: 0.1, + version: 1, + }); + store.publishUi(); + expect(store.getUiSnapshot("odom").stats).toMatchObject({ + frames: 2, + decodeErrors: 1, + decodeFailing: true, + lastSeq: 2, + lastTs: 0.2, + }); + + // Recovery: the next good frame takes the slot and clears the flag. + store.ingest("odom", header(3), { x: 3 }, true); + expect(store.get("odom")).toMatchObject({ value: { x: 3 }, seq: 3, version: 2 }); + store.publishUi(); + expect(store.getUiSnapshot("odom").stats.decodeFailing).toBe(false); + }); + + it("tracks received frames separately while nothing has ever decoded", () => { + const store = new ChannelStore(() => 0); + store.ingest("odom", header(4), undefined, false); + expect(store.get("odom")).toBeNull(); + store.publishUi(); + expect(store.getUiSnapshot("odom").stats).toMatchObject({ + frames: 1, + decodeErrors: 1, + decodeFailing: true, + lastSeq: 4, + lastTs: 0.4, + ageMs: null, + }); + }); + + it("clamps age at zero when the slot predates a re-learned skew", () => { + let now = 1000; + const store = new ChannelStore(() => now); + // Producer A's clock runs ~1000 s ahead; its frame owns the slot. + store.ingest("odom", header(100, 1001), { a: 1 }, true); + store.rebaseline(); + // Producer B (clock in sync with the browser) sends a corrupt frame: the + // skew re-learns from its header while the slot still holds A's frame. + now = 1500; + store.ingest("odom", header(5, 1.5), undefined, false); + store.publishUi(); + expect(store.getUiSnapshot("odom").stats.ageMs).toBe(0); + expect(store.getUiSnapshot("odom").stats.decodeFailing).toBe(true); + expect(store.get("odom")).toMatchObject({ seq: 100 }); + }); + + it("prunes rate state on ingest: high-rate flow with publishUi paused", () => { + let now = 0; + const store = new ChannelStore(() => now); + // 1024 Hz (power of two: every float here is exact) for ~50 s of browser + // time without a single UI tick - background tabs throttle timers while + // networking callbacks keep firing. The ring is fixed-size, so this must + // neither grow memory nor defer O(n) pruning to the next publish. + const stepMs = 1000 / 1024; + for (let i = 1; i <= 51_199; i++) { + now = i * stepMs; + store.ingest("odom", header(i, i / 1024), i, true); + } + store.publishUi(); + const stats = store.getUiSnapshot("odom").stats; + expect(stats.frames).toBe(51_199); + expect(stats.hz).toBe(1024); + expect(stats.ageMs).toBe(0); + }); + + it("accepts any seq inside the rebaseline window (robot restarted)", () => { + let now = 0; + const store = new ChannelStore(() => now); + store.ingest("odom", header(500), { x: 500 }, true); + // Same producer: a lower seq is a reordered stale stream -> dropped. + store.ingest("odom", header(1), { x: 1 }, true); + expect(store.get("odom")).toMatchObject({ seq: 500 }); + + // Producer changed: a late high-seq frame from the dead one may still + // drain in, but the restarted counter must win by arrival order. + store.rebaseline(); + store.ingest("odom", header(501), { x: 501 }, true); + expect(store.get("odom")).toMatchObject({ seq: 501 }); + store.ingest("odom", header(1), { x: 1 }, true); + expect(store.get("odom")).toMatchObject({ seq: 1, value: { x: 1 } }); + + // Window over: the latest-wins guard is back. + now = REBASELINE_WINDOW_MS; + store.ingest("odom", header(0), { x: 0 }, true); + expect(store.get("odom")).toMatchObject({ seq: 1 }); + store.ingest("odom", header(2), { x: 2 }, true); + expect(store.get("odom")).toMatchObject({ seq: 2, value: { x: 2 } }); + }); + + it("reset drops data and notifies both subscriber kinds", () => { + const store = new ChannelStore(() => 0); + const direct = vi.fn(); + const ui = vi.fn(); + store.ingest("odom", header(1), 1, true); + store.ingest("odom", header(2), undefined, false); + store.subscribe("odom", direct); + store.subscribeUi("odom", ui); + store.reset(); + expect(store.get("odom")).toBeNull(); + expect(store.getUiSnapshot("odom").stats).toMatchObject({ + frames: 0, + decodeErrors: 0, + decodeFailing: false, + lastSeq: -1, + }); + expect(direct).toHaveBeenCalledTimes(1); + expect(ui).toHaveBeenCalledTimes(1); + }); + + it("unsubscribe stops notifications", () => { + const store = new ChannelStore(() => 0); + const cb = vi.fn(); + const unsub = store.subscribe("odom", cb); + unsub(); + store.ingest("odom", header(1), 1, true); + expect(cb).not.toHaveBeenCalled(); + }); +}); + +describe("StatusStore", () => { + it("shallow-merges updates and notifies", () => { + const store = new StatusStore(); + const cb = vi.fn(); + store.subscribe(cb); + const before = store.get(); + store.update({ lastError: "boom" }); + expect(cb).toHaveBeenCalledTimes(1); + expect(store.get()).not.toBe(before); + expect(store.get().lastError).toBe("boom"); + expect(store.get().epoch).toBe(0); + expect(store.get()).toBe(store.get()); + }); +}); diff --git a/web/cockpit/src/session/store.ts b/web/cockpit/src/session/store.ts new file mode 100644 index 0000000000..a6d601bb7f --- /dev/null +++ b/web/cockpit/src/session/store.ts @@ -0,0 +1,334 @@ +// Channel + status stores. React-free by design: the session layer writes here +// at wire rate, while React reads through two decoupled paths (hooks.ts) so +// high-rate channels never cause per-frame renders. Canvas consumers (T4) use +// the direct subscribe/get path instead. +// +// Two clocks meet here. Header `ts` is the source's Unix time in seconds (the +// bridge stamps time.time()); everything else is browser milliseconds. Rate +// and age are computed in source time so a burst-delivered backlog reads as +// its true source rate and age, not as fresh high-rate data. The offset +// between the clocks is estimated per channel as min(arrival - header ts) +// over received frames; rebaseline() re-arms the estimate because a new +// producer brings a new clock, and published ages clamp at 0 when a slot +// predates the current estimate. + +import type { ChannelSpec, FrameHeader, RobotInfo } from "@dimos/shared"; +import type { TransportPhase } from "./transport.ts"; + +/** Latest successfully decoded frame of one channel (latest-wins by header seq). */ +export interface Slot { + value: unknown; + /** Bounded text form of `value` for the raw channel UI (decoder-provided). */ + preview?: string; + seq: number; + ts: number; + version: number; +} + +export interface ChannelStats { + frames: number; + decodeErrors: number; + /** Received-frame rate over the last HZ_WINDOW_MS of source time (header ts). */ + hz: number; + /** Skew-corrected source age of the slot's frame; null while there is no slot. */ + ageMs: number | null; + /** Browser arrival time of the newest frame (receive silence, not telemetry age). */ + lastFrameAtMs: number; + /** Header seq/ts of the newest received frame, decoded or not (-1/0 before data). */ + lastSeq: number; + lastTs: number; + /** True while the newest received frame failed to decode. */ + decodeFailing: boolean; +} + +export interface ChannelSnapshot { + slot: Slot | null; + stats: ChannelStats; +} + +const HZ_WINDOW_MS = 2000; +// The rate window is a ring of fixed source-time buckets, pruned on ingest and +// slid by projected source time on publish, so storage stays bounded no matter +// how long publishUi() is starved (background tabs throttle timers). +const HZ_BUCKET_MS = 100; +const HZ_BUCKET_COUNT = HZ_WINDOW_MS / HZ_BUCKET_MS; +// Staleness must keep rising on screen while a channel is silent; bucketing +// the published age makes publishUi() emit a fresh snapshot once per bucket. +const AGE_BUCKET_MS = 500; + +// After rebaseline() the latest-wins seq guard is suspended for this long: +// long enough to outlive frames of a dead robot session still draining out +// of the relay's queues (whose high seqs would lock out the new producer's +// restarted counter), short enough that ordinary out-of-order latest streams +// can only wobble the slot briefly after a producer change. +export const REBASELINE_WINDOW_MS = 3000; + +const EMPTY_SNAPSHOT: ChannelSnapshot = { + slot: null, + stats: { + frames: 0, + decodeErrors: 0, + hz: 0, + ageMs: null, + lastFrameAtMs: 0, + lastSeq: -1, + lastTs: 0, + decodeFailing: false, + }, +}; + +interface ChannelState { + slot: Slot | null; + frames: number; + decodeErrors: number; + decodeFailing: boolean; + lastFrameAtMs: number; + lastSeq: number; + lastTs: number; + skewMs: number; // min(arrival ms - header ts ms) since the last re-arm + skewRearm: boolean; // next frame replaces skewMs and clears the rate ring + rate: number[]; // frame counts per HZ_BUCKET_MS of source time + rateHead: number; // absolute bucket index of the ring's newest slot; -1 = empty + dirty: boolean; + ageBucket: number; + snapshot: ChannelSnapshot; + direct: Set<() => void>; + ui: Set<() => void>; +} + +function rateSlot(bucket: number): number { + return ((bucket % HZ_BUCKET_COUNT) + HZ_BUCKET_COUNT) % HZ_BUCKET_COUNT; +} + +/** Slide the ring forward so `bucket` is its newest slot, zeroing what it passes. */ +function advanceRate(state: ChannelState, bucket: number): void { + if (state.rateHead === -1 || bucket - state.rateHead >= HZ_BUCKET_COUNT) { + state.rate.fill(0); + } else { + for (let b = state.rateHead + 1; b <= bucket; b++) state.rate[rateSlot(b)] = 0; + } + state.rateHead = bucket; +} + +export class ChannelStore { + #channels = new Map(); + #now: () => number; + #rebaselineUntilMs = 0; + + constructor(now: () => number = Date.now) { + this.#now = now; + } + + #state(ch: string): ChannelState { + let state = this.#channels.get(ch); + if (state === undefined) { + state = { + slot: null, + frames: 0, + decodeErrors: 0, + decodeFailing: false, + lastFrameAtMs: 0, + lastSeq: -1, + lastTs: 0, + skewMs: Infinity, + skewRearm: false, + rate: new Array(HZ_BUCKET_COUNT).fill(0), + rateHead: -1, + dirty: false, + ageBucket: -1, + snapshot: EMPTY_SNAPSHOT, + direct: new Set(), + ui: new Set(), + }; + this.#channels.set(ch, state); + } + return state; + } + + /** + * Record an arrived frame. `value` is the decoded payload (undefined for + * encodings without a decoder); `decodeOk` is false when a decoder threw; + * `preview` is the decoder's bounded text form for the raw UI. A failed + * decode only updates stats: the slot always describes one successfully + * decoded frame. Decoded frames with seq <= the current slot's are dropped + * from the slot (streams arrive out of order by design) but still counted + * in the stats - except inside a rebaseline window, where arrival order + * wins. + */ + ingest( + ch: string, + header: FrameHeader, + value: unknown, + decodeOk: boolean, + preview?: string, + ): void { + const state = this.#state(ch); + const now = this.#now(); + state.frames += 1; + state.lastFrameAtMs = now; + state.lastSeq = header.seq; + state.lastTs = header.ts; + state.dirty = true; + const tsMs = header.ts * 1000; + const skew = now - tsMs; + if (state.skewRearm) { + state.skewRearm = false; + state.skewMs = skew; + state.rate.fill(0); + state.rateHead = -1; + } else if (skew < state.skewMs) { + state.skewMs = skew; + } + const bucket = Math.floor(tsMs / HZ_BUCKET_MS); + if (bucket > state.rateHead) advanceRate(state, bucket); + if (bucket > state.rateHead - HZ_BUCKET_COUNT) state.rate[rateSlot(bucket)] += 1; + if (!decodeOk) { + state.decodeErrors += 1; + state.decodeFailing = true; + return; + } + state.decodeFailing = false; + const prev = state.slot; + if (prev === null || now < this.#rebaselineUntilMs || header.seq > prev.seq) { + state.slot = { + value, + preview, + seq: header.seq, + ts: header.ts, + version: (prev?.version ?? 0) + 1, + }; + for (const cb of state.direct) cb(); + } + } + + /** Always-current slot (canvas/direct consumers; no snapshot indirection). */ + get(ch: string): Slot | null { + return this.#channels.get(ch)?.slot ?? null; + } + + /** Synchronous per-ingest notifications (canvas/direct consumers, T4). */ + subscribe(ch: string, cb: () => void): () => void { + const state = this.#state(ch); + state.direct.add(cb); + return () => state.direct.delete(cb); + } + + /** Notified only from publishUi(); pair with getUiSnapshot for React. */ + subscribeUi(ch: string, cb: () => void): () => void { + const state = this.#state(ch); + state.ui.add(cb); + return () => state.ui.delete(cb); + } + + /** Stable between publishUi() calls (useSyncExternalStore contract). */ + getUiSnapshot(ch: string): ChannelSnapshot { + return this.#channels.get(ch)?.snapshot ?? EMPTY_SNAPSHOT; + } + + /** Rebuild and publish snapshots for channels whose visible state changed. */ + publishUi(): void { + const now = this.#now(); + for (const state of this.#channels.values()) { + // Slide the rate window along projected source time so silence decays hz. + if (state.rateHead !== -1 && state.skewMs !== Infinity) { + const bucket = Math.floor((now - state.skewMs) / HZ_BUCKET_MS); + if (bucket > state.rateHead) advanceRate(state, bucket); + } + let inWindow = 0; + for (const n of state.rate) inWindow += n; + const hz = (inWindow * 1000) / HZ_WINDOW_MS; + const ageMs = state.slot === null + ? null + : Math.max(0, now - state.skewMs - state.slot.ts * 1000); + const ageBucket = ageMs === null ? -1 : Math.floor(ageMs / AGE_BUCKET_MS); + if (!state.dirty && hz === state.snapshot.stats.hz && ageBucket === state.ageBucket) { + continue; + } + state.dirty = false; + state.ageBucket = ageBucket; + state.snapshot = { + slot: state.slot, + stats: { + frames: state.frames, + decodeErrors: state.decodeErrors, + hz, + ageMs, + lastFrameAtMs: state.lastFrameAtMs, + lastSeq: state.lastSeq, + lastTs: state.lastTs, + decodeFailing: state.decodeFailing, + }, + }; + for (const cb of state.ui) cb(); + } + } + + /** + * The producer behind the channels may have changed (a manifest was + * (re)confirmed): suspend the seq guard for REBASELINE_WINDOW_MS so a + * restarted counter is accepted, and a late high-seq frame from the old + * producer is displaced by the next arrival instead of locking it out. + * The clock behind the header timestamps may have changed with it, so each + * channel re-learns its skew (and drops rate history recorded against the + * old clock) at its next frame. + */ + rebaseline(): void { + this.#rebaselineUntilMs = this.#now() + REBASELINE_WINDOW_MS; + for (const state of this.#channels.values()) state.skewRearm = true; + } + + /** Drop all data (the producer was invalidated); subscribers stay registered. */ + reset(): void { + for (const state of this.#channels.values()) { + state.slot = null; + state.frames = 0; + state.decodeErrors = 0; + state.decodeFailing = false; + state.lastFrameAtMs = 0; + state.lastSeq = -1; + state.lastTs = 0; + state.skewMs = Infinity; + state.skewRearm = false; + state.rate.fill(0); + state.rateHead = -1; + state.dirty = false; + state.ageBucket = -1; + state.snapshot = EMPTY_SNAPSHOT; + for (const cb of state.direct) cb(); + for (const cb of state.ui) cb(); + } + } +} + +export interface SessionStatus { + transport: TransportPhase; + robot: RobotInfo | null; + robotCount: number; + channels: ChannelSpec[]; + epoch: number; + lastError: string | null; +} + +export class StatusStore { + #status: SessionStatus = { + transport: { phase: "connecting", attempt: 1 }, + robot: null, + robotCount: 0, + channels: [], + epoch: 0, + lastError: null, + }; + #subscribers = new Set<() => void>(); + + get = (): SessionStatus => this.#status; + + update(patch: Partial): void { + this.#status = { ...this.#status, ...patch }; + for (const cb of this.#subscribers) cb(); + } + + subscribe = (cb: () => void): () => void => { + this.#subscribers.add(cb); + return () => this.#subscribers.delete(cb); + }; +} diff --git a/web/cockpit/src/session/transport.test.ts b/web/cockpit/src/session/transport.test.ts new file mode 100644 index 0000000000..92d861fa58 --- /dev/null +++ b/web/cockpit/src/session/transport.test.ts @@ -0,0 +1,336 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PROTOCOL_VERSION } from "@dimos/shared"; +import { + backoffDelayMs, + CONNECT_TIMEOUT_MS, + ReconnectingTransport, + type RelayInfo, + type TransportPhase, + type WebTransportLike, +} from "./transport.ts"; + +const INFO: RelayInfo = { + wtUrl: "https://127.0.0.1:1234/viewer", + certHash: "aGFzaA==", + v: PROTOCOL_VERSION, +}; + +interface FakeWt extends WebTransportLike { + resolveClosed(info?: unknown): void; + rejectClosed(): void; +} + +function makeFakeWt({ readyFails = false, readyHangs = false } = {}): FakeWt { + let resolveClosed = (_info?: unknown) => {}; + let rejectClosed = () => {}; + const closed = new Promise((resolve, reject) => { + resolveClosed = (info?: unknown) => resolve(info); + rejectClosed = () => reject(new Error("connection lost")); + }); + let ready: Promise = Promise.resolve(); + if (readyFails) ready = Promise.reject(new Error("handshake failed")); + if (readyHangs) ready = new Promise(() => {}); + return { + ready, + closed, + close: vi.fn(), + createBidirectionalStream: () => Promise.reject(new Error("not used in transport tests")), + incomingUnidirectionalStreams: new ReadableStream(), + resolveClosed, + rejectClosed, + }; +} + +describe("backoffDelayMs", () => { + it("doubles from 500 ms and caps at 8 s", () => { + expect([1, 2, 3, 4, 5, 6, 7].map(backoffDelayMs)).toEqual([ + 500, + 1000, + 2000, + 4000, + 8000, + 8000, + 8000, + ]); + }); +}); + +describe("ReconnectingTransport", () => { + let phases: TransportPhase[]; + + beforeEach(() => { + vi.useFakeTimers(); + phases = []; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function makeTransport(opts: { + fetchInfo: (signal: AbortSignal) => Promise; + createWebTransport?: (info: RelayInfo) => WebTransportLike; + onSession?: (wt: WebTransportLike) => Promise; + // welcome normally arrives right after connect; false keeps the session + // from signalling readiness so the pre-welcome phase can be observed. + autoReady?: boolean; + }) { + const transport: ReconnectingTransport = new ReconnectingTransport( + { + onPhase: (p) => phases.push(p), + onSession: (wt) => { + if (opts.autoReady !== false) transport.sessionReady(); + return opts.onSession !== undefined ? opts.onSession(wt) : new Promise(() => {}); + }, + }, + { + fetchInfo: opts.fetchInfo, + createWebTransport: opts.createWebTransport ?? (() => makeFakeWt()), + }, + ); + return transport; + } + + it("retries with doubling backoff and re-fetches /api/info every attempt", async () => { + const fetchInfo = vi.fn(() => Promise.reject(new Error("relay down"))); + const transport = makeTransport({ fetchInfo }); + transport.start(); + + await vi.advanceTimersByTimeAsync(0); + expect(fetchInfo).toHaveBeenCalledTimes(1); + expect(phases).toEqual([ + { phase: "connecting", attempt: 1 }, + { phase: "reconnecting", attempt: 1, retryAtMs: Date.now() + 500 }, + ]); + + await vi.advanceTimersByTimeAsync(499); + expect(fetchInfo).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(fetchInfo).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(1000); + expect(fetchInfo).toHaveBeenCalledTimes(3); + const delays = phases + .filter((p) => p.phase === "reconnecting") + .map((p) => (p.phase === "reconnecting" ? p.attempt : 0)); + expect(delays).toEqual([1, 2, 3]); + transport.stop(); + }); + + it("treats a dying connection as a failed attempt and resets after success", async () => { + const wts: FakeWt[] = []; + const fetchInfo = vi.fn(() => Promise.resolve(INFO)); + const transport = makeTransport({ + fetchInfo, + createWebTransport: () => { + const wt = makeFakeWt(); + wts.push(wt); + return wt; + }, + }); + transport.start(); + + await vi.advanceTimersByTimeAsync(0); + expect(transport.phase).toEqual({ phase: "connected" }); + expect(wts).toHaveLength(1); + + wts[0].rejectClosed(); + await vi.advanceTimersByTimeAsync(0); + expect(transport.phase).toMatchObject({ + phase: "reconnecting", + attempt: 1, + retryAtMs: Date.now() + 500, + }); + expect(wts[0].close).toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(500); + expect(transport.phase).toEqual({ phase: "connected" }); + expect(wts).toHaveLength(2); + expect(fetchInfo).toHaveBeenCalledTimes(2); + + wts[1].rejectClosed(); + await vi.advanceTimersByTimeAsync(0); + // Backoff reset: the delay after a successful connection is 500 ms again. + expect(transport.phase).toMatchObject({ + phase: "reconnecting", + attempt: 1, + retryAtMs: Date.now() + 500, + }); + transport.stop(); + }); + + it("surfaces the server's close reason in the reconnecting phase", async () => { + const wts: FakeWt[] = []; + const transport = makeTransport({ + fetchInfo: () => Promise.resolve(INFO), + createWebTransport: () => { + const wt = makeFakeWt(); + wts.push(wt); + return wt; + }, + }); + transport.start(); + await vi.advanceTimersByTimeAsync(0); + + // The relay kicks with a WebTransportCloseInfo; its reason must reach the UI. + wts[0].resolveClosed({ closeCode: 1, reason: "reliable channel overflow" }); + await vi.advanceTimersByTimeAsync(0); + expect(transport.phase).toMatchObject({ + phase: "reconnecting", + reason: "reliable channel overflow", + }); + + // The next disconnect without a reason must not inherit the old one. + await vi.advanceTimersByTimeAsync(500); + expect(transport.phase).toEqual({ phase: "connected" }); + wts[1].resolveClosed(undefined); + await vi.advanceTimersByTimeAsync(0); + expect(transport.phase).toEqual({ + phase: "reconnecting", + attempt: 1, + retryAtMs: Date.now() + 500, + }); + transport.stop(); + }); + + it("reconnects when the session run ends even if the connection stays up", async () => { + const fetchInfo = vi.fn(() => Promise.resolve(INFO)); + let endSession = () => {}; + const transport = makeTransport({ + fetchInfo, + onSession: () => + new Promise((resolve) => { + endSession = resolve; + }), + }); + transport.start(); + + await vi.advanceTimersByTimeAsync(0); + expect(transport.phase).toEqual({ phase: "connected" }); + endSession(); + await vi.advanceTimersByTimeAsync(0); + expect(transport.phase.phase).toBe("reconnecting"); + transport.stop(); + }); + + it("stop() cancels a pending retry", async () => { + const fetchInfo = vi.fn(() => Promise.reject(new Error("down"))); + const transport = makeTransport({ fetchInfo }); + transport.start(); + await vi.advanceTimersByTimeAsync(0); + expect(fetchInfo).toHaveBeenCalledTimes(1); + + transport.stop(); + await vi.advanceTimersByTimeAsync(60_000); + expect(fetchInfo).toHaveBeenCalledTimes(1); + }); + + it("fail() is terminal", async () => { + const fetchInfo = vi.fn(() => Promise.reject(new Error("down"))); + const transport = makeTransport({ fetchInfo }); + transport.start(); + await vi.advanceTimersByTimeAsync(0); + + transport.fail("protocol mismatch"); + expect(transport.phase).toEqual({ phase: "failed", reason: "protocol mismatch" }); + await vi.advanceTimersByTimeAsync(60_000); + expect(fetchInfo).toHaveBeenCalledTimes(1); + expect(phases.at(-1)).toEqual({ phase: "failed", reason: "protocol mismatch" }); + }); + + it("fails permanently on a protocol version mismatch from /api/info", async () => { + const fetchInfo = vi.fn(() => Promise.resolve({ ...INFO, v: 99 })); + const transport = makeTransport({ fetchInfo }); + transport.start(); + await vi.advanceTimersByTimeAsync(0); + + expect(transport.phase.phase).toBe("failed"); + await vi.advanceTimersByTimeAsync(60_000); + expect(fetchInfo).toHaveBeenCalledTimes(1); + }); + + it("counts a failed WebTransport handshake as a failed attempt", async () => { + const fetchInfo = vi.fn(() => Promise.resolve(INFO)); + const transport = makeTransport({ + fetchInfo, + createWebTransport: () => makeFakeWt({ readyFails: true }), + }); + transport.start(); + await vi.advanceTimersByTimeAsync(0); + expect(transport.phase.phase).toBe("reconnecting"); + transport.stop(); + }); + + it("stays in connecting after the QUIC handshake until sessionReady", async () => { + const transport = makeTransport({ + fetchInfo: () => Promise.resolve(INFO), + autoReady: false, + }); + transport.start(); + await vi.advanceTimersByTimeAsync(0); + // QUIC is up but no welcome arrived: the page is not usable yet. + expect(transport.phase).toEqual({ phase: "connecting", attempt: 1 }); + + transport.sessionReady(); + expect(transport.phase).toEqual({ phase: "connected" }); + transport.stop(); + }); + + it("times out a hung /api/info fetch and falls back to backoff", async () => { + const signals: AbortSignal[] = []; + const transport = makeTransport({ + fetchInfo: (signal) => + new Promise((_, reject) => { + signals.push(signal); + signal.addEventListener("abort", () => reject(new Error("aborted"))); + }), + }); + transport.start(); + await vi.advanceTimersByTimeAsync(0); + expect(transport.phase).toEqual({ phase: "connecting", attempt: 1 }); + + await vi.advanceTimersByTimeAsync(CONNECT_TIMEOUT_MS); + expect(signals[0].aborted).toBe(true); + expect(transport.phase).toMatchObject({ phase: "reconnecting", attempt: 1 }); + transport.stop(); + }); + + it("times out a hung WebTransport handshake and closes the attempt", async () => { + const wts: FakeWt[] = []; + const transport = makeTransport({ + fetchInfo: () => Promise.resolve(INFO), + createWebTransport: () => { + const wt = makeFakeWt({ readyHangs: true }); + wts.push(wt); + return wt; + }, + }); + transport.start(); + await vi.advanceTimersByTimeAsync(0); + expect(transport.phase).toEqual({ phase: "connecting", attempt: 1 }); + + await vi.advanceTimersByTimeAsync(CONNECT_TIMEOUT_MS); + expect(transport.phase).toMatchObject({ phase: "reconnecting", attempt: 1 }); + expect(wts[0].close).toHaveBeenCalled(); + transport.stop(); + }); + + it("stop() aborts an in-flight connection attempt", async () => { + const signals: AbortSignal[] = []; + const transport = makeTransport({ + fetchInfo: (signal) => + new Promise((_, reject) => { + signals.push(signal); + signal.addEventListener("abort", () => reject(new Error("aborted"))); + }), + }); + transport.start(); + await vi.advanceTimersByTimeAsync(0); + + transport.stop(); + expect(signals[0].aborted).toBe(true); + await vi.advanceTimersByTimeAsync(60_000); + expect(signals).toHaveLength(1); + expect(phases.filter((p) => p.phase === "reconnecting")).toEqual([]); + }); +}); diff --git a/web/cockpit/src/session/transport.ts b/web/cockpit/src/session/transport.ts new file mode 100644 index 0000000000..6ef32182ed --- /dev/null +++ b/web/cockpit/src/session/transport.ts @@ -0,0 +1,238 @@ +// Reconnecting WebTransport wrapper: fetch /api/info, connect with the pinned +// cert hash, hand the session off, and retry forever with capped backoff when +// anything dies. /api/info is re-fetched on every attempt because a relay +// restart means a new QUIC port and a new ephemeral certificate. + +import { PROTOCOL_VERSION } from "@dimos/shared"; + +export interface RelayInfo { + wtUrl: string; + certHash: string; + v: number; +} + +export type TransportPhase = + | { phase: "connecting"; attempt: number } + // connected: the session saw the relay's welcome (sessionReady()), not + // just QUIC readiness - a reachable relay with a wedged session stays + // "connecting". + | { phase: "connected" } + // reason: why the previous connection ended (e.g. the relay's kick reason + // from WebTransportCloseInfo), when known. + | { phase: "reconnecting"; attempt: number; retryAtMs: number; reason?: string } + | { phase: "failed"; reason: string }; + +// Structural subset of WebTransport so tests (and later non-browser hosts) can +// fake it. The real WebTransport satisfies this as-is. +export interface BidiStreamLike { + readable: ReadableStream; + writable: WritableStream; +} + +export interface WebTransportLike { + ready: Promise; + closed: Promise; + close(): void; + createBidirectionalStream(): Promise; + incomingUnidirectionalStreams: ReadableStream>; +} + +export interface TransportEvents { + onPhase(phase: TransportPhase): void; + /** Run one session; resolving (or rejecting) means the session is over. */ + onSession(wt: WebTransportLike, info: RelayInfo): Promise; +} + +export interface TransportDeps { + fetchInfo?: (signal: AbortSignal) => Promise; + createWebTransport?: (info: RelayInfo) => WebTransportLike; + now?: () => number; +} + +export const BACKOFF_INITIAL_MS = 500; +export const BACKOFF_MAX_MS = 8000; +// Per-attempt deadline for /api/info + the QUIC handshake: a blackholed +// attempt must fail into backoff instead of pinning the phase at +// "connecting" forever. +export const CONNECT_TIMEOUT_MS = 10_000; + +/** Delay before retry number `failures` (1-based): 500 ms doubling to 8 s. */ +export function backoffDelayMs(failures: number): number { + return Math.min(BACKOFF_INITIAL_MS * 2 ** (failures - 1), BACKOFF_MAX_MS); +} + +/** `promise`, but rejecting as soon as `signal` aborts (timeout or stop()). */ +function abortable(promise: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error("aborted")); + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + promise.then(resolve, reject); + }); +} + +export async function fetchRelayInfo(signal: AbortSignal): Promise { + const resp = await fetch("/api/info", { signal }); + if (!resp.ok) throw new Error(`/api/info returned ${resp.status}`); + const data: unknown = await resp.json(); + if ( + typeof data !== "object" || data === null || + typeof (data as Record).wtUrl !== "string" || + typeof (data as Record).certHash !== "string" || + typeof (data as Record).v !== "number" + ) { + throw new Error("/api/info returned an unexpected shape"); + } + return data as unknown as RelayInfo; +} + +function connectWebTransport(info: RelayInfo): WebTransportLike { + const hash = Uint8Array.from(atob(info.certHash), (c) => c.charCodeAt(0)); + return new WebTransport(info.wtUrl, { + serverCertificateHashes: [{ algorithm: "sha-256", value: hash }], + }); +} + +export class ReconnectingTransport { + #events: TransportEvents; + #fetchInfo: (signal: AbortSignal) => Promise; + #create: (info: RelayInfo) => WebTransportLike; + #now: () => number; + + #phase: TransportPhase = { phase: "connecting", attempt: 1 }; + #wt: WebTransportLike | null = null; + #timer: ReturnType | null = null; + #wake: (() => void) | null = null; + #abort: AbortController | null = null; + #failures = 0; + #started = false; + #stopped = false; + + constructor(events: TransportEvents, deps: TransportDeps = {}) { + this.#events = events; + this.#fetchInfo = deps.fetchInfo ?? fetchRelayInfo; + this.#create = deps.createWebTransport ?? connectWebTransport; + this.#now = deps.now ?? Date.now; + } + + get phase(): TransportPhase { + return this.#phase; + } + + start(): void { + if (this.#started) return; + this.#started = true; + void this.#loop(); + } + + /** Stop retrying and close the current connection. No further events. */ + stop(): void { + if (this.#stopped) return; + this.#stopped = true; + if (this.#timer !== null) clearTimeout(this.#timer); + this.#abort?.abort(); + this.#wake?.(); + this.#closeCurrent(); + } + + /** + * Session-level success (the relay's welcome arrived): publish the + * connected phase and reset the backoff. A connection that dies before + * welcome keeps counting as a failure. + */ + sessionReady(): void { + if (this.#stopped) return; + this.#failures = 0; + if (this.#phase.phase !== "connected") this.#setPhase({ phase: "connected" }); + } + + /** Terminal failure (protocol mismatch, no WebTransport support, ...). */ + fail(reason: string): void { + if (this.#stopped) return; + this.#setPhase({ phase: "failed", reason }); + this.stop(); + } + + #setPhase(phase: TransportPhase): void { + this.#phase = phase; + this.#events.onPhase(phase); + } + + #closeCurrent(): void { + try { + this.#wt?.close(); + } catch { + // already closed + } + this.#wt = null; + } + + async #loop(): Promise { + let endReason: string | null = null; + while (!this.#stopped) { + this.#setPhase({ phase: "connecting", attempt: this.#failures + 1 }); + const abort = new AbortController(); + this.#abort = abort; + const deadline = setTimeout(() => abort.abort(), CONNECT_TIMEOUT_MS); + try { + const info = await this.#fetchInfo(abort.signal); + if (this.#stopped) return; + if (info.v !== PROTOCOL_VERSION) { + this.fail(`relay speaks protocol v${info.v}, this page speaks v${PROTOCOL_VERSION}`); + return; + } + const wt = this.#create(info); + this.#wt = wt; + await abortable(wt.ready, abort.signal); + if (this.#stopped) return; + clearTimeout(deadline); + // The session run and the connection death both end the session; wait + // for whichever comes first, then drop the connection. The connected + // phase is published by sessionReady() once the session saw welcome. + await Promise.race([ + this.#events.onSession(wt, info).catch(() => {}), + wt.closed.then( + (info) => { + const reason = (info as { reason?: string } | undefined)?.reason; + if (reason) endReason = reason; + }, + (e) => { + endReason = String(e); + }, + ), + ]); + } catch { + // fetch or handshake failed or timed out; fall through to backoff + } finally { + clearTimeout(deadline); + this.#abort = null; + } + this.#closeCurrent(); + if (this.#stopped) return; + this.#failures += 1; + const delay = backoffDelayMs(this.#failures); + this.#setPhase({ + phase: "reconnecting", + attempt: this.#failures, + retryAtMs: this.#now() + delay, + ...(endReason !== null ? { reason: endReason } : {}), + }); + endReason = null; + await this.#sleep(delay); + } + } + + #sleep(ms: number): Promise { + return new Promise((resolve) => { + this.#wake = resolve; + this.#timer = setTimeout(() => { + this.#timer = null; + this.#wake = null; + resolve(); + }, ms); + }); + } +} diff --git a/web/cockpit/tsconfig.json b/web/cockpit/tsconfig.json new file mode 100644 index 0000000000..a3076b93a9 --- /dev/null +++ b/web/cockpit/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client", "node"], + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "useDefineForClassFields": true, + "isolatedModules": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@dimos/shared": ["../shared/protocol.ts"], + "@dimos/shared/manifest": ["../shared/manifest.ts"] + } + }, + "include": ["src", "vite.config.ts"] +} diff --git a/web/cockpit/vite.config.ts b/web/cockpit/vite.config.ts new file mode 100644 index 0000000000..2560d9b204 --- /dev/null +++ b/web/cockpit/vite.config.ts @@ -0,0 +1,38 @@ +/// +import { fileURLToPath } from "node:url"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// The cockpit imports the wire protocol straight from the workspace-sibling +// shared/ package; vite needs the aliases (and fs.allow) because those files +// live outside the cockpit root. The subpath alias must come first: aliases +// match in order and the bare one would otherwise swallow it. +const sharedProtocol = fileURLToPath(new URL("../shared/protocol.ts", import.meta.url)); +const sharedManifest = fileURLToPath(new URL("../shared/manifest.ts", import.meta.url)); +const webRoot = fileURLToPath(new URL("..", import.meta.url)); + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@dimos/shared/manifest": sharedManifest, + "@dimos/shared": sharedProtocol, + }, + }, + server: { + // HMR dev on :5173: /api/info is answered by the relay on :7780; the + // WebTransport connection then goes straight to the advertised wtUrl. + proxy: { "/api": "http://127.0.0.1:7780" }, + fs: { allow: [webRoot] }, + }, + build: { target: "es2022" }, + test: { + // UI tests (.test.tsx) opt into happy-dom per file via + // @vitest-environment; everything else stays on node. + environment: "node", + include: ["src/**/*.test.{ts,tsx}"], + // Vitest's default forks pool needs Node child-process IPC, which Deno + // does not emulate reliably (tinypool "fd is not from BiPipe" under CI). + pool: "threads", + }, +}); diff --git a/web/deno.json b/web/deno.json index 55faeeb6fa..ec285e5f14 100644 --- a/web/deno.json +++ b/web/deno.json @@ -1,28 +1,33 @@ { "workspace": [ "./relay", - "./shared" + "./shared", + "./cockpit" ], "unstable": [ "net" ], - "nodeModulesDir": "none", + "nodeModulesDir": "auto", "imports": { "@std/assert": "jsr:@std/assert@^1", "@std/cli": "jsr:@std/cli@^1" }, "tasks": { "dev": "deno run --allow-net --allow-read relay/main.ts", - "test": "deno test --allow-net --allow-read", - "check": "deno check ." + "test": "deno test --allow-net --allow-read relay shared", + "check": "deno check relay shared" }, "fmt": { "lineWidth": 100, "exclude": [ - "shared/fixtures/*.json" + "shared/fixtures/*.json", + "cockpit/dist/" ] }, "lint": { + "exclude": [ + "cockpit/dist/" + ], "rules": { "tags": [ "recommended" diff --git a/web/deno.lock b/web/deno.lock index e95a6778a2..7675a9a245 100644 --- a/web/deno.lock +++ b/web/deno.lock @@ -3,9 +3,23 @@ "specifiers": { "jsr:@std/assert@1": "1.0.19", "jsr:@std/cli@1": "1.0.28", + "jsr:@std/fmt@^1.0.9": "1.0.9", "jsr:@std/internal@^1.0.12": "1.0.12", "npm:@peculiar/x509@2": "2.0.0", - "npm:reflect-metadata@0.2": "0.2.2" + "npm:@types/node@^24.10.1": "24.12.2", + "npm:@types/react-dom@^19.2.0": "19.2.3_@types+react@19.2.17", + "npm:@types/react@^19.2.0": "19.2.17", + "npm:@vitejs/plugin-react@^5.1.0": "5.2.0_vite@7.3.2__@types+node@24.12.2__picomatch@4.0.5_@babel+core@7.29.7_@types+node@24.12.2", + "npm:happy-dom@^20.11.1": "20.11.1", + "npm:react-dom@^19.2.0": "19.2.8_react@19.2.8", + "npm:react@^19.2.0": "19.2.8", + "npm:reflect-metadata@0.2": "0.2.2", + "npm:typescript@*": "5.9.3", + "npm:typescript@~5.9.2": "5.9.3", + "npm:vite@*": "7.3.2_@types+node@24.12.2_picomatch@4.0.5", + "npm:vite@^7.1.0": "7.3.2_@types+node@24.12.2_picomatch@4.0.5", + "npm:vitest@*": "3.2.7_@types+node@24.12.2_happy-dom@20.11.1_vite@7.3.2__@types+node@24.12.2__picomatch@4.0.5", + "npm:vitest@^3.2.4": "3.2.7_@types+node@24.12.2_happy-dom@20.11.1_vite@7.3.2__@types+node@24.12.2__picomatch@4.0.5" }, "jsr": { "@std/assert@1.0.19": { @@ -17,14 +31,312 @@ "@std/cli@1.0.28": { "integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a", "dependencies": [ + "jsr:@std/fmt", "jsr:@std/internal" ] }, + "@std/fmt@1.0.9": { + "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" + }, "@std/internal@1.0.12": { "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" } }, "npm": { + "@babel/code-frame@7.29.7": { + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dependencies": [ + "@babel/helper-validator-identifier", + "js-tokens@4.0.0", + "picocolors" + ] + }, + "@babel/compat-data@7.29.7": { + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==" + }, + "@babel/core@7.29.7": { + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dependencies": [ + "@babel/code-frame", + "@babel/generator", + "@babel/helper-compilation-targets", + "@babel/helper-module-transforms", + "@babel/helpers", + "@babel/parser", + "@babel/template", + "@babel/traverse", + "@babel/types", + "@jridgewell/remapping", + "convert-source-map", + "debug", + "gensync", + "json5", + "semver" + ] + }, + "@babel/generator@7.29.7": { + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dependencies": [ + "@babel/parser", + "@babel/types", + "@jridgewell/gen-mapping", + "@jridgewell/trace-mapping", + "jsesc" + ] + }, + "@babel/helper-compilation-targets@7.29.7": { + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dependencies": [ + "@babel/compat-data", + "@babel/helper-validator-option", + "browserslist", + "lru-cache", + "semver" + ] + }, + "@babel/helper-globals@7.29.7": { + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==" + }, + "@babel/helper-module-imports@7.29.7": { + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dependencies": [ + "@babel/traverse", + "@babel/types" + ] + }, + "@babel/helper-module-transforms@7.29.7_@babel+core@7.29.7": { + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dependencies": [ + "@babel/core", + "@babel/helper-module-imports", + "@babel/helper-validator-identifier", + "@babel/traverse" + ] + }, + "@babel/helper-plugin-utils@7.29.7": { + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==" + }, + "@babel/helper-string-parser@7.29.7": { + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==" + }, + "@babel/helper-validator-identifier@7.29.7": { + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==" + }, + "@babel/helper-validator-option@7.29.7": { + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==" + }, + "@babel/helpers@7.29.7": { + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dependencies": [ + "@babel/template", + "@babel/types" + ] + }, + "@babel/parser@7.29.7": { + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dependencies": [ + "@babel/types" + ], + "bin": true + }, + "@babel/plugin-transform-react-jsx-self@7.29.7_@babel+core@7.29.7": { + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-transform-react-jsx-source@7.29.7_@babel+core@7.29.7": { + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/template@7.29.7": { + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dependencies": [ + "@babel/code-frame", + "@babel/parser", + "@babel/types" + ] + }, + "@babel/traverse@7.29.7": { + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dependencies": [ + "@babel/code-frame", + "@babel/generator", + "@babel/helper-globals", + "@babel/parser", + "@babel/template", + "@babel/types", + "debug" + ] + }, + "@babel/types@7.29.7": { + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dependencies": [ + "@babel/helper-string-parser", + "@babel/helper-validator-identifier" + ] + }, + "@esbuild/aix-ppc64@0.27.7": { + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "os": ["aix"], + "cpu": ["ppc64"] + }, + "@esbuild/android-arm64@0.27.7": { + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@esbuild/android-arm@0.27.7": { + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "os": ["android"], + "cpu": ["arm"] + }, + "@esbuild/android-x64@0.27.7": { + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "os": ["android"], + "cpu": ["x64"] + }, + "@esbuild/darwin-arm64@0.27.7": { + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@esbuild/darwin-x64@0.27.7": { + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@esbuild/freebsd-arm64@0.27.7": { + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@esbuild/freebsd-x64@0.27.7": { + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@esbuild/linux-arm64@0.27.7": { + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@esbuild/linux-arm@0.27.7": { + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@esbuild/linux-ia32@0.27.7": { + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "os": ["linux"], + "cpu": ["ia32"] + }, + "@esbuild/linux-loong64@0.27.7": { + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@esbuild/linux-mips64el@0.27.7": { + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@esbuild/linux-ppc64@0.27.7": { + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@esbuild/linux-riscv64@0.27.7": { + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@esbuild/linux-s390x@0.27.7": { + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@esbuild/linux-x64@0.27.7": { + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@esbuild/netbsd-arm64@0.27.7": { + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "os": ["netbsd"], + "cpu": ["arm64"] + }, + "@esbuild/netbsd-x64@0.27.7": { + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@esbuild/openbsd-arm64@0.27.7": { + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "os": ["openbsd"], + "cpu": ["arm64"] + }, + "@esbuild/openbsd-x64@0.27.7": { + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@esbuild/openharmony-arm64@0.27.7": { + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@esbuild/sunos-x64@0.27.7": { + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@esbuild/win32-arm64@0.27.7": { + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@esbuild/win32-ia32@0.27.7": { + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@esbuild/win32-x64@0.27.7": { + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@jridgewell/gen-mapping@0.3.13": { + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": [ + "@jridgewell/sourcemap-codec", + "@jridgewell/trace-mapping" + ] + }, + "@jridgewell/remapping@2.3.5": { + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dependencies": [ + "@jridgewell/gen-mapping", + "@jridgewell/trace-mapping" + ] + }, + "@jridgewell/resolve-uri@3.1.2": { + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" + }, + "@jridgewell/sourcemap-codec@1.5.5": { + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "@jridgewell/trace-mapping@0.3.31": { + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": [ + "@jridgewell/resolve-uri", + "@jridgewell/sourcemap-codec" + ] + }, "@peculiar/asn1-cms@2.8.0": { "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", "dependencies": [ @@ -142,6 +454,273 @@ "tsyringe" ] }, + "@rolldown/pluginutils@1.0.0-rc.3": { + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==" + }, + "@rollup/rollup-android-arm-eabi@4.60.2": { + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "os": ["android"], + "cpu": ["arm"] + }, + "@rollup/rollup-android-arm64@4.60.2": { + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@rollup/rollup-darwin-arm64@4.60.2": { + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@rollup/rollup-darwin-x64@4.60.2": { + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@rollup/rollup-freebsd-arm64@4.60.2": { + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@rollup/rollup-freebsd-x64@4.60.2": { + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@rollup/rollup-linux-arm-gnueabihf@4.60.2": { + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@rollup/rollup-linux-arm-musleabihf@4.60.2": { + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@rollup/rollup-linux-arm64-gnu@4.60.2": { + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rollup/rollup-linux-arm64-musl@4.60.2": { + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rollup/rollup-linux-loong64-gnu@4.60.2": { + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@rollup/rollup-linux-loong64-musl@4.60.2": { + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@rollup/rollup-linux-ppc64-gnu@4.60.2": { + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@rollup/rollup-linux-ppc64-musl@4.60.2": { + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@rollup/rollup-linux-riscv64-gnu@4.60.2": { + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@rollup/rollup-linux-riscv64-musl@4.60.2": { + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@rollup/rollup-linux-s390x-gnu@4.60.2": { + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@rollup/rollup-linux-x64-gnu@4.60.2": { + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rollup/rollup-linux-x64-musl@4.60.2": { + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rollup/rollup-openbsd-x64@4.60.2": { + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@rollup/rollup-openharmony-arm64@4.60.2": { + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@rollup/rollup-win32-arm64-msvc@4.60.2": { + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@rollup/rollup-win32-ia32-msvc@4.60.2": { + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@rollup/rollup-win32-x64-gnu@4.60.2": { + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@rollup/rollup-win32-x64-msvc@4.60.2": { + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@types/babel__core@7.20.5": { + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dependencies": [ + "@babel/parser", + "@babel/types", + "@types/babel__generator", + "@types/babel__template", + "@types/babel__traverse" + ] + }, + "@types/babel__generator@7.27.0": { + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dependencies": [ + "@babel/types" + ] + }, + "@types/babel__template@7.4.4": { + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dependencies": [ + "@babel/parser", + "@babel/types" + ] + }, + "@types/babel__traverse@7.28.0": { + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dependencies": [ + "@babel/types" + ] + }, + "@types/chai@5.2.3": { + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dependencies": [ + "@types/deep-eql", + "assertion-error" + ] + }, + "@types/deep-eql@4.0.2": { + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==" + }, + "@types/estree@1.0.8": { + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + }, + "@types/node@24.12.2": { + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dependencies": [ + "undici-types" + ] + }, + "@types/react-dom@19.2.3_@types+react@19.2.17": { + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dependencies": [ + "@types/react" + ] + }, + "@types/react@19.2.17": { + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dependencies": [ + "csstype" + ] + }, + "@types/whatwg-mimetype@3.0.2": { + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==" + }, + "@types/ws@8.18.1": { + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dependencies": [ + "@types/node" + ] + }, + "@vitejs/plugin-react@5.2.0_vite@7.3.2__@types+node@24.12.2__picomatch@4.0.5_@babel+core@7.29.7_@types+node@24.12.2": { + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dependencies": [ + "@babel/core", + "@babel/plugin-transform-react-jsx-self", + "@babel/plugin-transform-react-jsx-source", + "@rolldown/pluginutils", + "@types/babel__core", + "react-refresh", + "vite" + ] + }, + "@vitest/expect@3.2.7": { + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dependencies": [ + "@types/chai", + "@vitest/spy", + "@vitest/utils", + "chai", + "tinyrainbow" + ] + }, + "@vitest/mocker@3.2.7_vite@7.3.2__@types+node@24.12.2__picomatch@4.0.5_@types+node@24.12.2": { + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dependencies": [ + "@vitest/spy", + "estree-walker", + "magic-string", + "vite" + ], + "optionalPeers": [ + "vite" + ] + }, + "@vitest/pretty-format@3.2.7": { + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dependencies": [ + "tinyrainbow" + ] + }, + "@vitest/runner@3.2.7": { + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dependencies": [ + "@vitest/utils", + "pathe", + "strip-literal" + ] + }, + "@vitest/snapshot@3.2.7": { + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dependencies": [ + "@vitest/pretty-format", + "magic-string", + "pathe" + ] + }, + "@vitest/spy@3.2.7": { + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dependencies": [ + "tinyspy" + ] + }, + "@vitest/utils@3.2.7": { + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dependencies": [ + "@vitest/pretty-format", + "loupe", + "tinyrainbow" + ] + }, "asn1js@3.0.10": { "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", "dependencies": [ @@ -150,6 +729,206 @@ "tslib@2.8.1" ] }, + "assertion-error@2.0.1": { + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==" + }, + "baseline-browser-mapping@2.11.1": { + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", + "bin": true + }, + "browserslist@4.28.7": { + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dependencies": [ + "baseline-browser-mapping", + "caniuse-lite", + "electron-to-chromium", + "node-releases", + "update-browserslist-db" + ], + "bin": true + }, + "buffer-image-size@0.6.4": { + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dependencies": [ + "@types/node" + ] + }, + "cac@6.7.14": { + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==" + }, + "caniuse-lite@1.0.30001806": { + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==" + }, + "chai@5.3.3": { + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dependencies": [ + "assertion-error", + "check-error", + "deep-eql", + "loupe", + "pathval" + ] + }, + "check-error@2.1.3": { + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==" + }, + "convert-source-map@2.0.0": { + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + }, + "csstype@3.2.3": { + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "debug@4.4.3": { + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": [ + "ms" + ] + }, + "deep-eql@5.0.2": { + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==" + }, + "electron-to-chromium@1.5.395": { + "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==" + }, + "entities@7.0.1": { + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==" + }, + "es-module-lexer@1.7.0": { + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==" + }, + "esbuild@0.27.7": { + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "optionalDependencies": [ + "@esbuild/aix-ppc64", + "@esbuild/android-arm", + "@esbuild/android-arm64", + "@esbuild/android-x64", + "@esbuild/darwin-arm64", + "@esbuild/darwin-x64", + "@esbuild/freebsd-arm64", + "@esbuild/freebsd-x64", + "@esbuild/linux-arm", + "@esbuild/linux-arm64", + "@esbuild/linux-ia32", + "@esbuild/linux-loong64", + "@esbuild/linux-mips64el", + "@esbuild/linux-ppc64", + "@esbuild/linux-riscv64", + "@esbuild/linux-s390x", + "@esbuild/linux-x64", + "@esbuild/netbsd-arm64", + "@esbuild/netbsd-x64", + "@esbuild/openbsd-arm64", + "@esbuild/openbsd-x64", + "@esbuild/openharmony-arm64", + "@esbuild/sunos-x64", + "@esbuild/win32-arm64", + "@esbuild/win32-ia32", + "@esbuild/win32-x64" + ], + "scripts": true, + "bin": true + }, + "escalade@3.2.0": { + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" + }, + "estree-walker@3.0.3": { + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dependencies": [ + "@types/estree" + ] + }, + "expect-type@1.4.0": { + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==" + }, + "fdir@6.5.0_picomatch@4.0.5": { + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dependencies": [ + "picomatch" + ], + "optionalPeers": [ + "picomatch" + ] + }, + "fsevents@2.3.3": { + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "os": ["darwin"], + "scripts": true + }, + "gensync@1.0.0-beta.2": { + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" + }, + "happy-dom@20.11.1": { + "integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==", + "dependencies": [ + "@types/node", + "@types/whatwg-mimetype", + "@types/ws", + "buffer-image-size", + "entities", + "whatwg-mimetype", + "ws" + ] + }, + "js-tokens@4.0.0": { + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "js-tokens@9.0.1": { + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==" + }, + "jsesc@3.1.0": { + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "bin": true + }, + "json5@2.2.3": { + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "bin": true + }, + "loupe@3.2.1": { + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==" + }, + "lru-cache@5.1.1": { + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dependencies": [ + "yallist" + ] + }, + "magic-string@0.30.21": { + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": [ + "@jridgewell/sourcemap-codec" + ] + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "nanoid@3.3.11": { + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "bin": true + }, + "node-releases@2.0.51": { + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==" + }, + "pathe@2.0.3": { + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" + }, + "pathval@2.0.1": { + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==" + }, + "picocolors@1.1.1": { + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "picomatch@4.0.5": { + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==" + }, + "postcss@8.5.10": { + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dependencies": [ + "nanoid", + "picocolors", + "source-map-js" + ] + }, "pvtsutils@1.3.6": { "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", "dependencies": [ @@ -159,9 +938,104 @@ "pvutils@1.1.5": { "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==" }, + "react-dom@19.2.8_react@19.2.8": { + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "dependencies": [ + "react", + "scheduler" + ] + }, + "react-refresh@0.18.0": { + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==" + }, + "react@19.2.8": { + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==" + }, "reflect-metadata@0.2.2": { "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, + "rollup@4.60.2": { + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dependencies": [ + "@types/estree" + ], + "optionalDependencies": [ + "@rollup/rollup-android-arm-eabi", + "@rollup/rollup-android-arm64", + "@rollup/rollup-darwin-arm64", + "@rollup/rollup-darwin-x64", + "@rollup/rollup-freebsd-arm64", + "@rollup/rollup-freebsd-x64", + "@rollup/rollup-linux-arm-gnueabihf", + "@rollup/rollup-linux-arm-musleabihf", + "@rollup/rollup-linux-arm64-gnu", + "@rollup/rollup-linux-arm64-musl", + "@rollup/rollup-linux-loong64-gnu", + "@rollup/rollup-linux-loong64-musl", + "@rollup/rollup-linux-ppc64-gnu", + "@rollup/rollup-linux-ppc64-musl", + "@rollup/rollup-linux-riscv64-gnu", + "@rollup/rollup-linux-riscv64-musl", + "@rollup/rollup-linux-s390x-gnu", + "@rollup/rollup-linux-x64-gnu", + "@rollup/rollup-linux-x64-musl", + "@rollup/rollup-openbsd-x64", + "@rollup/rollup-openharmony-arm64", + "@rollup/rollup-win32-arm64-msvc", + "@rollup/rollup-win32-ia32-msvc", + "@rollup/rollup-win32-x64-gnu", + "@rollup/rollup-win32-x64-msvc", + "fsevents" + ], + "bin": true + }, + "scheduler@0.27.0": { + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "semver@6.3.1": { + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": true + }, + "siginfo@2.0.0": { + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==" + }, + "source-map-js@1.2.1": { + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + }, + "stackback@0.0.2": { + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==" + }, + "std-env@3.10.0": { + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==" + }, + "strip-literal@3.1.0": { + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dependencies": [ + "js-tokens@9.0.1" + ] + }, + "tinybench@2.9.0": { + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==" + }, + "tinyexec@0.3.2": { + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==" + }, + "tinyglobby@0.2.17_picomatch@4.0.5": { + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dependencies": [ + "fdir", + "picomatch" + ] + }, + "tinypool@1.1.1": { + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==" + }, + "tinyrainbow@2.0.0": { + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==" + }, + "tinyspy@4.0.4": { + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==" + }, "tslib@1.14.1": { "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, @@ -173,6 +1047,104 @@ "dependencies": [ "tslib@1.14.1" ] + }, + "typescript@5.9.3": { + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "bin": true + }, + "undici-types@7.16.0": { + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" + }, + "update-browserslist-db@1.2.3_browserslist@4.28.7": { + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dependencies": [ + "browserslist", + "escalade", + "picocolors" + ], + "bin": true + }, + "vite-node@3.2.4_@types+node@24.12.2": { + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dependencies": [ + "cac", + "debug", + "es-module-lexer", + "pathe", + "vite" + ], + "bin": true + }, + "vite@7.3.2_@types+node@24.12.2_picomatch@4.0.5": { + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dependencies": [ + "@types/node", + "esbuild", + "fdir", + "picomatch", + "postcss", + "rollup", + "tinyglobby" + ], + "optionalDependencies": [ + "fsevents" + ], + "optionalPeers": [ + "@types/node" + ], + "bin": true + }, + "vitest@3.2.7_@types+node@24.12.2_happy-dom@20.11.1_vite@7.3.2__@types+node@24.12.2__picomatch@4.0.5": { + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dependencies": [ + "@types/chai", + "@types/node", + "@vitest/expect", + "@vitest/mocker", + "@vitest/pretty-format", + "@vitest/runner", + "@vitest/snapshot", + "@vitest/spy", + "@vitest/utils", + "chai", + "debug", + "expect-type", + "happy-dom", + "magic-string", + "pathe", + "picomatch", + "std-env", + "tinybench", + "tinyexec", + "tinyglobby", + "tinypool", + "tinyrainbow", + "vite", + "vite-node", + "why-is-node-running" + ], + "optionalPeers": [ + "@types/node", + "happy-dom" + ], + "bin": true + }, + "whatwg-mimetype@3.0.0": { + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==" + }, + "why-is-node-running@2.3.0": { + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dependencies": [ + "siginfo", + "stackback" + ], + "bin": true + }, + "ws@8.21.1": { + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==" + }, + "yallist@3.1.1": { + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" } }, "workspace": { @@ -181,6 +1153,22 @@ "jsr:@std/cli@1" ], "members": { + "cockpit": { + "packageJson": { + "dependencies": [ + "npm:@types/node@^24.10.1", + "npm:@types/react-dom@^19.2.0", + "npm:@types/react@^19.2.0", + "npm:@vitejs/plugin-react@^5.1.0", + "npm:happy-dom@^20.11.1", + "npm:react-dom@^19.2.0", + "npm:react@^19.2.0", + "npm:typescript@~5.9.2", + "npm:vite@^7.1.0", + "npm:vitest@^3.2.4" + ] + } + }, "relay": { "dependencies": [ "npm:@peculiar/x509@2",