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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user