@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.
@@ -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
+ }
@@ -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;
package/server/index.js CHANGED
@@ -5,4 +5,6 @@
5
5
  export * from "./ssg-runtime.js";
6
6
  export * from "./head.js";
7
7
  export * from "./render.js";
8
+ export * from "./api.js";
9
+ export * from "./loader.js";
8
10
  import "./builtins.js";