pulse-js-framework 1.7.30 → 1.7.32

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.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Pulse Framework - Interceptor Manager
3
+ *
4
+ * Shared interceptor management for HTTP, WebSocket, and GraphQL clients.
5
+ * Provides a generic pattern for request/response/message interception.
6
+ *
7
+ * @module runtime/interceptor-manager
8
+ */
9
+
10
+ // ============================================================================
11
+ // Interceptor Manager
12
+ // ============================================================================
13
+
14
+ /**
15
+ * Generic interceptor manager for request/response pipelines.
16
+ * Used by HTTP client, WebSocket client, and GraphQL client.
17
+ *
18
+ * @example
19
+ * // HTTP-style interceptors (fulfilled/rejected)
20
+ * const requestInterceptors = new InterceptorManager();
21
+ * const id = requestInterceptors.use(
22
+ * (config) => ({ ...config, timestamp: Date.now() }),
23
+ * (error) => Promise.reject(error)
24
+ * );
25
+ *
26
+ * // Process through interceptor chain
27
+ * for (const { fulfilled, rejected } of requestInterceptors) {
28
+ * config = await fulfilled(config);
29
+ * }
30
+ *
31
+ * @example
32
+ * // WebSocket-style interceptors (onMessage/onError)
33
+ * const messageInterceptors = new InterceptorManager({
34
+ * handlerKeys: ['onMessage', 'onError']
35
+ * });
36
+ * messageInterceptors.use(
37
+ * (data) => ({ ...data, received: Date.now() }),
38
+ * (err) => console.error('Parse error:', err)
39
+ * );
40
+ */
41
+ class InterceptorManager {
42
+ #handlers = new Map();
43
+ #idCounter = 0;
44
+ #primaryKey;
45
+ #secondaryKey;
46
+
47
+ /**
48
+ * Create an interceptor manager
49
+ * @param {Object} [options] - Configuration options
50
+ * @param {string[]} [options.handlerKeys=['fulfilled', 'rejected']] - Property names for handlers
51
+ */
52
+ constructor(options = {}) {
53
+ const keys = options.handlerKeys || ['fulfilled', 'rejected'];
54
+ this.#primaryKey = keys[0];
55
+ this.#secondaryKey = keys[1] || null;
56
+ }
57
+
58
+ /**
59
+ * Add an interceptor to the chain
60
+ * @param {Function} primary - Primary handler (success/transform function)
61
+ * @param {Function} [secondary] - Secondary handler (error/fallback function)
62
+ * @returns {number} Interceptor ID for later removal
63
+ */
64
+ use(primary, secondary) {
65
+ const id = this.#idCounter++;
66
+ const handler = { [this.#primaryKey]: primary };
67
+ if (this.#secondaryKey) {
68
+ handler[this.#secondaryKey] = secondary;
69
+ }
70
+ this.#handlers.set(id, handler);
71
+ return id;
72
+ }
73
+
74
+ /**
75
+ * Remove an interceptor by ID
76
+ * @param {number} id - The interceptor ID returned from use()
77
+ * @returns {boolean} True if the interceptor was removed
78
+ */
79
+ eject(id) {
80
+ return this.#handlers.delete(id);
81
+ }
82
+
83
+ /**
84
+ * Remove all interceptors
85
+ */
86
+ clear() {
87
+ this.#handlers.clear();
88
+ }
89
+
90
+ /**
91
+ * Get the number of registered interceptors
92
+ * @returns {number}
93
+ */
94
+ get size() {
95
+ return this.#handlers.size;
96
+ }
97
+
98
+ /**
99
+ * Check if manager has any interceptors
100
+ * @returns {boolean}
101
+ */
102
+ get isEmpty() {
103
+ return this.#handlers.size === 0;
104
+ }
105
+
106
+ /**
107
+ * Get all handler IDs
108
+ * @returns {number[]}
109
+ */
110
+ get ids() {
111
+ return [...this.#handlers.keys()];
112
+ }
113
+
114
+ /**
115
+ * Iterate through all handlers
116
+ * @yields {Object} Handler object with primary and secondary functions
117
+ */
118
+ *[Symbol.iterator]() {
119
+ for (const handler of this.#handlers.values()) {
120
+ yield handler;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Get handlers as array (for pipeline processing)
126
+ * @returns {Object[]}
127
+ */
128
+ toArray() {
129
+ return [...this.#handlers.values()];
130
+ }
131
+
132
+ /**
133
+ * Run a value through all interceptors (async pipeline)
134
+ * Executes each interceptor's primary handler in sequence.
135
+ * If any handler throws, the secondary handler is called if available.
136
+ *
137
+ * @param {*} value - Initial value to process
138
+ * @returns {Promise<*>} Processed value after all interceptors
139
+ */
140
+ async run(value) {
141
+ let result = value;
142
+ for (const handler of this.#handlers.values()) {
143
+ try {
144
+ const fn = handler[this.#primaryKey];
145
+ if (typeof fn === 'function') {
146
+ result = await fn(result);
147
+ }
148
+ } catch (error) {
149
+ const errorFn = handler[this.#secondaryKey];
150
+ if (typeof errorFn === 'function') {
151
+ result = await errorFn(error);
152
+ } else {
153
+ throw error;
154
+ }
155
+ }
156
+ }
157
+ return result;
158
+ }
159
+
160
+ /**
161
+ * Run a value through all interceptors (sync pipeline)
162
+ * @param {*} value - Initial value to process
163
+ * @returns {*} Processed value after all interceptors
164
+ */
165
+ runSync(value) {
166
+ let result = value;
167
+ for (const handler of this.#handlers.values()) {
168
+ try {
169
+ const fn = handler[this.#primaryKey];
170
+ if (typeof fn === 'function') {
171
+ result = fn(result);
172
+ }
173
+ } catch (error) {
174
+ const errorFn = handler[this.#secondaryKey];
175
+ if (typeof errorFn === 'function') {
176
+ result = errorFn(error);
177
+ } else {
178
+ throw error;
179
+ }
180
+ }
181
+ }
182
+ return result;
183
+ }
184
+ }
185
+
186
+ // ============================================================================
187
+ // Specialized Interceptor Managers
188
+ // ============================================================================
189
+
190
+ /**
191
+ * Message interceptor manager pre-configured for WebSocket-style interception.
192
+ * Uses 'onMessage' and 'onError' as handler property names.
193
+ *
194
+ * @example
195
+ * const manager = new MessageInterceptorManager();
196
+ * manager.use(
197
+ * (data) => ({ ...data, timestamp: Date.now() }),
198
+ * (err) => console.error('Error:', err)
199
+ * );
200
+ *
201
+ * for (const { onMessage, onError } of manager) {
202
+ * data = onMessage(data);
203
+ * }
204
+ */
205
+ class MessageInterceptorManager extends InterceptorManager {
206
+ constructor() {
207
+ super({ handlerKeys: ['onMessage', 'onError'] });
208
+ }
209
+ }
210
+
211
+ // ============================================================================
212
+ // Factory Functions
213
+ // ============================================================================
214
+
215
+ /**
216
+ * Create an interceptor manager for HTTP-style request/response interception
217
+ * @returns {InterceptorManager}
218
+ */
219
+ function createRequestInterceptors() {
220
+ return new InterceptorManager({ handlerKeys: ['fulfilled', 'rejected'] });
221
+ }
222
+
223
+ /**
224
+ * Create an interceptor manager for WebSocket-style message interception
225
+ * @returns {InterceptorManager}
226
+ */
227
+ function createMessageInterceptors() {
228
+ return new InterceptorManager({ handlerKeys: ['onMessage', 'onError'] });
229
+ }
230
+
231
+ // ============================================================================
232
+ // Exports
233
+ // ============================================================================
234
+
235
+ export {
236
+ InterceptorManager,
237
+ MessageInterceptorManager,
238
+ createRequestInterceptors,
239
+ createMessageInterceptors
240
+ };
241
+
242
+ export default InterceptorManager;
package/runtime/router.js CHANGED
@@ -18,6 +18,7 @@ import { el } from './dom.js';
18
18
  import { loggers } from './logger.js';
19
19
  import { createVersionedAsync } from './async.js';
20
20
  import { Errors } from './errors.js';
21
+ import { LRUCache } from './lru-cache.js';
21
22
 
22
23
  const log = loggers.router;
23
24
 
@@ -516,7 +517,9 @@ export function createRouter(options = {}) {
516
517
  mode = 'history', // 'history' or 'hash'
517
518
  base = '',
518
519
  scrollBehavior = null, // Function to control scroll restoration
519
- middleware: initialMiddleware = [] // Middleware functions
520
+ middleware: initialMiddleware = [], // Middleware functions
521
+ persistScroll = false, // Persist scroll positions to sessionStorage
522
+ persistScrollKey = 'pulse-router-scroll' // Storage key for scroll persistence
520
523
  } = options;
521
524
 
522
525
  // Middleware array (mutable for dynamic registration)
@@ -534,8 +537,47 @@ export function createRouter(options = {}) {
534
537
  // Route error handler (configurable)
535
538
  let onRouteError = options.onRouteError || null;
536
539
 
537
- // Scroll positions for history
538
- const scrollPositions = new Map();
540
+ // Scroll positions for history (LRU cache to prevent memory leaks)
541
+ // Keeps last 100 scroll positions - enough for typical navigation patterns
542
+ const scrollPositions = new LRUCache(100);
543
+
544
+ // Restore scroll positions from sessionStorage if persistence is enabled
545
+ if (persistScroll && typeof sessionStorage !== 'undefined') {
546
+ try {
547
+ const stored = sessionStorage.getItem(persistScrollKey);
548
+ if (stored) {
549
+ const parsed = JSON.parse(stored);
550
+ // Restore up to 100 most recent positions
551
+ const entries = Object.entries(parsed).slice(-100);
552
+ for (const [path, pos] of entries) {
553
+ if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
554
+ scrollPositions.set(path, pos);
555
+ }
556
+ }
557
+ log.debug(`Restored ${entries.length} scroll positions from sessionStorage`);
558
+ }
559
+ } catch (err) {
560
+ log.warn('Failed to restore scroll positions from sessionStorage:', err.message);
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Persist scroll positions to sessionStorage
566
+ */
567
+ function persistScrollPositions() {
568
+ if (!persistScroll || typeof sessionStorage === 'undefined') return;
569
+
570
+ try {
571
+ const data = {};
572
+ for (const [path, pos] of scrollPositions.entries()) {
573
+ data[path] = pos;
574
+ }
575
+ sessionStorage.setItem(persistScrollKey, JSON.stringify(data));
576
+ } catch (err) {
577
+ // SessionStorage may be full or disabled
578
+ log.warn('Failed to persist scroll positions:', err.message);
579
+ }
580
+ }
539
581
 
540
582
  // Route trie for O(path length) lookups
541
583
  const routeTrie = new RouteTrie();
@@ -694,6 +736,7 @@ export function createRouter(options = {}) {
694
736
  x: window.scrollX,
695
737
  y: window.scrollY
696
738
  });
739
+ persistScrollPositions();
697
740
  }
698
741
 
699
742
  // Update URL
@@ -720,25 +763,47 @@ export function createRouter(options = {}) {
720
763
  */
721
764
  function handleScroll(to, from, savedPosition) {
722
765
  if (scrollBehavior) {
723
- const position = scrollBehavior(to, from, savedPosition);
724
- if (position) {
725
- if (position.selector) {
766
+ let position;
767
+ try {
768
+ position = scrollBehavior(to, from, savedPosition);
769
+ } catch (err) {
770
+ loggers.router.warn(`scrollBehavior threw an error: ${err.message}`);
771
+ // Fall back to default behavior
772
+ window.scrollTo(0, 0);
773
+ return;
774
+ }
775
+
776
+ // Validate position is a valid object
777
+ if (position && typeof position === 'object') {
778
+ if (typeof position.selector === 'string' && position.selector) {
726
779
  // Scroll to element
727
- const el = document.querySelector(position.selector);
728
- if (el) {
729
- el.scrollIntoView({ behavior: position.behavior || 'auto' });
780
+ try {
781
+ const el = document.querySelector(position.selector);
782
+ if (el) {
783
+ const behavior = position.behavior === 'smooth' || position.behavior === 'auto'
784
+ ? position.behavior
785
+ : 'auto';
786
+ el.scrollIntoView({ behavior });
787
+ }
788
+ } catch (err) {
789
+ loggers.router.warn(`Invalid selector in scrollBehavior: ${position.selector}`);
730
790
  }
731
791
  } else if (typeof position.x === 'number' || typeof position.y === 'number') {
732
- window.scrollTo({
733
- left: position.x || 0,
734
- top: position.y || 0,
735
- behavior: position.behavior || 'auto'
736
- });
792
+ const x = typeof position.x === 'number' && isFinite(position.x) ? position.x : 0;
793
+ const y = typeof position.y === 'number' && isFinite(position.y) ? position.y : 0;
794
+ const behavior = position.behavior === 'smooth' || position.behavior === 'auto'
795
+ ? position.behavior
796
+ : 'auto';
797
+ window.scrollTo({ left: x, top: y, behavior });
737
798
  }
799
+ // If position is object but no valid selector/x/y, do nothing (intentional no-scroll)
738
800
  }
801
+ // If position is falsy (null/undefined/false), do nothing (intentional no-scroll)
739
802
  } else if (savedPosition) {
740
803
  // Default: restore saved position
741
- window.scrollTo(savedPosition.x, savedPosition.y);
804
+ const x = typeof savedPosition.x === 'number' && isFinite(savedPosition.x) ? savedPosition.x : 0;
805
+ const y = typeof savedPosition.y === 'number' && isFinite(savedPosition.y) ? savedPosition.y : 0;
806
+ window.scrollTo(x, y);
742
807
  } else {
743
808
  // Default: scroll to top
744
809
  window.scrollTo(0, 0);
package/runtime/utils.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { createLogger } from './logger.js';
9
+ import { sanitizeHtml } from './security.js';
9
10
 
10
11
  const log = createLogger('Security');
11
12
 
@@ -98,10 +99,7 @@ export function dangerouslySetInnerHTML(element, html, options = {}) {
98
99
  const { sanitize = false, ...sanitizeOptions } = options;
99
100
 
100
101
  if (sanitize) {
101
- // Import sanitizeHtml from security module
102
- import('./security.js').then(({ sanitizeHtml }) => {
103
- element.innerHTML = sanitizeHtml(html, sanitizeOptions);
104
- });
102
+ element.innerHTML = sanitizeHtml(html, sanitizeOptions);
105
103
  } else {
106
104
  element.innerHTML = html;
107
105
  }
@@ -610,6 +608,117 @@ export function throttle(fn, interval) {
610
608
  return throttled;
611
609
  }
612
610
 
611
+ // ============================================================================
612
+ // Window Event Helpers
613
+ // ============================================================================
614
+
615
+ /**
616
+ * Check if running in a browser environment with window object
617
+ * @returns {boolean}
618
+ */
619
+ export function isBrowser() {
620
+ return typeof window !== 'undefined';
621
+ }
622
+
623
+ /**
624
+ * Add a window event listener with automatic cleanup via onCleanup.
625
+ * Safe to call in SSR - does nothing if window is not available.
626
+ *
627
+ * @param {string} event - Event name ('focus', 'online', 'offline', etc.)
628
+ * @param {Function} handler - Event handler function
629
+ * @param {Function} onCleanup - Cleanup registration function from pulse.js
630
+ * @param {Object} [options] - addEventListener options
631
+ * @returns {Function|null} Cleanup function, or null if not in browser
632
+ *
633
+ * @example
634
+ * // In an effect or hook
635
+ * import { onCleanup } from './pulse.js';
636
+ * import { onWindowEvent } from './utils.js';
637
+ *
638
+ * effect(() => {
639
+ * onWindowEvent('focus', () => refetch(), onCleanup);
640
+ * onWindowEvent('online', () => reconnect(), onCleanup);
641
+ * });
642
+ */
643
+ export function onWindowEvent(event, handler, onCleanup, options) {
644
+ if (!isBrowser()) return null;
645
+
646
+ window.addEventListener(event, handler, options);
647
+ const cleanup = () => window.removeEventListener(event, handler, options);
648
+
649
+ if (typeof onCleanup === 'function') {
650
+ onCleanup(cleanup);
651
+ }
652
+
653
+ return cleanup;
654
+ }
655
+
656
+ /**
657
+ * Add focus event listener for refetch-on-focus patterns.
658
+ * Common pattern in data fetching hooks.
659
+ *
660
+ * @param {Function} handler - Handler to call on window focus
661
+ * @param {Function} onCleanup - Cleanup registration function
662
+ * @returns {Function|null} Cleanup function
663
+ */
664
+ export function onWindowFocus(handler, onCleanup) {
665
+ return onWindowEvent('focus', handler, onCleanup);
666
+ }
667
+
668
+ /**
669
+ * Add online event listener for refetch-on-reconnect patterns.
670
+ * Common pattern in data fetching hooks.
671
+ *
672
+ * @param {Function} handler - Handler to call when going online
673
+ * @param {Function} onCleanup - Cleanup registration function
674
+ * @returns {Function|null} Cleanup function
675
+ */
676
+ export function onWindowOnline(handler, onCleanup) {
677
+ return onWindowEvent('online', handler, onCleanup);
678
+ }
679
+
680
+ /**
681
+ * Add offline event listener.
682
+ *
683
+ * @param {Function} handler - Handler to call when going offline
684
+ * @param {Function} onCleanup - Cleanup registration function
685
+ * @returns {Function|null} Cleanup function
686
+ */
687
+ export function onWindowOffline(handler, onCleanup) {
688
+ return onWindowEvent('offline', handler, onCleanup);
689
+ }
690
+
691
+ /**
692
+ * Setup both online and offline listeners at once.
693
+ * Useful for connection-aware features.
694
+ *
695
+ * @param {Object} handlers - Event handlers
696
+ * @param {Function} [handlers.onOnline] - Called when going online
697
+ * @param {Function} [handlers.onOffline] - Called when going offline
698
+ * @param {Function} onCleanup - Cleanup registration function
699
+ * @returns {Function|null} Combined cleanup function
700
+ */
701
+ export function onNetworkChange(handlers, onCleanup) {
702
+ if (!isBrowser()) return null;
703
+
704
+ const cleanups = [];
705
+
706
+ if (handlers.onOnline) {
707
+ cleanups.push(onWindowEvent('online', handlers.onOnline, null));
708
+ }
709
+ if (handlers.onOffline) {
710
+ cleanups.push(onWindowEvent('offline', handlers.onOffline, null));
711
+ }
712
+
713
+ const cleanup = () => cleanups.forEach(fn => fn?.());
714
+
715
+ if (typeof onCleanup === 'function') {
716
+ onCleanup(cleanup);
717
+ }
718
+
719
+ return cleanup;
720
+ }
721
+
613
722
  export default {
614
723
  // XSS Prevention
615
724
  escapeHtml,
@@ -626,5 +735,12 @@ export default {
626
735
  // Utilities
627
736
  deepClone,
628
737
  debounce,
629
- throttle
738
+ throttle,
739
+ // Window Event Helpers
740
+ isBrowser,
741
+ onWindowEvent,
742
+ onWindowFocus,
743
+ onWindowOnline,
744
+ onWindowOffline,
745
+ onNetworkChange
630
746
  };