@opentf/web 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server/api.js ADDED
@@ -0,0 +1,208 @@
1
+ // File-based API routes for the OTF Web runtime — the API backend (ARCHITECTURE
2
+ // §6, SPEC §11). An endpoint is a `route.{js,ts}` file — the API analogue of a
3
+ // page's `page.{jsx,tsx}` — so the folder is the URL (`app/api/users/[id]/route.ts`
4
+ // → `/api/users/:id`), exactly like pages. Handlers are *plain server modules*
5
+ // (not JSX/DOM-compiled): each exports named functions per HTTP method that take a
6
+ // standard `Request` and return a standard `Response`, portable across Bun / Node /
7
+ // Cloudflare Workers (SPEC §11.1). A folder may hold a `page.*` or a `route.*`, not
8
+ // both — the toolchain errors on the conflict (like Next.js's App Router).
9
+ //
10
+ // This module is runtime-agnostic: `createApiHandler` turns a discovered route
11
+ // map into a single `(request) => Response | null` function. `null` means "no
12
+ // API route matched" so the caller (dev/serve/adapter) can fall through to SSR
13
+ // or a 404. Matching reuses the `[param]` / `[...rest]` convention and regex
14
+ // approach of the page router (runtime/router.js `matchRoute`) so behavior is
15
+ // identical across pages and API and across every deployment target.
16
+
17
+ const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
18
+
19
+ /**
20
+ * Strip the app-directory prefix from a route/middleware file path. When the
21
+ * caller knows the app dir (the CLI always does), the exact prefix is removed —
22
+ * unambiguous even for an `app/app/...` folder. Without it, fall back to the last
23
+ * complete `/app` path segment; the lookahead keeps folders that merely *start*
24
+ * with "app" (`/appointments`, `/apps`) intact.
25
+ */
26
+ function stripAppDir(filePath, appDir) {
27
+ if (appDir) {
28
+ const base = appDir.replace(/\/+$/, "");
29
+ if (filePath.startsWith(base + "/")) return filePath.slice(base.length);
30
+ }
31
+ return filePath.replace(/^.*\/app(?=\/)/, "");
32
+ }
33
+
34
+ /**
35
+ * Derive the API route path from a `.../app/<...>/route.{js,ts}` file path — the
36
+ * folder is the URL, mirroring `routeFromPath` in the page router.
37
+ * `app/api/status/route.js` → `/api/status`, `app/api/users/[id]/route.ts` →
38
+ * `/api/users/[id]`, `app/route.js` → `/`. Pass `appDir` (the absolute app
39
+ * directory) when known so the prefix is stripped exactly.
40
+ */
41
+ export function apiRouteFromPath(filePath, appDir) {
42
+ const r = stripAppDir(filePath, appDir).replace(/\/route\.(jsx?|tsx?)$/, "");
43
+ return r === "" ? "/" : r;
44
+ }
45
+
46
+ /** The folder route a `_middleware.{js,ts}` file governs: `app/api/_middleware.js`
47
+ * → `/api` (applies to `/api` and everything nested under it). */
48
+ export function middlewareScopeFromPath(filePath, appDir) {
49
+ const r = stripAppDir(filePath, appDir).replace(/\/_middleware\.(jsx?|tsx?)$/, "");
50
+ return r === "" ? "/" : r;
51
+ }
52
+
53
+ /** Compile a route path with `[param]` / `[...rest]` segments to a matcher regex.
54
+ * Named groups become route params; literal parts are regex-escaped (a `v1.0`
55
+ * folder must not match `v1X0`); the optional trailing slash is tolerated. */
56
+ function compilePattern(route) {
57
+ const src = route
58
+ .split(/(\[\.\.\.[^\]]+\]|\[[^\]]+\])/)
59
+ .map((part) => {
60
+ if (part.startsWith("[...")) return `(?<${part.slice(4, -1)}>.+)`;
61
+ if (part.startsWith("[")) return `(?<${part.slice(1, -1)}>[^/]+)`;
62
+ return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
63
+ })
64
+ .join("");
65
+ return new RegExp(`^${src}/?$`);
66
+ }
67
+
68
+ // Percent-decode a matched param segment; malformed input stays raw rather than throwing.
69
+ const decodeParam = (s) => {
70
+ try {
71
+ return decodeURIComponent(s);
72
+ } catch {
73
+ return s;
74
+ }
75
+ };
76
+
77
+ const normalize = (p) => (p || "/").replace(/(.)\/+$/, "$1");
78
+
79
+ /** The `Allow` header value for a handler module: the methods it exports, plus the
80
+ * auto-provided HEAD (when GET exists) and OPTIONS. */
81
+ function allowedMethods(mod) {
82
+ const set = new Set(METHODS.filter((m) => typeof mod[m] === "function"));
83
+ if (set.has("GET")) set.add("HEAD");
84
+ set.add("OPTIONS");
85
+ return [...set].join(", ");
86
+ }
87
+
88
+ /**
89
+ * Build the API request handler from a discovered route + middleware map.
90
+ *
91
+ * @param {Record<string, object>} routeModules `{ [absFilePath]: moduleNamespace }`
92
+ * — each module exports method handlers (`GET`, `POST`, …).
93
+ * @param {Record<string, object>} middlewareModules `{ [absFilePath]: moduleNamespace }`
94
+ * for `_middleware.{js,ts}` files; the middleware is the default export.
95
+ * @param {{ appDir?: string }} [options] `appDir` — the absolute app directory the
96
+ * module keys live under, for exact route derivation (the CLI passes it).
97
+ * @returns {(request: Request) => Promise<Response | null>}
98
+ */
99
+ export function createApiHandler(routeModules = {}, middlewareModules = {}, { appDir } = {}) {
100
+ const routes = [];
101
+ for (const file in routeModules) {
102
+ const route = apiRouteFromPath(file, appDir);
103
+ routes.push({ route, pattern: compilePattern(route), module: routeModules[file], dynamic: route.includes("[") });
104
+ }
105
+ // Static routes before dynamic, longer (more specific) before shorter, so the
106
+ // most specific match wins deterministically regardless of discovery order.
107
+ routes.sort((a, b) => a.dynamic - b.dynamic || b.route.length - a.route.length);
108
+
109
+ const middleware = [];
110
+ for (const file in middlewareModules) {
111
+ const mod = middlewareModules[file];
112
+ const fn = mod.default ?? mod.middleware;
113
+ if (typeof fn === "function") middleware.push({ scope: middlewareScopeFromPath(file, appDir), fn });
114
+ }
115
+ // Outermost (shortest scope) runs first, so `/api/_middleware` wraps `/api/users/_middleware`.
116
+ middleware.sort((a, b) => a.scope.length - b.scope.length);
117
+
118
+ return async function handle(request) {
119
+ const url = new URL(request.url);
120
+ const pathname = normalize(url.pathname);
121
+
122
+ let matched = null;
123
+ let params = {};
124
+ for (const r of routes) {
125
+ const m = pathname.match(r.pattern);
126
+ if (!m) continue;
127
+ matched = r;
128
+ params = { ...(m.groups || {}) };
129
+ // Params reach handlers percent-decoded (`/users/John%20Doe` → "John Doe");
130
+ // catch-all segments are split first so an encoded `%2F` never adds a segment.
131
+ for (const k in params) {
132
+ params[k] = r.route.includes(`[...${k}]`)
133
+ ? params[k].split("/").map(decodeParam)
134
+ : decodeParam(params[k]);
135
+ }
136
+ break;
137
+ }
138
+ if (!matched) return null; // no API route — fall through to SSR / 404
139
+
140
+ // Shared per-request context. `locals` is the channel middleware uses to pass
141
+ // data (auth user, validated body) down to the handler.
142
+ const context = {
143
+ params,
144
+ query: Object.fromEntries(url.searchParams),
145
+ url,
146
+ locals: {},
147
+ };
148
+
149
+ const dispatch = async (req) => {
150
+ const mod = matched.module;
151
+ const method = req.method.toUpperCase();
152
+ const fn = mod[method];
153
+ if (typeof fn === "function") return fn(req, context);
154
+ // Auto HEAD from GET (drop the body); auto OPTIONS; else 405.
155
+ if (method === "HEAD" && typeof mod.GET === "function") {
156
+ const got = await mod.GET(req, context);
157
+ // GET may lean on the plain-value → JSON convenience; HEAD must then carry
158
+ // the same status/headers that GET response would have had.
159
+ const res = got instanceof Response ? got : Response.json(got);
160
+ return new Response(null, { status: res.status, headers: res.headers });
161
+ }
162
+ if (method === "OPTIONS") return new Response(null, { status: 204, headers: { Allow: allowedMethods(mod) } });
163
+ return new Response(null, { status: 405, headers: { Allow: allowedMethods(mod) } });
164
+ };
165
+
166
+ // Compose the applicable middleware chain around dispatch (outermost first).
167
+ // The root scope ("/", from an `app/_middleware.*`) governs every route.
168
+ const chain = middleware.filter(
169
+ (mw) => mw.scope === "/" || pathname === mw.scope || pathname.startsWith(mw.scope + "/"),
170
+ );
171
+ let next = dispatch;
172
+ for (let i = chain.length - 1; i >= 0; i--) {
173
+ const { fn } = chain[i];
174
+ const downstream = next;
175
+ next = (req) => fn(req, context, () => downstream(req));
176
+ }
177
+
178
+ try {
179
+ const res = await next(request);
180
+ if (res instanceof Response) return res;
181
+ if (res === undefined) {
182
+ throw new Error(`API handler for ${request.method} ${pathname} returned no Response`);
183
+ }
184
+ return Response.json(res); // lenient: a plain value becomes a JSON response
185
+ } catch (e) {
186
+ if (e instanceof Response) return e; // handlers/middleware may throw a Response
187
+ console.error(`✗ API ${request.method} ${pathname}:`, e?.stack ?? e);
188
+ return Response.json({ error: "Internal Server Error" }, { status: 500 });
189
+ }
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Wrap an API handler (`(request) => Response | null`) into a total Fetch handler
195
+ * that always returns a `Response` — a `null` (no route matched) becomes the
196
+ * optional `fallback(request)` or a 404. Fetch-native runtimes (Bun, Cloudflare
197
+ * Workers, Deno) can export it directly:
198
+ *
199
+ * import { apiHandler } from "./dist/server/api.js";
200
+ * export default { fetch: createFetchHandler(apiHandler) };
201
+ */
202
+ export function createFetchHandler(handler, { fallback } = {}) {
203
+ return async (request) => {
204
+ const res = await handler(request);
205
+ if (res) return res;
206
+ return fallback ? fallback(request) : new Response("Not Found", { status: 404 });
207
+ };
208
+ }
@@ -0,0 +1,92 @@
1
+ // Types for the `@opentf/web/server` entry. The API-routes surface is fully typed
2
+ // (./api); the SSG string-builders and render helpers — authored in JS and mostly
3
+ // consumed by generated entry code — are declared with permissive signatures.
4
+
5
+ export * from "./api.js";
6
+
7
+ // ── Render / route API (render.js) ─────────────────────────────────────────────
8
+ export interface RenderResult {
9
+ html: string;
10
+ metadata: Record<string, unknown>;
11
+ hydration: unknown;
12
+ status?: number;
13
+ }
14
+ export function renderRoute(
15
+ pathname: string,
16
+ params?: Record<string, unknown> | null,
17
+ search?: string,
18
+ options?: { data?: unknown },
19
+ ): Promise<RenderResult | null>;
20
+ export function renderToString(pathname: string, search?: string): Promise<string>;
21
+ export function collectRoutePaths(): Promise<string[]>;
22
+
23
+ // ── <head> / metadata (head.js) ─────────────────────────────────────────────────
24
+ export function resolveMetadata(args?: {
25
+ route?: string;
26
+ entry?: unknown;
27
+ params?: Record<string, unknown>;
28
+ query?: Record<string, unknown>;
29
+ }): Promise<Record<string, unknown>>;
30
+ export function renderHead(meta?: Record<string, unknown>, opts?: { path?: string; baseUrl?: string }): string;
31
+ export function localeAlternateLinks(
32
+ routePath: string,
33
+ cfg?: { locales?: string[]; defaultLocale?: string },
34
+ localize?: (path: string, locale: string) => string,
35
+ ): Array<Record<string, string>>;
36
+
37
+ // ── Route loaders (loader.js) ───────────────────────────────────────────────────
38
+ import type { RouteParams } from "./api.js";
39
+
40
+ /** The context a route loader receives (docs/DATA.md). `request` is the live
41
+ * Request under serve/dev and undefined at SSG prerender; `locals` is reserved. */
42
+ export interface LoaderContext {
43
+ params: RouteParams;
44
+ query: Record<string, string>;
45
+ request?: Request;
46
+ locale: string | null;
47
+ locals: Record<string, unknown>;
48
+ }
49
+
50
+ /** A route loader — the default (or named `loader`) export of a `loader.{js,ts}`
51
+ * file. Returns the page's JSON-serializable data. */
52
+ export type Loader = (context: LoaderContext) => unknown | Promise<unknown>;
53
+
54
+ export interface LoaderMatch {
55
+ route: string;
56
+ params: RouteParams;
57
+ locale: string | null;
58
+ }
59
+
60
+ export interface LoaderRegistry {
61
+ routes: string[];
62
+ match(pathname: string): LoaderMatch | null;
63
+ load(m: LoaderMatch, ctx?: { request?: Request; query?: Record<string, string> }): Promise<unknown>;
64
+ loadSerialized(
65
+ m: LoaderMatch,
66
+ ctx?: { request?: Request; query?: Record<string, string> },
67
+ ): Promise<{ data: unknown; json: string }>;
68
+ handle(request: Request): Promise<Response | null>;
69
+ }
70
+
71
+ export function createLoaderRegistry(
72
+ loaderModules?: Record<string, { default?: Loader; loader?: Loader }>,
73
+ options?: { appDir?: string; i18n?: { locales?: string[]; defaultLocale?: string } },
74
+ ): LoaderRegistry;
75
+ export function loaderRouteFromPath(filePath: string, appDir?: string): string;
76
+ /** Throw "this page does not exist" from inside a loader (404 semantics). */
77
+ export function notFound(message?: string): never;
78
+ export function isNotFound(e: unknown): boolean;
79
+ export function serializeRouteData(value: unknown): string;
80
+
81
+ // ── SSG string-builder helpers (ssg-runtime.js) ─────────────────────────────────
82
+ export function defineSSG(tag: string, render: (...args: any[]) => string): void;
83
+ export function beginHydrationCollect(): void;
84
+ export function endHydrationCollect(): unknown;
85
+ export function escapeHtml(s: unknown): string;
86
+ export function escapeAttr(s: unknown): string;
87
+ export function clsx(value: unknown): string;
88
+ export function styleString(value: unknown): string;
89
+ export function attr(name: string, value: unknown): string;
90
+ export function ssgText(v: unknown): string;
91
+ export function ssgList<T>(arr: T[], fn: (item: T, index: number) => string): string;
92
+ export function ssgComponent(tag: string, props?: Record<string, unknown>, children?: unknown): string;
package/server/index.js CHANGED
@@ -5,4 +5,6 @@
5
5
  export * from "./ssg-runtime.js";
6
6
  export * from "./head.js";
7
7
  export * from "./render.js";
8
+ export * from "./api.js";
9
+ export * from "./loader.js";
8
10
  import "./builtins.js";
@@ -0,0 +1,199 @@
1
+ // Route loaders — the server half of data fetching (docs/DATA.md, SPEC §8). A
2
+ // loader is a `loader.{js,ts}` file sibling to a `page.*` (the data analogue of
3
+ // a `route.*` API endpoint): a *plain server module* whose default export (or
4
+ // named `loader` export, the Phase B compiler convention) returns the page's
5
+ // JSON-serializable data:
6
+ //
7
+ // export default async function loader({ params, query, request, locale, locals }) {
8
+ // return db.todos.list();
9
+ // }
10
+ //
11
+ // `createLoaderRegistry` turns a discovered `{ absFilePath: namespace }` map into
12
+ // a matcher + runner shared by every consumer: SSG prerender (build time, no
13
+ // `request`), the SSR server (per request), the dev server, and the
14
+ // `<path>/__data.json` HTTP endpoint SPA navigation fetches from (`handle`).
15
+ //
16
+ // Matching duplicates the `[param]` / `[...rest]` compile/decode approach of the
17
+ // API handler (api.js) — like api.js itself mirrors the page router — so params
18
+ // behave identically across pages, endpoints, and loaders.
19
+
20
+ import { DATA_FILE } from "../runtime/route-data.js";
21
+
22
+ /** See `stripAppDir` in api.js — exact-prefix strip with a `/app` segment fallback. */
23
+ function stripAppDir(filePath, appDir) {
24
+ if (appDir) {
25
+ const base = appDir.replace(/\/+$/, "");
26
+ if (filePath.startsWith(base + "/")) return filePath.slice(base.length);
27
+ }
28
+ return filePath.replace(/^.*\/app(?=\/)/, "");
29
+ }
30
+
31
+ /** The page route a `.../app/<...>/loader.{js,ts}` file feeds (folder = URL):
32
+ * `app/todos/loader.js` → `/todos`, `app/loader.js` → `/`. */
33
+ export function loaderRouteFromPath(filePath, appDir) {
34
+ const r = stripAppDir(filePath, appDir).replace(/\/loader\.(js|ts)$/, "");
35
+ return r === "" ? "/" : r;
36
+ }
37
+
38
+ /** Compile a route with `[param]` / `[...rest]` segments to a matcher regex (see api.js). */
39
+ function compilePattern(route) {
40
+ const src = route
41
+ .split(/(\[\.\.\.[^\]]+\]|\[[^\]]+\])/)
42
+ .map((part) => {
43
+ if (part.startsWith("[...")) return `(?<${part.slice(4, -1)}>.+)`;
44
+ if (part.startsWith("[")) return `(?<${part.slice(1, -1)}>[^/]+)`;
45
+ return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46
+ })
47
+ .join("");
48
+ return new RegExp(`^${src}/?$`);
49
+ }
50
+
51
+ const decodeParam = (s) => {
52
+ try {
53
+ return decodeURIComponent(s);
54
+ } catch {
55
+ return s;
56
+ }
57
+ };
58
+
59
+ const normalize = (p) => (p || "/").replace(/(.)\/+$/, "$1");
60
+
61
+ /**
62
+ * Signal "this page does not exist for these params" from inside a loader — the
63
+ * SSR server responds 404 (rendering the 404 page), the data endpoint returns a
64
+ * 404 the client maps to `undefined`, and SSG records the path as failed.
65
+ */
66
+ export function notFound(message = "Not Found") {
67
+ const e = new Error(message);
68
+ e.name = "NotFoundError";
69
+ // A marker property, not a class: the loader bundle and the server bundle are
70
+ // separate module graphs (each bundles its own copy of this file), so an
71
+ // `instanceof` check would fail across the boundary.
72
+ e.otfwNotFound = true;
73
+ throw e;
74
+ }
75
+
76
+ /** Was `e` thrown by {@link notFound}? (Property check — see the note there.) */
77
+ export function isNotFound(e) {
78
+ return e?.otfwNotFound === true;
79
+ }
80
+
81
+ /**
82
+ * Serialize loader data for embedding: the inline `#__otfw_data` script and the
83
+ * static `__data.json` file must carry identical bytes. `<` is escaped so the
84
+ * payload can never close the inline `<script>` (same rule as the island-props
85
+ * collector in ssg-runtime.js). `undefined` (no data) serializes to `""` — the
86
+ * toolchain skips injection entirely.
87
+ */
88
+ export function serializeRouteData(value) {
89
+ const json = JSON.stringify(value);
90
+ return json === undefined ? "" : json.replace(/</g, "\\u003c");
91
+ }
92
+
93
+ /**
94
+ * Build the loader registry from a discovered `{ absFilePath: namespace }` map.
95
+ *
96
+ * @param {Record<string, object>} loaderModules each module's default (or `loader`)
97
+ * export is the loader function.
98
+ * @param {{ appDir?: string, i18n?: { locales?: string[], defaultLocale?: string } }} [options]
99
+ * `appDir` pins exact route derivation; `i18n` lets `match` strip a leading
100
+ * non-default locale prefix (`/fr/todos` matches the `/todos` loader with
101
+ * `locale: "fr"` in the context).
102
+ * @returns {{ routes: string[],
103
+ * match: (pathname: string) => object | null,
104
+ * load: (m: object, ctx?: object) => Promise<unknown>,
105
+ * loadSerialized: (m: object, ctx?: object) => Promise<{ data: unknown, json: string }>,
106
+ * handle: (request: Request) => Promise<Response | null> }}
107
+ */
108
+ export function createLoaderRegistry(loaderModules = {}, { appDir, i18n } = {}) {
109
+ const entries = [];
110
+ for (const file in loaderModules) {
111
+ const ns = loaderModules[file];
112
+ const fn = ns.default ?? ns.loader;
113
+ if (typeof fn !== "function") continue; // a loader file without a loader export is inert
114
+ const route = loaderRouteFromPath(file, appDir);
115
+ entries.push({ route, pattern: compilePattern(route), fn, dynamic: route.includes("[") });
116
+ }
117
+ // Static before dynamic, longer before shorter — most specific wins (see api.js).
118
+ entries.sort((a, b) => a.dynamic - b.dynamic || b.route.length - a.route.length);
119
+
120
+ const i18nOn = !!(i18n && Array.isArray(i18n.locales) && i18n.locales.length);
121
+ const defaultLocale = i18nOn ? (i18n.defaultLocale ?? i18n.locales[0]) : null;
122
+ const nonDefault = i18nOn ? new Set(i18n.locales.filter((l) => l !== defaultLocale)) : null;
123
+
124
+ /** Match a *page* pathname (no `__data.json` suffix) → `{ route, params, locale }` or null. */
125
+ function match(pathname) {
126
+ let path = normalize(pathname);
127
+ let locale = defaultLocale;
128
+ if (nonDefault) {
129
+ const m = path.match(/^\/([^/]+)(\/.*|)$/);
130
+ if (m && nonDefault.has(m[1])) {
131
+ locale = m[1];
132
+ path = m[2] || "/";
133
+ }
134
+ }
135
+ for (const e of entries) {
136
+ const m = path.match(e.pattern);
137
+ if (!m) continue;
138
+ const params = { ...(m.groups || {}) };
139
+ for (const k in params) {
140
+ params[k] = e.route.includes(`[...${k}]`)
141
+ ? params[k].split("/").map(decodeParam)
142
+ : decodeParam(params[k]);
143
+ }
144
+ return { route: e.route, params, locale, fn: e.fn };
145
+ }
146
+ return null;
147
+ }
148
+
149
+ /** Run a matched loader. `request` is the live Request under serve/dev and
150
+ * `undefined` at SSG prerender; `locals` is reserved (middleware is future work). */
151
+ async function load(m, { request, query = {} } = {}) {
152
+ return m.fn({ params: m.params, query, request, locale: m.locale, locals: {} });
153
+ }
154
+
155
+ /** {@link load}, plus the serialized form — escaping lives in one place. */
156
+ async function loadSerialized(m, ctx) {
157
+ const data = await load(m, ctx);
158
+ return { data, json: serializeRouteData(data) };
159
+ }
160
+
161
+ /**
162
+ * The `<path>/__data.json` HTTP endpoint (docs/DATA.md): a `Response` for any
163
+ * data URL — including 404 on a miss, so the reserved suffix never falls
164
+ * through to SSR — or `null` for every other path.
165
+ */
166
+ async function handle(request) {
167
+ const url = new URL(request.url);
168
+ const pathname = normalize(url.pathname);
169
+ let pagePath = null;
170
+ if (pathname === `/${DATA_FILE}`) pagePath = "/";
171
+ else if (pathname.endsWith(`/${DATA_FILE}`)) {
172
+ pagePath = pathname.slice(0, -(DATA_FILE.length + 1)) || "/";
173
+ }
174
+ if (pagePath === null) return null;
175
+
176
+ const method = request.method.toUpperCase();
177
+ if (method !== "GET" && method !== "HEAD") {
178
+ return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
179
+ }
180
+ const headers = { "content-type": "application/json", "cache-control": "no-store" };
181
+ const m = match(pagePath);
182
+ if (!m) return new Response("null", { status: 404, headers });
183
+ try {
184
+ const { json } = await loadSerialized(m, {
185
+ request,
186
+ query: Object.fromEntries(url.searchParams),
187
+ });
188
+ // `json || "null"` — an undefined loader result still yields valid JSON
189
+ // (the client maps a 404 to undefined; a 200 must parse).
190
+ return new Response(method === "HEAD" ? null : json || "null", { status: 200, headers });
191
+ } catch (e) {
192
+ if (isNotFound(e)) return new Response("null", { status: 404, headers });
193
+ console.error(`✗ loader ${pagePath}:`, e?.stack ?? e);
194
+ return Response.json({ error: "Internal Server Error" }, { status: 500 });
195
+ }
196
+ }
197
+
198
+ return { routes: entries.map((e) => e.route), match, load, loadSerialized, handle };
199
+ }
package/server/render.js CHANGED
@@ -15,27 +15,35 @@ import {
15
15
  setRouteState,
16
16
  } from "../runtime/router.js";
17
17
  import { resolveMetadata } from "./head.js";
18
+ import { beginHydrationCollect, endHydrationCollect } from "./ssg-runtime.js";
18
19
 
19
20
  /**
20
- * Render `pathname` to `{ html, metadata, status }`: the markup for inside `#app`,
21
- * the resolved SEO metadata (for the `<head>`), and an HTTP `status` (200 when the
22
- * path matched a real route, 404 when it fell back to the registered 404 page — the
23
- * SSR server uses it; SSG ignores it). `params` (from `getStaticPaths`) override the
24
- * matched route's params for dynamic routes. Returns `null` if there's no match and
25
- * no 404 page.
21
+ * Render `pathname` to `{ html, metadata, status, hydration }`: the markup for inside
22
+ * `#app`, the resolved SEO metadata (for the `<head>`), an HTTP `status` (200 when the
23
+ * path matched a real route, 404 when it fell back to the registered 404 page — the SSR
24
+ * server uses it; SSG ignores it), and `hydration` the JSON island-props payload the
25
+ * toolchain embeds so the client resumes from rich data (`""` when nothing needs it).
26
+ * `params` (from `getStaticPaths`) override the matched route's params for dynamic
27
+ * routes. `options.data` is the route's loader result (run by the caller — serve /
28
+ * prerender own the loader bundle) and is exposed to the page as `router.data`.
29
+ * Returns `null` if there's no match and no 404 page.
26
30
  */
27
- export async function renderRoute(pathname, params = null, search = "") {
31
+ export async function renderRoute(pathname, params = null, search = "", { data } = {}) {
28
32
  const real = matchRoute(pathname);
29
33
  const match =
30
34
  real || (routes.notFound ? { entry: routes.notFound, params: {}, route: null } : null);
31
35
  if (!match) return null;
32
36
  if (params) match.params = params;
33
37
 
34
- // Let a page reading `router.params`/`pathname`/`query` resolve to this route.
35
- setRouteState({ pathname, search, params: match.params });
38
+ // Let a page reading `router.params`/`pathname`/`query`/`data` resolve to this route.
39
+ setRouteState({ pathname, search, params: match.params, data });
36
40
 
37
41
  const query = Object.fromEntries(new URLSearchParams(search));
38
42
  const props = { params: match.params, query };
43
+
44
+ // Collect each island's rich props while the tree renders (ssgComponent records them
45
+ // and stamps `data-h` ids), then serialize the payload for the shell.
46
+ beginHydrationCollect();
39
47
  let html = (await resolveFactory(match.entry))(props);
40
48
 
41
49
  // Wrap with layouts, most-specific inward to root outermost.
@@ -44,6 +52,7 @@ export async function renderRoute(pathname, params = null, search = "") {
44
52
  const layout = await resolveFactory(chain[i]);
45
53
  html = layout({ ...props, children: html });
46
54
  }
55
+ const hydration = endHydrationCollect();
47
56
 
48
57
  const metadata = await resolveMetadata({
49
58
  route: match.route,
@@ -51,7 +60,7 @@ export async function renderRoute(pathname, params = null, search = "") {
51
60
  params: match.params,
52
61
  query,
53
62
  });
54
- return { html, metadata, status: real ? 200 : 404 };
63
+ return { html, metadata, status: real ? 200 : 404, hydration };
55
64
  }
56
65
 
57
66
  /** Back-compat / convenience: render just the `#app` markup for `pathname`. */
@@ -20,6 +20,53 @@ export function defineSSG(tag, render) {
20
20
  registry[tag] = render;
21
21
  }
22
22
 
23
+ // ── hydration props collector (compiler-driven data hydration) ────────────────
24
+ // The lossy path is passing rich props through string attributes. Instead, each
25
+ // server-rendered island gets a `data-h` id and its JSON-safe props are recorded into a
26
+ // single payload the shell embeds as `<script type="application/json">`. At upgrade the
27
+ // client component initializes its prop *signals* from those real JS values (objects,
28
+ // arrays, numbers) — not from `getAttribute` — so islands resume with correct data, with
29
+ // no flash and no dependence on a parent walk re-applying props. `renderRoute` brackets a
30
+ // render with `beginHydrationCollect()` / `endHydrationCollect()`.
31
+ let _collect = null;
32
+
33
+ /** Start collecting per-island hydration props for one render. */
34
+ export function beginHydrationCollect() {
35
+ _collect = [];
36
+ }
37
+
38
+ /**
39
+ * Finish collecting and return the payload as a JSON string ("" when nothing needs
40
+ * hydration). `<` is escaped so a prop value can never break out of the surrounding
41
+ * `<script>` (a `</script>` / `<!--` injection).
42
+ */
43
+ export function endHydrationCollect() {
44
+ const data = _collect;
45
+ _collect = null;
46
+ if (!data || data.length === 0) return "";
47
+ return JSON.stringify(data).replace(/</g, "\\u003c");
48
+ }
49
+
50
+ /**
51
+ * Record an island's JSON-safe props, returning its hydration id — or `null` when not
52
+ * collecting, or when nothing serializes. A JSON round-trip drops functions (event
53
+ * callbacks are client-only — applied by the parent walk, invisible so no flash),
54
+ * `undefined`, and anything cyclic/DOM/signal-shaped, so only plain data crosses.
55
+ */
56
+ function collectHydrationProps(props) {
57
+ if (!_collect || props == null || typeof props !== "object") return null;
58
+ let safe;
59
+ try {
60
+ safe = JSON.parse(JSON.stringify(props));
61
+ } catch {
62
+ return null; // cyclic / non-serializable → client falls back to attributes/defaults
63
+ }
64
+ if (!safe || typeof safe !== "object" || Object.keys(safe).length === 0) return null;
65
+ const id = _collect.length;
66
+ _collect.push(safe);
67
+ return id;
68
+ }
69
+
23
70
  /** Escape text content for HTML. */
24
71
  export function escapeHtml(s) {
25
72
  return String(s).replace(/[&<>]/g, (c) => (c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;"));
@@ -104,9 +151,13 @@ export function ssgComponent(tag, props, children) {
104
151
  inner = ""; // fail soft (client renders/handles it)
105
152
  }
106
153
  // Stamp the stable styling hook on the host (mirrors CSR's `classList.add`) so
107
- // tag-hashed components are still styleable by a readable class name.
154
+ // tag-hashed components are still styleable by a readable class name, plus the
155
+ // hydration id (`data-h`) keying this island's rich props in the payload (if any).
108
156
  const cls = render && render.hostClass;
109
- return cls ? `<${tag} class="${cls}">${inner}</${tag}>` : `<${tag}>${inner}</${tag}>`;
157
+ const id = collectHydrationProps(props);
158
+ let attrs = cls ? ` class="${cls}"` : "";
159
+ if (id != null) attrs += ` data-h="${id}"`;
160
+ return `<${tag}${attrs}>${inner}</${tag}>`;
110
161
  }
111
162
 
112
163
  export { VOID };