@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/core/signals.js +42 -1
- package/package.json +9 -2
- package/runtime/dom.js +63 -13
- package/runtime/hydrate.js +43 -1
- 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 +81 -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/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
|
-
.
|
|
275
|
-
.
|
|
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,
|
|
290
|
-
* (
|
|
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
|
+
}
|
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>;
|
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
|
+
}
|