@saolabs/client 1.5.2 → 1.7.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.
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/src/core/app/Application.d.ts +2 -0
- package/dist/src/core/app/Application.d.ts.map +1 -1
- package/dist/src/core/app/Application.js +3 -0
- package/dist/src/core/app/Application.js.map +1 -1
- package/dist/src/core/bootstrap/bundle.d.ts +1 -1
- package/dist/src/core/bootstrap/bundle.d.ts.map +1 -1
- package/dist/src/core/bootstrap/bundle.js +14 -1
- package/dist/src/core/bootstrap/bundle.js.map +1 -1
- package/dist/src/core/bootstrap/providers/CoreServiceProvider.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/CoreServiceProvider.js +9 -0
- package/dist/src/core/bootstrap/providers/CoreServiceProvider.js.map +1 -1
- package/dist/src/core/bootstrap/providers/default-providers.js +1 -1
- package/dist/src/core/bootstrap/providers/default-providers.js.map +1 -1
- package/dist/src/core/contracts/ApplicationInterface.d.ts +2 -0
- package/dist/src/core/contracts/ApplicationInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/BundleInterface.d.ts +5 -0
- package/dist/src/core/contracts/BundleInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/HelperInterface.d.ts +3 -0
- package/dist/src/core/contracts/HelperInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/RouterInterface.d.ts +2 -0
- package/dist/src/core/contracts/RouterInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/ViewManagerInterface.d.ts +2 -0
- package/dist/src/core/contracts/ViewManagerInterface.d.ts.map +1 -1
- package/dist/src/core/helpers/ApiClient.d.ts +1 -1
- package/dist/src/core/helpers/ApiClient.d.ts.map +1 -1
- package/dist/src/core/helpers/ApiClient.js +1 -11
- package/dist/src/core/helpers/ApiClient.js.map +1 -1
- package/dist/src/core/helpers/auth.d.ts +4 -0
- package/dist/src/core/helpers/auth.d.ts.map +1 -0
- package/dist/src/core/helpers/auth.js +10 -0
- package/dist/src/core/helpers/auth.js.map +1 -0
- package/dist/src/core/routers/ClientMiddleware.d.ts +45 -0
- package/dist/src/core/routers/ClientMiddleware.d.ts.map +1 -0
- package/dist/src/core/routers/ClientMiddleware.js +113 -0
- package/dist/src/core/routers/ClientMiddleware.js.map +1 -0
- package/dist/src/core/routers/Router.d.ts +6 -0
- package/dist/src/core/routers/Router.d.ts.map +1 -1
- package/dist/src/core/routers/Router.js +61 -5
- package/dist/src/core/routers/Router.js.map +1 -1
- package/dist/src/core/services/AuthClient.d.ts +54 -0
- package/dist/src/core/services/AuthClient.d.ts.map +1 -0
- package/dist/src/core/services/AuthClient.js +155 -0
- package/dist/src/core/services/AuthClient.js.map +1 -0
- package/dist/src/core/services/AuthService.d.ts +70 -0
- package/dist/src/core/services/AuthService.d.ts.map +1 -0
- package/dist/src/core/services/AuthService.js +157 -0
- package/dist/src/core/services/AuthService.js.map +1 -0
- package/dist/src/core/services/HelperService.d.ts +4 -0
- package/dist/src/core/services/HelperService.d.ts.map +1 -1
- package/dist/src/core/services/HelperService.js +9 -0
- package/dist/src/core/services/HelperService.js.map +1 -1
- package/dist/src/core/services/HttpService.d.ts.map +1 -1
- package/dist/src/core/services/HttpService.js +20 -0
- package/dist/src/core/services/HttpService.js.map +1 -1
- package/dist/src/core/services/ReactiveDependency.d.ts +15 -0
- package/dist/src/core/services/ReactiveDependency.d.ts.map +1 -0
- package/dist/src/core/services/ReactiveDependency.js +46 -0
- package/dist/src/core/services/ReactiveDependency.js.map +1 -0
- package/dist/src/core/services/index.d.ts +2 -0
- package/dist/src/core/services/index.d.ts.map +1 -1
- package/dist/src/core/services/index.js +2 -0
- package/dist/src/core/services/index.js.map +1 -1
- package/dist/src/core/view/ViewController.d.ts +2 -0
- package/dist/src/core/view/ViewController.d.ts.map +1 -1
- package/dist/src/core/view/ViewController.js +3 -0
- package/dist/src/core/view/ViewController.js.map +1 -1
- package/dist/src/core/view/ViewManager.d.ts +2 -0
- package/dist/src/core/view/ViewManager.d.ts.map +1 -1
- package/dist/src/core/view/ViewManager.js +23 -0
- package/dist/src/core/view/ViewManager.js.map +1 -1
- package/dist/src/core/view/ViewState.d.ts +1 -0
- package/dist/src/core/view/ViewState.d.ts.map +1 -1
- package/dist/src/core/view/ViewState.js +37 -2
- package/dist/src/core/view/ViewState.js.map +1 -1
- package/index.ts +10 -0
- package/package.json +1 -1
- package/src/core/app/Application.ts +6 -1
- package/src/core/bootstrap/bundle.ts +10 -1
- package/src/core/bootstrap/providers/CoreServiceProvider.ts +7 -0
- package/src/core/bootstrap/providers/default-providers.ts +1 -1
- package/src/core/contracts/ApplicationInterface.ts +2 -0
- package/src/core/contracts/BundleInterface.ts +5 -0
- package/src/core/contracts/HelperInterface.ts +3 -0
- package/src/core/contracts/RouterInterface.ts +2 -0
- package/src/core/contracts/ViewManagerInterface.ts +2 -0
- package/src/core/helpers/ApiClient.ts +1 -11
- package/src/core/helpers/auth.ts +10 -0
- package/src/core/routers/ClientMiddleware.ts +124 -0
- package/src/core/routers/Router.ts +56 -5
- package/src/core/services/AuthClient.ts +163 -0
- package/src/core/services/AuthService.ts +177 -0
- package/src/core/services/HelperService.ts +10 -0
- package/src/core/services/HttpService.ts +21 -0
- package/src/core/services/ReactiveDependency.ts +46 -0
- package/src/core/services/index.ts +2 -0
- package/src/core/view/ViewController.ts +3 -0
- package/src/core/view/ViewManager.ts +23 -0
- package/src/core/view/ViewState.ts +34 -3
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type {ApplicationInterface} from '../contracts/ApplicationInterface.js';
|
|
2
|
+
import type {ActiveRoute, Route, Router} from './Router.js';
|
|
3
|
+
|
|
4
|
+
export interface ClientMiddlewareContext {
|
|
5
|
+
app: ApplicationInterface;
|
|
6
|
+
router: Router;
|
|
7
|
+
to: Route;
|
|
8
|
+
from: ActiveRoute | null;
|
|
9
|
+
/** Complete local destination, including query and fragment. */
|
|
10
|
+
url: string;
|
|
11
|
+
signal: AbortSignal;
|
|
12
|
+
}
|
|
13
|
+
export type ClientMiddlewareResult = void | boolean | {redirect: string; replace?: boolean};
|
|
14
|
+
export type ClientMiddleware = (context: ClientMiddlewareContext, ...parameters: string[]) => ClientMiddlewareResult | Promise<ClientMiddlewareResult>;
|
|
15
|
+
export type ClientMiddlewareHandler = ClientMiddleware | {handle: ClientMiddleware};
|
|
16
|
+
export type ClientMiddlewareRef = string | ClientMiddlewareHandler;
|
|
17
|
+
export interface ClientMiddlewareConfig {
|
|
18
|
+
aliases?: Record<string, ClientMiddlewareHandler>;
|
|
19
|
+
groups?: Record<string, readonly ClientMiddlewareRef[]>;
|
|
20
|
+
global?: readonly ClientMiddlewareRef[];
|
|
21
|
+
/** Optional project-only assignment by exact route name. */
|
|
22
|
+
routes?: Record<string, readonly ClientMiddlewareRef[]>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Per-router registry. Projects register configuration through bootstrap, not the builder. */
|
|
26
|
+
export class ClientMiddlewareRegistry {
|
|
27
|
+
private configs = new Set<ClientMiddlewareConfig>();
|
|
28
|
+
|
|
29
|
+
register(config: ClientMiddlewareConfig): () => void {
|
|
30
|
+
const names = new Set<string>();
|
|
31
|
+
for (const current of [...this.configs, config]) {
|
|
32
|
+
for (const name of [...Object.keys(current.aliases ?? {}), ...Object.keys(current.groups ?? {})]) {
|
|
33
|
+
if (!/^[A-Za-z_][\w.-]*$/.test(name) || names.has(name)) throw new Error(`Duplicate or invalid client middleware alias: ${name}`);
|
|
34
|
+
names.add(name);
|
|
35
|
+
}
|
|
36
|
+
for (const handler of Object.values(current.aliases ?? {})) this.assertHandler(handler);
|
|
37
|
+
}
|
|
38
|
+
const installed = {
|
|
39
|
+
aliases: {...config.aliases},
|
|
40
|
+
groups: Object.fromEntries(Object.entries(config.groups ?? {}).map(([name, refs]) => [name, [...refs]])),
|
|
41
|
+
global: [...config.global ?? []],
|
|
42
|
+
routes: Object.fromEntries(Object.entries(config.routes ?? {}).map(([name, refs]) => [name, [...refs]])),
|
|
43
|
+
};
|
|
44
|
+
this.configs.add(installed);
|
|
45
|
+
return () => { this.configs.delete(installed); };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
has(route: Route): boolean { return this.configs.size > 0 || route.meta?.middleware !== undefined; }
|
|
49
|
+
clear(): void { this.configs.clear(); }
|
|
50
|
+
|
|
51
|
+
async run(context: ClientMiddlewareContext): Promise<ClientMiddlewareResult> {
|
|
52
|
+
const aliases: Record<string, ClientMiddlewareHandler> = Object.create(null);
|
|
53
|
+
const groups: Record<string, readonly ClientMiddlewareRef[]> = Object.create(null);
|
|
54
|
+
const global: ClientMiddlewareRef[] = [], local: ClientMiddlewareRef[] = [];
|
|
55
|
+
for (const config of this.configs) {
|
|
56
|
+
Object.assign(aliases, config.aliases); Object.assign(groups, config.groups);
|
|
57
|
+
global.push(...config.global ?? []);
|
|
58
|
+
local.push(...(context.to.name ? config.routes?.[context.to.name] ?? [] : []));
|
|
59
|
+
}
|
|
60
|
+
const declared = context.to.meta?.middleware ?? [];
|
|
61
|
+
if (!Array.isArray(declared)) throw new TypeError('Route meta.middleware must be an array.');
|
|
62
|
+
const pipeline: Array<{handler: ClientMiddlewareHandler; parameters: string[]}> = [];
|
|
63
|
+
const expand = (ref: ClientMiddlewareRef, stack: string[] = []) => {
|
|
64
|
+
if (typeof ref !== 'string') { this.assertHandler(ref); pipeline.push({handler:ref, parameters:[]}); return; }
|
|
65
|
+
const colon = ref.indexOf(':');
|
|
66
|
+
const name = colon < 0 ? ref : ref.slice(0, colon);
|
|
67
|
+
const parameters = colon < 0 ? [] : ref.slice(colon + 1).split(',');
|
|
68
|
+
if (Object.prototype.hasOwnProperty.call(groups, name)) {
|
|
69
|
+
if (parameters.length || stack.includes(name)) throw new Error(`Invalid or recursive client middleware group: ${[...stack, name].join(' -> ')}`);
|
|
70
|
+
for (const child of groups[name]) expand(child, [...stack, name]);
|
|
71
|
+
} else {
|
|
72
|
+
if (!Object.prototype.hasOwnProperty.call(aliases, name)) throw new Error(`Unknown client middleware: ${name}`);
|
|
73
|
+
pipeline.push({handler:aliases[name], parameters});
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
for (const ref of [...global, ...declared, ...local]) expand(ref);
|
|
77
|
+
for (const {handler, parameters} of pipeline) {
|
|
78
|
+
if (context.signal.aborted) return false;
|
|
79
|
+
const result = await this.waitForResult(
|
|
80
|
+
typeof handler === 'function' ? handler(context, ...parameters) : handler.handle(context, ...parameters), context.signal);
|
|
81
|
+
if (context.signal.aborted) return false;
|
|
82
|
+
if (result === false) return false;
|
|
83
|
+
if (result && typeof result === 'object' && typeof result.redirect === 'string') return result;
|
|
84
|
+
if (result !== undefined && result !== true) throw new TypeError('Client middleware must return true, false, void or {redirect}.');
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private assertHandler(handler: unknown): asserts handler is ClientMiddlewareHandler {
|
|
90
|
+
if (typeof handler !== 'function' && (!handler || typeof (handler as any).handle !== 'function')) {
|
|
91
|
+
throw new TypeError('Client middleware must be a function or an instance with handle(context).');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A middleware that ignores AbortSignal must not hold up a newer navigation. */
|
|
96
|
+
private waitForResult(value: ClientMiddlewareResult | Promise<ClientMiddlewareResult>, signal: AbortSignal): Promise<ClientMiddlewareResult> {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const abort = () => { signal.removeEventListener('abort', abort); resolve(false); };
|
|
99
|
+
if (signal.aborted) { Promise.resolve(value).catch(() => {}); resolve(false); return; }
|
|
100
|
+
signal.addEventListener('abort', abort, {once:true});
|
|
101
|
+
Promise.resolve(value).then(result => { signal.removeEventListener('abort', abort); resolve(result); },
|
|
102
|
+
error => { signal.removeEventListener('abort', abort); reject(error); });
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface AuthMiddlewareOptions {
|
|
108
|
+
redirect: string | ((context: ClientMiddlewareContext) => string);
|
|
109
|
+
guard?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function authMiddleware(options: AuthMiddlewareOptions): ClientMiddleware {
|
|
113
|
+
return (context, guard = options.guard) => {
|
|
114
|
+
if (context.app.Auth.guard(guard).check()) return true;
|
|
115
|
+
return {redirect: typeof options.redirect === 'function' ? options.redirect(context) : options.redirect};
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function guestMiddleware(options: AuthMiddlewareOptions): ClientMiddleware {
|
|
120
|
+
return (context, guard = options.guard) => {
|
|
121
|
+
if (context.app.Auth.guard(guard).guest()) return true;
|
|
122
|
+
return {redirect: typeof options.redirect === 'function' ? options.redirect(context) : options.redirect};
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { ViewManagerInterface } from "../contracts/ViewManagerInterface.js";
|
|
18
|
+
import {app as application} from '../helpers/app.js';
|
|
19
|
+
import {ClientMiddlewareRegistry, type ClientMiddlewareConfig} from './ClientMiddleware.js';
|
|
18
20
|
|
|
19
21
|
// ─── Types ──────────────────────────────────────────────────────
|
|
20
22
|
|
|
@@ -177,6 +179,9 @@ export class Router {
|
|
|
177
179
|
/** Navigation guards */
|
|
178
180
|
private _beforeEach: NavigationGuard | null = null;
|
|
179
181
|
private navigationGuards = new Set<NavigationGuard>();
|
|
182
|
+
private middleware = new ClientMiddlewareRegistry();
|
|
183
|
+
private middlewareAbort: AbortController | null = null;
|
|
184
|
+
private middlewareRedirects = 0;
|
|
180
185
|
/**
|
|
181
186
|
* NHIỀU hook, không phải một.
|
|
182
187
|
*
|
|
@@ -317,6 +322,9 @@ export class Router {
|
|
|
317
322
|
const { children, ...own } = route;
|
|
318
323
|
const path = Router.joinRoutePath(parentPath, route.path ?? '');
|
|
319
324
|
const meta = { ...parentMeta, ...(route.meta ?? {}) };
|
|
325
|
+
if (parentMeta.middleware && route.meta?.middleware) {
|
|
326
|
+
meta.middleware = [...parentMeta.middleware, ...route.meta.middleware];
|
|
327
|
+
}
|
|
320
328
|
|
|
321
329
|
const flatChildren = children && children.length
|
|
322
330
|
? this.flattenRouteTree(children, path, meta)
|
|
@@ -349,7 +357,9 @@ export class Router {
|
|
|
349
357
|
}
|
|
350
358
|
for (const route of this.flattenRouteTree(routes)) {
|
|
351
359
|
const component = route.component || route.view || '';
|
|
352
|
-
|
|
360
|
+
// Preserve legacy flattened option keys while keeping the named route
|
|
361
|
+
// and its actual meta object available to middleware and guards.
|
|
362
|
+
this.addRoute(route.path, component, {...route.meta, name:route.name, meta:route.meta});
|
|
353
363
|
if (route.name) {
|
|
354
364
|
this.addRouteConfig(route);
|
|
355
365
|
}
|
|
@@ -396,6 +406,9 @@ export class Router {
|
|
|
396
406
|
return () => { this.navigationGuards.delete(guard); };
|
|
397
407
|
}
|
|
398
408
|
|
|
409
|
+
/** Register project aliases/groups/global middleware. Returns a disposer. */
|
|
410
|
+
useMiddleware(config: ClientMiddlewareConfig): () => void { return this.middleware.register(config); }
|
|
411
|
+
|
|
399
412
|
/** Đăng ký hook chạy sau mỗi lần điều hướng. Trả về hàm HUỶ đăng ký. */
|
|
400
413
|
afterEach(hook: AfterNavigationHook): () => void {
|
|
401
414
|
this.afterHooks.add(hook);
|
|
@@ -551,7 +564,7 @@ export class Router {
|
|
|
551
564
|
}
|
|
552
565
|
|
|
553
566
|
const initialPath = this.mode === 'history'
|
|
554
|
-
? (window.location.pathname + window.location.search)
|
|
567
|
+
? (window.location.pathname + window.location.search + window.location.hash)
|
|
555
568
|
: (window.location.hash.substring(1) || this.defaultRoute);
|
|
556
569
|
|
|
557
570
|
// Set initial active route (without mounting view)
|
|
@@ -578,6 +591,7 @@ export class Router {
|
|
|
578
591
|
* Stop the router — remove event listeners.
|
|
579
592
|
*/
|
|
580
593
|
stop(): void {
|
|
594
|
+
this.middlewareAbort?.abort();
|
|
581
595
|
window.removeEventListener('popstate', this._handlePopState);
|
|
582
596
|
window.removeEventListener('hashchange', this._handlePopState);
|
|
583
597
|
document.removeEventListener('click', this._handleAutoNavigation);
|
|
@@ -664,6 +678,7 @@ export class Router {
|
|
|
664
678
|
this.routeCache.clear();
|
|
665
679
|
this._beforeEach = null;
|
|
666
680
|
this.navigationGuards.clear();
|
|
681
|
+
this.middleware.clear();
|
|
667
682
|
this.afterHooks.clear();
|
|
668
683
|
this.currentRoute = null;
|
|
669
684
|
this.liveRegion?.remove();
|
|
@@ -710,7 +725,9 @@ export class Router {
|
|
|
710
725
|
* Đang navigate dở → ghi nhận request MỚI NHẤT, xử lý sau khi xong
|
|
711
726
|
* (không drop im lặng như trước).
|
|
712
727
|
*/
|
|
713
|
-
private requestNavigation(path: string, type: NavigationType): void {
|
|
728
|
+
private requestNavigation(path: string, type: NavigationType, middlewareRedirect = false): void {
|
|
729
|
+
this.middlewareAbort?.abort();
|
|
730
|
+
if (!middlewareRedirect) this.middlewareRedirects = 0;
|
|
714
731
|
const request: NavigationRequest = { id: ++this.navigationSequence, path, type };
|
|
715
732
|
if (this.isNavigating) {
|
|
716
733
|
this.pendingNavigation = request;
|
|
@@ -734,6 +751,8 @@ export class Router {
|
|
|
734
751
|
this.isNavigating = true;
|
|
735
752
|
// Khai ngoài try: `finally` phải nhả được kể cả khi render ném giữa chừng.
|
|
736
753
|
let releaseHeight: () => void = () => {};
|
|
754
|
+
const middlewareAbort = new AbortController();
|
|
755
|
+
this.middlewareAbort = middlewareAbort;
|
|
737
756
|
|
|
738
757
|
try {
|
|
739
758
|
const { pathname, queryString, fragment } = this.splitLocation(path);
|
|
@@ -751,12 +770,40 @@ export class Router {
|
|
|
751
770
|
return;
|
|
752
771
|
}
|
|
753
772
|
|
|
754
|
-
const { route, params } = match;
|
|
773
|
+
const { route: matchedRoute, params } = match;
|
|
774
|
+
let route = matchedRoute;
|
|
755
775
|
const from = this.currentRoute;
|
|
756
776
|
|
|
777
|
+
const viewName = route.component || route.view;
|
|
778
|
+
const vm = this.viewManager ?? this.App?.View;
|
|
779
|
+
const viewMiddleware = viewName ? vm?.getViewMiddleware?.(viewName) ?? [] : [];
|
|
780
|
+
if (viewMiddleware.length) {
|
|
781
|
+
const declared = route.meta?.middleware ?? [];
|
|
782
|
+
if (!Array.isArray(declared)) throw new TypeError('Route meta.middleware must be an array.');
|
|
783
|
+
route = {...route, meta:{...route.meta, middleware:[...declared, ...viewMiddleware]}};
|
|
784
|
+
}
|
|
785
|
+
|
|
757
786
|
// Create ActiveRoute
|
|
758
787
|
const activeRoute = new ActiveRoute(route, normalizedPath, params, query, fragment, uri);
|
|
759
788
|
|
|
789
|
+
// Run before any page fetch, factory, mount/hydration or history commit.
|
|
790
|
+
if (this.middleware.has(route)) {
|
|
791
|
+
const result = await this.middleware.run({app:this.App ?? application(), router:this,
|
|
792
|
+
to:{...route, params, query}, from, url:path, signal:middlewareAbort.signal});
|
|
793
|
+
if (middlewareAbort.signal.aborted || requestId !== this.navigationSequence) return;
|
|
794
|
+
if (result !== true) {
|
|
795
|
+
if (type === 'pop') this.restoreUrlAfterFailedPop(from);
|
|
796
|
+
if (result && typeof result === 'object') {
|
|
797
|
+
const url = new URL(result.redirect, window.location.origin);
|
|
798
|
+
if (url.origin !== window.location.origin || url.username || url.password) throw new Error('Client middleware redirects must stay on the current origin.');
|
|
799
|
+
const destination = url.pathname + url.search + url.hash;
|
|
800
|
+
if (destination === path || ++this.middlewareRedirects > 8) throw new Error('Client middleware redirect loop.');
|
|
801
|
+
this.requestNavigation(destination, result.replace === false ? 'push' : 'replace', true);
|
|
802
|
+
}
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
760
807
|
// Before guard — chạy TRƯỚC khi đụng vào history: guard chặn thì URL giữ nguyên
|
|
761
808
|
if (this._beforeEach) {
|
|
762
809
|
const allow = await this._beforeEach(route, from, normalizedPath);
|
|
@@ -838,8 +885,12 @@ export class Router {
|
|
|
838
885
|
}
|
|
839
886
|
}
|
|
840
887
|
} catch (error) {
|
|
841
|
-
|
|
888
|
+
if (!middlewareAbort.signal.aborted) {
|
|
889
|
+
if (type === 'pop') this.restoreUrlAfterFailedPop(this.currentRoute);
|
|
890
|
+
console.error('[Router] Navigation error:', error);
|
|
891
|
+
}
|
|
842
892
|
} finally {
|
|
893
|
+
if (this.middlewareAbort === middlewareAbort) this.middlewareAbort = null;
|
|
843
894
|
releaseHeight();
|
|
844
895
|
this.isNavigating = false;
|
|
845
896
|
if (requestId === this.navigationSequence) this.activeNavigationUrl = null;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { AuthService, type AuthSnapshot } from './AuthService.js';
|
|
2
|
+
|
|
3
|
+
export interface AuthProviderInfo { name: string; label: string; url: string }
|
|
4
|
+
export interface AuthClientConfig {
|
|
5
|
+
version: 1;
|
|
6
|
+
guard: string;
|
|
7
|
+
csrfToken: string | null;
|
|
8
|
+
usernameField: string;
|
|
9
|
+
endpoints: { session: string; login: string | null; logout: string };
|
|
10
|
+
providers: AuthProviderInfo[];
|
|
11
|
+
}
|
|
12
|
+
export interface AuthClientOptions {
|
|
13
|
+
fetch?: typeof fetch;
|
|
14
|
+
navigate?: (url: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Same-origin session transport; no dependency on Router, View or a context API. */
|
|
18
|
+
export class AuthClient {
|
|
19
|
+
private config: AuthClientConfig | null = null;
|
|
20
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
21
|
+
private requests = new Set<AbortController>();
|
|
22
|
+
private disposed = false;
|
|
23
|
+
private generation = 0;
|
|
24
|
+
|
|
25
|
+
constructor(private readonly auth: AuthService, private readonly options: AuthClientOptions = {}) {}
|
|
26
|
+
|
|
27
|
+
configure(config: AuthClientConfig | null): this {
|
|
28
|
+
if (config !== null) {
|
|
29
|
+
if (config.version !== 1 || typeof config.guard !== 'string' || !config.guard
|
|
30
|
+
|| typeof config.usernameField !== 'string' || !config.usernameField
|
|
31
|
+
|| (config.csrfToken !== null && typeof config.csrfToken !== 'string')
|
|
32
|
+
|| !Array.isArray(config.providers)) throw new TypeError('Invalid Auth API configuration.');
|
|
33
|
+
this.localUrl(config.endpoints?.session);
|
|
34
|
+
this.localUrl(config.endpoints?.logout);
|
|
35
|
+
if (config.endpoints.login !== null) this.localUrl(config.endpoints.login);
|
|
36
|
+
for (const provider of config.providers) {
|
|
37
|
+
if (typeof provider.name !== 'string' || !provider.name || typeof provider.label !== 'string') {
|
|
38
|
+
throw new TypeError('Invalid auth provider.');
|
|
39
|
+
}
|
|
40
|
+
this.localUrl(provider.url);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
this.config = config === null ? null : structuredClone(config);
|
|
44
|
+
this.generation++;
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get available(): boolean { return this.config !== null && !this.disposed; }
|
|
49
|
+
get csrfToken(): string | null { return this.config?.csrfToken ?? null; }
|
|
50
|
+
get usernameField(): string | null { return this.config?.usernameField ?? null; }
|
|
51
|
+
get providers(): AuthProviderInfo[] { return structuredClone(this.config?.providers ?? []); }
|
|
52
|
+
|
|
53
|
+
login(credentials: Record<string, string | boolean>): Promise<AuthSnapshot> {
|
|
54
|
+
// Do not retain the caller's mutable object while waiting for another operation.
|
|
55
|
+
return this.enqueue('login', { ...credentials });
|
|
56
|
+
}
|
|
57
|
+
logout(): Promise<AuthSnapshot> { return this.enqueue('logout'); }
|
|
58
|
+
refresh(): Promise<AuthSnapshot> { return this.enqueue('session'); }
|
|
59
|
+
|
|
60
|
+
loginUrl(provider: string, options: { next?: string } = {}): string {
|
|
61
|
+
this.assertAvailable();
|
|
62
|
+
const item = this.config!.providers.find(item => item.name === provider);
|
|
63
|
+
if (!item) throw new Error(`Auth provider "${provider}" is not enabled.`);
|
|
64
|
+
const url = this.localUrl(item.url);
|
|
65
|
+
const next = this.localUrl(options.next ?? (window.location.pathname + window.location.search + window.location.hash));
|
|
66
|
+
url.searchParams.set('next', next.pathname + next.search + next.hash);
|
|
67
|
+
return url.href;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** OAuth redirects through the server; callback restores auth via a fresh SSR bootstrap. */
|
|
71
|
+
loginWith(provider: string, options: { next?: string } = {}): void {
|
|
72
|
+
const url = this.loginUrl(provider, options);
|
|
73
|
+
(this.options.navigate ?? (url => window.location.assign(url)))(url);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private enqueue(action: 'session' | 'login' | 'logout', body?: object): Promise<AuthSnapshot> {
|
|
77
|
+
const work = this.queue.then(async () => {
|
|
78
|
+
this.assertAvailable();
|
|
79
|
+
if (!this.config!.endpoints[action]) throw new Error('Password login is not enabled.');
|
|
80
|
+
const finish = action === 'session' ? () => {} : this.auth.beginClientTransition();
|
|
81
|
+
try {
|
|
82
|
+
// A refresh obtains the cookie/CSRF token when boot ran without a session.
|
|
83
|
+
if (action !== 'session' && !this.config!.csrfToken) await this.request('session');
|
|
84
|
+
return await this.request(action, body);
|
|
85
|
+
} finally { finish(); }
|
|
86
|
+
});
|
|
87
|
+
// A failed login must not poison later logout/refresh operations.
|
|
88
|
+
this.queue = work.catch(() => {});
|
|
89
|
+
return work;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private async request(action: 'session' | 'login' | 'logout', body?: object): Promise<AuthSnapshot> {
|
|
93
|
+
this.assertAvailable();
|
|
94
|
+
const config = this.config!;
|
|
95
|
+
const url = this.localUrl(config.endpoints[action]!);
|
|
96
|
+
const revision = this.auth.revision;
|
|
97
|
+
const generation = this.generation;
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
this.requests.add(controller);
|
|
100
|
+
const timeout = setTimeout(() => controller.abort(), 30000);
|
|
101
|
+
try {
|
|
102
|
+
const headers: Record<string, string> = { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' };
|
|
103
|
+
if (action !== 'session') {
|
|
104
|
+
if (!config.csrfToken) throw new Error('Auth API did not provide a CSRF token.');
|
|
105
|
+
headers['X-CSRF-TOKEN'] = config.csrfToken;
|
|
106
|
+
headers['Content-Type'] = 'application/json';
|
|
107
|
+
}
|
|
108
|
+
const response = await (this.options.fetch ?? fetch)(url.href, {
|
|
109
|
+
method: action === 'session' ? 'GET' : 'POST', headers,
|
|
110
|
+
credentials: 'same-origin', mode: 'same-origin', redirect: 'error', cache: 'no-store',
|
|
111
|
+
signal: controller.signal,
|
|
112
|
+
...(action === 'session' ? {} : {body: JSON.stringify(body ?? {})}),
|
|
113
|
+
});
|
|
114
|
+
if (response.url) this.localUrl(response.url);
|
|
115
|
+
const payload = await response.json();
|
|
116
|
+
controller.signal.throwIfAborted();
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
throw Object.assign(new Error(typeof payload?.message === 'string' ? payload.message : `Authentication request failed (${response.status}).`),
|
|
119
|
+
{status: response.status, errors: payload?.errors});
|
|
120
|
+
}
|
|
121
|
+
if (this.disposed || generation !== this.generation || revision !== this.auth.revision) {
|
|
122
|
+
throw Object.assign(new Error('Authentication changed while the request was pending.'), {name: 'AbortError'});
|
|
123
|
+
}
|
|
124
|
+
// Validate the next configuration before committing either state or CSRF.
|
|
125
|
+
const next = new AuthClient(this.auth);
|
|
126
|
+
next.configure(payload.authClient);
|
|
127
|
+
if (!payload.authClient) throw new TypeError('Missing Auth API configuration.');
|
|
128
|
+
new AuthService().initialize(payload.auth); // deep validation without emitting or changing current auth
|
|
129
|
+
// Subscribers may immediately send a request. Publish the new CSRF first.
|
|
130
|
+
this.configure(payload.authClient);
|
|
131
|
+
const meta = typeof document === 'undefined' ? null : document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]');
|
|
132
|
+
if (meta && this.config?.csrfToken) meta.content = this.config.csrfToken;
|
|
133
|
+
this.auth.sync(payload.auth, action === 'session' ? 'sync' : action);
|
|
134
|
+
return payload.auth;
|
|
135
|
+
} finally {
|
|
136
|
+
clearTimeout(timeout);
|
|
137
|
+
this.requests.delete(controller);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private localUrl(value: string): URL {
|
|
142
|
+
if (typeof window === 'undefined' || typeof value !== 'string' || !value.trim()) {
|
|
143
|
+
throw new TypeError('Auth API requires a browser and a same-origin URL.');
|
|
144
|
+
}
|
|
145
|
+
const url = new URL(value, window.location.origin);
|
|
146
|
+
if (url.origin !== window.location.origin || !['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
147
|
+
throw new TypeError('Auth API URLs must use the current origin.');
|
|
148
|
+
}
|
|
149
|
+
return url;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private assertAvailable(): void {
|
|
153
|
+
if (!this.available) throw new Error('Auth API is not configured or has been destroyed.');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
destroy(): void {
|
|
157
|
+
this.disposed = true;
|
|
158
|
+
this.generation++;
|
|
159
|
+
for (const controller of this.requests) controller.abort();
|
|
160
|
+
this.requests.clear();
|
|
161
|
+
this.config = null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { trackDependency } from './ReactiveDependency.js';
|
|
2
|
+
import { AuthClient } from './AuthClient.js';
|
|
3
|
+
|
|
4
|
+
export type AuthUser = Readonly<{ id: string | number; [key: string]: any }>;
|
|
5
|
+
export interface AuthSnapshot {
|
|
6
|
+
version: 1;
|
|
7
|
+
defaultGuard: string;
|
|
8
|
+
guards: Record<string, { user: AuthUser | null }>;
|
|
9
|
+
}
|
|
10
|
+
export interface AuthChange {
|
|
11
|
+
guard: string;
|
|
12
|
+
user: AuthUser | null;
|
|
13
|
+
previousUser: AuthUser | null;
|
|
14
|
+
source: 'login' | 'logout' | 'sync';
|
|
15
|
+
}
|
|
16
|
+
export type AuthEvent = 'login' | 'logout' | 'change';
|
|
17
|
+
type Listener = (change: AuthChange) => void;
|
|
18
|
+
type Context = {
|
|
19
|
+
defaultGuard: string;
|
|
20
|
+
users: Map<string, AuthUser | null>;
|
|
21
|
+
instances: Map<string, AuthService>;
|
|
22
|
+
listeners: Set<{ guard: string | null; event: AuthEvent; callback: Listener }>;
|
|
23
|
+
revision: number;
|
|
24
|
+
client?: AuthClient;
|
|
25
|
+
transitions: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Client view of server authentication. login/logout record successful server actions; they make no HTTP requests. */
|
|
29
|
+
export class AuthService {
|
|
30
|
+
private readonly context: Context;
|
|
31
|
+
private readonly guardName: string | null;
|
|
32
|
+
|
|
33
|
+
constructor(user: AuthUser | null = null, defaultGuard = 'web', context?: Context, guardName: string | null = null) {
|
|
34
|
+
this.context = context ?? {
|
|
35
|
+
defaultGuard, users: new Map([[defaultGuard, AuthService.copyUser(user)]]),
|
|
36
|
+
instances: new Map(), listeners: new Set(), revision: 0, transitions: 0,
|
|
37
|
+
};
|
|
38
|
+
this.guardName = guardName;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static fromSnapshot(snapshot?: unknown): AuthService {
|
|
42
|
+
const service = new AuthService();
|
|
43
|
+
if (snapshot !== undefined) service.applySnapshot(snapshot, true);
|
|
44
|
+
return service;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get revision(): number { return this.context.revision; }
|
|
48
|
+
get client(): AuthClient { return this.context.client ??= new AuthClient(this); }
|
|
49
|
+
get isTransitioning(): boolean { return this.context.transitions > 0; }
|
|
50
|
+
/** Internal transport barrier: page responses cannot overwrite an in-flight login/logout. */
|
|
51
|
+
beginClientTransition(): () => void {
|
|
52
|
+
this.context.revision++;
|
|
53
|
+
this.context.transitions++;
|
|
54
|
+
let finished = false;
|
|
55
|
+
return () => {
|
|
56
|
+
if (finished) return;
|
|
57
|
+
finished = true;
|
|
58
|
+
this.context.transitions--;
|
|
59
|
+
this.context.revision++;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
private get name(): string { return this.guardName ?? this.context.defaultGuard; }
|
|
63
|
+
|
|
64
|
+
guard(name?: string | null): AuthService {
|
|
65
|
+
if (name == null) return this;
|
|
66
|
+
if (!name.trim()) throw new TypeError('An auth guard must have a name.');
|
|
67
|
+
let guard = this.context.instances.get(name);
|
|
68
|
+
if (!guard) {
|
|
69
|
+
guard = new AuthService(null, this.context.defaultGuard, this.context, name);
|
|
70
|
+
this.context.instances.set(name, guard);
|
|
71
|
+
}
|
|
72
|
+
return guard;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
check(): boolean { return this.user() !== null; }
|
|
76
|
+
guest(): boolean { return !this.check(); }
|
|
77
|
+
id(): string | number | null { return this.user()?.id ?? null; }
|
|
78
|
+
user<T extends AuthUser = AuthUser>(): T | null {
|
|
79
|
+
trackDependency(this);
|
|
80
|
+
return (this.context.users.get(this.name) ?? null) as T | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Call only after the server accepted login (including registration/OAuth). */
|
|
84
|
+
login(user: AuthUser): this { return this.setUser(user, 'login'); }
|
|
85
|
+
/** Call only after the server confirmed logout or session expiry. */
|
|
86
|
+
logout(): this { return this.setUser(null, 'logout'); }
|
|
87
|
+
|
|
88
|
+
setUser(user: AuthUser | null, source: AuthChange['source'] = 'sync'): this {
|
|
89
|
+
const next = AuthService.copyUser(user);
|
|
90
|
+
const previous = this.user();
|
|
91
|
+
// Even a repeated explicit login/logout invalidates older in-flight responses.
|
|
92
|
+
if (source !== 'sync' || !AuthService.sameUser(previous, next)) this.context.revision++;
|
|
93
|
+
this.context.users.set(this.name, next);
|
|
94
|
+
this.emit(this.name, previous, next, source);
|
|
95
|
+
return this;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Authoritative page-data response. A snapshot replaces guards omitted by the server too. */
|
|
99
|
+
sync(snapshot: unknown, source: AuthChange['source'] = 'sync'): this { return this.applySnapshot(snapshot, false, source); }
|
|
100
|
+
/** Bootstrap only: restore the server session without reporting a new login. */
|
|
101
|
+
initialize(snapshot: unknown): this { return this.applySnapshot(snapshot, true); }
|
|
102
|
+
|
|
103
|
+
on(event: AuthEvent, callback: Listener): () => void {
|
|
104
|
+
const listener = { guard: this.guardName, event, callback };
|
|
105
|
+
this.context.listeners.add(listener);
|
|
106
|
+
return () => { this.context.listeners.delete(listener); };
|
|
107
|
+
}
|
|
108
|
+
subscribe(callback: Listener): () => void { return this.on('change', callback); }
|
|
109
|
+
destroy(): void { this.context.client?.destroy(); this.context.revision++; this.context.listeners.clear(); this.context.instances.clear(); this.context.users.clear(); }
|
|
110
|
+
|
|
111
|
+
private applySnapshot(snapshot: unknown, silent: boolean, source: AuthChange['source'] = 'sync'): this {
|
|
112
|
+
if (!AuthService.isSnapshot(snapshot)) throw new TypeError('Invalid auth snapshot.');
|
|
113
|
+
// Validate everything before replacing any current state.
|
|
114
|
+
const users = new Map(Object.entries(snapshot.guards).map(([name, guard]) => [name, AuthService.copyUser(guard.user)]));
|
|
115
|
+
const previous = this.context.users;
|
|
116
|
+
const previousDefault = this.context.defaultGuard;
|
|
117
|
+
this.context.users = users;
|
|
118
|
+
this.context.defaultGuard = snapshot.defaultGuard;
|
|
119
|
+
if (source !== 'sync' || previousDefault !== snapshot.defaultGuard || [...new Set([...previous.keys(), ...users.keys()])]
|
|
120
|
+
.some(name => !AuthService.sameUser(previous.get(name) ?? null, users.get(name) ?? null))) this.context.revision++;
|
|
121
|
+
if (!silent) {
|
|
122
|
+
for (const name of new Set([...previous.keys(), ...users.keys()])) {
|
|
123
|
+
this.emit(name, previous.get(name) ?? null, users.get(name) ?? null, source);
|
|
124
|
+
}
|
|
125
|
+
if (previousDefault !== snapshot.defaultGuard && AuthService.sameUser(previous.get(snapshot.defaultGuard) ?? null, users.get(snapshot.defaultGuard) ?? null)) {
|
|
126
|
+
this.emit(snapshot.defaultGuard, previous.get(previousDefault) ?? null, users.get(snapshot.defaultGuard) ?? null, source);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private emit(guard: string, previousUser: AuthUser | null, user: AuthUser | null, source: AuthChange['source']): void {
|
|
133
|
+
if (AuthService.sameUser(previousUser, user)) return;
|
|
134
|
+
const change = { guard, previousUser, user, source };
|
|
135
|
+
const identityChanged = previousUser?.id !== user?.id;
|
|
136
|
+
const events: AuthEvent[] = [];
|
|
137
|
+
if (identityChanged && previousUser) events.push('logout');
|
|
138
|
+
if (identityChanged && user) events.push('login');
|
|
139
|
+
events.push('change');
|
|
140
|
+
for (const event of events) {
|
|
141
|
+
for (const listener of [...this.context.listeners]) {
|
|
142
|
+
if (!this.context.listeners.has(listener) || listener.event !== event
|
|
143
|
+
|| (listener.guard !== null && listener.guard !== guard)) continue;
|
|
144
|
+
try { listener.callback(change); }
|
|
145
|
+
catch (error) { console.error('[AuthService] Listener error:', error); }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static isSnapshot(value: any): value is AuthSnapshot {
|
|
151
|
+
return value?.version === 1 && typeof value.defaultGuard === 'string' && value.defaultGuard.length > 0
|
|
152
|
+
&& value.guards !== null && typeof value.guards === 'object' && !Array.isArray(value.guards)
|
|
153
|
+
&& Object.prototype.hasOwnProperty.call(value.guards, value.defaultGuard)
|
|
154
|
+
&& Object.values(value.guards).every((guard: any) => guard && Object.prototype.hasOwnProperty.call(guard, 'user')
|
|
155
|
+
&& (guard.user === null || (typeof guard.user === 'object' && !Array.isArray(guard.user)
|
|
156
|
+
&& (typeof guard.user.id === 'string' || typeof guard.user.id === 'number'))));
|
|
157
|
+
}
|
|
158
|
+
private static copyUser(user: AuthUser | null): AuthUser | null {
|
|
159
|
+
if (user === null) return null;
|
|
160
|
+
if (!user || Array.isArray(user) || (typeof user.id !== 'string' && typeof user.id !== 'number')
|
|
161
|
+
|| (typeof user.id === 'number' && !Number.isFinite(user.id))) {
|
|
162
|
+
throw new TypeError('An authenticated user must have an id.');
|
|
163
|
+
}
|
|
164
|
+
const copy = structuredClone(user);
|
|
165
|
+
JSON.stringify(copy); // Reject non-JSON user data before changing any state.
|
|
166
|
+
const seen = new WeakSet<object>();
|
|
167
|
+
const freeze = (value: any): void => {
|
|
168
|
+
if (!value || typeof value !== 'object' || seen.has(value)) return;
|
|
169
|
+
seen.add(value);
|
|
170
|
+
for (const item of Object.values(value)) freeze(item);
|
|
171
|
+
Object.freeze(value);
|
|
172
|
+
};
|
|
173
|
+
freeze(copy);
|
|
174
|
+
return copy;
|
|
175
|
+
}
|
|
176
|
+
private static sameUser(a: AuthUser | null, b: AuthUser | null): boolean { return JSON.stringify(a) === JSON.stringify(b); }
|
|
177
|
+
}
|
|
@@ -2,6 +2,7 @@ import { TranslationService, type TranslationConfig } from './TranslationService
|
|
|
2
2
|
import { CollectionProxyInterface, HelperInterface } from "../contracts/HelperInterface.js";
|
|
3
3
|
import { ApplicationInterface } from "../contracts/ApplicationInterface.js";
|
|
4
4
|
import { app } from "../helpers/app.js";
|
|
5
|
+
import { AuthService } from './AuthService.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* HelperService — PHP-compatible helper functions for SaoView runtime.
|
|
@@ -46,6 +47,15 @@ export class HelperService implements HelperInterface {
|
|
|
46
47
|
if (App) this.App = App;
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
auth(guard?: string | null): AuthService {
|
|
51
|
+
const application = this.App ?? app();
|
|
52
|
+
if (!application.has('Auth')) application.instance('Auth', AuthService.fromSnapshot(application.shared('auth')));
|
|
53
|
+
return application.get('Auth').guard(guard);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Compatibility for previously compiled @auth/@guest views. */
|
|
57
|
+
isAuth(guard?: string | null): boolean { return this.auth(guard).check(); }
|
|
58
|
+
|
|
49
59
|
app<T = any>(key?: any, value?: any): T | ApplicationInterface
|
|
50
60
|
{
|
|
51
61
|
return app(key, value);
|