pulse-js-framework 1.4.7 → 1.4.9

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/runtime/router.js CHANGED
@@ -10,11 +10,328 @@
10
10
  * - Per-route and global guards
11
11
  * - Scroll restoration
12
12
  * - Lazy-loaded routes
13
+ * - Middleware support
13
14
  */
14
15
 
15
16
  import { pulse, effect, batch } from './pulse.js';
16
17
  import { el } from './dom.js';
17
18
 
19
+ /**
20
+ * Lazy load helper for route components
21
+ * Wraps a dynamic import to provide loading states and error handling
22
+ *
23
+ * @param {function} importFn - Dynamic import function () => import('./Component.js')
24
+ * @param {Object} options - Lazy loading options
25
+ * @param {function} options.loading - Loading component function
26
+ * @param {function} options.error - Error component function
27
+ * @param {number} options.timeout - Timeout in ms (default: 10000)
28
+ * @param {number} options.delay - Delay before showing loading (default: 200)
29
+ * @returns {function} Lazy route handler
30
+ *
31
+ * @example
32
+ * const routes = {
33
+ * '/dashboard': lazy(() => import('./Dashboard.js')),
34
+ * '/settings': lazy(() => import('./Settings.js'), {
35
+ * loading: () => el('div.spinner', 'Loading...'),
36
+ * error: (err) => el('div.error', `Failed to load: ${err.message}`),
37
+ * timeout: 5000
38
+ * })
39
+ * };
40
+ */
41
+ export function lazy(importFn, options = {}) {
42
+ const {
43
+ loading: LoadingComponent = null,
44
+ error: ErrorComponent = null,
45
+ timeout = 10000,
46
+ delay = 200
47
+ } = options;
48
+
49
+ // Cache for loaded component
50
+ let cachedComponent = null;
51
+ let loadPromise = null;
52
+
53
+ return function lazyHandler(ctx) {
54
+ // Return cached component if already loaded
55
+ if (cachedComponent) {
56
+ return typeof cachedComponent === 'function'
57
+ ? cachedComponent(ctx)
58
+ : cachedComponent.default
59
+ ? cachedComponent.default(ctx)
60
+ : cachedComponent.render
61
+ ? cachedComponent.render(ctx)
62
+ : cachedComponent;
63
+ }
64
+
65
+ // Create container for async loading
66
+ const container = el('div.lazy-route');
67
+ let loadingTimer = null;
68
+ let timeoutTimer = null;
69
+
70
+ // Start loading if not already
71
+ if (!loadPromise) {
72
+ loadPromise = importFn();
73
+ }
74
+
75
+ // Delay showing loading state to avoid flash
76
+ if (LoadingComponent && delay > 0) {
77
+ loadingTimer = setTimeout(() => {
78
+ if (!cachedComponent) {
79
+ container.replaceChildren(LoadingComponent());
80
+ }
81
+ }, delay);
82
+ } else if (LoadingComponent) {
83
+ container.replaceChildren(LoadingComponent());
84
+ }
85
+
86
+ // Set timeout for loading
87
+ const timeoutPromise = timeout > 0
88
+ ? new Promise((_, reject) => {
89
+ timeoutTimer = setTimeout(() => {
90
+ reject(new Error(`Lazy load timeout after ${timeout}ms`));
91
+ }, timeout);
92
+ })
93
+ : null;
94
+
95
+ // Race between load and timeout
96
+ const loadWithTimeout = timeoutPromise
97
+ ? Promise.race([loadPromise, timeoutPromise])
98
+ : loadPromise;
99
+
100
+ loadWithTimeout
101
+ .then(module => {
102
+ clearTimeout(loadingTimer);
103
+ clearTimeout(timeoutTimer);
104
+
105
+ // Cache the component
106
+ cachedComponent = module;
107
+
108
+ // Get the component from module
109
+ const Component = module.default || module;
110
+ const result = typeof Component === 'function'
111
+ ? Component(ctx)
112
+ : Component.render
113
+ ? Component.render(ctx)
114
+ : Component;
115
+
116
+ // Replace loading with actual component
117
+ if (result instanceof Node) {
118
+ container.replaceChildren(result);
119
+ }
120
+ })
121
+ .catch(err => {
122
+ clearTimeout(loadingTimer);
123
+ clearTimeout(timeoutTimer);
124
+ loadPromise = null; // Allow retry
125
+
126
+ if (ErrorComponent) {
127
+ container.replaceChildren(ErrorComponent(err));
128
+ } else {
129
+ console.error('Lazy load error:', err);
130
+ container.replaceChildren(
131
+ el('div.lazy-error', `Failed to load component: ${err.message}`)
132
+ );
133
+ }
134
+ });
135
+
136
+ return container;
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Preload a lazy component without rendering
142
+ * Useful for prefetching on hover or when likely to navigate
143
+ *
144
+ * @param {function} lazyHandler - Lazy handler created with lazy()
145
+ * @returns {Promise} Resolves when component is loaded
146
+ *
147
+ * @example
148
+ * const DashboardLazy = lazy(() => import('./Dashboard.js'));
149
+ * // Preload on link hover
150
+ * link.addEventListener('mouseenter', () => preload(DashboardLazy));
151
+ */
152
+ export function preload(lazyHandler) {
153
+ // Trigger the lazy handler with a dummy context to start loading
154
+ // The result is discarded, but the component will be cached
155
+ return new Promise(resolve => {
156
+ const result = lazyHandler({});
157
+ if (result instanceof Promise) {
158
+ result.then(resolve);
159
+ } else {
160
+ // Already loaded
161
+ resolve(result);
162
+ }
163
+ });
164
+ }
165
+
166
+ /**
167
+ * Middleware context passed to each middleware function
168
+ * @typedef {Object} MiddlewareContext
169
+ * @property {NavigationTarget} to - Target route
170
+ * @property {NavigationTarget} from - Source route
171
+ * @property {Object} meta - Shared metadata between middlewares
172
+ * @property {function} redirect - Redirect to another path
173
+ * @property {function} abort - Abort navigation
174
+ */
175
+
176
+ /**
177
+ * Create a middleware runner for the router
178
+ * Middlewares are executed in order, each can modify context or abort navigation
179
+ *
180
+ * @param {Array<function>} middlewares - Array of middleware functions
181
+ * @returns {function} Runner function
182
+ *
183
+ * @example
184
+ * const authMiddleware = async (ctx, next) => {
185
+ * if (ctx.to.meta.requiresAuth && !isAuthenticated()) {
186
+ * return ctx.redirect('/login');
187
+ * }
188
+ * await next();
189
+ * };
190
+ *
191
+ * const loggerMiddleware = async (ctx, next) => {
192
+ * console.log('Navigating to:', ctx.to.path);
193
+ * const start = Date.now();
194
+ * await next();
195
+ * console.log('Navigation took:', Date.now() - start, 'ms');
196
+ * };
197
+ *
198
+ * const router = createRouter({
199
+ * routes,
200
+ * middleware: [loggerMiddleware, authMiddleware]
201
+ * });
202
+ */
203
+ function createMiddlewareRunner(middlewares) {
204
+ return async function runMiddleware(context) {
205
+ let index = 0;
206
+ let aborted = false;
207
+ let redirectPath = null;
208
+
209
+ // Create enhanced context with redirect and abort
210
+ const ctx = {
211
+ ...context,
212
+ meta: {},
213
+ redirect: (path) => {
214
+ redirectPath = path;
215
+ },
216
+ abort: () => {
217
+ aborted = true;
218
+ }
219
+ };
220
+
221
+ async function next() {
222
+ if (aborted || redirectPath) return;
223
+ if (index >= middlewares.length) return;
224
+
225
+ const middleware = middlewares[index++];
226
+ await middleware(ctx, next);
227
+ }
228
+
229
+ await next();
230
+
231
+ return {
232
+ aborted,
233
+ redirectPath,
234
+ meta: ctx.meta
235
+ };
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Radix Trie for efficient route matching
241
+ * Provides O(path length) lookup instead of O(routes count)
242
+ */
243
+ class RouteTrie {
244
+ constructor() {
245
+ this.root = { children: new Map(), route: null, paramName: null, isWildcard: false };
246
+ }
247
+
248
+ /**
249
+ * Insert a route into the trie
250
+ */
251
+ insert(pattern, route) {
252
+ const segments = pattern === '/' ? [''] : pattern.split('/').filter(Boolean);
253
+ let node = this.root;
254
+
255
+ for (const segment of segments) {
256
+ let key;
257
+ let paramName = null;
258
+ let isWildcard = false;
259
+
260
+ if (segment.startsWith(':')) {
261
+ // Dynamic segment - :param
262
+ key = ':';
263
+ paramName = segment.slice(1);
264
+ } else if (segment.startsWith('*')) {
265
+ // Wildcard segment - *path
266
+ key = '*';
267
+ paramName = segment.slice(1) || 'wildcard';
268
+ isWildcard = true;
269
+ } else {
270
+ // Static segment
271
+ key = segment;
272
+ }
273
+
274
+ if (!node.children.has(key)) {
275
+ node.children.set(key, {
276
+ children: new Map(),
277
+ route: null,
278
+ paramName,
279
+ isWildcard
280
+ });
281
+ }
282
+ node = node.children.get(key);
283
+ }
284
+
285
+ node.route = route;
286
+ }
287
+
288
+ /**
289
+ * Find a matching route for a path
290
+ */
291
+ find(path) {
292
+ const segments = path === '/' ? [''] : path.split('/').filter(Boolean);
293
+ return this._findRecursive(this.root, segments, 0, {});
294
+ }
295
+
296
+ _findRecursive(node, segments, index, params) {
297
+ // End of path
298
+ if (index === segments.length) {
299
+ if (node.route) {
300
+ return { route: node.route, params };
301
+ }
302
+ return null;
303
+ }
304
+
305
+ const segment = segments[index];
306
+
307
+ // Try static match first (most specific)
308
+ if (node.children.has(segment)) {
309
+ const result = this._findRecursive(node.children.get(segment), segments, index + 1, params);
310
+ if (result) return result;
311
+ }
312
+
313
+ // Try dynamic param match
314
+ if (node.children.has(':')) {
315
+ const paramNode = node.children.get(':');
316
+ const newParams = { ...params, [paramNode.paramName]: decodeURIComponent(segment) };
317
+ const result = this._findRecursive(paramNode, segments, index + 1, newParams);
318
+ if (result) return result;
319
+ }
320
+
321
+ // Try wildcard match (catches all remaining segments)
322
+ if (node.children.has('*')) {
323
+ const wildcardNode = node.children.get('*');
324
+ const remaining = segments.slice(index).map(decodeURIComponent).join('/');
325
+ return {
326
+ route: wildcardNode.route,
327
+ params: { ...params, [wildcardNode.paramName]: remaining }
328
+ };
329
+ }
330
+
331
+ return null;
332
+ }
333
+ }
334
+
18
335
  /**
19
336
  * Parse a route pattern into a regex and extract param names
20
337
  * Supports: /users/:id, /posts/:id/comments, /files/*path, * (catch-all)
@@ -126,9 +443,13 @@ export function createRouter(options = {}) {
126
443
  routes = {},
127
444
  mode = 'history', // 'history' or 'hash'
128
445
  base = '',
129
- scrollBehavior = null // Function to control scroll restoration
446
+ scrollBehavior = null, // Function to control scroll restoration
447
+ middleware: initialMiddleware = [] // Middleware functions
130
448
  } = options;
131
449
 
450
+ // Middleware array (mutable for dynamic registration)
451
+ const middleware = [...initialMiddleware];
452
+
132
453
  // Reactive state
133
454
  const currentPath = pulse(getPath());
134
455
  const currentRoute = pulse(null);
@@ -140,6 +461,9 @@ export function createRouter(options = {}) {
140
461
  // Scroll positions for history
141
462
  const scrollPositions = new Map();
142
463
 
464
+ // Route trie for O(path length) lookups
465
+ const routeTrie = new RouteTrie();
466
+
143
467
  // Compile routes (supports nested routes)
144
468
  const compiledRoutes = [];
145
469
 
@@ -148,11 +472,16 @@ export function createRouter(options = {}) {
148
472
  const normalized = normalizeRoute(pattern, config);
149
473
  const fullPattern = parentPath + pattern;
150
474
 
151
- compiledRoutes.push({
475
+ const route = {
152
476
  ...normalized,
153
477
  pattern: fullPattern,
154
478
  ...parsePattern(fullPattern)
155
- });
479
+ };
480
+
481
+ compiledRoutes.push(route);
482
+
483
+ // Insert into trie for fast lookup
484
+ routeTrie.insert(fullPattern, route);
156
485
 
157
486
  // Compile children (nested routes)
158
487
  if (normalized.children) {
@@ -183,15 +512,22 @@ export function createRouter(options = {}) {
183
512
  }
184
513
 
185
514
  /**
186
- * Find matching route
515
+ * Find matching route using trie for O(path length) lookup
187
516
  */
188
517
  function findRoute(path) {
518
+ // Use trie for efficient lookup
519
+ const result = routeTrie.find(path);
520
+ if (result) {
521
+ return result;
522
+ }
523
+
524
+ // Fallback to catch-all route if exists
189
525
  for (const route of compiledRoutes) {
190
- const params = matchRoute(route.pattern, path);
191
- if (params !== null) {
192
- return { route, params };
526
+ if (route.pattern === '*') {
527
+ return { route, params: {} };
193
528
  }
194
529
  }
530
+
195
531
  return null;
196
532
  }
197
533
 
@@ -234,6 +570,20 @@ export function createRouter(options = {}) {
234
570
  meta: match?.route?.meta || {}
235
571
  };
236
572
 
573
+ // Run middleware if configured
574
+ if (middleware.length > 0) {
575
+ const runMiddleware = createMiddlewareRunner(middleware);
576
+ const middlewareResult = await runMiddleware({ to, from });
577
+ if (middlewareResult.aborted) {
578
+ return false;
579
+ }
580
+ if (middlewareResult.redirectPath) {
581
+ return navigate(middlewareResult.redirectPath, { replace: true });
582
+ }
583
+ // Merge middleware meta into route meta
584
+ Object.assign(to.meta, middlewareResult.meta);
585
+ }
586
+
237
587
  // Run global beforeEach hooks
238
588
  for (const hook of beforeHooks) {
239
589
  const result = await hook(to, from);
@@ -427,7 +777,7 @@ export function createRouter(options = {}) {
427
777
  // Cleanup previous view
428
778
  if (cleanup) cleanup();
429
779
  if (currentView) {
430
- container.innerHTML = '';
780
+ container.replaceChildren();
431
781
  }
432
782
 
433
783
  if (route && route.handler) {
@@ -466,6 +816,19 @@ export function createRouter(options = {}) {
466
816
  return container;
467
817
  }
468
818
 
819
+ /**
820
+ * Add middleware dynamically
821
+ * @param {function} middlewareFn - Middleware function (ctx, next) => {}
822
+ * @returns {function} Unregister function
823
+ */
824
+ function use(middlewareFn) {
825
+ middleware.push(middlewareFn);
826
+ return () => {
827
+ const index = middleware.indexOf(middlewareFn);
828
+ if (index > -1) middleware.splice(index, 1);
829
+ };
830
+ }
831
+
469
832
  /**
470
833
  * Add navigation guard
471
834
  */
@@ -559,6 +922,7 @@ export function createRouter(options = {}) {
559
922
  start,
560
923
  link,
561
924
  outlet,
925
+ use,
562
926
  beforeEach,
563
927
  beforeResolve,
564
928
  afterEach,
@@ -591,6 +955,8 @@ export function simpleRouter(routes, target = '#app') {
591
955
  export default {
592
956
  createRouter,
593
957
  simpleRouter,
958
+ lazy,
959
+ preload,
594
960
  matchRoute,
595
961
  parseQuery
596
962
  };
package/types/hmr.d.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Pulse Framework - HMR (Hot Module Replacement) Type Definitions
3
+ * @module pulse-js-framework/runtime/hmr
4
+ */
5
+
6
+ import { Pulse, PulseOptions } from './pulse';
7
+
8
+ /**
9
+ * HMR Context interface for state preservation and effect cleanup
10
+ */
11
+ export interface HMRContext {
12
+ /**
13
+ * Persistent data storage across HMR updates.
14
+ * Values stored here survive module reloads.
15
+ */
16
+ data: Record<string, unknown>;
17
+
18
+ /**
19
+ * Create a pulse with state preservation across HMR updates.
20
+ * If a value exists from a previous module load, it's restored.
21
+ *
22
+ * @param key - Unique key for this pulse within the module
23
+ * @param initialValue - Initial value (used on first load only)
24
+ * @param options - Pulse options (equals function, etc.)
25
+ * @returns A pulse instance with preserved state
26
+ *
27
+ * @example
28
+ * const count = hmr.preservePulse('count', 0);
29
+ * const todos = hmr.preservePulse('todos', [], { equals: deepEquals });
30
+ */
31
+ preservePulse<T>(key: string, initialValue: T, options?: PulseOptions<T>): Pulse<T>;
32
+
33
+ /**
34
+ * Execute code with module tracking enabled.
35
+ * Effects created within this callback will be registered
36
+ * for automatic cleanup during HMR.
37
+ *
38
+ * @param callback - Code to execute with tracking
39
+ * @returns The return value of the callback
40
+ *
41
+ * @example
42
+ * hmr.setup(() => {
43
+ * effect(() => {
44
+ * document.title = `Count: ${count.get()}`;
45
+ * });
46
+ * });
47
+ */
48
+ setup<T>(callback: () => T): T;
49
+
50
+ /**
51
+ * Register a callback to run when the module accepts an HMR update.
52
+ * Call without arguments to auto-accept updates.
53
+ *
54
+ * @param callback - Optional callback for custom handling
55
+ *
56
+ * @example
57
+ * hmr.accept(); // Auto-accept
58
+ * hmr.accept(() => console.log('Module updated!'));
59
+ */
60
+ accept(callback?: () => void): void;
61
+
62
+ /**
63
+ * Register a callback to run before the module is replaced.
64
+ * Use this for custom cleanup logic.
65
+ *
66
+ * @param callback - Cleanup callback
67
+ *
68
+ * @example
69
+ * hmr.dispose(() => {
70
+ * socket.close();
71
+ * clearInterval(timer);
72
+ * });
73
+ */
74
+ dispose(callback: () => void): void;
75
+ }
76
+
77
+ /**
78
+ * Create an HMR context for a module.
79
+ * Provides utilities for state preservation and effect cleanup during HMR.
80
+ *
81
+ * In production or non-HMR environments, returns a no-op context
82
+ * that works normally but without HMR-specific behavior.
83
+ *
84
+ * @param moduleId - The module identifier (typically import.meta.url)
85
+ * @returns HMR context with preservation utilities
86
+ *
87
+ * @example
88
+ * import { createHMRContext } from 'pulse-js-framework/runtime/hmr';
89
+ *
90
+ * const hmr = createHMRContext(import.meta.url);
91
+ *
92
+ * // Preserve state across HMR
93
+ * const todos = hmr.preservePulse('todos', []);
94
+ * const filter = hmr.preservePulse('filter', 'all');
95
+ *
96
+ * // Setup effects with automatic cleanup
97
+ * hmr.setup(() => {
98
+ * effect(() => {
99
+ * document.title = `${todos.get().length} todos`;
100
+ * });
101
+ * });
102
+ *
103
+ * // Accept HMR updates
104
+ * hmr.accept();
105
+ */
106
+ export function createHMRContext(moduleId: string): HMRContext;
107
+
108
+ declare const hmr: {
109
+ createHMRContext: typeof createHMRContext;
110
+ };
111
+
112
+ export default hmr;
package/types/index.d.ts CHANGED
@@ -90,8 +90,15 @@ export {
90
90
  LinkOptions,
91
91
  MatchedRoute,
92
92
  Router,
93
+ MiddlewareContext,
94
+ MiddlewareFn,
95
+ LazyOptions,
96
+ LazyRouteHandler,
97
+ RouteContext,
93
98
  createRouter,
94
- simpleRouter
99
+ simpleRouter,
100
+ lazy,
101
+ preload
95
102
  } from './router';
96
103
 
97
104
  // Store
@@ -137,3 +144,20 @@ export {
137
144
  logger,
138
145
  loggers
139
146
  } from './logger';
147
+
148
+ // HMR (Hot Module Replacement)
149
+ export {
150
+ HMRContext,
151
+ createHMRContext
152
+ } from './hmr';
153
+
154
+ // Source Maps (Compiler)
155
+ export {
156
+ Position,
157
+ Mapping,
158
+ SourceMapV3,
159
+ OriginalPosition,
160
+ SourceMapGenerator,
161
+ SourceMapConsumer,
162
+ encodeVLQ
163
+ } from './sourcemap';