diff options
Diffstat (limited to 'src')
27 files changed, 1776 insertions, 0 deletions
diff --git a/src/App.svelte b/src/App.svelte new file mode 100644 index 0000000..ffd5543 --- /dev/null +++ b/src/App.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import { App, createAppStore } from "./app"; + + const store = createAppStore(); +</script> + +<App {store} /> diff --git a/src/adapters/ws/index.test.ts b/src/adapters/ws/index.test.ts new file mode 100644 index 0000000..92b8753 --- /dev/null +++ b/src/adapters/ws/index.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it, vi } from "vitest"; +import type { WebSocketLike } from "./index"; +import { createSurfaceSocket } from "./index"; + +interface FakeSocket extends WebSocketLike { + sent: string[]; + resolveOpen(): void; + invokeMessage(data: string): void; + invokeClose(): void; +} + +function fakeSocket(): FakeSocket { + let onopen: (() => void) | null = null; + let onmessage: ((ev: { data: string }) => void) | null = null; + let onclose: ((ev: { code: number; reason: string }) => void) | null = null; + const sent: string[] = []; + + const ws: FakeSocket = { + send(data: string) { + sent.push(data); + }, + close() {}, + get onopen() { + return onopen; + }, + set onopen(fn) { + onopen = fn; + }, + get onmessage() { + return onmessage; + }, + set onmessage(fn) { + onmessage = fn; + }, + get onclose() { + return onclose; + }, + set onclose(fn) { + onclose = fn; + }, + resolveOpen() { + onopen?.(); + }, + invokeMessage(data: string) { + onmessage?.({ data }); + }, + invokeClose() { + onclose?.({ code: 1000, reason: "" }); + }, + sent, + }; + return ws; +} + +describe("createSurfaceSocket", () => { + it("sends queued messages once socket opens", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage, + socketFactory: () => ws, + }); + + handle.send({ type: "subscribe", surfaceId: "s1" }); + handle.send({ type: "subscribe", surfaceId: "s2" }); + expect(ws.sent).toHaveLength(0); + + ws.resolveOpen(); + expect(ws.sent).toHaveLength(2); + expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "s1" }); + expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "s2" }); + }); + + it("sends immediately when socket is already open", () => { + const ws = fakeSocket(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.sent.length = 0; + + handle.send({ type: "subscribe", surfaceId: "s1" }); + expect(ws.sent).toHaveLength(1); + }); + + it("routes inbound messages to onMessage via parseServerMessage", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] })); + expect(onMessage).toHaveBeenCalledOnce(); + expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] }); + }); + + it("drops malformed inbound messages silently", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage("not json"); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("auto-reconnects on close and fires onReopen after successful reconnect", () => { + vi.useFakeTimers(); + try { + const sockets: ReturnType<typeof fakeSocket>[] = []; + const onMessage = vi.fn(); + const onReopen = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onReopen, + socketFactory: () => { + const ws = fakeSocket(); + sockets.push(ws); + return ws; + }, + }); + + expect(sockets).toHaveLength(1); + sockets[0]?.resolveOpen(); + + // Simulate close + sockets[0]?.invokeClose(); + + // Fast-forward past the backoff delay + vi.advanceTimersByTime(600); + + expect(sockets).toHaveLength(2); + // onReopen should NOT have fired yet (socket not open) + expect(onReopen).not.toHaveBeenCalled(); + + sockets[1]?.resolveOpen(); + expect(onReopen).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not fire onReopen on initial connect", () => { + const ws = fakeSocket(); + const onReopen = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + onReopen, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + expect(onReopen).not.toHaveBeenCalled(); + }); + + it("close() prevents further reconnects", () => { + vi.useFakeTimers(); + try { + const sockets: ReturnType<typeof fakeSocket>[] = []; + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => { + const ws = fakeSocket(); + sockets.push(ws); + return ws; + }, + }); + + sockets[0]?.resolveOpen(); + sockets[0]?.invokeClose(); + handle.close(); + + vi.advanceTimersByTime(10_000); + expect(sockets).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + + it("close() prevents further sends", () => { + const ws = fakeSocket(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.sent.length = 0; + handle.close(); + + handle.send({ type: "subscribe", surfaceId: "s1" }); + expect(ws.sent).toHaveLength(0); + }); + + it("queues multiple sends before open and flushes in order", () => { + const ws = fakeSocket(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => ws, + }); + + handle.send({ type: "subscribe", surfaceId: "a" }); + handle.send({ type: "subscribe", surfaceId: "b" }); + handle.send({ type: "invoke", surfaceId: "a", actionId: "x", payload: 1 }); + ws.resolveOpen(); + + expect(ws.sent).toHaveLength(3); + expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "a" }); + expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "b" }); + expect(JSON.parse(ws.sent[2] ?? "")).toEqual({ + type: "invoke", + surfaceId: "a", + actionId: "x", + payload: 1, + }); + }); +}); diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts new file mode 100644 index 0000000..40eda2b --- /dev/null +++ b/src/adapters/ws/index.ts @@ -0,0 +1,98 @@ +import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract"; +import { nextBackoffMs, parseServerMessage, serialize } from "./logic"; + +export interface WebSocketLike { + send(data: string): void; + close(): void; + onopen: (() => void) | null; + onmessage: ((ev: { data: string }) => void) | null; + onclose: ((ev: { code: number; reason: string }) => void) | null; +} + +export interface SurfaceSocketOptions { + url: string; + onMessage: (msg: SurfaceServerMessage) => void; + onReopen?: () => void; + socketFactory?: (url: string) => WebSocketLike; +} + +export interface SurfaceSocketHandle { + send(msg: SurfaceClientMessage): void; + close(): void; +} + +export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHandle { + const factory = + opts.socketFactory ?? ((url: string) => new WebSocket(url) as unknown as WebSocketLike); + + let socket: WebSocketLike | null = null; + let disposed = false; + let reconnectAttempt = 0; + let reconnectTimer: ReturnType<typeof setTimeout> | null = null; + let isOpen = false; + const queue: string[] = []; + + function connect(isReconnect: boolean): void { + socket = factory(opts.url); + isOpen = false; + + socket.onopen = () => { + if (disposed) return; + isOpen = true; + reconnectAttempt = 0; + for (const raw of queue.splice(0)) { + socket?.send(raw); + } + if (isReconnect) { + opts.onReopen?.(); + } + }; + + socket.onmessage = (ev) => { + if (disposed) return; + const msg = parseServerMessage(ev.data); + if (msg !== null) { + opts.onMessage(msg); + } + }; + + socket.onclose = () => { + if (disposed) return; + isOpen = false; + scheduleReconnect(); + }; + } + + function scheduleReconnect(): void { + const delay = nextBackoffMs(reconnectAttempt); + reconnectAttempt++; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + if (disposed) return; + connect(true); + }, delay); + } + + connect(false); + + return { + send(msg: SurfaceClientMessage): void { + if (disposed) return; + const raw = serialize(msg); + if (isOpen) { + socket?.send(raw); + } else { + queue.push(raw); + } + }, + close(): void { + disposed = true; + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + socket?.close(); + socket = null; + }, + }; +} diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts new file mode 100644 index 0000000..62ae6a0 --- /dev/null +++ b/src/adapters/ws/logic.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vitest"; +import { nextBackoffMs, parseServerMessage, serialize } from "./logic"; + +describe("serialize", () => { + it("serializes a subscribe message", () => { + const msg = { type: "subscribe" as const, surfaceId: "s1" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); + + it("serializes an unsubscribe message", () => { + const msg = { type: "unsubscribe" as const, surfaceId: "s1" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); + + it("serializes an invoke message with payload", () => { + const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: true }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); + + it("serializes an invoke message without payload", () => { + const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "click" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); +}); + +describe("parseServerMessage", () => { + it("parses a catalog message", () => { + const data = JSON.stringify({ + type: "catalog", + catalog: [{ id: "s1", region: "r", title: "S1" }], + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "catalog", + catalog: [{ id: "s1", region: "r", title: "S1" }], + }); + }); + + it("parses a surface message", () => { + const data = JSON.stringify({ + type: "surface", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "surface", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + }); + }); + + it("parses an update message", () => { + const data = JSON.stringify({ + type: "update", + update: { + surfaceId: "s1", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + }, + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "update", + update: { + surfaceId: "s1", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + }, + }); + }); + + it("parses an error message with surfaceId", () => { + const data = JSON.stringify({ type: "error", surfaceId: "s1", message: "boom" }); + const result = parseServerMessage(data); + expect(result).toEqual({ type: "error", surfaceId: "s1", message: "boom" }); + }); + + it("parses an error message without surfaceId", () => { + const data = JSON.stringify({ type: "error", message: "global boom" }); + const result = parseServerMessage(data); + expect(result).toEqual({ type: "error", message: "global boom" }); + }); + + it("returns null for malformed JSON", () => { + expect(parseServerMessage("not json")).toBeNull(); + expect(parseServerMessage("{broken")).toBeNull(); + expect(parseServerMessage("")).toBeNull(); + }); + + it("returns null for non-object JSON", () => { + expect(parseServerMessage("42")).toBeNull(); + expect(parseServerMessage('"hello"')).toBeNull(); + expect(parseServerMessage("null")).toBeNull(); + expect(parseServerMessage("true")).toBeNull(); + expect(parseServerMessage("[1,2,3]")).toBeNull(); + }); + + it("returns null for unknown type", () => { + expect(parseServerMessage(JSON.stringify({ type: "unknown" }))).toBeNull(); + }); + + it("returns null when type is missing", () => { + expect(parseServerMessage(JSON.stringify({ foo: "bar" }))).toBeNull(); + }); + + it("returns null when type is not a string", () => { + expect(parseServerMessage(JSON.stringify({ type: 42 }))).toBeNull(); + }); + + it("returns null for catalog with non-array catalog field", () => { + expect(parseServerMessage(JSON.stringify({ type: "catalog", catalog: "nope" }))).toBeNull(); + }); + + it("returns null for surface with missing spec fields", () => { + expect(parseServerMessage(JSON.stringify({ type: "surface", spec: { id: "s1" } }))).toBeNull(); + }); + + it("returns null for surface with non-object spec", () => { + expect(parseServerMessage(JSON.stringify({ type: "surface", spec: "nope" }))).toBeNull(); + }); + + it("returns null for update with missing update field", () => { + expect(parseServerMessage(JSON.stringify({ type: "update" }))).toBeNull(); + }); + + it("returns null for update with invalid spec", () => { + expect( + parseServerMessage(JSON.stringify({ type: "update", update: { surfaceId: "s1", spec: {} } })), + ).toBeNull(); + }); + + it("returns null for error with non-string message", () => { + expect(parseServerMessage(JSON.stringify({ type: "error", message: 42 }))).toBeNull(); + }); + + it("returns null for error with invalid surfaceId type", () => { + expect( + parseServerMessage(JSON.stringify({ type: "error", surfaceId: 42, message: "boom" })), + ).toBeNull(); + }); +}); + +describe("round-trip: parseServerMessage(serialize(...))", () => { + it("round-trips a subscribe message through serialize only", () => { + const msg = { type: "subscribe" as const, surfaceId: "s1" }; + const wire = serialize(msg); + expect(JSON.parse(wire)).toEqual(msg); + }); + + it("round-trips an invoke message with payload", () => { + const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: false }; + const wire = serialize(msg); + expect(JSON.parse(wire)).toEqual(msg); + }); +}); + +describe("nextBackoffMs", () => { + it("returns a positive number", () => { + expect(nextBackoffMs(0)).toBeGreaterThan(0); + }); + + it("is capped at 30s + jitter (at most ~36s)", () => { + for (let i = 0; i < 100; i++) { + expect(nextBackoffMs(100)).toBeLessThanOrEqual(36_000); + } + }); + + it("starts around 500ms (±20% jitter)", () => { + for (let i = 0; i < 100; i++) { + const ms = nextBackoffMs(0); + expect(ms).toBeGreaterThanOrEqual(400); + expect(ms).toBeLessThanOrEqual(600); + } + }); + + it("grows exponentially with attempt", () => { + const averages = [0, 1, 2, 3].map((attempt) => { + let sum = 0; + for (let i = 0; i < 200; i++) { + sum += nextBackoffMs(attempt); + } + return sum / 200; + }); + for (let i = 1; i < averages.length; i++) { + const prev = averages[i - 1]; + if (prev === undefined) throw new Error("unreachable"); + expect(averages[i]).toBeGreaterThan(prev); + } + }); + + it("treats negative attempt as 0", () => { + for (let i = 0; i < 50; i++) { + const ms = nextBackoffMs(-5); + expect(ms).toBeGreaterThanOrEqual(400); + expect(ms).toBeLessThanOrEqual(600); + } + }); +}); diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts new file mode 100644 index 0000000..83a5802 --- /dev/null +++ b/src/adapters/ws/logic.ts @@ -0,0 +1,91 @@ +import type { + CatalogMessage, + SurfaceClientMessage, + SurfaceErrorMessage, + SurfaceMessage, + SurfaceServerMessage, + SurfaceUpdateMessage, +} from "@dispatch/ui-contract"; + +const VALID_SERVER_TYPES = new Set(["catalog", "surface", "update", "error"]); + +/** Serialize a client message to a JSON string for the wire. */ +export function serialize(msg: SurfaceClientMessage): string { + return JSON.stringify(msg); +} + +function isRecord(v: unknown): v is Record<string, unknown> { + return v !== null && typeof v === "object" && !Array.isArray(v); +} + +/** + * Parse a raw server message string into a typed SurfaceServerMessage. + * Returns null for malformed JSON or shapes that don't match the protocol. + */ +export function parseServerMessage(data: string): SurfaceServerMessage | null { + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + return null; + } + if (!isRecord(parsed)) { + return null; + } + const t = parsed.type; + if (typeof t !== "string" || !VALID_SERVER_TYPES.has(t)) { + return null; + } + switch (t) { + case "catalog": { + if (!Array.isArray(parsed.catalog)) return null; + return { type: "catalog", catalog: parsed.catalog as CatalogMessage["catalog"] }; + } + case "surface": { + const spec = parsed.spec; + if (!isRecord(spec)) return null; + if (typeof spec.id !== "string") return null; + if (typeof spec.region !== "string") return null; + if (typeof spec.title !== "string") return null; + if (!Array.isArray(spec.fields)) return null; + return { type: "surface", spec: spec as unknown as SurfaceMessage["spec"] }; + } + case "update": { + const update = parsed.update; + if (!isRecord(update)) return null; + if (typeof update.surfaceId !== "string") return null; + const spec = update.spec; + if (!isRecord(spec)) return null; + if (typeof spec.id !== "string") return null; + if (typeof spec.region !== "string") return null; + if (typeof spec.title !== "string") return null; + if (!Array.isArray(spec.fields)) return null; + return { type: "update", update: update as unknown as SurfaceUpdateMessage["update"] }; + } + case "error": { + if (typeof parsed.message !== "string") return null; + const surfaceId = parsed.surfaceId; + if (surfaceId !== undefined && typeof surfaceId !== "string") return null; + const msg: SurfaceErrorMessage = + surfaceId !== undefined + ? { type: "error", surfaceId, message: parsed.message } + : { type: "error", message: parsed.message }; + return msg; + } + default: + return null; + } +} + +/** + * Bounded exponential backoff with jitter. + * Base: 500ms, doubles each attempt, caps at 30s, adds ±20% jitter. + */ +export function nextBackoffMs(attempt: number): number { + const base = 500; + const max = 30_000; + const exponential = base * 2 ** Math.max(0, attempt); + const capped = Math.min(exponential, max); + const jitter = 0.8 + Math.random() * 0.4; + return Math.round(capped * jitter); +} diff --git a/src/app/App.svelte b/src/app/App.svelte new file mode 100644 index 0000000..2619a39 --- /dev/null +++ b/src/app/App.svelte @@ -0,0 +1,53 @@ +<script lang="ts"> + import type { InvokeMessage } from "@dispatch/ui-contract"; + import { SurfaceView } from "../features/surface-host"; + import type { AppStore } from "./store.svelte"; + + let { store }: { store: AppStore } = $props(); + + function handleSelect(surfaceId: string) { + store.select(surfaceId); + } + + function handleInvoke(msg: InvokeMessage) { + store.invoke(msg.surfaceId, msg.actionId, msg.payload); + } +</script> + +<main> + <h1>Dispatch</h1> + + {#if store.lastError} + <div role="alert"> + <strong>Error:</strong> + {store.lastError.message} + </div> + {/if} + + <section> + <h2>Surfaces</h2> + {#if store.catalog.length === 0} + <p>No surfaces available</p> + {:else} + <ul> + {#each store.catalog as entry (entry.id)} + <li> + <button + aria-current={entry.id === store.selectedId ? "true" : undefined} + onclick={() => handleSelect(entry.id)} + > + {entry.title} + <span>({entry.region})</span> + </button> + </li> + {/each} + </ul> + {/if} + </section> + + {#if store.selectedSpec} + <section> + <SurfaceView spec={store.selectedSpec} onInvoke={handleInvoke} /> + </section> + {/if} +</main> diff --git a/src/app/index.ts b/src/app/index.ts new file mode 100644 index 0000000..f94b554 --- /dev/null +++ b/src/app/index.ts @@ -0,0 +1,3 @@ +export { default as App } from "./App.svelte"; +export type { AppStore } from "./store.svelte"; +export { createAppStore } from "./store.svelte"; diff --git a/src/app/resolve-ws-url.test.ts b/src/app/resolve-ws-url.test.ts new file mode 100644 index 0000000..24c2f24 --- /dev/null +++ b/src/app/resolve-ws-url.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { resolveWsUrl } from "./resolve-ws-url"; + +describe("resolveWsUrl", () => { + it("explicit url wins over everything", () => { + const result = resolveWsUrl( + { VITE_WS_URL: "wss://env.example.com:9999" }, + { protocol: "https:", hostname: "page.example.com" }, + ); + expect(result).toBe("wss://env.example.com:9999"); + }); + + it("VITE_WS_URL wins over derivation", () => { + const result = resolveWsUrl( + { VITE_WS_URL: "wss://env.example.com:9999" }, + { protocol: "https:", hostname: "page.example.com" }, + ); + expect(result).toBe("wss://env.example.com:9999"); + }); + + it("derives ws://<hostname>:24205 from http location", () => { + const result = resolveWsUrl({}, { protocol: "http:", hostname: "100.126.75.103" }); + expect(result).toBe("ws://100.126.75.103:24205"); + }); + + it("derives wss://<hostname>:24205 from https location", () => { + const result = resolveWsUrl({}, { protocol: "https:", hostname: "arch-razer" }); + expect(result).toBe("wss://arch-razer:24205"); + }); + + it("uses VITE_WS_PORT when set", () => { + const result = resolveWsUrl( + { VITE_WS_PORT: "3000" }, + { protocol: "http:", hostname: "localhost" }, + ); + expect(result).toBe("ws://localhost:3000"); + }); + + it("falls back to ws://localhost:24205 when location is missing", () => { + const result = resolveWsUrl({}); + expect(result).toBe("ws://localhost:24205"); + }); + + it("VITE_WS_URL empty string treated as unset", () => { + const result = resolveWsUrl({ VITE_WS_URL: "" }, { protocol: "http:", hostname: "myhost" }); + expect(result).toBe("ws://myhost:24205"); + }); + + it("VITE_WS_PORT empty string falls back to default", () => { + const result = resolveWsUrl({ VITE_WS_PORT: "" }, { protocol: "http:", hostname: "localhost" }); + expect(result).toBe("ws://localhost:24205"); + }); +}); diff --git a/src/app/resolve-ws-url.ts b/src/app/resolve-ws-url.ts new file mode 100644 index 0000000..a264606 --- /dev/null +++ b/src/app/resolve-ws-url.ts @@ -0,0 +1,27 @@ +export interface WsUrlEnv { + readonly VITE_WS_URL?: string; + readonly VITE_WS_PORT?: string; +} + +export interface WsUrlLocation { + readonly protocol: string; + readonly hostname: string; +} + +const DEFAULT_PORT = "24205"; +const DEFAULT_FALLBACK = "ws://localhost:24205"; + +export function resolveWsUrl(env: WsUrlEnv, location?: WsUrlLocation): string { + if (env.VITE_WS_URL !== undefined && env.VITE_WS_URL !== "") { + return env.VITE_WS_URL; + } + + if (location === undefined) { + return DEFAULT_FALLBACK; + } + + const wsProtocol = location.protocol === "https:" ? "wss" : "ws"; + const port = + env.VITE_WS_PORT !== undefined && env.VITE_WS_PORT !== "" ? env.VITE_WS_PORT : DEFAULT_PORT; + return `${wsProtocol}://${location.hostname}:${port}`; +} diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts new file mode 100644 index 0000000..6b7a910 --- /dev/null +++ b/src/app/store.svelte.ts @@ -0,0 +1,103 @@ +import type { SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract"; +import type { WebSocketLike } from "../adapters/ws"; +import { createSurfaceSocket, type SurfaceSocketOptions } from "../adapters/ws"; +import { + applyServerMessage, + initialState, + type ProtocolState, + invoke as protocolInvoke, + subscribe as protocolSubscribe, + unsubscribe as protocolUnsubscribe, +} from "../core/protocol"; +import { resolveWsUrl } from "./resolve-ws-url"; + +export interface AppStore { + readonly catalog: ProtocolState["catalog"]; + readonly selectedId: string | null; + readonly selectedSpec: SurfaceSpec | null; + readonly lastError: ProtocolState["lastError"]; + select(surfaceId: string): void; + invoke(surfaceId: string, actionId: string, payload?: unknown): void; + dispose(): void; +} + +export function createAppStore(opts?: { + url?: string; + socketFactory?: (url: string) => WebSocketLike; +}): AppStore { + let protocol = $state<ProtocolState>(initialState()); + let selectedId = $state<string | null>(null); + + let socket: ReturnType<typeof createSurfaceSocket> | null = null; + + function handleServerMessage(msg: SurfaceServerMessage): void { + protocol = applyServerMessage(protocol, msg); + } + + const wsLocation = typeof location !== "undefined" ? location : undefined; + const url = + opts?.url ?? + resolveWsUrl( + { VITE_WS_URL: import.meta.env.VITE_WS_URL, VITE_WS_PORT: import.meta.env.VITE_WS_PORT }, + wsLocation, + ); + const socketOpts: SurfaceSocketOptions = { + url, + onMessage: handleServerMessage, + onReopen() { + if (selectedId !== null) { + const result = protocolSubscribe(protocol, selectedId); + protocol = result.state; + for (const msg of result.outgoing) { + socket?.send(msg); + } + } + }, + }; + if (opts?.socketFactory !== undefined) { + socketOpts.socketFactory = opts.socketFactory; + } + socket = createSurfaceSocket(socketOpts); + + return { + get catalog() { + return protocol.catalog; + }, + get selectedId() { + return selectedId; + }, + get selectedSpec() { + if (selectedId === null) return null; + return protocol.subscriptions.get(selectedId) ?? null; + }, + get lastError() { + return protocol.lastError; + }, + select(surfaceId: string): void { + if (selectedId !== null && selectedId !== surfaceId) { + const unsub = protocolUnsubscribe(protocol, selectedId); + protocol = unsub.state; + for (const msg of unsub.outgoing) { + socket?.send(msg); + } + } + selectedId = surfaceId; + const sub = protocolSubscribe(protocol, surfaceId); + protocol = sub.state; + for (const msg of sub.outgoing) { + socket?.send(msg); + } + }, + invoke(surfaceId: string, actionId: string, payload?: unknown): void { + const result = protocolInvoke(protocol, surfaceId, actionId, payload); + protocol = result.state; + for (const msg of result.outgoing) { + socket?.send(msg); + } + }, + dispose(): void { + socket?.close(); + socket = null; + }, + }; +} diff --git a/src/app/store.test.ts b/src/app/store.test.ts new file mode 100644 index 0000000..b521975 --- /dev/null +++ b/src/app/store.test.ts @@ -0,0 +1,220 @@ +import type { SurfaceServerMessage } from "@dispatch/ui-contract"; +import { describe, expect, it } from "vitest"; +import type { WebSocketLike } from "../adapters/ws"; +import { createAppStore } from "./store.svelte"; + +interface FakeSocket extends WebSocketLike { + sent: string[]; + resolveOpen(): void; + feedMessage(data: SurfaceServerMessage): void; +} + +function fakeSocket(): FakeSocket { + let onopen: (() => void) | null = null; + let onmessage: ((ev: { data: string }) => void) | null = null; + const sent: string[] = []; + + const ws: FakeSocket = { + send(data: string) { + sent.push(data); + }, + close() {}, + get onopen() { + return onopen; + }, + set onopen(fn) { + onopen = fn; + }, + get onmessage() { + return onmessage; + }, + set onmessage(fn) { + onmessage = fn; + }, + get onclose() { + return null; + }, + set onclose(_fn) {}, + resolveOpen() { + onopen?.(); + }, + feedMessage(msg: SurfaceServerMessage) { + onmessage?.({ data: JSON.stringify(msg) }); + }, + sent, + }; + return ws; +} + +describe("createAppStore", () => { + it("starts with empty catalog and no selection", () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + expect(store.catalog).toEqual([]); + expect(store.selectedId).toBeNull(); + expect(store.selectedSpec).toBeNull(); + expect(store.lastError).toBeNull(); + + store.dispose(); + }); + + it("updates catalog when catalog message arrives", () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + ws.feedMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + expect(store.catalog).toHaveLength(2); + expect(store.catalog[0]?.id).toBe("s1"); + expect(store.catalog[1]?.id).toBe("s2"); + + store.dispose(); + }); + + it("select sends subscribe and sets selectedId", () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + ws.feedMessage({ + type: "catalog", + catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], + }); + + ws.sent.length = 0; + store.select("s1"); + + expect(store.selectedId).toBe("s1"); + const subscribeMsg = ws.sent.find((s) => { + const parsed = JSON.parse(s); + return parsed.type === "subscribe" && parsed.surfaceId === "s1"; + }); + expect(subscribeMsg).toBeTruthy(); + + store.dispose(); + }); + + it("selecting a different surface unsubscribes from previous", () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + ws.feedMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + ws.sent.length = 0; + store.select("s1"); + store.select("s2"); + + const unsubscribeMsg = ws.sent.find((s) => { + const parsed = JSON.parse(s); + return parsed.type === "unsubscribe" && parsed.surfaceId === "s1"; + }); + expect(unsubscribeMsg).toBeTruthy(); + + const subscribeMsg = ws.sent.find((s) => { + const parsed = JSON.parse(s); + return parsed.type === "subscribe" && parsed.surfaceId === "s2"; + }); + expect(subscribeMsg).toBeTruthy(); + + store.dispose(); + }); + + it("surface message updates selectedSpec", () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + ws.feedMessage({ + type: "catalog", + catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], + }); + + store.select("s1"); + + ws.feedMessage({ + type: "surface", + spec: { + id: "s1", + region: "sidebar", + title: "Surface One", + fields: [{ kind: "stat", label: "Tokens", value: "1,234" }], + }, + }); + + expect(store.selectedSpec).not.toBeNull(); + expect(store.selectedSpec?.id).toBe("s1"); + expect(store.selectedSpec?.fields).toHaveLength(1); + + store.dispose(); + }); + + it("invoke sends an invoke message", () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + ws.sent.length = 0; + store.invoke("s1", "toggle-dark", true); + + const invokeMsg = ws.sent.find((s) => { + const parsed = JSON.parse(s); + return ( + parsed.type === "invoke" && + parsed.surfaceId === "s1" && + parsed.actionId === "toggle-dark" && + parsed.payload === true + ); + }); + expect(invokeMsg).toBeTruthy(); + + store.dispose(); + }); + + it("error message updates lastError", () => { + const ws = fakeSocket(); + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + ws.feedMessage({ + type: "error", + message: "Something went wrong", + }); + + expect(store.lastError).not.toBeNull(); + expect(store.lastError?.message).toBe("Something went wrong"); + + store.dispose(); + }); + + it("dispose closes the socket", () => { + const ws = fakeSocket(); + const closeSpy = { called: false }; + const origClose = ws.close.bind(ws); + ws.close = () => { + closeSpy.called = true; + origClose(); + }; + + const store = createAppStore({ socketFactory: () => ws }); + ws.resolveOpen(); + + store.dispose(); + expect(closeSpy.called).toBe(true); + }); +}); diff --git a/src/core/protocol/index.ts b/src/core/protocol/index.ts new file mode 100644 index 0000000..25174ea --- /dev/null +++ b/src/core/protocol/index.ts @@ -0,0 +1,2 @@ +export { applyServerMessage, initialState, invoke, subscribe, unsubscribe } from "./reducer"; +export type { ProtocolResult, ProtocolState } from "./types"; diff --git a/src/core/protocol/reducer.test.ts b/src/core/protocol/reducer.test.ts new file mode 100644 index 0000000..57e12f2 --- /dev/null +++ b/src/core/protocol/reducer.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import { applyServerMessage, initialState, invoke, subscribe, unsubscribe } from "./reducer"; + +const makeSpec = (id: string, title = id) => ({ + id, + region: "test", + title, + fields: [], +}); + +describe("initialState", () => { + it("returns empty catalog, no subscriptions, no error", () => { + const s = initialState(); + expect(s.catalog).toEqual([]); + expect(s.subscriptions.size).toBe(0); + expect(s.lastError).toBeNull(); + }); +}); + +describe("applyServerMessage — catalog", () => { + it("replaces the catalog", () => { + const s = initialState(); + const catalog = [ + { id: "a", region: "r", title: "A" }, + { id: "b", region: "r", title: "B" }, + ]; + const next = applyServerMessage(s, { type: "catalog", catalog }); + expect(next.catalog).toEqual(catalog); + }); +}); + +describe("applyServerMessage — surface", () => { + it("sets the spec for a subscribed surface", () => { + let s = initialState(); + const result = subscribe(s, "s1"); + s = result.state; + const spec = makeSpec("s1", "Surface 1"); + const next = applyServerMessage(s, { type: "surface", spec }); + expect(next.subscriptions.get("s1")).toEqual(spec); + }); + + it("ignores a surface message for a non-subscribed surface", () => { + const s = initialState(); + const spec = makeSpec("unknown"); + const next = applyServerMessage(s, { type: "surface", spec }); + expect(next.subscriptions.has("unknown")).toBe(false); + }); +}); + +describe("applyServerMessage — update", () => { + it("replaces spec for a subscribed surface", () => { + let s = initialState(); + s = subscribe(s, "s1").state; + s = applyServerMessage(s, { type: "surface", spec: makeSpec("s1", "V1") }); + const next = applyServerMessage(s, { + type: "update", + update: { surfaceId: "s1", spec: makeSpec("s1", "V2") }, + }); + expect(next.subscriptions.get("s1")?.title).toBe("V2"); + }); + + it("ignores an update for a non-subscribed surface", () => { + const s = initialState(); + const next = applyServerMessage(s, { + type: "update", + update: { surfaceId: "nope", spec: makeSpec("nope") }, + }); + expect(next.subscriptions.has("nope")).toBe(false); + }); +}); + +describe("applyServerMessage — error", () => { + it("records the error without throwing", () => { + const s = initialState(); + const err = { type: "error" as const, surfaceId: "s1", message: "boom" }; + const next = applyServerMessage(s, err); + expect(next.lastError).toEqual(err); + }); + + it("records error without surfaceId", () => { + const s = initialState(); + const err = { type: "error" as const, message: "global boom" }; + const next = applyServerMessage(s, err); + expect(next.lastError).toEqual(err); + }); +}); + +describe("subscribe", () => { + it("emits exactly one subscribe message", () => { + const s = initialState(); + const result = subscribe(s, "s1"); + expect(result.outgoing).toEqual([{ type: "subscribe", surfaceId: "s1" }]); + expect(result.outgoing).toHaveLength(1); + }); + + it("adds the surface to subscriptions with null spec", () => { + const s = initialState(); + const result = subscribe(s, "s1"); + expect(result.state.subscriptions.get("s1")).toBeNull(); + }); + + it("is idempotent — second subscribe is a no-op", () => { + let s = initialState(); + s = subscribe(s, "s1").state; + const result = subscribe(s, "s1"); + expect(result.outgoing).toEqual([]); + expect(result.state).toBe(s); + }); +}); + +describe("unsubscribe", () => { + it("emits unsubscribe and drops the spec", () => { + let s = initialState(); + s = subscribe(s, "s1").state; + s = applyServerMessage(s, { type: "surface", spec: makeSpec("s1") }); + const result = unsubscribe(s, "s1"); + expect(result.outgoing).toEqual([{ type: "unsubscribe", surfaceId: "s1" }]); + expect(result.state.subscriptions.has("s1")).toBe(false); + }); + + it("is a no-op if not subscribed", () => { + const s = initialState(); + const result = unsubscribe(s, "nope"); + expect(result.outgoing).toEqual([]); + expect(result.state).toBe(s); + }); +}); + +describe("invoke", () => { + it("emits the correct InvokeMessage", () => { + const s = initialState(); + const result = invoke(s, "s1", "toggle", true); + expect(result.outgoing).toEqual([ + { type: "invoke", surfaceId: "s1", actionId: "toggle", payload: true }, + ]); + }); + + it("omits payload when not provided", () => { + const s = initialState(); + const result = invoke(s, "s1", "click"); + expect(result.outgoing).toEqual([ + { type: "invoke", surfaceId: "s1", actionId: "click", payload: undefined }, + ]); + }); + + it("does not mutate state", () => { + const s = initialState(); + const result = invoke(s, "s1", "a1"); + expect(result.state).toBe(s); + }); +}); diff --git a/src/core/protocol/reducer.ts b/src/core/protocol/reducer.ts new file mode 100644 index 0000000..992a918 --- /dev/null +++ b/src/core/protocol/reducer.ts @@ -0,0 +1,82 @@ +import type { + InvokeMessage, + SubscribeMessage, + SurfaceServerMessage, + UnsubscribeMessage, +} from "@dispatch/ui-contract"; +import type { ProtocolResult, ProtocolState } from "./types"; + +/** The initial protocol state: empty catalog, no subscriptions, no error. */ +export function initialState(): ProtocolState { + return { + catalog: [], + subscriptions: new Map(), + lastError: null, + }; +} + +/** Fold an inbound server message into the next protocol state. */ +export function applyServerMessage(state: ProtocolState, msg: SurfaceServerMessage): ProtocolState { + switch (msg.type) { + case "catalog": + return { ...state, catalog: msg.catalog }; + + case "surface": { + const surfaceId = msg.spec.id; + if (!state.subscriptions.has(surfaceId)) return state; + const subs = new Map(state.subscriptions); + subs.set(surfaceId, msg.spec); + return { ...state, subscriptions: subs }; + } + + case "update": { + const surfaceId = msg.update.surfaceId; + if (!state.subscriptions.has(surfaceId)) return state; + const subs = new Map(state.subscriptions); + subs.set(surfaceId, msg.update.spec); + return { ...state, subscriptions: subs }; + } + + case "error": + return { ...state, lastError: msg }; + } +} + +/** + * Subscribe to a surface. Idempotent: if already subscribed, returns the same + * state with no outgoing message. + */ +export function subscribe(state: ProtocolState, surfaceId: string): ProtocolResult { + if (state.subscriptions.has(surfaceId)) { + return { state, outgoing: [] }; + } + const subs = new Map(state.subscriptions); + subs.set(surfaceId, null); + const outgoing: SubscribeMessage = { type: "subscribe", surfaceId }; + return { state: { ...state, subscriptions: subs }, outgoing: [outgoing] }; +} + +/** + * Unsubscribe from a surface. Drops the local spec and emits one unsubscribe. + * If not subscribed, returns the same state with no outgoing. + */ +export function unsubscribe(state: ProtocolState, surfaceId: string): ProtocolResult { + if (!state.subscriptions.has(surfaceId)) { + return { state, outgoing: [] }; + } + const subs = new Map(state.subscriptions); + subs.delete(surfaceId); + const outgoing: UnsubscribeMessage = { type: "unsubscribe", surfaceId }; + return { state: { ...state, subscriptions: subs }, outgoing: [outgoing] }; +} + +/** Invoke a field's action on a surface. Emits an InvokeMessage; no state change. */ +export function invoke( + state: ProtocolState, + surfaceId: string, + actionId: string, + payload?: unknown, +): ProtocolResult { + const outgoing: InvokeMessage = { type: "invoke", surfaceId, actionId, payload }; + return { state, outgoing: [outgoing] }; +} diff --git a/src/core/protocol/types.ts b/src/core/protocol/types.ts new file mode 100644 index 0000000..effec0d --- /dev/null +++ b/src/core/protocol/types.ts @@ -0,0 +1,22 @@ +import type { + SurfaceCatalog, + SurfaceClientMessage, + SurfaceErrorMessage, + SurfaceSpec, +} from "@dispatch/ui-contract"; + +/** The client-side view of the surface protocol state. */ +export interface ProtocolState { + /** The latest catalog received from the server (empty until first CatalogMessage). */ + readonly catalog: SurfaceCatalog; + /** Surfaces the client intends to be subscribed to; null = subscribed but no spec yet. */ + readonly subscriptions: ReadonlyMap<string, SurfaceSpec | null>; + /** The last error received from the server, if any. */ + readonly lastError: SurfaceErrorMessage | null; +} + +/** A state transition result: the next state plus any outgoing messages to send. */ +export interface ProtocolResult { + readonly state: ProtocolState; + readonly outgoing: readonly SurfaceClientMessage[]; +} diff --git a/src/features/surface-host/index.ts b/src/features/surface-host/index.ts new file mode 100644 index 0000000..afa3127 --- /dev/null +++ b/src/features/surface-host/index.ts @@ -0,0 +1,3 @@ +export { buildInvoke, planSurface } from "./logic/plan"; +export type { FieldView, SurfaceRenderPlan } from "./logic/types"; +export { default as SurfaceView } from "./ui/SurfaceView.svelte"; diff --git a/src/features/surface-host/logic/plan.test.ts b/src/features/surface-host/logic/plan.test.ts new file mode 100644 index 0000000..50d6f11 --- /dev/null +++ b/src/features/surface-host/logic/plan.test.ts @@ -0,0 +1,161 @@ +import type { SurfaceField, SurfaceSpec } from "@dispatch/ui-contract"; +import { describe, expect, it } from "vitest"; +import { buildInvoke, planSurface } from "./plan"; + +const makeSpec = (...fields: SurfaceField[]): SurfaceSpec => ({ + id: "test-surface", + region: "test", + title: "Test Surface", + fields, +}); + +describe("planSurface", () => { + it("maps a toggle field to a ToggleFieldView", () => { + const plan = planSurface( + makeSpec({ kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } }), + ); + expect(plan.fields).toEqual([ + { kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } }, + ]); + }); + + it("maps a progress field to a ProgressFieldView", () => { + const plan = planSurface(makeSpec({ kind: "progress", label: "Loading", value: 0.42 })); + expect(plan.fields).toEqual([{ kind: "progress", label: "Loading", value: 0.42 }]); + }); + + it("maps a selector field to a SelectorFieldView", () => { + const plan = planSurface( + makeSpec({ + kind: "selector", + label: "Model", + value: "gpt-4", + options: [ + { value: "gpt-4", label: "GPT-4" }, + { value: "gpt-3.5", label: "GPT-3.5" }, + ], + action: { actionId: "set-model" }, + }), + ); + expect(plan.fields).toEqual([ + { + kind: "selector", + label: "Model", + value: "gpt-4", + options: [ + { value: "gpt-4", label: "GPT-4" }, + { value: "gpt-3.5", label: "GPT-3.5" }, + ], + action: { actionId: "set-model" }, + }, + ]); + }); + + it("maps a stat field to a StatFieldView", () => { + const plan = planSurface(makeSpec({ kind: "stat", label: "Tokens", value: "1,234" })); + expect(plan.fields).toEqual([{ kind: "stat", label: "Tokens", value: "1,234" }]); + }); + + it("maps a button field to a ButtonFieldView", () => { + const plan = planSurface( + makeSpec({ kind: "button", label: "Retry", action: { actionId: "retry" } }), + ); + expect(plan.fields).toEqual([ + { kind: "button", label: "Retry", action: { actionId: "retry" } }, + ]); + }); + + it("preserves field order", () => { + const plan = planSurface( + makeSpec( + { kind: "stat", label: "A", value: "1" }, + { kind: "toggle", label: "B", value: false, action: { actionId: "b" } }, + { kind: "progress", label: "C", value: 0.5 }, + { kind: "button", label: "D", action: { actionId: "d" } }, + ), + ); + expect(plan.fields.map((f) => f.label)).toEqual(["A", "B", "C", "D"]); + }); + + it("drops unknown field kinds gracefully", () => { + const plan = planSurface( + makeSpec({ kind: "stat", label: "Known", value: "ok" }, { + kind: "future-kind" as "stat", + label: "Unknown", + value: "?", + } as SurfaceField), + ); + expect(plan.fields).toHaveLength(1); + expect(plan.fields[0]?.label).toBe("Known"); + }); + + it("drops custom fields (no renderer registered)", () => { + const plan = planSurface( + makeSpec( + { kind: "stat", label: "Before", value: "1" }, + { kind: "custom", rendererId: "chart", payload: { data: [1, 2, 3] } }, + { kind: "stat", label: "After", value: "2" }, + ), + ); + expect(plan.fields).toHaveLength(2); + expect(plan.fields.map((f) => f.label)).toEqual(["Before", "After"]); + }); + + it("returns empty fields for an empty spec", () => { + const plan = planSurface(makeSpec()); + expect(plan.fields).toEqual([]); + }); + + it("drops all fields when all are custom", () => { + const plan = planSurface( + makeSpec( + { kind: "custom", rendererId: "x", payload: null }, + { kind: "custom", rendererId: "y", payload: 42 }, + ), + ); + expect(plan.fields).toEqual([]); + }); +}); + +describe("buildInvoke", () => { + it("builds an invoke message for a toggle field", () => { + const field = { kind: "toggle" as const, label: "T", value: false, action: { actionId: "t" } }; + const msg = buildInvoke("s1", field, true); + expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "t", payload: true }); + }); + + it("builds an invoke message for a selector field", () => { + const field = { + kind: "selector" as const, + label: "S", + value: "a", + options: [], + action: { actionId: "sel" }, + }; + const msg = buildInvoke("s1", field, "b"); + expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "sel", payload: "b" }); + }); + + it("builds an invoke message without payload for a button field", () => { + const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } }; + const msg = buildInvoke("s1", field); + expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "btn" }); + }); + + it("omits payload key when value is undefined", () => { + const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } }; + const msg = buildInvoke("s1", field, undefined); + expect(msg).not.toHaveProperty("payload"); + }); + + it("uses the field's actionId, not a surface-level id", () => { + const field = { + kind: "toggle" as const, + label: "X", + value: true, + action: { actionId: "custom-action-123" }, + }; + const msg = buildInvoke("surf", field, false); + expect(msg.actionId).toBe("custom-action-123"); + }); +}); diff --git a/src/features/surface-host/logic/plan.ts b/src/features/surface-host/logic/plan.ts new file mode 100644 index 0000000..5b4530b --- /dev/null +++ b/src/features/surface-host/logic/plan.ts @@ -0,0 +1,74 @@ +import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; +import type { FieldView, SurfaceRenderPlan } from "./types"; + +const KNOWN_KINDS = new Set(["toggle", "progress", "selector", "stat", "button"]); + +/** + * Validate and normalise a SurfaceSpec into a renderable plan. + * Keeps known field kinds in order; drops unknown kinds and `custom` fields + * (no renderer registry yet — graceful skip, never throw). + */ +export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan { + const fields: FieldView[] = []; + for (const field of spec.fields) { + if (!KNOWN_KINDS.has(field.kind)) continue; + switch (field.kind) { + case "toggle": + fields.push({ + kind: "toggle", + label: field.label, + value: field.value, + action: field.action, + }); + break; + case "progress": + fields.push({ + kind: "progress", + label: field.label, + value: field.value, + }); + break; + case "selector": + fields.push({ + kind: "selector", + label: field.label, + value: field.value, + options: field.options, + action: field.action, + }); + break; + case "stat": + fields.push({ + kind: "stat", + label: field.label, + value: field.value, + }); + break; + case "button": + fields.push({ + kind: "button", + label: field.label, + action: field.action, + }); + break; + } + } + return { fields }; +} + +/** + * Construct a typed `invoke` client message for an actionable field. + * For toggle the payload is the new boolean; for selector the chosen value; + * for button the payload is omitted. + */ +export function buildInvoke( + surfaceId: string, + field: Extract<FieldView, { action: unknown }>, + value?: unknown, +): InvokeMessage { + const base = { type: "invoke" as const, surfaceId, actionId: field.action.actionId }; + if (value !== undefined) { + return { ...base, payload: value }; + } + return base; +} diff --git a/src/features/surface-host/logic/types.ts b/src/features/surface-host/logic/types.ts new file mode 100644 index 0000000..f24438a --- /dev/null +++ b/src/features/surface-host/logic/types.ts @@ -0,0 +1,52 @@ +import type { ActionRef, SurfaceOption } from "@dispatch/ui-contract"; + +/** Normalised view-model for a toggle field. */ +export interface ToggleFieldView { + readonly kind: "toggle"; + readonly label: string; + readonly value: boolean; + readonly action: ActionRef; +} + +/** Normalised view-model for a progress field. */ +export interface ProgressFieldView { + readonly kind: "progress"; + readonly label: string; + readonly value: number; +} + +/** Normalised view-model for a selector field. */ +export interface SelectorFieldView { + readonly kind: "selector"; + readonly label: string; + readonly value: string; + readonly options: readonly SurfaceOption[]; + readonly action: ActionRef; +} + +/** Normalised view-model for a stat field. */ +export interface StatFieldView { + readonly kind: "stat"; + readonly label: string; + readonly value: string; +} + +/** Normalised view-model for a button field. */ +export interface ButtonFieldView { + readonly kind: "button"; + readonly label: string; + readonly action: ActionRef; +} + +/** A normalised field view-model — one entry per renderable field kind. */ +export type FieldView = + | ToggleFieldView + | ProgressFieldView + | SelectorFieldView + | StatFieldView + | ButtonFieldView; + +/** The output of `planSurface`: the ordered list of renderable fields. */ +export interface SurfaceRenderPlan { + readonly fields: readonly FieldView[]; +} diff --git a/src/features/surface-host/ui/Button.svelte b/src/features/surface-host/ui/Button.svelte new file mode 100644 index 0000000..62d7acf --- /dev/null +++ b/src/features/surface-host/ui/Button.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import type { InvokeMessage } from "@dispatch/ui-contract"; + import type { ButtonFieldView } from "../logic/types"; + + let { + field, + surfaceId, + onInvoke, + }: { field: ButtonFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = + $props(); + + function handleClick() { + onInvoke({ + type: "invoke", + surfaceId, + actionId: field.action.actionId, + }); + } +</script> + +<button onclick={handleClick}>{field.label}</button> diff --git a/src/features/surface-host/ui/Progress.svelte b/src/features/surface-host/ui/Progress.svelte new file mode 100644 index 0000000..cba9e0f --- /dev/null +++ b/src/features/surface-host/ui/Progress.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + import type { ProgressFieldView } from "../logic/types"; + + let { field }: { field: ProgressFieldView } = $props(); + + const percent = $derived(Math.round(field.value * 100)); +</script> + +<div> + <span>{field.label}</span> + <progress max="100" value={percent}>{percent}%</progress> + <span>{percent}%</span> +</div> diff --git a/src/features/surface-host/ui/Selector.svelte b/src/features/surface-host/ui/Selector.svelte new file mode 100644 index 0000000..2da104f --- /dev/null +++ b/src/features/surface-host/ui/Selector.svelte @@ -0,0 +1,32 @@ +<script lang="ts"> + import type { InvokeMessage } from "@dispatch/ui-contract"; + import type { SelectorFieldView } from "../logic/types"; + + let { + field, + surfaceId, + onInvoke, + }: { field: SelectorFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = + $props(); + + function handleChange(event: Event) { + const target = event.target as HTMLSelectElement; + onInvoke({ + type: "invoke", + surfaceId, + actionId: field.action.actionId, + payload: target.value, + }); + } +</script> + +<label> + {field.label} + <select onchange={handleChange}> + {#each field.options as option (option.value)} + <option value={option.value} selected={option.value === field.value}> + {option.label} + </option> + {/each} + </select> +</label> diff --git a/src/features/surface-host/ui/Stat.svelte b/src/features/surface-host/ui/Stat.svelte new file mode 100644 index 0000000..e184dab --- /dev/null +++ b/src/features/surface-host/ui/Stat.svelte @@ -0,0 +1,10 @@ +<script lang="ts"> + import type { StatFieldView } from "../logic/types"; + + let { field }: { field: StatFieldView } = $props(); +</script> + +<dl> + <dt>{field.label}</dt> + <dd>{field.value}</dd> +</dl> diff --git a/src/features/surface-host/ui/SurfaceView.svelte b/src/features/surface-host/ui/SurfaceView.svelte new file mode 100644 index 0000000..4207913 --- /dev/null +++ b/src/features/surface-host/ui/SurfaceView.svelte @@ -0,0 +1,33 @@ +<script lang="ts"> + import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; + import { planSurface } from "../logic/plan"; + import Button from "./Button.svelte"; + import Progress from "./Progress.svelte"; + import Selector from "./Selector.svelte"; + import Stat from "./Stat.svelte"; + import Toggle from "./Toggle.svelte"; + + let { + spec, + onInvoke, + }: { spec: SurfaceSpec; onInvoke: (msg: InvokeMessage) => void } = $props(); + + const plan = $derived(planSurface(spec)); +</script> + +<article> + <h2>{spec.title}</h2> + {#each plan.fields as field (field)} + {#if field.kind === "toggle"} + <Toggle {field} surfaceId={spec.id} {onInvoke} /> + {:else if field.kind === "progress"} + <Progress {field} /> + {:else if field.kind === "selector"} + <Selector {field} surfaceId={spec.id} {onInvoke} /> + {:else if field.kind === "stat"} + <Stat {field} /> + {:else if field.kind === "button"} + <Button {field} surfaceId={spec.id} {onInvoke} /> + {/if} + {/each} +</article> diff --git a/src/features/surface-host/ui/Toggle.svelte b/src/features/surface-host/ui/Toggle.svelte new file mode 100644 index 0000000..aec8f4e --- /dev/null +++ b/src/features/surface-host/ui/Toggle.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + import type { InvokeMessage } from "@dispatch/ui-contract"; + import type { ToggleFieldView } from "../logic/types"; + + let { + field, + surfaceId, + onInvoke, + }: { field: ToggleFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } = + $props(); + + function handleChange() { + onInvoke({ + type: "invoke", + surfaceId, + actionId: field.action.actionId, + payload: !field.value, + }); + } +</script> + +<label> + <input type="checkbox" checked={field.value} onchange={handleChange} /> + {field.label} +</label> diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..f58cfe2 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,9 @@ +import { mount } from "svelte"; +import App from "./App.svelte"; + +const target = document.getElementById("app"); +if (!target) { + throw new Error("missing #app mount target"); +} + +export default mount(App, { target }); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..4078e74 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// <reference types="svelte" /> +/// <reference types="vite/client" /> |
