ui5-lib-guard-router 1.0.1

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.
Files changed (33) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +382 -0
  3. package/dist/index.d.ts +27 -0
  4. package/dist/resources/ui5/guard/router/.library +17 -0
  5. package/dist/resources/ui5/guard/router/Router-dbg.js +431 -0
  6. package/dist/resources/ui5/guard/router/Router-dbg.js.map +1 -0
  7. package/dist/resources/ui5/guard/router/Router.d.ts +28 -0
  8. package/dist/resources/ui5/guard/router/Router.d.ts.map +1 -0
  9. package/dist/resources/ui5/guard/router/Router.js +2 -0
  10. package/dist/resources/ui5/guard/router/Router.js.map +1 -0
  11. package/dist/resources/ui5/guard/router/library-dbg.js +17 -0
  12. package/dist/resources/ui5/guard/router/library-dbg.js.map +1 -0
  13. package/dist/resources/ui5/guard/router/library-preload.js +10 -0
  14. package/dist/resources/ui5/guard/router/library-preload.js.map +1 -0
  15. package/dist/resources/ui5/guard/router/library.d.ts +7 -0
  16. package/dist/resources/ui5/guard/router/library.d.ts.map +1 -0
  17. package/dist/resources/ui5/guard/router/library.js +2 -0
  18. package/dist/resources/ui5/guard/router/library.js.map +1 -0
  19. package/dist/resources/ui5/guard/router/manifest.json +33 -0
  20. package/dist/resources/ui5/guard/router/types-dbg.js +2 -0
  21. package/dist/resources/ui5/guard/router/types-dbg.js.map +1 -0
  22. package/dist/resources/ui5/guard/router/types.d.ts +118 -0
  23. package/dist/resources/ui5/guard/router/types.d.ts.map +1 -0
  24. package/dist/resources/ui5/guard/router/types.js +2 -0
  25. package/dist/resources/ui5/guard/router/types.js.map +1 -0
  26. package/package.json +52 -0
  27. package/src/.library +17 -0
  28. package/src/Router.ts +540 -0
  29. package/src/library.ts +17 -0
  30. package/src/manifest.json +33 -0
  31. package/src/types.ts +136 -0
  32. package/tsconfig.json +13 -0
  33. package/ui5.yaml +24 -0
package/src/Router.ts ADDED
@@ -0,0 +1,540 @@
1
+ import MobileRouter from "sap/m/routing/Router";
2
+ import Log from "sap/base/Log";
3
+ import coreLibrary from "sap/ui/core/library";
4
+ import type {
5
+ GuardFn,
6
+ GuardContext,
7
+ GuardResult,
8
+ GuardRedirect,
9
+ GuardRouter,
10
+ LeaveGuardFn,
11
+ RouteGuardConfig,
12
+ RouterInternal,
13
+ } from "./types";
14
+
15
+ const HistoryDirection = coreLibrary.routing.HistoryDirection;
16
+
17
+ const LOG_COMPONENT = "ui5.guard.router.Router";
18
+
19
+ function isGuardRedirect(value: GuardResult): value is GuardRedirect {
20
+ return typeof value === "object" && value !== null;
21
+ }
22
+
23
+ function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
24
+ return value instanceof Promise;
25
+ }
26
+
27
+ function isRouteGuardConfig(guard: GuardFn | RouteGuardConfig): guard is RouteGuardConfig {
28
+ return typeof guard === "object";
29
+ }
30
+
31
+ function addToGuardMap<T>(map: Map<string, T[]>, key: string, guard: T): void {
32
+ let guards = map.get(key);
33
+ if (!guards) {
34
+ guards = [];
35
+ map.set(key, guards);
36
+ }
37
+ guards.push(guard);
38
+ }
39
+
40
+ function removeFromGuardMap<T>(map: Map<string, T[]>, key: string, guard: T): void {
41
+ const guards = map.get(key);
42
+ if (!guards) return;
43
+ const index = guards.indexOf(guard);
44
+ if (index !== -1) guards.splice(index, 1);
45
+ if (guards.length === 0) map.delete(key);
46
+ }
47
+
48
+ /**
49
+ * Router with navigation guard support.
50
+ *
51
+ * Extends `sap.m.routing.Router` by overriding `parse()` to run
52
+ * registered guard functions before any route matching, target loading,
53
+ * or event firing occurs.
54
+ *
55
+ * Key assumptions (see docs/architecture.md for full rationale):
56
+ * - `parse()` is intentionally NOT async. Sync guards execute in the
57
+ * same tick; async guards fall back to a deferred path.
58
+ * - `replaceHash` fires `hashChanged` synchronously (validated by test).
59
+ * - Redirect targets bypass guards to prevent infinite loops.
60
+ *
61
+ * @extends sap.m.routing.Router
62
+ */
63
+ const Router = MobileRouter.extend("ui5.guard.router.Router", {
64
+ constructor: function (this: RouterInternal, ...args: unknown[]) {
65
+ MobileRouter.prototype.constructor.apply(this, args);
66
+ this._globalGuards = [];
67
+ this._enterGuards = new Map<string, GuardFn[]>();
68
+ this._leaveGuards = new Map<string, LeaveGuardFn[]>();
69
+ this._currentRoute = "";
70
+ this._currentHash = null; // null = no parse processed yet
71
+ this._pendingHash = null;
72
+ this._redirecting = false;
73
+ this._parseGeneration = 0;
74
+ this._suppressNextParse = false;
75
+ this._abortController = null;
76
+ },
77
+
78
+ /**
79
+ * Register a global guard that runs for every navigation.
80
+ */
81
+ addGuard(this: RouterInternal, guard: GuardFn): GuardRouter {
82
+ this._globalGuards.push(guard);
83
+ return this;
84
+ },
85
+
86
+ /**
87
+ * Remove a previously registered global guard.
88
+ */
89
+ removeGuard(this: RouterInternal, guard: GuardFn): GuardRouter {
90
+ const index = this._globalGuards.indexOf(guard);
91
+ if (index !== -1) {
92
+ this._globalGuards.splice(index, 1);
93
+ }
94
+ return this;
95
+ },
96
+
97
+ /**
98
+ * Register a guard for a specific route.
99
+ *
100
+ * Accepts either a guard function (registered as an enter guard) or a
101
+ * configuration object with `beforeEnter` and/or `beforeLeave` guards.
102
+ */
103
+ addRouteGuard(this: RouterInternal, routeName: string, guard: GuardFn | RouteGuardConfig): GuardRouter {
104
+ if (isRouteGuardConfig(guard)) {
105
+ if (!guard.beforeEnter && !guard.beforeLeave) {
106
+ Log.info(
107
+ "addRouteGuard called with config missing both beforeEnter and beforeLeave",
108
+ routeName,
109
+ LOG_COMPONENT,
110
+ );
111
+ }
112
+ if (guard.beforeEnter) {
113
+ this.addRouteGuard(routeName, guard.beforeEnter);
114
+ }
115
+ if (guard.beforeLeave) {
116
+ this.addLeaveGuard(routeName, guard.beforeLeave);
117
+ }
118
+ return this;
119
+ }
120
+ addToGuardMap(this._enterGuards, routeName, guard);
121
+ return this;
122
+ },
123
+
124
+ /**
125
+ * Remove a guard from a specific route.
126
+ *
127
+ * Accepts the same forms as `addRouteGuard`: a guard function removes
128
+ * an enter guard; a configuration object removes `beforeEnter` and/or
129
+ * `beforeLeave` by reference.
130
+ */
131
+ removeRouteGuard(this: RouterInternal, routeName: string, guard: GuardFn | RouteGuardConfig): GuardRouter {
132
+ if (isRouteGuardConfig(guard)) {
133
+ if (guard.beforeEnter) {
134
+ this.removeRouteGuard(routeName, guard.beforeEnter);
135
+ }
136
+ if (guard.beforeLeave) {
137
+ this.removeLeaveGuard(routeName, guard.beforeLeave);
138
+ }
139
+ return this;
140
+ }
141
+ removeFromGuardMap(this._enterGuards, routeName, guard);
142
+ return this;
143
+ },
144
+
145
+ /**
146
+ * Register a leave guard for a specific route.
147
+ *
148
+ * Leave guards run when navigating **away from** the route, before any
149
+ * enter guards for the target route. They answer the binary question
150
+ * "can I leave?" and return only a boolean (no redirects).
151
+ */
152
+ addLeaveGuard(this: RouterInternal, routeName: string, guard: LeaveGuardFn): GuardRouter {
153
+ addToGuardMap(this._leaveGuards, routeName, guard);
154
+ return this;
155
+ },
156
+
157
+ /**
158
+ * Remove a leave guard from a specific route.
159
+ */
160
+ removeLeaveGuard(this: RouterInternal, routeName: string, guard: LeaveGuardFn): GuardRouter {
161
+ removeFromGuardMap(this._leaveGuards, routeName, guard);
162
+ return this;
163
+ },
164
+
165
+ /**
166
+ * Intercept hash changes and run the guard pipeline before route matching.
167
+ *
168
+ * Called by the HashChanger on every `hashChanged` event. Runs leave guards
169
+ * (current route), then global + route-specific enter guards (target route).
170
+ * Stays synchronous when all guards return plain values; falls back to async
171
+ * when a guard returns a Promise. A generation counter discards stale results
172
+ * when navigations overlap.
173
+ *
174
+ * @override sap.ui.core.routing.Router#parse
175
+ */
176
+ parse(this: RouterInternal, newHash: string): void {
177
+ if (this._suppressNextParse) {
178
+ this._suppressNextParse = false;
179
+ return;
180
+ }
181
+
182
+ if (this._redirecting) {
183
+ this._commitNavigation(newHash);
184
+ return;
185
+ }
186
+
187
+ // Same-hash dedup: also invalidates any pending async guard
188
+ if (this._currentHash !== null && newHash === this._currentHash) {
189
+ this._pendingHash = null;
190
+ ++this._parseGeneration;
191
+ this._abortController?.abort();
192
+ this._abortController = null;
193
+ return;
194
+ }
195
+
196
+ // Dedup against in-flight pending navigation
197
+ if (this._pendingHash !== null && newHash === this._pendingHash) {
198
+ return;
199
+ }
200
+
201
+ const routeInfo = this.getRouteInfoByHash(newHash);
202
+ const toRoute = routeInfo?.name ?? "";
203
+
204
+ // Invalidate any pending async guards from a previous navigation
205
+ this._abortController?.abort();
206
+ this._abortController = null;
207
+ const generation = ++this._parseGeneration;
208
+
209
+ this._pendingHash = newHash;
210
+
211
+ // Check if any guards apply (leave OR enter)
212
+ const hasLeaveGuards = this._currentRoute !== "" && this._leaveGuards.has(this._currentRoute);
213
+ const hasEnterGuards = this._globalGuards.length > 0 || (toRoute !== "" && this._enterGuards.has(toRoute));
214
+
215
+ // No guards → fast path
216
+ if (!hasLeaveGuards && !hasEnterGuards) {
217
+ this._commitNavigation(newHash, toRoute);
218
+ return;
219
+ }
220
+
221
+ // Only create a controller when guards will actually run
222
+ this._abortController = new AbortController();
223
+
224
+ const context: GuardContext = {
225
+ toRoute,
226
+ toHash: newHash,
227
+ toArguments: routeInfo?.arguments ?? {},
228
+ fromRoute: this._currentRoute,
229
+ fromHash: this._currentHash ?? "",
230
+ signal: this._abortController.signal,
231
+ };
232
+
233
+ // Run enter guards and apply result (reused after leave guards pass)
234
+ const runEnterGuards = (): void => {
235
+ const enterResult = this._runEnterGuards(this._globalGuards, toRoute, context);
236
+
237
+ if (isPromise(enterResult)) {
238
+ enterResult
239
+ .then((guardResult: GuardResult) => {
240
+ if (generation !== this._parseGeneration) {
241
+ Log.debug(
242
+ "Async enter guard result discarded (superseded by newer navigation)",
243
+ newHash,
244
+ LOG_COMPONENT,
245
+ );
246
+ return;
247
+ }
248
+ // Apply result: true=commit, false=block, other=redirect
249
+ if (guardResult === true) {
250
+ this._commitNavigation(newHash, toRoute);
251
+ } else if (guardResult === false) {
252
+ this._blockNavigation();
253
+ } else {
254
+ this._redirect(guardResult);
255
+ }
256
+ })
257
+ .catch((error: unknown) => {
258
+ if (generation !== this._parseGeneration) return;
259
+ Log.error(
260
+ `Async enter guard for route "${toRoute}" failed, blocking navigation`,
261
+ String(error),
262
+ LOG_COMPONENT,
263
+ );
264
+ this._blockNavigation();
265
+ });
266
+ return;
267
+ }
268
+ // Apply result: true=commit, false=block, other=redirect
269
+ if (enterResult === true) {
270
+ this._commitNavigation(newHash, toRoute);
271
+ } else if (enterResult === false) {
272
+ this._blockNavigation();
273
+ } else {
274
+ this._redirect(enterResult);
275
+ }
276
+ };
277
+
278
+ // Run leave guards first, then enter guards
279
+ if (hasLeaveGuards) {
280
+ const leaveResult = this._runLeaveGuards(context);
281
+
282
+ if (isPromise(leaveResult)) {
283
+ leaveResult
284
+ .then((allowed: boolean) => {
285
+ if (generation !== this._parseGeneration) {
286
+ Log.debug(
287
+ "Async leave guard result discarded (superseded by newer navigation)",
288
+ newHash,
289
+ LOG_COMPONENT,
290
+ );
291
+ return;
292
+ }
293
+ if (allowed !== true) {
294
+ this._blockNavigation();
295
+ return;
296
+ }
297
+ runEnterGuards();
298
+ })
299
+ .catch((error: unknown) => {
300
+ if (generation !== this._parseGeneration) return;
301
+ Log.error(
302
+ `Async leave guard on route "${this._currentRoute}" failed, blocking navigation`,
303
+ String(error),
304
+ LOG_COMPONENT,
305
+ );
306
+ this._blockNavigation();
307
+ });
308
+ return;
309
+ }
310
+ if (leaveResult !== true) {
311
+ this._blockNavigation();
312
+ return;
313
+ }
314
+ }
315
+
316
+ // Enter guards (leave guards passed or were absent)
317
+ runEnterGuards();
318
+ },
319
+
320
+ /**
321
+ * Run leave guards for the current route. Returns boolean (no redirects).
322
+ *
323
+ * The guard array is snapshot-copied before iteration so that guards
324
+ * may safely add/remove themselves (e.g. one-shot guards) without
325
+ * affecting the current pipeline run.
326
+ */
327
+ _runLeaveGuards(this: RouterInternal, context: GuardContext): boolean | Promise<boolean> {
328
+ const registered = this._leaveGuards.get(this._currentRoute);
329
+ if (!registered || registered.length === 0) return true;
330
+
331
+ const guards = registered.slice();
332
+ for (let i = 0; i < guards.length; i++) {
333
+ try {
334
+ const result = guards[i](context);
335
+ if (isPromise(result)) {
336
+ return this._continueGuardsAsync(
337
+ result,
338
+ guards,
339
+ i,
340
+ context,
341
+ () => false,
342
+ "Leave guard",
343
+ true,
344
+ ) as Promise<boolean>;
345
+ }
346
+ if (result !== true) return false;
347
+ } catch (error) {
348
+ Log.error(
349
+ `Leave guard [${i}] on route "${this._currentRoute}" threw, blocking navigation`,
350
+ String(error),
351
+ LOG_COMPONENT,
352
+ );
353
+ return false;
354
+ }
355
+ }
356
+ return true;
357
+ },
358
+
359
+ /**
360
+ * Delegate to the parent router and update internal state.
361
+ *
362
+ * State is updated BEFORE calling parse to ensure that if event handlers
363
+ * (e.g., routeMatched) trigger nested navigation, the leave guards will
364
+ * run for the correct (new) route rather than the old one.
365
+ */
366
+ _commitNavigation(this: RouterInternal, hash: string, route?: string): void {
367
+ this._pendingHash = null;
368
+ this._currentHash = hash;
369
+ this._currentRoute = route ?? this.getRouteInfoByHash(hash)?.name ?? "";
370
+ MobileRouter.prototype.parse.call(this, hash);
371
+ },
372
+
373
+ /** Run global guards, then route-specific guards. Stays sync when possible. */
374
+ _runEnterGuards(
375
+ this: RouterInternal,
376
+ globalGuards: GuardFn[],
377
+ toRoute: string,
378
+ context: GuardContext,
379
+ ): GuardResult | Promise<GuardResult> {
380
+ const globalResult = this._runGuards(globalGuards, context);
381
+
382
+ if (isPromise(globalResult)) {
383
+ return globalResult.then((r: GuardResult) => {
384
+ if (r !== true) return r;
385
+ if (context.signal.aborted) return false;
386
+ return this._runRouteGuards(toRoute, context);
387
+ });
388
+ }
389
+ if (globalResult !== true) return globalResult;
390
+ return this._runRouteGuards(toRoute, context);
391
+ },
392
+
393
+ /** Run route-specific guards if any are registered. */
394
+ _runRouteGuards(this: RouterInternal, toRoute: string, context: GuardContext): GuardResult | Promise<GuardResult> {
395
+ if (!toRoute || !this._enterGuards.has(toRoute)) return true;
396
+ return this._runGuards(this._enterGuards.get(toRoute)!, context);
397
+ },
398
+
399
+ /**
400
+ * Run guards sync; switch to async path if a Promise is returned.
401
+ *
402
+ * The guard array is snapshot-copied before iteration so that guards
403
+ * may safely add/remove themselves (e.g. one-shot guards) without
404
+ * affecting the current pipeline run.
405
+ */
406
+ _runGuards(this: RouterInternal, guards: GuardFn[], context: GuardContext): GuardResult | Promise<GuardResult> {
407
+ guards = guards.slice();
408
+ for (let i = 0; i < guards.length; i++) {
409
+ try {
410
+ const result = guards[i](context);
411
+ if (isPromise(result)) {
412
+ return this._continueGuardsAsync(
413
+ result,
414
+ guards,
415
+ i,
416
+ context,
417
+ (r) => this._validateGuardResult(r),
418
+ "Enter guard",
419
+ false,
420
+ );
421
+ }
422
+ if (result !== true) return this._validateGuardResult(result);
423
+ } catch (error) {
424
+ Log.error(
425
+ `Enter guard [${i}] for route "${context.toRoute}" threw, blocking navigation`,
426
+ String(error),
427
+ LOG_COMPONENT,
428
+ );
429
+ return false;
430
+ }
431
+ }
432
+ return true;
433
+ },
434
+
435
+ /**
436
+ * Continue guard array async from the first Promise onward.
437
+ *
438
+ * Shared by both enter and leave guard pipelines. The `onBlock` callback
439
+ * determines what to return for non-true results: leave guards always
440
+ * return `false`, enter guards validate and may return redirects.
441
+ *
442
+ * @param isLeaveGuard - When true, error logs reference `fromRoute`; otherwise `toRoute`.
443
+ */
444
+ async _continueGuardsAsync(
445
+ this: RouterInternal,
446
+ pendingResult: Promise<GuardResult>,
447
+ guards: Array<(context: GuardContext) => GuardResult | Promise<GuardResult>>,
448
+ currentIndex: number,
449
+ context: GuardContext,
450
+ onBlock: (result: GuardResult) => GuardResult,
451
+ label: string,
452
+ isLeaveGuard: boolean,
453
+ ): Promise<GuardResult> {
454
+ let guardIndex = currentIndex;
455
+ try {
456
+ const result = await pendingResult;
457
+ if (result !== true) return onBlock(result);
458
+
459
+ for (let i = currentIndex + 1; i < guards.length; i++) {
460
+ if (context.signal.aborted) return false;
461
+ guardIndex = i;
462
+ const r = await guards[i](context);
463
+ if (r !== true) return onBlock(r);
464
+ }
465
+ return true;
466
+ } catch (error) {
467
+ if (!context.signal.aborted) {
468
+ const route = isLeaveGuard ? context.fromRoute : context.toRoute;
469
+ Log.error(
470
+ `${label} [${guardIndex}] on route "${route}" threw, blocking navigation`,
471
+ String(error),
472
+ LOG_COMPONENT,
473
+ );
474
+ }
475
+ return false;
476
+ }
477
+ },
478
+
479
+ /** Validate a non-true guard result; invalid values become false. */
480
+ _validateGuardResult(this: RouterInternal, result: GuardResult): GuardResult {
481
+ if (typeof result === "string" || typeof result === "boolean" || isGuardRedirect(result)) {
482
+ return result;
483
+ }
484
+ Log.warning("Guard returned invalid value, treating as block", String(result), LOG_COMPONENT);
485
+ return false;
486
+ },
487
+
488
+ /** Perform a guard redirect (string route name or GuardRedirect object). */
489
+ _redirect(this: RouterInternal, target: string | GuardRedirect): void {
490
+ this._pendingHash = null;
491
+ this._redirecting = true;
492
+ try {
493
+ if (typeof target === "string") {
494
+ this.navTo(target, {}, {}, true);
495
+ } else {
496
+ this.navTo(target.route, target.parameters ?? {}, target.componentTargetInfo, true);
497
+ }
498
+ } finally {
499
+ this._redirecting = false;
500
+ }
501
+ },
502
+
503
+ /** Clear pending state and restore the previous hash. */
504
+ _blockNavigation(this: RouterInternal): void {
505
+ this._pendingHash = null;
506
+ this._restoreHash();
507
+ },
508
+
509
+ /**
510
+ * Restore the previous hash without creating a history entry.
511
+ * Assumes replaceHash fires hashChanged synchronously (validated by test).
512
+ * Note: _currentRoute intentionally stays unchanged — the blocked navigation
513
+ * never committed, so the user remains on the same logical route.
514
+ */
515
+ _restoreHash(this: RouterInternal): void {
516
+ const hashChanger = this.getHashChanger();
517
+ if (hashChanger) {
518
+ this._suppressNextParse = true;
519
+ hashChanger.replaceHash(this._currentHash ?? "", HistoryDirection.Unknown);
520
+ if (this._suppressNextParse) {
521
+ // replaceHash was a no-op (same hash) - reset to prevent leak
522
+ this._suppressNextParse = false;
523
+ }
524
+ }
525
+ },
526
+
527
+ /** Clean up guards on destroy. Bumps generation to discard pending async results. */
528
+ destroy(this: RouterInternal) {
529
+ this._globalGuards = [];
530
+ this._enterGuards.clear();
531
+ this._leaveGuards.clear();
532
+ ++this._parseGeneration;
533
+ this._pendingHash = null;
534
+ this._abortController?.abort();
535
+ this._abortController = null;
536
+ return MobileRouter.prototype.destroy.call(this);
537
+ },
538
+ });
539
+
540
+ export default Router;
package/src/library.ts ADDED
@@ -0,0 +1,17 @@
1
+ import Lib from "sap/ui/core/Lib";
2
+ import "sap/ui/core/library";
3
+ import "sap/m/library";
4
+
5
+ const library = Lib.init({
6
+ apiVersion: 2,
7
+ name: "ui5.guard.router",
8
+ version: "${version}",
9
+ dependencies: ["sap.ui.core", "sap.m"],
10
+ types: [],
11
+ interfaces: [],
12
+ controls: [],
13
+ elements: [],
14
+ noLibraryCSS: true,
15
+ });
16
+
17
+ export default library;
@@ -0,0 +1,33 @@
1
+ {
2
+ "_version": "2.0.0",
3
+ "sap.app": {
4
+ "id": "ui5.guard.router",
5
+ "type": "library",
6
+ "applicationVersion": {
7
+ "version": "1.0.1"
8
+ },
9
+ "title": "UI5 Router extension with async navigation guards",
10
+ "description": "Extends sap.m.routing.Router with async navigation guards, running before route matching begins."
11
+ },
12
+ "sap.ui": {
13
+ "technology": "UI5",
14
+ "deviceTypes": {
15
+ "desktop": true,
16
+ "tablet": true,
17
+ "phone": true
18
+ }
19
+ },
20
+ "sap.ui5": {
21
+ "contentDensities": {
22
+ "compact": true,
23
+ "cozy": true
24
+ },
25
+ "dependencies": {
26
+ "minUI5Version": "1.144.0",
27
+ "libs": {
28
+ "sap.ui.core": {},
29
+ "sap.m": {}
30
+ }
31
+ }
32
+ }
33
+ }