fix(cli): include complete API artifacts in CLI package

Merge the complete generated .next-cli-build/server tree into the
packaged CLI after the standalone copy, since Next's standalone output
is trace-pruned and can omit route modules (e.g. /api/v1/messages) or
chunks loaded dynamically. Add a post-copy integrity check for the
required API route artifacts so an incomplete package fails during
pack:cli instead of at runtime.

Fixes #2945
This commit is contained in:
Matt Van Horn 2026-08-05 11:49:45 +07:00
parent c570fe33ae
commit 25e4bf1c6c
2 changed files with 400 additions and 192 deletions

View file

@ -81,6 +81,74 @@ function copyRecursive(src, dest) {
} }
} }
function resolveStandaloneBuild(appDir, buildDistDir) {
const legacyStandaloneRoot = path.join(appDir, ".next", "standalone");
const resolvedStandaloneRoot = path.join(buildDistDir, "standalone");
let standaloneRoot = fs.existsSync(resolvedStandaloneRoot)
? resolvedStandaloneRoot
: legacyStandaloneRoot;
// Next.js 16 nests standalone output under the project name when
// NEXT_TRACING_ROOT_MODE=workspace, e.g. standalone/9router/server.js.
const pkgName = path.basename(appDir);
const nestedRoot = path.join(standaloneRoot, pkgName);
if (fs.existsSync(path.join(nestedRoot, "server.js")) && !fs.existsSync(path.join(standaloneRoot, "server.js"))) {
console.log(`ℹ️ Detected nested standalone output: ${pkgName}/`);
standaloneRoot = nestedRoot;
}
const standaloneApp = fs.existsSync(path.join(standaloneRoot, "server.js"))
? standaloneRoot
: path.join(standaloneRoot, "app");
if (!fs.existsSync(standaloneApp)) {
throw new Error(
"Next.js standalone build not found under .next/standalone; " +
"expected either .next/standalone/server.js or .next/standalone/app/",
);
}
return { standaloneApp, standaloneRoot };
}
function copyStandaloneBuild(appDir, buildDistDir, cliAppDir) {
const { standaloneApp, standaloneRoot } = resolveStandaloneBuild(appDir, buildDistDir);
copyRecursive(standaloneApp, cliAppDir);
// Older nested-app layout stores traced node_modules at standalone root.
const standaloneNodeModules = path.join(standaloneRoot, "node_modules");
if (standaloneApp !== standaloneRoot && fs.existsSync(standaloneNodeModules)) {
copyRecursive(standaloneNodeModules, path.join(cliAppDir, "node_modules"));
}
}
function mergeServerArtifacts(buildDistDir, cliAppDir) {
const serverSrc = path.join(buildDistDir, "server");
const serverDest = path.join(cliAppDir, buildDistDirName, "server");
if (!fs.existsSync(serverSrc)) {
throw new Error(`Complete Next.js server build not found: ${serverSrc}`);
}
copyRecursive(serverSrc, serverDest);
}
function assertRequiredApiArtifacts(cliAppDir) {
const requiredArtifacts = [
"app/api/v1/chat/completions/route.js",
"app/api/v1/messages/route.js",
];
const serverDir = path.join(cliAppDir, buildDistDirName, "server");
const missingArtifacts = requiredArtifacts
.map((artifact) => path.join(serverDir, artifact))
.filter((artifact) => !fs.existsSync(artifact));
if (missingArtifacts.length > 0) {
throw new Error(
`Required CLI API route artifact${missingArtifacts.length === 1 ? " is" : "s are"} missing:\n` +
missingArtifacts.join("\n"),
);
}
}
function buildCliPackage() {
console.log("📦 Building 9Router CLI package with Next.js...\n"); console.log("📦 Building 9Router CLI package with Next.js...\n");
fs.mkdirSync(buildHomeDir, { recursive: true }); fs.mkdirSync(buildHomeDir, { recursive: true });
@ -133,32 +201,13 @@ console.log("✅ Cleaned\n");
// Newer Next.js standalone output writes server.js/package.json plus .next/, src/, and // Newer Next.js standalone output writes server.js/package.json plus .next/, src/, and
// node_modules/ directly under .next/standalone. Older builds may still use a nested app/. // node_modules/ directly under .next/standalone. Older builds may still use a nested app/.
console.log("3️⃣ Copying Next.js standalone build to app/cli/app..."); console.log("3️⃣ Copying Next.js standalone build to app/cli/app...");
const standaloneRoot = path.join(appDir, ".next", "standalone"); try {
const standaloneRootResolved = path.join(buildDistDir, "standalone"); copyStandaloneBuild(appDir, buildDistDir, cliAppDir);
let standaloneRootToUse = fs.existsSync(standaloneRootResolved) ? standaloneRootResolved : standaloneRoot; } catch (error) {
// Next.js 16 nests standalone output under the project name when NEXT_TRACING_ROOT_MODE=workspace
// e.g. .next-cli-build/standalone/9router/server.js
const pkgName = path.basename(appDir);
const nestedRoot = path.join(standaloneRootToUse, pkgName);
if (fs.existsSync(path.join(nestedRoot, "server.js")) && !fs.existsSync(path.join(standaloneRootToUse, "server.js"))) {
console.log(`ℹ️ Detected nested standalone output: ${pkgName}/`);
standaloneRootToUse = nestedRoot;
}
const standaloneApp = fs.existsSync(path.join(standaloneRootToUse, "server.js"))
? standaloneRootToUse
: path.join(standaloneRootToUse, "app");
if (!fs.existsSync(standaloneApp)) {
console.error("❌ Next.js standalone build not found under .next/standalone"); console.error("❌ Next.js standalone build not found under .next/standalone");
console.error("Expected either .next/standalone/server.js or .next/standalone/app/"); console.error("Expected either .next/standalone/server.js or .next/standalone/app/");
process.exit(1); process.exit(1);
} }
copyRecursive(standaloneApp, cliAppDir);
// Older nested-app layout stores traced node_modules at standalone root.
const standaloneNodeModules = path.join(standaloneRootToUse, "node_modules");
if (standaloneApp !== standaloneRootToUse && fs.existsSync(standaloneNodeModules)) {
copyRecursive(standaloneNodeModules, path.join(cliAppDir, "node_modules"));
}
console.log("✅ Copied standalone build\n"); console.log("✅ Copied standalone build\n");
// Step 3a: Copy custom server (injects real socket IP, strips spoofable XFF). // Step 3a: Copy custom server (injects real socket IP, strips spoofable XFF).
@ -241,6 +290,13 @@ if (fs.existsSync(vendorChunksSrcResolved) || fs.existsSync(vendorChunksSrc)) {
console.log("⏭️ No vendor-chunks found\n"); console.log("⏭️ No vendor-chunks found\n");
} }
// Step 6b: Merge the complete generated server tree. Next.js standalone output
// is trace-pruned and can omit route modules or chunks loaded dynamically.
console.log("6️⃣ b Copying complete server artifacts...");
mergeServerArtifacts(buildDistDir, cliAppDir);
assertRequiredApiArtifacts(cliAppDir);
console.log("✅ Copied complete server artifacts\n");
// Step 7: Copy MITM server files (not bundled by Next.js standalone) // Step 7: Copy MITM server files (not bundled by Next.js standalone)
console.log("7️⃣ Copying MITM server files..."); console.log("7️⃣ Copying MITM server files...");
const mitmSrc = path.join(appDir, "src", "mitm"); const mitmSrc = path.join(appDir, "src", "mitm");
@ -283,3 +339,14 @@ try {
} catch (e) { } catch (e) {
// Silent fail on size check // Silent fail on size check
} }
}
module.exports = {
assertRequiredApiArtifacts,
copyStandaloneBuild,
mergeServerArtifacts,
};
if (require.main === module) {
buildCliPackage();
}

View file

@ -0,0 +1,141 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
let testApi;
try {
testApi = await import("vitest");
} catch (error) {
if (error.code !== "ERR_MODULE_NOT_FOUND") throw error;
testApi = await import("node:test");
}
const { afterEach, describe, it } = testApi;
const require = createRequire(import.meta.url);
const {
assertRequiredApiArtifacts,
copyStandaloneBuild,
mergeServerArtifacts,
} = require("../../cli/scripts/build-cli.js");
const tempDirs = [];
function createTempDir() {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-cli-build-"));
tempDirs.push(tempDir);
return tempDir;
}
function writeFixture(root, relativePath, contents = relativePath) {
const filePath = path.join(root, relativePath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, contents);
return filePath;
}
function createCompleteServer(buildDistDir) {
const serverDir = path.join(buildDistDir, "server");
writeFixture(serverDir, "app/api/v1/chat/completions/route.js", "chat route");
writeFixture(serverDir, "app/api/v1/messages/route.js", "messages route");
writeFixture(serverDir, "chunks/openai-provider.js", "openai chunk");
writeFixture(serverDir, "chunks/anthropic-provider.js", "anthropic chunk");
return serverDir;
}
afterEach(() => {
for (const tempDir of tempDirs.splice(0)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
describe("CLI build server artifacts", () => {
for (const { name, standalonePath } of [
{
name: "legacy nested app",
standalonePath: (appDir, buildDistDir) => path.join(appDir, ".next", "standalone", "app"),
},
{
name: "Next 16 workspace",
standalonePath: (appDir, buildDistDir) => path.join(buildDistDir, "standalone", path.basename(appDir)),
},
]) {
it(`merges complete API routes and provider chunks for the ${name} layout`, () => {
const root = createTempDir();
const appDir = path.join(root, "9router");
const buildDistDir = path.join(appDir, ".next-cli-build");
const cliAppDir = path.join(root, "cli-app");
const standaloneDir = standalonePath(appDir, buildDistDir);
writeFixture(standaloneDir, "server.js", "standalone server");
writeFixture(
standaloneDir,
".next-cli-build/server/app/api/v1/chat/completions/route.js",
"standalone chat route",
);
createCompleteServer(buildDistDir);
copyStandaloneBuild(appDir, buildDistDir, cliAppDir);
mergeServerArtifacts(buildDistDir, cliAppDir);
assertRequiredApiArtifacts(cliAppDir);
const packagedServer = path.join(cliAppDir, ".next-cli-build", "server");
assert.equal(
fs.readFileSync(path.join(packagedServer, "app/api/v1/messages/route.js"), "utf8"),
"messages route",
);
assert.equal(
fs.readFileSync(path.join(packagedServer, "chunks/openai-provider.js"), "utf8"),
"openai chunk",
);
assert.equal(
fs.readFileSync(path.join(packagedServer, "chunks/anthropic-provider.js"), "utf8"),
"anthropic chunk",
);
});
}
it("merges idempotently without removing standalone-generated files", () => {
const root = createTempDir();
const buildDistDir = path.join(root, ".next-cli-build");
const cliAppDir = path.join(root, "cli-app");
const packagedServer = path.join(cliAppDir, ".next-cli-build", "server");
createCompleteServer(buildDistDir);
writeFixture(packagedServer, "standalone-only.js", "keep me");
mergeServerArtifacts(buildDistDir, cliAppDir);
mergeServerArtifacts(buildDistDir, cliAppDir);
assert.equal(
fs.readFileSync(path.join(packagedServer, "standalone-only.js"), "utf8"),
"keep me",
);
assert.equal(
fs.readFileSync(path.join(packagedServer, "app/api/v1/messages/route.js"), "utf8"),
"messages route",
);
});
it("reports the missing required API route artifact path", () => {
const root = createTempDir();
const buildDistDir = path.join(root, ".next-cli-build");
const cliAppDir = path.join(root, "cli-app");
writeFixture(
path.join(buildDistDir, "server"),
"app/api/v1/chat/completions/route.js",
"chat route",
);
mergeServerArtifacts(buildDistDir, cliAppDir);
assert.throws(
() => assertRequiredApiArtifacts(cliAppDir),
(error) => error.message.includes(path.join(
cliAppDir,
".next-cli-build/server/app/api/v1/messages/route.js",
)),
);
});
});