diff --git a/crates/wasmeld-console/web/src/host/app.tsx b/crates/wasmeld-console/web/src/host/app.tsx index 748f275..86e58fc 100644 --- a/crates/wasmeld-console/web/src/host/app.tsx +++ b/crates/wasmeld-console/web/src/host/app.tsx @@ -11,10 +11,12 @@ import { WifiOff, } from "lucide-solid"; import { + batch, createEffect, createMemo, createSignal, For, + on, onCleanup, onMount, Show, @@ -39,12 +41,14 @@ import { servicesFrom } from "../lib/view-state"; import { pageUrl, sendLifecycle, sendState } from "../sdk/host"; import { isPageMessage, + type HostGlobalState, type HostSharedState, type PageCommand, type RuntimeAction, } from "../sdk/protocol"; import { DialogLayer, type DialogState } from "./dialogs"; import { FrameCache, type FrameEntry } from "./frame-cache"; +import { createBrowserHostTabSync, type HostTabSync } from "./tab-sync"; const NAVIGATION: Array<{ id: View; @@ -83,19 +87,24 @@ export default function HostApp() { const [dialogBusy, setDialogBusy] = createSignal(false); const [toast, setToast] = createSignal(null); const frameElements = new Map(); + let tabSync: HostTabSync | null = null; + let applyingSharedHostState = false; let toastTimer: number | undefined; let refreshGeneration = 0; - const state = createMemo(() => ({ - view: view(), + const globalState = createMemo(() => ({ snapshot: snapshot(), connection: connection(), - query: query(), apiBase: apiBase(), runtimeAction: runtimeAction(), serviceAction: serviceAction(), deploymentAction: deploymentAction(), })); + const state = createMemo(() => ({ + ...globalState(), + view: view(), + query: query(), + })); const activeServices = createMemo( () => servicesFrom(state()).filter((service) => service.status === "running").length, ); @@ -108,17 +117,35 @@ export default function HostApp() { return { ...state(), view: targetView }; } + function applySharedHostState(next: HostGlobalState) { + applyingSharedHostState = true; + refreshGeneration += 1; + batch(() => { + setSnapshot(next.snapshot); + setConnection(next.connection); + setApiBase(next.apiBase); + setRuntimeAction(next.runtimeAction); + setServiceAction(next.serviceAction); + setDeploymentAction(next.deploymentAction); + }); + applyingSharedHostState = false; + } + async function refresh() { const generation = ++refreshGeneration; try { const next = await fetchSnapshot(apiBase()); if (generation !== refreshGeneration) return; - setSnapshot(next); - setConnection("online"); + batch(() => { + setSnapshot(next); + setConnection("online"); + }); } catch { if (generation !== refreshGeneration) return; - setSnapshot(null); - setConnection("offline"); + batch(() => { + setSnapshot(null); + setConnection("offline"); + }); } } @@ -290,6 +317,10 @@ export default function HostApp() { onMount(() => { window.addEventListener("message", receive); window.addEventListener("popstate", popState); + tabSync = createBrowserHostTabSync({ + getState: globalState, + applyState: applySharedHostState, + }); void refresh(); const interval = window.setInterval(() => void refresh(), 5000); onCleanup(() => window.clearInterval(interval)); @@ -298,6 +329,7 @@ export default function HostApp() { onCleanup(() => { window.removeEventListener("message", receive); window.removeEventListener("popstate", popState); + tabSync?.close(); if (toastTimer !== undefined) window.clearTimeout(toastTimer); for (const frameView of frameElements.keys()) sendFrameLifecycle(frameView, "dispose"); }); @@ -310,6 +342,16 @@ export default function HostApp() { } }); + createEffect( + on( + globalState, + (next) => { + if (!applyingSharedHostState) tabSync?.publish(next); + }, + { defer: true }, + ), + ); + async function register(file: File) { setDialogBusy(true); try { diff --git a/crates/wasmeld-console/web/src/host/tab-sync.ts b/crates/wasmeld-console/web/src/host/tab-sync.ts new file mode 100644 index 0000000..e0c3998 --- /dev/null +++ b/crates/wasmeld-console/web/src/host/tab-sync.ts @@ -0,0 +1,152 @@ +import type { HostGlobalState, RuntimeAction } from "../sdk/protocol"; + +const HOST_TAB_CHANNEL = "wasmeld-console:host-state"; +const HOST_TAB_PROTOCOL_VERSION = 1; + +type HostTabMessage = + | { + channel: typeof HOST_TAB_CHANNEL; + version: typeof HOST_TAB_PROTOCOL_VERSION; + type: "hello"; + sender: string; + } + | { + channel: typeof HOST_TAB_CHANNEL; + version: typeof HOST_TAB_PROTOCOL_VERSION; + type: "state"; + sender: string; + sequence: number; + state: HostGlobalState; + }; + +export type TabChannel = { + postMessage: (message: unknown) => void; + addEventListener: (type: "message", listener: (event: MessageEvent) => void) => void; + removeEventListener: (type: "message", listener: (event: MessageEvent) => void) => void; + close: () => void; +}; + +export type HostTabSyncOptions = { + getState: () => HostGlobalState; + applyState: (state: HostGlobalState) => void; + channel?: TabChannel; + tabId?: string; +}; + +/** + * Replicates Host-owned control-plane state between same-origin browser tabs. + * + * Every tab may publish; there is deliberately no leader election. Sequence + * numbers are scoped to a sender and suppress stale delivery, while consumers + * suppress their own Solid broadcast effect when applying a remote snapshot. + * Page-local state never enters this channel. + */ +export class HostTabSync { + readonly #channel: TabChannel; + readonly #tabId: string; + readonly #getState: () => HostGlobalState; + readonly #applyState: (state: HostGlobalState) => void; + readonly #latestSequence = new Map(); + #sequence = 0; + #closed = false; + + constructor(options: HostTabSyncOptions) { + this.#channel = options.channel ?? new BroadcastChannel(HOST_TAB_CHANNEL); + this.#tabId = options.tabId ?? crypto.randomUUID(); + this.#getState = options.getState; + this.#applyState = options.applyState; + this.#channel.addEventListener("message", this.#receive); + this.#send({ + channel: HOST_TAB_CHANNEL, + version: HOST_TAB_PROTOCOL_VERSION, + type: "hello", + sender: this.#tabId, + }); + } + + publish(state: HostGlobalState): void { + this.#send({ + channel: HOST_TAB_CHANNEL, + version: HOST_TAB_PROTOCOL_VERSION, + type: "state", + sender: this.#tabId, + sequence: ++this.#sequence, + state, + }); + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#channel.removeEventListener("message", this.#receive); + this.#channel.close(); + } + + readonly #receive = (event: MessageEvent) => { + const message = event.data; + if (!isHostTabMessage(message) || message.sender === this.#tabId) return; + + if (message.type === "hello") { + this.publish(this.#getState()); + return; + } + + const latest = this.#latestSequence.get(message.sender) ?? 0; + if (message.sequence <= latest) return; + this.#latestSequence.set(message.sender, message.sequence); + this.#applyState(message.state); + }; + + #send(message: HostTabMessage): void { + if (!this.#closed) this.#channel.postMessage(message); + } +} + +export function createBrowserHostTabSync( + options: Omit, +): HostTabSync | null { + if (typeof BroadcastChannel === "undefined") return null; + return new HostTabSync(options); +} + +function isHostTabMessage(value: unknown): value is HostTabMessage { + if (!isRecord(value)) return false; + if ( + value.channel !== HOST_TAB_CHANNEL || + value.version !== HOST_TAB_PROTOCOL_VERSION || + typeof value.sender !== "string" + ) { + return false; + } + if (value.type === "hello") return true; + return ( + value.type === "state" && + Number.isSafeInteger(value.sequence) && + (value.sequence as number) > 0 && + isHostGlobalState(value.state) + ); +} + +function isHostGlobalState(value: unknown): value is HostGlobalState { + if (!isRecord(value)) return false; + return ( + (value.snapshot === null || isRecord(value.snapshot)) && + isConnection(value.connection) && + typeof value.apiBase === "string" && + (value.runtimeAction === null || isRuntimeAction(value.runtimeAction)) && + (value.serviceAction === null || typeof value.serviceAction === "string") && + (value.deploymentAction === null || typeof value.deploymentAction === "string") + ); +} + +function isConnection(value: unknown): value is HostGlobalState["connection"] { + return value === "connecting" || value === "online" || value === "offline"; +} + +function isRuntimeAction(value: unknown): value is RuntimeAction { + return value === "start" || value === "stop" || value === "restart"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/crates/wasmeld-console/web/src/sdk/protocol.ts b/crates/wasmeld-console/web/src/sdk/protocol.ts index b492a17..c6b4bf6 100644 --- a/crates/wasmeld-console/web/src/sdk/protocol.ts +++ b/crates/wasmeld-console/web/src/sdk/protocol.ts @@ -16,18 +16,27 @@ export type RuntimeAction = "start" | "stop" | "restart"; export type ServiceAction = "start" | "stop" | "restart"; export type PageLifecyclePhase = "activate" | "deactivate" | "dispose"; -export type HostSharedState = { - // This is a serializable snapshot, never a Solid signal crossing realms. - view: View; +/** + * Control-plane state shared unconditionally between same-origin Host tabs. + * + * Navigation, search, dialogs, notifications and iframe caches are excluded: + * those values describe one browser tab rather than the Wasmeld runtime. + */ +export type HostGlobalState = { snapshot: BackendSnapshot | null; connection: ConnectionState; - query: string; apiBase: string; runtimeAction: RuntimeAction | null; serviceAction: string | null; deploymentAction: string | null; }; +export type HostSharedState = HostGlobalState & { + // This is a serializable snapshot, never a Solid signal crossing realms. + view: View; + query: string; +}; + export type PageCommand = | { type: "refresh" } | { type: "navigate"; view: View } diff --git a/crates/wasmeld-console/web/tests/tab-sync.test.mjs b/crates/wasmeld-console/web/tests/tab-sync.test.mjs new file mode 100644 index 0000000..20ef8a1 --- /dev/null +++ b/crates/wasmeld-console/web/tests/tab-sync.test.mjs @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { HostTabSync } from "../src/host/tab-sync.ts"; + +class FakeBus { + channels = new Set(); + messages = []; + + createChannel() { + const channel = new FakeChannel(this); + this.channels.add(channel); + return channel; + } + + deliver(sender, message) { + const copy = structuredClone(message); + this.messages.push(copy); + for (const channel of this.channels) { + if (channel !== sender) channel.receive(copy); + } + } + + replay(message) { + for (const channel of this.channels) channel.receive(structuredClone(message)); + } +} + +class FakeChannel { + listeners = new Set(); + + constructor(bus) { + this.bus = bus; + } + + postMessage(message) { + this.bus.deliver(this, message); + } + + addEventListener(type, listener) { + if (type === "message") this.listeners.add(listener); + } + + removeEventListener(type, listener) { + if (type === "message") this.listeners.delete(listener); + } + + receive(data) { + for (const listener of this.listeners) listener({ data }); + } + + close() { + this.bus.channels.delete(this); + this.listeners.clear(); + } +} + +test("new tabs request and receive the current Host state", () => { + const bus = new FakeBus(); + const firstState = hostState("http://runtime-a.test"); + const received = []; + const first = new HostTabSync({ + tabId: "tab-a", + channel: bus.createChannel(), + getState: () => firstState, + applyState: () => {}, + }); + const second = new HostTabSync({ + tabId: "tab-b", + channel: bus.createChannel(), + getState: () => hostState("http://runtime-b.test"), + applyState: (state) => received.push(state), + }); + + assert.deepEqual(received, [firstState]); + first.close(); + second.close(); +}); + +test("publishes Host state but rejects self, stale and malformed messages", () => { + const bus = new FakeBus(); + const received = []; + const first = new HostTabSync({ + tabId: "tab-a", + channel: bus.createChannel(), + getState: () => hostState(""), + applyState: (state) => received.push(state), + }); + const second = new HostTabSync({ + tabId: "tab-b", + channel: bus.createChannel(), + getState: () => hostState(""), + applyState: () => {}, + }); + received.length = 0; + + const update = hostState("http://shared.test"); + second.publish(update); + assert.deepEqual(received, [update]); + + const stateMessage = bus.messages.findLast( + (message) => message.type === "state" && message.sender === "tab-b", + ); + bus.replay(stateMessage); + bus.replay({ + ...stateMessage, + sequence: stateMessage.sequence + 1, + state: { ...stateMessage.state, connection: "invalid" }, + }); + assert.deepEqual(received, [update]); + + first.close(); + second.close(); +}); + +function hostState(apiBase) { + return { + snapshot: null, + connection: "online", + apiBase, + runtimeAction: null, + serviceAction: null, + deploymentAction: null, + }; +}