what-server 0.10.0 → 0.11.1

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.
@@ -32,6 +32,57 @@ function jsonResponse(status, bodyObj) {
32
32
  };
33
33
  }
34
34
 
35
+ function htmlResponse(status, message) {
36
+ return {
37
+ status,
38
+ headers: { 'content-type': 'text/html; charset=utf-8' },
39
+ body: `<!DOCTYPE html><html><body><h1>${status}</h1><p>${String(message)
40
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p></body></html>`,
41
+ };
42
+ }
43
+
44
+ // Resolve a safe local redirect target for the form (POST/redirect/GET) path.
45
+ // `_redirect` must be a same-origin local path ("/x"); protocol-relative
46
+ // ("//evil"), backslash-smuggled ("/\evil", "/\\evil") and absolute
47
+ // ("https://evil") targets are rejected. The Referer header (an absolute URL)
48
+ // is reduced to its path + query. Falls back to '/'.
49
+ //
50
+ // Backslashes matter: browsers and `new URL()` treat "\" like "/", so
51
+ // "/\evil.com" canonicalizes to http://evil.com (an open redirect). We reject
52
+ // anything starting with two slash-or-backslash chars or containing a
53
+ // backslash, then canonicalize via URL and require the localhost origin.
54
+ function safeLocalPath(value) {
55
+ if (typeof value !== 'string' || !value.startsWith('/')) return null;
56
+ // Reject protocol-relative / backslash-smuggled targets up front.
57
+ if (/^[/\\]{2}/.test(value) || value.includes('\\')) return null;
58
+ try {
59
+ const u = new URL(value, 'http://localhost');
60
+ if (u.origin !== 'http://localhost') return null;
61
+ return u.pathname + u.search;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function safeRedirectTarget(form, headers) {
68
+ const explicit = safeLocalPath(form && form._redirect);
69
+ if (explicit) return explicit;
70
+ const referer = headers.referer || headers.referrer;
71
+ if (referer) {
72
+ try {
73
+ const u = new URL(referer, 'http://localhost');
74
+ const path = safeLocalPath(u.pathname + u.search);
75
+ if (path) return path;
76
+ } catch { /* fall through */ }
77
+ }
78
+ return '/';
79
+ }
80
+
81
+ // Reserved form fields consumed by the framework (not passed to the action).
82
+ // `what-csrf-token` is an alias for `_csrf` matching the <meta name="what-csrf-token">
83
+ // tag SSR pages embed, so templates can reuse one name for both surfaces.
84
+ const RESERVED_FORM_FIELDS = new Set(['_action', 'data-action', '_csrf', 'what-csrf-token', '_redirect']);
85
+
35
86
  /**
36
87
  * Framework-agnostic action dispatcher.
37
88
  *
@@ -41,7 +92,25 @@ function jsonResponse(status, bodyObj) {
41
92
  * - basePath: string — defaults to '/__what_action' (used by the adapters).
42
93
  *
43
94
  * Returns: async (reqLike) -> { status, headers, body:string }
44
- * reqLike: { method, headers, body } where body is the parsed JSON ({ args }).
95
+ * reqLike: { method, headers, body, query? }
96
+ *
97
+ * Two request shapes are accepted:
98
+ *
99
+ * 1. JSON + header (fetch clients — what the `action()` client wrapper sends):
100
+ * POST with `X-What-Action: <id>` header, JSON body `{ args: [...] }`,
101
+ * CSRF token in the `X-CSRF-Token` header. Responds with JSON.
102
+ *
103
+ * 2. Plain HTML form post (progressive enhancement — works without JS):
104
+ * POST with `Content-Type: application/x-www-form-urlencoded` and NO
105
+ * X-What-Action header. `body` is the parsed form fields object.
106
+ * - action id: `_action` (or `data-action`) hidden field, or `?action=`
107
+ * query param (reqLike.query.action)
108
+ * - CSRF token: `_csrf` (or `what-csrf-token`) hidden field; an
109
+ * `x-csrf-token` HEADER wins when both are present
110
+ * - redirect: `_redirect` hidden field (local path), else Referer, else '/'
111
+ * The action receives ONE argument: the form fields object (reserved
112
+ * fields stripped). Success responds 303 See Other (POST/redirect/GET);
113
+ * failures respond with an HTML error page and the matching status.
45
114
  */
46
115
  export function createActionHandler(options = {}) {
47
116
  const { getCsrfToken, skipCsrf = false } = options;
@@ -53,21 +122,72 @@ export function createActionHandler(options = {}) {
53
122
  }
54
123
 
55
124
  const headers = lowerHeaders(reqLike.headers);
56
- const actionId = headers['x-what-action'];
57
- if (!actionId) {
125
+ const headerActionId = headers['x-what-action'];
126
+ const contentType = headers['content-type'] || '';
127
+ const isFormPost = !headerActionId && contentType.includes('application/x-www-form-urlencoded');
128
+
129
+ const sessionCsrfToken = skipCsrf
130
+ ? undefined
131
+ : (getCsrfToken ? await getCsrfToken(reqLike) : undefined);
132
+
133
+ // --- Plain HTML form post (progressive enhancement) ---
134
+ if (isFormPost) {
135
+ const form = reqLike.body || {};
136
+ const actionId = form._action || form['data-action'] || (reqLike.query && reqLike.query.action);
137
+ if (!actionId) {
138
+ return htmlResponse(400, 'Missing action name (add a hidden "_action" field or ?action= query param)');
139
+ }
140
+
141
+ // CSRF token travels in the `_csrf` (or `what-csrf-token`) form field
142
+ // for plain forms; map it to the header slot handleActionRequest
143
+ // validates against. The header wins when both are present.
144
+ const formHeaders = { ...headers };
145
+ const bodyToken = form._csrf ?? form['what-csrf-token'];
146
+ if (bodyToken && !formHeaders['x-csrf-token']) formHeaders['x-csrf-token'] = String(bodyToken);
147
+
148
+ if (!skipCsrf && getCsrfToken && !sessionCsrfToken) {
149
+ // CSRF is configured but this client has no token (e.g. no cookie yet).
150
+ return htmlResponse(403, 'Missing CSRF token');
151
+ }
152
+
153
+ const data = {};
154
+ for (const [k, v] of Object.entries(form)) {
155
+ if (!RESERVED_FORM_FIELDS.has(k)) data[k] = v;
156
+ }
157
+
158
+ const result = await handleActionRequest(
159
+ { headers: formHeaders },
160
+ actionId,
161
+ [data],
162
+ { csrfToken: sessionCsrfToken, skipCsrf }
163
+ );
164
+
165
+ if (result.status === 200) {
166
+ return {
167
+ status: 303,
168
+ headers: { location: safeRedirectTarget(form, headers) },
169
+ body: '',
170
+ };
171
+ }
172
+ return htmlResponse(result.status, (result.body && result.body.message) || 'Action failed');
173
+ }
174
+
175
+ // --- JSON + X-What-Action header (fetch clients) ---
176
+ if (!headerActionId) {
58
177
  return jsonResponse(400, { message: 'Missing X-What-Action header' });
59
178
  }
60
179
 
180
+ if (!skipCsrf && getCsrfToken && !sessionCsrfToken) {
181
+ // CSRF configured, but the client presented no session token (no cookie).
182
+ return jsonResponse(403, { message: 'Missing CSRF token' });
183
+ }
184
+
61
185
  const body = reqLike.body || {};
62
186
  const args = body.args;
63
187
 
64
- const sessionCsrfToken = skipCsrf
65
- ? undefined
66
- : (getCsrfToken ? await getCsrfToken(reqLike) : undefined);
67
-
68
188
  const result = await handleActionRequest(
69
189
  { headers },
70
- actionId,
190
+ headerActionId,
71
191
  args,
72
192
  { csrfToken: sessionCsrfToken, skipCsrf }
73
193
  );
@@ -84,27 +204,87 @@ export function nodeActionMiddleware(options = {}) {
84
204
  const handle = createActionHandler(options);
85
205
 
86
206
  return async function middleware(req, res, next) {
87
- const url = (req.url || '').split('?')[0];
207
+ const [url, search] = (req.url || '').split('?');
88
208
  if (url !== basePath || (req.method || '').toUpperCase() !== 'POST') {
89
209
  return next ? next() : undefined;
90
210
  }
91
211
 
92
212
  let body;
93
213
  try {
94
- body = await readJsonBody(req);
214
+ const raw = await readRawBody(req);
215
+ body = parseActionBody(raw, req.headers['content-type'] || '');
95
216
  } catch (err) {
96
217
  res.writeHead(err.code === 'BODY_TOO_LARGE' ? 413 : 400, { 'content-type': 'application/json' });
97
- res.end(JSON.stringify({ message: err.code === 'BODY_TOO_LARGE' ? 'Payload too large' : 'Invalid JSON body' }));
218
+ res.end(JSON.stringify({ message: err.code === 'BODY_TOO_LARGE' ? 'Payload too large' : 'Invalid request body' }));
98
219
  return;
99
220
  }
100
221
 
101
- const out = await handle({ method: req.method, headers: req.headers, body });
222
+ const query = Object.fromEntries(new URLSearchParams(search || ''));
223
+ const out = await handle({ method: req.method, headers: req.headers, body, query });
102
224
  res.writeHead(out.status, out.headers);
103
225
  res.end(out.body);
104
226
  };
105
227
  }
106
228
 
107
- function readJsonBody(req) {
229
+ /** Parse an action request body by content type: form-urlencoded -> fields object, else JSON. */
230
+ export function parseActionBody(raw, contentType) {
231
+ if ((contentType || '').includes('application/x-www-form-urlencoded')) {
232
+ const fields = {};
233
+ for (const [k, v] of new URLSearchParams(String(raw))) {
234
+ if (fields[k] === undefined) fields[k] = v;
235
+ else if (Array.isArray(fields[k])) fields[k].push(v);
236
+ else fields[k] = [fields[k], v];
237
+ }
238
+ return fields;
239
+ }
240
+ if (raw == null || raw === '') return {};
241
+ return JSON.parse(String(raw));
242
+ }
243
+
244
+ /**
245
+ * Read a Web Fetch `Request` body as text with the same MAX_BODY_BYTES cap the
246
+ * Node middleware enforces. Used by the adapter/edge entry points (Vercel /
247
+ * Cloudflare / Node-adapter) so all three share one DoS guard.
248
+ *
249
+ * Returns { raw } on success or { tooLarge: true } when the cap is exceeded —
250
+ * checked first via Content-Length, then enforced while streaming (chunked /
251
+ * spoofed Content-Length can't bypass it).
252
+ *
253
+ * @param {Request} request
254
+ * @param {number} [limit=MAX_BODY_BYTES]
255
+ */
256
+ export async function readFetchBodyCapped(request, limit = MAX_BODY_BYTES) {
257
+ const declared = Number(request.headers.get('content-length'));
258
+ if (Number.isFinite(declared) && declared > limit) {
259
+ return { tooLarge: true };
260
+ }
261
+ const body = request.body;
262
+ // No stream available (or env without ReadableStream): fall back to text()
263
+ // but still re-check the resulting size against the cap.
264
+ if (!body || typeof body.getReader !== 'function') {
265
+ const raw = await request.text();
266
+ if (Buffer.byteLength(raw, 'utf8') > limit) return { tooLarge: true };
267
+ return { raw };
268
+ }
269
+ const reader = body.getReader();
270
+ const chunks = [];
271
+ let size = 0;
272
+ while (true) {
273
+ const { done, value } = await reader.read();
274
+ if (done) break;
275
+ if (value) {
276
+ size += value.byteLength;
277
+ if (size > limit) {
278
+ try { await reader.cancel(); } catch { /* ignore */ }
279
+ return { tooLarge: true };
280
+ }
281
+ chunks.push(value);
282
+ }
283
+ }
284
+ return { raw: Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8') };
285
+ }
286
+
287
+ function readRawBody(req) {
108
288
  return new Promise((resolve, reject) => {
109
289
  let size = 0;
110
290
  const chunks = [];
@@ -120,12 +300,8 @@ function readJsonBody(req) {
120
300
  chunks.push(chunk);
121
301
  });
122
302
  req.on('end', () => {
123
- if (chunks.length === 0) return resolve({});
124
- try {
125
- resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
126
- } catch (e) {
127
- reject(e);
128
- }
303
+ if (chunks.length === 0) return resolve('');
304
+ resolve(Buffer.concat(chunks).toString('utf8'));
129
305
  });
130
306
  req.on('error', reject);
131
307
  });
@@ -139,11 +315,22 @@ export function fetchActionHandler(options = {}) {
139
315
  return async function (request) {
140
316
  let body = {};
141
317
  try {
142
- body = await request.json();
318
+ const read = await readFetchBodyCapped(request);
319
+ if (read.tooLarge) {
320
+ return new Response(JSON.stringify({ message: 'Payload too large' }), {
321
+ status: 413,
322
+ headers: { 'content-type': 'application/json' },
323
+ });
324
+ }
325
+ body = parseActionBody(read.raw, request.headers.get('content-type') || '');
143
326
  } catch {
144
327
  body = {};
145
328
  }
146
- const out = await handle({ method: request.method, headers: request.headers, body });
329
+ let query = {};
330
+ try {
331
+ query = Object.fromEntries(new URL(request.url, 'http://localhost').searchParams);
332
+ } catch { /* no query */ }
333
+ const out = await handle({ method: request.method, headers: request.headers, body, query });
147
334
  return new Response(out.body, { status: out.status, headers: out.headers });
148
335
  };
149
336
  }
@@ -1,6 +1,6 @@
1
1
  // Cloudflare Workers adapter — exposes a `fetch(request, env, ctx)` worker
2
2
  // entry over the same Web-Fetch core handler. ISR runs via the origin cache
3
- // engine; pass a what-cache redis/KV-backed store for cross-isolate caching and
3
+ // engine; pass a what-isr redis/KV-backed store for cross-isolate caching and
4
4
  // use ctx.waitUntil for background regeneration.
5
5
 
6
6
  import { createRequestHandler } from './core.js';
@@ -3,16 +3,18 @@
3
3
  // match route -> intercept actions + revalidate webhook -> ISR cache
4
4
  // (HIT/STALE/MISS) -> render -> respond with Cache-Control headers.
5
5
  //
6
- // The cache engine is OPTIONAL and injected (from what-cache) so what-server
6
+ // The cache engine is OPTIONAL and injected (from what-isr) so what-server
7
7
  // stays standalone. Render is owned here (renderDocument) but overridable.
8
8
 
9
9
  import { matchRoute, parseQuery } from 'what-router/match';
10
10
  import { renderDocument } from '../index.js';
11
- import { createActionHandler } from '../action-handler.js';
11
+ import { createActionHandler, parseActionBody, readFetchBodyCapped } from '../action-handler.js';
12
12
  import { setRevalidationHandler } from '../revalidation-registry.js';
13
+ import { generateCsrfToken } from '../actions.js';
13
14
 
14
15
  const ACTION_PATH = '/__what_action';
15
16
  const REVALIDATE_PATH = '/__what_revalidate';
17
+ const CSRF_COOKIE = 'what-csrf';
16
18
 
17
19
  function headersToObject(headers) {
18
20
  const out = {};
@@ -20,6 +22,23 @@ function headersToObject(headers) {
20
22
  return out;
21
23
  }
22
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
+
23
42
  async function readJsonBody(request) {
24
43
  try { return await request.json(); } catch { return {}; }
25
44
  }
@@ -28,7 +47,10 @@ function defaultRenderRoute(documentOptions) {
28
47
  return async function renderRoute(routeMatch) {
29
48
  const { route, params, query, request } = routeMatch;
30
49
  const pageModule = { default: route.component, loader: route.loader };
31
- const html = await renderDocument(pageModule, { params, query, request }, documentOptions);
50
+ const opts = routeMatch.csrfToken
51
+ ? { ...documentOptions, csrfToken: routeMatch.csrfToken }
52
+ : documentOptions;
53
+ const html = await renderDocument(pageModule, { params, query, request }, opts);
32
54
  return {
33
55
  html,
34
56
  status: 200,
@@ -38,18 +60,49 @@ function defaultRenderRoute(documentOptions) {
38
60
  };
39
61
  }
40
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` or `what-csrf-token` form field for plain HTML
74
+ * forms — the header wins when both are present) against the cookie.
75
+ *
76
+ * Opt out with `csrf: false` (e.g. token-authed APIs behind another gateway),
77
+ * or take full control by passing your own `actionHandler` — a custom handler
78
+ * owns its CSRF policy and the cookie/meta auto-provisioning is skipped.
79
+ *
80
+ * Plain HTML form posts (progressive enhancement, no JS) are accepted on
81
+ * /__what_action as application/x-www-form-urlencoded — see createActionHandler
82
+ * in action-handler.js for the field contract (_action, _csrf/what-csrf-token,
83
+ * _redirect).
84
+ */
41
85
  export function createRequestHandler(options = {}) {
42
86
  const {
43
87
  routes = [],
44
88
  cache,
45
89
  render,
46
- actionHandler = createActionHandler({ skipCsrf: true }),
47
90
  revalidateWebhook,
48
91
  document: documentOptions = {},
49
92
  notFound,
50
93
  basePath = '',
94
+ csrf = true,
51
95
  } = options;
52
96
 
97
+ // Auto-provisioning (cookie + meta tag) only applies to the built-in
98
+ // handler; a user-supplied actionHandler owns its own CSRF policy.
99
+ const autoCsrf = csrf !== false && !options.actionHandler;
100
+ const actionHandler = options.actionHandler || createActionHandler(
101
+ autoCsrf
102
+ ? { getCsrfToken: (reqLike) => readCookie(reqLike.headers && reqLike.headers.cookie, CSRF_COOKIE) }
103
+ : { skipCsrf: true }
104
+ );
105
+
53
106
  const renderRoute = render || defaultRenderRoute(documentOptions);
54
107
 
55
108
  // Bind the cache engine so server actions' revalidatePath/revalidateTag (and
@@ -66,13 +119,49 @@ export function createRequestHandler(options = {}) {
66
119
  let pathname = url.pathname;
67
120
  if (basePath && pathname.startsWith(basePath)) pathname = pathname.slice(basePath.length) || '/';
68
121
 
69
- // Server actions
122
+ // Server actions (JSON fetch path AND plain form-post fallback)
70
123
  if (request.method === 'POST' && pathname === ACTION_PATH) {
71
- const body = await readJsonBody(request);
72
- const out = await actionHandler({ method: 'POST', headers: headersToObject(request.headers), body });
124
+ const body = await readActionBody(request);
125
+ if (body && body.tooLarge) {
126
+ return new Response(JSON.stringify({ message: 'Payload too large' }), {
127
+ status: 413,
128
+ headers: { 'content-type': 'application/json' },
129
+ });
130
+ }
131
+ const out = await actionHandler({
132
+ method: 'POST',
133
+ headers: headersToObject(request.headers),
134
+ body,
135
+ query: Object.fromEntries(url.searchParams),
136
+ });
73
137
  return new Response(out.body, { status: out.status, headers: out.headers });
74
138
  }
75
139
 
140
+ // CSRF provisioning for HTML responses (double-submit cookie). If the
141
+ // visitor has no token cookie yet, mint one and Set-Cookie it below.
142
+ let csrfToken = null;
143
+ let csrfSetCookie = null;
144
+ if (autoCsrf) {
145
+ csrfToken = readCookie(headersToObject(request.headers).cookie, CSRF_COOKIE);
146
+ if (!csrfToken) {
147
+ csrfToken = generateCsrfToken();
148
+ // NOT HttpOnly: the client action() wrapper reads it to send X-CSRF-Token.
149
+ // Secure when the request is HTTPS (direct or via a proxy's
150
+ // x-forwarded-proto) or in production; OFF for plain-http localhost dev
151
+ // so the cookie still sets and CSRF keeps working locally.
152
+ const reqHeaders = headersToObject(request.headers);
153
+ const isHttps = reqHeaders['x-forwarded-proto'] === 'https'
154
+ || url.protocol === 'https:'
155
+ || process.env.NODE_ENV === 'production';
156
+ csrfSetCookie = `${CSRF_COOKIE}=${encodeURIComponent(csrfToken)}; Path=/; SameSite=Lax`
157
+ + (isHttps ? '; Secure' : '');
158
+ }
159
+ }
160
+ const withCsrfCookie = (headers) => {
161
+ if (csrfSetCookie) headers['set-cookie'] = csrfSetCookie;
162
+ return headers;
163
+ };
164
+
76
165
  // On-demand revalidation webhook
77
166
  if (request.method === 'POST' && pathname === REVALIDATE_PATH && revalidateWebhook) {
78
167
  const body = await readJsonBody(request);
@@ -87,7 +176,7 @@ export function createRequestHandler(options = {}) {
87
176
  const matched = matchRoute(pathname, routes);
88
177
  if (!matched) {
89
178
  const html = notFound ? notFound() : '<!DOCTYPE html><html><body><h1>404 — Not Found</h1></body></html>';
90
- return new Response(html, { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } });
179
+ return new Response(html, { status: 404, headers: withCsrfCookie({ 'content-type': 'text/html; charset=utf-8' }) });
91
180
  }
92
181
 
93
182
  const { route, params } = matched;
@@ -95,17 +184,21 @@ export function createRequestHandler(options = {}) {
95
184
  const routeMatch = { path: pathname, query: parseQuery(url.search), config, route, params, request };
96
185
 
97
186
  // ISR cache path (static/hybrid with a cache engine). Server-mode bypasses.
187
+ // NOTE: cached HTML is shared across users, so the per-user CSRF token is
188
+ // NOT embedded in the page here — clients read it from the cookie instead.
98
189
  if (cache && config.mode !== 'server') {
99
190
  const result = await cache.handle(routeMatch, () => renderRoute(routeMatch));
100
191
  return new Response(result.html, {
101
192
  status: result.status || 200,
102
- headers: { 'content-type': 'text/html; charset=utf-8', ...(result.headers || {}) },
193
+ headers: withCsrfCookie({ 'content-type': 'text/html; charset=utf-8', ...(result.headers || {}) }),
103
194
  });
104
195
  }
105
196
 
106
- // Direct render (server mode, or no cache configured)
197
+ // Direct render (server mode, or no cache configured): per-request HTML,
198
+ // safe to embed the CSRF token as a <meta> tag for forms/fetch clients.
199
+ if (csrfToken) routeMatch.csrfToken = csrfToken;
107
200
  const out = await renderRoute(routeMatch);
108
- const headers = { 'content-type': 'text/html; charset=utf-8' };
201
+ const headers = withCsrfCookie({ 'content-type': 'text/html; charset=utf-8' });
109
202
  if (config.mode === 'server') headers['Cache-Control'] = 'private, no-store';
110
203
  return new Response(out.html, { status: out.status || 200, headers });
111
204
  };
@@ -1,8 +1,8 @@
1
1
  // Vercel adapter. The runtime render function is the same Web-Fetch core
2
2
  // handler (deployable as a Vercel Function). ISR maps to Vercel's native
3
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 minimal
5
- // Build Output API config pointing at the function.
4
+ // works on Vercel with no extra config. buildVercelOutput writes a Build
5
+ // Output API v3 directory (config.json + functions/<name>.func layout).
6
6
 
7
7
  import { createRequestHandler } from './core.js';
8
8
 
@@ -12,18 +12,82 @@ export function createVercelHandler(options = {}) {
12
12
  }
13
13
 
14
14
  /**
15
- * Write a minimal .vercel/output/config.json that routes all requests to a
16
- * single render function. The function file itself is emitted by the build step
17
- * (it imports createVercelHandler with the app's routes).
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 });
18
42
  */
19
- export async function buildVercelOutput({ outDir = '.vercel/output', functionName = 'render' } = {}) {
20
- const { mkdir, writeFile } = await import('node:fs/promises');
21
- const { join } = await import('node:path');
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');
22
53
  await mkdir(outDir, { recursive: true });
54
+
23
55
  const config = {
24
56
  version: 3,
25
- routes: [{ src: '/.*', dest: `/${functionName}` }],
57
+ routes: [
58
+ // CDN-served static assets win before the render function runs.
59
+ { handle: 'filesystem' },
60
+ { src: '/.*', dest: `/${functionName}` },
61
+ ],
26
62
  };
27
63
  await writeFile(join(outDir, 'config.json'), JSON.stringify(config, null, 2));
28
- return { config, outDir };
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 };
29
93
  }
package/src/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { h, runWithServerContext, beginHeadCollection, endHeadCollection } from 'what-core';
6
6
  import { serializeState } from './serialize.js';
7
7
  import { getIslandStoresSnapshot } from './islands.js';
8
+ import { csrfMetaTag } from './actions.js';
8
9
 
9
10
  // Build a fresh render-scoped server context (head sink, loader data, resources).
10
11
  function createRenderContext(loaderData) {
@@ -253,7 +254,11 @@ function wrapHtmlDocument({ body, head, payload, options = {} }) {
253
254
  const clientScript = options.clientEntry
254
255
  ? `<script type="module" src="${escapeHtml(options.clientEntry)}"></script>`
255
256
  : '';
256
- const extraHead = options.head || '';
257
+ // CSRF auto-provisioning: when the caller (e.g. the deploy adapter with
258
+ // default-on CSRF) passes the per-request token, embed it as the meta tag
259
+ // the client action() wrapper and plain <form> posts read it from.
260
+ const csrfHead = options.csrfToken ? csrfMetaTag(options.csrfToken) : '';
261
+ const extraHead = csrfHead + (options.head || '');
257
262
  const bodyClass = options.bodyClass ? ` class="${escapeHtml(options.bodyClass)}"` : '';
258
263
  return (
259
264
  `<!DOCTYPE html><html lang="${escapeHtml(lang)}"><head>` +
@@ -476,12 +481,26 @@ function _resolveInnerHTML(props) {
476
481
  return null;
477
482
  }
478
483
 
484
+ // Attribute NAMES are emitted verbatim into the HTML, so they must be
485
+ // validated (values are escaped, names cannot be). Anything outside this
486
+ // pattern (spaces, quotes, equals, ...) could otherwise inject attributes:
487
+ // h('div', { 'x" onload="alert(1)': '' }) -> <div x" onload="alert(1)>
488
+ // Allows letters, digits, '_', ':', '.', '-' — covers data-*, aria-*,
489
+ // xlink:href, SVG names like stroke-width, and namespaced attributes.
490
+ const SAFE_ATTR_NAME = /^[a-zA-Z_:][a-zA-Z0-9:._-]*$/;
491
+
479
492
  function renderAttrs(props) {
480
493
  let out = '';
481
494
  for (const [key, val] of Object.entries(props)) {
482
495
  if (key === 'key' || key === 'ref' || key === 'children' || key === 'dangerouslySetInnerHTML' || key === 'innerHTML') continue;
483
496
  if (key.startsWith('on') && key.length > 2) continue; // Skip event handlers in SSR
484
497
  if (val === false || val == null) continue;
498
+ if (!SAFE_ATTR_NAME.test(key)) {
499
+ if (_isDevMode) {
500
+ console.warn(`[what-server] Skipping invalid attribute name in SSR: ${JSON.stringify(key)}`);
501
+ }
502
+ continue;
503
+ }
485
504
 
486
505
  if (key === 'className' || key === 'class') {
487
506
  out += ` class="${escapeHtml(String(val))}"`;
@@ -562,7 +581,7 @@ export {
562
581
  } from './action-handler.js';
563
582
 
564
583
  // Revalidation registry — app code calls revalidatePath/revalidateTag; the
565
- // deploy adapter binds a what-cache engine via setRevalidationHandler.
584
+ // deploy adapter binds a what-isr engine via setRevalidationHandler.
566
585
  export {
567
586
  revalidatePath,
568
587
  revalidateTag,
@@ -1,6 +1,6 @@
1
1
  // Revalidation registry — the indirection that lets app code call
2
2
  // revalidatePath()/revalidateTag() from `what-framework/server` while the actual
3
- // cache engine lives in the optional `what-cache` package. The deploy adapter
3
+ // cache engine lives in the optional `what-isr` package. The deploy adapter
4
4
  // binds the engine at startup via setRevalidationHandler(); until then these are
5
5
  // safe no-ops (with a dev hint).
6
6
 
@@ -22,7 +22,7 @@ export async function revalidatePath(path, options) {
22
22
  if (isDev) {
23
23
  console.warn(
24
24
  `[what] revalidatePath('${path}') had no effect: no cache engine is bound. ` +
25
- 'Create a what-cache engine and bind it in your adapter (setRevalidationHandler).'
25
+ 'Create a what-isr engine and bind it in your adapter (setRevalidationHandler).'
26
26
  );
27
27
  }
28
28
  }