Skip to content
/ klobb Public

A small, experimental Node.js server abstraction focused on immutability and middleware

Notifications You must be signed in to change notification settings

danneu/klobb

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

95 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

klobb

Build Status NPM version Dependency Status

NPM

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.
import { Response } from 'klobb'

export default function handler (request) {
  return Response.ok('Hello, world!')
}

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:

import { Response } from 'klobb';

export default async function (request) {
  return Response.ok('Hello, world!')
}

Now serve it:

$ npm start
Listening on 3000

http://localhost: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
import { Response, compose } from 'klobb'

function logger () {
  return function middleware (handler) {
    return async function newHandler (request) {
      console.log(`--> ${request.method} ${request.url}`)
      const start = Date.now()
      const response = await handler(request)
      console.log(`<-- ${response.status} - ${Date.now() - start} ms`)
      return response
    }
  }
}

const middleware = logger()

const handler = async function (request) {
  return Response.ok('Hello world')
}

export default middleware(handler)

Batteries Included

klobb also comes with a Batteries module that implements (untested) common and demonstratively useful middleware.

import { Response, Batteries, compose } from 'klobb'
const Cookie = Batteries.Cookie

const middleware = compose(
  Batteries.logger(),
  Batteries.notModified({ etag: true }),
  Batteries.serveStatic('public', { maxage: 1000 * 60 * 60 * 24 }),
  Batteries.jsonBodyParser(),
  Batteries.Cookie.middleware(),
  Batteries.Flash.middleware()
)

// You update cookies by simply returning a response with the
// cookies you want to send to the client

async function handler (request) {
  const views = (parseInt(Cookie.get('views', request), 10) || 0) + 1

  return Response.ok(`You have viewed this page ${views} time(s)`)
    .tap(Cookie.set('views', views))
}

export default middleware(handler)

Routing

I cobbled together a router that takes a tree and outputs a handler function.

Here's Reddits URL structure:

import { Response, Batteries, compose } from 'klobb'

const middleware = compose(...)

const handler = Batteries.router({
  '/': {
    // stick middleware anywhere. they'll get applied to all downstream
    // routes and compose with any downstream middleware.
    middleware: [mw1(), mw2()],
    '/r': {
      '/:subreddit': {
        middleware: [loadSubreddit()],
        GET: (request) => {
          const subreddit = request.getIn(['state', 'params', 'subreddit'])
          return Response.ok(`viewing subreddit ${subreddit}`)
        }
      }
    },
    '/u' {
      '/:user': {
        middleware: [loadUser()],
        GET: (request) => {
          const user = request.getIn(['state', 'params', 'user']);
          return Response.ok(`viewing user ${user}`)
        }
      }
    }
  }
})

export default middleware(handler)

Wildcards (/users/:id) and params

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: [ensureAdmin()],
    GET: () => Response.ok('admin panel'),
    '/users': {
      '/:user_id': {
        DELETE: () => {}
      }
    }
  }
}

const authenticationRoutes = {
  '/login': { GET: ..., POST: ... },
  '/logout': { GET: ..., DELETE: ... },
  '/register': { GET: ..., POST: ... }
}

const handler = Batteries.router({
  '/': {
    GET: () => Response.ok('Homepage'),
    // Mount them to a root
    '/admin': adminRoutes['/admin'],
    // Or spread them in when they're flat
    ...authenticationRoutes
  }
})

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.

import { Batteries } from 'klobb'

const render = Batteries.nunjucks('views', {
  ext: '.html',
  noCache: process.env.NODE_ENV === 'development'
})

const handler = Batteries.router({
  '/': {
    GET: async (request) => Response.ok(await render('homepage'))
    '/:uname': {
      middleware: [ensureAuthorized()],
      GET: async (request) => {
        const user = await db.getUser(request.getIn(['state', 'params', 'uname']))
        if (!user) return Response.notFound()
        return Response.ok(await render('show-user', { user: user }))
      }
    }
  }
})

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.

import { Batteries, Response, Middleware } from 'klobb'
const Flash = Batteries.Flash
const { v, validateBody, ValidationError } = Batteries.Validate

async function handleSignup (req) {
  // Throws ValidationError if the following fails

  const vals = validateBody(req, {
    uname: [
      v.tip('Username required'),
      v.required(),
      v.isString(),
      v.trim(),
      v.checkPred(s => s.length > 0),
      v.match(/^[a-z0-9_-]+$/i,
        'Username must only contain a-z, 0-9, underscore (_), or hypen (-)'),
      v.match(/[a-z]/i,
        'Username must contain at least one letter (a-z)'),
      v.checkNotPredMemo(memo => db.findUserByUname(memo.val), 'Username taken')
    ],
    password2: [
      v.tip('Password confirmation is required'),
      v.required(),
      v.isString(),
      v.checkPred(s => s.length > 0)
    ],
    password1: [
      v.tip('Password is required'),
      v.required(),
      v.isString(),
      v.checkPred(s => s.length > 0),
      v.isLength(6, 100, 'Password must be 6-100 chars'),
      v.tip('Password must match confirmation'),
      v.checkPredMemo(memo => memo.vals.password2 === memo.val)
    ],
    email: [
      v.optional(),
      v.tip('Invalid email address'),
      v.trim(),
      v.isEmail(),
      v.isLength(1, 140, 'Email is too long')
    ]
  })

  // 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 db.insertUser(vals.uname, vals.password1, vals.email)

  return Response.redirect(user.url)
    .tap(Flash.set('message', ['success', 'Successfully registered. Welcome!']))
}

const interceptValidationError = Middleware.make(async (handler, request) => {
  try {
    return await handler(request)
  } catch(err) {
    if (err instanceof ValidationError) {
      return Response.redirectBack(request)
        .tap(Flash.set('message', ['dange', err.message || 'Validation error']))
        .tap(Flash.set('progress', request.body))
    }
    throw err
  }
})

const handler = Batteries.router({
  '/': {
    '/users': {
      middleware: [interceptValidationError()],
      POST: handleCreateUser
    }
  }
})

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 (invalid) {
  const err = new Error("I can't let you do that, Starfox.")
  err.status = 400
  throw err
}

createError Helper

Though klobb has a convenience function for throwing custom errors, mainly helpful in that it allows for one-liners:

import { createError } from 'klobb'

if (invalid) {
  throw createError(400, 'My custom message')
  throw createError(400)
}

If an error has no err.message set, klobb sets the response body to the standard HTTP description for the status code:

import { createError } from 'klobb';

throw new Error()                => 500 'Internal Server Error'
throw new Error('Uh oh!')        => 500 'Uh oh!'
throw createError(500)           => 500 'Internal Server Error'
throw createError(500, 'Uh oh!') => 500 'Uh oh!'
throw createError(503)           => 503 'Service Unavailable'
throw createError(413)           => 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:

import { Middleware } from 'klobb'

const logOnErrors = Middleware.make(async (handler, request) => {
  try {
    return await handler(request)
  } catch(err) {
    logError(err)
    throw err
  }
})

const middleware = compose(logOnErrors(), serveStatic('public'), ...)

export default 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:

import { Middleware, Response } from 'klobb'
import statuses from 'statuses' // npm install --save statuses

const jsonErrors = Middleware.make(async (handler, request) => {
  try {
    return await handler(request)
  } catch(err) {
    const status = err.status || 500
    const message = err.message || statuses[status]
    return new Response(status).json({ error: message })
  }
})

const middleware = compose(jsonErrors(), serveStatic('public'), ...)

export default 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:

import { Middleware, Batteries, compose } from 'klobb'
const Cookie = Batteries.Cookie

const loadCurrentUser = Middleware.make(async (handler, request) => {
  const sessionId = Cookie.get('session_id', request)
  if (!sessionId) return handler(request)
  const currUser = await database.getUserBySessionId(sessionId)
  if (!currUser) return handler(request)
  return handler(request.setIn(['state', 'currUser'], currUser))
})

const handler = (request) => {
  const currUser = request.getIn(['state', 'currUser'])
  if (!currUser) return Response.ok('You are not logged in')
  return Response.ok(`You are logged in as ${currUser.uname}`)
};

const middleware = compose(Cookie.middleware(), loadCurrentUser())

export default 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.

import { Batteries } from 'klobb'
import app1 from './app1'
import app2 from './app2'
import app3 from './app3'

export default Batteries.router({
  '/': {
    '/app1': app1,
    '/app2': app2,
    '/app3': app3
  }
})

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.negotiate({
  'text/plain': () => Response.ok('Hello'),
  'text/html': () => Response.ok('<p>Hello</p>'),
  'application/json': () => Response.json({ message: 'Hello' })
})

Or, even simpler:

const handler = Batteries.negotiate({
  text: () => Response.ok('Hello'),
  html: () => Response.ok('<p>Hello</p>'),
  json: () => Response.json({ message: 'Hello' })
})

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.negotiate({
  text: () => ...,
  json: () => ...,
  default: request => {
    console.log('Unhandled accept header: ${request.getHeader('accept')}')
    return new Response(406)
  }
})

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: 200,
  headers: { '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:

async function handler (request) {
  return new Response(200, {}, 'Hello, world!')
}

And here are some conveniences functions for making responses:

async function handler (request) {
  return Response.make(200, {}, body)  // alternative to `new` constructor
  return Response.ok(body)             // 200
  return Response.notFound()           // 404
  return Response.notModified()        // 304
  return Response.json({ foo: 'bar' }) // 200, JSON encoded
  return Response.redirect(url)        // 302 Temporary
  return Response.redirect(url, 301)   // 301 Permanent
  return Response.redirectBack()       // 302 Permanent to referrer || homepage
}

Middleware :: Handler -> Handler

Middleware are functions that take and return handlers.

function noop (handler) {
  return newHandler (request) {
    // request is going downstream
    const response = await handler(request)
    // response is coming upstream
    return response
  }
}

Composing Middleware

Use Middleware.compose to compose middleware.

// compose is re-exported from the root module for convenience
import { compose } from 'klobb'

const middleware = compose(mw1, mw2, mw3)
export default middleware(handler)

compose returns a function that applies middleware from right to left to the handler argument:

const middleware = compose(a, b, c)(handler)
const middleware = a(b(c(handler)))

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.

async function handler (request) {
  return  // will get promoted into a 404
}

// e.g.

import { Response } from 'klobb'

async function handler (request) {
  return Response.notFound()
}

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.make(async (handler, req) => {
  console.log('>>')
  const res = await handler(req)
  console.log('<<')
  return res
});

const middleware = compose(mw(), mw(), mw())

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

About

A small, experimental Node.js server abstraction focused on immutability and middleware

Resources

Stars

Watchers

Forks

Packages

No packages published