|
| 1 | +/* |
| 2 | + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one |
| 3 | + * or more contributor license agreements. Licensed under the Elastic License |
| 4 | + * 2.0 and the Server Side Public License, v 1; you may not use this file except |
| 5 | + * in compliance with, at your election, the Elastic License 2.0 or the Server |
| 6 | + * Side Public License, v 1. |
| 7 | + */ |
| 8 | + |
| 9 | +const { parseExpression } = require('@babel/parser'); |
| 10 | +const { default: generate } = require('@babel/generator'); |
| 11 | +const tsEstree = require('@typescript-eslint/typescript-estree'); |
| 12 | +const traverse = require('eslint-traverse'); |
| 13 | +const esTypes = tsEstree.AST_NODE_TYPES; |
| 14 | +const babelTypes = require('@babel/types'); |
| 15 | + |
| 16 | +/** @typedef {import("eslint").Rule.RuleModule} Rule */ |
| 17 | +/** @typedef {import("@typescript-eslint/parser").ParserServices} ParserServices */ |
| 18 | +/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.Expression} Expression */ |
| 19 | +/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.ArrowFunctionExpression} ArrowFunctionExpression */ |
| 20 | +/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.FunctionExpression} FunctionExpression */ |
| 21 | +/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.TryStatement} TryStatement */ |
| 22 | +/** @typedef {import("@typescript-eslint/typescript-estree").TSESTree.NewExpression} NewExpression */ |
| 23 | +/** @typedef {import("typescript").ExportDeclaration} ExportDeclaration */ |
| 24 | +/** @typedef {import("eslint").Rule.RuleFixer} Fixer */ |
| 25 | + |
| 26 | +const ERROR_MSG = |
| 27 | + 'Passing an async function to the Promise constructor leads to a hidden promise being created and prevents handling rejections'; |
| 28 | + |
| 29 | +/** |
| 30 | + * @param {Expression} node |
| 31 | + */ |
| 32 | +const isPromise = (node) => node.type === esTypes.Identifier && node.name === 'Promise'; |
| 33 | + |
| 34 | +/** |
| 35 | + * @param {Expression} node |
| 36 | + * @returns {node is ArrowFunctionExpression | FunctionExpression} |
| 37 | + */ |
| 38 | +const isFunc = (node) => |
| 39 | + node.type === esTypes.ArrowFunctionExpression || node.type === esTypes.FunctionExpression; |
| 40 | + |
| 41 | +/** |
| 42 | + * @param {any} context |
| 43 | + * @param {ArrowFunctionExpression | FunctionExpression} node |
| 44 | + */ |
| 45 | +const isFuncBodySafe = (context, node) => { |
| 46 | + // if the body isn't wrapped in a blockStatement it can't have a try/catch at the root |
| 47 | + if (node.body.type !== esTypes.BlockStatement) { |
| 48 | + return false; |
| 49 | + } |
| 50 | + |
| 51 | + // when the entire body is wrapped in a try/catch it is the only node |
| 52 | + if (node.body.body.length !== 1) { |
| 53 | + return false; |
| 54 | + } |
| 55 | + |
| 56 | + const tryNode = node.body.body[0]; |
| 57 | + // ensure we have a try node with a handler |
| 58 | + if (tryNode.type !== esTypes.TryStatement || !tryNode.handler) { |
| 59 | + return false; |
| 60 | + } |
| 61 | + |
| 62 | + // ensure the handler doesn't throw |
| 63 | + let hasThrow = false; |
| 64 | + traverse(context, tryNode.handler, (path) => { |
| 65 | + if (path.node.type === esTypes.ThrowStatement) { |
| 66 | + hasThrow = true; |
| 67 | + return traverse.STOP; |
| 68 | + } |
| 69 | + }); |
| 70 | + return !hasThrow; |
| 71 | +}; |
| 72 | + |
| 73 | +/** |
| 74 | + * @param {string} code |
| 75 | + */ |
| 76 | +const wrapFunctionInTryCatch = (code) => { |
| 77 | + // parse the code with babel so we can mutate the AST |
| 78 | + const ast = parseExpression(code, { |
| 79 | + plugins: ['typescript', 'jsx'], |
| 80 | + }); |
| 81 | + |
| 82 | + // validate that the code reperesents an arrow or function expression |
| 83 | + if (!babelTypes.isArrowFunctionExpression(ast) && !babelTypes.isFunctionExpression(ast)) { |
| 84 | + throw new Error('expected function to be an arrow or function expression'); |
| 85 | + } |
| 86 | + |
| 87 | + // ensure that the function receives the second argument, and capture its name if already defined |
| 88 | + let rejectName = 'reject'; |
| 89 | + if (ast.params.length === 0) { |
| 90 | + ast.params.push(babelTypes.identifier('resolve'), babelTypes.identifier(rejectName)); |
| 91 | + } else if (ast.params.length === 1) { |
| 92 | + ast.params.push(babelTypes.identifier(rejectName)); |
| 93 | + } else if (ast.params.length === 2) { |
| 94 | + if (babelTypes.isIdentifier(ast.params[1])) { |
| 95 | + rejectName = ast.params[1].name; |
| 96 | + } else { |
| 97 | + throw new Error('expected second param of promise definition function to be an identifier'); |
| 98 | + } |
| 99 | + } |
| 100 | + |
| 101 | + // ensure that the body of the function is a blockStatement |
| 102 | + let block = ast.body; |
| 103 | + if (!babelTypes.isBlockStatement(block)) { |
| 104 | + block = babelTypes.blockStatement([babelTypes.returnStatement(block)]); |
| 105 | + } |
| 106 | + |
| 107 | + // redefine the body of the function as a new blockStatement containing a tryStatement |
| 108 | + // which catches errors and forwards them to reject() when caught |
| 109 | + ast.body = babelTypes.blockStatement([ |
| 110 | + // try { |
| 111 | + babelTypes.tryStatement( |
| 112 | + block, |
| 113 | + // catch (error) { |
| 114 | + babelTypes.catchClause( |
| 115 | + babelTypes.identifier('error'), |
| 116 | + babelTypes.blockStatement([ |
| 117 | + // reject(error) |
| 118 | + babelTypes.expressionStatement( |
| 119 | + babelTypes.callExpression(babelTypes.identifier(rejectName), [ |
| 120 | + babelTypes.identifier('error'), |
| 121 | + ]) |
| 122 | + ), |
| 123 | + ]) |
| 124 | + ) |
| 125 | + ), |
| 126 | + ]); |
| 127 | + |
| 128 | + return generate(ast).code; |
| 129 | +}; |
| 130 | + |
| 131 | +/** @type {Rule} */ |
| 132 | +module.exports = { |
| 133 | + meta: { |
| 134 | + fixable: 'code', |
| 135 | + schema: [], |
| 136 | + }, |
| 137 | + create: (context) => ({ |
| 138 | + NewExpression(_) { |
| 139 | + const node = /** @type {NewExpression} */ (_); |
| 140 | + |
| 141 | + // ensure we are newing up a promise with a single argument |
| 142 | + if (!isPromise(node.callee) || node.arguments.length !== 1) { |
| 143 | + return; |
| 144 | + } |
| 145 | + |
| 146 | + const func = node.arguments[0]; |
| 147 | + // ensure the argument is an arrow or function expression and is async |
| 148 | + if (!isFunc(func) || !func.async) { |
| 149 | + return; |
| 150 | + } |
| 151 | + |
| 152 | + // body must be a blockStatement, try/catch can't exist outside of a block |
| 153 | + if (!isFuncBodySafe(context, func)) { |
| 154 | + context.report({ |
| 155 | + message: ERROR_MSG, |
| 156 | + loc: func.loc, |
| 157 | + fix(fixer) { |
| 158 | + const source = context.getSourceCode(); |
| 159 | + return fixer.replaceText(func, wrapFunctionInTryCatch(source.getText(func))); |
| 160 | + }, |
| 161 | + }); |
| 162 | + } |
| 163 | + }, |
| 164 | + }), |
| 165 | +}; |
0 commit comments