what-server 0.2.1 → 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.
Files changed (2) hide show
  1. package/package.json +2 -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.2.1",
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.2.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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 || `action_${++actionIdCounter}`;
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
- 'Content-Type': 'application/json',
46
- 'X-What-Action': id,
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
- pending.set(pending.peek().filter(a => a !== action));
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.set(pending.peek().filter(a => a !== action));
203
- value.set(realValue);
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
- set: (v) => value.set(v),
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: `Action "${actionId}" not found` } };
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
- status: 500,
254
- body: { message: error.message || 'Action failed' },
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) ---