Skip to content

Commit

Permalink
feat(web): Added support for running it in a browser
Browse files Browse the repository at this point in the history
  • Loading branch information
grantila committed Jul 1, 2019
1 parent 38cd27e commit 8026f2a
Show file tree
Hide file tree
Showing 15 changed files with 293 additions and 117 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
coverage/
node_modules/
dist/
dist-web/
yarn.lock
browser.js
88 changes: 86 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<anonymous> (/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 (<anonymous>)
at Object.<anonymous> (/my/directory/test.js:9:5)
==== Error at: ====================
at Object.<anonymous> (/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.<anonymous> (/my/directory/test.js:8:13)
==== Error at: ====================
at Object.<anonymous> (/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.
Expand All @@ -30,6 +106,13 @@ You can also use `npx`:
`npx trace-unhandled index.js`


# In a website

```html
<head><script src="https://cdn.jsdelivr.net/npm/trace-unhandled@latest/browser.js"></script></head>
```


## Programatically - API

```ts
Expand All @@ -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:
Expand All @@ -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
Expand Down
1 change: 0 additions & 1 deletion index.d.ts

This file was deleted.

2 changes: 0 additions & 2 deletions index.js

This file was deleted.

2 changes: 2 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@

export const register = ( ) => require( './lib/register' );
5 changes: 3 additions & 2 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
module.exports = {
testEnvironment: 'node',
coverageReporters: ['lcov', 'text', 'html'],
testEnvironment: "node",
testMatch: ['<rootDir>/test/**/*.spec.js'],
coverageReporters: ["lcov", "text", "html"],
};
125 changes: 125 additions & 0 deletions lib/core.ts
Original file line number Diff line number Diff line change
@@ -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 (<anonymous>)",
"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 );
}
}
}
9 changes: 9 additions & 0 deletions lib/register.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@

import { logger, TraceablePromise } from './core';

process.on( "unhandledRejection", ( reason, promise ) =>
{
logger( < undefined | Error >reason, promise, process.pid );
} );

global.Promise = TraceablePromise;
19 changes: 14 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
Loading

0 comments on commit 8026f2a

Please sign in to comment.