@jreusch/router-node
TypeScript icon, indicating that this package has built-in type declarations

1.1.1 • Public • Published

A simple, generic router based on @jreusch/router, intended for server-side use.

Features

  • 🚀 Tiny - only 100LOC of Typescript, on top of the ~300LOC core library
  • 🤘 Well-tested - Lots of tests for the core functionality
  • 🤩 Powerful supports custom patterns, backtracking, grouping, custom route filters, middleware, fully generic callback arguments and return types, and more!

Quick Start

Install the library, you do not need to instead @jreusch/router separately:

npm install @jreusch/router-node

Routers are constructed by composing handler functions. Most routing is assumed to be based on method/pattern tuples, but more advanced schemes are also supported.

import { compile, get, delet } from '@jreusch/router-node'

const dispatch = compile(
    // we need a way to extract the method...
    (req: Request) => req.method,
    // ...and the pathname
    (req: Request) => new URL(req.url).pathname,

    // everything else is generic!
    // here, we use the fetch API types (Request and Resposne) to define our routes
    get('/', (params, req: Request) => new Response('Hello, Seaman!')),
    get('/about', (params, req: Request) => new Response('About this app')),

    // nested routes with parameters
    group('/users/:userId',
        get('/', (params, req: Request) => new Response('Hello, User ' + params.userId)),
        delet('/', (params, req: Request) => new Response('User ' + params.userId + ' deleted!')),
    )
)

// the returned function either returns a Response or null, if no response matches
function onRequest(request: Request): Response {
    const response = dispatch(request)
    if (response) {
        return response
    }

    return new Response('Not found', { status: 404 })
}

Read the rest of the documentation to see what else is there 🙂

You might also want to learn more about the pattern syntax in the core API documentation.

API

Types

Most of the functions in this library use the Handler and Router interfaces. Both of those are generic, forwarding additional arguments, and allowing any return type. The generics make sure that composed routers always take in the same arguments, and return compatible values as well.

For simplicity and readabilty, I omit those generic parameters in this documentation.

A Handler is a function that you should provide, taking the extracted URL params as its first argument. All other arguments to a handler are generic and can be adapted to whichever types you have in your application. It can almost take on arbitrary forms, as long as it adheres to these 2 rules:

  1. The first argument has to be params: Params, containing the matched parameters from the pathname
  2. All handler functions in a composed router need to accept the same arguments, and return the same type.
function fetchHandler(params: Params, req: Request): Response {
    // do something, using the Web fetch API!
}

function nodeHandler(params: Params, req: http.IncomingMessage, res: http.ServerResponse): void {
    // do something, using Node's http module types!
}

A Router is a function produced by this library. You can in principle provide custom functions matching the same signature as well, but I don't promise that this interface will never change.

NOTE: The Router interface here is distinct from the one in @jreusch/router. The core library type deals with client-side routing, while here we do not have the same concepts (such as a history or the ability to "navigate"). In fact, none of the router parts of the core library are used here!

Basic routing

on(method: string, pattern: string, handler: Handler): Router

The most basic building block. Match against a URL pattern and a method, calling the handler with the params if it matches.

The library also exposes convenient helper functions, baking in the method parameter:

import {
    get,
    head,
    post,
    put,
    delet, // sic - `delete` would be a reserved keyword!
    connect,
    options,
    trace,
    patch,

    on,
    routes
} from '@jreusch/router-node'

// learn more about "routes" down below!
routes(
    get('/', getDashboard),
    // read on to learn how to clean up this duplication!
    get('/users/:userId', getUser),
    post('/users/:userId', createUser),
    put('/users/:userId', updateUser),
    delet('/users/:userId', deleteUser),

    // custom method using the long-form `on`
    on('LIST', '/users', listUsers)
)

The functions you provide are the custom Handler functions described above.

You might also want to learn more about the pattern syntax in the core API documentation.

all(pattern: string, handler: Handler): Router

Only match on the pattern, ignoring the method.

all('/users/:userId', (params, req: Request) => {
    if (req.method === 'OPTIONS') {
        // custom logic for options
        return cors(req)
    }

    // common logic for all other verbs
    const { userId } = params
    // ...
})

compile(getMethod: (...args) => string, getPath: (...args) => string, ...routes: Router[]): (...args) => TRet|null

compile turns a list of routes into a dispatcher function, which takes the your arguments, extract the method and path from them, and calls the matching Handler, if any.

You should usually only call compile at the top-level of your server, As long as the types all match up. When exporting groups from sub-modules, just exporting the generic Router is usually fine.

// compile for fetch API routes
compile(
    (req: Request) => req.method,
    // req.url is the entire url, including the host and scheme.
    // we only need to pathname for routing.
    (req: Request) => new URL(req.url).pathname,

    ...routes
)

// compile for Node http requests
const dispatch = compile(
    (req: http.IncomingMessage, res: http.ServerResponse) => req.method,
    // in NodeJS' http module, the url does not contain the host,
    // but does contain query params
    (req: http.IncomingMessage, res: http.ServerResponse) =>
        new URL(req.url, `http://${req.headers.host}`).pathname,

    ...routes
)

// the returned `dispatch` function accepts the arguments and calls the
// appropriate handler:
const server = http.createServer((req, res) => {
    const result = dispatch(req, res)
    if (result === null) { // no match
        return res.writeHead(404).end()
    }
})

Combining multiple routers

routes(...routes: Router[]): Router

Group multiple routes, trying them in order. The first Router that matches "wins" and its Handler function will be called.

If one of the routers is a group as well, performs a depth-first search for a match.

All other functions transforming routers in this library take in multiple routers as well, so calling this function directly is rarely necessarry in practice.

group(base: string, ...routes: Router[]): Router

Group multiple routes under a shared base namespace. The base can itself be a pattern and produce params, but it's not allowed to contain any optional or repeated segments.

This allows groups to greedily match against their base, skipping the entire group if the base does not match.

// matches GET /api/info
// and GET/POST/PUT/DELETE /api/usrs/:userId
const apiRouter = group('/api',
    get('/info', getApiInfo), // /api/info

    // variables are fine in groups, as long as they are not optional!
    group('/users/:userId', // /api/users/:userId
        get('/', getUser),
        post('/', postUser),
        patch('/', updateUser),
        delet('/', deleteUser)
    )
)

Filters and transformations

filter(predicate: (...args) => boolean, ...routers: Router[]): Router

Filter a route based on some custom logic. For example, you might want to check a header like the Accept here!

const isApiRequest = (req: Request) =>
    req.headers.get('Accept') === 'application/json'

// see the documentation of `group` below!
group('/users/:userId',
    // using Content-Type negotation:
    // If JSON is requested, we assume this is an API request
    filter(isApiRequest, get('', api.getUser)),
    // ... otherwise, we render the frontend template.
    // since routes are always matched in order, this acts as a "else" branch for the filter.
    get('', front.renderUserPage)
)

NOTE: This is explicitely supposed to help in situations where you want to do routing based on other properties than method or pathname. You might want to look into middlewares if you want to have asynchronous logic, outside if the pure routing process.

mapRet(f: (oldRet: TRetIn) => TRetOut, ...routers: Router[]): Router

Change the return type of your routers.

Let's say all of your route callbacks just return objects, but you want to send them serialized as JSON. You can build up the inner router tree using just any or unknown as the return type, and then do something like this:

function jsonToResponse(obj: any): Response {
    return new Response(JSON.stringify(obj), {
        status: 200,
        headers: {
            'Content-Type': 'application/json'
        }
    })
}

mapRet(jsonToResponse,
    get('/', (params, req) => ({ msg: 'Hello, Seaman!' })))

mapArgs(f: (...oldArgs: TArgsOut) => TArgsIn, ...routers: Router[]): Router

Change the type of the arguments for the nested routes.

Usually, you will pass in the entire request into the router. Using this function, you can narrow the arguments down to just the ones you need.

For example, let's say you don't need any arguments, and your routes just depend on the params:

function dropArgs<TArgs extends any[]>(...args: TArgs): [] {
    return []
}

mapArgs(dropArgs,
    get('/', (params) => new Response('Hello, Seaman!')))

Middleware

wrap(f: (next, params, ...args) => TRet|null, ...routers: Router[]): Router

Wrap some routers using a middleware-style function. The provided function works just like a Handler, but you get an additional next function as the first argument. You can call this function with any params and arguments you like, and it will continue dispatching to the nested routers.

This simple idea enables lots of advanced use cases, like filtering requests, adding aditional headers on every request, automatically handling exceptions in the inner router or integrating promises into the routing process to automatically fetch some common resources on every request, just to name a few.

I think it is best to just show a few examples, so you can get a feel of what is possible:

// All these examples use a Router<[Request], Promise<Response>>, so they take in
// a request object and return a Promise resolving to a Response.
// here is an example handler function:

async function getUser(params: Params, req: Request): Promise<Response> {
    const userId = parseInt('' + params.userId, 10)
    const user = await db.fetchUser(userId)
    if (!user) {
        return new Response('Not found', { status: 404 })
    }

    return new Response(JSON.stringify(user), {
        status: 200,
        headers: { 'Content-Type': 'application/json' }
    })
}

const route = get('/users/:userId', getUser) // : Router<[Request], Promise<Response>>

// Exception handling
wrap(async (next, params, req) => {
    try {
        return await next(params, req)
    } catch (err) {
        console.error('[ERROR]', req.method, req.url, ' -- ', err.message)
        return new Response(err.message, { status: 500 })
    }
}, route)

// Server Timing
wrap(async (next, params, req) => {
    const start = Date.now()
    const res = await next(params, req)
    res.headers.add('Server-Timing', `total;dur=${Date.now() - start}`)
    return res
}, route)

// authorization
wrap(async (next, params, req) => {
    const auth = req.headers.get('Authorization')
    const user = await authenticate(auth)
    if (!user) {
        return new Response('Unauthorized', { status: 401 })
    }

    // add the user to the arguments - you can complitely change the types here,
    // similar to using mapArgs/mapRet!
    return await next(params, req, user)
}, ...)

// fetch a common resource based on url params
// the order here is important -
// if you first wrap and then grou, params.postId wont be set!
group('/blog/:postId', wrap(async (next, params, req) => {
    const postId = parseInt('' + params.postId, 10)
    const post = await db.fetchPost(postId)
    if (!post) {
        return new Response('Not found', { status: 404 })
    }

    // the inner routes get passed the post as an additional argument
    return await next(params, req, post)
}, ...postRoutes))

Please note that calling the next function might return null, in case the route actually match. This means that introducing an asynchronous middleware looks like "always matching" from the outside, because an async function always returns a Promise.

Usually, this is not a problem, since "no match" is best handled by a catch-all wildcard route at the end anyways.

Since every call to wrap can change the request/response types completely, there is no "easy" way to pass multiple functions to wrap. Instead,

Performance considerations

Similar to the client-side libraries, this package is also based on the assumption that linearly checking all is "fast enough" in most situtations, and being smart about things is mostly overkill. This means that there are no built-in tries, and there is no intermediate representation that can be optimized. Under the hood, it's all just fancy function composition.

If performance becomes a problem, you can strategically use groups to improve matching speed. If a group's base URL does not match, the whole group can be skipped.

If this is still not enough, please feel free to open an issue or reach out to me.

Support / Climate action

This library was made with ☕, 💦 and 💜 by joshua If you really like what you see, you can Buy me more ☕, or get in touch!

If you work on limiting climate change, preserving the environment, et al. and any of my software is useful to you, please let me know if I can help and prioritize issues!

Dependents (0)

Package Sidebar

Install

npm i @jreusch/router-node

Weekly Downloads

0

Version

1.1.1

License

BSD-3-Clause

Unpacked Size

38.5 kB

Total Files

5

Last publish

Collaborators

  • jreusch