From df84ae0df2e46970ae830e188c4e1014eb17e5f1 Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Fri, 6 Nov 2026 13:56:12 +0900 Subject: [PATCH 1/2] fix(prerender): build getPrerenderPaths() URLs from complete route params Prerender URLs for dynamic pages were built by plain string replacement of `:name`, which filled the wrong segment (`:id` inside `:idx`), ignored misspelled keys, and left values unencoded. Build them with interpolateRouteParams() instead. An invalid entry now fails the build with the server file and entry index, and prerendered files are written at the decoded path static hosts look up. --- docs/guide/edge/prerendering.md | 2 + docs/guide/ssg.md | 2 + packages/void/src/prerender-render.ts | 60 +++++++++++++---- packages/void/src/prerender.ts | 4 +- packages/void/src/router/compile.ts | 27 +++----- packages/void/src/runtime/prerender-path.ts | 67 +++++++++++++++++++ packages/void/src/runtime/routing-worker.ts | 19 +++++- packages/void/src/runtime/routing.ts | 2 + packages/void/test/unit/compile.test.ts | 48 +++++++++++++ .../void/test/unit/prerender-path.test.ts | 42 ++++++++++++ .../void/test/unit/prerender-render.test.ts | 63 +++++++++++++++-- .../void/test/unit/routing-runtime.test.ts | 16 +++++ 12 files changed, 318 insertions(+), 34 deletions(-) create mode 100644 packages/void/src/runtime/prerender-path.ts create mode 100644 packages/void/test/unit/prerender-path.test.ts diff --git a/docs/guide/edge/prerendering.md b/docs/guide/edge/prerendering.md index 0969a0a56..eab20347c 100644 --- a/docs/guide/edge/prerendering.md +++ b/docs/guide/edge/prerendering.md @@ -34,6 +34,8 @@ export async function getPrerenderPaths() { } ``` +Each object must include every param in the page path. Values can be strings or numbers, and Void URL-encodes them. A single-segment param such as `[slug]` cannot contain `/`. For nested paths, use a catch-all page such as `pages/docs/[...slug]` and return `{ slug: 'guides/intro' }`. If an entry is invalid, `void deploy` fails. The error names the server file and the entry index. + ## Custom SSR In custom SSR mode (`src/main.ssr.ts`), export `getPrerenderPaths()` at the top level. Since there's no file-based routing, return full path strings: diff --git a/docs/guide/ssg.md b/docs/guide/ssg.md index 025594c65..c8cecca50 100644 --- a/docs/guide/ssg.md +++ b/docs/guide/ssg.md @@ -37,6 +37,8 @@ export async function getPrerenderPaths() { } ``` +Each object must include every param in the page path. Values can be strings or numbers, and Void URL-encodes them. A single-segment param such as `[slug]` cannot contain `/`. For nested paths, use a catch-all page such as `pages/docs/[...slug]` and return `{ slug: 'guides/intro' }`. If an entry is invalid, the build fails. The error names the server file and the entry index. + ## 404 pages Static builds that prerender at least one page include a default `404.html` in `dist/client/`. To provide your own, add `public/404.html` or prerender a page at `/404`. diff --git a/packages/void/src/prerender-render.ts b/packages/void/src/prerender-render.ts index 3462eeef8..951ec9cda 100644 --- a/packages/void/src/prerender-render.ts +++ b/packages/void/src/prerender-render.ts @@ -17,13 +17,42 @@ export function assertNoPrerenderFailures(failed: PrerenderResult['failed']): vo throw new Error(`${failed.length} page(s) failed to prerender:\n${details}`); } -export function outputFileForPath(path: string): string { - return path === '/' ? 'index.html' : `${path.slice(1)}.html`; +/** + * Static file server that will serve the prerendered files. Hosts decode the + * request path before the file lookup, so files are written decoded: + * Cloudflare assets decode each segment with decodeURIComponent, and Hono's + * serveStatic (Node, Bun, Deno) uses decodeURI, which keeps reserved + * characters such as %23 and %3F encoded. + */ +export type PrerenderHost = 'cloudflare' | 'node'; + +function tryDecode(value: string, decode: (value: string) => string): string { + try { + return decode(value); + } catch { + return value; + } +} + +function filePathForPath(path: string, host: PrerenderHost): string { + return host === 'node' + ? tryDecode(path, decodeURI) + : path + .split('/') + .map((segment) => tryDecode(segment, decodeURIComponent)) + .join('/'); +} + +export function outputFileForPath(path: string, host: PrerenderHost = 'cloudflare'): string { + return path === '/' ? 'index.html' : `${filePathForPath(path, host).slice(1)}.html`; } export async function readPrerenderPathsResponse(response: Response): Promise> { if (!response.ok) { - throw new Error(`prerender: Failed to collect prerender paths. HTTP ${response.status}.`); + // The app returns getPrerenderPaths() errors as { error }. + const body = (await response.json().catch(() => null)) as { error?: unknown } | null; + const detail = typeof body?.error === 'string' ? body.error : `HTTP ${response.status}.`; + throw new Error(`prerender: Failed to collect prerender paths. ${detail}`); } const payload = (await response.json()) as { paths?: unknown } | null; @@ -38,8 +67,10 @@ export async function readPrerenderPathsResponse(response: Response): Promise { - return [outputFileForPath(path), pageDataFileForPath(path)].map((file) => +function generatedFiles(path: string, clientDir: string, host: PrerenderHost): Array { + return [outputFileForPath(path, host), pageDataFileForPath(path, host)].map((file) => normalizedFile(clientDir, file), ); } @@ -89,8 +120,12 @@ function requestAssetCandidates(path: string, clientDir: string): Set { return new Set([...candidates].map((file) => normalizedFile(clientDir, file))); } -function renderConcurrency(paths: ReadonlyArray, clientDir: string): number { - const outputs = paths.map((path) => generatedFiles(path, clientDir)); +function renderConcurrency( + paths: ReadonlyArray, + clientDir: string, + host: PrerenderHost, +): number { + const outputs = paths.map((path) => generatedFiles(path, clientDir, host)); const ownerByOutput = new Map(); for (let index = 0; index < outputs.length; index++) { for (const output of outputs[index]!) { @@ -132,20 +167,21 @@ export async function renderPrerenderPaths( fetchPath: (path: string) => Promise, fetchPageData?: (path: string) => Promise, onResult?: (path: string, outputFile: string, error?: string) => void, + host: PrerenderHost = 'cloudflare', ): Promise { const results = paths.map((): PrerenderResult => ({ rendered: [], failed: [] })); let next = 0; await Promise.all( Array.from( - { length: Math.min(paths.length, renderConcurrency(paths, clientDir)) }, + { length: Math.min(paths.length, renderConcurrency(paths, clientDir, host)) }, async () => { while (next < paths.length) { const index = next++; const path = paths[index]!; const result = results[index]!; - const outputFile = outputFileForPath(path); + const outputFile = outputFileForPath(path, host); const outputPath = join(clientDir, outputFile); - const pageDataPath = join(clientDir, pageDataFileForPath(path)); + const pageDataPath = join(clientDir, pageDataFileForPath(path, host)); // Paths come from getPrerenderPaths and may include unencoded user data // such as '../', which must not write files outside the output directory. if ( diff --git a/packages/void/src/prerender.ts b/packages/void/src/prerender.ts index a0894d029..080e745b4 100644 --- a/packages/void/src/prerender.ts +++ b/packages/void/src/prerender.ts @@ -417,6 +417,8 @@ async function renderPaths(paths) { (path) => fetch(new Request("http://localhost" + path, { headers: { "Accept": "application/json", "Purpose": "prefetch", "X-VoidPages": "true" }, })), + undefined, + "node", ); } @@ -497,7 +499,7 @@ export async function prerenderPagesNode(options: { ensureStatic404(options.clientDir); for (const path of result.rendered) { - voidLog(`prerender ${pc.blue(outputFileForPath(path))}`); + voidLog(`prerender ${pc.blue(outputFileForPath(path, 'node'))}`); } for (const { path, error } of result.failed) { voidWarn(`prerender ${path} failed: ${error}`); diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 611786510..60561414e 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -191,7 +191,7 @@ export function compileRouteTable( lines.push(`import { AsyncLocalStorage } from "node:async_hooks";`); lines.push(`import { Hono } from ${JSON.stringify(options.honoPath)};`); lines.push( - `import { createRoutingRuntime as __createRoutingRuntime, createHttpRouteHandler as __createHttpRouteHandler, createErrorRenderer as __createErrorRenderer, createPageGetHandler as __createPageGetHandler, createPageMutationHandler as __createPageMutationHandler, createNamedActionMarker as __createNamedActionMarker, markRouteHandler as __markRouteHandler, createPagesFallback as __createPagesFallback, createModuleLoader as __createModuleLoader, createClientAssetTags as __createClientAssetTags, createManifestLoader as __createManifestLoader, createSsrFallback as __createSsrFallback, createAssetNotFound as __createAssetNotFound } from ${JSON.stringify(ROUTING_RUNTIME_PATH)};`, + `import { createRoutingRuntime as __createRoutingRuntime, createHttpRouteHandler as __createHttpRouteHandler, createErrorRenderer as __createErrorRenderer, createPageGetHandler as __createPageGetHandler, createPageMutationHandler as __createPageMutationHandler, createNamedActionMarker as __createNamedActionMarker, markRouteHandler as __markRouteHandler, createPagesFallback as __createPagesFallback, createModuleLoader as __createModuleLoader, createClientAssetTags as __createClientAssetTags, createManifestLoader as __createManifestLoader, createSsrFallback as __createSsrFallback, createAssetNotFound as __createAssetNotFound, buildPrerenderPath as __buildPrerenderPath } from ${JSON.stringify(ROUTING_RUNTIME_PATH)};`, ); if (options.nodeTarget) { lines.push(`const withRuntimeEnv = (_env, fn) => fn();`); @@ -451,7 +451,7 @@ export function compileRouteTable( ); } - compilePrerenderCollector(lines, scan, ssr, options, pageNames); + compilePrerenderCollector(lines, scan, ssr, options, pageNames, pages?.pagesDirRelative); if (ssr) { lines.push( @@ -852,6 +852,7 @@ function compilePrerenderCollector( ssr: SsrCompileOptions | null, options: CompileOptions, pageNames: (componentId: string) => string, + pagesDirRelative: string | undefined, ): void { const staticPaths = scan.pages.pages .filter((page) => page.shouldPrerender && page.params.length === 0 && !page.catchAll) @@ -864,16 +865,6 @@ function compilePrerenderCollector( ); const explicitPaths = options.explicitPrerenderPaths ?? []; - lines.push(`function __voidBuildPrerenderPath(pattern, params) {`); - lines.push(` let path = pattern;`); - lines.push(` for (const [key, value] of Object.entries(params)) {`); - lines.push(` path = path.replace(":" + key + "{.+}", value);`); - lines.push(` path = path.replace(":" + key, value);`); - lines.push(` }`); - lines.push(` return path;`); - lines.push(`}`); - lines.push(''); - lines.push(`export async function __voidCollectPrerenderPaths(request, env, ctx) {`); lines.push(` return withRuntimeEnv(env, async () => {`); if (options.nodeTarget) { @@ -894,9 +885,13 @@ function compilePrerenderCollector( lines.push(` if (typeof mod.getPrerenderPaths === "function") {`); lines.push(` const paramSets = await mod.getPrerenderPaths();`); lines.push(` if (Array.isArray(paramSets)) {`); - lines.push(` for (const params of paramSets) {`); + // Name the file that defines getPrerenderPaths() in build errors. + const file = [pagesDirRelative, page.serverPath ?? page.componentPath] + .filter(Boolean) + .join('/'); + lines.push(` for (let index = 0; index < paramSets.length; index++) {`); lines.push( - ` paths.push(__voidBuildPrerenderPath(${JSON.stringify(page.pattern)}, params));`, + ` paths.push(__buildPrerenderPath(${JSON.stringify(page.pattern)}, paramSets[index], ${JSON.stringify(file)}, index));`, ); lines.push(` }`); lines.push(` }`); @@ -1414,6 +1409,7 @@ export function compileNodeApp( import { fileURLToPath } from "node:url"; import { Hono } from ${JSON.stringify(honoPath)}; import innerApp, { __voidCollectPrerenderPaths } from "virtual:void-routes"; +import { prerenderPathsResponse as __prerenderPathsResponse } from ${JSON.stringify(ROUTING_RUNTIME_PATH)}; ${envSchemaImport} // Resolved by nodeTargetPlugin for this emitted chunk, including shared chunks. const clientRoot = fileURLToPath(import.meta.voidClientDirectory); @@ -1423,8 +1419,7 @@ const assets = { fetch: (request) => assetApp.fetch(request) }; const app = new Hono(); ${envMiddleware}app.post("/__void/prerender-paths", async (c) => { if (c.env?.__VOID_PRERENDER !== "1") return c.notFound(); - const paths = await __voidCollectPrerenderPaths(c.req.raw); - return c.json({ paths }); + return __prerenderPathsResponse(() => __voidCollectPrerenderPaths(c.req.raw)); }); app.get("/assets/*", (c) => assets.fetch(c.req.raw)); app.all("*", (c) => innerApp.fetch(c.req.raw, { ...c.env, ASSETS: assets })); diff --git a/packages/void/src/runtime/prerender-path.ts b/packages/void/src/runtime/prerender-path.ts new file mode 100644 index 000000000..00b079e84 --- /dev/null +++ b/packages/void/src/runtime/prerender-path.ts @@ -0,0 +1,67 @@ +import { interpolateRouteParams } from './route-url.ts'; + +// Same complete-segment tokens as `interpolateRouteParams`: `:name`, `:name{.+}` and `*`. +const ROUTE_TOKEN = /(?:^|\/)(?::([^/{}]+)(\{\.\+\})?|\*)(?=\/|$)/g; + +function describeValue(value: unknown): string { + if (value === null) { + return 'null'; + } + if (Array.isArray(value)) { + return 'an array'; + } + if (value === undefined) { + return 'undefined'; + } + return typeof value === 'object' ? 'an object' : `a ${typeof value}`; +} + +/** Validate one `getPrerenderPaths()` entry and convert the values its pattern uses to strings. */ +function routeParams(pattern: string, params: unknown): Record { + if (typeof params !== 'object' || params === null || Array.isArray(params)) { + throw new TypeError(`Expected an object of route params, got ${describeValue(params)}.`); + } + const values: Record = {}; + for (const [, paramName, catchAll] of pattern.matchAll(ROUTE_TOKEN)) { + const name = paramName ?? '*'; + if (!Object.hasOwn(params, name)) { + // interpolateRouteParams reports the missing param. + continue; + } + const value: unknown = (params as Record)[name]; + if (typeof value !== 'string' && typeof value !== 'number') { + throw new TypeError(`Route param "${name}" must be a string or number.`); + } + const text = String(value); + // A `/` in a one-segment param would build a URL this page does not match. + // routeUrl() encodes it as %2F, but static hosts decode %2F before the + // file lookup, so the prerendered file would never be served. + if (paramName !== undefined && !catchAll && text.includes('/')) { + throw new TypeError( + `Route param "${name}" cannot contain "/". Use a catch-all page ([...${name}]) for nested paths.`, + ); + } + values[name] = text; + } + return values; +} + +/** + * Build the URL for one entry a page's `getPrerenderPaths()` returned. Values + * are percent-encoded like `routeUrl()`. Errors name the server file and the + * entry index, and fail the build: a wrong entry must not silently produce a + * missing or misplaced page. + */ +export function buildPrerenderPath( + pattern: string, + params: unknown, + file: string, + index: number, +): string { + try { + return interpolateRouteParams(pattern, routeParams(pattern, params), true); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new TypeError(`${file} getPrerenderPaths()[${index}]: ${message}`); + } +} diff --git a/packages/void/src/runtime/routing-worker.ts b/packages/void/src/runtime/routing-worker.ts index d75cf3414..66cc4fd82 100644 --- a/packages/void/src/runtime/routing-worker.ts +++ b/packages/void/src/runtime/routing-worker.ts @@ -203,6 +203,23 @@ export type WorkerFetchOptions = { ) => Promise; }; }; +/** + * Answer the prerender runner. A getPrerenderPaths() error is returned in the + * body so the build can show which page and entry to fix, not only HTTP 500. + */ +export async function prerenderPathsResponse( + collect: () => Promise>, +): Promise { + try { + return Response.json({ paths: await collect() }); + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : String(error) }, + { status: 500 }, + ); + } +} + export function createWorkerFetch( options: WorkerFetchOptions, ): (request: Request, env: RuntimeEnvironment, ctx: ExecutionContext) => Promise { @@ -246,7 +263,7 @@ export function createWorkerFetch( return internal; } if (discovery) { - return Response.json({ paths: await options.collectPrerenderPaths(request, env, ctx) }); + return prerenderPathsResponse(() => options.collectPrerenderPaths(request, env, ctx)); } // After the Void endpoints that tools and the platform call above, and // before ISR, so a cached page is never served over HTTP. The diff --git a/packages/void/src/runtime/routing.ts b/packages/void/src/runtime/routing.ts index 27ef66a5e..9f2f1d231 100644 --- a/packages/void/src/runtime/routing.ts +++ b/packages/void/src/runtime/routing.ts @@ -385,6 +385,8 @@ export { createAssetFallback, createWorkerFetch, filterInternalBindings, + prerenderPathsResponse, } from './routing-worker.ts'; export { createClientAssetTags, createManifestLoader } from './routing-assets.ts'; export { createSsrFallback, createAssetNotFound } from './routing-pages.ts'; +export { buildPrerenderPath } from './prerender-path.ts'; diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index 5d56eb189..dacdf62d8 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -1102,6 +1102,10 @@ describe('compileNodeApp', () => { expect(code).toContain('app.post("/__void/prerender-paths"'); // Only the prerender runner passes the marker as the Hono env. expect(code).toContain('if (c.env?.__VOID_PRERENDER !== "1") return c.notFound();'); + // getPrerenderPaths() errors reach the build in the response body. + expect(code).toContain( + 'return __prerenderPathsResponse(() => __voidCollectPrerenderPaths(c.req.raw));', + ); expect(code).not.toContain('__VOID_PROXY_TOKEN'); expect(code).not.toContain('x-void-internal'); }); @@ -1455,3 +1459,47 @@ describe('typed runtime metadata wiring', () => { expect(code).not.toContain('async function serveWithAssets'); }); }); + +describe('compileRouteTable — prerender path collector', () => { + it('builds dynamic page paths with the route-param helper, naming the server file', () => { + const scan: ScanResult = { + ...emptyScan, + pages: { + layouts: [], + namedLayouts: [], + pages: [ + { + pattern: '/blog/:slug', + params: ['slug'], + catchAll: false, + componentPath: 'blog/[slug].vue', + componentId: 'blog/[slug]', + serverPath: 'blog/[slug].server.ts', + methods: [], + ssrMode: 'server', + prerenderMode: 'force', + renderMode: 'server', + shouldPrerender: true, + hasGetPrerenderPaths: true, + }, + ], + }, + }; + const code = compileRouteTable( + scan, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + testOptions, + { pagesDir: '/pages', pagesDirRelative: 'pages', clientEntryId: 'virtual:void-pages-client' }, + ); + + expect(code).toMatch(/buildPrerenderPath as __buildPrerenderPath[,\s][^;]*from "[^"]*routing/); + expect(code).toContain( + 'paths.push(__buildPrerenderPath("/blog/:slug", paramSets[index], "pages/blog/[slug].server.ts", index));', + ); + expect(code).not.toContain('__voidBuildPrerenderPath'); + }); +}); diff --git a/packages/void/test/unit/prerender-path.test.ts b/packages/void/test/unit/prerender-path.test.ts new file mode 100644 index 000000000..088bde445 --- /dev/null +++ b/packages/void/test/unit/prerender-path.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { buildPrerenderPath } from '../../src/runtime/prerender-path.ts'; + +const FILE = 'pages/blog/[slug].server.ts'; + +describe('buildPrerenderPath', () => { + it.each([ + ['/blog/:slug', { slug: 'hello' }, '/blog/hello'], + // A param name that is a prefix of another fills only its own segment. + ['/u/:idx/:id', { id: '1', idx: '2' }, '/u/2/1'], + ['/blog/:slug', { slug: 'a b?c#d' }, '/blog/a%20b%3Fc%23d'], + ['/blog/:slug', { slug: 'café' }, '/blog/caf%C3%A9'], + ['/posts/:id', { id: 42 }, '/posts/42'], + // Keys the pattern does not use are ignored. + ['/blog/:slug', { slug: 'hello', title: 'Hello' }, '/blog/hello'], + ['/docs/:slug{.+}', { slug: 'guides/a b' }, '/docs/guides/a%20b'], + ['/files/*', { '*': 'a/b' }, '/files/a/b'], + ])('builds %s from %j', (pattern, params, expected) => { + expect(buildPrerenderPath(pattern, params, FILE, 0)).toBe(expected); + }); + + it.each([ + ['/blog/:slug', { slg: 'hello' }, 'Missing route param "slug".'], + ['/blog/:slug', { slug: '' }, 'Route param "slug" must not be empty.'], + ['/blog/:slug', { slug: '..' }, 'Route param "slug" cannot be "..";'], + ['/docs/:slug{.+}', { slug: 'a/../b' }, 'Route param "slug" cannot be "..";'], + [ + '/blog/:slug', + { slug: 'x/y' }, + 'Route param "slug" cannot contain "/". Use a catch-all page ([...slug]) for nested paths.', + ], + ['/blog/:slug', { slug: true }, 'Route param "slug" must be a string or number.'], + ['/docs/:slug{.+}', { slug: ['a', 'b'] }, 'Route param "slug" must be a string or number.'], + ['/blog/:slug', 'hello', 'Expected an object of route params, got a string.'], + ['/blog/:slug', null, 'Expected an object of route params, got null.'], + ['/blog/:slug', ['hello'], 'Expected an object of route params, got an array.'], + ])('rejects %s with %j', (pattern, params, message) => { + expect(() => buildPrerenderPath(pattern, params, FILE, 2)).toThrow( + `${FILE} getPrerenderPaths()[2]: ${message}`, + ); + }); +}); diff --git a/packages/void/test/unit/prerender-render.test.ts b/packages/void/test/unit/prerender-render.test.ts index e07a14bd1..0cbf45b5d 100644 --- a/packages/void/test/unit/prerender-render.test.ts +++ b/packages/void/test/unit/prerender-render.test.ts @@ -3,7 +3,11 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vite-plus/test'; import { serializePageData } from '../../src/pages/serialize.ts'; -import { assertNoPrerenderFailures, renderPrerenderPaths } from '../../src/prerender-render.ts'; +import { + assertNoPrerenderFailures, + readPrerenderPathsResponse, + renderPrerenderPaths, +} from '../../src/prerender-render.ts'; import { createAssetFallback } from '../../src/runtime/routing-worker.ts'; const roots: Array = []; @@ -192,9 +196,40 @@ describe('prerender pool', () => { }, ); expect(result).toEqual({ rendered: [generatedPath, fallbackPath], failed: [] }); - expect(readFileSync(join(root, `${fallbackPath.slice(1)}.html`), 'utf8')).toBe( - `generated by ${generatedPath}`, + // Files are written at the decoded path the asset host looks up. + const fallbackFile = `${decodeURIComponent(fallbackPath.slice(1))}.html`; + expect(readFileSync(join(root, fallbackFile), 'utf8')).toBe(`generated by ${generatedPath}`); + }, + ); + + it.each([ + // Cloudflare assets decode every escape in a segment before the file lookup. + ['cloudflare', 't/a#b?c'], + // Hono serveStatic (Node, Bun, Deno) uses decodeURI, which keeps reserved characters encoded. + ['node', 't/a%23b%3Fc'], + ] as const)( + 'writes percent-encoded paths to the files %s static hosting reads', + async (host, reservedFile) => { + const clientDir = fixture(); + const result = await renderPrerenderPaths( + ['/router-url/a%20b', '/t/a%23b%3Fc', '/caf%C3%A9'], + clientDir, + async (path) => new Response(pageHtml({ path })), + undefined, + undefined, + host, ); + expect(result.failed).toEqual([]); + for (const [path, file] of [ + ['/router-url/a%20b', 'router-url/a b'], + ['/t/a%23b%3Fc', reservedFile], + ['/caf%C3%A9', 'café'], + ]) { + expect(readFileSync(join(clientDir, `${file}.html`), 'utf8')).toBe(pageHtml({ path })); + expect( + JSON.parse(readFileSync(join(clientDir, `_void/pages/${file}.json`), 'utf8')), + ).toEqual({ path }); + } }, ); @@ -205,7 +240,7 @@ describe('prerender pool', () => { const dataRequests: Array = []; const outputs: Array<[string, string, string | undefined]> = []; const result = await renderPrerenderPaths( - ['/blog/../../escape', '/ok', '/../client/data-escape'], + ['/blog/../../escape', '/ok', '/../client/data-escape', '/%2E%2E/%2E%2E/encoded-escape'], clientDir, async (path) => { fetched.push(path); @@ -222,6 +257,8 @@ describe('prerender pool', () => { expect(result.failed).toEqual([ { path: '/blog/../../escape', error }, { path: '/../client/data-escape', error }, + // Decoding the file name must not let an encoded `..` escape either. + { path: '/%2E%2E/%2E%2E/encoded-escape', error }, ]); expect(fetched).toEqual(['/ok']); expect(dataRequests).toEqual([]); @@ -237,6 +274,24 @@ describe('prerender pool', () => { }); }); +describe('readPrerenderPathsResponse', () => { + it('includes the error the app returned for path collection', async () => { + await expect( + readPrerenderPathsResponse( + Response.json({ error: 'pages/a.server.ts getPrerenderPaths()[0]: bad' }, { status: 500 }), + ), + ).rejects.toThrow( + 'prerender: Failed to collect prerender paths. pages/a.server.ts getPrerenderPaths()[0]: bad', + ); + }); + + it('reports the HTTP status when the response has no error message', async () => { + await expect( + readPrerenderPathsResponse(new Response('Internal Server Error', { status: 500 })), + ).rejects.toThrow('prerender: Failed to collect prerender paths. HTTP 500.'); + }); +}); + describe('assertNoPrerenderFailures', () => { it('does nothing when there are no failures', () => { expect(() => assertNoPrerenderFailures([])).not.toThrow(); diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index b6e9fd782..abb31b912 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -273,6 +273,22 @@ describe('typed Worker fetch execution', () => { expect(await runner.json()).toEqual({ paths: ['/page'] }); expect(validateEnv).toHaveBeenCalledTimes(4); }); + it('returns a getPrerenderPaths() error to the prerender runner', async () => { + const fetch = worker({ + collectPrerenderPaths: async () => { + throw new TypeError('pages/a.server.ts getPrerenderPaths()[0]: bad'); + }, + }); + const response = await fetch( + new Request('https://app.test/__void/prerender-paths', { method: 'POST' }), + { __VOID_PRERENDER: '1' }, + context, + ); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + error: 'pages/a.server.ts getPrerenderPaths()[0]: bad', + }); + }); it('resolves remote env after validating original bindings and enters scopes before internal routes', async () => { const original = { __VOID_REMOTE: '1' }; const resolved = { PUBLIC: 'remote' }; From c547ce128ed3f331610d95768cc0028fcc1b1934 Mon Sep 17 00:00:00 2001 From: Christoph Nakazawa Date: Fri, 6 Nov 2026 15:10:20 +0900 Subject: [PATCH 2/2] Fix encoded prerender assets and special parameter names. --- packages/void/src/router/compile.ts | 2 +- packages/void/src/runtime/prerender-path.ts | 2 +- .../void/test/integration/node-target.test.ts | 43 +++++++++++++++++++ .../void/test/unit/prerender-path.test.ts | 1 + 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 60561414e..fec8be3e6 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -1414,7 +1414,7 @@ ${envSchemaImport} // Resolved by nodeTargetPlugin for this emitted chunk, including shared chunks. const clientRoot = fileURLToPath(import.meta.voidClientDirectory); const assetApp = new Hono(); -assetApp.get("*", serveStatic({ root: clientRoot })); +assetApp.get("*", serveStatic({ root: clientRoot, allowPercentInPath: true })); const assets = { fetch: (request) => assetApp.fetch(request) }; const app = new Hono(); ${envMiddleware}app.post("/__void/prerender-paths", async (c) => { diff --git a/packages/void/src/runtime/prerender-path.ts b/packages/void/src/runtime/prerender-path.ts index 00b079e84..5a41df468 100644 --- a/packages/void/src/runtime/prerender-path.ts +++ b/packages/void/src/runtime/prerender-path.ts @@ -21,7 +21,7 @@ function routeParams(pattern: string, params: unknown): Record { if (typeof params !== 'object' || params === null || Array.isArray(params)) { throw new TypeError(`Expected an object of route params, got ${describeValue(params)}.`); } - const values: Record = {}; + const values: Record = Object.create(null); for (const [, paramName, catchAll] of pattern.matchAll(ROUTE_TOKEN)) { const name = paramName ?? '*'; if (!Object.hasOwn(params, name)) { diff --git a/packages/void/test/integration/node-target.test.ts b/packages/void/test/integration/node-target.test.ts index 3a257bdb2..b917e2fe0 100644 --- a/packages/void/test/integration/node-target.test.ts +++ b/packages/void/test/integration/node-target.test.ts @@ -8,6 +8,8 @@ import { startServer, type IsolatedPlayground, } from '../../../../test/integration-helpers.ts'; +import { renderPrerenderPaths } from '../../src/prerender-render.ts'; +import { buildPrerenderPath } from '../../src/runtime/prerender-path.ts'; const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); let DEV_BASE: string; @@ -129,4 +131,45 @@ describe('playground/node-app build + production', () => { expect(html).toContain('Welcome'); expect(html).toContain('Home | Node App'); }); + + it.each(['plain', 'a b?c#d', 'a%b', '%20', 'café'])( + 'serves prerendered HTML and page data for parameter %j', + async (slug) => { + const path = buildPrerenderPath( + '/assets/prerender/:slug', + { slug }, + 'pages/[slug].server.ts', + 0, + ); + const data = { component: 'prerender', props: { slug }, url: path }; + const html = ``; + const result = await renderPrerenderPaths( + [path], + join(playground.root, 'dist/client'), + async () => new Response(html), + undefined, + undefined, + 'node', + ); + expect(result).toEqual({ rendered: [path], failed: [] }); + + const document = await fetch(`${PROD_BASE}${path}.html`); + expect(document.status).toBe(200); + expect(await document.text()).toBe(html); + + const pageData = await fetch(`${PROD_BASE}/_void/pages${path}.json`); + expect(pageData.status).toBe(200); + expect(await pageData.json()).toEqual(data); + }, + ); + + it.each([ + '/assets/%252e%252e/%252e%252e/%252e%252e/package.json', + '/assets/..%2F..%2F..%2Fpackage.json', + '/assets/%5C..%5C..%5C..%5Cpackage.json', + ])('does not serve files outside the client directory via %s', async (path) => { + const response = await fetch(`${PROD_BASE}${path}`); + expect(response.status).toBe(404); + await response.text(); + }); }); diff --git a/packages/void/test/unit/prerender-path.test.ts b/packages/void/test/unit/prerender-path.test.ts index 088bde445..7aa055941 100644 --- a/packages/void/test/unit/prerender-path.test.ts +++ b/packages/void/test/unit/prerender-path.test.ts @@ -11,6 +11,7 @@ describe('buildPrerenderPath', () => { ['/blog/:slug', { slug: 'a b?c#d' }, '/blog/a%20b%3Fc%23d'], ['/blog/:slug', { slug: 'café' }, '/blog/caf%C3%A9'], ['/posts/:id', { id: 42 }, '/posts/42'], + ['/blog/:__proto__', { ['__proto__']: 'hello' }, '/blog/hello'], // Keys the pattern does not use are ignored. ['/blog/:slug', { slug: 'hello', title: 'Hello' }, '/blog/hello'], ['/docs/:slug{.+}', { slug: 'guides/a b' }, '/docs/guides/a%20b'],