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:
@@ -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");
|
||||
|
||||
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
|
||||
// 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...");
|
||||
const standaloneRoot = path.join(appDir, ".next", "standalone");
|
||||
const standaloneRootResolved = path.join(buildDistDir, "standalone");
|
||||
let standaloneRootToUse = fs.existsSync(standaloneRootResolved) ? standaloneRootResolved : standaloneRoot;
|
||||
// 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)) {
|
||||
try {
|
||||
copyStandaloneBuild(appDir, buildDistDir, cliAppDir);
|
||||
} catch (error) {
|
||||
console.error("❌ Next.js standalone build not found under .next/standalone");
|
||||
console.error("Expected either .next/standalone/server.js or .next/standalone/app/");
|
||||
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");
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
// 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)
|
||||
console.log("7️⃣ Copying MITM server files...");
|
||||
const mitmSrc = path.join(appDir, "src", "mitm");
|
||||
@@ -283,3 +339,14 @@ try {
|
||||
} catch (e) {
|
||||
// Silent fail on size check
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertRequiredApiArtifacts,
|
||||
copyStandaloneBuild,
|
||||
mergeServerArtifacts,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
buildCliPackage();
|
||||
}
|
||||
|
||||
141
tests/unit/cli-build-artifacts.test.js
Normal file
141
tests/unit/cli-build-artifacts.test.js
Normal 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",
|
||||
)),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user