what-server 0.3.0 → 0.4.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.
- package/package.json +2 -2
- package/src/actions.js +158 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "what-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "What Framework - SSR, islands architecture, static generation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"author": "",
|
|
33
33
|
"license": "MIT",
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"what-core": "^0.
|
|
35
|
+
"what-core": "^0.4.0"
|
|
36
36
|
},
|
|
37
37
|
"repository": {
|
|
38
38
|
"type": "git",
|
package/src/actions.js
CHANGED
|
@@ -19,10 +19,68 @@ import { signal, batch } from 'what-core';
|
|
|
19
19
|
const actionRegistry = new Map();
|
|
20
20
|
let actionIdCounter = 0;
|
|
21
21
|
|
|
22
|
+
// --- CSRF Protection ---
|
|
23
|
+
// Server generates a token per session; client sends it with every action request.
|
|
24
|
+
// The token is injected into the page via a meta tag or embedded in the server response.
|
|
25
|
+
|
|
26
|
+
// Client: read the CSRF token from the page meta tag or cookie
|
|
27
|
+
// Re-reads on every call to handle token rotation
|
|
28
|
+
function getCsrfToken() {
|
|
29
|
+
if (typeof document !== 'undefined') {
|
|
30
|
+
// Try meta tag first
|
|
31
|
+
const meta = document.querySelector('meta[name="what-csrf-token"]');
|
|
32
|
+
if (meta) {
|
|
33
|
+
return meta.getAttribute('content');
|
|
34
|
+
}
|
|
35
|
+
// Try cookie
|
|
36
|
+
const match = document.cookie.match(/(?:^|;\s*)what-csrf=([^;]+)/);
|
|
37
|
+
if (match) {
|
|
38
|
+
return decodeURIComponent(match[1]);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Server: generate a CSRF token (call this per session/request)
|
|
45
|
+
export function generateCsrfToken() {
|
|
46
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
47
|
+
return crypto.randomUUID();
|
|
48
|
+
}
|
|
49
|
+
// Fallback for older Node
|
|
50
|
+
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Server: validate CSRF token from request header against session token
|
|
54
|
+
export function validateCsrfToken(requestToken, sessionToken) {
|
|
55
|
+
if (!requestToken || !sessionToken) return false;
|
|
56
|
+
// Constant-time comparison to prevent timing attacks
|
|
57
|
+
if (requestToken.length !== sessionToken.length) return false;
|
|
58
|
+
let result = 0;
|
|
59
|
+
for (let i = 0; i < requestToken.length; i++) {
|
|
60
|
+
result |= requestToken.charCodeAt(i) ^ sessionToken.charCodeAt(i);
|
|
61
|
+
}
|
|
62
|
+
return result === 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Server: middleware helper to inject CSRF meta tag into HTML
|
|
66
|
+
export function csrfMetaTag(token) {
|
|
67
|
+
// HTML-escape the token to prevent XSS if a non-standard value is used
|
|
68
|
+
const escaped = String(token).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
69
|
+
return `<meta name="what-csrf-token" content="${escaped}">`;
|
|
70
|
+
}
|
|
71
|
+
|
|
22
72
|
// --- Define a server action ---
|
|
23
73
|
|
|
74
|
+
function generateActionId() {
|
|
75
|
+
// Generate a random ID that's not easily enumerable
|
|
76
|
+
const rand = typeof crypto !== 'undefined' && crypto.getRandomValues
|
|
77
|
+
? Array.from(crypto.getRandomValues(new Uint8Array(6)), b => b.toString(16).padStart(2, '0')).join('')
|
|
78
|
+
: Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
79
|
+
return `a_${rand}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
24
82
|
export function action(fn, options = {}) {
|
|
25
|
-
const id = options.id ||
|
|
83
|
+
const id = options.id || generateActionId();
|
|
26
84
|
const { onError, onSuccess, revalidate } = options;
|
|
27
85
|
|
|
28
86
|
// Server-side: register the action
|
|
@@ -37,14 +95,24 @@ export function action(fn, options = {}) {
|
|
|
37
95
|
return fn(...args);
|
|
38
96
|
}
|
|
39
97
|
|
|
40
|
-
// Client-side: call via fetch
|
|
98
|
+
// Client-side: call via fetch with timeout support
|
|
99
|
+
const timeout = options.timeout || 30000; // Default 30s timeout
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
102
|
+
|
|
41
103
|
try {
|
|
104
|
+
const csrfToken = getCsrfToken();
|
|
105
|
+
const headers = {
|
|
106
|
+
'Content-Type': 'application/json',
|
|
107
|
+
'X-What-Action': id,
|
|
108
|
+
};
|
|
109
|
+
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
|
110
|
+
|
|
42
111
|
const response = await fetch('/__what_action', {
|
|
43
112
|
method: 'POST',
|
|
44
|
-
headers
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
},
|
|
113
|
+
headers,
|
|
114
|
+
credentials: 'same-origin',
|
|
115
|
+
signal: controller.signal,
|
|
48
116
|
body: JSON.stringify({ args }),
|
|
49
117
|
});
|
|
50
118
|
|
|
@@ -65,8 +133,16 @@ export function action(fn, options = {}) {
|
|
|
65
133
|
|
|
66
134
|
return result;
|
|
67
135
|
} catch (error) {
|
|
136
|
+
if (error.name === 'AbortError') {
|
|
137
|
+
const timeoutError = new Error(`Action "${id}" timed out after ${timeout}ms`);
|
|
138
|
+
timeoutError.code = 'TIMEOUT';
|
|
139
|
+
if (onError) onError(timeoutError);
|
|
140
|
+
throw timeoutError;
|
|
141
|
+
}
|
|
68
142
|
if (onError) onError(error);
|
|
69
143
|
throw error;
|
|
144
|
+
} finally {
|
|
145
|
+
clearTimeout(timeoutId);
|
|
70
146
|
}
|
|
71
147
|
}
|
|
72
148
|
|
|
@@ -184,6 +260,7 @@ export function useFormAction(actionFn, options = {}) {
|
|
|
184
260
|
export function useOptimistic(initialValue, reducer) {
|
|
185
261
|
const value = signal(initialValue);
|
|
186
262
|
const pending = signal([]);
|
|
263
|
+
const baseValue = signal(initialValue); // Track the confirmed server value
|
|
187
264
|
|
|
188
265
|
function addOptimistic(action) {
|
|
189
266
|
const optimisticValue = reducer(value.peek(), action);
|
|
@@ -193,24 +270,57 @@ export function useOptimistic(initialValue, reducer) {
|
|
|
193
270
|
});
|
|
194
271
|
}
|
|
195
272
|
|
|
196
|
-
function resolve(action) {
|
|
197
|
-
|
|
273
|
+
function resolve(action, serverValue) {
|
|
274
|
+
batch(() => {
|
|
275
|
+
pending.set(pending.peek().filter(a => a !== action));
|
|
276
|
+
if (serverValue !== undefined) {
|
|
277
|
+
baseValue.set(serverValue);
|
|
278
|
+
// Recompute optimistic state from new base + remaining pending actions
|
|
279
|
+
let current = serverValue;
|
|
280
|
+
for (const a of pending.peek()) {
|
|
281
|
+
current = reducer(current, a);
|
|
282
|
+
}
|
|
283
|
+
value.set(current);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
198
286
|
}
|
|
199
287
|
|
|
200
288
|
function rollback(action, realValue) {
|
|
201
289
|
batch(() => {
|
|
202
|
-
pending.
|
|
203
|
-
|
|
290
|
+
const newPending = pending.peek().filter(a => a !== action);
|
|
291
|
+
pending.set(newPending);
|
|
292
|
+
const base = realValue !== undefined ? realValue : baseValue.peek();
|
|
293
|
+
baseValue.set(base);
|
|
294
|
+
// Recompute from base + remaining pending actions
|
|
295
|
+
let current = base;
|
|
296
|
+
for (const a of newPending) {
|
|
297
|
+
current = reducer(current, a);
|
|
298
|
+
}
|
|
299
|
+
value.set(current);
|
|
204
300
|
});
|
|
205
301
|
}
|
|
206
302
|
|
|
303
|
+
// Auto-rollback helper: wraps an async action with automatic rollback on error
|
|
304
|
+
async function withOptimistic(action, asyncFn) {
|
|
305
|
+
addOptimistic(action);
|
|
306
|
+
try {
|
|
307
|
+
const result = await asyncFn();
|
|
308
|
+
resolve(action, result);
|
|
309
|
+
return result;
|
|
310
|
+
} catch (e) {
|
|
311
|
+
rollback(action);
|
|
312
|
+
throw e;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
207
316
|
return {
|
|
208
317
|
value: () => value(),
|
|
209
318
|
isPending: () => pending().length > 0,
|
|
210
319
|
addOptimistic,
|
|
211
320
|
resolve,
|
|
212
321
|
rollback,
|
|
213
|
-
|
|
322
|
+
withOptimistic,
|
|
323
|
+
set: (v) => { value.set(v); baseValue.set(v); },
|
|
214
324
|
};
|
|
215
325
|
}
|
|
216
326
|
|
|
@@ -241,18 +351,49 @@ export function invalidatePath(path) {
|
|
|
241
351
|
// --- Server-side action handler ---
|
|
242
352
|
// Add this to your server middleware.
|
|
243
353
|
|
|
244
|
-
export function handleActionRequest(req, actionId, args) {
|
|
354
|
+
export function handleActionRequest(req, actionId, args, options = {}) {
|
|
355
|
+
const { csrfToken: sessionCsrfToken, skipCsrf = false } = options;
|
|
356
|
+
|
|
357
|
+
// Validate CSRF token unless explicitly skipped
|
|
358
|
+
if (!skipCsrf) {
|
|
359
|
+
if (!sessionCsrfToken) {
|
|
360
|
+
// Fail closed: no CSRF token configured means the developer forgot to set it up.
|
|
361
|
+
// This prevents silent security vulnerabilities in production.
|
|
362
|
+
return Promise.resolve({
|
|
363
|
+
status: 500,
|
|
364
|
+
body: {
|
|
365
|
+
message: '[what] CSRF token not configured. ' +
|
|
366
|
+
'Pass { csrfToken: sessionToken } to handleActionRequest, ' +
|
|
367
|
+
'or { skipCsrf: true } to explicitly opt out.'
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
const requestCsrfToken = req?.headers?.['x-csrf-token'] || req?.headers?.['X-CSRF-Token'];
|
|
372
|
+
if (!validateCsrfToken(requestCsrfToken, sessionCsrfToken)) {
|
|
373
|
+
return Promise.resolve({ status: 403, body: { message: 'Invalid CSRF token' } });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
245
377
|
const action = actionRegistry.get(actionId);
|
|
246
378
|
if (!action) {
|
|
247
|
-
return { status: 404, body: { message:
|
|
379
|
+
return Promise.resolve({ status: 404, body: { message: 'Action not found' } });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Validate args is an array to prevent prototype pollution
|
|
383
|
+
if (!Array.isArray(args)) {
|
|
384
|
+
return Promise.resolve({ status: 400, body: { message: 'Invalid action arguments' } });
|
|
248
385
|
}
|
|
249
386
|
|
|
250
387
|
return action.fn(...args)
|
|
251
388
|
.then(result => ({ status: 200, body: result }))
|
|
252
|
-
.catch(error =>
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
389
|
+
.catch(error => {
|
|
390
|
+
// Log the full error server-side, return generic message to client
|
|
391
|
+
console.error(`[what] Action "${actionId}" error:`, error);
|
|
392
|
+
return {
|
|
393
|
+
status: 500,
|
|
394
|
+
body: { message: 'Action failed' },
|
|
395
|
+
};
|
|
396
|
+
});
|
|
256
397
|
}
|
|
257
398
|
|
|
258
399
|
// --- Get all registered actions (for SSR/build) ---
|