pulse-js-framework 1.5.2 → 1.5.3

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/cli/release.js CHANGED
@@ -460,17 +460,24 @@ Types:
460
460
  major Bump major version (1.0.0 -> 2.0.0)
461
461
 
462
462
  Options:
463
- --dry-run Show what would be done without making changes
464
- --no-push Create commit and tag but don't push
465
- --title <text> Release title (e.g., "Performance Improvements")
466
- --skip-prompt Use empty changelog (for automated releases)
467
- --from-commits Auto-extract changelog from git commits since last tag
463
+ --dry-run Show what would be done without making changes
464
+ --no-push Create commit and tag but don't push
465
+ --title <text> Release title (e.g., "Performance Improvements")
466
+ --skip-prompt Use empty changelog (for automated releases)
467
+ --from-commits Auto-extract changelog from git commits since last tag
468
+ --yes, -y Auto-confirm all prompts
469
+ --changes <json> Pass changelog as JSON (e.g., '{"added":["Feature 1"],"fixed":["Bug 1"]}')
470
+ --added <items> Comma-separated list of added features
471
+ --changed <items> Comma-separated list of changes
472
+ --fixed <items> Comma-separated list of fixes
468
473
 
469
474
  Examples:
470
475
  pulse release patch
471
- pulse release minor --title "New Features"
476
+ pulse release minor --title "New Features" -y
472
477
  pulse release major --dry-run
473
- pulse release patch --from-commits --title "Bug Fixes"
478
+ pulse release patch --from-commits --title "Bug Fixes" -y
479
+ pulse release patch --title "Security" --fixed "XSS vulnerability,SQL injection" -y
480
+ pulse release patch --title "New API" --added "Feature A,Feature B" --fixed "Bug X" -y
474
481
  `);
475
482
  }
476
483
 
@@ -490,6 +497,7 @@ export async function runRelease(args) {
490
497
  const noPush = args.includes('--no-push');
491
498
  const skipPrompt = args.includes('--skip-prompt');
492
499
  const fromCommits = args.includes('--from-commits');
500
+ const autoConfirm = args.includes('--yes') || args.includes('-y');
493
501
 
494
502
  let title = '';
495
503
  const titleIndex = args.indexOf('--title');
@@ -497,6 +505,33 @@ export async function runRelease(args) {
497
505
  title = args[titleIndex + 1];
498
506
  }
499
507
 
508
+ // Parse --changes JSON option
509
+ let changesFromArgs = null;
510
+ const changesIndex = args.indexOf('--changes');
511
+ if (changesIndex !== -1 && args[changesIndex + 1]) {
512
+ try {
513
+ changesFromArgs = JSON.parse(args[changesIndex + 1]);
514
+ } catch (e) {
515
+ log.error('Invalid JSON for --changes option');
516
+ process.exit(1);
517
+ }
518
+ }
519
+
520
+ // Parse individual change type options
521
+ const addedIndex = args.indexOf('--added');
522
+ const changedIndex = args.indexOf('--changed');
523
+ const fixedIndex = args.indexOf('--fixed');
524
+ const removedIndex = args.indexOf('--removed');
525
+
526
+ if (!changesFromArgs && (addedIndex !== -1 || changedIndex !== -1 || fixedIndex !== -1 || removedIndex !== -1)) {
527
+ changesFromArgs = {
528
+ added: addedIndex !== -1 && args[addedIndex + 1] ? args[addedIndex + 1].split(',').map(s => s.trim()) : [],
529
+ changed: changedIndex !== -1 && args[changedIndex + 1] ? args[changedIndex + 1].split(',').map(s => s.trim()) : [],
530
+ fixed: fixedIndex !== -1 && args[fixedIndex + 1] ? args[fixedIndex + 1].split(',').map(s => s.trim()) : [],
531
+ removed: removedIndex !== -1 && args[removedIndex + 1] ? args[removedIndex + 1].split(',').map(s => s.trim()) : []
532
+ };
533
+ }
534
+
500
535
  // Read current version
501
536
  const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
502
537
  const currentVersion = pkg.version;
@@ -517,10 +552,14 @@ export async function runRelease(args) {
517
552
  if (status.trim()) {
518
553
  log.warn('You have uncommitted changes:');
519
554
  log.info(status);
520
- const proceed = await prompt('Continue anyway? (y/N) ');
521
- if (proceed.toLowerCase() !== 'y') {
522
- log.info('Aborted.');
523
- process.exit(0);
555
+ if (!autoConfirm) {
556
+ const proceed = await prompt('Continue anyway? (y/N) ');
557
+ if (proceed.toLowerCase() !== 'y') {
558
+ log.info('Aborted.');
559
+ process.exit(0);
560
+ }
561
+ } else {
562
+ log.info('Auto-confirming with --yes flag');
524
563
  }
525
564
  }
526
565
  } catch (error) {
@@ -531,7 +570,24 @@ export async function runRelease(args) {
531
570
  // Collect changelog entries
532
571
  let changes = { added: [], changed: [], fixed: [], removed: [] };
533
572
 
534
- if (fromCommits) {
573
+ // Use changes from command-line arguments if provided
574
+ if (changesFromArgs) {
575
+ changes = {
576
+ added: changesFromArgs.added || [],
577
+ changed: changesFromArgs.changed || [],
578
+ fixed: changesFromArgs.fixed || [],
579
+ removed: changesFromArgs.removed || []
580
+ };
581
+ const totalChanges = Object.values(changes).flat().length;
582
+ if (totalChanges > 0) {
583
+ log.info('');
584
+ log.info('Changelog entries from arguments:');
585
+ if (changes.added.length) log.info(` Added: ${changes.added.length} items`);
586
+ if (changes.changed.length) log.info(` Changed: ${changes.changed.length} items`);
587
+ if (changes.fixed.length) log.info(` Fixed: ${changes.fixed.length} items`);
588
+ if (changes.removed.length) log.info(` Removed: ${changes.removed.length} items`);
589
+ }
590
+ } else if (fromCommits) {
535
591
  // Auto-extract from git commits since last tag
536
592
  const lastTag = getLastTag();
537
593
  const commits = getCommitsSinceLastTag();
@@ -554,10 +610,10 @@ export async function runRelease(args) {
554
610
  }
555
611
  }
556
612
 
557
- if (!title) {
613
+ if (!title && !autoConfirm) {
558
614
  title = await prompt('Release title (e.g., "Performance Improvements"): ');
559
615
  }
560
- } else if (!skipPrompt) {
616
+ } else if (!skipPrompt && !autoConfirm) {
561
617
  if (!title) {
562
618
  title = await prompt('Release title (e.g., "Performance Improvements"): ');
563
619
  }
@@ -574,7 +630,7 @@ export async function runRelease(args) {
574
630
 
575
631
  const hasChanges = Object.values(changes).some(arr => arr.length > 0);
576
632
 
577
- if (!hasChanges && !skipPrompt) {
633
+ if (!hasChanges && !skipPrompt && !autoConfirm) {
578
634
  const proceed = await prompt('No changelog entries. Continue? (y/N) ');
579
635
  if (proceed.toLowerCase() !== 'y') {
580
636
  log.info('Aborted.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-js-framework",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
4
4
  "description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/runtime/dom.js CHANGED
@@ -28,6 +28,38 @@ const log = loggers.dom;
28
28
  // Cache hit returns a shallow copy to prevent mutation of cached config
29
29
  const selectorCache = new LRUCache(500);
30
30
 
31
+ /**
32
+ * Safely insert a node before a reference node
33
+ * Returns false if the parent is detached (no parentNode)
34
+ * @private
35
+ * @param {Node} newNode - Node to insert
36
+ * @param {Node} refNode - Reference node (insert before this)
37
+ * @returns {boolean} True if insertion succeeded
38
+ */
39
+ function safeInsertBefore(newNode, refNode) {
40
+ if (!refNode.parentNode) {
41
+ log.warn('Cannot insert node: reference node has no parent (may be detached)');
42
+ return false;
43
+ }
44
+ refNode.parentNode.insertBefore(newNode, refNode);
45
+ return true;
46
+ }
47
+
48
+ /**
49
+ * Resolve a selector string or element to a DOM element
50
+ * @private
51
+ * @param {string|HTMLElement} target - CSS selector or DOM element
52
+ * @param {string} context - Context name for error messages
53
+ * @returns {{element: HTMLElement|null, selector: string}} Resolved element and original selector
54
+ */
55
+ function resolveSelector(target, context = 'target') {
56
+ if (typeof target === 'string') {
57
+ const element = document.querySelector(target);
58
+ return { element, selector: target };
59
+ }
60
+ return { element: target, selector: '(element)' };
61
+ }
62
+
31
63
  // Lifecycle tracking
32
64
  let mountCallbacks = [];
33
65
  let unmountCallbacks = [];
@@ -58,8 +90,13 @@ export function onUnmount(fn) {
58
90
 
59
91
  /**
60
92
  * Parse a CSS selector-like string into element configuration
61
- * Supports: tag, #id, .class, [attr=value]
62
- * Results are cached for performance.
93
+ * Results are cached for performance using LRU cache.
94
+ *
95
+ * Supported syntax:
96
+ * - Tag: `div`, `span`, `custom-element`
97
+ * - ID: `#app`, `#my-id`, `#_private`
98
+ * - Classes: `.class`, `.my-class`, `.-modifier`
99
+ * - Attributes: `[attr]`, `[attr=value]`, `[attr="quoted value"]`, `[attr='single quoted']`
63
100
  *
64
101
  * Examples:
65
102
  * "div" -> { tag: "div" }
@@ -67,6 +104,10 @@ export function onUnmount(fn) {
67
104
  * ".container" -> { tag: "div", classes: ["container"] }
68
105
  * "button.primary.large" -> { tag: "button", classes: ["primary", "large"] }
69
106
  * "input[type=text][placeholder=Name]" -> { tag: "input", attrs: { type: "text", placeholder: "Name" } }
107
+ * "div[data-id=\"complex-123\"]" -> { tag: "div", attrs: { "data-id": "complex-123" } }
108
+ *
109
+ * @param {string} selector - CSS selector-like string
110
+ * @returns {{tag: string, id: string|null, classes: string[], attrs: Object}} Parsed configuration
70
111
  */
71
112
  export function parseSelector(selector) {
72
113
  if (!selector || selector === '') {
@@ -101,32 +142,43 @@ export function parseSelector(selector) {
101
142
  remaining = remaining.slice(tagMatch[0].length);
102
143
  }
103
144
 
104
- // Match ID
105
- const idMatch = remaining.match(/#([a-zA-Z][a-zA-Z0-9-_]*)/);
145
+ // Match ID (supports starting with letter, underscore, or hyphen followed by valid chars)
146
+ const idMatch = remaining.match(/#([a-zA-Z_-][a-zA-Z0-9-_]*)/);
106
147
  if (idMatch) {
107
148
  config.id = idMatch[1];
108
149
  remaining = remaining.replace(idMatch[0], '');
109
150
  }
110
151
 
111
- // Match classes
112
- const classMatches = remaining.matchAll(/\.([a-zA-Z][a-zA-Z0-9-_]*)/g);
152
+ // Match classes (supports starting with letter, underscore, or hyphen)
153
+ const classMatches = remaining.matchAll(/\.([a-zA-Z_-][a-zA-Z0-9-_]*)/g);
113
154
  for (const match of classMatches) {
114
155
  config.classes.push(match[1]);
115
156
  }
116
157
 
117
- // Match attributes
118
- const attrMatches = remaining.matchAll(/\[([a-zA-Z][a-zA-Z0-9-_]*)(?:=([^\]]+))?\]/g);
158
+ // Match attributes - improved regex handles quoted values with special characters
159
+ // Matches: [attr], [attr=value], [attr="quoted value"], [attr='quoted value']
160
+ const attrRegex = /\[([a-zA-Z_][a-zA-Z0-9-_]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]*)))?\]/g;
161
+ const attrMatches = remaining.matchAll(attrRegex);
119
162
  for (const match of attrMatches) {
120
163
  const key = match[1];
121
- let value = match[2] || '';
122
- // Remove quotes if present
123
- if ((value.startsWith('"') && value.endsWith('"')) ||
124
- (value.startsWith("'") && value.endsWith("'"))) {
125
- value = value.slice(1, -1);
126
- }
164
+ // Value can be in match[2] (double-quoted), match[3] (single-quoted), or match[4] (unquoted)
165
+ const value = match[2] ?? match[3] ?? match[4] ?? '';
127
166
  config.attrs[key] = value;
128
167
  }
129
168
 
169
+ // Validate: check for unparsed content (malformed selector parts)
170
+ // Remove all parsed parts to see if anything remains
171
+ let unparsed = remaining
172
+ .replace(/#[a-zA-Z_-][a-zA-Z0-9-_]*/g, '') // Remove IDs
173
+ .replace(/\.[a-zA-Z_-][a-zA-Z0-9-_]*/g, '') // Remove classes
174
+ .replace(/\[([a-zA-Z_][a-zA-Z0-9-_]*)(?:=(?:"[^"]*"|'[^']*'|[^\]]*))?\]/g, '') // Remove attrs
175
+ .trim();
176
+
177
+ if (unparsed) {
178
+ log.warn(`Selector "${selector}" contains unrecognized parts: "${unparsed}". ` +
179
+ 'Supported syntax: tag#id.class[attr=value]');
180
+ }
181
+
130
182
  // Cache the result (LRU cache handles eviction automatically)
131
183
  selectorCache.set(selector, config);
132
184
 
@@ -217,7 +269,11 @@ function appendChild(parent, child) {
217
269
  }
218
270
  }
219
271
  }
220
- placeholder.parentNode.insertBefore(fragment, placeholder.nextSibling);
272
+ if (placeholder.parentNode) {
273
+ placeholder.parentNode.insertBefore(fragment, placeholder.nextSibling);
274
+ } else {
275
+ log.warn('Cannot insert reactive children: placeholder has no parent node');
276
+ }
221
277
  }
222
278
  });
223
279
  }
@@ -552,12 +608,8 @@ export function model(element, pulseValue) {
552
608
  * @throws {Error} If target element is not found
553
609
  */
554
610
  export function mount(target, element) {
555
- const originalTarget = target;
556
- if (typeof target === 'string') {
557
- target = document.querySelector(target);
558
- }
559
- if (!target) {
560
- const selector = typeof originalTarget === 'string' ? originalTarget : '(element)';
611
+ const { element: resolved, selector } = resolveSelector(target, 'mount');
612
+ if (!resolved) {
561
613
  throw new Error(
562
614
  `[Pulse] Mount target not found: "${selector}". ` +
563
615
  `Ensure the element exists in the DOM before mounting. ` +
@@ -565,7 +617,7 @@ export function mount(target, element) {
565
617
  `or place your script at the end of <body>.`
566
618
  );
567
619
  }
568
- target.appendChild(element);
620
+ resolved.appendChild(element);
569
621
  return () => {
570
622
  element.remove();
571
623
  };
@@ -649,12 +701,10 @@ export function show(condition, element) {
649
701
  * Portal - render children into a different DOM location
650
702
  */
651
703
  export function portal(children, target) {
652
- const resolvedTarget = typeof target === 'string'
653
- ? document.querySelector(target)
654
- : target;
704
+ const { element: resolvedTarget, selector } = resolveSelector(target, 'portal');
655
705
 
656
706
  if (!resolvedTarget) {
657
- log.warn('Portal target not found:', target);
707
+ log.warn(`Portal target not found: "${selector}"`);
658
708
  return document.createComment('portal-target-not-found');
659
709
  }
660
710
 
package/runtime/logger.js CHANGED
@@ -39,6 +39,13 @@ let globalLevel = LogLevel.INFO;
39
39
  /** @type {LogFormatter|null} */
40
40
  let globalFormatter = null;
41
41
 
42
+ /**
43
+ * Namespace formatting helpers
44
+ * @private
45
+ */
46
+ const NAMESPACE_SEPARATOR = ':';
47
+ const formatNamespace = (ns) => `[${ns}]`;
48
+
42
49
  /**
43
50
  * @callback LogFormatter
44
51
  * @param {'error'|'warn'|'info'|'debug'} level - The log level
@@ -97,13 +104,14 @@ export function setFormatter(formatter) {
97
104
  function formatArgs(namespace, args) {
98
105
  if (!namespace) return args;
99
106
 
107
+ const prefix = formatNamespace(namespace);
100
108
  // If first arg is a string, prepend namespace
101
109
  if (typeof args[0] === 'string') {
102
- return [`[${namespace}] ${args[0]}`, ...args.slice(1)];
110
+ return [`${prefix} ${args[0]}`, ...args.slice(1)];
103
111
  }
104
112
 
105
113
  // Otherwise, add namespace as first arg
106
- return [`[${namespace}]`, ...args];
114
+ return [prefix, ...args];
107
115
  }
108
116
 
109
117
  /**
@@ -217,7 +225,7 @@ export function createLogger(namespace = null, options = {}) {
217
225
  */
218
226
  group(label) {
219
227
  if (shouldLog(LogLevel.DEBUG)) {
220
- console.group(namespace ? `[${namespace}] ${label}` : label);
228
+ console.group(namespace ? `${formatNamespace(namespace)} ${label}` : label);
221
229
  }
222
230
  },
223
231
 
@@ -264,7 +272,7 @@ export function createLogger(namespace = null, options = {}) {
264
272
  */
265
273
  child(childNamespace) {
266
274
  const combined = namespace
267
- ? `${namespace}:${childNamespace}`
275
+ ? `${namespace}${NAMESPACE_SEPARATOR}${childNamespace}`
268
276
  : childNamespace;
269
277
  return createLogger(combined, options);
270
278
  }
@@ -15,13 +15,21 @@ export class LRUCache {
15
15
  /**
16
16
  * Create an LRU cache
17
17
  * @param {number} capacity - Maximum number of items to store
18
+ * @param {Object} [options] - Configuration options
19
+ * @param {boolean} [options.trackMetrics=false] - Enable hit/miss/eviction tracking
18
20
  */
19
- constructor(capacity) {
21
+ constructor(capacity, options = {}) {
20
22
  if (capacity <= 0) {
21
23
  throw new Error('LRU cache capacity must be greater than 0');
22
24
  }
23
25
  this._capacity = capacity;
24
26
  this._cache = new Map();
27
+
28
+ // Metrics tracking
29
+ this._trackMetrics = options.trackMetrics || false;
30
+ this._hits = 0;
31
+ this._misses = 0;
32
+ this._evictions = 0;
25
33
  }
26
34
 
27
35
  /**
@@ -32,9 +40,12 @@ export class LRUCache {
32
40
  */
33
41
  get(key) {
34
42
  if (!this._cache.has(key)) {
43
+ if (this._trackMetrics) this._misses++;
35
44
  return undefined;
36
45
  }
37
46
 
47
+ if (this._trackMetrics) this._hits++;
48
+
38
49
  // Move to end (most recently used) by re-inserting
39
50
  const value = this._cache.get(key);
40
51
  this._cache.delete(key);
@@ -57,6 +68,7 @@ export class LRUCache {
57
68
  // Remove oldest (first item in Map)
58
69
  const oldest = this._cache.keys().next().value;
59
70
  this._cache.delete(oldest);
71
+ if (this._trackMetrics) this._evictions++;
60
72
  }
61
73
 
62
74
  this._cache.set(key, value);
@@ -136,6 +148,46 @@ export class LRUCache {
136
148
  forEach(callback) {
137
149
  this._cache.forEach((value, key) => callback(value, key, this));
138
150
  }
151
+
152
+ /**
153
+ * Get cache performance metrics
154
+ * Only available if trackMetrics option was enabled
155
+ * @returns {{hits: number, misses: number, evictions: number, hitRate: number, size: number, capacity: number}}
156
+ * @example
157
+ * const cache = new LRUCache(100, { trackMetrics: true });
158
+ * // ... use cache ...
159
+ * const stats = cache.getMetrics();
160
+ * console.log(`Hit rate: ${(stats.hitRate * 100).toFixed(1)}%`);
161
+ */
162
+ getMetrics() {
163
+ const total = this._hits + this._misses;
164
+ return {
165
+ hits: this._hits,
166
+ misses: this._misses,
167
+ evictions: this._evictions,
168
+ hitRate: total > 0 ? this._hits / total : 0,
169
+ size: this._cache.size,
170
+ capacity: this._capacity
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Reset all metrics counters to zero
176
+ * Useful for measuring metrics over specific time periods
177
+ */
178
+ resetMetrics() {
179
+ this._hits = 0;
180
+ this._misses = 0;
181
+ this._evictions = 0;
182
+ }
183
+
184
+ /**
185
+ * Enable or disable metrics tracking
186
+ * @param {boolean} enabled - Whether to track metrics
187
+ */
188
+ setMetricsTracking(enabled) {
189
+ this._trackMetrics = enabled;
190
+ }
139
191
  }
140
192
 
141
193
  export default LRUCache;
package/runtime/pulse.js CHANGED
@@ -69,6 +69,14 @@ const log = loggers.pulse;
69
69
  * @property {Map<string, Set<EffectFn>>} effectRegistry - Module ID to effects mapping for HMR
70
70
  */
71
71
 
72
+ /**
73
+ * Maximum number of effect re-run iterations before aborting.
74
+ * Prevents infinite loops when effects trigger each other cyclically.
75
+ * Set to 100 to allow deep chain reactions while catching most real loops.
76
+ * @type {number}
77
+ */
78
+ const MAX_EFFECT_ITERATIONS = 100;
79
+
72
80
  /**
73
81
  * Global reactive context - holds all tracking state.
74
82
  * Exported for testing purposes (use resetContext() to reset).
@@ -515,10 +523,9 @@ function flushEffects() {
515
523
 
516
524
  context.isRunningEffects = true;
517
525
  let iterations = 0;
518
- const maxIterations = 100; // Prevent infinite loops
519
526
 
520
527
  try {
521
- while (context.pendingEffects.size > 0 && iterations < maxIterations) {
528
+ while (context.pendingEffects.size > 0 && iterations < MAX_EFFECT_ITERATIONS) {
522
529
  iterations++;
523
530
  const effects = [...context.pendingEffects];
524
531
  context.pendingEffects.clear();
@@ -528,8 +535,8 @@ function flushEffects() {
528
535
  }
529
536
  }
530
537
 
531
- if (iterations >= maxIterations) {
532
- log.warn('Maximum effect iterations reached. Possible infinite loop.');
538
+ if (iterations >= MAX_EFFECT_ITERATIONS) {
539
+ log.warn(`Maximum effect iterations (${MAX_EFFECT_ITERATIONS}) reached. Possible infinite loop.`);
533
540
  context.pendingEffects.clear();
534
541
  }
535
542
  } finally {
@@ -586,6 +593,8 @@ export function computed(fn, options = {}) {
586
593
 
587
594
  // Track which pulses this depends on
588
595
  let trackedDeps = new Set();
596
+ // Track subscription cleanup functions to prevent memory leaks
597
+ let subscriptionCleanups = [];
589
598
 
590
599
  p.get = function() {
591
600
  if (dirty) {
@@ -603,18 +612,21 @@ export function computed(fn, options = {}) {
603
612
  dirty = false;
604
613
 
605
614
  // Cleanup old subscriptions
606
- for (const dep of trackedDeps) {
607
- dep._unsubscribe(markDirty);
615
+ for (const unsubscribe of subscriptionCleanups) {
616
+ unsubscribe();
608
617
  }
618
+ subscriptionCleanups = [];
619
+ trackedDeps.clear();
609
620
 
610
621
  // Set up new subscriptions
611
622
  trackedDeps = tempEffect.dependencies;
612
623
  for (const dep of trackedDeps) {
613
- dep.subscribe(() => {
624
+ const unsubscribe = dep.subscribe(() => {
614
625
  dirty = true;
615
626
  // Notify our own subscribers
616
627
  p._triggerNotify();
617
628
  });
629
+ subscriptionCleanups.push(unsubscribe);
618
630
  }
619
631
 
620
632
  p._init(cachedValue);
@@ -632,7 +644,14 @@ export function computed(fn, options = {}) {
632
644
  return cachedValue;
633
645
  };
634
646
 
635
- const markDirty = { run: () => { dirty = true; }, dependencies: new Set(), cleanups: [] };
647
+ // Cleanup function for lazy computed
648
+ cleanup = () => {
649
+ for (const unsubscribe of subscriptionCleanups) {
650
+ unsubscribe();
651
+ }
652
+ subscriptionCleanups = [];
653
+ trackedDeps.clear();
654
+ };
636
655
  } else {
637
656
  // Eager computed - updates immediately when dependencies change
638
657
  cleanup = effect(() => {
package/runtime/router.js CHANGED
@@ -225,8 +225,14 @@ function createMiddlewareRunner(middlewares) {
225
225
  if (aborted || redirectPath) return;
226
226
  if (index >= middlewares.length) return;
227
227
 
228
+ const middlewareIndex = index;
228
229
  const middleware = middlewares[index++];
229
- await middleware(ctx, next);
230
+ try {
231
+ await middleware(ctx, next);
232
+ } catch (error) {
233
+ log.error(`Middleware error at index ${middlewareIndex}:`, error);
234
+ throw error; // Re-throw to halt navigation
235
+ }
230
236
  }
231
237
 
232
238
  await next();
@@ -417,23 +423,58 @@ function matchRoute(pattern, path) {
417
423
  return params;
418
424
  }
419
425
 
426
+ // Query string validation limits
427
+ const QUERY_LIMITS = {
428
+ maxTotalLength: 2048, // 2KB max for entire query string
429
+ maxValueLength: 1024, // 1KB max per individual value
430
+ maxParams: 50 // Maximum number of query parameters
431
+ };
432
+
420
433
  /**
421
- * Parse query string into object
434
+ * Parse query string into object with validation
435
+ * @param {string} search - Query string (with or without leading ?)
436
+ * @returns {Object} Parsed query parameters
422
437
  */
423
438
  function parseQuery(search) {
424
- const params = new URLSearchParams(search);
439
+ if (!search) return {};
440
+
441
+ // Remove leading ? if present
442
+ const queryStr = search.startsWith('?') ? search.slice(1) : search;
443
+
444
+ // Validate total length
445
+ if (queryStr.length > QUERY_LIMITS.maxTotalLength) {
446
+ log.warn(`Query string exceeds maximum length (${QUERY_LIMITS.maxTotalLength} chars). Truncating.`);
447
+ }
448
+
449
+ const params = new URLSearchParams(queryStr.slice(0, QUERY_LIMITS.maxTotalLength));
425
450
  const query = {};
451
+ let paramCount = 0;
452
+
426
453
  for (const [key, value] of params) {
454
+ // Check parameter count limit
455
+ if (paramCount >= QUERY_LIMITS.maxParams) {
456
+ log.warn(`Query string exceeds maximum parameters (${QUERY_LIMITS.maxParams}). Ignoring excess.`);
457
+ break;
458
+ }
459
+
460
+ // Validate and potentially truncate value length
461
+ let safeValue = value;
462
+ if (value.length > QUERY_LIMITS.maxValueLength) {
463
+ log.warn(`Query parameter "${key}" exceeds maximum length. Truncating.`);
464
+ safeValue = value.slice(0, QUERY_LIMITS.maxValueLength);
465
+ }
466
+
427
467
  if (key in query) {
428
468
  // Multiple values for same key
429
469
  if (Array.isArray(query[key])) {
430
- query[key].push(value);
470
+ query[key].push(safeValue);
431
471
  } else {
432
- query[key] = [query[key], value];
472
+ query[key] = [query[key], safeValue];
433
473
  }
434
474
  } else {
435
- query[key] = value;
475
+ query[key] = safeValue;
436
476
  }
477
+ paramCount++;
437
478
  }
438
479
  return query;
439
480
  }
@@ -460,6 +501,10 @@ export function createRouter(options = {}) {
460
501
  const currentQuery = pulse({});
461
502
  const currentMeta = pulse({});
462
503
  const isLoading = pulse(false);
504
+ const routeError = pulse(null);
505
+
506
+ // Route error handler (configurable)
507
+ let onRouteError = options.onRouteError || null;
463
508
 
464
509
  // Scroll positions for history
465
510
  const scrollPositions = new Map();
@@ -793,25 +838,65 @@ export function createRouter(options = {}) {
793
838
  router
794
839
  };
795
840
 
796
- // Call handler and render result
797
- const result = typeof route.handler === 'function'
798
- ? route.handler(ctx)
799
- : route.handler;
841
+ // Helper to handle errors
842
+ const handleError = (error) => {
843
+ routeError.set(error);
844
+ log.error('Route component error:', error);
845
+
846
+ if (onRouteError) {
847
+ try {
848
+ const errorView = onRouteError(error, ctx);
849
+ if (errorView instanceof Node) {
850
+ container.replaceChildren(errorView);
851
+ currentView = errorView;
852
+ return true;
853
+ }
854
+ } catch (handlerError) {
855
+ log.error('Route error handler threw:', handlerError);
856
+ }
857
+ }
858
+
859
+ const errorEl = el('div.route-error', [
860
+ el('h2', 'Route Error'),
861
+ el('p', error.message || 'Failed to load route component')
862
+ ]);
863
+ container.replaceChildren(errorEl);
864
+ currentView = errorEl;
865
+ return true;
866
+ };
867
+
868
+ // Call handler and render result (with error handling)
869
+ let result;
870
+ try {
871
+ result = typeof route.handler === 'function'
872
+ ? route.handler(ctx)
873
+ : route.handler;
874
+ } catch (error) {
875
+ handleError(error);
876
+ return;
877
+ }
800
878
 
801
879
  if (result instanceof Node) {
802
880
  container.appendChild(result);
803
881
  currentView = result;
882
+ routeError.set(null);
804
883
  } else if (result && typeof result.then === 'function') {
805
884
  // Async component
806
885
  isLoading.set(true);
807
- result.then(component => {
808
- isLoading.set(false);
809
- const view = typeof component === 'function' ? component(ctx) : component;
810
- if (view instanceof Node) {
811
- container.appendChild(view);
812
- currentView = view;
813
- }
814
- });
886
+ routeError.set(null);
887
+ result
888
+ .then(component => {
889
+ isLoading.set(false);
890
+ const view = typeof component === 'function' ? component(ctx) : component;
891
+ if (view instanceof Node) {
892
+ container.appendChild(view);
893
+ currentView = view;
894
+ }
895
+ })
896
+ .catch(error => {
897
+ isLoading.set(false);
898
+ handleError(error);
899
+ });
815
900
  }
816
901
  }
817
902
  });
@@ -868,6 +953,17 @@ export function createRouter(options = {}) {
868
953
  /**
869
954
  * Check if a route matches the given path
870
955
  */
956
+ /**
957
+ * Check if a path matches the current route
958
+ * @param {string} path - Path to check
959
+ * @param {boolean} [exact=false] - If true, requires exact match; if false, matches prefixes
960
+ * @returns {boolean} True if path is active
961
+ * @example
962
+ * // Current path: /users/123
963
+ * router.isActive('/users'); // true (prefix match)
964
+ * router.isActive('/users', true); // false (not exact)
965
+ * router.isActive('/users/123', true); // true (exact match)
966
+ */
871
967
  function isActive(path, exact = false) {
872
968
  const current = currentPath.get();
873
969
  if (exact) {
@@ -877,7 +973,12 @@ export function createRouter(options = {}) {
877
973
  }
878
974
 
879
975
  /**
880
- * Get all matched routes (for nested routes)
976
+ * Get all routes that match a given path (useful for nested routes)
977
+ * @param {string} path - Path to match against routes
978
+ * @returns {Array<{route: Object, params: Object}>} Array of matched routes with extracted params
979
+ * @example
980
+ * const matches = router.getMatchedRoutes('/admin/users/123');
981
+ * // Returns: [{route: adminRoute, params: {}}, {route: userRoute, params: {id: '123'}}]
881
982
  */
882
983
  function getMatchedRoutes(path) {
883
984
  const matches = [];
@@ -891,53 +992,107 @@ export function createRouter(options = {}) {
891
992
  }
892
993
 
893
994
  /**
894
- * Go back in history
995
+ * Navigate back in browser history
996
+ * Equivalent to browser back button
997
+ * @returns {void}
998
+ * @example
999
+ * router.back(); // Go to previous page
895
1000
  */
896
1001
  function back() {
897
1002
  window.history.back();
898
1003
  }
899
1004
 
900
1005
  /**
901
- * Go forward in history
1006
+ * Navigate forward in browser history
1007
+ * Equivalent to browser forward button
1008
+ * @returns {void}
1009
+ * @example
1010
+ * router.forward(); // Go to next page (if available)
902
1011
  */
903
1012
  function forward() {
904
1013
  window.history.forward();
905
1014
  }
906
1015
 
907
1016
  /**
908
- * Go to specific history entry
1017
+ * Navigate to a specific position in browser history
1018
+ * @param {number} delta - Number of entries to move (negative = back, positive = forward)
1019
+ * @returns {void}
1020
+ * @example
1021
+ * router.go(-2); // Go back 2 pages
1022
+ * router.go(1); // Go forward 1 page
909
1023
  */
910
1024
  function go(delta) {
911
1025
  window.history.go(delta);
912
1026
  }
913
1027
 
1028
+ /**
1029
+ * Set route error handler
1030
+ * @param {function} handler - Error handler (error, ctx) => Node
1031
+ * @returns {function} Previous handler
1032
+ */
1033
+ function setErrorHandler(handler) {
1034
+ const prev = onRouteError;
1035
+ onRouteError = handler;
1036
+ return prev;
1037
+ }
1038
+
1039
+ /**
1040
+ * Router instance with reactive state and navigation methods.
1041
+ *
1042
+ * Reactive properties (use .get() to read value, auto-updates in effects):
1043
+ * - path: Current URL path as string
1044
+ * - route: Current matched route object or null
1045
+ * - params: Route params object, e.g., {id: '123'}
1046
+ * - query: Query params object, e.g., {page: '1'}
1047
+ * - meta: Route meta data object
1048
+ * - loading: Boolean indicating async route loading
1049
+ * - error: Current route error or null
1050
+ *
1051
+ * @example
1052
+ * // Read reactive state
1053
+ * router.path.get(); // '/users/123'
1054
+ * router.params.get(); // {id: '123'}
1055
+ *
1056
+ * // Subscribe to changes
1057
+ * effect(() => {
1058
+ * console.log('Path changed:', router.path.get());
1059
+ * });
1060
+ *
1061
+ * // Navigate
1062
+ * router.navigate('/users/456');
1063
+ * router.back();
1064
+ */
914
1065
  const router = {
915
- // Reactive state (read-only)
1066
+ // Reactive state (read-only) - use .get() to read, subscribe with effects
916
1067
  path: currentPath,
917
1068
  route: currentRoute,
918
1069
  params: currentParams,
919
1070
  query: currentQuery,
920
1071
  meta: currentMeta,
921
1072
  loading: isLoading,
1073
+ error: routeError,
922
1074
 
923
- // Methods
1075
+ // Navigation methods
924
1076
  navigate,
925
1077
  start,
926
1078
  link,
927
1079
  outlet,
1080
+ back,
1081
+ forward,
1082
+ go,
1083
+
1084
+ // Guards and middleware
928
1085
  use,
929
1086
  beforeEach,
930
1087
  beforeResolve,
931
1088
  afterEach,
932
- back,
933
- forward,
934
- go,
1089
+ setErrorHandler,
935
1090
 
936
1091
  // Route inspection
937
1092
  isActive,
938
1093
  getMatchedRoutes,
939
1094
 
940
- // Utils
1095
+ // Utility functions
941
1096
  matchRoute,
942
1097
  parseQuery
943
1098
  };
package/runtime/store.js CHANGED
@@ -58,6 +58,56 @@ const MAX_NESTING_DEPTH = 10;
58
58
  */
59
59
  const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
60
60
 
61
+ /**
62
+ * Invalid value types that cannot be stored in state
63
+ * @type {Set<string>}
64
+ */
65
+ const INVALID_TYPES = new Set(['function', 'symbol']);
66
+
67
+ /**
68
+ * Validate state values, rejecting functions, symbols, and circular references
69
+ * @private
70
+ * @param {*} value - Value to validate
71
+ * @param {string} path - Current path for error messages
72
+ * @param {WeakSet} seen - Set of objects already visited (for circular detection)
73
+ * @throws {TypeError} If value contains invalid types or circular references
74
+ */
75
+ function validateStateValue(value, path = 'state', seen = new WeakSet()) {
76
+ const valueType = typeof value;
77
+
78
+ // Check for invalid types
79
+ if (INVALID_TYPES.has(valueType)) {
80
+ throw new TypeError(
81
+ `Invalid state value at "${path}": ${valueType}s cannot be stored in state. ` +
82
+ `State values must be primitives, arrays, or plain objects.`
83
+ );
84
+ }
85
+
86
+ // Check objects for circular references and nested invalid types
87
+ if (value !== null && valueType === 'object') {
88
+ // Check for circular reference
89
+ if (seen.has(value)) {
90
+ throw new TypeError(
91
+ `Circular reference detected at "${path}". ` +
92
+ `State must not contain circular references.`
93
+ );
94
+ }
95
+ seen.add(value);
96
+
97
+ // Validate array elements
98
+ if (Array.isArray(value)) {
99
+ for (let i = 0; i < value.length; i++) {
100
+ validateStateValue(value[i], `${path}[${i}]`, seen);
101
+ }
102
+ } else {
103
+ // Validate object properties
104
+ for (const [key, val] of Object.entries(value)) {
105
+ validateStateValue(val, `${path}.${key}`, seen);
106
+ }
107
+ }
108
+ }
109
+ }
110
+
61
111
  /**
62
112
  * Safely deserialize persisted state, preventing prototype pollution
63
113
  * and property injection attacks.
@@ -122,6 +172,9 @@ function safeDeserialize(savedState, schema) {
122
172
  export function createStore(initialState = {}, options = {}) {
123
173
  const { persist = false, storageKey = 'pulse-store' } = options;
124
174
 
175
+ // Validate initial state
176
+ validateStateValue(initialState, 'initialState');
177
+
125
178
  // Load persisted state if enabled
126
179
  let state = initialState;
127
180
  if (persist && typeof localStorage !== 'undefined') {
package/runtime/utils.js CHANGED
@@ -22,10 +22,10 @@ const HTML_ESCAPES = {
22
22
  };
23
23
 
24
24
  /**
25
- * Regex for HTML special characters
25
+ * Regex for HTML special characters (auto-generated from HTML_ESCAPES keys)
26
26
  * @private
27
27
  */
28
- const HTML_ESCAPE_REGEX = /[&<>"']/g;
28
+ const HTML_ESCAPE_REGEX = new RegExp(`[${Object.keys(HTML_ESCAPES).join('')}]`, 'g');
29
29
 
30
30
  /**
31
31
  * Escape HTML special characters to prevent XSS attacks.