# TerminalRouter

Source: /tuil/docs/reference/packages/router/api/terminal-router
Locale: en

class exported by @mwillbanks/tuil-router.



{/* Generated by tooling/docs/generate-reference.ts. */}

## class [#class]

Public class exported by `@mwillbanks/tuil-router`.

```ts
export class TerminalRouter<
  TRoutes extends Readonly<Record<string, object>> = Readonly<
    Record<string, object>
  >,
> {
  readonly #routes: Map<string, readonly InternalRouteMatch[]>;
  readonly #store = createNusmStore<RouterState>({
    history: Object.freeze([]),
    index: -1,
  });
  readonly #observers = new Set<(event: RouterEvent) => void>();
  #navigation?: AbortController;

  constructor(
    routes: TRoutes,
    readonly options: TerminalRouterOptions = {},
  ) {
    this.#routes = flattenRoutes(
      routes as unknown as Readonly<Record<string, InternalRouteDefinition>>,
    );
  }

  get state(): RouterState {
    return this.#store.state;
  }

  get routes(): readonly string[] {
    return Object.freeze([...this.#routes.keys()]);
  }

  subscribe(observer: () => void): () => void {
    const subscription = this.#store.subscribe(observer);
    return () => subscription.unsubscribe();
  }

  observe(observer: (event: RouterEvent) => void): () => void {
    this.#observers.add(observer);
    return () => this.#observers.delete(observer);
  }

  navigate<TPath extends RoutePaths<TRoutes>>(
    target: NavigationTarget<TPath, RouteParamsAtPath<TRoutes, TPath>>,
  ): Promise<NavigationEntry> {
    return this.#navigate(target as NavigationTarget);
  }

  async #navigate(target: NavigationTarget): Promise<NavigationEntry> {
    const routeChain = this.#routes.get(target.to);
    if (!routeChain) throw new Error(`Unknown route "${target.to}"`);
    this.#navigation?.abort(new DOMException("Superseded", "AbortError"));
    const navigation = new AbortController();
    this.#navigation = navigation;
    const signal = target.signal
      ? AbortSignal.any([navigation.signal, target.signal])
      : navigation.signal;
    const from = this.state.location;
    this.#setState({ ...this.state, pending: target, error: undefined });
    this.#emit({ type: "route:before-navigate", from, to: target });
    const contexts: RouteContext[] = [];
    let activeMatchIndex = 0;
    try {
      for (const [index, match] of routeChain.entries()) {
        activeMatchIndex = index;
        const rawParams = match.definition.parseParams
          ? match.definition.parseParams(target.params)
          : target.params;
        contexts.push({
          params: normalizeParams(rawParams),
          signal,
          router: this as unknown as TerminalRouter,
        });
      }
      for (const [index, match] of routeChain.entries()) {
        activeMatchIndex = index;
        const guard = await match.definition.beforeEnter?.(
          contexts[index] as RouteContext,
        );
        signal.throwIfAborted();
        if (typeof guard === "string") {
          return this.#navigate({
            to: guard,
            surface: target.surface,
            signal: target.signal,
          });
        }
        if (guard === false) {
          throw new Error(`Navigation to "${target.to}" denied`);
        }
      }
      await this.#assertCanLeave(from, signal);
      const params =
        contexts.at(-1)?.params ?? Object.freeze<Record<string, unknown>>({});
      const routeMatches: RouteMatch[] = routeChain.map((match, index) =>
        Object.freeze({
          route: match.route,
          params: (contexts[index] as RouteContext).params,
          component: match.definition.component,
        }),
      );
      const entryBase: NavigationEntry = Object.freeze({
        id: crypto.randomUUID(),
        route: target.to,
        params,
        surface: target.surface ?? "screen",
        matches: Object.freeze([...routeMatches]),
        createdAt: Date.now(),
      });
      this.#emit({ type: "route:navigate", from, to: entryBase });
      for (const [index, match] of routeChain.entries()) {
        if (!match.definition.loader) continue;
        activeMatchIndex = index;
        this.#emit({ type: "route:load", from, to: entryBase });
        const data = await match.definition.loader(
          contexts[index] as RouteContext,
        );
        signal.throwIfAborted();
        routeMatches[index] = Object.freeze({
          ...(routeMatches[index] as RouteMatch),
          data,
        });
      }
      const entry = Object.freeze({
        ...entryBase,
        data: routeMatches.at(-1)?.data,
        matches: Object.freeze(routeMatches),
      });
      signal.throwIfAborted();
      const historyWithFocus = this.#captureCurrentFocus();
      const retained = target.replace
        ? historyWithFocus.slice(0, Math.max(0, this.state.index))
        : historyWithFocus.slice(0, this.state.index + 1);
      const history = Object.freeze(
        target.replace && this.state.index >= 0
          ? [...retained, entry]
          : [...retained, entry],
      );
      if (from) this.#emit({ type: "route:leave", from, to: entry });
      this.#setState({
        location: entry,
        history,
        index: history.length - 1,
      });
      this.#emit({ type: "route:ready", from, to: entry });
      return entry;
    } catch (error) {
      if (!navigation.signal.aborted) {
        let boundaryIndex = activeMatchIndex;
        while (
          boundaryIndex >= 0 &&
          !routeChain[boundaryIndex]?.definition.onError
        ) {
          boundaryIndex -= 1;
        }
        const boundary = routeChain[boundaryIndex];
        let reportedError = error;
        try {
          boundary?.definition.onError?.(
            error,
            contexts[boundaryIndex] ??
              ({
                params: Object.freeze({}),
                signal,
                router: this as unknown as TerminalRouter,
              } as RouteContext),
          );
        } catch (boundaryError) {
          reportedError = new AggregateError(
            [error, boundaryError],
            `Route error boundary for "${target.to}" failed`,
          );
        }
        this.#setState({
          ...this.state,
          pending: undefined,
          error: reportedError,
        });
        this.#emit({
          type: "route:error",
          from,
          to: target,
          error: reportedError,
        });
        throw reportedError;
      }
      throw error;
    }
  }

  open<TPath extends RoutePaths<TRoutes>>(
    target: {
      readonly route: TPath;
      readonly surface: Exclude<NavigationSurface, "screen">;
      readonly signal?: AbortSignal;
    } & (unknown extends RouteParamsAtPath<TRoutes, TPath>
      ? { readonly params?: unknown }
      : keyof RouteParamsAtPath<TRoutes, TPath> extends never
        ? { readonly params?: RouteParamsAtPath<TRoutes, TPath> }
        : { readonly params: RouteParamsAtPath<TRoutes, TPath> }),
  ): Promise<NavigationEntry> {
    const navigationTarget: NavigationTarget = {
      to: target.route,
      params: target.params,
      surface: target.surface,
      signal: target.signal,
    };
    return this.#navigate(navigationTarget);
  }

  async back(signal?: AbortSignal): Promise<NavigationEntry | undefined> {
    return this.#restore(this.state.index - 1, signal);
  }

  async forward(signal?: AbortSignal): Promise<NavigationEntry | undefined> {
    return this.#restore(this.state.index + 1, signal);
  }

  cancel(reason: unknown = new DOMException("Cancelled", "AbortError")): void {
    this.#navigation?.abort(reason);
    this.#setState({ ...this.state, pending: undefined });
  }

  dispose(): void {
    this.cancel(new DOMException("Disposed", "AbortError"));
    this.#observers.clear();
  }

  async #restore(
    index: number,
    externalSignal?: AbortSignal,
  ): Promise<NavigationEntry | undefined> {
    const entry = this.state.history[index];
    if (!entry) {
      this.cancel(new DOMException("Superseded", "AbortError"));
      return undefined;
    }
    this.#navigation?.abort(new DOMException("Superseded", "AbortError"));
    const navigation = new AbortController();
    this.#navigation = navigation;
    const signal = externalSignal
      ? AbortSignal.any([navigation.signal, externalSignal])
      : navigation.signal;
    const from = this.state.location;
    this.#emit({ type: "route:before-navigate", from, to: entry });
    try {
      await this.#assertCanLeave(from, signal);
      const routeChain = this.#routes.get(entry.route) ?? [];
      for (const match of routeChain) {
        const context: RouteContext = {
          params:
            entry.matches.find((entryMatch) => entryMatch.route === match.route)
              ?.params ?? entry.params,
          signal,
          router: this as unknown as TerminalRouter,
        };
        const guard = await match.definition.beforeEnter?.(context);
        signal.throwIfAborted();
        if (guard === false || typeof guard === "string") {
          throw new Error(`History navigation to "${entry.route}" denied`);
        }
      }
      const history = this.#captureCurrentFocus();
      const restoredEntry = history[index] ?? entry;
      const previousState = this.state;
      this.#setState({
        ...this.state,
        history: Object.freeze(history),
        location: restoredEntry,
        index,
        pending: undefined,
        error: undefined,
      });
      try {
        if (restoredEntry.focus && this.options.restoreFocus) {
          await this.options.restoreFocus(restoredEntry.focus, signal);
          signal.throwIfAborted();
          this.#emit({
            type: "route:restore-focus",
            from,
            to: restoredEntry,
          });
        }
        signal.throwIfAborted();
      } catch (error) {
        this.#setState({
          ...previousState,
          history: Object.freeze(history),
          pending: undefined,
        });
        throw error;
      }
      if (from) {
        this.#emit({ type: "route:leave", from, to: restoredEntry });
      }
      this.#emit({ type: "route:ready", from, to: restoredEntry });
      return restoredEntry;
    } catch (error) {
      if (!signal.aborted) {
        this.#setState({ ...this.state, pending: undefined, error });
        this.#emit({ type: "route:error", from, to: entry, error });
      }
      throw error;
    }
  }

  async #assertCanLeave(
    entry: NavigationEntry | undefined,
    signal: AbortSignal,
  ): Promise<void> {
    if (!entry) return;
    const routeChain = this.#routes.get(entry.route) ?? [];
    for (const match of [...routeChain].reverse()) {
      const allowed = await match.definition.beforeLeave?.({
        params:
          entry.matches.find((entryMatch) => entryMatch.route === match.route)
            ?.params ?? entry.params,
        signal,
        router: this as unknown as TerminalRouter,
      });
      signal.throwIfAborted();
      if (allowed === false) {
        throw new Error(`Navigation away from "${entry.route}" denied`);
      }
    }
  }

  #captureCurrentFocus(): readonly NavigationEntry[] {
    const captured = this.state.location
      ? this.options.captureFocus?.()
      : undefined;
    if (!captured || this.state.index < 0) return this.state.history;
    return this.state.history.map((entry, index) =>
      index === this.state.index
        ? Object.freeze({ ...entry, focus: captured })
        : entry,
    );
  }

  #setState(state: RouterState): void {
    this.#store.setState(() => Object.freeze(state));
  }

  #emit(event: Omit<RouterEvent, "at">): void {
    const complete = Object.freeze({ ...event, at: Date.now() });
    for (const observer of this.#observers) {
      try {
        observer(complete);
      } catch (error) {
        try {
          this.options.onObserverError?.(error);
        } catch {
          // Observer error reporting must not corrupt router state.
        }
      }
    }
  }
}
```

## Members [#members]

| Member                 | Type                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | Required | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | Related types                                                                                                                                            |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `#routes`              | `Map<string, readonly InternalRouteMatch[]>`                                                                                                                                                                                                                                                                                                                                                                                                                                                             | Yes      | The `#routes` member uses the `Map<string, readonly InternalRouteMatch[]>` contract.                                                                                                                                                                                                                                                                                                                                                                                                                                                          | —                                                                                                                                                        |
| `#store`               | `import("nusm").NusmStore<RouterState>`                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | Yes      | The `#store` member uses the `import("nusm").NusmStore<RouterState>` contract.                                                                                                                                                                                                                                                                                                                                                                                                                                                                | [`RouterState`](/tuil/docs/reference/packages/router/api/router-state)                                                                                        |
| `#observers`           | `Set<(event: RouterEvent) => void>`                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | Yes      | The `#observers` member uses the `Set<(event: RouterEvent) => void>` contract.                                                                                                                                                                                                                                                                                                                                                                                                                                                                | [`RouterEvent`](/tuil/docs/reference/packages/router/api/router-event)                                                                                        |
| `#navigation`          | `AbortController \| undefined`                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | No       | The `#navigation` member uses the `AbortController \| undefined` contract.                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | —                                                                                                                                                        |
| `__constructor`        | `any`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | Yes      | The `__constructor` member uses the `any` contract.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | —                                                                                                                                                        |
| `state`                | `RouterState`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | Yes      | The `state` member uses the `RouterState` contract.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | [`RouterState`](/tuil/docs/reference/packages/router/api/router-state)                                                                                        |
| `routes`               | `readonly string[]`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | Yes      | The `routes` member uses the `readonly string[]` contract.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | —                                                                                                                                                        |
| `subscribe`            | `(observer: () => void) => () => void`                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | Yes      | The `subscribe` member uses the `(observer: () => void) => () => void` contract.                                                                                                                                                                                                                                                                                                                                                                                                                                                              | —                                                                                                                                                        |
| `observe`              | `(observer: (event: RouterEvent) => void) => () => void`                                                                                                                                                                                                                                                                                                                                                                                                                                                 | Yes      | The `observe` member uses the `(observer: (event: RouterEvent) => void) => () => void` contract.                                                                                                                                                                                                                                                                                                                                                                                                                                              | [`RouterEvent`](/tuil/docs/reference/packages/router/api/router-event)                                                                                        |
| `navigate`             | `<TPath extends RoutePaths<TRoutes>>(target: NavigationTarget<TPath, RouteParamsAtPath<TRoutes, TPath>>) => Promise<NavigationEntry>`                                                                                                                                                                                                                                                                                                                                                                    | Yes      | The `navigate` member uses the `<TPath extends RoutePaths<TRoutes>>(target: NavigationTarget<TPath, RouteParamsAtPath<TRoutes, TPath>>) => Promise<NavigationEntry>` contract.                                                                                                                                                                                                                                                                                                                                                                | [`NavigationEntry`](/tuil/docs/reference/packages/router/api/navigation-entry), [`NavigationTarget`](/tuil/docs/reference/packages/router/api/navigation-target)   |
| `#navigate`            | `(target: NavigationTarget) => Promise<NavigationEntry>`                                                                                                                                                                                                                                                                                                                                                                                                                                                 | Yes      | The `#navigate` member uses the `(target: NavigationTarget) => Promise<NavigationEntry>` contract.                                                                                                                                                                                                                                                                                                                                                                                                                                            | [`NavigationEntry`](/tuil/docs/reference/packages/router/api/navigation-entry), [`NavigationTarget`](/tuil/docs/reference/packages/router/api/navigation-target)   |
| `open`                 | `<TPath extends RoutePaths<TRoutes>>(target: &#123; readonly route: TPath; readonly surface: Exclude<NavigationSurface, "screen">; readonly signal?: AbortSignal; &#125; & (unknown extends RouteParamsAtPath<TRoutes, TPath> ? &#123; readonly params?: unknown; &#125; : keyof RouteParamsAtPath<TRoutes, TPath> extends never ? &#123; readonly params?: RouteParamsAtPath<TRoutes, TPath>; &#125; : &#123; readonly params: RouteParamsAtPath<TRoutes, TPath>; &#125;)) => Promise<NavigationEntry>` | Yes      | The `open` member uses the `<TPath extends RoutePaths<TRoutes>>(target: &#123; readonly route: TPath; readonly surface: Exclude<NavigationSurface, "screen">; readonly signal?: AbortSignal; &#125; & (unknown extends RouteParamsAtPath<TRoutes, TPath> ? &#123; readonly params?: unknown; &#125; : keyof RouteParamsAtPath<TRoutes, TPath> extends never ? &#123; readonly params?: RouteParamsAtPath<TRoutes, TPath>; &#125; : &#123; readonly params: RouteParamsAtPath<TRoutes, TPath>; &#125;)) => Promise<NavigationEntry>` contract. | [`NavigationEntry`](/tuil/docs/reference/packages/router/api/navigation-entry), [`NavigationSurface`](/tuil/docs/reference/packages/router/api/navigation-surface) |
| `back`                 | `(signal?: AbortSignal) => Promise<NavigationEntry \| undefined>`                                                                                                                                                                                                                                                                                                                                                                                                                                        | Yes      | The `back` member uses the `(signal?: AbortSignal) => Promise<NavigationEntry \| undefined>` contract.                                                                                                                                                                                                                                                                                                                                                                                                                                        | [`NavigationEntry`](/tuil/docs/reference/packages/router/api/navigation-entry)                                                                                |
| `forward`              | `(signal?: AbortSignal) => Promise<NavigationEntry \| undefined>`                                                                                                                                                                                                                                                                                                                                                                                                                                        | Yes      | The `forward` member uses the `(signal?: AbortSignal) => Promise<NavigationEntry \| undefined>` contract.                                                                                                                                                                                                                                                                                                                                                                                                                                     | [`NavigationEntry`](/tuil/docs/reference/packages/router/api/navigation-entry)                                                                                |
| `cancel`               | `(reason?: unknown) => void`                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | Yes      | The `cancel` member uses the `(reason?: unknown) => void` contract.                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | —                                                                                                                                                        |
| `dispose`              | `() => void`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | Yes      | The `dispose` member uses the `() => void` contract.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          | —                                                                                                                                                        |
| `#restore`             | `(index: number, externalSignal?: AbortSignal) => Promise<NavigationEntry \| undefined>`                                                                                                                                                                                                                                                                                                                                                                                                                 | Yes      | The `#restore` member uses the `(index: number, externalSignal?: AbortSignal) => Promise<NavigationEntry \| undefined>` contract.                                                                                                                                                                                                                                                                                                                                                                                                             | [`NavigationEntry`](/tuil/docs/reference/packages/router/api/navigation-entry)                                                                                |
| `#assertCanLeave`      | `(entry: NavigationEntry \| undefined, signal: AbortSignal) => Promise<void>`                                                                                                                                                                                                                                                                                                                                                                                                                            | Yes      | The `#assertCanLeave` member uses the `(entry: NavigationEntry \| undefined, signal: AbortSignal) => Promise<void>` contract.                                                                                                                                                                                                                                                                                                                                                                                                                 | [`NavigationEntry`](/tuil/docs/reference/packages/router/api/navigation-entry)                                                                                |
| `#captureCurrentFocus` | `() => readonly NavigationEntry[]`                                                                                                                                                                                                                                                                                                                                                                                                                                                                       | Yes      | The `#captureCurrentFocus` member uses the `() => readonly NavigationEntry[]` contract.                                                                                                                                                                                                                                                                                                                                                                                                                                                       | [`NavigationEntry`](/tuil/docs/reference/packages/router/api/navigation-entry)                                                                                |
| `#setState`            | `(state: RouterState) => void`                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | Yes      | The `#setState` member uses the `(state: RouterState) => void` contract.                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | [`RouterState`](/tuil/docs/reference/packages/router/api/router-state)                                                                                        |
| `#emit`                | `(event: Omit<RouterEvent, "at">) => void`                                                                                                                                                                                                                                                                                                                                                                                                                                                               | Yes      | The `#emit` member uses the `(event: Omit<RouterEvent, "at">) => void` contract.                                                                                                                                                                                                                                                                                                                                                                                                                                                              | [`RouterEvent`](/tuil/docs/reference/packages/router/api/router-event)                                                                                        |

## Parameters [#parameters]

This declaration has no public members.

## Returns [#returns]

This declaration does not return a value.

## Throws [#throws]

No thrown errors are documented for this declaration.

## Related types [#related-types]

* [`NavigationEntry`](/tuil/docs/reference/packages/router/api/navigation-entry)
* [`NavigationSurface`](/tuil/docs/reference/packages/router/api/navigation-surface)
* [`NavigationTarget`](/tuil/docs/reference/packages/router/api/navigation-target)
* [`RouteContext`](/tuil/docs/reference/packages/router/api/route-context)
* [`RouteMatch`](/tuil/docs/reference/packages/router/api/route-match)
* [`RouterEvent`](/tuil/docs/reference/packages/router/api/router-event)
* [`RouterState`](/tuil/docs/reference/packages/router/api/router-state)
* [`TerminalRouterOptions`](/tuil/docs/reference/packages/router/api/terminal-router-options)

## Source [#source]

[View the secondary source reference](https://github.com/mwillbanks/tuil/blob/main/packages/router/src/index.ts)

## Package [#package]

[@mwillbanks/tuil-router](/tuil/docs/reference/packages/router)
