bobe-router 0.0.67 → 0.0.68

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,1108 @@
1
+ 'use strict';
2
+
3
+ var aoye = require('aoye');
4
+
5
+ let GlobalKey = function (GlobalKey) {
6
+ GlobalKey["Routes"] = "__BOBE_INIT_ROUTES__";
7
+ GlobalKey["Menus"] = "__BOBE_INIT_MENUS__";
8
+ GlobalKey["Path"] = "__BOBE_INIT_PATH__";
9
+ return GlobalKey;
10
+ }({});
11
+
12
+ function compilePattern(pattern) {
13
+ const paramNames = [];
14
+ const regexStr = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/:([a-zA-Z_]\w*)/g, (_, name) => {
15
+ paramNames.push(name);
16
+ return '([^/]+)';
17
+ });
18
+ return {
19
+ regex: new RegExp(`^${regexStr}$`),
20
+ params: paramNames
21
+ };
22
+ }
23
+ let compiledCache = null;
24
+ let cacheKey = null;
25
+ function compileRoutes(map) {
26
+ if (cacheKey === map && compiledCache) return compiledCache;
27
+ compiledCache = Object.keys(map).filter(k => k !== '*').map(pattern => {
28
+ const _compilePattern = compilePattern(pattern),
29
+ regex = _compilePattern.regex,
30
+ paramNames = _compilePattern.params;
31
+ return {
32
+ pattern,
33
+ regex,
34
+ paramNames
35
+ };
36
+ });
37
+ if (map['*']) {
38
+ compiledCache.push({
39
+ pattern: '*',
40
+ regex: /.*/,
41
+ paramNames: []
42
+ });
43
+ }
44
+ cacheKey = map;
45
+ return compiledCache;
46
+ }
47
+ function match(path, map) {
48
+ const parsed = new URL(path, 'http://localhost');
49
+ let normalized = parsed.pathname;
50
+ if (normalized.length > 1 && normalized.endsWith('/')) {
51
+ normalized = normalized.slice(0, -1);
52
+ }
53
+ for (const compiled of compileRoutes(map)) {
54
+ const m = normalized.match(compiled.regex);
55
+ if (!m) continue;
56
+ const params = {};
57
+ for (let i = 0; i < compiled.paramNames.length; i++) {
58
+ params[compiled.paramNames[i]] = m[i + 1];
59
+ }
60
+ return {
61
+ path: compiled.pattern,
62
+ params,
63
+ url: normalized
64
+ };
65
+ }
66
+ return null;
67
+ }
68
+
69
+ class NavigationTransactionRunner {
70
+ #nextTokenId = 0;
71
+ #currentTokenId = 0;
72
+ createToken(request) {
73
+ const token = {
74
+ id: ++this.#nextTokenId,
75
+ request,
76
+ cancelled: false
77
+ };
78
+ this.#currentTokenId = token.id;
79
+ return token;
80
+ }
81
+ isTokenValid(ctx) {
82
+ return !ctx.token.cancelled && ctx.token.id === this.#currentTokenId;
83
+ }
84
+ isTokenIdCurrent(tokenId) {
85
+ return tokenId === this.#currentTokenId;
86
+ }
87
+ cancelCurrent() {
88
+ this.#currentTokenId = ++this.#nextTokenId;
89
+ }
90
+ async run(router, handlers, request) {
91
+ const ctx = {
92
+ router,
93
+ request,
94
+ token: this.createToken(request),
95
+ status: 'running',
96
+ rollbackStack: []
97
+ };
98
+ let queue = [...handlers];
99
+ while (queue.length > 0) {
100
+ const handler = queue.shift();
101
+ let result;
102
+ try {
103
+ result = await handler(ctx);
104
+ } catch (error) {
105
+ ctx.error = error;
106
+ ctx.status = 'error';
107
+ await this.#rollback(ctx);
108
+ return {
109
+ status: ctx.status,
110
+ ctx,
111
+ error
112
+ };
113
+ }
114
+ if (!this.isTokenValid(ctx)) {
115
+ ctx.status = 'cancelled';
116
+ return {
117
+ status: ctx.status,
118
+ ctx
119
+ };
120
+ }
121
+ if (!result) continue;
122
+ if (result.type === 'stop') {
123
+ ctx.status = result.status ?? ctx.status;
124
+ return {
125
+ status: ctx.status,
126
+ ctx
127
+ };
128
+ }
129
+ if (result.type === 'prepend') {
130
+ queue.unshift(...result.handlers);
131
+ continue;
132
+ }
133
+ if (result.type === 'replace') {
134
+ queue = [...result.handlers];
135
+ }
136
+ }
137
+ if (ctx.status === 'running') ctx.status = 'completed';
138
+ return {
139
+ status: ctx.status,
140
+ ctx
141
+ };
142
+ }
143
+ stop(status = 'completed') {
144
+ return {
145
+ type: 'stop',
146
+ status
147
+ };
148
+ }
149
+ replace(handlers) {
150
+ return {
151
+ type: 'replace',
152
+ handlers
153
+ };
154
+ }
155
+ prepend(handlers) {
156
+ return {
157
+ type: 'prepend',
158
+ handlers
159
+ };
160
+ }
161
+ async #rollback(ctx) {
162
+ for (let i = ctx.rollbackStack.length - 1; i >= 0; i--) {
163
+ await ctx.rollbackStack[i](ctx);
164
+ }
165
+ }
166
+ }
167
+
168
+ const DEFAULT_HISTORY_KEY = '__bobe_router_runtime__';
169
+ function createRouteRecord(opts = {}) {
170
+ return {
171
+ import: opts.import,
172
+ component: opts.component,
173
+ status: opts.component ? 'loaded' : 'idle',
174
+ params: opts.params,
175
+ meta: opts.meta,
176
+ layout: opts.layout
177
+ };
178
+ }
179
+ class Router extends aoye.Store {
180
+ static [aoye.StoreIgnoreKeys] = ['routes', 'menus', 'stack', 'ready', 'scrollRootId', 'historyKey', 'maxPreload', 'onError', 'loadTimeout', 'onTimeout'];
181
+ active = null;
182
+ routes = {};
183
+ menus = [];
184
+ maxPreload = 3;
185
+ historyKey = DEFAULT_HISTORY_KEY;
186
+ #stack = [];
187
+ #stackIndex = 0;
188
+ #ready = false;
189
+ #readyQueue = [];
190
+ #idleSet = new Set();
191
+ #loadingCount = 0;
192
+ #pendingHistoryDelta = null;
193
+ #initialHistoryState = null;
194
+ #transaction = new NavigationTransactionRunner();
195
+ #entryId = 0;
196
+ constructor(opt) {
197
+ super();
198
+ this.routes = opt?.routes || globalThis[GlobalKey.Routes] || {};
199
+ this.menus = globalThis[GlobalKey.Menus] || [];
200
+ this.enterGuard = opt?.enterGuard;
201
+ this.leaveGuard = opt?.leaveGuard;
202
+ this.onError = opt?.onError;
203
+ this.loadTimeout = opt?.loadTimeout;
204
+ this.onTimeout = opt?.onTimeout;
205
+ this.scrollRootId = opt?.scrollRootId;
206
+ this.maxPreload = opt?.maxPreload ?? this.maxPreload;
207
+ this.historyKey = opt?.historyKey || DEFAULT_HISTORY_KEY;
208
+ this.#initialHistoryState = this.#hasBrowser() ? this.#readHistoryState(history.state) : null;
209
+ const initialUrl = opt?.initialPath || globalThis[GlobalKey.Path] || (this.#hasBrowser() ? location.pathname + location.search + location.hash : '/');
210
+ this.#initIdleSet();
211
+ void this.#init(initialUrl);
212
+ }
213
+ ready(cb) {
214
+ if (cb) {
215
+ if (this.#ready) cb();else this.#readyQueue.push(cb);
216
+ return;
217
+ }
218
+ return new Promise(resolve => this.ready(resolve));
219
+ }
220
+ async pushState(url) {
221
+ const request = {
222
+ source: 'api',
223
+ url,
224
+ historyMode: 'push',
225
+ runGuards: true,
226
+ restoreScroll: false
227
+ };
228
+ return this.#runNavigation(request, [this.#handleCreateUrlContext(request), this.#handleBuildRouteEntry, this.#handleSaveCurrentScroll, this.#handleRunGuards, this.#handleLoadComponent, this.#handleBuildCommitPlan('push', 'append', () => this.#stackIndex + 1), this.#handleCommitHistory, this.#handleCommitStack, this.#handlePrepareScrollWaiter, this.#handleSetActive, this.#handleScroll, this.#handleStartIdlePreload]);
229
+ }
230
+ async replaceState(url) {
231
+ const request = {
232
+ source: 'api',
233
+ url,
234
+ historyMode: 'replace',
235
+ runGuards: true,
236
+ restoreScroll: false
237
+ };
238
+ return this.#runNavigation(request, this.#replaceHandlers(request));
239
+ }
240
+ async back() {
241
+ return this.#goByDelta(-1);
242
+ }
243
+ async forward() {
244
+ return this.#goByDelta(1);
245
+ }
246
+ async go(delta) {
247
+ const request = {
248
+ source: 'api',
249
+ historyMode: 'browser-delta',
250
+ delta,
251
+ runGuards: true,
252
+ restoreScroll: true
253
+ };
254
+ if (delta === 0) {
255
+ return {
256
+ status: 'ignored',
257
+ ctx: {
258
+ router: this,
259
+ request,
260
+ token: {
261
+ id: 0,
262
+ request,
263
+ cancelled: false
264
+ },
265
+ status: 'ignored',
266
+ rollbackStack: []
267
+ }
268
+ };
269
+ }
270
+ return this.#goByDelta(delta, request);
271
+ }
272
+ async #init(url) {
273
+ const request = {
274
+ source: 'init',
275
+ url,
276
+ historyMode: 'replace',
277
+ runGuards: true,
278
+ restoreScroll: false
279
+ };
280
+ await this.#runNavigation(request, [this.#handleCreateUrlContext(request), this.#handleBuildRouteEntry, this.#handleRunGuards, this.#handleLoadComponent, this.#handleBuildCommitPlan('replace', 'replace-current', ctx => {
281
+ const existingState = this.#initialHistoryState;
282
+ if (existingState && ctx.to) ctx.to.id = existingState.entryId;
283
+ return existingState?.index ?? 0;
284
+ }), this.#handleCommitHistory, this.#handleCommitStack, this.#handlePrepareScrollWaiter, this.#handleSetActive, this.#handleScroll, this.#handleStartIdlePreload]);
285
+ this.#initBrowser();
286
+ this.#ready = true;
287
+ const queue = this.#readyQueue;
288
+ this.#readyQueue = [];
289
+ for (const cb of queue) cb();
290
+ }
291
+ async #goByDelta(delta, request) {
292
+ const currentRequest = request || {
293
+ source: 'api',
294
+ historyMode: 'browser-delta',
295
+ delta,
296
+ runGuards: true,
297
+ restoreScroll: true
298
+ };
299
+ if (!this.#hasBrowser()) {
300
+ return {
301
+ status: 'ignored',
302
+ ctx: {
303
+ router: this,
304
+ request: currentRequest,
305
+ token: {
306
+ id: 0,
307
+ request: currentRequest,
308
+ cancelled: false
309
+ },
310
+ status: 'ignored',
311
+ rollbackStack: []
312
+ }
313
+ };
314
+ }
315
+ return this.#runNavigation(currentRequest, [ctx => {
316
+ ctx.historyDelta = {
317
+ delta
318
+ };
319
+ ctx.from = this.active;
320
+ }, this.#handleResolveHistoryDeltaTarget, this.#handleBuildRouteEntryFromStack, this.#handleSaveCurrentScroll, this.#handleRunGuards, this.#handleLoadComponent, this.#handleSetPendingHistoryDelta, ctx => {
321
+ const delta = ctx.historyDelta?.delta;
322
+ if (!this.#hasBrowser() || typeof delta !== 'number') return;
323
+ history.go(delta);
324
+ }]);
325
+ }
326
+ async #runNavigation(request, handlers) {
327
+ const result = await this.#transaction.run(this, handlers, request);
328
+ if (result.status === 'error') {
329
+ await this.#notifyNavigationError(result.error, result.ctx);
330
+ }
331
+ return result;
332
+ }
333
+ async #notifyNavigationError(error, ctx) {
334
+ if (!this.onError) {
335
+ console.error(error);
336
+ return;
337
+ }
338
+ try {
339
+ await this.onError(error, ctx);
340
+ } catch (notifyError) {
341
+ console.error(notifyError);
342
+ }
343
+ }
344
+ #replaceHandlers(request) {
345
+ return [this.#handleCreateUrlContext(request), this.#handleBuildRouteEntry, this.#handleRunGuards, this.#handleLoadComponent, this.#handleBuildCommitPlan('replace', 'replace-current', () => this.#stackIndex), this.#handleCommitHistory, this.#handleCommitStack, this.#handlePrepareScrollWaiter, this.#handleSetActive, this.#handleScroll, this.#handleStartIdlePreload];
346
+ }
347
+ #hashOnlyScrollHandlers(url) {
348
+ return [ctx => {
349
+ ctx.url = url;
350
+ ctx.from = this.active;
351
+ ctx.to = this.active ?? undefined;
352
+ ctx.scrollIntent = this.#createScrollIntentFromUrl(url, undefined, false);
353
+ ctx.commitPlan = {
354
+ historyAction: 'none',
355
+ stackAction: 'none',
356
+ scrollIntent: ctx.scrollIntent
357
+ };
358
+ if (this.active) {
359
+ const parsed = this.#parseUrl(url);
360
+ this.active.url = url;
361
+ this.active.hash = parsed.hash;
362
+ if (this.#stack[this.#stackIndex]?.id === this.active.id) {
363
+ this.#stack[this.#stackIndex].url = url;
364
+ this.#stack[this.#stackIndex].hash = parsed.hash;
365
+ }
366
+ }
367
+ }, this.#handlePrepareScrollWaiter, this.#handleScroll];
368
+ }
369
+ #handleCreateUrlContext(request) {
370
+ return ctx => {
371
+ ctx.request = request;
372
+ ctx.url = request.url;
373
+ ctx.from = this.active;
374
+ };
375
+ }
376
+ #handleBuildRouteEntry = ctx => {
377
+ if (!ctx.url) return this.#transaction.stop('ignored');
378
+ const routeMatch = this.#matchUrl(ctx.url);
379
+ if (!routeMatch) return this.#transaction.stop('ignored');
380
+ ctx.match = routeMatch;
381
+ ctx.to = this.#createRouteEntry(routeMatch);
382
+ ctx.scrollIntent = this.#createScrollIntentFromUrl(ctx.to.url);
383
+ };
384
+ #handleBuildRouteEntryFromStack = ctx => {
385
+ const target = ctx.historyDelta?.targetEntry;
386
+ if (!target) {
387
+ this.#allowExternalHistoryDelta(ctx);
388
+ return this.#transaction.stop('completed');
389
+ }
390
+ ctx.to = target;
391
+ ctx.url = target.url;
392
+ const routeMatch = this.#matchUrl(target.url);
393
+ if (routeMatch) ctx.match = routeMatch;
394
+ ctx.scrollIntent = target.scroll ? this.#createRestoreScrollIntent(target.scroll) : this.#createScrollIntentFromUrl(target.url);
395
+ };
396
+ #handleBuildRouteEntryFromHistoryState(state) {
397
+ return ctx => {
398
+ const entry = this.#stack[state.index];
399
+ ctx.historyDelta = {
400
+ delta: state.index - this.#stackIndex,
401
+ targetState: state,
402
+ targetEntry: entry
403
+ };
404
+ if (entry && entry.id === state.entryId) {
405
+ ctx.to = entry;
406
+ ctx.url = entry.url;
407
+ const routeMatch = this.#matchUrl(entry.url);
408
+ if (routeMatch) ctx.match = routeMatch;
409
+ ctx.scrollIntent = entry.scroll ? this.#createRestoreScrollIntent(entry.scroll) : this.#createScrollIntentFromUrl(entry.url);
410
+ return;
411
+ }
412
+ const routeMatch = this.#matchUrl(ctx.url || state.url);
413
+ if (!routeMatch) return this.#transaction.stop('ignored');
414
+ ctx.match = routeMatch;
415
+ ctx.to = this.#createRouteEntry(routeMatch, state.entryId);
416
+ ctx.historyDelta.targetEntry = ctx.to;
417
+ ctx.scrollIntent = this.#createScrollIntentFromUrl(ctx.to.url);
418
+ };
419
+ }
420
+ #handleResolveHistoryDeltaTarget = ctx => {
421
+ const delta = ctx.historyDelta?.delta ?? 0;
422
+ const nextIndex = this.#stackIndex + delta;
423
+ if (nextIndex < 0 || nextIndex >= this.#stack.length || !this.#stack[nextIndex]) {
424
+ this.#allowExternalHistoryDelta(ctx);
425
+ return this.#transaction.stop('completed');
426
+ }
427
+ ctx.historyDelta = {
428
+ delta,
429
+ targetEntry: this.#stack[nextIndex],
430
+ targetState: this.#createHistoryState(this.#stack[nextIndex], nextIndex)
431
+ };
432
+ };
433
+ #handleRunGuards = async ctx => {
434
+ if (ctx.request && !ctx.request.runGuards) {
435
+ ctx.guard = {
436
+ type: 'allowed'
437
+ };
438
+ return;
439
+ }
440
+ if (!ctx.to) return this.#transaction.stop('ignored');
441
+ const decision = await this.#runGuardsOnce(ctx.from ?? null, ctx.to);
442
+ if (!this.#transaction.isTokenValid(ctx)) return this.#transaction.stop('cancelled');
443
+ ctx.guard = decision;
444
+ if (decision.type === 'allowed') return;
445
+ if (decision.type === 'blocked') {
446
+ this.#rollbackBrowserHistoryIfNeeded(ctx);
447
+ return this.#transaction.stop('blocked');
448
+ }
449
+ return this.#transaction.replace(this.#replaceHandlers({
450
+ source: 'redirect',
451
+ url: decision.to,
452
+ historyMode: 'replace',
453
+ runGuards: true,
454
+ restoreScroll: false
455
+ }));
456
+ };
457
+ #handleLoadComponent = async ctx => {
458
+ if (!ctx.to) return this.#transaction.stop('ignored');
459
+ const pattern = ctx.match?.pattern || this.#matchUrl(ctx.to.url)?.pattern || ctx.to.path;
460
+ await this.#loadComponentWithTimeout(pattern, ctx);
461
+ if (!this.#transaction.isTokenValid(ctx)) return this.#transaction.stop('cancelled');
462
+ this.#syncRouteRecordToEntry(ctx.to, pattern);
463
+ };
464
+ #handleSaveCurrentScroll = () => {
465
+ this.#saveScroll(this.#stackIndex);
466
+ };
467
+ #handleBuildCommitPlan(historyAction, stackAction, getNextStackIndex) {
468
+ return ctx => {
469
+ if (!ctx.to) return this.#transaction.stop('ignored');
470
+ const nextStackIndex = getNextStackIndex(ctx);
471
+ ctx.commitPlan = {
472
+ historyState: this.#createHistoryState(ctx.to, nextStackIndex),
473
+ historyAction,
474
+ stackAction,
475
+ nextStackIndex,
476
+ scrollIntent: ctx.scrollIntent
477
+ };
478
+ };
479
+ }
480
+ #handleBuildPopstateMoveCommitPlan(state) {
481
+ return ctx => {
482
+ if (!ctx.to) return this.#transaction.stop('ignored');
483
+ const hasMemoryEntry = !!this.#stack[state.index] && this.#stack[state.index].id === state.entryId;
484
+ ctx.commitPlan = {
485
+ historyState: hasMemoryEntry ? state : this.#createHistoryState(ctx.to, 0),
486
+ historyAction: hasMemoryEntry ? 'none' : 'replace',
487
+ stackAction: hasMemoryEntry ? 'move-index' : 'reset',
488
+ nextStackIndex: hasMemoryEntry ? state.index : 0,
489
+ scrollIntent: ctx.scrollIntent
490
+ };
491
+ };
492
+ }
493
+ #handleCommitHistory = ctx => {
494
+ const action = ctx.commitPlan?.historyAction;
495
+ if (!action || action === 'none') return;
496
+ if (!this.#hasBrowser() || !ctx.to || !ctx.commitPlan?.historyState) return;
497
+ const previousState = history.state;
498
+ const previousUrl = this.#toUrlString(location);
499
+ if (action === 'push') {
500
+ history.pushState(ctx.commitPlan.historyState, '', ctx.to.url);
501
+ ctx.rollbackStack.push(() => {
502
+ if (this.#hasBrowser()) history.go(-1);
503
+ });
504
+ } else {
505
+ history.replaceState(ctx.commitPlan.historyState, '', ctx.to.url);
506
+ ctx.rollbackStack.push(() => {
507
+ if (this.#hasBrowser()) history.replaceState(previousState, '', previousUrl);
508
+ });
509
+ }
510
+ };
511
+ #handleCommitStack = ctx => {
512
+ const action = ctx.commitPlan?.stackAction;
513
+ if (!action || action === 'none') return;
514
+ const previousStack = this.#stack.slice();
515
+ const previousStackIndex = this.#stackIndex;
516
+ const rollbackStack = () => {
517
+ this.#stack = previousStack;
518
+ this.#stackIndex = previousStackIndex;
519
+ };
520
+ if (action === 'move-index') {
521
+ const nextIndex = ctx.commitPlan?.nextStackIndex;
522
+ if (typeof nextIndex !== 'number' || !this.#stack[nextIndex]) {
523
+ return this.#transaction.stop('ignored');
524
+ }
525
+ this.#stackIndex = nextIndex;
526
+ ctx.to = ctx.to || this.#stack[nextIndex];
527
+ ctx.rollbackStack.push(rollbackStack);
528
+ return;
529
+ }
530
+ if (!ctx.to) return this.#transaction.stop('ignored');
531
+ if (action === 'append') {
532
+ this.#stack.length = this.#stackIndex + 1;
533
+ this.#stack.push(ctx.to);
534
+ this.#stackIndex = this.#stack.length - 1;
535
+ ctx.rollbackStack.push(rollbackStack);
536
+ return;
537
+ }
538
+ if (action === 'replace-current') {
539
+ const nextIndex = ctx.commitPlan?.nextStackIndex ?? this.#stackIndex;
540
+ this.#stack[nextIndex] = ctx.to;
541
+ this.#stackIndex = nextIndex;
542
+ ctx.rollbackStack.push(rollbackStack);
543
+ return;
544
+ }
545
+ if (action === 'reset') {
546
+ this.#stack = [ctx.to];
547
+ this.#stackIndex = 0;
548
+ ctx.rollbackStack.push(rollbackStack);
549
+ }
550
+ };
551
+ #handleSetActive = ctx => {
552
+ if (!ctx.to) return this.#transaction.stop('ignored');
553
+ const previousActive = this.active;
554
+ this.active = ctx.to;
555
+ ctx.rollbackStack.push(() => {
556
+ this.active = previousActive;
557
+ });
558
+ };
559
+ #handleRegisterPopstateRollback = ctx => {
560
+ if (!this.#hasBrowser()) return;
561
+ if (ctx.request?.source === 'popstate') {
562
+ const delta = ctx.historyDelta?.delta;
563
+ if (typeof delta === 'number' && delta !== 0) {
564
+ ctx.rollbackStack.push(() => {
565
+ if (this.#hasBrowser()) history.go(-delta);
566
+ });
567
+ }
568
+ return;
569
+ }
570
+ if (ctx.request?.source === 'external-popstate' && ctx.from) {
571
+ const previousEntry = ctx.from;
572
+ const previousState = this.#createHistoryState(previousEntry, this.#stackIndex);
573
+ ctx.rollbackStack.push(() => {
574
+ if (this.#hasBrowser()) history.replaceState(previousState, '', previousEntry.url);
575
+ });
576
+ }
577
+ };
578
+ #handleSetPendingHistoryDelta = ctx => {
579
+ const delta = ctx.historyDelta?.delta;
580
+ const target = ctx.historyDelta?.targetEntry;
581
+ if (typeof delta !== 'number' || !target) {
582
+ return this.#transaction.stop('ignored');
583
+ }
584
+ const toIndex = this.#stackIndex + delta;
585
+ this.#pendingHistoryDelta = {
586
+ tokenId: ctx.token.id,
587
+ delta,
588
+ fromIndex: this.#stackIndex,
589
+ toIndex,
590
+ targetEntryId: target.id,
591
+ targetUrl: target.url,
592
+ consumed: false
593
+ };
594
+ };
595
+ #handlePrepareScrollWaiter = ctx => {
596
+ const scrollIntent = ctx.scrollIntent || ctx.commitPlan?.scrollIntent;
597
+ if (!scrollIntent) return;
598
+ const needsActiveRender = ctx.commitPlan?.stackAction !== 'none';
599
+ const renderWaiter = needsActiveRender ? this.#createPostEffectWaiter() : this.#createFallbackWaiter(scrollIntent.retry.wait);
600
+ ctx.renderWaiter = renderWaiter;
601
+ if (ctx.commitPlan) ctx.commitPlan.renderWaiter = renderWaiter;
602
+ ctx.rollbackStack.push(() => renderWaiter.dispose?.());
603
+ };
604
+ #handleScroll = async ctx => {
605
+ const scrollIntent = ctx.scrollIntent || ctx.commitPlan?.scrollIntent;
606
+ if (!scrollIntent) return;
607
+ const waiter = ctx.renderWaiter || ctx.commitPlan?.renderWaiter || this.#createFallbackWaiter(scrollIntent.retry.wait);
608
+ await waiter.promise;
609
+ waiter.dispose?.();
610
+ if (!this.#transaction.isTokenValid(ctx)) return this.#transaction.stop('cancelled');
611
+ await this.#applyScrollIntent(ctx, scrollIntent);
612
+ };
613
+ #handleStartIdlePreload = () => {
614
+ this.#preloadNext();
615
+ };
616
+ #initBrowser() {
617
+ if (!this.#hasBrowser()) return;
618
+ if ('scrollRestoration' in history) {
619
+ history.scrollRestoration = 'manual';
620
+ }
621
+ document.addEventListener('click', this.#onClick);
622
+ window.addEventListener('popstate', this.#onPopstate);
623
+ }
624
+ #onClick = event => {
625
+ const target = event.target;
626
+ if (!(target instanceof Element)) return;
627
+ const link = target.closest('a');
628
+ if (!link) return;
629
+ const href = link.getAttribute('href');
630
+ if (!href) return;
631
+ try {
632
+ const url = new URL(href, location.origin);
633
+ if (url.origin !== location.origin) return;
634
+ if (link.target === '_blank' || link.hasAttribute('download')) return;
635
+ if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
636
+ const nextUrl = this.#toUrlString(url);
637
+ if (href.startsWith('#') && !this.#isHashOnlyForActive(nextUrl)) return;
638
+ event.preventDefault();
639
+ if (this.active && this.#isSameRouteBase(nextUrl, this.active.url) && this.#toUrlString(location) !== nextUrl) {
640
+ void this.#runNavigation(undefined, this.#hashOnlyScrollHandlers(nextUrl));
641
+ this.active.url = nextUrl;
642
+ this.active.hash = url.hash;
643
+ if (this.#stack[this.#stackIndex]) {
644
+ this.#stack[this.#stackIndex].url = nextUrl;
645
+ this.#stack[this.#stackIndex].hash = url.hash;
646
+ }
647
+ history.pushState({
648
+ ...this.#createHistoryState(this.active, this.#stackIndex),
649
+ url: nextUrl
650
+ }, '', nextUrl);
651
+ return;
652
+ }
653
+ void this.pushState(nextUrl);
654
+ } catch {}
655
+ };
656
+ #onPopstate = event => {
657
+ void this.#handlePopstate(event).catch(error => {
658
+ console.error(error);
659
+ });
660
+ };
661
+ async #handlePopstate(event) {
662
+ const url = this.#toUrlString(location);
663
+ const state = this.#readHistoryState(event.state);
664
+ if (state && this.#isValidPendingHistoryDelta(state, url)) {
665
+ const entry = this.#stack[state.index];
666
+ const scrollIntent = entry.scroll ? this.#createRestoreScrollIntent(entry.scroll) : this.#createScrollIntentFromUrl(entry.url);
667
+ await this.#runNavigation(undefined, [ctx => {
668
+ if (!this.#pendingHistoryDelta) return this.#transaction.stop('ignored');
669
+ this.#pendingHistoryDelta.consumed = true;
670
+ this.#pendingHistoryDelta = null;
671
+ }, ctx => {
672
+ ctx.to = entry;
673
+ ctx.url = entry.url;
674
+ ctx.scrollIntent = scrollIntent;
675
+ ctx.commitPlan = {
676
+ historyState: state,
677
+ historyAction: 'none',
678
+ stackAction: 'move-index',
679
+ nextStackIndex: state.index,
680
+ scrollIntent
681
+ };
682
+ }, this.#handleCommitStack, this.#handlePrepareScrollWaiter, this.#handleSetActive, this.#handleScroll, this.#handleStartIdlePreload]);
683
+ return;
684
+ }
685
+ if (this.#isHashOnlyForActive(url) && (!state || state.index === this.#stackIndex)) {
686
+ await this.#runNavigation(undefined, this.#hashOnlyScrollHandlers(url));
687
+ return;
688
+ }
689
+ if (!state) {
690
+ const request = {
691
+ source: 'external-popstate',
692
+ url,
693
+ historyMode: 'replace',
694
+ runGuards: true,
695
+ restoreScroll: false
696
+ };
697
+ await this.#runNavigation(undefined, [this.#handleCreateUrlContext(request), this.#handleBuildRouteEntry, this.#handleSaveCurrentScroll, this.#handleRegisterPopstateRollback, this.#handleRunGuards, this.#handleLoadComponent, this.#handleBuildCommitPlan('replace', 'reset', () => 0), this.#handleCommitHistory, this.#handleCommitStack, this.#handlePrepareScrollWaiter, this.#handleSetActive, this.#handleScroll, this.#handleStartIdlePreload]);
698
+ return;
699
+ }
700
+ const request = {
701
+ source: 'popstate',
702
+ url,
703
+ historyMode: 'none',
704
+ runGuards: true,
705
+ restoreScroll: true
706
+ };
707
+ await this.#runNavigation(undefined, [this.#handleCreateUrlContext(request), this.#handleBuildRouteEntryFromHistoryState(state), this.#handleSaveCurrentScroll, this.#handleRegisterPopstateRollback, this.#handleRunGuards, this.#handleLoadComponent, this.#handleBuildPopstateMoveCommitPlan(state), this.#handleCommitHistory, this.#handleCommitStack, this.#handlePrepareScrollWaiter, this.#handleSetActive, this.#handleScroll, this.#handleStartIdlePreload]);
708
+ }
709
+ async #runGuardsOnce(from, to) {
710
+ if (from && this.leaveGuard) {
711
+ const result = await this.leaveGuard(from);
712
+ const decision = this.#toGuardDecision(result);
713
+ if (decision.type !== 'allowed') return decision;
714
+ }
715
+ if (this.enterGuard) {
716
+ const result = await this.enterGuard(to);
717
+ return this.#toGuardDecision(result);
718
+ }
719
+ return {
720
+ type: 'allowed'
721
+ };
722
+ }
723
+ #toGuardDecision(result) {
724
+ if (result === false) return {
725
+ type: 'blocked',
726
+ result
727
+ };
728
+ if (result === true) return {
729
+ type: 'allowed'
730
+ };
731
+ if (result && typeof result === 'object') {
732
+ if (result.redirect) return {
733
+ type: 'redirect',
734
+ to: result.redirect,
735
+ result
736
+ };
737
+ if (!result.ok) return {
738
+ type: 'blocked',
739
+ result
740
+ };
741
+ return {
742
+ type: 'allowed'
743
+ };
744
+ }
745
+ return {
746
+ type: 'allowed'
747
+ };
748
+ }
749
+ async #loadComponent(pattern, counted = false) {
750
+ const route = this.routes[pattern];
751
+ if (!route) {
752
+ if (counted) this.#loadingCount--;
753
+ return undefined;
754
+ }
755
+ switch (route.status) {
756
+ case 'loaded':
757
+ if (counted) this.#loadingCount--;
758
+ return route.component;
759
+ case 'loading':
760
+ if (counted) this.#loadingCount--;
761
+ return route.promise;
762
+ }
763
+ if (!route.import) {
764
+ if (route.component) {
765
+ route.status = 'loaded';
766
+ if (counted) this.#loadingCount--;
767
+ return route.component;
768
+ }
769
+ if (counted) this.#loadingCount--;
770
+ throw new Error(`Route "${pattern}" has no import function`);
771
+ }
772
+ this.#idleSet.delete(pattern);
773
+ route.status = 'loading';
774
+ if (!counted) this.#loadingCount++;
775
+ route.promise = route.import().then(mod => {
776
+ const record = mod;
777
+ const component = typeof mod === 'function' ? mod : record.default || mod;
778
+ route.status = 'loaded';
779
+ route.component = component;
780
+ if (!route.meta && record.routeMeta) route.meta = record.routeMeta;
781
+ if (!route.layout && record.layout) route.layout = record.layout;
782
+ return route.component;
783
+ }).catch(error => {
784
+ route.status = 'error';
785
+ throw error;
786
+ }).finally(() => {
787
+ this.#loadingCount--;
788
+ this.#preloadNext();
789
+ });
790
+ return route.promise;
791
+ }
792
+ async #loadComponentWithTimeout(pattern, ctx) {
793
+ const promise = this.#loadComponent(pattern);
794
+ const timeout = this.loadTimeout;
795
+ if (!timeout || timeout <= 0) {
796
+ return promise;
797
+ }
798
+ let timeoutId;
799
+ const timeoutPromise = new Promise(resolve => {
800
+ timeoutId = setTimeout(() => resolve('timeout'), timeout);
801
+ });
802
+ let result;
803
+ try {
804
+ result = await Promise.race([promise, timeoutPromise]);
805
+ } finally {
806
+ if (timeoutId) clearTimeout(timeoutId);
807
+ }
808
+ if (result !== 'timeout') {
809
+ return result;
810
+ }
811
+ const decision = this.onTimeout ? await this.onTimeout(ctx) : 'continue';
812
+ if (decision === 'cancel') {
813
+ throw new Error(`Route "${pattern}" import timed out after ${timeout}ms`);
814
+ }
815
+ return promise;
816
+ }
817
+ #preloadNext() {
818
+ if (!this.#hasBrowser()) return;
819
+ while (this.#loadingCount < this.maxPreload && this.#idleSet.size > 0) {
820
+ const path = this.#idleSet.values().next().value;
821
+ if (!path) return;
822
+ this.#idleSet.delete(path);
823
+ this.#loadingCount++;
824
+ const task = {
825
+ path};
826
+ const scheduler = typeof requestIdleCallback !== 'undefined' ? requestIdleCallback : fn => setTimeout(fn, 0);
827
+ scheduler(() => {
828
+ this.#loadComponent(task.path, true).catch(error => {
829
+ const route = this.routes[task.path];
830
+ if (route) route.status = 'error';
831
+ console.error(error);
832
+ });
833
+ });
834
+ }
835
+ }
836
+ async #applyScrollIntent(ctx, intent) {
837
+ const retry = {
838
+ ...intent.retry
839
+ };
840
+ while (true) {
841
+ if (!this.#transaction.isTokenValid(ctx)) return;
842
+ if (intent.type === 'hash') {
843
+ const el = this.#queryHashElement(intent.hash);
844
+ if (el) {
845
+ el.scrollIntoView({
846
+ behavior: intent.behavior ?? 'smooth'
847
+ });
848
+ return;
849
+ }
850
+ } else if (intent.type === 'restore') {
851
+ const target = this.#getScrollTarget();
852
+ if (target) {
853
+ this.#setScroll(target.root, intent.top, intent.left);
854
+ return;
855
+ }
856
+ } else {
857
+ const target = this.#getScrollTarget();
858
+ if (target) {
859
+ this.#setScroll(target.root, 0, 0);
860
+ return;
861
+ }
862
+ }
863
+ if (retry.attempts >= retry.maxAttempts) return;
864
+ retry.attempts++;
865
+ await this.#createFallbackWaiter('raf').promise;
866
+ }
867
+ }
868
+ #createPostEffectWaiter() {
869
+ if (!this.#hasBrowser()) return this.#createFallbackWaiter('resolved');
870
+ let dispose;
871
+ const promise = new Promise(resolve => {
872
+ const watcher = aoye.effect(() => {
873
+ resolve();
874
+ queueMicrotask(() => dispose?.());
875
+ }, [() => this.active], {
876
+ type: 'post',
877
+ immediate: false
878
+ });
879
+ dispose = () => watcher.dispose();
880
+ });
881
+ return {
882
+ promise: promise.finally(() => dispose?.()),
883
+ source: 'post-effect',
884
+ dispose: () => dispose?.()
885
+ };
886
+ }
887
+ #createFallbackWaiter(wait) {
888
+ if (wait === 'raf' && typeof requestAnimationFrame !== 'undefined') {
889
+ return {
890
+ promise: new Promise(resolve => requestAnimationFrame(() => resolve())),
891
+ source: 'raf'
892
+ };
893
+ }
894
+ return {
895
+ promise: Promise.resolve(),
896
+ source: 'resolved'
897
+ };
898
+ }
899
+ #createScrollIntentFromUrl(url, snapshot, renderDependent = true) {
900
+ const parsed = this.#parseUrl(url);
901
+ const retry = this.#createRetry(renderDependent ? 'post-effect' : 'raf');
902
+ if (parsed.hash) {
903
+ return {
904
+ type: 'hash',
905
+ hash: parsed.hash,
906
+ retry,
907
+ behavior: 'smooth'
908
+ };
909
+ }
910
+ if (snapshot) {
911
+ return {
912
+ type: 'restore',
913
+ top: snapshot.top,
914
+ left: snapshot.left,
915
+ retry
916
+ };
917
+ }
918
+ return {
919
+ type: 'top',
920
+ retry
921
+ };
922
+ }
923
+ #createRestoreScrollIntent(snapshot) {
924
+ return {
925
+ type: 'restore',
926
+ top: snapshot.top,
927
+ left: snapshot.left,
928
+ retry: this.#createRetry('post-effect')
929
+ };
930
+ }
931
+ #createRetry(wait) {
932
+ return {
933
+ attempts: 0,
934
+ maxAttempts: 8,
935
+ wait
936
+ };
937
+ }
938
+ #saveScroll(index) {
939
+ const entry = this.#stack[index];
940
+ if (!entry || !this.#hasBrowser()) return;
941
+ entry.scroll = this.#getScrollSnapshot();
942
+ }
943
+ #getScrollSnapshot() {
944
+ const target = this.#getScrollTarget();
945
+ if (!target) return {
946
+ top: 0,
947
+ left: 0
948
+ };
949
+ if (target.root === window) {
950
+ return {
951
+ top: window.scrollY,
952
+ left: window.scrollX
953
+ };
954
+ }
955
+ return {
956
+ top: target.root.scrollTop,
957
+ left: target.root.scrollLeft
958
+ };
959
+ }
960
+ #getScrollTarget() {
961
+ if (!this.#hasBrowser()) return null;
962
+ if (this.scrollRootId) {
963
+ const el = document.getElementById(this.scrollRootId);
964
+ if (el) return {
965
+ root: el,
966
+ scrollRootId: this.scrollRootId
967
+ };
968
+ }
969
+ return {
970
+ root: window,
971
+ scrollRootId: this.scrollRootId
972
+ };
973
+ }
974
+ #setScroll(root, top, left) {
975
+ if (root === window) {
976
+ window.scrollTo(left, top);
977
+ return;
978
+ }
979
+ const el = root;
980
+ el.scrollTop = top;
981
+ el.scrollLeft = left;
982
+ }
983
+ #queryHashElement(hash) {
984
+ if (typeof document === 'undefined' || !hash) return null;
985
+ try {
986
+ return document.querySelector(decodeURIComponent(hash));
987
+ } catch {
988
+ return null;
989
+ }
990
+ }
991
+ #allowExternalHistoryDelta(ctx) {
992
+ const delta = ctx.historyDelta?.delta;
993
+ if (!this.#hasBrowser() || typeof delta !== 'number') return;
994
+ history.go(delta);
995
+ }
996
+ #rollbackBrowserHistoryIfNeeded(ctx) {
997
+ const delta = ctx.historyDelta?.delta;
998
+ if (ctx.request?.source !== 'popstate') return;
999
+ if (!this.#hasBrowser() || typeof delta !== 'number') return;
1000
+ history.go(-delta);
1001
+ }
1002
+ #isValidPendingHistoryDelta(state, url) {
1003
+ const pending = this.#pendingHistoryDelta;
1004
+ if (!pending || pending.consumed) return false;
1005
+ if (!this.#transaction.isTokenIdCurrent(pending.tokenId)) return false;
1006
+ if (state.historyKey !== this.historyKey) return false;
1007
+ if (state.index !== pending.toIndex) return false;
1008
+ if (state.entryId !== pending.targetEntryId) return false;
1009
+ if (this.#normalizeUrl(url) !== this.#normalizeUrl(pending.targetUrl)) return false;
1010
+ return true;
1011
+ }
1012
+ #readHistoryState(state) {
1013
+ if (!state || typeof state !== 'object') return null;
1014
+ const value = state;
1015
+ if (!value.__bobeRouter || value.historyKey !== this.historyKey) return null;
1016
+ if (typeof value.entryId !== 'string' || typeof value.index !== 'number') return null;
1017
+ return value;
1018
+ }
1019
+ #createHistoryState(entry, index) {
1020
+ return {
1021
+ __bobeRouter: true,
1022
+ historyKey: this.historyKey,
1023
+ entryId: entry.id,
1024
+ index,
1025
+ url: entry.url
1026
+ };
1027
+ }
1028
+ #createRouteEntry(routeMatch, id = this.#createEntryId()) {
1029
+ return {
1030
+ id,
1031
+ path: routeMatch.url,
1032
+ url: this.#buildUrl(routeMatch.url, routeMatch.search, routeMatch.hash),
1033
+ hash: routeMatch.hash,
1034
+ params: routeMatch.params,
1035
+ component: routeMatch.record.component,
1036
+ meta: routeMatch.record.meta,
1037
+ layout: routeMatch.record.layout
1038
+ };
1039
+ }
1040
+ #syncRouteRecordToEntry(entry, pattern) {
1041
+ const route = this.routes[pattern];
1042
+ if (!route) return;
1043
+ entry.component = route.component;
1044
+ entry.meta = route.meta;
1045
+ entry.layout = route.layout;
1046
+ }
1047
+ #matchUrl(url) {
1048
+ const result = match(url, this.routes);
1049
+ if (!result) return null;
1050
+ const parsed = this.#parseUrl(url);
1051
+ const record = this.routes[result.path];
1052
+ if (!record) return null;
1053
+ return {
1054
+ pattern: result.path,
1055
+ path: result.path,
1056
+ url: result.url,
1057
+ search: parsed.search,
1058
+ hash: parsed.hash,
1059
+ params: result.params,
1060
+ record
1061
+ };
1062
+ }
1063
+ #parseUrl(url) {
1064
+ const base = this.#hasBrowser() ? location.origin : 'http://localhost';
1065
+ return new URL(url, base);
1066
+ }
1067
+ #buildUrl(path, search = '', hash = '') {
1068
+ return `${path}${search}${hash}`;
1069
+ }
1070
+ #normalizePath(path) {
1071
+ const result = match(path, this.routes);
1072
+ return result?.url || path;
1073
+ }
1074
+ #normalizeUrl(url) {
1075
+ const parsed = this.#parseUrl(url);
1076
+ return this.#buildUrl(this.#normalizePath(parsed.pathname), parsed.search, parsed.hash);
1077
+ }
1078
+ #isSameRouteBase(left, right) {
1079
+ const a = this.#parseUrl(left);
1080
+ const b = this.#parseUrl(right);
1081
+ return this.#normalizePath(a.pathname) === this.#normalizePath(b.pathname) && a.search === b.search;
1082
+ }
1083
+ #toUrlString(url) {
1084
+ return `${url.pathname}${url.search}${url.hash}`;
1085
+ }
1086
+ #isHashOnlyForActive(url) {
1087
+ if (!this.active) return false;
1088
+ return this.#isSameRouteBase(url, this.active.url);
1089
+ }
1090
+ #createEntryId() {
1091
+ this.#entryId++;
1092
+ return `${this.historyKey}:${this.#entryId}`;
1093
+ }
1094
+ #initIdleSet() {
1095
+ for (const path of Object.keys(this.routes)) {
1096
+ if (this.routes[path].status === 'idle') {
1097
+ this.#idleSet.add(path);
1098
+ }
1099
+ }
1100
+ }
1101
+ #hasBrowser() {
1102
+ return typeof window !== 'undefined' && typeof document !== 'undefined' && typeof history !== 'undefined';
1103
+ }
1104
+ }
1105
+
1106
+ exports.Router = Router;
1107
+ exports.createRouteRecord = createRouteRecord;
1108
+ //# sourceMappingURL=bobe-router.cjs.map