Skip to content
Open
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
19 changes: 19 additions & 0 deletions packages/fresh/src/dev/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,25 @@ export class Builder<State = any> {
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<State>().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);
};
Expand Down
12 changes: 11 additions & 1 deletion packages/fresh/src/dev/dev_build_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface DevBuildCache<State> extends BuildCache<State> {
pathname: string,
content: Uint8Array,
hash: string | null,
contentType?: string,
): Promise<void>;
flush(): Promise<void>;
prepare(): Promise<void>;
Expand Down Expand Up @@ -193,11 +194,12 @@ export class MemoryBuildCache<State> implements DevBuildCache<State> {
pathname: string,
content: Uint8Array,
hash: string | null,
contentType?: string,
): Promise<void> {
this.#processedFiles.set(pathname, {
content,
hash,
contentType: getContentType(pathname),
contentType: contentType ?? getContentType(pathname),
});
}

Expand All @@ -221,6 +223,12 @@ export class MemoryBuildCache<State> implements DevBuildCache<State> {
);
}

#loadedFiles: FsRouteFile<State>[] = [];

get loadedFsRoutes(): FsRouteFile<State>[] {
return this.#loadedFiles;
}

async prepare(): Promise<void> {
// Load FS routes
const files = await Promise.all(this.#fsRoutes.files.map(async (file) => {
Expand All @@ -230,6 +238,7 @@ export class MemoryBuildCache<State> implements DevBuildCache<State> {
mod: file.lazy ? () => import(fileUrl) : await import(fileUrl),
};
}));
this.#loadedFiles = files;
this.#commands = fsItemsToCommands(files);
}
}
Expand Down Expand Up @@ -280,6 +289,7 @@ export class DiskBuildCache<State> implements DevBuildCache<State> {
pathname: string,
content: Uint8Array,
hash: string | null,
_contentType?: string,
) {
this.#processedFiles.set(pathname, hash);

Expand Down
2 changes: 1 addition & 1 deletion packages/fresh/src/dev/fs_crawl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export async function crawlRouteDir<State>(
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
Expand Down
98 changes: 98 additions & 0 deletions packages/fresh/src/dev/prerender.ts
Original file line number Diff line number Diff line change
@@ -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<State>(
app: App<State>,
buildCache: DevBuildCache<State>,
fsRoutes: FsRouteFile<State>[],
): Promise<number> {
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<State>(
fsRoutes: FsRouteFile<State>[],
): Promise<string[]> {
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, string>,
): 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;
}
139 changes: 139 additions & 0 deletions packages/fresh/src/dev/prerender_test.ts
Original file line number Diff line number Diff line change
@@ -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 () => <h1>Home</h1>;
`,
"routes/about.tsx": `
export const config = { prerender: true };
export default () => <h1>About</h1>;
`,
"routes/dynamic.tsx": `
export default () => <h1>Dynamic</h1>;
`,
});

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("<h1>Home</h1>");

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

// 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("<h1>Dynamic</h1>");
});

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) => <h1>{ctx.params.slug}</h1>;
`,
});

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("<h1>hello</h1>");

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

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) => <h1>{ctx.params.id}</h1>;
`,
});

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("<h1>test</h1>");
},
);
2 changes: 1 addition & 1 deletion packages/fresh/src/middlewares/static_files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function staticFiles<T>(): Middleware<T> {
// 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 });
Expand Down
23 changes: 23 additions & 0 deletions packages/fresh/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>>
| Promise<Array<Record<string, string>>>);
}

export interface LayoutConfig {
Expand Down