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
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "vite dev",
"build": "vite build && tsc --noEmit",
"preview": "vite preview",
"test": "npm run build && node --test tests/build-output.test.mjs",
"test": "npm run build && node --experimental-strip-types --test tests/*.test.mjs",
"lint": "oxlint . --deny-warnings",
"format": "oxfmt .",
"format:check": "oxfmt --check ."
+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}
/>
);
}
@@ -0,0 +1,116 @@
import type { View } from "../lib/model";
export type FrameEntry = {
view: View;
instance: number;
keepAlive: boolean;
lastUsed: number;
};
export type FrameCacheOptions = {
/**
* Maximum number of keep-alive documents, including the active document.
*
* A non-keep-alive active document may temporarily exist in addition to this
* limit. The active document is never evicted; inactive entries are removed
* from least to most recently used.
*/
maxKeepAliveEntries: number;
shouldKeepAlive: (view: View) => boolean;
};
export type FrameTransition = {
entries: FrameEntry[];
removed: FrameEntry[];
activated: FrameEntry;
};
/**
* Owns the bounded set of iframe document identities.
*
* Entries retain object identity between snapshots so Solid's `<For>` keeps
* the corresponding iframe DOM node alive. `instance` is never reused, which
* makes a recreated document observable in its URL and prevents stale browser
* history or service-worker responses from masquerading as a retained frame.
*/
export class FrameCache {
readonly #options: FrameCacheOptions;
readonly #entries = new Map<View, FrameEntry>();
#active: View;
#nextInstance = 1;
#clock = 0;
constructor(initialView: View, options: FrameCacheOptions) {
if (!Number.isInteger(options.maxKeepAliveEntries) || options.maxKeepAliveEntries < 0) {
throw new RangeError("maxKeepAliveEntries must be a non-negative integer");
}
this.#options = options;
this.#active = initialView;
this.#entries.set(initialView, this.#create(initialView));
}
get active(): View {
return this.#active;
}
snapshot(): FrameEntry[] {
return [...this.#entries.values()];
}
activate(view: View): FrameTransition {
const removed: FrameEntry[] = [];
const previous = this.#entries.get(this.#active);
if (previous && previous.view !== view && !previous.keepAlive) {
this.#entries.delete(previous.view);
removed.push(previous);
}
let activated = this.#entries.get(view);
if (!activated) {
activated = this.#create(view);
this.#entries.set(view, activated);
}
activated.lastUsed = ++this.#clock;
this.#active = view;
while (this.#keepAliveCount() > this.#options.maxKeepAliveEntries) {
const candidate = this.#oldestInactiveKeepAlive();
if (!candidate) break;
this.#entries.delete(candidate.view);
removed.push(candidate);
}
return {
entries: this.snapshot(),
removed,
activated,
};
}
#create(view: View): FrameEntry {
return {
view,
instance: this.#nextInstance++,
keepAlive: this.#options.shouldKeepAlive(view),
lastUsed: ++this.#clock,
};
}
#keepAliveCount(): number {
let count = 0;
for (const entry of this.#entries.values()) {
if (entry.keepAlive) count += 1;
}
return count;
}
#oldestInactiveKeepAlive(): FrameEntry | undefined {
let oldest: FrameEntry | undefined;
for (const entry of this.#entries.values()) {
if (!entry.keepAlive || entry.view === this.#active) continue;
if (!oldest || entry.lastUsed < oldest.lastUsed) oldest = entry;
}
return oldest;
}
}
@@ -46,7 +46,7 @@ function FramePage() {
</main>
}
>
<Page state={bridge.state} command={bridge.command} />
<Page state={bridge.state} active={bridge.active} command={bridge.command} />
</Suspense>
);
}}
@@ -3,5 +3,10 @@ import type { HostSharedState, PageCommand } from "../sdk/protocol";
export type PageProps = {
state: Accessor<HostSharedState | null>;
/**
* False while a keep-alive iframe is hidden. Page-owned polling, media or
* render loops should pause until this accessor becomes true again.
*/
active: Accessor<boolean>;
command: (command: PageCommand) => void;
};
@@ -6,12 +6,13 @@ import { notifyReady, sendCommand } from "../sdk/page";
/**
* Connects one iframe document to the persistent Host Shell.
*
* The listener and state belong to the iframe's Solid owner. Navigating or
* removing the iframe runs `onCleanup`, so the old document cannot continue to
* receive Host snapshots.
* A keep-alive document remains mounted while inactive and exposes that state
* through `active`. Removing the iframe still runs `onCleanup`, so an evicted
* document cannot continue to receive Host snapshots.
*/
export function createFrameBridge(view: View) {
const [state, setState] = createSignal<HostSharedState | null>(null);
const [active, setActive] = createSignal(false);
const receive = (event: MessageEvent<unknown>) => {
if (event.origin !== window.location.origin || event.source !== window.parent) return;
@@ -19,8 +20,15 @@ export function createFrameBridge(view: View) {
if (event.data.type === "state" && event.data.state.view === view) {
setState(event.data.state);
}
if (event.data.type === "dispose") {
window.dispatchEvent(new Event("wasmeld:dispose"));
if (event.data.type === "lifecycle") {
const phase = event.data.phase;
setActive(phase === "activate");
window.dispatchEvent(
new CustomEvent("wasmeld:lifecycle", {
detail: { phase },
}),
);
window.dispatchEvent(new Event(`wasmeld:${phase}`));
}
};
@@ -33,6 +41,7 @@ export function createFrameBridge(view: View) {
return {
state,
active,
command(command: PageCommand) {
sendCommand(view, command);
},
+10 -3
View File
@@ -1,5 +1,11 @@
import type { View } from "../lib/model";
import { SDK_CHANNEL, SDK_VERSION, type HostMessage, type HostSharedState } from "./protocol";
import {
SDK_CHANNEL,
SDK_VERSION,
type HostMessage,
type HostSharedState,
type PageLifecyclePhase,
} from "./protocol";
export function sendState(target: Window, state: HostSharedState): void {
send(target, {
@@ -11,12 +17,13 @@ export function sendState(target: Window, state: HostSharedState): void {
});
}
export function disposePage(target: Window): void {
export function sendLifecycle(target: Window, phase: PageLifecyclePhase): void {
send(target, {
channel: SDK_CHANNEL,
version: SDK_VERSION,
source: "host",
type: "dispose",
type: "lifecycle",
phase,
});
}
+12 -7
View File
@@ -14,6 +14,7 @@ export const SDK_VERSION = 1;
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.
@@ -67,7 +68,8 @@ export type HostMessage =
channel: typeof SDK_CHANNEL;
version: typeof SDK_VERSION;
source: "host";
type: "dispose";
type: "lifecycle";
phase: PageLifecyclePhase;
};
export function isPageMessage(value: unknown): value is PageMessage {
@@ -82,12 +84,15 @@ export function isPageMessage(value: unknown): value is PageMessage {
export function isHostMessage(value: unknown): value is HostMessage {
if (!isRecord(value)) return false;
return (
value.channel === SDK_CHANNEL &&
value.version === SDK_VERSION &&
value.source === "host" &&
(value.type === "state" || value.type === "dispose")
);
if (value.channel !== SDK_CHANNEL || value.version !== SDK_VERSION || value.source !== "host") {
return false;
}
if (value.type === "state") return isRecord(value.state);
return value.type === "lifecycle" && isPageLifecyclePhase(value.phase);
}
function isPageLifecyclePhase(value: unknown): value is PageLifecyclePhase {
return value === "activate" || value === "deactivate" || value === "dispose";
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import test from "node:test";
import { FrameCache } from "../src/host/frame-cache.ts";
test("retains opted-in iframe identities across navigation", () => {
const cache = new FrameCache("services", {
maxKeepAliveEntries: 2,
shouldKeepAlive: (view) => view === "services" || view === "settings",
});
const services = cache.snapshot()[0];
const away = cache.activate("overview");
assert.deepEqual(
away.entries.map((entry) => entry.view),
["services", "overview"],
);
const back = cache.activate("services");
assert.equal(back.activated, services);
assert.equal(back.activated.instance, services.instance);
assert.deepEqual(
back.removed.map((entry) => entry.view),
["overview"],
);
});
test("recreates non-keep-alive documents after leaving them", () => {
const cache = new FrameCache("overview", {
maxKeepAliveEntries: 2,
shouldKeepAlive: () => false,
});
const firstInstance = cache.snapshot()[0].instance;
cache.activate("activity");
const transition = cache.activate("overview");
assert.notEqual(transition.activated.instance, firstInstance);
assert.deepEqual(
transition.removed.map((entry) => entry.view),
["activity"],
);
});
test("evicts inactive keep-alive documents in LRU order", () => {
const cache = new FrameCache("services", {
maxKeepAliveEntries: 2,
shouldKeepAlive: () => true,
});
const servicesInstance = cache.snapshot()[0].instance;
cache.activate("settings");
const overflow = cache.activate("instances");
assert.deepEqual(
overflow.removed.map((entry) => entry.view),
["services"],
);
assert.deepEqual(
overflow.entries.map((entry) => entry.view),
["settings", "instances"],
);
const recreated = cache.activate("services");
assert.notEqual(recreated.activated.instance, servicesInstance);
assert.deepEqual(
recreated.removed.map((entry) => entry.view),
["settings"],
);
});
test("rejects invalid keep-alive limits", () => {
assert.throws(
() =>
new FrameCache("overview", {
maxKeepAliveEntries: -1,
shouldKeepAlive: () => true,
}),
RangeError,
);
});