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