- Delete old Vite+Svelte frontend - Initialize new SvelteKit project with TypeScript - Configure Tailwind CSS v4 + DaisyUI - Implement JWT authentication with auto-refresh - Create login page with form validation (Zod) - Add protected route guards - Update Docker configuration for single-stage build - Add E2E tests with Playwright (6/11 passing) - Fix Svelte 5 reactivity with $state() runes Known issues: - 5 E2E tests failing (timing/async issues) - Token refresh implementation needs debugging - Validation error display timing
521 lines
23 KiB
JavaScript
521 lines
23 KiB
JavaScript
import BrowserErrorCaptureEnum from '../browser/enums/BrowserErrorCaptureEnum.js';
|
|
import WindowBrowserContext from '../window/WindowBrowserContext.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 {
|
|
window;
|
|
/**
|
|
* 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.
|
|
* @returns Result.
|
|
*/
|
|
compile(moduleURL, code) {
|
|
const browserSettings = new WindowBrowserContext(this.window).getSettings();
|
|
const regExp = new RegExp(CODE_REGEXP);
|
|
const imports = [];
|
|
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: null, code: [] }
|
|
};
|
|
const templateString = [];
|
|
const exportSpreadVariables = [];
|
|
let newCode = `(async function anonymous($happy_dom) {\n//# sourceURL=${moduleURL}\n`;
|
|
let match;
|
|
let precedingToken;
|
|
let isEscaped;
|
|
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 = [];
|
|
let importMatch;
|
|
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 = [];
|
|
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 = [];
|
|
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 = 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.
|
|
*/
|
|
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;
|
|
}
|
|
}
|
|
//# sourceMappingURL=ECMAScriptModuleCompiler.js.map
|