@opentf/web 0.7.0 → 0.9.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/runtime/router.js CHANGED
@@ -11,12 +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";
18
19
  import { beginHydration, cursor, endHydration } from "./hydrate.js";
19
20
  import { runCleanup, runMount } from "./mount.js";
21
+ import { fetchRouteData, readInlineRouteData } from "./route-data.js";
20
22
 
21
23
  const isBrowser = typeof window !== "undefined";
22
24
 
@@ -34,9 +36,10 @@ const state = {
34
36
  searchParams: signal(new URLSearchParams(isBrowser ? window.location.search : "")),
35
37
  params: signal({}),
36
38
  locale: signal(null),
39
+ data: signal(undefined),
37
40
  };
38
41
 
39
- export const routes = { pages: {}, layouts: {}, notFound: null };
42
+ export const routes = { pages: {}, layouts: {}, notFound: null, loaderRoutes: new Set() };
40
43
  let guard = null;
41
44
  let rootEl = null;
42
45
 
@@ -135,14 +138,19 @@ export const router = {
135
138
  get locale() {
136
139
  return state.locale.value;
137
140
  },
141
+ get data() {
142
+ return state.data.value;
143
+ },
138
144
  push: (path) => navigate(path),
139
145
  replace: (path) => navigate(path, true),
140
146
  };
141
147
 
142
- /** 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. */
143
151
  function routeFromPath(filePath) {
144
152
  const r = filePath
145
- .replace(/^.*\/app/, "")
153
+ .replace(/^.*\/app(?=\/)/, "")
146
154
  .replace(/\/(page|layout|404)\.(jsx|tsx|mdx|md)$/, "");
147
155
  return r === "" ? "/" : r;
148
156
  }
@@ -162,6 +170,22 @@ export function registerRoutes(modules) {
162
170
  }
163
171
  }
164
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
+
165
189
  /** Layout entries that wrap `route`, outermost (root) first. */
166
190
  export function layoutChain(route) {
167
191
  const chain = [];
@@ -255,11 +279,14 @@ async function resolveModule(entry) {
255
279
  * so a page reading `router.pathname`/`params`/`query` resolves to the route being
256
280
  * pre-rendered. The client uses `navigate` instead.
257
281
  */
258
- export function setRouteState({ pathname = "/", search = "", params = {}, locale } = {}) {
282
+ export function setRouteState({ pathname = "/", search = "", params = {}, locale, data } = {}) {
259
283
  state.pathname.value = normalizePath(pathname);
260
284
  state.searchParams.value = new URLSearchParams(search);
261
285
  state.params.value = params;
262
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;
263
290
  }
264
291
 
265
292
  /**
@@ -270,9 +297,16 @@ export function setRouteState({ pathname = "/", search = "", params = {}, locale
270
297
  export function matchRoute(pathname) {
271
298
  pathname = resolveLocale(pathname).path;
272
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`).
273
302
  const pattern = route
274
- .replace(/\[\.\.\.([^\]]+)\]/g, "(?<$1>.+)")
275
- .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("");
276
310
  const m = pathname.match(new RegExp(`^${pattern}/?$`));
277
311
  if (m) {
278
312
  const params = { ...(m.groups || {}) };
@@ -285,12 +319,20 @@ export function matchRoute(pathname) {
285
319
  return null;
286
320
  }
287
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
+
288
327
  /**
289
- * Navigate to `path`. Runs an optional route guard, swaps the rendered page
290
- * (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.
291
332
  */
292
333
  export async function navigate(path, replace = false, isPop = false, hydrate = false) {
293
334
  if (!path || !rootEl) return;
335
+ const seq = ++navSeq;
294
336
  const url = new URL(path, window.location.origin);
295
337
 
296
338
  if (guard) {
@@ -323,10 +365,30 @@ export async function navigate(path, replace = false, isPop = false, hydrate = f
323
365
  matchRoute(url.pathname) ||
324
366
  (routes.notFound ? { entry: routes.notFound, params: {}, route: null } : null);
325
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
+
326
387
  state.pathname.value = normalizePath(url.pathname);
327
388
  state.searchParams.value = url.searchParams;
328
389
  state.params.value = match ? match.params : {};
329
390
  state.locale.value = resolveLocale(url.pathname).locale;
391
+ state.data.value = data;
330
392
 
331
393
  if (!isPop) {
332
394
  if (replace) window.history.replaceState({}, "", path);
@@ -412,12 +474,15 @@ export async function navigate(path, replace = false, isPop = false, hydrate = f
412
474
  * @param {Function} [opts.guard] optional `(to, tools) => …` route guard.
413
475
  * @param {"spa"|"mpa"} [opts.nav] navigation mode (default "spa"); "mpa" disables
414
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.
415
479
  */
416
- export function mountApp({ pages, target, guard: g, i18n, nav } = {}) {
480
+ export function mountApp({ pages, target, guard: g, i18n, nav, loaders } = {}) {
417
481
  rootEl = target || (isBrowser ? document.getElementById("app") : null);
418
482
  navMode = nav === "mpa" ? "mpa" : "spa";
419
483
  if (i18n) configureI18n(i18n);
420
484
  if (pages) registerRoutes(pages);
485
+ if (loaders) registerLoaderRoutes(loaders);
421
486
  guard = g || null;
422
487
  // In MPA mode the browser owns navigation (full loads push real history entries),
423
488
  // so there is no client-side history to react to — only wire popstate for SPA.
@@ -439,6 +504,12 @@ export function mountApp({ pages, target, guard: g, i18n, nav } = {}) {
439
504
  typeof rootEl.hasAttribute === "function" &&
440
505
  rootEl.hasAttribute("data-otfw-hydrate")
441
506
  );
507
+ // The flag is seeded `true` from the sentinel at module load (so eagerly-defined
508
+ // framework components adopt on upgrade — see hydrate.js). If this mount is NOT
509
+ // hydrating after all (no sentinel, or an empty root), clear it now so the CSR build
510
+ // below — and every later navigation — builds fresh. The hydrate path clears it itself
511
+ // (via `endHydration` in `navigate`'s `finally`) once first paint is adopted.
512
+ if (!hydrate) endHydration();
442
513
  return navigate(
443
514
  window.location.pathname + window.location.search + window.location.hash,
444
515
  true,
@@ -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>;
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
+ }