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:
parent
c570fe33ae
commit
25e4bf1c6c
2 changed files with 400 additions and 192 deletions
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
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",
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue