initial commit
This commit is contained in:
514
fluxer_app/src/lib/router/core.ts
Normal file
514
fluxer_app/src/lib/router/core.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createBrowserHistory} from './history';
|
||||
import {
|
||||
type Match,
|
||||
type NavigateOptions,
|
||||
NotFound,
|
||||
Redirect,
|
||||
type Route,
|
||||
type RouteContext,
|
||||
type Router,
|
||||
type RouterOptions,
|
||||
type RouterState,
|
||||
type ScrollBehavior,
|
||||
type To,
|
||||
} from './types';
|
||||
|
||||
export class RouterImpl implements Router {
|
||||
private readonly routes: Array<Route>;
|
||||
private readonly routeById: Map<string, Route>;
|
||||
private readonly history;
|
||||
private readonly options: RouterOptions;
|
||||
private state: RouterState;
|
||||
private listeners = new Set<() => void>();
|
||||
private unsubscribeHistory?: () => void;
|
||||
private preloadCache = new Map<string, Promise<unknown> | 'done'>();
|
||||
|
||||
constructor(options: RouterOptions) {
|
||||
this.options = options;
|
||||
|
||||
this.history = options.history ?? createBrowserHistory();
|
||||
|
||||
this.routes = options.routes.map((cfg) => ({
|
||||
...cfg,
|
||||
pattern:
|
||||
cfg.pattern ?? (cfg.path != null ? new URLPattern({pathname: cfg.path}) : new URLPattern({pathname: '*'})),
|
||||
}));
|
||||
|
||||
this.routeById = new Map(this.routes.map((r) => [r.id, r]));
|
||||
|
||||
const loc = this.history.getLocation();
|
||||
const normalizedInitialUrl = this.normalizeUrl(loc.url);
|
||||
if (normalizedInitialUrl.toString() !== loc.url.toString()) {
|
||||
this.history.replace(normalizedInitialUrl, loc.state);
|
||||
}
|
||||
const matches = this.matchUrl(normalizedInitialUrl);
|
||||
|
||||
this.state = {
|
||||
location: normalizedInitialUrl,
|
||||
matches,
|
||||
navigating: false,
|
||||
pending: null,
|
||||
error: undefined,
|
||||
historyState: loc.state,
|
||||
};
|
||||
|
||||
this.unsubscribeHistory = this.history.listen((location) => {
|
||||
void this.handlePop(location.url, location.state);
|
||||
});
|
||||
|
||||
this.runInitialGuards();
|
||||
}
|
||||
|
||||
getState(): RouterState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
getRoutes(): Array<Route> {
|
||||
return this.routes;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.unsubscribeHistory) {
|
||||
this.unsubscribeHistory();
|
||||
this.unsubscribeHistory = undefined;
|
||||
}
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
private notify() {
|
||||
for (const l of this.listeners) l();
|
||||
}
|
||||
|
||||
private runInitialGuards(): void {
|
||||
const {location, historyState} = this.state;
|
||||
|
||||
void this.transitionTo(location, {
|
||||
replace: true,
|
||||
scroll: 'preserve',
|
||||
historyState,
|
||||
isPop: true,
|
||||
forceEnter: true,
|
||||
}).catch((err) => {
|
||||
if (typeof console !== 'undefined') {
|
||||
console.error('[router] initial guard check failed', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeUrl(url: URL): URL {
|
||||
if (url.pathname.length <= 1 || !url.pathname.endsWith('/')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const normalized = new URL(url.toString());
|
||||
const trimmedPath = url.pathname.replace(/\/+$/, '') || '/';
|
||||
normalized.pathname = trimmedPath;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
resolveTo(to: To, from?: URL): URL {
|
||||
const base = from ?? this.state.location ?? new URL(window.location.href);
|
||||
|
||||
if (typeof to === 'string') {
|
||||
return this.normalizeUrl(new URL(to, base));
|
||||
}
|
||||
|
||||
const url = new URL(to.to, base);
|
||||
|
||||
if (to.search) {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(to.search)) {
|
||||
if (v === undefined) continue;
|
||||
if (v === null) {
|
||||
sp.set(k, '');
|
||||
continue;
|
||||
}
|
||||
sp.set(k, String(v));
|
||||
}
|
||||
const s = sp.toString();
|
||||
url.search = s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
if (to.hash) {
|
||||
url.hash = to.hash.startsWith('#') ? to.hash : `#${to.hash}`;
|
||||
}
|
||||
|
||||
return this.normalizeUrl(url);
|
||||
}
|
||||
|
||||
async navigate(to: To, opts: NavigateOptions = {}): Promise<void> {
|
||||
const from = this.state.location;
|
||||
const targetUrl = this.resolveTo(to, from);
|
||||
|
||||
let historyState =
|
||||
opts.state ?? (typeof to === 'object' && 'state' in to ? to.state : undefined) ?? this.state.historyState;
|
||||
|
||||
if (historyState === undefined) historyState = null;
|
||||
|
||||
const pendingMatches = this.matchUrl(targetUrl);
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
navigating: true,
|
||||
pending: pendingMatches,
|
||||
error: undefined,
|
||||
historyState,
|
||||
};
|
||||
this.notify();
|
||||
|
||||
await this.transitionTo(targetUrl, {
|
||||
replace: opts.replace,
|
||||
scroll: opts.scroll ?? this.options.scrollRestoration ?? 'top',
|
||||
historyState,
|
||||
isPop: false,
|
||||
});
|
||||
}
|
||||
|
||||
async preload(to: To): Promise<void> {
|
||||
const url = this.resolveTo(to, this.state.location);
|
||||
const matches = this.matchUrl(url);
|
||||
if (!matches.length) return;
|
||||
|
||||
const leaf = matches[matches.length - 1];
|
||||
if (!leaf.route.preload) return;
|
||||
|
||||
const key = this.preloadKey(leaf.route, url);
|
||||
const existing = this.preloadCache.get(key);
|
||||
if (existing) {
|
||||
if (existing === 'done') return;
|
||||
await existing;
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx: RouteContext = {
|
||||
url,
|
||||
params: leaf.params,
|
||||
search: leaf.search,
|
||||
state: this.state.historyState,
|
||||
route: leaf.route,
|
||||
matches,
|
||||
router: this,
|
||||
};
|
||||
|
||||
const promise = Promise.resolve(leaf.route.preload(ctx)).then(
|
||||
() => {
|
||||
this.preloadCache.set(key, 'done');
|
||||
},
|
||||
(err) => {
|
||||
this.preloadCache.delete(key);
|
||||
if (typeof console !== 'undefined') {
|
||||
console.error('[router] preload failed', err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.preloadCache.set(key, promise);
|
||||
await promise;
|
||||
}
|
||||
|
||||
private preloadKey(route: Route, url: URL): string {
|
||||
return `${route.id}|${url.pathname}|${url.search}`;
|
||||
}
|
||||
|
||||
private async handlePop(url: URL, state: unknown): Promise<void> {
|
||||
const normalizedUrl = this.normalizeUrl(url);
|
||||
if (normalizedUrl.toString() !== url.toString()) {
|
||||
this.history.replace(normalizedUrl, state);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.transitionTo(normalizedUrl, {
|
||||
isPop: true,
|
||||
historyState: state,
|
||||
scroll: 'preserve',
|
||||
});
|
||||
}
|
||||
|
||||
private matchUrl(url: URL): Array<Match> {
|
||||
const routesWithPaths = this.routes
|
||||
.filter((r) => r.path != null)
|
||||
.sort((a, b) => {
|
||||
const aSegments = (a.path ?? '').split('/').filter(Boolean);
|
||||
const bSegments = (b.path ?? '').split('/').filter(Boolean);
|
||||
if (bSegments.length !== aSegments.length) {
|
||||
return bSegments.length - aSegments.length;
|
||||
}
|
||||
const aWildcards = aSegments.filter((s) => s.startsWith(':') || s === '*').length;
|
||||
const bWildcards = bSegments.filter((s) => s.startsWith(':') || s === '*').length;
|
||||
if (aWildcards !== bWildcards) {
|
||||
return aWildcards - bWildcards;
|
||||
}
|
||||
if (a.parentId && !b.parentId) return -1;
|
||||
if (!a.parentId && b.parentId) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
for (const route of routesWithPaths) {
|
||||
const exec = route.pattern.exec(url);
|
||||
if (!exec) continue;
|
||||
|
||||
const search = url.searchParams;
|
||||
|
||||
const chain: Array<Match> = [];
|
||||
let current: Route | undefined = route;
|
||||
|
||||
while (current) {
|
||||
const currentExec =
|
||||
current === route ? exec : (current.pattern.exec(url) ?? ({pathname: {groups: {}}} as URLPatternResult));
|
||||
|
||||
const params = (currentExec.pathname.groups ?? {}) as Record<string, string>;
|
||||
|
||||
chain.push({
|
||||
route: current,
|
||||
params,
|
||||
search,
|
||||
});
|
||||
|
||||
if (!current.parentId) break;
|
||||
current = this.routeById.get(current.parentId);
|
||||
}
|
||||
|
||||
chain.reverse();
|
||||
return chain;
|
||||
}
|
||||
|
||||
const notFoundRouteId = this.options.notFoundRouteId;
|
||||
if (notFoundRouteId) {
|
||||
const nf = this.routeById.get(notFoundRouteId);
|
||||
if (nf) {
|
||||
return [
|
||||
{
|
||||
route: nf,
|
||||
params: {},
|
||||
search: url.searchParams,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private async transitionTo(
|
||||
url: URL,
|
||||
opts: {
|
||||
replace?: boolean;
|
||||
scroll?: ScrollBehavior;
|
||||
historyState?: unknown;
|
||||
isPop?: boolean;
|
||||
forceEnter?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const prevState = this.state;
|
||||
const prevMatches = opts.forceEnter ? [] : prevState.matches;
|
||||
const nextMatches = this.matchUrl(url);
|
||||
const historyState = opts.historyState ?? prevState.historyState ?? null;
|
||||
|
||||
const mkCtx = (match: Match): RouteContext => ({
|
||||
url,
|
||||
params: match.params,
|
||||
search: match.search,
|
||||
state: historyState,
|
||||
route: match.route,
|
||||
matches: nextMatches,
|
||||
router: this,
|
||||
});
|
||||
|
||||
const firstDifferentIndex =
|
||||
opts.forceEnter === true
|
||||
? 0
|
||||
: (() => {
|
||||
let i = 0;
|
||||
while (
|
||||
i < prevMatches.length &&
|
||||
i < nextMatches.length &&
|
||||
prevMatches[i].route.id === nextMatches[i].route.id
|
||||
) {
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
})();
|
||||
|
||||
try {
|
||||
for (let i = firstDifferentIndex; i < nextMatches.length; i++) {
|
||||
const match = nextMatches[i];
|
||||
const route = match.route;
|
||||
if (route.onEnter) {
|
||||
const res = route.onEnter(mkCtx(match));
|
||||
if (res instanceof Redirect) {
|
||||
const redirectUrl = this.resolveTo(res.to, url);
|
||||
await this.transitionTo(redirectUrl, {
|
||||
replace: res.replace ?? true,
|
||||
scroll: opts.scroll,
|
||||
historyState,
|
||||
isPop: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (res instanceof NotFound) {
|
||||
await this.handleNotFound(url, historyState);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Redirect) {
|
||||
const redirectUrl = this.resolveTo(err.to, url);
|
||||
await this.transitionTo(redirectUrl, {
|
||||
replace: err.replace ?? true,
|
||||
scroll: opts.scroll,
|
||||
historyState,
|
||||
isPop: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (err instanceof NotFound) {
|
||||
await this.handleNotFound(url, historyState);
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...prevState,
|
||||
navigating: false,
|
||||
pending: null,
|
||||
error: err,
|
||||
historyState,
|
||||
};
|
||||
this.notify();
|
||||
throw err;
|
||||
}
|
||||
|
||||
for (let i = prevMatches.length - 1; i >= firstDifferentIndex; i--) {
|
||||
const prevMatch = prevMatches[i];
|
||||
const route = prevMatch.route;
|
||||
if (route.onLeave) {
|
||||
route.onLeave({
|
||||
url: prevState.location,
|
||||
params: prevMatch.params,
|
||||
search: prevState.location.searchParams,
|
||||
state: prevState.historyState,
|
||||
route,
|
||||
matches: prevMatches,
|
||||
router: this,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isSamePath =
|
||||
prevState.location.pathname === url.pathname &&
|
||||
prevState.location.search === url.search &&
|
||||
prevState.location.hash === url.hash;
|
||||
|
||||
if (!opts.isPop) {
|
||||
if (opts.replace || isSamePath) {
|
||||
this.history.replace(url, historyState);
|
||||
} else {
|
||||
this.history.push(url, historyState);
|
||||
}
|
||||
}
|
||||
|
||||
this.state = {
|
||||
location: url,
|
||||
matches: nextMatches,
|
||||
navigating: false,
|
||||
pending: null,
|
||||
error: undefined,
|
||||
historyState,
|
||||
};
|
||||
this.notify();
|
||||
|
||||
const behavior = opts.scroll ?? this.options.scrollRestoration ?? 'top';
|
||||
queueMicrotask(() => {
|
||||
if (behavior === 'preserve') return;
|
||||
if (url.hash) {
|
||||
const id = url.hash.slice(1);
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.scrollIntoView();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (behavior === 'top') {
|
||||
window.scrollTo({top: 0, left: 0});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async handleNotFound(url: URL, historyState: unknown): Promise<void> {
|
||||
const notFoundRouteId = this.options.notFoundRouteId;
|
||||
if (!notFoundRouteId) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
location: url,
|
||||
matches: [],
|
||||
navigating: false,
|
||||
pending: null,
|
||||
error: new NotFound(),
|
||||
historyState,
|
||||
};
|
||||
this.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
const nf = this.routeById.get(notFoundRouteId);
|
||||
if (!nf) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
location: url,
|
||||
matches: [],
|
||||
navigating: false,
|
||||
pending: null,
|
||||
error: new NotFound(),
|
||||
historyState,
|
||||
};
|
||||
this.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
const match: Match = {
|
||||
route: nf,
|
||||
params: {},
|
||||
search: url.searchParams,
|
||||
};
|
||||
|
||||
this.state = {
|
||||
location: url,
|
||||
matches: [match],
|
||||
navigating: false,
|
||||
pending: null,
|
||||
error: undefined,
|
||||
historyState,
|
||||
};
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
export function createRouter(options: RouterOptions): Router {
|
||||
return new RouterImpl(options);
|
||||
}
|
||||
Reference in New Issue
Block a user