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.
- package/cli/build.js +34 -1
- package/cli/dev.js +54 -3
- package/cli/index.js +20 -5
- package/compiler/preprocessor.js +819 -0
- package/loader/vite-plugin.js +57 -12
- package/package.json +14 -2
- package/runtime/async.js +14 -26
- package/runtime/devtools/diagnostics.js +51 -3
- package/runtime/devtools.js +58 -1
- package/runtime/errors.js +159 -0
- package/runtime/graphql.js +83 -113
- package/runtime/http.js +18 -100
- package/runtime/interceptor-manager.js +242 -0
- package/runtime/router.js +80 -15
- package/runtime/utils.js +121 -5
- package/runtime/websocket.js +62 -73
|
@@ -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
|
-
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
el
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|