klobb
A small, experimental, functional Node.js web server
focused on middleware, immutability, and the
simple abstraction of Handler(Request) -> Response
.
klobb boils down to this:
- Handlers are async functions of signature
Request -> Response
. - Middleware are higher-order functions of signature
Handler -> Handler
. - Requests and Responses are Immutable.js Records.
{return Response}
Install
npm install --save klobb
package.json
:
..."scripts":"start": "klobb -p 3000 index.js"
An app in klobb just needs to export a default async handler function.
index.js
:
;{return Response}
Now serve it:
$ npm start
Listening on 3000
Sometimes I have trouble getting klobb's CLI to launch the server
til I add a .babelrc
to my project root:
"presets": "es2015""plugins":"transform-runtime""syntax-async-functions""transform-async-to-generator""transform-object-rest-spread"
I have a weak understanding of where Babel looks for config. #willfix
Why?
Inspired by Clojure's ring, klobb aims to make systems slower and easier to reason about by modeling the request/response cycle as a succession of pure transformations:
Handler(Request) -> Response
For contrast, Node itself and thus other Node frameworks like Express and Koa expose the request and response as mutable arguments to all functions:
(Response, Response) -> ??? -> (Request, Response)
My goal with klobb is to see if I can arrive at a nice developer experience for this abstraction in Javascript.
Example
Basic
Logging is a classic demonstration of the middleware abstraction.
// server.js{return {return {consoleconst start = Dateconst response = awaitconsolereturn response}}}const middleware =const handler = {return Response}handler
Batteries Included
klobb also comes with a Batteries
module that implements (untested)
common and demonstratively useful middleware.
const Cookie = BatteriesCookieconst middleware =// You update cookies by simply returning a response with the// cookies you want to send to the client{const views = || 0 + 1return Response}handler
Routing
I cobbled together a router that takes a tree and outputs a handler function.
Here's Reddits URL structure:
const middleware =const handler = Batterieshandler
/users/:id
) and params
Wildcards (Wildcard segments like '/:user'
accrete a params map that can be found
in request.getIn(['state', 'params'])
.
Wildcards only match if there isn't an exact match segment on the same level.
For instance, GET /foo
will always match /foo
before /:uname
.
If GET /users/42/big/zzz
matches the route path '/users/:id/:size/zzz'
,
then request.getIn(['state', 'params'])
:
{ id: '42', size: 'big' }
Route nesting
const adminRoutes ='/admin':middleware:Response'/users':'/:user_id':{}const authenticationRoutes ='/login': GET: ... POST: ...'/logout': GET: ... DELETE: ...'/register': GET: ... POST: ...const handler = Batteries
Caveat: The router is naive and doesn't do any sort of backtracking.
Given these two routes:
/users/admin
/users/:user/info
The request GET /users/admin/info
will 404 since the router makes no
attempt to reverse into the /:user/info
branch once it commits to the
/admin
branch.
Templating
It's trivial to bring your own templating. Just wrap your favorite library
with a promise that resolves into HTML and then await
it.
klobb comes with a wrapper for Nunjucks in its Batteries module. It's just 28 lines of code.
const render = Batteriesconst handler = Batteries
The code above would expect views/homepage.html
and views/show-user.html
to exist, relative to the project root.
Validation
I also put together a quick and dirty validation library in
Batteries/Validate.js
.
For demonstration, I'll reimplement koa-skeleton's moderately advanced user-register validation.
const Flash = BatteriesFlashconst v validateBody ValidationError = BatteriesValidate{// Throws ValidationError if the following failsconst vals =// If it succeeds, then we will make it down here and `vals`// will be set to an obj of our validated parameters.const user = await dbreturn Response}const interceptValidationError = Middlewareconst handler = Batteries
Error Handling
klobb wraps your handler with its own top-level try/catch
middleware
that turns any uncaught Error
object into a response.
By default, it returns a 500 response with the body 'Internal Server Error'.
If you want to throw a custom status code and message, then just throw
an Error
with those fields set:
if invalidconst err = "I can't let you do that, Starfox."errstatus = 400throw err
createError
Helper
Though klobb has a convenience function for throwing custom errors, mainly helpful in that it allows for one-liners:
if invalidthrowthrow
If an error has no err.message
set, klobb sets the response body to the
standard HTTP description for the status code:
;throw 500 'Internal Server Error'throw 'Uh oh!' 500 'Uh oh!'throw 500 'Internal Server Error'throw 500 'Uh oh!'throw 503 'Service Unavailable'throw 400 'Payload Too Large'
Custom Error Handling
If you want to handle uncaught errors yourself, just wrap your handler in your
own try/catch
middleware which will get to handle errors before klobb.
Example: Logging errors and then re-throwing them for klobb to handle:
const logOnErrors = Middlewareconst middleware =handler
Or you can just return a response so that klobb's error handling never even catches any errors, effectively overriding klobb.
Example: Custom error-handler that converts all errors into JSON responses:
const jsonErrors = Middlewareconst middleware =handler
Storing State in the Request/Response
klobb's Request and Response objects are Immutable.js Records.
This basically means that their core keys cannot be removed, and arbitrary keys cannot be added. But they have most of the instance methods of Immutable.js Maps.
Instead, each Request and Response has a .state
field which is an
Immutable.js Map that can be arbitrarily modified. Any extensions to
a Request or Response should be stored in the state map.
Example: Loading the current user
For example, here's an example of common middleware that uses the "session_id" cookie to load the current user from the database and then attaches the user to the request so that downstream middleware and handlers can access it:
const Cookie = BatteriesCookieconst loadCurrentUser = Middlewareconst handler = {const currUser = requestif !currUser return Responsereturn Response};const middleware =handler
Composing Multiple Apps
Since a klobb app is just a function that takes a Request and returns a Response (i.e. a handler), it's trivial to compose multiple apps together.
Content Negotiation
This abstraction was inspired by Express' res.format.
If you want to respond differently based on the request's Accept
header,
then Batteries.negotiate
takes a mapping of accept headers -> handlers and
returns a new handler.
It matches based on Request#accepts(...types)
.
If none of the branches match, then the negotiate handler returns a "406 Not Acceptable" response.
const handler = Batteries
Or, even simpler:
const handler = Batteries
If you want to hook into the case where none of the branches match, then
you can either wrap the final handle in middleware that checks if
response.status === 406
.
Or you can provide a default
branch:
const handler = Batteries
Though be sure to respond with the appropriate 406 status code if you implement your own default branch handler.
Concepts
Response
A basic response just needs three keys:
status: 200headers: 'content-type': 'text/plain'body: 'Hello, world!' // body can be one of String, Buffer, or Stream
Request
A request looks something like this:
method: 'GET'url: '/test?foo=42'path: '/test'headers: {}body: 'Hello, world!'querystring: '?foo=42'ip: '1.2.3.4,'nreq: ... underlying Node request ...
Though without any additional middleware, klobb does not parse the body at all.
The underlying Node request is always available at request.nreq
and
is never converted into an immutable map itself.
Handler :: async (Request -> Response)
A handler is an async
function that takes a request and returns a response.
Being an async
function, it actually returns a promise that you await
,
but like Koa, klobb tries to free you from having to work with promises
directly in your middleware/handlers.
Here's a basic handler:
{return 200 {} 'Hello, world!'}
And here are some conveniences functions for making responses:
{return Response // alternative to `new` constructorreturn Response // 200return Response // 404return Response // 304return Response // 200, JSON encodedreturn Response // 302 Temporaryreturn Response // 301 Permanentreturn Response // 302 Permanent to referrer || homepage}
Middleware :: Handler -> Handler
Middleware are functions that take and return handlers.
{return {// request is going downstreamconst response = await// response is coming upstreamreturn response}}
Composing Middleware
Use Middleware.compose
to compose middleware.
// compose is re-exported from the root module for convenienceconst middleware =handler
compose
returns a function that applies middleware from right to left
to the handler argument:
const middleware = handlerconst middleware =
During a request, the above middleware execution order can be visualized as this:
+-------------------------------------------------+
| +---------------------------------------+ |
| | +-----------------------------+ | |
| | | | | |
request -> a -> b -> c -> (handler -> response) -> c -> b -> a -> response
^ | | | | | | |
| | | +-----------------------------+ | | v
client | +---------------------------------------+ | client
+-------------------------------------------------+
That is, in compose(a, b, c)
, middleware a
touches the request first
and the response last.
The benefit of using klobb's own compose
function is that it wraps
each middleware function to promote null responses into 404 responses.
{return // will get promoted into a 404}// e.g.{return Response}
I would pefer to find a way to achieve this without having to provide my own compose function.
Middleware Helpers
Middleware.make
saves you some boilerplate by letting you create a
Middleware function by passing it a function of signature
(Handler, Request) -> Response
:
const mw = Middleware;const middleware =
As per [unnecessary?] middleware convention, you must still invoke
the function mw()
to get the middleware function. This is so that you
don't always need to look up whether a function is middleware or if it
returns middleware. i.e. mw(opts)
vs mw
.
License
MIT