From 8026f2a6af888262f8f0c4aa85b0cd7e44e50f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Tue, 2 Jul 2019 01:01:36 +0200 Subject: [PATCH] feat(web): Added support for running it in a browser --- .gitignore | 3 + README.md | 88 ++++++++++++++- index.d.ts | 1 - index.js | 2 - index.ts | 2 + jest.config.js | 5 +- lib/core.ts | 125 ++++++++++++++++++++++ lib/register.ts | 9 ++ package.json | 19 +++- register.js | 102 +----------------- index.spec.js => test/index.spec.js | 6 +- register.spec.js => test/register.spec.js | 2 +- tsconfig.json | 26 +++++ tsconfig.rollup.json | 11 ++ web/register-web.ts | 9 ++ 15 files changed, 293 insertions(+), 117 deletions(-) delete mode 100644 index.d.ts delete mode 100644 index.js create mode 100644 index.ts create mode 100644 lib/core.ts create mode 100644 lib/register.ts rename index.spec.js => test/index.spec.js (69%) rename register.spec.js => test/register.spec.js (99%) create mode 100644 tsconfig.json create mode 100644 tsconfig.rollup.json create mode 100644 web/register-web.ts diff --git a/.gitignore b/.gitignore index 331e343..4ce4aa5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ coverage/ node_modules/ +dist/ +dist-web/ yarn.lock +browser.js diff --git a/README.md b/README.md index 00cd2ab..e8a2d43 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,90 @@ Node.js warns on unhandled promise rejections. You might have seen: (node:1234) UnhandledPromiseRejectionWarning ``` -When this happens, it's not always obvious what promise is unhandled. The error displayed in the stack trace is the trace to the *error object construction*, not the construction of the promise which left it dangling. It might have travelled through various asynchronous chains before it got to an unhandled promise chain. +When this happens, it's not always obvious what promise is unhandled. The error displayed in the stacktrace is the trace to the *error object construction*, not the construction of the promise which left it dangling. It might have travelled through various asynchronous chains before it got to an unhandled promise chain. `trace-unhandled` changes this. It keeps track of promises and when an *unhandled promise rejection* is logged, the location of both the error object **and** the promise is logged. This makes it a lot easier to find the bug. **This package is not intended to be used in production, only to aid locating bugs** +# Why + +Consider the following code which creates an error (on line 1) and rejects a promise (on line 3) and "forgets" to catch it on line 9 (the last line). This is an **incredibly** simple example, and in real life, this would span over a lot of files and a lot of complexity. + +```ts +const err = new Error( "foo" ); +function b( ) { + return Promise.reject( err ); +} +function a( ) { + return b( ); +} +const foo = a( ); +foo.then( ( ) => { } ); +``` + +Without `trace-unhandled`, you would get something like: + +``` +(node:1234) UnhandledPromiseRejectionWarning: Error: foo + at Object. (/my/directory/test.js:1:13) + at Module._compile (internal/modules/cjs/loader.js:776:30) + at Object.Module._extensions..js (internal/modules/cjs/loader.js:787:10) + at Module.load (internal/modules/cjs/loader.js:643:32) + at Function.Module._load (internal/modules/cjs/loader.js:556:12) + at Function.Module.runMain (internal/modules/cjs/loader.js:839:10) + at internal/main/run_main_module.js:17:11 +``` + +This is the output of Node.js. You'll see the stacktrace up to the point of the **Error** `err`, but that's rather irrelevant. What you want to know is where the promise was used leaving a rejection unhandled (i.e. a missing `catch()`). With `trace-unhandled` this is exactly what you get, including the Error construction location: + +``` +(node:1234) UnhandledPromiseRejectionWarning +[ Stacktrace altered by https://github.com/grantila/trace-unhandled ] +Error: foo + ==== Promise at: ================== + at Promise.then () + at Object. (/my/directory/test.js:9:5) + + ==== Error at: ==================== + at Object. (/my/directory/test.js:1:13) + + ==== Shared trace: ================ + at Module._compile (internal/modules/cjs/loader.js:776:30) + ... more lines below ... +``` + +We *"used"* the promise by appending another `.then()` to it. This means that the promise was actually used, and that the new promise should handle rejections. If we delete the last line (line 9), we see where the promise was last *"used"*: + +``` +(node:1234) UnhandledPromiseRejectionWarning +[ Stacktrace altered by https://github.com/grantila/trace-unhandled ] +Error: foo + ==== Promise at: ================== + at b (/my/directory/test.js:3:17) + at a (/my/directory/test.js:6:9) + at Object. (/my/directory/test.js:8:13) + + ==== Error at: ==================== + at Object. (/my/directory/test.js:1:13) + + ==== Shared trace: ================ + at Module._compile (internal/modules/cjs/loader.js:776:30) + ... more lines below ... +``` + +Both these examples show **clearly** where the *promise* is left unhandled, and not only where the Error object is constructed. + + # Usage +`trace-unhandled` can be used in 4 ways. + + * As a standalone program to bootstrap a Node.js app + * From a CDN directly to a browser + * Programmatically from JavaScript (either for Node.js or the web using a bundler) + * In unit tests + ## As a standalone program `trace-unhandled` exports a program which can run JavaScript files and shebang scripts. Instead of running your program as `node index.js` you can do `trace-unhandled index.js` as long as `trace-unhandled` is globally installed. @@ -30,6 +106,13 @@ You can also use `npx`: `npx trace-unhandled index.js` +# In a website + +```html + +``` + + ## Programatically - API ```ts @@ -45,6 +128,7 @@ const { register } = require( 'trace-unhandled' ); register( ); ``` + ## Use in unit tests To use this package when running `jest`, install the package and configure jest with the following setup: @@ -57,7 +141,7 @@ To use this package when running `jest`, install the package and configure jest } ``` -The tests will now log much better information about unhandled promise rejections. +For `mocha` you can use `--require node_modules/trace-unhandled/register.js`. [npm-image]: https://img.shields.io/npm/v/trace-unhandled.svg diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index 887ed7e..0000000 --- a/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -export function register( ): void; diff --git a/index.js b/index.js deleted file mode 100644 index 6f79771..0000000 --- a/index.js +++ /dev/null @@ -1,2 +0,0 @@ - -exports.register = ( ) => require( './register' ); diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..72135ac --- /dev/null +++ b/index.ts @@ -0,0 +1,2 @@ + +export const register = ( ) => require( './lib/register' ); diff --git a/jest.config.js b/jest.config.js index 4bb9505..d47197d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,5 @@ module.exports = { - testEnvironment: 'node', - coverageReporters: ['lcov', 'text', 'html'], + testEnvironment: "node", + testMatch: ['/test/**/*.spec.js'], + coverageReporters: ["lcov", "text", "html"], }; diff --git a/lib/core.ts b/lib/core.ts new file mode 100644 index 0000000..6ea49d7 --- /dev/null +++ b/lib/core.ts @@ -0,0 +1,125 @@ + +Error.stackTraceLimit = Math.max( 100, Error.stackTraceLimit || 0 ); + +const reStackEntry = /\s+at\s/; + +function splitErrorStack( error?: Error ) +{ + if ( !error ) + return { message: "Unknown error", lines: [ ] }; + + if ( !error.stack ) + return { message: error.message, lines: [ ] }; + + const lines = error.stack.split( "\n" ); + const index = lines.findIndex( line => reStackEntry.test( line ) ); + const message = lines.slice( 0, index ).join( "\n" ); + return { message, lines: lines.slice( index ) }; +} + +function mergeErrors( traceError: Error, mainError?: Error ) +{ + const { lines: traceLines } = splitErrorStack( traceError ); + const { lines: errorLines, message } = splitErrorStack( mainError ) + + if ( traceLines[ 0 ].includes( "at new TraceablePromise" ) ) + { + traceLines.shift( ); + + const ignore = [ + "at Function.reject ()", + "at Promise.__proto__.constructor.reject", + ]; + if ( ignore.some( test => traceLines[ 0 ].includes( test ) ) ) + traceLines.shift( ); + } + + traceLines.reverse( ); + errorLines.reverse( ); + + var i = 0; + for ( ; + i < errorLines.length && + i < traceLines.length && + errorLines[ i ] === traceLines[ i ]; + ++i + ); + + return message + + "\n ==== Promise at: ==================\n" + + traceLines.slice( i ).reverse( ).join( "\n" ) + + "\n\n ==== Error at: ====================\n" + + errorLines.slice( i ).reverse( ).join( "\n" ) + + "\n\n ==== Shared trace: ================\n" + + errorLines.slice( 0, i ).reverse( ).join( "\n" ); +} + +export function logger( + reason: Error | undefined, + promise: TraceablePromise< any >, + pid: number | undefined = void 0 +) +{ + const stack = + promise.__tracedError + ? mergeErrors( promise.__tracedError, reason ) + : reason + ? reason.stack + : "Unknown stack"; + + const prefix = pid == null ? '' : `(node:${pid}) `; + + console.error( + `${prefix}UnhandledPromiseRejectionWarning\n` + + ( + !promise.__tracedError + ? "" + : `[ Stacktrace altered by https://github.com/grantila/trace-unhandled ]\n` + ) + + stack + ); +} + +const state: { resolve: any; reject: any; } = { resolve: null, reject: null }; + +export type PromiseResolver< T > = ( value?: T | PromiseLike< T > ) => void; +export type PromiseRejecter = ( reason?: any ) => void; + +export type PromiseConstructor< T > = + ( resolve: PromiseResolver< T >, reject: PromiseRejecter ) => void; + +export class TraceablePromise< T > extends Promise< T > +{ + public __tracedError?: Error; + + public constructor( executor: PromiseConstructor< T > ) + { + super( wrappedExecutor ); + + function wrappedExecutor( + resolve: PromiseResolver< T >, + reject: PromiseRejecter + ) + { + state.resolve = resolve; + state.reject = reject; + } + + const resolve = state.resolve; + const reject = state.reject; + state.resolve = null; + state.reject = null; + + const err = new Error( "Non-failing tracing error" ); + this.__tracedError = err; + + try + { + executor( resolve, reject ); + } + catch ( err ) + { + reject( err ); + } + } +} diff --git a/lib/register.ts b/lib/register.ts new file mode 100644 index 0000000..a4e6630 --- /dev/null +++ b/lib/register.ts @@ -0,0 +1,9 @@ + +import { logger, TraceablePromise } from './core'; + +process.on( "unhandledRejection", ( reason, promise ) => +{ + logger( < undefined | Error >reason, promise, process.pid ); +} ); + +global.Promise = TraceablePromise; diff --git a/package.json b/package.json index 8e2d286..36d149f 100644 --- a/package.json +++ b/package.json @@ -11,18 +11,23 @@ "trace-unhandled": "bin.js" }, "homepage": "https://github.com/grantila/trace-unhandled#readme", - "main": "./index.js", - "types": "./index.d.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "browser": "./browser.js", "engines": { "node": ">=8" }, "files": [ - "index.js", - "index.d.ts", + "dist", + "browser.js", "register.js", "bin.js" ], "scripts": { + "build:node": "node_modules/.bin/rimraf dist && node_modules/.bin/tsc -p .", + "build:web": "node_modules/.bin/rimraf dist-web && node_modules/.bin/tsc -p tsconfig.rollup.json", + "build:rollup": "rm browser.js && node_modules/.bin/rollup dist-web/web/register-web.js --file browser.js --format iife", + "build": "concurrently 'yarn build:node' 'yarn build:web' && yarn build:rollup", "test": "node --expose-gc node_modules/.bin/jest --coverage", "travis-deploy-once": "travis-deploy-once", "semantic-release": "semantic-release", @@ -48,11 +53,15 @@ "@types/node": "^12", "already": "^1.8.0", "commitizen": "^3", + "concurrently": "^4.1.1", "coveralls": "^3", "cz-conventional-changelog": "^2", "jest": "^20", + "rimraf": "^2.6.3", + "rollup": "^1.16.3", "semantic-release": "^15.13.18", - "travis-deploy-once": "^5" + "travis-deploy-once": "^5", + "typescript": "^3.5.2" }, "config": { "commitizen": { diff --git a/register.js b/register.js index 2b60dea..4e98e45 100644 --- a/register.js +++ b/register.js @@ -1,102 +1,2 @@ -Error.stackTraceLimit = 100; - -const reStackEntry = /\s+at\s/; - -function splitErrorStack( error ) -{ - const lines = error.stack.split( "\n" ); - const index = lines.findIndex( line => reStackEntry.test( line ) ); - const message = lines.slice( 0, index ).join( "\n" ); - return { message, lines: lines.slice( index ) }; -} - -function mergeErrors( traceError, mainError ) -{ - const { lines: traceLines } = splitErrorStack( traceError ); - const { lines: errorLines, message } = splitErrorStack( mainError ) - - if ( traceLines[ 0 ].includes( "at new TraceablePromise" ) ) - { - traceLines.shift( ); - - const ignore = [ - "at Function.reject ()", - "at Promise.__proto__.constructor.reject", - ]; - if ( ignore.some( test => traceLines[ 0 ].includes( test ) ) ) - traceLines.shift( ); - } - - traceLines.reverse( ); - errorLines.reverse( ); - - var i = 0; - for ( ; - i < errorLines.length && - i < traceLines.length && - errorLines[ i ] === traceLines[ i ]; - ++i - ); - - return message + - "\n ==== Promise at: ==================\n" + - traceLines.slice( i ).reverse( ).join( "\n" ) + - "\n\n ==== Error at: ====================\n" + - errorLines.slice( i ).reverse( ).join( "\n" ) + - "\n\n ==== Shared trace: ================\n" + - errorLines.slice( 0, i ).reverse( ).join( "\n" ); -} - -process.on( "unhandledRejection", ( reason, promise ) => -{ - const stack = - promise.__tracedError - ? mergeErrors( promise.__tracedError, reason ) - : reason.stack; - - console.error( - `(node:${process.pid}) UnhandledPromiseRejectionWarning\n` + - ( - !promise.__tracedError - ? "" - : `[ Stacktrace altered by trace-unhandled-rejection ]\n` - ) + - stack - ); -} ); - -const state = { resolve: null, reject: null }; - -class TraceablePromise extends Promise -{ - constructor( executor ) - { - super( wrappedExecutor ); - - function wrappedExecutor( resolve, reject ) - { - state.resolve = resolve; - state.reject = reject; - } - - const resolve = state.resolve; - const reject = state.reject; - state.resolve = null; - state.reject = null; - - const err = new Error( "Non-failing tracing error" ); - this.__tracedError = err; - - try - { - executor( resolve, reject ); - } - catch ( err ) - { - reject( err ); - } - } -} - -global.Promise = TraceablePromise; +require( './dist/lib/register' ); diff --git a/index.spec.js b/test/index.spec.js similarity index 69% rename from index.spec.js rename to test/index.spec.js index d9cb6fc..cd41a41 100644 --- a/index.spec.js +++ b/test/index.spec.js @@ -1,5 +1,5 @@ -const index = require( './' ); +const index = require( '../dist/index' ); describe( "register", ( ) => { @@ -13,9 +13,9 @@ describe( "register", ( ) => it( "should load 'register'", ( ) => { const spy = jest.fn( ); - jest.doMock( './register.js', spy ); + jest.doMock( '../dist/lib/register.js', spy ); index.register( ); expect( spy.mock.calls.length ).toBe( 1 ); - jest.dontMock( './register.js' ); + jest.dontMock( '../dist/lib/register.js' ); } ); } ); diff --git a/register.spec.js b/test/register.spec.js similarity index 99% rename from register.spec.js rename to test/register.spec.js index bec069e..60a33ea 100644 --- a/register.spec.js +++ b/test/register.spec.js @@ -1,6 +1,6 @@ const { Finally, Try, delay } = require( 'already' ); -require( './register' ); +require( '../register' ); const withConsoleSpy = fn => async ( ) => { diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6a49743 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "allowJs": false, + "declaration": true, + "lib": [ "es2015", "es2017", "dom" ], + "types": [ + "node", + "jest" + ], + "target": "es6", + "module": "commonjs", + "outDir": "dist", + "moduleResolution": "node", + "noImplicitAny": true, + "noUnusedLocals": true, + "pretty": true, + "sourceMap": true, + "strict": true, + "alwaysStrict": true, + "allowSyntheticDefaultImports": true + }, + "include": [ + "index.ts", + "lib", "web/register-web.ts" + ] +} diff --git a/tsconfig.rollup.json b/tsconfig.rollup.json new file mode 100644 index 0000000..4bf5083 --- /dev/null +++ b/tsconfig.rollup.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist-web", + "module": "es6", + }, + "include": [ + "lib", + "web" + ] +} diff --git a/web/register-web.ts b/web/register-web.ts new file mode 100644 index 0000000..5ae7b35 --- /dev/null +++ b/web/register-web.ts @@ -0,0 +1,9 @@ + +import { logger, TraceablePromise } from "../lib/core"; + +window.onunhandledrejection = function( event ) +{ + logger( event.reason, event.promise ); +}; + +( < any >window ).Promise = TraceablePromise;