Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Find details of SyntaxError thrown by javascript new Function() constructor

Tags:

javascript

When creating new function from JavaScript code using new Function(params,body) constructor, passing invalid string in body yelds SyntaxError. While this exception contains error message (ie: Unexpected token =), but does not seem to contain context (ie. line/column or character where error was found).

Example fiddle: https://jsfiddle.net/gheh1m8p/

var testWithSyntaxError = "{\n\n\n=2;}";

try {
    var f=new Function('',testWithSyntaxError);
} catch(e) {
  console.log(e instanceof SyntaxError); 
  console.log(e.message);               
  console.log(e.name);                
  console.log(e.fileName);            
  console.log(e.lineNumber);           
  console.log(e.columnNumber);         
  console.log(e.stack);               
}

Output:

true
(index):54 Unexpected token =
(index):55 SyntaxError
(index):56 undefined
(index):57 undefined
(index):58 undefined
(index):59 SyntaxError: Unexpected token =
    at Function (native)
    at window.onload (https://fiddle.jshell.net/_display/:51:8)

How can I, without using external dependencies, pinpoint SyntaxError location withinn passed string? I require solution both for browser and nodejs.

Please note: I do have a valid reason to use eval-equivalent code.

like image 285
Koder Avatar asked Feb 07 '16 11:02

Koder


Video Answer


2 Answers

In Chromium-based browsers, as you've seen, putting try/catch around something that throws a SyntaxError while V8 is parsing the code (before actually running it) won't produce anything helpful; it will describe the line that caused the evaluation of the problematic script in the stack trace, but no details on where the problem was in said script.

But, there's a cross-browser workaround. Instead of using try/catch, you can add an error listener to window, and the first argument provided to the callback will be an ErrorEvent which has useful lineno and colno properties:

window.addEventListener('error', (errorEvent) => {
  const { lineno, colno } = errorEvent;
  console.log(`Error thrown at: ${lineno}:${colno}`);
  // Don't pollute the console with additional info:
  errorEvent.preventDefault();
});

const checkSyntax = (str) => {
  // Using setTimeout because when an error is thrown without a catch,
  // even if the error listener calls preventDefault(),
  // the current thread will stop
  setTimeout(() => {
    eval(str);
  });
};

checkSyntax(`console.log('foo') bar baz`);
checkSyntax(`foo bar baz`);
Look in your browser console to see this in action, not in the snippet console

Check the results in your browser console:

Error thrown at: 1:20
Error thrown at: 1:5

Which is what we want! Character 20 corresponds to

console.log('foo') bar baz
                       ^

and character 5 corresponds to

foo bar baz
    ^

There are a couple issues, though: it would be good to make sure in the error listened for is an error thrown when running checkSyntax. Also, try/catch can be used for runtime errors (including syntax errors) after the script text has been parsed into an AST by the interpreter. So, you might have checkSyntax only check that the Javascript is initially parsable, and nothing else, and then use try/catch (if you want to run the code for real) to catch runtime errors. You can do this by inserting throw new Error to the top of the text that's evaled.

Here's a convenient Promise-based function which can accomplish that:

// Use an IIFE to keep from polluting the global scope
(async () => {
  let stringToEval;
  let checkSyntaxResolve;
  const cleanup = () => {
    stringToEval = null;
    checkSyntaxResolve = null; // not necessary, but makes things clearer
  };
  window.addEventListener('error', (errorEvent) => {
    if (!stringToEval) {
      // The error was caused by something other than the checkSyntax function below; ignore it
      return;
    }
    const stringToEvalToPrint = stringToEval.split('\n').slice(1).join('\n');
    // Don't pollute the console with additional info:
    errorEvent.preventDefault();
    if (errorEvent.message === 'Uncaught Error: Parsing successful!') {
      console.log(`Parsing successful for: ${stringToEvalToPrint}`);
      checkSyntaxResolve();
      cleanup();
      return;
    }
    const { lineno, colno } = errorEvent;
    console.log(`Syntax error thrown at: ${lineno - 1}:${colno}`);
    console.log(describeError(stringToEval, lineno, colno));
    // checkSyntaxResolve should *always* be defined at this point - checkSyntax's eval was just called (synchronously)
    checkSyntaxResolve();
    cleanup();
  });

  const checkSyntax = (str) => {
    console.log('----------------------------------------');
    return new Promise((resolve) => {
      checkSyntaxResolve = resolve;
      // Using setTimeout because when an error is thrown without a catch,
      // even if the 'error' listener calls preventDefault(),
      // the current thread will stop
      setTimeout(() => {
        // If we only want to check the syntax for initial parsing validity,
        // but not run the code for real, throw an error at the top:
        stringToEval = `throw new Error('Parsing successful!');\n${str}`;
        eval(stringToEval);
      });
    });
  };
  const describeError = (stringToEval, lineno, colno) => {
    const lines = stringToEval.split('\n');
    const line = lines[lineno - 1];
    return `${line}\n${' '.repeat(colno - 1) + '^'}`;
  };

  await checkSyntax(`console.log('I will throw') bar baz`);
  await checkSyntax(`foo bar baz will throw too`);
  await checkSyntax(`console.log('A snippet without compile errors'); const foo = bar;`);
  await checkSyntax(`console.log('A multi line snippet');
With a syntax error on the second line`);
})();
Look in your browser console to see this in action, not in the snippet console
await checkSyntax(`console.log('I will throw') bar baz`);
await checkSyntax(`foo bar baz will throw too`);
await checkSyntax(`console.log('A snippet without compile errors'); const foo = bar;`);
await checkSyntax(`console.log('A multi line snippet');
With a syntax error on the second line`);

Result:

----------------------------------------
Syntax error thrown at: 1:29
console.log('I will throw') bar baz
                            ^
----------------------------------------
Syntax error thrown at: 1:5
foo bar baz will throw too
    ^
----------------------------------------
Parsing successful for: console.log('A snippet without compile errors'); const foo = bar;
----------------------------------------
Syntax error thrown at: 2:6
With a syntax error on the second line
     ^

If the fact that an error is thrown at window is a problem (for example, if something else is already listening for window errors, which you don't want to disturb, and you can't attach your listener first and call stopImmediatePropagation() on the event), another option is to use a web worker instead, which has its own execution context completely separate from the original window:

// Worker:
const getErrorEvent = (() => { 
  const workerFn = () => {
    const doEvalAndReply = (jsText) => { 
      self.addEventListener(
        'error', 
        (errorEvent) => { 
          // Don't pollute the browser console:
          errorEvent.preventDefault();
          // The properties we want are actually getters on the prototype;
          // they won't be retrieved when just stringifying
          // so, extract them manually, and put them into a new object:
          const { lineno, colno, message } = errorEvent;
          const plainErrorEventObj = { lineno, colno, message };
          self.postMessage(JSON.stringify(plainErrorEventObj));
        },
        { once: true }
      );
      eval(jsText);
    };
    self.addEventListener('message', (e) => {
      doEvalAndReply(e.data);
    });
  };
  const blob = new Blob(
    [ `(${workerFn})();`],
    { type: "text/javascript" }
  );
  const worker = new Worker(window.URL.createObjectURL(blob));
  // Use a queue to ensure processNext only calls the worker once the worker is idle
  const processingQueue = [];
  let processing = false;
  const processNext = () => {
    processing = true;
    const { resolve, jsText } = processingQueue.shift();
    worker.addEventListener(
      'message',
      ({ data }) => {
        resolve(JSON.parse(data));
        if (processingQueue.length) {
          processNext();
        } else {
          processing = false;
        }
      },
      { once: true }
    );
    worker.postMessage(jsText);
  };
  return (jsText) => new Promise((resolve) => {
    processingQueue.push({ resolve, jsText });
    if (!processing) {
      processNext();
    }
  });
})();


// Calls worker:
(async () => {
  const checkSyntax = async (str) => {
    console.log('----------------------------------------');
     const stringToEval = `throw new Error('Parsing successful!');\n${str}`;
     const { lineno, colno, message } = await getErrorEvent(stringToEval);
     if (message === 'Uncaught Error: Parsing successful!') {
       console.log(`Parsing successful for: ${str}`);
       return;
     }
    console.log(`Syntax error thrown at: ${lineno - 1}:${colno}`);
    console.log(describeError(stringToEval, lineno, colno));
  };
  const describeError = (stringToEval, lineno, colno) => {
    const lines = stringToEval.split('\n');
    const line = lines[lineno - 1];
    return `${line}\n${' '.repeat(colno - 1) + '^'}`;
  };

  await checkSyntax(`console.log('I will throw') bar baz`);
  await checkSyntax(`foo bar baz will throw too`);
  await checkSyntax(`console.log('A snippet without compile errors'); const foo = bar;`);
  await checkSyntax(`console.log('A multi line snippet');
With a syntax error on the second line`);
})();
Look in your browser console to see this in action, not in the snippet console

Essentially, what checkSyntax is doing is checking to see if the code provided can be parsed into an Abstract Syntax Tree by the current interpreter. You can also use packages like @babel/parser or acorn to attempt to parse the string, though you'll have to configure it for the syntax permitted in the current environment (which will change as new syntax gets added to the language).

const checkSyntax = (str) => {
  try {
    acorn.Parser.parse(str);
    console.log('Parsing successful');
  } catch(e){
    console.error(e.message);
  }
};

checkSyntax(`console.log('I will throw') bar baz`);
checkSyntax(`foo bar baz will throw too`);
checkSyntax(`console.log('A snippet without compile errors'); const foo = bar;`);
checkSyntax(`console.log('A multi line snippet');
With a syntax error on the second line`);
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/acorn.min.js"></script>

The above works for browsers. In Node, the situation is different: listening for an uncaughtException can't be used to intercept the details of syntax errors, AFAIK. However, you can use vm module to attempt to compile the code, and if it throws a SyntaxError before running, you'll see something like this. Running

console.log('I will throw') bar baz

results in a stack of

evalmachine.<anonymous>:1
console.log('I will throw') bar baz
                            ^^^

SyntaxError: Unexpected identifier
    at createScript (vm.js:80:10)
    at Object.runInNewContext (vm.js:135:10)
    <etc>

So, just look at the first item in the stack to get the line number, and at the number of spaces before the ^ to get the column number. Using a similar technique as earlier, throw an error on the first line if parsing is successful:

const vm = require('vm');
const checkSyntax = (code) => {
  console.log('---------------------------');
  try {
    vm.runInNewContext(`throw new Error();\n${code}`);
  }
  catch (e) {
    describeError(e.stack);
  }
};
const describeError = (stack) => {
  const match = stack
    .match(/^\D+(\d+)\n(.+\n( *)\^+)\n\n(SyntaxError.+)/);
  if (!match) {
    console.log('Parse successful!');
    return;
  }
  const [, linenoPlusOne, caretString, colSpaces, message] = match;
  const lineno = linenoPlusOne - 1;
  const colno = colSpaces.length + 1;
  console.log(`${lineno}:${colno}: ${message}\n${caretString}`);
};


checkSyntax(`console.log('I will throw') bar baz`);
checkSyntax(`foo bar baz will throw too`);
checkSyntax(`console.log('A snippet without compile errors'); const foo = bar;`);
checkSyntax(`console.log('A multi line snippet');
With a syntax error on the second line`);

Result:

---------------------------
1:29: SyntaxError: Unexpected identifier
console.log('I will throw') bar baz
                            ^^^
---------------------------
1:5: SyntaxError: Unexpected identifier
foo bar baz will throw too
    ^^^
---------------------------
Parse successful!
---------------------------
2:6: SyntaxError: Unexpected identifier
With a syntax error on the second line
     ^

That said:

How can I, without using external dependencies, pinpoint SyntaxError location withinn passed string? I require solution both for browser and nodejs.

Unless you have to achieve this without an external library, using a library really would be the easiest (and tried-and-tested) solution. Acorn, as shown earlier (and other parsers) work in Node as well.

like image 127
CertainPerformance Avatar answered Sep 22 '22 23:09

CertainPerformance


I'm sumarizing comments and some additional research:

Simple anwer: currently impossible

There is currently no cross-platform way to retrive syntax error position from new Function() or eval() call.

Partial solutions

  1. Firefox support non-standard properties error.lineNumber and error.e.columnNumber. This can be used with feature detection if position of error is not critical.
  2. There are filled bug reports/feature request for v8 that could bring support of (1) to chrome/node.js: Issue #1281, #1914, #2589
  3. Use separate javascript parser, based on JSLint or PEG.js.
  4. Write custom javascript parser for the job.

Solutions 1 and 2 are incomplete, rely on features that are not part of standard. They can be suitable if this information is a help, not an requirement.

Solution 3 depends on external codebase, which was explicitly required by original question. It is suitable if this information is required and larger codebase is acceptable compromise.

Solution 4 is impractical.

Credits: @user3896470, @ivan-kuckir, @aprillion

like image 35
Koder Avatar answered Sep 21 '22 23:09

Koder