@opentf/web 0.7.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 +43 -13
- 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 +75 -10
- 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 +6 -4
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
|
@@ -24,17 +24,19 @@ import { beginHydrationCollect, endHydrationCollect } from "./ssg-runtime.js";
|
|
|
24
24
|
* server uses it; SSG ignores it), and `hydration` — the JSON island-props payload the
|
|
25
25
|
* toolchain embeds so the client resumes from rich data (`""` when nothing needs it).
|
|
26
26
|
* `params` (from `getStaticPaths`) override the matched route's params for dynamic
|
|
27
|
-
* routes.
|
|
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.
|
|
28
30
|
*/
|
|
29
|
-
export async function renderRoute(pathname, params = null, search = "") {
|
|
31
|
+
export async function renderRoute(pathname, params = null, search = "", { data } = {}) {
|
|
30
32
|
const real = matchRoute(pathname);
|
|
31
33
|
const match =
|
|
32
34
|
real || (routes.notFound ? { entry: routes.notFound, params: {}, route: null } : null);
|
|
33
35
|
if (!match) return null;
|
|
34
36
|
if (params) match.params = params;
|
|
35
37
|
|
|
36
|
-
// Let a page reading `router.params`/`pathname`/`query` resolve to this route.
|
|
37
|
-
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 });
|
|
38
40
|
|
|
39
41
|
const query = Object.fromEntries(new URLSearchParams(search));
|
|
40
42
|
const props = { params: match.params, query };
|