Skip to content

Commit

Permalink
Implements mde#575 Better error handling via the new options 'error' …
Browse files Browse the repository at this point in the history
…and 'processSource'
  • Loading branch information
blutorange committed Nov 28, 2021
1 parent e4180b4 commit d47793a
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 7 deletions.
80 changes: 80 additions & 0 deletions README.md
Expand Up @@ -86,6 +86,12 @@ Therefore, we do not recommend using this shortcut.
- `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
Expand All @@ -95,6 +101,14 @@ Therefore, we do not recommend using this shortcut.
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
Expand Down Expand Up @@ -257,6 +271,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:
Expand Down
3 changes: 2 additions & 1 deletion benchmark/bench-ejs.js
Expand Up @@ -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('-');
Expand All @@ -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 });

Expand Down
19 changes: 19 additions & 0 deletions docs/jsdoc/options.jsdoc
Expand Up @@ -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
*/
27 changes: 21 additions & 6 deletions lib/ejs.js
Expand Up @@ -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;
Expand Down Expand Up @@ -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} */
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions test/ejs.js
Expand Up @@ -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';
Expand Down Expand Up @@ -262,6 +269,12 @@ suite('client mode', function () {
fn();
}, /Error: &lt;script&gt;/);
});

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&amp;");
});
});

/* Old API -- remove when this shim goes away */
Expand Down Expand Up @@ -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("&")}}),
"&amp;");
});

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("&")}}),
"&amp;");
});

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;
Expand Down

0 comments on commit d47793a

Please sign in to comment.