From 4bc390a33aea0bc42e3a7bb5771ff5cadeabc5e6 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 9 Jun 2026 17:06:18 +0200 Subject: [PATCH] fix: keep machine restore snappy --- .changeset/snappy-machine-restore.md | 5 ++ src/client/src/components/PiWebApp.ts | 36 ++++++++--- src/server/app.test.ts | 11 ---- src/server/app.ts | 7 +- src/server/machines/machineService.test.ts | 30 ++++++++- src/server/machines/machineService.ts | 27 ++++++-- src/server/piWebStatus.ts | 74 +++++++++++++--------- src/server/piWebStatusCache.test.ts | 74 ++++++++++++++++++++++ src/server/piWebStatusCache.ts | 46 ++++++++++++++ 9 files changed, 252 insertions(+), 58 deletions(-) create mode 100644 .changeset/snappy-machine-restore.md create mode 100644 src/server/piWebStatusCache.test.ts create mode 100644 src/server/piWebStatusCache.ts diff --git a/.changeset/snappy-machine-restore.md b/.changeset/snappy-machine-restore.md new file mode 100644 index 0000000..5450783 --- /dev/null +++ b/.changeset/snappy-machine-restore.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep machine/session navigation snappy by deferring expensive Pi-Web status refreshes and caching status checks. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index d88cae3..8eb9dbf 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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(); @@ -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 { 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 { 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 { diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 92b0ad2..5eda1a5 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -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", diff --git a/src/server/app.ts b/src/server/app.ts index 7eb39ea..4829678 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -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 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 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()); diff --git a/src/server/machines/machineService.test.ts b/src/server/machines/machineService.test.ts index 337deb7..2e255bf 100644 --- a/src/server/machines/machineService.test.ts +++ b/src/server/machines/machineService.test.ts @@ -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"); diff --git a/src/server/machines/machineService.ts b/src/server/machines/machineService.ts index 1e55f68..8044cee 100644 --- a/src/server/machines/machineService.ts +++ b/src/server/machines/machineService.ts @@ -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; export interface MachineServiceDependencies { - localStatus?: () => Promise; localRuntime?: () => Promise; remoteClientFactory?: (machine: StoredMachine) => MachineClient; now?: () => Date; @@ -108,8 +107,15 @@ export class MachineService { private async localHealth(): Promise { 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, diff --git a/src/server/piWebStatus.ts b/src/server/piWebStatus.ts index e1bf4be..e033e12 100644 --- a/src/server/piWebStatus.ts +++ b/src/server/piWebStatus.ts @@ -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 = { 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 { - 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 { + 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 { return version; } -function commandsFor(components: PiWebStatusResponse["components"]): PiWebStatusResponse["commands"] { +async function commandsFor(components: PiWebStatusResponse["components"]): Promise { 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 { + 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 { 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 { + 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 { + 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 { + 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 { 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 { + try { + await execFileAsync(command, args, { encoding: "utf8" }); + return true; + } catch { + return false; + } } function shellQuote(value: string): string { diff --git a/src/server/piWebStatusCache.test.ts b/src/server/piWebStatusCache.test.ts new file mode 100644 index 0000000..44170f3 --- /dev/null +++ b/src/server/piWebStatusCache.test.ts @@ -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(); + 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 { + await Promise.resolve(); + await Promise.resolve(); +} + +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} diff --git a/src/server/piWebStatusCache.ts b/src/server/piWebStatusCache.ts new file mode 100644 index 0000000..ec8813b --- /dev/null +++ b/src/server/piWebStatusCache.ts @@ -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; + refresh(): Promise; +} + +export function createPiWebStatusCache(load: () => Promise, 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 | undefined; + + const refresh = (): Promise => { + pending ??= Promise.resolve() + .then(load) + .then((status) => { + cached = { status, expiresAt: now() + ttlMs }; + return status; + }) + .finally(() => { + pending = undefined; + }); + return pending; + }; + + return { + async get(): Promise { + if (cached !== undefined) { + if (cached.expiresAt > now()) return cached.status; + void refresh().catch((error: unknown) => { options.onError?.(error); }); + return cached.status; + } + return refresh(); + }, + refresh, + }; +}