Source: lib/core/runtime/AvenxRouter.js

import { AvenxErrorCodes, formatMessage, AvenxError } from './AvenxError.js';
import { GuardContext } from './AvenxGuard.js';
import { logger } from './AvenxLogger.js';
import { ProxyHandlerFactory } from '../reactive/proxyHandler.js';
import { RouteMatcher } from './RouteMatcher.js';
import { createNavigationDelegate } from './navigation/index.js';
import { tracer } from '../trace/tracer.js';
import { TraceNodeType } from '../trace/schema.js';
import { announce, manageFocus } from './navigation/A11yManager.js';

// This is declared for controlling the depth of a redirected loop
const MAX_REDIRECT = 10;

/**
 * AvenxRouter handles routing for the application.
 * Matches URL route definitions to specific Page components using pluggable navigation delegates.
 */
export class AvenxRouter {
  /** @type {Map<string, { x: number, y: number }>} */
  #scrollPositions = new Map();

  /**
   * Monotonic id identifying the most recently started navigation.
   *
   * Route guards may be asynchronous, so two navigations can be in flight at
   * once and resolve out of order. Each attempt captures the id it started
   * with and only commits if it is still the current one, so a guard belonging
   * to an abandoned route cannot mount its page over the route the user
   * actually asked for.
   * @type {number}
   */
  #navigationId = 0;

  /**
   * @param {AvenxApp} app - The main application instance.
   * @param {Object<string, string | object>} routes - A map of hash routes to page names or route definitions.
   * @param {object} [options] - Optional router configurations (e.g. prefix, delegate, mode).
   */
  constructor(app, routes = {}, options = {}) {
    /** @type {AvenxApp} */
    this.app = app;
    /** @type {Object<string, string | object>} */
    this.routes = routes;
    /** @type {object} */
    this.options = options;
    this.currentRoute = null;
    /** @type {{ chain: string[], count: number } | null} */
    this.redirectContext = null; // The context that is used to prevent the redirect loop using redirect chain and a maxRedirect limit
    /** @type {string|null} */
    this.hashToIgnore = null;

    /** @type {Array<Function|object>} */
    this.beforeHooks = [];
    /** @type {Array<Function>} */
    this.afterHooks = [];

    /** @type {object} */
    this.a11y = {
      focusOnNavigate: true,
      announceRouteChanges: true,
      focusTarget: '[data-ax-page-heading]',
      ...(options.a11y || {}),
    };
    this._isInitialLoad = true;

    /** @type {object} */
    this.delegate = createNavigationDelegate(options);

    for (const routePattern of Object.keys(routes)) {
      if (routePattern === '*') continue;

      // Normalize by stripping leading '#' if present
      const path = routePattern.startsWith('#') ? routePattern.slice(1) : routePattern;

      if (!path.startsWith('/')) {
        logger.warn(formatMessage(AvenxErrorCodes.ROUTE_PATH_MISSING_LEADING_SLASH, routePattern));
      }
    }

    this.delegate.registerRouter(this);

    this.unsubscribeHashChange = this.delegate.onHashChange(() => this.#handleRoute());
    this.unsubscribeLinkClick = this.delegate.onLinkClick((route) => this.navigate(route));
  }

  /**
   * Registers a global guard callback that executes before route guards on navigation transitions.
   * @param {Function|object} callback - The guard callback or guard instance/class.
   * @returns {Function} Unregister function.
   */
  beforeEach(callback) {
    if (typeof callback === 'function' || (callback && typeof callback.canActivate === 'function')) {
      this.beforeHooks.push(callback);
    }
    return () => {
      const idx = this.beforeHooks.indexOf(callback);
      if (idx !== -1) {
        this.beforeHooks.splice(idx, 1);
      }
    };
  }

  /**
   * Registers a global after hook callback that executes after successful route navigation.
   * @param {Function} callback - The callback to execute after navigation.
   * @returns {Function} Unregister function.
   */
  afterEach(callback) {
    if (typeof callback === 'function') {
      this.afterHooks.push(callback);
    }
    return () => {
      const idx = this.afterHooks.indexOf(callback);
      if (idx !== -1) {
        this.afterHooks.splice(idx, 1);
      }
    };
  }

  /**
   * Starts the router and handles the initial route.
   */
  start() {
    this.#handleRoute();
  }

  /**
   * Navigates to a specific hash route.
   * @param {string} hash - The target hash (e.g., '#/about').
   * @param {Object} [options] - Navigation options.
   */
  navigate(hash, options = {}) {
    // Force clean paths like '/profile' into hash paths like '#/profile'
    let targetHash = hash.startsWith('#') ? decodeURIComponent(hash) : '#' + decodeURIComponent(hash);

    if (this.options && this.options.prefix) {
      const prefix = this.options.prefix;
      if (targetHash.startsWith('#/')) {
        targetHash = '#' + prefix + targetHash.substring(1);
      } else if (targetHash.startsWith('#')) {
        targetHash = '#' + prefix + '/' + targetHash.substring(1);
      }
    }
    this.delegate.setHash(targetHash, options);
  }

  /**
   * Steps backward in navigation history.
   */
  back() {
    this.delegate.back();
  }

  /**
   * Steps forward in navigation history.
   */
  forward() {
    this.delegate.forward();
  }

  /**
   * Moves to a specific history position relative to current position.
   * @param {number} delta - Relative step count in history (e.g. -1 for back, 1 for forward).
   */
  go(delta) {
    this.delegate.go(delta);
  }

  /**
   * Destroys the router and cleans up event listeners.
   */
  destroy() {
    if (this.unsubscribeHashChange) this.unsubscribeHashChange();
    if (this.unsubscribeLinkClick) this.unsubscribeLinkClick();
    this.delegate.unregisterRouter(this);
    this.delegate.destroy();
  }

  /**
   * Returns a snapshot of all registered routes.
   * @returns {Array<{pattern: string, definition: string | object}>}
   */
  getRoutes() {
    return Object.entries(this.routes).map(([pattern, definition]) => ({
      pattern,
      definition: typeof definition === 'object' && definition !== null ? { ...definition } : definition,
    }));
  }

  /**
   * Checks if this router has a matching route (excluding fallback) for the given hash.
   * @param {string} hash - The URL hash.
   * @returns {boolean} True if a non-fallback route matches.
   */
  matches(hash) {
    try {
      return RouteMatcher.matches(this.routes, decodeURIComponent(hash).replace(/%20/g, ' '), this.options);
    } catch {
      return RouteMatcher.matches(this.routes, hash, this.options);
    }
  }

  /**
   * Sequentially executes an array of guards for a route transition.
   * @param {Array<Function|object>} guards - Route guards.
   * @param {object} to - Target route details.
   * @param {object | null} from - Current route details.
   * @returns {Promise<boolean|string|object>} Result of the guard checks.
   * @private
   */
  /**
   * Builds the capabilities handed to a guard for one navigation.
   *
   * A guard used to receive nothing but the route, which made "is this visitor
   * signed in?" unanswerable: it had no supported route to shared state. The
   * context carries the application's bridges and the router asking, and
   * deliberately carries no browser globals -- a guard that needs one reads it
   * through a bridge, like everything else.
   * @returns {GuardContext} The context for this navigation.
   * @private
   */
  #guardContext() {
    return new GuardContext({
      bridges: this.app ? this.app.bridges : {},
      app: this.app || null,
      router: this,
    });
  }

  /**
   * Sequentially executes an array of guards for a route transition.
   * @param {Array<Function|object>} guards - Route guards.
   * @param {object} to - The target route.
   * @param {object} from - The route being left.
   * @returns {Promise<boolean|string|object>} Result of the guard checks.
   * @private
   */
  #runGuards(guards, to, from) {
    return new Promise((resolve, reject) => {
      const nextGuard = (index) => {
        if (index >= guards.length) {
          resolve(true);
        } else {
          const Guard = guards[index];
          let canActivateResult;
          // Built per guard rather than per navigation so a guard that runs
          // after an earlier one mutated a bridge sees the current registry.
          const context = this.#guardContext();
          try {
            if (typeof Guard === 'function') {
              if (Guard.prototype && typeof Guard.prototype.canActivate === 'function') {
                const instance = new Guard(context);
                canActivateResult = instance.canActivate(to, from, context);
              } else {
                canActivateResult = Guard(to, from, context);
              }
            } else if (Guard && typeof Guard.canActivate === 'function') {
              canActivateResult = Guard.canActivate(to, from, context);
            } else {
              canActivateResult = true;
            }
          } catch (err) {
            canActivateResult = Promise.reject(err);
          }

          const guardTimeout = this.options && this.options.guardTimeout !== undefined ? this.options.guardTimeout : 5000;

          let timeoutId;
          const timeoutPromise = new Promise((_, reqReject) => {
            timeoutId = setTimeout(() => {
              reqReject(new AvenxError(AvenxErrorCodes.ROUTER_GUARD_TIMEOUT, guardTimeout, to.hash));
            }, guardTimeout);
          });

          Promise.race([Promise.resolve(canActivateResult), timeoutPromise])
            .then((result) => {
              clearTimeout(timeoutId);
              if (result === undefined) {
                logger.warn(formatMessage(AvenxErrorCodes.ROUTER_GUARD_UNDEFINED_RETURN, to.hash));
                nextGuard(index + 1);
                return;
              }
              const isControlObject =
                typeof result === 'object' &&
                result !== null &&
                (result.cancel === true || typeof result.redirect === 'string');

              if (result === false || typeof result === 'string' || isControlObject) {
                resolve(result);
              } else {
                nextGuard(index + 1);
              }
            })
            .catch((err) => {
              clearTimeout(timeoutId);
              if (err.code === AvenxErrorCodes.ROUTER_GUARD_TIMEOUT) {
                reject(err);
              } else {
                logger.error(formatMessage(AvenxErrorCodes.ROUTER_GUARD_ERROR, to.hash, err));
                resolve(false);
              }
            });
        }
      };
      nextGuard(0);
    });
  }

  /**
   * Sequentially executes all registered global after hooks.
   * @param {object} to - Target route details.
   * @param {object|null} from - Previous route details.
   * @private
   */
  #runAfterHooks(to, from) {
    for (const hook of this.afterHooks) {
      try {
        const res = hook(to, from);
        if (res && typeof res.catch === 'function') {
          res.catch((err) => {
            logger.error(formatMessage(AvenxErrorCodes.ROUTER_GUARD_ERROR, to.hash, err));
          });
        }
      } catch (err) {
        logger.error(formatMessage(AvenxErrorCodes.ROUTER_GUARD_ERROR, to.hash, err));
      }
    }
  }

  /**
   * Handles the current route by matching it against patterns, executing guards,
   * and mounting the corresponding page.
   * @private
   */
  #handleRoute() {
    // Starting a new navigation supersedes any still-pending one.
    const navigationId = ++this.#navigationId;

    let hash = this.delegate.getHash();
    try {
      hash = decodeURIComponent(hash);
    } catch {
      // Fallback
    }

    if (this.hashToIgnore === hash) {
      this.hashToIgnore = null;
      return;
    }

    const activeRouters = this.delegate.getActiveRouters();
    const { matchedRoute, params, query, path, otherRouterMatches, normalizedHash } = RouteMatcher.matchRoute(
      this.routes,
      hash,
      this.options,
      activeRouters,
      this,
    );

    if (!matchedRoute) {
      if (!otherRouterMatches) {
        logger.warn(formatMessage(AvenxErrorCodes.ROUTE_NOT_FOUND, hash));
      }
      return;
    }

    const def = matchedRoute.definition;
    const meta = typeof def === 'object' && def.meta && typeof def.meta === 'object' ? def.meta : {};

    if (def && typeof def === 'object' && def.redirect) {
      // Create a new redirect context when the redirect starts
      if (!this.redirectContext) {
        this.redirectContext = {
          chain: [normalizedHash],
          count: 0,
        };
      }

      Promise.resolve()
        .then(() => {
          // If redirect is function then execute it with params
          if (typeof def.redirect === 'function') {
            return def.redirect(params);
          }

          return def.redirect;
        })
        .then((target) => {
          if (typeof target !== 'string' || target.length === 0) {
            logger.error('Invalid redirect target');
            this.redirectContext = null;
            return;
          }

          // Check the maximum redirect limit
          if (this.redirectContext.count >= MAX_REDIRECT) {
            logger.error('Maximum redirect limit exceeded');
            this.redirectContext = null;
            return;
          }

          // Normalize the target hash same like navigate does
          let targetHash = target.startsWith('#') ? decodeURIComponent(target) : '#' + decodeURIComponent(target);

          if (this.options && this.options.prefix) {
            const prefix = this.options.prefix;

            if (targetHash.startsWith('#/')) {
              targetHash = '#' + prefix + targetHash.substring(1);
            } else if (targetHash.startsWith('#')) {
              targetHash = '#' + prefix + '/' + targetHash.substring(1);
            }
          }

          // Check whether the target is already in the redirect chain
          if (this.redirectContext.chain.includes(targetHash)) {
            logger.error(`Redirect loop detected: ${[...this.redirectContext.chain, targetHash].join(' → ')}`);

            this.redirectContext = null;
            return;
          }

          this.redirectContext.chain.push(targetHash);
          this.redirectContext.count++;

          this.navigate(targetHash, { replace: true });
        })
        .catch((err) => {
          logger.error(err);
          this.redirectContext = null;
        });

      // Do not continue to page, guards or mountPage for redirect route
      return;
    }

    const parentDef = matchedRoute.parent;
    const pageName = typeof def === 'string' ? def : def.page;
    const childGuards = typeof def === 'object' ? def.guards || [] : [];
    const parentGuards = parentDef && typeof parentDef === 'object' ? parentDef.guards || [] : [];
    const guards = [...this.beforeHooks, ...parentGuards, ...childGuards];

    const to = {
      hash: normalizedHash,
      path: path || '',
      page: pageName,
      params,
      query: query || {},
      meta,
    };
    const from = this.currentRoute
      ? {
        hash: this.currentRoute.hash,
        path: this.currentRoute.path,
        page: this.currentRoute.page,
        params: { ...this.currentRoute.params },
        query: { ...this.currentRoute.query },
        meta: { ...(this.currentRoute.meta || {}) },
      }
      : null;

    this.#runGuards(guards, to, from)
      .then((result) => {
        // A newer navigation started while these guards were pending; its
        // outcome is the one that must be applied, not this one's.
        if (navigationId !== this.#navigationId) {
          return;
        }

        if (result === false) {
          logger.warn(formatMessage(AvenxErrorCodes.ROUTER_GUARD_DENIED, to.hash));
          if (from && from.hash !== this.delegate.getHash()) {
            this.hashToIgnore = from.hash;
            this.delegate.setHash(from.hash);
          }
        } else if (typeof result === 'string') {
          this.navigate(result);
        } else if (
          typeof result === 'object' &&
          result !== null &&
          (result.cancel === true || typeof result.redirect === 'string')
        ) {
          if (result.cancel) {
            if (!result.silent) {
              logger.warn(formatMessage(AvenxErrorCodes.ROUTER_GUARD_DENIED, to.hash));
            }
            if (from && from.hash !== this.delegate.getHash()) {
              this.hashToIgnore = from.hash;
              this.delegate.setHash(from.hash);
            }
          } else if (result.redirect) {
            let redirectPath = result.redirect;
            const paramsToAppend = { ...result.state, ...result.query };
            if (Object.keys(paramsToAppend).length > 0) {
              const [pathPart, queryPart] = redirectPath.split('?');
              const searchParams = new URLSearchParams(queryPart || '');
              for (const [key, value] of Object.entries(paramsToAppend)) {
                if (value !== undefined && value !== null) {
                  searchParams.set(key, String(value));
                }
              }
              redirectPath = pathPart + '?' + searchParams.toString();
            }
            this.navigate(redirectPath);
          }
        } else {
          this.redirectContext = null;
          if (tracer.on) {
            tracer.record(TraceNodeType.NAVIGATION, {
              from: from ? from.hash : null,
              to: to.hash,
              page: to.page,
              params: to.params,
              query: to.query,
            });
          }
          this.currentRoute = to;
          this.#applyTitle(def, params);
          const transitionName = (typeof def === 'object' && def.transition) || this.options.transition;
          const keepAlive =
            typeof def === 'object' && def.keepAlive !== undefined ? !!def.keepAlive : !!this.options.keepAlive;
          if (this.app && typeof this.app.mountPage === 'function') {
            const layoutName = parentDef && typeof parentDef === 'object' ? parentDef.page : null;
            this.app.mountPage(pageName, params, { transition: transitionName, keepAlive, layout: layoutName });
          }
          this.#runAfterHooks(to, from);
          this.#applyScrollRestoration(to, from);

          // Accessibility management (announcements & focus shift)
          this.#applyA11y();
        }
      })
      .catch((err) => {
        if (navigationId !== this.#navigationId) {
          return;
        }

        logger.error(err);
        if (this.options && this.options.guardTimeoutRedirect) {
          this.navigate(this.options.guardTimeoutRedirect);
        } else {
          if (from && from.hash !== this.delegate.getHash()) {
            this.hashToIgnore = from.hash;
            this.delegate.setHash(from.hash);
          }
        }
      });
  }

  /**
   * Applies focus management and live-region announcements after successful navigation.
   * @private
   */
  #applyA11y() {
    if (typeof document === 'undefined') return;

    if (this.a11y.announceRouteChanges) {
      if (document.title) {
        announce(document.title);
      }
    }

    if (this.a11y.focusOnNavigate && !this._isInitialLoad) {
      const schedule =
        typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function'
          ? (cb) => window.requestAnimationFrame(cb)
          : (cb) => setTimeout(cb, 0);

      schedule(() => {
        if (typeof document === 'undefined') return;
        const pageRoot =
          (this.app && this.app.currentPageInstance && this.app.currentPageInstance.$el) ||
          (typeof document.querySelector === 'function'
            ? document.querySelector('[data-ax-page]') || document.querySelector('main') || document.body
            : document.body || null);

        if (pageRoot) {
          manageFocus(pageRoot, this.a11y.focusTarget);
        }
      });
    }

    this._isInitialLoad = false;
  }

  /**
   * Applies scroll restoration after a successful navigation.
   * Modes: 'top' (default) scrolls to 0,0; 'auto' restores saved position for the target
   * hash or scrolls to top; 'manual' is a no-op.
   * @param {{ hash: string }} to - Target route.
   * @param {{ hash: string } | null} from - Previous route.
   * @private
   */
  #applyScrollRestoration(to, from) {
    if (typeof window === 'undefined' || typeof window.scrollTo !== 'function') {
      return;
    }

    const mode = this.options?.scrollRestoration ?? 'top';
    if (mode === 'manual') {
      return;
    }

    if (mode === 'auto' && from?.hash) {
      this.#scrollPositions.set(from.hash, {
        x: window.scrollX || 0,
        y: window.scrollY || 0,
      });
    }

    const apply = () => {
      if (mode === 'top') {
        window.scrollTo(0, 0);
        return;
      }

      if (mode === 'auto') {
        const saved = this.#scrollPositions.get(to.hash);
        if (saved) {
          window.scrollTo(saved.x, saved.y);
        } else {
          window.scrollTo(0, 0);
        }
      }
    };

    // Double rAF so layout has settled after mountPage
    const schedule =
      typeof window.requestAnimationFrame === 'function'
        ? (cb) => window.requestAnimationFrame(cb)
        : (cb) => setTimeout(cb, 0);

    schedule(() => schedule(apply));
  }

  /**
   * Resolves the title from a route definition and updates current title.
   * @param {string | object} def - The route definition.
   * @param {object} params - Parsed route parameters.
   * @private
   */
  #applyTitle(def, params) {
    const rawTitle = typeof def === 'object' ? def.title : undefined;
    if (rawTitle === undefined) return;

    let resolved;
    if (typeof rawTitle === 'function') {
      try {
        resolved = rawTitle(params);
      } catch (err) {
        logger.warn(formatMessage(AvenxErrorCodes.ROUTE_TITLE_EVALUATION_FAILED, err));
        return;
      }
    } else {
      resolved = rawTitle;
    }

    if (typeof resolved !== 'string') return;

    const prefix = this.options.titlePrefix || '';
    const suffix = this.options.titleSuffix || '';
    this.delegate.setTitle(prefix + resolved + suffix);
  }

  #currentRouteProxy = null;
  #currentRouteIsNull = true;

  /**
   * Getter for currentRoute. Returns null if null was assigned, or the stable reactive proxy.
   * @returns {object|null}
   */
  get currentRoute() {
    return this.#currentRouteIsNull ? null : this.#currentRouteProxy;
  }

  /**
   * Setter for currentRoute. Reactively updates the stable proxy properties.
   * @param {object|null} val
   */
  set currentRoute(val) {
    if (val === null) {
      this.#currentRouteIsNull = true;
      if (this.#currentRouteProxy) {
        this.#currentRouteProxy.hash = '';
        this.#currentRouteProxy.path = '';
        this.#currentRouteProxy.page = '';
        this.#currentRouteProxy.meta = {};
        for (const key of Object.keys(this.#currentRouteProxy.params)) {
          delete this.#currentRouteProxy.params[key];
        }
        for (const key of Object.keys(this.#currentRouteProxy.query)) {
          delete this.#currentRouteProxy.query[key];
        }
      }
    } else {
      this.#currentRouteIsNull = false;
      if (!this.#currentRouteProxy) {
        const handlerFactory = new ProxyHandlerFactory();
        this.#currentRouteProxy = new Proxy(
          { hash: '', path: '', page: '', params: {}, query: {}, meta: {} },
          handlerFactory.create(),
        );
      }
      this.#currentRouteProxy.hash = val.hash || '';
      this.#currentRouteProxy.path = val.path !== undefined ? val.path : val.hash ? val.hash.split('?')[0] : '';
      this.#currentRouteProxy.page = val.page || '';
      this.#currentRouteProxy.meta = { ...(val.meta || {}) };
      for (const key of Object.keys(this.#currentRouteProxy.params)) {
        delete this.#currentRouteProxy.params[key];
      }
      for (const [key, v] of Object.entries(val.params || {})) {
        this.#currentRouteProxy.params[key] = v;
      }
      for (const key of Object.keys(this.#currentRouteProxy.query)) {
        delete this.#currentRouteProxy.query[key];
      }
      const incomingQuery = val.query || (val.params && val.params.query) || {};
      for (const [key, v] of Object.entries(incomingQuery)) {
        this.#currentRouteProxy.query[key] = v;
      }
    }
  }
}