├── .eslintignore
├── .eslintrc
├── .gitignore
├── tsconfig.json
├── test
├── helper.ts
├── fluid.test.ts
├── form_app.ts
├── multipart.test.ts
├── redirect.test.ts
├── accepts.test.ts
├── html.test.ts
├── text.test.ts
└── json.test.ts
├── .github
└── workflows
│ ├── release.yml
│ └── nodejs.yml
├── example.cjs
├── src
├── templates
│ ├── prod_error.html
│ └── dev_error.html
└── index.ts
├── LICENSE
├── package.json
├── README.md
└── CHANGELOG.md
/.eslintignore:
--------------------------------------------------------------------------------
1 | test/fixtures
2 | coverage
3 | __snapshots__
4 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "eslint-config-egg/typescript",
4 | "eslint-config-egg/lib/rules/enforce-node-prefix"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | logs/
2 | npm-debug.log
3 | node_modules/
4 | coverage/
5 | test/fixtures/**/run
6 | .DS_Store
7 | .tshy*
8 | .eslintcache
9 | dist
10 | package-lock.json
11 | .package-lock.json
12 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@eggjs/tsconfig",
3 | "compilerOptions": {
4 | "strict": true,
5 | "noImplicitAny": true,
6 | "target": "ES2022",
7 | "module": "NodeNext",
8 | "moduleResolution": "NodeNext"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/test/helper.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import { fileURLToPath } from 'node:url';
3 |
4 | const __filename = fileURLToPath(import.meta.url);
5 | const __dirname = path.dirname(__filename);
6 |
7 | export function getFixtures(filename: string) {
8 | return path.join(__dirname, 'fixtures', filename);
9 | }
10 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 |
7 | jobs:
8 | release:
9 | name: Node.js
10 | uses: koajs/github-actions/.github/workflows/node-release.yml@master
11 | secrets:
12 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
13 | GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
14 |
--------------------------------------------------------------------------------
/example.cjs:
--------------------------------------------------------------------------------
1 | const fs = require('node:fs');
2 | const Koa = require('koa');
3 | const { onerror } = require('./');
4 |
5 | const app = new Koa();
6 |
7 | onerror(app);
8 |
9 | app.use(async ctx => {
10 | foo();
11 | ctx.body = fs.createReadStream('not exist');
12 | });
13 |
14 | app.listen(3000);
15 | console.log('listening on port http://localhost:3000');
16 |
--------------------------------------------------------------------------------
/test/fluid.test.ts:
--------------------------------------------------------------------------------
1 | import { strict as assert } from 'node:assert';
2 | import Koa from 'koa';
3 | import { onerror } from '../src/index.js';
4 |
5 | describe('test/fluid.test.ts', () => {
6 | it('should return app reference', () => {
7 | const app = new Koa();
8 | const res = onerror(app);
9 | assert(res instanceof Koa);
10 | assert.equal(res, app);
11 | });
12 | });
13 |
--------------------------------------------------------------------------------
/.github/workflows/nodejs.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 |
9 | jobs:
10 | Job:
11 | name: Node.js
12 | uses: node-modules/github-actions/.github/workflows/node-test.yml@master
13 | with:
14 | os: 'ubuntu-latest, macos-latest, windows-latest'
15 | version: '18, 20, 22'
16 | secrets:
17 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
18 |
--------------------------------------------------------------------------------
/src/templates/prod_error.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Error - {{status}}
5 |
6 |
7 |
22 |
23 |
24 |
25 |
Error
26 |
Looks like something broke!
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/templates/dev_error.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Error - {{status}}
5 |
6 |
7 |
22 |
23 |
24 |
25 |
Error
26 |
Looks like something broke!
27 |
28 |
29 | {{stack}}
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2014 node_modules.
4 | Copyright (c) 2015 - present koajs and other contributors.
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 |
--------------------------------------------------------------------------------
/test/form_app.ts:
--------------------------------------------------------------------------------
1 | import { scheduler } from 'node:timers/promises';
2 | import Koa from 'koa';
3 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
4 | // @ts-ignore - co-busboy is not typed
5 | import parse from 'co-busboy';
6 | import { onerror } from '../src/index.js';
7 |
8 | const app = new Koa();
9 | app.on('error', () => {});
10 | onerror(app);
11 | app.use(async ctx => {
12 | if (ctx.path === '/') {
13 | ctx.body = `
14 | Upload File
15 |
22 | `;
23 | return;
24 | }
25 | await scheduler.wait(10);
26 | if (!ctx.is('multipart')) {
27 | ctx.throw(400, 'Content-Type must be multipart/*');
28 | }
29 | const parts = parse(ctx, { autoFields: true });
30 | const stream = await parts();
31 | // console.log(stream.filename, parts.field);
32 | stream.undefiend.error();
33 | });
34 |
35 | // if (!module.parent) {
36 | // app.listen(8080);
37 | // console.log('Listen at http://127.0.0.1:8080');
38 | // }
39 |
40 | export { app };
41 |
--------------------------------------------------------------------------------
/test/multipart.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import { scheduler } from 'node:timers/promises';
3 | import { Readable } from 'node:stream';
4 | import urllib from 'urllib';
5 | import formstream from 'formstream';
6 | import { mm } from 'mm';
7 | import { app } from './form_app.js';
8 | import { getFixtures } from './helper.js';
9 |
10 | describe('test/multipart.test.ts', () => {
11 | let host: string;
12 | before(done => {
13 | const server = app.listen(0, () => {
14 | const addr = server.address();
15 | if (addr && typeof addr !== 'string') {
16 | host = `http://127.0.0.1:${addr.port}`;
17 | }
18 | done();
19 | });
20 | });
21 |
22 | beforeEach(() => {
23 | mm(process.env, 'NODE_ENV', '');
24 | });
25 |
26 | afterEach(() => {
27 | mm.restore();
28 | });
29 |
30 | it('should consume all request data after error throw', async () => {
31 | // retry 10 times
32 | for (let i = 0; i < 10; i++) {
33 | const form = formstream();
34 | form.file('file1', getFixtures('bigdata.txt'));
35 | form.field('foo', 'fengmk2')
36 | .field('love', 'koa')
37 | .field('index', `${i}`);
38 |
39 | const headers = form.headers();
40 | const result = await urllib.request(`${host}/upload`, {
41 | method: 'POST',
42 | headers,
43 | stream: form as unknown as Readable,
44 | timing: true,
45 | });
46 |
47 | const data = result.data;
48 | const response = result.res;
49 | assert.equal(response.status, 500);
50 | assert.match(data.toString(), /TypeError: Cannot read properties of undefined/);
51 | // wait for the request data is consumed by onerror
52 | await scheduler.wait(200);
53 | }
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/test/redirect.test.ts:
--------------------------------------------------------------------------------
1 | import koa from 'koa';
2 | import { request } from '@eggjs/supertest';
3 | import { onerror } from '../src/index.js';
4 | import { mm } from 'mm';
5 |
6 | describe('test/redirect.test.ts', () => {
7 | beforeEach(() => {
8 | mm(process.env, 'NODE_ENV', 'development');
9 | });
10 |
11 | afterEach(() => {
12 | mm.restore();
13 | });
14 |
15 | it('should handle error and redirect to real error page', async () => {
16 | const app = new koa();
17 | app.on('error', () => {});
18 | onerror(app, {
19 | redirect: 'http://example/500.html',
20 | });
21 | app.use(commonError);
22 |
23 | await request(app.callback())
24 | .get('/')
25 | .set('Accept', 'text/html')
26 | .expect('Content-Type', 'text/html; charset=utf-8')
27 | .expect('Redirecting to http://example/500.html.')
28 | .expect('Location', 'http://example/500.html');
29 | });
30 |
31 | it('should got text/plain header', async () => {
32 | const app = new koa();
33 | app.on('error', () => {});
34 | onerror(app, {
35 | redirect: 'http://example/500.html',
36 | });
37 | app.use(commonError);
38 |
39 | await request(app.callback())
40 | .get('/')
41 | .set('Accept', 'text/plain')
42 | .expect('Content-Type', 'text/plain; charset=utf-8')
43 | .expect('Redirecting to http://example/500.html.')
44 | .expect('Location', 'http://example/500.html');
45 | });
46 |
47 | it('should show json when accept is json', async () => {
48 | const app = new koa();
49 | app.on('error', () => {});
50 | onerror(app, {
51 | redirect: 'http://example/500.html',
52 | });
53 | app.use(commonError);
54 |
55 | await request(app.callback())
56 | .get('/')
57 | .set('Accept', 'application/json')
58 | .expect('Content-Type', 'application/json; charset=utf-8')
59 | .expect({ error: 'foo is not defined' });
60 | });
61 | });
62 |
63 | function commonError() {
64 | // eslint-disable-next-line
65 | // @ts-ignore - intentionally calling undefined function to trigger error
66 | foo();
67 | }
68 |
--------------------------------------------------------------------------------
/test/accepts.test.ts:
--------------------------------------------------------------------------------
1 | import Koa from 'koa';
2 | import { request } from '@eggjs/supertest';
3 | import { mm } from 'mm';
4 | import { onerror } from '../src/index.js';
5 |
6 | describe('test/accepts.test.ts', () => {
7 | beforeEach(() => {
8 | mm(process.env, 'NODE_ENV', '');
9 | });
10 |
11 | afterEach(() => {
12 | mm.restore();
13 | });
14 |
15 | it('should return json response', async () => {
16 | const app = new Koa();
17 | app.on('error', () => {});
18 | onerror(app, {
19 | accepts(this: { url: string }) {
20 | if (this.url.includes('.json')) {
21 | return 'json';
22 | }
23 | return 'text';
24 | },
25 | });
26 | app.use(commonError);
27 |
28 | await request(app.callback())
29 | .get('/user.json')
30 | .set('Accept', '*/*')
31 | .expect(500)
32 | .expect('Content-Type', 'application/json; charset=utf-8')
33 | .expect({ error: 'foo is not defined' });
34 |
35 | await request(app.callback())
36 | .get('/user')
37 | .set('Accept', 'application/json')
38 | .expect(500)
39 | .expect('Content-Type', 'text/plain; charset=utf-8')
40 | .expect('foo is not defined');
41 |
42 | // NODE_ENV=production
43 | mm(process.env, 'NODE_ENV', 'production');
44 | await request(app.callback())
45 | .get('/user')
46 | .set('Accept', 'application/json')
47 | .expect(500)
48 | .expect('Content-Type', 'text/plain; charset=utf-8')
49 | .expect('Internal Server Error');
50 | });
51 |
52 | it('should redirect when accepts type not json', async () => {
53 | const app = new Koa();
54 | app.on('error', () => {});
55 | onerror(app, {
56 | accepts(this: any) {
57 | if (this.url.indexOf('.json') > 0) {
58 | return 'json';
59 | }
60 | return 'text';
61 | },
62 | redirect: 'http://foo.com/500.html',
63 | });
64 | app.use(commonError);
65 |
66 | await request(app.callback())
67 | .get('/user')
68 | .set('Accept', '*/*')
69 | .expect('Content-Type', 'text/html; charset=utf-8')
70 | .expect('Location', 'http://foo.com/500.html')
71 | .expect('Redirecting to http://foo.com/500.html.')
72 | .expect(302);
73 | });
74 | });
75 |
76 | function commonError() {
77 | // eslint-disable-next-line
78 | // @ts-ignore - intentionally calling undefined function to trigger error
79 | foo();
80 | }
81 |
--------------------------------------------------------------------------------
/test/html.test.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs';
2 | import { scheduler } from 'node:timers/promises';
3 | import Koa from 'koa';
4 | import { request } from '@eggjs/supertest';
5 | import { mm } from 'mm';
6 | import { onerror } from '../src/index.js';
7 |
8 | describe('test/html.test.ts', () => {
9 | beforeEach(() => {
10 | mm(process.env, 'NODE_ENV', 'development');
11 | });
12 |
13 | afterEach(() => {
14 | mm.restore();
15 | });
16 |
17 | it('should common error ok', async () => {
18 | const app = new Koa();
19 | app.on('error', () => {});
20 | onerror(app);
21 | app.use(commonError);
22 |
23 | await request(app.callback())
24 | .get('/')
25 | .set('Accept', 'text/html')
26 | .expect(/Looks like something broke!<\/p>/);
27 | });
28 |
29 | it('should common error after sleep a little while ok', async () => {
30 | const app = new Koa();
31 | app.on('error', () => {});
32 | onerror(app);
33 | app.use(commonSleepError);
34 |
35 | await request(app.callback())
36 | .get('/')
37 | .set('Accept', 'text/html')
38 | .expect(/
Looks like something broke!<\/p>/);
39 | });
40 |
41 | it('should stream error ok', async () => {
42 | const app = new Koa();
43 | app.on('error', () => {});
44 | onerror(app);
45 | app.use(streamError);
46 |
47 | await request(app.callback())
48 | .get('/')
49 | .set('Accept', 'text/html')
50 | .expect(/
Looks like something broke!<\/p>/)
51 | .expect(/ENOENT/);
52 | });
53 |
54 | it('should unsafe error ok', async () => {
55 | const app = new Koa();
56 | app.on('error', () => {});
57 | onerror(app);
58 | app.use(unsafeError);
59 |
60 | await request(app.callback())
61 | .get('/')
62 | .set('Accept', 'text/html')
63 | .expect(/
Looks like something broke!<\/p>/)
64 | .expect(/<anonymous>/);
65 | });
66 | });
67 |
68 | function commonError() {
69 | // eslint-disable-next-line
70 | // @ts-ignore - intentionally calling undefined function to trigger error
71 | foo();
72 | }
73 |
74 | async function commonSleepError() {
75 | await scheduler.wait(50);
76 | // eslint-disable-next-line
77 | // @ts-ignore - intentionally calling undefined function to trigger error
78 | fooAfterSleep();
79 | }
80 |
81 | function streamError(ctx: Koa.Context) {
82 | ctx.body = fs.createReadStream('not exist');
83 | }
84 |
85 | function unsafeError() {
86 | throw new Error('');
87 | }
88 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "koa-onerror",
3 | "version": "5.0.1",
4 | "description": "koa error handler, hack ctx.onerror",
5 | "repository": {
6 | "type": "git",
7 | "url": "git://github.com/koajs/onerror.git"
8 | },
9 | "keywords": [
10 | "koa",
11 | "middleware",
12 | "error"
13 | ],
14 | "author": "dead_horse ",
15 | "license": "MIT",
16 | "bugs": {
17 | "url": "https://github.com/koajs/onerror/issues"
18 | },
19 | "homepage": "https://github.com/koajs/onerror",
20 | "engines": {
21 | "node": ">= 18.19.0"
22 | },
23 | "dependencies": {
24 | "escape-html": "^1.0.3",
25 | "stream-wormhole": "^2.0.1"
26 | },
27 | "devDependencies": {
28 | "@arethetypeswrong/cli": "^0.17.3",
29 | "@eggjs/bin": "7",
30 | "@eggjs/supertest": "^8.2.0",
31 | "@eggjs/tsconfig": "1",
32 | "@types/escape-html": "^1.0.4",
33 | "@types/koa": "^2.15.0",
34 | "@types/mocha": "10",
35 | "@types/node": "22",
36 | "co-busboy": "^1.4.0",
37 | "eslint": "8",
38 | "eslint-config-egg": "14",
39 | "formstream": "^1.5.1",
40 | "koa": "2",
41 | "mm": "^4.0.2",
42 | "rimraf": "6",
43 | "snap-shot-it": "^7.9.10",
44 | "tshy": "3",
45 | "tshy-after": "1",
46 | "typescript": "5",
47 | "urllib": "^4.6.11"
48 | },
49 | "scripts": {
50 | "lint": "eslint --cache src test --ext .ts",
51 | "pretest": "npm run clean && npm run lint -- --fix",
52 | "test": "egg-bin test",
53 | "test:snapshot:update": "SNAPSHOT_UPDATE=1 egg-bin test",
54 | "preci": "npm run clean && npm run lint",
55 | "ci": "egg-bin cov",
56 | "postci": "npm run prepublishOnly && npm run clean",
57 | "clean": "rimraf dist",
58 | "prepublishOnly": "tshy && tshy-after && attw --pack"
59 | },
60 | "type": "module",
61 | "tshy": {
62 | "exports": {
63 | ".": "./src/index.ts",
64 | "./package.json": "./package.json"
65 | }
66 | },
67 | "exports": {
68 | ".": {
69 | "import": {
70 | "types": "./dist/esm/index.d.ts",
71 | "default": "./dist/esm/index.js"
72 | },
73 | "require": {
74 | "types": "./dist/commonjs/index.d.ts",
75 | "default": "./dist/commonjs/index.js"
76 | }
77 | },
78 | "./package.json": "./package.json"
79 | },
80 | "files": [
81 | "dist",
82 | "src"
83 | ],
84 | "types": "./dist/commonjs/index.d.ts",
85 | "main": "./dist/commonjs/index.js",
86 | "module": "./dist/esm/index.js"
87 | }
88 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # koa-onerror
2 |
3 | [![NPM version][npm-image]][npm-url]
4 | [![Test coverage][codecov-image]][codecov-url]
5 | [![Known Vulnerabilities][snyk-image]][snyk-url]
6 | [![npm download][download-image]][download-url]
7 | [](https://nodejs.org/en/download/)
8 | [](https://makeapullrequest.com)
9 |
10 | [npm-image]: https://img.shields.io/npm/v/koa-onerror.svg?style=flat
11 | [npm-url]: https://npmjs.org/package/koa-onerror
12 | [codecov-image]: https://codecov.io/gh/koajs/onerror/branch/master/graph/badge.svg
13 | [codecov-url]: https://codecov.io/gh/koajs/onerror
14 | [snyk-image]: https://snyk.io/test/npm/koa-onerror/badge.svg?style=flat-square
15 | [snyk-url]: https://snyk.io/test/npm/koa-onerror
16 | [download-image]: https://img.shields.io/npm/dm/koa-onerror.svg?style=flat-square
17 | [download-url]: https://npmjs.org/package/koa-onerror
18 |
19 | an error handler for koa, hack ctx.onerror.
20 |
21 | different with [koa-error](https://github.com/koajs/error):
22 |
23 | - we can not just use try catch to handle all errors, steams' and events'
24 | errors are directly handle by `ctx.onerror`, so if we want to handle all
25 | errors in one place, the only way i can see is to hack `ctx.onerror`.
26 | - it is more customizable.
27 |
28 | ## install
29 |
30 | ```bash
31 | npm install koa-onerror
32 | ```
33 |
34 | ## Usage
35 |
36 | ```js
37 | const fs = require('fs');
38 | const Koa = require('koa');
39 | const { onerror } = require('koa-onerror');
40 |
41 | const app = new Koa();
42 |
43 | onerror(app);
44 |
45 | app.use(ctx => {
46 | // foo();
47 | ctx.body = fs.createReadStream('not exist');
48 | });
49 | ```
50 |
51 | ## Options
52 |
53 | ```js
54 | onerror(app, options);
55 | ```
56 |
57 | - **all**: if `options.all` exist, ignore negotiation
58 | - **text**: text error handler
59 | - **json**: json error handler
60 | - **html**: html error handler
61 | - **redirect**: if accept `html` or `text`, can redirect to another error page
62 |
63 | check out default handler to write your own handler.
64 |
65 | ## Status and Headers
66 |
67 | `koa-onerror` will automatic set `err.status` as response status code, and `err.headers` as response headers.
68 |
69 | ## License
70 |
71 | [MIT](LICENSE)
72 |
73 | ## Contributors
74 |
75 | [](https://github.com/koajs/onerror/graphs/contributors)
76 |
77 | Made with [contributors-img](https://contrib.rocks).
78 |
--------------------------------------------------------------------------------
/test/text.test.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs';
2 | import koa from 'koa';
3 | import { request } from '@eggjs/supertest';
4 | import { mm } from 'mm';
5 | import { onerror } from '../src/index.js';
6 | import { OnerrorError } from '../src/index.js';
7 |
8 | describe('test/text.test.ts', () => {
9 | beforeEach(() => {
10 | mm(process.env, 'NODE_ENV', 'development');
11 | });
12 |
13 | afterEach(() => {
14 | mm.restore();
15 | });
16 |
17 | it('should common error ok', async () => {
18 | const app = new koa();
19 | app.on('error', () => {});
20 | onerror(app);
21 | app.use(commonError);
22 |
23 | await request(app.callback())
24 | .get('/')
25 | .set('Accept', 'text/plain')
26 | .expect(500)
27 | .expect('foo is not defined');
28 | });
29 |
30 | it('should show error message ok', async () => {
31 | const app = new koa();
32 | app.on('error', () => {});
33 | onerror(app);
34 | app.use(exposeError);
35 |
36 | await request(app.callback())
37 | .get('/')
38 | .set('Accept', 'text/plain')
39 | .expect(500)
40 | .expect('this message will be expose');
41 | });
42 |
43 | it('should show status error when err.message not present', async () => {
44 | const app = new koa();
45 | app.on('error', () => {});
46 | onerror(app);
47 | app.use(emptyError);
48 |
49 | await request(app.callback())
50 | .get('/')
51 | .set('Accept', 'text/plain')
52 | .expect(500)
53 | .expect('Internal Server Error');
54 | });
55 |
56 | it('should set headers from error.headers ok', async () => {
57 | const app = new koa();
58 | app.on('error', () => {});
59 | onerror(app);
60 | app.use(headerError);
61 |
62 | await request(app.callback())
63 | .get('/')
64 | .set('Accept', 'text/plain')
65 | .expect(500)
66 | .expect('foo', 'bar');
67 | });
68 |
69 | it('should stream error ok', async () => {
70 | const app = new koa();
71 | app.on('error', () => {});
72 | onerror(app);
73 | app.use(streamError);
74 |
75 | await request(app.callback())
76 | .get('/')
77 | .set('Accept', 'text/plain')
78 | .expect(404)
79 | .expect(/ENOENT/);
80 | });
81 |
82 | it('should custom handler', async () => {
83 | const app = new koa();
84 | app.on('error', () => {});
85 | onerror(app, {
86 | text(this: any) {
87 | this.status = 500;
88 | this.body = 'error';
89 | },
90 | });
91 | app.use(commonError);
92 |
93 | await request(app.callback())
94 | .get('/')
95 | .set('Accept', 'text/plain')
96 | .expect(500)
97 | .expect('error');
98 | });
99 | });
100 |
101 | function exposeError() {
102 | const err = new Error('this message will be expose') as OnerrorError;
103 | err.expose = true;
104 | throw err;
105 | }
106 |
107 | function emptyError() {
108 | const err = new Error('') as OnerrorError;
109 | err.expose = true;
110 | throw err;
111 | }
112 |
113 | function commonError() {
114 | // eslint-disable-next-line
115 | // @ts-ignore - intentionally calling undefined function to trigger error
116 | foo();
117 | }
118 |
119 | function headerError() {
120 | const err = new Error('error with headers') as OnerrorError;
121 | err.headers = {
122 | foo: 'bar',
123 | };
124 | throw err;
125 | }
126 |
127 | function streamError(ctx: any) {
128 | ctx.body = fs.createReadStream('not exist');
129 | }
130 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## [5.0.1](https://github.com/koajs/onerror/compare/v5.0.0...v5.0.1) (2025-02-02)
4 |
5 |
6 | ### Bug Fixes
7 |
8 | * add js options ([#39](https://github.com/koajs/onerror/issues/39)) ([f38c05a](https://github.com/koajs/onerror/commit/f38c05a2b5ec7acdb2591d6d4717653cbdda3144))
9 |
10 | ## [5.0.0](https://github.com/koajs/onerror/compare/v4.2.0...v5.0.0) (2025-02-02)
11 |
12 |
13 | ### ⚠ BREAKING CHANGES
14 |
15 | * drop Node.js < 18.19.0 support
16 |
17 | part of https://github.com/eggjs/egg/issues/3644
18 |
19 | https://github.com/eggjs/egg/issues/5257
20 |
21 | ### Features
22 |
23 | * support cjs and esm both by tshy ([#38](https://github.com/koajs/onerror/issues/38)) ([8177649](https://github.com/koajs/onerror/commit/8177649a7f95795c02712748e6f19546eddf84ef))
24 |
25 | 4.2.0 / 2022-02-09
26 | ==================
27 |
28 | **features**
29 | * [[`9a899f5`](http://github.com/koajs/onerror/commit/9a899f58f65a2964cbf4f99793887623b4e01c5e)] - feat: stringify err object on non-error (#36) (弘树@阿里 <>)
30 |
31 | 4.1.0 / 2018-08-19
32 | ==================
33 |
34 | **fixes**
35 | * [[`d4291c2`](http://github.com/koajs/onerror/commit/d4291c29319dee23d745bb7f7a37ca1e86741691)] - fix: the req data should be consumed on error (#33) (fengmk2 <>)
36 |
37 | 4.0.1 / 2018-08-19
38 | ==================
39 |
40 | **fixes**
41 | * [[`46a79dd`](http://github.com/koajs/onerror/commit/46a79ddcf81434dd2974ed7906f67ca4674dbf52)] - fix: escape unsafe characters in html response (Simon Ratner <>)
42 |
43 | 4.0.0 / 2017-11-09
44 | ==================
45 |
46 | **others**
47 | * [[`df878e4`](http://github.com/koajs/onerror/commit/df878e4605c91aa55489a249c4093642f16ce96b)] - refactor: support koa 2 (#27) (Yiyu He <>)
48 |
49 | 3.1.0 / 2017-03-02
50 | ==================
51 |
52 | * feat: can reach err.headerSent in app error listener (#23)
53 | * feat: non-error wrapper support status and headers (#22)
54 |
55 | 3.0.2 / 2017-02-16
56 | ==================
57 |
58 | * fix: try to restore non Error instance properties (#20)
59 | * fix: change the koa-error url (#17)
60 |
61 | 3.0.1 / 2016-10-21
62 | ==================
63 |
64 | * fix: use absolute path (#16)
65 |
66 | 3.0.0 / 2016-10-21
67 | ==================
68 |
69 | * fix: Send default text/plain body if message is undefined
70 | * refactor: remove nunjucks
71 |
72 | 2.1.0 / 2016-10-19
73 | ==================
74 |
75 | * fix: don't throw when non-error object passed (#15)
76 | * Return reference to app (#7)
77 |
78 | 2.0.0 / 2016-07-04
79 | ==================
80 |
81 | * refactor: use nunjucks instead of swig
82 |
83 | 1.3.1 / 2016-03-21
84 | ==================
85 |
86 | * fix: only unset text headers
87 |
88 | 1.3.0 / 2016-03-10
89 | ==================
90 |
91 | * feat: support set err.headers
92 |
93 | 1.2.1 / 2015-05-13
94 | ==================
95 |
96 | * Merge pull request #5 from koajs/fix-test-iojs
97 | * fix: test run on iojs and upgrade copy-to
98 |
99 | 1.2.0 / 2014-08-08
100 | ==================
101 |
102 | * fix status in on error
103 |
104 | 1.1.0 / 2014-08-05
105 | ==================
106 |
107 | * fix link
108 | * Merge pull request #4 from koajs/custom-accepts
109 | * update links and add coveralls
110 | * Support options.accepts custom detect function
111 | * fix readme
112 |
113 | 1.0.3 / 2014-04-25
114 | ==================
115 |
116 | * Merge pull request #2 from koajs/redirect
117 | * Allow `options.redirect = 'http://example/500.html'`.
118 |
119 | 1.0.2 / 2014-04-25
120 | ==================
121 |
122 | * use path.join
123 |
124 | 1.0.1 / 2014-04-25
125 | ==================
126 |
127 | * add assert error type
128 | * update repo
129 |
130 | 1.0.0 / 2014-04-24
131 | ==================
132 |
133 | * refine readme, bump dependencies
134 | * fix status
135 |
136 | 0.0.2 / 2014-04-18
137 | ==================
138 |
139 | * all do not set type
140 |
141 | 0.0.1 / 2014-04-18
142 | ==================
143 |
144 | * add test
145 | * fix status
146 | * rename to koa-onerror
147 | * refactor
148 | * update readme
149 | * update readme
150 | * error handler by hack ctx.onerror
151 | * Initial commit
152 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import http from 'node:http';
2 | import path from 'node:path';
3 | import fs from 'node:fs';
4 | import { fileURLToPath } from 'node:url';
5 | import { debuglog, format } from 'node:util';
6 | import escapeHtml from 'escape-html';
7 | import { sendToWormhole } from 'stream-wormhole';
8 |
9 | const debug = debuglog('koa-onerror');
10 |
11 | export type OnerrorError = Error & {
12 | status: number;
13 | headers?: Record;
14 | expose?: boolean;
15 | };
16 |
17 | export type OnerrorHandler = (err: OnerrorError, ctx: any) => void;
18 |
19 | export type OnerrorOptions = {
20 | text?: OnerrorHandler;
21 | json?: OnerrorHandler;
22 | html?: OnerrorHandler;
23 | all?: OnerrorHandler;
24 | js?: OnerrorHandler;
25 | redirect?: string | null;
26 | accepts?: (...args: string[]) => string;
27 | };
28 |
29 | const defaultOptions: OnerrorOptions = {
30 | text,
31 | json,
32 | html,
33 | };
34 |
35 | export function onerror(app: any, options?: OnerrorOptions) {
36 | options = { ...defaultOptions, ...options };
37 |
38 | app.context.onerror = function(err: any) {
39 | debug('onerror: %s', err);
40 | // don't do anything if there is no error.
41 | // this allows you to pass `this.onerror`
42 | // to node-style callbacks.
43 | if (err == null) {
44 | return;
45 | }
46 |
47 | // ignore all padding request stream
48 | if (this.req) {
49 | sendToWormhole(this.req);
50 | debug('send the req to wormhole');
51 | }
52 |
53 | // wrap non-error object
54 | if (!(err instanceof Error)) {
55 | debug('err is not an instance of Error');
56 | let errMsg = err;
57 | if (typeof err === 'object') {
58 | try {
59 | errMsg = JSON.stringify(err);
60 | } catch (e) {
61 | debug('stringify error: %s', e);
62 | errMsg = format('%s', e);
63 | }
64 | }
65 | const newError = new Error('non-error thrown: ' + errMsg);
66 | // err maybe an object, try to copy the name, message and stack to the new error instance
67 | if (err) {
68 | if (err.name) newError.name = err.name;
69 | if (err.message) newError.message = err.message;
70 | if (err.stack) newError.stack = err.stack;
71 | if (err.status) {
72 | Reflect.set(newError, 'status', err.status);
73 | }
74 | if (err.headers) {
75 | Reflect.set(newError, 'headers', err.headers);
76 | }
77 | }
78 | err = newError;
79 | debug('wrap err: %s', err);
80 | }
81 |
82 | const headerSent = this.headerSent || !this.writable;
83 | if (headerSent) {
84 | debug('headerSent is true');
85 | err.headerSent = true;
86 | }
87 |
88 | // delegate
89 | this.app.emit('error', err, this);
90 |
91 | // nothing we can do here other
92 | // than delegate to the app-level
93 | // handler and log.
94 | if (headerSent) return;
95 |
96 | // ENOENT support
97 | if (err.code === 'ENOENT') {
98 | err.status = 404;
99 | }
100 |
101 | if (typeof err.status !== 'number' || !http.STATUS_CODES[err.status]) {
102 | err.status = 500;
103 | }
104 | this.status = err.status;
105 |
106 | this.set(err.headers);
107 | let type = 'text';
108 | if (options.accepts) {
109 | type = options.accepts.call(this, 'html', 'text', 'json');
110 | } else {
111 | type = this.accepts('html', 'text', 'json');
112 | }
113 | debug('accepts type: %s', type);
114 | type = type || 'text';
115 | if (options.all) {
116 | options.all.call(this, err, this);
117 | } else {
118 | if (options.redirect && type !== 'json') {
119 | this.redirect(options.redirect);
120 | } else {
121 | (options as any)[type].call(this, err, this);
122 | this.type = type;
123 | }
124 | }
125 |
126 | if (type === 'json') {
127 | this.body = JSON.stringify(this.body);
128 | }
129 | debug('end the response, body: %s', this.body);
130 | this.res.end(this.body);
131 | };
132 |
133 | return app;
134 | }
135 |
136 | const devTemplate = fs.readFileSync(path.join(getSourceDirname(), 'templates/dev_error.html'), 'utf8');
137 | const prodTemplate = fs.readFileSync(path.join(getSourceDirname(), 'templates/prod_error.html'), 'utf8');
138 |
139 | function isDev() {
140 | return !process.env.NODE_ENV || process.env.NODE_ENV === 'development';
141 | }
142 |
143 | /**
144 | * default text error handler
145 | */
146 | function text(err: OnerrorError, ctx: any) {
147 | // unset all headers, and set those specified
148 | ctx.res._headers = {};
149 | ctx.set(err.headers);
150 |
151 | ctx.body = (isDev() || err.expose) && err.message
152 | ? err.message
153 | : http.STATUS_CODES[ctx.status];
154 | }
155 |
156 | /**
157 | * default json error handler
158 | */
159 | function json(err: OnerrorError, ctx: any) {
160 | const message = (isDev() || err.expose) && err.message
161 | ? err.message
162 | : http.STATUS_CODES[ctx.status];
163 |
164 | ctx.body = { error: message };
165 | }
166 |
167 | /**
168 | * default html error handler
169 | */
170 | function html(err: OnerrorError, ctx: any) {
171 | const template = isDev() ? devTemplate : prodTemplate;
172 | ctx.body = template
173 | .replace('{{status}}', escapeHtml(String(err.status)))
174 | .replace('{{stack}}', escapeHtml(err.stack));
175 | ctx.type = 'html';
176 | }
177 |
178 | function getSourceDirname() {
179 | if (typeof __dirname === 'string') {
180 | return __dirname;
181 | }
182 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
183 | // @ts-ignore
184 | const __filename = fileURLToPath(import.meta.url);
185 | return path.dirname(__filename);
186 | }
187 |
--------------------------------------------------------------------------------
/test/json.test.ts:
--------------------------------------------------------------------------------
1 | import { strict as assert } from 'node:assert';
2 | import { once } from 'node:events';
3 | import fs from 'node:fs';
4 | import Koa, { Context } from 'koa';
5 | import { request } from '@eggjs/supertest';
6 | import { mm } from 'mm';
7 | import { onerror, OnerrorError } from '../src/index.js';
8 |
9 | describe('test/json.test.ts', () => {
10 | beforeEach(() => {
11 | mm(process.env, 'NODE_ENV', 'development');
12 | });
13 |
14 | afterEach(() => {
15 | mm.restore();
16 | });
17 |
18 | it('should common error ok', async () => {
19 | const app = new Koa();
20 | app.on('error', () => {});
21 | onerror(app);
22 | app.use(commonError);
23 |
24 | await request(app.callback())
25 | .get('/')
26 | .set('Accept', 'application/json')
27 | .expect(500)
28 | .expect({ error: 'foo is not defined' });
29 | });
30 |
31 | it('should work on jsonp', async () => {
32 | const app = new Koa();
33 | app.on('error', () => {});
34 | onerror(app, {
35 | accepts(this: Context) {
36 | return 'js';
37 | },
38 | js(err: OnerrorError, ctx: Context) {
39 | ctx.body = `callback(${JSON.stringify({ error: err.message })})`;
40 | },
41 | });
42 | app.use(commonError);
43 |
44 | await request(app.callback())
45 | .get('/')
46 | .set('Accept', 'application/javascript')
47 | .expect('Content-Type', 'application/javascript; charset=utf-8')
48 | .expect(500)
49 | .expect('callback({"error":"foo is not defined"})');
50 | });
51 |
52 | it('should stream error ok', async () => {
53 | const app = new Koa();
54 | app.on('error', () => {});
55 | onerror(app);
56 | app.use(streamError);
57 |
58 | const res = await request(app.callback())
59 | .get('/')
60 | .set('Accept', 'application/json')
61 | .expect(404);
62 | assert.equal(typeof res.body.error, 'string');
63 | assert.match(res.body.error, /ENOENT/);
64 | });
65 |
66 | it('should custom handler', async () => {
67 | const app = new Koa();
68 | app.on('error', () => {});
69 | onerror(app, {
70 | json(this: Context) {
71 | this.status = 500;
72 | this.body = {
73 | message: 'error',
74 | };
75 | },
76 | });
77 | app.use(commonError);
78 |
79 | await request(app.callback())
80 | .get('/')
81 | .set('Accept', 'application/json')
82 | .expect(500)
83 | .expect({ message: 'error' });
84 | });
85 |
86 | it('should show status error when err.message not present', async () => {
87 | const app = new Koa();
88 | app.on('error', () => {});
89 | onerror(app);
90 | app.use(emptyError);
91 |
92 | await request(app.callback())
93 | .get('/')
94 | .set('Accept', 'application/json')
95 | .expect(500)
96 | .expect({ error: 'Internal Server Error' });
97 | });
98 |
99 | it('should wrap non-error primitive value', async () => {
100 | const app = new Koa();
101 | app.on('error', () => {});
102 | onerror(app);
103 | app.use(() => {
104 | throw 1;
105 | });
106 |
107 | await request(app.callback())
108 | .get('/')
109 | .set('Accept', 'application/json')
110 | .expect(500)
111 | .expect({ error: 'non-error thrown: 1' });
112 | });
113 |
114 | it('should wrap non-error object and stringify it', async () => {
115 | const app = new Koa();
116 | app.on('error', () => {});
117 | onerror(app);
118 | app.use(() => {
119 | throw { error: true };
120 | });
121 |
122 | await request(app.callback())
123 | .get('/')
124 | .set('Accept', 'application/json')
125 | .expect(500)
126 | .expect({ error: 'non-error thrown: {"error":true}' });
127 | });
128 |
129 | it('should wrap mock error obj instead of Error instance', async () => {
130 | const app = new Koa();
131 | onerror(app);
132 | app.use(() => {
133 | const err = {
134 | name: 'TypeError',
135 | message: 'mock error',
136 | stack: new Error().stack,
137 | status: 404,
138 | headers: { foo: 'bar' },
139 | };
140 | throw err;
141 | });
142 |
143 | const errorEvent = once(app, 'error');
144 |
145 | await request(app.callback())
146 | .get('/')
147 | .set('Accept', 'application/json')
148 | .expect(404)
149 | .expect('foo', 'bar')
150 | .expect({ error: 'mock error' });
151 |
152 | const [ err ] = await errorEvent;
153 | assert(err instanceof Error);
154 | assert.equal(err.name, 'TypeError');
155 | assert.equal(err.message, 'mock error');
156 | assert.match(err.stack!, /json\.test\./);
157 | });
158 |
159 | it('should custom handler with ctx', async () => {
160 | const app = new Koa();
161 | app.on('error', () => {});
162 | onerror(app, {
163 | json: (_err, ctx) => {
164 | ctx.status = 500;
165 | ctx.body = {
166 | message: 'error',
167 | };
168 | },
169 | });
170 | app.use(commonError);
171 |
172 | await request(app.callback())
173 | .get('/')
174 | .set('Accept', 'application/json')
175 | .expect(500)
176 | .expect({ message: 'error' });
177 | });
178 |
179 | it('should get headerSent in error listener', async () => {
180 | const app = new Koa();
181 | onerror(app, {
182 | json: (_err: OnerrorError, ctx: Context) => {
183 | ctx.status = 500;
184 | ctx.body = {
185 | message: 'error',
186 | };
187 | },
188 | });
189 |
190 | app.use(ctx => {
191 | ctx.res.flushHeaders();
192 | throw new Error('mock error');
193 | });
194 |
195 | const errorEvent = once(app, 'error');
196 |
197 | request(app.callback())
198 | .get('/')
199 | .set('Accept', 'application/json')
200 | .expect(500)
201 | .expect({ message: 'error' })
202 | .send()
203 | .catch(err => {
204 | assert(err instanceof Error);
205 | assert.equal((err as any).headerSent, true);
206 | });
207 |
208 | const [ err ] = await errorEvent;
209 | assert(err instanceof Error);
210 | assert.equal((err as any).headerSent, true);
211 | });
212 | });
213 |
214 | function emptyError() {
215 | const err = new Error('') as OnerrorError;
216 | err.expose = true;
217 | throw err;
218 | }
219 |
220 | function commonError() {
221 | // eslint-disable-next-line
222 | // @ts-ignore foo is not defined
223 | foo();
224 | }
225 |
226 | function streamError(ctx: Context) {
227 | ctx.body = fs.createReadStream('not exist');
228 | }
229 |
--------------------------------------------------------------------------------