mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 14:52:09 +03:00
fix(build): split pure semver helpers into versionCompare so the Kimi client banner gate stops dragging child_process into the browser bundle
The KimiSponsorBanner (use client) version gate imported isNewer/normalizeVersion
from versionCheck.ts, whose top-level 'import { execFile } from child_process'
cannot be tree-shaken out of a client bundle — Turbopack next build failed with 33
'Module not found' errors (child_process, fs, net, dns, module). Move the pure
helpers to a dependency-free versionCompare.ts; versionCheck.ts re-exports them.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
1
changelog.d/fixes/kimi-banner-client-build.md
Normal file
1
changelog.d/fixes/kimi-banner-client-build.md
Normal file
@@ -0,0 +1 @@
|
||||
- Fix the Turbopack `next build` breaking with "Module not found: Can't resolve 'child_process'": the Kimi sponsor banner's client-side version gate imported semver helpers from `versionCheck.ts` (a server module with a top-level `child_process` import), dragging Node built-ins into the browser bundle. The pure `isNewer`/`normalizeVersion` helpers now live in a dependency-free `versionCompare.ts`; `versionCheck.ts` re-exports them for back-compat.
|
||||
@@ -3,7 +3,10 @@
|
||||
// Pure logic split out of KimiSponsorBanner.tsx so the gate can be unit-tested
|
||||
// with node:test (no DOM/next-intl needed), mirroring homeAppearance.ts.
|
||||
|
||||
import { isNewer, normalizeVersion } from "@/lib/system/versionCheck";
|
||||
// Import the pure helpers from versionCompare (NOT versionCheck): this module is
|
||||
// pulled into the "use client" KimiSponsorBanner bundle, and versionCheck.ts's
|
||||
// top-level child_process import would break the Turbopack client build (#7872 VPS build).
|
||||
import { isNewer, normalizeVersion } from "@/lib/system/versionCompare";
|
||||
|
||||
/**
|
||||
* Last app version that still shows the Kimi sponsor banner (inclusive).
|
||||
|
||||
@@ -37,37 +37,11 @@ const GITHUB_RELEASES_LATEST_URL =
|
||||
|
||||
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;
|
||||
}
|
||||
// The pure semver helpers live in `./versionCompare` (dependency-free) so
|
||||
// client-reachable modules can import them without pulling this file's
|
||||
// server-only `child_process` import into the browser bundle. Re-exported here
|
||||
// for back-compat with existing server-side importers.
|
||||
export { normalizeVersion, isNewer } from "./versionCompare";
|
||||
|
||||
/** Latest published version via the `npm` CLI (fast when npm is on PATH, e.g. source installs). */
|
||||
export async function getLatestVersionFromNpmCli(): Promise<string | null> {
|
||||
|
||||
46
src/lib/system/versionCompare.ts
Normal file
46
src/lib/system/versionCompare.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Pure semver comparison helpers, split out of `versionCheck.ts` so they are
|
||||
* importable from CLIENT components without dragging that module's server-only
|
||||
* top-level `import { execFile } from "child_process"` into the browser bundle.
|
||||
*
|
||||
* `versionCheck.ts` re-exports both names for back-compat; new client-reachable
|
||||
* callers (e.g. `kimiSponsorBannerGate.ts`) MUST import from here instead — a
|
||||
* value-import of the server module breaks the Turbopack `next build` with
|
||||
* "Module not found: Can't resolve 'child_process'" (the client bundle cannot
|
||||
* tree-shake a top-level Node built-in import away).
|
||||
*
|
||||
* This file must stay dependency-free (no Node built-ins, no logger, no
|
||||
* installer utils) so it is safe in any bundling context.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
63
tests/unit/version-compare-client-safe.test.ts
Normal file
63
tests/unit/version-compare-client-safe.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Regression guard for the base-red that broke the Turbopack `next build`:
|
||||
// kimiSponsorBannerGate.ts (pulled into the "use client" KimiSponsorBanner
|
||||
// bundle) imported the semver helpers from `versionCheck.ts`, whose top-level
|
||||
// `import { execFile } from "child_process"` cannot be tree-shaken out of a
|
||||
// client bundle → "Module not found: Can't resolve 'child_process'".
|
||||
//
|
||||
// The fix moved the pure helpers into `versionCompare.ts` (dependency-free) and
|
||||
// pointed the client-reachable gate at it. These assertions lock that in so the
|
||||
// server module can never sneak back into the client bundle via this path.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = (p: string) => readFileSync(resolve(here, "../../", p), "utf8");
|
||||
|
||||
const COMPARE = "src/lib/system/versionCompare.ts";
|
||||
const GATE = "src/app/(dashboard)/dashboard/kimiSponsorBannerGate.ts";
|
||||
|
||||
test("versionCompare.ts is dependency-free (no server-only imports)", () => {
|
||||
// Match actual import/require statements, not the word appearing in the
|
||||
// module's own docstring (which explains WHY it avoids these).
|
||||
const importLines = src(COMPARE)
|
||||
.split("\n")
|
||||
.filter((l) => /^\s*import\b/.test(l) || /\brequire\s*\(/.test(l));
|
||||
const joined = importLines.join("\n");
|
||||
for (const forbidden of ["child_process", "@/lib/services/installers", "@/shared/utils/logger", '"util"']) {
|
||||
assert.ok(
|
||||
!joined.includes(forbidden),
|
||||
`versionCompare.ts must stay client-safe — found forbidden import ${forbidden}`
|
||||
);
|
||||
}
|
||||
// The file must in fact have no import statements at all (fully self-contained).
|
||||
assert.equal(importLines.length, 0, "versionCompare.ts should have zero imports");
|
||||
});
|
||||
|
||||
test("the client-reachable Kimi banner gate imports helpers from versionCompare, not versionCheck", () => {
|
||||
const code = src(GATE);
|
||||
assert.match(code, /from "@\/lib\/system\/versionCompare"/);
|
||||
assert.ok(
|
||||
!/from "@\/lib\/system\/versionCheck"/.test(code),
|
||||
"kimiSponsorBannerGate.ts must NOT import from versionCheck (drags child_process into the client bundle)"
|
||||
);
|
||||
});
|
||||
|
||||
test("versionCompare exports working isNewer/normalizeVersion", async () => {
|
||||
const m = await import("../../src/lib/system/versionCompare.ts");
|
||||
assert.deepEqual(m.normalizeVersion("v3.8.60"), [3, 8, 60]);
|
||||
assert.equal(m.normalizeVersion("garbage"), null);
|
||||
assert.equal(m.isNewer("3.8.61", "3.8.60"), true);
|
||||
assert.equal(m.isNewer("3.8.60", "3.8.60"), false);
|
||||
assert.equal(m.isNewer(null, "3.8.60"), false);
|
||||
});
|
||||
|
||||
test("versionCheck still re-exports the helpers (back-compat for server importers)", async () => {
|
||||
const m = await import("../../src/lib/system/versionCheck.ts");
|
||||
assert.equal(typeof m.isNewer, "function");
|
||||
assert.equal(typeof m.normalizeVersion, "function");
|
||||
assert.equal(m.isNewer("3.9.0", "3.8.60"), true);
|
||||
});
|
||||
Reference in New Issue
Block a user