diff --git a/.changeset/static-wire-answers.md b/.changeset/static-wire-answers.md
new file mode 100644
index 0000000..0482afe
--- /dev/null
+++ b/.changeset/static-wire-answers.md
@@ -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 => `) 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//…`), so they can be cached as immutable and a deploy never serves the previous build's answer from a CDN.
+
+Breaking: artifacts are `_static//..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`.
diff --git a/examples/ssg/package.json b/examples/ssg/package.json
index 4ef0c73..1ebdb8b 100644
--- a/examples/ssg/package.json
+++ b/examples/ssg/package.json
@@ -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"
}
diff --git a/examples/ssg/src/data.ts b/examples/ssg/src/data.ts
index fe4ccda..f219f64 100644
--- a/examples/ssg/src/data.ts
+++ b/examples/ssg/src/data.ts
@@ -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.
diff --git a/packages/crawler/README.md b/packages/crawler/README.md
index 194be52..f548fe0 100644
--- a/packages/crawler/README.md
+++ b/packages/crawler/README.md
@@ -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:
@@ -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). |
@@ -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; // 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
diff --git a/packages/crawler/src/crawl.ts b/packages/crawler/src/crawl.ts
index 492e23a..305316a 100644
--- a/packages/crawler/src/crawl.ts
+++ b/packages/crawler/src/crawl.ts
@@ -53,6 +53,7 @@ export async function runPrerender(options: RunOptions): Promise 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[] = [];
@@ -81,13 +85,22 @@ export async function runPrerender(options: RunOptions): Promise 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
diff --git a/packages/crawler/src/types.ts b/packages/crawler/src/types.ts
index 3f32e3a..3b46d17 100644
--- a/packages/crawler/src/types.ts
+++ b/packages/crawler/src/types.ts
@@ -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. */
@@ -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;
}
/** A hook bundle participating in the run without owning any of it. */
@@ -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//…`): 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;
/**
diff --git a/packages/crawler/src/vite.ts b/packages/crawler/src/vite.ts
index fe0b762..5c752af 100644
--- a/packages/crawler/src/vite.ts
+++ b/packages/crawler/src/vite.ts
@@ -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";
@@ -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 {
/**
@@ -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",
@@ -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",
@@ -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
@@ -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));
diff --git a/packages/crawler/test/crawl.test.ts b/packages/crawler/test/crawl.test.ts
index 575d286..f3a520d 100644
--- a/packages/crawler/test/crawl.test.ts
+++ b/packages/crawler/test/crawl.test.ts
@@ -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(`d `),
diff --git a/packages/solid/README.md b/packages/solid/README.md
index 0b95826..e5cf62b 100644
--- a/packages/solid/README.md
+++ b/packages/solid/README.md
@@ -2,7 +2,7 @@
Static server functions for Solid. `prerendered()` declares that a `'use server'` function runs at build time — during prerendering — and that each call's result is captured as a static artifact the deployed client fetches instead of calling a server. Pair it with [`prerender-crawler`](../crawler) and `vite build` produces a folder of files that is a complete Solid app with typed server-side data loading and no server.
-Requires `@solidjs/web` 2 (server functions) and `@solidjs/vite-plugin` with `serverFunctions: true`.
+Requires `@solidjs/web` 2.0.0-rc.16 or later (server functions) and `@solidjs/vite-plugin` with `serverFunctions: true`.
## Setup
@@ -39,8 +39,9 @@ export const getPost = query(
```
- **Implies `GET`.** A prerendered call is by definition a safe read. Outside a prerendered build — the dev server, or a build without the prerender plugin — the reference behaves exactly like `GET(fn)`, so the app still works against a live server. Dev stays fullstack; production is static.
-- **Arguments are the address.** Each call identity (function id + arguments) becomes one artifact under `_static/`. Both the build and the client derive the key from the arguments, so they must be JSON-safe and spell identically in both realms.
-- **Results are unrestricted.** JSON-safe results are stored as plain JSON; rich values (Dates, Maps, typed errors) ride the server-function codec into the artifact, so whatever survives a live call survives the artifact.
+- **Arguments are the address.** Each call identity (function id + arguments) becomes one artifact under `_static//`, versioned by the build so a deploy never shares an artifact URL with the previous one (serve `_static/` as immutable). Both the build and the client derive the key from the arguments, so they must be JSON-safe and spell identically in both realms.
+- **Results are unrestricted.** The artifact is the live answer: the build asks the app's own server-function endpoint for each call it captured and stores the response, headers included. Plain data, rich values (Dates, Maps, typed errors) and server components (`return props => `) all come back exactly as a live call would deliver them.
+- **The reference is the runtime's own.** In a prerendered build the client still calls through Solid's transport, so hydration adopts server-rendered reads and server components without a request; only the send is answered from `_static/` (see [Your transport `fetch`](#your-transport-fetch)).
- **Only calls the build made have artifacts.** In a static build, a client calling with arguments no prerendered page used gets a rejected call (there is no server). In a hybrid build the call falls back to the live server.
Design pages so the crawl exercises the calls the site needs — which happens naturally when pages link to what they use.
@@ -75,7 +76,7 @@ The enumerators behind it are exported too — `solidRouterPages(router | routes
The integration has two jobs.
-**Capture.** For the crawl's duration every executed `prerendered` call is collected (the crawl is in-process, so the built server bundle and the integration share a realm), encoded once per call identity, and emitted alongside the pages.
+**Capture.** For the crawl's duration every executed `prerendered` call is collected (the crawl is in-process, so the built server bundle and the integration share a realm), once per call identity. After the crawl each one's GET address is requested through the crawl's transport (`context.fetch`), and the endpoint's answer — status, headers, body — is emitted alongside the pages. Each captured call therefore runs once more at the end of the build.
**The guard.** `@solidjs/vite-plugin` records every server function the client build can dispatch. After the crawl, any of those ids nothing captured is a call a static deployment cannot answer — because the function lacks `prerendered()`, or because no prerendered page called it. In static mode the build fails naming each one:
@@ -85,10 +86,9 @@ The integration has two jobs.
A static deployment has no server to answer them, so these calls fail at runtime. ...
```
-| Option | Default | |
-| ------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
-| `uncaptured` | `"error"` static / `"ignore"` hybrid | `"error"`, `"warn"`, or `"ignore"`. |
-| `codec` | | Codec options for encoding rich results; must match the client's `configureServerFunctionsClient({ codec })`. |
+| Option | Default | |
+| ------------ | ------------------------------------ | ----------------------------------- |
+| `uncaptured` | `"error"` static / `"ignore"` hybrid | `"error"`, `"warn"`, or `"ignore"`. |
The guard needs a `@solidjs/vite-plugin` that records function ids in `.vite/solid-server-functions.json`; with an older version it warns that it cannot verify and lets the build through.
@@ -99,10 +99,16 @@ The guard needs a `@solidjs/vite-plugin` that records function ids in `.vite/sol
The client learns the posture from `import.meta.env.PRERENDER_MODE`, which the crawler's Vite plugin defines. No plugin, no constant, live behavior.
+## Your transport `fetch`
+
+A prerendered reference declares its own send (`withMeta(fn, { fetch })`): the runtime calls it for that function's requests and passes the app's transport `fetch` as `next`, so your `configureServerFunctionsClient({ fetch })` stays yours and the live fallback in hybrid goes through it. Nothing needs wiring.
+
+A host answering a missing artifact with a 200 page (an SPA fallback, an SSR catch-all) is still a miss: artifacts are recognized by their first line, not by status.
+
## Other exports
- `solidRouterPages(router | routes, { base? })`, `tanstackRouterPages(router)` — the static-page enumerators behind `announceRoutes`, pure and isomorphic.
-- `staticCallKey(id, args)` / `staticArtifactPath(id, args)` — the artifact key derivation, for tooling that needs to locate an artifact.
+- `staticCallKey(id, args)` / `staticArtifactPath(id, args, build?)` — the artifact key derivation (`_static//..txt`), for tooling that needs to locate an artifact.
- Types: `PrerenderedFunction`, `AnnounceableRouter`, `AnnounceRoutesOptions`, `SolidRouterLike`, `SolidRouteLike`, `TanStackRouterLike`, `ServerFunctionsIntegration`, `ServerFunctionsIntegrationOptions`, `CaptureSink` (server).
## License
diff --git a/packages/solid/package.json b/packages/solid/package.json
index 863c3fa..a24ea56 100644
--- a/packages/solid/package.json
+++ b/packages/solid/package.json
@@ -50,16 +50,16 @@
"test:watch": "vitest"
},
"peerDependencies": {
- "@solidjs/web": ">=2.0.0-rc.6",
+ "@solidjs/web": ">=2.0.0-rc.16",
"prerender-crawler": ">=0.1.0"
},
"devDependencies": {
- "@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",
"@tanstack/router-core": "^1.168.0",
"@types/node": "^22.0.0",
"prerender-crawler": "workspace:^",
- "solid-js": "^2.0.0-rc.6",
+ "solid-js": "^2.0.0-rc.16",
"tsup": "^8.5.0",
"typescript": "^5.8.0",
"vitest": "^4.0.0"
diff --git a/packages/solid/src/client.ts b/packages/solid/src/client.ts
index 2d22a83..cc52569 100644
--- a/packages/solid/src/client.ts
+++ b/packages/solid/src/client.ts
@@ -6,21 +6,27 @@
// function — calls dispatch over HTTP to a live server. The app degrades
// to a live-server deployment; nothing breaks, nothing is static.
// - `"static"` / `"hybrid"` (built with `prerender()` from
-// `prerender-crawler/vite`, which defines the constant): the call's artifact
-// key is derived from (id, arguments) — the same derivation the build
-// performed — and the payload is fetched from `_static/` as an ordinary
-// cacheable file. On a miss, hybrid falls back to the live call (a server
-// is deployed); static has nobody to fall back to and throws.
+// `prerender-crawler/vite`, which defines the constant): the reference is
+// STILL the runtime's own GET reference — the call path, hydration
+// adoption, server-component interception and response decoding are all
+// the runtime's. Only the send changes: the reference declares its own
+// (`withMeta(fn, { fetch })`), which answers the call's GET address with
+// its artifact from `_static/` (the live answer the build captured,
+// headers and all) and leaves the app's transport `fetch` alone. On a
+// miss, hybrid sends the original request through the app's send (a
+// server is deployed); static has nobody to fall back to and throws.
import {
GET,
- SERVER_FUNCTION_INVOKE,
- deserializeStream,
getServerFunctionMetadata,
- getServerFunctionsCodec,
isServerFunction,
withMeta
} from "@solidjs/web/server-functions/client";
-import { PRERENDERED_META_KEY, staticArtifactPath } from "./shared.ts";
+import {
+ PRERENDERED_META_KEY,
+ decodeArtifact,
+ parseDataAddress,
+ staticArtifactPathSync
+} from "./shared.ts";
import type { AnnounceRoutesOptions, PrerenderedFunction } from "./shared.ts";
export { staticArtifactPath, staticCallKey } from "./shared.ts";
@@ -46,15 +52,11 @@ export function announceRoutes(
return false;
}
-// the metadata brand rides the same registered symbol the runtime uses, so
-// `isServerFunction` / `getServerFunctionMetadata` recognize prerendered
-// references like any other declaration wrapper's
-const SERVER_FUNCTION_METADATA = Symbol.for("solid.ServerFunctionMetadata");
-
/** The build posture, as `prerender-crawler/vite` defined it — or live when it did not. */
interface Posture {
mode: "static" | "hybrid" | undefined;
base: string;
+ build: string | undefined;
}
// Read at call time, not module scope: a build replaces both expressions
@@ -66,16 +68,66 @@ function posture(): Posture {
const mode = env?.PRERENDER_MODE;
return {
mode: mode === "static" || mode === "hybrid" ? mode : undefined,
- base: typeof env?.BASE_URL === "string" ? env.BASE_URL : "/"
+ base: typeof env?.BASE_URL === "string" ? env.BASE_URL : "/",
+ build: typeof env?.PRERENDER_BUILD === "string" ? env.PRERENDER_BUILD : undefined
};
}
+// Ids declared prerendered in this realm: the only calls answered from
+// artifacts.
+const prerenderedIds = new Set();
+
+type Send = (input: string, init: RequestInit) => Response | Promise;
+
+// The send `prerendered()` declares on its reference: the runtime calls it
+// for that function's requests, with the app's configured send as `next`.
+// The artifact lookup reaches the global `fetch` in the same tick as the
+// send: hydration adopts a server-rendered read only if its request is made
+// synchronously, so keys are derived without an await.
+const referenceSend = (address: string, init: RequestInit, next: Send) =>
+ serveArtifact(address, (init.method ?? "GET").toUpperCase(), init, next) ?? next(address, init);
+
+/** Answers a prerendered call's GET address from its artifact; undefined for any other request. */
+function serveArtifact(
+ url: string,
+ method: string,
+ init: RequestInit,
+ send: Send
+): Promise | undefined {
+ const { mode, base, build } = posture();
+ const call = mode && method === "GET" ? parseDataAddress(url) : undefined;
+ if (!call || !prerenderedIds.has(call.id)) return;
+ let path: string;
+ try {
+ path = staticArtifactPathSync(call.id, call.args, build);
+ } catch {
+ return; // arguments that cannot address an artifact
+ }
+ const artifactUrl = base.endsWith("/") ? base + path : `${base}/${path}`;
+ const signal = init.signal ?? undefined;
+ return globalThis.fetch(artifactUrl, signal ? { signal } : undefined).then(async response => {
+ const answer = response.ok
+ ? decodeArtifact(new Uint8Array(await response.arrayBuffer()))
+ : undefined;
+ if (answer) return answer;
+ // hybrid deployed a live server: a call the build never made is an
+ // ordinary GET dispatch, not a failure. Static has nobody to ask.
+ if (mode === "hybrid") return send(url, init);
+ throw new Error(
+ `No static artifact for this call of "${call.id}" (${artifactUrl} answered ` +
+ `${response.status}${response.ok ? " without an artifact" : ""}): only calls made ` +
+ `during prerendering are captured. Prerender a page that performs this call (same ` +
+ `arguments), or make the function a live server function.`
+ );
+ });
+}
+
/**
* Declares a server function PRERENDERED: it runs at build time, during
- * prerendering, and each call's result is captured as a static JSON
- * artifact addressed by the call's identity (function id + arguments).
- * Clients of the built site fetch the artifact instead of invoking a
- * server — a static deploy serves typed, codec-faithful data with no
+ * prerendering, and each call's answer is captured as a static artifact
+ * addressed by the call's identity (function id + arguments). Clients of
+ * the built site fetch the artifact instead of invoking a server — a static
+ * deploy serves typed, codec-faithful data, and server components, with no
* runtime server at all.
*
* The declaration implies `GET`: a prerendered call is by definition a
@@ -86,8 +138,9 @@ function posture(): Posture {
*
* Arguments must be JSON-safe: they are the call's ADDRESS (both the
* build and the client derive the artifact key from them), so they must
- * spell identically in both realms. Results are unrestricted — rich values
- * (Dates, Maps, typed errors) ride the codec into the artifact.
+ * spell identically in both realms. Results are unrestricted — anything a
+ * live call can answer (rich values through the codec, a server component's
+ * markup) is what the artifact holds.
*
* Only calls that actually happen during prerendering have artifacts. In a
* static build, a client calling with arguments no prerendered page used
@@ -111,52 +164,13 @@ export function prerendered(
// builds; an already-GET-declared reference is adopted as-is.
const source: any =
getServerFunctionMetadata(fn)?.method === "GET" ? fn : GET(fn as (...args: any[]) => any);
-
- if (posture().mode === undefined) {
- // live posture: the reference IS the GET reference, branded so
- // integrations can still detect the declaration
- return withMeta(source, { [PRERENDERED_META_KEY]: true }) as PrerenderedFunction >;
+ const meta: Record = { [PRERENDERED_META_KEY]: true };
+ if (posture().mode !== undefined) {
+ prerenderedIds.add(source.id);
+ // the reference's own send: the app's transport `fetch` stays the app's
+ meta.fetch = referenceSend;
}
-
- const id: string = source.id;
- const run = async (args: A, options?: { signal?: AbortSignal }) => {
- const { mode, base } = posture();
- const path = await staticArtifactPath(id, args);
- const url = base.endsWith("/") ? base + path : `${base}/${path}`;
- const response = await fetch(url, options?.signal ? { signal: options.signal } : undefined);
- if (!response.ok) {
- // hybrid deployed a live server: a call the build never made is an
- // ordinary GET dispatch, not a failure — artifact-first, live
- // fallback. Static has nobody to fall back to.
- if (mode === "hybrid") {
- return source[SERVER_FUNCTION_INVOKE](args, options) as Promise>;
- }
- throw new Error(
- `No static artifact for this call of "${id}" (${response.status} at ${url}): only calls ` +
- `made during prerendering are captured. Prerender a page that performs this call ` +
- `(same arguments), or make the function a live server function.`
- );
- }
- const text = await response.text();
- if (text === "") return undefined as Awaited;
- // the payload mirrors the wire's format negotiation: plain JSON for
- // JSON-safe results, the codec's framed encoding (`;0x` prefix, which
- // valid JSON can never open with) for everything else
- return text.startsWith(";0x")
- ? ((await deserializeStream(new Response(text), getServerFunctionsCodec())) as Awaited)
- : (JSON.parse(text) as Awaited);
- };
-
- const wrapped = ((...args: A) => run(args)) as any;
- wrapped[SERVER_FUNCTION_METADATA] = {
- ...getServerFunctionMetadata(source),
- [PRERENDERED_META_KEY]: true
- };
- wrapped[SERVER_FUNCTION_INVOKE] = run;
- wrapped.id = id;
- // lazy, like the runtime's own references: the endpoint may be configured
- // after module scope runs — and in a prerendered build it names the live
- // address the artifact stands in for
- Object.defineProperty(wrapped, "url", { get: () => source.url, configurable: true });
- return wrapped as PrerenderedFunction>;
+ // the reference IS the runtime's GET reference, branded so integrations
+ // can detect the declaration
+ return withMeta(source, meta) as PrerenderedFunction >;
}
diff --git a/packages/solid/src/integration.ts b/packages/solid/src/integration.ts
index 69767bd..445eb60 100644
--- a/packages/solid/src/integration.ts
+++ b/packages/solid/src/integration.ts
@@ -7,11 +7,13 @@
// 1. Capture. A sink installed for the crawl's duration (a registered-symbol
// global — the crawl is in-process, so the built server bundle and this
// module share a realm) collects every `prerendered` call the renders
-// execute, encodes each first-seen call once, and emits the artifacts
-// after the crawl. Encoding mirrors the wire's format negotiation:
-// JSON-safe results as plain JSON, everything else through the codec's
-// framed encoding (`;0x` prefix), which is exactly what the client half
-// sniffs.
+// execute, one per call identity. After the crawl each one's GET address
+// is replayed through the app (`context.fetch`, the crawl's own
+// transport) and the endpoint's answer — status, headers, body — is the
+// artifact. Storing the live answer rather than encoding the value here
+// means the server's own result policies run: codec payloads, and
+// server components, whose value is a render function only the frames
+// transform turns into a body.
// 2. The guard. `@solidjs/vite-plugin` records every server function the
// CLIENT build can dispatch (by wire id) in its persisted manifest. After
// the crawl, ids nothing captured are calls a static deployment has no
@@ -20,17 +22,10 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import type { PrerenderContext, PrerenderIntegration } from "prerender-crawler";
-import { CAPTURE_SINK, canonicalJSON, staticArtifactPath } from "./shared.ts";
+import { CAPTURE_SINK, encodeArtifact, staticArtifactPathSync } from "./shared.ts";
import type { CaptureSink } from "./shared.ts";
export interface ServerFunctionsIntegrationOptions {
- /**
- * Codec options for encoding artifacts that carry rich (non-JSON-safe)
- * results. Must match the CLIENT's configured codec
- * (`configureServerFunctionsClient({ codec })`) — the artifact is decoded
- * there. Plain JSON-safe results never touch the codec.
- */
- codec?: unknown;
/**
* What to do when the client build references server functions the crawl
* never captured — calls a static deployment cannot answer (no server),
@@ -71,33 +66,39 @@ interface PersistedManifest {
export function serverFunctions(
options: ServerFunctionsIntegrationOptions = {}
): ServerFunctionsIntegration {
- const artifacts = new Map>();
+ // artifact filename -> the GET address its call requests; one entry per
+ // call identity however many pages make the call
+ const calls = new Map();
const captured = new Set();
const globals = globalThis as { [CAPTURE_SINK]?: CaptureSink };
return {
name: "solid:server-functions",
captured,
- setup() {
+ setup(context) {
globals[CAPTURE_SINK] = {
- async capture(id, args, value) {
+ capture(id, args, _value, url) {
captured.add(id);
- const filename = await staticArtifactPath(id, args);
- // one call identity, one artifact: the same call captured on many
- // pages encodes once, and every capture awaits the same settle
- let payload = artifacts.get(filename);
- if (!payload) {
- payload = encodeArtifact(value, options.codec);
- artifacts.set(filename, payload);
- }
- await payload;
+ const filename = staticArtifactPathSync(id, args, context.buildId);
+ if (!calls.has(filename)) calls.set(filename, url);
}
};
},
async teardown(context) {
+ // removed before replaying: a replay runs the function again, and
+ // that run is not a page's call
delete globals[CAPTURE_SINK];
guard(context, captured, options.uncaptured);
- for (const [filename, payload] of artifacts) {
- context.emitFile({ filename, contents: await payload });
+ for (const [filename, url] of calls) {
+ const response = await context.fetch(url);
+ if (!response.ok) {
+ throw new Error(
+ `[@solidjs/prerender] capturing ${url} for ${filename} failed: the server-function ` +
+ `endpoint answered ${response.status}. The call succeeded during the page render, ` +
+ `so its answer at the GET address should too — check middleware that treats ` +
+ `the crawl's requests differently.`
+ );
+ }
+ context.emitFile({ filename, contents: await encodeArtifact(response) });
}
}
};
@@ -138,23 +139,3 @@ function guard(
if (policy === "error") throw new Error(message);
console.warn(message);
}
-
-async function encodeArtifact(value: unknown, codec: unknown): Promise {
- if (value === undefined) return "";
- try {
- // canonical form doubles as the JSON-safety guard: it throws on
- // anything a JSON round trip would reshape
- return canonicalJSON(value);
- } catch {
- // rich results ride the codec's framed encoding — the same bytes the
- // wire would carry, so whatever survives a live call survives the
- // artifact. Imported lazily: plain-data builds never load the codec.
- const { serializeResponseStream } = await import("@solidjs/web/server-functions/server");
- return await new Response(
- (serializeResponseStream as (v: unknown, c?: unknown) => ReadableStream)(
- value,
- codec
- )
- ).text();
- }
-}
diff --git a/packages/solid/src/server.ts b/packages/solid/src/server.ts
index ef95b52..54dfec7 100644
--- a/packages/solid/src/server.ts
+++ b/packages/solid/src/server.ts
@@ -2,15 +2,16 @@
// the GET-declared reference with one extra behavior: while a capture sink
// is installed (the prerender integration installs one for the duration of
// the crawl — see ./integration.ts), every executed call is delivered to it as
-// (id, args, settled value), and the delivery is AWAITED so a page's
-// render does not finish before its artifacts are safely captured.
+// (id, args, settled value, GET address), and the delivery is AWAITED so a
+// page's render does not finish before its artifacts are safely captured.
// Without a sink — dev SSR, a live production server — the wrapper is
// call-through: the function runs in-process like any direct SSR call.
import {
GET,
SERVER_FUNCTION_INVOKE,
getServerFunctionMetadata,
- isServerFunction
+ isServerFunction,
+ serverFunctionUrl
} from "@solidjs/web/server-functions/server";
import { getRequestEvent } from "@solidjs/web";
import type { RequestEvent, ResponseStub } from "@solidjs/web";
@@ -77,6 +78,8 @@ const isTanStackRouter = (router: AnnounceableRouter): router is TanStackRouterL
const SERVER_FUNCTION_METADATA = Symbol.for("solid.ServerFunctionMetadata");
+const addressOf = serverFunctionUrl as (fn: unknown, ...args: readonly unknown[]) => string;
+
/**
* Declares a server function PRERENDERED — the server half. Calling the
* reference during SSR runs the function in-process exactly like a direct
@@ -106,8 +109,9 @@ export function prerendered(
const value = await source[SERVER_FUNCTION_INVOKE](args, options);
const sink = (globalThis as { [CAPTURE_SINK]?: CaptureSink })[CAPTURE_SINK];
// awaited so the render that triggered the call cannot outrun the
- // capture — the prerender run's teardown sees every artifact settled
- if (sink) await sink.capture(id, args, value);
+ // capture. The address is resolved here, in the app's realm, where the
+ // endpoint is configured: it is what the client's own call requests.
+ if (sink) await sink.capture(id, args, value, addressOf(source, ...args));
return value as Awaited;
};
diff --git a/packages/solid/src/shared.ts b/packages/solid/src/shared.ts
index 5381117..d7f5f46 100644
--- a/packages/solid/src/shared.ts
+++ b/packages/solid/src/shared.ts
@@ -4,7 +4,11 @@
// halves derive the artifact's filename from it INDEPENDENTLY — the client
// never learns what the build wrote — so derivation must be deterministic
// across realms: canonical-JSON the arguments (sorted keys), hash with
-// SHA-256, done. Everything here is dependency-free and isomorphic.
+// SHA-256, done. The artifact itself is the live answer: the response the
+// server-function endpoint gave for the call's GET address, status and
+// headers included, so the client's own transport decodes it exactly as it
+// would a live call — plain data, codec payloads and server-component frames
+// alike. Everything here is dependency-free and isomorphic.
/** Directory (under the static output root) where artifacts land. */
export const ARTIFACT_DIR = "_static";
@@ -19,9 +23,14 @@ export const ARTIFACT_DIR = "_static";
*/
export const CAPTURE_SINK = Symbol.for("solid-prerender.captureArtifacts");
-/** What the server wrapper hands the sink per executed static call. */
+/**
+ * What the server wrapper hands the sink per executed static call: the
+ * call's identity, its settled value, and the GET address a client's call
+ * requests — what the integration replays through the app to capture the
+ * live answer.
+ */
export interface CaptureSink {
- capture(id: string, args: readonly unknown[], value: unknown): void | Promise;
+ capture(id: string, args: readonly unknown[], value: unknown, url: string): void | Promise;
}
/**
@@ -88,28 +97,201 @@ function notJSONSafe(what: string): Error {
/**
* The artifact key of a static call: 128 bits of SHA-256 over the call's
- * canonical spelling, hex-encoded. Async because hashing is
- * (`crypto.subtle` is the one SHA-256 both realms share).
+ * canonical spelling, hex-encoded. Synchronous on purpose: the client
+ * derives it inside the transport's send, and hydration adopts a
+ * server-rendered read only if that send reaches `fetch` in the same tick
+ * (`crypto.subtle` would put an await in between).
*/
-export async function staticCallKey(id: string, args: readonly unknown[]): Promise {
- const spelling = canonicalJSON([id, ...args]);
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(spelling));
- const bytes = new Uint8Array(digest).subarray(0, 16);
+export function staticCallKeySync(id: string, args: readonly unknown[]): string {
+ const bytes = sha256(new TextEncoder().encode(canonicalJSON([id, ...args]))).subarray(0, 16);
let hex = "";
for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
return hex;
}
+/** `staticCallKeySync`, as a promise (the original public shape). */
+export async function staticCallKey(id: string, args: readonly unknown[]): Promise {
+ return staticCallKeySync(id, args);
+}
+
/**
* The artifact's path relative to the static output root:
- * `_static/..json`. The label is a sanitized slice of the
- * function id — for humans reading a build output or a network tab; the
- * key alone carries the identity.
+ * `_static//..txt`. The build directory (the crawl's
+ * `buildId`, `import.meta.env.PRERENDER_BUILD` on the client) keeps every
+ * deploy's artifacts at urls of their own, so they cache as immutable; a
+ * build without one writes `_static/..txt`. The label is a
+ * sanitized slice of the function id — for humans reading a build output
+ * or a network tab; the key alone carries the identity. `.txt` so static
+ * hosts serve it as text, which they compress.
*/
-export async function staticArtifactPath(id: string, args: readonly unknown[]): Promise {
- const key = await staticCallKey(id, args);
+export function staticArtifactPathSync(
+ id: string,
+ args: readonly unknown[],
+ build?: string
+): string {
+ const key = staticCallKeySync(id, args);
const label = id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 48);
- return `${ARTIFACT_DIR}/${label ? label + "." : ""}${key}.json`;
+ return `${ARTIFACT_DIR}/${build ? build + "/" : ""}${label ? label + "." : ""}${key}.txt`;
+}
+
+/** `staticArtifactPathSync`, as a promise (the original public shape). */
+export async function staticArtifactPath(
+ id: string,
+ args: readonly unknown[],
+ build?: string
+): Promise {
+ return staticArtifactPathSync(id, args, build);
+}
+
+// SHA-256 (FIPS 180-4), synchronous and dependency-free — see
+// `staticCallKeySync` for why `crypto.subtle` will not do. Keys are tiny
+// (a function id plus JSON arguments), so speed is irrelevant.
+const K = new Uint32Array([
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
+]);
+
+export function sha256(message: Uint8Array): Uint8Array {
+ const bitLength = message.length * 8;
+ const padded = new Uint8Array(((message.length + 9 + 63) >> 6) << 6);
+ padded.set(message);
+ padded[message.length] = 0x80;
+ const view = new DataView(padded.buffer);
+ view.setUint32(padded.length - 8, Math.floor(bitLength / 0x100000000));
+ view.setUint32(padded.length - 4, bitLength >>> 0);
+ const h = new Uint32Array([
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19
+ ]);
+ const w = new Uint32Array(64);
+ const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
+ for (let offset = 0; offset < padded.length; offset += 64) {
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);
+ for (let i = 16; i < 64; i++) {
+ const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
+ const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
+ w[i] = w[i - 16] + s0 + w[i - 7] + s1;
+ }
+ let [a, b, c, d, e, f, g, hh] = h;
+ for (let i = 0; i < 64; i++) {
+ const t1 = hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i];
+ const t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c));
+ hh = g;
+ g = f;
+ f = e;
+ e = (d + t1) >>> 0;
+ d = c;
+ c = b;
+ b = a;
+ a = (t1 + t2) >>> 0;
+ }
+ h[0] += a;
+ h[1] += b;
+ h[2] += c;
+ h[3] += d;
+ h[4] += e;
+ h[5] += f;
+ h[6] += g;
+ h[7] += hh;
+ }
+ const out = new Uint8Array(32);
+ const outView = new DataView(out.buffer);
+ for (let i = 0; i < 8; i++) outView.setUint32(i * 4, h[i]);
+ return out;
+}
+
+/**
+ * The first line of every artifact. A static host answers a path it has no
+ * file for with whatever it likes — a 404, but just as often a 200 SPA
+ * index or an SSR page — so the client recognizes an artifact by this
+ * marker, never by status.
+ */
+const ARTIFACT_MARKER = "solid-prerender-artifact/1 ";
+
+// Transport-level headers the stored answer must not replay: the body is
+// stored decoded and whole, and cookies are per-visitor.
+const DROPPED_HEADERS = new Set([
+ "content-encoding",
+ "content-length",
+ "transfer-encoding",
+ "connection",
+ "keep-alive",
+ "date",
+ "set-cookie"
+]);
+
+/**
+ * Encodes a live answer as an artifact: the marker line carrying status and
+ * headers as JSON, then the body bytes untouched.
+ */
+export async function encodeArtifact(response: Response): Promise {
+ const headers: [string, string][] = [];
+ response.headers.forEach((value, name) => {
+ if (!DROPPED_HEADERS.has(name.toLowerCase())) headers.push([name, value]);
+ });
+ const head = new TextEncoder().encode(
+ ARTIFACT_MARKER + JSON.stringify({ status: response.status, headers }) + "\n"
+ );
+ const body = new Uint8Array(await response.arrayBuffer());
+ const out = new Uint8Array(head.length + body.length);
+ out.set(head);
+ out.set(body, head.length);
+ return out;
+}
+
+/**
+ * Decodes an artifact back into the Response the live endpoint gave, or
+ * undefined when the bytes are not an artifact (a host's fallback page).
+ */
+export function decodeArtifact(bytes: Uint8Array): Response | undefined {
+ const marker = new TextEncoder().encode(ARTIFACT_MARKER);
+ if (bytes.length < marker.length) return undefined;
+ for (let i = 0; i < marker.length; i++) if (bytes[i] !== marker[i]) return undefined;
+ const newline = bytes.indexOf(0x0a);
+ if (newline < 0) return undefined;
+ let head: { status: number; headers: [string, string][] };
+ try {
+ head = JSON.parse(new TextDecoder().decode(bytes.subarray(marker.length, newline)));
+ } catch {
+ return undefined;
+ }
+ return new Response(bytes.slice(newline + 1), { status: head.status, headers: head.headers });
+}
+
+/**
+ * Reads a call's identity back off its GET address
+ * (`/data/[?args=]`, as `serverFunctionUrl` and the
+ * transport spell it). Undefined for anything else — a POST-encoded call,
+ * a live address, arguments that are not plain JSON.
+ */
+export function parseDataAddress(url: string): { id: string; args: unknown[] } | undefined {
+ let parsed: URL;
+ try {
+ parsed = new URL(url, "http://localhost");
+ } catch {
+ return undefined;
+ }
+ const match = /\/data\/([^/]+)$/.exec(parsed.pathname);
+ if (!match) return undefined;
+ let id: string;
+ try {
+ id = decodeURIComponent(match[1]);
+ } catch {
+ return undefined;
+ }
+ const encoded = parsed.searchParams.get("args");
+ if (encoded === null) return { id, args: [] };
+ try {
+ const args = JSON.parse(encoded);
+ return Array.isArray(args) ? { id, args } : undefined;
+ } catch {
+ return undefined;
+ }
}
/**
diff --git a/packages/solid/test/prerendered.test.ts b/packages/solid/test/prerendered.test.ts
index 48f1e6d..f90252c 100644
--- a/packages/solid/test/prerendered.test.ts
+++ b/packages/solid/test/prerendered.test.ts
@@ -1,8 +1,9 @@
// The full prerendered-function story, without Vite: the server half
// captures during a real prerender-crawler crawl (through the capture
-// integration the plugin installs), artifacts land on disk, and the client
-// half — flipped to the static posture — reads them back, rich types
-// included.
+// integration the plugin installs), the integration replays each call
+// through the app's real server-function endpoint, artifacts land on disk,
+// and the client half — flipped to the static posture — reads them back
+// through the runtime's own transport, rich types included.
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -11,15 +12,19 @@ import { runPrerender } from "prerender-crawler";
import {
createServerReference as createServerSideReference,
getServerFunctionMetadata,
+ handleServerFunctionRequest,
registerServerReference
} from "@solidjs/web/server-functions/server";
-import { createServerReference as createClientReference } from "@solidjs/web/server-functions/client";
+import {
+ configureServerFunctionsClient,
+ createServerReference as createClientReference
+} from "@solidjs/web/server-functions/client";
import { createRequestEvent } from "@solidjs/web";
import { provideRequestEvent } from "@solidjs/web/storage";
import { prerendered as prerenderedServer } from "../src/server.ts";
import { prerendered as prerenderedClient } from "../src/client.ts";
import { serverFunctions } from "../src/integration.ts";
-import { CAPTURE_SINK, staticArtifactPath } from "../src/shared.ts";
+import { CAPTURE_SINK, decodeArtifact, encodeArtifact, staticArtifactPath } from "../src/shared.ts";
let ids = 0;
const nextId = (label: string) => `test/prerendered.ts#${label}${ids++}`;
@@ -35,6 +40,7 @@ const globals = globalThis as { [CAPTURE_SINK]?: unknown };
afterEach(() => {
delete globals[CAPTURE_SINK];
+ configureServerFunctionsClient({ fetch: null });
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
@@ -53,7 +59,14 @@ describe("prerendered (server half)", () => {
const captured: unknown[][] = [];
globals[CAPTURE_SINK] = { capture: (...entry: unknown[]) => void captured.push(entry) };
await withEvent(() => getTeam("design"));
- expect(captured).toEqual([[id, ["design"], { dept: "design", size: 3 }]]);
+ expect(captured).toEqual([
+ [
+ id,
+ ["design"],
+ { dept: "design", size: 3 },
+ `/_server/data/${encodeURIComponent(id)}?args=${encodeURIComponent('["design"]')}`
+ ]
+ ]);
});
it("awaits the sink before returning, and captures nothing for thrown calls", async () => {
@@ -121,6 +134,57 @@ describe("prerendered (client half, hybrid posture)", () => {
expect(requested[1]).toBe(`/_server/data/${encodeURIComponent(id)}`); // then live
});
+ it("keeps serving artifacts when the app configures its own fetch afterwards", async () => {
+ posture("hybrid");
+ const id = nextId("appFetch");
+ const artifact = await encodeArtifact(
+ new Response(JSON.stringify({ baked: true }), {
+ headers: { "X-Server-Function-Format": "8" }
+ })
+ );
+ vi.stubGlobal("fetch", async () => new Response(artifact));
+ const ref = prerenderedClient(createClientReference(id));
+ // the app's own transport fetch, configured after prerendered() ran
+ const appSends: string[] = [];
+ configureServerFunctionsClient({
+ fetch: (address, init) => {
+ appSends.push(address);
+ return globalThis.fetch(address, init);
+ }
+ });
+ try {
+ expect(await ref("x")).toEqual({ baked: true });
+ expect(appSends).toEqual([]); // answered by the reference, never displaced
+ } finally {
+ configureServerFunctionsClient({ fetch: null });
+ }
+ });
+
+ it("misses through the app's fetch after one artifact lookup", async () => {
+ posture("hybrid");
+ const id = nextId("wiredToo");
+ const requested: string[] = [];
+ vi.stubGlobal("fetch", async (url: string | URL | Request) => {
+ const target = new URL(String(url instanceof Request ? url.url : url), "http://localhost");
+ requested.push(target.pathname);
+ if (target.pathname.startsWith("/_static/")) return new Response("missing", { status: 404 });
+ return new Response(JSON.stringify({ live: true }), {
+ headers: { "X-Server-Function-Format": "8" }
+ });
+ });
+ const ref = prerenderedClient(createClientReference(id));
+ const appSends: string[] = [];
+ configureServerFunctionsClient({
+ fetch: (address, init) => {
+ appSends.push(address);
+ return globalThis.fetch(address, init);
+ }
+ });
+ expect(await ref("unseen")).toEqual({ live: true });
+ expect(requested.filter(path => path.startsWith("/_static/"))).toHaveLength(1);
+ expect(appSends).toHaveLength(1); // the live fallback went through the app's fetch
+ });
+
it("errors on a miss in the static posture (no fallback)", async () => {
posture("static"); // nobody to fall back to
vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 }));
@@ -151,9 +215,12 @@ describe("static round trip through a prerender run", () => {
}));
// a fake built app: the page render performs the prerendered calls,
- // the way SSR component code would
+ // the way SSR component code would; the endpoint is the real handler
const transport = {
- async fetch(_request: Request) {
+ async fetch(request: Request) {
+ if (new URL(request.url).pathname.startsWith("/_server/")) {
+ return handleServerFunctionRequest(request);
+ }
const [team] = await withEvent(() => Promise.all([getTeam("eng"), getReport()]));
return new Response(`${team.users.join(", ")}`, {
headers: { "content-type": "text/html" }
@@ -163,33 +230,39 @@ describe("static round trip through a prerender run", () => {
const result = await runPrerender({
transport,
outDir,
+ buildId: "b1",
crawlLinks: false,
integrations: [serverFunctions()]
});
expect(result.pages).toHaveLength(1);
- expect(result.files.map(f => f.filename).sort()).toEqual(
- [await staticArtifactPath(plainId, ["eng"]), await staticArtifactPath(richId, [])].sort()
- );
+ const plainPath = await staticArtifactPath(plainId, ["eng"], "b1");
+ const richPath = await staticArtifactPath(richId, [], "b1");
+ // versioned by the build: a deploy never shares an artifact url
+ expect(plainPath.startsWith("_static/b1/")).toBe(true);
+ expect(result.files.map(f => f.filename).sort()).toEqual([plainPath, richPath].sort());
// the sink is gone after teardown
expect(globals[CAPTURE_SINK]).toBeUndefined();
- // plain results are human-readable JSON on disk
- const plainText = await readFile(
- join(outDir, await staticArtifactPath(plainId, ["eng"])),
- "utf8"
- );
- expect(JSON.parse(plainText)).toEqual({ dept: "eng", users: ["ada", "grace"] });
- // rich results ride the codec's framed encoding
- const richText = await readFile(join(outDir, await staticArtifactPath(richId, [])), "utf8");
- expect(richText.startsWith(";0x")).toBe(true);
+ // each artifact is the endpoint's answer: plain results as JSON, rich
+ // ones in the codec's framed encoding — whatever the wire carried
+ const stored = async (path: string) =>
+ decodeArtifact(new Uint8Array(await readFile(join(outDir, path))))!;
+ const plain = await stored(plainPath);
+ expect(plain.status).toBe(200);
+ expect(JSON.parse(await plain.text())).toEqual({ dept: "eng", users: ["ada", "grace"] });
+ const rich = await stored(richPath);
+ expect((await rich.text()).startsWith(";0x")).toBe(true);
// ---- the client, in the STATIC posture (no fallback server), against
// the written files ----
posture("static");
+ vi.stubEnv("PRERENDER_BUILD", "b1");
+ const requested: string[] = [];
vi.stubGlobal("fetch", async (url: string | URL) => {
const pathname = new URL(String(url), "http://localhost").pathname;
+ requested.push(pathname);
try {
- return new Response(await readFile(join(outDir, pathname), "utf8"));
+ return new Response(await readFile(join(outDir, pathname)));
} catch {
return new Response("not found", { status: 404 });
}
@@ -204,17 +277,44 @@ describe("static round trip through a prerender run", () => {
expect(report.asOf.toISOString()).toBe("2026-01-15T00:00:00.000Z");
expect(report.totals).toBeInstanceOf(Map);
expect(report.totals.get("eng")).toBe(7);
+ // only artifacts were requested: no server exists
+ expect(requested.every(path => path.startsWith("/_static/b1/"))).toBe(true);
// a call no prerendered page made has no artifact — and no server to
// fall back to
await expect(teamClient("marketing")).rejects.toThrow(/No static artifact/);
});
+ it("treats a host's 200 fallback page as a miss", async () => {
+ posture("hybrid");
+ const id = nextId("spaFallback");
+ const requested: string[] = [];
+ vi.stubGlobal("fetch", async (url: string | URL | Request) => {
+ const target = new URL(String(url instanceof Request ? url.url : url), "http://localhost");
+ requested.push(target.pathname);
+ if (target.pathname.startsWith("/_static/")) {
+ // what an SPA host or an SSR catch-all answers for a missing file
+ return new Response("", {
+ headers: { "content-type": "text/html" }
+ });
+ }
+ return new Response(JSON.stringify({ live: true }), {
+ headers: { "X-Server-Function-Format": "8" }
+ });
+ });
+ const ref = prerenderedClient(createClientReference(id));
+ expect(await ref("x")).toEqual({ live: true });
+ expect(requested[1]).toMatch(/^\/_server\/data\//);
+ });
+
it("keys artifacts by arguments, not just by function", async () => {
const id = nextId("byArgs");
const byTag = declare(id, async (tag: string) => ({ tag }));
const transport = {
async fetch(request: Request) {
+ if (new URL(request.url).pathname.startsWith("/_server/")) {
+ return handleServerFunctionRequest(request);
+ }
const tag = new URL(request.url).pathname === "/" ? "a" : "b";
await withEvent(() => byTag(tag));
return new Response(`next `, {
@@ -255,7 +355,10 @@ describe("the static-mode guard", () => {
/** A one-page site whose render performs exactly the given prerendered calls. */
const siteCalling = (calls: () => Promise) => ({
- async fetch() {
+ async fetch(request: Request) {
+ if (new URL(request.url).pathname.startsWith("/_server/")) {
+ return handleServerFunctionRequest(request);
+ }
await withEvent(calls);
return new Response("", { headers: { "content-type": "text/html" } });
}
diff --git a/packages/solid/test/shared.test.ts b/packages/solid/test/shared.test.ts
index 9dffd66..5a74b96 100644
--- a/packages/solid/test/shared.test.ts
+++ b/packages/solid/test/shared.test.ts
@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
-import { canonicalJSON, staticArtifactPath, staticCallKey } from "../src/shared.ts";
+import {
+ canonicalJSON,
+ decodeArtifact,
+ encodeArtifact,
+ parseDataAddress,
+ sha256,
+ staticArtifactPath,
+ staticCallKey,
+ staticCallKeySync
+} from "../src/shared.ts";
describe("canonicalJSON", () => {
it("spells structurally equal values identically regardless of key order", () => {
@@ -36,7 +45,23 @@ describe("canonicalJSON", () => {
});
});
+describe("sha256", () => {
+ it("matches crypto.subtle across block boundaries", async () => {
+ for (const length of [0, 1, 55, 56, 63, 64, 65, 119, 120, 1000]) {
+ const input = new Uint8Array(length).map((_, i) => (i * 31 + length) & 0xff);
+ const expected = new Uint8Array(await crypto.subtle.digest("SHA-256", input));
+ expect(sha256(input)).toEqual(expected);
+ }
+ });
+});
+
describe("staticCallKey", () => {
+ it("is the same key synchronously and as a promise", async () => {
+ expect(staticCallKeySync("fn#1", ["x", { a: 1 }])).toBe(
+ await staticCallKey("fn#1", ["x", { a: 1 }])
+ );
+ });
+
it("is deterministic and argument-order-insensitive for object keys", async () => {
const a = await staticCallKey("fn#1", [{ tag: "x", page: 2 }]);
const b = await staticCallKey("fn#1", [{ page: 2, tag: "x" }]);
@@ -55,11 +80,68 @@ describe("staticCallKey", () => {
describe("staticArtifactPath", () => {
it("carries a sanitized human label plus the key", async () => {
const path = await staticArtifactPath("src/routes/users.tsx#getUsers", ["eng"]);
- expect(path).toMatch(/^_static\/src_routes_users_tsx_getUsers\.[0-9a-f]{32}\.json$/);
+ expect(path).toMatch(/^_static\/src_routes_users_tsx_getUsers\.[0-9a-f]{32}\.txt$/);
});
it("replaces filename-unsafe characters and drops an empty label", async () => {
- expect(await staticArtifactPath("///", [])).toMatch(/^_static\/___\.[0-9a-f]{32}\.json$/);
- expect(await staticArtifactPath("", [])).toMatch(/^_static\/[0-9a-f]{32}\.json$/);
+ expect(await staticArtifactPath("///", [])).toMatch(/^_static\/___\.[0-9a-f]{32}\.txt$/);
+ expect(await staticArtifactPath("", [])).toMatch(/^_static\/[0-9a-f]{32}\.txt$/);
+ });
+
+ it("nests under the build's directory when given one", async () => {
+ const path = await staticArtifactPath("getUsers", ["eng"], "b1");
+ expect(path).toMatch(/^_static\/b1\/getUsers\.[0-9a-f]{32}\.txt$/);
+ expect(path.slice("_static/b1/".length)).toBe(
+ (await staticArtifactPath("getUsers", ["eng"])).slice("_static/".length)
+ );
+ });
+});
+
+describe("artifacts", () => {
+ it("round-trip the live answer: status, headers and body bytes", async () => {
+ const live = new Response(";0x00000005;hello", {
+ status: 200,
+ headers: {
+ "X-Frame-Stream": "f1",
+ "X-Content-Raw": "1",
+ "Content-Length": "17",
+ "Set-Cookie": "session=secret"
+ }
+ });
+ const stored = await encodeArtifact(live);
+ const answer = decodeArtifact(stored)!;
+ expect(answer.status).toBe(200);
+ expect(answer.headers.get("x-frame-stream")).toBe("f1");
+ expect(answer.headers.get("x-content-raw")).toBe("1");
+ // transport and per-visitor headers are not replayed
+ expect(answer.headers.has("set-cookie")).toBe(false);
+ expect(await answer.text()).toBe(";0x00000005;hello");
+ });
+
+ it("are recognized by their marker, not by status", () => {
+ const page = new TextEncoder().encode("");
+ expect(decodeArtifact(page)).toBeUndefined();
+ expect(decodeArtifact(new Uint8Array())).toBeUndefined();
+ });
+});
+
+describe("parseDataAddress", () => {
+ it("reads id and arguments off a GET address", () => {
+ const args = [{ params: { id: "1" } }, "x"];
+ const url = `/_server/data/${encodeURIComponent("src/a.ts#get")}?args=${encodeURIComponent(
+ JSON.stringify(args)
+ )}`;
+ expect(parseDataAddress(url)).toEqual({ id: "src/a.ts#get", args });
+ expect(parseDataAddress("https://example.com/api/data/fn-1")).toEqual({
+ id: "fn-1",
+ args: []
+ });
+ });
+
+ it("ignores anything that is not one", () => {
+ expect(parseDataAddress("/_server/fn-1")).toBeUndefined();
+ expect(parseDataAddress("/_server/live/fn-1")).toBeUndefined();
+ expect(parseDataAddress("/_server/data/fn-1?args=not-json")).toBeUndefined();
+ expect(parseDataAddress("/_server/data/fn-1?args=%7B%7D")).toBeUndefined();
});
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index eab82b4..28726bf 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -19,29 +19,29 @@ importers:
dependencies:
'@solidjs/meta':
specifier: ^1.0.0-next.2
- version: 1.0.0-next.2(@solidjs/web@2.0.0-rc.6(solid-js@2.0.0-rc.6))(solid-js@2.0.0-rc.6)
+ version: 1.0.0-next.2(@solidjs/web@2.0.0-rc.16(solid-js@2.0.0-rc.16))(solid-js@2.0.0-rc.16)
'@solidjs/prerender':
specifier: workspace:*
version: link:../../packages/solid
'@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)
+ specifier: ^2.0.0-next.41
+ version: 2.0.0-next.41(@solidjs/web@2.0.0-rc.16(solid-js@2.0.0-rc.16))(filesystem-routing@0.4.0(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)))(solid-js@2.0.0-rc.16)
'@solidjs/web':
- specifier: ^2.0.0-rc.6
- version: 2.0.0-rc.6(solid-js@2.0.0-rc.6)
+ specifier: ^2.0.0-rc.16
+ version: 2.0.0-rc.16(solid-js@2.0.0-rc.16)
prerender-crawler:
specifier: workspace:*
version: link:../../packages/crawler
solid-js:
- specifier: ^2.0.0-rc.6
- version: 2.0.0-rc.6
+ specifier: ^2.0.0-rc.16
+ version: 2.0.0-rc.16
devDependencies:
'@solidjs/vite-plugin':
- specifier: ^3.0.0-next.39
- version: 3.0.0-next.39(@solidjs/web@2.0.0-rc.6(solid-js@2.0.0-rc.6))(solid-js@2.0.0-rc.6)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7))
+ specifier: ^3.0.0-next.50
+ version: 3.0.0-next.50(@solidjs/web@2.0.0-rc.16(solid-js@2.0.0-rc.16))(solid-js@2.0.0-rc.16)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7))
filesystem-routing:
- specifier: 0.2.1
- version: 0.2.1(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7))
+ specifier: 0.4.0
+ version: 0.4.0(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7))
typescript:
specifier: ^5.9.2
version: 5.9.3
@@ -67,11 +67,11 @@ importers:
packages/solid:
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)
+ specifier: ^2.0.0-next.41
+ version: 2.0.0-next.41(@solidjs/web@2.0.0-rc.16(solid-js@2.0.0-rc.16))(filesystem-routing@0.4.0(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)))(solid-js@2.0.0-rc.16)
'@solidjs/web':
- specifier: ^2.0.0-rc.6
- version: 2.0.0-rc.6(solid-js@2.0.0-rc.6)
+ specifier: ^2.0.0-rc.16
+ version: 2.0.0-rc.16(solid-js@2.0.0-rc.16)
'@tanstack/router-core':
specifier: ^1.168.0
version: 1.171.27
@@ -82,8 +82,8 @@ importers:
specifier: workspace:^
version: link:../crawler
solid-js:
- specifier: ^2.0.0-rc.6
- version: 2.0.0-rc.6
+ specifier: ^2.0.0-rc.16
+ version: 2.0.0-rc.16
tsup:
specifier: ^8.5.0
version: 8.5.1(postcss@8.5.28)(typescript@5.9.3)
@@ -813,8 +813,8 @@ packages:
cpu: [x64]
os: [win32]
- '@solidjs/babel-plugin@2.0.0-rc.6':
- resolution: {integrity: sha512-bPw4UjS4OGgIHxetWwEj1OEBpU59RYt7t2jE1QPxqYlomJiS2fQFLiXFy62rkqme+zAFVWNV4SMyS+2THQI2Mw==}
+ '@solidjs/babel-plugin@2.0.0-rc.15':
+ resolution: {integrity: sha512-jsz08IPwxue2qQ6Nol2hnRN2M2z/Fymp7ka8L7XoV129L5N8l31s9tEWF/JXw+hbdGrrXTI3KM4kYvZdtma13A==}
peerDependencies:
'@babel/core': ^7.20.12
'@tsrx/core': 0.1.63
@@ -822,37 +822,37 @@ packages:
'@tsrx/core':
optional: true
- '@solidjs/compiler-darwin-arm64@2.0.0-rc.6':
- resolution: {integrity: sha512-uTZeFIIqsoDEgp4YUDrmnrIt63Hgz5quEIDexTnzcL6sPUpSMFoDGtNR5db6+d/pCUXAAE8ZgimpG0EJ6gOOiw==}
+ '@solidjs/compiler-darwin-arm64@2.0.0-rc.15':
+ resolution: {integrity: sha512-UCvBgahyk69qxM2rPIgxMom1+3ZQaTiUCVUJ8LbkJUEaC/ZxXm1nUZiJ0rkQExGRqDld9Yq1G1tpYavfa3bDtg==}
cpu: [arm64]
os: [darwin]
- '@solidjs/compiler-darwin-x64@2.0.0-rc.6':
- resolution: {integrity: sha512-GmUujhjlOT9TBm10+8qS8Ft0sKc5that4CFmj4klbt40PywwPDfdhEzlw+awhlFgl+7FN75q+tRQdQJFNcrAgA==}
+ '@solidjs/compiler-darwin-x64@2.0.0-rc.15':
+ resolution: {integrity: sha512-Xx0Y2XU8Rr/xOcbKs4l3Zl0tmWMq9pcolwbO/HhFhbooDQfcd/n6l8cFsMg0ePj4H7cUTYGdTZDCiKJbXhucqg==}
cpu: [x64]
os: [darwin]
- '@solidjs/compiler-linux-arm64-gnu@2.0.0-rc.6':
- resolution: {integrity: sha512-/edSzc/fjl+QCYeaAxSEQwpOBiaB5DNrb8EUm4zKQHGWY1WwFeKSkSLoMKwnhmwCu+SIt1Z8fTEG6lZirUj1hw==}
+ '@solidjs/compiler-linux-arm64-gnu@2.0.0-rc.15':
+ resolution: {integrity: sha512-XLzSisluhLUJRFblhems1VHJuakvPH4EBlK++str+Htx0RAHlE6rjh9dE+ToQAzYI+msXMdFpJ/WPzgxEZgUEQ==}
cpu: [arm64]
os: [linux]
- '@solidjs/compiler-linux-x64-gnu@2.0.0-rc.6':
- resolution: {integrity: sha512-EWb0ypXtL7LbLn70M7O6FmK3pOKNDX9HaQETCiwZxjj/AXP7zYDsb7H5M1HsprbeXspij5+gft9Oeq7NfJJ+mQ==}
+ '@solidjs/compiler-linux-x64-gnu@2.0.0-rc.15':
+ resolution: {integrity: sha512-vdRC2eSDyIysZQvnyb2cA0JhbeEvY6+I22AR7jeCHz/6V61Hey6O99qeUT6YdJFw3UqleRoAy5DZX+8CtZbvxw==}
cpu: [x64]
os: [linux]
- '@solidjs/compiler-wasm32-wasi@2.0.0-rc.6':
- resolution: {integrity: sha512-aQCYH+d3TAx1i05vtdINSRnKxPFL8VnkSOoab2OkX3rmxwCGxpRRUvj+fOePiF28XO4ijDoY7Bty4QsP1jbUUw==}
+ '@solidjs/compiler-wasm32-wasi@2.0.0-rc.15':
+ resolution: {integrity: sha512-Eir7w176oGeMZvdKO5OFaYTLhFOYyd/qPH/AbS60yelFta6Wriqs9IYyMLnECpYo2IcxtQx2AA5Gj1dENempPw==}
engines: {node: '>=14.0.0'}
- '@solidjs/compiler-win32-x64-msvc@2.0.0-rc.6':
- resolution: {integrity: sha512-8luAnqBxHJxEfw8SKqKGV1W2x+j1yk0G2rb242/z45pbVkQz+J5HNKMYSnvfKN3B2VpIvhZ447yph449wDKpEA==}
+ '@solidjs/compiler-win32-x64-msvc@2.0.0-rc.15':
+ resolution: {integrity: sha512-2Ma/gZhqplQkpXrZvNqnBV2Sf0cDJ9TgbnI/veVXYlYz+1OclkdvD/nVYHIl1l+Q0/tC0G+OCSFQJwn3ALIakw==}
cpu: [x64]
os: [win32]
- '@solidjs/compiler@2.0.0-rc.6':
- resolution: {integrity: sha512-3B6zdACJp+lImXJW/E0E5cXfsMlMXSIg9+s8me2sxz5Q4juGptiW+aUtluDb2e+lCUZt7kDVZdy5eHOmZ3T/mA==}
+ '@solidjs/compiler@2.0.0-rc.15':
+ resolution: {integrity: sha512-DrtzyGsnOl6Ad6XpHFMrGX9LjBix6MLGbQFcF2UGlL1KgYoUvBpUfDv/a7fTlccL9+CzBd35E9Rq97Uvjq64zg==}
'@solidjs/meta@1.0.0-next.2':
resolution: {integrity: sha512-4aqPczFqDdep4JTAUXfh4nyfq7InbTgimd7NuN6ZWWE29UPv0uR5dyr53yFcf2YgVXjFdX+JGhXtsE0njGL+fA==}
@@ -860,23 +860,28 @@ packages:
'@solidjs/web': ^2.0.0-rc.0
solid-js: ^2.0.0-rc.0
- '@solidjs/router@2.0.0-next.21':
- resolution: {integrity: sha512-sU9n6HsWpkHN0EmT5/r7z2uXzqlIWFJEJMe3V0MXCe19S4Gc8j33sSNZzrF431uOXwUQ+/KPKU4ChzcPQPDoEw==}
+ '@solidjs/router@2.0.0-next.41':
+ resolution: {integrity: sha512-wCnhq37p4dpTYuy2Zz8nlf9Si/t4cMXUiMY0r7MxzHSH2c4jUiSrA8LkSdif6swwAIT/dOP7Pbv4hM1qY5ZOuA==}
peerDependencies:
- '@solidjs/web': ^2.0.0-rc.5
- solid-js: ^2.0.0-rc.5
+ '@solidjs/web': ^2.0.0-rc.15
+ filesystem-routing: '>=0.4.0'
+ solid-js: ^2.0.0-rc.15
+ peerDependenciesMeta:
+ filesystem-routing:
+ optional: true
- '@solidjs/signals@2.0.0-rc.6':
- resolution: {integrity: sha512-lPqwZNLPq1Z9CBvgXkMvi1ZFr5OHUiFNz1X40+yehszDWEbJkneZx7BGKIe9eMT/AN1NSL+PMjOiMyZaqVB2xw==}
+ '@solidjs/signals@2.0.0-rc.16':
+ resolution: {integrity: sha512-WPMb8FuAVKyzF6sIgZn/c89V69VS3R4DtCnOoaS1ZXy5Uos9K/8yrclyQkG6kqkN3ubtPiPnaPfdjt/jfsi4qQ==}
+ engines: {node: '>=22.12.0'}
- '@solidjs/vite-plugin@3.0.0-next.39':
- resolution: {integrity: sha512-gIJI6TJd3Vs5LYSZ7w1oil5sy0dC+QuheHhTUYdwYSFy/v8yuHXKJ+tUl3dBg1yF7pZ9zbtV9d9UvNMgeyceFg==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@solidjs/vite-plugin@3.0.0-next.50':
+ resolution: {integrity: sha512-eCtI4SwZOCnhwFist0WpUtAf/HPQ26z74XOtzm3z41FFJdKprHEMEVKw8NKfViwp9mecMK4FLZSm6k6OY4yBsA==}
+ engines: {node: '>=22.12.0'}
peerDependencies:
'@solidjs/start-devtools': ^1.0.0-next.2
- '@solidjs/web': ^2.0.0-rc.0
- '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.*
- solid-js: ^2.0.0-rc.0
+ '@solidjs/web': ^2.0.0-rc.14
+ '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.0.0 || ^7.0.0
+ solid-js: ^2.0.0-rc.14
vite: ^8.0.0 || ^9.0.0
peerDependenciesMeta:
'@solidjs/start-devtools':
@@ -884,10 +889,11 @@ packages:
'@testing-library/jest-dom':
optional: true
- '@solidjs/web@2.0.0-rc.6':
- resolution: {integrity: sha512-JgQ2NCjygQpZizZrVjcwPqH3dhIZQZoMRGoGSC6+Tr622cvbuXthDC3pKdNMsjCKCVp19UbT0kkPX15FOdT0pw==}
+ '@solidjs/web@2.0.0-rc.16':
+ resolution: {integrity: sha512-dWbRvG8PlcXelWYdoOmyJwItFPrPSsppUbOJ/RLrQhesQD3sEL9BTRUi8vdXyi+rdA8klYvxJtB43/BTqxJcbw==}
+ engines: {node: '>=22.12.0'}
peerDependencies:
- solid-js: ^2.0.0-rc.6
+ solid-js: ^2.0.0-rc.16
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -1136,11 +1142,14 @@ packages:
picomatch:
optional: true
- filesystem-routing@0.2.1:
- resolution: {integrity: sha512-xlYz4ag/uunqVgs5rd3bbmUFJTuXFa/eeZcM/jRoCYh+J8kLmeIsQM5PKL0NyHreQs+7URq1J31yxMm+z/nh4g==}
+ filesystem-routing@0.4.0:
+ resolution: {integrity: sha512-HSGdLhrkYgF4/E0IKxirCShkrGUfvavQuOOP2Nio0dCxp/PrhiATGWQPUODUJuDavD+N6hIp1K7e/rh347o9nw==}
peerDependencies:
+ '@tsrx/oxc': '>=0.9.0'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
+ '@tsrx/oxc':
+ optional: true
vite:
optional: true
@@ -1541,26 +1550,26 @@ packages:
engines: {node: '>=10'}
hasBin: true
- seroval-plugins@1.5.6:
- resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==}
- engines: {node: '>=10'}
- 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==}
+ seroval-plugins@1.6.7:
+ resolution: {integrity: sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw==}
engines: {node: '>=10'}
+ peerDependencies:
+ seroval: ^1.0
seroval@1.6.4:
resolution: {integrity: sha512-LErWMNS2RRFdu2RMA5u/PA59/IWs0XsikyEXGQ2/36iEWFrdG0ABmg17E17cikrv76891kOAMq3TkTFXpwAHXw==}
engines: {node: '>=10'}
+ seroval@1.6.7:
+ resolution: {integrity: sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ==}
+ engines: {node: '>=10'}
+
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -1580,8 +1589,9 @@ packages:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
- solid-js@2.0.0-rc.6:
- resolution: {integrity: sha512-Z/M8s9ypLBf+6Bl3AAb5upgkYCl73HkpM+UxdUJ5uFGctTwpOQVCM9Gpj3Mjbiaki270HHUfA3Z0Lyn4w+fDtg==}
+ solid-js@2.0.0-rc.16:
+ resolution: {integrity: sha512-GtrX+ieB9ctN2eQ0raDOHAHZBpFPoMn98gCsUn6YD5hz0xSou3zzV4J3Hti6xclvvrnPIFO8NJ+kk+pXbuXPHg==}
+ engines: {node: '>=22.12.0'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
@@ -1699,9 +1709,6 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
- validate-html-nesting@1.2.4:
- resolution: {integrity: sha512-doQi7e8EJ2OWneSG1aZpJluS6A49aZM0+EICXWKm1i6WvqTLmq0tpUcImc4KTWG50mORO0C4YDBtOCSYvElftw==}
-
vite@8.2.2:
resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2441,7 +2448,7 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.63.1':
optional: true
- '@solidjs/babel-plugin@2.0.0-rc.6(@babel/core@7.29.7)':
+ '@solidjs/babel-plugin@2.0.0-rc.15(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-module-imports': 7.18.6
@@ -2449,72 +2456,73 @@ snapshots:
'@babel/types': 7.29.8
html-entities: 2.3.3
parse5: 7.3.0
- validate-html-nesting: 1.2.4
- '@solidjs/compiler-darwin-arm64@2.0.0-rc.6':
+ '@solidjs/compiler-darwin-arm64@2.0.0-rc.15':
optional: true
- '@solidjs/compiler-darwin-x64@2.0.0-rc.6':
+ '@solidjs/compiler-darwin-x64@2.0.0-rc.15':
optional: true
- '@solidjs/compiler-linux-arm64-gnu@2.0.0-rc.6':
+ '@solidjs/compiler-linux-arm64-gnu@2.0.0-rc.15':
optional: true
- '@solidjs/compiler-linux-x64-gnu@2.0.0-rc.6':
+ '@solidjs/compiler-linux-x64-gnu@2.0.0-rc.15':
optional: true
- '@solidjs/compiler-wasm32-wasi@2.0.0-rc.6':
+ '@solidjs/compiler-wasm32-wasi@2.0.0-rc.15':
dependencies:
'@emnapi/core': 1.11.3
'@emnapi/runtime': 1.11.3
'@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
optional: true
- '@solidjs/compiler-win32-x64-msvc@2.0.0-rc.6':
+ '@solidjs/compiler-win32-x64-msvc@2.0.0-rc.15':
optional: true
- '@solidjs/compiler@2.0.0-rc.6':
+ '@solidjs/compiler@2.0.0-rc.15':
optionalDependencies:
- '@solidjs/compiler-darwin-arm64': 2.0.0-rc.6
- '@solidjs/compiler-darwin-x64': 2.0.0-rc.6
- '@solidjs/compiler-linux-arm64-gnu': 2.0.0-rc.6
- '@solidjs/compiler-linux-x64-gnu': 2.0.0-rc.6
- '@solidjs/compiler-wasm32-wasi': 2.0.0-rc.6
- '@solidjs/compiler-win32-x64-msvc': 2.0.0-rc.6
+ '@solidjs/compiler-darwin-arm64': 2.0.0-rc.15
+ '@solidjs/compiler-darwin-x64': 2.0.0-rc.15
+ '@solidjs/compiler-linux-arm64-gnu': 2.0.0-rc.15
+ '@solidjs/compiler-linux-x64-gnu': 2.0.0-rc.15
+ '@solidjs/compiler-wasm32-wasi': 2.0.0-rc.15
+ '@solidjs/compiler-win32-x64-msvc': 2.0.0-rc.15
- '@solidjs/meta@1.0.0-next.2(@solidjs/web@2.0.0-rc.6(solid-js@2.0.0-rc.6))(solid-js@2.0.0-rc.6)':
+ '@solidjs/meta@1.0.0-next.2(@solidjs/web@2.0.0-rc.16(solid-js@2.0.0-rc.16))(solid-js@2.0.0-rc.16)':
dependencies:
- '@solidjs/web': 2.0.0-rc.6(solid-js@2.0.0-rc.6)
- solid-js: 2.0.0-rc.6
+ '@solidjs/web': 2.0.0-rc.16(solid-js@2.0.0-rc.16)
+ solid-js: 2.0.0-rc.16
- '@solidjs/router@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/router@2.0.0-next.41(@solidjs/web@2.0.0-rc.16(solid-js@2.0.0-rc.16))(filesystem-routing@0.4.0(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)))(solid-js@2.0.0-rc.16)':
dependencies:
- '@solidjs/web': 2.0.0-rc.6(solid-js@2.0.0-rc.6)
- solid-js: 2.0.0-rc.6
+ '@solidjs/web': 2.0.0-rc.16(solid-js@2.0.0-rc.16)
+ solid-js: 2.0.0-rc.16
+ optionalDependencies:
+ filesystem-routing: 0.4.0(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7))
- '@solidjs/signals@2.0.0-rc.6': {}
+ '@solidjs/signals@2.0.0-rc.16': {}
- '@solidjs/vite-plugin@3.0.0-next.39(@solidjs/web@2.0.0-rc.6(solid-js@2.0.0-rc.6))(solid-js@2.0.0-rc.6)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7))':
+ '@solidjs/vite-plugin@3.0.0-next.50(@solidjs/web@2.0.0-rc.16(solid-js@2.0.0-rc.16))(solid-js@2.0.0-rc.16)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7))':
dependencies:
'@ampproject/remapping': 2.3.0
'@babel/core': 7.29.7
- '@solidjs/babel-plugin': 2.0.0-rc.6(@babel/core@7.29.7)
- '@solidjs/compiler': 2.0.0-rc.6
- '@solidjs/web': 2.0.0-rc.6(solid-js@2.0.0-rc.6)
+ '@solidjs/babel-plugin': 2.0.0-rc.15(@babel/core@7.29.7)
+ '@solidjs/compiler': 2.0.0-rc.15
+ '@solidjs/web': 2.0.0-rc.16(solid-js@2.0.0-rc.16)
'@types/babel__core': 7.20.5
merge-anything: 5.1.7
- solid-js: 2.0.0-rc.6
+ solid-js: 2.0.0-rc.16
vite: 8.2.2(@types/node@22.20.1)(esbuild@0.27.7)
vitefu: 1.1.3(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7))
transitivePeerDependencies:
- '@tsrx/core'
- supports-color
- '@solidjs/web@2.0.0-rc.6(solid-js@2.0.0-rc.6)':
+ '@solidjs/web@2.0.0-rc.16(solid-js@2.0.0-rc.16)':
dependencies:
- seroval: 1.5.6
- seroval-plugins: 1.5.6(seroval@1.5.6)
- solid-js: 2.0.0-rc.6
+ seroval: 1.6.7
+ seroval-plugins: 1.6.7(seroval@1.6.7)
+ solid-js: 2.0.0-rc.16
'@standard-schema/spec@1.1.0': {}
@@ -2766,7 +2774,7 @@ snapshots:
optionalDependencies:
picomatch: 4.0.7
- filesystem-routing@0.2.1(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)):
+ filesystem-routing@0.4.0(vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7)):
dependencies:
'@babel/core': 7.29.7
'@types/babel__core': 7.20.5
@@ -3155,18 +3163,18 @@ snapshots:
semver@7.8.5: {}
- seroval-plugins@1.5.6(seroval@1.5.6):
- dependencies:
- seroval: 1.5.6
-
seroval-plugins@1.6.4(seroval@1.6.4):
dependencies:
seroval: 1.6.4
- seroval@1.5.6: {}
+ seroval-plugins@1.6.7(seroval@1.6.7):
+ dependencies:
+ seroval: 1.6.7
seroval@1.6.4: {}
+ seroval@1.6.7: {}
+
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -3179,12 +3187,12 @@ snapshots:
slash@3.0.0: {}
- solid-js@2.0.0-rc.6:
+ solid-js@2.0.0-rc.16:
dependencies:
- '@solidjs/signals': 2.0.0-rc.6
+ '@solidjs/signals': 2.0.0-rc.16
csstype: 3.2.3
- seroval: 1.5.6
- seroval-plugins: 1.5.6(seroval@1.5.6)
+ seroval: 1.6.7
+ seroval-plugins: 1.6.7(seroval@1.6.7)
source-map-js@1.2.1: {}
@@ -3293,8 +3301,6 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
- validate-html-nesting@1.2.4: {}
-
vite@8.2.2(@types/node@22.20.1)(esbuild@0.27.7):
dependencies:
lightningcss: 1.33.0