From 84468fe0a2303575b1d8744f5b35dd047213fdb3 Mon Sep 17 00:00:00 2001 From: Andre Wachsmuth Date: Sun, 28 Nov 2021 00:29:58 +0100 Subject: [PATCH 1/3] Implements #575 Better error handling via the new options 'error' and 'processSource' --- README.md | 80 ++++++++++++++++++++++++++++++++++++++++ benchmark/bench-ejs.js | 3 +- docs/jsdoc/options.jsdoc | 19 ++++++++++ lib/ejs.js | 27 +++++++++++--- test/ejs.js | 71 +++++++++++++++++++++++++++++++++++ 5 files changed, 193 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 81a9aa10..09482fde 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,12 @@ You should never give end-users unfettered access to the EJS render method, If y - `escape` The escaping function used with `<%=` construct. It is used in rendering and is `.toString()`ed in the generation of client functions. (By default escapes XML). + - `error` The error handler function used for the `<%=` construct and + `<%-` construct. When an error is thrown within these constructs, the error handler is + called. It receives two arguments: `err` (type `unknown`), which is the error object + that was thrown; and `escapeFn` (type `(text: string) => string`), which is the current + function for escaping literal text. The error function may return a `string`, and if it + does, that value is inserted into the template instead. - `outputFunctionName` Set to a string (e.g., 'echo' or 'print') for a function to print output inside scriptlet tags. - `async` When `true`, EJS will use an async function for rendering. (Depends @@ -98,6 +104,14 @@ You should never give end-users unfettered access to the EJS render method, If y previously resolved path. Should return an object `{ filename, template }`, you may return only one of the properties, where `filename` is the final parsed path and `template` is the included content. + - `processSource` Callback that is invoked with the generated source code of the + template, without the header and footer added by EJS. Can be used to transform the source. + The callback receives two arguments: `source` (`string`), which is the generated source text; + and `outputVar` (type `string`), which is the name of the variable that contains the template + text and can be appended to. The callback must return a value of type `string`, which is the + transformed source. One use case for this callback is to wrap all individual top-level statements + in try-catch-blocks (e.g. by using a parser such as `acorn` and a stringifier such as `astring`) + for improved error resilience. This project uses [JSDoc](http://usejsdoc.org/). For the full public API documentation, clone the repository and run `jake doc`. This will run JSDoc @@ -260,6 +274,72 @@ Most of EJS will work as expected; however, there are a few things to note: See the [examples folder](https://github.com/mde/ejs/tree/master/examples) for more details. +## Error handling + +In an ideal world, all templates are valid and all JavaScript code they contain +never throws an error. Unfortunately, this is not always the case in the real +world. By default, when any JavaScript code in a template throws an error, the +entire templates fails and not text is rendered. Sometimes you might want to +ignore errors and still render the rest of the template. + +You can use the `error` option to handle errors within expressions (the `<%=%>` +and `<%-%>` tags). This is a callback that is invoked when an unhandled error +is thrown: + +```javascript +const ejs = require('ejs'); + +ejs.render('<%= valid %> <%= i.am.invalid %>', { valid: 2 }, { + error: function(err, escapeFn) { + console.error(err); + return escapeFn("ERROR"); + } +}); +``` + +The code above logs the error to the console and renders the text `2 ERROR`. + +Note that this only applies to expression, not to other control blocks such +as `<%if (something.invalid) { %> ... <% } %>`. To handle errors in these cases, +you e.g. can use the `processSource` option to wrap individual top-level +statements in try-catch blocks. For example, by using `acorn` and `astring` +for processing JavaScript source code: + +```javascript +const ejs = require('ejs'); +const acorn = require('acorn'); +const astring = require('astring'); + +ejs.render('<%= valid %> <%if (something.invalid) { %> foo <% } %>', + { valid: 2 }, + { + // Wrap all individual top-level statement in a try-catch block + processSource: function(source, outputVar) { + const ast = acorn.parse(source, { + allowAwaitOutsideFunction: true, + allowReturnOutsideFunction: true, + ecmaVersion: 2020, + }); + return ast.body + .filter(node => node.type !== "EmptyStatement") + .map(node => { + const statement = astring.generate(node, { indent: "", lineEnd: "" }); + switch (node.type) { + case "ReturnStatement": + case "TryStatement": + case "EmptyStatement": + return statement; + default: + return `try{${statement}}catch(e){console.error(e);${outputVar}+='STATEMENT_ERROR'}`; + } + }) + .join("\n"); + }, +}); +``` + +The above code logs the error to the console and renders the text `2 STATEMENT_ERROR`. + ## CLI EJS ships with a full-featured CLI. Options are similar to those used in JavaScript code: diff --git a/benchmark/bench-ejs.js b/benchmark/bench-ejs.js index b44f5471..d7fea2b7 100755 --- a/benchmark/bench-ejs.js +++ b/benchmark/bench-ejs.js @@ -158,9 +158,11 @@ if (runCompile) { console.log(fill('name: ',30), fill('avg',10), fill('med',10), fill('med/avg',10), fill('min',10), fill('max',10), fillR('loops',15)); benchCompile('single tmpl compile', 'bench1', {compileDebug: false}, { loopFactor: 2 }); + benchCompile('single tmpl compile (error)', 'bench1', {compileDebug: false, error: function(){}}, { loopFactor: 2 }); benchCompile('single tmpl compile (debug)', 'bench1', {compileDebug: true}, { loopFactor: 2 }); benchCompile('large tmpl compile', 'bench2', {compileDebug: false}, { loopFactor: 0.1 }); + benchCompile('large tmpl compile (error)', 'bench2', {compileDebug: false, error: function(){}}, { loopFactor: 0.1 }); benchCompile('include-1 compile', 'include1', {compileDebug: false}, { loopFactor: 2 }); console.log('-'); @@ -176,7 +178,6 @@ if (runCache) { benchRender('include-1 cached', 'include1', data, {cache:true, compileDebug: false}, { loopFactor: 2 }); benchRender('include-2 cached', 'include2', data, {cache:true, compileDebug: false}, { loopFactor: 2 }); - benchRender('locals tmpl cached "with"', 'locals1', data, {cache:true, compileDebug: false, _with: true}, { loopFactor: 3 }); benchRender('locals tmpl cached NO-"with"', 'locals1', data, {cache:true, compileDebug: false, _with: false}, { loopFactor: 3 }); diff --git a/docs/jsdoc/options.jsdoc b/docs/jsdoc/options.jsdoc index 7747bb14..a2980fa7 100644 --- a/docs/jsdoc/options.jsdoc +++ b/docs/jsdoc/options.jsdoc @@ -79,6 +79,25 @@ * Whether or not to create an async function instead of a regular function. * This requires language support. * + * @property {ErrorCallback} [error=undefined] + * The error handler function used for the `<%=` construct and `<%-` construct. + * When an error is thrown within these constructs, the error handler is called. + * It receives two arguments: `err` (type `unknown`), which is the error object + * that was thrown; and `escapeFn` (type `(text: string) => string`), which is + * the current function for escaping literal text. The error function may return + * a `string`, and if it does, that value is inserted into the template instead. + * + * @property {ProcessSourceCallback} [processSource=undefined] + * Callback that is invoked with the generated source code of the template, + * without the header and footer added by EJS. Can be used to transform the + * source. The callback receives two arguments: `source` (`string`), which is + * the generated source text; and `outputVar` (type `string`), which is the name + * of the variable that contains the template text and can be appended to. The + * callback must return a value of type `string`, which is the transformed + * source. One use case for this callback is to wrap all individual top-level + * statements in try-catch-blocks (e.g. by using a parser such as `acorn` and a + * stringifier such as `astring`) for improved error resilience. + * * @static * @global */ diff --git a/lib/ejs.js b/lib/ejs.js index 65590eae..4f825c94 100755 --- a/lib/ejs.js +++ b/lib/ejs.js @@ -535,6 +535,8 @@ function Template(text, opts) { options.async = opts.async; options.destructuredLocals = opts.destructuredLocals; options.legacyInclude = typeof opts.legacyInclude != 'undefined' ? !!opts.legacyInclude : true; + options.errorFunction = opts.error || undefined; + options.processSourceFunction = opts.processSource || function (source) { return source; }; if (options.strict) { options._with = false; @@ -578,6 +580,8 @@ Template.prototype = { var appended = ''; /** @type {EscapeCallback} */ var escapeFn = opts.escapeFunction; + /** @type {ErrorCallback} */ + var errorFn = opts.errorFunction; /** @type {FunctionConstructor} */ var ctor; /** @type {string} */ @@ -616,7 +620,7 @@ Template.prototype = { appended += ' }' + '\n'; } appended += ' return __output;' + '\n'; - this.source = prepended + this.source + appended; + this.source = prepended + opts.processSourceFunction(this.source, '__output') + appended; } if (opts.compileDebug) { @@ -626,7 +630,7 @@ Template.prototype = { + 'try {' + '\n' + this.source + '} catch (e) {' + '\n' - + ' rethrow(e, __lines, __filename, __line, escapeFn);' + '\n' + + ' rethrow(e, __lines, __filename, __line, escapeFn, errorFn);' + '\n' + '}' + '\n'; } else { @@ -635,6 +639,7 @@ Template.prototype = { if (opts.client) { src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src; + src = 'errorFn = errorFn || ' + (errorFn ? errorFn.toString() : 'undefined') + ';' + '\n' + src; if (opts.compileDebug) { src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src; } @@ -670,7 +675,7 @@ Template.prototype = { else { ctor = Function; } - fn = new ctor(opts.localsName + ', escapeFn, include, rethrow', src); + fn = new ctor(opts.localsName + ', escapeFn, errorFn, include, rethrow', src); } catch(e) { // istanbul ignore else @@ -701,7 +706,7 @@ Template.prototype = { return includeFile(path, opts)(d); }; return fn.apply(opts.context, - [data || utils.createNullProtoObjWherePossible(), escapeFn, include, rethrow]); + [data || utils.createNullProtoObjWherePossible(), escapeFn, errorFn, include, rethrow]); }; if (opts.filename && typeof Object.defineProperty === 'function') { var filename = opts.filename; @@ -872,11 +877,21 @@ Template.prototype = { break; // Exec, esc, and output case Template.modes.ESCAPED: - this.source += ' ; __append(escapeFn(' + stripSemi(line) + '))' + '\n'; + if (this.opts.errorFunction) { + this.source += ' ; try{__append(escapeFn(' + stripSemi(line) + '))}catch(e){__append(errorFn(e,escapeFn))}' + '\n'; + } + else { + this.source += ' ; __append(escapeFn(' + stripSemi(line) + '))' + '\n'; + } break; // Exec and output case Template.modes.RAW: - this.source += ' ; __append(' + stripSemi(line) + ')' + '\n'; + if (this.opts.errorFunction) { + this.source += ' ; try{__append(' + stripSemi(line) + ')}catch(e){__append(errorFn(e,escapeFn))}' + '\n'; + } + else { + this.source += ' ; __append(' + stripSemi(line) + ')' + '\n'; + } break; case Template.modes.COMMENT: // Do nothing diff --git a/test/ejs.js b/test/ejs.js index 607b4a71..9ff212af 100755 --- a/test/ejs.js +++ b/test/ejs.js @@ -144,6 +144,13 @@ suite('ejs.compile(str, options)', function () { }), locals.foo); }); + test('transforms the source via the process source function', function () { + var compiled = ejs.compile('<%=42%>', { + processSource: function(source, ovar){return `${ovar}+='21';${source};${ovar}+='84'`;} + }); + assert.equal(compiled(), '214284'); + }); + testAsync('destructuring works in strict and async mode', function (done) { var locals = Object.create(null); locals.foo = 'bar'; @@ -262,6 +269,12 @@ suite('client mode', function () { fn(); }, /Error: <script>/); }); + + test('supports error handler in client mode', function () { + assert.equal(ejs.render('<%= it.does.not.exist %>', {}, { + client: true, error: function(e,escapeFn){return (e instanceof ReferenceError) + escapeFn('&')} + }), "true&"); + }); }); /* Old API -- remove when this shim goes away */ @@ -911,6 +924,64 @@ suite('exceptions', function () { }, /Error: zooby/); }); + test('catches errors in expressions in escaped mode', function () { + assert.equal(ejs.render('<%= it.does.not.exist %>', {}, {error: function(){return 'error'}}), + "error"); + }); + + testAsync('catches errors in expressions in escaped mode with async', function (done) { + ejs.render('<%= await it.does.not.exist %><%=await Promise.resolve(42)%><%=await Promise.reject(0)%>', {}, { + async: true, + error: function(){return 'error'} + }).then(function(value) { + try { + assert.equal(value, "error42error"); + } + finally { + done(); + } + }); + }); + + test('passes the escapeFn to the error handler in escaped mode', function () { + assert.equal(ejs.render('<%= it.does.not.exist %>', {}, {error: function(_, escapeFn){return escapeFn("&")}}), + "&"); + }); + + test('passes the error object to the error handler in escaped mode', function () { + assert.equal(ejs.render('<%= it.does.not.exist %>', {}, {error: function(e){return e instanceof ReferenceError}}), + "true"); + }); + + test('catches errors in expressions in raw mode', function () { + assert.equal(ejs.render('<%- it.does.not.exist %>', {}, {error: function(){return 'error'}}), + "error"); + }); + + testAsync('catches errors in expressions in raw mode with async', function (done) { + ejs.render('<%- await it.does.not.exist %><%-await Promise.resolve(42)%><%-await Promise.reject(0)%>', {}, { + async: true, + error: function(){return 'error'} + }).then(function(value) { + try { + assert.equal(value, "error42error"); + } + finally { + done(); + } + }); + }); + + test('passes the escapeFn to the error handler in raw mode', function () { + assert.equal(ejs.render('<%- it.does.not.exist %>', {}, {error: function(_, escapeFn){return escapeFn("&")}}), + "&"); + }); + + test('passes the error object to the error handler in raw mode', function () { + assert.equal(ejs.render('<%- it.does.not.exist %>', {}, {error: function(e){return e instanceof ReferenceError}}), + "true"); + }); + teardown(function() { if (!unhook) { return; From bfb18b22d812cb4de28637af95fd5affa02afd48 Mon Sep 17 00:00:00 2001 From: Andre Wachsmuth Date: Sun, 28 Nov 2021 01:25:00 +0100 Subject: [PATCH 2/3] #575 Add case for error option to benchmarkRender --- benchmark/bench-ejs.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/benchmark/bench-ejs.js b/benchmark/bench-ejs.js index d7fea2b7..d0a2223a 100755 --- a/benchmark/bench-ejs.js +++ b/benchmark/bench-ejs.js @@ -172,9 +172,11 @@ if (runCompile) { if (runCache) { benchRender('single tmpl cached', 'bench1', data, {cache:true, compileDebug: false}, { loopFactor: 5 }); + benchRender('single tmpl cached (error)', 'bench1', data, {cache:true, compileDebug: false, error: function(){}}, { loopFactor: 5 }); benchRender('single tmpl cached (debug)', 'bench1', data, {cache:true, compileDebug: true}, { loopFactor: 5 }); benchRender('large tmpl cached', 'bench2', data, {cache:true, compileDebug: false}, { loopFactor: 0.4 }); + benchRender('large tmpl cached (error)', 'bench2', data, {cache:true, compileDebug: false, error: function(){}}, { loopFactor: 0.4 }); benchRender('include-1 cached', 'include1', data, {cache:true, compileDebug: false}, { loopFactor: 2 }); benchRender('include-2 cached', 'include2', data, {cache:true, compileDebug: false}, { loopFactor: 2 }); @@ -187,9 +189,11 @@ if (runCache) { if (runNoCache) { benchRender('single tmpl NO-cache', 'bench1', data, {cache:false, compileDebug: false}); + benchRender('single tmpl NO-cache (error)', 'bench1', data, {cache:false, compileDebug: false, error: function(){}}); benchRender('single tmpl NO-cache (debug)', 'bench1', data, {cache:false, compileDebug: true}); benchRender('large tmpl NO-cache', 'bench2', data, {cache:false, compileDebug: false}, { loopFactor: 0.1 }); + benchRender('large tmpl NO-cache (error)', 'bench2', data, {cache:false, compileDebug: false, error: function(){}}, { loopFactor: 0.1 }); benchRender('include-1 NO-cache', 'include1', data, {cache:false, compileDebug: false}); benchRender('include-2 NO-cache', 'include2', data, {cache:false, compileDebug: false}); From 74d689d79a4f58ce2247809a55e400578df44979 Mon Sep 17 00:00:00 2001 From: Andre Wachsmuth Date: Sat, 14 Jan 2023 11:52:23 +0100 Subject: [PATCH 3/3] fix lint issues --- test/ejs.js | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/test/ejs.js b/test/ejs.js index 9ff212af..fe48d0db 100755 --- a/test/ejs.js +++ b/test/ejs.js @@ -272,8 +272,8 @@ suite('client mode', function () { test('supports error handler in client mode', function () { assert.equal(ejs.render('<%= it.does.not.exist %>', {}, { - client: true, error: function(e,escapeFn){return (e instanceof ReferenceError) + escapeFn('&')} - }), "true&"); + client: true, error: function(e,escapeFn){return (e instanceof ReferenceError) + escapeFn('&');} + }), 'true&'); }); }); @@ -925,17 +925,17 @@ suite('exceptions', function () { }); test('catches errors in expressions in escaped mode', function () { - assert.equal(ejs.render('<%= it.does.not.exist %>', {}, {error: function(){return 'error'}}), - "error"); + assert.equal(ejs.render('<%= it.does.not.exist %>', {}, {error: function(){return 'error';}}), + 'error'); }); testAsync('catches errors in expressions in escaped mode with async', function (done) { ejs.render('<%= await it.does.not.exist %><%=await Promise.resolve(42)%><%=await Promise.reject(0)%>', {}, { - async: true, - error: function(){return 'error'} + async: true, + error: function(){return 'error';} }).then(function(value) { try { - assert.equal(value, "error42error"); + assert.equal(value, 'error42error'); } finally { done(); @@ -944,27 +944,27 @@ suite('exceptions', function () { }); test('passes the escapeFn to the error handler in escaped mode', function () { - assert.equal(ejs.render('<%= it.does.not.exist %>', {}, {error: function(_, escapeFn){return escapeFn("&")}}), - "&"); + assert.equal(ejs.render('<%= it.does.not.exist %>', {}, {error: function(_, escapeFn){return escapeFn('&');}}), + '&'); }); test('passes the error object to the error handler in escaped mode', function () { - assert.equal(ejs.render('<%= it.does.not.exist %>', {}, {error: function(e){return e instanceof ReferenceError}}), - "true"); + assert.equal(ejs.render('<%= it.does.not.exist %>', {}, {error: function(e){return e instanceof ReferenceError;}}), + 'true'); }); test('catches errors in expressions in raw mode', function () { - assert.equal(ejs.render('<%- it.does.not.exist %>', {}, {error: function(){return 'error'}}), - "error"); + assert.equal(ejs.render('<%- it.does.not.exist %>', {}, {error: function(){return 'error';}}), + 'error'); }); testAsync('catches errors in expressions in raw mode with async', function (done) { ejs.render('<%- await it.does.not.exist %><%-await Promise.resolve(42)%><%-await Promise.reject(0)%>', {}, { - async: true, - error: function(){return 'error'} + async: true, + error: function(){return 'error';} }).then(function(value) { try { - assert.equal(value, "error42error"); + assert.equal(value, 'error42error'); } finally { done(); @@ -973,13 +973,13 @@ suite('exceptions', function () { }); test('passes the escapeFn to the error handler in raw mode', function () { - assert.equal(ejs.render('<%- it.does.not.exist %>', {}, {error: function(_, escapeFn){return escapeFn("&")}}), - "&"); + assert.equal(ejs.render('<%- it.does.not.exist %>', {}, {error: function(_, escapeFn){return escapeFn('&');}}), + '&'); }); test('passes the error object to the error handler in raw mode', function () { - assert.equal(ejs.render('<%- it.does.not.exist %>', {}, {error: function(e){return e instanceof ReferenceError}}), - "true"); + assert.equal(ejs.render('<%- it.does.not.exist %>', {}, {error: function(e){return e instanceof ReferenceError;}}), + 'true'); }); teardown(function() {