Skip to content

Custom Router

Routup ships two built-in routers (LinearRouter, TrieRouter) but the router is fully pluggable. You can write your own — for example, to integrate a third-party route table or instrument lookups for tracing — and pass it to App via the router option. Path-level caching is a separate concern handled by the cache option (see the Custom Cache guide).

The router contract is the IRouter<T> interface, generic over the per-route data you want to carry. App uses IRouter<RouteEntry> (where RouteEntry discriminates handler vs. nested-app), but IRouter<T> accepts any object-shaped T so the same router can be used standalone for routing problems that have nothing to do with App.

The IRouter<T> contract

typescript
interface IRouter<T extends ObjectLiteral = ObjectLiteral> {
    add(route: Route<T>): void;
    lookup(path: string, method?: string): readonly RouteMatch<T>[];
}

type Route<T> = {
    path?: Path;
    method?: MethodName;
    data: T;
};

type RouteMatch<T> = {
    route: Route<T>;
    index: number;
    params: Record<string, any>;
    path?: string;
};

The router is responsible for path and methoddata is opaque and must be returned as-is on match. The router should never inspect data.

Exact-vs-prefix convention

Custom implementations must honor a single rule:

  • route.method !== undefined → match the path exactly (the route is method-bound, e.g. app.get('/users', …)).
  • route.method === undefined → match by prefix (middleware, nested app).

Method matching against the request's HTTP method stays at the dispatch-loop call site — your router only decides whether the path qualifies. (Method-aware routers like TrieRouter may use the optional method argument to narrow at lookup time as a perf optimisation.)

No enumeration on the contract

IRouter<T> deliberately has no routes accessor. App keeps its own list of registered routes and uses that for plugin sub-app mounting and option cascading — your router never has to expose its internal storage. This frees future router implementations (aggregated regex, freeze-after-first-match) to discard the original entries once they've built their lookup structure.

Registration order

Lookup results must come back in registration order. The dispatch loop's setNext continuation relies on this: when a middleware calls event.next(), the pipeline resumes from index + 1 in the same list.

A minimal example

A custom router that wraps LinearRouter and counts lookups:

typescript
import { App, LinearRouter, defineCoreHandler } from 'routup';
import type { IRouter, Route, RouteMatch, RouteEntry } from 'routup';

class CountingRouter implements IRouter<RouteEntry> {
    private inner = new LinearRouter<RouteEntry>();
    public lookups = 0;

    add(route: Route<RouteEntry>): void {
        this.inner.add(route);
    }

    lookup(path: string): readonly RouteMatch<RouteEntry>[] {
        this.lookups += 1;
        return this.inner.lookup(path);
    }
}

const router = new CountingRouter();
const app = new App({ router });
app.get('/ping', defineCoreHandler(() => 'pong'));

await app.fetch(new Request('http://localhost/ping'));
console.log(router.lookups); // 1

Using IRouter<T> outside App

Because the router is generic, you can use the built-in routers for any path-keyed lookup — no App required:

typescript
import { TrieRouter } from 'routup';
import type { IRouter } from 'routup';

type Config = { handler: string; cache: boolean };

const table: IRouter<Config> = new TrieRouter<Config>();

table.add({
    path: '/users/:id',
    method: 'GET',
    data: { handler: 'users.show', cache: true },
});

const [match] = table.lookup('/users/42');
console.log(match.route.data.handler); // 'users.show'
console.log(match.params);              // { id: '42' }

routup exposes one helper that custom routers commonly want — buildRoutePathMatcher(route) — which returns a path-to-regexp-backed IPathMatcher honoring the exact-vs-prefix convention. It returns undefined when the route has no path (middleware that matches every request).

typescript
import { buildRoutePathMatcher } from 'routup';

const matcher = buildRoutePathMatcher(route);
if (matcher) {
    const result = matcher.exec(requestPath);
    if (result) {
        // result.params, result.path
    }
}

Use it directly when you want stock path semantics; bypass it when you're building a radix tree, an aggregated regex, or another structure-specific mechanism.