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,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse Server Components (PSC) - Type Definitions
|
|
3
|
+
*
|
|
4
|
+
* Type definitions for the PSC Wire Format, which is used to serialize
|
|
5
|
+
* Server Component trees with Client Component boundaries for transmission
|
|
6
|
+
* from server to client.
|
|
7
|
+
*
|
|
8
|
+
* @module pulse-js-framework/runtime/server-components/types
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { RuntimeError } from '../errors.js';
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// PSC Wire Format Types
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Complete PSC payload sent from server to client.
|
|
19
|
+
* Contains the component tree, client component manifest, and optional state.
|
|
20
|
+
*
|
|
21
|
+
* @typedef {Object} PSCPayload
|
|
22
|
+
* @property {string} version - PSC format version (e.g., "1.0")
|
|
23
|
+
* @property {PSCNode} root - Root node of the component tree
|
|
24
|
+
* @property {Record<string, ClientManifestEntry>} clientManifest - Mapping of component IDs to chunk URLs
|
|
25
|
+
* @property {Record<string, any>} [state] - Optional serialized server state
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* {
|
|
29
|
+
* version: "1.0",
|
|
30
|
+
* root: {
|
|
31
|
+
* type: 'element',
|
|
32
|
+
* tag: 'div',
|
|
33
|
+
* props: { class: 'app' },
|
|
34
|
+
* children: [
|
|
35
|
+
* { type: 'text', value: 'Hello' },
|
|
36
|
+
* { type: 'client', id: 'Button', props: { text: 'Click' } }
|
|
37
|
+
* ]
|
|
38
|
+
* },
|
|
39
|
+
* clientManifest: {
|
|
40
|
+
* Button: { id: 'Button', chunk: '/assets/Button.js', exports: ['default'] }
|
|
41
|
+
* },
|
|
42
|
+
* state: { user: { id: 1, name: 'Alice' } }
|
|
43
|
+
* }
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* PSC Node - union type representing any node in the component tree.
|
|
48
|
+
*
|
|
49
|
+
* @typedef {PSCElement | PSCText | PSCClientBoundary | PSCFragment | PSCComment} PSCNode
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* PSC Element Node - represents a DOM element (e.g., <div>, <span>).
|
|
54
|
+
*
|
|
55
|
+
* @typedef {Object} PSCElement
|
|
56
|
+
* @property {'element'} type - Node type discriminator
|
|
57
|
+
* @property {string} tag - HTML tag name (e.g., 'div', 'span', 'button')
|
|
58
|
+
* @property {Record<string, any>} props - Element attributes and properties
|
|
59
|
+
* @property {PSCNode[]} children - Child nodes
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* {
|
|
63
|
+
* type: 'element',
|
|
64
|
+
* tag: 'div',
|
|
65
|
+
* props: { class: 'container', 'data-id': '123' },
|
|
66
|
+
* children: [
|
|
67
|
+
* { type: 'text', value: 'Content' }
|
|
68
|
+
* ]
|
|
69
|
+
* }
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* PSC Text Node - represents a text node.
|
|
74
|
+
*
|
|
75
|
+
* @typedef {Object} PSCText
|
|
76
|
+
* @property {'text'} type - Node type discriminator
|
|
77
|
+
* @property {string} value - Text content
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* { type: 'text', value: 'Hello, world!' }
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* PSC Client Boundary - represents a Client Component boundary.
|
|
85
|
+
* This is a placeholder for a Client Component that will be lazy-loaded
|
|
86
|
+
* and hydrated on the client side.
|
|
87
|
+
*
|
|
88
|
+
* @typedef {Object} PSCClientBoundary
|
|
89
|
+
* @property {'client'} type - Node type discriminator
|
|
90
|
+
* @property {string} id - Component ID matching clientManifest key
|
|
91
|
+
* @property {Record<string, any>} props - Serialized props for the Client Component
|
|
92
|
+
* @property {PSCNode} [fallback] - Optional fallback UI while loading
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* {
|
|
96
|
+
* type: 'client',
|
|
97
|
+
* id: 'AddToCartButton',
|
|
98
|
+
* props: { productId: 123, productName: 'Widget' },
|
|
99
|
+
* fallback: {
|
|
100
|
+
* type: 'element',
|
|
101
|
+
* tag: 'div',
|
|
102
|
+
* props: { class: 'skeleton-button' },
|
|
103
|
+
* children: []
|
|
104
|
+
* }
|
|
105
|
+
* }
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* PSC Fragment - represents a fragment (multiple nodes without a wrapper).
|
|
110
|
+
*
|
|
111
|
+
* @typedef {Object} PSCFragment
|
|
112
|
+
* @property {'fragment'} type - Node type discriminator
|
|
113
|
+
* @property {PSCNode[]} children - Child nodes
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* {
|
|
117
|
+
* type: 'fragment',
|
|
118
|
+
* children: [
|
|
119
|
+
* { type: 'text', value: 'First' },
|
|
120
|
+
* { type: 'text', value: 'Second' }
|
|
121
|
+
* ]
|
|
122
|
+
* }
|
|
123
|
+
*/
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* PSC Comment Node - represents an HTML comment.
|
|
127
|
+
*
|
|
128
|
+
* @typedef {Object} PSCComment
|
|
129
|
+
* @property {'comment'} type - Node type discriminator
|
|
130
|
+
* @property {string} value - Comment text
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* { type: 'comment', value: 'client-only' }
|
|
134
|
+
*/
|
|
135
|
+
|
|
136
|
+
// ============================================================================
|
|
137
|
+
// Client Manifest Types
|
|
138
|
+
// ============================================================================
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Client Manifest Entry - metadata for a Client Component chunk.
|
|
142
|
+
*
|
|
143
|
+
* @typedef {Object} ClientManifestEntry
|
|
144
|
+
* @property {string} id - Component ID (unique identifier)
|
|
145
|
+
* @property {string} chunk - URL path to the JS chunk (e.g., '/assets/Button.a1b2c3.js')
|
|
146
|
+
* @property {string[]} exports - Exported names from the chunk (e.g., ['default', 'Button'])
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* {
|
|
150
|
+
* id: 'ClientButton',
|
|
151
|
+
* chunk: '/assets/ClientButton.a1b2c3.js',
|
|
152
|
+
* exports: ['default']
|
|
153
|
+
* }
|
|
154
|
+
*/
|
|
155
|
+
|
|
156
|
+
// ============================================================================
|
|
157
|
+
// Node Type Constants
|
|
158
|
+
// ============================================================================
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Node type constants for PSC Wire Format.
|
|
162
|
+
* Use these instead of string literals for type safety.
|
|
163
|
+
*/
|
|
164
|
+
export const PSCNodeType = {
|
|
165
|
+
ELEMENT: 'element',
|
|
166
|
+
TEXT: 'text',
|
|
167
|
+
CLIENT: 'client',
|
|
168
|
+
FRAGMENT: 'fragment',
|
|
169
|
+
COMMENT: 'comment'
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* PSC format version.
|
|
174
|
+
*/
|
|
175
|
+
export const PSC_VERSION = '1.0';
|
|
176
|
+
|
|
177
|
+
// ============================================================================
|
|
178
|
+
// Type Guards
|
|
179
|
+
// ============================================================================
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Check if a PSC node is an element node.
|
|
183
|
+
*
|
|
184
|
+
* @param {PSCNode} node - Node to check
|
|
185
|
+
* @returns {node is PSCElement}
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* if (isPSCElement(node)) {
|
|
189
|
+
* console.log('Tag:', node.tag);
|
|
190
|
+
* }
|
|
191
|
+
*/
|
|
192
|
+
export function isPSCElement(node) {
|
|
193
|
+
return node && node.type === PSCNodeType.ELEMENT;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Check if a PSC node is a text node.
|
|
198
|
+
*
|
|
199
|
+
* @param {PSCNode} node - Node to check
|
|
200
|
+
* @returns {node is PSCText}
|
|
201
|
+
*/
|
|
202
|
+
export function isPSCText(node) {
|
|
203
|
+
return node && node.type === PSCNodeType.TEXT;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Check if a PSC node is a client boundary.
|
|
208
|
+
*
|
|
209
|
+
* @param {PSCNode} node - Node to check
|
|
210
|
+
* @returns {node is PSCClientBoundary}
|
|
211
|
+
*/
|
|
212
|
+
export function isPSCClientBoundary(node) {
|
|
213
|
+
return node && node.type === PSCNodeType.CLIENT;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Check if a PSC node is a fragment.
|
|
218
|
+
*
|
|
219
|
+
* @param {PSCNode} node - Node to check
|
|
220
|
+
* @returns {node is PSCFragment}
|
|
221
|
+
*/
|
|
222
|
+
export function isPSCFragment(node) {
|
|
223
|
+
return node && node.type === PSCNodeType.FRAGMENT;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Check if a PSC node is a comment.
|
|
228
|
+
*
|
|
229
|
+
* @param {PSCNode} node - Node to check
|
|
230
|
+
* @returns {node is PSCComment}
|
|
231
|
+
*/
|
|
232
|
+
export function isPSCComment(node) {
|
|
233
|
+
return node && node.type === PSCNodeType.COMMENT;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ============================================================================
|
|
237
|
+
// Validation
|
|
238
|
+
// ============================================================================
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Validate a PSC payload structure.
|
|
242
|
+
*
|
|
243
|
+
* @param {any} payload - Payload to validate
|
|
244
|
+
* @returns {boolean} True if valid PSC payload
|
|
245
|
+
* @throws {RuntimeError} If validation fails with detailed error message
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* try {
|
|
249
|
+
* validatePSCPayload(payload);
|
|
250
|
+
* // Payload is valid
|
|
251
|
+
* } catch (err) {
|
|
252
|
+
* console.error('Invalid PSC payload:', err.message);
|
|
253
|
+
* }
|
|
254
|
+
*/
|
|
255
|
+
export function validatePSCPayload(payload) {
|
|
256
|
+
if (!payload || typeof payload !== 'object') {
|
|
257
|
+
throw new RuntimeError('PSC payload must be an object', { code: 'PSC_VALIDATION_ERROR' });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!payload.version || typeof payload.version !== 'string') {
|
|
261
|
+
throw new RuntimeError('PSC payload must have a version string', { code: 'PSC_VALIDATION_ERROR' });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (!payload.root) {
|
|
265
|
+
throw new RuntimeError('PSC payload must have a root node', { code: 'PSC_VALIDATION_ERROR' });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!payload.clientManifest || typeof payload.clientManifest !== 'object') {
|
|
269
|
+
throw new RuntimeError('PSC payload must have a clientManifest object', { code: 'PSC_VALIDATION_ERROR' });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Validate root node structure
|
|
273
|
+
validatePSCNode(payload.root);
|
|
274
|
+
|
|
275
|
+
// Validate client manifest entries
|
|
276
|
+
for (const [key, entry] of Object.entries(payload.clientManifest)) {
|
|
277
|
+
if (!entry.id || typeof entry.id !== 'string') {
|
|
278
|
+
throw new RuntimeError(`Client manifest entry '${key}' missing id`, { code: 'PSC_VALIDATION_ERROR' });
|
|
279
|
+
}
|
|
280
|
+
if (!entry.chunk || typeof entry.chunk !== 'string') {
|
|
281
|
+
throw new RuntimeError(`Client manifest entry '${key}' missing chunk URL`, { code: 'PSC_VALIDATION_ERROR' });
|
|
282
|
+
}
|
|
283
|
+
if (!Array.isArray(entry.exports)) {
|
|
284
|
+
throw new RuntimeError(`Client manifest entry '${key}' missing exports array`, { code: 'PSC_VALIDATION_ERROR' });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Validate a PSC node structure (recursive).
|
|
293
|
+
*
|
|
294
|
+
* @param {PSCNode} node - Node to validate
|
|
295
|
+
* @param {number} [depth=0] - Current recursion depth (for cycle detection)
|
|
296
|
+
* @returns {boolean} True if valid
|
|
297
|
+
* @throws {RuntimeError} If validation fails
|
|
298
|
+
*/
|
|
299
|
+
export function validatePSCNode(node, depth = 0) {
|
|
300
|
+
if (depth > 100) {
|
|
301
|
+
throw new RuntimeError('PSC node tree too deep (max 100 levels)', { code: 'PSC_VALIDATION_ERROR' });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!node || typeof node !== 'object') {
|
|
305
|
+
throw new RuntimeError('PSC node must be an object', { code: 'PSC_VALIDATION_ERROR' });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (!node.type) {
|
|
309
|
+
throw new RuntimeError('PSC node missing type property', { code: 'PSC_VALIDATION_ERROR' });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
switch (node.type) {
|
|
313
|
+
case PSCNodeType.ELEMENT:
|
|
314
|
+
if (!node.tag || typeof node.tag !== 'string') {
|
|
315
|
+
throw new RuntimeError('PSC element missing tag', { code: 'PSC_VALIDATION_ERROR' });
|
|
316
|
+
}
|
|
317
|
+
if (node.props && typeof node.props !== 'object') {
|
|
318
|
+
throw new RuntimeError('PSC element props must be an object', { code: 'PSC_VALIDATION_ERROR' });
|
|
319
|
+
}
|
|
320
|
+
if (!Array.isArray(node.children)) {
|
|
321
|
+
throw new RuntimeError('PSC element children must be an array', { code: 'PSC_VALIDATION_ERROR' });
|
|
322
|
+
}
|
|
323
|
+
// Validate children recursively
|
|
324
|
+
for (const child of node.children) {
|
|
325
|
+
validatePSCNode(child, depth + 1);
|
|
326
|
+
}
|
|
327
|
+
break;
|
|
328
|
+
|
|
329
|
+
case PSCNodeType.TEXT:
|
|
330
|
+
if (typeof node.value !== 'string') {
|
|
331
|
+
throw new RuntimeError('PSC text node must have a string value', { code: 'PSC_VALIDATION_ERROR' });
|
|
332
|
+
}
|
|
333
|
+
break;
|
|
334
|
+
|
|
335
|
+
case PSCNodeType.CLIENT:
|
|
336
|
+
if (!node.id || typeof node.id !== 'string') {
|
|
337
|
+
throw new RuntimeError('PSC client boundary missing id', { code: 'PSC_VALIDATION_ERROR' });
|
|
338
|
+
}
|
|
339
|
+
if (node.props && typeof node.props !== 'object') {
|
|
340
|
+
throw new RuntimeError('PSC client boundary props must be an object', { code: 'PSC_VALIDATION_ERROR' });
|
|
341
|
+
}
|
|
342
|
+
if (node.fallback) {
|
|
343
|
+
validatePSCNode(node.fallback, depth + 1);
|
|
344
|
+
}
|
|
345
|
+
break;
|
|
346
|
+
|
|
347
|
+
case PSCNodeType.FRAGMENT:
|
|
348
|
+
if (!Array.isArray(node.children)) {
|
|
349
|
+
throw new RuntimeError('PSC fragment children must be an array', { code: 'PSC_VALIDATION_ERROR' });
|
|
350
|
+
}
|
|
351
|
+
for (const child of node.children) {
|
|
352
|
+
validatePSCNode(child, depth + 1);
|
|
353
|
+
}
|
|
354
|
+
break;
|
|
355
|
+
|
|
356
|
+
case PSCNodeType.COMMENT:
|
|
357
|
+
if (typeof node.value !== 'string') {
|
|
358
|
+
throw new RuntimeError('PSC comment must have a string value', { code: 'PSC_VALIDATION_ERROR' });
|
|
359
|
+
}
|
|
360
|
+
break;
|
|
361
|
+
|
|
362
|
+
default:
|
|
363
|
+
throw new RuntimeError(`Unknown PSC node type: ${node.type}`, { code: 'PSC_VALIDATION_ERROR' });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ============================================================================
|
|
370
|
+
// Exports
|
|
371
|
+
// ============================================================================
|
|
372
|
+
|
|
373
|
+
export default {
|
|
374
|
+
PSCNodeType,
|
|
375
|
+
PSC_VERSION,
|
|
376
|
+
isPSCElement,
|
|
377
|
+
isPSCText,
|
|
378
|
+
isPSCClientBoundary,
|
|
379
|
+
isPSCFragment,
|
|
380
|
+
isPSCComment,
|
|
381
|
+
validatePSCPayload,
|
|
382
|
+
validatePSCNode
|
|
383
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple Mutex (Mutual Exclusion) Lock
|
|
3
|
+
*
|
|
4
|
+
* Provides exclusive access to critical sections to prevent race conditions.
|
|
5
|
+
* Uses a queue-based approach with promises for async/await compatibility.
|
|
6
|
+
*
|
|
7
|
+
* @module pulse-js-framework/runtime/server-components/utils/mutex
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create a mutex lock for protecting critical sections
|
|
12
|
+
*
|
|
13
|
+
* @returns {Object} Mutex instance with lock() method
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const mutex = createMutex();
|
|
17
|
+
*
|
|
18
|
+
* await mutex.lock(async () => {
|
|
19
|
+
* // Critical section - only one caller at a time
|
|
20
|
+
* await someAsyncOperation();
|
|
21
|
+
* });
|
|
22
|
+
*/
|
|
23
|
+
export function createMutex() {
|
|
24
|
+
// Promise-chain pattern: the ONLY correct way to implement mutex in JavaScript
|
|
25
|
+
// Each lock() captures the current chain, updates it to their own promise,
|
|
26
|
+
// then waits for the previous chain before executing
|
|
27
|
+
let chain = Promise.resolve();
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
/**
|
|
31
|
+
* Execute function with exclusive lock
|
|
32
|
+
*
|
|
33
|
+
* @param {Function} fn - Function to execute (can be async)
|
|
34
|
+
* @returns {Promise<any>} Result of the function
|
|
35
|
+
*/
|
|
36
|
+
lock(fn) {
|
|
37
|
+
// Create completion promise
|
|
38
|
+
let unlock;
|
|
39
|
+
const ready = new Promise(resolve => {
|
|
40
|
+
unlock = resolve;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// CRITICAL: These two lines MUST be synchronous (no await between them)
|
|
44
|
+
// to prevent race conditions
|
|
45
|
+
const prevChain = chain; // Capture current chain
|
|
46
|
+
chain = ready; // Update chain to our promise
|
|
47
|
+
|
|
48
|
+
// Return a promise that waits for previous, executes, then unlocks
|
|
49
|
+
return prevChain.then(async () => {
|
|
50
|
+
try {
|
|
51
|
+
return await fn();
|
|
52
|
+
} finally {
|
|
53
|
+
unlock(); // Release next waiter
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default { createMutex };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path Sanitization Utilities
|
|
3
|
+
*
|
|
4
|
+
* Prevents path traversal attacks by validating and normalizing file paths.
|
|
5
|
+
* Ensures paths stay within the project directory.
|
|
6
|
+
*
|
|
7
|
+
* @module pulse-js-framework/runtime/server-components/utils/path-sanitizer
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { resolve, normalize, relative, isAbsolute } from 'path';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Sanitize and validate an import path to prevent traversal attacks
|
|
14
|
+
*
|
|
15
|
+
* Security checks:
|
|
16
|
+
* 1. Normalizes path to remove ../ sequences
|
|
17
|
+
* 2. Resolves to absolute path
|
|
18
|
+
* 3. Ensures result is within basePath (no escaping project directory)
|
|
19
|
+
* 4. Blocks absolute paths that bypass basePath
|
|
20
|
+
*
|
|
21
|
+
* @param {string} importPath - Path to sanitize (possibly malicious)
|
|
22
|
+
* @param {string} basePath - Base directory that paths must stay within
|
|
23
|
+
* @returns {string} Sanitized absolute path
|
|
24
|
+
* @throws {Error} If path attempts to escape basePath
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* // Safe path
|
|
28
|
+
* sanitizeImportPath('./components/Button.js', '/project')
|
|
29
|
+
* // → '/project/components/Button.js'
|
|
30
|
+
*
|
|
31
|
+
* // Path traversal attempt - THROWS
|
|
32
|
+
* sanitizeImportPath('../../../etc/passwd', '/project')
|
|
33
|
+
* // → Error: Invalid import path
|
|
34
|
+
*/
|
|
35
|
+
export function sanitizeImportPath(importPath, basePath) {
|
|
36
|
+
if (!importPath || typeof importPath !== 'string') {
|
|
37
|
+
throw new Error('Invalid import path: path must be a non-empty string');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!basePath || typeof basePath !== 'string') {
|
|
41
|
+
throw new Error('Invalid base path: basePath must be a non-empty string');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// SECURITY: Block Windows absolute paths on Unix systems (e.g., C:\...)
|
|
45
|
+
// These could bypass path checks on cross-platform builds
|
|
46
|
+
if (/^[A-Za-z]:\\/.test(importPath)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Invalid import path: "${importPath}" attempts to use absolute Windows path`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Normalize to remove ../ and ./ sequences
|
|
53
|
+
const normalized = normalize(importPath);
|
|
54
|
+
|
|
55
|
+
// Resolve to absolute path relative to basePath
|
|
56
|
+
const resolved = resolve(basePath, normalized);
|
|
57
|
+
|
|
58
|
+
// Get relative path from basePath to resolved path
|
|
59
|
+
const rel = relative(basePath, resolved);
|
|
60
|
+
|
|
61
|
+
// Check if path escapes basePath
|
|
62
|
+
// - If rel starts with '..', it's outside basePath
|
|
63
|
+
// - If rel is absolute, it bypassed basePath
|
|
64
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Invalid import path: "${importPath}" attempts to escape project directory`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return resolved;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Sanitize multiple import paths
|
|
75
|
+
*
|
|
76
|
+
* @param {string[]} paths - Array of paths to sanitize
|
|
77
|
+
* @param {string} basePath - Base directory
|
|
78
|
+
* @returns {string[]} Array of sanitized paths
|
|
79
|
+
* @throws {Error} If any path is invalid
|
|
80
|
+
*/
|
|
81
|
+
export function sanitizeImportPaths(paths, basePath) {
|
|
82
|
+
if (!Array.isArray(paths)) {
|
|
83
|
+
throw new Error('Invalid paths: must be an array');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return paths.map(path => sanitizeImportPath(path, basePath));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check if a path is safe (within basePath) without throwing
|
|
91
|
+
*
|
|
92
|
+
* @param {string} importPath - Path to check
|
|
93
|
+
* @param {string} basePath - Base directory
|
|
94
|
+
* @returns {boolean} True if path is safe
|
|
95
|
+
*/
|
|
96
|
+
export function isPathSafe(importPath, basePath) {
|
|
97
|
+
try {
|
|
98
|
+
sanitizeImportPath(importPath, basePath);
|
|
99
|
+
return true;
|
|
100
|
+
} catch (e) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export default {
|
|
106
|
+
sanitizeImportPath,
|
|
107
|
+
sanitizeImportPaths,
|
|
108
|
+
isPathSafe
|
|
109
|
+
};
|
package/runtime/ssr.js
CHANGED
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
getHydrationContext
|
|
51
51
|
} from './ssr-hydrator.js';
|
|
52
52
|
import { loggers } from './logger.js';
|
|
53
|
+
import { DOMError } from './errors.js';
|
|
53
54
|
|
|
54
55
|
const log = loggers.dom;
|
|
55
56
|
|
|
@@ -277,7 +278,7 @@ export function hydrate(target, componentFactory, options = {}) {
|
|
|
277
278
|
: target;
|
|
278
279
|
|
|
279
280
|
if (!container) {
|
|
280
|
-
throw new
|
|
281
|
+
throw new DOMError(`Hydration target not found: ${target}`, { code: 'MOUNT_ERROR', suggestion: `Ensure an element matching '${target}' exists in the DOM` });
|
|
281
282
|
}
|
|
282
283
|
|
|
283
284
|
// Restore state if provided
|
package/runtime/store.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { pulse, computed, effect, batch } from './pulse.js';
|
|
19
19
|
import { loggers, createLogger } from './logger.js';
|
|
20
20
|
import { Errors, createErrorMessage } from './errors.js';
|
|
21
|
+
import { DANGEROUS_KEYS } from './security.js';
|
|
21
22
|
|
|
22
23
|
const log = loggers.store;
|
|
23
24
|
|
|
@@ -53,11 +54,7 @@ const MAX_NESTING_DEPTH = 10;
|
|
|
53
54
|
* @typedef {function(Store): Store} StorePlugin
|
|
54
55
|
*/
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
* Dangerous property names that could cause prototype pollution
|
|
58
|
-
* @type {Set<string>}
|
|
59
|
-
*/
|
|
60
|
-
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
57
|
+
// DANGEROUS_KEYS imported from security.js (single source of truth — 16 entries)
|
|
61
58
|
|
|
62
59
|
/**
|
|
63
60
|
* Invalid value types that cannot be stored in state
|
|
@@ -74,7 +71,7 @@ const INVALID_TYPES = new Set(['function', 'symbol']);
|
|
|
74
71
|
* Uses WeakMap to allow garbage collection of unreferenced objects
|
|
75
72
|
* @type {WeakMap<Object, {keys: string[], values: any[]}>}
|
|
76
73
|
*/
|
|
77
|
-
|
|
74
|
+
let _validationCache = new WeakMap();
|
|
78
75
|
|
|
79
76
|
/**
|
|
80
77
|
* Check shallow equality between cached and current object
|
|
@@ -110,9 +107,7 @@ function _cacheObject(obj) {
|
|
|
110
107
|
* @returns {void}
|
|
111
108
|
*/
|
|
112
109
|
export function clearValidationCache() {
|
|
113
|
-
|
|
114
|
-
// Since it's module-level, we just document that cache is auto-cleared
|
|
115
|
-
// when objects are garbage collected
|
|
110
|
+
_validationCache = new WeakMap();
|
|
116
111
|
}
|
|
117
112
|
|
|
118
113
|
/**
|
|
@@ -452,6 +447,20 @@ export function createStore(initialState = {}, options = {}) {
|
|
|
452
447
|
});
|
|
453
448
|
}
|
|
454
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Get a tracked snapshot of the current state (registers reactive dependencies).
|
|
452
|
+
* Unlike getState() which uses peek(), this uses get() so effects re-run on changes.
|
|
453
|
+
* @private
|
|
454
|
+
* @returns {Object} Plain object with current values
|
|
455
|
+
*/
|
|
456
|
+
function getTrackedState() {
|
|
457
|
+
const snapshot = {};
|
|
458
|
+
for (const [key, p] of Object.entries(pulses)) {
|
|
459
|
+
snapshot[key] = p.get();
|
|
460
|
+
}
|
|
461
|
+
return snapshot;
|
|
462
|
+
}
|
|
463
|
+
|
|
455
464
|
/**
|
|
456
465
|
* Subscribe to all state changes
|
|
457
466
|
* @param {function(Object): void} callback - Called with current state on each change
|
|
@@ -459,7 +468,7 @@ export function createStore(initialState = {}, options = {}) {
|
|
|
459
468
|
*/
|
|
460
469
|
function subscribe(callback) {
|
|
461
470
|
return effect(() => {
|
|
462
|
-
const state =
|
|
471
|
+
const state = getTrackedState();
|
|
463
472
|
callback(state);
|
|
464
473
|
});
|
|
465
474
|
}
|