what-server 0.10.0 → 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.
@@ -32,6 +32,55 @@ 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
+ const RESERVED_FORM_FIELDS = new Set(['_action', 'data-action', '_csrf', '_redirect']);
83
+
35
84
  /**
36
85
  * Framework-agnostic action dispatcher.
37
86
  *
@@ -41,7 +90,24 @@ function jsonResponse(status, bodyObj) {
41
90
  * - basePath: string — defaults to '/__what_action' (used by the adapters).
42
91
  *
43
92
  * Returns: async (reqLike) -> { status, headers, body:string }
44
- * reqLike: { method, headers, body } where body is the parsed JSON ({ args }).
93
+ * reqLike: { method, headers, body, query? }
94
+ *
95
+ * Two request shapes are accepted:
96
+ *
97
+ * 1. JSON + header (fetch clients — what the `action()` client wrapper sends):
98
+ * POST with `X-What-Action: <id>` header, JSON body `{ args: [...] }`,
99
+ * CSRF token in the `X-CSRF-Token` header. Responds with JSON.
100
+ *
101
+ * 2. Plain HTML form post (progressive enhancement — works without JS):
102
+ * POST with `Content-Type: application/x-www-form-urlencoded` and NO
103
+ * X-What-Action header. `body` is the parsed form fields object.
104
+ * - action id: `_action` (or `data-action`) hidden field, or `?action=`
105
+ * query param (reqLike.query.action)
106
+ * - CSRF token: `_csrf` hidden field
107
+ * - redirect: `_redirect` hidden field (local path), else Referer, else '/'
108
+ * The action receives ONE argument: the form fields object (reserved
109
+ * fields stripped). Success responds 303 See Other (POST/redirect/GET);
110
+ * failures respond with an HTML error page and the matching status.
45
111
  */
46
112
  export function createActionHandler(options = {}) {
47
113
  const { getCsrfToken, skipCsrf = false } = options;
@@ -53,21 +119,70 @@ export function createActionHandler(options = {}) {
53
119
  }
54
120
 
55
121
  const headers = lowerHeaders(reqLike.headers);
56
- const actionId = headers['x-what-action'];
57
- if (!actionId) {
122
+ const headerActionId = headers['x-what-action'];
123
+ const contentType = headers['content-type'] || '';
124
+ const isFormPost = !headerActionId && contentType.includes('application/x-www-form-urlencoded');
125
+
126
+ const sessionCsrfToken = skipCsrf
127
+ ? undefined
128
+ : (getCsrfToken ? await getCsrfToken(reqLike) : undefined);
129
+
130
+ // --- Plain HTML form post (progressive enhancement) ---
131
+ if (isFormPost) {
132
+ const form = reqLike.body || {};
133
+ const actionId = form._action || form['data-action'] || (reqLike.query && reqLike.query.action);
134
+ if (!actionId) {
135
+ return htmlResponse(400, 'Missing action name (add a hidden "_action" field or ?action= query param)');
136
+ }
137
+
138
+ // CSRF token travels in the `_csrf` form field for plain forms; map it
139
+ // to the header slot handleActionRequest validates against.
140
+ const formHeaders = { ...headers };
141
+ if (form._csrf && !formHeaders['x-csrf-token']) formHeaders['x-csrf-token'] = String(form._csrf);
142
+
143
+ if (!skipCsrf && getCsrfToken && !sessionCsrfToken) {
144
+ // CSRF is configured but this client has no token (e.g. no cookie yet).
145
+ return htmlResponse(403, 'Missing CSRF token');
146
+ }
147
+
148
+ const data = {};
149
+ for (const [k, v] of Object.entries(form)) {
150
+ if (!RESERVED_FORM_FIELDS.has(k)) data[k] = v;
151
+ }
152
+
153
+ const result = await handleActionRequest(
154
+ { headers: formHeaders },
155
+ actionId,
156
+ [data],
157
+ { csrfToken: sessionCsrfToken, skipCsrf }
158
+ );
159
+
160
+ if (result.status === 200) {
161
+ return {
162
+ status: 303,
163
+ headers: { location: safeRedirectTarget(form, headers) },
164
+ body: '',
165
+ };
166
+ }
167
+ return htmlResponse(result.status, (result.body && result.body.message) || 'Action failed');
168
+ }
169
+
170
+ // --- JSON + X-What-Action header (fetch clients) ---
171
+ if (!headerActionId) {
58
172
  return jsonResponse(400, { message: 'Missing X-What-Action header' });
59
173
  }
60
174
 
175
+ if (!skipCsrf && getCsrfToken && !sessionCsrfToken) {
176
+ // CSRF configured, but the client presented no session token (no cookie).
177
+ return jsonResponse(403, { message: 'Missing CSRF token' });
178
+ }
179
+
61
180
  const body = reqLike.body || {};
62
181
  const args = body.args;
63
182
 
64
- const sessionCsrfToken = skipCsrf
65
- ? undefined
66
- : (getCsrfToken ? await getCsrfToken(reqLike) : undefined);
67
-
68
183
  const result = await handleActionRequest(
69
184
  { headers },
70
- actionId,
185
+ headerActionId,
71
186
  args,
72
187
  { csrfToken: sessionCsrfToken, skipCsrf }
73
188
  );
@@ -84,27 +199,87 @@ export function nodeActionMiddleware(options = {}) {
84
199
  const handle = createActionHandler(options);
85
200
 
86
201
  return async function middleware(req, res, next) {
87
- const url = (req.url || '').split('?')[0];
202
+ const [url, search] = (req.url || '').split('?');
88
203
  if (url !== basePath || (req.method || '').toUpperCase() !== 'POST') {
89
204
  return next ? next() : undefined;
90
205
  }
91
206
 
92
207
  let body;
93
208
  try {
94
- body = await readJsonBody(req);
209
+ const raw = await readRawBody(req);
210
+ body = parseActionBody(raw, req.headers['content-type'] || '');
95
211
  } catch (err) {
96
212
  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' }));
213
+ res.end(JSON.stringify({ message: err.code === 'BODY_TOO_LARGE' ? 'Payload too large' : 'Invalid request body' }));
98
214
  return;
99
215
  }
100
216
 
101
- const out = await handle({ method: req.method, headers: req.headers, body });
217
+ const query = Object.fromEntries(new URLSearchParams(search || ''));
218
+ const out = await handle({ method: req.method, headers: req.headers, body, query });
102
219
  res.writeHead(out.status, out.headers);
103
220
  res.end(out.body);
104
221
  };
105
222
  }
106
223
 
107
- function readJsonBody(req) {
224
+ /** Parse an action request body by content type: form-urlencoded -> fields object, else JSON. */
225
+ export function parseActionBody(raw, contentType) {
226
+ if ((contentType || '').includes('application/x-www-form-urlencoded')) {
227
+ const fields = {};
228
+ for (const [k, v] of new URLSearchParams(String(raw))) {
229
+ if (fields[k] === undefined) fields[k] = v;
230
+ else if (Array.isArray(fields[k])) fields[k].push(v);
231
+ else fields[k] = [fields[k], v];
232
+ }
233
+ return fields;
234
+ }
235
+ if (raw == null || raw === '') return {};
236
+ return JSON.parse(String(raw));
237
+ }
238
+
239
+ /**
240
+ * Read a Web Fetch `Request` body as text with the same MAX_BODY_BYTES cap the
241
+ * Node middleware enforces. Used by the adapter/edge entry points (Vercel /
242
+ * Cloudflare / Node-adapter) so all three share one DoS guard.
243
+ *
244
+ * Returns { raw } on success or { tooLarge: true } when the cap is exceeded —
245
+ * checked first via Content-Length, then enforced while streaming (chunked /
246
+ * spoofed Content-Length can't bypass it).
247
+ *
248
+ * @param {Request} request
249
+ * @param {number} [limit=MAX_BODY_BYTES]
250
+ */
251
+ export async function readFetchBodyCapped(request, limit = MAX_BODY_BYTES) {
252
+ const declared = Number(request.headers.get('content-length'));
253
+ if (Number.isFinite(declared) && declared > limit) {
254
+ return { tooLarge: true };
255
+ }
256
+ const body = request.body;
257
+ // No stream available (or env without ReadableStream): fall back to text()
258
+ // but still re-check the resulting size against the cap.
259
+ if (!body || typeof body.getReader !== 'function') {
260
+ const raw = await request.text();
261
+ if (Buffer.byteLength(raw, 'utf8') > limit) return { tooLarge: true };
262
+ return { raw };
263
+ }
264
+ const reader = body.getReader();
265
+ const chunks = [];
266
+ let size = 0;
267
+ while (true) {
268
+ const { done, value } = await reader.read();
269
+ if (done) break;
270
+ if (value) {
271
+ size += value.byteLength;
272
+ if (size > limit) {
273
+ try { await reader.cancel(); } catch { /* ignore */ }
274
+ return { tooLarge: true };
275
+ }
276
+ chunks.push(value);
277
+ }
278
+ }
279
+ return { raw: Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8') };
280
+ }
281
+
282
+ function readRawBody(req) {
108
283
  return new Promise((resolve, reject) => {
109
284
  let size = 0;
110
285
  const chunks = [];
@@ -120,12 +295,8 @@ function readJsonBody(req) {
120
295
  chunks.push(chunk);
121
296
  });
122
297
  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
- }
298
+ if (chunks.length === 0) return resolve('');
299
+ resolve(Buffer.concat(chunks).toString('utf8'));
129
300
  });
130
301
  req.on('error', reject);
131
302
  });
@@ -139,11 +310,22 @@ export function fetchActionHandler(options = {}) {
139
310
  return async function (request) {
140
311
  let body = {};
141
312
  try {
142
- body = await request.json();
313
+ const read = await readFetchBodyCapped(request);
314
+ if (read.tooLarge) {
315
+ return new Response(JSON.stringify({ message: 'Payload too large' }), {
316
+ status: 413,
317
+ headers: { 'content-type': 'application/json' },
318
+ });
319
+ }
320
+ body = parseActionBody(read.raw, request.headers.get('content-type') || '');
143
321
  } catch {
144
322
  body = {};
145
323
  }
146
- const out = await handle({ method: request.method, headers: request.headers, body });
324
+ let query = {};
325
+ try {
326
+ query = Object.fromEntries(new URL(request.url, 'http://localhost').searchParams);
327
+ } catch { /* no query */ }
328
+ const out = await handle({ method: request.method, headers: request.headers, body, query });
147
329
  return new Response(out.body, { status: out.status, headers: out.headers });
148
330
  };
149
331
  }
@@ -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,47 @@ 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` 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
+ */
41
83
  export function createRequestHandler(options = {}) {
42
84
  const {
43
85
  routes = [],
44
86
  cache,
45
87
  render,
46
- actionHandler = createActionHandler({ skipCsrf: true }),
47
88
  revalidateWebhook,
48
89
  document: documentOptions = {},
49
90
  notFound,
50
91
  basePath = '',
92
+ csrf = true,
51
93
  } = options;
52
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
+
53
104
  const renderRoute = render || defaultRenderRoute(documentOptions);
54
105
 
55
106
  // Bind the cache engine so server actions' revalidatePath/revalidateTag (and
@@ -66,13 +117,49 @@ export function createRequestHandler(options = {}) {
66
117
  let pathname = url.pathname;
67
118
  if (basePath && pathname.startsWith(basePath)) pathname = pathname.slice(basePath.length) || '/';
68
119
 
69
- // Server actions
120
+ // Server actions (JSON fetch path AND plain form-post fallback)
70
121
  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 });
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
+ });
73
135
  return new Response(out.body, { status: out.status, headers: out.headers });
74
136
  }
75
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
+
76
163
  // On-demand revalidation webhook
77
164
  if (request.method === 'POST' && pathname === REVALIDATE_PATH && revalidateWebhook) {
78
165
  const body = await readJsonBody(request);
@@ -87,7 +174,7 @@ export function createRequestHandler(options = {}) {
87
174
  const matched = matchRoute(pathname, routes);
88
175
  if (!matched) {
89
176
  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' } });
177
+ return new Response(html, { status: 404, headers: withCsrfCookie({ 'content-type': 'text/html; charset=utf-8' }) });
91
178
  }
92
179
 
93
180
  const { route, params } = matched;
@@ -95,17 +182,21 @@ export function createRequestHandler(options = {}) {
95
182
  const routeMatch = { path: pathname, query: parseQuery(url.search), config, route, params, request };
96
183
 
97
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.
98
187
  if (cache && config.mode !== 'server') {
99
188
  const result = await cache.handle(routeMatch, () => renderRoute(routeMatch));
100
189
  return new Response(result.html, {
101
190
  status: result.status || 200,
102
- headers: { 'content-type': 'text/html; charset=utf-8', ...(result.headers || {}) },
191
+ headers: withCsrfCookie({ 'content-type': 'text/html; charset=utf-8', ...(result.headers || {}) }),
103
192
  });
104
193
  }
105
194
 
106
- // Direct render (server mode, or no cache configured)
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;
107
198
  const out = await renderRoute(routeMatch);
108
- const headers = { 'content-type': 'text/html; charset=utf-8' };
199
+ const headers = withCsrfCookie({ 'content-type': 'text/html; charset=utf-8' });
109
200
  if (config.mode === 'server') headers['Cache-Control'] = 'private, no-store';
110
201
  return new Response(out.html, { status: out.status || 200, headers });
111
202
  };
@@ -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
  }