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,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 Error(`[Pulse SSR] Hydration target not found: ${target}`);
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
- const _validationCache = new WeakMap();
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
- // WeakMap doesn't have clear(), but we can create a new one
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 = getState();
471
+ const state = getTrackedState();
463
472
  callback(state);
464
473
  });
465
474
  }