Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/edge/prerendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/ssg.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
60 changes: 48 additions & 12 deletions packages/void/src/prerender-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<string>> {
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;
Expand All @@ -38,8 +67,10 @@ export async function readPrerenderPathsResponse(response: Response): Promise<Ar
return payload.paths;
}

function pageDataFileForPath(path: string): string {
return path === '/' ? '_void/pages/index.json' : `_void/pages/${path.slice(1)}.json`;
function pageDataFileForPath(path: string, host: PrerenderHost): string {
return path === '/'
? '_void/pages/index.json'
: `_void/pages/${filePathForPath(path, host).slice(1)}.json`;
}

/** Whether `path` resolves strictly inside `directory`. */
Expand All @@ -52,8 +83,8 @@ function normalizedFile(clientDir: string, path: string): string {
return join(clientDir, path).normalize('NFC').toLowerCase();
}

function generatedFiles(path: string, clientDir: string): Array<string> {
return [outputFileForPath(path), pageDataFileForPath(path)].map((file) =>
function generatedFiles(path: string, clientDir: string, host: PrerenderHost): Array<string> {
return [outputFileForPath(path, host), pageDataFileForPath(path, host)].map((file) =>
normalizedFile(clientDir, file),
);
}
Expand Down Expand Up @@ -89,8 +120,12 @@ function requestAssetCandidates(path: string, clientDir: string): Set<string> {
return new Set([...candidates].map((file) => normalizedFile(clientDir, file)));
}

function renderConcurrency(paths: ReadonlyArray<string>, clientDir: string): number {
const outputs = paths.map((path) => generatedFiles(path, clientDir));
function renderConcurrency(
paths: ReadonlyArray<string>,
clientDir: string,
host: PrerenderHost,
): number {
const outputs = paths.map((path) => generatedFiles(path, clientDir, host));
const ownerByOutput = new Map<string, number>();
for (let index = 0; index < outputs.length; index++) {
for (const output of outputs[index]!) {
Expand Down Expand Up @@ -132,20 +167,21 @@ export async function renderPrerenderPaths(
fetchPath: (path: string) => Promise<Response>,
fetchPageData?: (path: string) => Promise<Response>,
onResult?: (path: string, outputFile: string, error?: string) => void,
host: PrerenderHost = 'cloudflare',
): Promise<PrerenderResult> {
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 (
Expand Down
4 changes: 3 additions & 1 deletion packages/void/src/prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
}

Expand Down Expand Up @@ -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}`);
Expand Down
29 changes: 12 additions & 17 deletions packages/void/src/router/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();`);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand All @@ -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(` }`);
Expand Down Expand Up @@ -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 }));
Expand Down
67 changes: 67 additions & 0 deletions packages/void/src/runtime/prerender-path.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
if (typeof params !== 'object' || params === null || Array.isArray(params)) {
throw new TypeError(`Expected an object of route params, got ${describeValue(params)}.`);
}
const values: Record<string, string> = 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<string, unknown>)[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}`);
}
}
19 changes: 18 additions & 1 deletion packages/void/src/runtime/routing-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,23 @@ export type WorkerFetchOptions = {
) => Promise<Response | null>;
};
};
/**
* 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<Array<string>>,
): Promise<Response> {
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<Response> {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/void/src/runtime/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
43 changes: 43 additions & 0 deletions packages/void/test/integration/node-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -129,4 +131,45 @@ describe('playground/node-app build + production', () => {
expect(html).toContain('Welcome');
expect(html).toContain('<title>Home | Node App</title>');
});

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 = `<script id="__VOID_PAGE_DATA__" type="application/json">${JSON.stringify(data)}</script>`;
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();
});
});
Loading
Loading