diff options
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/App.svelte | 53 | ||||
| -rw-r--r-- | src/app/index.ts | 3 | ||||
| -rw-r--r-- | src/app/resolve-ws-url.test.ts | 53 | ||||
| -rw-r--r-- | src/app/resolve-ws-url.ts | 27 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 103 | ||||
| -rw-r--r-- | src/app/store.test.ts | 220 |
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); + }); +}); |
