Skip to content
🔀
@tuvix.js/router
ルーター
マイクロアプリ向けのhistoryルーティングとhashルーティング、coreとサーバーで共有する単一のパスマッチャー、クライアントサイドナビゲーションのためのリンクインターセプトを提供します。

インストール ​

bash
npm install @tuvix.js/router
# or pnpm add @tuvix.js/router

@tuvix.js/coreはこのパッケージに依存し、createRouter、matchPath、interceptLinksを再エクスポートしています。そのため、この3つはcoreからもインポートできます。

使いどころ ​

ルーターはURLパスをマイクロアプリ名に対応付け、パスが変わったときに通知します。シェルがナビゲーションを管理し、URLごとにどのマイクロアプリをアクティブにするかを決める場合に使います。createOrchestratorにrouterを渡すとオーケストレーターがルーターを作成するため、ほとんどのシェルでは手動で構築する必要はありません。

matchPathはTuvix.jsで唯一のパターンマッチャーです。coreはactiveWhen文字列に、@tuvix.js/serverはアプリのroutesにこれを使います。そのため、パターンは3か所すべてで同じ意味になります。ルーターなしで単独で呼び出すこともできます。

シェルですでにReact Router、TanStack Router、または他のフレームワークのルーターを使っている場合は、そのまま使い続けてください。@tuvix.js/coreのorchestrator.connectRouter({ subscribe, getPath })でオーケストレーターに接続し、このルーターは使いません。その場合でもmatchPathとinterceptLinksは併用できます。

クイックスタート ​

ts
import { createRouter, interceptLinks } from '@tuvix.js/router';

const router = createRouter({
  mode: 'history',
  base: '/app',
  routes: [
    { path: '/dashboard/*', app: 'dashboard' },
    { path: '/users/:id', app: 'user-profile' },
    { path: '/settings', app: 'settings' },
  ],
});

router.onChange(({ from, to, toRoute }) => {
  console.log(`${from} -> ${to}`, toRoute?.route.app, toRoute?.params);
});

router.beforeEach(({ from }) => !from.startsWith('/settings') || confirm('Discard unsaved settings?'));

// <a href="/app/users/42"> now calls router.push('/users/42') instead of reloading.
const stopLinks = interceptLinks({
  navigate: (path) => router.push(path),
  base: '/app',
});

await router.push('/users/42?tab=posts');
console.log(router.currentPath); // '/users/42?tab=posts'
console.log(router.currentRoute?.params); // { id: '42' }
console.log(window.location.pathname); // '/app/users/42'

// On teardown:
stopLinks();
router.destroy();

ルーターはコンストラクター内でpopstate(historyモード)またはhashchange(hashモード)のリスナーを登録します。start()の呼び出しはありません。

オーケストレーターと併用する場合は、同じ設定をrouterとして渡し、オーケストレーター経由でナビゲーションします。

ts
import { createOrchestrator } from '@tuvix.js/core';
import { interceptLinks } from '@tuvix.js/router';

const orchestrator = createOrchestrator({
  router: {
    mode: 'history',
    routes: [{ path: '/dashboard/*', app: 'dashboard' }],
  },
});

orchestrator.register({
  name: 'dashboard',
  entry: 'https://cdn.example.com/dashboard/main.js',
  container: '#main',
});

await orchestrator.start();

interceptLinks({ navigate: (path) => orchestrator.navigateTo(path) });

activeWhenを持たないアプリは、そのアプリ名を指定しているroutesに従います。orchestrator.getRouter()はIRouterインスタンスを返します。

API ​

createRouter ​

ts
import type { IRouter, RouterConfig } from '@tuvix.js/router';

declare function createRouter(config: RouterConfig): IRouter;

IRouterとして型付けされたnew Router(config)を返します。

オプション型デフォルト説明
routesRouteConfig[]必須ルートテーブル。ルーターは配列の順にルートを検査し、最初に一致したものが採用されます。
mode'history' | 'hash''history'historyはlocation.pathname + location.searchを読み取り、pushStateで書き込みます。hashは#/pathを読み書きします。
basestring'/'アプリを配信するパスのプレフィックス。historyモード専用です。ルーターはURLの読み取り時にこれを取り除き、書き込み時に付加します。hashモードでは無視されます。

各RouteConfig:

オプション型デフォルト説明
pathstring必須matchPath構文のパターン。パス全体に対して照合します。'/'はホームページのみ、'/*'はすべてのパス、'/blog/*'は/blogとその配下すべてに一致します。
appstring必須アクティブにするマイクロアプリ名。
metaRecord<string, unknown>なし任意のデータ。ルーターはMatchedRoute.route.metaでそのまま渡します。

Router ​

ts
import { Router } from '@tuvix.js/router';

const router = new Router({ routes: [{ path: '/cart', app: 'cart' }] });

createRouterの背後にあるクラスです。IRouterを実装しています。

メンバーシグネチャ動作
currentPathreadonly stringbaseを除いたアプリ相対パス。historyモードではクエリ文字列を含みます。
currentRoutereadonly MatchedRoute | nullmatch(currentPath)の結果。
push(path: string) => Promise<void>ガードを実行し、history.pushStateでURLを書き込み(historyモードではbase + path、hashモードでは#path)、その後onChangeハンドラーを実行します。
replace(path: string) => Promise<void>history.replaceStateを使う点以外はpushと同じです。
back() => voidwindow.history.back()を呼び出します。
forward() => voidwindow.history.forward()を呼び出します。
onChange(handler: RouteChangeHandler) => () => voidナビゲーションが完了するたびにhandlerを実行します。購読解除関数を返します。
beforeEach(guard: NavigationGuard) => () => voidガードを追加します。そのガードを削除する関数を返します。
match(path: string) => MatchedRoute | nullpathに一致する最初のルート、またはnull。
getActiveApps(path?: string) => string[]path(デフォルトはcurrentPath)全体に一致するすべてのルートのアプリ名を、ルート順に重複なしで返します。
destroy() => voidwindowのリスナーを削除し、ハンドラーとガードをクリアします。

pushとreplaceにはbaseからの相対パスを渡します。base: '/app'の場合、router.push('/x')は/app/xを書き込み、currentPathは/xになります。

ts
import { createRouter } from '@tuvix.js/router';

const router = createRouter({
  base: '/app',
  routes: [
    { path: '/*', app: 'header' },
    { path: '/docs/*', app: 'docs' },
    { path: '/docs/:page', app: 'doc-sidebar' },
  ],
});

router.match('/docs/intro')?.route.app; // 'header' (first match wins)
router.getActiveApps('/docs/intro'); // ['header', 'docs', 'doc-sidebar']
router.getActiveApps('/'); // ['header']
await router.replace('/docs/intro'); // URL: /app/docs/intro, no new history entry
router.back();

match、getActiveApps、isPathActiveは同じパス全体のルールを適用します。ルート'/'は/でのみアクティブです。すべてのページに表示するアプリには'/*'を使ってください。

ガード ​

ガードは追加した順に1つずつ、URLが変わる前に実行されます。falseに解決されるとナビゲーションはキャンセルされ、残りのガードはスキップされます。それ以外の値ではナビゲーションが続行されます。例外を投げる、またはrejectするガードはナビゲーションをキャンセルし、ルーターはそのエラーをconsole.errorでログに出力します。

ts
import { createRouter } from '@tuvix.js/router';

declare function isAdmin(): Promise<boolean>;

const router = createRouter({
  routes: [{ path: '/admin/*', app: 'admin' }],
});

const removeGuard = router.beforeEach(async ({ to }) => {
  if (to.startsWith('/admin')) return isAdmin();
  return true;
});

removeGuard();

ガードはブラウザの戻る・進むボタンでも実行されます。その時点でブラウザはすでに移動しているため、ガードがキャンセルすると、ルーターはhistory.replaceStateを呼び出して直前のURL(base付き)をアドレスバーに戻します。履歴エントリーはブラウザが移動した位置のままです。

matchPath ​

ts
import type { MatchPathOptions, PathMatch } from '@tuvix.js/router';

declare function matchPath(
  pattern: string,
  path: string,
  options?: MatchPathOptions
): PathMatch;
オプション型デフォルト説明
endbooleanfalsetrueの場合、/*を含まないパターンはパス全体に一致する必要があります。falseの場合はすべてのサブパスにも一致します。

{ matched, params }を返します。ルールは次のとおりです。

  • マッチャーはpathから?queryと#hashを取り除き、両側の末尾スラッシュを無視します。
  • :nameは空でない1つのセグメントをparams.nameにURIデコードして取り込みます。パーセントエンコーディングが不正な値は記述されたままになります。セグメントにはテキストとパラメーターを混在できます:/v:versionは/v2に一致します。
  • 末尾の/*はベースパスとその配下すべてに一致します:/docs/*は/docsと/docs/a/bに一致します。残りの部分はparamsに取り込まれません。
  • パターンの途中にある*は1つのセグメントに一致します。
  • デフォルトでは、通常のパターンはセグメント境界でサブパスに一致します:/docsは/docs/introに一致し、/docsifyには一致しません。
  • '/'はデフォルトですべてのパスに一致します。{ end: true }を指定すると/にのみ一致します。ルーターのルートは常にend: trueのルールを使い、coreのactiveWhen文字列と@tuvix.js/serverのアプリroutesはデフォルトのプレフィックスルールを使います。
  • パターン内のその他の正規表現文字はリテラルとして一致します。照合は大文字と小文字を区別します。
ts
import { matchPath } from '@tuvix.js/router';

matchPath('/users/:id', '/users/42?tab=posts#bio'); // { matched: true, params: { id: '42' } }
matchPath('/files/:name', '/files/a%20b.txt').params.name; // 'a b.txt'
matchPath('/docs', '/docs/intro').matched; // true
matchPath('/docs', '/docs/intro', { end: true }).matched; // false
matchPath('/docs', '/docsify').matched; // false
matchPath('/', '/anything').matched; // true
matchPath('/a/*/c', '/a/b/c').matched; // true

マッチャーは各パターンを一度だけコンパイルしてRegExpをキャッシュするため、ナビゲーションのたびに呼び出せます。

matchRoute ​

ts
import type { MatchedRoute, RouteConfig } from '@tuvix.js/router';

declare function matchRoute(path: string, routes: RouteConfig[]): MatchedRoute | null;

pathがパス全体で一致する(end: trueのルール)最初のルートを、正規化したパス、デコード済みパラメーター、解析済みクエリとともに返します。#hashは無視します。Router.matchはこの関数を呼び出します。

ts
import { matchRoute } from '@tuvix.js/router';

matchRoute('/users/42?tab=info', [{ path: '/users/:id', app: 'users' }]);
// { route: { path: '/users/:id', app: 'users' }, path: '/users/42', params: { id: '42' }, query: { tab: 'info' } }

isPathActive ​

ts
declare function isPathActive(path: string, pattern: string): boolean;

matchPath(pattern, path, { end: true }).matchedです。ルートが使うパス全体のルールなので、'/'は/でのみアクティブです。Router.getActiveAppsはこの関数を使います。

ts
import { isPathActive } from '@tuvix.js/router';

isPathActive('/settings/profile', '/settings'); // false
isPathActive('/settings/profile', '/settings/*'); // true
isPathActive('/settings/profile', '/'); // false
isPathActive('/settings/profile', '/*'); // true

pathToRegex ​

ts
declare function pathToRegex(pattern: string): { regex: RegExp; paramNames: string[] };

パターンをコンパイルした完全一致のRegExpと、そのパラメーター名を順に返します。regexオブジェクトは共有キャッシュから取得されるため、lastIndexなどの状態を変更しないでください。この正規表現はクエリやハッシュを含まない正規化済みパスに対して実行されます。パスにそれらが含まれる可能性がある場合はmatchPathを使ってください。

ts
import { pathToRegex } from '@tuvix.js/router';

const { regex, paramNames } = pathToRegex('/posts/:slug');
regex.test('/posts/hello'); // true
paramNames; // ['slug']

parseQuery ​

ts
declare function parseQuery(queryString: string): Record<string, string>;

先頭の?の有無にかかわらずa=1&b=2を解析します。各ペアを最初の=で分割するため、JWTのような値は=文字を保持します。キーと値をデコードし、デコードに失敗した場合は元のテキストを保持します。=を持たないキーは''に対応付けられます。重複したキーは最後の値を保持します。

ts
import { parseQuery } from '@tuvix.js/router';

parseQuery('?token=abc.def=ghi&debug'); // { token: 'abc.def=ghi', debug: '' }

normalizePath ​

ts
declare function normalizePath(path: string): string;

先頭にスラッシュを追加し、末尾のスラッシュを1つ取り除きます。クエリとハッシュには手を加えません。

ts
import { normalizePath } from '@tuvix.js/router';

normalizePath('users/42/'); // '/users/42'
ts
import type { InterceptLinksOptions } from '@tuvix.js/router';

declare function interceptLinks(options: InterceptLinksOptions): () => void;

root配下の<a href>要素のクリックを監視し、処理対象のクリックでpreventDefault()を呼び出して、そのパスをnavigateに渡します。追加したすべてのリスナーを削除する関数を返します。

オプション型デフォルト説明
navigate(path: string, event: MouseEvent) => void | Promise<void>必須baseを取り除いたpathname + search + hashを受け取ります。投げられたエラーやrejectされたPromiseはconsole.errorでログに出力されます。
rootDocument | HTMLElementdocumentクリックリスナーを登録する要素。
basestring'/'アプリを配信するパスのプレフィックス。その外側へのリンクはブラウザに任せます。navigateとprefetchを呼び出す前にこれを取り除きます。
shouldIntercept(anchor: HTMLAnchorElement, url: URL, event: MouseEvent) => booleanなし組み込みのチェックの後に実行されます。falseを返すとクリックをブラウザに任せます。
prefetch(path: string, anchor: HTMLAnchorElement) => voidなし対象となるリンク上でのpointerenterまたはfocusinで、pathname + searchごとに1回実行されます。パスにハッシュは含まれません。

次のいずれかに該当する場合、interceptLinksはクリックをブラウザに任せます。

  • 別のハンドラーがすでにpreventDefault()を呼び出している。
  • 押されたボタンが主ボタンではない、またはCtrl、Meta、Shift、Altが押されている。
  • アンカーが_self以外のtarget、download属性、externalを含むrel、またはdata-tuvix-reload属性を持っている。
  • 解決されたURLのオリジンがページと異なる。
  • URLのパスがbaseの外側にある(/appは/appと/app/...を対象とし、/applicationは対象外)。
  • リンクがハッシュを持ち、パスとクエリが現在のページと同じである。この場合はブラウザがアンカーまでスクロールします。
  • shouldInterceptがfalseを返す。

prefetchには属性、オリジン、baseのチェックが適用されます。修飾キーとハッシュのみのルールは無視され、現在のページへのリンクでも実行されます。

ts
import { createRouter, interceptLinks } from '@tuvix.js/router';

const router = createRouter({ routes: [{ path: '/shop/*', app: 'shop' }] });
const nav = document.querySelector('nav') ?? document;

const stop = interceptLinks({
  navigate: (path) => router.push(path),
  root: nav,
  shouldIntercept: (anchor) => !anchor.closest('[data-legacy]'),
  prefetch: (path) => {
    const link = document.createElement('link');
    link.rel = 'prefetch';
    link.href = path;
    document.head.append(link);
  },
});

stop();

interceptLinksはresolveLinkでアンカーを見つけます。そのため、open shadow root内のリンクも動作し、SVGの<a>要素はブラウザに任されます。documentやwindowのない環境では、何もしないクリーンアップ関数を返します。

ts
import type { ResolvedLink, ResolveLinkOptions } from '@tuvix.js/router';

declare function resolveLink(
  event: Event,
  options?: ResolveLinkOptions
): ResolvedLink | null;

イベントが発生した<a href>を見つけ、その絶対URLとアプリ相対パスとともに返します。リンクをブラウザに任せるべき場合はnullを返します。interceptLinksとオーケストレーターのprefetch: { strategy: 'hover' }がこの関数を使います。同じリンクのルールが必要な場合は、独自のリスナーから呼び出してください。たとえば、ホバー時にデータを事前に読み込む場合や、アプリ内ナビゲーションをログに記録する場合です。

オプション型デフォルト説明
basestring'/'アプリを配信するパスのプレフィックス。その外側へのリンクではnullを返します。pathからはこれを取り除きます。
フィールド型説明
anchorHTMLAnchorElement<a>要素。
urlURLリンクの絶対URL。
pathstringbaseを取り除いたpathname + search + hash。
ts
import { resolveLink } from '@tuvix.js/router';

const warmed = new Set<string>();

function warmUp(event: Event): void {
  const link = resolveLink(event, { base: '/app' });
  if (!link || warmed.has(link.url.pathname)) return;
  warmed.add(link.url.pathname);
  void fetch(`/api/page-data?path=${encodeURIComponent(link.path)}`);
}

// pointerenter does not bubble, so listen in the capture phase.
document.addEventListener('pointerenter', warmUp, true);
document.addEventListener('focusin', warmUp);

次のいずれかに該当する場合、resolveLinkはnullを返します。

  • イベントパス上で最も近い<a>がSVGの<a>である、hrefのないHTMLの<a>である、または<a>がまったくない。探索は最初に見つかった<a>で止まります。
  • アンカーが_self以外のtarget、download属性、externalを含むrel、またはdata-tuvix-reload属性を持っている。
  • URLのオリジンがページと異なる。
  • URLのパスがbaseの外側にある(/appは/appと/app/...を対象とし、/applicationは対象外)。
  • サーバー上のようにwindowがない。

event.composedPath()を読み取るため、document上のリスナーでもopen shadow root内のリンクを見つけられます。closed shadow root内のリンクは見つけられません。イベントのディスパッチ中に呼び出してください。ディスパッチが終わるとcomposed pathは空になり、関数はevent.target.closest('a')にフォールバックしますが、これはshadow rootの内側を参照できません。

マウスボタン、修飾キー、defaultPrevented、ハッシュのみのルールは確認しません。クリックに対しては、interceptLinksがこれらを追加で適用します。

型 ​

型説明
RouterMode'history' | 'hash'。
RouterConfig{ mode?, base?, routes }。createRouterのオプションです。
RouteConfig{ path, app, meta? }。1つのルートです。
MatchedRoute{ route, path, params, query }。pathは正規化したパス名、paramsはデコード済みの:paramの値、queryは解析済みのクエリ文字列です。
NavigationEvent{ from, to, fromRoute, toRoute }。fromとtoはbaseを除いたアプリ相対パスです。
NavigationGuard(event: NavigationEvent) => boolean | Promise<boolean>。
RouteChangeHandler(event: NavigationEvent) => void。
IRouterRouterが実装し、createRouterが返すインターフェース。
PathMatch{ matched: boolean; params: Record<string, string> }。matchPathの結果です。一致しなかった場合、paramsは{}です。
MatchPathOptions{ end?: boolean }。
InterceptLinksOptionsinterceptLinksのオプション。
ResolveLinkOptions{ base?: string }。resolveLinkのオプションです。
ResolvedLink{ anchor: HTMLAnchorElement; url: URL; path: string }。resolveLinkの結果です。

エッジケース ​

  • 現在のパスと同じパス(クエリを含めて同じ文字列)へのpushとreplaceは、ガードもハンドラーも実行せずに戻ります。
  • 別のナビゲーションを開始しても、ルーターは保留中のナビゲーションをキャンセルしません。各pushは自身のガードを待ってからURLを書き込むため、最後に完了したガードチェーンが優先されます。
  • ルーターがURLの変更を知るのは、自身のpush/replaceと、popstate/hashchangeからだけです。他のコードがhistory.pushStateを呼び出した場合、次のイベントまたはルーターによるナビゲーションまでcurrentPathは古いままです。
  • destroy()の後、push、replace、back、forward、onChange、beforeEachは[Tuvix Router] Cannot use a destroyed Router. Create a new one.を投げます。matchとgetActiveAppsは引き続き動作します。
  • onChangeハンドラーが例外を投げた場合、ルーターはそれをconsole.errorでログに出力し、他のハンドラーは引き続き呼び出します。
  • base: '/app'の場合、ルーターは/applicationを/applicationとして読み取ります。baseはセグメント境界でのみ取り除かれます。historyモードでは、URLの読み取り時に#hashを取り除きます。
  • サーバー上(windowなし)では、コンストラクターはcurrentPathを'/'に設定し、リスナーを登録しません。push、replace、back、forwardにはwindowが必要です。
  • /*パターンのmatchPathはベースパス自体にも一致します:/dashboard/*は/dashboardに一致します。
  • <svg>内の<a>で発生したイベントは、HTMLの<a>がSVGを囲んでいる場合でもnullに解決されます。interceptLinksとオーケストレーターのホバープリフェッチはそのリンクをブラウザに任せ、ブラウザはページ全体を読み込みます。

対象外のこと ​

  • マイクロアプリのマウントやアンマウントは行いません。一致と変更を通知するだけで、それに基づいて動作するのは@tuvix.js/coreのオーケストレーターです。
  • interceptLinksを呼び出さない限り、リンクのクリックはインターセプトしません。
  • ネストしたルート、名前付きルート、リダイレクト、ルートごとのガードはありません。beforeEachを使い、toまたはtoRouteを確認してください。
  • パスの/*の残りの部分をparamsに取り込みません。
  • スクロール位置は復元しません。

0.4からの移行 ​

push()とreplace()はbaseをプレフィックスとして付加するようになりました。baseからの相対パスを渡してください。

ts
import { createRouter } from '@tuvix.js/router';

const router = createRouter({ base: '/app', routes: [] });

// 0.4: router.push('/app/x') wrote /app/x, router.push('/x') wrote /x
// 0.5: router.push('/x') writes /app/x
await router.push('/x');

その他の破壊的変更:

  • baseはセグメント境界でのみ取り除かれます:/applicationが/licationとして読み取られることはなくなりました。
  • match、getActiveApps、isPathActiveでは、ルートはパス全体に対して一致します。0.4では、getActiveAppsとisPathActiveは'/'ルートをすべてのパスでアクティブとして扱っていました。キャッチオールの{ path: '/', app }は{ path: '/*', app }に変更してください。
  • RouteConfig.exactは削除されました。受け付けられても無視されていたので、ルートオブジェクトから削除してください。
  • pathToRegex(pattern)は2番目の引数exactがなくなり、キャッシュされたRegExpを返します。
  • パターン内の正規表現文字はリテラルとして一致し、パターンの途中にある*は1つのセグメントに一致します。
  • matchRoute()とisPathActive()はパス内の#hashを無視します。
  • 0.4のドキュメントにはnavigate、go、onRouteChange、ルーターのorchestratorオプション、ルートごとのguardが記載されていました。これらはいずれも存在しません。push、back/forward、onChange、オーケストレーターのrouter設定、beforeEachを使ってください。