autohost

Resource driven, transport agnostic host

Usage no npm install needed!

<script type="module">
  import autohost from 'https://cdn.skypack.dev/autohost';
</script>

README

autohost

Convention-based, opinionated HTTP server library based on express. Lovingly ripped from the guts of Anvil.

Rationale

As more services are introduced to a system, the tedium of fitting together all the same libraries over and over:

  • is soul-draining
  • encourages copy/pasta
  • adds inertia across multiple projects
  • increases the surface area for defects and maintenance

I created autohost so we could have a consistent, reliable and extendible way to create HTTP/socket powered sites and services. By introducing conventions and structure to projects, route definitions and handlers aren't scattered throughout the source and mixed with application logic.

Features

  • Resource-based: define transport-agnostic resources that interact via HTTP or WebSockets
  • Supports server-side websockets and socket.io
  • Supports multiple Passport strategies via a pluggable auth provider approach

Note

The dashboard and related APIs are no longer included with autohost. They have been moved to a separate project: autohost-admin.

Quick Start

npm init
npm install autohost autohost-nedb-auth -S

./index.js - the most boring app ever

var autohost = require( 'autohost' );
var auth = require( 'autohost-nedb-auth' )( {} );
var host = autohost( { authProvider: auth } );
// additional setup, like custom middleware would go here
host.start(); // starts the server
node index.js

autohost( config )

Refer to the section below for a list of available configuration properties and default values.

Configuration

The object literal follows the format:

// default shown for each property
{
    static: './public', 	// where to host static resources from
    anonymous: [], 			// add paths or url patterns that bypass authentication and authorization,

    port: 8800, 			// host port
    urlPrefix: undefined, 	// applies a global prefix to all routes - for use behind reverse proxy
    apiPrefix: '/api', 		// changes the prefix for resource action URLs only

    resources: './resource', // where to load resource modules from
    modules: [], 			// list of npm resource modules to load

    authProvider: undefined, // a promise for or instance of an authentication provider
    allowedOrigin: , 		// used to filter incoming web socket connections based on origin
    socketIO: false, 		// enables socket.io - can be false, true (using default options) or an options object
    websocket: false, 		// enables websockets

    noBody: false, 			// disables body parsing
    noCookie: false, 		// disables cookies
    noCrossOrigin: false, 	// disables cross origin
    noOptions: false, 		// disables automatic options middleware
    noProxy: false, 		// disables trusted proxies
    noSession: false, 		// disables sessions

    session: 				// session configuration
    cookie: 				// session cookie configuration
    getUserString:			// method to return a string id for a user

    enableAccessLogs: true,// enables access logs
    logging: {}, 			// configuration passed to autohost's whistlepunk instance
    fount: undefined, 		// pass the app's fount instance to autohost

    parseAhead: false, 			// parses path parameters before application middleware
    handleRouteErrors: false, 	// wrap routes in try/catch
    urlStrategy: undefined 		// a function that generates the URL per resource action
}

Socket.io

The configuration options (above) value for socketIO can be false (disabled), true (enabled with basic socket.io defaults) or an object specifying any option supported by socket.io.

Session Configuration

By default express session is the session provider. To change any settings for how the session is configured, provide a hash with values for any of the properties shown below.

// default shown for each property
{
    name: 'ah.sid',
    secret: 'autohostthing',
    resave: true,
    store: new sessionLib.MemoryStore(),
    saveUninitialized: true,
    rolling: false
}

This example demonstrates using the redis and connect-redis libraries to create a redis-backed session store.

var autohost = require( 'autohost' );
var auth = require( 'autohost-nedb-auth' )( {} );

var redis = require( 'redis' ).createClient( port, address );
var RedisStore = require( 'connect-redis' )( host.session );
var store = new RedisStore( {
        client: redis,
        prefix: 'ah:'
    } );

host = autohost( {
    authProvider: auth,
    session: {
        name: 'myapp.sid',
        secret: 'youdontevenknow',
        store: store
    }
} );
host.start();

Ending a session

To end a session:

  • logout method on the envelope in a resource action handle
  • logout on the request in any middleware

Session Cookie Configuration

To change any settings for how the session cookie is configured, provide a hash with values for any of the properties shown below.

// default shown for each property
{
    path: '/',
    secure: false,
    maxAge: null
}

Static

The static option supports either a path, an options hash, or false. Currently, the options (except path) are passed through to express.static with the path property being used as the route. (If set to false, no default static path will be auto-configured):

{
    static: {
        path: './public',
        maxAge: '2d',
        setHeaders: function ( res, path, stat ) { ... }
    }
}

AuthProvider

There are already two available auth provider libraries available:

Each library supports all optional features and can be managed from the admin add-on.

Note: the authProvider passed in can be an unresolved promise, autohost will handle it

Planned support for:

  • MS SQL server

getUserString

The getUserString option expects a method that accepts user as its only parameter, and returns a string (used for logging) to identify the user. The default method provided attempts the following steps:

  • return user.name if available, otherwise:
  • return user.username if available, otherwise:
  • return user.id if available, otherwise:
  • return JSON.stringify( user )

Override this method with custom logic if the default does not match your field names on your user object. For instance:

{
   getUserString: function ( user ) {
       return user.login;
   }
}

fount

fount is a dependency injection library for Node. If the application is using fount, the application's instance can be provided at the end of the init call so that resources will have access to the same fount instance the application is using. The fount instance in use by autohost is available via host.fount.

Resources

Resources are expected to be simple modules containing a factory method that return one or more resource definitions. Dependency resolution by argument is supported in these resource factory methods. All arguments after the first (host) will be checked against autohost's fount instance. This is especially useful when taking a dependency on a promise or asynchronous function. Fount will only invoke the resource's factory once all dependnecies are available, eliminating dependency callbacks or promises in the resource's implementation. See the Asynchronous Module example under the Module section.

Path conventions

All resources must be placed under a top level folder (./resource by default) and shared static resources under a top level folder (./public by default). Each resource should have its own sub-folder and contain a resource.js file that contains a module defining the resource.

Folder structure

-myProject
 `-- resource
 |	`-- profile
 |  |  |-- resource.js
 |  |
 |  `-- otherThing
 |  |  |-- resource.js
 `--public
 |  `--css
 |  |  |--main.css
 |  `--js
 |  |  |--jquery.min.js
 |  |  |--youmightnotneed.js
 |  |--index.html

Module

Synchronous Module - No Fount Dependencies

module.exports = function( host ) {
    return {
        name: 'resource-name',
        static: '', // relative path to static assets for this resource,
        apiPrefix: '', // Optional override for global apiPrefix setting. Omit entirely to use default.
        urlPrefix: '', // URL prefix for all actions in this resource
        middleware: [],// one or more middleware functions to mount to the resource's url
        actions:  {
            send: {
                method: 'get', // http verb
                url: '', // url pattern appended to the resource name
                topic: 'send', // topic segment appended the resource name
                middleware: [], // one or more middleware functions to mount to the action's url
                authorize: , // optional predicate to check user permissions
                handle: function( envelope ) {
                    // see section on envelope for more detail
                }
            }
        }
    };
};

Asynchronous Module - Fount Dependencies This example assumes that either:

  • the application fount instance was plugged into autohost or
  • all defined dependencies were made with autohost's fount instance before calling autohost's init call.
// example using autohost's fount instance
var autohost = require( 'autohost' );
var host = autohost( { ... } );
host.fount.register( 'myDependency1', { ... } );
host.fount.register( 'myDependency2', somePromise );

// Each argument after `host` will be passed to fount for resolution before the exported function
// is called.
module.exports = function( host, myDependency1, myDependency2 ) {
    return {
        name: 'resource-name',
        static: '', // relative path to static assets for this resource
        apiPrefix: '', // Optional override for global apiPrefix setting. Omit entirely to use default.
        urlPrefix: '', // URL prefix for all actions in this resource
        middleware: [],// one or more middleware functions to mount to the resource's url
        actions: {
            send: {
                method: 'get', // http verb
                url: '', // url pattern appended to the resource name
                topic: 'send', // topic segment appended the resource name
                middleware: [], // one or more middleware functions to mount to the action's url
                authorize: , // optional predicate to check user permissions
                handle: function( envelope ) {
                    // see section on envelope for more detail
                }
            }
        ]
    };
};

name

The resource name is pre-pended to the action's alias to create a globally unique action name: resource-name.action-alias. The resource name is also the first part of the action's URL (after the api prefix) and the first part of a socket message's topic:

http://{host}:{port}/api/{resource-name}/{action-alias|action-path}

topic: {resource-name}.{action-topic|action-alias}

Note: If defining resources for use with [hyped](https://github.com/leankit-labs/hyped) - the resource name is not automatically pre-pended to the url.

static

You can host nested static files under a resource using this property. The directory and its contents found at the path will be hosted after the resource name in the URL.

middleware

Provides a mechanism for defining resource-level middleware either in a single function or in a list of functions. These functions are provided with an envelope and are able to make changes to it before it reaches action-specific middleware or the handle call.

Below are several examples of different middleware patterns. This should demonstrate both synchronous and asynchronous patterns for proceding and short-circuiting the stack.

...
// all middleware must return either the result of next or a promise/data structure
middleware: [
    function( envelope, next ) {
        // invokes the next middleware or handle call
        return next();
    },
    function( envelope, next ) {
        // demonstrates returning a data structure
        if( envelope.data.example === 1 ) {
            return { data: { message: 'This will short circuit the stack and respond immediately' } };
        } else if( envelope.data.example === 2 ) {
            // demonstrates returning next asynchronously
            return somethingPromisey()
                .then( function( x ) {
                    envelope.context.importantThing = x;
                    return next();
                } );
        } else if( envelope.data.example === 3 ) {
            // demonstrates short-circuiting a stack via a promise
            return anotherPromise()
                .then( function( x ) {
                    return { data: x };
                } );
        } else {
            return next();
        }
    }
}
...

Actions

The hash of actions are the operations exposed on a resource on the available transports.

[key]

They key of the action in the hash acts as the 'friendly' name for the action. To create a globally unique action name, autohost pre-pends the resource name to the alias: resource-name.action-alias.

method

Controls the HTTP method an action will be bound to.

topic

This property controls what is appended to the resource name in order to create a socket topic. The topic is what a socket client would publish a message to in order to activate an action.

url - string pattern

The url property provides the URL assigned to this action. You can put path variables in this following the express convention of a leading :

url: '/thing/:arg1/:arg2'

Path variables are accessible on the envelope's params property. If a path variable does NOT collide with a property on the request body, the path variable is written to the envelope.data hash as well:

    envelope.data.arg1 === envelope.params.arg1;

url - regular expression

The url can also be defined as a regular expression that will be evaluated against incoming URLs. Both apiPrefix and urlPrefix will be pre-pended to the regular expression automatically - do not include them in the expression provided.

query parameters

Query parameters behave exactly like path variables. They are available on the params property of the envelope and copied to the envelope.data hash if they wouldn't collide with an existing property.

custom url strategy

A function can be provided during configuration that will determine the url assigned to an action. The function should take the form:

function myStrategy( resourceName, actionName, action, resourceList ) { ... }

The string returned will be the URL used to route requests to this action. Proceed with extreme caution.

middleware

Provides a mechanism for defining action-level middleware either in a single function or in a list of functions. These functions are provided with an envelope and are able to make changes to it before it reaches the handle call.

IMPORTANT Middleware must return either the result of the next call or a promise/data structure to short circuit the stack with a response. They are mutually exclusive. Do not call both. Do not fail to return one or the other.

authorize

The authorize predicate was added to actions to allow for much more fine grained, explicit control of user authorization. By default, authorization checks are performed after all middleware has run. This allows middleware to provide any necessary data on the envelope's context so that the authorization strategy can use this in determining the user's access level.

To change this, provide the string "authorize" in place of a middleware call in the resource or action middleware property and the check will be performed where the string appeared in the stack. This allows an application to short-circuit the stack and avoid running middleware that performs expensive and unnecessary i/o if the user does not have permission to perform the action.

Note: when present, this overrides rather than augments any authorization check that would have been performed by a configured autohost auth library.

...
authorize: function( envelope ) {
    return envelope.user.isAwesome;
}
...
}

handle

The handle is a callback that will be invoked if the caller has adequate permissions. The handle call can return a hash (or a promise that resolve to one) with the following properties:

Note: data, file, forward and redirect are mutually exclusive. Websockets only supports data and file.

// defaults shown
{
    status: 200,
    data: undefined,
    cookies: {}, // set cookies sent back to the client
    headers: {}, // set headers sent back in the response
    file: { // only used when replying with file
        name: , // the file name for the response
        type: , // the content-type
        stream: // a file stream to pipe to the response
    },
    forward: { // only used if forwarding the request
        url: , // the url to forward to
        method: , // if unspecified, copies the method of the original request
        headers: , // if unspecified, copies headers in the original request
        body: // use if changing the body contents
    },
    redirect: { // only used when redirecting
        status: 302, // use to set a status other than 302
        url: // the URL to redirect to
    }
}

Recommendation

Don't include application logic in a resource file. The resource is there as a means to 'plug' application logic into HTTP and websocket transports. Keeping behavior in a separate module will make it easy to test application behavior apart from autohost.

differentiated handlers

In rare cases, you may need the ability to have multiple handler behaviors that change based on some aspect of the request. Autohost allows a hash to specify multiple handlers and conditions for when each should be applied. The list is evaluated in order, keep this in mind when putting conditions in place. If no condition is met, a 400 will be returned - the assumption being that you, the developer intentionally excluded some condition and the caller submitted a malformed request.

Note: this feature exists primarily so our upstream hypermedia library, hyped can plug in handlers based on version.

{
    handle: [
        {
            when: { version: 1 }, // providing a set of properties and values to filter requests for the handler
            then: function( envelope ) {
                ...
            }
        },
        {
            when: function( envelope ) { // provide a predicate to test the envelope
                return envelope.version === 2;
            },
            then: function( envelope ) {
                ...
            }
        },
        {
            when: true, // use at the end as a catch-all if desired
            then: function( envelope ) {
                ...
            }
        }
    ]
}

Controlling Error Responses

Responses sent to the client based on an error returned from an action's handle can be controlled at the config, resource or action level. How to handle a specific error type is determined by first checking the action, then resource, then config (host) levels.

The errors property can be set at any of these levels and is a set of case-sensitive error names and a literal specifying how to render the error. The literal can contain a status to control the status code used and a static body, file or reply function that takes the error as an argument and returns the content for the response body.

Note: File is only applicable for the http transport and will be ignored in sockets.

// this could exist in the config, a resource or an action
errors: {
    Error: {
        status: 500,
        body: 'oops'
    },
    NotFoundError: {
        status: 404,
        file: './404.html' // file is relative to the static folder
    },
    BadRequestError: {
        status: 400,
        reply: function( err ) {
            return 'This is no good: ' + err.message;
        }
    }
},

Tighter Response Control

Read the section on envelopes for details on data available and alternate ways to produce a response.

Envelope

Envelopes are an abstraction around the incoming message or request. They are intended to help normalize interactions with a client despite the transport being used.

// common properties/methods
{
    context: // metadata added by middleware
    cookies: // cookies on the request
    data: // the request/message body
    headers: // request or message headers
    logout: // a method to end the current session
    path: // url of the request (minus protocol/domain/port) OR message topic
    session: // session hash
    responseStream: // a write stream for streaming a response back to the client
    transport: // 'http' or 'websocket'
    version: // will check `req.context.version` - provide middleware to set this
    user: // the user attached to the request or socket
    reply: function( envelope ) // responds to client
    replyWithFile: function( contentType, fileName, fileStream ) // streams a file back to the client
}

// the following properties/methods are only available on HTTP envelopes
{
    params: // query parameters
    files: // files supplied in body
    forwardTo: function( options ) // forward the request (for building proxies)
    redirect: function( [statusCode = 302 ,] url) //redirects to url.
}

// the following properties are only available on Socket envelopes
{
    replyTo: // the topic to send the reply to
    socket: // the client's socket
}

reply( envelope )

Sends a reply back to the requestor via HTTP or web socket. Response envelope is expected to always have a data property containing the body/reply. HTTP responses can included the following properties

  • statusCode: defaults to 200
  • headers: a hash of headers to set on the response
  • cookies: a hash of cookies to set on the response. The value is an object with a value and options property.
  • data: content of the response body
    envelope.reply( { data: { something: 'interesting' }, statusCode: 200 } );
    // HTTP response body will be JSON { something: 'interesting' }
    // Socket.io will have a payload of { something: 'interesting' } published to the replyTo property OR the original topic
    // Websockets will get a message of { topic: replyTo|topic, data: { something: 'interesting' } }

The options property for a cookie can have the following properties: domain, path, maxAge, expires, httpOnly, secure, signed

replyWithFile( contentType, fileName, fileStream )

Sends a file as a response.

forwardTo( opts )

Forwards the request using the request library and returns the resulting stream. Works for simple proxying.

    envelope.forwardTo( {
        uri: 'http://myProxy/url'
    } ).pipe( envelope.responseStream );

External Resources - Loading an NPM Resource Module

A list of NPM modules can be specified that will be loaded as resources. This feature is intended to support packages that supply a resource and static files as a sharable module. Hopefully it will lead to some interesting sharing of common APIs and/or UIs for autohost based services. (example - realtime metrics dashboard)

To enable this, simply add the module names as an array in the modules property of the configuration hash passed to init.

HTTP Transport

The http transport API has three methods to add middleware, API routes and static content routes. While resources are the preferred means of adding static and API routes, it's very common to add application specific middleware. Custom middleware is added after standard middleware and passport (unless specific middleware was disabled via configuration).

  • host.http.middleware( mountPath, callback, [middlewareAlias] )
  • host.http.route( url, callback )
  • host.http.static( url, filePath or options ) (See static above for details on options)

Note: when custom features are needed, middleware should be the preferred way to add them.

Route prefixes

The config hash provides two optional properties to control how HTTP routes are created.

apiPrefix

By default autohost places all resource action routes behind /api to prevent any collisions with static routes. You can remove this entirely by providing an empty string or simply change it so something else.

Note: a `urlPrefix` will always precede this if one has been supplied.

This setting can be controlled per-resource via the apiPrefix setting.

urlPrefix

In the event that a reverse proxy is in front of autohost that routes requests from a path segment to the service, use a urlPrefix to align the leading path from the original url with routes generated by autohost.

Example You have a public HTTP endpoint that directs traffic to the primary application (http://yourco.io). You want to reverse proxy any request sent to the path http://yourco.io/special/ to an interal application built with autohost. The challenge is that all static resources (html, css, js) that contain paths would normally use absolute paths when referencing api routes or other static resources. ( examples: /css/style.css, /js/lib/jquery.min.js, /api/thingy/10) The problem is that the browser will make these requests which will be directed to the original application server since instead of the /special path segment required to route to the autohost app via reverse proxy. This will either activate routes in the original application (which will be incorrect) or get a bunch of 404s back from the front-end application.

While all of the URLs in static resources in the previous example could be prefixed with `/special', this creates a tight coupling to a reverse proxy configured exactly like production. This makes integration testing and local development unecessarily difficult.

The simpler solution is to use a urlPrefix set to 'special'. The prefix will automatically be applied to all routes in the service so that requests from the proxy align with the routes defined in the application consistently. This results in an application that remains usable outside of the reverse proxy and can even be built and deployed with different path prefixes (or no prefixes).

parseAhead

Normally, middleware can't have access to path variables that aren't defined as part of its mount point. This is because the sequential routing table doesn't know what path will eventually be resolved when it's processing general purpose middleware (e.g. mounted at /). Setting parseAhead to true in configuration will add special middleware that does two things:

  • add a preparams property to the request with parameters from "future" matching routes
  • redefines the req.param function to check preparams before falling back to default

The upside is that general purpose middleware can access path variables instead of having to write the same kind of middleware for a lot of different paths and then worry about keeping paths synchronized. The downside is that there is obviously some performance penalty for traversing the route stack in advance like this.

Web Socket Transport

There are two socket libraries - socket.io for browser clients and websocket-node for programmatic/server clients.

HTTP Middleware

HTTP middleware runs during socket interactions as well. This ensures predictability in how any client is authenticated and what metadata is available within the context of activating resource actions.

Authentication

The HTTP upgrade request is authenticated before the upgrade is established. This is preferable to the standard practice of allowing a socket connection to upgrade and then checking the request or performing some client-implemented handshake after the fact.

WebSocket-Node library

When establishing a connection to autohost using the WebSocket-Node client, append '/websocket' to the end of the URL.

Uniform API

The differences between each library are normalized with the same set of calls:

  • socket.publish( topic, message ) - sends a message with the topic and message contents to the socket
  • host.socket.send( id, topic, message ) - sends message to specific client via websocket (returns true if successful)
  • host.socket.notify( topic, message ) - sends message to all clients connected via socket

Events

These events can be subscribed to via host.on:

  • 'socket.client.connected', { socket: socketConnection } - raised when a client connects
  • 'socket.client.identified', { socket: socketConnection, id: id } - raised when client reports unique id
  • 'socket.client.closed', { socket: socketConnection, id: id } - raised when client disconnects the websocket connection

Auth - via Auth Provider

Authentication and authorization are supplied by an auth provider library that conforms to autohost's auth specifications. You can read more about that at here.

Programmatic control

The auth library is available by reference via the auth property on the host object: host.auth. Whatever API methods have been implemented are callable by the application.

Authentication

The auth provider should supply one or more Passport strategies.

Authorization

Roles are assigned to users and actions. If a user has a role that is in an action's list, the user can invoke that action via HTTP or a socket message. If the action has no roles assigned, there is no restriction and any authenticated user (including anonymous users) can activate the action.

The general approach is this:

  1. every action in the system is made available to the auth provider library on start-up
  2. an action may be assigned to one or more roles
  3. a user may be assigned to one or more roles
  4. when a user attempts to activate an action, the action roles are checked against the user roles
  5. if a match is found in both lists, the action completes
  6. if the user has no roles that match any of the action's roles, the action is rejected (403)
  7. if the action has NO roles assigned to it, the user will be able to activate the action

This basically goes against least-priviledge and is really only in place to prevent services from spinning up and rejecting everything. To prevent access issues, never expose a service publicly before configuring users, roles and actions.

Logging

Logging is provided by whistlepunk and can be controlled by the logging property of the config provided to the init call.

Access Log

The access log uses the namespace autohost.access and logs at the info level. Below is a template and then an example entry:

{timestamp} autohost.access {processTitle}@{hostName} {clientIP} ({duration} ms) [{user}] {method} {requestURL} ({ingress} bytes) {statusCode} ({egress} bytes)


Debugging

A lot of visibility can be gained into what's happening in autohost in real-time by setting the DEBUG environment variable. To filter down to autohost debug entries only, use autohost* as the DEBUG value.

    DEBUG=autohost* node index.js

Metadata

Metadata describing the routes and topic are available via an OPTIONS to api:

OPTIONS http://{host}:{port}/api

The metadata follows this format:

{
    "resource-name": {
        "routes": {
            "action-alias": {
                "verb": "get",
                "url": "/api/resource-name/action-alias|action-path"
            }
        },
        "path": {
            "url": "/_autohost",
            "directory": "/git/node/node_modules/autohost/src/_autohost/public"
        },
        "topics": {
            "action-alias": {
                "topic": "resource-name.action-alias"
            }
        }
    },
    "prefix": "/api"
}

While this is useful, we have developed hyped,a hypermedia library that bolts onto autohost, and halon, a browser/Node hypermedia client for consuming APIs built with hyped.

Dependencies

autohost would not exist without the following libraries:

  • body-parser 1.12.3
  • cookie-parser 1.3.4
  • express 4.12.3
  • express-session 1.11.1
  • fount 0.1.0
  • lodash 3.7.0
  • metronic 0.2.1
  • multer 0.1.8
  • node-uuid 1.4.3
  • parseurl 1.3.0
  • passport 0.2.1
  • postal 1.0.2
  • qs 2.4.1
  • request 2.55.0
  • socket.io 1.3.5
  • websocket 1.0.18
  • when 3.7.2
  • whistlepunk 0.3.0

TO DO

  • Add ability to define message middleware

Contributing

There are a lot of places you can contribute to autohost. Here are just some ideas:

Designers

  • Better designs for both the general dashboard and auth dashboard
  • Logo

Op/Sec

I would be interested in seeing if particular Passport strategies and how they're being wired in would be subject to any exploits. Knowing this in general would be great, but especially if I'm doing something ignorant with how it's all being handled and introducing new attack vectors, I'd like to find out what those are so they can be addressed.

License

MIT License - http://opensource.org/licenses/MIT