Skip to content
Merged
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
42 changes: 31 additions & 11 deletions packages/vite/hmr/server/deps-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ import { collectVendorModules } from '../shared/vendor/manifest-collect.js';
import { generatePlatformPolyfills } from '../shared/runtime/platform-polyfills.js';
import { getAngularLinkerFactory, runAngularLinker } from '../frameworks/angular/build/shared-linker.js';
import { getBlockedDeviceNodeModulesReason, isFlavorClientPackageSpecifier, resolveCandidateFilePath } from './websocket-module-specifiers.js';
import { extractDirectExportedNames, JS_IDENTIFIER_RE, parseExportSpecList } from './websocket-core-bridge.js';
import { extractDirectExportedNames, JS_IDENTIFIER_RE, maskJsComments, parseExportSpecList } from './websocket-core-bridge.js';
import { hasExistingDefaultExport } from './ns-core-cjs-shape.js';
import { getCjsNamedExports } from '../helpers/cjs-named-exports.js';
import { loadPersistedBootRecording } from './boot-recording.js';
Expand Down Expand Up @@ -850,7 +850,15 @@ export interface DepsModuleExportInfo {
opaqueCjs?: boolean;
}

const ESM_SYNTAX_RE = /^[ \t]*(?:import[\s{"'*]|export[\s{*])/m;
// ESM markers can sit anywhere on a line — minified chunks emit

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

those AI comments are far too long. I think it is best to limit any comment to 2/3 lines or the code will become a book to read with a few lines of code ;)
it applies to all comments

// `...;export{mt as g};` after other statements, and a line-anchored check
// misclassifies them as opaque CJS (the served shim then drops every named
// export). The pattern runs against a copy with comments and string/template/
// regex literal interiors blanked (positions preserved), so look-alike text
// inside literals can never match. The lookbehind keeps `$export`-style
// identifiers and `obj.import`/`obj.export` member access from matching.
const ESM_PREFIX = '(?<![.$\\u200c\\u200d\\p{ID_Continue}])';
const ESM_SYNTAX_RE = new RegExp(ESM_PREFIX + String.raw`(?:import[\s{"'*]|export[\s{*])`, 'u');

function resolveLocalReExportTarget(spec: string, importerPath: string, platform: string): string | null {
const base = path.resolve(path.dirname(importerPath), spec);
Expand Down Expand Up @@ -934,22 +942,34 @@ export function collectDepsModuleExportInfo(absPath: string, platform: string, s
} catch {
return { names: null, hasDefault: false };
}
if (!/\.mjs$/i.test(absPath) && !ESM_SYNTAX_RE.test(code)) return collectCjsExportInfo(code);
const names = new Set<string>(extractDirectExportedNames(code));
const maskedCode = maskJsComments(code, true);
if (!/\.mjs$/i.test(absPath) && !ESM_SYNTAX_RE.test(maskedCode)) return collectCjsExportInfo(code);
const names = new Set<string>(extractDirectExportedNames(maskedCode));
let match: RegExpExecArray | null;
const starAsRe = /\bexport\s+\*\s+as\s+([$_\p{ID_Start}][$\p{ID_Continue}]*)\s+from\s+["'][^"']+["']/gu;
while ((match = starAsRe.exec(code)) !== null) {
// Minifiers drop the spaces `export * as ns from "x"` keeps — allow none.
const starAsRe = new RegExp(ESM_PREFIX + String.raw`export\s*\*\s*as\s+([$_\p{ID_Start}][$\p{ID_Continue}]*)\s+from\s*["'][^"']*["']`, 'gu');
while ((match = starAsRe.exec(maskedCode)) !== null) {
names.add(match[1]);
}
const braceRe = /\bexport\s*\{([^}]+)\}/g;
while ((match = braceRe.exec(code)) !== null) {
const braceRe = new RegExp(ESM_PREFIX + String.raw`export\s*\{([^}]+)\}`, 'gu');
while ((match = braceRe.exec(maskedCode)) !== null) {
for (const { exportedName } of parseExportSpecList(match[1])) {
names.add(exportedName);
}
}
const starRe = /^[ \t]*export\s+\*\s+from\s+["']([^"']+)["']/gm;
while ((match = starRe.exec(code)) !== null) {
const spec = match[1];
// `export * from` carries the same mid-line minification hazard — find it
// on the masked copy, then read the specifier literal from the original
// code at the same offset (masked interiors are blanked).
const starRe = new RegExp(ESM_PREFIX + String.raw`export\s*\*\s*from\s*(["'])`, 'gu');
while ((match = starRe.exec(maskedCode)) !== null) {
const quoteIndex = match.index + match[0].length - 1;
const quote = match[1];
let specEnd = quoteIndex + 1;
while (specEnd < code.length && code[specEnd] !== quote) {
specEnd += code[specEnd] === '\\' ? 2 : 1;
}
if (specEnd >= code.length) return { names: null, hasDefault: false };
const spec = code.slice(quoteIndex + 1, specEnd);
const target = spec.startsWith('.') ? resolveLocalReExportTarget(spec, absPath, platform) : resolveBareReExportTarget(spec, absPath, platform);
if (!target) return { names: null, hasDefault: false };
const child = collectDepsModuleExportInfo(target, platform, seen);
Expand Down
104 changes: 82 additions & 22 deletions packages/vite/hmr/server/websocket-core-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,12 @@ const REGEX_PRECEDING_KEYWORD_RE = /(?:^|[^$\w])(?:return|typeof|instanceof|in|o
/**
* Blank line and block comments (preserving newlines) so the regex export
* scanners never see a commented-out `export` declaration. String, template
* and regex literals are skipped so their contents are left intact.
* and regex literals are skipped so their contents are left intact — pass
* `literals` to blank their interiors too (keeping quotes/positions) so the
* scanners can also trust keyword matches as real syntax.
*/
export function maskJsComments(code: string): string {
if (!code || (code.indexOf('//') === -1 && code.indexOf('/*') === -1)) {
export function maskJsComments(code: string, literals = false): string {
if (!code || (!literals && code.indexOf('//') === -1 && code.indexOf('/*') === -1)) {
return code;
}
const chars = code.split('');
Expand All @@ -69,6 +71,77 @@ export function maskJsComments(code: string): string {
if ('(,=:[!&|?{};+-*%<>~^'.includes(code[j])) return true;
return REGEX_PRECEDING_KEYWORD_RE.test(code.slice(Math.max(0, j - 10), j + 1));
};
// Skip a regex literal starting at `at`; returns the index just past it.
const skipRegex = (at: number): number => {
let j = at + 1;
let inClass = false;
while (j < n && code[j] !== '\n') {
const r = code[j];
if (r === '\\') {
j += 2;
continue;
}
if (r === '[') inClass = true;
else if (r === ']') inClass = false;
else if (r === '/' && !inClass) break;
j++;
}
return j + 1;
};
// Skip a string/template literal starting at `at`; returns the index just
// past it. Template `${}` expressions may hold nested literals, comments
// and regexes — a naive next-backtick scan closes early and desyncs every
// later literal (`...it's...` inside a template opened a fake string).
const skipQuoted = (at: number): number => {
const quote = code[at];
let j = at + 1;
if (quote !== '`') {
while (j < n && code[j] !== quote) {
j += code[j] === '\\' ? 2 : 1;
}
return j + 1;
}
while (j < n) {
const t = code[j];
if (t === '\\') {
j += 2;
continue;
}
if (t === '`') return j + 1;
if (t === '$' && code[j + 1] === '{') {
j += 2;
let depth = 1;
while (j < n && depth) {
const e = code[j];
const e2 = code[j + 1];
if (e === "'" || e === '"' || e === '`') {
j = skipQuoted(j);
continue;
}
if (e === '/' && e2 === '/') {
const eol = code.indexOf('\n', j);
j = eol === -1 ? n : eol;
continue;
}
if (e === '/' && e2 === '*') {
const close = code.indexOf('*/', j + 2);
j = close === -1 ? n : close + 2;
continue;
}
if (e === '/' && slashStartsRegex(j)) {
j = skipRegex(j);
continue;
}
if (e === '{') depth++;
else if (e === '}') depth--;
j++;
}
continue;
}
j++;
}
return j;
};
let i = 0;
while (i < n) {
const c = code[i];
Expand All @@ -84,26 +157,13 @@ export function maskJsComments(code: string): string {
blank(i, stop);
i = stop;
} else if (c === "'" || c === '"' || c === '`') {
i++;
while (i < n && code[i] !== c) {
i += code[i] === '\\' ? 2 : 1;
}
i++;
const start = i;
i = skipQuoted(i);
if (literals) blank(start + 1, i - 1);
} else if (c === '/' && slashStartsRegex(i)) {
i++;
let inClass = false;
while (i < n && code[i] !== '\n') {
const r = code[i];
if (r === '\\') {
i += 2;
continue;
}
if (r === '[') inClass = true;
else if (r === ']') inClass = false;
else if (r === '/' && !inClass) break;
i++;
}
i++;
const start = i;
i = skipRegex(i);
if (literals) blank(start, i);
} else {
i++;
}
Expand Down
Loading