Skip to content

Commit e754ca0

Browse files
committed
fix(build): preserve nested server artifact roots
1 parent 99ea760 commit e754ca0

11 files changed

Lines changed: 101 additions & 19 deletions

packages/vinext/src/build/prerender-paths.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,6 +1231,7 @@ async function startPathDiscoveryServer(options: {
12311231
? path.dirname(path.dirname(options.pagesBundlePath))
12321232
: path.dirname(options.serverDir),
12331233
rscEntryPath: options.rscBundlePath,
1234+
serverDir: options.serverDir,
12341235
serverEntryPath: options.pagesBundlePath,
12351236
noCompression: true,
12361237
purpose: "prerender",
@@ -1247,14 +1248,19 @@ export async function emitPrerenderPathManifest(
12471248

12481249
if (!appDir && !pagesDir) return null;
12491250

1250-
const rscServerDir = options.routeRootConfig?.rscOutDir
1251+
const configuredRscServerDir = options.routeRootConfig?.rscOutDir
12511252
? path.resolve(root, options.routeRootConfig.rscOutDir)
12521253
: path.join(root, "dist", "server");
1253-
const defaultRscBundlePath = resolveBuiltRscEntryPath(rscServerDir);
1254+
const defaultRscBundlePath = resolveBuiltRscEntryPath(configuredRscServerDir);
12541255
const rscBundlePath = options.rscBundlePath ?? defaultRscBundlePath;
1256+
const relativeRscEntryPath = path.relative(configuredRscServerDir, rscBundlePath);
1257+
const rscServerDir =
1258+
!relativeRscEntryPath.startsWith("../") && !path.isAbsolute(relativeRscEntryPath)
1259+
? configuredRscServerDir
1260+
: path.dirname(rscBundlePath);
12551261
const pagesBundlePath = options.pagesBundlePath ?? path.join(root, "dist", "server", "entry.js");
12561262
const bundleServerDir = fs.existsSync(rscBundlePath)
1257-
? path.dirname(rscBundlePath)
1263+
? rscServerDir
12581264
: path.dirname(pagesBundlePath);
12591265
const manifestDir = path.join(root, "dist", "server");
12601266
const config = options.nextConfig

packages/vinext/src/build/prerender-server-entry.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
* over IPC. The parent then load-balances per-route fetches across the pool.
1010
*
1111
* `VINEXT_PRERENDER`, `NEXT_PHASE`, `VINEXT_PRERENDER_OUTDIR`, and the optional
12-
* `VINEXT_PRERENDER_RSC_ENTRY_PATH` are passed via the fork env so they are set
12+
* `VINEXT_PRERENDER_RSC_ENTRY_PATH`, and `VINEXT_PRERENDER_SERVER_DIR` are
13+
* passed via the fork env so they are set
1314
* before any module loads (some server and user modules read the phase at
1415
* import time).
1516
*/
@@ -19,6 +20,7 @@ import { NoOpCacheHandler, setCacheHandler } from "vinext/shims/cache-handler";
1920
async function main(): Promise<void> {
2021
const outDir = process.env.VINEXT_PRERENDER_OUTDIR;
2122
const rscEntryPath = process.env.VINEXT_PRERENDER_RSC_ENTRY_PATH;
23+
const serverDir = process.env.VINEXT_PRERENDER_SERVER_DIR;
2224
if (!outDir) {
2325
throw new Error("[vinext] prerender server worker: VINEXT_PRERENDER_OUTDIR not set");
2426
}
@@ -33,6 +35,7 @@ async function main(): Promise<void> {
3335
host: "127.0.0.1",
3436
outDir,
3537
...(rscEntryPath ? { rscEntryPath } : {}),
38+
...(serverDir ? { serverDir } : {}),
3639
noCompression: true,
3740
purpose: "prerender",
3841
silent: true,

packages/vinext/src/build/prerender-server-pool.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,9 @@ function isWorkerTransportError(err: unknown): boolean {
126126
export async function startPrerenderServerPool(
127127
outDir: string,
128128
size: number,
129-
options: { entry?: string; rscEntryPath?: string } = {},
129+
options: { entry?: string; rscEntryPath?: string; serverDir?: string } = {},
130130
): Promise<PrerenderServerPool> {
131-
const { entry = WORKER_ENTRY, rscEntryPath } = options;
131+
const { entry = WORKER_ENTRY, rscEntryPath, serverDir } = options;
132132
const children: ChildProcess[] = [];
133133
let shuttingDown = false;
134134
let crash: { port?: number; code: number | null; signal: NodeJS.Signals | null } | null = null;
@@ -149,6 +149,7 @@ export async function startPrerenderServerPool(
149149
NEXT_PHASE: PHASE_PRODUCTION_BUILD,
150150
VINEXT_PRERENDER_OUTDIR: outDir,
151151
...(rscEntryPath ? { VINEXT_PRERENDER_RSC_ENTRY_PATH: rscEntryPath } : {}),
152+
...(serverDir ? { VINEXT_PRERENDER_SERVER_DIR: serverDir } : {}),
152153
},
153154
// Inherit stdout/stderr so server-side errors surface; keep IPC.
154155
stdio: ["ignore", "inherit", "inherit", "ipc"],

packages/vinext/src/build/prerender.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,10 @@ async function startOptionalPrerenderServerPool(
9898
outDir: string,
9999
poolSize: number,
100100
rscEntryPath?: string,
101+
serverDir?: string,
101102
): Promise<PrerenderServerPool | null> {
102103
try {
103-
return await startPrerenderServerPool(outDir, poolSize, { rscEntryPath });
104+
return await startPrerenderServerPool(outDir, poolSize, { rscEntryPath, serverDir });
104105
} catch (e) {
105106
// The pool is a performance optimization layered over the already-running
106107
// in-process prerender server. Startup failure is still before any route has
@@ -1131,6 +1132,7 @@ export async function prerenderApp({
11311132
host: "127.0.0.1",
11321133
outDir: path.dirname(serverDir),
11331134
rscEntryPath: rscBundlePath,
1135+
serverDir,
11341136
noCompression: true,
11351137
purpose: "prerender",
11361138
});
@@ -1807,6 +1809,7 @@ export async function prerenderApp({
18071809
path.dirname(serverDir),
18081810
poolSize,
18091811
rscBundlePath,
1812+
serverDir,
18101813
);
18111814
if (renderPool) renderPorts = renderPool.ports;
18121815
}

packages/vinext/src/build/run-prerender.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ export async function runPrerender(options: RunPrerenderOptions): Promise<Preren
190190
// output aligned with the bundle it was generated from. (Spreading
191191
// `loadedConfig` above is required so this assignment does not mutate the
192192
// shared loaded config.)
193-
const builtBuildId = readBuiltBuildId(serverDir);
193+
const builtBuildId = readBuiltBuildId(manifestDir) ?? readBuiltBuildId(serverDir);
194194
if (builtBuildId) {
195195
config.buildId = builtBuildId;
196196
}
@@ -243,6 +243,7 @@ export async function runPrerender(options: RunPrerenderOptions): Promise<Preren
243243
host: "127.0.0.1",
244244
outDir: path.dirname(serverDir),
245245
rscEntryPath: rscBundlePath,
246+
serverDir,
246247
noCompression: true,
247248
purpose: "prerender",
248249
});

packages/vinext/src/server/prod-server.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,8 @@ export type ProdServerOptions = {
278278
outDir?: string;
279279
/** Explicit App Router RSC entry path. Defaults to `<outDir>/server/index.js`. */
280280
rscEntryPath?: string;
281+
/** Directory containing server manifests, sidecars, and prerender artifacts. */
282+
serverDir?: string;
281283
/** Explicit Pages Router server entry path. Defaults to `<outDir>/server/entry.js`. */
282284
serverEntryPath?: string;
283285
/** Disable compression (default: false) */
@@ -1266,6 +1268,7 @@ export async function startProdServer(options: ProdServerOptions = {}) {
12661268
host = "0.0.0.0",
12671269
outDir = path.resolve("dist"),
12681270
rscEntryPath: explicitRscEntryPath,
1271+
serverDir: explicitServerDir,
12691272
serverEntryPath: explicitServerEntryPath,
12701273
noCompression = false,
12711274
purpose,
@@ -1276,14 +1279,17 @@ export async function startProdServer(options: ProdServerOptions = {}) {
12761279
// Always resolve outDir to absolute to ensure dynamic import() works
12771280
const resolvedOutDir = path.resolve(outDir);
12781281
const clientDir = path.join(resolvedOutDir, "client");
1282+
const serverDir = explicitServerDir
1283+
? path.resolve(explicitServerDir)
1284+
: path.join(resolvedOutDir, "server");
12791285

12801286
// Detect build type
12811287
const rscEntryPath = explicitRscEntryPath
12821288
? path.resolve(explicitRscEntryPath)
1283-
: path.join(resolvedOutDir, "server", "index.js");
1289+
: path.join(serverDir, "index.js");
12841290
const serverEntryPath = explicitServerEntryPath
12851291
? path.resolve(explicitServerEntryPath)
1286-
: path.join(resolvedOutDir, "server", "entry.js");
1292+
: path.join(serverDir, "entry.js");
12871293
const isAppRouter = fs.existsSync(rscEntryPath);
12881294

12891295
if (!isAppRouter && !fs.existsSync(serverEntryPath)) {
@@ -1293,7 +1299,16 @@ export async function startProdServer(options: ProdServerOptions = {}) {
12931299
}
12941300

12951301
if (isAppRouter) {
1296-
return startAppRouterServer({ port, host, clientDir, rscEntryPath, compress, purpose, silent });
1302+
return startAppRouterServer({
1303+
port,
1304+
host,
1305+
clientDir,
1306+
serverDir,
1307+
rscEntryPath,
1308+
compress,
1309+
purpose,
1310+
silent,
1311+
});
12971312
}
12981313

12991314
return startPagesRouterServer({
@@ -1313,6 +1328,7 @@ type AppRouterServerOptions = {
13131328
port: number;
13141329
host: string;
13151330
clientDir: string;
1331+
serverDir: string;
13161332
rscEntryPath: string;
13171333
compress: boolean;
13181334
purpose?: ProdServerOptions["purpose"];
@@ -1547,11 +1563,11 @@ function installPagesClientAssets(options: {
15471563
* 4. Stream the Web Response back (with optional compression)
15481564
*/
15491565
async function startAppRouterServer(options: AppRouterServerOptions) {
1550-
const { port, host, clientDir, rscEntryPath, compress, purpose, silent } = options;
1566+
const { port, host, clientDir, serverDir, rscEntryPath, compress, purpose, silent } = options;
15511567

15521568
// Load prerender secret written at build time by vinext:server-manifest plugin.
15531569
// Used to authenticate internal /__vinext/prerender/* HTTP endpoints.
1554-
const prerenderSecret = readPrerenderSecret(path.dirname(rscEntryPath));
1570+
const prerenderSecret = readPrerenderSecret(serverDir);
15551571

15561572
// Import the RSC handler. importServerEntryModule uses the bare file://
15571573
// URL so lazy chunks that import the entry back resolve to the same module
@@ -1586,7 +1602,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) {
15861602
? (rscModule.__imageConfig as ImageConfig)
15871603
: undefined;
15881604
if (imageConfig === undefined) {
1589-
const imageConfigPath = path.join(path.dirname(rscEntryPath), "image-config.json");
1605+
const imageConfigPath = path.join(serverDir, "image-config.json");
15901606
if (fs.existsSync(imageConfigPath)) {
15911607
try {
15921608
imageConfig = JSON.parse(fs.readFileSync(imageConfigPath, "utf-8"));
@@ -1619,7 +1635,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) {
16191635
// any pre-rendered page is a cache HIT instead of a full re-render.
16201636
const seedPrerenderedRoutes = resolveAppRouterPrerenderSeeder(rscModule);
16211637
const seededRoutes = await runWithServerEntryRequire(rscEntryRequire, () =>
1622-
seedPrerenderedRoutes(path.dirname(rscEntryPath)),
1638+
seedPrerenderedRoutes(serverDir),
16231639
);
16241640
if (seededRoutes > 0) {
16251641
console.log(

tests/prerender-entry.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ describe("App prerender entry", () => {
4848
expect.objectContaining({
4949
outDir: path.join(root, "dist"),
5050
rscEntryPath: rscBundlePath,
51+
serverDir,
5152
}),
5253
);
5354
});

tests/prerender-paths.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1843,12 +1843,12 @@ describe("prerender path manifest", () => {
18431843
writeFile("package.json", JSON.stringify({ type: "module" }));
18441844
writeFile("dist/server/BUILD_ID", "build-a\n");
18451845
writeFile("dist/server/index.js", 'import "cloudflare:workers";\n');
1846-
writeFile("dist/server/application-entry.js", "export default {};\n");
1846+
writeFile("dist/server/entries/application-entry.js", "export default {};\n");
18471847
writeFile(
18481848
"dist/server/.vite/manifest.json",
18491849
JSON.stringify({
18501850
"virtual:vinext-rsc-entry": {
1851-
file: "application-entry.js",
1851+
file: "entries/application-entry.js",
18521852
isDynamicEntry: true,
18531853
},
18541854
}),
@@ -1869,7 +1869,8 @@ describe("prerender path manifest", () => {
18691869

18701870
expect(startProdServerMock).toHaveBeenCalledWith(
18711871
expect.objectContaining({
1872-
rscEntryPath: toSlash(path.join(tmpDir, "dist/server/application-entry.js")),
1872+
rscEntryPath: toSlash(path.join(tmpDir, "dist/server/entries/application-entry.js")),
1873+
serverDir: toSlash(path.join(tmpDir, "dist/server")),
18731874
}),
18741875
);
18751876
});

tests/prerender-server-pool.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,14 @@ describe("prerender server pool sizing", () => {
7777
if (
7878
process.env.VINEXT_PRERENDER !== "1" ||
7979
process.env.NEXT_PHASE !== "phase-production-build" ||
80-
process.env.VINEXT_PRERENDER_RSC_ENTRY_PATH !== "application-entry.js"
80+
process.env.VINEXT_PRERENDER_RSC_ENTRY_PATH !== "application-entry.js" ||
81+
process.env.VINEXT_PRERENDER_SERVER_DIR !== "server-artifacts"
8182
) {
8283
process.send({ type: "error", error: JSON.stringify({
8384
VINEXT_PRERENDER: process.env.VINEXT_PRERENDER,
8485
NEXT_PHASE: process.env.NEXT_PHASE,
8586
VINEXT_PRERENDER_RSC_ENTRY_PATH: process.env.VINEXT_PRERENDER_RSC_ENTRY_PATH,
87+
VINEXT_PRERENDER_SERVER_DIR: process.env.VINEXT_PRERENDER_SERVER_DIR,
8688
}) });
8789
} else {
8890
process.send({ type: "ready", port: 4125 });
@@ -94,6 +96,7 @@ describe("prerender server pool sizing", () => {
9496
const pool = await startPrerenderServerPool(dir, 1, {
9597
entry,
9698
rscEntryPath: "application-entry.js",
99+
serverDir: "server-artifacts",
97100
});
98101
await pool.close();
99102
} finally {

tests/prod-server-logs.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,47 @@ describe("startProdServer logging", () => {
101101
]);
102102
});
103103

104+
it("keeps nested App entries authenticated against the server artifact root", async () => {
105+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-prod-server-nested-entry-"));
106+
roots.push(root);
107+
const distDir = path.join(root, "dist");
108+
const clientDir = path.join(distDir, "client");
109+
const serverDir = path.join(distDir, "server");
110+
const entryPath = path.join(serverDir, "entries", "application.js");
111+
fs.mkdirSync(clientDir, { recursive: true });
112+
fs.mkdirSync(path.dirname(entryPath), { recursive: true });
113+
fs.writeFileSync(
114+
path.join(serverDir, "vinext-server.json"),
115+
JSON.stringify({ prerenderSecret: "nested-secret" }),
116+
);
117+
fs.writeFileSync(
118+
entryPath,
119+
["export default async function handler() { return new Response('ok'); }", ""].join("\n"),
120+
);
121+
122+
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
123+
const { server, port } = await startProdServer({
124+
port: 0,
125+
host: "127.0.0.1",
126+
outDir: distDir,
127+
rscEntryPath: entryPath,
128+
serverDir,
129+
noCompression: true,
130+
silent: true,
131+
});
132+
try {
133+
const denied = await fetch(`http://127.0.0.1:${port}/__vinext/prerender/static-params`);
134+
expect(denied.status).toBe(403);
135+
const allowed = await fetch(`http://127.0.0.1:${port}/__vinext/prerender/static-params`, {
136+
headers: { "x-vinext-prerender-secret": "nested-secret" },
137+
});
138+
expect(allowed.status).toBe(200);
139+
await expect(allowed.text()).resolves.toBe("ok");
140+
} finally {
141+
await new Promise<void>((resolve) => server.close(() => resolve()));
142+
}
143+
});
144+
104145
it("uses the prerender-specific startup log for App Router production servers", async () => {
105146
const root = createAppBuild();
106147
roots.push(root);

0 commit comments

Comments
 (0)