/serverx-ts

Experimental Node.js HTTP framework using RxJS, built with TypeScript and optimized for serverless deployments

Primary LanguageTypeScript

ServeRX-ts

Build Status Jest Coverage npm node License: MIT

NPM

Experimental Node.js HTTP framework using RxJS, built with TypeScript and optimized for serverless deployments. Heavily inspired by Marble.js and NestJS.

See ServeRX-serverless for a sample app operating in a serverless environment.

See ServeRX-angular to see ServeRX-ts put to the test of deploying and hosting any Angular app without change and with no dependencies..

Rationale

ServeRX-ts is an experimental project only. It doesn't advocate replacing any other framework and certainly not those from which it has drawn extensively.

Design Objectives

  • Declarative routes like Angular

  • Functional reactive programming using RxJS like Marble.js

  • Dependency injection like Angular and NestJS

  • Serverless support out-of-the-box for AWS Lambda with functionality similar to AWS Serverless Express but without the overhead

  • Serverless support out-of-the-box for Google Cloud HTTP Functions

  • Low cold-start latency as needed in serverless deployments, where in theory every request can trigger a cold start

  • Optimized for microservices in particular those that send application/json responses and typically deployed in serverless environments

  • OpenAPI support out-of-the-box to support the automated discovery and activation of the microservices for which ServeRX-ts is intended via the standard OpenAPI specification

  • Full type safety by using TypeScript exclusively

  • Maximal test coverage using Jest

Design Non-Objectives

  • Deployment of static resources which can be commoditized via, for example, a CDN. However, ServeRX-ts supplies a simple but effective FileServer handler that has just enough capability to deploy (say) an Angular app.

  • FRP religion ServeRX-ts believes in using functions where appropriate and classes and class inheritance where they are appropriate

Some Bookmarks for Future Work

  • Emulator for Express middleware (but that's hard and definitely back-burner!)

Key Concepts

Like Marble.js, linear request/response logic is not used to process HTTP traffic. Instead, application code operates on an observable stream. ServeRX-ts does not provide any abstractions for server creation. Either standard Node.js APIs are used or appropriate serverless functions.

ServeRX-ts does however abstract requests and responses (whatever their source or destination) and bundles them into a stream of messages.

A Handler is application code that observes this stream, mapping requests into responses.

Similarly, middleware is code that maps requests into new requests and/or responses into new responses. For example, CORS middleware takes note of request headers and injects appropriate response headers.

Services can be injected into both handlers and middleware. ServeRX-ts uses the injection-js dependency injection library, which itself provides the same capabilities as in Angular 4. In Angular, services are often used to provide common state, which makes less sense server-side. However in ServeRX-ts, services are a good means of isolating common functionality into testable, extensible and mockable units.

DI is also often used in ServeRX-ts to inject configuration parameters into handlers, middleware and services.

Routes are the backbone of a ServeRX-ts application. A route binds an HTTP method and path to a handler, middleware and services. Routes can be nested arbitrarily in parent/child relationships, just like Angular. Middleware and services (and other route attributes) are inherited from a parent.

Routes can be annotated with OpenAPI metadata to enable the automated deployment of an OpenAPI YAML file that completely describes the API that the ServeRX-ts application implements.

Sample Application

import 'reflect-metadata';

import { AWSLambdaApp } from 'serverx-ts';
import { Compressor } from 'serverx-ts';
import { CORS } from 'serverx-ts';
import { GCFApp } from 'serverx-ts';
import { Handler } from 'serverx-ts';
import { HttpApp } from 'serverx-ts';
import { Injectable } from 'injection-js';
import { Message } from 'serverx-ts';
import { Observable } from 'rxjs';
import { RequestLogger } from 'serverx-ts';
import { Route } from 'serverx-ts';

import { createServer } from 'http';
import { tap } from 'rxjs/operators';

@Injectable()
class HelloWorld extends Handler {
  handle(message$: Observable<Message>): Observable<Message> {
    return message$.pipe(
      tap(({ response }) => {
        response.body = 'Hello, world!';
      })
    );
  }
}

const routes: Route[] = [
  {
    path: '',
    methods: ['GET'],
    middlewares: [RequestLogger, Compressor, CORS],
    children: [
      {
        path: '/hello',
        handler: HelloWorld
      },
      {
        // NOTE: default handler sends 200
        // for example: useful in load balancers
        path: '/isalive'
      },
      {
        path: '/not-here',
        redirectTo: 'http://over-there.com'
      }
    ]
  }
];

// local HTTP server
const httpApp = new HttpApp(routes);
createServer(httpApp.listen()).listen(4200);

// AWS Lambda function
const lambdaApp = new AWSLambdaApp(routes);
export function handler(event, context) {
  return lambdaApp.handle(event, context);
}

// Google Cloud HTTP Function
const gcfApp = new GCFApp(routes);
export function handler(req, res) {
  gcfApp.handle(req, res);
}

Be sure to include the following options in tsconfig.json when you build ServeRX-ts applications:

{
  "compilerOptions": {
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true
  }
}

See ServeRX-serverless for a sample app operating in a serverless environment.

Primer

There's just enough information here to understand the principles behind ServeRX-ts. Much detail can be learned from interfaces.ts and from the Jest test cases, which are in-line with the body of the code.

Serverless Support

AWS Serverless Express connects AWS Lambda to an Express application by creating a proxy server and routing lambda calls through it so that they appear to Express code as regular HTTP requests and responses. That's a lot of overhead for a cold start, bearing in mind that in theory every serverless request could require a cold start.

Google Cloud Functions take a different approach and fabricate Express request and response objects.

ServeRX-ts attempts to minimize overhead by injecting serverless calls right into its application code. This approach led a number of design decisions, notably messages, discussed next.

ServeRX-ts recommends using the excellent serverless framework to deploy to serverless environments.

AWS Lambda Considerations

TODO: discuss how to control binary types and recommended serverless.yml.

Google Cloud Functions Considerations

TODO: ??? and recommended serverless.yml.

Messages

ServeRX-ts creates messages from inbound requests (either HTTP or serverless) and represents the request and response as simple inner objects.

message.context message.request message.response
info: InfoObject body: any body: any
router: Router headers: any headers: any
  httpVersion: string statusCode: number
  method: string
  params: any
  path: string
  query: URLSearchParams
  remoteAddr: string
  route: Route
  timestamp: number

messages are strictly mutable, meaning that application code cannot create new ones. Similarly, inner request and response should be mutated. A common mutation, for example, is to add or remove request or response headers.

Handlers

The job of a ServeRX-ts handler is to populate message.response, perhaps by analyzing the data in message.request.

If response.headers['Content-Type'] is not set, then ServeRX-ts sets it to application/json. If response.statusCode is not set, then ServeRX-ts sets it to 200.

All handlers must implement a handle method to process a stream of messages. Typically:

@Injectable() class MyHandler extends Handler {
  handle(message$: Observable<Message>): Observable<Message> {
    return message$.pipe( ... );
  }
}

Middleware

The job of ServeRX-ts middleware is to prepare and/or post-process streams of messages. In a framework like Express, programmers can control when middleware is executed by the appropriate placement of app.use() calls. Because routing in ServeRX-ts is declarative, it uses a different approach.

All ServeRX-ts middleware must implement either a prehandle or a posthandle method, or in special circumstances, both. All prehandle methods are executed before a handler gains control and all posthandle methods afterwards. Otherwise the shape of middleware is very similar to that of a handler:

@Injectable() class MyMiddleware extends Middleware {
  prehandle(message$: Observable<Message>): Observable<Message> {
    return message$.pipe( ... );
  }
}

A third entrypoint exists: the postcatch method is invoked after all posthandle methods and even after an error has been thrown. The built-in RequestLogger middleware uses this entrypoint to make sure that all requests are logged, even those that end in a failure.

The postcatch method cannot itself cause or throw an error.

Immediate Response

middleware code can trigger an immediate response, bypassing downstream middleware and any handler by simply throwing an error. A good example might be authentication middleware that rejects a request by throwing a 401 error.

A handler can do this too, but errors are more commonly thrown by middleware.

import { Exception } from 'serverx-ts';
// more imports
@Injectable()
class Authenticator extends Middleware {
  prehandle(message$: Observable<Message>): Observable<Message> {
    return message$.pipe(
      // more pipeline functions
      mergeMap((message: Message): Observable<Message> => {
        return iif(
          () => !isAuthenticated,
          // NOTE: the format of an Exception is the same as a Response
          throwError(new Exception({ statusCode: 401 })),
          of(message)
        );
      })
    );
  }
}

Built-in Middleware

  • The Normalizer middleware is automatically provided for all routes and is guaranteed to run after all other posthandlers. It makes sure that response.headers['Content-Length'], response.headers['Content-Type'] and response.statusCode are set correctly.

  • The BodyParser middleware is automatically provided, except in serverless environments, where body parsing is automatically performed.

Available Middleware

Services

TODO: discuss default LogProvider and possible Loggly log provider.

Routing

ServeRX's routes follow the pattern set by Angular: they are declarative and hierarchical. For example, the following defines two routes, GET /foo/bar and PUT /foo/baz:

const routes: Route[] = {
  {
    path: '/foo',
    children: [
      {
        methods: ['GET'],
        path: '/bar',
        Handler: FooBar
      },
      {
        methods: ['PUT'],
        path: '/baz',
        Handler: FooBaz
      }
    ]
  }
};

Notice how path components are inherited from parent to child. Parent/child relationships can be arbitrarily deep.

Inheritance

Paths are not the only route attribute that is inherited; methods, middleware and services are too. Consider this example:

const routes: Route[] = [
  {
    path: '',
    methods: ['GET'],
    middlewares: [RequestLogger, CORS],
    services: [{ provide: REQUEST_LOGGER_OPTS, useValue: { colorize: true } }]
    children: [
      {
        path: '/bar',
        Handler: FooBar
      },
      {
        path: '/baz',
        services: [{ provide: LogProvider, useClass: MyLogProvider }]
        Handler: FooBaz
      }
    ]
  }
];

Notice how an empty path component propagates inheritance but doesn't affect the computed path.

Routes for GET /bar and GET /baz are defined. Both share the RequestLogger and CORS middleware, the former nominally configured to colorize its output. However, GET /baz uses its own custom LogProvider.

ServeRX-ts leverages inheritance to inject its standard middleware and services without special code. The Router takes the supplied routes and wraps them like this:

  {
    path: '',
    middlewares: [BodyParser /* HTTP only */, Normalizer],
    services: [LogProvider],
    children: [ /* supplied routes */ ]
  }

Path Parameters

Path parameters are coded using OpenAPI notation:

  {
    methods: ['GET'],
    path: '/foo/{this}',
    handler: Foo
  },
  {
    methods: ['GET'],
    path: '/foo/{this}/{that}',
    handler: Foo
  }

Notice how optional parameters are coded by routing variants to the same handler.

Path parameters are available to handlers in message.request.params.

Redirect

A redirect can be coded directly into a route:

  {
    methods: ['GET', 'PUT', 'POST'],
    path: '/not-here',
    redirectTo: 'http://over-there.com',
    redirectAs: 307
  }

If redirectAs is not coded, 301 is assumed.

Route Data

An arbitrary data object can be attached to a route:

  {
    data: { db: process.env['DB'] },
    methods: ['GET'],
    path: '/foo/bar/baz',
    handler: FooBarBaz
  }

Route data can be accessed by both middleware and a handler via message.request.route.data.

File Server

ServeRX-ts supplies a simple but effective FileServer handler that has just enough capability to deploy (say) an Angular app. It can be used in any route, for example:

const routes: Route[] = [
  {
    path: '',
    children: [
      {
        methods: ['GET'],
        path: '/public',
        handler: FileServer,
        provide: [
          { provide: FILE_SERVER_OPTS, useValue: { maxAge: 999, root: '/tmp' } }
        ]
      }
      // other routes
    ]
  }
];

By default, it serves files starting from the user's home directory, although that can be customized as shown above. So in that example, GET /public/x/y/z.js would attempt to serve /tmp/x/y/z.js.

ServeRX-ts forces must-revalidate caching and sets max-age as customized or one year by default. The file's modification timestamp is used as an Etag to control caching via If-None-Match.

OpenAPI

ServeRX-ts supplies an OpenAPI handler that can be used in any route, although by convention:

const routes: Route[] = [
  {
    path: '',
    children: [
      {
        path: 'openapi.yml',
        handler: OpenAPI
      }
      // other routes
    ]
  }
];

const app = new HttpApp(routes, { title: 'http-server', version: '1.0' });

The OpenAPI handler creates a YAML response that describes the entire ServeRX-ts application.

Notice how an InfoObject can be passed to HttpApp, AWSLambdaApp and so on to fulfill the OpenAPI specification. The excellent OpenApi3-TS package is a ServeRX-ts dependency and its model definitions can be imported for type-safety.

Informational Annotations

Routes can be annotated with summary and description information:

const routes: Route[] = [
  {
    path: '',
    methods: ['GET'],
    summary: 'A family of blah blah endpoints',
    children: [
      {
        description: 'Get some bar-type data',
        path: '/bar',
        Handler: FooBar
      },
      {
        description: 'Get some baz-type data',
        path: '/baz',
        Handler: FooBaz
      }
    ]
  }
];

Both summary and description are inherited.

Because ServeRX-ts is biased toward microservices, ServeRX-ts does not currently support the many other informational annotations that the full OpenAPI specification does.

Metadata Annotations

Routes can also be annotated with request and responses metadata. The idea is to provide OpenAPI with decorated classes that describe the format of headers, parameters and request/response body. These classes are the same classes that would be used in middleware and handlers for type-safety. The request and responses annotations are inherited.

Consider the following classes:

class CommonHeader {
  @Attr({ required: true }) x: string;
  @Attr() y: boolean;
  @Attr() z: number;
}

class FooBodyInner {
  @Attr() a: number;
  @Attr() b: string;
  @Attr() c: boolean;
}

class FooBody {
  @Attr() p: string;
  @Attr() q: boolean;
  @Attr() r: number;
  // NOTE: _class is only necessary because TypeScript's design:type tells us
  // that a field is an array, but not of what type -- when it can we'll deprecate
  @Attr({ _class: FooBodyInner }) t[]: FooBodyInner;
}

class FooPath {
  @Attr() k: boolean;
}

class FooQuery {
  @Attr({ required: true }) k: number;
}

They could be used in the following routes:

const routes: Route[] = [
  {
    path: '',
    request: {
      header: CommonHeader
    },
    children: [
      {
        methods: ['GET'],
        path: '/foo',
        request: {
          path: FooPath,
          query: FooQuery
        }
      },
      {
        methods: ['PUT'],
        path: '/foo',
        request: {
          body: {
            'application/x-www-form-urlencoded': FooBody,
            'application/json': FooBody
          }
        }
      },
      {
        methods: ['POST'],
        path: '/bar',
        responses: {
          '200': {
            'application/json': BarBody
          }
        }
      }
    ]
  }
];

Notice how request and responses are inherited cumulatively.

When ServeRX-ts wraps supplied routes, it automatically adds metadata about the 500 response it handles itself, as if this were coded:

  {
    path: '',
    middlewares: [BodyParser /* HTTP only */, Normalizer],
    services: [LogProvider],
    responses: {
      '500': {
        'application/json': Response500
      }
    },
    children: [ /* supplied routes */ ]
  }