From e1c8cf3257cb33457aa882c548f5195ecc0f9854 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 6 Jun 2026 22:08:16 +0900 Subject: 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. --- src/core/protocol/reducer.ts | 82 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/core/protocol/reducer.ts (limited to 'src/core/protocol/reducer.ts') 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] }; +} -- cgit v1.2.3