import BrowserErrorCaptureEnum from '../browser/enums/BrowserErrorCaptureEnum.js'; import WindowBrowserContext from '../window/WindowBrowserContext.js'; import ModuleURLUtility from './ModuleURLUtility.js'; import * as PropertySymbol from '../PropertySymbol.js'; /** * Statement regexp. * * Group 1: Import meta. * Group 2: Import without exported properties. * Group 3: Dynamic import function call. * Group 4: Import exported variables. * Group 5: Import exported url. * Group 6: Import with group. * Group 7: Import with type. * Group 8: Import spacing. * Group 9: Modules in export from module statement. * Group 10: Import in export from module statement. * Group 11: Export default indicator. * Group 12: Export async function indicator. * Group 13: Export function or class type. * Group 14: Export function or class name. * Group 15: Export default statement that is not a function or class. * Group 16: Export object. * Group 17: Export variable type (var, let or const). * Group 18: Export variable name. * Group 19: Export variable name end character (= or ;). */ const STATEMENT_REGEXP = /import\.meta\.([a-zA-Z]+)|import\s*["']([^"']+)["'];{0,1}|import\s*\(([^)]+)\)|(import[\s{])|[\s}]from\s*["']([^"']+)["'](\s+with\s*{\s*type\s*:\s*["']([^"']+)["']\s*}){0,1}([;\s]*)|export(\s+[a-zA-Z0-9-_$]+\s+|\s+\*\s+|\s+\*\s+as\s+["'a-zA-Z0-9-_$]+\s+|\s*{[^}]+}\s*)from\s*["']([^"']+)["']|export\s*(default\s+){0,1}(async\s+){0,1}(function\*{0,1}|class)\s*([^({\s]+)|(export\s*default\s*)|export\s*{([^}]+)}|export\s+(var|let|const)\s+([^=;]+)(=|;)/gm; /** * Syntax regexp. * * Group 1: Slash (RegExp or comment). * Group 2: Parentheses. * Group 3: Curly braces. * Group 4: Square brackets. * Group 5: Escape template string (${). * Group 6: Template string apostrophe (`). * Group 7: String apostrophe ('). * Group 8: String apostrophe ("). * Group 9: Line feed character. */ const SYNTAX_REGEXP = /(\/)|(\(|\))|({|})|(\[|\])|(\${)|(`)|(')|(")|(\n)/gm; /** * Import regexp. * * Group 1: Import braces. * Group 2: Import all as. * Group 3: Import default. */ const IMPORT_REGEXP = /{([^}]+)}|\*\s+as\s+([a-zA-Z0-9-_$]+)|([a-zA-Z0-9-_$]+)/gm; /** * Valid preceding token before a statement. */ const PRECEDING_STATEMENT_TOKEN_REGEXP = /['"`(){}\s;=><\[\]+-,:&]/; /** * Valid preceding token before a regexp. */ const PRECEDING_REGEXP_TOKEN_REGEXP = /['"`({};=><\[+-,:&]/; /** * Multiline comment regexp. */ const MULTILINE_COMMENT_REGEXP = /\/\*|\*\//gm; /** * Export regexp. */ const EXPORT_REGEXP = /export\s*/; /** * Export default regexp. */ const EXPORT_DEFAULT_REGEXP = /export\s*default\s*/; /** * Ends with semicolon regexp. */ const ENDS_WITH_SEMICOLON_REGEXP = /;\s*$/; /** * ECMAScript module compiler. */ export default class ECMAScriptModuleCompiler { window; debug = false; count = { comment: 0, singleLineComment: 0, parentheses: 0, curlyBraces: 0, squareBrackets: 0, regExp: 0, regExpSquareBrackets: 0, escapeTemplateString: 0, simpleString: 0, doubleString: 0 }; debugCount = { comment: [], singleLineComment: [], parentheses: [], curlyBraces: [], squareBrackets: [], regExp: [], regExpSquareBrackets: [], escapeTemplateString: [], simpleString: [], doubleString: [] }; templateString = []; /** * Constructor. * * @param window Window. * @param url Module URL. */ constructor(window) { this.window = window; } /** * Compiles code and returns imports and compiled code. * * @param moduleURL Module URL. * @param code Code. * @param sourceURL Source URL. * @returns Result. */ compile(moduleURL, code, sourceURL = null) { const browserSettings = new WindowBrowserContext(this.window).getSettings(); if (!browserSettings) { return { imports: [], execute: async () => { } }; } this.reset(); const regExp = new RegExp(STATEMENT_REGEXP); const imports = []; const resolvableCircularImports = []; const count = this.count; let newCode = ''; let newCodeStart = ''; let newCodeEnd = ''; let match = null; let precedingToken; let textBetweenStatements; let isTopLevel = true; let lastIndex = 0; let importStartIndex = -1; while ((match = regExp.exec(code))) { precedingToken = code[match.index - 1] || ' '; textBetweenStatements = code.substring(lastIndex, match.index); this.parseSyntax(textBetweenStatements, lastIndex); if (importStartIndex === -1) { newCode += textBetweenStatements; } // Imports and exports are only valid outside any statement, string or comment at the top level isTopLevel = count.comment === 0 && count.singleLineComment === 0 && count.parentheses === 0 && count.curlyBraces === 0 && count.squareBrackets === 0 && count.regExp === 0 && count.simpleString === 0 && count.doubleString === 0 && this.templateString.length === 0; if (match[1] && count.simpleString === 0 && count.doubleString === 0 && count.comment === 0 && count.singleLineComment === 0 && count.regExp === 0 && (this.templateString.length === 0 || this.templateString[0] > 0) && PRECEDING_STATEMENT_TOKEN_REGEXP.test(this.getNonSpacePrecedingToken(code, match.index))) { // Import meta newCode += `$happy_dom.importMeta.${match[1]}`; } else if (match[2] && isTopLevel && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { // Import without exported properties imports.push({ url: ModuleURLUtility.getURL(this.window, moduleURL, match[2]).href, type: 'esm' }); } else if (match[3] && count.simpleString === 0 && count.doubleString === 0 && count.comment === 0 && count.singleLineComment === 0 && count.regExp === 0 && (this.templateString.length === 0 || this.templateString[0] > 0) && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { // Dynamic import function call newCode += `$happy_dom.dynamicImport(${match[3]})`; } else if (match[4] && isTopLevel && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { // Import statement start if (importStartIndex !== -1) { throw new this.window.TypeError(`Failed to parse module: Unexpected import statement in "${moduleURL}"`); } importStartIndex = match.index + match[0].length - 1; } else if (match[5] && isTopLevel && importStartIndex !== -1) { // Import statement end const url = ModuleURLUtility.getURL(this.window, moduleURL, match[5]).href; const variables = code.substring(importStartIndex, match.index + 1); const importRegExp = new RegExp(IMPORT_REGEXP); let importMatch = null; while ((importMatch = importRegExp.exec(variables))) { if (importMatch[1]) { // Import braces const parts = this.removeMultilineComments(importMatch[1]).split(/\s*,\s*/); const properties = []; for (const part of parts) { const nameParts = part.trim().split(/\s+as\s+/); const alias = (nameParts[1] || nameParts[0]).replace(/["']/g, ''); const name = nameParts[0].replace(/["']/g, ''); if (alias && name) { if (name === alias) { properties.push({ name }); } else { properties.push({ name, alias }); } } } if (!match[7] || match[7] === 'esm') { resolvableCircularImports.push({ url, properties }); } newCodeStart += `let {${properties.map((property) => (property.alias ? `"${property.name}": ${property.alias}` : property.name)).join(', ')}} = $happy_dom.imports.get('${url}')${match[8]}`; } else if (importMatch[2]) { // Import all as newCodeStart += `const ${importMatch[2]} = $happy_dom.imports.get('${url}')${match[8]}`; } else if (importMatch[3]) { // Import default if (!match[7] || match[7] === 'esm') { resolvableCircularImports.push({ url, properties: [{ name: 'default', alias: importMatch[3] }] }); } newCodeStart += `let ${importMatch[3]} = $happy_dom.imports.get('${url}').default${match[8]}`; } } importStartIndex = -1; imports.push({ url, type: match[7] || 'esm' }); } else if (match[9] && match[10] && isTopLevel && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { // Export from module statement const url = ModuleURLUtility.getURL(this.window, moduleURL, match[10]).href; const imported = match[9].trim(); if (imported === '*') { newCode += `Object.assign($happy_dom.exports, $happy_dom.imports.get('${url}'))`; imports.push({ url, type: 'esm' }); } else if (imported[0] === '*') { const parts = imported.split(/\s+as\s+/); if (parts.length === 2) { const exportName = parts[1].replace(/["']/g, ''); newCode += `$happy_dom.exports['${exportName}'] = $happy_dom.imports.get('${url}')`; imports.push({ url, type: 'esm' }); } } else if (imported[0] === '{') { const parts = this.removeMultilineComments(imported) .slice(1, -1) .split(/\s*,\s*/); const exportCode = []; for (const part of parts) { const nameParts = part.trim().split(/\s+as\s+/); const exportName = (nameParts[1] || nameParts[0]).replace(/["']/g, ''); const importName = nameParts[0].replace(/["']/g, ''); if (exportName && importName) { exportCode.push(`$happy_dom.exports['${exportName}'] = $happy_dom.imports.get('${url}')['${importName}']`); } } newCode += exportCode.join(';\n'); imports.push({ url, type: 'esm' }); } } else if (match[13] && match[14] && isTopLevel && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { // Export function or class type const name = match[14].replace('*', ''); if (name) { if (match[11]) { newCode += match[0].replace(EXPORT_DEFAULT_REGEXP, ''); newCodeEnd += `$happy_dom.exports.default = ${name};\n`; } else { newCode += match[0].replace(EXPORT_REGEXP, ''); newCodeEnd += `$happy_dom.exports['${name}'] = ${name};\n`; } } else { if (match[11]) { newCode += `$happy_dom.exports.default = ${match[12] || ''}${match[13]}${name ? ' ' : ''}${match[14]}`; } else { throw new this.window.SyntaxError(`Failed to parse module: Missing function or class name in export statement in "${moduleURL}"`); } } } else if (match[15] && isTopLevel && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { // Export default statement newCode += '$happy_dom.exports.default = '; } else if (match[16] && isTopLevel && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { // Export object const parts = this.removeMultilineComments(match[16]).split(/\s*,\s*/); const exportCode = []; for (const part of parts) { const nameParts = part.trim().split(/\s+as\s+/); const exportName = (nameParts[1] || nameParts[0]).replace(/["']/g, ''); const importName = nameParts[0].replace(/["']/g, ''); if (exportName && importName) { exportCode.push(`$happy_dom.exports['${exportName}'] = ${importName}`); } } newCode += exportCode.join(';\n'); } else if (match[17] && isTopLevel && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { // Export variable if (match[19] === '=') { const exportName = this.removeMultilineComments(match[18]).trim(); if ((exportName[0] === '{' && exportName[exportName.length - 1] === '}') || (exportName[0] === '[' && exportName[exportName.length - 1] === ']')) { const parts = exportName.slice(1, -1).split(/\s*,\s*/); for (const part of parts) { const nameParts = part.trim().split(/\s*:\s*/); const exportName = (nameParts[1] || nameParts[0]).replace(/["']/g, ''); if (exportName) { newCodeEnd += `$happy_dom.exports['${exportName}'] = ${exportName};\n`; } } newCode += match[0].replace(EXPORT_REGEXP, ''); } else { newCode += match[0].replace(EXPORT_REGEXP, ''); newCodeEnd += `$happy_dom.exports['${exportName}'] = ${exportName};\n`; } } else { // Example: export let name1, name2, name3; newCode += match[0].replace(EXPORT_REGEXP, ''); const parts = this.removeMultilineComments(match[18]).split(','); for (const part of parts) { const exportName = part.trim(); newCodeEnd += `$happy_dom.exports['${exportName}'] = ${exportName};\n`; } } } else if (importStartIndex !== -1) { // Invalid import statement // E.g. "import defaultExport from invalid;" or just "import defaultExport;" const unparsed = code.substring(importStartIndex, match.index + 1); newCode += unparsed; this.parseSyntax(unparsed, importStartIndex); } else { // No valid statement found // This happens when there is a statement inside a string or comment // E.g. "const str = 'import defaultExport from invalid;';" newCode += match[0]; this.parseSyntax(match[0], match.index); } lastIndex = regExp.lastIndex; } // In debug mode we don't want to execute the code, just take information from it if (this.debug) { return { imports, execute: async () => { } }; } if (!!newCodeStart && !ENDS_WITH_SEMICOLON_REGEXP.test(newCodeStart)) { newCodeStart = `\n${newCodeStart};`; } if (!browserSettings.disableErrorCapturing && browserSettings.errorCapture === BrowserErrorCaptureEnum.tryAndCatch) { newCodeStart = '(async function anonymous($happy_dom) { try {' + newCodeStart; } else { newCodeStart = '(async function anonymous($happy_dom) {' + newCodeStart; } newCode = newCodeStart + newCode; if (lastIndex === 0) { newCode += code; } else { newCode += code.substring(lastIndex); } newCode += '\n' + newCodeEnd; if (resolvableCircularImports.length > 0) { newCode += '$happy_dom.addCircularImportResolver(() => {\n'; for (const circularImport of resolvableCircularImports) { for (const property of circularImport.properties) { if (property.alias) { newCode += `${property.alias} = $happy_dom.imports.get('${circularImport.url}')['${property.name}'];\n`; } else { newCode += `${property.name} = $happy_dom.imports.get('${circularImport.url}')['${property.name}'];\n`; } } } newCode += '});'; } if (!browserSettings.disableErrorCapturing && browserSettings.errorCapture === BrowserErrorCaptureEnum.tryAndCatch) { newCode += `} catch(e) { $happy_dom.dispatchError(e); }`; } newCode += '})'; try { return { imports, execute: this.window[PropertySymbol.evaluateScript](newCode, { filename: sourceURL || moduleURL }) }; } catch (e) { const compileError = this.getError(moduleURL, code, sourceURL); const error = compileError ? new this.window.SyntaxError(`Failed to parse module in '${moduleURL}'. Execution error: ${e.message}. Compile error: ${compileError}`) : e; e.message = `Failed to parse module in '${sourceURL}': ${error.message}`; if (browserSettings.disableErrorCapturing || browserSettings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch) { throw error; } else { this.window[PropertySymbol.dispatchError](error); return { imports, execute: async () => { } }; } } } /** * Parses syntax. * * @param code Code. * @param index Index. */ parseSyntax(code, index) { const regExp = new RegExp(SYNTAX_REGEXP); const count = this.count; const debug = this.debug; const debugCount = this.debugCount; let match = null; let precedingToken; let isEscaped; index++; while ((match = regExp.exec(code))) { precedingToken = code[match.index - 1] || ' '; isEscaped = precedingToken === '\\' && code[match.index - 2] !== '\\'; if (match[1]) { // Slash (RegExp or Comment) if (count.simpleString === 0 && count.doubleString === 0 && count.singleLineComment === 0 && count.regExpSquareBrackets === 0 && (this.templateString.length === 0 || this.templateString[0] > 0)) { if (count.comment === 1) { if (precedingToken === '*') { count.comment = 0; if (debug) { debugCount.comment.length = 0; } } } else { if (count.regExp === 0) { if (code[match.index + 1] === '*') { count.comment = 1; if (debug) { debugCount.comment = [index + match.index]; } } else if (code[match.index + 1] === '/') { count.singleLineComment = 1; if (debug) { debugCount.singleLineComment = [index + match.index]; } } else { if (PRECEDING_REGEXP_TOKEN_REGEXP.test(this.getNonSpacePrecedingToken(code, match.index))) { count.regExp = 1; if (debug) { debugCount.regExp = [index + match.index]; } } } } else if (!isEscaped) { count.regExp = 0; if (debug) { debugCount.regExp.length = 0; } } } } } else if (match[2]) { // Parentheses if (count.simpleString === 0 && count.doubleString === 0 && count.regExp === 0 && count.comment === 0 && count.singleLineComment === 0 && (this.templateString.length === 0 || this.templateString[0] > 0)) { if (match[2] === '(') { count.parentheses++; if (debug) { debugCount.parentheses.push(index + match.index); } } else if (match[2] === ')' && count.parentheses > 0) { count.parentheses--; if (debug) { debugCount.parentheses.pop(); } } } } else if (match[3]) { // Curly braces if (count.simpleString === 0 && count.doubleString === 0 && count.regExp === 0 && count.comment === 0 && count.singleLineComment === 0 && (this.templateString.length === 0 || this.templateString[0] > 0)) { if (match[3] === '{') { if (this.templateString.length) { this.templateString[0]++; } count.curlyBraces++; if (debug) { debugCount.curlyBraces.push(index + match.index); } } else if (match[3] === '}') { if (this.templateString.length && this.templateString[0] > 0) { this.templateString[0]--; } if (count.curlyBraces > 0) { count.curlyBraces--; if (debug) { debugCount.curlyBraces.pop(); } } } } } else if (match[4]) { // Square brackets if (count.simpleString === 0 && count.doubleString === 0 && count.comment === 0 && count.singleLineComment === 0 && (this.templateString.length === 0 || this.templateString[0] > 0)) { // We need to check for square brackets in RegExp as well to know when the RegExp ends if (count.regExp === 1) { if (!isEscaped) { if (match[4] === '[' && count.regExpSquareBrackets === 0) { count.regExpSquareBrackets = 1; if (debug) { debugCount.regExpSquareBrackets = [index + match.index]; } } else if (match[4] === ']' && count.regExpSquareBrackets === 1) { count.regExpSquareBrackets = 0; if (debug) { debugCount.regExpSquareBrackets.length = 0; } } } } else { if (match[4] === '[') { count.squareBrackets++; if (debug) { debugCount.squareBrackets.push(index + match.index); } } else if (match[4] === ']' && count.squareBrackets > 0) { count.squareBrackets--; if (debug) { debugCount.squareBrackets.pop(); } } } } } else if (match[5]) { // Escape template string (${) if (count.simpleString === 0 && count.doubleString === 0 && count.comment === 0 && count.singleLineComment === 0 && count.regExp === 0 && !isEscaped) { if (this.templateString[0] === 0) { this.templateString[0] = 1; count.curlyBraces++; if (debug) { debugCount.curlyBraces.push(index + match.index); } } } } else if (match[6]) { // Template string if (count.simpleString === 0 && count.doubleString === 0 && count.comment === 0 && count.singleLineComment === 0 && count.regExp === 0 && !isEscaped) { if (this.templateString?.[0] === 0) { this.templateString.shift(); } else { this.templateString.unshift(0); } } } else if (match[7]) { // String apostrophe (') if (count.doubleString === 0 && count.comment === 0 && count.singleLineComment === 0 && count.regExp === 0 && !isEscaped && (this.templateString.length === 0 || this.templateString[0] > 0)) { if (count.simpleString === 0) { count.simpleString = 1; if (debug) { debugCount.simpleString = [index + match.index]; } } else { count.simpleString = 0; if (debug) { debugCount.simpleString.length = 0; } } } } else if (match[8]) { // String apostrophe (") if (count.simpleString === 0 && count.comment === 0 && count.singleLineComment === 0 && count.regExp === 0 && !isEscaped && (this.templateString.length === 0 || this.templateString[0] > 0)) { if (count.doubleString === 0) { count.doubleString = 1; if (debug) { debugCount.doubleString = [index + match.index]; } } else { count.doubleString = 0; if (debug) { debugCount.doubleString.length = 0; } } } } else if (match[9]) { // Line feed character count.singleLineComment = 0; if (debug) { debugCount.singleLineComment.length = 0; } } } } /** * Returns error. * * @param moduleURL Module URL. * @param code Code. * @param sourceURL Source URL. * @returns Error. */ getError(moduleURL, code, sourceURL) { if (this.count.comment === 0 && this.count.singleLineComment === 0 && this.count.parentheses === 0 && this.count.curlyBraces === 0 && this.count.squareBrackets === 0 && this.count.regExp === 0 && this.count.regExpSquareBrackets === 0 && this.count.simpleString === 0 && this.count.doubleString === 0) { return null; } // Enable debug mode to get error information if (!this.debug) { this.debug = true; this.compile(moduleURL, code, sourceURL); } const debugCount = this.debugCount; if (debugCount.comment.length > 0) { const { line, column } = this.getLineAndColumn(code, debugCount.comment[0]); return `Missing end of comment on line ${line} and column ${column}`; } else if (debugCount.regExp.length > 0) { const { line, column } = this.getLineAndColumn(code, debugCount.regExp[0]); return `Missing end of regular expression on line ${line} and column ${column}`; } else if (debugCount.regExpSquareBrackets.length > 0) { const { line, column } = this.getLineAndColumn(code, debugCount.regExpSquareBrackets[0]); return `Missing end of regular expression square brackets on line ${line} and column ${column}`; } else if (debugCount.simpleString.length > 0) { const { line, column } = this.getLineAndColumn(code, debugCount.simpleString[0]); return `Missing end of single quote string on line ${line} and column ${column}`; } else if (debugCount.doubleString.length > 0) { const { line, column } = this.getLineAndColumn(code, debugCount.doubleString[0]); return `Missing end of double quote string on line ${line} and column ${column}`; } else if (debugCount.parentheses.length > 0) { const { line, column } = this.getLineAndColumn(code, debugCount.parentheses[0]); return `Missing end of parentheses on line ${line} and column ${column}`; } else if (debugCount.curlyBraces.length > 0) { const { line, column } = this.getLineAndColumn(code, debugCount.curlyBraces[0]); return `Missing end of curly braces on line ${line} and column ${column}`; } else if (debugCount.squareBrackets.length > 0) { const { line, column } = this.getLineAndColumn(code, debugCount.squareBrackets[0]); return `Missing end of square brackets on line ${line} and column ${column}`; } else if (debugCount.escapeTemplateString.length > 0) { const { line, column } = this.getLineAndColumn(code, debugCount.escapeTemplateString[0]); return `Missing end of escaped template string on line ${line} and column ${column}`; } return null; } /** * Returns line and column for an error. * * @param code Code. * @param index Index. * @returns Line and column. */ getLineAndColumn(code, index) { const lines = code.substring(0, index).split('\n'); const line = lines.length; const column = lines[lines.length - 1].length; return { line, column }; } /** * Get the non-space preceding token. * * @param code Code. * @param index Index. * @returns Non-space preceding token. */ getNonSpacePrecedingToken(code, index) { index--; let nonSpacePrecedingToken = code[index]; while (nonSpacePrecedingToken === ' ' || nonSpacePrecedingToken === '\n' || nonSpacePrecedingToken === '\t') { index--; nonSpacePrecedingToken = code[index]; } return nonSpacePrecedingToken || ';'; } /** * Remove multiline comments. * * @param code Code. * @returns Code without multiline comments. */ removeMultilineComments(code) { const regexp = new RegExp(MULTILINE_COMMENT_REGEXP); let match; let count = 0; let lastIndex = 0; let newCode = ''; while ((match = regexp.exec(code))) { if (count === 0) { newCode += code.substring(lastIndex, match.index); } if (match[0] === '/*') { count++; } else if (match[0] === '*/' && count > 0) { count--; } lastIndex = regexp.lastIndex; } newCode += code.substring(lastIndex); return newCode; } /** * Resets the compiler state. */ reset() { this.count = { comment: 0, singleLineComment: 0, parentheses: 0, curlyBraces: 0, squareBrackets: 0, regExp: 0, regExpSquareBrackets: 0, escapeTemplateString: 0, simpleString: 0, doubleString: 0 }; this.debugCount = { comment: [], singleLineComment: [], parentheses: [], curlyBraces: [], squareBrackets: [], regExp: [], regExpSquareBrackets: [], escapeTemplateString: [], simpleString: [], doubleString: [] }; this.templateString = []; } } //# sourceMappingURL=ECMAScriptModuleCompiler.js.map