effectsloop-server-utils v1.8.0
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.state
object.- When the core
buildServer
function's default logging middleware is left enabled and the optionalhandleError
fn 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.warning
andctx.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.start
as aDate.now()
value. - It should be simple to tell what types of responses you'll get from a route, so the
handleError
is 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 | ArraybuildRouter() | 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 | Arrayfn() | 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.error
calls from within routes. - Logging an
info
output on successful requests, awarn
on requests withctx.state.warning
, and anerror
on requests withctx.state.err
. Because of this, I recommend using this middleware in conjunction with thehandleError
function 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
options
are passed, and the thrownerror.code
key does not match any options (options[error.code]
), and nooptions.defaultMessage
is passed, then a 500 is sent automatically with a generic error. - If both are provided then a warning is logged and the
msg
is 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 = router
Extend 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 }
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago