effectsloop-server-utils
v1.8.0
Published
Utilities useful for spinning up servers
Readme
Who Is This For?
I made this for myself as I spin up new servers for solo projects. So it's a bit opinionated.
It's served me well so I thought I'd make it available to anyone who wants it. Also, I wanted access to it as an NPM package, and splurging to make this lib private felt unnecessary.
It uses Koa under the hood because I like how lightweight it is, so this is also for people who are not wed to Express.
What Is Its Purpose?
- Building an extendable server with some default middleware, and an optional logging middleware (
buildServer). - Building a logger than can be used throughout your app and consumed by
buildServer(buildLogger). - Building routes that you pass to the server builder (
buildRouter). - Handling errors with a syntax that is self-documenting (
handleError).
Core Concepts
- Information is persisted across a request's lifespan via the request's
ctx.stateobject.- When the core
buildServerfunction's default logging middleware is left enabled and the optionalhandleErrorfn is used, you will find that your response status is reflected in your logging level (info, warn, error), as well as some other niceties. This is thanks to information that is persisted toctx.state.warningandctx.state.err.
- When the core
- Every request has a unique UUID assigned to it, stored in
ctx.state.id. - Every request's start time is stored in
ctx.state.startas aDate.now()value. - It should be simple to tell what types of responses you'll get from a route, so the
handleErroris intended to receive arguments in a way that is relatively self-documenting. Unless you specifically specify otherwise, the error message from a thrown error will be returned in the server response body. - I assume that every request body is either empty or an object.
- I assume that every response body is either empty or an object.
API
buildServer
Used to build the base Koa server and Bunyan logger, with optional middleware.
Ex:
const { app, log } = buildServer({ name: 'my-server', port: 8080, routes: routesArray })Arguments
| Name | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| port * | Number | Required | The port for the server to listen on. |
| name * | String | | Server name for use in logging. Is REQUIRED if you do not provide a log argument, as buildServer expects one of the two. |
| log | Bunyan Logger | | The Bunyan logger you would like to use to log requests. Recommended to be generated by buildLogger. |
| logPath | String | | The folder path where you want to store your log files. |
| | | | When not passed, output is sent to the console. |
| | | | When passed, output is sent to a file in the specified directory with the syntax [logPath].log. The log file is rotated out once per day, and 3 days of logs plus the current day are held as per the Bunyan spec. |
| routes | Array[buildRouter()] | | Array of routes to use built with the buildRouter API fn. |
| beforeStartup | Function(log) => Promise | | Anything you want to happen before we start listening on a port, such as connecting to a DB. It must be a function that returns a Promise. The function is passed the log for use before the server starts listening. |
| noLogging | Boolean | false | Whether you want each request to be logged via the logging middleware. |
| allowCors | Boolean | false | Whether you want to allow CORS for the server. |
| middleware | Array[fn()] | | Array of middleware you would like to use. They are applied right before your routes. |
| requestBodyMaxLoggingLen | Number | 500 | The max length of the request body that you want to log - anything afterwards is truncated. Only matters if noLogging is not set. |
| responseBodyMaxLoggingLen | Number | 500 | The max length of the response body that you want to log - anything afterwards is truncated. Only matters if noLogging is not set. |
Returns
| Name | Type | Description |
| ---- | ---- | ----------- |
| app | Koa server | This way you can do anything else you want to the server. |
| log | Bunyan logger | This is used by the request/response logging middleware, and is also provided here for you to call as you want. |
Logging In Depth
When the logging middleware is enabled, each request gains several features:
- Access to a logger which automatically includes the id of the request at
ctx.state.log. It enablesctx.log.info,ctx.log.warn, andctx.log.errorcalls from within routes. - Logging an
infooutput on successful requests, awarnon requests withctx.state.warning, and anerroron requests withctx.state.err. Because of this, I recommend using this middleware in conjunction with thehandleErrorfunction that is also exposed by this library, as it leverages the paradigm. - Logging the following information upon completion of every request:
| Name | Type | Description |
| ---- | ---- | ----------- |
| responseStatus | Number | The response status. |
| method | String | The request method. |
| url | String | The request url. |
| body | String or Object | The request body. |
| | | Top-level token and password keys are logged as *******. |
| | | Because request bodies can get long, logs of them are truncated to 500 characters by default. You can override this on a per-request basis with ctx.state.requestBodyMaxLoggingLen, or for all requests with the requestBodyMaxLoggingLen argument as described above. |
| ip | String | The IP of the request. |
| responseTime | Number | The response time in ms. |
| responseBody | Object or Undefined | The response body. |
| | | Top-level token and password keys are logged as *******. |
| | | Because response bodies can get long, logs of them are truncated to 500 characters by default. You can override this on a per-request basis with ctx.state.responseBodyMaxLoggingLen, or for all requests with the responseBodyMaxLoggingLen argument as described above. |
| id | String | The UUID of the request. |
buildLogger
Used to build a Bunyan logger.
Ex:
const log = buildLogger({ name: 'my-server', logPath: './optionalLogFolderPath' })
const { app } = buildServer({ log, port: 3000 })
log.info('Something to show')Arguments
| Name | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| name * | String | Required | The name of the logger that will be generated (this shows up in logs, and would typically be your server name). |
| logPath | String | | The folder path where you want to store your log files. |
| | | | When not passed, output is sent to the console. |
| | | | When passed, output is sent to a file in the specified directory with the syntax [logPath].log. The log file is rotated out once per day, and 3 days of logs plus the current day are held as per the Bunyan spec. |
Returns
| Name | Type | Description | | ---- | ---- | ----------- | | n/a | Bunyan logger instance. | An instance of a Bunyan logger. |
buildRouter
Used to build a Koa router.
Ex:
const router = buildRouter('/users')
router.post('/sign-up', async ({ request, response, state }) => { ... })Arguments
| Name | Type | Default | Description | | ---- | ---- | ------- | ----------- | | n/a | String or Object (koa router options) | | When a String, is the route prefix to use. When an Object, is assumed to be koaRouterOptions. |
Returns
| Name | Type | Description |
| ---- | ---- | ----------- |
| n/a | Router instance | An instance of a Koa Router for you to apply router.post, router.get, etc routes to. |
handleError
Used to handle errors in your routes with a self-documenting API.
Ex:
router.post('/sign-up', async ({ request, response, state }) => {
try {
// route logic here
} catch (err) {
const options = {
emailTaken: 409,
usernameTaken: {
status: 409,
message: 'That username is already taken',
code: 'userTaken',
},
defaultMessage: 'Something broke while attempting to sign up',
}
handleError({ response, state, err, options })
}
})Arguments
| Name | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| response * | Object | Required | Koa response. |
| state * | Object | Required | Koa state. |
| err * | Error | Required | The thrown error. |
| msg | Object or String | | The error response to use for all error cases. See below. |
| options | Object | | The different possible error responses to use. See below. |
| StructError | StructError | | The error class to use for data structure validation checks - an err instanceof StructError check will be used on it, and if there is a match, the passed err shape is expected to match the shape of superstruct errors. |
msg:
{
message: String (non-empty),
status: Number (optional, 500 default),
code: String (optional)
}
OR
String (returns 500)options:
{
[optionCode]:
{
status: Number,
isError: Bool (optional, if true state.err = err, else state.warning = (message || err.message)),
message: String (optional, overrides err.message as returned error.message),
code: String (optional, overrides optionCode as returned error.code),
noCode: Bool (optional, when true no error.code is returned)
},
OR
Number (status to return, which means the thrown err.message and err.code are sent; if >= 500, state.err = err)
...
defaultMessage: same as msg
}Pass either but not both msg and options.
- If neither is provided then a 500 is sent automatically with a generic error.
- If only
optionsare passed, and the thrownerror.codekey does not match any options (options[error.code]), and nooptions.defaultMessageis passed, then a 500 is sent automatically with a generic error. - If both are provided then a warning is logged and the
msgis used.
Responses
Standard errors have the format:
{
error: {
message: String (msg.message || msg || options[err.code].message || options[err.code])
code: String (options[err.code].code || err.code) // Not sent for generic errors, or when options[err.code].noCode is passed
}
}Validation (struct) errors have the format:
{
error: {
message: String
fields: {
[fieldName]: validation error message,
...
}
}
}Everything In Action
Define Routes
const { buildRouter } = require('effectsloop-server-utils')
const users = require('./someUserModule')
const handleError = require('./handleError')
const router = buildRouter('/users')
router.post('/sign-up', async ({ request, response, state }) => {
try {
// route logic here
} catch (err) {
const { validationErrors } = users.signUp
// The codes for errors that could be thrown, and the info to send back for those errors
// 4xx errors will log a warning, and the message from the error will be sent in the
// the response body as { error: { message: error message } }
// The defaultMessage logs an error, and the response body is { error: { message: defaultMessage } }
const options = {
[validationErrors.EMAIL_TAKEN]: 409,
[validationErrors.USERNAME_TAKEN]: {
status: 409,
message: 'That username is already taken',
code: 'userTaken',
},
defaultMessage: 'Something broke while attempting to sign up',
}
handleError({ response, state, err, options })
}
})
router.post('/delete-account', async ({ request, response, state }) => {
try {
// route logic here
} catch (err) {
// No route-specific errors are anticipated here, so any error results in a 500
// with { error: { message: msg } }
handleError({ response, state, err, msg: 'Something broke while attempting to delete account' })
}
})
module.exports = routerExtend handleError If Needed
const { handleError } = require('effectsloop-server-utils')
const { StructError } = require('superstruct')
const { DBError, ValidationError } = require('../lib/Errors')
const { users } = require('../repo')
module.exports = ({ response, state, err, msg, options }) => {
// Handle route-agnostic DBErrors here so that you can avoid defining this handling in multiple routes
if (err instanceof DBError) {
response.status = 500
response.body = { error: { message: 'An error occured in the DB', code: 'dbError' } }
state.err = err // eslint-disable-line no-param-reassign
return
}
// Handle route-agnostic ValidationErrors here so that you can avoid defining this handling in multiple routes
const { INVALID_EMAIL, DEACTIVATED_ACCOUNT, UNVERIFIED_ACCOUNT } = users.genericValidationErrors
if (
err instanceof ValidationError &&
[INVALID_EMAIL, DEACTIVATED_ACCOUNT, UNVERIFIED_ACCOUNT].includes(err.code)
) {
const { code, message } = err
response.status = code === INVALID_EMAIL ? 401 : 409
response.body = { error: { message, code } }
state.warning = 'Generic user validation failed' // eslint-disable-line no-param-reassign
return
}
// Handle route-specific errors as you've defined in each route
handleError({ response, state, err, msg, options, StructError })
}Create The Logger and Server
const { buildLogger, buildServer } = require('effectsloop-server-utils')
const connectToDB = require('./connectToDB')
const userRoutes = require('../userRoutes')
const { SERVER_PORT } = process.env
const connectToDB = log = new Promise((resolve, reject) => {
// DB connection logic here
db.on('error', err => {
log.error({ err }, 'Connection error')
reject()
})
db.on('open', () => {
log.info('Connected to the DB!')
resolve()
})
})
// Instead of building here, could pass `name` to buildServer and get `{ app, log }` from it
const log = buildLogger({ name: 'user-service' })
// The only unused option here is noLogging
const { app } = buildServer({
log,
routes: [userRoutes],
port: SERVER_PORT,
allowCors: true,
requestBodyMaxLoggingLen: 300,
responseBodyMaxLoggingLen: 400,
beforeStartup: connectToDB,
})
module.exports = { app, log }