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:
Diego Rodrigues de Sa e Souza
2026-07-22 00:07:19 -03:00
parent 97df8d254f
commit effddc6a0e
5 changed files with 119 additions and 32 deletions

View 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.

View File

@@ -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).

View File

@@ -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> {

View 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;
}

View 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);
});