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
+49 -7
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;
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 {