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
16 changes: 16 additions & 0 deletions .changeset/static-wire-answers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@solidjs/prerender": minor
"prerender-crawler": minor
---

Prerendered calls now store the live answer, so server components can be prerendered.

**`@solidjs/prerender`.** An artifact is now the response the app's own server-function endpoint gives for the call's GET address (status, headers and body), captured by replaying each call through the crawl after the pages render. Before, the integration encoded the returned value itself, which bypassed the server's result policies: a server component (`return props => <jsx/>`) was stored as an error, and client navigation to it crashed in both static and hybrid builds.

On the client, `prerendered()` now returns the runtime's own GET reference and serves artifacts through the reference's own send (`withMeta(fn, { fetch })`), so an app's transport `fetch` stays the app's and carries the hybrid fallback. Hydration adopts server-rendered reads and server components without a request, where it used to refetch them, and responses decode through the runtime's own pipeline. Artifacts are recognized by a marker line instead of status, so a host answering a missing artifact with a 200 page now falls back to the live server in hybrid mode instead of failing to parse.

Artifacts live under the build's id (`_static/<build>/…`), so they can be cached as immutable and a deploy never serves the previous build's answer from a CDN.

Breaking: artifacts are `_static/<build>/<label>.<key>.txt` in a new format (rebuild to regenerate them); `serverFunctions()` no longer takes `codec` (the server's configured codec is used); `@solidjs/web` 2.0.0-rc.16 or later is required.

**`prerender-crawler`.** `PrerenderContext.fetch(input)` sends a request through the crawl's own transport (paced by `interval`, relative urls resolved against `origin`) and returns the raw response, for integrations that capture what the app answers at an address that is not a page. `buildId` names the build (`context.buildId`); the Vite plugin mints one per build and defines it as `import.meta.env.PRERENDER_BUILD`.
10 changes: 5 additions & 5 deletions examples/ssg/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@
"dependencies": {
"@solidjs/meta": "^1.0.0-next.2",
"@solidjs/prerender": "workspace:*",
"@solidjs/router": "^2.0.0-next.21",
"@solidjs/web": "^2.0.0-rc.6",
"@solidjs/router": "^2.0.0-next.41",
"@solidjs/web": "^2.0.0-rc.16",
"prerender-crawler": "workspace:*",
"solid-js": "^2.0.0-rc.6"
"solid-js": "^2.0.0-rc.16"
},
"devDependencies": {
"@solidjs/vite-plugin": "^3.0.0-next.39",
"filesystem-routing": "0.2.1",
"@solidjs/vite-plugin": "^3.0.0-next.50",
"filesystem-routing": "0.4.0",
"typescript": "^5.9.2",
"vite": "^8.1.5"
}
Expand Down
2 changes: 1 addition & 1 deletion examples/ssg/src/data.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// The data layer: server functions declared PRERENDERED. Each call a page
// makes during the build is captured as a static JSON artifact
// makes during the build is captured as a static artifact
// (dist/client/_static/…) keyed by the call's identity — function +
// arguments. In the built site, calling these fetches the artifact; no
// server runs anywhere. In dev they are ordinary live server functions.
Expand Down
8 changes: 8 additions & 0 deletions packages/crawler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ Everything from [`PrerenderOptions`](#engine-options) plus:

The plugin defines this constant in every build environment with the run's `mode`. Runtime code can ask "am I a prerendered build, and which kind" — absent (dev, or a build without the plugin) means live. It's how integrations ship posture-aware client behavior without the app wiring anything.

### `import.meta.env.PRERENDER_BUILD`

The run's `buildId`, defined beside `PRERENDER_MODE` and handed to integrations as `context.buildId`. The plugin mints one per build unless you pass `buildId` (letters, digits, `-`, `_`), for example a commit SHA to make builds reproducible. Integrations version what they emit by it, so a new deploy never serves a previous build's file from a CDN or browser cache.

## Modes

The one distinction every downstream policy keys on:
Expand Down Expand Up @@ -178,6 +182,7 @@ runPrerender({
| Option | Default | |
| ------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode` | `"static"` | See [Modes](#modes). Decides the `emitPages` default. |
| `buildId` | minted by the Vite plugin | Names the build; integrations version files by it (`context.buildId`). |
| `pages` | `["/"]` | Seeds: strings, `{ path, filename?, emit? }` entries, or a (async) function returning them. Duplicates collapse to one render. |
| `crawlLinks` | `true` | Follow same-origin links in rendered HTML. The only way dynamic routes are discovered without explicit seeding. |
| `hintHeader` | `"x-prerender"` | Response header naming additional paths (comma-separated). See [Announcing pages](#announcing-pages). |
Expand Down Expand Up @@ -216,9 +221,12 @@ interface PrerenderContext {
skipped: readonly SkippedPage[]; // complete by teardown
files: readonly EmittedFile[]; // what earlier integrations emitted
emitFile(file: { filename: string; contents: string | Uint8Array }): void;
fetch(input: Request | string | URL): Promise<Response>; // through the crawl's transport
}
```

`fetch` sends a request through the crawl's own transport (paced by `interval`, relative urls resolved against `origin`) and returns the raw response — for capturing what the app answers at an address that is not a page, such as a data endpoint, byte-for-byte as a live client would receive it.

`emitFile` is the channel for artifacts produced during the crawl — captured server-function results, extracted payloads, sitemaps. Filenames resolve against the output directory; `../` or an absolute path lands outside it. Throwing from `teardown` fails the run: the place to verify the crawl produced everything the runtime half will need. `redirects()`, `sitemap()` and `report()` above are the shipped examples, each a formatter over the context; [`@solidjs/prerender`](../solid) is the reference integration with a runtime half.

### Utilities
Expand Down
15 changes: 14 additions & 1 deletion packages/crawler/src/crawl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export async function runPrerender(options: RunOptions): Promise<PrerenderResult
transport,
outDir,
mode = "static",
buildId,
crawlLinks = true,
hintHeader = "x-prerender",
filter,
Expand All @@ -73,6 +74,9 @@ export async function runPrerender(options: RunOptions): Promise<PrerenderResult
redirectStubs = !integrations.some(integration => integration.handlesRedirects)
} = options;

if (buildId !== undefined && !/^[\w-]+$/.test(buildId)) {
throw new Error(`buildId must be letters, digits, "-" or "_" (got ${JSON.stringify(buildId)})`);
}
const originUrl = new URL(origin);
const links: LinkOptions = { keepQuery };
const rendered: RenderedPage[] = [];
Expand All @@ -81,13 +85,22 @@ export async function runPrerender(options: RunOptions): Promise<PrerenderResult
const emitted: EmittedFile[] = [];
const context: PrerenderContext = {
mode,
buildId,
origin,
outDir,
pages: rendered,
redirects,
skipped,
files: emitted,
emitFile: file => void emitted.push(file)
emitFile: file => void emitted.push(file),
// integrations reach the app through the crawl's transport, paced like
// a page so a throttle protecting rate-limited APIs covers them too
async fetch(input) {
await pace();
return transport.fetch(
input instanceof Request ? input : new Request(new URL(String(input), originUrl))
);
}
};
// A query spelling without a filename of its own has nowhere correct to
// go: `posts/index.html` is `/posts`, and a static host serves it for
Expand Down
22 changes: 22 additions & 0 deletions packages/crawler/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ export type PrerenderMode = "static" | "hybrid";
*/
export interface PrerenderContext {
mode: PrerenderMode;
/** The run's `buildId`, when it has one — see `PrerenderOptions.buildId`. */
buildId: string | undefined;
origin: string;
outDir: string;
/** Every page rendered so far — complete by `teardown`. Live view; do not mutate. */
Expand All @@ -128,6 +130,16 @@ export interface PrerenderContext {
* it (a build report that should not deploy, say).
*/
emitFile(file: EmittedFile): void;
/**
* Sends a request through the crawl's own transport — the same app the
* pages render from, paced by `interval` like a page fetch — and returns
* the raw response: no redirect handling, no page bookkeeping, nothing
* written. A relative url resolves against `origin`. For integrations
* that capture what the app answers at an address other than a page (a
* data endpoint, a feed), so the artifact is byte-for-byte the live
* answer.
*/
fetch(input: Request | string | URL): Promise<Response>;
}

/** A hook bundle participating in the run without owning any of it. */
Expand Down Expand Up @@ -162,6 +174,16 @@ export interface PrerenderIntegration {
export interface PrerenderOptions {
/** See `PrerenderMode`. Decides the `emitPages` default. @default "static" */
mode?: PrerenderMode;
/**
* Names this build, for integrations that version what they emit by it
* (`_static/<buildId>/…`): files of one build never share a URL with the
* previous build's, so they can be cached as immutable and a deploy never
* serves a stale copy from a CDN or browser cache. Letters, digits, `-`
* and `_`. The Vite plugin mints one per build (unless given) and defines
* it as `import.meta.env.PRERENDER_BUILD` for the client. Absent, files
* are unversioned.
*/
buildId?: string;
/** Seed pages. @default ["/"] */
pages?: PagesSource;
/**
Expand Down
30 changes: 27 additions & 3 deletions packages/crawler/src/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
// Pages and integration-emitted files land in the client output.
// 2. Posture: `import.meta.env.PRERENDER_MODE` is defined in every build
// environment so runtime code can ask "am I a prerendered build, and of
// which kind" — the one bit of client-side knowledge integrations need.
// which kind" — the one bit of client-side knowledge integrations need —
// and `import.meta.env.PRERENDER_BUILD` names the build, so files an
// integration versions by it resolve to the same urls on both sides.
// Absent (dev, or a build without this plugin) means "live".
import path from "node:path";
import type { Plugin } from "vite";
Expand All @@ -41,6 +43,8 @@ export type * from "./types.ts";

/** The `import.meta.env` key the plugin defines with the build's `PrerenderMode`. */
export const PRERENDER_MODE_ENV = "PRERENDER_MODE";
/** The `import.meta.env` key the plugin defines with the build's `buildId`. */
export const PRERENDER_BUILD_ENV = "PRERENDER_BUILD";

export interface PrerenderPluginOptions extends PrerenderOptions {
/**
Expand All @@ -67,6 +71,11 @@ export interface PrerenderPluginOptions extends PrerenderOptions {
*/
export function prerender(options: PrerenderPluginOptions = {}): Plugin {
const mode = options.mode ?? "static";
// One id for every environment and the crawl: the client derives artifact
// urls from it, the integrations write under it. Minted per process, not
// per plugin instance: a build may evaluate the config (and so call
// `prerender()`) more than once, and every instance must agree.
const buildId = options.buildId ?? (processBuild[BUILD_ID] ??= mintBuildId());

return {
name: "prerender",
Expand All @@ -76,7 +85,12 @@ export function prerender(options: PrerenderPluginOptions = {}): Plugin {
// too, so `import.meta.env.PRERENDER_MODE` and `import.meta.env`
// destructuring both see it. Every environment gets it: the server
// build may legitimately ask as well.
return { define: { [`import.meta.env.${PRERENDER_MODE_ENV}`]: JSON.stringify(mode) } };
return {
define: {
[`import.meta.env.${PRERENDER_MODE_ENV}`]: JSON.stringify(mode),
[`import.meta.env.${PRERENDER_BUILD_ENV}`]: JSON.stringify(buildId)
}
};
},
buildApp: {
order: "post",
Expand Down Expand Up @@ -115,7 +129,13 @@ export function prerender(options: PrerenderPluginOptions = {}): Plugin {
);
}
const { serverEntry: _entry, ...crawl } = options;
const result = await runPrerender({ ...crawl, mode, transport, outDir: clientOut });
const result = await runPrerender({
...crawl,
mode,
buildId,
transport,
outDir: clientOut
});

const written = result.pages.filter(page => page.emitted).length;
const redirected = result.redirects.length
Expand All @@ -133,4 +153,8 @@ export function prerender(options: PrerenderPluginOptions = {}): Plugin {
};
}

const BUILD_ID = Symbol.for("prerender-crawler.buildId");
const processBuild = globalThis as { [BUILD_ID]?: string };
const mintBuildId = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 8);

const describe = (error: unknown) => (error instanceof Error ? error.message : String(error));
56 changes: 56 additions & 0 deletions packages/crawler/test/crawl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,62 @@ describe("crawl", () => {
expect(await readFile(join(outDir, "_static/a1.txt"), "utf8")).toBe("artifact");
});

it("lets integrations fetch through the crawl's transport", async () => {
const { transport, requests } = site({
"/": html("index"),
"/api/data": new Response('{"n":1}', { headers: { "x-format": "json" } })
});
let answered: { status: number; format: string | null; body: string } | undefined;
const result = await runPrerender({
transport,
outDir: await makeOutDir(),
integrations: [
{
name: "capture",
async teardown(context) {
const response = await context.fetch("/api/data");
answered = {
status: response.status,
format: response.headers.get("x-format"),
body: await response.text()
};
}
}
]
});
expect(answered).toEqual({ status: 200, format: "json", body: '{"n":1}' });
expect(requests).toEqual(["/", "/api/data"]);
// a raw request, not a page: nothing rendered or written for it
expect(result.pages.map(page => page.path)).toEqual(["/"]);
});

it("hands integrations the build id, and rejects one that is not path-safe", async () => {
const seen: (string | undefined)[] = [];
const probe = {
name: "probe",
setup: (context: { buildId?: string }) => void seen.push(context.buildId)
};
await runPrerender({
transport: site({ "/": html("a") }).transport,
outDir: await makeOutDir(),
integrations: [probe]
});
await runPrerender({
transport: site({ "/": html("a") }).transport,
outDir: await makeOutDir(),
buildId: "b1",
integrations: [probe]
});
expect(seen).toEqual([undefined, "b1"]);
await expect(
runPrerender({
transport: site({ "/": html("a") }).transport,
outDir: await makeOutDir(),
buildId: "../x"
})
).rejects.toThrow(/buildId/);
});

it("renders without writing when emission is off, per-entry flags override", async () => {
const { transport, requests } = site({
"/": html(`<a href="/data-only">d</a>`),
Expand Down
Loading
Loading