@opentf/web 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/signals.js +42 -1
- package/package.json +9 -2
- package/runtime/dom.js +43 -13
- package/runtime/index.js +2 -0
- package/runtime/mount.js +14 -1
- package/runtime/resource.js +139 -0
- package/runtime/route-data.js +64 -0
- package/runtime/router.js +75 -10
- package/server/adapters/node.d.ts +28 -0
- package/server/adapters/node.js +74 -0
- package/server/api.d.ts +106 -0
- package/server/api.js +208 -0
- package/server/index.d.ts +92 -0
- package/server/index.js +2 -0
- package/server/loader.js +199 -0
- package/server/render.js +6 -4
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Types for the `@opentf/web/server` entry. The API-routes surface is fully typed
|
|
2
|
+
// (./api); the SSG string-builders and render helpers — authored in JS and mostly
|
|
3
|
+
// consumed by generated entry code — are declared with permissive signatures.
|
|
4
|
+
|
|
5
|
+
export * from "./api.js";
|
|
6
|
+
|
|
7
|
+
// ── Render / route API (render.js) ─────────────────────────────────────────────
|
|
8
|
+
export interface RenderResult {
|
|
9
|
+
html: string;
|
|
10
|
+
metadata: Record<string, unknown>;
|
|
11
|
+
hydration: unknown;
|
|
12
|
+
status?: number;
|
|
13
|
+
}
|
|
14
|
+
export function renderRoute(
|
|
15
|
+
pathname: string,
|
|
16
|
+
params?: Record<string, unknown> | null,
|
|
17
|
+
search?: string,
|
|
18
|
+
options?: { data?: unknown },
|
|
19
|
+
): Promise<RenderResult | null>;
|
|
20
|
+
export function renderToString(pathname: string, search?: string): Promise<string>;
|
|
21
|
+
export function collectRoutePaths(): Promise<string[]>;
|
|
22
|
+
|
|
23
|
+
// ── <head> / metadata (head.js) ─────────────────────────────────────────────────
|
|
24
|
+
export function resolveMetadata(args?: {
|
|
25
|
+
route?: string;
|
|
26
|
+
entry?: unknown;
|
|
27
|
+
params?: Record<string, unknown>;
|
|
28
|
+
query?: Record<string, unknown>;
|
|
29
|
+
}): Promise<Record<string, unknown>>;
|
|
30
|
+
export function renderHead(meta?: Record<string, unknown>, opts?: { path?: string; baseUrl?: string }): string;
|
|
31
|
+
export function localeAlternateLinks(
|
|
32
|
+
routePath: string,
|
|
33
|
+
cfg?: { locales?: string[]; defaultLocale?: string },
|
|
34
|
+
localize?: (path: string, locale: string) => string,
|
|
35
|
+
): Array<Record<string, string>>;
|
|
36
|
+
|
|
37
|
+
// ── Route loaders (loader.js) ───────────────────────────────────────────────────
|
|
38
|
+
import type { RouteParams } from "./api.js";
|
|
39
|
+
|
|
40
|
+
/** The context a route loader receives (docs/DATA.md). `request` is the live
|
|
41
|
+
* Request under serve/dev and undefined at SSG prerender; `locals` is reserved. */
|
|
42
|
+
export interface LoaderContext {
|
|
43
|
+
params: RouteParams;
|
|
44
|
+
query: Record<string, string>;
|
|
45
|
+
request?: Request;
|
|
46
|
+
locale: string | null;
|
|
47
|
+
locals: Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A route loader — the default (or named `loader`) export of a `loader.{js,ts}`
|
|
51
|
+
* file. Returns the page's JSON-serializable data. */
|
|
52
|
+
export type Loader = (context: LoaderContext) => unknown | Promise<unknown>;
|
|
53
|
+
|
|
54
|
+
export interface LoaderMatch {
|
|
55
|
+
route: string;
|
|
56
|
+
params: RouteParams;
|
|
57
|
+
locale: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface LoaderRegistry {
|
|
61
|
+
routes: string[];
|
|
62
|
+
match(pathname: string): LoaderMatch | null;
|
|
63
|
+
load(m: LoaderMatch, ctx?: { request?: Request; query?: Record<string, string> }): Promise<unknown>;
|
|
64
|
+
loadSerialized(
|
|
65
|
+
m: LoaderMatch,
|
|
66
|
+
ctx?: { request?: Request; query?: Record<string, string> },
|
|
67
|
+
): Promise<{ data: unknown; json: string }>;
|
|
68
|
+
handle(request: Request): Promise<Response | null>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createLoaderRegistry(
|
|
72
|
+
loaderModules?: Record<string, { default?: Loader; loader?: Loader }>,
|
|
73
|
+
options?: { appDir?: string; i18n?: { locales?: string[]; defaultLocale?: string } },
|
|
74
|
+
): LoaderRegistry;
|
|
75
|
+
export function loaderRouteFromPath(filePath: string, appDir?: string): string;
|
|
76
|
+
/** Throw "this page does not exist" from inside a loader (404 semantics). */
|
|
77
|
+
export function notFound(message?: string): never;
|
|
78
|
+
export function isNotFound(e: unknown): boolean;
|
|
79
|
+
export function serializeRouteData(value: unknown): string;
|
|
80
|
+
|
|
81
|
+
// ── SSG string-builder helpers (ssg-runtime.js) ─────────────────────────────────
|
|
82
|
+
export function defineSSG(tag: string, render: (...args: any[]) => string): void;
|
|
83
|
+
export function beginHydrationCollect(): void;
|
|
84
|
+
export function endHydrationCollect(): unknown;
|
|
85
|
+
export function escapeHtml(s: unknown): string;
|
|
86
|
+
export function escapeAttr(s: unknown): string;
|
|
87
|
+
export function clsx(value: unknown): string;
|
|
88
|
+
export function styleString(value: unknown): string;
|
|
89
|
+
export function attr(name: string, value: unknown): string;
|
|
90
|
+
export function ssgText(v: unknown): string;
|
|
91
|
+
export function ssgList<T>(arr: T[], fn: (item: T, index: number) => string): string;
|
|
92
|
+
export function ssgComponent(tag: string, props?: Record<string, unknown>, children?: unknown): string;
|