feat(console-web): add bounded iframe keep-alive

- opt service and settings pages into document retention
- preserve iframe identity and local Solid state across navigation
- cap retained documents with deterministic LRU eviction
- deliver activate, deactivate, and dispose lifecycle messages
- reject commands emitted by inactive page documents
- expose page activity as a Solid accessor
- test retention, recreation, eviction, and invalid limits
This commit is contained in:
Maofeng
2026-07-30 09:43:08 +08:00
parent ccad05fa5a
commit 1b2c50c951
9 changed files with 360 additions and 52 deletions
+121 -35
View File
@@ -36,7 +36,7 @@ import {
} from "../lib/api";
import { isView, serviceKey, type ConnectionState, type Service, type View } from "../lib/model";
import { servicesFrom } from "../lib/view-state";
import { disposePage, pageUrl, sendState } from "../sdk/host";
import { pageUrl, sendLifecycle, sendState } from "../sdk/host";
import {
isPageMessage,
type HostSharedState,
@@ -44,26 +44,34 @@ import {
type RuntimeAction,
} from "../sdk/protocol";
import { DialogLayer, type DialogState } from "./dialogs";
import { FrameCache, type FrameEntry } from "./frame-cache";
const NAVIGATION: Array<{
id: View;
label: string;
icon: Component<LucideProps>;
keepAlive?: boolean;
}> = [
{ id: "overview", label: "运行概览", icon: LayoutDashboard },
{ id: "services", label: "服务版本", icon: Package },
{ id: "services", label: "服务版本", icon: Package, keepAlive: true },
{ id: "instances", label: "运行实例", icon: Server },
{ id: "wit-packages", label: "WIT 包", icon: FileCode2 },
{ id: "activity", label: "调用记录", icon: History },
{ id: "settings", label: "运行设置", icon: Settings },
{ id: "settings", label: "运行设置", icon: Settings, keepAlive: true },
];
const MAX_KEEP_ALIVE_IFRAMES = 3;
export default function HostApp() {
// All backend ownership stays in this persistent document. Page iframes are
// disposable renderers and can only request typed commands through the SDK.
const [view, setView] = createSignal(initialView());
const [frameInstance, setFrameInstance] = createSignal(1);
const [frameReady, setFrameReady] = createSignal(false);
const initial = initialView();
const frameCache = new FrameCache(initial, {
maxKeepAliveEntries: MAX_KEEP_ALIVE_IFRAMES,
shouldKeepAlive: keepsFrameAlive,
});
const [view, setView] = createSignal(initial);
const [frames, setFrames] = createSignal(frameCache.snapshot());
const [readyViews, setReadyViews] = createSignal<ReadonlySet<View>>(new Set<View>());
const [snapshot, setSnapshot] = createSignal<BackendSnapshot | null>(null);
const [connection, setConnection] = createSignal<ConnectionState>("connecting");
const [apiBase, setApiBase] = createSignal(getApiBase());
@@ -74,7 +82,7 @@ export default function HostApp() {
const [dialog, setDialog] = createSignal<DialogState | null>(null);
const [dialogBusy, setDialogBusy] = createSignal(false);
const [toast, setToast] = createSignal<string | null>(null);
let iframe: HTMLIFrameElement | undefined;
const frameElements = new Map<View, HTMLIFrameElement>();
let toastTimer: number | undefined;
let refreshGeneration = 0;
@@ -91,11 +99,15 @@ export default function HostApp() {
const activeServices = createMemo(
() => servicesFrom(state()).filter((service) => service.status === "running").length,
);
const frameSource = createMemo(() => pageUrl(view(), frameInstance()));
const frameReady = createMemo(() => readyViews().has(view()));
const title = createMemo(
() => NAVIGATION.find((item) => item.id === view())?.label ?? "运行概览",
);
function stateFor(targetView: View): HostSharedState {
return { ...state(), view: targetView };
}
async function refresh() {
const generation = ++refreshGeneration;
try {
@@ -118,14 +130,29 @@ export default function HostApp() {
function navigate(next: View, pushHistory = true) {
if (next === view()) return;
// Give page-owned libraries a synchronous cleanup hook, then change the
// keyed URL so Solid removes the old iframe realm in the same transition.
const target = iframe?.contentWindow;
if (target) disposePage(target);
setFrameReady(false);
const previous = view();
const transition = frameCache.activate(next);
const removedViews = new Set(transition.removed.map((entry) => entry.view));
// Retained frames are paused before being hidden. Transient and LRU-evicted
// frames receive dispose before Solid removes their document from the DOM.
if (!removedViews.has(previous)) sendFrameLifecycle(previous, "deactivate");
for (const entry of transition.removed) sendFrameLifecycle(entry.view, "dispose");
setReadyViews((current) => {
const remaining = new Set(current);
for (const removed of removedViews) remaining.delete(removed);
return remaining;
});
setFrames(transition.entries);
setView(next);
setFrameInstance((instance) => instance + 1);
setQuery("");
if (readyViews().has(next)) {
sendFrameState(next);
sendFrameLifecycle(next, "activate");
}
if (pushHistory) {
const url = new URL(window.location.href);
url.searchParams.set("view", next);
@@ -222,22 +249,39 @@ export default function HostApp() {
}
const receive = (event: MessageEvent<unknown>) => {
if (
event.origin !== window.location.origin ||
event.source !== iframe?.contentWindow ||
!isPageMessage(event.data) ||
event.data.view !== view()
) {
return;
}
if (event.origin !== window.location.origin || !isPageMessage(event.data)) return;
const sourceView = viewForSource(event.source);
if (!sourceView || event.data.view !== sourceView) return;
if (event.data.type === "ready") {
setFrameReady(true);
if (iframe?.contentWindow) sendState(iframe.contentWindow, state());
setReadyViews((current) => new Set(current).add(sourceView));
sendFrameState(sourceView);
sendFrameLifecycle(sourceView, sourceView === view() ? "activate" : "deactivate");
return;
}
// Hidden documents cannot initiate management operations. This matters for
// page-owned timers or libraries that may finish work after deactivation.
if (sourceView !== view()) return;
void handleCommand(event.data.command);
};
function viewForSource(source: MessageEventSource | null): View | null {
for (const [frameView, element] of frameElements) {
if (source === element.contentWindow) return frameView;
}
return null;
}
function sendFrameState(targetView: View) {
const target = frameElements.get(targetView)?.contentWindow;
if (target) sendState(target, stateFor(targetView));
}
function sendFrameLifecycle(targetView: View, phase: "activate" | "deactivate" | "dispose") {
const target = frameElements.get(targetView)?.contentWindow;
if (target) sendLifecycle(target, phase);
}
const popState = () => {
const requested = new URLSearchParams(window.location.search).get("view");
navigate(isView(requested) ? requested : "overview", false);
@@ -255,11 +299,15 @@ export default function HostApp() {
window.removeEventListener("message", receive);
window.removeEventListener("popstate", popState);
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
for (const frameView of frameElements.keys()) sendFrameLifecycle(frameView, "dispose");
});
createEffect(() => {
const next = state();
if (frameReady() && iframe?.contentWindow) sendState(iframe.contentWindow, next);
state();
const ready = readyViews();
for (const frame of frames()) {
if (ready.has(frame.view)) sendFrameState(frame.view);
}
});
async function register(file: File) {
@@ -377,18 +425,19 @@ export default function HostApp() {
</header>
<section class="relative col-start-1 min-h-0 min-w-0 overflow-hidden lg:col-start-2">
<Show keyed when={frameSource()}>
{(source) => (
<iframe
ref={(element) => {
iframe = element;
<For each={frames()}>
{(frame) => (
<PageFrame
frame={frame}
active={view() === frame.view}
title={titleFor(frame.view)}
register={(frameView, element) => frameElements.set(frameView, element)}
unregister={(frameView, element) => {
if (frameElements.get(frameView) === element) frameElements.delete(frameView);
}}
class="size-full border-0 bg-canvas"
src={source}
title={title()}
/>
)}
</Show>
</For>
<Show when={!frameReady()}>
<div class="pointer-events-none absolute inset-0 flex items-center justify-center bg-canvas">
<div class="flex items-center gap-2 text-sm text-muted">
@@ -442,3 +491,40 @@ function initialView(): View {
const requested = new URLSearchParams(window.location.search).get("view");
return isView(requested) ? requested : "overview";
}
function keepsFrameAlive(view: View): boolean {
return NAVIGATION.some((item) => item.id === view && item.keepAlive === true);
}
function titleFor(view: View): string {
return NAVIGATION.find((item) => item.id === view)?.label ?? view;
}
function PageFrame(props: {
frame: FrameEntry;
active: boolean;
title: string;
register: (view: View, element: HTMLIFrameElement) => void;
unregister: (view: View, element: HTMLIFrameElement) => void;
}) {
let element: HTMLIFrameElement | undefined;
onCleanup(() => {
if (element) props.unregister(props.frame.view, element);
});
return (
<iframe
ref={(next) => {
element = next;
props.register(props.frame.view, next);
}}
class="size-full border-0 bg-canvas"
classList={{ hidden: !props.active }}
src={pageUrl(props.frame.view, props.frame.instance)}
title={props.title}
aria-hidden={!props.active}
tabIndex={props.active ? 0 : -1}
/>
);
}