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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user