@miurajs/miura-router
v0.1.6
Published
Modern, declarative routing for miura applications. Built for Web Components, the router handles hash/history/memory navigation modes, async guards, data loaders, redirects, and DOM rendering hooks.
Readme
@miurajs/miura-router
Modern, declarative routing for miura applications. Built for Web Components, the router handles hash/history/memory navigation modes, async guards, data loaders, redirects, and DOM rendering hooks.
✨ Features
- Multiple navigation modes:
hash,history, or in-memory for tests. - Guards & loaders: resolve access/gate data before components render.
- Nested routes & redirects: declarative tree definitions.
- Type-safe route params:
defineRoute<TParams>()with typedbuildPath()andnavigate(). - Runtime param validation: optional Zod / Valibot / ArkType schema on any route.
- Event-driven: emits lifecycle events through the framework EventBus.
- Performance hooks: timing integration via
PerformanceMonitor.
🚦 Quick Start
import { createRouter } from '@miurajs/miura-router';
const router = createRouter({
mode: 'history',
base: '/',
fallback: '/login',
routes: [
{ path: '/', component: 'app-home' },
{
path: '/admin',
component: 'app-admin',
renderZone: '#primary',
guards: [async ({ data }) => (data.user?.isAdmin ? true : '/login')],
loaders: [async () => ({ stats: await fetchStats() })],
},
],
render: (context) => mountIntoDom(context),
eventBus,
performance,
});
await router.start();🛡️ Route Guards
Guards run before loaders/rendering. They may:
- return
true/voidto continue - return
falseto block (router:blockedevent) - return a string path (sync/async) to redirect
const routes = [
{
path: '/dashboard',
component: 'app-dashboard',
guards: [async ({ data }) => (data.session ? true : '/login')],
},
];📦 Route Loaders
Loaders run after guards and before rendering. Existing function loaders still work as before and their returned objects are shallow-merged into context.data.
const routes = [
{
path: '/profile/:id',
component: 'app-profile',
loaders: [
({ params }) => ({ profile: fetchProfile(params.id) }),
async ({ params }) => ({ permissions: await fetchPermissions(params.id) }),
],
},
];Access loader results inside the render callback (or components via routeContext) through context.data.
For richer route state, you can also use named loaders:
const routes = [
{
path: '/profile/:id',
component: 'app-profile',
loaders: [
{
key: 'profile',
load: ({ params }) => fetchProfile(params.id),
},
{
key: 'permissions',
optional: true,
load: async ({ params }) => fetchPermissions(params.id),
},
],
},
];Named loader results are exposed under context.data.<key>, and the full loader lifecycle is available on context.loaders:
context.loaders.statuscontext.loaders.entries.profilecontext.loaders.entries.permissionscontext.loaders.error
Optional loaders may fail without aborting navigation, which makes it easier to render partial route data.
🗂️ Nested Routes & Layout Outlets
Define a children array on any route to create a parent/child hierarchy. The parent route acts as a layout shell; the matched child fills the <miura-router-outlet> inside it.
const routes = [
{
path: '/app',
component: 'app-layout', // renders the shell + <miura-router-outlet>
children: [
{ path: 'dashboard', component: 'app-dashboard' },
{ path: 'settings', component: 'app-settings' },
{ path: 'profile/:id', component: 'app-profile' },
],
},
];// app-layout component
template() {
return html`
<nav>...</nav>
<main>
<miura-router-outlet></miura-router-outlet> <!-- child component mounts here -->
</main>
`;
}context.matched contains the full chain from root to leaf, so nested outlets at any depth receive the correct slice of the matched array.
🔗 <miura-router-outlet>
The <miura-router-outlet> custom element is a passive mount-point. The router's render callback uses context.matched to determine which components to mount at each level.
import { RouterOutlet } from '@miurajs/miura-router';
// RouterOutlet registers itself as <miura-router-outlet> when imported� Redirects
{ path: '/old-path', redirect: '/new-path' }
{ path: '/dynamic', redirect: (ctx) => `/target/${ctx.params.id}` }�📢 Router Events
| Event | When |
|-------|------|
| router:setup | Router initialised |
| router:navigating | Navigation started |
| router:navigated | Navigation committed |
| router:blocked | Guard returned false |
| router:not-found | No matching route |
| router:error | Unhandled navigation error |
| router:rendered | Render callback completed |
🛠️ Router API
| Method | Description |
|--------|-------------|
| router.navigate(path, opts?) | Push a new entry and navigate |
| router.replace(path, opts?) | Replace current entry and navigate |
| router.back() | Go back in history |
| router.forward() | Go forward in history |
| router.current | Current RouteContext |
| router.previous | Previous RouteContext |
| router.start() | Start listening to navigation events |
| router.stop() | Stop listening (keeps state) |
| router.destroy() | Full teardown |
navigate() and replace() both return Promise<NavigationResult>:
const result = await router.navigate('/dashboard');
if (!result.ok) console.log('blocked:', result.reason);🔷 Type-Safe Route Params
Use defineRoute<TParams>() to get typed buildPath() and navigate() helpers with compile-time safety on route params.
import { defineRoute, createRouter } from '@miurajs/miura-router';
// No params
const homeRoute = defineRoute({ path: '/', component: 'app-home' });
// Single param — TypeScript enforces { id: string }
const userRoute = defineRoute<{ id: string }>({
path: '/users/:id',
component: 'user-page',
});
// Multiple params
const postRoute = defineRoute<{ userId: string; postId: string }>({
path: '/users/:userId/posts/:postId',
component: 'post-page',
});
// Pass records to createRouter
const router = createRouter({
mode: 'history',
routes: [homeRoute.record, userRoute.record, postRoute.record],
render: (ctx) => mountComponent(ctx),
});
// Typed navigation — TS error if a param is missing or wrong type
await userRoute.navigate(router, { id: '42' });
await postRoute.navigate(router, { userId: '1', postId: '99' });
// Build path without navigating
userRoute.buildPath({ id: '42' }); // → '/users/42'
postRoute.buildPath({ userId: '1', postId: '99' }); // → '/users/1/posts/99'Runtime validation with Zod
Pass any Zod-compatible schema as a second argument. Params are validated (and coerced) after every match, before guards run.
import { z } from 'zod';
const UserParams = z.object({ id: z.string().regex(/^\d+$/) });
const userRoute = defineRoute(
{ path: '/users/:id', component: 'user-page' },
UserParams, // ← schema
);
// Navigation to /users/abc → NavigationResult { ok: false, reason: 'error' }
// Navigation to /users/42 → proceeds normally, ctx.params.id is '42'The ParamsSchema interface is minimal — anything with safeParse() works (Zod, Valibot, ArkType, custom).
🧪 Testing
Use mode: 'memory' to avoid touching the real browser location. Provide a spy render callback to inspect contexts:
const renders: RouteRenderContext[] = [];
const router = createRouter({
mode: 'memory',
routes,
render: (ctx) => { renders.push(ctx); },
});
await router.start();
await router.navigate('/dashboard');
assert.equal(renders.at(-1)?.route.component, 'app-dashboard');The repository contains test/router.guards-loaders.test.ts covering redirects, blocks, and loader merges.
📚 Framework Integration
MiuraFramework wires this router automatically. Define a static router config in your framework subclass, and the framework handles instantiation, DOM zones, and navigation helpers (navigate, replaceRoute, goBack, goForward).
When a route defines meta.title, MiuraFramework also updates document.title automatically:
{
path: '/dashboard',
component: 'app-dashboard',
meta: {
title: ({ data }) => `Dashboard (${data.stats?.total ?? 0})`,
},
}