Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implements #575 Better error handling via the new options 'error' and 'processSource' #640

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion benchmark/bench-ejs.js
Original file line number Diff line number Diff line change
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 @@ -170,13 +172,14 @@ 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 });


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 All @@ -186,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});
Expand Down
19 changes: 19 additions & 0 deletions docs/jsdoc/options.jsdoc
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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