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..fec8be3e6 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,17 +1409,17 @@ 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); 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) => { 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..5a41df468 --- /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 = Object.create(null); + 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/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/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..7aa055941 --- /dev/null +++ b/packages/void/test/unit/prerender-path.test.ts @@ -0,0 +1,43 @@ +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'], + ['/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'], + ['/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' };