From effddc6a0e89f4781ed0dd6d4a753e42ceee1343 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 22 Jul 2026 00:07:19 -0300 Subject: [PATCH] fix(build): split pure semver helpers into versionCompare so the Kimi client banner gate stops dragging child_process into the browser bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- changelog.d/fixes/kimi-banner-client-build.md | 1 + .../dashboard/kimiSponsorBannerGate.ts | 5 +- src/lib/system/versionCheck.ts | 36 ++--------- src/lib/system/versionCompare.ts | 46 ++++++++++++++ .../unit/version-compare-client-safe.test.ts | 63 +++++++++++++++++++ 5 files changed, 119 insertions(+), 32 deletions(-) create mode 100644 changelog.d/fixes/kimi-banner-client-build.md create mode 100644 src/lib/system/versionCompare.ts create mode 100644 tests/unit/version-compare-client-safe.test.ts diff --git a/changelog.d/fixes/kimi-banner-client-build.md b/changelog.d/fixes/kimi-banner-client-build.md new file mode 100644 index 0000000000..5f4d1a64a4 --- /dev/null +++ b/changelog.d/fixes/kimi-banner-client-build.md @@ -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. diff --git a/src/app/(dashboard)/dashboard/kimiSponsorBannerGate.ts b/src/app/(dashboard)/dashboard/kimiSponsorBannerGate.ts index 22cd943e66..22d029b2cf 100644 --- a/src/app/(dashboard)/dashboard/kimiSponsorBannerGate.ts +++ b/src/app/(dashboard)/dashboard/kimiSponsorBannerGate.ts @@ -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). diff --git a/src/lib/system/versionCheck.ts b/src/lib/system/versionCheck.ts index 0cc53822d6..a89eddb53a 100644 --- a/src/lib/system/versionCheck.ts +++ b/src/lib/system/versionCheck.ts @@ -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 { diff --git a/src/lib/system/versionCompare.ts b/src/lib/system/versionCompare.ts new file mode 100644 index 0000000000..afaa0fee27 --- /dev/null +++ b/src/lib/system/versionCompare.ts @@ -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; +} diff --git a/tests/unit/version-compare-client-safe.test.ts b/tests/unit/version-compare-client-safe.test.ts new file mode 100644 index 0000000000..f62cb59716 --- /dev/null +++ b/tests/unit/version-compare-client-safe.test.ts @@ -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); +});