Skip to content

Commit 3f2cbbc

Browse files
committed
fix(build): keep staged prerender artifacts canonical
1 parent 91e63ff commit 3f2cbbc

7 files changed

Lines changed: 160 additions & 102 deletions

File tree

packages/vinext/src/build/inject-pregenerated-paths.ts

Lines changed: 7 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,17 @@ import fs from "node:fs";
22
import path from "pathslash";
33
import { readPrerenderManifest } from "../server/prerender-manifest.js";
44
import { PREGENERATED_CONCRETE_PATHS_MODULE } from "../server/pregenerated-concrete-paths.js";
5-
import { escapeRegExp } from "../utils/regex.js";
65

76
declare global {
87
var __VINEXT_PREGENERATED_CONCRETE_PATHS: unknown;
98
}
109

11-
const VINEXT_PREGEN_START = "/* __VINEXT_PREGENERATED_CONCRETE_PATHS_START__ */";
12-
const VINEXT_PREGEN_END = "/* __VINEXT_PREGENERATED_CONCRETE_PATHS_END__ */";
13-
const VINEXT_PREGEN_RE = new RegExp(
14-
`${escapeRegExp(VINEXT_PREGEN_START)}[\\s\\S]*?${escapeRegExp(VINEXT_PREGEN_END)}\\n?`,
15-
"g",
16-
);
17-
1810
export function injectPregeneratedConcretePaths(
1911
root: string,
20-
workerEntry = path.resolve(root, "dist", "server", "index.js"),
12+
applicationEntry = path.resolve(root, "dist", "server", "index.js"),
2113
serverOutputDir = path.resolve(root, "dist", "server"),
14+
additionalRuntimeDirs: readonly string[] = [],
2215
): void {
23-
const runtimeDir = path.dirname(workerEntry);
2416
const manifest = readPrerenderManifest(path.join(serverOutputDir, "vinext-prerender.json"));
2517
const table = manifest?.pregeneratedConcretePaths ?? [];
2618

@@ -34,29 +26,18 @@ export function injectPregeneratedConcretePaths(
3426
table.length > 0
3527
? `globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = ${JSON.stringify(table)};\n`
3628
: "delete globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS;\n";
37-
for (const outputDir of new Set([serverOutputDir, runtimeDir])) {
29+
for (const outputDir of new Set([
30+
serverOutputDir,
31+
path.dirname(applicationEntry),
32+
...additionalRuntimeDirs,
33+
])) {
3834
fs.mkdirSync(outputDir, { recursive: true });
3935
fs.writeFileSync(path.join(outputDir, PREGENERATED_CONCRETE_PATHS_MODULE), runtimeModuleCode);
4036
}
4137

42-
if (!fs.existsSync(workerEntry)) {
43-
if (table.length > 0) globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = table;
44-
else delete globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS;
45-
return;
46-
}
47-
48-
let code = fs.readFileSync(workerEntry, "utf-8").replace(VINEXT_PREGEN_RE, "");
49-
5038
if (table.length > 0) {
5139
globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = table;
52-
code =
53-
`${VINEXT_PREGEN_START}\n` +
54-
`globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = ${JSON.stringify(table)};\n` +
55-
`${VINEXT_PREGEN_END}\n` +
56-
code;
5740
} else {
5841
delete globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS;
5942
}
60-
61-
fs.writeFileSync(workerEntry, code);
6243
}

packages/vinext/src/build/prerender.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,11 @@ type PrerenderAppOptions = {
275275
* to the RSC entry's directory for compatibility with standalone callers.
276276
*/
277277
serverDir?: string;
278+
/**
279+
* Root of the complete production build passed to the local prerender
280+
* server. Defaults to the parent of `serverDir` for standalone callers.
281+
*/
282+
buildOutDir?: string;
278283
} & PrerenderOptions;
279284

280285
// ─── Internal option extensions ───────────────────────────────────────────────
@@ -1076,6 +1081,7 @@ export async function prerenderApp({
10761081
mode,
10771082
rscBundlePath,
10781083
serverDir = path.dirname(rscBundlePath),
1084+
buildOutDir = path.dirname(serverDir),
10791085
...options
10801086
}: PrerenderAppOptionsInternal): Promise<PrerenderResult> {
10811087
const manifestDir = options.manifestDir ?? outDir;
@@ -1130,7 +1136,7 @@ export async function prerenderApp({
11301136
const srv = await startProdServer({
11311137
port: 0,
11321138
host: "127.0.0.1",
1133-
outDir: path.dirname(serverDir),
1139+
outDir: buildOutDir,
11341140
rscEntryPath: rscBundlePath,
11351141
serverDir,
11361142
noCompression: true,
@@ -1806,7 +1812,7 @@ export async function prerenderApp({
18061812
const poolSize = resolvePrerenderPoolSize(urlsToRender.length, concurrency);
18071813
if (poolSize > 1) {
18081814
renderPool = await startOptionalPrerenderServerPool(
1809-
path.dirname(serverDir),
1815+
buildOutDir,
18101816
poolSize,
18111817
rscBundlePath,
18121818
serverDir,

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

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -167,20 +167,24 @@ export async function runPrerender(options: RunPrerenderOptions): Promise<Preren
167167

168168
if (!appDir && !pagesDir) return null;
169169

170-
// The manifest lands in dist/server/ alongside the server bundle so it's
171-
// cleaned with the rest of vinext's build output on rebuild and co-located
172-
// with server artifacts.
173-
const manifestDir = path.resolve(
170+
// Framework manifests and prerendered routes have one canonical location,
171+
// independent of where an adapter asks Vite to emit the executable RSC
172+
// graph. Consumers such as cache prewarming always resolve these artifacts
173+
// from dist/server.
174+
const manifestDir = path.resolve(root, "dist", "server");
175+
const buildOutDir = path.resolve(root, "dist");
176+
const configuredRscServerDir = path.resolve(
174177
root,
175178
options.routeRootConfig?.rscOutDir ?? path.join("dist", "server"),
176179
);
177-
const rscBundlePath = options.rscBundlePath ?? resolveBuiltRscEntryPath(manifestDir);
178-
// The emitted entry may live below dist/server (for example entries/app.js).
179-
// Build metadata and prerender artifacts remain rooted at dist/server.
180-
const relativeEntryPath = path.relative(manifestDir, rscBundlePath);
180+
const rscBundlePath = options.rscBundlePath ?? resolveBuiltRscEntryPath(configuredRscServerDir);
181+
// The emitted entry may live below its server root (for example
182+
// entries/app.js). Keep adjacent build metadata rooted at the configured
183+
// RSC output while resolving an explicit external entry from its own folder.
184+
const relativeEntryPath = path.relative(configuredRscServerDir, rscBundlePath);
181185
const serverDir =
182186
!relativeEntryPath.startsWith("../") && !path.isAbsolute(relativeEntryPath)
183-
? manifestDir
187+
? configuredRscServerDir
184188
: path.dirname(rscBundlePath);
185189

186190
const config = options.nextConfig
@@ -247,7 +251,7 @@ export async function runPrerender(options: RunPrerenderOptions): Promise<Preren
247251
sharedProdServer = await startProdServer({
248252
port: 0,
249253
host: "127.0.0.1",
250-
outDir: path.dirname(serverDir),
254+
outDir: buildOutDir,
251255
rscEntryPath: rscBundlePath,
252256
serverDir,
253257
noCompression: true,
@@ -278,6 +282,7 @@ export async function runPrerender(options: RunPrerenderOptions): Promise<Preren
278282
concurrency: options.concurrency,
279283
rscBundlePath,
280284
serverDir,
285+
buildOutDir,
281286
// For hybrid builds pass the shared prod server via internal field.
282287
// prerenderApp will use it instead of starting its own.
283288
...(sharedProdServer ? { _prodServer: sharedProdServer } : {}),
@@ -388,7 +393,7 @@ export async function runPrerender(options: RunPrerenderOptions): Promise<Preren
388393
);
389394
}
390395

391-
injectPregeneratedConcretePaths(root, rscBundlePath, manifestDir);
396+
injectPregeneratedConcretePaths(root, rscBundlePath, manifestDir, [configuredRscServerDir]);
392397
if (fs.existsSync(rscBundlePath)) {
393398
rememberCurrentServerEntryImportMtime(rscBundlePath);
394399
}

packages/vinext/src/index.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7094,16 +7094,14 @@ export const loadServerActionClient = ${
70947094
const source = JSON.stringify(manifest);
70957095
fs.writeFileSync(path.join(outDir, "vinext-server.json"), source);
70967096

7097-
// Staged discovery and cacheability probing deliberately read build
7098-
// metadata from the platform-independent server directory. A Pages
7099-
// Worker bundle may live in a platform-named output directory, so
7100-
// retain the adjacent copy above and also publish the canonical copy.
7101-
if (!hasAppDir) {
7102-
const canonicalServerDir = path.join(root, "dist", "server");
7103-
if (path.resolve(outDir) !== canonicalServerDir) {
7104-
fs.mkdirSync(canonicalServerDir, { recursive: true });
7105-
fs.writeFileSync(path.join(canonicalServerDir, "vinext-server.json"), source);
7106-
}
7097+
// Post-build discovery deliberately reads metadata from the
7098+
// platform-independent server directory. An adapter may emit either
7099+
// router's executable graph elsewhere, so retain the adjacent copy
7100+
// above and also publish the canonical copy.
7101+
const canonicalServerDir = path.join(root, "dist", "server");
7102+
if (path.resolve(outDir) !== canonicalServerDir) {
7103+
fs.mkdirSync(canonicalServerDir, { recursive: true });
7104+
fs.writeFileSync(path.join(canonicalServerDir, "vinext-server.json"), source);
71077105
}
71087106
},
71097107
},

tests/client-assets-build.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ describe("client asset sidecar builds", () => {
7676

7777
const rscEntry = await fs.readFile(path.join(rscOutDir, "index.js"), "utf8");
7878
expect(rscEntry).toContain("./vinext-client-assets.js");
79+
80+
const adjacentServerManifest = await fs.readFile(
81+
path.join(rscOutDir, "vinext-server.json"),
82+
"utf8",
83+
);
84+
expect(
85+
await fs.readFile(path.join(fixtureRoot, "dist/server/vinext-server.json"), "utf8"),
86+
).toBe(adjacentServerManifest);
7987
} finally {
8088
await fs.rm(fixtureRoot, { recursive: true, force: true });
8189
await fs.rm(outRoot, { recursive: true, force: true });

tests/inject-pregenerated-paths.test.ts

Lines changed: 50 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,20 @@ afterEach(() => {
2929
});
3030

3131
describe("injectPregeneratedConcretePaths", () => {
32-
it("replaces an earlier injection", () => {
33-
writeFile("dist/server/index.js", 'import { handler } from "vinext/server/fetch-handler";\n');
32+
it("updates the sidecar without rewriting the built entry or its sourcemap", () => {
33+
const entry = 'import { handler } from "vinext/server/fetch-handler";\n';
34+
const sourceMap = '{"version":3,"sources":["entry.ts"]}\n';
35+
writeFile("dist/server/index-a1b2.js", entry);
36+
writeFile("dist/server/index-a1b2.js.map", sourceMap);
3437
writeFile(
3538
"dist/server/vinext-prerender.json",
3639
JSON.stringify({
3740
buildId: "build-a",
3841
pregeneratedConcretePaths: [["/blog/:slug", ["/blog/post-a"]]],
3942
}),
4043
);
41-
injectPregeneratedConcretePaths(tmpDir);
44+
const entryPath = path.join(tmpDir, "dist/server/index-a1b2.js");
45+
injectPregeneratedConcretePaths(tmpDir, entryPath);
4246

4347
writeFile(
4448
"dist/server/vinext-prerender.json",
@@ -47,31 +51,31 @@ describe("injectPregeneratedConcretePaths", () => {
4751
pregeneratedConcretePaths: [["/blog/:slug", ["/blog/post-b"]]],
4852
}),
4953
);
50-
injectPregeneratedConcretePaths(tmpDir);
54+
injectPregeneratedConcretePaths(tmpDir, entryPath);
5155

52-
const output = fs.readFileSync(path.join(tmpDir, "dist/server/index.js"), "utf-8");
53-
expect(output).toContain("post-b");
54-
expect(output).not.toContain("post-a");
55-
expect(output).toContain('import { handler } from "vinext/server/fetch-handler"');
56+
const runtimeTable = fs.readFileSync(
57+
path.join(tmpDir, "dist/server", PREGENERATED_CONCRETE_PATHS_MODULE),
58+
"utf-8",
59+
);
60+
expect(runtimeTable).toContain("post-b");
61+
expect(runtimeTable).not.toContain("post-a");
62+
expect(fs.readFileSync(entryPath, "utf-8")).toBe(entry);
63+
expect(fs.readFileSync(`${entryPath}.map`, "utf-8")).toBe(sourceMap);
5664
});
5765

58-
it("strips an earlier injection when the manifest is missing", () => {
59-
writeFile(
60-
"dist/server/index.js",
61-
[
62-
"/* __VINEXT_PREGENERATED_CONCRETE_PATHS_START__ */",
63-
'globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = [["/blog/:slug",["/blog/post-a"]]];',
64-
"/* __VINEXT_PREGENERATED_CONCRETE_PATHS_END__ */",
65-
'import { handler } from "vinext/server/fetch-handler";',
66-
"",
67-
].join("\n"),
68-
);
66+
it("clears the sidecar when the manifest is missing", () => {
67+
const entry = 'import { handler } from "vinext/server/fetch-handler";\n';
68+
writeFile("dist/server/index.js", entry);
6969

7070
injectPregeneratedConcretePaths(tmpDir);
7171

72-
const output = fs.readFileSync(path.join(tmpDir, "dist/server/index.js"), "utf-8");
73-
expect(output).not.toContain("__VINEXT_PREGENERATED_CONCRETE_PATHS");
74-
expect(output).toContain('import { handler } from "vinext/server/fetch-handler"');
72+
expect(
73+
fs.readFileSync(
74+
path.join(tmpDir, "dist/server", PREGENERATED_CONCRETE_PATHS_MODULE),
75+
"utf-8",
76+
),
77+
).toBe("delete globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS;\n");
78+
expect(fs.readFileSync(path.join(tmpDir, "dist/server/index.js"), "utf-8")).toBe(entry);
7579
});
7680

7781
it("uses the concrete-path table stored in the prerender manifest", () => {
@@ -89,7 +93,10 @@ describe("injectPregeneratedConcretePaths", () => {
8993

9094
injectPregeneratedConcretePaths(tmpDir);
9195

92-
const output = fs.readFileSync(path.join(tmpDir, "dist/server/index.js"), "utf-8");
96+
const output = fs.readFileSync(
97+
path.join(tmpDir, "dist/server", PREGENERATED_CONCRETE_PATHS_MODULE),
98+
"utf-8",
99+
);
93100
const match = output.match(/globalThis\.__VINEXT_PREGENERATED_CONCRETE_PATHS = (\[.*?\]);/);
94101
expect(match).not.toBeNull();
95102
expect(JSON.parse(match![1])).toEqual([["/blog/:slug", ["/blog/post-a"]]]);
@@ -100,16 +107,6 @@ describe("injectPregeneratedConcretePaths", () => {
100107

101108
it("clears the current-process global when no concrete paths are available", () => {
102109
globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = [["/old/:slug", ["/old/post"]]];
103-
writeFile(
104-
"dist/server/index.js",
105-
[
106-
"/* __VINEXT_PREGENERATED_CONCRETE_PATHS_START__ */",
107-
'globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = [["/old/:slug",["/old/post"]]];',
108-
"/* __VINEXT_PREGENERATED_CONCRETE_PATHS_END__ */",
109-
'export default { fetch() { return new Response("ok"); } };',
110-
"",
111-
].join("\n"),
112-
);
113110

114111
injectPregeneratedConcretePaths(tmpDir);
115112

@@ -123,6 +120,7 @@ describe("injectPregeneratedConcretePaths", () => {
123120
writeFile(
124121
"dist/server/index.js",
125122
[
123+
`import "./${PREGENERATED_CONCRETE_PATHS_MODULE}";`,
126124
`import { getRenderedConcreteUrlPathsForRoute, initPregeneratedPathsFromGlobals } from ${JSON.stringify(registryModuleUrl)};`,
127125
"initPregeneratedPathsFromGlobals();",
128126
'export const renderedPaths = [...(getRenderedConcreteUrlPathsForRoute("/blog/:slug") ?? [])];',
@@ -180,9 +178,10 @@ describe("injectPregeneratedConcretePaths", () => {
180178
const registryModuleUrl = pathToFileURL(
181179
path.resolve("packages/vinext/src/server/pregenerated-concrete-paths.ts"),
182180
).href;
183-
const entryPath = path.join(tmpDir, "dist/custom-rsc/application-entry.js");
181+
const rscServerDir = path.join(tmpDir, "dist/custom-rsc");
182+
const entryPath = path.join(rscServerDir, "entries/application-entry.js");
184183
writeFile(
185-
"dist/custom-rsc/application-entry.js",
184+
"dist/custom-rsc/entries/application-entry.js",
186185
[
187186
`import "./${PREGENERATED_CONCRETE_PATHS_MODULE}";`,
188187
`import { getRenderedConcreteUrlPathsForRoute, initPregeneratedPathsFromGlobals } from ${JSON.stringify(registryModuleUrl)};`,
@@ -199,7 +198,9 @@ describe("injectPregeneratedConcretePaths", () => {
199198
}),
200199
);
201200

202-
injectPregeneratedConcretePaths(tmpDir, entryPath);
201+
injectPregeneratedConcretePaths(tmpDir, entryPath, path.join(tmpDir, "dist/server"), [
202+
rscServerDir,
203+
]);
203204

204205
expect(
205206
fs.existsSync(path.join(path.dirname(entryPath), PREGENERATED_CONCRETE_PATHS_MODULE)),
@@ -210,31 +211,30 @@ describe("injectPregeneratedConcretePaths", () => {
210211
"utf-8",
211212
),
212213
).toContain("/blog/post-a");
214+
expect(
215+
fs.readFileSync(path.join(rscServerDir, PREGENERATED_CONCRETE_PATHS_MODULE), "utf-8"),
216+
).toContain("/blog/post-a");
213217
const applicationEntry: unknown = await import(
214218
`${pathToFileURL(entryPath).href}?t=${Date.now()}`
215219
);
216220
expect(applicationEntry).toMatchObject({ renderedPaths: ["/blog/post-a"] });
217221
});
218222

219-
it("strips an earlier injection when the manifest is corrupt", () => {
223+
it("clears the sidecar without rewriting the entry when the manifest is corrupt", () => {
220224
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
221-
writeFile(
222-
"dist/server/index.js",
223-
[
224-
"/* __VINEXT_PREGENERATED_CONCRETE_PATHS_START__ */",
225-
'globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = [["/",["/"]]];',
226-
"/* __VINEXT_PREGENERATED_CONCRETE_PATHS_END__ */",
227-
'export default { fetch() { return new Response("ok"); } };',
228-
"",
229-
].join("\n"),
230-
);
225+
const entry = 'export default { fetch() { return new Response("ok"); } };\n';
226+
writeFile("dist/server/index.js", entry);
231227
writeFile("dist/server/vinext-prerender.json", "{invalid json}");
232228

233229
injectPregeneratedConcretePaths(tmpDir);
234230

235-
const output = fs.readFileSync(path.join(tmpDir, "dist/server/index.js"), "utf-8");
236-
expect(output).not.toContain("__VINEXT_PREGENERATED_CONCRETE_PATHS");
237-
expect(output).toContain('export default { fetch() { return new Response("ok"); } }');
231+
expect(
232+
fs.readFileSync(
233+
path.join(tmpDir, "dist/server", PREGENERATED_CONCRETE_PATHS_MODULE),
234+
"utf-8",
235+
),
236+
).toBe("delete globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS;\n");
237+
expect(fs.readFileSync(path.join(tmpDir, "dist/server/index.js"), "utf-8")).toBe(entry);
238238
expect(warnSpy).toHaveBeenCalledWith(
239239
expect.stringContaining("[vinext] Failed to read prerender manifest"),
240240
expect.any(SyntaxError),

0 commit comments

Comments
 (0)