LoopBack 4 Improves Inbound HTTP Processing
We started LoopBack 4 with the desire to improve all aspects of application development. One of the pain points in LoopBack 3.x was composition of Express middleware handlers, which we decided to address by rolling out a different design based on Sequence of actions. As time progressed, we realized we would have to end up ‘reinventing the wheel’ and provide our own Sequence-friendly version for many frequently used middleware like CORS. Instead, we decided to step back and keep using the vast ecosystem of existing Express middleware that is battle-tested by years of production use.
While discussing the new direction, we thought it would be great to support multiple different HTTP frameworks like Express and Koa and implement a thin integration layer to allow mounting of LoopBack 4 REST router. Raymond researched this approach in a spike pull request #1082. Later on, Yaapa looked into practical implications of the proposed approach and discovered that Express and Koa are fundamentally incompatible at such level that it’s impossible to write a a single middleware/route function that would work with both frameworks. That left us with a difficult decision to make: going forward, should we pick Express or Koa as the framework of choice for LoopBack 4? While we like Koa’s design a lot, we see even more value in the wider adoption and maturity of Express framework and middleware. Ultimately we decided to use Express under the hood but bring some parts of Koa design into LoopBack too.
Request Context as an Object
Traditionally in Express (and Connect), functions implementing request handling logic have the following signature:
function (req, res, next);
While it’s amazing how powerful this simple signature is, it comes with major flaws too:
-
Because the list of arguments is given, it’s difficult to pass additional data between different handlers. The convention is to attach custom properties to the Request object, e.g. body-parsing middleware sets
req.body
property. This approach is difficult to describe in TypeScript and the ever-changing shape of the Request object has negative impact on (micro)performance. -
Flow control is difficult to map to Promises and async/await. Execution of a middleware handler has three possible outcomes:
i. The request was handled and the remaining items in the middleware chain are skipped. Implementation wise, middleware did not call
next()
.ii. The middleware modified the request/response objects, or a route did not match the requested path, and the next handler in the middleware chain should be executed. Implementation wise, middleware called
next()
with no arguments.iii. There was an error and request handling should be aborted. Implementation wise, middleware called
next()
with a single argument - the error.
It is difficult to run a piece of code after the request was handled or transform the response before it’s being sent. For example, to print a log line containing information about the request including timing information, one has to register the logging middleware early in the middleware chain so that it can register event observers and/or monkey-patch request/response objects before any actual work is done - this is counter-intuitive since the log line is printed at the end of request handling.
Koa addresses these flaws by introducing an extensible Context object that contains not only the request and response, but also additional properties and helper methods. Since in LoopBack 4 we have already had a per-request context object used to resolve dependencies, it made sense to extend this context object to also act as a context for low-level HTTP code.
In pull request #1316, I have introduced two context types:
HandlerContext
is an interface describing objects required to handle an incoming HTTP request. There are two properties for starter:context.request
andcontext.response
. Low-level entities like Sequence actions should be using this interface to allow easy interoperability with potentially any HTTP framework and more importantly, to keep the code easy to test.RequestContext
is a class extending our IoCContext
and implementingHandlerContext
interface. This object is used when invoking the sequence handler. By combining both IoC and HTTP contexts into a single object, there will be (hopefully) less confusion among LB4 users about what shape a “context” object has in different places.
Having a single context object allowed me to simplify many places around the
Sequence. For example, in the app.handler()
function, we no longer need to
distinguish between a handler context containing request/response and an IoC
context previously available via sequence.ctx
- there is a single context
argument providing access to both now.
Before this change, a sequence class was accepting IoC Context as a constructor
argument and a pair of request/response objects in the handle()
method:
class MySequence implements SequenceHandler {
constructor(
@inject(RestBindings.Http.CONTEXT) public ctx: Context,
// inject sequence actions
) {}
async handle(req: ParsedRequest, res: ServerResponse): Promise<void> {
// handle the request
}
}
Now with the single RequestContext object:
class MySequence implements SequenceHandler {
constructor(
// inject sequence actions
) {}
async handle(context: RequestContext): Promise<void> {
// handle the request
}
}
The built-in sequence action “reject” is another place we could simplify. Before, reject had three arguments - response, request, error; never mind the unusual order where response is before request. The new API expects only two arguments:
function reject(handlerContext: HandlerContext, err: Error): void;
Implementors can leverage ES6 destructuring to get the same local arguments as before:
function reject({ response, request }: HandlerContext, err: Error): void;
Express Request & Response Everywhere
In the next step (see pull request
#1326), I switched our
code from ServerRequest
and ServerResponse
types provided by Node.js core
module http to Express-flavored variants and
integrated Express into RestServer’s request handler. This change allowed
further simplifications in many places (the patch added 352 and removed 378
lines), which further validated our decision to leverage Express.
The first and pretty obvious change was removal of our custom ParsedRequest
class, which was adding useful URL-related properties like req.path
and
req.query
. In the past, we had to carefully distinguish which places can
access ParsedRequest and where only the core ServerRequest is available. Now we
can use the same Express Request everywhere.
Our reference implementation of an authentication extension was the second place
that became possible to greatly simplify. Because we leverage Passport to
provide actual authentication strategies, our extension had to bridge between
LoopBack’s ParsedRequest
and the Express Request type expected by Passport.
That’s no longer the case and the authentication extension simply forwards the
request received from LoopBack now.
Last but not least, I cleaned up the code handling CORS in LoopBack 4 applications. Originally, we employed a hack to use Express middleware inside a non-express LoopBack4 request handler:
cors(corsOptions)(request, response, () => {});
if (request.method === 'OPTIONS') {
return Promise.resolve();
}
With an Express app running under the hood of our RestServer, we can mount the CORS middleware as it was intended to (see pull request #1338):
this._expressApp.use(cors(corsOptions));
Not all was a bed of roses though. The request and response objects provided by
Express are more difficult to stub when compared to core ServerRequest and
ServerResponse objects. I’ll spare the readers from details on how the sausages
are made, it’s sufficient to say that
@loopback/testlab was updated
to provide a factory function stubExpressContext()
to create Express-flavored
request/response stubs too. See module’s
documentation to learn
more.
A Factory for HTTP(S) Endpoints
The last change identified in the original spike was refactoring of the code setting up the HTTP server. We have had the following goals in mind:
- Allow different HTTP-based transports to share the same code and configuration options.
- Make it easy to add HTTPS support in the (near) future, use the same API to create both HTTP and HTTPS endpoints depending on the configuration.
- Manage the lifecycle of http endpoints (start/stop API).
- Expose bound addresses. Typically in tests, the server is configured with port 0 to let the operating system to provide an available port. After the server started, tests need to get the URL where the server is listening at, using the updated port number.
The pull request #1369 added a new package @loopback/http-server which exposes convenience API for creating new instances of HTTP servers.
const httpServer = new HttpServer(
(req, res) => {
res.end('Hello world');
},
{ port: 3000, host: '127.0.01' },
);
We expect the initial implementation to evolve over time and get support for additional features, from HTTPS to WebSockets and HTTP/2. Contributions are welcome!
See Robust processing capabilities for even more improvements of our HTTP stack we are thinking about .
Originally published at https://strongloop.com/strongblog/loopback4-improves-inbound-http-processing