Skip to content

Commit

Permalink
Add error code extract and transform (#138)
Browse files Browse the repository at this point in the history
* Add error code extract and transform

* Add flag --extractErrors flag

* Make error extraction opt-in

* fix small reference to React

* catch extractErrors

* small fix for a comment

* fix readFileSync bug

* add in basic docs

* Move to errors directory

* Move everything to errors directory out of source
  • Loading branch information
jaredpalmer committed Jul 11, 2019
1 parent 37d7664 commit a20429d
Show file tree
Hide file tree
Showing 13 changed files with 580 additions and 47 deletions.
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,9 @@ if (!condition) {
}
```

Recommended for use with smaller https://github.com/alexreardon/tiny-invariant.
Note: TSDX doesn't supply an `invariant` function for you, you need to import one yourself. We recommend https://github.com/alexreardon/tiny-invariant.

To extract and minify error codes in production into a static `codes.json` file, pass an `extractErrors` flag with a URL where you will decode the error code. Example: `tsdx build --extractErrors=https://your-url.com/?invariant=`

##### `warning`

Expand All @@ -214,7 +216,7 @@ if ('production' !== process.env.NODE_ENV) {
}
```

Recommended for use with https://github.com/alexreardon/tiny-warning.
Note: TSDX doesn't supply a `warning` function for you, you need to import one yourself. We recommend https://github.com/alexreardon/tiny-warning.

### Using lodash

Expand Down Expand Up @@ -304,25 +306,33 @@ Usage
$ tsdx build [options]

Options
-i, --entry Entry module(s)
--target Specify your target environment (default web)
--name Specify name exposed in UMD builds
--format Specify module format(s) (default cjs,esm)
--tsconfig Specify your custom tsconfig path (default <root-folder>/tsconfig.json)
-h, --help Displays this message
-i, --entry Entry module(s)
--target Specify your target environment (default web)
--name Specify name exposed in UMD builds
--format Specify module format(s) (default cjs,esm)
--extractErrors Specify url for extracting error codes
--tsconfig Specify your custom tsconfig path (default <root-folder>/tsconfig.json)
-h, --help Displays this message

Examples
$ tsdx build --entry src/foo.tsx
$ tsdx build --target node
$ tsdx build --name Foo
$ tsdx build --format cjs,esm,umd
$ tsdx build --extractErrors=https://reactjs.org/docs/error-decoder.html?invariant=
$ tsdx build --tsconfig ./tsconfig.foo.json
```

### `tsdx test`

This runs Jest v24.x in watch mode. See [https://jestjs.io](https://jestjs.io) for options. If you are using the React template, jest uses the flag `--env=jsdom` by default.

## Hosting extracted Errors

After running `--extractErrors`, you will have a `codes.json` file with all your extracted error codes. You will need to host the decoder somewhere (with the URL that you passed in to `--extractErrors`).

_Simple guide to host error codes to be completed_

## Author

- [Jared Palmer](https://twitter.com/jaredpalmer)
Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
],
"dependencies": {
"@babel/core": "^7.4.4",
"@babel/helper-module-imports": "^7.0.0",
"@babel/plugin-proposal-class-properties": "^7.4.4",
"@babel/polyfill": "^7.4.4",
"@babel/preset-env": "^7.4.4",
Expand All @@ -40,6 +41,8 @@
"babel-plugin-dev-expression": "^0.2.1",
"babel-plugin-transform-async-to-promises": "^0.8.11",
"babel-plugin-transform-rename-import": "^2.3.0",
"babel-traverse": "^6.26.0",
"babylon": "^6.18.0",
"camelcase": "^5.0.0",
"chalk": "^2.4.2",
"cross-env": "5.2.0",
Expand All @@ -51,6 +54,7 @@
"jpjs": "^1.2.1",
"mkdirp": "^0.5.1",
"ora": "^3.4.0",
"pascal-case": "^2.0.1",
"progress-estimator": "^0.2.2",
"rollup": "^1.12.0",
"rollup-plugin-babel": "^4.3.2",
Expand All @@ -71,7 +75,7 @@
"@types/ansi-escapes": "^4.0.0",
"@types/camelcase": "^5.2.0",
"@types/execa": "^0.9.0",
"@types/fs-extra": "^7.0.0",
"@types/fs-extra": "^8.0.0",
"@types/jest": "^24.0.15",
"@types/mkdirp": "^0.5.2",
"@types/ms": "^0.7.30",
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ export const paths = {
testsSetup: resolveApp('test/setupTests.ts'),
appRoot: resolveApp('.'),
appSrc: resolveApp('src'),
appErrorsJson: resolveApp('./codes.json'),
appDist: resolveApp('dist'),
};
47 changes: 32 additions & 15 deletions src/createRollupConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,25 @@ import replace from 'rollup-plugin-replace';
import resolve from 'rollup-plugin-node-resolve';
import sourceMaps from 'rollup-plugin-sourcemaps';
import typescript from 'rollup-plugin-typescript2';
import shebangPlugin from '@jaredpalmer/rollup-plugin-preserve-shebang';
import { extractErrors } from './errors/extractErrors';

const replacements = [{ original: 'lodash', replacement: 'lodash-es' }];

const babelOptions = (
format: 'cjs' | 'esm' | 'umd',
target: 'node' | 'browser'
) => ({
const errorCodeOpts = {
errorMapFilePath: paths.appRoot + '/errors/codes.json',
};

interface TsdxOptions {
input: string;
name: string;
target: 'node' | 'browser';
env: 'development' | 'production';
tsconfig?: string;
extractErrors?: string;
minify?: boolean;
}

const babelOptions = (format: 'cjs' | 'esm' | 'umd', opts: TsdxOptions) => ({
exclude: 'node_modules/**',
extensions: [...DEFAULT_EXTENSIONS, 'ts', 'tsx'],
passPerPreset: true, // @see https://babeljs.io/docs/en/options#passperpreset
Expand All @@ -26,7 +37,7 @@ const babelOptions = (
{
loose: true,
modules: false,
targets: target === 'node' ? { node: '8' } : undefined,
targets: opts.target === 'node' ? { node: '8' } : undefined,
exclude: ['transform-async-to-generator'],
},
],
Expand All @@ -46,22 +57,22 @@ const babelOptions = (
require.resolve('@babel/plugin-proposal-class-properties'),
{ loose: true },
],
opts.extractErrors && require('./errors/transformErrorMessages'),
].filter(Boolean),
});

// shebang cache map thing because the transform only gets run once
let shebang: any = {};

export function createRollupConfig(
format: 'cjs' | 'umd' | 'esm',
opts: {
env?: 'development' | 'production';
minify?: boolean;
input: string;
name: string;
target: 'node' | 'browser';
tsconfig?: string;
}
opts: TsdxOptions
) {
const findAndRecordErrorCodes = extractErrors({
...errorCodeOpts,
...opts,
});

const shouldMinify =
opts.minify !== undefined ? opts.minify : opts.env === 'production';

Expand Down Expand Up @@ -122,6 +133,12 @@ export function createRollupConfig(
exports: 'named',
},
plugins: [
!!opts.extractErrors && {
transform(source: any) {
findAndRecordErrorCodes(source);
return source;
},
},
resolve({
mainFields: [
'module',
Expand Down Expand Up @@ -172,7 +189,7 @@ export function createRollupConfig(
},
},
}),
babel(babelOptions(format, opts.target)),
babel(babelOptions(format, opts)),
opts.env !== undefined &&
replace({
'process.env.NODE_ENV': JSON.stringify(opts.env),
Expand Down
3 changes: 3 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ declare module 'rollup-plugin-babel';
declare module 'rollup-plugin-size-snapshot';
declare module 'rollup-plugin-terser';
declare module 'camelcase';
declare module 'babel-traverse';
declare module 'babylon';
declare module '@babel/helper-module-imports';
13 changes: 13 additions & 0 deletions src/errors/evalToString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export function evalToString(ast: any): string {
switch (ast.type) {
case 'StringLiteral':
return ast.value;
case 'BinaryExpression': // `+`
if (ast.operator !== '+') {
throw new Error('Unsupported binary operator ' + ast.operator);
}
return evalToString(ast.left) + evalToString(ast.right);
default:
throw new Error('Unsupported type ' + ast.type);
}
}
156 changes: 156 additions & 0 deletions src/errors/extractErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
'use strict';

import fs from 'fs-extra';
import * as babylon from 'babylon';
import traverse from 'babel-traverse';
import { invertObject } from './invertObject';
import { evalToString } from './evalToString';
import { paths } from '../constants';
import { safeVariableName } from '../utils';
import pascalCase from 'pascal-case';

const babylonOptions = {
sourceType: 'module',
// As a parser, babylon has its own options and we can't directly
// import/require a babel preset. It should be kept **the same** as
// the `babel-plugin-syntax-*` ones specified in
// https://github.com/facebook/fbjs/blob/master/packages/babel-preset-fbjs/configure.js
plugins: [
'classProperties',
'flow',
'jsx',
'trailingFunctionCommas',
'objectRestSpread',
],
};

export function extractErrors(opts: any) {
if (!opts || !('errorMapFilePath' in opts)) {
throw new Error(
'Missing options. Ensure you pass an object with `errorMapFilePath`.'
);
}

if (!opts.name || !('name' in opts)) {
throw new Error('Missing options. Ensure you pass --name flag to tsdx');
}

if (typeof opts.extractErrors === 'boolean') {
throw new Error(
'No url passed to extractErrors flag.' +
'Ensure you pass a url, eg. `--extractErrors=https://reactjs.org/docs/error-decoder.html?invariant=`.'
);
}

const errorMapFilePath = opts.errorMapFilePath;
let existingErrorMap: any;
try {
// Using `fs.readFileSync` instead of `require` here, because `require()`
// calls are cached, and the cache map is not properly invalidated after
// file changes.
existingErrorMap = JSON.parse(fs.readFileSync(errorMapFilePath, 'utf8'));
} catch (e) {
existingErrorMap = {};
}

const allErrorIDs = Object.keys(existingErrorMap);
let currentID: any;

if (allErrorIDs.length === 0) {
// Map is empty
currentID = 0;
} else {
currentID = Math.max.apply(null, allErrorIDs as any) + 1;
}

// Here we invert the map object in memory for faster error code lookup
existingErrorMap = invertObject(existingErrorMap);

function transform(source: string) {
const ast = babylon.parse(source, babylonOptions);

traverse(ast, {
CallExpression: {
exit(astPath: any) {
if (astPath.get('callee').isIdentifier({ name: 'invariant' })) {
const node = astPath.node;

// error messages can be concatenated (`+`) at runtime, so here's a
// trivial partial evaluator that interprets the literal value
const errorMsgLiteral = evalToString(node.arguments[1]);
addToErrorMap(errorMsgLiteral);
}
},
},
});
}

function addToErrorMap(errorMsgLiteral: any) {
if (existingErrorMap.hasOwnProperty(errorMsgLiteral)) {
return;
}
existingErrorMap[errorMsgLiteral] = '' + currentID++;
}

function flush(cb?: any) {
const prettyName = pascalCase(safeVariableName(opts.name));
// Output messages to ./codes.json
fs.writeFileSync(
errorMapFilePath,
JSON.stringify(invertObject(existingErrorMap), null, 2) + '\n',
'utf-8'
);

// Ensure that the ./src/errors directory exists or create it
fs.ensureDirSync(paths.appRoot + '/errors');

// Write the error files, unless they already exist
fs.writeFileSync(
paths.appRoot + '/errors/ErrorDev.js',
`
function ErrorDev(message) {
const error = new Error(message);
error.name = 'Invariant Violation';
return error;
}
export default ErrorDev;
`,
'utf-8'
);

fs.writeFileSync(
paths.appRoot + '/errors/ErrorProd.js',
`// Do not require this module directly! Use a normal error constructor with
// template literal strings. The messages will be converted to ErrorProd during
// build, and in production they will be minified.
function ErrorProd(code) {
let url = '${opts.extractErrors}' + code;
for (let i = 1; i < arguments.length; i++) {
url += '&args[]=' + encodeURIComponent(arguments[i]);
}
return new Error(
\`Minified ${prettyName} error #\$\{code\}; visit \$\{url\} for the full message or \` +
'use the non-minified dev environment for full errors and additional \' +
'helpful warnings. '
);
}
export default ErrorProd;
`,
'utf-8'
);
}

return function extractErrors(source: any) {
transform(source);
flush();
};
}
24 changes: 24 additions & 0 deletions src/errors/invertObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* turns
* { 'MUCH ERROR': '0', 'SUCH WRONG': '1' }
* into
* { 0: 'MUCH ERROR', 1: 'SUCH WRONG' }
*/

type Dict = { [key: string]: any };

export function invertObject(
targetObj: Dict /* : ErrorMap */
) /* : ErrorMap */ {
const result: Dict = {};
const mapKeys = Object.keys(targetObj);

// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const originalKey of mapKeys) {
const originalVal = targetObj[originalKey];

result[originalVal] = originalKey;
}

return result;
}
Loading

0 comments on commit a20429d

Please sign in to comment.