@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.
@@ -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
- .replace(/\[\.\.\.([^\]]+)\]/g, "(?<$1>.+)")
225
- .replace(/\[([^\]]+)\]/g, "(?<$1>[^/]+)");
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, swaps the rendered page
240
- * (tearing down the previous one's lifecycle), and updates window.history.
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 `hydrate` adopt factory (compiled with the hydrate
288
- // target). Only leaf routes (no layout chain) hydrate so far — `{children}`-slot
289
- // adoption is a later phase so anything else falls through to a clean CSR build. A
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 mod = typeof match.entry === "function" ? await match.entry() : match.entry;
294
- if (mod && typeof mod.hydrate === "function" && layoutChain(match.route).length === 0) {
295
- const query = Object.fromEntries(url.searchParams);
296
- const node = mod.hydrate(rootEl, { params: match.params, query });
297
- currentNodes = [node];
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
+ }
@@ -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>;