Archived
feat: add manual PI WEB update checks
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
|
||||
import { buildApp } from "./app.js";
|
||||
|
||||
describe("PI WEB status routes", () => {
|
||||
it("forces a fresh status load when refresh is requested", async () => {
|
||||
const get = vi.fn(() => Promise.resolve(status("cached")));
|
||||
const refresh = vi.fn(() => Promise.resolve(status("forced")));
|
||||
const app = await buildApp({ piWebStatusCache: { get, refresh }, clientDist: false, logger: false });
|
||||
|
||||
try {
|
||||
const cachedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status" });
|
||||
const forcedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status?refresh=1" });
|
||||
|
||||
expect(cachedResponse.json<PiWebStatusResponse>().generatedAt).toBe("cached");
|
||||
expect(forcedResponse.json<PiWebStatusResponse>().generatedAt).toBe("forced");
|
||||
expect(get).toHaveBeenCalledOnce();
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
expect(refresh).toHaveBeenCalledWith({ force: true });
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,23 @@ describe("buildApp remote machine proxy routes", () => {
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
|
||||
});
|
||||
|
||||
it("preserves the force-refresh query when proxying update checks", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const request = vi.fn<MachineClient["request"]>(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: Readable.from([JSON.stringify({ ok: true })]),
|
||||
}));
|
||||
appTestContext.remoteClient = fakeRemoteClient({ request });
|
||||
|
||||
const response = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web/status?refresh=1` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ ok: true });
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/pi-web/status?refresh=1", undefined);
|
||||
});
|
||||
|
||||
it("proxies remote Pi package routes and gives package mutations a longer timeout", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
|
||||
+9
-5
@@ -23,7 +23,7 @@ import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachin
|
||||
import { PiWebPluginService } from "./piWebPluginService.js";
|
||||
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
|
||||
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
|
||||
import { createPiWebStatusCache } from "./piWebStatusCache.js";
|
||||
import { createPiWebStatusCache, type PiWebStatusCache } from "./piWebStatusCache.js";
|
||||
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
import { MachineService } from "./machines/machineService.js";
|
||||
import { registerMachineRoutes } from "./machines/machineRoutes.js";
|
||||
@@ -38,6 +38,7 @@ export interface AppDependencies {
|
||||
sessionDaemon?: SessionProxyDaemon;
|
||||
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
|
||||
piPackages?: PiPackageService;
|
||||
piWebStatusCache?: PiWebStatusCache;
|
||||
config?: PiWebConfigService;
|
||||
clientDist?: string | false;
|
||||
logger?: FastifyServerOptions["logger"];
|
||||
@@ -136,9 +137,10 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
const piPackages = deps.piPackages ?? createDefaultPiPackageService();
|
||||
const configService = deps.config ?? createFilePiWebConfigService();
|
||||
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 piWebStatusCache = deps.piWebStatusCache ?? createPiWebStatusCache(
|
||||
({ force }) => getPiWebStatus(sessionDaemon, { forceReleaseCheck: force }),
|
||||
{ onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); } },
|
||||
);
|
||||
const machines = deps.machines ?? new MachineService(undefined, {
|
||||
localRuntime: () => getPiWebRuntime(sessionDaemon),
|
||||
});
|
||||
@@ -153,7 +155,9 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
return reply.type(asset.contentType).send(asset.content);
|
||||
});
|
||||
|
||||
app.get("/api/pi-web/status", async () => piWebStatusCache.get());
|
||||
app.get<{ Querystring: { refresh?: string } }>("/api/pi-web/status", async (request) => request.query.refresh === "1"
|
||||
? piWebStatusCache.refresh({ force: true })
|
||||
: 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());
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createPiWebReleaseLookupCache } from "./piWebReleaseLookupCache.js";
|
||||
|
||||
describe("createPiWebReleaseLookupCache", () => {
|
||||
it("serves a fresh cached release lookup", async () => {
|
||||
let now = 1_000;
|
||||
const load = vi.fn(() => Promise.resolve("1.0.0"));
|
||||
const cache = createPiWebReleaseLookupCache(load, { ttlMs: 100, now: () => now });
|
||||
|
||||
await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "1.0.0", checkedAtMs: 1_000 });
|
||||
now = 1_050;
|
||||
await expect(cache.get("0.9.1")).resolves.toMatchObject({ latestVersion: "1.0.0", checkedAtMs: 1_000 });
|
||||
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
expect(load).toHaveBeenCalledWith("0.9.0");
|
||||
});
|
||||
|
||||
it("bypasses a fresh lookup when forced", async () => {
|
||||
let now = 1_000;
|
||||
const load = vi.fn()
|
||||
.mockResolvedValueOnce("1.0.0")
|
||||
.mockResolvedValueOnce("1.1.0");
|
||||
const cache = createPiWebReleaseLookupCache(load, { ttlMs: 100, now: () => now });
|
||||
|
||||
await cache.get("0.9.0");
|
||||
now = 1_050;
|
||||
|
||||
await expect(cache.get("0.9.0", { force: true })).resolves.toMatchObject({ latestVersion: "1.1.0", checkedAtMs: 1_050 });
|
||||
await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "1.1.0", checkedAtMs: 1_050 });
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(["forced-first", "regular-first"] as const)("does not let an older regular lookup replace a forced result when %s completes", async (completionOrder) => {
|
||||
const regular = createDeferred<string>();
|
||||
const forced = createDeferred<string>();
|
||||
const load = vi.fn()
|
||||
.mockImplementationOnce(() => regular.promise)
|
||||
.mockImplementationOnce(() => forced.promise);
|
||||
const cache = createPiWebReleaseLookupCache(load);
|
||||
|
||||
const regularLookup = cache.get("0.9.0");
|
||||
const forcedLookup = cache.get("0.9.0", { force: true });
|
||||
if (completionOrder === "forced-first") {
|
||||
forced.resolve("2.0.0");
|
||||
await expect(forcedLookup).resolves.toMatchObject({ latestVersion: "2.0.0" });
|
||||
regular.resolve("1.0.0");
|
||||
await expect(regularLookup).resolves.toMatchObject({ latestVersion: "1.0.0" });
|
||||
} else {
|
||||
regular.resolve("1.0.0");
|
||||
await expect(regularLookup).resolves.toMatchObject({ latestVersion: "1.0.0" });
|
||||
forced.resolve("2.0.0");
|
||||
await expect(forcedLookup).resolves.toMatchObject({ latestVersion: "2.0.0" });
|
||||
}
|
||||
|
||||
await expect(cache.get("0.9.0")).resolves.toMatchObject({ latestVersion: "2.0.0" });
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("makes regular callers join a pending forced lookup", async () => {
|
||||
const forced = createDeferred<string>();
|
||||
const load = vi.fn(() => forced.promise);
|
||||
const cache = createPiWebReleaseLookupCache(load);
|
||||
|
||||
const forcedLookup = cache.get("0.9.0", { force: true });
|
||||
const regularLookup = cache.get("0.9.0");
|
||||
|
||||
expect(regularLookup).toBe(forcedLookup);
|
||||
forced.resolve("2.0.0");
|
||||
await expect(regularLookup).resolves.toMatchObject({ latestVersion: "2.0.0" });
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
const DEFAULT_PI_WEB_RELEASE_LOOKUP_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
export interface PiWebReleaseLookup {
|
||||
checkedAtMs: number;
|
||||
latestVersion?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PiWebReleaseLookupCacheOptions {
|
||||
ttlMs?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface PiWebReleaseLookupOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface PiWebReleaseLookupCache {
|
||||
get(currentVersion: string, options?: PiWebReleaseLookupOptions): Promise<PiWebReleaseLookup>;
|
||||
}
|
||||
|
||||
export function createPiWebReleaseLookupCache(
|
||||
load: (currentVersion: string) => Promise<string>,
|
||||
options: PiWebReleaseLookupCacheOptions = {},
|
||||
): PiWebReleaseLookupCache {
|
||||
const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_RELEASE_LOOKUP_CACHE_TTL_MS;
|
||||
const now = options.now ?? Date.now;
|
||||
let cached: PiWebReleaseLookup | undefined;
|
||||
let pending: { promise: Promise<PiWebReleaseLookup>; force: boolean; sequence: number } | undefined;
|
||||
let loadSequence = 0;
|
||||
|
||||
return {
|
||||
get(currentVersion: string, lookupOptions: PiWebReleaseLookupOptions = {}): Promise<PiWebReleaseLookup> {
|
||||
const force = lookupOptions.force === true;
|
||||
if (pending?.force === true) return pending.promise;
|
||||
|
||||
const checkedAtMs = now();
|
||||
if (!force && cached !== undefined && checkedAtMs - cached.checkedAtMs < ttlMs) return Promise.resolve(cached);
|
||||
if (!force && pending !== undefined) return pending.promise;
|
||||
|
||||
const sequence = ++loadSequence;
|
||||
const promise = Promise.resolve()
|
||||
.then(() => load(currentVersion))
|
||||
.then((latestVersion): PiWebReleaseLookup => ({ checkedAtMs, latestVersion }))
|
||||
.catch((error: unknown): PiWebReleaseLookup => ({ checkedAtMs, error: error instanceof Error ? error.message : String(error) }))
|
||||
.then((lookup) => {
|
||||
if (sequence === loadSequence) cached = lookup;
|
||||
return lookup;
|
||||
})
|
||||
.finally(() => {
|
||||
if (pending?.sequence === sequence) pending = undefined;
|
||||
});
|
||||
pending = { promise, force, sequence };
|
||||
return promise;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -69,6 +69,33 @@ describe("PI WEB status", () => {
|
||||
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
|
||||
});
|
||||
|
||||
it("bypasses cached npm release data for a forced check", async () => {
|
||||
Reflect.deleteProperty(process.env, "PI_WEB_SKIP_VERSION_CHECK");
|
||||
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
|
||||
process.env["PI_WEB_DOCKER_MODE"] = "runtime";
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(npmVersionResponse("1.202607.1"))
|
||||
.mockResolvedValueOnce(npmVersionResponse("1.202607.2"));
|
||||
const daemon = daemonWithComponent({
|
||||
component: "sessiond",
|
||||
label: "Session daemon",
|
||||
runtimeVersion: "1.202607.0",
|
||||
installedVersion: "1.202607.0",
|
||||
stale: false,
|
||||
available: true,
|
||||
installation: { kind: "docker", dockerMode: "runtime" },
|
||||
});
|
||||
|
||||
const first = await getPiWebStatus(daemon, { forceReleaseCheck: true });
|
||||
const cached = await getPiWebStatus(daemon);
|
||||
const forced = await getPiWebStatus(daemon, { forceReleaseCheck: true });
|
||||
|
||||
expect(first.release.latestVersion).toBe("1.202607.1");
|
||||
expect(cached.release.latestVersion).toBe("1.202607.1");
|
||||
expect(forced.release.latestVersion).toBe("1.202607.2");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports stale session daemon versions as messages", async () => {
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
disableDockerRuntimeEnv();
|
||||
@@ -82,7 +109,7 @@ describe("PI WEB status", () => {
|
||||
installation: { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
|
||||
});
|
||||
|
||||
const status = await getPiWebStatus(daemon);
|
||||
const status = await getPiWebStatus(daemon, { forceReleaseCheck: true });
|
||||
|
||||
expect(status.release.skipped).toBe(true);
|
||||
expect(status.components.sessiond.stale).toBe(true);
|
||||
@@ -192,6 +219,10 @@ describe("PI WEB status", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function npmVersionResponse(version: string): Response {
|
||||
return new Response(JSON.stringify({ version }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function daemonWithComponent(component: PiWebComponentStatus): SessionDaemonClient {
|
||||
const daemon = new SessionDaemonClient();
|
||||
vi.spyOn(daemon, "request").mockResolvedValue({
|
||||
|
||||
+10
-16
@@ -11,11 +11,11 @@ import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/
|
||||
import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js";
|
||||
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
|
||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||
import { createPiWebReleaseLookupCache, type PiWebReleaseLookup } from "./piWebReleaseLookupCache.js";
|
||||
|
||||
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
|
||||
const PI_WEB_NPM_SOURCE = `npm:${PI_WEB_PACKAGE_NAME}`;
|
||||
const DEFAULT_VERSION = "0.0.0-dev";
|
||||
const LATEST_RELEASE_CACHE_MS = 6 * 60 * 60 * 1000;
|
||||
const VERSION_CHECK_TIMEOUT_MS = 5000;
|
||||
|
||||
type ServiceId = "sessiond" | "web" | "uiDev";
|
||||
@@ -74,8 +74,11 @@ interface PiWebStatusDaemon {
|
||||
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }>;
|
||||
}
|
||||
|
||||
let latestReleaseCache: { checkedAtMs: number; latestVersion?: string; error?: string } | undefined;
|
||||
export interface PiWebStatusOptions {
|
||||
forceReleaseCheck?: boolean;
|
||||
}
|
||||
|
||||
const latestReleaseLookupCache = createPiWebReleaseLookupCache(fetchLatestNpmVersion);
|
||||
const runtimePackageInfo = readPackageInfoSync();
|
||||
|
||||
export function getPiWebRuntimeComponent(component: PiWebServiceComponent, capabilities: readonly PiWebCapability[] = []): PiWebRuntimeComponent {
|
||||
@@ -129,10 +132,10 @@ export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new Sess
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
|
||||
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise<PiWebStatusResponse> {
|
||||
const versionStatus = await getPiWebVersionStatus(daemon);
|
||||
const { web, sessiond } = versionStatus.components;
|
||||
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION);
|
||||
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true);
|
||||
const components = { web, sessiond };
|
||||
const commands = await commandsFor(components);
|
||||
const messages = buildMessages(components, release, commands);
|
||||
@@ -375,25 +378,16 @@ function unavailableSessiond(error: string): PiWebComponentStatus {
|
||||
};
|
||||
}
|
||||
|
||||
async function getLatestReleaseStatus(currentVersion: string): Promise<PiWebReleaseStatus> {
|
||||
async function getLatestReleaseStatus(currentVersion: string, force: boolean): Promise<PiWebReleaseStatus> {
|
||||
const checkedAtMs = Date.now();
|
||||
if (skipVersionCheck()) {
|
||||
return { packageName: PI_WEB_PACKAGE_NAME, updateAvailable: false, checkedAt: new Date(checkedAtMs).toISOString(), skipped: true };
|
||||
}
|
||||
|
||||
if (latestReleaseCache !== undefined && checkedAtMs - latestReleaseCache.checkedAtMs < LATEST_RELEASE_CACHE_MS) {
|
||||
return releaseStatusFromCache(latestReleaseCache, currentVersion);
|
||||
}
|
||||
|
||||
try {
|
||||
latestReleaseCache = { checkedAtMs, latestVersion: await fetchLatestNpmVersion(currentVersion) };
|
||||
} catch (error) {
|
||||
latestReleaseCache = { checkedAtMs, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
return releaseStatusFromCache(latestReleaseCache, currentVersion);
|
||||
return releaseStatusFromCache(await latestReleaseLookupCache.get(currentVersion, { force }), currentVersion);
|
||||
}
|
||||
|
||||
function releaseStatusFromCache(cache: { checkedAtMs: number; latestVersion?: string; error?: string }, currentVersion: string): PiWebReleaseStatus {
|
||||
function releaseStatusFromCache(cache: PiWebReleaseLookup, currentVersion: string): PiWebReleaseStatus {
|
||||
return {
|
||||
packageName: PI_WEB_PACKAGE_NAME,
|
||||
...(cache.latestVersion === undefined ? {} : { latestVersion: cache.latestVersion }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
|
||||
import { createPiWebStatusCache } from "./piWebStatusCache.js";
|
||||
import { createPiWebStatusCache, type PiWebStatusCacheLoadOptions } from "./piWebStatusCache.js";
|
||||
|
||||
describe("createPiWebStatusCache", () => {
|
||||
it("serves cached status while it is fresh", async () => {
|
||||
@@ -46,6 +46,45 @@ describe("createPiWebStatusCache", () => {
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(["forced-first", "regular-first"] as const)("does not let an older refresh replace a forced result when %s completes", async (completionOrder) => {
|
||||
const regular = createDeferred<PiWebStatusResponse>();
|
||||
const forced = createDeferred<PiWebStatusResponse>();
|
||||
const load = vi.fn(({ force }: PiWebStatusCacheLoadOptions) => force ? forced.promise : regular.promise);
|
||||
const cache = createPiWebStatusCache(load);
|
||||
|
||||
const regularRefresh = cache.refresh();
|
||||
const forcedRefresh = cache.refresh({ force: true });
|
||||
if (completionOrder === "forced-first") {
|
||||
forced.resolve(status("forced"));
|
||||
await expect(forcedRefresh).resolves.toMatchObject({ generatedAt: "forced" });
|
||||
regular.resolve(status("regular"));
|
||||
await expect(regularRefresh).resolves.toMatchObject({ generatedAt: "regular" });
|
||||
} else {
|
||||
regular.resolve(status("regular"));
|
||||
await expect(regularRefresh).resolves.toMatchObject({ generatedAt: "regular" });
|
||||
forced.resolve(status("forced"));
|
||||
await expect(forcedRefresh).resolves.toMatchObject({ generatedAt: "forced" });
|
||||
}
|
||||
|
||||
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "forced" });
|
||||
expect(load).toHaveBeenNthCalledWith(1, { force: false });
|
||||
expect(load).toHaveBeenNthCalledWith(2, { force: true });
|
||||
});
|
||||
|
||||
it("makes regular refreshes join a pending forced refresh", async () => {
|
||||
const deferred = createDeferred<PiWebStatusResponse>();
|
||||
const load = vi.fn(() => deferred.promise);
|
||||
const cache = createPiWebStatusCache(load);
|
||||
|
||||
const forced = cache.refresh({ force: true });
|
||||
const regular = cache.refresh();
|
||||
|
||||
expect(regular).toBe(forced);
|
||||
deferred.resolve(status("forced"));
|
||||
await forced;
|
||||
expect(load).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("retains stale status and reports background refresh errors", async () => {
|
||||
let now = 1_000;
|
||||
const refreshError = new Error("refresh failed");
|
||||
|
||||
@@ -8,28 +8,42 @@ export interface PiWebStatusCacheOptions {
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface PiWebStatusCache {
|
||||
get(): Promise<PiWebStatusResponse>;
|
||||
refresh(): Promise<PiWebStatusResponse>;
|
||||
export interface PiWebStatusCacheLoadOptions {
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
export function createPiWebStatusCache(load: () => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
|
||||
export interface PiWebStatusCacheRefreshOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface PiWebStatusCache {
|
||||
get(): Promise<PiWebStatusResponse>;
|
||||
refresh(options?: PiWebStatusCacheRefreshOptions): Promise<PiWebStatusResponse>;
|
||||
}
|
||||
|
||||
export function createPiWebStatusCache(load: (options: PiWebStatusCacheLoadOptions) => 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;
|
||||
let pending: { promise: Promise<PiWebStatusResponse>; force: boolean; sequence: number } | undefined;
|
||||
let loadSequence = 0;
|
||||
|
||||
const refresh = (): Promise<PiWebStatusResponse> => {
|
||||
pending ??= Promise.resolve()
|
||||
.then(load)
|
||||
const refresh = (refreshOptions: PiWebStatusCacheRefreshOptions = {}): Promise<PiWebStatusResponse> => {
|
||||
const force = refreshOptions.force === true;
|
||||
if (pending !== undefined && (!force || pending.force)) return pending.promise;
|
||||
|
||||
const sequence = ++loadSequence;
|
||||
const promise = Promise.resolve()
|
||||
.then(() => load({ force }))
|
||||
.then((status) => {
|
||||
cached = { status, expiresAt: now() + ttlMs };
|
||||
if (sequence === loadSequence) cached = { status, expiresAt: now() + ttlMs };
|
||||
return status;
|
||||
})
|
||||
.finally(() => {
|
||||
pending = undefined;
|
||||
if (pending?.sequence === sequence) pending = undefined;
|
||||
});
|
||||
return pending;
|
||||
pending = { promise, force, sequence };
|
||||
return promise;
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user