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:
@@ -7,7 +7,7 @@
|
|||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build && tsc --noEmit",
|
"build": "vite build && tsc --noEmit",
|
||||||
"preview": "vite preview",
|
"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",
|
"lint": "oxlint . --deny-warnings",
|
||||||
"format": "oxfmt .",
|
"format": "oxfmt .",
|
||||||
"format:check": "oxfmt --check ."
|
"format:check": "oxfmt --check ."
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { isView, serviceKey, type ConnectionState, type Service, type View } from "../lib/model";
|
import { isView, serviceKey, type ConnectionState, type Service, type View } from "../lib/model";
|
||||||
import { servicesFrom } from "../lib/view-state";
|
import { servicesFrom } from "../lib/view-state";
|
||||||
import { disposePage, pageUrl, sendState } from "../sdk/host";
|
import { pageUrl, sendLifecycle, sendState } from "../sdk/host";
|
||||||
import {
|
import {
|
||||||
isPageMessage,
|
isPageMessage,
|
||||||
type HostSharedState,
|
type HostSharedState,
|
||||||
@@ -44,26 +44,34 @@ import {
|
|||||||
type RuntimeAction,
|
type RuntimeAction,
|
||||||
} from "../sdk/protocol";
|
} from "../sdk/protocol";
|
||||||
import { DialogLayer, type DialogState } from "./dialogs";
|
import { DialogLayer, type DialogState } from "./dialogs";
|
||||||
|
import { FrameCache, type FrameEntry } from "./frame-cache";
|
||||||
|
|
||||||
const NAVIGATION: Array<{
|
const NAVIGATION: Array<{
|
||||||
id: View;
|
id: View;
|
||||||
label: string;
|
label: string;
|
||||||
icon: Component<LucideProps>;
|
icon: Component<LucideProps>;
|
||||||
|
keepAlive?: boolean;
|
||||||
}> = [
|
}> = [
|
||||||
{ id: "overview", label: "运行概览", icon: LayoutDashboard },
|
{ id: "overview", label: "运行概览", icon: LayoutDashboard },
|
||||||
{ id: "services", label: "服务版本", icon: Package },
|
{ id: "services", label: "服务版本", icon: Package, keepAlive: true },
|
||||||
{ id: "instances", label: "运行实例", icon: Server },
|
{ id: "instances", label: "运行实例", icon: Server },
|
||||||
{ id: "wit-packages", label: "WIT 包", icon: FileCode2 },
|
{ id: "wit-packages", label: "WIT 包", icon: FileCode2 },
|
||||||
{ id: "activity", label: "调用记录", icon: History },
|
{ 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() {
|
export default function HostApp() {
|
||||||
// All backend ownership stays in this persistent document. Page iframes are
|
// All backend ownership stays in this persistent document. Page iframes are
|
||||||
// disposable renderers and can only request typed commands through the SDK.
|
// disposable renderers and can only request typed commands through the SDK.
|
||||||
const [view, setView] = createSignal(initialView());
|
const initial = initialView();
|
||||||
const [frameInstance, setFrameInstance] = createSignal(1);
|
const frameCache = new FrameCache(initial, {
|
||||||
const [frameReady, setFrameReady] = createSignal(false);
|
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 [snapshot, setSnapshot] = createSignal<BackendSnapshot | null>(null);
|
||||||
const [connection, setConnection] = createSignal<ConnectionState>("connecting");
|
const [connection, setConnection] = createSignal<ConnectionState>("connecting");
|
||||||
const [apiBase, setApiBase] = createSignal(getApiBase());
|
const [apiBase, setApiBase] = createSignal(getApiBase());
|
||||||
@@ -74,7 +82,7 @@ export default function HostApp() {
|
|||||||
const [dialog, setDialog] = createSignal<DialogState | null>(null);
|
const [dialog, setDialog] = createSignal<DialogState | null>(null);
|
||||||
const [dialogBusy, setDialogBusy] = createSignal(false);
|
const [dialogBusy, setDialogBusy] = createSignal(false);
|
||||||
const [toast, setToast] = createSignal<string | null>(null);
|
const [toast, setToast] = createSignal<string | null>(null);
|
||||||
let iframe: HTMLIFrameElement | undefined;
|
const frameElements = new Map<View, HTMLIFrameElement>();
|
||||||
let toastTimer: number | undefined;
|
let toastTimer: number | undefined;
|
||||||
let refreshGeneration = 0;
|
let refreshGeneration = 0;
|
||||||
|
|
||||||
@@ -91,11 +99,15 @@ export default function HostApp() {
|
|||||||
const activeServices = createMemo(
|
const activeServices = createMemo(
|
||||||
() => servicesFrom(state()).filter((service) => service.status === "running").length,
|
() => servicesFrom(state()).filter((service) => service.status === "running").length,
|
||||||
);
|
);
|
||||||
const frameSource = createMemo(() => pageUrl(view(), frameInstance()));
|
const frameReady = createMemo(() => readyViews().has(view()));
|
||||||
const title = createMemo(
|
const title = createMemo(
|
||||||
() => NAVIGATION.find((item) => item.id === view())?.label ?? "运行概览",
|
() => NAVIGATION.find((item) => item.id === view())?.label ?? "运行概览",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function stateFor(targetView: View): HostSharedState {
|
||||||
|
return { ...state(), view: targetView };
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const generation = ++refreshGeneration;
|
const generation = ++refreshGeneration;
|
||||||
try {
|
try {
|
||||||
@@ -118,14 +130,29 @@ export default function HostApp() {
|
|||||||
|
|
||||||
function navigate(next: View, pushHistory = true) {
|
function navigate(next: View, pushHistory = true) {
|
||||||
if (next === view()) return;
|
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 previous = view();
|
||||||
const target = iframe?.contentWindow;
|
const transition = frameCache.activate(next);
|
||||||
if (target) disposePage(target);
|
const removedViews = new Set(transition.removed.map((entry) => entry.view));
|
||||||
setFrameReady(false);
|
|
||||||
|
// 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);
|
setView(next);
|
||||||
setFrameInstance((instance) => instance + 1);
|
|
||||||
setQuery("");
|
setQuery("");
|
||||||
|
|
||||||
|
if (readyViews().has(next)) {
|
||||||
|
sendFrameState(next);
|
||||||
|
sendFrameLifecycle(next, "activate");
|
||||||
|
}
|
||||||
if (pushHistory) {
|
if (pushHistory) {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set("view", next);
|
url.searchParams.set("view", next);
|
||||||
@@ -222,22 +249,39 @@ export default function HostApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const receive = (event: MessageEvent<unknown>) => {
|
const receive = (event: MessageEvent<unknown>) => {
|
||||||
if (
|
if (event.origin !== window.location.origin || !isPageMessage(event.data)) return;
|
||||||
event.origin !== window.location.origin ||
|
const sourceView = viewForSource(event.source);
|
||||||
event.source !== iframe?.contentWindow ||
|
if (!sourceView || event.data.view !== sourceView) return;
|
||||||
!isPageMessage(event.data) ||
|
|
||||||
event.data.view !== view()
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.data.type === "ready") {
|
if (event.data.type === "ready") {
|
||||||
setFrameReady(true);
|
setReadyViews((current) => new Set(current).add(sourceView));
|
||||||
if (iframe?.contentWindow) sendState(iframe.contentWindow, state());
|
sendFrameState(sourceView);
|
||||||
|
sendFrameLifecycle(sourceView, sourceView === view() ? "activate" : "deactivate");
|
||||||
return;
|
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);
|
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 popState = () => {
|
||||||
const requested = new URLSearchParams(window.location.search).get("view");
|
const requested = new URLSearchParams(window.location.search).get("view");
|
||||||
navigate(isView(requested) ? requested : "overview", false);
|
navigate(isView(requested) ? requested : "overview", false);
|
||||||
@@ -255,11 +299,15 @@ export default function HostApp() {
|
|||||||
window.removeEventListener("message", receive);
|
window.removeEventListener("message", receive);
|
||||||
window.removeEventListener("popstate", popState);
|
window.removeEventListener("popstate", popState);
|
||||||
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
||||||
|
for (const frameView of frameElements.keys()) sendFrameLifecycle(frameView, "dispose");
|
||||||
});
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const next = state();
|
state();
|
||||||
if (frameReady() && iframe?.contentWindow) sendState(iframe.contentWindow, next);
|
const ready = readyViews();
|
||||||
|
for (const frame of frames()) {
|
||||||
|
if (ready.has(frame.view)) sendFrameState(frame.view);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function register(file: File) {
|
async function register(file: File) {
|
||||||
@@ -377,18 +425,19 @@ export default function HostApp() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="relative col-start-1 min-h-0 min-w-0 overflow-hidden lg:col-start-2">
|
<section class="relative col-start-1 min-h-0 min-w-0 overflow-hidden lg:col-start-2">
|
||||||
<Show keyed when={frameSource()}>
|
<For each={frames()}>
|
||||||
{(source) => (
|
{(frame) => (
|
||||||
<iframe
|
<PageFrame
|
||||||
ref={(element) => {
|
frame={frame}
|
||||||
iframe = element;
|
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()}>
|
<Show when={!frameReady()}>
|
||||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center bg-canvas">
|
<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">
|
<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");
|
const requested = new URLSearchParams(window.location.search).get("view");
|
||||||
return isView(requested) ? requested : "overview";
|
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>
|
</main>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Page state={bridge.state} command={bridge.command} />
|
<Page state={bridge.state} active={bridge.active} command={bridge.command} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -3,5 +3,10 @@ import type { HostSharedState, PageCommand } from "../sdk/protocol";
|
|||||||
|
|
||||||
export type PageProps = {
|
export type PageProps = {
|
||||||
state: Accessor<HostSharedState | null>;
|
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;
|
command: (command: PageCommand) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import { notifyReady, sendCommand } from "../sdk/page";
|
|||||||
/**
|
/**
|
||||||
* Connects one iframe document to the persistent Host Shell.
|
* Connects one iframe document to the persistent Host Shell.
|
||||||
*
|
*
|
||||||
* The listener and state belong to the iframe's Solid owner. Navigating or
|
* A keep-alive document remains mounted while inactive and exposes that state
|
||||||
* removing the iframe runs `onCleanup`, so the old document cannot continue to
|
* through `active`. Removing the iframe still runs `onCleanup`, so an evicted
|
||||||
* receive Host snapshots.
|
* document cannot continue to receive Host snapshots.
|
||||||
*/
|
*/
|
||||||
export function createFrameBridge(view: View) {
|
export function createFrameBridge(view: View) {
|
||||||
const [state, setState] = createSignal<HostSharedState | null>(null);
|
const [state, setState] = createSignal<HostSharedState | null>(null);
|
||||||
|
const [active, setActive] = createSignal(false);
|
||||||
|
|
||||||
const receive = (event: MessageEvent<unknown>) => {
|
const receive = (event: MessageEvent<unknown>) => {
|
||||||
if (event.origin !== window.location.origin || event.source !== window.parent) return;
|
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) {
|
if (event.data.type === "state" && event.data.state.view === view) {
|
||||||
setState(event.data.state);
|
setState(event.data.state);
|
||||||
}
|
}
|
||||||
if (event.data.type === "dispose") {
|
if (event.data.type === "lifecycle") {
|
||||||
window.dispatchEvent(new Event("wasmeld:dispose"));
|
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 {
|
return {
|
||||||
state,
|
state,
|
||||||
|
active,
|
||||||
command(command: PageCommand) {
|
command(command: PageCommand) {
|
||||||
sendCommand(view, command);
|
sendCommand(view, command);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { View } from "../lib/model";
|
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 {
|
export function sendState(target: Window, state: HostSharedState): void {
|
||||||
send(target, {
|
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, {
|
send(target, {
|
||||||
channel: SDK_CHANNEL,
|
channel: SDK_CHANNEL,
|
||||||
version: SDK_VERSION,
|
version: SDK_VERSION,
|
||||||
source: "host",
|
source: "host",
|
||||||
type: "dispose",
|
type: "lifecycle",
|
||||||
|
phase,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const SDK_VERSION = 1;
|
|||||||
|
|
||||||
export type RuntimeAction = "start" | "stop" | "restart";
|
export type RuntimeAction = "start" | "stop" | "restart";
|
||||||
export type ServiceAction = "start" | "stop" | "restart";
|
export type ServiceAction = "start" | "stop" | "restart";
|
||||||
|
export type PageLifecyclePhase = "activate" | "deactivate" | "dispose";
|
||||||
|
|
||||||
export type HostSharedState = {
|
export type HostSharedState = {
|
||||||
// This is a serializable snapshot, never a Solid signal crossing realms.
|
// This is a serializable snapshot, never a Solid signal crossing realms.
|
||||||
@@ -67,7 +68,8 @@ export type HostMessage =
|
|||||||
channel: typeof SDK_CHANNEL;
|
channel: typeof SDK_CHANNEL;
|
||||||
version: typeof SDK_VERSION;
|
version: typeof SDK_VERSION;
|
||||||
source: "host";
|
source: "host";
|
||||||
type: "dispose";
|
type: "lifecycle";
|
||||||
|
phase: PageLifecyclePhase;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isPageMessage(value: unknown): value is PageMessage {
|
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 {
|
export function isHostMessage(value: unknown): value is HostMessage {
|
||||||
if (!isRecord(value)) return false;
|
if (!isRecord(value)) return false;
|
||||||
return (
|
if (value.channel !== SDK_CHANNEL || value.version !== SDK_VERSION || value.source !== "host") {
|
||||||
value.channel === SDK_CHANNEL &&
|
return false;
|
||||||
value.version === SDK_VERSION &&
|
}
|
||||||
value.source === "host" &&
|
if (value.type === "state") return isRecord(value.state);
|
||||||
(value.type === "state" || value.type === "dispose")
|
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> {
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user