Skip to content

Commit adc233c

Browse files
committed
feat: update to Node.js 18+, fix status code reporting, replace AVA with native test runner
BREAKING CHANGES: - Minimum Node.js version is now 18 - Removed lodash dependencies (replaced with native JS) - Removed co dependency (replaced with native Promises) Fixes: - Status code is now correctly set before emitting error events, fixing incorrect status code reporting in logs (e.g., Boom.unauthorized() now correctly reports 401 instead of 500) - Status code determination moved before headerSent early return to ensure all error paths have correct status codes Changes: - Replace AVA with Node.js native test runner - Replace camelcase v8 (ESM) with v6 (CommonJS) for compatibility - Replace humanize-string v3 (ESM) with v2 (CommonJS) for compatibility - Replace co with native Promise wrappers for session store callbacks - Replace lodash.iserror, lodash.isfunction, lodash.isnumber, lodash.isobject, lodash.isstring, lodash.map, lodash.values with native JavaScript equivalents - Update GitHub CI to test Node.js 18, 20, 22, 24 - Update actions/checkout and actions/setup-node to v4 - Update husky to v9 (new pre-commit hook format) - Update devDependencies: @commitlint/cli, @commitlint/config-conventional, @koa/router, mongoose, mongodb, lint-staged, xo, and others - Replace nyc with c8 for code coverage
1 parent eb6c2cb commit adc233c

File tree

8 files changed

+205
-150
lines changed

8 files changed

+205
-150
lines changed

.github/workflows/ci.yml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ jobs:
1010
os:
1111
- ubuntu-latest
1212
node_version:
13-
- 14
14-
- 16
1513
- 18
14+
- 20
15+
- 22
16+
- 24
1617
name: Node ${{ matrix.node_version }} on ${{ matrix.os }}
1718
steps:
18-
- uses: actions/checkout@v3
19+
- uses: actions/checkout@v4
1920
- name: Setup node
20-
uses: actions/setup-node@v3
21+
uses: actions/setup-node@v4
2122
with:
2223
node-version: ${{ matrix.node_version }}
2324
- name: Install dependencies

.husky/pre-commit

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1 @@
1-
#!/bin/sh
2-
. "$(dirname "$0")/_/husky.sh"
3-
41
npx --no-install lint-staged && npm test

.xo-config.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
module.exports = {
22
prettier: true,
33
space: true,
4-
extends: ['xo-lass']
4+
extends: ['xo-lass'],
5+
overrides: [
6+
{
7+
files: 'test/**/*.js',
8+
rules: {
9+
'n/no-unsupported-features/node-builtins': 'off'
10+
}
11+
}
12+
]
513
};

examples/api.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
const Koa = require('koa');
22
const Router = require('koa-router');
33
const koa404Handler = require('koa-404-handler');
4-
54
const errorHandler = require('..');
65

76
// initialize our app

examples/web-app.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ const flash = require('koa-connect-flash');
66
const convert = require('koa-convert');
77
const Router = require('koa-router');
88
const koa404Handler = require('koa-404-handler');
9-
109
const errorHandler = require('..');
1110

1211
// initialize our app

index.js

Lines changed: 93 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ const fs = require('node:fs');
22
const path = require('node:path');
33
const process = require('node:process');
44
const { Buffer } = require('node:buffer');
5-
6-
const co = require('co');
75
const Boom = require('@hapi/boom');
86
const camelCase = require('camelcase');
97
const capitalize = require('capitalize');
@@ -13,15 +11,6 @@ const statuses = require('statuses');
1311
const toIdentifier = require('toidentifier');
1412
const { convert } = require('html-to-text');
1513

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-
2514
// <https://github.com/nodejs/node/blob/08dd4b1723b20d56fbedf37d52e736fe09715f80/lib/dns.js#L296-L320>
2615
const DNS_RETRY_CODES = new Set([
2716
'EADDRGETNETWORKPARAMS',
@@ -54,6 +43,33 @@ const opts = {
5443
encoding: 'utf8'
5544
};
5645

46+
// Helper functions to replace lodash dependencies
47+
function isError(value) {
48+
return (
49+
value instanceof Error ||
50+
(typeof value === 'object' &&
51+
value !== null &&
52+
typeof value.message === 'string' &&
53+
typeof value.name === 'string')
54+
);
55+
}
56+
57+
function isFunction(value) {
58+
return typeof value === 'function';
59+
}
60+
61+
function isNumber(value) {
62+
return typeof value === 'number' && !Number.isNaN(value);
63+
}
64+
65+
function isObject(value) {
66+
return value !== null && typeof value === 'object';
67+
}
68+
69+
function isString(value) {
70+
return typeof value === 'string';
71+
}
72+
5773
function isErrorConstructorName(err, name) {
5874
const names = [];
5975

@@ -130,18 +146,34 @@ function errorHandler(
130146
try {
131147
if (!err) return;
132148

133-
this.app.emit('error', err, this);
149+
if (!isError(err)) err = new Error(err);
150+
151+
// check if we have a boom error that specified
152+
// a status code already for us (and then use it)
153+
if (isObject(err.output) && isNumber(err.output.statusCode)) {
154+
err.status = err.output.statusCode;
155+
} else if (isString(err.code) && DNS_RETRY_CODES.has(err.code)) {
156+
// check if this was a DNS error and if so
157+
// then set status code for retries appropriately
158+
err.status = 408;
159+
}
160+
161+
if (!isNumber(err.status)) err.status = 500;
162+
163+
// set err.statusCode for consistency
164+
err.statusCode = err.status;
134165

135166
// nothing we can do here other
136167
// than delegate to the app-level
137168
// handler and log.
138-
if (this.headerSent || !this.writable) return;
169+
if (this.headerSent || !this.writable) {
170+
this.app.emit('error', err, this);
171+
return;
172+
}
139173

140174
// translate messages
141175
const translate = (message) =>
142-
_isFunction(this.request.t) ? this.request.t(message) : message;
143-
144-
if (!_isError(err)) err = new Error(err);
176+
isFunction(this.request.t) ? this.request.t(message) : message;
145177

146178
const type = this.accepts(['text', 'json', 'html']);
147179

@@ -151,7 +183,7 @@ function errorHandler(
151183
}
152184

153185
const val = Number.parseInt(err.message, 10);
154-
if (_isNumber(val) && val >= 400 && val < 600) {
186+
if (isNumber(val) && val >= 400 && val < 600) {
155187
// check if we threw just a status code in order to keep it simple
156188
err = Boom[camelCase(toIdentifier(statuses.message[val]))]();
157189
err.message = translate(err.message);
@@ -209,32 +241,22 @@ function errorHandler(
209241
err.message = translate(Boom.internal().output.payload.message);
210242
}
211243

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-
}
244+
// finalize status code after all error type checks
245+
err.status ||= 500;
246+
err.statusCode = err.status;
222247

223-
if (!_isNumber(err.status)) err.status = 500;
248+
// emit error event AFTER status code has been determined
249+
// so that error listeners (e.g. loggers) have access to the correct status
250+
this.app.emit('error', err, this);
224251

225252
// check if there is flash messaging
226-
const hasFlash = _isFunction(this.flash);
253+
const hasFlash = isFunction(this.flash);
227254

228255
// check if there is a view rendering engine binding `this.render`
229-
const hasRender = _isFunction(this.render);
256+
const hasRender = isFunction(this.render);
230257

231258
// check if we're about to go into a possible endless redirect loop
232259
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;
238260
this.statusCode = err.statusCode;
239261
this.status = this.statusCode;
240262

@@ -246,7 +268,7 @@ function errorHandler(
246268

247269
// set any additional error headers specified
248270
// (e.g. for BasicAuth we use `basic-auth` which specifies WWW-Authenticate)
249-
if (_isObject(err.headers) && Object.keys(err.headers).length > 0)
271+
if (isObject(err.headers) && Object.keys(err.headers).length > 0)
250272
this.set(err.headers);
251273

252274
// fix page title and description
@@ -304,9 +326,12 @@ function errorHandler(
304326
cookiesKey
305327
) {
306328
try {
307-
await co
308-
.wrap(this.sessionStore.set)
309-
.call(this.sessionStore, this.sessionId, this.session);
329+
await new Promise((resolve, reject) => {
330+
this.sessionStore.set(this.sessionId, this.session, (err) => {
331+
if (err) reject(err);
332+
else resolve();
333+
});
334+
});
310335
this.cookies.set(
311336
cookiesKey,
312337
this.sessionId,
@@ -326,11 +351,16 @@ function errorHandler(
326351
//
327352
// if we're using `koa-session-store` we need to add
328353
// `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-
);
354+
await new Promise((resolve, reject) => {
355+
this._session._store.save(
356+
this._session._sid,
357+
stringify(this.session),
358+
(err) => {
359+
if (err) reject(err);
360+
else resolve();
361+
}
362+
);
363+
});
334364
this.cookies.set(this._session._name, stringify({
335365
_sid: this._session._sid
336366
}), this._session._cookieOpts);
@@ -391,28 +421,31 @@ function makeAPIFriendly(ctx, message) {
391421
function parseValidationError(ctx, err, translate) {
392422
// transform the error messages to be humanized as adapted from:
393423
// 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-
}
424+
err.errors = Object.fromEntries(
425+
Object.entries(err.errors).map(([key, error]) => {
426+
if (!isString(error.path)) {
427+
error.message = capitalize(error.message);
428+
return [key, error];
429+
}
399430

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-
});
431+
error.message = error.message.replaceAll(
432+
new RegExp(error.path, 'g'),
433+
humanize(error.path)
434+
);
435+
error.message = capitalize(error.message);
436+
return [key, error];
437+
})
438+
);
407439

408440
// loop over the errors object of the Validation Error
409441
// with support for HTML error lists
410-
if (_values(err.errors).length === 1) {
411-
err.message = _values(err.errors)[0].message;
442+
const errorValues = Object.values(err.errors);
443+
if (errorValues.length === 1) {
444+
err.message = errorValues[0].message;
412445
if (!err.no_translate) err.message = translate(err.message);
413446
} else {
414-
const errors = _map(_map(_values(err.errors), 'message'), (message) =>
415-
err.no_translate ? message : translate(message)
447+
const errors = errorValues.map((error) =>
448+
err.no_translate ? error.message : translate(error.message)
416449
);
417450
err.message = makeAPIFriendly(
418451
ctx,

package.json

Lines changed: 21 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -31,54 +31,44 @@
3131
}
3232
],
3333
"dependencies": {
34-
"@hapi/boom": "^10.0.0",
34+
"@hapi/boom": "^10.0.1",
3535
"camelcase": "6",
3636
"capitalize": "^2.0.4",
37-
"co": "^4.6.0",
3837
"fast-safe-stringify": "^2.1.1",
39-
"html-to-text": "^9.0.3",
38+
"html-to-text": "^9.0.5",
4039
"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",
4840
"statuses": "^2.0.1",
4941
"toidentifier": "^1.0.1"
5042
},
5143
"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",
44+
"@commitlint/cli": "^19.8.1",
45+
"@commitlint/config-conventional": "^19.8.1",
46+
"@koa/router": "^13.1.0",
47+
"c8": "^10.1.3",
5648
"cross-env": "^7.0.3",
5749
"eslint-config-xo-lass": "^2.0.1",
5850
"fixpack": "^4.0.0",
59-
"get-port": "5",
60-
"husky": "^8.0.3",
61-
"koa": "^2.14.1",
51+
"get-port": "^7.1.0",
52+
"husky": "^9.1.7",
53+
"koa": "^2.16.3",
6254
"koa-404-handler": "^0.1.0",
6355
"koa-basic-auth": "^4.0.0",
6456
"koa-connect-flash": "^0.1.2",
6557
"koa-convert": "^2.0.0",
66-
"koa-generic-session": "^2.3.0",
58+
"koa-generic-session": "^2.3.1",
6759
"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",
60+
"lint-staged": "^15.5.2",
61+
"mongodb": "^6.16.0",
62+
"mongoose": "^8.15.1",
63+
"redis": "^5.10.0",
7364
"redis-errors": "^1.2.0",
74-
"remark-cli": "^11.0.0",
65+
"remark-cli": "^12.0.1",
7566
"remark-preset-github": "^4.0.4",
76-
"rimraf": "^4.1.1",
77-
"supertest": "^6.3.3",
78-
"xo": "^0.53.1"
67+
"supertest": "^7.1.0",
68+
"xo": "^0.60.0"
7969
},
8070
"engines": {
81-
"node": ">= 14"
71+
"node": ">= 18"
8272
},
8373
"files": [
8474
"404.html",
@@ -113,9 +103,9 @@
113103
"main": "index.js",
114104
"repository": "ladjs/koa-better-error-handler",
115105
"scripts": {
116-
"lint": "xo --fix && remark . -qfo && fixpack",
117-
"prepare": "husky install",
106+
"lint": "xo --fix && fixpack",
107+
"prepare": "husky",
118108
"pretest": "npm run lint",
119-
"test": "cross-env NODE_ENV=test nyc ava"
109+
"test": "cross-env NODE_ENV=test c8 node --test test/test.js"
120110
}
121111
}

0 commit comments

Comments
 (0)