@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
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Route-loader data on the client (docs/DATA.md). A page with a sibling
|
|
2
|
+
// `loader.{js,ts}` gets its data three ways, all converging on `router.data`:
|
|
3
|
+
//
|
|
4
|
+
// • first paint over server HTML — the payload is inlined as
|
|
5
|
+
// `<script type="application/json" id="__otfw_data">` and read here;
|
|
6
|
+
// • SPA navigation — fetched from `<path>/__data.json` (the same URL a static
|
|
7
|
+
// host serves as a literal file written at SSG time, and the serve/dev
|
|
8
|
+
// servers answer dynamically);
|
|
9
|
+
// • dev/CSR first load — the same fetch, since there is no server markup.
|
|
10
|
+
//
|
|
11
|
+
// The endpoint returns the raw loader JSON (no envelope): 404 means "no data for
|
|
12
|
+
// this route" (no loader, or the loader called `notFound()`) and maps to
|
|
13
|
+
// `undefined` so the page renders its empty state.
|
|
14
|
+
|
|
15
|
+
/** The reserved per-route data filename/URL suffix (`/todos` → `/todos/__data.json`). */
|
|
16
|
+
export const DATA_FILE = "__data.json";
|
|
17
|
+
|
|
18
|
+
// Match the router's path normalization (a trailing slash must hit the same URL).
|
|
19
|
+
const normalize = (p) => (p || "/").replace(/(.)\/+$/, "$1");
|
|
20
|
+
|
|
21
|
+
/** The data-endpoint URL for a page path: `"/"` → `/__data.json`,
|
|
22
|
+
* `"/todos"` → `/todos/__data.json`, preserving the query string. */
|
|
23
|
+
export function dataUrlFor(pathname, search = "") {
|
|
24
|
+
const path = normalize(pathname);
|
|
25
|
+
return (path === "/" ? `/${DATA_FILE}` : `${path}/${DATA_FILE}`) + (search || "");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Fetch a route's loader data. 200 → the parsed JSON; 404 → `undefined` (no
|
|
30
|
+
* loader / `notFound()`); anything else throws (the router reports it and
|
|
31
|
+
* commits the navigation with no data).
|
|
32
|
+
*/
|
|
33
|
+
export async function fetchRouteData(pathname, search = "") {
|
|
34
|
+
const res = await fetch(dataUrlFor(pathname, search));
|
|
35
|
+
if (res.status === 404) return undefined;
|
|
36
|
+
if (!res.ok) throw new Error(`route data request for ${pathname} failed (${res.status})`);
|
|
37
|
+
return res.json();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The inline first-paint payload, read once and cached (mirrors the island-props
|
|
41
|
+
// payload reader in hydrate.js). `undefined` data serializes to *no script at all*
|
|
42
|
+
// (the toolchain skips injection), so a missing element simply reads as undefined.
|
|
43
|
+
let _read = false;
|
|
44
|
+
let _value;
|
|
45
|
+
|
|
46
|
+
/** The loader data inlined by the server for the current document, or `undefined`. */
|
|
47
|
+
export function readInlineRouteData() {
|
|
48
|
+
if (!_read) {
|
|
49
|
+
_read = true;
|
|
50
|
+
const el = typeof document !== "undefined" ? document.getElementById("__otfw_data") : null;
|
|
51
|
+
try {
|
|
52
|
+
_value = el ? JSON.parse(el.textContent || "null") : undefined;
|
|
53
|
+
} catch {
|
|
54
|
+
_value = undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return _value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Reset the cached inline payload (tests only — a fresh document between cases). */
|
|
61
|
+
export function __resetInlineRouteData() {
|
|
62
|
+
_read = false;
|
|
63
|
+
_value = undefined;
|
|
64
|
+
}
|
package/runtime/router.js
CHANGED
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
// NOTE: passing route params to a page via its `props` argument
|
|
12
12
|
// (`function Page(props) { props.params.id }`) and layout `props.children`
|
|
13
13
|
// composition both require signal-free page props, which the compiler does not
|
|
14
|
-
// emit yet — for now pages read params via the reactive `router.params
|
|
14
|
+
// emit yet — for now pages read params via the reactive `router.params`, and a
|
|
15
|
+
// route loader's data via the reactive `router.data` (docs/DATA.md).
|
|
15
16
|
|
|
16
17
|
import { clearError, reportError } from "../core/errors.js";
|
|
17
18
|
import { signal } from "../core/signals.js";
|
|
19
|
+
import { beginHydration, cursor, endHydration } from "./hydrate.js";
|
|
18
20
|
import { runCleanup, runMount } from "./mount.js";
|
|
21
|
+
import { fetchRouteData, readInlineRouteData } from "./route-data.js";
|
|
19
22
|
|
|
20
23
|
const isBrowser = typeof window !== "undefined";
|
|
21
24
|
|
|
@@ -33,9 +36,10 @@ const state = {
|
|
|
33
36
|
searchParams: signal(new URLSearchParams(isBrowser ? window.location.search : "")),
|
|
34
37
|
params: signal({}),
|
|
35
38
|
locale: signal(null),
|
|
39
|
+
data: signal(undefined),
|
|
36
40
|
};
|
|
37
41
|
|
|
38
|
-
export const routes = { pages: {}, layouts: {}, notFound: null };
|
|
42
|
+
export const routes = { pages: {}, layouts: {}, notFound: null, loaderRoutes: new Set() };
|
|
39
43
|
let guard = null;
|
|
40
44
|
let rootEl = null;
|
|
41
45
|
|
|
@@ -134,14 +138,19 @@ export const router = {
|
|
|
134
138
|
get locale() {
|
|
135
139
|
return state.locale.value;
|
|
136
140
|
},
|
|
141
|
+
get data() {
|
|
142
|
+
return state.data.value;
|
|
143
|
+
},
|
|
137
144
|
push: (path) => navigate(path),
|
|
138
145
|
replace: (path) => navigate(path, true),
|
|
139
146
|
};
|
|
140
147
|
|
|
141
|
-
/** Derive the route ("/counter", "/") from a `.../app/<route>/page.jsx` path.
|
|
148
|
+
/** Derive the route ("/counter", "/") from a `.../app/<route>/page.jsx` path. The
|
|
149
|
+
* lookahead pins `/app` to a complete path segment, so a route folder that merely
|
|
150
|
+
* starts with "app" (`/appointments`) isn't clipped. */
|
|
142
151
|
function routeFromPath(filePath) {
|
|
143
152
|
const r = filePath
|
|
144
|
-
.replace(/^.*\/app/, "")
|
|
153
|
+
.replace(/^.*\/app(?=\/)/, "")
|
|
145
154
|
.replace(/\/(page|layout|404)\.(jsx|tsx|mdx|md)$/, "");
|
|
146
155
|
return r === "" ? "/" : r;
|
|
147
156
|
}
|
|
@@ -161,6 +170,22 @@ export function registerRoutes(modules) {
|
|
|
161
170
|
}
|
|
162
171
|
}
|
|
163
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Register which route *patterns* (`"/todos"`, `"/items/[id]"`) have a server
|
|
175
|
+
* loader (docs/DATA.md) — the toolchain discovers `loader.{js,ts}` files and
|
|
176
|
+
* passes the list via `mountApp({ loaders })`. `navigate` only fetches
|
|
177
|
+
* `<path>/__data.json` for routes in this set; `matchRoute` returns the same
|
|
178
|
+
* pattern string, so membership is a plain Set lookup.
|
|
179
|
+
*/
|
|
180
|
+
export function registerLoaderRoutes(paths) {
|
|
181
|
+
routes.loaderRoutes = new Set(paths || []);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Set the reactive `router.data` directly (server render and tests). */
|
|
185
|
+
export function setRouteData(data) {
|
|
186
|
+
state.data.value = data;
|
|
187
|
+
}
|
|
188
|
+
|
|
164
189
|
/** Layout entries that wrap `route`, outermost (root) first. */
|
|
165
190
|
export function layoutChain(route) {
|
|
166
191
|
const chain = [];
|
|
@@ -200,16 +225,68 @@ export async function buildRouteNode(match, query = {}) {
|
|
|
200
225
|
return { node, nodes };
|
|
201
226
|
}
|
|
202
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Adopt a matched route's server-rendered DOM (docs/HYDRATION.md §3.4, 2.1c) — the hydrate
|
|
230
|
+
* analogue of {@link buildRouteNode}. One cursor threads the whole layout chain: the
|
|
231
|
+
* outermost layout adopts at the container, and each layout hands that cursor to the next at
|
|
232
|
+
* its `{children}` slot (`props.children` is a thunk that claims the nested route's subtree
|
|
233
|
+
* and advances the cursor), down to the page. Returns `{ nodes }` (page → … → root) for
|
|
234
|
+
* lifecycle, or `null` if the page or any layout isn't adoptable (no `hydrateAt` export) — the
|
|
235
|
+
* caller then falls back to a clean CSR build. A thrown `HydrationMismatch` propagates up,
|
|
236
|
+
* disposing each layer's partial wiring on the way (each `hydrateAt` has its own guard).
|
|
237
|
+
*/
|
|
238
|
+
export async function hydrateRouteNode(match, query, rootEl) {
|
|
239
|
+
const props = { params: match.params, query };
|
|
240
|
+
const pageMod = await resolveModule(match.entry);
|
|
241
|
+
if (!pageMod || typeof pageMod.hydrateAt !== "function") return null;
|
|
242
|
+
const chain = layoutChain(match.route);
|
|
243
|
+
const layoutMods = [];
|
|
244
|
+
for (const entry of chain) {
|
|
245
|
+
const m = await resolveModule(entry);
|
|
246
|
+
if (!m || typeof m.hydrateAt !== "function") return null; // whole chain must be adoptable
|
|
247
|
+
layoutMods.push(m);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Compose the adopt thunks innermost-first: the page, then each layout wrapping it. Each
|
|
251
|
+
// thunk pushes its claimed root node, so `nodes` ends up page → … → outermost (the inner
|
|
252
|
+
// thunk runs — and pushes — during the outer layout's walk, before the outer pushes).
|
|
253
|
+
const nodes = [];
|
|
254
|
+
let thunk = (c) => {
|
|
255
|
+
const n = pageMod.hydrateAt(c, props);
|
|
256
|
+
nodes.push(n);
|
|
257
|
+
return n;
|
|
258
|
+
};
|
|
259
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
260
|
+
const layout = layoutMods[i];
|
|
261
|
+
const inner = thunk;
|
|
262
|
+
thunk = (c) => {
|
|
263
|
+
const n = layout.hydrateAt(c, { ...props, children: inner });
|
|
264
|
+
nodes.push(n);
|
|
265
|
+
return n;
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
thunk(cursor(rootEl)); // outermost adopts at the container; the chain threads inward
|
|
269
|
+
return { nodes };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Resolve a route entry (lazy loader or module namespace) to its module. */
|
|
273
|
+
async function resolveModule(entry) {
|
|
274
|
+
return typeof entry === "function" ? await entry() : entry;
|
|
275
|
+
}
|
|
276
|
+
|
|
203
277
|
/**
|
|
204
278
|
* Set the reactive route state directly (no history/render). Used by server render
|
|
205
279
|
* so a page reading `router.pathname`/`params`/`query` resolves to the route being
|
|
206
280
|
* pre-rendered. The client uses `navigate` instead.
|
|
207
281
|
*/
|
|
208
|
-
export function setRouteState({ pathname = "/", search = "", params = {}, locale } = {}) {
|
|
282
|
+
export function setRouteState({ pathname = "/", search = "", params = {}, locale, data } = {}) {
|
|
209
283
|
state.pathname.value = normalizePath(pathname);
|
|
210
284
|
state.searchParams.value = new URLSearchParams(search);
|
|
211
285
|
state.params.value = params;
|
|
212
286
|
state.locale.value = locale !== undefined ? locale : resolveLocale(pathname).locale;
|
|
287
|
+
// Always assigned (even when the caller passed none) so a loader-less render
|
|
288
|
+
// never shows a previous route's stale data.
|
|
289
|
+
state.data.value = data;
|
|
213
290
|
}
|
|
214
291
|
|
|
215
292
|
/**
|
|
@@ -220,9 +297,16 @@ export function setRouteState({ pathname = "/", search = "", params = {}, locale
|
|
|
220
297
|
export function matchRoute(pathname) {
|
|
221
298
|
pathname = resolveLocale(pathname).path;
|
|
222
299
|
for (const route in routes.pages) {
|
|
300
|
+
// `[param]` / `[...rest]` become named groups; literal parts are regex-escaped
|
|
301
|
+
// (a `v1.0` folder must not match `v1X0`).
|
|
223
302
|
const pattern = route
|
|
224
|
-
.
|
|
225
|
-
.
|
|
303
|
+
.split(/(\[\.\.\.[^\]]+\]|\[[^\]]+\])/)
|
|
304
|
+
.map((part) => {
|
|
305
|
+
if (part.startsWith("[...")) return `(?<${part.slice(4, -1)}>.+)`;
|
|
306
|
+
if (part.startsWith("[")) return `(?<${part.slice(1, -1)}>[^/]+)`;
|
|
307
|
+
return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
308
|
+
})
|
|
309
|
+
.join("");
|
|
226
310
|
const m = pathname.match(new RegExp(`^${pattern}/?$`));
|
|
227
311
|
if (m) {
|
|
228
312
|
const params = { ...(m.groups || {}) };
|
|
@@ -235,12 +319,20 @@ export function matchRoute(pathname) {
|
|
|
235
319
|
return null;
|
|
236
320
|
}
|
|
237
321
|
|
|
322
|
+
// Monotonic navigation sequence: a navigation that awaited its loader-data fetch
|
|
323
|
+
// commits only if no newer navigation started meanwhile (stale data must never
|
|
324
|
+
// win over a later click).
|
|
325
|
+
let navSeq = 0;
|
|
326
|
+
|
|
238
327
|
/**
|
|
239
|
-
* Navigate to `path`. Runs an optional route guard,
|
|
240
|
-
* (
|
|
328
|
+
* Navigate to `path`. Runs an optional route guard, fetches the route's loader
|
|
329
|
+
* data when it has any (docs/DATA.md — fetch *then* commit, like the guard),
|
|
330
|
+
* swaps the rendered page (tearing down the previous one's lifecycle), and
|
|
331
|
+
* updates window.history.
|
|
241
332
|
*/
|
|
242
333
|
export async function navigate(path, replace = false, isPop = false, hydrate = false) {
|
|
243
334
|
if (!path || !rootEl) return;
|
|
335
|
+
const seq = ++navSeq;
|
|
244
336
|
const url = new URL(path, window.location.origin);
|
|
245
337
|
|
|
246
338
|
if (guard) {
|
|
@@ -273,10 +365,30 @@ export async function navigate(path, replace = false, isPop = false, hydrate = f
|
|
|
273
365
|
matchRoute(url.pathname) ||
|
|
274
366
|
(routes.notFound ? { entry: routes.notFound, params: {}, route: null } : null);
|
|
275
367
|
|
|
368
|
+
// Loader data (docs/DATA.md): resolved before any state write / history push /
|
|
369
|
+
// DOM swap, mirroring the guard's "settle before commit" flow. First paint over
|
|
370
|
+
// server HTML reads the inlined payload; every other navigation (SPA nav, dev/CSR
|
|
371
|
+
// first load, popstate) fetches the `<path>/__data.json` endpoint. A failed fetch
|
|
372
|
+
// is reported and the navigation commits with `data === undefined`.
|
|
373
|
+
let data;
|
|
374
|
+
if (match && match.route && routes.loaderRoutes.has(match.route)) {
|
|
375
|
+
if (hydrate) {
|
|
376
|
+
data = readInlineRouteData();
|
|
377
|
+
} else {
|
|
378
|
+
try {
|
|
379
|
+
data = await fetchRouteData(url.pathname, url.search);
|
|
380
|
+
} catch (e) {
|
|
381
|
+
reportError(e, { phase: "data", path: url.pathname });
|
|
382
|
+
}
|
|
383
|
+
if (seq !== navSeq) return; // a newer navigation superseded this one
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
276
387
|
state.pathname.value = normalizePath(url.pathname);
|
|
277
388
|
state.searchParams.value = url.searchParams;
|
|
278
389
|
state.params.value = match ? match.params : {};
|
|
279
390
|
state.locale.value = resolveLocale(url.pathname).locale;
|
|
391
|
+
state.data.value = data;
|
|
280
392
|
|
|
281
393
|
if (!isPop) {
|
|
282
394
|
if (replace) window.history.replaceState({}, "", path);
|
|
@@ -284,24 +396,34 @@ export async function navigate(path, replace = false, isPop = false, hydrate = f
|
|
|
284
396
|
}
|
|
285
397
|
|
|
286
398
|
// First paint over server-rendered DOM: *adopt* it (hydrate) instead of rebuilding,
|
|
287
|
-
// when the route module exposes a `
|
|
288
|
-
// target).
|
|
289
|
-
//
|
|
399
|
+
// when the route module exposes a `hydrateAt` adopt factory (compiled with the hydrate
|
|
400
|
+
// target). `hydrateRouteNode` threads one cursor through the whole layout chain (2.1c);
|
|
401
|
+
// a route whose page or any layout isn't adoptable returns null → clean CSR build. A
|
|
290
402
|
// hydration mismatch is reported (never silent) and also falls through to a rebuild.
|
|
403
|
+
//
|
|
404
|
+
// The hydration flag must be live *before* the route modules are imported: route chunks
|
|
405
|
+
// are code-split, so `customElements.define` — and the synchronous upgrade of every
|
|
406
|
+
// server-rendered `<web-*>` host — happens during those imports, before the factories
|
|
407
|
+
// run. Those upgrading components read `isHydrating()` to adopt their server DOM rather
|
|
408
|
+
// than build (docs/HYDRATION.md §3.4). `endHydration()` in `finally` makes every
|
|
409
|
+
// subsequent client navigation build fresh; the CSR fallback below then runs with the
|
|
410
|
+
// flag cleared, so a rebuilt host builds instead of trying to re-adopt.
|
|
291
411
|
if (hydrate && match) {
|
|
412
|
+
beginHydration();
|
|
292
413
|
try {
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
runMount(node);
|
|
414
|
+
const query = Object.fromEntries(url.searchParams);
|
|
415
|
+
const adopted = await hydrateRouteNode(match, query, rootEl);
|
|
416
|
+
if (adopted) {
|
|
417
|
+
currentNodes = adopted.nodes;
|
|
418
|
+
for (const n of adopted.nodes) runMount(n); // onMount for page + every layout
|
|
299
419
|
clearError({ phase: "route" });
|
|
300
420
|
return;
|
|
301
421
|
}
|
|
302
422
|
} catch (e) {
|
|
303
423
|
reportError(e, { phase: "hydrate", path: url.pathname });
|
|
304
424
|
// fall through to a clean CSR build below
|
|
425
|
+
} finally {
|
|
426
|
+
endHydration();
|
|
305
427
|
}
|
|
306
428
|
}
|
|
307
429
|
|
|
@@ -352,12 +474,15 @@ export async function navigate(path, replace = false, isPop = false, hydrate = f
|
|
|
352
474
|
* @param {Function} [opts.guard] optional `(to, tools) => …` route guard.
|
|
353
475
|
* @param {"spa"|"mpa"} [opts.nav] navigation mode (default "spa"); "mpa" disables
|
|
354
476
|
* client-side link interception so every navigation is a full page load.
|
|
477
|
+
* @param {string[]} [opts.loaders] route patterns that have a server loader
|
|
478
|
+
* (docs/DATA.md) — navigation fetches `<path>/__data.json` for these.
|
|
355
479
|
*/
|
|
356
|
-
export function mountApp({ pages, target, guard: g, i18n, nav } = {}) {
|
|
480
|
+
export function mountApp({ pages, target, guard: g, i18n, nav, loaders } = {}) {
|
|
357
481
|
rootEl = target || (isBrowser ? document.getElementById("app") : null);
|
|
358
482
|
navMode = nav === "mpa" ? "mpa" : "spa";
|
|
359
483
|
if (i18n) configureI18n(i18n);
|
|
360
484
|
if (pages) registerRoutes(pages);
|
|
485
|
+
if (loaders) registerLoaderRoutes(loaders);
|
|
361
486
|
guard = g || null;
|
|
362
487
|
// In MPA mode the browser owns navigation (full loads push real history entries),
|
|
363
488
|
// so there is no client-side history to react to — only wire popstate for SPA.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Type definitions for the Node.js adapter (SPEC §11.4).
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
|
|
4
|
+
import type { RequestHandler } from "../api.js";
|
|
5
|
+
|
|
6
|
+
export interface NodeAdapterOptions {
|
|
7
|
+
/** Response for requests that match no API route (default: a 404). */
|
|
8
|
+
fallback?: (request: Request) => Response | Promise<Response>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Build a WHATWG `Request` from a Node `IncomingMessage`. */
|
|
12
|
+
export function toWebRequest(req: IncomingMessage, options?: { protocol?: string }): Promise<Request>;
|
|
13
|
+
|
|
14
|
+
/** Write a WHATWG `Response` to a Node `ServerResponse`. */
|
|
15
|
+
export function sendWebResponse(res: ServerResponse, webRes: Response): Promise<void>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Turn a Fetch handler into a Node `(req, res)` listener:
|
|
19
|
+
*
|
|
20
|
+
* import { createServer } from "node:http";
|
|
21
|
+
* import { apiHandler } from "./dist/server/api.js";
|
|
22
|
+
* import { toNodeListener } from "@opentf/web/server/adapters/node";
|
|
23
|
+
* createServer(toNodeListener(apiHandler)).listen(3000);
|
|
24
|
+
*/
|
|
25
|
+
export function toNodeListener(
|
|
26
|
+
handler: RequestHandler,
|
|
27
|
+
options?: NodeAdapterOptions,
|
|
28
|
+
): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Node.js adapter for OTF Web API routes (SPEC §11.4). The API handler speaks the
|
|
2
|
+
// standard Fetch `Request`/`Response`; Bun, Cloudflare Workers, and Deno provide
|
|
3
|
+
// those natively, but `node:http` speaks its own `IncomingMessage`/`ServerResponse`.
|
|
4
|
+
// This adapter translates between the two so `dist/server/api.js` runs on Node.
|
|
5
|
+
//
|
|
6
|
+
// import { createServer } from "node:http";
|
|
7
|
+
// import { apiHandler } from "./dist/server/api.js";
|
|
8
|
+
// import { toNodeListener } from "@opentf/web/server/adapters/node";
|
|
9
|
+
// createServer(toNodeListener(apiHandler)).listen(3000);
|
|
10
|
+
|
|
11
|
+
/** Build a WHATWG `Request` from a Node `IncomingMessage`. */
|
|
12
|
+
export async function toWebRequest(req, { protocol = "http" } = {}) {
|
|
13
|
+
const host = req.headers.host ?? "localhost";
|
|
14
|
+
const url = `${protocol}://${host}${req.url}`;
|
|
15
|
+
const method = req.method ?? "GET";
|
|
16
|
+
const headers = new Headers();
|
|
17
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
18
|
+
if (Array.isArray(v)) for (const one of v) headers.append(k, one);
|
|
19
|
+
else if (v != null) headers.set(k, v);
|
|
20
|
+
}
|
|
21
|
+
// GET/HEAD must not carry a body; everything else streams the request in.
|
|
22
|
+
const hasBody = method !== "GET" && method !== "HEAD";
|
|
23
|
+
const body = hasBody ? await readBody(req) : undefined;
|
|
24
|
+
return new Request(url, { method, headers, body });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readBody(req) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const chunks = [];
|
|
30
|
+
req.on("data", (c) => chunks.push(c));
|
|
31
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
32
|
+
req.on("error", reject);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Write a WHATWG `Response` to a Node `ServerResponse`. */
|
|
37
|
+
export async function sendWebResponse(res, webRes) {
|
|
38
|
+
const headers = {};
|
|
39
|
+
webRes.headers.forEach((value, key) => {
|
|
40
|
+
if (key !== "set-cookie") headers[key] = value;
|
|
41
|
+
});
|
|
42
|
+
// `Headers` iteration collapses repeated Set-Cookie values into one string, which
|
|
43
|
+
// breaks multi-cookie responses (session + CSRF). Recover the individual cookies;
|
|
44
|
+
// Node accepts an array header value and writes one Set-Cookie line per entry.
|
|
45
|
+
const cookies = webRes.headers.getSetCookie?.() ?? [];
|
|
46
|
+
if (cookies.length > 0) headers["set-cookie"] = cookies;
|
|
47
|
+
res.writeHead(webRes.status, headers);
|
|
48
|
+
if (webRes.body) {
|
|
49
|
+
const buf = Buffer.from(await webRes.arrayBuffer());
|
|
50
|
+
res.end(buf);
|
|
51
|
+
} else {
|
|
52
|
+
res.end();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Turn a Fetch handler `(request) => Response | null` into a Node
|
|
58
|
+
* `(req, res)` listener. A `null` result (no API route matched) becomes the
|
|
59
|
+
* optional `fallback(request)` response, or a 404.
|
|
60
|
+
*/
|
|
61
|
+
export function toNodeListener(handler, { fallback } = {}) {
|
|
62
|
+
return async (req, res) => {
|
|
63
|
+
try {
|
|
64
|
+
const request = await toWebRequest(req);
|
|
65
|
+
let webRes = await handler(request);
|
|
66
|
+
if (!webRes) webRes = fallback ? await fallback(request) : new Response("Not Found", { status: 404 });
|
|
67
|
+
await sendWebResponse(res, webRes);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
if (!res.headersSent) res.writeHead(500, { "content-type": "application/json" });
|
|
70
|
+
res.end(JSON.stringify({ error: "Internal Server Error" }));
|
|
71
|
+
console.error("✗ API (node adapter):", e?.stack ?? e);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
package/server/api.d.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Type definitions for file-based API routes (SPEC §11). Authoring handlers in
|
|
2
|
+
// TypeScript: annotate the method exports with `ApiHandler` and the `_middleware`
|
|
3
|
+
// default export with `Middleware`.
|
|
4
|
+
//
|
|
5
|
+
// import type { ApiHandler, Middleware } from "@opentf/web/server";
|
|
6
|
+
// export const GET: ApiHandler = (request, { params }) => Response.json({ id: params.id });
|
|
7
|
+
|
|
8
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Route params resolved from `[param]` / `[...rest]` segments. A `[param]` segment
|
|
12
|
+
* resolves to a `string`; a `[...rest]` catch-all resolves to a `string[]`. Values
|
|
13
|
+
* arrive percent-decoded (`/users/John%20Doe` → `"John Doe"`).
|
|
14
|
+
*/
|
|
15
|
+
export type RouteParams = Record<string, string | string[]>;
|
|
16
|
+
|
|
17
|
+
/** Per-request context, passed as the second argument to handlers and middleware. */
|
|
18
|
+
export interface ApiContext {
|
|
19
|
+
/** Dynamic route params resolved from the matched path. */
|
|
20
|
+
params: RouteParams;
|
|
21
|
+
/** Parsed query-string parameters. */
|
|
22
|
+
query: Record<string, string>;
|
|
23
|
+
/** The parsed request URL. */
|
|
24
|
+
url: URL;
|
|
25
|
+
/**
|
|
26
|
+
* Mutable per-request bag. Middleware writes to it (e.g. an authenticated user
|
|
27
|
+
* or a validated body) and downstream middleware / the handler reads from it.
|
|
28
|
+
*/
|
|
29
|
+
locals: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* An HTTP method handler (`GET`, `POST`, …). Receives the standard Fetch `Request`
|
|
34
|
+
* plus the {@link ApiContext}. Should return (or throw) a `Response`; a plain value
|
|
35
|
+
* is a convenience that gets JSON-encoded.
|
|
36
|
+
*/
|
|
37
|
+
export type ApiHandler = (request: Request, context: ApiContext) => MaybePromise<Response>;
|
|
38
|
+
|
|
39
|
+
/** Continue to the next middleware, or ultimately the route handler. */
|
|
40
|
+
export type NextFn = () => Promise<Response>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Folder middleware — the default export of a `_middleware.{js,ts}` file. Applies
|
|
44
|
+
* to its folder and everything nested under it; multiple middleware compose
|
|
45
|
+
* outermost-first. Call `next()` to continue, or return a `Response` to
|
|
46
|
+
* short-circuit (e.g. an auth guard).
|
|
47
|
+
*/
|
|
48
|
+
export type Middleware = (request: Request, context: ApiContext, next: NextFn) => MaybePromise<Response>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The composed request handler produced by {@link createApiHandler}: resolves to a
|
|
52
|
+
* `Response`, or `null` when no API route matched (so the caller can fall through
|
|
53
|
+
* to SSR or a 404).
|
|
54
|
+
*/
|
|
55
|
+
export type RequestHandler = (request: Request) => Promise<Response | null>;
|
|
56
|
+
|
|
57
|
+
/** A discovered route module: its method-named handler exports, keyed by method. */
|
|
58
|
+
export type RouteModule = Partial<Record<string, ApiHandler>>;
|
|
59
|
+
|
|
60
|
+
/** A discovered `_middleware` module: the middleware is the default export. */
|
|
61
|
+
export interface MiddlewareModule {
|
|
62
|
+
default?: Middleware;
|
|
63
|
+
middleware?: Middleware;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface FetchHandlerOptions {
|
|
67
|
+
/** Response for requests that match no API route (default: a 404). */
|
|
68
|
+
fallback?: (request: Request) => MaybePromise<Response>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ApiHandlerOptions {
|
|
72
|
+
/**
|
|
73
|
+
* Absolute path of the app directory the module keys live under. When given, the
|
|
74
|
+
* route is derived by stripping this exact prefix (the CLI always passes it);
|
|
75
|
+
* without it a `/app` path-segment heuristic is used.
|
|
76
|
+
*/
|
|
77
|
+
appDir?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Derive the route path from an `app/**/route.{js,ts}` file path (folder = URL). */
|
|
81
|
+
export function apiRouteFromPath(filePath: string, appDir?: string): string;
|
|
82
|
+
|
|
83
|
+
/** Derive the folder route a `_middleware` file governs from its file path. */
|
|
84
|
+
export function middlewareScopeFromPath(filePath: string, appDir?: string): string;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build the API request handler from discovered route + middleware modules (both
|
|
88
|
+
* keyed by absolute file path so routes are derived from the path).
|
|
89
|
+
*/
|
|
90
|
+
export function createApiHandler(
|
|
91
|
+
routeModules?: Record<string, RouteModule>,
|
|
92
|
+
middlewareModules?: Record<string, MiddlewareModule>,
|
|
93
|
+
options?: ApiHandlerOptions,
|
|
94
|
+
): RequestHandler;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Wrap a {@link RequestHandler} into a total Fetch handler that always returns a
|
|
98
|
+
* `Response` — a `null` (no route matched) becomes the `fallback` or a 404. Use it
|
|
99
|
+
* to mount the handler on Fetch-native runtimes (Bun, Cloudflare Workers, Deno):
|
|
100
|
+
*
|
|
101
|
+
* export default { fetch: createFetchHandler(apiHandler) };
|
|
102
|
+
*/
|
|
103
|
+
export function createFetchHandler(
|
|
104
|
+
handler: RequestHandler,
|
|
105
|
+
options?: FetchHandlerOptions,
|
|
106
|
+
): (request: Request) => Promise<Response>;
|