pulse-js-framework 1.5.1 → 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/index.js +2 -0
- package/cli/release.js +245 -33
- package/package.json +1 -1
- package/runtime/dom.js +76 -26
- package/runtime/logger.js +12 -4
- package/runtime/lru-cache.js +53 -1
- package/runtime/pulse.js +158 -13
- package/runtime/router.js +183 -28
- package/runtime/store.js +128 -3
- package/runtime/utils.js +2 -2
package/runtime/lru-cache.js
CHANGED
|
@@ -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).
|
|
@@ -102,6 +110,63 @@ export function resetContext() {
|
|
|
102
110
|
context.effectRegistry.clear();
|
|
103
111
|
}
|
|
104
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Counter for generating unique effect IDs
|
|
115
|
+
* @type {number}
|
|
116
|
+
*/
|
|
117
|
+
let effectIdCounter = 0;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Global effect error handler
|
|
121
|
+
* @type {Function|null}
|
|
122
|
+
*/
|
|
123
|
+
let globalEffectErrorHandler = null;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Custom error class for effect-related errors with context information.
|
|
127
|
+
* Provides details about which effect failed, in what phase, and its dependencies.
|
|
128
|
+
*/
|
|
129
|
+
export class EffectError extends Error {
|
|
130
|
+
/**
|
|
131
|
+
* Create an EffectError with context information
|
|
132
|
+
* @param {string} message - Error message
|
|
133
|
+
* @param {Object} options - Error context
|
|
134
|
+
* @param {string} [options.effectId] - Effect identifier
|
|
135
|
+
* @param {string} [options.phase] - Phase when error occurred ('cleanup' | 'execution')
|
|
136
|
+
* @param {number} [options.dependencyCount] - Number of dependencies
|
|
137
|
+
* @param {Error} [options.cause] - Original error that caused this
|
|
138
|
+
*/
|
|
139
|
+
constructor(message, options = {}) {
|
|
140
|
+
super(message);
|
|
141
|
+
this.name = 'EffectError';
|
|
142
|
+
this.effectId = options.effectId || null;
|
|
143
|
+
this.phase = options.phase || 'unknown';
|
|
144
|
+
this.dependencyCount = options.dependencyCount ?? 0;
|
|
145
|
+
this.cause = options.cause || null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Set a global error handler for effect errors.
|
|
151
|
+
* The handler receives an EffectError with full context about the failure.
|
|
152
|
+
* @param {Function|null} handler - Error handler (effectError) => void, or null to clear
|
|
153
|
+
* @returns {Function|null} Previous handler (for restoration)
|
|
154
|
+
* @example
|
|
155
|
+
* // Set up global error tracking
|
|
156
|
+
* const prevHandler = onEffectError((err) => {
|
|
157
|
+
* console.error(`Effect ${err.effectId} failed during ${err.phase}:`, err.cause);
|
|
158
|
+
* reportToErrorService(err);
|
|
159
|
+
* });
|
|
160
|
+
*
|
|
161
|
+
* // Later, restore previous handler
|
|
162
|
+
* onEffectError(prevHandler);
|
|
163
|
+
*/
|
|
164
|
+
export function onEffectError(handler) {
|
|
165
|
+
const prev = globalEffectErrorHandler;
|
|
166
|
+
globalEffectErrorHandler = handler;
|
|
167
|
+
return prev;
|
|
168
|
+
}
|
|
169
|
+
|
|
105
170
|
/**
|
|
106
171
|
* Set the current module ID for HMR effect tracking.
|
|
107
172
|
* Effects created while a module ID is set will be registered for cleanup.
|
|
@@ -386,6 +451,52 @@ export class Pulse {
|
|
|
386
451
|
}
|
|
387
452
|
}
|
|
388
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Handle an effect error with full context information.
|
|
456
|
+
* Tries effect-specific handler, then global handler, then logs.
|
|
457
|
+
* @private
|
|
458
|
+
* @param {Error} error - The original error
|
|
459
|
+
* @param {EffectFn} effectFn - The effect that errored
|
|
460
|
+
* @param {string} phase - Phase when error occurred ('cleanup' | 'execution')
|
|
461
|
+
*/
|
|
462
|
+
function handleEffectError(error, effectFn, phase) {
|
|
463
|
+
const effectError = new EffectError(
|
|
464
|
+
`Effect [${effectFn.id}] error during ${phase}: ${error.message}`,
|
|
465
|
+
{
|
|
466
|
+
effectId: effectFn.id,
|
|
467
|
+
phase,
|
|
468
|
+
dependencyCount: effectFn.dependencies?.size ?? 0,
|
|
469
|
+
cause: error
|
|
470
|
+
}
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
// Try effect-specific handler first
|
|
474
|
+
if (effectFn.onError) {
|
|
475
|
+
try {
|
|
476
|
+
effectFn.onError(effectError);
|
|
477
|
+
return;
|
|
478
|
+
} catch (handlerError) {
|
|
479
|
+
log.error('Effect onError handler threw:', handlerError);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Try global handler
|
|
484
|
+
if (globalEffectErrorHandler) {
|
|
485
|
+
try {
|
|
486
|
+
globalEffectErrorHandler(effectError);
|
|
487
|
+
return;
|
|
488
|
+
} catch (handlerError) {
|
|
489
|
+
log.error('Global effect error handler threw:', handlerError);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Default: log with context
|
|
494
|
+
log.error(`[${effectError.effectId}] ${effectError.message}`, {
|
|
495
|
+
phase: effectError.phase,
|
|
496
|
+
dependencies: effectError.dependencyCount
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
389
500
|
/**
|
|
390
501
|
* Run a single effect safely
|
|
391
502
|
* @private
|
|
@@ -398,7 +509,7 @@ function runEffect(effectFn) {
|
|
|
398
509
|
try {
|
|
399
510
|
effectFn.run();
|
|
400
511
|
} catch (error) {
|
|
401
|
-
|
|
512
|
+
handleEffectError(error, effectFn, 'execution');
|
|
402
513
|
}
|
|
403
514
|
}
|
|
404
515
|
|
|
@@ -412,10 +523,9 @@ function flushEffects() {
|
|
|
412
523
|
|
|
413
524
|
context.isRunningEffects = true;
|
|
414
525
|
let iterations = 0;
|
|
415
|
-
const maxIterations = 100; // Prevent infinite loops
|
|
416
526
|
|
|
417
527
|
try {
|
|
418
|
-
while (context.pendingEffects.size > 0 && iterations <
|
|
528
|
+
while (context.pendingEffects.size > 0 && iterations < MAX_EFFECT_ITERATIONS) {
|
|
419
529
|
iterations++;
|
|
420
530
|
const effects = [...context.pendingEffects];
|
|
421
531
|
context.pendingEffects.clear();
|
|
@@ -425,8 +535,8 @@ function flushEffects() {
|
|
|
425
535
|
}
|
|
426
536
|
}
|
|
427
537
|
|
|
428
|
-
if (iterations >=
|
|
429
|
-
log.warn(
|
|
538
|
+
if (iterations >= MAX_EFFECT_ITERATIONS) {
|
|
539
|
+
log.warn(`Maximum effect iterations (${MAX_EFFECT_ITERATIONS}) reached. Possible infinite loop.`);
|
|
430
540
|
context.pendingEffects.clear();
|
|
431
541
|
}
|
|
432
542
|
} finally {
|
|
@@ -483,6 +593,8 @@ export function computed(fn, options = {}) {
|
|
|
483
593
|
|
|
484
594
|
// Track which pulses this depends on
|
|
485
595
|
let trackedDeps = new Set();
|
|
596
|
+
// Track subscription cleanup functions to prevent memory leaks
|
|
597
|
+
let subscriptionCleanups = [];
|
|
486
598
|
|
|
487
599
|
p.get = function() {
|
|
488
600
|
if (dirty) {
|
|
@@ -500,18 +612,21 @@ export function computed(fn, options = {}) {
|
|
|
500
612
|
dirty = false;
|
|
501
613
|
|
|
502
614
|
// Cleanup old subscriptions
|
|
503
|
-
for (const
|
|
504
|
-
|
|
615
|
+
for (const unsubscribe of subscriptionCleanups) {
|
|
616
|
+
unsubscribe();
|
|
505
617
|
}
|
|
618
|
+
subscriptionCleanups = [];
|
|
619
|
+
trackedDeps.clear();
|
|
506
620
|
|
|
507
621
|
// Set up new subscriptions
|
|
508
622
|
trackedDeps = tempEffect.dependencies;
|
|
509
623
|
for (const dep of trackedDeps) {
|
|
510
|
-
dep.subscribe(() => {
|
|
624
|
+
const unsubscribe = dep.subscribe(() => {
|
|
511
625
|
dirty = true;
|
|
512
626
|
// Notify our own subscribers
|
|
513
627
|
p._triggerNotify();
|
|
514
628
|
});
|
|
629
|
+
subscriptionCleanups.push(unsubscribe);
|
|
515
630
|
}
|
|
516
631
|
|
|
517
632
|
p._init(cachedValue);
|
|
@@ -529,7 +644,14 @@ export function computed(fn, options = {}) {
|
|
|
529
644
|
return cachedValue;
|
|
530
645
|
};
|
|
531
646
|
|
|
532
|
-
|
|
647
|
+
// Cleanup function for lazy computed
|
|
648
|
+
cleanup = () => {
|
|
649
|
+
for (const unsubscribe of subscriptionCleanups) {
|
|
650
|
+
unsubscribe();
|
|
651
|
+
}
|
|
652
|
+
subscriptionCleanups = [];
|
|
653
|
+
trackedDeps.clear();
|
|
654
|
+
};
|
|
533
655
|
} else {
|
|
534
656
|
// Eager computed - updates immediately when dependencies change
|
|
535
657
|
cleanup = effect(() => {
|
|
@@ -568,9 +690,16 @@ export function computed(fn, options = {}) {
|
|
|
568
690
|
return p;
|
|
569
691
|
}
|
|
570
692
|
|
|
693
|
+
/**
|
|
694
|
+
* @typedef {Object} EffectOptions
|
|
695
|
+
* @property {string} [id] - Custom effect identifier for debugging
|
|
696
|
+
* @property {function(EffectError): void} [onError] - Error handler for this effect
|
|
697
|
+
*/
|
|
698
|
+
|
|
571
699
|
/**
|
|
572
700
|
* Create an effect that runs when its dependencies change
|
|
573
701
|
* @param {function(): void|function(): void} fn - Effect function, may return a cleanup function
|
|
702
|
+
* @param {EffectOptions} [options={}] - Effect configuration options
|
|
574
703
|
* @returns {function(): void} Dispose function to stop the effect
|
|
575
704
|
* @example
|
|
576
705
|
* const count = pulse(0);
|
|
@@ -588,19 +717,32 @@ export function computed(fn, options = {}) {
|
|
|
588
717
|
* const timer = setInterval(() => tick(), 1000);
|
|
589
718
|
* return () => clearInterval(timer); // Cleanup on re-run or dispose
|
|
590
719
|
* });
|
|
720
|
+
*
|
|
721
|
+
* // With custom ID and error handler
|
|
722
|
+
* effect(() => {
|
|
723
|
+
* // Effect logic that might fail
|
|
724
|
+
* }, {
|
|
725
|
+
* id: 'data-sync',
|
|
726
|
+
* onError: (err) => console.error('Data sync failed:', err.cause)
|
|
727
|
+
* });
|
|
591
728
|
*/
|
|
592
|
-
export function effect(fn) {
|
|
729
|
+
export function effect(fn, options = {}) {
|
|
730
|
+
const { id: customId, onError } = options;
|
|
731
|
+
const effectId = customId || `effect_${++effectIdCounter}`;
|
|
732
|
+
|
|
593
733
|
// Capture module ID at creation time for HMR tracking
|
|
594
734
|
const moduleId = context.currentModuleId;
|
|
595
735
|
|
|
596
736
|
const effectFn = {
|
|
737
|
+
id: effectId,
|
|
738
|
+
onError,
|
|
597
739
|
run: () => {
|
|
598
740
|
// Run cleanup functions from previous run
|
|
599
741
|
for (const cleanup of effectFn.cleanups) {
|
|
600
742
|
try {
|
|
601
743
|
cleanup();
|
|
602
744
|
} catch (e) {
|
|
603
|
-
|
|
745
|
+
handleEffectError(e, effectFn, 'cleanup');
|
|
604
746
|
}
|
|
605
747
|
}
|
|
606
748
|
effectFn.cleanups = [];
|
|
@@ -618,7 +760,7 @@ export function effect(fn) {
|
|
|
618
760
|
try {
|
|
619
761
|
fn();
|
|
620
762
|
} catch (error) {
|
|
621
|
-
|
|
763
|
+
handleEffectError(error, effectFn, 'execution');
|
|
622
764
|
} finally {
|
|
623
765
|
context.currentEffect = prevEffect;
|
|
624
766
|
}
|
|
@@ -645,7 +787,7 @@ export function effect(fn) {
|
|
|
645
787
|
try {
|
|
646
788
|
cleanup();
|
|
647
789
|
} catch (e) {
|
|
648
|
-
|
|
790
|
+
handleEffectError(e, effectFn, 'cleanup');
|
|
649
791
|
}
|
|
650
792
|
}
|
|
651
793
|
effectFn.cleanups = [];
|
|
@@ -994,6 +1136,9 @@ export default {
|
|
|
994
1136
|
memoComputed,
|
|
995
1137
|
context,
|
|
996
1138
|
resetContext,
|
|
1139
|
+
// Error handling
|
|
1140
|
+
EffectError,
|
|
1141
|
+
onEffectError,
|
|
997
1142
|
// HMR support
|
|
998
1143
|
setCurrentModule,
|
|
999
1144
|
clearCurrentModule,
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
470
|
+
query[key].push(safeValue);
|
|
431
471
|
} else {
|
|
432
|
-
query[key] = [query[key],
|
|
472
|
+
query[key] = [query[key], safeValue];
|
|
433
473
|
}
|
|
434
474
|
} else {
|
|
435
|
-
query[key] =
|
|
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
|
-
//
|
|
797
|
-
const
|
|
798
|
-
|
|
799
|
-
:
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
933
|
-
forward,
|
|
934
|
-
go,
|
|
1089
|
+
setErrorHandler,
|
|
935
1090
|
|
|
936
1091
|
// Route inspection
|
|
937
1092
|
isActive,
|
|
938
1093
|
getMatchedRoutes,
|
|
939
1094
|
|
|
940
|
-
//
|
|
1095
|
+
// Utility functions
|
|
941
1096
|
matchRoute,
|
|
942
1097
|
parseQuery
|
|
943
1098
|
};
|