fix: keep machine restore snappy

This commit is contained in:
Federico Jaramillo Martinez
2026-06-09 17:06:18 +02:00
parent 65b4c76513
commit 4bc390a33a
9 changed files with 252 additions and 58 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Keep machine/session navigation snappy by deferring expensive Pi-Web status refreshes and caching status checks.
+28 -8
View File
@@ -64,6 +64,7 @@ import { appStyles } from "./shared";
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
const PI_WEB_STATUS_DEFER_MS = 750;
const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
const THEME_AUTO_ON_VALUE = "auto:on";
const THEME_AUTO_OFF_VALUE = "auto:off";
@@ -144,6 +145,7 @@ export class PiWebApp extends LitElement {
private readonly systemLightThemeMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(prefers-color-scheme: light)") : undefined;
private terminalAutoStartWorkspaceId: string | undefined;
private piWebStatusTimer: number | undefined;
private piWebStatusDeferredTimer: number | undefined;
private workspaceDeletionPollTimer: number | undefined;
private refreshingWorkspaceDeletionRuns = false;
private readonly handledWorkspaceDeletionRunIds = new Set<string>();
@@ -171,7 +173,7 @@ export class PiWebApp extends LitElement {
private readonly onFocus = () => {
this.appShell.repairViewportPosition();
void this.sessions.refreshSelectedSession();
void this.refreshPiWebStatus();
this.schedulePiWebStatusRefresh();
void this.refreshMachineActivities();
void this.refreshWorkspaceDeletionRuns();
};
@@ -179,7 +181,7 @@ export class PiWebApp extends LitElement {
if (document.visibilityState === "visible") {
this.appShell.repairViewportPosition();
void this.sessions.refreshSelectedSession();
void this.refreshPiWebStatus();
this.schedulePiWebStatusRefresh();
void this.refreshMachineActivities();
void this.refreshWorkspaceDeletionRuns();
}
@@ -213,12 +215,11 @@ export class PiWebApp extends LitElement {
this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange);
this.applyPreferredTheme(false);
this.connectRealtime();
this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS);
void this.refreshPiWebStatus();
this.piWebStatusTimer = window.setInterval(() => { this.schedulePiWebStatusRefresh(); }, PI_WEB_STATUS_REFRESH_MS);
void this.refreshWorkspaceActivity();
void this.loadClientConfig();
void this.ensureGatewayPluginsLoaded();
void this.loadProjectsAndRestoreRoute();
void this.loadProjectsAndRestoreRoute().finally(() => { this.schedulePiWebStatusRefresh(); });
}
override disconnectedCallback(): void {
@@ -236,6 +237,7 @@ export class PiWebApp extends LitElement {
this.git.dispose();
if (this.piWebStatusTimer !== undefined) window.clearInterval(this.piWebStatusTimer);
this.piWebStatusTimer = undefined;
this.clearScheduledPiWebStatusRefresh();
if (this.workspaceDeletionPollTimer !== undefined) window.clearInterval(this.workspaceDeletionPollTimer);
this.workspaceDeletionPollTimer = undefined;
super.disconnectedCallback();
@@ -265,6 +267,20 @@ export class PiWebApp extends LitElement {
await this.refreshWorkspaceDeletionRuns();
}
private schedulePiWebStatusRefresh(delayMs = PI_WEB_STATUS_DEFER_MS): void {
this.clearScheduledPiWebStatusRefresh();
this.piWebStatusDeferredTimer = window.setTimeout(() => {
this.piWebStatusDeferredTimer = undefined;
void this.refreshPiWebStatus();
}, delayMs);
}
private clearScheduledPiWebStatusRefresh(): void {
if (this.piWebStatusDeferredTimer === undefined) return;
window.clearTimeout(this.piWebStatusDeferredTimer);
this.piWebStatusDeferredTimer = undefined;
}
private async refreshPiWebStatus(): Promise<void> {
const machineId = selectedMachineId(this.state);
try {
@@ -311,12 +327,12 @@ export class PiWebApp extends LitElement {
try {
await Promise.all([
this.sessions.refreshSelectedSession(),
this.refreshPiWebStatus(),
this.refreshMachineActivities(),
this.loadClientConfig(),
this.refreshWorkspaceDeletionRuns(),
this.refreshCurrentWorkspaceSurface(),
]);
this.schedulePiWebStatusRefresh();
} finally {
this.isRefreshingApp = false;
}
@@ -340,6 +356,7 @@ export class PiWebApp extends LitElement {
}
private async restoreRouteFor(route: AppRoute, updateUrl: boolean, surface = this.readWorkspaceRouteSurface(route), restoredMainView?: AppState["mainView"]) {
const machineBeforeRestore = selectedMachineId(this.state);
const routeSurface = route.projectId === undefined || route.projectId === "" ? emptyWorkspaceRouteSurface() : surface;
const restoreSeq = ++this.routeRestoreSeq;
this.routeRestoreDepth += 1;
@@ -383,6 +400,7 @@ export class PiWebApp extends LitElement {
} finally {
this.routeRestoreDepth = Math.max(0, this.routeRestoreDepth - 1);
if (this.routeRestoreDepth === 0) this.restoringRouteTerminalId = undefined;
if (selectedMachineId(this.state) !== machineBeforeRestore) this.schedulePiWebStatusRefresh();
}
}
@@ -728,7 +746,6 @@ export class PiWebApp extends LitElement {
this.activeTerminalIds.clear();
this.setState({ piWebStatus: undefined });
this.git.updatePolling();
void this.refreshPiWebStatus();
void this.loadPluginsForSelectedMachine();
}
@@ -1360,7 +1377,10 @@ export class PiWebApp extends LitElement {
private async submitMachineDialog(input: MachineDialogSubmit): Promise<void> {
const machine = await this.machines.addMachine(input);
if (machine !== undefined) this.setState({ machineDialogOpen: false });
if (machine !== undefined) {
this.setState({ machineDialogOpen: false });
this.schedulePiWebStatusRefresh();
}
}
private async removeMachine(machine: Machine | undefined = this.state.selectedMachine): Promise<void> {
-11
View File
@@ -37,17 +37,6 @@ beforeEach(async () => {
return remoteClient;
},
now: () => new Date("2026-05-25T00:00:00.000Z"),
localStatus: () => Promise.resolve({
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web", label: "PI WEB", stale: false, available: true },
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", stale: false, available: true },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
commands: { update: "", restart: "", restartSystemd: "", restartDev: "" },
messages: [],
}),
localRuntime: () => Promise.resolve({
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
+5 -2
View File
@@ -17,6 +17,7 @@ import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js";
import { createPiWebStatusCache } from "./piWebStatusCache.js";
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { MachineService } from "./machines/machineService.js";
import { registerMachineRoutes } from "./machines/machineRoutes.js";
@@ -92,9 +93,11 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
const workspaces = deps.workspaces ?? new WorkspaceService();
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); },
});
const machines = deps.machines ?? new MachineService(undefined, {
localRuntime: () => getPiWebRuntime(sessionDaemon),
localStatus: () => getPiWebStatus(sessionDaemon),
});
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest());
@@ -107,7 +110,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
return reply.type(asset.contentType).send(asset.content);
});
app.get("/api/pi-web/status", async () => getPiWebStatus(sessionDaemon));
app.get("/api/pi-web/status", async () => piWebStatusCache.get());
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
app.get("/api/plugins", async () => piWebPlugins.plugins());
+29 -1
View File
@@ -1,7 +1,7 @@
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MachineService } from "./machineService.js";
import { MachineStore, machineStorePath } from "./machineStore.js";
@@ -69,6 +69,34 @@ describe("MachineService", () => {
await expect(service.add({ name: "Bad", baseUrl: "https://example.test", headers: { Connection: "close" } })).rejects.toThrow("not allowed");
});
it("uses the lightweight runtime check for local machine health", async () => {
const localRuntime = vi.fn(() => Promise.resolve({
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web" as const, label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [] },
sessiond: { component: "sessiond" as const, label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: [] },
},
capabilities: [],
}));
const healthService = new MachineService(new MachineStore(storePath), {
localRuntime,
now: () => new Date("2026-05-25T00:00:00.000Z"),
});
const health = await healthService.health("local");
expect(localRuntime).toHaveBeenCalledTimes(1);
expect(health).toEqual({
machineId: "local",
ok: true,
checkedAt: "2026-05-25T00:00:00.000Z",
status: "online",
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", stale: false, available: true },
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", stale: false, available: true },
});
});
it("does not allow local machine mutation", async () => {
await expect(service.update("local", { name: "Other" })).rejects.toThrow("Local machine cannot be changed");
await expect(service.remove("local")).rejects.toThrow("Local machine cannot be deleted");
+22 -5
View File
@@ -1,6 +1,6 @@
import type { Machine, MachineHealth, MachineRuntime, PiWebComponentStatus, PiWebRuntimeResponse, PiWebStatusResponse } from "../../shared/apiTypes.js";
import type { Machine, MachineHealth, MachineRuntime, PiWebComponentStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebStatusResponse } from "../../shared/apiTypes.js";
import { isPiWebCapability } from "../../shared/capabilities.js";
import { getPiWebRuntime, getPiWebStatus } from "../piWebStatus.js";
import { getPiWebRuntime } from "../piWebStatus.js";
import { DEFAULT_REMOTE_HEALTH_TIMEOUT_MS, RemoteMachineClient, type MachineClient, validateConfiguredMachineHeaders } from "./machineClient.js";
import { MachineStore, type StoredMachine } from "./machineStore.js";
@@ -14,7 +14,6 @@ export interface CreateMachineInput {
export type UpdateMachineInput = Partial<CreateMachineInput>;
export interface MachineServiceDependencies {
localStatus?: () => Promise<PiWebStatusResponse>;
localRuntime?: () => Promise<PiWebRuntimeResponse>;
remoteClientFactory?: (machine: StoredMachine) => MachineClient;
now?: () => Date;
@@ -108,8 +107,15 @@ export class MachineService {
private async localHealth(): Promise<MachineHealth> {
const checkedAt = this.now().toISOString();
try {
const status = await (this.deps.localStatus ?? getPiWebStatus)();
return { machineId: "local", ok: true, checkedAt, status: "online", web: status.components.web, sessiond: status.components.sessiond };
const runtime = await (this.deps.localRuntime ?? getPiWebRuntime)();
return {
machineId: "local",
ok: true,
checkedAt,
status: "online",
web: componentStatusFromRuntime(runtime.components.web),
sessiond: componentStatusFromRuntime(runtime.components.sessiond),
};
} catch (error) {
return { machineId: "local", ok: false, checkedAt, status: "error", error: errorMessage(error) };
}
@@ -205,6 +211,17 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function componentStatusFromRuntime(runtime: PiWebRuntimeComponent): PiWebComponentStatus {
return {
component: runtime.component,
label: runtime.label,
...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }),
stale: false,
available: runtime.available,
...(runtime.error === undefined ? {} : { error: runtime.error }),
};
}
function machineRuntime(machineId: string, checkedAt: string, runtime: PiWebRuntimeResponse): MachineRuntime {
return {
machineId,
+43 -31
View File
@@ -1,6 +1,7 @@
import { spawnSync } from "node:child_process";
import { execFile } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { readFile, realpath, stat } from "node:fs/promises";
import { promisify } from "node:util";
import { homedir } from "node:os";
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
@@ -33,6 +34,8 @@ interface NativeServiceCommands {
status?: string;
}
const execFileAsync = promisify(execFile);
const serviceRefs: Record<ServiceId, NativeServiceRef> = {
sessiond: {
id: "sessiond",
@@ -126,7 +129,7 @@ export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaem
const { web, sessiond } = versionStatus.components;
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION);
const components = { web, sessiond };
const commands = commandsFor(components);
const commands = await commandsFor(components);
const messages = buildMessages(components, release, commands);
return {
...versionStatus,
@@ -213,18 +216,21 @@ async function detectPiPackageInstallation(realRoot: string, displayPath: string
}
async function detectNpmGlobalInstallation(realRoot: string, displayPath: string): Promise<PiWebInstallationInfo | undefined> {
const npmRoot = npmGlobalRoot();
const npmRoot = await npmGlobalRoot();
if (npmRoot === undefined) return undefined;
const realNpmRoot = await realPathOrSelf(npmRoot);
if (!isSameOrWithin(realNpmRoot, realRoot)) return undefined;
return { kind: "npm-global", path: displayPath, npmRoot };
}
function npmGlobalRoot(): string | undefined {
const result = spawnSync("npm", ["root", "-g"], { encoding: "utf8" });
if (result.status !== 0) return undefined;
const root = result.stdout.trim();
return root === "" ? undefined : root;
async function npmGlobalRoot(): Promise<string | undefined> {
try {
const { stdout } = await execFileAsync("npm", ["root", "-g"], { encoding: "utf8" });
const root = stdout.trim();
return root === "" ? undefined : root;
} catch {
return undefined;
}
}
function packageRootPath(): string {
@@ -365,15 +371,17 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise<string> {
return version;
}
function commandsFor(components: PiWebStatusResponse["components"]): PiWebStatusResponse["commands"] {
async function commandsFor(components: PiWebStatusResponse["components"]): Promise<PiWebStatusResponse["commands"]> {
const installation = preferredInstallation(components);
const serviceCommands = nativeServiceCommands();
const cliCommands = piWebCliCommands(installation);
const [serviceCommands, cliCommands] = await Promise.all([
nativeServiceCommands(),
piWebCliCommands(installation),
]);
const restart = restartCommandFor(installation, serviceCommands, cliCommands);
const restartWeb = serviceCommands.restartWeb ?? cliCommands.restart;
const restartSessiond = serviceCommands.restartSessiond ?? cliCommands.restart;
const status = serviceCommands.status ?? cliCommands.status;
const update = updateCommandFor(installation, restart);
const update = await updateCommandFor(installation, restart);
return {
...(update === undefined ? {} : { update }),
@@ -391,8 +399,8 @@ function preferredInstallation(components: PiWebStatusResponse["components"]): P
return web ?? sessiond;
}
function piWebCliCommands(installation: PiWebInstallationInfo | undefined): NativeServiceCommands {
if (installation?.kind !== "npm-global" || !hasCommand("pi-web")) return {};
async function piWebCliCommands(installation: PiWebInstallationInfo | undefined): Promise<NativeServiceCommands> {
if (installation?.kind !== "npm-global" || !(await hasCommand("pi-web"))) return {};
return { restart: "pi-web restart", status: "pi-web status" };
}
@@ -401,22 +409,22 @@ function restartCommandFor(installation: PiWebInstallationInfo | undefined, serv
return cliCommands.restart ?? serviceCommands.restart;
}
function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined): string | undefined {
async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined): Promise<string | undefined> {
if (restartCommand === undefined) return undefined;
if (installation?.kind === "pi-package") {
if (!hasCommand("pi")) return undefined;
if (!(await hasCommand("pi"))) return undefined;
return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommand}`;
}
if (installation?.kind === "local" && installation.path !== undefined) {
if (!hasCommand("npm") || !isGitCheckoutWithUpstream(installation.path)) return undefined;
if (!(await hasCommand("npm")) || !(await isGitCheckoutWithUpstream(installation.path))) return undefined;
return `cd ${shellQuote(installation.path)} && git pull --ff-only && npm install && npm run build && ${restartCommand}`;
}
if (installation?.kind !== "npm-global" || !hasCommand("npm")) return undefined;
if (installation?.kind !== "npm-global" || !(await hasCommand("npm"))) return undefined;
return `npm install -g ${PI_WEB_PACKAGE_NAME} && ${restartCommand}`;
}
function nativeServiceCommands(): NativeServiceCommands {
const backend = nativeServiceBackend();
async function nativeServiceCommands(): Promise<NativeServiceCommands> {
const backend = await nativeServiceBackend();
if (backend === undefined) return {};
const installed = installedServiceIds(backend);
if (installed.size === 0) return {};
@@ -432,9 +440,9 @@ function nativeServiceCommands(): NativeServiceCommands {
};
}
function nativeServiceBackend(): NativeServiceBackendKind | undefined {
if (process.platform === "linux" && hasCommand("systemctl")) return "systemd";
if (process.platform === "darwin" && hasCommand("launchctl")) return "launchd";
async function nativeServiceBackend(): Promise<NativeServiceBackendKind | undefined> {
if (process.platform === "linux" && await hasCommand("systemctl")) return "systemd";
if (process.platform === "darwin" && await hasCommand("launchctl")) return "launchd";
return undefined;
}
@@ -468,19 +476,23 @@ function statusNativeServicesCommand(backend: NativeServiceBackendKind, refs: Na
return refs.map((ref) => `launchctl print gui/$(id -u)/${ref.launchdLabel}`).join(" && ");
}
function isGitCheckoutWithUpstream(path: string): boolean {
return hasCommand("git")
&& commandSucceeds("git", ["-C", path, "rev-parse", "--is-inside-work-tree"])
&& commandSucceeds("git", ["-C", path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
async function isGitCheckoutWithUpstream(path: string): Promise<boolean> {
return await hasCommand("git")
&& await commandSucceeds("git", ["-C", path, "rev-parse", "--is-inside-work-tree"])
&& await commandSucceeds("git", ["-C", path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
}
function hasCommand(command: string): boolean {
function hasCommand(command: string): Promise<boolean> {
return commandSucceeds("/usr/bin/env", ["sh", "-c", `command -v ${command}`]);
}
function commandSucceeds(command: string, args: string[]): boolean {
const result = spawnSync(command, args, { encoding: "utf8" });
return result.status === 0;
async function commandSucceeds(command: string, args: string[]): Promise<boolean> {
try {
await execFileAsync(command, args, { encoding: "utf8" });
return true;
} catch {
return false;
}
}
function shellQuote(value: string): string {
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
import { createPiWebStatusCache } from "./piWebStatusCache.js";
describe("createPiWebStatusCache", () => {
it("serves cached status while it is fresh", async () => {
const now = 1_000;
const load = vi.fn(() => Promise.resolve(status("first")));
const cache = createPiWebStatusCache(load, { ttlMs: 100, now: () => now });
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
expect(load).toHaveBeenCalledTimes(1);
});
it("returns stale status immediately while refreshing in the background", async () => {
let now = 1_000;
const load = vi.fn()
.mockResolvedValueOnce(status("first"))
.mockResolvedValueOnce(status("second"));
const cache = createPiWebStatusCache(load, { ttlMs: 100, now: () => now });
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
now = 1_101;
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
await waitForMicrotasks();
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "second" });
expect(load).toHaveBeenCalledTimes(2);
});
it("deduplicates concurrent cold loads", async () => {
const deferred = createDeferred<PiWebStatusResponse>();
const load = vi.fn(() => deferred.promise);
const cache = createPiWebStatusCache(load);
const first = cache.get();
const second = cache.get();
deferred.resolve(status("ready"));
await expect(first).resolves.toMatchObject({ generatedAt: "ready" });
await expect(second).resolves.toMatchObject({ generatedAt: "ready" });
expect(load).toHaveBeenCalledTimes(1);
});
});
function status(generatedAt: string): PiWebStatusResponse {
return {
packageName: "@jmfederico/pi-web",
generatedAt,
components: {
web: { component: "web", label: "Web/UI", stale: false, available: true },
sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
commands: {},
messages: [],
};
}
async function waitForMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
+46
View File
@@ -0,0 +1,46 @@
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
const DEFAULT_PI_WEB_STATUS_CACHE_TTL_MS = 60_000;
export interface PiWebStatusCacheOptions {
ttlMs?: number;
now?: () => number;
onError?: (error: unknown) => void;
}
export interface PiWebStatusCache {
get(): Promise<PiWebStatusResponse>;
refresh(): Promise<PiWebStatusResponse>;
}
export function createPiWebStatusCache(load: () => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_STATUS_CACHE_TTL_MS;
const now = options.now ?? Date.now;
let cached: { status: PiWebStatusResponse; expiresAt: number } | undefined;
let pending: Promise<PiWebStatusResponse> | undefined;
const refresh = (): Promise<PiWebStatusResponse> => {
pending ??= Promise.resolve()
.then(load)
.then((status) => {
cached = { status, expiresAt: now() + ttlMs };
return status;
})
.finally(() => {
pending = undefined;
});
return pending;
};
return {
async get(): Promise<PiWebStatusResponse> {
if (cached !== undefined) {
if (cached.expiresAt > now()) return cached.status;
void refresh().catch((error: unknown) => { options.onError?.(error); });
return cached.status;
}
return refresh();
},
refresh,
};
}