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.
- package/README.md +148 -0
- package/dist/actions.min.js +1 -2
- package/dist/index.min.js +5 -6
- package/dist/islands.min.js +0 -1
- package/package.json +4 -4
- package/src/action-handler.js +208 -21
- package/src/adapter/cloudflare.js +1 -1
- package/src/adapter/core.js +104 -11
- package/src/adapter/vercel.js +74 -10
- package/src/index.js +21 -2
- package/src/revalidation-registry.js +2 -2
- package/dist/actions.js +0 -384
- package/dist/actions.js.map +0 -7
- package/dist/actions.min.js.map +0 -7
- package/dist/index.js +0 -1150
- package/dist/index.js.map +0 -7
- package/dist/index.min.js.map +0 -7
- package/dist/islands.js +0 -355
- package/dist/islands.js.map +0 -7
- package/dist/islands.min.js.map +0 -7
package/src/action-handler.js
CHANGED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')}</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
|
|
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
|
|
57
|
-
|
|
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
|
-
|
|
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('?')
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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';
|
package/src/adapter/core.js
CHANGED
|
@@ -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-
|
|
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
|
|
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
|
|
72
|
-
|
|
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
|
};
|
package/src/adapter/vercel.js
CHANGED
|
@@ -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
|
|
5
|
-
//
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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({
|
|
20
|
-
|
|
21
|
-
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
25
|
+
'Create a what-isr engine and bind it in your adapter (setRevalidationHandler).'
|
|
26
26
|
);
|
|
27
27
|
}
|
|
28
28
|
}
|