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.
- package/README.md +11 -0
- package/cli/build.js +13 -3
- package/compiler/directives.js +356 -0
- package/compiler/lexer.js +18 -3
- package/compiler/parser/core.js +6 -0
- package/compiler/parser/view.js +2 -6
- package/compiler/preprocessor.js +43 -23
- package/compiler/sourcemap.js +3 -1
- package/compiler/transformer/actions.js +329 -0
- package/compiler/transformer/export.js +7 -0
- package/compiler/transformer/expressions.js +85 -33
- package/compiler/transformer/imports.js +3 -0
- package/compiler/transformer/index.js +2 -0
- package/compiler/transformer/store.js +1 -1
- package/compiler/transformer/style.js +45 -16
- package/compiler/transformer/view.js +23 -2
- package/loader/rollup-plugin-server-components.js +391 -0
- package/loader/vite-plugin-server-components.js +420 -0
- package/loader/webpack-loader-server-components.js +356 -0
- package/package.json +124 -82
- package/runtime/async.js +4 -0
- package/runtime/context.js +16 -3
- package/runtime/dom-adapter.js +5 -3
- package/runtime/dom-virtual-list.js +2 -1
- package/runtime/form.js +8 -3
- package/runtime/graphql/cache.js +1 -1
- package/runtime/graphql/client.js +22 -0
- package/runtime/graphql/hooks.js +12 -6
- package/runtime/graphql/subscriptions.js +2 -0
- package/runtime/hmr.js +6 -3
- package/runtime/http.js +1 -0
- package/runtime/i18n.js +2 -0
- package/runtime/lru-cache.js +3 -1
- package/runtime/native.js +46 -20
- package/runtime/pulse.js +3 -0
- package/runtime/router/core.js +5 -1
- package/runtime/router/index.js +17 -1
- package/runtime/router/psc-integration.js +301 -0
- package/runtime/security.js +58 -29
- package/runtime/server-components/actions-server.js +798 -0
- package/runtime/server-components/actions.js +389 -0
- package/runtime/server-components/client.js +447 -0
- package/runtime/server-components/error-sanitizer.js +438 -0
- package/runtime/server-components/index.js +275 -0
- package/runtime/server-components/security-csrf.js +593 -0
- package/runtime/server-components/security-errors.js +227 -0
- package/runtime/server-components/security-ratelimit.js +733 -0
- package/runtime/server-components/security-validation.js +467 -0
- package/runtime/server-components/security.js +598 -0
- package/runtime/server-components/serializer.js +617 -0
- package/runtime/server-components/server.js +382 -0
- package/runtime/server-components/types.js +383 -0
- package/runtime/server-components/utils/mutex.js +60 -0
- package/runtime/server-components/utils/path-sanitizer.js +109 -0
- package/runtime/ssr.js +2 -1
- package/runtime/store.js +19 -10
- package/runtime/utils.js +12 -128
- package/types/animation.d.ts +300 -0
- package/types/i18n.d.ts +283 -0
- package/types/persistence.d.ts +267 -0
- package/types/sse.d.ts +248 -0
- package/types/sw.d.ts +150 -0
- package/runtime/a11y.js.original +0 -1844
- package/runtime/graphql.js.original +0 -1326
- 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
|
+
};
|