pulse-js-framework 1.10.4 → 1.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.
Files changed (65) hide show
  1. package/README.md +11 -0
  2. package/cli/build.js +13 -3
  3. package/compiler/directives.js +356 -0
  4. package/compiler/lexer.js +18 -3
  5. package/compiler/parser/core.js +6 -0
  6. package/compiler/parser/view.js +2 -6
  7. package/compiler/preprocessor.js +43 -23
  8. package/compiler/sourcemap.js +3 -1
  9. package/compiler/transformer/actions.js +329 -0
  10. package/compiler/transformer/export.js +7 -0
  11. package/compiler/transformer/expressions.js +85 -33
  12. package/compiler/transformer/imports.js +3 -0
  13. package/compiler/transformer/index.js +2 -0
  14. package/compiler/transformer/store.js +1 -1
  15. package/compiler/transformer/style.js +45 -16
  16. package/compiler/transformer/view.js +23 -2
  17. package/loader/rollup-plugin-server-components.js +391 -0
  18. package/loader/vite-plugin-server-components.js +420 -0
  19. package/loader/webpack-loader-server-components.js +356 -0
  20. package/package.json +124 -82
  21. package/runtime/async.js +4 -0
  22. package/runtime/context.js +16 -3
  23. package/runtime/dom-adapter.js +5 -3
  24. package/runtime/dom-virtual-list.js +2 -1
  25. package/runtime/form.js +8 -3
  26. package/runtime/graphql/cache.js +1 -1
  27. package/runtime/graphql/client.js +22 -0
  28. package/runtime/graphql/hooks.js +12 -6
  29. package/runtime/graphql/subscriptions.js +2 -0
  30. package/runtime/hmr.js +6 -3
  31. package/runtime/http.js +1 -0
  32. package/runtime/i18n.js +2 -0
  33. package/runtime/lru-cache.js +3 -1
  34. package/runtime/native.js +46 -20
  35. package/runtime/pulse.js +3 -0
  36. package/runtime/router/core.js +5 -1
  37. package/runtime/router/index.js +17 -1
  38. package/runtime/router/psc-integration.js +301 -0
  39. package/runtime/security.js +58 -29
  40. package/runtime/server-components/actions-server.js +798 -0
  41. package/runtime/server-components/actions.js +389 -0
  42. package/runtime/server-components/client.js +447 -0
  43. package/runtime/server-components/error-sanitizer.js +438 -0
  44. package/runtime/server-components/index.js +275 -0
  45. package/runtime/server-components/security-csrf.js +593 -0
  46. package/runtime/server-components/security-errors.js +227 -0
  47. package/runtime/server-components/security-ratelimit.js +733 -0
  48. package/runtime/server-components/security-validation.js +467 -0
  49. package/runtime/server-components/security.js +598 -0
  50. package/runtime/server-components/serializer.js +617 -0
  51. package/runtime/server-components/server.js +382 -0
  52. package/runtime/server-components/types.js +383 -0
  53. package/runtime/server-components/utils/mutex.js +60 -0
  54. package/runtime/server-components/utils/path-sanitizer.js +109 -0
  55. package/runtime/ssr.js +2 -1
  56. package/runtime/store.js +19 -10
  57. package/runtime/utils.js +12 -128
  58. package/types/animation.d.ts +300 -0
  59. package/types/i18n.d.ts +283 -0
  60. package/types/persistence.d.ts +267 -0
  61. package/types/sse.d.ts +248 -0
  62. package/types/sw.d.ts +150 -0
  63. package/runtime/a11y.js.original +0 -1844
  64. package/runtime/graphql.js.original +0 -1326
  65. package/runtime/router.js.original +0 -1605
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Server Actions - Client Runtime
3
+ *
4
+ * Provides client-side utilities for invoking Server Actions.
5
+ * Server Actions are async functions marked with 'use server' that execute
6
+ * on the server but can be called from Client Components.
7
+ *
8
+ * Features:
9
+ * - Secure RPC mechanism with CSRF protection
10
+ * - Automatic serialization/deserialization
11
+ * - Reactive hook (useServerAction) for loading/error states
12
+ * - Progressive enhancement for forms (bindFormAction)
13
+ *
14
+ * @module pulse-js-framework/runtime/server-components/actions
15
+ */
16
+
17
+ import { pulse } from '../pulse.js';
18
+ import { PSCRateLimitError } from './security-errors.js';
19
+
20
+ // ============================================================
21
+ // Utilities
22
+ // ============================================================
23
+
24
+ /**
25
+ * Sleep for specified milliseconds
26
+ * @param {number} ms - Milliseconds to sleep
27
+ * @returns {Promise<void>}
28
+ */
29
+ function sleep(ms) {
30
+ return new Promise(resolve => setTimeout(resolve, ms));
31
+ }
32
+
33
+ // ============================================================
34
+ // Action Registry
35
+ // ============================================================
36
+
37
+ /**
38
+ * Action registry (populated by build-time injection or runtime registration)
39
+ * @type {Map<string, Object>}
40
+ */
41
+ const actionRegistry = new Map(); // actionId → { endpoint, method }
42
+
43
+ /**
44
+ * Register a Server Action
45
+ * @param {string} actionId - Unique action identifier
46
+ * @param {Object} config - Action configuration
47
+ * @param {string} [config.endpoint='/_actions'] - RPC endpoint
48
+ * @param {string} [config.method='POST'] - HTTP method
49
+ */
50
+ export function registerAction(actionId, config = {}) {
51
+ const { endpoint = '/_actions', method = 'POST' } = config;
52
+
53
+ actionRegistry.set(actionId, { endpoint, method });
54
+ }
55
+
56
+ /**
57
+ * Get registered action configuration
58
+ * @param {string} actionId - Action identifier
59
+ * @returns {Object|null} Action config or null
60
+ */
61
+ export function getActionConfig(actionId) {
62
+ return actionRegistry.get(actionId) || null;
63
+ }
64
+
65
+ /**
66
+ * Clear all registered actions (for testing)
67
+ */
68
+ export function clearActionRegistry() {
69
+ actionRegistry.clear();
70
+ }
71
+
72
+ // ============================================================
73
+ // CSRF Token Handling
74
+ // ============================================================
75
+
76
+ /**
77
+ * Get CSRF token from meta tag or cookie
78
+ * @returns {string} CSRF token or empty string
79
+ */
80
+ function getCSRFToken() {
81
+ // Try meta tag first (most common pattern)
82
+ if (typeof document !== 'undefined') {
83
+ const meta = document.querySelector('meta[name="csrf-token"]');
84
+ if (meta) return meta.getAttribute('content') || '';
85
+
86
+ // Try cookie fallback (double-submit pattern)
87
+ const match = document.cookie.match(/csrf-token=([^;]+)/);
88
+ if (match) return match[1];
89
+ }
90
+
91
+ return '';
92
+ }
93
+
94
+ /**
95
+ * Update CSRF token from response header
96
+ * Called after successful Server Action when token rotation is enabled
97
+ * @param {Response} response - Fetch response
98
+ */
99
+ function updateCSRFToken(response) {
100
+ if (typeof document === 'undefined') return;
101
+
102
+ const newToken = response.headers.get('X-New-CSRF-Token');
103
+ if (!newToken) return;
104
+
105
+ // Update meta tag
106
+ const meta = document.querySelector('meta[name="csrf-token"]');
107
+ if (meta) {
108
+ meta.setAttribute('content', newToken);
109
+ }
110
+
111
+ // Cookie is automatically updated by server's Set-Cookie header
112
+ }
113
+
114
+ // ============================================================
115
+ // Action Invocation
116
+ // ============================================================
117
+
118
+ /**
119
+ * Create action invocation function with automatic retry on rate limit
120
+ * @param {string} actionId - Action identifier
121
+ * @param {Object} [options] - Invoker options
122
+ * @param {number} [options.maxRetries=3] - Maximum retry attempts on rate limit
123
+ * @param {boolean} [options.autoRetry=true] - Automatically retry on rate limit
124
+ * @returns {Function} Action invoker (async function)
125
+ *
126
+ * @example
127
+ * const createUser = createActionInvoker('UserForm$createUser');
128
+ * const user = await createUser({ name: 'John', email: 'john@example.com' });
129
+ *
130
+ * @example
131
+ * // With custom retry behavior
132
+ * const createUser = createActionInvoker('UserForm$createUser', {
133
+ * maxRetries: 5,
134
+ * autoRetry: true
135
+ * });
136
+ */
137
+ export function createActionInvoker(actionId, options = {}) {
138
+ const { maxRetries = 3, autoRetry = true } = options;
139
+
140
+ return async (...args) => {
141
+ const config = actionRegistry.get(actionId);
142
+ if (!config) {
143
+ throw new Error(`Server Action not found: ${actionId}`);
144
+ }
145
+
146
+ let attempts = 0;
147
+
148
+ while (attempts <= maxRetries) {
149
+ try {
150
+ // Send RPC request to server
151
+ const response = await fetch(config.endpoint, {
152
+ method: config.method,
153
+ headers: {
154
+ 'Content-Type': 'application/json',
155
+ 'X-Pulse-Action': actionId,
156
+ 'X-CSRF-Token': getCSRFToken()
157
+ },
158
+ body: JSON.stringify({ args }),
159
+ credentials: 'same-origin'
160
+ });
161
+
162
+ // Handle rate limiting (429)
163
+ if (response.status === 429) {
164
+ const retryAfterHeader = response.headers.get('Retry-After');
165
+ const retryAfter = retryAfterHeader ? parseInt(retryAfterHeader, 10) * 1000 : 1000;
166
+
167
+ // Parse error details
168
+ let errorData = {};
169
+ try {
170
+ errorData = await response.json();
171
+ } catch {
172
+ // Ignore parse errors
173
+ }
174
+
175
+ // Check if we should retry
176
+ if (attempts < maxRetries && autoRetry) {
177
+ attempts++;
178
+ await sleep(retryAfter);
179
+ continue; // Retry
180
+ }
181
+
182
+ // Max retries reached or auto-retry disabled
183
+ throw new PSCRateLimitError('Rate limit exceeded', {
184
+ actionId,
185
+ reason: errorData.reason,
186
+ retryAfter,
187
+ resetAt: response.headers.get('X-RateLimit-Reset'),
188
+ limit: parseInt(response.headers.get('X-RateLimit-Limit') || '0', 10)
189
+ });
190
+ }
191
+
192
+ // Handle other errors
193
+ if (!response.ok) {
194
+ let errorMessage = 'Server Action failed';
195
+
196
+ try {
197
+ const error = await response.json();
198
+ errorMessage = error.message || error.error || errorMessage;
199
+ } catch {
200
+ // Couldn't parse error as JSON, use status text
201
+ errorMessage = response.statusText || errorMessage;
202
+ }
203
+
204
+ throw new Error(errorMessage);
205
+ }
206
+
207
+ // Update CSRF token if rotated
208
+ updateCSRFToken(response);
209
+
210
+ return response.json();
211
+ } catch (error) {
212
+ // If it's a rate limit error and we have retries left, continue
213
+ if (PSCRateLimitError.isRateLimitError(error) && attempts < maxRetries && autoRetry) {
214
+ attempts++;
215
+ await sleep(error.retryAfter || 1000);
216
+ continue;
217
+ }
218
+
219
+ // Otherwise, throw the error
220
+ throw error;
221
+ }
222
+ }
223
+
224
+ // Should never reach here, but just in case
225
+ throw new PSCRateLimitError('Max retries exceeded', { actionId });
226
+ };
227
+ }
228
+
229
+ // ============================================================
230
+ // Reactive Hook
231
+ // ============================================================
232
+
233
+ /**
234
+ * useServerAction hook for reactive Server Actions
235
+ *
236
+ * @param {Function|string} action - Action function or action ID
237
+ * @returns {Object} Action state and controls
238
+ * @returns {Function} returns.invoke - Invoke the action
239
+ * @returns {Pulse<any>} returns.data - Result data (Pulse)
240
+ * @returns {Pulse<boolean>} returns.loading - Loading state (Pulse)
241
+ * @returns {Pulse<Error|null>} returns.error - Error state (Pulse)
242
+ * @returns {Function} returns.reset - Reset to initial state
243
+ *
244
+ * @example
245
+ * const { invoke, data, loading, error } = useServerAction('createUser');
246
+ *
247
+ * // In effect or event handler
248
+ * effect(() => {
249
+ * if (loading.get()) console.log('Submitting...');
250
+ * if (error.get()) console.log('Error:', error.get().message);
251
+ * if (data.get()) console.log('Success:', data.get());
252
+ * });
253
+ *
254
+ * await invoke({ name: 'John', email: 'john@example.com' });
255
+ */
256
+ export function useServerAction(action) {
257
+ const data = pulse(null);
258
+ const loading = pulse(false);
259
+ const error = pulse(null);
260
+
261
+ const actionFn = typeof action === 'string'
262
+ ? createActionInvoker(action)
263
+ : action;
264
+
265
+ async function invoke(...args) {
266
+ loading.set(true);
267
+ error.set(null);
268
+
269
+ try {
270
+ const result = await actionFn(...args);
271
+ data.set(result);
272
+ return result;
273
+ } catch (err) {
274
+ error.set(err);
275
+ throw err;
276
+ } finally {
277
+ loading.set(false);
278
+ }
279
+ }
280
+
281
+ function reset() {
282
+ data.set(null);
283
+ loading.set(false);
284
+ error.set(null);
285
+ }
286
+
287
+ return { invoke, data, loading, error, reset };
288
+ }
289
+
290
+ // ============================================================
291
+ // Form Binding (Progressive Enhancement)
292
+ // ============================================================
293
+
294
+ /**
295
+ * Bind form to Server Action (progressive enhancement)
296
+ *
297
+ * Intercepts form submission and submits via Server Action instead.
298
+ * Provides automatic loading state and form reset on success.
299
+ *
300
+ * @param {HTMLFormElement} form - Form element
301
+ * @param {Function|string} action - Server Action function or ID
302
+ * @param {Object} [options] - Binding options
303
+ * @param {boolean} [options.resetOnSuccess=true] - Reset form on successful submission
304
+ * @param {Function} [options.onSuccess] - Success callback
305
+ * @param {Function} [options.onError] - Error callback
306
+ * @returns {Function} Cleanup function
307
+ *
308
+ * @example
309
+ * const form = document.querySelector('#user-form');
310
+ * const cleanup = bindFormAction(form, 'createUser', {
311
+ * onSuccess: (result) => console.log('User created:', result),
312
+ * onError: (error) => console.error('Failed:', error)
313
+ * });
314
+ */
315
+ export function bindFormAction(form, action, options = {}) {
316
+ const {
317
+ resetOnSuccess = true,
318
+ onSuccess = null,
319
+ onError = null
320
+ } = options;
321
+
322
+ const { invoke, loading } = useServerAction(action);
323
+
324
+ async function handleSubmit(e) {
325
+ e.preventDefault();
326
+
327
+ // Disable form during submission
328
+ const submitter = form.querySelector('[type=submit]');
329
+ const originalSubmitterText = submitter?.textContent;
330
+
331
+ if (submitter) {
332
+ submitter.disabled = true;
333
+ if (submitter.textContent) {
334
+ submitter.textContent = 'Submitting...';
335
+ }
336
+ }
337
+
338
+ // Extract form data
339
+ const formData = new FormData(form);
340
+ const data = Object.fromEntries(formData.entries());
341
+
342
+ try {
343
+ const result = await invoke(data);
344
+
345
+ if (resetOnSuccess) {
346
+ form.reset();
347
+ }
348
+
349
+ if (onSuccess) {
350
+ onSuccess(result);
351
+ }
352
+ } catch (error) {
353
+ if (onError) {
354
+ onError(error);
355
+ } else {
356
+ // Default error display
357
+ console.error('Form submission failed:', error);
358
+ alert(`Error: ${error.message}`);
359
+ }
360
+ } finally {
361
+ if (submitter) {
362
+ submitter.disabled = false;
363
+ if (originalSubmitterText) {
364
+ submitter.textContent = originalSubmitterText;
365
+ }
366
+ }
367
+ }
368
+ }
369
+
370
+ form.addEventListener('submit', handleSubmit);
371
+
372
+ // Return cleanup function
373
+ return () => {
374
+ form.removeEventListener('submit', handleSubmit);
375
+ };
376
+ }
377
+
378
+ // ============================================================
379
+ // Exports
380
+ // ============================================================
381
+
382
+ export default {
383
+ registerAction,
384
+ getActionConfig,
385
+ clearActionRegistry,
386
+ createActionInvoker,
387
+ useServerAction,
388
+ bindFormAction
389
+ };