diff --git a/plugins/onerror/package.json b/plugins/onerror/package.json index e21b75a202..d7b7792fd9 100644 --- a/plugins/onerror/package.json +++ b/plugins/onerror/package.json @@ -52,7 +52,6 @@ }, "dependencies": { "cookie": "catalog:", - "koa-onerror": "catalog:", "mustache": "catalog:", "stack-trace": "catalog:" }, diff --git a/plugins/onerror/src/app.ts b/plugins/onerror/src/app.ts index 0c9a56763b..dcb471fe4a 100644 --- a/plugins/onerror/src/app.ts +++ b/plugins/onerror/src/app.ts @@ -2,10 +2,10 @@ import fs from 'node:fs'; import http from 'node:http'; import type { ILifecycleBoot, Application, Context } from 'egg'; -import { onerror, type OnerrorOptions, type OnerrorError } from 'koa-onerror'; import type { OnerrorConfig } from './config/config.default.ts'; import { ErrorView } from './lib/error_view.ts'; +import { onerror, type OnerrorOptions, type OnerrorError } from './lib/onerror.ts'; import { isProd, detectStatus, detectErrorMessage, accepts } from './lib/utils.ts'; export interface OnerrorErrorWithCode extends OnerrorError { diff --git a/plugins/onerror/src/config/config.default.ts b/plugins/onerror/src/config/config.default.ts index dc7a5e5ee5..57ab5b99b6 100644 --- a/plugins/onerror/src/config/config.default.ts +++ b/plugins/onerror/src/config/config.default.ts @@ -1,5 +1,6 @@ import type { Context } from 'egg'; -import type { OnerrorError, OnerrorOptions } from 'koa-onerror'; + +import type { OnerrorError, OnerrorOptions } from '../lib/onerror.ts'; export interface OnerrorConfig extends OnerrorOptions { /** diff --git a/plugins/onerror/src/lib/error_view.ts b/plugins/onerror/src/lib/error_view.ts index 9fa89f68ef..98d5e0033d 100644 --- a/plugins/onerror/src/lib/error_view.ts +++ b/plugins/onerror/src/lib/error_view.ts @@ -6,10 +6,10 @@ import util from 'node:util'; import { parse } from 'cookie'; import type { Context } from 'egg'; -import type { OnerrorError } from 'koa-onerror'; import Mustache from 'mustache'; import stackTrace, { type StackFrame } from 'stack-trace'; +import type { OnerrorError } from './onerror.ts'; import { detectErrorMessage } from './utils.ts'; const startingSlashRegex = /\\|\//; diff --git a/plugins/onerror/src/lib/onerror.ts b/plugins/onerror/src/lib/onerror.ts new file mode 100644 index 0000000000..3d11dcd933 --- /dev/null +++ b/plugins/onerror/src/lib/onerror.ts @@ -0,0 +1,163 @@ +import http from 'node:http'; +import { debuglog, inspect } from 'node:util'; + +const debug = debuglog('egg-onerror'); + +export type OnerrorError = Error & { + status: number; + code?: string; + headers?: Record; + expose?: boolean; +}; + +export type OnerrorHandler = (err: OnerrorError, ctx: any) => void; + +export interface OnerrorOptions { + text?: OnerrorHandler; + json?: OnerrorHandler; + html?: OnerrorHandler; + all?: OnerrorHandler; + js?: OnerrorHandler; + redirect?: string | null; + accepts?: (...args: string[]) => string; +} + +const defaultOptions: OnerrorOptions = { + text, + json, + html, +}; + +export function onerror(app: any, options?: OnerrorOptions): any { + options = { ...defaultOptions, ...options }; + + app.context.onerror = function (err: any) { + debug('onerror: %s', err); + if (err == null) return; + + if (typeof this.req?.resume === 'function') { + this.req.resume(); + debug('resume the req stream'); + } + + if (!(err instanceof Error)) { + debug('err is not an instance of Error'); + let errMsg = err; + if (typeof err === 'object') { + try { + errMsg = JSON.stringify(err); + } catch (e) { + debug('stringify error: %s', e); + errMsg = inspect(err); + } + } + const newError = new Error('non-error thrown: ' + errMsg); + if (err) { + if (err.name) newError.name = err.name; + if (err.message) newError.message = err.message; + if (err.stack) newError.stack = err.stack; + if (err.status) Reflect.set(newError, 'status', err.status); + if (err.headers) Reflect.set(newError, 'headers', err.headers); + } + err = newError; + debug('wrap err: %s', err); + } + + const headerSent = this.headerSent || !this.writable; + if (headerSent) { + debug('headerSent is true'); + err.headerSent = true; + } + + this.app.emit('error', err, this); + if (headerSent) return; + + if (err.code === 'ENOENT') { + err.status = 404; + } + if (typeof err.status !== 'number' || !http.STATUS_CODES[err.status]) { + err.status = 500; + } + this.status = err.status; + + clearResponseHeaders(this); + if (err.headers) { + this.set(err.headers); + } + let type: string; + if (options.accepts) { + type = options.accepts.call(this, 'html', 'text', 'json', 'js'); + } else { + type = this.accepts('html', 'text', 'json', 'js'); + } + debug('accepts type: %s', type); + type = type || 'text'; + if (options.all) { + options.all.call(this, err, this); + } else if (options.redirect && type !== 'json') { + this.redirect(options.redirect); + } else { + const handler = getHandler(options, type); + handler?.call(this, err, this); + this.type = type; + } + + if (type === 'json' && typeof this.body !== 'string') { + this.body = JSON.stringify(this.body); + } + debug('end the response, body: %s', this.body); + this.res.end(this.body); + }; + + return app; +} + +function getHandler(options: OnerrorOptions, type: string): OnerrorHandler | undefined { + if (type === 'html' || type === 'text' || type === 'json' || type === 'js') { + return options[type]; + } +} + +function isDev(): boolean { + return !process.env.NODE_ENV || process.env.NODE_ENV === 'development'; +} + +function text(err: OnerrorError, ctx: any): void { + ctx.body = (isDev() || err.expose) && err.message ? err.message : http.STATUS_CODES[ctx.status]; +} + +function json(err: OnerrorError, ctx: any): void { + const message = (isDev() || err.expose) && err.message ? err.message : http.STATUS_CODES[ctx.status]; + ctx.body = { error: message }; +} + +function html(err: OnerrorError, ctx: any): void { + const message = (isDev() || err.expose) && err.message ? err.message : http.STATUS_CODES[ctx.status]; + ctx.body = `

${escapeHtml(String(err.status))} ${escapeHtml(String(message))}

`; + ctx.type = 'html'; +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => { + switch (char) { + case '&': + return '&'; + case '<': + return '<'; + case '>': + return '>'; + case '"': + return '"'; + default: + return '''; + } + }); +} + +function clearResponseHeaders(ctx: any): void { + const headers = ctx.response?.header ?? ctx.response?.headers ?? ctx.res.getHeaders?.() ?? {}; + for (const name of Object.keys(headers)) { + if (name.toLowerCase() === 'set-cookie') continue; + ctx.res.removeHeader(name); + } +} diff --git a/plugins/onerror/src/lib/utils.ts b/plugins/onerror/src/lib/utils.ts index a821e42df3..a44ddb5070 100644 --- a/plugins/onerror/src/lib/utils.ts +++ b/plugins/onerror/src/lib/utils.ts @@ -1,5 +1,6 @@ import type { Context, Application } from 'egg'; -import type { OnerrorError } from 'koa-onerror'; + +import type { OnerrorError } from './onerror.ts'; export function detectErrorMessage(ctx: Context, err: OnerrorError): string { // detect json parse error diff --git a/plugins/onerror/test/onerror.test.ts b/plugins/onerror/test/onerror.test.ts index 568263fc86..a47601a2c1 100644 --- a/plugins/onerror/test/onerror.test.ts +++ b/plugins/onerror/test/onerror.test.ts @@ -1,7 +1,7 @@ import { strict as assert } from 'node:assert'; import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { mm, type MockApplication } from '@eggjs/mock'; import { type Context } from 'egg'; @@ -42,7 +42,7 @@ describe('test/onerror.test.ts', () => { app.emit('error', err, null); (err as any).status = 400; app.emit('error', err, null); - app.close(); + await app.close(); }); it('should handle status:-1 as status:500', async () => { @@ -585,4 +585,21 @@ describe('test/onerror.test.ts', () => { .expect(500); }); }); + + it('should not read koa-onerror package templates when importing the plugin app boot hook', async () => { + const originalReadFileSync = fs.readFileSync; + const blockedReads: string[] = []; + mm(fs, 'readFileSync', ((file: fs.PathOrFileDescriptor, ...args: any[]) => { + const filename = file instanceof URL ? file.href : String(file); + if (filename.includes('koa-onerror') && filename.includes('templates')) { + blockedReads.push(filename); + throw new Error(`unexpected koa-onerror template read: ${filename}`); + } + return originalReadFileSync.call(fs, file as any, ...args); + }) as typeof fs.readFileSync); + + const appBootHookUrl = pathToFileURL(path.join(__dirname, '../src/app.ts')).href; + await import(/* @vite-ignore */ `${appBootHookUrl}?bundle-static-resource=${Date.now()}`); + assert.deepEqual(blockedReads, []); + }); }); diff --git a/plugins/onerror/test/onerror_lib.test.ts b/plugins/onerror/test/onerror_lib.test.ts new file mode 100644 index 0000000000..586b690775 --- /dev/null +++ b/plugins/onerror/test/onerror_lib.test.ts @@ -0,0 +1,264 @@ +import { strict as assert } from 'node:assert'; + +import { afterEach, describe, it } from 'vitest'; + +import { onerror, type OnerrorError, type OnerrorOptions } from '../src/lib/onerror.ts'; + +interface TestContext { + app: TestApp; + req: { resumed: boolean; resume: () => void }; + res: { end: (body: unknown) => void; removeHeader: (name: string) => void; getHeaders: () => Record }; + response: { header: Record }; + writable: boolean; + headerSent: boolean; + status: number; + type?: string; + body?: unknown; + endedBody?: unknown; + removedHeaders: string[]; + setCalls: unknown[]; + acceptArgs?: string[]; + accepts: (...args: string[]) => string; + set: (headers: unknown) => void; + redirect: (url: string) => void; + redirectedTo?: string; +} + +interface TestApp { + context: { onerror?: (this: TestContext, err: unknown) => void }; + emitted: unknown[][]; + emit: (...args: unknown[]) => void; +} + +function createApp(options?: OnerrorOptions): TestApp { + const app: TestApp = { + context: {}, + emitted: [], + emit(...args: unknown[]) { + this.emitted.push(args); + }, + }; + onerror(app, options); + return app; +} + +function createContext(app: TestApp, type: string): TestContext { + const headers: Record = { 'x-old': '1' }; + const ctx = { + app, + req: { + resumed: false, + resume() { + this.resumed = true; + }, + }, + res: { + end(body: unknown) { + ctx.endedBody = body; + }, + removeHeader(name: string) { + ctx.removedHeaders.push(name); + delete headers[name]; + }, + getHeaders() { + return headers; + }, + }, + response: { header: headers }, + writable: true, + headerSent: false, + status: 200, + removedHeaders: [] as string[], + setCalls: [] as unknown[], + accepts(...args: string[]) { + ctx.acceptArgs = args; + return type; + }, + set(value: unknown) { + ctx.setCalls.push(value); + }, + redirect(url: string) { + ctx.redirectedTo = url; + }, + } as TestContext; + return ctx; +} + +function callOnerror(app: TestApp, ctx: TestContext, err: unknown): void { + assert(app.context.onerror); + app.context.onerror.call(ctx, err); +} + +function makeError(status: number, message = 'boom', extra?: Partial): OnerrorError { + return Object.assign(new Error(message), { status }, extra) as OnerrorError; +} + +describe('lib/onerror.ts', () => { + const originalNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + it('drains the request, emits the error, clears text headers, and reapplies error headers', () => { + const app = createApp(); + const ctx = createContext(app, 'text'); + const err = makeError(418, 'teapot', { expose: true, headers: { 'x-new': '2' } }); + + callOnerror(app, ctx, err); + + assert.equal(ctx.req.resumed, true); + assert.equal(ctx.status, 418); + assert.equal(ctx.body, 'teapot'); + assert.equal(ctx.endedBody, 'teapot'); + assert.equal(ctx.type, 'text'); + assert.deepEqual(ctx.removedHeaders, ['x-old']); + assert.deepEqual(ctx.setCalls, [{ 'x-new': '2' }]); + assert.deepEqual(ctx.acceptArgs, ['html', 'text', 'json', 'js']); + assert.equal(app.emitted[0][0], 'error'); + assert.equal(app.emitted[0][1], err); + }); + + it('does not pass undefined headers into ctx.set', () => { + const app = createApp(); + const ctx = createContext(app, 'json'); + ctx.response.header['set-cookie'] = 'csrf=token'; + + callOnerror(app, ctx, makeError(500, 'boom', { expose: true })); + + assert.deepEqual(ctx.removedHeaders, ['x-old']); + assert.deepEqual(ctx.setCalls, []); + assert.equal(ctx.body, '{"error":"boom"}'); + assert.equal(ctx.endedBody, '{"error":"boom"}'); + }); + + it('escapes default html responses', () => { + const app = createApp(); + const ctx = createContext(app, 'html'); + + callOnerror(app, ctx, makeError(400, `&<>"'`, { expose: true })); + + assert.equal(ctx.type, 'html'); + assert.equal(ctx.body, '

400 &<>"'

'); + assert.equal(ctx.endedBody, '

400 &<>"'

'); + }); + + it('uses generic status text for non-exposed production errors', () => { + process.env.NODE_ENV = 'production'; + const app = createApp(); + + const textCtx = createContext(app, 'text'); + callOnerror(app, textCtx, makeError(503, 'internal detail')); + assert.equal(textCtx.body, 'Service Unavailable'); + assert.equal(textCtx.endedBody, 'Service Unavailable'); + + const jsonCtx = createContext(app, 'json'); + callOnerror(app, jsonCtx, makeError(503, 'internal detail')); + assert.equal(jsonCtx.body, '{"error":"Service Unavailable"}'); + assert.equal(jsonCtx.endedBody, '{"error":"Service Unavailable"}'); + + const htmlCtx = createContext(app, 'html'); + callOnerror(app, htmlCtx, makeError(503, 'internal detail')); + assert.equal(htmlCtx.body, '

503 Service Unavailable

'); + assert.equal(htmlCtx.endedBody, '

503 Service Unavailable

'); + }); + + it('does not double stringify custom json string bodies', () => { + const app = createApp({ + json(_err, ctx) { + ctx.body = '{"ok":true}'; + }, + }); + const ctx = createContext(app, 'json'); + + callOnerror(app, ctx, makeError(500)); + + assert.equal(ctx.body, '{"ok":true}'); + assert.equal(ctx.endedBody, '{"ok":true}'); + }); + + it('selects js handlers through default negotiation', () => { + const app = createApp({ + js(err, ctx) { + ctx.body = `jsonp:${err.message}`; + }, + }); + const ctx = createContext(app, 'js'); + + callOnerror(app, ctx, makeError(500, 'boom', { expose: true })); + + assert.deepEqual(ctx.acceptArgs, ['html', 'text', 'json', 'js']); + assert.equal(ctx.type, 'js'); + assert.equal(ctx.body, 'jsonp:boom'); + assert.equal(ctx.endedBody, 'jsonp:boom'); + }); + + it('wraps non-error throws and normalizes invalid status to 500', () => { + const app = createApp(); + const ctx = createContext(app, 'json'); + + callOnerror(app, ctx, { message: 'bad', status: 1 }); + + assert.equal(ctx.status, 500); + assert(app.emitted[0][1] instanceof Error); + assert.equal((app.emitted[0][1] as Error).message, 'bad'); + }); + + it('formats circular non-error throws when JSON.stringify fails', () => { + const app = createApp(); + const ctx = createContext(app, 'json'); + const circular: Record = { status: 400 }; + circular.self = circular; + + callOnerror(app, ctx, circular); + + assert(app.emitted[0][1] instanceof Error); + assert.match((app.emitted[0][1] as Error).message, /\[Circular/); + }); + + it('supports custom accepts and all handlers', () => { + let acceptArgs: string[] = []; + const app = createApp({ + accepts(...args: string[]) { + acceptArgs = args; + return 'html'; + }, + all(err, ctx) { + ctx.body = `all:${err.status}`; + }, + }); + const ctx = createContext(app, 'json'); + const err = makeError(451, 'blocked', { headers: { 'x-reason': 'legal' } }); + + callOnerror(app, ctx, err); + + assert.equal(ctx.body, 'all:451'); + assert.equal(ctx.endedBody, 'all:451'); + assert.deepEqual(ctx.setCalls, [{ 'x-reason': 'legal' }]); + assert.deepEqual(ctx.removedHeaders, ['x-old']); + assert.deepEqual(acceptArgs, ['html', 'text', 'json', 'js']); + }); + + it('redirects non-json responses when configured', () => { + const app = createApp({ redirect: '/error-page' }); + const ctx = createContext(app, 'html'); + + callOnerror(app, ctx, makeError(500)); + + assert.equal(ctx.redirectedTo, '/error-page'); + assert.equal(ctx.endedBody, undefined); + }); + + it('only emits when headers were already sent', () => { + const app = createApp(); + const ctx = createContext(app, 'text'); + ctx.headerSent = true; + const err = makeError(500); + + callOnerror(app, ctx, err); + + assert.equal((err as OnerrorError & { headerSent?: boolean }).headerSent, true); + assert.equal(app.emitted.length, 1); + assert.equal(ctx.endedBody, undefined); + }); +});