From be98647649d8fc019ff4623da8c609befb43a219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 9 Apr 2026 13:28:53 +0200 Subject: [PATCH] feat: support build-time prerendering of routes Add `prerender` option to `RouteConfig` that generates static HTML at build time. Routes marked with `prerender: true` are rendered during the build step and served as static files at runtime, bypassing the route handler entirely. For dynamic routes, `prerender` accepts a function that returns an array of param objects to enumerate which paths to generate. Closes #3555 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fresh/src/dev/builder.ts | 19 +++ packages/fresh/src/dev/dev_build_cache.ts | 12 +- packages/fresh/src/dev/fs_crawl.ts | 2 +- packages/fresh/src/dev/prerender.ts | 98 ++++++++++++ packages/fresh/src/dev/prerender_test.ts | 139 ++++++++++++++++++ .../fresh/src/middlewares/static_files.ts | 2 +- packages/fresh/src/types.ts | 23 +++ 7 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 packages/fresh/src/dev/prerender.ts create mode 100644 packages/fresh/src/dev/prerender_test.ts diff --git a/packages/fresh/src/dev/builder.ts b/packages/fresh/src/dev/builder.ts index 69b3711583c..481adfc2d33 100644 --- a/packages/fresh/src/dev/builder.ts +++ b/packages/fresh/src/dev/builder.ts @@ -268,6 +268,25 @@ export class Builder { await this.#build(buildCache, this.config.mode === "development"); await buildCache.prepare(); + // Prerender marked routes + if ( + this.config.mode === "production" && + "loadedFsRoutes" in buildCache + ) { + const { prerenderRoutes } = await import("./prerender.ts"); + const tempApp = new App().fsRoutes(); + setBuildCache(tempApp, buildCache, "production"); + const count = await prerenderRoutes( + tempApp, + buildCache, + buildCache.loadedFsRoutes, + ); + if (count > 0) { + // deno-lint-ignore no-console + console.log(`Prerendered ${count} page(s)`); + } + } + return (app) => { setBuildCache(app, buildCache, app.config.mode); }; diff --git a/packages/fresh/src/dev/dev_build_cache.ts b/packages/fresh/src/dev/dev_build_cache.ts index 220353b28b2..35ff0c0970a 100644 --- a/packages/fresh/src/dev/dev_build_cache.ts +++ b/packages/fresh/src/dev/dev_build_cache.ts @@ -54,6 +54,7 @@ export interface DevBuildCache extends BuildCache { pathname: string, content: Uint8Array, hash: string | null, + contentType?: string, ): Promise; flush(): Promise; prepare(): Promise; @@ -193,11 +194,12 @@ export class MemoryBuildCache implements DevBuildCache { pathname: string, content: Uint8Array, hash: string | null, + contentType?: string, ): Promise { this.#processedFiles.set(pathname, { content, hash, - contentType: getContentType(pathname), + contentType: contentType ?? getContentType(pathname), }); } @@ -221,6 +223,12 @@ export class MemoryBuildCache implements DevBuildCache { ); } + #loadedFiles: FsRouteFile[] = []; + + get loadedFsRoutes(): FsRouteFile[] { + return this.#loadedFiles; + } + async prepare(): Promise { // Load FS routes const files = await Promise.all(this.#fsRoutes.files.map(async (file) => { @@ -230,6 +238,7 @@ export class MemoryBuildCache implements DevBuildCache { mod: file.lazy ? () => import(fileUrl) : await import(fileUrl), }; })); + this.#loadedFiles = files; this.#commands = fsItemsToCommands(files); } } @@ -280,6 +289,7 @@ export class DiskBuildCache implements DevBuildCache { pathname: string, content: Uint8Array, hash: string | null, + _contentType?: string, ) { this.#processedFiles.set(pathname, hash); diff --git a/packages/fresh/src/dev/fs_crawl.ts b/packages/fresh/src/dev/fs_crawl.ts index cc61f6669f2..9b3f2aa8825 100644 --- a/packages/fresh/src/dev/fs_crawl.ts +++ b/packages/fresh/src/dev/fs_crawl.ts @@ -74,7 +74,7 @@ export async function crawlRouteDir( routePattern = pathToPattern(id.slice(1)); const code = await fs.readTextFile(entry.path); - lazy = !code.includes("routeOverride"); + lazy = !code.includes("routeOverride") && !code.includes("prerender"); // TODO: We could do an AST parse here to detect the // kind of handler that's used to get a more accurate diff --git a/packages/fresh/src/dev/prerender.ts b/packages/fresh/src/dev/prerender.ts new file mode 100644 index 00000000000..4c1fae53633 --- /dev/null +++ b/packages/fresh/src/dev/prerender.ts @@ -0,0 +1,98 @@ +import type { App } from "../app.ts"; +import type { DevBuildCache } from "./dev_build_cache.ts"; +import type { FsRouteFile } from "../fs_routes.ts"; +import type { RouteConfig } from "../types.ts"; +import { CommandType } from "../commands.ts"; + +/** + * Prerender routes marked with `config.prerender` and add the resulting + * HTML to the build cache as static files. + */ +export async function prerenderRoutes( + app: App, + buildCache: DevBuildCache, + fsRoutes: FsRouteFile[], +): Promise { + const paths = await collectPrerenderPaths(fsRoutes); + if (paths.length === 0) return 0; + + const handler = app.handler(); + const encoder = new TextEncoder(); + + for (const pathname of paths) { + const url = new URL(pathname, "http://localhost"); + const req = new Request(url); + const res = await handler(req, { + remoteAddr: { hostname: "127.0.0.1", port: 0, transport: "tcp" }, + completed: Promise.resolve(), + }); + + if (res.status !== 200) { + // deno-lint-ignore no-console + console.warn( + `Prerender: ${pathname} returned status ${res.status}, skipping`, + ); + await res.body?.cancel(); + continue; + } + + const html = encoder.encode(await res.text()); + await buildCache.addProcessedFile( + pathname, + html, + null, + "text/html; charset=UTF-8", + ); + } + + return paths.length; +} + +async function collectPrerenderPaths( + fsRoutes: FsRouteFile[], +): Promise { + const paths: string[] = []; + + for (const file of fsRoutes) { + if (file.type !== CommandType.Route) continue; + + const mod = typeof file.mod === "function" ? await file.mod() : file.mod; + const config = mod.config as RouteConfig | undefined; + if (!config?.prerender) continue; + + if (typeof config.prerender === "function") { + const paramsList = await config.prerender(); + for (const params of paramsList) { + paths.push(substituteParams(file.routePattern, params)); + } + } else { + // prerender: true — route must not have dynamic segments + if (file.routePattern.includes(":")) { + // deno-lint-ignore no-console + console.warn( + `Prerender: route "${file.routePattern}" has dynamic segments but ` + + `prerender is set to true (not a function). Skipping. ` + + `Use prerender: () => [...] to enumerate paths.`, + ); + continue; + } + paths.push(file.routePattern); + } + } + + return paths; +} + +function substituteParams( + pattern: string, + params: Record, +): string { + let result = pattern; + for (const [key, value] of Object.entries(params)) { + // Handle catch-all :slug* + result = result.replace(`:${key}*`, value); + // Handle regular :param + result = result.replace(`:${key}`, value); + } + return result; +} diff --git a/packages/fresh/src/dev/prerender_test.ts b/packages/fresh/src/dev/prerender_test.ts new file mode 100644 index 00000000000..e278ca1a15c --- /dev/null +++ b/packages/fresh/src/dev/prerender_test.ts @@ -0,0 +1,139 @@ +import { expect } from "@std/expect"; +import * as path from "@std/path"; +import { Builder } from "./builder.ts"; +import { App } from "../app.ts"; +import { staticFiles } from "../middlewares/static_files.ts"; +import { + FakeServer, + integrationTest, + withTmpDir, + writeFiles, +} from "../test_utils.ts"; + +integrationTest("Prerender - static route", async () => { + const root = path.join(import.meta.dirname!, "..", ".."); + await using _tmp = await withTmpDir({ dir: root, prefix: "tmp_builder_" }); + const tmp = _tmp.dir; + + await writeFiles(tmp, { + "routes/index.tsx": ` + export const config = { prerender: true }; + export default () =>

Home

; + `, + "routes/about.tsx": ` + export const config = { prerender: true }; + export default () =>

About

; + `, + "routes/dynamic.tsx": ` + export default () =>

Dynamic

; + `, + }); + + const builder = new Builder({ + root: tmp, + outDir: path.join(tmp, "_fresh"), + }); + + const applyBuildCache = await builder.build({ + mode: "production", + snapshot: "memory", + }); + + const app = new App().use(staticFiles()).fsRoutes(); + applyBuildCache(app); + const server = new FakeServer(app.handler()); + + // Prerendered routes should be served as static files + const homeRes = await server.get("/"); + const homeText = await homeRes.text(); + expect(homeRes.status).toEqual(200); + expect(homeText).toContain("

Home

"); + + const aboutRes = await server.get("/about"); + const aboutText = await aboutRes.text(); + expect(aboutRes.status).toEqual(200); + expect(aboutText).toContain("

About

"); + + // Non-prerendered route should NOT be a static file + // (it falls through to the route handler) + const dynamicRes = await server.get("/dynamic"); + const dynamicText = await dynamicRes.text(); + expect(dynamicRes.status).toEqual(200); + expect(dynamicText).toContain("

Dynamic

"); +}); + +integrationTest("Prerender - dynamic route with path function", async () => { + const root = path.join(import.meta.dirname!, "..", ".."); + await using _tmp = await withTmpDir({ dir: root, prefix: "tmp_builder_" }); + const tmp = _tmp.dir; + + await writeFiles(tmp, { + "routes/blog/[slug].tsx": ` + export const config = { + prerender: () => [{ slug: "hello" }, { slug: "world" }], + }; + export default (ctx) =>

{ctx.params.slug}

; + `, + }); + + const builder = new Builder({ + root: tmp, + outDir: path.join(tmp, "_fresh"), + }); + + const applyBuildCache = await builder.build({ + mode: "production", + snapshot: "memory", + }); + + const app = new App().use(staticFiles()).fsRoutes(); + applyBuildCache(app); + const server = new FakeServer(app.handler()); + + const helloRes = await server.get("/blog/hello"); + expect(helloRes.status).toEqual(200); + expect(await helloRes.text()).toContain("

hello

"); + + const worldRes = await server.get("/blog/world"); + expect(worldRes.status).toEqual(200); + expect(await worldRes.text()).toContain("

world

"); +}); + +integrationTest( + "Prerender - skips dynamic route with prerender: true", + async () => { + const root = path.join(import.meta.dirname!, "..", ".."); + await using _tmp = await withTmpDir({ + dir: root, + prefix: "tmp_builder_", + }); + const tmp = _tmp.dir; + + await writeFiles(tmp, { + "routes/[id].tsx": ` + export const config = { prerender: true }; + export default (ctx) =>

{ctx.params.id}

; + `, + }); + + const builder = new Builder({ + root: tmp, + outDir: path.join(tmp, "_fresh"), + }); + + // Should not throw, just warn and skip + const applyBuildCache = await builder.build({ + mode: "production", + snapshot: "memory", + }); + + const app = new App().use(staticFiles()).fsRoutes(); + applyBuildCache(app); + const server = new FakeServer(app.handler()); + + // Dynamic route falls through to handler since it wasn't prerendered + const res = await server.get("/test"); + expect(res.status).toEqual(200); + expect(await res.text()).toContain("

test

"); + }, +); diff --git a/packages/fresh/src/middlewares/static_files.ts b/packages/fresh/src/middlewares/static_files.ts index fce149aa154..d25c666f68f 100644 --- a/packages/fresh/src/middlewares/static_files.ts +++ b/packages/fresh/src/middlewares/static_files.ts @@ -28,7 +28,7 @@ export function staticFiles(): Middleware { // Fast path bail out const startTime = performance.now() + performance.timeOrigin; const file = await buildCache.readFile(pathname); - if (pathname === "/" || file === null) { + if (file === null) { // Optimization: Prevent long responses for favicon.ico requests if (pathname === "/favicon.ico") { return new Response(null, { status: 404 }); diff --git a/packages/fresh/src/types.ts b/packages/fresh/src/types.ts index e3a4d0bac65..6314ae0584a 100644 --- a/packages/fresh/src/types.ts +++ b/packages/fresh/src/types.ts @@ -36,6 +36,29 @@ export interface RouteConfig { * Default: `ALL` */ methods?: "ALL" | Method[]; + + /** + * Prerender this route at build time. The generated HTML is served as a + * static file at runtime, bypassing the route handler entirely. + * + * - `true` — prerender a static route (no dynamic segments). + * - A function — for dynamic routes like `[slug]`, return an array of + * param objects describing which paths to prerender. + * + * @example + * // Static route + * export const config: RouteConfig = { prerender: true }; + * + * // Dynamic route + * export const config: RouteConfig = { + * prerender: () => [{ slug: "about" }, { slug: "faq" }], + * }; + */ + prerender?: + | boolean + | (() => + | Array> + | Promise>>); } export interface LayoutConfig {