Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ _In development — bullets added per PR; finalized at release._
- **fix(ws): start the LiveWS sidecar with `cwd` at the package root (global/systemd installs)** — the standalone LiveWS launcher (`scripts/start-ws-server.mjs`) re-spawns itself with `node --import tsx <self>` but did not set `cwd`. When the WebSocket sidecar was launched from outside the package directory — a global npm/homebrew install, or a `systemd`/`launchd` unit started from `$HOME` — Node could not resolve the `tsx` package (`ERR_MODULE_NOT_FOUND: Cannot find package 'tsx'`), and even from the package directory `tsx` could not resolve the tsconfig `@/*` path aliases (e.g. `@/types/databaseSettings`), so the sidecar never booted. The spawn now pins `cwd` to the package root (the directory above `scripts/`, where `package.json` + `tsconfig.json` live), which resolves both `tsx` discovery and the `@/*` aliases regardless of launch directory. ([#4055](https://github.com/diegosouzapw/OmniRoute/issues/4055) — thanks @Rahulsharma0810)
- **fix(dashboard): Logs page auto-refresh now works in embedded/proxied dashboards** — the Request Logger gated each auto-refresh tick on a static `document.visibilityState === "visible"` read. Hosts that report a permanent non-`"visible"` state without ever firing a `visibilitychange` event (Docker dashboard wrappers, embedded webviews) froze auto-refresh entirely — only the manual Refresh button worked, a regression from 3.8.24's unconditional polling. The pause is now event-driven and fail-open: polling starts enabled and only pauses after a real `visibilitychange` → hidden transition (still preserving the backgrounded-tab optimization for normal browser tabs). ([#4054](https://github.com/diegosouzapw/OmniRoute/issues/4054) — thanks @tjengbudi)
- **fix(docker): raise the build-stage Node heap to stop the production-build OOM** — the Docker `builder` stage ran `npm run build` with V8's default heap ceiling (~2 GB). After #4052 forced the heavier webpack engine (Turbopack panics on this Next.js version), the production optimization pass exceeded that ceiling and the build died with `FATAL ERROR: … JavaScript heap out of memory` at `[builder] npm run build`. The builder stage now sets `NODE_OPTIONS=--max-old-space-size` (default 4096 MB, overridable via `--build-arg OMNIROUTE_BUILD_MEMORY_MB=…`) before the build; the value propagates to the spawned `next build`. Build-only — the runtime heap (`OMNIROUTE_MEMORY_MB` on the runner stage) is unchanged. ([#4076](https://github.com/diegosouzapw/OmniRoute/issues/4076) — thanks @kamenkadmitry)
- **fix(dashboard): "Update Available" banner reappears reliably across Docker/npm/desktop installs** — the home-page banner is gated on `GET /api/system/version`'s `updateAvailable`, which derived the latest version ONLY from `npm info omniroute version --json` via the `npm` CLI binary. When that binary is absent from the runtime PATH (Docker/desktop/locked-down installs) or the registry is unreachable, the call returned `null` → `updateAvailable=false` → the banner silently never rendered even when a newer release existed. The route now resolves the latest version through `resolveLatestVersion()`: the fast `npm` CLI path first, then an npm-binary-free fallback over the registry HTTP API (`registry.npmjs.org/omniroute/latest`), and a logged warning instead of silent degradation when both fail. Version comparison was also hardened to tolerate `v`-prefixed and pre-release version strings. ([#4100](https://github.com/diegosouzapw/OmniRoute/issues/4100))

---

Expand Down
27 changes: 3 additions & 24 deletions src/app/api/system/version/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,12 @@ import {
PROJECT_ROOT,
} from "@/lib/system/autoUpdate";
import { NEWS_JSON_URL, parseActiveNewsPayload } from "@/shared/utils/releaseNotes";
import { isNewer, resolveLatestVersion } from "@/lib/system/versionCheck";

const execFileAsync = promisify(execFile);

export const dynamic = "force-dynamic";

async function getLatestNpmVersion(): Promise<string | null> {
try {
const { stdout } = await execFileAsync("npm", ["info", "omniroute", "version", "--json"], {
timeout: 10000,
});
const parsed = JSON.parse(stdout.trim());
return typeof parsed === "string" ? parsed : null;
} catch {
return null;
}
}

function getCurrentVersion(): string {
try {
return require("../../../../../package.json").version as string;
Expand All @@ -42,16 +31,6 @@ function getCurrentVersion(): string {
}
}

function isNewer(a: string | null, b: string): boolean {
if (!a) return false;
const parse = (v: string) => v.split(".").map(Number);
const [aMaj, aMin, aPat] = parse(a);
const [bMaj, bMin, bPat] = parse(b);
if (aMaj !== bMaj) return aMaj > bMaj;
if (aMin !== bMin) return aMin > bMin;
return aPat > bPat;
}

async function getNews() {
try {
const res = await fetch(NEWS_JSON_URL, { next: { revalidate: 3600 } });
Expand All @@ -72,7 +51,7 @@ export async function GET(req: NextRequest) {
const config = getAutoUpdateConfig();

const [latest, news, validation] = await Promise.all([
getLatestNpmVersion(),
resolveLatestVersion(),
getNews(),
validateAutoUpdateRuntime(config),
]);
Expand All @@ -96,7 +75,7 @@ export async function POST(req: NextRequest) {
}

const current = getCurrentVersion();
const latest = await getLatestNpmVersion();
const latest = await resolveLatestVersion();

if (!latest) {
return NextResponse.json(
Expand Down
113 changes: 113 additions & 0 deletions src/lib/system/versionCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Latest-version discovery + comparison for the dashboard "Update Available" banner.
*
* #4100: the banner is gated on `isNewer(latest, current)`. Previously `latest` came
* ONLY from `npm info omniroute version --json` (the `npm` CLI binary). When that binary
* is absent (Docker / desktop / locked-down installs) or the registry is unreachable, the
* call returned null and the banner silently never rendered — even when an update existed.
*
* This module keeps the fast `npm` CLI path as the primary source but adds an
* npm-binary-free HTTP fallback (the npm registry JSON API, reachable with plain `fetch`)
* and logs a warning instead of degrading silently. Version parsing is also hardened so a
* `v`-prefix or pre-release suffix no longer collapses the comparison to `false` via `NaN`.
*/
import { execFile } from "child_process";
import { promisify } from "util";
import { createLogger } from "@/shared/utils/logger";

const execFileAsync = promisify(execFile);
const log = createLogger("system/versionCheck");

/** npm-binary-free latest-version source: the registry JSON API. */
const NPM_REGISTRY_LATEST_URL = "https://registry.npmjs.org/omniroute/latest";

const LOOKUP_TIMEOUT_MS = 10_000;

/**
* Strip a leading `v`, drop pre-release/build metadata (`-`/`+` suffix), split on `.`,
* and return a numeric tuple. Returns null when the string is empty or any segment is
* non-numeric, so callers can fail safe instead of comparing `NaN`.
*/
export function normalizeVersion(v: string): number[] | null {
if (typeof v !== "string") return null;
const cleaned = v.trim().replace(/^v/i, "").split(/[-+]/)[0];
if (!cleaned) return null;
const parts = cleaned.split(".").map((p) => Number(p));
if (parts.length === 0 || parts.some((n) => !Number.isFinite(n))) return null;
return parts;
}

/**
* True iff `latest` is a strictly higher semver than `current`. Safe on null/garbage
* (returns false rather than throwing or yielding a `NaN`-driven false positive).
*/
export function isNewer(latest: string | null | undefined, current: string): boolean {
if (!latest) return false;
const a = normalizeVersion(latest);
const b = normalizeVersion(current);
if (!a || !b) return false;
const len = Math.max(a.length, b.length);
for (let i = 0; i < len; i++) {
const av = a[i] ?? 0;
const bv = b[i] ?? 0;
if (av !== bv) return av > bv;
}
return false;
}

/** Latest published version via the `npm` CLI (fast when npm is on PATH, e.g. source installs). */
export async function getLatestVersionFromNpmCli(): Promise<string | null> {
try {
const { stdout } = await execFileAsync("npm", ["info", "omniroute", "version", "--json"], {
timeout: LOOKUP_TIMEOUT_MS,
});
const parsed = JSON.parse(String(stdout).trim());
return typeof parsed === "string" && parsed ? parsed : null;
} catch {
return 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.
*/
export async function getLatestVersionFromRegistry(
fetchImpl: typeof fetch = fetch
): Promise<string | null> {
try {
const res = await fetchImpl(NPM_REGISTRY_LATEST_URL, {
signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS),
});
if (!res.ok) return null;
const data = (await res.json()) as { version?: unknown };
return typeof data?.version === "string" && data.version ? data.version : null;
} catch {
return null;
}
}

/**
* Resolve the latest published version. Tries the `npm` CLI first (fast on source installs),
* then falls back to the registry HTTP API (npm-binary-free). Logs a warning — instead of
* silently degrading to "no update available" — when BOTH sources fail. Thunks are injectable
* for tests.
*/
export async function resolveLatestVersion(opts?: {
npmCli?: () => Promise<string | null>;
registry?: () => Promise<string | null>;
}): Promise<string | null> {
const npmCli = opts?.npmCli ?? getLatestVersionFromNpmCli;
const registry = opts?.registry ?? (() => getLatestVersionFromRegistry());

const viaCli = await npmCli();
if (viaCli) return viaCli;

const viaRegistry = await registry();
if (viaRegistry) return viaRegistry;

log.warn(
"Latest-version lookup failed via both npm CLI and registry HTTP — the update banner will not show even if a newer release exists"
);
return null;
}
77 changes: 77 additions & 0 deletions tests/unit/system-version-check-4100.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Regression test for #4100 — Home "Update Available" banner no longer appears.
*
* Root cause: `GET /api/system/version` derived `latest` ONLY from `npm info` via the
* `npm` CLI binary, returning null on ANY error (binary missing in Docker/desktop,
* registry unreachable) → updateAvailable=false → banner silently never renders.
* Secondary: `isNewer()`'s `v.split(".").map(Number)` collapsed to false on `v`-prefixed
* or pre-release version strings (NaN comparisons).
*
* These assertions fail against the old inline semantics (no module, fragile isNewer,
* no npm-binary-free fallback) and pass once `src/lib/system/versionCheck.ts` exists.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
normalizeVersion,
isNewer,
resolveLatestVersion,
} from "@/lib/system/versionCheck";

test("normalizeVersion strips v-prefix, pre-release/build, returns numeric tuple", () => {
assert.deepEqual(normalizeVersion("3.8.28"), [3, 8, 28]);
assert.deepEqual(normalizeVersion("v3.8.28"), [3, 8, 28]);
assert.deepEqual(normalizeVersion("3.8.28-rc.1"), [3, 8, 28]);
assert.deepEqual(normalizeVersion("3.8.28+build.5"), [3, 8, 28]);
assert.equal(normalizeVersion(""), null);
assert.equal(normalizeVersion("not-a-version"), null);
});

test("isNewer: basic ordering and null safety", () => {
assert.equal(isNewer("3.8.29", "3.8.28"), true);
assert.equal(isNewer("3.8.28", "3.8.28"), false);
assert.equal(isNewer("3.8.27", "3.8.28"), false);
assert.equal(isNewer(null, "3.8.28"), false);
});

test("isNewer: v-prefixed latest is handled (#4100 — old code returned false via NaN)", () => {
assert.equal(isNewer("v3.8.29", "3.8.28"), true);
});

test("isNewer: pre-release suffix is handled (#4100 — old code returned false via NaN)", () => {
assert.equal(isNewer("3.8.29-rc.1", "3.8.28"), true);
});

test("isNewer: multi-digit minor ordering", () => {
assert.equal(isNewer("3.10.0", "3.9.9"), true);
assert.equal(isNewer("3.9.9", "3.10.0"), false);
});

test("resolveLatestVersion falls back to the registry when the npm CLI path fails (#4100)", async () => {
const latest = await resolveLatestVersion({
npmCli: async () => null, // npm binary missing / registry unreachable via CLI
registry: async () => "3.8.29", // npm-binary-free HTTP fallback succeeds
});
assert.equal(latest, "3.8.29");
});

test("resolveLatestVersion prefers the npm CLI when it succeeds", async () => {
let registryCalled = false;
const latest = await resolveLatestVersion({
npmCli: async () => "3.8.30",
registry: async () => {
registryCalled = true;
return "3.8.29";
},
});
assert.equal(latest, "3.8.30");
assert.equal(registryCalled, false);
});

test("resolveLatestVersion returns null when both sources fail (no silent crash)", async () => {
const latest = await resolveLatestVersion({
npmCli: async () => null,
registry: async () => null,
});
assert.equal(latest, null);
});
Loading