├── .commitlintrc.js
├── .editorconfig
├── .eslintignore
├── .gitattributes
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .husky
├── commit-msg
└── pre-commit
├── .lintstagedrc.js
├── .npmrc
├── .prettierrc.js
├── .remarkrc.js
├── .xo-config.js
├── 404.html
├── 500.html
├── LICENSE
├── README.md
├── examples
├── api.js
└── web-app.js
├── index.js
├── package.json
└── test
└── test.js
/.commitlintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['@commitlint/config-conventional']
3 | };
4 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | !.*.js
2 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | - push
4 | - pull_request
5 | jobs:
6 | build:
7 | runs-on: ${{ matrix.os }}
8 | strategy:
9 | matrix:
10 | os:
11 | - ubuntu-latest
12 | node_version:
13 | - 14
14 | - 16
15 | - 18
16 | name: Node ${{ matrix.node_version }} on ${{ matrix.os }}
17 | steps:
18 | - uses: actions/checkout@v3
19 | - name: Setup node
20 | uses: actions/setup-node@v3
21 | with:
22 | node-version: ${{ matrix.node_version }}
23 | - name: Install dependencies
24 | run: npm install
25 | - name: Run tests
26 | run: npm run test
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | *.log
3 | .idea
4 | node_modules
5 | coverage
6 | .nyc_output
7 | locales/
8 | package-lock.json
9 | yarn.lock
10 |
11 | Thumbs.db
12 | tmp/
13 | temp/
14 | *.lcov
15 | .env
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx --no-install commitlint --edit $1
5 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx --no-install lint-staged && npm test
5 |
--------------------------------------------------------------------------------
/.lintstagedrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "*.md": filenames => filenames.map(filename => `remark ${filename} -qfo`),
3 | 'package.json': 'fixpack',
4 | '*.js': 'xo --fix'
5 | };
6 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | singleQuote: true,
3 | bracketSpacing: true,
4 | trailingComma: 'none'
5 | };
6 |
--------------------------------------------------------------------------------
/.remarkrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: ['preset-github']
3 | };
4 |
--------------------------------------------------------------------------------
/.xo-config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | prettier: true,
3 | space: true,
4 | extends: ['xo-lass']
5 | };
6 |
--------------------------------------------------------------------------------
/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Page Not Found
6 |
7 |
54 |
55 |
56 | Page Not Found
57 | Sorry, but the page you were trying to view does not exist.
58 | Go to homepage
59 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/500.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Internal Server Error
6 |
7 |
54 |
55 |
56 | Internal Server Error
57 | An internal server error occurred.
58 | Try again
59 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License
2 |
3 | Copyright (c) 2016-present Nick Baugh
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # koa-better-error-handler
2 |
3 | [](https://github.com/ladjs/koa-better-error-handler/actions/workflows/ci.yml)
4 | [](https://github.com/sindresorhus/xo)
5 | [](https://github.com/prettier/prettier)
6 | [](https://lass.js.org)
7 | [](LICENSE)
8 |
9 | > A better error-handler for [Lad][] and [Koa][]. Makes `ctx.throw` awesome (best used with [koa-404-handler][])
10 |
11 |
12 | ## Index
13 |
14 | * [Features](#features)
15 | * [Install](#install)
16 | * [Usage](#usage)
17 | * [API](#api)
18 | * [Web App](#web-app)
19 | * [User-Friendly Responses](#user-friendly-responses)
20 | * [HTML Error Lists](#html-error-lists)
21 | * [License](#license)
22 |
23 |
24 | ## Features
25 |
26 | * Detects Node.js DNS errors (e.g. `ETIMEOUT` and `EBADFAMILY`) and sends 408 Client Timeout error
27 | * Detects Mongoose errors and sends 408 Client Timeout error
28 | * Detects common programmer mistakes by detecting errors of TypeError, SyntaxError, ReferenceError, RangeError, URIError, and EvalError and yields generic "Internal Server Error" (only applies to production mode)
29 | * Detects Redis errors (e.g. ioredis' MaxRetriesPerRequestError) and sends 408 Client Timeout error
30 | * Uses [Boom][boom] for making error messages beautiful (see [User Friendly Responses](#user-friendly-responses) below)
31 | * Simply a better error handler (doesn't remove all headers [like the built-in one does][gh-issue])
32 | * Doesn't make all status codes 500 ([like the built-in Koa error handler does][gh-500-issue])
33 | * Supports Flash messages and preservation of newly set session object
34 | * Fixes annoying redirect issue where flash messages were lost upon an error being thrown
35 | * Supports [HTML Error Lists](#html-error-lists) using `` for Mongoose validation errors with more than one message
36 | * Makes `ctx.throw` beautiful messages (e.g. `ctx.throw(404)` will output a beautiful error object :hibiscus:)
37 | * Supports `text/html`, `application/json`, and `text` response types
38 | * Supports and recommends use of [mongoose-beautiful-unique-validation][mongoose-beautiful-unique-validation]
39 |
40 |
41 | ## Install
42 |
43 | ```bash
44 | npm install --save koa-better-error-handler
45 | ```
46 |
47 |
48 | ## Usage
49 |
50 | > You should probably be using this in combination with [koa-404-handler][] too!
51 |
52 | The package exports a function which accepts four arguments (in order):
53 |
54 | * `cookiesKey` - defaults to `false`
55 | * `logger` - defaults to `console`
56 | * `useCtxLogger` - defaults to `true`
57 | * `stringify` - defaults to `fast-safe-stringify` (you can also use `JSON.stringify` or another option here if preferred)
58 |
59 | If you pass a `cookiesKey` then support for sessions will be added. You should always set this argument's value if you are using cookies and sessions (e.g. web server).
60 |
61 | We recommend to use [Cabin][] for your `logger` and also you should use its middleware too, as it will auto-populate `ctx.logger` for you to make context-based logs easy.
62 |
63 | Note that this package only supports `koa-generic-session`, and does not yet support `koa-session-store` (see the code in [index.js](index.js) for more insight, pull requests are welcome).
64 |
65 | ### API
66 |
67 | > No support for sessions, cookies, or flash messaging:
68 |
69 | ```js
70 | const errorHandler = require('koa-better-error-handler');
71 | const Koa = require('koa');
72 | const Router = require('koa-router');
73 | const koa404Handler = require('koa-404-handler');
74 |
75 | // initialize our app
76 | const app = new Koa();
77 |
78 | // override koa's undocumented error handler
79 | app.context.onerror = errorHandler();
80 |
81 | // specify that this is our api
82 | app.context.api = true;
83 |
84 | // use koa-404-handler
85 | app.use(koa404Handler);
86 |
87 | // set up some routes
88 | const router = new Router();
89 |
90 | // throw an error anywhere you want!
91 | router.get('/404', ctx => ctx.throw(404));
92 | router.get('/500', ctx => ctx.throw(500));
93 |
94 | // initialize routes on the app
95 | app.use(router.routes());
96 |
97 | // start the server
98 | app.listen(3000);
99 | console.log('listening on port 3000');
100 | ```
101 |
102 | ### Web App
103 |
104 | > Built-in support for sessions, cookies, and flash messaging:
105 |
106 | ```js
107 | const errorHandler = require('koa-better-error-handler');
108 | const Koa = require('koa');
109 | const redis = require('redis');
110 | const RedisStore = require('koa-redis');
111 | const session = require('koa-generic-session');
112 | const flash = require('koa-connect-flash');
113 | const convert = require('koa-convert');
114 | const Router = require('koa-router');
115 | const koa404Handler = require('koa-404-handler');
116 |
117 | // initialize our app
118 | const app = new Koa();
119 |
120 | // define keys used for signing cookies
121 | app.keys = ['foo', 'bar'];
122 |
123 | // initialize redis store
124 | const redisClient = redis.createClient();
125 | redisClient.on('connect', () => app.emit('log', 'info', 'redis connected'));
126 | redisClient.on('error', err => app.emit('error', err));
127 |
128 | // define our storage
129 | const redisStore = new RedisStore({
130 | client: redisClient
131 | });
132 |
133 | // add sessions to our app
134 | const cookiesKey = 'lad.sid';
135 | app.use(
136 | convert(
137 | session({
138 | key: cookiesKey,
139 | store: redisStore
140 | })
141 | )
142 | );
143 |
144 | // add support for flash messages (e.g. `req.flash('error', 'Oops!')`)
145 | app.use(convert(flash()));
146 |
147 | // override koa's undocumented error handler
148 | app.context.onerror = errorHandler(cookiesKey);
149 |
150 | // use koa-404-handler
151 | app.use(koa404Handler);
152 |
153 | // set up some routes
154 | const router = new Router();
155 |
156 | // throw an error anywhere you want!
157 | router.get('/404', ctx => ctx.throw(404));
158 | router.get('/500', ctx => ctx.throw(500));
159 |
160 | // initialize routes on the app
161 | app.use(router.routes());
162 |
163 | // start the server
164 | app.listen(3000);
165 | console.log('listening on port 3000');
166 | ```
167 |
168 |
169 | ## User-Friendly Responses
170 |
171 | > Example Request:
172 |
173 | ```bash
174 | curl -H "Accept: application/json" http://localhost/some-page-does-not-exist
175 | ```
176 |
177 | > Example Response:
178 |
179 | ```json
180 | {
181 | "statusCode": 404,
182 | "error": "Not Found",
183 | "message":"Not Found"
184 | }
185 | ```
186 |
187 |
188 | ## Prevent Errors From Being Automatically Translated
189 |
190 | As of v3.0.5, you can prevent an error from being automatically translated by setting the error property of `no_translate` to have a value of `true`:
191 |
192 | ```js
193 | function middleware(ctx) {
194 | const err = Boom.badRequest('Uh oh!');
195 | err.no_translate = true; // <----
196 | ctx.throw(err);
197 | }
198 | ```
199 |
200 |
201 | ## HTML Error Lists
202 |
203 | If you specify `app.context.api = true` or set `ctx.api = true`, and if a Mongoose validation error message occurs that has more than one message (e.g. multiple fields were invalid) – then `err.message` will be joined by a comma instead of by ``.
204 |
205 | Therefore if you *DO* want your API error messages to return HTML formatted error lists for Mongoose validation, then set `app.context.api = false`, `ctx.api = false`, or simply make sure to not set them before using this error handler.
206 |
207 | ```js
208 | try {
209 | // trigger manual validation
210 | // (this allows us to have a 400 error code instead of 500)
211 | await company.validate();
212 | } catch (err) {
213 | ctx.throw(Boom.badRequest(err));
214 | }
215 | ```
216 |
217 | > With error lists:
218 |
219 | ```json
220 | {
221 | "statusCode": 400,
222 | "error": "Bad Request",
223 | "message": "Path `company_logo` is required. Gig description must be 100-300 characters. "
224 | }
225 | ```
226 |
227 | > Without error lists:
228 |
229 | ```json
230 | {
231 | "statusCode":400,
232 | "error":"Bad Request",
233 | "message":"Path `company_logo` is required., Gig description must be 100-300 characters."
234 | }
235 | ```
236 |
237 |
238 | ## API Friendly Messages
239 |
240 | By default if `ctx.api` is true, then [html-to-text](https://github.com/werk85/node-html-to-text) will be invoked upon the `err.message`, thus converting all the HTML markup into text format.
241 |
242 | You can also specify a base URI in the environment variable for rendering as `process.env.ERROR_HANDLER_BASE_URL`, e.g. `ERROR_HANDLER_BASE_URL=https://example.com` (omit trailing slash), and any HTML links such as `Click here ` will be converted to `[Click here][1]` with a `[1]` link appended of `https://example.com/foo/bar/baz`.
243 |
244 |
245 | ## License
246 |
247 | [MIT](LICENSE) © [Nick Baugh](http://niftylettuce.com/)
248 |
249 |
250 | ##
251 |
252 | [boom]: https://github.com/hapijs/boom
253 |
254 | [gh-issue]: https://github.com/koajs/koa/issues/571
255 |
256 | [gh-500-issue]: https://github.com/koajs/koa/blob/e4bcdecef295d7adbf5cce1bdc09adc0a24117b7/lib/context.js#L94-L140
257 |
258 | [mongoose-beautiful-unique-validation]: https://github.com/matteodelabre/mongoose-beautiful-unique-validation
259 |
260 | [lad]: https://lad.js.org
261 |
262 | [koa]: http://koajs.com/
263 |
264 | [koa-404-handler]: https://github.com/ladjs/koa-404-handler
265 |
266 | [cabin]: https://cabinjs.com
267 |
--------------------------------------------------------------------------------
/examples/api.js:
--------------------------------------------------------------------------------
1 | const Koa = require('koa');
2 | const Router = require('koa-router');
3 | const koa404Handler = require('koa-404-handler');
4 |
5 | const errorHandler = require('..');
6 |
7 | // initialize our app
8 | const app = new Koa();
9 |
10 | // override koa's undocumented error handler
11 | app.context.onerror = errorHandler;
12 |
13 | // specify that this is our api
14 | app.context.api = true;
15 |
16 | // use koa-404-handler
17 | app.use(koa404Handler);
18 |
19 | // set up some routes
20 | const router = new Router();
21 |
22 | // throw an error anywhere you want!
23 | router.get('/404', (ctx) => ctx.throw(404));
24 | router.get('/500', (ctx) => ctx.throw(500));
25 |
26 | // initialize routes on the app
27 | app.use(router.routes());
28 |
29 | // start the server
30 | app.listen(3000);
31 | console.log('listening on port 3000');
32 |
--------------------------------------------------------------------------------
/examples/web-app.js:
--------------------------------------------------------------------------------
1 | const Koa = require('koa');
2 | const redis = require('redis');
3 | const RedisStore = require('koa-redis');
4 | const session = require('koa-generic-session');
5 | const flash = require('koa-connect-flash');
6 | const convert = require('koa-convert');
7 | const Router = require('koa-router');
8 | const koa404Handler = require('koa-404-handler');
9 |
10 | const errorHandler = require('..');
11 |
12 | // initialize our app
13 | const app = new Koa();
14 |
15 | // define keys used for signing cookies
16 | app.keys = ['foo', 'bar'];
17 |
18 | // initialize redis store
19 | const redisClient = redis.createClient();
20 | redisClient.on('connect', () => app.emit('log', 'info', 'redis connected'));
21 | redisClient.on('error', (err) => app.emit('error', err));
22 |
23 | // define our storage
24 | const redisStore = new RedisStore({
25 | client: redisClient
26 | });
27 |
28 | // add sessions to our app
29 | app.use(
30 | convert(
31 | session({
32 | store: redisStore
33 | })
34 | )
35 | );
36 |
37 | // add support for flash messages (e.g. `req.flash('error', 'Oops!')`)
38 | app.use(convert(flash()));
39 |
40 | // override koa's undocumented error handler
41 | app.context.onerror = errorHandler;
42 |
43 | // use koa-404-handler
44 | app.use(koa404Handler);
45 |
46 | // set up some routes
47 | const router = new Router();
48 |
49 | // throw an error anywhere you want!
50 | router.get('/404', (ctx) => ctx.throw(404));
51 | router.get('/500', (ctx) => ctx.throw(500));
52 |
53 | // initialize routes on the app
54 | app.use(router.routes());
55 |
56 | // start the server
57 | app.listen(3000);
58 | console.log('listening on port 3000');
59 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | const fs = require('node:fs');
2 | const path = require('node:path');
3 | const process = require('node:process');
4 | const { Buffer } = require('node:buffer');
5 |
6 | const co = require('co');
7 | const Boom = require('@hapi/boom');
8 | const camelCase = require('camelcase');
9 | const capitalize = require('capitalize');
10 | const fastSafeStringify = require('fast-safe-stringify');
11 | const humanize = require('humanize-string');
12 | const statuses = require('statuses');
13 | const toIdentifier = require('toidentifier');
14 | const { convert } = require('html-to-text');
15 |
16 | // lodash
17 | const _isError = require('lodash.iserror');
18 | const _isFunction = require('lodash.isfunction');
19 | const _isNumber = require('lodash.isnumber');
20 | const _isObject = require('lodash.isobject');
21 | const _isString = require('lodash.isstring');
22 | const _map = require('lodash.map');
23 | const _values = require('lodash.values');
24 |
25 | //
26 | const DNS_RETRY_CODES = new Set([
27 | 'EADDRGETNETWORKPARAMS',
28 | 'EBADFAMILY',
29 | 'EBADFLAGS',
30 | 'EBADHINTS',
31 | 'EBADNAME',
32 | 'EBADQUERY',
33 | 'EBADRESP',
34 | 'EBADSTR',
35 | 'ECANCELLED',
36 | 'ECONNREFUSED',
37 | 'EDESTRUCTION',
38 | 'EFILE',
39 | 'EFORMERR',
40 | 'ELOADIPHLPAPI',
41 | 'ENODATA',
42 | 'ENOMEM',
43 | 'ENONAME',
44 | 'ENOTFOUND',
45 | 'ENOTIMP',
46 | 'ENOTINITIALIZED',
47 | 'EOF',
48 | 'EREFUSED',
49 | 'ESERVFAIL',
50 | 'ETIMEOUT'
51 | ]);
52 |
53 | const opts = {
54 | encoding: 'utf8'
55 | };
56 |
57 | function isErrorConstructorName(err, name) {
58 | const names = [];
59 |
60 | let e = err;
61 | while (e) {
62 | if (!e || !e.name || names.includes(e.name)) break;
63 | names.push(e.name);
64 | if (
65 | !err.constructor ||
66 | !Object.getPrototypeOf(err.constructor).name ||
67 | names.includes(Object.getPrototypeOf(err.constructor).name)
68 | )
69 | break;
70 | names.push(Object.getPrototypeOf(err.constructor).name);
71 | if (
72 | !Object.getPrototypeOf(Object.getPrototypeOf(err.constructor)).name ||
73 | names.includes(
74 | Object.getPrototypeOf(Object.getPrototypeOf(err.constructor)).name
75 | )
76 | )
77 | break;
78 | names.push(
79 | Object.getPrototypeOf(Object.getPrototypeOf(err.constructor)).name
80 | );
81 | e = Object.getPrototypeOf(e.constructor);
82 | }
83 |
84 | return names.includes(name);
85 | }
86 |
87 | //
88 | // NOTE: we could eventually use this https://github.com/alexphelps/server-error-pages/
89 | //
90 | // error pages were inspired by HTML5 Boilerplate's default 404.html page
91 | // https://github.com/h5bp/html5-boilerplate/blob/master/src/404.html
92 | const _404 = fs.readFileSync(path.join(__dirname, '404.html'), opts);
93 | const _500 = fs.readFileSync(path.join(__dirname, '500.html'), opts);
94 |
95 | const passportLocalMongooseErrorNames = new Set([
96 | 'AuthenticationError',
97 | 'MissingPasswordError',
98 | 'AttemptTooSoonError',
99 | 'TooManyAttemptsError',
100 | 'NoSaltValueStoredError',
101 | 'IncorrectPasswordError',
102 | 'IncorrectUsernameError',
103 | 'MissingUsernameError',
104 | 'UserExistsError'
105 | ]);
106 |
107 | const passportLocalMongooseTooManyRequests = new Set([
108 | 'AttemptTooSoonError',
109 | 'TooManyAttemptsError'
110 | ]);
111 |
112 | //
113 | // initialize try/catch error handling right away
114 | // adapted from: https://github.com/koajs/onerror/blob/master/index.js
115 | // https://github.com/koajs/examples/issues/20#issuecomment-31568401
116 | //
117 | // inspired by:
118 | // https://github.com/koajs/koa/blob/9f80296fc49fa0c03db939e866215f3721fcbbc6/lib/context.js#L101-L139
119 | //
120 |
121 | function errorHandler(
122 | cookiesKey = false,
123 | _logger = console,
124 | useCtxLogger = true, // useful if you have ctx.logger (e.g. you're using Cabin's middleware)
125 | stringify = fastSafeStringify // you could alternatively use JSON.stringify
126 | ) {
127 | // eslint-disable-next-line complexity
128 | return async function (err) {
129 | const logger = useCtxLogger && this.logger ? this.logger : _logger;
130 | try {
131 | if (!err) return;
132 |
133 | this.app.emit('error', err, this);
134 |
135 | // nothing we can do here other
136 | // than delegate to the app-level
137 | // handler and log.
138 | if (this.headerSent || !this.writable) return;
139 |
140 | // translate messages
141 | const translate = (message) =>
142 | _isFunction(this.request.t) ? this.request.t(message) : message;
143 |
144 | if (!_isError(err)) err = new Error(err);
145 |
146 | const type = this.accepts(['text', 'json', 'html']);
147 |
148 | if (!type) {
149 | err.status = 406;
150 | err.message = translate(Boom.notAcceptable().output.payload.message);
151 | }
152 |
153 | const val = Number.parseInt(err.message, 10);
154 | if (_isNumber(val) && val >= 400 && val < 600) {
155 | // check if we threw just a status code in order to keep it simple
156 | err = Boom[camelCase(toIdentifier(statuses.message[val]))]();
157 | err.message = translate(err.message);
158 | } else if (isErrorConstructorName(err, 'RedisError')) {
159 | // redis errors (e.g. ioredis' MaxRetriesPerRequestError)
160 | //
161 | // NOTE: we have to have 500 error here to prevent endless redirect loop
162 | //
163 | err.status = type === 'html' ? 504 : 408;
164 | err.message = translate(
165 | type === 'html'
166 | ? Boom.gatewayTimeout().output.payload.message
167 | : Boom.clientTimeout().output.payload.message
168 | );
169 | } else if (passportLocalMongooseErrorNames.has(err.name)) {
170 | // passport-local-mongoose support
171 | if (!err.no_translate) err.message = translate(err.message);
172 | // this ensures the error shows up client-side
173 | err.status = 400;
174 | // 429 = too many requests
175 | if (passportLocalMongooseTooManyRequests.has(err.name))
176 | err.status = 429;
177 | } else if (
178 | err.name === 'ValidationError' &&
179 | isErrorConstructorName(err, 'MongooseError')
180 | ) {
181 | // parse mongoose validation errors
182 | err = parseValidationError(this, err, translate);
183 | } else if (
184 | isErrorConstructorName(err, 'MongoError') ||
185 | isErrorConstructorName(err, 'MongooseError')
186 | ) {
187 | // parse mongoose (and mongodb connection errors)
188 | //
189 | // NOTE: we have to have 500 error here to prevent endless redirect loop
190 | //
191 | err.status = type === 'html' ? 504 : 408;
192 | err.message = translate(
193 | type === 'html'
194 | ? Boom.gatewayTimeout().output.payload.message
195 | : Boom.clientTimeout().output.payload.message
196 | );
197 | } else if (
198 | // prevent code related bugs from
199 | // displaying to users in production environments
200 | process.env.NODE_ENV === 'production' &&
201 | (err instanceof TypeError ||
202 | err instanceof SyntaxError ||
203 | err instanceof ReferenceError ||
204 | err instanceof RangeError ||
205 | err instanceof URIError ||
206 | err instanceof EvalError)
207 | ) {
208 | err.isCodeBug = true;
209 | err.message = translate(Boom.internal().output.payload.message);
210 | }
211 |
212 | // check if we have a boom error that specified
213 | // a status code already for us (and then use it)
214 | if (_isObject(err.output) && _isNumber(err.output.statusCode)) {
215 | err.status = err.output.statusCode;
216 | } else if (_isString(err.code) && DNS_RETRY_CODES.has(err.code)) {
217 | // check if this was a DNS error and if so
218 | // then set status code for retries appropriately
219 | err.status = 408;
220 | err.message = translate(Boom.clientTimeout().output.payload.message);
221 | }
222 |
223 | if (!_isNumber(err.status)) err.status = 500;
224 |
225 | // check if there is flash messaging
226 | const hasFlash = _isFunction(this.flash);
227 |
228 | // check if there is a view rendering engine binding `this.render`
229 | const hasRender = _isFunction(this.render);
230 |
231 | // check if we're about to go into a possible endless redirect loop
232 | const noReferrer = this.get('Referrer') === '';
233 |
234 | // populate the status and body with `boom` error message payload
235 | // (e.g. you can do `ctx.throw(404)` and it will output a beautiful err obj)
236 | err.status = err.status || 500;
237 | err.statusCode = err.status;
238 | this.statusCode = err.statusCode;
239 | this.status = this.statusCode;
240 |
241 | const friendlyAPIMessage = makeAPIFriendly(this, err.message);
242 |
243 | this.body = new Boom.Boom(friendlyAPIMessage, {
244 | statusCode: err.status
245 | }).output.payload;
246 |
247 | // set any additional error headers specified
248 | // (e.g. for BasicAuth we use `basic-auth` which specifies WWW-Authenticate)
249 | if (_isObject(err.headers) && Object.keys(err.headers).length > 0)
250 | this.set(err.headers);
251 |
252 | // fix page title and description
253 | const meta = {
254 | title: this.body.error,
255 | description: err.message
256 | };
257 |
258 | switch (type) {
259 | case 'html': {
260 | this.type = 'html';
261 |
262 | if (this.status === 404) {
263 | // render the 404 page
264 | // https://github.com/koajs/koa/issues/646
265 | if (hasRender) {
266 | try {
267 | await this.render('404', { meta });
268 | } catch (err_) {
269 | logger.error(err_);
270 | this.body = _404;
271 | }
272 | } else {
273 | this.body = _404;
274 | }
275 | } else if (noReferrer || this.status >= 500) {
276 | // flash an error message
277 | if (hasFlash) this.flash('error', err.message);
278 |
279 | // render the 5xx page
280 | if (hasRender) {
281 | try {
282 | await this.render('500', { meta });
283 | } catch (err_) {
284 | logger.error(err_);
285 | this.body = _500;
286 | }
287 | } else {
288 | this.body = _500;
289 | }
290 | } else {
291 | //
292 | // attempt to redirect the user back
293 | //
294 |
295 | // flash an error message
296 | if (hasFlash) this.flash('error', err.message);
297 |
298 | // NOTE: until the issue is resolved, we need to add this here
299 | //
300 | if (
301 | this.sessionStore &&
302 | this.sessionId &&
303 | this.session &&
304 | cookiesKey
305 | ) {
306 | try {
307 | await co
308 | .wrap(this.sessionStore.set)
309 | .call(this.sessionStore, this.sessionId, this.session);
310 | this.cookies.set(
311 | cookiesKey,
312 | this.sessionId,
313 | this.session.cookie
314 | );
315 | } catch (err) {
316 | logger.error(err);
317 | if (err.code === 'ERR_HTTP_HEADERS_SENT') return;
318 | }
319 | }
320 |
321 | /*
322 | // TODO: we need to add support for `koa-session-store` here
323 | //
324 | //
325 | // these comments may no longer be valid and need reconsidered:
326 | //
327 | // if we're using `koa-session-store` we need to add
328 | // `this._session = new Session()`, and then run this:
329 | await co.wrap(this._session._store.save).call(
330 | this._session._store,
331 | this._session._sid,
332 | stringify(this.session)
333 | );
334 | this.cookies.set(this._session._name, stringify({
335 | _sid: this._session._sid
336 | }), this._session._cookieOpts);
337 | */
338 |
339 | // redirect the user to the page they were just on
340 | this.redirect('back');
341 | }
342 |
343 | break;
344 | }
345 |
346 | case 'json': {
347 | this.type = 'json';
348 | this.body = stringify(this.body, null, 2);
349 | break;
350 | }
351 |
352 | default: {
353 | this.type = this.api ? 'json' : 'text';
354 | this.body = stringify(this.body, null, 2);
355 | break;
356 | }
357 | }
358 | } catch (err) {
359 | logger.error(err);
360 | this.status = 500;
361 | this.body = 'Internal Server Error';
362 | }
363 |
364 | if (!this.headerSent || this.writeable) {
365 | this.length = Buffer.byteLength(this.body);
366 | this.res.end(this.body);
367 | }
368 | };
369 | }
370 |
371 | function makeAPIFriendly(ctx, message) {
372 | return ctx.api
373 | ? convert(message, {
374 | wordwrap: false,
375 | selectors: [
376 | {
377 | selector: 'a',
378 | options: {
379 | hideLinkHrefIfSameAsText: true,
380 | baseUrl: process.env.ERROR_HANDLER_BASE_URL || ''
381 | }
382 | },
383 | { selector: 'img', format: 'skip' }
384 | ],
385 | linkBrackets: false
386 | })
387 | : message;
388 | }
389 |
390 | // inspired by https://github.com/syntagma/mongoose-error-helper
391 | function parseValidationError(ctx, err, translate) {
392 | // transform the error messages to be humanized as adapted from:
393 | // https://github.com/niftylettuce/mongoose-validation-error-transform
394 | err.errors = _map(err.errors, (error) => {
395 | if (!_isString(error.path)) {
396 | error.message = capitalize(error.message);
397 | return error;
398 | }
399 |
400 | error.message = error.message.replace(
401 | new RegExp(error.path, 'g'),
402 | humanize(error.path)
403 | );
404 | error.message = capitalize(error.message);
405 | return error;
406 | });
407 |
408 | // loop over the errors object of the Validation Error
409 | // with support for HTML error lists
410 | if (_values(err.errors).length === 1) {
411 | err.message = _values(err.errors)[0].message;
412 | if (!err.no_translate) err.message = translate(err.message);
413 | } else {
414 | const errors = _map(_map(_values(err.errors), 'message'), (message) =>
415 | err.no_translate ? message : translate(message)
416 | );
417 | err.message = makeAPIFriendly(
418 | ctx,
419 | ``
420 | );
421 | }
422 |
423 | // this ensures the error shows up client-side
424 | err.status = 400;
425 |
426 | return err;
427 | }
428 |
429 | module.exports = errorHandler;
430 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "koa-better-error-handler",
3 | "description": "A better error-handler for Lad and Koa. Makes `ctx.throw` awesome (best used with koa-404-handler)",
4 | "version": "11.0.4",
5 | "author": {
6 | "name": "Nick Baugh",
7 | "email": "niftylettuce@gmail.com",
8 | "url": "http://niftylettuce.com/"
9 | },
10 | "bugs": "https://github.com/ladjs/koa-better-error-handler/issues",
11 | "contributors": [
12 | {
13 | "name": "Nick Baugh",
14 | "email": "niftylettuce@gmail.com",
15 | "url": "http://niftylettuce.com/"
16 | },
17 | {
18 | "name": "Shaun Warman",
19 | "email": "shaunwarman1@gmail.com",
20 | "url": "https://shaunwarman.com/"
21 | },
22 | {
23 | "name": "Pablo P Varela",
24 | "email": "yo@pablo.pink",
25 | "url": "https://pablo.pink/"
26 | },
27 | {
28 | "name": "Imed Jaberi",
29 | "email": "imed-jaberi@outlook.com",
30 | "url": "https://www.3imed-jaberi.com/"
31 | }
32 | ],
33 | "dependencies": {
34 | "@hapi/boom": "^10.0.0",
35 | "camelcase": "6",
36 | "capitalize": "^2.0.4",
37 | "co": "^4.6.0",
38 | "fast-safe-stringify": "^2.1.1",
39 | "html-to-text": "^9.0.3",
40 | "humanize-string": "2",
41 | "lodash.iserror": "^3.1.1",
42 | "lodash.isfunction": "^3.0.9",
43 | "lodash.isnumber": "^3.0.3",
44 | "lodash.isobject": "^3.0.2",
45 | "lodash.isstring": "^4.0.1",
46 | "lodash.map": "^4.6.0",
47 | "lodash.values": "^4.3.0",
48 | "statuses": "^2.0.1",
49 | "toidentifier": "^1.0.1"
50 | },
51 | "devDependencies": {
52 | "@commitlint/cli": "^17.4.2",
53 | "@commitlint/config-conventional": "^17.4.2",
54 | "@koa/router": "^12.0.0",
55 | "ava": "^5.1.1",
56 | "cross-env": "^7.0.3",
57 | "eslint-config-xo-lass": "^2.0.1",
58 | "fixpack": "^4.0.0",
59 | "get-port": "5",
60 | "husky": "^8.0.3",
61 | "koa": "^2.14.1",
62 | "koa-404-handler": "^0.1.0",
63 | "koa-basic-auth": "^4.0.0",
64 | "koa-connect-flash": "^0.1.2",
65 | "koa-convert": "^2.0.0",
66 | "koa-generic-session": "^2.3.0",
67 | "koa-redis": "^4.0.1",
68 | "lint-staged": "^13.1.0",
69 | "mongodb": "^4.13.0",
70 | "mongoose": "^6.8.4",
71 | "nyc": "^15.1.0",
72 | "redis": "^4.5.1",
73 | "redis-errors": "^1.2.0",
74 | "remark-cli": "^11.0.0",
75 | "remark-preset-github": "^4.0.4",
76 | "rimraf": "^4.1.1",
77 | "supertest": "^6.3.3",
78 | "xo": "^0.53.1"
79 | },
80 | "engines": {
81 | "node": ">= 14"
82 | },
83 | "files": [
84 | "404.html",
85 | "500.html",
86 | "index.js"
87 | ],
88 | "homepage": "https://github.com/ladjs/koa-better-error-handler",
89 | "keywords": [
90 | "404",
91 | "500",
92 | "async",
93 | "await",
94 | "better",
95 | "boom",
96 | "context",
97 | "ctx",
98 | "custom",
99 | "error",
100 | "error-handler",
101 | "errors",
102 | "es7",
103 | "flash",
104 | "handler",
105 | "handling",
106 | "koa",
107 | "messages",
108 | "override",
109 | "promises",
110 | "stripe"
111 | ],
112 | "license": "MIT",
113 | "main": "index.js",
114 | "repository": "ladjs/koa-better-error-handler",
115 | "scripts": {
116 | "lint": "xo --fix && remark . -qfo && fixpack",
117 | "prepare": "husky install",
118 | "pretest": "npm run lint",
119 | "test": "cross-env NODE_ENV=test nyc ava"
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/test/test.js:
--------------------------------------------------------------------------------
1 | const http = require('node:http');
2 |
3 | const Koa = require('koa');
4 | const MongooseError = require('mongoose/lib/error');
5 | const Router = require('@koa/router');
6 | const auth = require('koa-basic-auth');
7 | const getPort = require('get-port');
8 | const koa404Handler = require('koa-404-handler');
9 | const request = require('supertest');
10 | const test = require('ava');
11 | const { MongoNetworkTimeoutError } = require('mongodb');
12 | const { InterruptError } = require('redis-errors');
13 |
14 | const errorHandler = require('..');
15 |
16 | const statusCodes = Object.keys(http.STATUS_CODES)
17 | .map((code) => {
18 | return Number.parseInt(code, 10);
19 | })
20 | .filter((code) => code >= 400);
21 |
22 | // this doesn't ensure 100% code coverage, but ensures that
23 | // the responses are sending proper Boom status messages
24 | // and that the status codes passed through `ctx.throw(code)`
25 | // are accurate sent in the response header's status code
26 |
27 | test.beforeEach(async (t) => {
28 | // initialize our app
29 | const app = new Koa();
30 |
31 | // override koa's undocumented error handler
32 | app.context.onerror = errorHandler();
33 |
34 | // set up some routes
35 | const router = new Router();
36 |
37 | // throw an error anywhere you want!
38 | for (const code of statusCodes) {
39 | router.get(`/${code}`, (ctx) => ctx.throw(code));
40 | }
41 |
42 | router.get('/basic-auth', auth({ name: 'tj', pass: 'tobi' }), (ctx) => {
43 | ctx.body = 'Hello World';
44 | });
45 |
46 | router.get('/html', (ctx) => {
47 | ctx.api = true;
48 | ctx.throw(
49 | 400,
50 | 'Hello world \n\nHow are you?\n\ngithub.com '
51 | );
52 | });
53 |
54 | router.get('/break-headers-sent', (ctx) => {
55 | ctx.type = 'text/html';
56 | ctx.status = 200;
57 | ctx.res.end('foo');
58 | ctx.throw(404);
59 | });
60 |
61 | router.get('/redis-error', (ctx) => {
62 | ctx.throw(new InterruptError('oops'));
63 | });
64 |
65 | router.get('/mongoose-error', (ctx) => {
66 | ctx.throw(new MongooseError('oops'));
67 | });
68 |
69 | router.get('/mongodb-error', (ctx) => {
70 | ctx.throw(new MongoNetworkTimeoutError('oops'));
71 | });
72 |
73 | // initialize routes on the app
74 | app.use(router.routes());
75 |
76 | // use koa-404-handler
77 | app.use(koa404Handler);
78 |
79 | const port = await getPort();
80 |
81 | t.context.app = request.agent(app.listen(port));
82 | });
83 |
84 | // check for response types
85 | for (const type of ['text/html', 'application/json', 'text/plain']) {
86 | for (const code of statusCodes) {
87 | test(`responds with ${type} for ${code} request`, async (t) => {
88 | const res = await t.context.app
89 | .get(`/${code}`)
90 | .set('Accept', type)
91 | .expect('Content-Type', new RegExp(type));
92 | t.is(res.status, code);
93 | });
94 | }
95 | }
96 |
97 | test("Won't throw after sending headers", async (t) => {
98 | const res = await t.context.app
99 | .get('/break-headers-sent')
100 | .set('Accept', 'text/html');
101 | t.is(res.text, 'foo');
102 | t.is(res.status, 200);
103 | });
104 |
105 | test('Throws with WWW-Authenticate header on basic auth fail', async (t) => {
106 | const res = await t.context.app
107 | .get('/basic-auth')
108 | .expect('WWW-Authenticate', 'Basic realm="Secure Area"');
109 | t.is(res.status, 401);
110 | });
111 |
112 | test('makes API friendly error messages without HTML', async (t) => {
113 | const res = await t.context.app
114 | .get('/html')
115 | .set('Accept', 'application/json');
116 | t.is(res.status, 400);
117 | t.is(
118 | res.body.message,
119 | 'Hello world How are you? github.com [https://github.com]'
120 | );
121 | });
122 |
123 | test('throws 408 on redis error', async (t) => {
124 | const res = await t.context.app.get('/redis-error');
125 | t.is(res.status, 408);
126 | });
127 |
128 | test('throws 408 on mongoose error', async (t) => {
129 | const res = await t.context.app.get('/mongoose-error');
130 | t.is(res.status, 408);
131 | });
132 |
133 | test('throws 408 on mongodb error', async (t) => {
134 | const res = await t.context.app.get('/mongodb-error');
135 | t.is(res.status, 408);
136 | });
137 |
--------------------------------------------------------------------------------