From 61355b396bafd07feb4803cc51decf8772879980 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 6 Sep 2026 00:23:31 -0700 Subject: [PATCH 1/4] =?UTF-8?q?crawler:=20routers=20module=20=E2=80=94=20s?= =?UTF-8?q?tatic=20pages=20from=20the=20router=20the=20server=20built?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prerender-crawler/routers (no Node imports, for application server code): tanstackRouterPages(router) reads a TanStack instance's routesByPath; solidRouterPages(router | routes) walks a Solid Router 2 definition tree; announcePages(request, headers, paths) puts them on the hint header when the request is the crawler's. Tested against real @tanstack/router-core and @solidjs/router instances (dev deps) so drift in either shape shows up here first. Co-authored-by: Cursor --- packages/crawler/src/index.ts | 9 ++ packages/crawler/src/routers.ts | 179 ++++++++++++++++++++++++++ packages/crawler/test/routers.test.ts | 131 +++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 packages/crawler/src/routers.ts create mode 100644 packages/crawler/test/routers.test.ts diff --git a/packages/crawler/src/index.ts b/packages/crawler/src/index.ts index 264be26..d1ed0fc 100644 --- a/packages/crawler/src/index.ts +++ b/packages/crawler/src/index.ts @@ -6,6 +6,15 @@ export { outputFilename } from "./output.ts"; export { formatRedirectsFile, redirects } from "./redirects.ts"; export type { RedirectsIntegrationOptions } from "./redirects.ts"; export { report } from "./report.ts"; +export { HINT_HEADER, announcePages, solidRouterPages, tanstackRouterPages } from "./routers.ts"; +export type { + AnnounceOptions, + SolidRouteLike, + SolidRouterLike, + SolidRouterPagesOptions, + TanStackRouteLike, + TanStackRouterLike +} from "./routers.ts"; export type { PrerenderReport, ReportIntegrationOptions, diff --git a/packages/crawler/src/routers.ts b/packages/crawler/src/routers.ts new file mode 100644 index 0000000..681dde4 --- /dev/null +++ b/packages/crawler/src/routers.ts @@ -0,0 +1,179 @@ +/** + * Router helpers: which of a router's routes are static pages. + * + * The crawl finds pages by following links and by reading the hint header + * (`x-prerender`) off responses. A route nothing links to is invisible to + * the first; the second is how a server that KNOWS its routes declares + * them — and the thing that knows the routes is the router the app built + * for the request. So each helper here takes a router (or its route tree) + * and returns the paths that address a static page: no parameters, no + * splats, a leaf or an index. The app puts them on the hint header of its + * response with `announcePages`, and every crawl — the Vite plugin, the + * CLI against a built module, the CLI against a running server — seeds + * from the answer. + * + * Nothing here imports a router package: each helper is typed against the + * subset of the router's public shape it reads. + * + * This module is imported by application SERVER code, so it stays free of + * Node imports. + */ + +/** The hint header the engine reads, and the request header it sends. */ +export const HINT_HEADER = "x-prerender"; + +export interface AnnounceOptions { + /** The header name, if the crawl was configured with a custom `hintHeader`. @default "x-prerender" */ + header?: string; +} + +/** + * Puts `paths` on the response's hint header — when the request is the + * crawler's (it carries the hint header). Returns whether it did. A + * regular visitor's response is left untouched. + * + * ```ts + * announcePages(event.request, event.response.headers, solidRouterPages(Router)); + * ``` + */ +export function announcePages( + request: Request, + headers: Headers, + paths: readonly string[], + options: AnnounceOptions = {} +): boolean { + const { header = HINT_HEADER } = options; + if (!request.headers.has(header) || paths.length === 0) return false; + headers.set(header, paths.join(",")); + return true; +} + +// --------------------------------------------------------------------------- +// TanStack Router (@tanstack/react-router, solid-router, vue-router — one core) + +/** The subset of a TanStack `Router` instance this reads. */ +export interface TanStackRouterLike { + /** + * Every route with a path, keyed by full path with the trailing slash + * trimmed; where a layout and its index share a path the index wins. + * Public on the router instance, built by `@tanstack/router-core`. + */ + routesByPath: Record; +} + +export interface TanStackRouteLike { + /** The route's full path — an index route's ends with `/`. */ + fullPath: string; + children?: unknown; +} + +/** + * The static pages of a TanStack Router instance. A route is a page when + * its path has no `$` segment (params `$id`, splats `$`, optional params + * `{-$id}`) and it is a leaf or an index — a layout route with children + * but no index has no page of its own (its URL renders not-found). + * + * The router must be a built instance (`createRouter({ routeTree })`): the + * generated `routeTree` alone has no full paths until the router + * initializes it. + */ +export function tanstackRouterPages(router: TanStackRouterLike): string[] { + const paths = new Set(); + for (const [path, route] of Object.entries(router.routesByPath)) { + if (path.split("/").some(segment => segment.includes("$"))) continue; + const isIndex = route.fullPath.endsWith("/"); + const isLeaf = !hasChildren(route.children); + if (!isIndex && !isLeaf) continue; + paths.add(normalize(path)); + } + return [...paths]; +} + +// --------------------------------------------------------------------------- +// Solid Router (@solidjs/router 2) + +/** The subset of a Solid Router `RouteDefinition` this reads. */ +export interface SolidRouteLike { + /** A pattern, or several — aliases for one route. Absent on a pathless layout. */ + path?: string | readonly string[]; + /** + * Nested routes, or a thunk producing them lazily. Lazy children are not + * enumerated — that would load modules during a render; the crawl finds + * those pages by their links. + */ + children?: SolidRouteLike | readonly SolidRouteLike[] | ((...args: never[]) => unknown); +} + +/** The subset of a Solid Router `createRouter` instance this reads. */ +export interface SolidRouterLike { + readonly routes: SolidRouteLike | readonly SolidRouteLike[]; + readonly config?: { base?: string }; +} + +export interface SolidRouterPagesOptions { + /** The app's base path, when the tree is passed without its router. */ + base?: string; +} + +/** + * The static pages of a Solid Router instance (or a route-definition tree). + * Paths join root to leaf; a route is a page when it is a leaf — a parent + * with children has a page only through an index child (`""` or `"/"`) — + * and its joined path has no `:param`, optional `:param?`, or `*splat` + * segment. Pathless layouts join through; each alias in a `path` array is + * its own page. + */ +export function solidRouterPages( + router: SolidRouterLike | SolidRouteLike | readonly SolidRouteLike[], + options: SolidRouterPagesOptions = {} +): string[] { + const instance = isSolidRouter(router) ? router : undefined; + const routes = instance + ? instance.routes + : (router as SolidRouteLike | readonly SolidRouteLike[]); + const base = options.base ?? instance?.config?.base ?? ""; + const paths = new Set(); + const walk = (route: SolidRouteLike, prefix: string) => { + const own = route.path === undefined ? [""] : ([] as string[]).concat(route.path); + for (const pattern of own) { + const full = join(prefix, pattern); + const children = route.children; + if (children === undefined) { + if (!isDynamic(full)) paths.add(normalize(full)); + continue; + } + if (typeof children === "function") continue; // lazy subtree: found by links + for (const child of ([] as SolidRouteLike[]).concat(children as any)) walk(child, full); + } + }; + for (const route of ([] as SolidRouteLike[]).concat(routes as any)) walk(route, base); + return [...paths]; +} + +// a `createRouter` instance is the provider component with `routes` on it; +// a route definition never has a `routes` key +function isSolidRouter(value: unknown): value is SolidRouterLike { + if (value === null || Array.isArray(value)) return false; + return (typeof value === "function" || typeof value === "object") && "routes" in value; +} + +const isDynamic = (path: string) => + path.split("/").some(segment => segment.startsWith(":") || segment.startsWith("*")); + +function hasChildren(children: unknown): boolean { + if (children === undefined || children === null) return false; + if (Array.isArray(children)) return children.length > 0; + return typeof children === "object" ? Object.keys(children).length > 0 : true; +} + +function join(prefix: string, path: string): string { + const left = prefix.replace(/\/+$/, ""); + const right = path.replace(/^\/+/, ""); + return right ? `${left}/${right}` : left || "/"; +} + +/** One spelling per page, matching the engine's: no trailing slash except the root. */ +function normalize(path: string): string { + const trimmed = path.replace(/\/+$/, ""); + return trimmed === "" ? "/" : trimmed.startsWith("/") ? trimmed : `/${trimmed}`; +} diff --git a/packages/crawler/test/routers.test.ts b/packages/crawler/test/routers.test.ts new file mode 100644 index 0000000..def13fb --- /dev/null +++ b/packages/crawler/test/routers.test.ts @@ -0,0 +1,131 @@ +import { createRouter } from "@solidjs/router"; +import { BaseRootRoute, BaseRoute, RouterCore } from "@tanstack/router-core"; +import { describe, expect, it } from "vitest"; +import { runPrerender } from "../src/crawl.ts"; +import { announcePages, solidRouterPages, tanstackRouterPages } from "../src/routers.ts"; +import type { Transport } from "../src/types.ts"; + +describe("tanstackRouterPages", () => { + it("lists leaves and indexes of a real router, skipping params, splats, optionals and index-less layouts", () => { + const root = new BaseRootRoute({}); + const child = (parent: any, options: Record) => + new BaseRoute({ getParentRoute: () => parent, ...options } as any); + const posts = child(root, { path: "/posts" }); + const docs = child(root, { path: "/docs" }); + const layout = child(root, { id: "_auth" }); + const tree = root.addChildren([ + child(root, { path: "/" }), + child(root, { path: "/about" }), + posts.addChildren([child(posts, { path: "/" }), child(posts, { path: "/$id" })]), + docs.addChildren([child(docs, { path: "/$" })]), + layout.addChildren([child(layout, { path: "/account" })]), + child(root, { path: "/lang/{-$locale}" }) + ]); + const router = new RouterCore({ routeTree: tree } as any); + expect(tanstackRouterPages(router as any).sort()).toEqual([ + "/", + "/about", + "/account", + "/posts" + ]); + }); + + it("reads the documented shape without a router package", () => { + expect( + tanstackRouterPages({ + routesByPath: { + "/": { fullPath: "/" }, + "/blog": { fullPath: "/blog/", children: [{}] }, + "/blog/$slug": { fullPath: "/blog/$slug" }, + "/team": { fullPath: "/team", children: { member: {} } } + } + }) + ).toEqual(["/", "/blog"]); + }); +}); + +describe("solidRouterPages", () => { + const routes = [ + { path: "/", component: () => null }, + { path: "/about" }, + { path: "/posts", children: [{ path: "/" }, { path: "/:id" }, { path: "/:id/edit" }] }, + { path: "/docs", children: [{ path: "/*rest" }] }, + { children: [{ path: "/account" }, { path: "/settings/" }] }, // pathless layout + { path: ["/help", "/faq"] }, // aliases + { path: "/optional/:lang?" }, + { path: "/lazy", children: () => Promise.resolve([{ path: "/child" }]) } + ]; + + it("walks a real createRouter instance", () => { + const Router = createRouter({ routes } as any); + expect(solidRouterPages(Router).sort()).toEqual([ + "/", + "/about", + "/account", + "/faq", + "/help", + "/posts", + "/settings" + ]); + }); + + it("accepts a bare tree with a base, and a single root definition", () => { + expect(solidRouterPages(routes, { base: "/app" })).toContain("/app/posts"); + expect(solidRouterPages(routes, { base: "/app" })).not.toContain("/posts"); + expect(solidRouterPages({ path: "/", children: [{ path: "/" }, { path: "/x" }] })).toEqual([ + "/", + "/x" + ]); + }); + + it("uses the instance's configured base", () => { + const Router = createRouter({ routes: [{ path: "/" }, { path: "/a" }], base: "/site" } as any); + expect(solidRouterPages(Router).sort()).toEqual(["/site", "/site/a"]); + }); +}); + +describe("announcePages", () => { + it("answers only the crawler's request, on the configured header", () => { + const crawler = new Request("http://localhost/", { headers: { "x-prerender": "1" } }); + const visitor = new Request("http://localhost/"); + const headers = new Headers(); + + expect(announcePages(visitor, headers, ["/a"])).toBe(false); + expect(headers.has("x-prerender")).toBe(false); + + expect(announcePages(crawler, headers, ["/a", "/b"])).toBe(true); + expect(headers.get("x-prerender")).toBe("/a,/b"); + + expect(announcePages(crawler, new Headers(), [])).toBe(false); + + const custom = new Request("http://localhost/", { headers: { "x-pages": "1" } }); + const customHeaders = new Headers(); + expect(announcePages(custom, customHeaders, ["/a"], { header: "x-pages" })).toBe(true); + expect(customHeaders.get("x-pages")).toBe("/a"); + }); + + it("seeds a crawl end to end: the server announces its router's pages on the first response", async () => { + const Router = createRouter({ + routes: [{ path: "/" }, { path: "/unlinked" }, { path: "/posts/:id" }] + } as any); + const fetched: string[] = []; + const transport: Transport = { + async fetch(request) { + const path = new URL(request.url).pathname; + fetched.push(path); + const headers = new Headers({ "content-type": "text/html" }); + announcePages(request, headers, solidRouterPages(Router)); + return new Response(`

${path}

`, { headers }); + } + }; + const { mkdtemp, rm } = await import("node:fs/promises"); + const outDir = await mkdtemp("/tmp/routers-"); + try { + const result = await runPrerender({ transport, outDir, emitPages: false }); + expect(result.pages.map(page => page.path).sort()).toEqual(["/", "/unlinked"]); + expect(fetched.sort()).toEqual(["/", "/unlinked"]); + } finally { + await rm(outDir, { recursive: true, force: true }); + } + }); +}); From 6f8f5b4f5b8468496aba0ba7d464c076bd50f8a6 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 6 Sep 2026 00:23:31 -0700 Subject: [PATCH 2/4] crawler: drop filesystem-routing seeding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fileRoutePages, staticRoutePaths, and the plugin's fileRoutes option walked a route directory at build time and re-derived its path rules — a copy of another package's semantics, blind to routes defined in code, and useless to a crawl that never sees the project's disk (the CLI against a running server). The server's router announcing its pages on the hint header replaces all of it. filesystem-routing is no longer a peer; the router packages join as dev deps for the routers tests. Co-authored-by: Cursor --- packages/crawler/package.json | 13 +-- packages/crawler/src/file-routes.ts | 100 ---------------------- packages/crawler/src/vite.ts | 69 +++------------ packages/crawler/test/file-routes.test.ts | 68 --------------- pnpm-lock.yaml | 53 +++++++++++- 5 files changed, 71 insertions(+), 232 deletions(-) delete mode 100644 packages/crawler/src/file-routes.ts delete mode 100644 packages/crawler/test/file-routes.test.ts diff --git a/packages/crawler/package.json b/packages/crawler/package.json index 6ca4de6..58f9388 100644 --- a/packages/crawler/package.json +++ b/packages/crawler/package.json @@ -30,6 +30,10 @@ "./vite": { "types": "./dist/vite.d.ts", "default": "./dist/vite.js" + }, + "./routers": { + "types": "./dist/routers.d.ts", + "default": "./dist/routers.js" } }, "bin": { @@ -49,20 +53,19 @@ "test:watch": "vitest" }, "peerDependencies": { - "filesystem-routing": ">=0.2.0", "vite": "^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { - "filesystem-routing": { - "optional": true - }, "vite": { "optional": true } }, "devDependencies": { + "@solidjs/router": "2.0.0-next.21", + "@solidjs/web": "2.0.0-rc.6", + "@tanstack/router-core": "^1.171.27", "@types/node": "^22.0.0", - "filesystem-routing": "0.2.1", + "solid-js": "2.0.0-rc.6", "typescript": "^5.8.0", "vite": "^8.0.0", "vitest": "^4.0.0" diff --git a/packages/crawler/src/file-routes.ts b/packages/crawler/src/file-routes.ts deleted file mode 100644 index ff8c04b..0000000 --- a/packages/crawler/src/file-routes.ts +++ /dev/null @@ -1,100 +0,0 @@ -// Route-manifest seeding: the static pages a file-system router declares -// are known before anything renders, so they seed the crawl directly — -// a page nothing links to still gets built, and the crawl starts wide -// instead of unwinding from `/`. Dynamic routes (`/posts/:slug`, -// `/*404`) are left to link discovery: only a render knows their values. -import { createRequire } from "node:module"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; - -export interface FileRoutePagesOptions { - /** Project root the route dir resolves against. @default process.cwd() */ - root?: string; - /** Route directory, mirroring `fileRoutes({ dir })`. @default "src/routes" */ - dir?: string; - /** Route file extensions, mirroring `fileRoutes({ extensions })`. @default ["js", "jsx", "ts", "tsx"] */ - extensions?: string[]; -} - -/** The subset of a `filesystem-routing` manifest entry this module reads. */ -export interface RouteEntryLike { - path: string; - page?: boolean; -} - -/** - * The statically addressable page paths of a route manifest: pages whose - * path has no parameter or catch-all segment, with `(group)` segments - * stripped the way emission adapters strip them. Pure — the seam the - * plugin and tests share. - */ -export function staticRoutePaths(entries: readonly RouteEntryLike[]): string[] { - const paths = new Set(); - for (const entry of entries) { - if (!entry.page) continue; - const segments = entry.path.split("/").filter(segment => segment !== ""); - if (segments.some(segment => segment.startsWith(":") || segment.startsWith("*"))) continue; - const concrete = segments.filter(segment => !/^\(.*\)$/.test(segment)); - paths.add("/" + concrete.join("/")); - } - return [...paths]; -} - -/** - * A `pages` source scanning a `filesystem-routing` route directory for its - * static pages. The package is resolved from the project root (it is the - * APP's dependency), loaded lazily so projects without it pay nothing. - * - * ```ts - * prerender({ pages: fileRoutePages({ dir: "src/pages" }) }) - * ``` - * - * The `prerender()` plugin applies this automatically (`fileRoutes: true`, - * the default) when the package and the route directory exist. - */ -export function fileRoutePages(options: FileRoutePagesOptions = {}): () => Promise { - const root = options.root ?? process.cwd(); - const dir = path.resolve(root, options.dir ?? "src/routes"); - const extensions = options.extensions ?? ["js", "jsx", "ts", "tsx"]; - return async () => { - const routing = await loadFileSystemRouting(root); - const router = new routing.PageFileSystemRouter({ dir, extensions }); - return staticRoutePaths(await router.getRoutes()); - }; -} - -interface FileSystemRoutingModule { - PageFileSystemRouter: new (config: { dir: string; extensions: string[] }) => { - getRoutes(): Promise; - }; -} - -/** Whether `filesystem-routing` resolves from the project (for the plugin's auto mode). */ -export function hasFileSystemRouting(root: string): boolean { - try { - resolveFileSystemRouting(root); - return true; - } catch { - return false; - } -} - -function resolveFileSystemRouting(root: string): string { - // from the project first (the app's copy), then from here (a test or a - // setup that installed it alongside the plugin) - for (const from of [path.join(root, "package.json"), import.meta.url]) { - try { - return createRequire(from).resolve("filesystem-routing"); - } catch { - // try the next base - } - } - throw new Error( - `fileRoutePages needs the "filesystem-routing" package, which could not be resolved from ${root}.` - ); -} - -async function loadFileSystemRouting(root: string): Promise { - const resolved = resolveFileSystemRouting(root); - return (await import(pathToFileURL(resolved).href)) as FileSystemRoutingModule; -} diff --git a/packages/crawler/src/vite.ts b/packages/crawler/src/vite.ts index cc70b68..c74affa 100644 --- a/packages/crawler/src/vite.ts +++ b/packages/crawler/src/vite.ts @@ -1,13 +1,13 @@ // The Vite plugin: `prerender()` from `prerender-crawler/vite`. // -// Framework-agnostic by construction. It knows three things about the app: -// that `vite build` produces a client output directory, that some +// Framework-agnostic by construction. It knows two things about the app: +// that `vite build` produces a client output directory, and that some // environment's output includes a module exporting a fetch-shaped handler -// (`handleRequest` or `fetch`: Request in, Response out), and — optionally -// — that a `filesystem-routing` directory names the static pages. Anything +// (`handleRequest` or `fetch`: Request in, Response out). Anything // framework-specific rides along as an integration (see // `PrerenderIntegration`), the same seam the engine exposes to non-Vite -// drivers. +// drivers. Which pages exist is the server's to say — through links and +// the hint header (see ./routers.ts) — not something read off the disk. // // Responsibilities, all build-only: // @@ -20,19 +20,12 @@ // environment so runtime code can ask "am I a prerendered build, and of // which kind" — the one bit of client-side knowledge integrations need. // Absent (dev, or a build without this plugin) means "live". -// 3. Seeding: the static pages of a `filesystem-routing` route directory -// seed the crawl automatically, so a page nothing links to still builds. -import { existsSync } from "node:fs"; import path from "node:path"; import type { Plugin } from "vite"; import { runPrerender } from "./crawl.ts"; -import { fileRoutePages, hasFileSystemRouting } from "./file-routes.ts"; -import type { FileRoutePagesOptions } from "./file-routes.ts"; import { moduleTransport } from "./transports.ts"; -import type { PageEntry, PrerenderOptions } from "./types.ts"; +import type { PrerenderOptions } from "./types.ts"; -export { fileRoutePages, staticRoutePaths } from "./file-routes.ts"; -export type { FileRoutePagesOptions, RouteEntryLike } from "./file-routes.ts"; export { redirects } from "./redirects.ts"; export type { RedirectsIntegrationOptions } from "./redirects.ts"; export { report } from "./report.ts"; @@ -56,25 +49,14 @@ export interface PrerenderPluginOptions extends PrerenderOptions { * Defaults to `server.js` inside the `ssr` environment's output directory. */ serverEntry?: string; - /** - * Seed the crawl with the static pages of the project's - * `filesystem-routing` route directory, merged with `pages`. Dynamic - * routes (`/posts/:slug`) are still discovered by links — only a render - * knows their values. - * - * `true` (default) applies when the package and `src/routes` exist and - * is silently skipped otherwise; pass options to mirror a customized - * `fileRoutes({ dir, extensions })` (then a missing package is an error); - * `false` disables it. - */ - fileRoutes?: boolean | FileRoutePagesOptions; } /** * Prerenders the app at build time: crawls the built server handler from - * `pages` (default `["/"]`, plus the file-routed static pages, plus every - * same-origin link discovered along the way) and writes each page's HTML — - * and whatever the integrations emit — into the client output. + * `pages` (default `["/"]`, plus every page the server announces on the + * hint header, plus every same-origin link discovered along the way) and + * writes each page's HTML — and whatever the integrations emit — into the + * client output. * * ```ts * import { prerender } from "prerender-crawler/vite"; @@ -132,26 +114,15 @@ export function prerender(options: PrerenderPluginOptions = {}): Plugin { { cause: error } ); } - const routeSeeds = await fileRouteSeeds(root, options.fileRoutes); - const { serverEntry: _entry, fileRoutes: _fileRoutes, pages, ...crawl } = options; - const result = await runPrerender({ - ...crawl, - mode, - pages: async () => [ - ...(typeof pages === "function" ? await pages() : (pages ?? ["/"])), - ...routeSeeds - ], - transport, - outDir: clientOut - }); + const { serverEntry: _entry, ...crawl } = options; + const result = await runPrerender({ ...crawl, mode, transport, outDir: clientOut }); const written = result.pages.filter(page => page.emitted).length; - const seeded = routeSeeds.length ? `, ${routeSeeds.length} seeded from file routes` : ""; const redirected = result.redirects.length ? `, ${result.redirects.length} redirect(s)` : ""; logger.info( - `[prerender] rendered ${result.pages.length} page(s) (${written} written${seeded})` + + `[prerender] rendered ${result.pages.length} page(s) (${written} written)` + `${redirected}, ${result.files.length} file(s) emitted -> ${path.relative(root, clientOut)}` ); for (const miss of result.skipped) { @@ -162,18 +133,4 @@ export function prerender(options: PrerenderPluginOptions = {}): Plugin { }; } -async function fileRouteSeeds( - root: string, - option: PrerenderPluginOptions["fileRoutes"] -): Promise> { - if (option === false) return []; - const explicit = typeof option === "object" ? option : undefined; - if (!explicit) { - // auto mode: only when the project actually uses file routing - const dir = path.resolve(root, "src/routes"); - if (!hasFileSystemRouting(root) || !existsSync(dir)) return []; - } - return fileRoutePages({ root, ...explicit })(); -} - const describe = (error: unknown) => (error instanceof Error ? error.message : String(error)); diff --git a/packages/crawler/test/file-routes.test.ts b/packages/crawler/test/file-routes.test.ts deleted file mode 100644 index cd74bef..0000000 --- a/packages/crawler/test/file-routes.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { fileRoutePages, hasFileSystemRouting, staticRoutePaths } from "../src/file-routes.ts"; - -// filesystem-routing is an optional peer resolved from the PROJECT root at -// runtime; here it is this package's devDependency, reached by the fallback. - -describe("staticRoutePaths", () => { - it("keeps concrete pages, drops dynamic ones, strips groups, dedupes", () => { - const paths = staticRoutePaths([ - { path: "/", page: true }, - { path: "/about", page: true }, - { path: "/posts", page: true }, - { path: "/posts/:slug", page: true }, - { path: "/docs/:version?", page: true }, - { path: "/*404", page: true }, - { path: "/(marketing)/pricing", page: true }, - { path: "/(marketing)", page: true }, - { path: "/api/users" }, // a handler-only route is not a page - { path: "/about", page: true } - ]); - expect(paths).toEqual(["/", "/about", "/posts", "/pricing"]); - }); -}); - -describe("fileRoutePages", () => { - let root: string; - afterEach(async () => { - if (root) await rm(root, { recursive: true, force: true }); - }); - - it("scans a route directory for its static pages", async () => { - root = await mkdtemp(join(tmpdir(), "prerender-routes-")); - const routes = join(root, "src/routes"); - await mkdir(join(routes, "posts"), { recursive: true }); - await mkdir(join(routes, "(group)"), { recursive: true }); - const page = "export default function Page() { return null; }\n"; - await writeFile(join(routes, "index.tsx"), page); - await writeFile(join(routes, "about.tsx"), page); - await writeFile(join(routes, "posts.tsx"), page); - await writeFile(join(routes, "posts/index.tsx"), page); - await writeFile(join(routes, "posts/[slug].tsx"), page); - await writeFile(join(routes, "[...404].tsx"), page); - await writeFile(join(routes, "(group)/pricing.tsx"), page); - await writeFile(join(routes, "helpers.ts"), "export const notARoute = 1;\n"); - - // the temp root has no node_modules; resolution falls back to the - // package installed alongside this module - const pages = await fileRoutePages({ root })(); - expect(pages.sort()).toEqual(["/", "/about", "/posts", "/pricing"]); - }); - - it("honors dir and extensions", async () => { - root = await mkdtemp(join(tmpdir(), "prerender-routes-")); - const routes = join(root, "pages"); - await mkdir(routes, { recursive: true }); - await writeFile(join(routes, "index.jsx"), "export default () => null;\n"); - await writeFile(join(routes, "skipped.tsx"), "export default () => null;\n"); - const pages = await fileRoutePages({ root, dir: "pages", extensions: ["jsx"] })(); - expect(pages).toEqual(["/"]); - }); - - it("reports whether the package resolves (from the project, or from here)", () => { - expect(hasFileSystemRouting(process.cwd())).toBe(true); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc1d48a..bf0acb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,12 +51,21 @@ importers: packages/crawler: devDependencies: + '@solidjs/router': + specifier: 2.0.0-next.21 + version: 2.0.0-next.21(@solidjs/web@2.0.0-rc.6(solid-js@2.0.0-rc.6))(solid-js@2.0.0-rc.6) + '@solidjs/web': + specifier: 2.0.0-rc.6 + version: 2.0.0-rc.6(solid-js@2.0.0-rc.6) + '@tanstack/router-core': + specifier: ^1.171.27 + version: 1.171.27 '@types/node': specifier: ^22.0.0 version: 22.20.1 - filesystem-routing: - specifier: 0.2.1 - version: 0.2.1(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)) + solid-js: + specifier: 2.0.0-rc.6 + version: 2.0.0-rc.6 typescript: specifier: ^5.8.0 version: 5.9.3 @@ -889,6 +898,14 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tanstack/history@1.162.1': + resolution: {integrity: sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==} + engines: {node: '>=20.19'} + + '@tanstack/router-core@1.171.27': + resolution: {integrity: sha512-wDwSLvoLwIaNcnx9UNcN9Mb7Y8QwCYq1U1RQZwyN186gnkIoIYI2SOxy8VqH1vFigbkHkk4FmwMAQlghPgDK2g==} + engines: {node: '>=20.19'} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -1040,6 +1057,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1533,10 +1553,20 @@ packages: peerDependencies: seroval: ^1.0 + seroval-plugins@1.6.4: + resolution: {integrity: sha512-R0f1U9hmn38+dFMz6b6ab8lwucmw4AtiY7St+JPWudy1dm+Bs3g884nyrsH9Cy6rKpZKLYayXuMda9GZ/fl8JQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.5.6: resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} engines: {node: '>=10'} + seroval@1.6.4: + resolution: {integrity: sha512-LErWMNS2RRFdu2RMA5u/PA59/IWs0XsikyEXGQ2/36iEWFrdG0ABmg17E17cikrv76891kOAMq3TkTFXpwAHXw==} + engines: {node: '>=10'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2494,6 +2524,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tanstack/history@1.162.1': {} + + '@tanstack/router-core@1.171.27': + dependencies: + '@tanstack/history': 1.162.1 + cookie-es: 3.1.1 + seroval: 1.6.4 + seroval-plugins: 1.6.4(seroval@1.6.4) + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -2643,6 +2682,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@3.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3124,8 +3165,14 @@ snapshots: dependencies: seroval: 1.5.6 + seroval-plugins@1.6.4(seroval@1.6.4): + dependencies: + seroval: 1.6.4 + seroval@1.5.6: {} + seroval@1.6.4: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 From ad968cc766c1f251783740847172b952e5c65d4d Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 6 Sep 2026 00:23:32 -0700 Subject: [PATCH 3/4] =?UTF-8?q?solid:=20announceRoutes(Router)=20=E2=80=94?= =?UTF-8?q?=20one=20line=20in=20the=20app=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server half reads the ambient request event and, for the crawler's request, writes the router's static pages to the response's hint header; client half is a no-op. The example root calls it. Co-authored-by: Cursor --- examples/ssg/src/App.tsx | 6 ++- packages/solid/src/client.ts | 9 ++++- packages/solid/src/server.ts | 39 ++++++++++++++++++- packages/solid/src/shared.ts | 8 ++++ packages/solid/test/announce.test.ts | 56 ++++++++++++++++++++++++++++ packages/solid/tsup.config.ts | 2 +- 6 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 packages/solid/test/announce.test.ts diff --git a/examples/ssg/src/App.tsx b/examples/ssg/src/App.tsx index 4faa73d..a6767e3 100644 --- a/examples/ssg/src/App.tsx +++ b/examples/ssg/src/App.tsx @@ -1,10 +1,14 @@ import { Title } from "@solidjs/meta"; +import { announceRoutes } from "@solidjs/prerender"; import { Loading } from "solid-js"; import { Router } from "./router"; // The app root: the router and the site-wide layout. Pages live under -// src/routes; the build crawls them into static HTML starting from "/". +// src/routes; the build crawls them into static HTML starting from "/", +// and the router announces its static pages to the crawl so one nothing +// links to still builds. (A no-op for visitors and in the browser.) export default function App() { + announceRoutes(Router); return ( {props => ( diff --git a/packages/solid/src/client.ts b/packages/solid/src/client.ts index ef93371..7349510 100644 --- a/packages/solid/src/client.ts +++ b/packages/solid/src/client.ts @@ -21,10 +21,15 @@ import { withMeta } from "@solidjs/web/server-functions/client"; import { PRERENDERED_META_KEY, staticArtifactPath } from "./shared.ts"; -import type { PrerenderedFunction } from "./shared.ts"; +import type { AnnounceRoutesOptions, PrerenderedFunction } from "./shared.ts"; export { staticArtifactPath, staticCallKey } from "./shared.ts"; -export type { PrerenderedFunction } from "./shared.ts"; +export type { AnnounceRoutesOptions, PrerenderedFunction } from "./shared.ts"; + +/** The client half of `announceRoutes`: there is no request here. Always false. */ +export function announceRoutes(_router: unknown, _options?: AnnounceRoutesOptions): boolean { + return false; +} // the metadata brand rides the same registered symbol the runtime uses, so // `isServerFunction` / `getServerFunctionMetadata` recognize prerendered diff --git a/packages/solid/src/server.ts b/packages/solid/src/server.ts index 17d84a6..498e900 100644 --- a/packages/solid/src/server.ts +++ b/packages/solid/src/server.ts @@ -12,11 +12,48 @@ import { getServerFunctionMetadata, isServerFunction } from "@solidjs/web/server-functions/server"; +import { getRequestEvent } from "@solidjs/web"; +import type { RequestEvent, ResponseStub } from "@solidjs/web"; +import { announcePages, solidRouterPages } from "prerender-crawler/routers"; +import type { SolidRouteLike, SolidRouterLike } from "prerender-crawler/routers"; import { CAPTURE_SINK, PRERENDERED_META_KEY } from "./shared.ts"; -import type { CaptureSink, PrerenderedFunction } from "./shared.ts"; +import type { AnnounceRoutesOptions, CaptureSink, PrerenderedFunction } from "./shared.ts"; export { staticArtifactPath, staticCallKey } from "./shared.ts"; export type { CaptureSink, PrerenderedFunction } from "./shared.ts"; +export type { AnnounceRoutesOptions } from "./shared.ts"; + +/** + * Tells a prerender crawl which pages this app's router has — the server + * half. Called during a server render (the app root is the natural place), + * it reads the ambient request: when the request is the crawler's, the + * router's static pages go on the response's hint header and the crawl + * seeds every one of them, linked or not. A visitor's request is untouched; + * on the client this is a no-op. Returns whether it announced. + * + * ```tsx + * import { announceRoutes } from "@solidjs/prerender"; + * import { Router } from "./router"; + * + * export default function App() { + * announceRoutes(Router); + * return {props => props.children}; + * } + * ``` + * + * Dynamic routes (`/posts/:id`) are not announced — only a render knows + * their values; the crawl finds them by their links. + */ +export function announceRoutes( + router: SolidRouterLike | SolidRouteLike | readonly SolidRouteLike[], + options: AnnounceRoutesOptions = {} +): boolean { + const event = getRequestEvent() as (RequestEvent & { response?: ResponseStub }) | undefined; + if (!event?.response || event.response.committed) return false; + if (!event.request.headers.has(options.header ?? "x-prerender")) return false; + const pages = solidRouterPages(router, { base: options.base }); + return announcePages(event.request, event.response.headers, pages, { header: options.header }); +} const SERVER_FUNCTION_METADATA = Symbol.for("solid.ServerFunctionMetadata"); diff --git a/packages/solid/src/shared.ts b/packages/solid/src/shared.ts index ef13abf..5381117 100644 --- a/packages/solid/src/shared.ts +++ b/packages/solid/src/shared.ts @@ -132,3 +132,11 @@ export interface PrerenderedFunction null, { routes, config: { base: "" } }); + +const withEvent = (request: Request, body: (event: { response: { headers: Headers } }) => T) => { + const event = createRequestEvent(request); + return provideRequestEvent(event, () => body(event)); +}; + +describe("announceRoutes", () => { + it("puts the router's static pages on the crawler's response", () => { + const request = new Request("http://localhost/", { headers: { "x-prerender": "1" } }); + withEvent(request, event => { + expect(announceRoutes(Router)).toBe(true); + expect(event.response.headers.get("x-prerender")!.split(",").sort()).toEqual([ + "/", + "/about", + "/posts" + ]); + }); + }); + + it("leaves a visitor's response alone, and does nothing without a request scope", () => { + withEvent(new Request("http://localhost/"), event => { + expect(announceRoutes(Router)).toBe(false); + expect(event.response.headers.has("x-prerender")).toBe(false); + }); + expect(announceRoutes(Router)).toBe(false); + }); + + it("honors a custom hint header and an explicit base for a bare tree", () => { + const request = new Request("http://localhost/", { headers: { "x-pages": "1" } }); + withEvent(request, event => { + expect(announceRoutes(routes, { header: "x-pages", base: "/app" })).toBe(true); + expect(event.response.headers.get("x-pages")).toContain("/app/about"); + expect(event.response.headers.has("x-prerender")).toBe(false); + }); + }); + + it("is a no-op on the client", () => { + expect(announceClient(Router)).toBe(false); + }); +}); diff --git a/packages/solid/tsup.config.ts b/packages/solid/tsup.config.ts index 154d898..69b9607 100644 --- a/packages/solid/tsup.config.ts +++ b/packages/solid/tsup.config.ts @@ -14,5 +14,5 @@ export default defineConfig({ dts: true, splitting: true, clean: false, - external: ["prerender-crawler"] + external: [/^prerender-crawler(\/|$)/] }); From 840641d55c3aa70bb50ec35aa3e214bade0e2358 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 6 Sep 2026 00:23:32 -0700 Subject: [PATCH 4/4] docs + changeset for router seeding Co-authored-by: Cursor --- .changeset/http-transport-cli-redirects.md | 5 ++++ packages/crawler/README.md | 34 ++++++++++++++++------ packages/solid/README.md | 16 ++++++++++ 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/.changeset/http-transport-cli-redirects.md b/.changeset/http-transport-cli-redirects.md index 72e4315..2bf25b6 100644 --- a/.changeset/http-transport-cli-redirects.md +++ b/.changeset/http-transport-cli-redirects.md @@ -1,5 +1,6 @@ --- "prerender-crawler": minor +"@solidjs/prerender": minor --- Prerender anything over HTTP, from the command line, with redirects the host can serve. @@ -18,6 +19,10 @@ Prerender anything over HTTP, from the command line, with redirects the host can **Query-string pages.** `keepQuery: true` renders `/posts?page=2` apart from `/posts` (parameters sorted for dedupe), following its links and capturing its data, but writes it only when its seed entry names a `filename` — a static host serves a path the same for every query. Off by default; CLI `--keep-query`. +**Router seeding, from the server.** New `prerender-crawler/routers` (no Node imports — for application server code): `tanstackRouterPages(router)` and `solidRouterPages(router | routes, { base? })` list a router's static pages from the instance the app built for the request (leaves and indexes, no params or splats), and `announcePages(request, headers, paths)` puts them on the response's hint header when the request is the crawler's. `@solidjs/prerender` gains `announceRoutes(Router)` — one line in the app root, reading the ambient request event; a no-op in the browser. The Vite plugin, the CLI against a module, and the CLI against a running server all seed from the same header. + +**Removed:** `fileRoutePages`, `staticRoutePaths`, and the Vite plugin's `fileRoutes` option — build-time seeding that walked a `filesystem-routing` directory and re-derived its path rules. The server's own router is the source of truth for which pages exist, and the header works for crawls that never see the project's disk (the CLI against a running server). `filesystem-routing` is no longer a peer dependency. + **Seeds are normalized like links.** A seed spelled `about/`, `/a#top`, or `/posts?page=2` now meets the crawled link to the same page in one queue entry. Previously a seed with a query was fetched verbatim and written to a literal `posts?page=2/` directory. **`RenderedPage.duration`.** Milliseconds from request start to body read, pacing excluded. diff --git a/packages/crawler/README.md b/packages/crawler/README.md index 531a04a..45443ab 100644 --- a/packages/crawler/README.md +++ b/packages/crawler/README.md @@ -56,22 +56,20 @@ export default defineConfig({ }); ``` -The plugin is build-only. It assumes three things about the app: +The plugin is build-only. It assumes two things about the app: 1. `vite build` produces a client output directory (the `client` environment's `outDir`, default `dist/client`). 2. Some environment's output includes a module exporting a request handler — `handleRequest`, `fetch`, or `default.fetch`. Default: `server.js` in the `ssr` environment's `outDir`; override with `serverEntry`. -3. Optionally, a [`filesystem-routing`](https://www.npmjs.com/package/filesystem-routing) route directory names the static pages. -After the other environments build, it imports the server handler and crawls it in-process. Pages and integration-emitted files land in the client output. +After the other environments build, it imports the server handler and crawls it in-process. Pages and integration-emitted files land in the client output. Which pages exist is the server's to say — see [Seeding from the router](#seeding-from-the-router). ### Options Everything from [`PrerenderOptions`](#engine-options) plus: -| Option | Default | | -| ------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `serverEntry` | `/server.js` | Built module exporting the handler. | -| `fileRoutes` | `true` | Seed the crawl with the static pages of the project's `filesystem-routing` directory. `true` applies when the package and `src/routes` exist and is skipped silently otherwise; pass `{ dir, extensions }` to mirror a customized `fileRoutes()` (then a missing package is an error); `false` disables. Dynamic routes are still found by following links. | +| Option | Default | | +| ------------- | ------------------------ | ----------------------------------- | +| `serverEntry` | `/server.js` | Built module exporting the handler. | ### `import.meta.env.PRERENDER_MODE` @@ -108,6 +106,24 @@ result.skipped; // SkippedPage[] — failures left out (failOnError: false) `httpTransport` sends the crawl's requests to the target's origin (path and query kept) and hands redirects back as the 3xx responses the server sent. Pass `{ headers }` for an auth token or `{ fetch }` for a custom implementation. `moduleTransport` imports a module exporting `handleRequest`, `fetch`, or `default.fetch` and calls it directly. +### Seeding from the router + +The crawl finds pages by following links and by reading the hint header (`x-prerender`, comma-separated paths) off responses. A page nothing links to is invisible to the first; the second is how a server that knows its routes declares them — and the thing that knows the routes is the router the app built for the request. `prerender-crawler/routers` has the helpers, free of Node imports so application server code can use them: + +```ts +import { announcePages, tanstackRouterPages, solidRouterPages } from "prerender-crawler/routers"; + +// TanStack Router (any flavor — the helper reads the instance's `routesByPath`) +announcePages(request, response.headers, tanstackRouterPages(router)); + +// Solid Router (a `createRouter` instance, or a route-definition tree with `{ base }`) +announcePages(request, response.headers, solidRouterPages(Router)); +``` + +`announcePages` writes the header only when the request is the crawler's (it carries the hint header) — a visitor's response is untouched. Each `*Pages` helper returns the paths that address a static page: no parameters or splats, a leaf or an index (a layout with children but no index has no page of its own). Dynamic routes are still found by their links; only a render knows their values. The Vite plugin, the CLI against a module, and the CLI against a running server all send the hint header, so one line in the app seeds all three. + +[`@solidjs/prerender`](../solid) wraps this as `announceRoutes(Router)` for Solid apps, reading the request from the ambient request event. + ### Redirects A path that answers 3xx is recorded (`result.redirects`, one record per hop — `/a → /b → /c` is two records, the way host rules spell it) and its same-origin target is crawled as a page in its own right, so the destination renders once at its own URL. The redirected path itself gets a **meta-refresh stub** pointing at the chain's final destination, so the old URL keeps working on hosts with no redirect support. A redirect to another spelling of the same page (`/posts → /posts/`) is followed in place, not recorded. @@ -209,12 +225,12 @@ interface PrerenderContext { - `redirects(options?)`, `formatRedirectsFile(records, force?)` — the redirects integration and its `_redirects` formatter. - `sitemap(options)`, `indexable(page)`, `formatSitemap(entries)` — the sitemap integration and its parts. - `report(options?)` — the crawl report integration. -- `fileRoutePages({ root, dir, extensions })` / `staticRoutePaths(entries)` — the static page paths of a `filesystem-routing` manifest, as a `pages` source. +- `prerender-crawler/routers`: `announcePages(request, headers, paths)`, `tanstackRouterPages(router)`, `solidRouterPages(router | routes, { base? })`, `HINT_HEADER`. - `extractLinks(html, pageUrl, { keepQuery? })`, `normalizeLink(href, base, origin)`, `normalizeRoute(url)`, `normalizePath(pathname)`, `splitRoute(route)`, `outputFilename(path, autoSubfolderIndex)` — the crawl's own primitives. ## Requirements -Node 20+. Vite 7 or 8 for the plugin (optional peer). `filesystem-routing` ≥ 0.2 for route seeding (optional peer). +Node 20+. Vite 7 or 8 for the plugin (optional peer). ## License diff --git a/packages/solid/README.md b/packages/solid/README.md index a8fa1a9..b9c5dc7 100644 --- a/packages/solid/README.md +++ b/packages/solid/README.md @@ -45,6 +45,22 @@ export const getPost = query( Design pages so the crawl exercises the calls the site needs — which happens naturally when pages link to what they use. +## `announceRoutes(router)` + +The crawl follows links; a page nothing links to needs announcing. Call this in the app root during render with the `createRouter` instance (or a route-definition tree, with `{ base }`): + +```tsx +import { announceRoutes } from "@solidjs/prerender"; +import { Router } from "./router"; + +export default function App() { + announceRoutes(Router); + return {props => props.children}; +} +``` + +On the server, when the request is the crawler's, the router's static pages go on the response's hint header and the crawl seeds every one of them. A visitor's response is untouched; in the browser it is a no-op. Dynamic routes (`/posts/:id`) are not announced — only a render knows their values; the crawl finds them by their links. Options: `header` (a custom crawl `hintHeader`), `base`. + ## `serverFunctions(options?)` The integration has two jobs.