Experimental Node.js HTTP framework using RxJS, built with TypeScript and optimized for serverless deployments. Heavily inspired by Marble.js and NestJS.
- Rationale
- Key Concepts
- Sample Application
- Primer
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..
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.
-
Declarative routes like Angular
-
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
-
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
- Emulator for Express middleware (but that's hard and definitely back-burner!)
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.
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.
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.
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.
TODO: discuss how to control binary types and recommended
serverless.yml
.
TODO: ??? and recommended
serverless.yml
.
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
.
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( ... );
}
}
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.
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 bymiddleware
.
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)
);
})
);
}
}
-
The Normalizer
middleware
is automatically provided for all routes and is guaranteed to run after all otherposthandler
s. It makes sure thatresponse.headers['Content-Length']
,response.headers['Content-Type']
andresponse.statusCode
are set correctly. -
The BodyParser
middleware
is automatically provided, except in serverless environments, where body parsing is automatically performed.
-
The Compressor
middleware
performsrequest.body
gzip
ordeflate
compression, if accepted by the client. See the compressor tests for an illustration of how it is used and configured. -
The CORS
middleware
is a wrapper around the robust Express CORS middleware. See the CORS tests for an illustration of how it is used and configured. -
The RequestLogger
middleware
is a gross simplification of the Express Morgan middleware. See the request logger tests for an illustration of how it is used and configured. -
The Timer
middleware
injects timing information intoresponse.header
. See the timer tests for an illustration of how it is used.
TODO: discuss default LogProvider and possible Loggly log provider.
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.
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 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
.
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.
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 bothmiddleware
and ahandler
viamessage.request.route.data
.
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
.
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 toHttpApp
,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.
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
anddescription
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.
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
andresponses
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 */ ]
}