UNPKG

happy-dom

Version:

Happy DOM is a JavaScript implementation of a web browser without its graphical user interface. It includes many web standards from WHATWG DOM and HTML.

568 lines (534 loc) 17.9 kB
import BrowserErrorCaptureEnum from '../browser/enums/BrowserErrorCaptureEnum.js'; import BrowserWindow from '../window/BrowserWindow.js'; import WindowBrowserContext from '../window/WindowBrowserContext.js'; import IECMAScriptModuleCompiledResult from './IECMAScriptModuleCompiledResult.js'; import IECMAScriptModuleImport from './IECMAScriptModuleImport.js'; import ModuleURLUtility from './ModuleURLUtility.js'; import * as PropertySymbol from '../PropertySymbol.js'; /** * Code regexp. * * Group 1: Import without exported properties. * Group 2: Dynamic import function call. * Group 3: Import exported variables. * Group 4: Import exported url. * Group 5: Import with group. * Group 6: Import with type. * Group 7: Modules in export from module statement. * Group 8: Import in export from module statement. * Group 9: Export default statement. * Group 10: Export function or class type. * Group 11: Export function or class name. * Group 12: Export object. * Group 13: Export variable type (var, let or const). * Group 14: Export variable name. * Group 15: Export variable name end character (= or ;). * Group 16: Slash (RegExp or comment). * Group 17: Parentheses. * Group 18: Curly braces. * Group 19: Square brackets. * Group 20: Escape template string (${). * Group 21: Template string apostrophe (`). * Group 22: String apostrophe ('). * Group 23: String apostrophe ("). * Group 24: Line feed character. */ const CODE_REGEXP = /import\s*["']([^"']+)["'];{0,1}|import\s*\(([^)]+)\)|(import[\s{])|[\s}]from\s*["']([^"']+)["'](\s+with\s*{\s*type\s*:\s*["']([^"']+)["']\s*}){0,1}|export\s([a-zA-Z0-9-_$]+|\*|\*\s+as\s+["'a-zA-Z0-9-_$]+|{[^}]+})\s*from\s["']([^"']+)["']|(export\s*default\s*)|export\s*(function\*{0,1}|class)\s*([^({\s]+)|export\s*{([^}]+)}|export\s+(var|let|const)\s+([^=;]+)(=|;)|(\/)|(\(|\))|({|})|(\[|\])|(\${)|(`)|(')|(")|(\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; /** * ECMAScript module compiler. */ export default class ECMAScriptModuleCompiler { public readonly window: BrowserWindow; /** * Constructor. * * @param window Window. * @param url Module URL. */ constructor(window: BrowserWindow) { this.window = window; } /** * Compiles code and returns imports and compiled code. * * @param moduleURL Module URL. * @param code Code. * @returns Result. */ public compile(moduleURL: string, code: string): IECMAScriptModuleCompiledResult { const browserSettings = new WindowBrowserContext(this.window).getSettings(); const regExp = new RegExp(CODE_REGEXP); const imports: IECMAScriptModuleImport[] = []; const count = { comment: 0, singleLineComment: 0, parantheses: 0, curlyBraces: 0, squareBrackets: 0, regExp: 0, regExpSquareBrackets: 0, escapeTemplateString: 0, simpleString: 0, doubleString: 0 }; const stack: { templateString: { index: number | null; code: string[] } } = { templateString: { index: null, code: [] } }; const templateString: number[] = []; const exportSpreadVariables: Array<Map<string, string>> = []; let newCode = `(async function anonymous($happy_dom) {\n//# sourceURL=${moduleURL}\n`; let match: RegExpExecArray; let precedingToken: string; let isEscaped: boolean; let lastIndex = 0; let importStartIndex = -1; let skipMatchedCode = false; if ( !browserSettings.disableErrorCapturing && browserSettings.errorCapture === BrowserErrorCaptureEnum.tryAndCatch ) { newCode += 'try {\n'; } while ((match = regExp.exec(code))) { if (importStartIndex === -1) { newCode += code.substring(lastIndex, match.index); } precedingToken = code[match.index - 1] || ' '; isEscaped = precedingToken === '\\' && code[match.index - 2] !== '\\'; // Imports and exports are only valid outside any statement, string or comment at the top level if ( count.comment === 0 && count.singleLineComment === 0 && count.parantheses === 0 && count.curlyBraces === 0 && count.squareBrackets === 0 && count.regExp === 0 && count.simpleString === 0 && count.doubleString === 0 && templateString.length === 0 ) { if (match[1] && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { // Import without exported properties imports.push({ url: ModuleURLUtility.getURL(this.window, moduleURL, match[1]).href, type: 'esm' }); skipMatchedCode = true; } else if (match[3] && 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; skipMatchedCode = true; } else if (match[4]) { // Import statement end if (importStartIndex !== -1) { const url = ModuleURLUtility.getURL(this.window, moduleURL, match[4]).href; const variables = code.substring(importStartIndex, match.index + 1); const importRegExp = new RegExp(IMPORT_REGEXP); const importCode: string[] = []; let importMatch: RegExpExecArray; while ((importMatch = importRegExp.exec(variables))) { if (importMatch[1]) { // Import braces importCode.push( `const {${importMatch[1].replace( /\s+as\s+/gm, ': ' )}} = $happy_dom.imports.get('${url}')` ); } else if (importMatch[2]) { // Import all as importCode.push(`const ${importMatch[2]} = $happy_dom.imports.get('${url}')`); } else if (importMatch[3]) { // Import default importCode.push( `const ${importMatch[3]} = $happy_dom.imports.get('${url}').default` ); } } newCode += importCode.join(';\n'); importStartIndex = -1; imports.push({ url, type: match[6] || 'esm' }); skipMatchedCode = true; } } else if (match[7] && match[8] && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { // Export from module statement const url = ModuleURLUtility.getURL(this.window, moduleURL, match[8]).href; const imported = match[7]; 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: string[] = []; 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' }); } skipMatchedCode = true; } else if (match[9] && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { // Export default statement newCode += '$happy_dom.exports.default = '; skipMatchedCode = true; } else if ( match[10] && match[11] && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken) ) { // Export function or class type newCode += `$happy_dom.exports['${match[11]}'] = ${match[10]} ${match[11]}`; skipMatchedCode = true; } else if (match[12] && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { // Export object const parts = this.removeMultilineComments(match[12]).split(/\s*,\s*/); const exportCode: string[] = []; 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'); skipMatchedCode = true; } else if (match[13] && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken)) { // Export variable if (match[15] === '=') { const exportName = this.removeMultilineComments(match[14]).trim(); if ( (exportName[0] === '{' && exportName[exportName.length - 1] === '}') || (exportName[0] === '[' && exportName[exportName.length - 1] === ']') ) { const parts = exportName.slice(1, -1).split(/\s*,\s*/); const variableObject: Map<string, string> = new Map(); for (const part of parts) { const nameParts = part.trim().split(/\s*:\s*/); const exportName = (nameParts[1] || nameParts[0]).replace(/["']/g, ''); const importName = nameParts[0].replace(/["']/g, ''); if (exportName && importName) { variableObject.set(exportName, importName); } } newCode += `const $happy_dom_export_${exportSpreadVariables.length} =`; exportSpreadVariables.push(variableObject); } else { newCode += `$happy_dom.exports['${exportName}'] =`; } } else { // TODO: If there is no =, we should ignore until we know what it is useful for // Example: export let name1, name2, name3; newCode += `/*Unknown export: ${match[0]}*/`; this.window.console.warn(`Unknown export in "${moduleURL}": ${match[0]}`); } skipMatchedCode = true; } } if (match[2]) { // Dynamic import function call if ( count.simpleString === 0 && count.doubleString === 0 && count.comment === 0 && count.singleLineComment === 0 && count.regExp === 0 && (templateString.length === 0 || templateString[0] > 0) && PRECEDING_STATEMENT_TOKEN_REGEXP.test(precedingToken) ) { newCode += `$happy_dom.dynamicImport(${match[2]})`; skipMatchedCode = true; } } else if (match[16]) { // Slash (RegExp or Comment) if ( count.simpleString === 0 && count.doubleString === 0 && count.singleLineComment === 0 && count.regExpSquareBrackets === 0 && (templateString.length === 0 || templateString[0] > 0) ) { if (count.comment === 1) { if (precedingToken === '*') { count.comment = 0; } } else { if (count.regExp === 0) { if (code[match.index + 1] === '*') { count.comment = 1; } else if (code[match.index + 1] === '/') { count.singleLineComment = 1; } else { if (!isEscaped) { let index = match.index - 1; let nonSpacePrecedingToken = code[index]; while (nonSpacePrecedingToken === ' ' || nonSpacePrecedingToken === '\n') { index--; nonSpacePrecedingToken = code[index]; } if (PRECEDING_REGEXP_TOKEN_REGEXP.test(nonSpacePrecedingToken)) { count.regExp = 1; } } } } else if (!isEscaped) { count.regExp = 0; } } } } else if (match[17]) { // Parentheses if ( count.simpleString === 0 && count.doubleString === 0 && count.regExp === 0 && count.comment === 0 && count.singleLineComment === 0 && (templateString.length === 0 || templateString[0] > 0) ) { if (match[17] === '(') { count.parantheses++; } else if (match[17] === ')' && count.parantheses > 0) { count.parantheses--; } } } else if (match[18]) { // Curly braces if ( count.simpleString === 0 && count.doubleString === 0 && count.regExp === 0 && count.comment === 0 && count.singleLineComment === 0 && (templateString.length === 0 || templateString[0] > 0) ) { if (match[18] === '{') { if (templateString.length) { templateString[0]++; } count.curlyBraces++; } else if (match[18] === '}') { if (templateString.length && templateString[0] > 0) { templateString[0]--; } if (count.curlyBraces > 0) { count.curlyBraces--; } } } } else if (match[19]) { // Square brackets if ( count.simpleString === 0 && count.doubleString === 0 && count.comment === 0 && count.singleLineComment === 0 && (templateString.length === 0 || 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[19] === '[' && count.regExpSquareBrackets === 0) { count.regExpSquareBrackets = 1; } else if (match[19] === ']' && count.regExpSquareBrackets === 1) { count.regExpSquareBrackets = 0; } } } else { if (match[19] === '[') { count.squareBrackets++; } else if (match[19] === ']' && count.squareBrackets > 0) { count.squareBrackets--; } } } } else if (match[20]) { // Escape template string (${) if ( count.simpleString === 0 && count.doubleString === 0 && count.comment === 0 && count.singleLineComment === 0 && count.regExp === 0 && !isEscaped ) { if (templateString.length > 0) { templateString[0]++; } count.curlyBraces++; } } else if (match[21]) { // Template string if ( count.simpleString === 0 && count.doubleString === 0 && count.comment === 0 && count.singleLineComment === 0 && count.regExp === 0 && !isEscaped ) { if (templateString?.[0] == 0) { templateString.shift(); stack.templateString.code.push( code.substring(stack.templateString.index, match.index + 1) ); } else { templateString.unshift(0); stack.templateString.index = match.index; } } } else if (match[22]) { // String apostrophe (') if ( count.doubleString === 0 && count.comment === 0 && count.singleLineComment === 0 && count.regExp === 0 && !isEscaped && (templateString.length === 0 || templateString[0] > 0) ) { if (count.simpleString === 0) { count.simpleString = 1; } else { count.simpleString = 0; } } } else if (match[23]) { // String apostrophe (") if ( count.simpleString === 0 && count.comment === 0 && count.singleLineComment === 0 && count.regExp === 0 && !isEscaped && (templateString.length === 0 || templateString[0] > 0) ) { if (count.doubleString === 0) { count.doubleString = 1; } else { count.doubleString = 0; } } } else if (match[24]) { // Line feed character count.singleLineComment = 0; } // Unless the code has been handled by transforming imports or exports, we add it to the new code if (!skipMatchedCode && importStartIndex === -1) { newCode += match[0]; } skipMatchedCode = false; lastIndex = regExp.lastIndex; } if (importStartIndex !== -1) { // We will end up here if there is an import statement without a valid "from" part // E.g. "import defaultExport from invalid;" or just "import defaultExport;" throw new this.window.TypeError( `Failed to parse module: Unexpected import statement in "${moduleURL}"` ); } newCode += code.substring(lastIndex); if (exportSpreadVariables.length > 0) { newCode += '\n\n'; for (let i = 0; i < exportSpreadVariables.length; i++) { for (const [exportName, importName] of exportSpreadVariables[i]) { newCode += `$happy_dom.exports['${exportName}'] = $happy_dom_export_${i}['${importName}'];\n`; } } } if ( !browserSettings.disableErrorCapturing && browserSettings.errorCapture === BrowserErrorCaptureEnum.tryAndCatch ) { newCode += `\n} catch(e) {\n $happy_dom.dispatchError(e);\n}`; } newCode += '\n})'; try { return { imports, execute: this.window.eval(newCode) }; } catch (e) { const error = new this.window.SyntaxError( `Failed to parse module '${moduleURL}': ${e.message}` ); if ( browserSettings.disableErrorCapturing || browserSettings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch ) { throw error; } else { this.window[PropertySymbol.dispatchError](error); return { imports, execute: () => {} }; } } } /** * Remove multiline comments. * * @param code Code. * @returns Code without multiline comments. */ private removeMultilineComments(code: string): string { const regexp = new RegExp(MULTILINE_COMMENT_REGEXP); let match: RegExpExecArray; 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; } }