@overlook/route

Overlook framework Route class

Usage no npm install needed!

<script type="module">
  import overlookRoute from 'https://cdn.skypack.dev/@overlook/route';
</script>

README

NPM version Build Status Dependency Status Dev dependency Status Greenkeeper badge Coverage Status

Overlook framework Route class

Part of the Overlook framework.

Abstract

This module exports the base Route class, which all routes should be instances of.

Overlook handles routes by building them in a tree. Each route can either handle a request itself or pass the request on to child routes to handle.

A route can have functionality added by either:

  1. Adding properties and methods to a Route instance
  2. Using plugins (see here)

Usage

Constructor

Create a route:

const Route = require('@overlook/route');
const route = new Route();

This route is not connected to the router tree. It will need to be connected to be useful.

Properties

Properties to add to the Route instance can be passed as an object to the constructor.

const route = new Route( { a: 123 } );
// route.a === 123

All routes except root should add a name property to improve debugging experience.

const route = new Route( { name: 'myRoute' } );

Subclassing

Route can be subclassed to add additional methods, or extend existing ones.

class MyRoute extends Route {
  // New method
  myMethod() { /* ... */ }

  // Extending existing method
  handle( req ) {
    super.handle( req );
    /* ... */
  }
}

Methods

Route has the following public instance methods:

and public static methods:

It also has these instance methods intended for extension in routes/plugins:

The above are all symbols, accessible as properties of @overlook/route:

const { INIT_PROPS } = require('@overlook/route');
// or
import { INIT_PROPS } from '@overlook/route';

Handling requests

route.handle( req )

route.handle() is an async function used to ask the route to handle a request.

The .handle() method provided by Route class does nothing and returns undefined.

This method is intended to be extended in routes or plugins, to add functionality for the particular behavior of the route.

Delegation to children

.handle() may handle the request itself or delegate handling to one of its children. Delegation to children is implemented in plugins such as @overlook/plugin-match and @overlook/plugin-path.

Return value

If the route does not handle the request (for example the request doesn't match a path that the route handles), it should return undefined.

If it does handle the request (including handling by a child which it delegates to), it should return any value other than null and undefined.

Typically, requests will be handled asynchronously. In that case .handle() should return a Promise.

NB a Promise is a non-null value and therefore it's considered that the route has handled the request, regardless of whether the Promise's resolution is null or not.

Building a router tree

.attachChild( child )

Routes can be constructed into a tree using .attachChild().

One route serves as the root, and it can have multiple children. Children can have their own nested children, which have their own children, and so on.

const root = new Route();
const child = new Route();
root.attachChild( child );

const childOfChild = new Route();
child.attachChild( childOfChild );

[ATTACH_TO]( parent )

This method should not be called directly. It is called by .attachChild().

The method is exposed to allow it to be extended by subclasses.

Initialization

When a route is created, it is initially not linked to the router tree and must be added later.

Properties can be added via the constructor, or manually, but often the route will behave differently depending on what its parent is, or global state, which is only available once the router tree has been built.

.init()

.init() is an async function. It should be called on the root route once the whole router tree is built.

.init() will call [INIT_ROUTE](), followed by [INIT_CHILDREN](). [INIT_CHILDREN]() will call the .init() method of all children in parallel. So calling .init() on the root route will cause .init() to be called on all routes in the whole router tree.

.init() should not be extended in subclasses - extend [INIT_ROUTE]() or [INIT_CHILDREN]() instead.

[INIT_ROUTE]()

Should not be called directly. Is called automatically by .init().

The [INIT_ROUTE]() method provided by Route class does nothing.

To add init actions to your route, extend [INIT_ROUTE]():

const { INIT_ROUTE } = Route;

class MyRoute extends Route {
  async [INIT_ROUTE]() {
    await super[INIT_ROUTE]();
    /* ... */
  }
}

const route = new MyRoute();

[INIT_CHILDREN]()

Should not be called directly. Is called automatically by .init().

It is exposed for extension in subclasses where there is some initialization which needs to happen after all children are initialized.

const { INIT_CHILDREN } = Route;

class MyRoute extends Route {
  async [INIT_CHILDREN]() {
    await super[INIT_CHILDREN]();

    // Children are initialized now
    /* ... */
  }
}

Extend [INIT_ROUTE]() instead if the action doesn't require the children to be initialized.

[INIT_PROPS]( props )

Should not be called directly. Is called automatically by class constructor before any properties provided to constructor are added to the route object.

[INIT_PROPS]() can be extended to initialize properties to undefined, to ensure all route objects are the same "shape", allowing Javscript engines to better optimize.

It should not be used to set default values. Do that in [INIT_ROUTE]() instead.

const { INIT_PROPS } = Route;

class MyRoute extends Route {
  [INIT_PROPS]( props ) {
    super[INIT_PROPS]( props );
    // Initialize to undefined
    this.myProp = undefined;
  }

  async [INIT_ROUTE]() {
    await super[INIT_ROUTE]();
    // Set default if not defined
    if (this.myProp === undefined) this.myProp = 1;
  }
}

const routeWithDefinedProp = new MyRoute( { myProp: 2 } );
// routeWithDefinedProp.myProp === 2

const routeWithDefaultProp = new MyRoute();
await routeWithDefaultProp.init();
// routeWithDefaultProp.myProp === 1

Properties

Each route's relatives can be accessed via:

  • .parent - Parent route (null for root route)
  • .children - Array of child routes
  • .root - Root route

Plugins

Introduction

Overlook is intended to be extremely modular and flexible.

The base Route class has very little functionality, and most functionality is intended to be added using plugins.

Plugins are similar to those used by other frameworks, but the main difference is this:

Plugins apply at route level, not application level.

One route, or subtree of routes, can have one behavior, another subtree can have another. So, for example, one part of the app can use React, another part can server-render pages from EJS templates.

This architecture allows:

  1. "Snap in" plugins providing common functionality, making building apps fast
  2. Granular control over every route's individual behavior

Creating a plugin

Plugins are created with @overlook/plugin class.

Plugins define an extend function, which receives a Route class and should return a subclass of it.

const Plugin = require('@overlook/plugin');

const loggingPlugin = new Plugin( Route => (
  class extends Route {
    async handle( req ) {
      console.log(`Handling request ${req.path}`);
      const res = await super.handle( req );
      console.log(`Handled request ${req.path}`);
      return res;
    }
  }
) );

New methods and properties should have Symbol keys, not strings. If properties are intended to be accessed by other plugins, or methods intended to be available for extending, the Symbols should be exported as a properties of the plugin.

For more info on creating plugins please see here.

Using plugins

Route.extend( plugin ) static method is used to apply a plugin to a Route class.

It returns a subclass of the original Route class. It does not mutate the original Route class.

const RouteWithLogging = Route.extend( loggingPlugin );

Multiple plugins can be chained:

const RouteWithLoggingAndAuth =
  Route.extend( loggingPlugin )
    .extend( authPlugin );

Plugins extending other plugins

Plugins can themselves utilise other plugins, by providing an array of other plugins to the Plugin constructor.

const myPlugin = new Plugin(
  [ loggingPlugin ],
  Route => class extends Route { /* ... */ }
);

Optimizations

Overlook's plugin system applies a couple of optimizations:

Avoid extending twice

Many plugins depend on other plugins in turn. So it'd be easy to end up the same plugin being applied more than once, and ending up with:

Route.extend( plugin1 )
  .extend( plugin2 )
  .extend( plugin1 );

This is likely to lead to incorrect behavior. So Route.extend() checks if a plugin has already been applied, and if so does not apply it again.

Deduplicating subclasses

.extend() is memoized. If the same plugin is applied to the same route class in multiple places, it always returns the same subclass.

const RouteSubclass1 = Route.extend( loggingPlugin );
const RouteSubclass2 = Route.extend( loggingPlugin );
RouteSubclass1 === RouteSubclass2 // -> true

Type checking

isRoute()

Pass an object to determine if it's a Route instance (including instances of a subclass of Route).

isRouteClass()

Pass a function to determine if it's a Route class (including subclasses of Route).

Debugging

The problem

The majority of the functionality of an Overlook app will likely be provided by plugins.

So if an error is thrown, the stack trace will likely point to a file which is inside a plugin NPM module. Additionally, delegating actions up through the router tree is also performed within plugin methods, so the stack trace likely won't include any info allowing you to trace what route the error came from.

This is not very helpful for debugging.

Solution

Route instances have 2 methods which add debug info to errors, [DEBUG_ZONE]() and [DEBUG_ERROR]().

Any errors thrown will be tagged with:

  1. Router path added to the end of error message '... (router path /abc/def)'
  2. Router path recorded on the error object as error[ROUTER_PATH]

ROUTER_PATH is a symbol exported as Route.ROUTER_PATH.

When to use these methods

These methods are already built in to .init() and .attachChild().

Any error thrown in .init(), [INIT_ROUTE](), [INIT_CHILDREN](), .attachChild() or [ATTACH_TO]() will be caught and tagged with debug info as above. You don't need to use the debug methods to get the debug info.

If you create a plugin which passes control from one route to another - for example, delegating to children - use [DEBUG_ZONE]() to wrap that call (see below).

If you create a plugin which could provide some additional debug info, use [DEBUG_ERROR]() to add that info to the error object (see below).

When not to use these methods

It's only required where contol passes from one route to another. Mostly you won't be doing that, so generally there's no need to use the debug methods.

[DEBUG_ZONE]( fn )

Executes a function within debug context of the route. Any errors thrown will be tagged with the debug info for that route.

If you want to extend .handle() to delegate handling requests to the route's children:

const { DEBUG_ZONE } = Route;

class MyRoute extends Route {
  handle( req ) {
    let res = super.handle( req );
    if ( res !== undefined ) return res;

    // First child which returns value has handled request
    for (let child of this.children) {
      res = child[DEBUG_ZONE]( () => {
        return child.handle( req );
      } );
      if ( res !== undefined ) break;
    }

    return res;
  }
}

[DEBUG_ERROR]( err )

[DEBUG_ERROR]() is called by [DEBUG_ZONE]() with any error which occurs in the route. [DEBUG_ERROR]() adds debug info to the error (as described above).

You can extend [DEBUG_ERROR]() to add further debugging info.

const { DEBUG_ERROR } = Route;
const FILE_PATH = Symbol('FILE_PATH');

class MyRoute extends Route {
  // ... some methods which define this[FILE_PATH] ...

  [DEBUG_ERROR]( err ) {
    err = super[DEBUG_ERROR]( err );
    err[FILE_PATH] = this[FILE_PATH];
    err.message += ` (file path ${this[FILE_PATH]})`;
    return err;
  }
}

Versioning

This module follows semver. Breaking changes will only be made in major version updates.

All active NodeJS release lines are supported (v10+ at time of writing). After a release line of NodeJS reaches end of life according to Node's LTS schedule, support for that version of Node may be dropped at any time, and this will not be considered a breaking change. Dropping support for a Node version will be made in a minor version update (e.g. 1.2.0 to 1.3.0). If you are using a Node version which is approaching end of life, pin your dependency of this module to patch updates only using tilde (~) e.g. ~1.2.3 to avoid breakages.

Tests

Use npm test to run the tests. Use npm run cover to check coverage.

Changelog

See changelog.md

Issues

If you discover a bug, please raise an issue on Github. https://github.com/overlookjs/route/issues

Contribution

Pull requests are very welcome. Please:

  • ensure all tests pass before submitting PR
  • add tests for new features
  • document new functionality/API additions in README
  • do not add an entry to Changelog (Changelog is created when cutting releases)