@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/core/signals.js +42 -1
- package/package.json +9 -2
- package/runtime/dom.js +109 -10
- package/runtime/hydrate.js +150 -3
- package/runtime/index.js +2 -0
- package/runtime/mount.js +14 -1
- package/runtime/resource.js +139 -0
- package/runtime/route-data.js +64 -0
- package/runtime/router.js +144 -19
- package/server/adapters/node.d.ts +28 -0
- package/server/adapters/node.js +74 -0
- package/server/api.d.ts +106 -0
- package/server/api.js +208 -0
- package/server/index.d.ts +92 -0
- package/server/index.js +2 -0
- package/server/loader.js +199 -0
- package/server/render.js +19 -10
- package/server/ssg-runtime.js +53 -2
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
package/server/loader.js
ADDED
|
@@ -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
|
|
21
|
-
* the resolved SEO metadata (for the `<head>`),
|
|
22
|
-
* path matched a real route, 404 when it fell back to the registered 404 page — the
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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`. */
|
package/server/ssg-runtime.js
CHANGED
|
@@ -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 === "&" ? "&" : c === "<" ? "<" : ">"));
|
|
@@ -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
|
-
|
|
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 };
|