perf(api): singleflight version lookups (#8278) (#8301)

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Ravi Tharuma
2026-07-24 14:35:18 +02:00
committed by GitHub
parent b84f86ad4f
commit 8a2a3d48b0
4 changed files with 267 additions and 9 deletions

View File

@@ -5,6 +5,7 @@
* Security: Requires admin authentication (same as other management routes).
* Safety: Update only runs if a newer version is available on npm.
*/
import { createHash } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { execFile } from "child_process";
import { promisify } from "util";
@@ -17,7 +18,11 @@ import {
PROJECT_ROOT,
} from "@/lib/system/autoUpdate";
import { NEWS_JSON_URL, parseActiveNewsPayload } from "@/shared/utils/releaseNotes";
import { isNewer, resolveLatestVersion } from "@/lib/system/versionCheck";
import {
clearLatestVersionCache,
isNewer,
resolveLatestVersionCached,
} from "@/lib/system/versionCheck";
import { resolveGlobalOmniroutePath } from "@/lib/system/globalPackagePath";
// #5542 — On Windows npm is `npm.cmd`; Node ≥24 refuses to execFile a `.cmd` without
// a shell (nodejs/node#52554 → "spawn npm ENOENT"). buildNpmExecOptions enables the
@@ -56,21 +61,34 @@ export async function GET(req: NextRequest) {
const config = getAutoUpdateConfig();
const [latest, news, validation] = await Promise.all([
resolveLatestVersion(),
resolveLatestVersionCached({
bypassCache: /(?:^|,)\s*(?:no-cache|no-store)\b/i.test(
req.headers.get("Cache-Control") ?? ""
),
storeResult: !/(?:^|,)\s*no-store\b/i.test(req.headers.get("Cache-Control") ?? ""),
}),
getNews(),
validateAutoUpdateRuntime(config),
]);
const updateAvailable = isNewer(latest, current);
return NextResponse.json({
const body = {
current,
latest: latest ?? "unavailable",
updateAvailable,
updateAvailable: isNewer(latest, current),
channel: config.mode,
autoUpdateSupported: validation.supported,
autoUpdateError: validation.reason,
news,
};
const serialized = JSON.stringify(body);
const etag = `"${createHash("sha256").update(serialized).digest("base64url")}"`;
const headers = { "Cache-Control": "private, no-cache, must-revalidate", ETag: etag };
const validators = req.headers.get("If-None-Match")?.split(",").map((value) => value.trim());
if (validators?.some((value) => value === etag || value === `W/${etag}`)) {
return new NextResponse(null, { status: 304, headers });
}
return new NextResponse(serialized, {
headers: { ...headers, "Content-Type": "application/json" },
});
}
@@ -80,7 +98,7 @@ export async function POST(req: NextRequest) {
}
const current = getCurrentVersion();
const latest = await resolveLatestVersion();
const latest = await resolveLatestVersionCached({ bypassCache: true });
if (!latest) {
return NextResponse.json(
@@ -128,6 +146,7 @@ export async function POST(req: NextRequest) {
);
}
clearLatestVersionCache();
return NextResponse.json({
success: true,
message: `Update to v${latest} started. Docker rebuild is running in the background.`,
@@ -339,6 +358,7 @@ export async function POST(req: NextRequest) {
});
}
clearLatestVersionCache();
send({
step: "complete",
status: "done",

View File

@@ -36,6 +36,16 @@ const GITHUB_RELEASES_LATEST_URL =
"https://api.github.com/repos/diegosouzapw/OmniRoute/releases/latest";
const LOOKUP_TIMEOUT_MS = 10_000;
const MAX_VERSION_RESPONSE_BYTES = 16 * 1024;
const LATEST_VERSION_CACHE_TTL_MS = 10 * 60_000;
const MAX_LATEST_VERSION_CACHE_TTL_MS = 10 * 60_000;
type LatestVersionCacheEntry = { value: string; expiresAt: number };
let latestVersionCache: LatestVersionCacheEntry | null = null;
let latestVersionLookup: Promise<string | null> | null = null;
let latestVersionRefresh: Promise<string | null> | null = null;
let latestVersionCacheGeneration = 0;
// The pure semver helpers live in `./versionCompare` (dependency-free) so
// client-reachable modules can import them without pulling this file's
@@ -64,6 +74,40 @@ export async function getLatestVersionFromNpmCli(): Promise<string | null> {
* Latest published version via the npm registry HTTP API. Needs only network access — no
* `npm` binary — so it works in Docker / desktop / locked-down installs.
*/
async function readBoundedJson(response: Response): Promise<unknown> {
const declaredLength = Number(response.headers.get("Content-Length"));
if (Number.isFinite(declaredLength) && declaredLength > MAX_VERSION_RESPONSE_BYTES) {
await response.body?.cancel();
throw new Error("Version metadata response is too large");
}
if (!response.body) return response.json();
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > MAX_VERSION_RESPONSE_BYTES) {
await reader.cancel();
throw new Error("Version metadata response is too large");
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const body = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return JSON.parse(new TextDecoder().decode(body));
}
export async function getLatestVersionFromRegistry(
fetchImpl: typeof fetch = fetch
): Promise<string | null> {
@@ -72,7 +116,7 @@ export async function getLatestVersionFromRegistry(
signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS),
});
if (!res.ok) return null;
const data = (await res.json()) as { version?: unknown };
const data = (await readBoundedJson(res)) as { version?: unknown };
return typeof data?.version === "string" && data.version ? data.version : null;
} catch {
return null;
@@ -99,7 +143,7 @@ export async function getLatestVersionFromGitHub(
},
});
if (!res.ok) return null;
const data = (await res.json()) as { tag_name?: unknown };
const data = (await readBoundedJson(res)) as { tag_name?: unknown };
return typeof data?.tag_name === "string" && data.tag_name ? data.tag_name : null;
} catch {
return null;
@@ -112,6 +156,51 @@ export async function getLatestVersionFromGitHub(
* warning — instead of silently degrading to "no update available" — when ALL sources fail.
* Thunks are injectable for tests.
*/
export function clearLatestVersionCache(): void {
latestVersionCache = null;
latestVersionCacheGeneration += 1;
}
/** Coalesce and briefly cache successful latest-version lookups. */
export async function resolveLatestVersionCached(opts?: {
lookup?: () => Promise<string | null>;
bypassCache?: boolean;
storeResult?: boolean;
now?: () => number;
ttlMs?: number;
}): Promise<string | null> {
const now = opts?.now ?? Date.now;
if (!opts?.bypassCache && latestVersionCache?.expiresAt > now()) {
return latestVersionCache.value;
}
const inFlight = opts?.bypassCache ? latestVersionRefresh : latestVersionLookup;
if (inFlight) return inFlight;
if (opts?.bypassCache) clearLatestVersionCache();
const generation = latestVersionCacheGeneration;
const lookup = opts?.lookup ?? resolveLatestVersion;
const ttlMs = Math.min(
Math.max(opts?.ttlMs ?? LATEST_VERSION_CACHE_TTL_MS, 0),
MAX_LATEST_VERSION_CACHE_TTL_MS
);
const pending = lookup().then((value) => {
if (value && opts?.storeResult !== false && latestVersionCacheGeneration === generation) {
latestVersionCache = { value, expiresAt: now() + ttlMs };
}
return value;
});
if (opts?.bypassCache) latestVersionRefresh = pending;
else latestVersionLookup = pending;
try {
return await pending;
} finally {
if (latestVersionLookup === pending) latestVersionLookup = null;
if (latestVersionRefresh === pending) latestVersionRefresh = null;
}
}
export async function resolveLatestVersion(opts?: {
npmCli?: () => Promise<string | null>;
registry?: () => Promise<string | null>;

View File

@@ -0,0 +1,114 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
clearLatestVersionCache,
resolveLatestVersionCached,
} from "@/lib/system/versionCheck";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
test.beforeEach(() => clearLatestVersionCache());
test("coalesces concurrent latest-version resolution into one lookup", async () => {
let calls = 0;
const pending = deferred<string | null>();
const lookup = () => {
calls += 1;
return pending.promise;
};
const requests = Array.from({ length: 10 }, () => resolveLatestVersionCached({ lookup }));
assert.equal(calls, 1);
pending.resolve("3.8.50");
assert.deepEqual(await Promise.all(requests), Array(10).fill("3.8.50"));
});
test("reuses a successful result only inside the bounded TTL", async () => {
let now = 1_000;
let calls = 0;
const lookup = async () => `3.8.${++calls + 49}`;
assert.equal(await resolveLatestVersionCached({ lookup, now: () => now, ttlMs: 500 }), "3.8.50");
now = 1_499;
assert.equal(await resolveLatestVersionCached({ lookup, now: () => now, ttlMs: 500 }), "3.8.50");
now = 1_500;
assert.equal(await resolveLatestVersionCached({ lookup, now: () => now, ttlMs: 500 }), "3.8.51");
assert.equal(calls, 2);
});
test("explicit refresh bypasses an ordinary in-flight lookup and coalesces with refreshes", async () => {
let calls = 0;
const ordinary = deferred<string | null>();
const refresh = deferred<string | null>();
const lookup = () => {
calls += 1;
return calls === 1 ? ordinary.promise : refresh.promise;
};
const stale = resolveLatestVersionCached({ lookup });
const firstRefresh = resolveLatestVersionCached({ lookup, bypassCache: true });
const secondRefresh = resolveLatestVersionCached({ lookup, bypassCache: true });
assert.equal(calls, 2);
ordinary.resolve("3.8.49");
refresh.resolve("3.8.50");
assert.equal(await stale, "3.8.49");
assert.deepEqual(await Promise.all([firstRefresh, secondRefresh]), ["3.8.50", "3.8.50"]);
assert.equal(await resolveLatestVersionCached({ lookup }), "3.8.50");
assert.equal(calls, 2);
});
test("no-store refresh does not populate the process cache", async () => {
let calls = 0;
const lookup = async () => `3.8.${++calls + 49}`;
assert.equal(
await resolveLatestVersionCached({ lookup, bypassCache: true, storeResult: false }),
"3.8.50"
);
assert.equal(await resolveLatestVersionCached({ lookup }), "3.8.51");
assert.equal(calls, 2);
});
test("cache invalidation prevents an older in-flight result from repopulating the cache", async () => {
let calls = 0;
const pending = deferred<string | null>();
const first = resolveLatestVersionCached({
lookup: () => {
calls += 1;
return pending.promise;
},
});
clearLatestVersionCache();
pending.resolve("3.8.50");
assert.equal(await first, "3.8.50");
assert.equal(
await resolveLatestVersionCached({ lookup: async () => `3.8.${++calls + 49}` }),
"3.8.51"
);
});
test("failed and unavailable lookups are not cached and leave no stale singleflight", async () => {
let calls = 0;
const lookup = async () => {
calls += 1;
if (calls === 1) throw new Error("lookup failed");
if (calls === 2) return null;
return "3.8.50";
};
await assert.rejects(resolveLatestVersionCached({ lookup }), /lookup failed/);
assert.equal(await resolveLatestVersionCached({ lookup }), null);
assert.equal(await resolveLatestVersionCached({ lookup }), "3.8.50");
assert.equal(await resolveLatestVersionCached({ lookup }), "3.8.50");
assert.equal(calls, 3);
});

View File

@@ -17,6 +17,7 @@ import {
isNewer,
resolveLatestVersion,
getLatestVersionFromGitHub,
getLatestVersionFromRegistry,
} from "@/lib/system/versionCheck";
test("normalizeVersion strips v-prefix, pre-release/build, returns numeric tuple", () => {
@@ -120,3 +121,37 @@ test("getLatestVersionFromGitHub returns null on a non-OK response", async () =>
new Response("rate limited", { status: 403 })) as unknown as typeof fetch;
assert.equal(await getLatestVersionFromGitHub(fakeFetch), null);
});
test("HTTP version sources reject oversized bodies without parsing them", async () => {
let registryCancelled = false;
let githubCancelled = false;
const oversizedResponse = (onCancel: () => void) =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(32 * 1024));
},
cancel() {
onCancel();
},
}),
{ status: 200 }
);
assert.equal(
await getLatestVersionFromRegistry((async () =>
oversizedResponse(() => {
registryCancelled = true;
})) as unknown as typeof fetch),
null
);
assert.equal(
await getLatestVersionFromGitHub((async () =>
oversizedResponse(() => {
githubCancelled = true;
})) as unknown as typeof fetch),
null
);
assert.equal(registryCancelled, true);
assert.equal(githubCancelled, true);
});