├── .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 |
16 | 21 |
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 | [![Node.js Version](https://img.shields.io/node/v/koa-onerror.svg?style=flat)](https://nodejs.org/en/download/) 8 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](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 | [![Contributors](https://contrib.rocks/image?repo=koajs/onerror)](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 | --------------------------------------------------------------------------------