/just-router

Primary LanguageTypeScriptMIT LicenseMIT

just-router

A very simple path matching and routing library. It is framework-independent, works synchronously, has a small size (< 750 B min/gz) and concise API.

Installation

Install it from npm as @davidmz/just-router

Usage (sort of tutorial)

What is "router" and how to create one

Routers are created by createRouter(…) call.

The router itself has a simple function <T>(path: string) => T. It takes path (string) and returns something that makes sense for your application. It can be, fo example, a React component or HTML string.

Path is a regular pathname, the string like "/path/to/some/thing". Path actually processed as a list of segments: "/path/to/some/thing" -> ["path", "to", "some", "thing"]. All slashes are ignored: "/foo", "/foo/", "foo" or "///foo/" are all the same for the router.

Handlers (who do everything here)

The createRouter function takes single argument of type Handler. Handler is the main working type for the all library. It is a function with signature:

type Handler<T, S extends object = object> = (
  context: Context<S>,
  next: Next<Nullable<T>>
) => Nullable<T>;

It may look complex because of type declarations, but actually it is very simple middleware-like (as in koa or other routing libs) function that accepts the request context and the next function, and returns type T or nothing (null | undefined). If handler is a middleware (i.e. it just prepare data for the next handlers), it should call next. If it is a terminal handler, it shouldn't (this will cause an error).

By the way, the context has the following type:

type Context<S extends object = object> = {
  // The initial 'path' passed to router. Immutable.
  readonly path: string;
  // The path segments. This list can be altered by handlers.
  segments: string[];
  // Named parameters, taken from path segments (see _param_ handler).
  pathParams: Record<string, string>;
  // An arbitrary request state. Handlers can store everything in it.
  state: S;
};

The simplest router looks like this:

const router = createRouter(() => "Hello!");

expect(router("/foo/bar")).toBe("Hello!");

This router is pretty useless because it will return "Hello!" for any given path. The createRouter hasn't any path processing logic, we need an additional handlers for it.

Routes and path matching

The route function creates handler that allows to check path segments and call the terminal handler (handler that returns value and don't call 'next'):

const router = createRouter(route("foo", () => "Hello!"));

expect(router("/foo")).toBe("Hello!");
expect(() => router("/foo/bar")).toThrow(RouteNotFound);

route takes variable (but at least one) number of arguments. The last argument must be a handler function, other can be handler functions, strings, or regexps.

route internally converts strings and regexps to the regular handlers. String means that the segment must exactly match the string. Regexp also requires match, but can also capture part of the segment (see below).

The route arguments forms handlers chain and executes sequentially, being linked via the next argument of each other. So route(a, b, c) will act like a(ctx, () => b(ctx, () => c(ctx, next))).

Path parameters

Having route and middleware handlers, we can now process path parameters. Like this:

const router = createRouter(
  route(
    "articles",
    param("slug"),
    (ctx) => "Article: " + ctx.pathParams["slug"]
  )
);

expect(router("/articles/routing")).toBe("Article: routing");

The param helper takes current path segment and places it to the context's pathParams object with the given key.

Regexp matchers

Regexp matchers combines string and param functionality, since it allows you to both specify a segment pattern and capture come data from it.

To match specific segment format just use a relevant regexp: the /[1-9]\d*/ will match only the numeric segments.

To capture segment or it part(s), use regex named groups:

const router = createRouter(
  route(/article(?<id>[1-9]\d*)/, (ctx) => "Article ID=" + ctx.pathParams["id"])
);

expect(router("/article42")).toBe("Article ID=42");

'split' helper

Route arguments work on a per-segment basis, but sometimes it is more convenient to specify (sub)path as a single string. It is possible with split helper. The following routes are equivalent:

route("path", "to", "articles", param("id"), showArticle);
route(split("path/to/articles"), param("id"), showArticle);

Root path

The root path can be matched by the route with single terminal handler:

const router = createRouter(route(() => "I am root!"));

expect(router("/")).toBe("I am root!");

Bunch of routes

So far we have dealt with one route, which matches a single path to a single terminal handler. In practice we need to handle many different paths, a bunch of them. That's what the bunch function is for.

const router = createRouter(
  bunch(
    route(() => "Root page"),
    route("foo", () => "Foo page"),
    route("bar", () => "Bar page")
  )
);

expect(router("/")).toBe("Root page");
expect(router("/foo")).toBe("Foo page");
expect(router("/bar")).toBe("Bar page");
expect(() => router("/baz")).toThrow(RouteNotFound);

The bunch function takes set of handlers and returns a handler (again!) that:

  • Calls given handlers from first to last;
  • Stops on first non-nullish result and returns it.

Catch-all handlers

You may notice that our router throws a RouteNotFound error it there is not matched route. This is because handlers, created with route, (almost) always require a complete path match. In practice, we often need a handler for "all other" paths, for example to show the "Not found" page.

Fortunately, it's very easy to add it. Remember that the "bare" handler doesn't care about path matching. So we can do the following:

const router = createRouter(
  bunch(
    route("foo", () => "Foo page"),
    () => "Not found"
  )
);

expect(router("/foo")).toBe("Foo page");
expect(router("/bar")).toBe("Not found");

Greedy handlers

As was mentioned earlier, route requires the complete path match. This means that at the time the last handler in the chain is called, all segments of the path must be processed. So the route("foo", handler) will match "/foo", but not "/foo/bar", although the last one also starts from "foo" segment.

Sometimes we need a "greedy" handler that grabs all the remaining segments. In such case, just wrap you handler with batch. The handler the bunch returns is greedy, this allows to make nested bunches.

const router = createRouter(
  bunch(
    route("foo", () => "Foo page"),
    route(
      "bar",
      bunch((ctx) => "Bar page: " + (ctx.segments.join(", ") || "-"))
    )
  )
);

expect(router("/foo")).toBe("Foo page");
expect(router("/bar")).toBe("Bar page: -");
expect(router("/bar/baz")).toBe("Bar page: baz");
expect(router("/bar/baz/qux")).toBe("Bar page: baz, qux");

Nested bunches

Here we can create a really complex staff!

const router = createRouter(
  bunch(
    route(() => "Home"),
    route("about", () => "About"),
    route(split("about/contacts"), () => "Contacts"),
    route(
      "projects",
      bunch(
        route(() => "Projects list"),
        route(param("name"), (ctx) => "Project: " + ctx.pathParams["name"]),
      )
    ),
    route(
      "admin",
      checkRights, // Your own logic!
      bunch(
        route(() => "Admin home"),
        route("users", () => "List of users")
        () => "Admin page not found"
      )
    ),
    () => "Page not found"
  )
);

expect(router("/")).toBe("Home");
expect(router("/about")).toBe("About");
expect(router("/about/contacts")).toBe("Contacts");
expect(router("/projects")).toBe("Projects list");
expect(router("/projects/foo")).toBe("Project: foo");
expect(router("/admin/foo")).toBe("Admin page not found");
// ...and so on

That's all you need to know!

API methods signatures

createRouter

function createRouter<T, S>(route: Handler<T, S>): Router<T>;

route

function route<T, S>(
  ...handlers: [...(string | Handler<T, S>)[], Handler<T, S>]
): Handler<T, S>;

bunch

function bunch<T, S>(...handlers: Handler<T, S>[]): Handler<T, S>;

param

function param<T, S>(name: string): Handler<T, S>;

split

function split<T, S>(path: string): Handler<T, S>;