what-server 0.8.4 → 0.11.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,203 @@
1
+ // Framework-agnostic deploy adapter core. A Web-Fetch handler
2
+ // (request) -> Response that powers Node, Vercel and Cloudflare alike:
3
+ // match route -> intercept actions + revalidate webhook -> ISR cache
4
+ // (HIT/STALE/MISS) -> render -> respond with Cache-Control headers.
5
+ //
6
+ // The cache engine is OPTIONAL and injected (from what-isr) so what-server
7
+ // stays standalone. Render is owned here (renderDocument) but overridable.
8
+
9
+ import { matchRoute, parseQuery } from 'what-router/match';
10
+ import { renderDocument } from '../index.js';
11
+ import { createActionHandler, parseActionBody, readFetchBodyCapped } from '../action-handler.js';
12
+ import { setRevalidationHandler } from '../revalidation-registry.js';
13
+ import { generateCsrfToken } from '../actions.js';
14
+
15
+ const ACTION_PATH = '/__what_action';
16
+ const REVALIDATE_PATH = '/__what_revalidate';
17
+ const CSRF_COOKIE = 'what-csrf';
18
+
19
+ function headersToObject(headers) {
20
+ const out = {};
21
+ if (headers && typeof headers.forEach === 'function') headers.forEach((v, k) => { out[k.toLowerCase()] = v; });
22
+ return out;
23
+ }
24
+
25
+ function readCookie(cookieHeader, name) {
26
+ if (!cookieHeader) return null;
27
+ const match = String(cookieHeader).match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
28
+ return match ? decodeURIComponent(match[1]) : null;
29
+ }
30
+
31
+ // Read + parse the action body with the shared MAX_BODY_BYTES cap. Returns
32
+ // { tooLarge: true } when over the limit so the caller can respond 413 (DoS
33
+ // guard parity with the Node connect/express middleware).
34
+ async function readActionBody(request) {
35
+ try {
36
+ const read = await readFetchBodyCapped(request);
37
+ if (read.tooLarge) return { tooLarge: true };
38
+ return parseActionBody(read.raw, request.headers.get('content-type') || '');
39
+ } catch { return {}; }
40
+ }
41
+
42
+ async function readJsonBody(request) {
43
+ try { return await request.json(); } catch { return {}; }
44
+ }
45
+
46
+ function defaultRenderRoute(documentOptions) {
47
+ return async function renderRoute(routeMatch) {
48
+ const { route, params, query, request } = routeMatch;
49
+ const pageModule = { default: route.component, loader: route.loader };
50
+ const opts = routeMatch.csrfToken
51
+ ? { ...documentOptions, csrfToken: routeMatch.csrfToken }
52
+ : documentOptions;
53
+ const html = await renderDocument(pageModule, { params, query, request }, opts);
54
+ return {
55
+ html,
56
+ status: 200,
57
+ tags: (routeMatch.config && routeMatch.config.tags) || [],
58
+ path: routeMatch.path,
59
+ };
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Create the framework request handler: (Request) -> Response.
65
+ *
66
+ * CSRF is ON BY DEFAULT (double-submit cookie):
67
+ * - Every HTML response ensures a `what-csrf` cookie (SameSite=Lax, NOT
68
+ * HttpOnly so the fetch client can echo it in the X-CSRF-Token header).
69
+ * - Uncached HTML renders also embed <meta name="what-csrf-token"> plus the
70
+ * token for hidden form fields (cached/ISR pages rely on the cookie only,
71
+ * so a per-user token is never baked into shared cache entries).
72
+ * - POST /__what_action validates the client token (X-CSRF-Token header for
73
+ * fetch clients, `_csrf` form field for plain HTML forms) against the cookie.
74
+ *
75
+ * Opt out with `csrf: false` (e.g. token-authed APIs behind another gateway),
76
+ * or take full control by passing your own `actionHandler` — a custom handler
77
+ * owns its CSRF policy and the cookie/meta auto-provisioning is skipped.
78
+ *
79
+ * Plain HTML form posts (progressive enhancement, no JS) are accepted on
80
+ * /__what_action as application/x-www-form-urlencoded — see createActionHandler
81
+ * in action-handler.js for the field contract (_action, _csrf, _redirect).
82
+ */
83
+ export function createRequestHandler(options = {}) {
84
+ const {
85
+ routes = [],
86
+ cache,
87
+ render,
88
+ revalidateWebhook,
89
+ document: documentOptions = {},
90
+ notFound,
91
+ basePath = '',
92
+ csrf = true,
93
+ } = options;
94
+
95
+ // Auto-provisioning (cookie + meta tag) only applies to the built-in
96
+ // handler; a user-supplied actionHandler owns its own CSRF policy.
97
+ const autoCsrf = csrf !== false && !options.actionHandler;
98
+ const actionHandler = options.actionHandler || createActionHandler(
99
+ autoCsrf
100
+ ? { getCsrfToken: (reqLike) => readCookie(reqLike.headers && reqLike.headers.cookie, CSRF_COOKIE) }
101
+ : { skipCsrf: true }
102
+ );
103
+
104
+ const renderRoute = render || defaultRenderRoute(documentOptions);
105
+
106
+ // Bind the cache engine so server actions' revalidatePath/revalidateTag (and
107
+ // any app code calling them from what-framework/server) purge this engine.
108
+ if (cache && (cache.revalidatePath || cache.revalidateTag)) {
109
+ setRevalidationHandler({
110
+ revalidatePath: cache.revalidatePath,
111
+ revalidateTag: cache.revalidateTag,
112
+ });
113
+ }
114
+
115
+ return async function handle(request) {
116
+ const url = new URL(request.url, 'http://localhost');
117
+ let pathname = url.pathname;
118
+ if (basePath && pathname.startsWith(basePath)) pathname = pathname.slice(basePath.length) || '/';
119
+
120
+ // Server actions (JSON fetch path AND plain form-post fallback)
121
+ if (request.method === 'POST' && pathname === ACTION_PATH) {
122
+ const body = await readActionBody(request);
123
+ if (body && body.tooLarge) {
124
+ return new Response(JSON.stringify({ message: 'Payload too large' }), {
125
+ status: 413,
126
+ headers: { 'content-type': 'application/json' },
127
+ });
128
+ }
129
+ const out = await actionHandler({
130
+ method: 'POST',
131
+ headers: headersToObject(request.headers),
132
+ body,
133
+ query: Object.fromEntries(url.searchParams),
134
+ });
135
+ return new Response(out.body, { status: out.status, headers: out.headers });
136
+ }
137
+
138
+ // CSRF provisioning for HTML responses (double-submit cookie). If the
139
+ // visitor has no token cookie yet, mint one and Set-Cookie it below.
140
+ let csrfToken = null;
141
+ let csrfSetCookie = null;
142
+ if (autoCsrf) {
143
+ csrfToken = readCookie(headersToObject(request.headers).cookie, CSRF_COOKIE);
144
+ if (!csrfToken) {
145
+ csrfToken = generateCsrfToken();
146
+ // NOT HttpOnly: the client action() wrapper reads it to send X-CSRF-Token.
147
+ // Secure when the request is HTTPS (direct or via a proxy's
148
+ // x-forwarded-proto) or in production; OFF for plain-http localhost dev
149
+ // so the cookie still sets and CSRF keeps working locally.
150
+ const reqHeaders = headersToObject(request.headers);
151
+ const isHttps = reqHeaders['x-forwarded-proto'] === 'https'
152
+ || url.protocol === 'https:'
153
+ || process.env.NODE_ENV === 'production';
154
+ csrfSetCookie = `${CSRF_COOKIE}=${encodeURIComponent(csrfToken)}; Path=/; SameSite=Lax`
155
+ + (isHttps ? '; Secure' : '');
156
+ }
157
+ }
158
+ const withCsrfCookie = (headers) => {
159
+ if (csrfSetCookie) headers['set-cookie'] = csrfSetCookie;
160
+ return headers;
161
+ };
162
+
163
+ // On-demand revalidation webhook
164
+ if (request.method === 'POST' && pathname === REVALIDATE_PATH && revalidateWebhook) {
165
+ const body = await readJsonBody(request);
166
+ const out = await revalidateWebhook({ headers: headersToObject(request.headers), body });
167
+ return new Response(JSON.stringify(out.body), {
168
+ status: out.status,
169
+ headers: { 'content-type': 'application/json' },
170
+ });
171
+ }
172
+
173
+ // Route match
174
+ const matched = matchRoute(pathname, routes);
175
+ if (!matched) {
176
+ const html = notFound ? notFound() : '<!DOCTYPE html><html><body><h1>404 — Not Found</h1></body></html>';
177
+ return new Response(html, { status: 404, headers: withCsrfCookie({ 'content-type': 'text/html; charset=utf-8' }) });
178
+ }
179
+
180
+ const { route, params } = matched;
181
+ const config = route.page || { mode: route.mode || 'client' };
182
+ const routeMatch = { path: pathname, query: parseQuery(url.search), config, route, params, request };
183
+
184
+ // ISR cache path (static/hybrid with a cache engine). Server-mode bypasses.
185
+ // NOTE: cached HTML is shared across users, so the per-user CSRF token is
186
+ // NOT embedded in the page here — clients read it from the cookie instead.
187
+ if (cache && config.mode !== 'server') {
188
+ const result = await cache.handle(routeMatch, () => renderRoute(routeMatch));
189
+ return new Response(result.html, {
190
+ status: result.status || 200,
191
+ headers: withCsrfCookie({ 'content-type': 'text/html; charset=utf-8', ...(result.headers || {}) }),
192
+ });
193
+ }
194
+
195
+ // Direct render (server mode, or no cache configured): per-request HTML,
196
+ // safe to embed the CSRF token as a <meta> tag for forms/fetch clients.
197
+ if (csrfToken) routeMatch.csrfToken = csrfToken;
198
+ const out = await renderRoute(routeMatch);
199
+ const headers = withCsrfCookie({ 'content-type': 'text/html; charset=utf-8' });
200
+ if (config.mode === 'server') headers['Cache-Control'] = 'private, no-store';
201
+ return new Response(out.html, { status: out.status || 200, headers });
202
+ };
203
+ }
@@ -0,0 +1,77 @@
1
+ // Node deploy adapter — wraps the framework-agnostic Web-Fetch handler from
2
+ // core.js in a Node http.Server / connect-style middleware. Dependency-free
3
+ // (Node 18+ ships global Request/Response/Headers).
4
+
5
+ import http from 'node:http';
6
+ import { createRequestHandler } from './core.js';
7
+
8
+ async function nodeToWebRequest(req) {
9
+ const host = req.headers.host || 'localhost';
10
+ const url = `http://${host}${req.url}`;
11
+ const headers = new Headers();
12
+ for (const [k, v] of Object.entries(req.headers)) {
13
+ if (v != null) headers.set(k, Array.isArray(v) ? v.join(', ') : String(v));
14
+ }
15
+ let body;
16
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
17
+ const chunks = [];
18
+ for await (const chunk of req) chunks.push(chunk);
19
+ if (chunks.length) body = Buffer.concat(chunks);
20
+ }
21
+ return new Request(url, { method: req.method, headers, body });
22
+ }
23
+
24
+ async function sendWebResponse(res, webRes) {
25
+ res.statusCode = webRes.status;
26
+ webRes.headers.forEach((value, key) => res.setHeader(key, value));
27
+ const text = await webRes.text();
28
+ res.end(text);
29
+ }
30
+
31
+ /** Convert a Web-Fetch handler into a Node (req, res) listener. */
32
+ export function toNodeListener(handler) {
33
+ return async function listener(req, res) {
34
+ try {
35
+ const webReq = await nodeToWebRequest(req);
36
+ const webRes = await handler(webReq);
37
+ await sendWebResponse(res, webRes);
38
+ } catch (err) {
39
+ if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/html; charset=utf-8' });
40
+ res.end('<!DOCTYPE html><html><body><h1>500 — Server Error</h1></body></html>');
41
+ // eslint-disable-next-line no-console
42
+ console.error('[what-server] request error:', err);
43
+ }
44
+ };
45
+ }
46
+
47
+ /** connect/express middleware: handles app routes, calls next() on a 404. */
48
+ export function whatMiddleware(options = {}) {
49
+ const handler = createRequestHandler(options);
50
+ return async function middleware(req, res, next) {
51
+ const webReq = await nodeToWebRequest(req);
52
+ const webRes = await handler(webReq);
53
+ if (webRes.status === 404 && typeof next === 'function') return next();
54
+ await sendWebResponse(res, webRes);
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Create a ready-to-listen Node server. Starts the poll scheduler (if provided)
60
+ * and stops it on SIGTERM/SIGINT.
61
+ * const server = createServer({ routes, cache, scheduler });
62
+ * server.listen(3000);
63
+ */
64
+ export function createServer(options = {}) {
65
+ const handler = createRequestHandler(options);
66
+ const server = http.createServer(toNodeListener(handler));
67
+
68
+ const { scheduler } = options;
69
+ if (scheduler) {
70
+ scheduler.start();
71
+ const stop = () => { try { scheduler.stop(); } catch {} server.close(); };
72
+ process.once('SIGTERM', stop);
73
+ process.once('SIGINT', stop);
74
+ }
75
+
76
+ return server;
77
+ }
@@ -0,0 +1,62 @@
1
+ // Static export adapter — build-time render of static/hybrid routes to a
2
+ // deployable directory of .html files (+ a data.json per page for client-side
3
+ // navigation, mirroring Next's _next/data).
4
+
5
+ import { mkdir, writeFile } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import { matchRoute } from 'what-router/match';
8
+ import { renderDocument, serializeState } from '../index.js';
9
+
10
+ function isDynamic(path) {
11
+ return path.includes(':') || path.includes('*') || path.includes('[');
12
+ }
13
+
14
+ // Build a concrete URL from a route pattern + params (:p, [p], [...p], *p).
15
+ function buildConcretePath(pattern, params) {
16
+ return pattern
17
+ .replace(/\[\.\.\.(\w+)\]/g, (_, n) => params[n] ?? '')
18
+ .replace(/\[(\w+)\]/g, (_, n) => params[n] ?? '')
19
+ .replace(/[:*](\w+)/g, (_, n) => params[n] ?? '');
20
+ }
21
+
22
+ export async function exportStatic({ routes = [], outDir, render, documentOptions = {} } = {}) {
23
+ const written = [];
24
+
25
+ for (const route of routes) {
26
+ const mode = (route.page && route.page.mode) || route.mode;
27
+ if (mode !== 'static' && mode !== 'hybrid') continue;
28
+
29
+ const pageModule = { default: route.component, loader: route.loader };
30
+
31
+ let concrete = [route.path];
32
+ if (isDynamic(route.path)) {
33
+ if (typeof route.getStaticPaths !== 'function') continue; // can't enumerate
34
+ const result = await route.getStaticPaths();
35
+ concrete = (result.paths || []).map((p) => buildConcretePath(route.path, p.params || {}));
36
+ }
37
+
38
+ for (const urlPath of concrete) {
39
+ const matched = matchRoute(urlPath, [route]);
40
+ const params = matched ? matched.params : {};
41
+ const reqCtx = { params, query: {} };
42
+
43
+ const html = render
44
+ ? await render(pageModule, reqCtx)
45
+ : await renderDocument(pageModule, reqCtx, documentOptions);
46
+
47
+ const dirPath = join(outDir, urlPath === '/' ? '' : urlPath);
48
+ await mkdir(dirPath, { recursive: true });
49
+ await writeFile(join(dirPath, 'index.html'), html);
50
+
51
+ // data.json for client-side navigation (loader data without a round-trip)
52
+ if (typeof route.loader === 'function') {
53
+ const data = await route.loader(reqCtx);
54
+ await writeFile(join(dirPath, '__what_data.json'), serializeState({ loaderData: data }));
55
+ }
56
+
57
+ written.push(urlPath);
58
+ }
59
+ }
60
+
61
+ return { pages: written };
62
+ }
@@ -0,0 +1,93 @@
1
+ // Vercel adapter. The runtime render function is the same Web-Fetch core
2
+ // handler (deployable as a Vercel Function). ISR maps to Vercel's native
3
+ // s-maxage/stale-while-revalidate headers emitted by the cache engine, so it
4
+ // works on Vercel with no extra config. buildVercelOutput writes a Build
5
+ // Output API v3 directory (config.json + functions/<name>.func layout).
6
+
7
+ import { createRequestHandler } from './core.js';
8
+
9
+ export function createVercelHandler(options = {}) {
10
+ // Vercel Functions accept a Web-Fetch (Request) -> Response handler.
11
+ return createRequestHandler(options);
12
+ }
13
+
14
+ /**
15
+ * Write a Build Output API v3 directory (https://vercel.com/docs/build-output-api/v3):
16
+ *
17
+ * .vercel/output/
18
+ * config.json { version: 3, routes: [...] }
19
+ * static/** (optional) copied from `staticDir`
20
+ * functions/<name>.func/
21
+ * .vc-config.json { runtime, handler, launcherType }
22
+ * index.mjs (+ any `files`) the bundled function entry
23
+ *
24
+ * Options:
25
+ * - outDir default '.vercel/output'
26
+ * - functionName default 'render'
27
+ * - runtime default 'nodejs22.x' (any Vercel Node runtime id)
28
+ * - files map of { 'relative/path.mjs': contents } written INTO the
29
+ * .func directory. Must include the handler entry (the app's
30
+ * build step bundles routes + createVercelHandler into it).
31
+ * - handler entry filename inside the .func dir, default 'index.mjs'
32
+ * - staticDir (optional) directory copied to static/ — served by Vercel's
33
+ * CDN before the function ever runs.
34
+ *
35
+ * Backward compatible: called with no `files`, it writes config.json only and
36
+ * the app's build step is responsible for emitting the functions/ directory.
37
+ *
38
+ * The function entry must export a Web-Fetch handler, e.g.:
39
+ * // index.mjs (bundled with routes + what-server)
40
+ * import { createVercelHandler } from 'what-server';
41
+ * export default createVercelHandler({ routes });
42
+ */
43
+ export async function buildVercelOutput({
44
+ outDir = '.vercel/output',
45
+ functionName = 'render',
46
+ runtime = 'nodejs22.x',
47
+ files = null,
48
+ handler = 'index.mjs',
49
+ staticDir = null,
50
+ } = {}) {
51
+ const { mkdir, writeFile, cp } = await import('node:fs/promises');
52
+ const { join, dirname } = await import('node:path');
53
+ await mkdir(outDir, { recursive: true });
54
+
55
+ const config = {
56
+ version: 3,
57
+ routes: [
58
+ // CDN-served static assets win before the render function runs.
59
+ { handle: 'filesystem' },
60
+ { src: '/.*', dest: `/${functionName}` },
61
+ ],
62
+ };
63
+ await writeFile(join(outDir, 'config.json'), JSON.stringify(config, null, 2));
64
+
65
+ if (staticDir) {
66
+ await cp(staticDir, join(outDir, 'static'), { recursive: true });
67
+ }
68
+
69
+ let functionDir = null;
70
+ if (files && typeof files === 'object') {
71
+ functionDir = join(outDir, 'functions', `${functionName}.func`);
72
+ await mkdir(functionDir, { recursive: true });
73
+ const vcConfig = {
74
+ runtime,
75
+ handler,
76
+ launcherType: 'Nodejs',
77
+ shouldAddHelpers: false,
78
+ supportsResponseStreaming: true,
79
+ };
80
+ await writeFile(join(functionDir, '.vc-config.json'), JSON.stringify(vcConfig, null, 2));
81
+ for (const [rel, contents] of Object.entries(files)) {
82
+ const dest = join(functionDir, rel);
83
+ await mkdir(dirname(dest), { recursive: true });
84
+ await writeFile(dest, contents);
85
+ }
86
+ if (!(handler in files)) {
87
+ // eslint-disable-next-line no-console
88
+ console.warn(`[what-server] buildVercelOutput: files does not include the handler entry "${handler}" — the deploy will 500 until your build emits it.`);
89
+ }
90
+ }
91
+
92
+ return { config, outDir, functionDir };
93
+ }