summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-06 22:08:16 +0900
committerAdam Malczewski <[email protected]>2026-06-06 22:08:16 +0900
commite1c8cf3257cb33457aa882c548f5195ecc0f9854 (patch)
treed355147cdab8eb77917ad02caedf26b3d8d0be57 /src/app
downloaddispatch-web-e1c8cf3257cb33457aa882c548f5195ecc0f9854.tar.gz
dispatch-web-e1c8cf3257cb33457aa882c548f5195ecc0f9854.zip
Slice 1: surface system + WS transport + composition root
Pure-core feature libraries assembled at the composition root: - core/protocol: pure reducer over surface catalog/spec/error messages - features/surface-host: generic field-kind interpreter (toggle/progress/ selector/stat/button) + pure plan logic; no surface-id special-casing - adapters/ws: injected WebSocket client (effects at the edge) - app: composition root store (Svelte 5 runes over the pure reducer), host-relative surface WS URL resolution (resolveWsUrl), root App.svelte Verified green: svelte-check 0/0, vitest 84 passed, biome clean, vite build ok.
Diffstat (limited to 'src/app')
-rw-r--r--src/app/App.svelte53
-rw-r--r--src/app/index.ts3
-rw-r--r--src/app/resolve-ws-url.test.ts53
-rw-r--r--src/app/resolve-ws-url.ts27
-rw-r--r--src/app/store.svelte.ts103
-rw-r--r--src/app/store.test.ts220
6 files changed, 459 insertions, 0 deletions
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);
+ });
+});