feat(console-web): synchronize Host state across tabs

- separate global Host state from tab-local shell and Page state
- replicate control-plane snapshots through a versioned BroadcastChannel
- handshake new tabs without publishing empty startup state
- reject self, stale, and malformed cross-tab messages
- suppress broadcast echoes while applying remote snapshots
- publish API snapshots and connection state atomically
- keep Page state private and update only mounted iframe documents
- test initial synchronization and protocol rejection paths
This commit is contained in:
Maofeng
2026-07-30 09:57:06 +08:00
parent 93f4a505ba
commit 88a7fba0b0
4 changed files with 338 additions and 11 deletions
+45 -3
View File
@@ -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<string | null>(null);
const frameElements = new Map<View, HTMLIFrameElement>();
let tabSync: HostTabSync | null = null;
let applyingSharedHostState = false;
let toastTimer: number | undefined;
let refreshGeneration = 0;
const state = createMemo<HostSharedState>(() => ({
view: view(),
const globalState = createMemo<HostGlobalState>(() => ({
snapshot: snapshot(),
connection: connection(),
query: query(),
apiBase: apiBase(),
runtimeAction: runtimeAction(),
serviceAction: serviceAction(),
deploymentAction: deploymentAction(),
}));
const state = createMemo<HostSharedState>(() => ({
...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;
batch(() => {
setSnapshot(next);
setConnection("online");
});
} catch {
if (generation !== refreshGeneration) return;
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 {
@@ -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<unknown>) => void) => void;
removeEventListener: (type: "message", listener: (event: MessageEvent<unknown>) => 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<string, number>();
#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<unknown>) => {
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<HostTabSyncOptions, "channel">,
): 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<string, unknown> {
return typeof value === "object" && value !== null;
}
+13 -4
View File
@@ -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 }
@@ -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,
};
}