From 30bf3a30b39287f0cf69f5b8c3e3fbdef4ac4c19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Sat, 29 Aug 2026 03:20:27 +0200 Subject: [PATCH 1/5] Avoid duplicate CommonJS TypeScript transforms (GOL-418) --- crates/wasm-rquickjs/skeleton/Cargo.toml_ | 2 + .../skeleton/src/builtin/module.js | 181 ++++++++++-- .../skeleton/src/builtin/typescript.rs | 5 + .../skeleton/src/internal/module_loading.rs | 272 +++++++++++++++--- .../src/typescript-runtime.js | 142 +++++++++ .../src/typescript-transform-runtime.js | 1 + tests/common/mod.rs | 9 +- tests/runtime/typescript_runtime.rs | 37 +++ 8 files changed, 596 insertions(+), 53 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/Cargo.toml_ b/crates/wasm-rquickjs/skeleton/Cargo.toml_ index 2303ddcc..711c1856 100644 --- a/crates/wasm-rquickjs/skeleton/Cargo.toml_ +++ b/crates/wasm-rquickjs/skeleton/Cargo.toml_ @@ -83,6 +83,8 @@ timezone = ["dep:chrono-tz"] # TypeScript typescript-runtime = ["dep:swc_common", "dep:swc_ts_fast_strip"] typescript-transform-runtime = ["typescript-runtime"] +# Test-harness-only transform counting. This is intentionally excluded from every runtime tier. +typescript-transform-observability = ["typescript-runtime"] # WebSocket support via the target-specific `golem:websocket@1.5.0` bindings. websocket = ["dep:golem-websocket"] diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 8b4ccf24..28564914 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -65,6 +65,7 @@ import { eval_with_filename as _evalWithFilename, require_esm as _requireEsm } f import { transform_typescript as transformTypeScriptNative, transform_typescript_module as transformTypeScriptModuleNative, + transform_observability_enabled as typeScriptTransformObservabilityEnabledNative, } from '__wasm_rquickjs_builtin/typescript_native'; const objectPrototypeHasOwnProperty = Function.prototype.call.bind(Object.prototype.hasOwnProperty); @@ -1601,15 +1602,61 @@ function isTypeScriptFilename(filename) { return filename.endsWith('.ts') || filename.endsWith('.cts') || filename.endsWith('.mts'); } +let recordTypeScriptModuleTransform = () => {}; +if (typeScriptTransformObservabilityEnabledNative()) { + let typeScriptModuleTransformCount = 0; + recordTypeScriptModuleTransform = () => { typeScriptModuleTransformCount += 1; }; + Object.defineProperties(globalThis, { + __wasm_rquickjs_record_typescript_module_transform: { + value: recordTypeScriptModuleTransform, + writable: false, + configurable: false, + }, + __wasm_rquickjs_get_typescript_module_transform_count: { + value: () => typeScriptModuleTransformCount, + writable: false, + configurable: false, + }, + __wasm_rquickjs_reset_typescript_module_transform_count: { + value: () => { typeScriptModuleTransformCount = 0; }, + writable: false, + configurable: false, + }, + }); +} + function transpileTypeScriptModule(filename, source, module = undefined) { if (!isTypeScriptFilename(filename)) { return source; } // Rust owns the transform semantics. This adapter only applies CommonJS // loader policy; the Rust filesystem loader applies the same service for ESM. - return JSON.parse(transformTypeScriptModuleNative( + const output = JSON.parse(transformTypeScriptModuleNative( String(source), filename, module - )).code; + )); + recordTypeScriptModuleTransform(); + return output.code; +} + +function prepareCommonJsTypeScript(filename, source) { + if (!isTypeScriptFilename(filename)) { + return { source, exportNames: undefined, preparedTypeScriptGraph: undefined }; + } + const preparedSource = transpileTypeScriptModule(filename, source, false); + const analysis = wasmRquickjsModuleGlobalThis.__wasm_rquickjs_analyze_cjs_typescript_for_require( + filename, + preparedSource, + ); + return { + source: preparedSource, + exportNames: analysis.exports, + preparedTypeScriptGraph: analysis.preparedTypeScriptGraph, + }; +} + +function clearPreparedTypeScriptGraph(graph) { + if (!graph || typeof graph !== 'object') return; + for (const filename of Object.keys(graph)) delete graph[filename]; } export function stripTypeScriptTypes(code, options = undefined) { @@ -1858,7 +1905,7 @@ const cjsEsmDefaultSnapshotToken = {}; function installCjsEsmDefaultSnapshotSlot(mod) { if (!mod || (typeof mod !== 'object' && typeof mod !== 'function') || cjsFacadeHasOwnProperty(mod, cjsEsmDefaultSnapshotSymbol)) return; - const state = { captured: false, value: undefined }; + const state = { captured: false, value: undefined, typeScriptExportNames: undefined }; Object.defineProperty(mod, cjsEsmDefaultSnapshotSymbol, { value: function cjsEsmDefaultSnapshotSlot(token, op, value) { if (token !== cjsEsmDefaultSnapshotToken) return undefined; @@ -1871,6 +1918,11 @@ function installCjsEsmDefaultSnapshotSlot(mod) { } if (op === 'has') return state.captured; if (op === 'get') return state.value; + if (op === 'set-typescript-export-names') { + state.typeScriptExportNames = Array.isArray(value) ? value.slice() : []; + return state.typeScriptExportNames; + } + if (op === 'get-typescript-export-names') return state.typeScriptExportNames; return undefined; }, writable: false, @@ -1905,6 +1957,11 @@ function getCjsEsmDefaultSnapshot(cache, filename) { return slot ? slot(cjsEsmDefaultSnapshotToken, 'get') : undefined; } +function captureCjsTypeScriptExportNames(mod, names) { + const slot = cjsEsmDefaultSnapshotSlot(mod); + if (slot) slot(cjsEsmDefaultSnapshotToken, 'set-typescript-export-names', names); +} + Object.defineProperty(globalThis, '__wasm_rquickjs_has_cjs_esm_default_snapshot', { value: hasCjsEsmDefaultSnapshot, writable: false, @@ -1917,12 +1974,44 @@ Object.defineProperty(globalThis, '__wasm_rquickjs_get_cjs_esm_default_snapshot' configurable: false, }); -function loadCjsEsmFacadeDefault(filename) { +function getCachedCjsTypeScriptExportNames(filename) { + const require = wasmRquickjsModuleGlobalThis.__wasm_rquickjs_create_require(filename); + const resolvedFilename = require.resolve(filename); + const mod = require.cache[resolvedFilename]; + const slot = cjsEsmDefaultSnapshotSlot(mod); + return slot ? slot(cjsEsmDefaultSnapshotToken, 'get-typescript-export-names') : undefined; +} + +Object.defineProperty(globalThis, '__wasm_rquickjs_get_cached_cjs_typescript_export_names', { + value: getCachedCjsTypeScriptExportNames, + writable: false, + configurable: false, +}); + +function takePreparedCjsTypeScript(meta) { + const prepared = meta.__wasm_rquickjs_prepared_cjs_typescript; + delete meta.__wasm_rquickjs_prepared_cjs_typescript; + return prepared; +} + +Object.defineProperty(globalThis, '__wasm_rquickjs_take_prepared_cjs_typescript', { + value: takePreparedCjsTypeScript, + writable: false, + configurable: false, +}); + +function loadCjsEsmFacadeDefault(filename, preparedTypeScriptGraph) { const require = wasmRquickjsModuleGlobalThis.__wasm_rquickjs_create_require(filename); const resolvedFilename = require.resolve(filename); return hasCjsEsmDefaultSnapshot(require.cache, resolvedFilename) ? getCjsEsmDefaultSnapshot(require.cache, resolvedFilename) - : require(filename); + : preparedTypeScriptGraph + ? require.__wasm_rquickjs_load_prepared_typescript( + resolvedFilename, + preparedTypeScriptGraph, + filename, + ) + : require(filename); } Object.defineProperty(globalThis, '__wasm_rquickjs_load_cjs_esm_facade_default', { @@ -2126,7 +2215,7 @@ function wrapForCompile(script, dynamicImportBindings) { return activeWrapper[0] + script + activeWrapper[1]; } -function compileCjs(filename, source) { +function compileCjs(filename, source, isPreparedTypeScript = false) { if (source.length > 0 && source.charCodeAt(0) === 0xFEFF) { source = source.slice(1); } @@ -2135,7 +2224,9 @@ function compileCjs(filename, source) { source = '//' + source; } - source = transpileTypeScriptModule(filename, source, false); + if (!isPreparedTypeScript) { + source = transpileTypeScriptModule(filename, source, false); + } source = stripV8OptimizationIntrinsics(source); const strippedImportAttributes = wasmRquickjsModuleGlobalThis.__wasm_rquickjs_prepare_cjs_source( source, @@ -2663,6 +2754,9 @@ function loadCommonJsTransaction(descriptor) { const isLoaderSource = descriptor.sourceKind === 'loader'; const isMainModuleLoad = descriptor.isMainModule === true; const canFallbackToEsm = descriptor.allowEsmFallback === true; + const preparedTypeScriptGraph = descriptor.preparedTypeScriptGraph; + const preparedTypeScript = preparedTypeScriptGraph && preparedTypeScriptGraph[filename]; + if (preparedTypeScript) delete preparedTypeScriptGraph[filename]; const cacheKey = descriptor.cacheKey; const dirname = pathModule.dirname(filename); const pathsBase = isLoaderSource && !pathModule.isAbsolute(filename) ? '/' : dirname; @@ -2800,14 +2894,35 @@ function loadCommonJsTransaction(descriptor) { } } else { try { - source = fsModule.readFileSync(filename, 'utf8'); + source = preparedTypeScript + ? preparedTypeScript.originalSource + : fsModule.readFileSync(filename, 'utf8'); registerSourceMapForCjs(filename, source, mod); } catch (err) { discardCjsModuleLoad(cacheKey, parentModule, mod); throw err; } const dirname = pathModule.dirname(filename); - const childRequire = makeRequire(dirname, mod); + let activePreparedTypeScriptGraph = preparedTypeScriptGraph; + let compiledSource = preparedTypeScript ? preparedTypeScript.preparedSource : source; + let typeScriptExportNames; + let ownsPreparedTypeScriptGraph = false; + if (!preparedTypeScript) { + const prepared = prepareCommonJsTypeScript(filename, source); + compiledSource = prepared.source; + typeScriptExportNames = prepared.exportNames; + if (prepared.preparedTypeScriptGraph) { + activePreparedTypeScriptGraph = prepared.preparedTypeScriptGraph; + ownsPreparedTypeScriptGraph = true; + } + } + const childRequire = makeRequire( + dirname, + mod, + undefined, + mainModule, + activePreparedTypeScriptGraph, + ); let compiledFn; let cjsSyntaxError = null; const shouldFallbackToEsm = canFallbackToEsm && @@ -2815,7 +2930,11 @@ function loadCommonJsTransaction(descriptor) { let cjsWrapperLexicalRedeclaration = false; let cjsSourceLooksEsm = false; try { - compiledFn = compileCjs(filename, source); + compiledFn = compileCjs( + filename, + compiledSource, + true, + ); } catch (err) { // Normalize QuickJS SyntaxError messages for ESM keywords in CJS context if (err && err.name === 'SyntaxError') { @@ -2863,6 +2982,15 @@ function loadCommonJsTransaction(descriptor) { discardCjsModuleLoad(cacheKey, parentModule, mod); maybeSetArrowMessageOnSyntaxError(err, filename, source); throw err; + } finally { + if (ownsPreparedTypeScriptGraph) { + clearPreparedTypeScriptGraph(activePreparedTypeScriptGraph); + } + } + if (typeScriptExportNames !== undefined) { + captureCjsTypeScriptExportNames(mod, typeScriptExportNames); + } else if (preparedTypeScript && Array.isArray(preparedTypeScript.exportNames)) { + captureCjsTypeScriptExportNames(mod, preparedTypeScript.exportNames); } cjsEsmDefaultSnapshotEligible = true; } @@ -2878,7 +3006,7 @@ function loadCommonJsTransaction(descriptor) { return mod; } -function loadFilesystemCommonJs(resolvedFilename, parentModule) { +function loadFilesystemCommonJs(resolvedFilename, parentModule, preparedTypeScriptGraph = undefined) { const isMainModule = isMainEntryFilename(resolvedFilename); const filename = toCjsCanonicalFilename(resolvedFilename, isMainModule); return loadCommonJsTransaction({ @@ -2890,6 +3018,7 @@ function loadFilesystemCommonJs(resolvedFilename, parentModule) { sourceUrl: undefined, isMainModule, allowEsmFallback: true, + preparedTypeScriptGraph, }); } @@ -3158,7 +3287,7 @@ function currentRequireMain() { return mainModule.filename === '/' ? undefined : mainModule; } -function makeRequire(parentDir, parentModule, parentFilenameOverride, requireMainOverride) { +function makeRequire(parentDir, parentModule, parentFilenameOverride, requireMainOverride, preparedTypeScriptGraph) { const parentFilename = parentFilenameOverride || (parentModule && parentModule.filename) || null; const parentLookupPaths = parentModule && Array.isArray(parentModule.paths) ? parentModule.paths.concat(globalPaths) @@ -3218,7 +3347,7 @@ function makeRequire(parentDir, parentModule, parentFilenameOverride, requireMai const cacheKey = cjsPathCacheKey(id, pathModule.isAbsolute(id) ? [''] : [parentDir]); const cached = cjsCachedPathResolution(cjsPathCacheValue(cacheKey)); if (cached !== null) { - const mod = loadFilesystemCommonJs(cached.filename, parentModule || null); + const mod = loadFilesystemCommonJs(cached.filename, parentModule || null, preparedTypeScriptGraph); return mod.exports; } let resolved; @@ -3228,7 +3357,7 @@ function makeRequire(parentDir, parentModule, parentFilenameOverride, requireMai throw addRequireStackToModuleNotFound(err, id, parentFilename); } cjsSetPathCacheResolvedFilename(cacheKey, resolved.filename); - const mod = loadFilesystemCommonJs(resolved.filename, parentModule || null); + const mod = loadFilesystemCommonJs(resolved.filename, parentModule || null, preparedTypeScriptGraph); return mod.exports; } @@ -3236,7 +3365,7 @@ function makeRequire(parentDir, parentModule, parentFilenameOverride, requireMai const resolution = makeCjsResolutionState(); const importsResolved = resolveCjsPackageImportOrNodeModules(id, parentDir, parentFilename, parentLookupPaths, resolution); if (importsResolved.builtin) return requireBuiltinModule(importsResolved.builtin); - const mod = loadFilesystemCommonJs(importsResolved.filename, parentModule || null); + const mod = loadFilesystemCommonJs(importsResolved.filename, parentModule || null, preparedTypeScriptGraph); return mod.exports; } @@ -3244,7 +3373,7 @@ function makeRequire(parentDir, parentModule, parentFilenameOverride, requireMai const resolution = makeCjsResolutionState(); const nmResolved = resolveFromNodeModules(id, parentDir, parentFilename, undefined, parentLookupPaths, resolution); if (nmResolved) { - const mod = loadFilesystemCommonJs(nmResolved.filename, parentModule || null); + const mod = loadFilesystemCommonJs(nmResolved.filename, parentModule || null, preparedTypeScriptGraph); return mod.exports; } @@ -3276,6 +3405,26 @@ function makeRequire(parentDir, parentModule, parentFilenameOverride, requireMai enumerable: true, }); + Object.defineProperty(localRequire, '__wasm_rquickjs_load_prepared_typescript', { + value: (resolvedFilename, graph, traceId) => traceModuleRequire( + traceId, + parentFilename, + () => { + try { + return loadFilesystemCommonJs( + resolvedFilename, + parentModule || null, + graph, + ).exports; + } finally { + clearPreparedTypeScriptGraph(graph); + } + }, + ), + writable: false, + configurable: false, + }); + return localRequire; } diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/typescript.rs b/crates/wasm-rquickjs/skeleton/src/builtin/typescript.rs index dc98059f..ff4e731e 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/typescript.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/typescript.rs @@ -91,4 +91,9 @@ pub mod native_module { )) } } + + #[rquickjs::function] + pub fn transform_observability_enabled() -> bool { + cfg!(feature = "typescript-transform-observability") + } } diff --git a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs index 3968fc15..a8f808df 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs @@ -6554,6 +6554,17 @@ fn is_typescript_module_path(path: &str) -> bool { ) } +#[cfg(feature = "typescript-runtime")] +fn cached_cjs_typescript_export_names_for_filename( + ctx: &Ctx<'_>, + filename: &str, +) -> rquickjs::Result>> { + let get_names: Function = ctx + .globals() + .get("__wasm_rquickjs_get_cached_cjs_typescript_export_names")?; + get_names.call((filename,)) +} + #[cfg(feature = "typescript-runtime")] fn transform_typescript_module_source<'js>( ctx: &Ctx<'js>, @@ -6573,7 +6584,10 @@ fn transform_typescript_module_source<'js>( _ => None, }, ) { - Ok(output) => Ok(output.code), + Ok(output) => { + record_typescript_module_transform(ctx)?; + Ok(output.code) + } Err(error) => { let constructor_name = match error.kind { crate::internal::typescript::TypeScriptErrorKind::Error => "Error", @@ -6587,6 +6601,18 @@ fn transform_typescript_module_source<'js>( } } +#[cfg(feature = "typescript-runtime")] +fn record_typescript_module_transform(ctx: &Ctx<'_>) -> rquickjs::Result<()> { + #[cfg(feature = "typescript-transform-observability")] + { + let record: Function = ctx + .globals() + .get("__wasm_rquickjs_record_typescript_module_transform")?; + record.call::<_, ()>(())?; + } + Ok(()) +} + #[derive(Default)] struct CjsExportAnalysis { exports: Vec, @@ -8625,12 +8651,22 @@ fn canonical_cjs_analysis_path(ctx: &Ctx<'_>, path: &str) -> String { crate::builtin::realpath_for_module_resolution(ctx, path).unwrap_or_else(|| path.to_string()) } +#[derive(Clone)] +struct PreparedCjsTypeScript { + original_source: String, + prepared_source: String, + export_names: Vec, +} + +type PreparedCjsTypeScriptGraph = HashMap; + fn analyze_cjs_reexport_specifier_names( ctx: &Ctx<'_>, filename: &str, reexport_specifiers: Vec, seen: &mut HashSet, conditions: &[String], + mut prepared_typescript: Option<&mut PreparedCjsTypeScriptGraph>, ) -> Vec { let mut names = Vec::new(); for reexport in reexport_specifiers { @@ -8640,13 +8676,20 @@ fn analyze_cjs_reexport_specifier_names( && is_cjs_analysis_source_path(&physical_path) && let Ok(source) = std::fs::read_to_string(&physical_path) { + let child_filename = if NodeFileResolver::has_exec_argv_flag(ctx, "--preserve-symlinks") + { + logical_path + } else { + physical_path + }; #[cfg(feature = "typescript-runtime")] - let source = if is_typescript_module_path(&physical_path) { + let source = if is_typescript_module_path(&child_filename) { + let original_source = source; let Ok(output) = crate::internal::typescript::transform_module( - source, - &physical_path, + original_source.clone(), + &child_filename, false, - match std::path::Path::new(&physical_path) + match std::path::Path::new(&child_filename) .extension() .and_then(|extension| extension.to_str()) { @@ -8657,18 +8700,34 @@ fn analyze_cjs_reexport_specifier_names( ) else { continue; }; + if let Some(graph) = prepared_typescript.as_deref_mut() { + let _ = record_typescript_module_transform(ctx); + graph.insert( + child_filename.clone(), + PreparedCjsTypeScript { + original_source, + prepared_source: output.code.clone(), + export_names: Vec::new(), + }, + ); + } output.code } else { source }; - let child_filename = if NodeFileResolver::has_exec_argv_flag(ctx, "--preserve-symlinks") + let child = analyze_cjs_exports_for_file_impl( + ctx, + &child_filename, + &source, + seen, + conditions, + prepared_typescript.as_deref_mut(), + ); + if let Some(graph) = prepared_typescript.as_deref_mut() + && let Some(prepared) = graph.get_mut(&child_filename) { - logical_path - } else { - physical_path - }; - let child = - analyze_cjs_exports_for_file(ctx, &child_filename, &source, seen, conditions); + prepared.export_names = child.exports.clone(); + } for name in child.exports { add_unique(&mut names, name); } @@ -8683,18 +8742,86 @@ fn analyze_cjs_exports_for_file( source: &str, seen: &mut HashSet, conditions: &[String], +) -> CjsExportAnalysis { + analyze_cjs_exports_for_file_impl(ctx, filename, source, seen, conditions, None) +} + +fn analyze_cjs_exports_for_file_impl( + ctx: &Ctx<'_>, + filename: &str, + source: &str, + seen: &mut HashSet, + conditions: &[String], + prepared_typescript: Option<&mut PreparedCjsTypeScriptGraph>, ) -> CjsExportAnalysis { let mut analysis = analyze_cjs_exports(source); if !seen.insert(canonical_cjs_analysis_path(ctx, filename)) { return analysis; } let reexports = analysis.reexports.clone(); - for name in analyze_cjs_reexport_specifier_names(ctx, filename, reexports, seen, conditions) { + for name in analyze_cjs_reexport_specifier_names( + ctx, + filename, + reexports, + seen, + conditions, + prepared_typescript, + ) { add_unique(&mut analysis.exports, name); } analysis } +fn prepared_cjs_typescript_graph_object<'js>( + ctx: &Ctx<'js>, + graph: &PreparedCjsTypeScriptGraph, +) -> rquickjs::Result> { + let value = Object::new(ctx.clone())?; + for (filename, prepared) in graph { + let entry = Object::new(ctx.clone())?; + entry.set("originalSource", prepared.original_source.clone())?; + entry.set("preparedSource", prepared.prepared_source.clone())?; + let export_names = rquickjs::Array::new(ctx.clone())?; + for (index, name) in prepared.export_names.iter().enumerate() { + export_names.set(index, name.clone())?; + } + entry.set("exportNames", export_names)?; + value.set(filename, entry)?; + } + Ok(value) +} + +fn analyze_cjs_typescript_for_require<'js>( + ctx: Ctx<'js>, + filename: String, + prepared_source: String, +) -> rquickjs::Result> { + let conditions = NodeModulesResolver::conditions_from_global( + &ctx, + NodePackageResolveMode::CjsAnalysis.condition_mode(), + ); + let mut prepared_typescript = PreparedCjsTypeScriptGraph::new(); + let analysis = analyze_cjs_exports_for_file_impl( + &ctx, + &filename, + &prepared_source, + &mut HashSet::new(), + &conditions, + Some(&mut prepared_typescript), + ); + let result = Object::new(ctx.clone())?; + let exports = rquickjs::Array::new(ctx.clone())?; + for (index, name) in analysis.exports.into_iter().enumerate() { + exports.set(index, name)?; + } + result.set("exports", exports)?; + result.set( + "preparedTypeScriptGraph", + prepared_cjs_typescript_graph_object(&ctx, &prepared_typescript)?, + )?; + Ok(result) +} + struct PackageScopeInfo { package_type: Option, is_node_modules_package: bool, @@ -8956,18 +9083,16 @@ impl Loader for CjsCompatLoader { return throw_import_attr_type_incompatible(ctx); } - let source_path = module_source_filesystem_path(ctx, path); - let source = read_module_source_or_throw(ctx, path, &source_path)?; + let fs_abs_path = ensure_absolute_path(fs_path); #[cfg(feature = "typescript-runtime")] - let source = if is_typescript { - transform_typescript_module_source(ctx, fs_path, source)? + let cached_typescript_export_names = if is_typescript { + cached_cjs_typescript_export_names_for_filename(ctx, &fs_abs_path)? } else { - source + None }; - - let fs_abs_path = ensure_absolute_path(fs_path); - let url = path_to_file_url(path); - let force_module = require_esm_forced_module(ctx, &fs_abs_path, &url); + #[cfg(not(feature = "typescript-runtime"))] + let cached_typescript_export_names: Option> = None; + let has_cached_cjs_typescript = cached_typescript_export_names.is_some(); let package_scope = if fs_abs_path.ends_with(".js") || fs_abs_path.ends_with(".ts") || is_extensionless { @@ -8984,13 +9109,39 @@ impl Loader for CjsCompatLoader { && package_scope .as_ref() .is_some_and(|scope| scope.is_node_modules_package)); + + let source_path = module_source_filesystem_path(ctx, path); + let source = read_module_source_or_throw(ctx, path, &source_path)?; + #[cfg(feature = "typescript-runtime")] + let raw_typescript_looks_esm = is_typescript && source_looks_like_esm(&source); + #[cfg(not(feature = "typescript-runtime"))] + let raw_typescript_looks_esm = false; + #[cfg(feature = "typescript-runtime")] + let original_typescript_source = (is_typescript + && !has_cached_cjs_typescript + && !raw_typescript_looks_esm + && (fs_path.ends_with(".cts") + || (!fs_path.ends_with(".mts") && !is_module_package_js))) + .then(|| source.clone()); + #[cfg(feature = "typescript-runtime")] + let source = if is_typescript && !has_cached_cjs_typescript { + transform_typescript_module_source(ctx, fs_path, source)? + } else { + source + }; + + let url = path_to_file_url(path); + let force_module = require_esm_forced_module(ctx, &fs_abs_path, &url); + let cjs_url = url.clone(); let has_esm_syntax = force_module + || raw_typescript_looks_esm || source_looks_like_esm(&source) || has_cjs_wrapper_lexical_redeclaration(&source); // .cjs files are always CommonJS; JS-like files outside a module package // remain CommonJS unless syntax detection finds ESM. - let is_cjs = fs_path.ends_with(".cts") + let is_cjs = has_cached_cjs_typescript + || fs_path.ends_with(".cts") || is_cjs_ext || (!fs_path.ends_with(".mts") && (is_commonjs_package_js || (!is_module_package_js && !has_esm_syntax))); @@ -9014,20 +9165,53 @@ impl Loader for CjsCompatLoader { ctx, NodePackageResolveMode::CjsAnalysis.condition_mode(), ); - let detected_analysis = analyze_cjs_exports_for_file( - ctx, - &fs_abs_path, - &source, - &mut HashSet::new(), - &cjs_conditions, - ); + let mut prepared_typescript = PreparedCjsTypeScriptGraph::new(); + #[cfg(feature = "typescript-runtime")] + if let Some(original_source) = original_typescript_source { + prepared_typescript.insert( + fs_abs_path.clone(), + PreparedCjsTypeScript { + original_source, + prepared_source: source.clone(), + export_names: Vec::new(), + }, + ); + } + let detected_analysis = if let Some(exports) = cached_typescript_export_names { + CjsExportAnalysis { + exports, + ..CjsExportAnalysis::default() + } + } else { + analyze_cjs_exports_for_file_impl( + ctx, + &fs_abs_path, + &source, + &mut HashSet::new(), + &cjs_conditions, + Some(&mut prepared_typescript), + ) + }; + if let Some(prepared) = prepared_typescript.get_mut(&fs_abs_path) { + prepared.export_names = detected_analysis.exports.clone(); + } // Let the existing CommonJS loader execute and cache the module. The // facade only exposes the shared module.exports object to ESM. - let init = file_import_meta_init(cjs_url, fs_abs_path.clone()); - let default_member_expression = format!( - "__wasm_rquickjs_load_cjs_esm_facade_default(\"{}\")", - escape_js_string(&fs_abs_path), - ); + let mut init = file_import_meta_init(cjs_url, fs_abs_path.clone()); + if !prepared_typescript.is_empty() { + init.prepared_cjs_typescript = Some(prepared_typescript); + } + let default_member_expression = if init.prepared_cjs_typescript.is_some() { + format!( + "__wasm_rquickjs_load_cjs_esm_facade_default(\"{}\",__wasm_rquickjs_take_prepared_cjs_typescript(import.meta))", + escape_js_string(&fs_abs_path), + ) + } else { + format!( + "__wasm_rquickjs_load_cjs_esm_facade_default(\"{}\")", + escape_js_string(&fs_abs_path), + ) + }; let wrapped = build_cjs_facade_source(&default_member_expression, &detected_analysis.exports); @@ -9040,6 +9224,7 @@ struct ImportMetaInit { filename: Option, dirname: Option, include_resolve: bool, + prepared_cjs_typescript: Option, } fn declare_module_with_import_meta<'js>( @@ -9070,6 +9255,13 @@ fn initialize_module_import_meta<'js>( }) .configurable(), )?; + if let Some(prepared) = &init.prepared_cjs_typescript { + let value = prepared_cjs_typescript_graph_object(ctx, prepared)?; + meta.prop( + "__wasm_rquickjs_prepared_cjs_typescript", + Property::from(value).configurable(), + )?; + } if let Some(ref dirname) = init.dirname { meta.prop( "dirname", @@ -9170,6 +9362,7 @@ fn url_only_import_meta_init(url: String) -> ImportMetaInit { filename: None, dirname: None, include_resolve: true, + prepared_cjs_typescript: None, } } @@ -9182,6 +9375,7 @@ fn file_import_meta_init(url: String, filename: String) -> ImportMetaInit { filename: Some(filename), dirname, include_resolve: true, + prepared_cjs_typescript: None, } } @@ -10788,6 +10982,14 @@ pub(crate) async fn initialize_module_loading(rt: &AsyncRuntime, ctx: &AsyncCont ) .expect("Failed to initialize module source analyzer"); + set_non_replaceable_global( + &global, + "__wasm_rquickjs_analyze_cjs_typescript_for_require", + Function::new(ctx.clone(), analyze_cjs_typescript_for_require) + .expect("Failed to create CommonJS TypeScript analyzer"), + ) + .expect("Failed to initialize CommonJS TypeScript analyzer"); + set_non_replaceable_global( &global, "__wasm_rquickjs_prepare_cjs_source", diff --git a/examples/runtime/typescript-runtime/src/typescript-runtime.js b/examples/runtime/typescript-runtime/src/typescript-runtime.js index 083ad43b..a0782836 100644 --- a/examples/runtime/typescript-runtime/src/typescript-runtime.js +++ b/examples/runtime/typescript-runtime/src/typescript-runtime.js @@ -1,6 +1,7 @@ import { stripTypeScriptTypes } from 'node:module'; import { createRequire } from 'node:module'; import fs from 'node:fs'; +import diagnosticsChannel from 'node:diagnostics_channel'; import { runJavaScript, startJavaScript } from 'wasm-rquickjs:execution'; export async function run() { @@ -60,6 +61,111 @@ export async function run() { const moduleTs = (await import('/typescript-runtime/module-package/value.ts')).default; const moduleMts = (await import('/typescript-runtime/value.mts')).default; const commonJsCts = require('/typescript-runtime/value.cts'); + + const getTransformCount = globalThis.__wasm_rquickjs_get_typescript_module_transform_count; + const resetTransformCount = globalThis.__wasm_rquickjs_reset_typescript_module_transform_count; + resetTransformCount(); + fs.writeFileSync( + '/typescript-runtime/transform-count-direct.cts', + 'const value: number = 42; module.exports = value;', + ); + const directTransformValue = require('/typescript-runtime/transform-count-direct.cts'); + const directFirstLoadTransformCount = getTransformCount(); + const directCachedTransformValue = require('/typescript-runtime/transform-count-direct.cts'); + const directCachedTransformCount = getTransformCount(); + + resetTransformCount(); + fs.writeFileSync( + '/typescript-runtime/transform-count-import.ts', + 'const value: number = 42; module.exports = value;', + ); + const moduleRequireTrace = diagnosticsChannel.tracingChannel('module.require'); + const preparedImportTraceEvents = []; + moduleRequireTrace.subscribe({ + start: (event) => { + if (event.id === '/typescript-runtime/transform-count-import.ts') { + preparedImportTraceEvents.push('start'); + } + }, + end: (event) => { + if (event.id === '/typescript-runtime/transform-count-import.ts') { + preparedImportTraceEvents.push('end'); + } + }, + }); + const importedTransformValue = (await import('/typescript-runtime/transform-count-import.ts')).default; + const importedFirstLoadTransformCount = getTransformCount(); + const preparedImportFirstLoadTrace = preparedImportTraceEvents.join(','); + const importedCachedTransformValue = (await import('/typescript-runtime/transform-count-import.ts')).default; + const importedCachedTransformCount = getTransformCount(); + const importedThenRequiredTransformValue = require('/typescript-runtime/transform-count-import.ts'); + const importedThenRequiredTransformCount = getTransformCount(); + + resetTransformCount(); + fs.writeFileSync( + '/typescript-runtime/transform-count-require-import.cts', + 'type Hidden = typeof exports.phantom; exports.answer = 42;', + ); + const requiredBeforeImportTransformValue = require('/typescript-runtime/transform-count-require-import.cts'); + const requiredBeforeImportTransformCount = getTransformCount(); + const requiredThenImportedNamespace = await import('/typescript-runtime/transform-count-require-import.cts'); + const requiredThenImportedTransformValue = requiredThenImportedNamespace.answer; + const requiredThenImportedHasPhantom = 'phantom' in requiredThenImportedNamespace; + const requiredThenImportedTransformCount = getTransformCount(); + + resetTransformCount(); + fs.writeFileSync( + '/typescript-runtime/transform-count-reexport-child.cts', + 'type Hidden = typeof exports.phantom; exports.answer = 42;', + ); + fs.writeFileSync( + '/typescript-runtime/transform-count-reexport.cts', + `const child: { answer: number } = require('./transform-count-reexport-child.cts'); + Object.keys(child).forEach(function (key) { + if (key === 'default' || key === '__esModule') return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { return child[key]; }, + }); + });`, + ); + const requiredReexportValue = require('/typescript-runtime/transform-count-reexport.cts'); + const requiredReexportTransformCount = getTransformCount(); + const reexportNamespace = await import('/typescript-runtime/transform-count-reexport.cts'); + const reexportTransformValue = reexportNamespace.answer; + const reexportHasPhantom = 'phantom' in reexportNamespace; + const reexportFirstLoadTransformCount = getTransformCount(); + const reexportCachedNamespace = await import('/typescript-runtime/transform-count-reexport.cts'); + const reexportCachedTransformValue = reexportCachedNamespace.answer; + const reexportCachedTransformCount = getTransformCount(); + const reexportChildNamespace = await import('/typescript-runtime/transform-count-reexport-child.cts'); + const reexportChildTransformValue = reexportChildNamespace.answer; + const reexportChildHasPhantom = 'phantom' in reexportChildNamespace; + const reexportChildImportTransformCount = getTransformCount(); + + resetTransformCount(); + fs.writeFileSync( + '/typescript-runtime/transform-count-esm.mts', + 'export let live: number = 1; export default 42;', + ); + const requiredMtsNamespace = require('/typescript-runtime/transform-count-esm.mts'); + const importedMtsNamespace = await import('/typescript-runtime/transform-count-esm.mts'); + const mtsRequireImportTransformCount = getTransformCount(); + + resetTransformCount(); + fs.mkdirSync('/typescript-runtime/transform-count-module-package', { recursive: true }); + fs.writeFileSync( + '/typescript-runtime/transform-count-module-package/package.json', + JSON.stringify({ type: 'module' }), + ); + fs.writeFileSync( + '/typescript-runtime/transform-count-module-package/value.ts', + 'export let live: number = 1; export default 42;', + ); + const modulePackageFilename = '/typescript-runtime/transform-count-module-package/value.ts'; + const requiredModuleTsNamespace = require(modulePackageFilename); + const importedModuleTsNamespace = await import(modulePackageFilename); + const moduleTsRequireImportTransformCount = getTransformCount(); let extensionlessCommonJsTsError; try { require('/typescript-runtime/cjs-value'); @@ -188,6 +294,42 @@ export async function run() { moduleTs, moduleMts, commonJsCts, + directTransformValue, + directCachedTransformValue, + directFirstLoadTransformCount, + directCachedTransformCount, + importedTransformValue, + importedCachedTransformValue, + importedFirstLoadTransformCount, + preparedImportFirstLoadTrace, + importedCachedTransformCount, + importedThenRequiredTransformValue, + importedThenRequiredTransformCount, + requiredBeforeImportTransformValue, + requiredBeforeImportTransformCount, + requiredThenImportedTransformValue, + requiredThenImportedHasPhantom, + requiredThenImportedTransformCount, + reexportTransformValue, + requiredReexportValue, + requiredReexportTransformCount, + reexportHasPhantom, + reexportFirstLoadTransformCount, + reexportCachedTransformValue, + reexportCachedTransformCount, + reexportChildTransformValue, + reexportChildHasPhantom, + reexportChildImportTransformCount, + requiredMtsDefault: requiredMtsNamespace.default, + importedMtsDefault: importedMtsNamespace.default, + importedMtsLive: importedMtsNamespace.live, + mtsRequireImportSameNamespace: requiredMtsNamespace === importedMtsNamespace, + mtsRequireImportTransformCount, + requiredModuleTsDefault: requiredModuleTsNamespace.default, + importedModuleTsDefault: importedModuleTsNamespace.default, + importedModuleTsLive: importedModuleTsNamespace.live, + moduleTsRequireImportSameNamespace: requiredModuleTsNamespace === importedModuleTsNamespace, + moduleTsRequireImportTransformCount, extensionlessCommonJsTsError, extensionlessEsmError, loaderUnsupportedCode, diff --git a/examples/runtime/typescript-transform-runtime/src/typescript-transform-runtime.js b/examples/runtime/typescript-transform-runtime/src/typescript-transform-runtime.js index 1236b31b..c83b780d 100644 --- a/examples/runtime/typescript-transform-runtime/src/typescript-transform-runtime.js +++ b/examples/runtime/typescript-transform-runtime/src/typescript-transform-runtime.js @@ -98,6 +98,7 @@ export async function run() { }); return JSON.stringify({ processFeature: process.features.typescript, + transformObservability: typeof globalThis.__wasm_rquickjs_get_typescript_module_transform_count, transformedModule, executionEntry: executionEntry.value, commonJsExecutionEntry: commonJsExecutionEntry.value, diff --git a/tests/common/mod.rs b/tests/common/mod.rs index c971f6d8..f25f3839 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1530,7 +1530,10 @@ impl FeatureCombination { FeatureCombination::InternalTestExecution => { vec!["--features", "internal-test-execution"] } - FeatureCombination::TypeScriptRuntime => vec!["--features", "typescript-runtime"], + FeatureCombination::TypeScriptRuntime => vec![ + "--features", + "typescript-runtime,typescript-transform-observability", + ], FeatureCombination::TypeScriptTransformRuntime => { vec!["--features", "typescript-transform-runtime"] } @@ -1581,7 +1584,9 @@ impl FeatureCombination { FeatureCombination::InternalTestExecution => { "normal-p3,internal-test-execution" } - FeatureCombination::TypeScriptRuntime => "normal-p3,typescript-runtime", + FeatureCombination::TypeScriptRuntime => { + "normal-p3,typescript-runtime,typescript-transform-observability" + } FeatureCombination::TypeScriptTransformRuntime => { "normal-p3,typescript-transform-runtime" } diff --git a/tests/runtime/typescript_runtime.rs b/tests/runtime/typescript_runtime.rs index 4ac9a733..f20d15cd 100644 --- a/tests/runtime/typescript_runtime.rs +++ b/tests/runtime/typescript_runtime.rs @@ -64,6 +64,42 @@ async fn strip_typescript_types_matches_node_contract( assert_eq!(report["moduleTs"], 42); assert_eq!(report["moduleMts"], 42); assert_eq!(report["commonJsCts"], 42); + assert_eq!(report["directTransformValue"], 42); + assert_eq!(report["directCachedTransformValue"], 42); + assert_eq!(report["directFirstLoadTransformCount"], 1); + assert_eq!(report["directCachedTransformCount"], 1); + assert_eq!(report["importedTransformValue"], 42); + assert_eq!(report["importedCachedTransformValue"], 42); + assert_eq!(report["importedFirstLoadTransformCount"], 1); + assert_eq!(report["preparedImportFirstLoadTrace"], "start,end"); + assert_eq!(report["importedCachedTransformCount"], 1); + assert_eq!(report["importedThenRequiredTransformValue"], 42); + assert_eq!(report["importedThenRequiredTransformCount"], 1); + assert_eq!(report["requiredBeforeImportTransformValue"]["answer"], 42); + assert_eq!(report["requiredBeforeImportTransformCount"], 1); + assert_eq!(report["requiredThenImportedTransformValue"], 42); + assert_eq!(report["requiredThenImportedHasPhantom"], false); + assert_eq!(report["requiredThenImportedTransformCount"], 1); + assert_eq!(report["requiredReexportValue"]["answer"], 42); + assert_eq!(report["requiredReexportTransformCount"], 2); + assert_eq!(report["reexportTransformValue"], 42); + assert_eq!(report["reexportHasPhantom"], false); + assert_eq!(report["reexportFirstLoadTransformCount"], 2); + assert_eq!(report["reexportCachedTransformValue"], 42); + assert_eq!(report["reexportCachedTransformCount"], 2); + assert_eq!(report["reexportChildTransformValue"], 42); + assert_eq!(report["reexportChildHasPhantom"], false); + assert_eq!(report["reexportChildImportTransformCount"], 2); + assert_eq!(report["requiredMtsDefault"], 42); + assert_eq!(report["importedMtsDefault"], 42); + assert_eq!(report["importedMtsLive"], 1); + assert_eq!(report["mtsRequireImportSameNamespace"], false); + assert_eq!(report["mtsRequireImportTransformCount"], 2); + assert_eq!(report["requiredModuleTsDefault"], 42); + assert_eq!(report["importedModuleTsDefault"], 42); + assert_eq!(report["importedModuleTsLive"], 1); + assert_eq!(report["moduleTsRequireImportSameNamespace"], false); + assert_eq!(report["moduleTsRequireImportTransformCount"], 2); assert_eq!(report["extensionlessCommonJsTsError"], "MODULE_NOT_FOUND"); assert_eq!(report["extensionlessEsmError"], "ERR_MODULE_NOT_FOUND"); assert_eq!( @@ -126,6 +162,7 @@ async fn typescript_transform_runtime_is_immutable( }; let report: serde_json::Value = serde_json::from_str(&json)?; assert_eq!(report["processFeature"], "transform"); + assert_eq!(report["transformObservability"], "undefined"); assert_eq!(report["transformedModule"], 1); assert_eq!(report["executionEntry"], 1); assert_eq!(report["commonJsExecutionEntry"], 42); From 6fcc679c7a929d2080c68c76a54e586a758132d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Sat, 29 Aug 2026 17:34:26 +0200 Subject: [PATCH 2/5] fix(module): preserve single TypeScript transform ownership --- .../skeleton/src/builtin/module.js | 19 ++-- .../skeleton/src/internal/module_loading.rs | 81 ++++++++++++++++- .../src/typescript-runtime.js | 90 +++++++++++++++++++ tests/runtime/typescript_runtime.rs | 22 +++++ 4 files changed, 203 insertions(+), 9 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 28564914..1fe770f1 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -2907,14 +2907,19 @@ function loadCommonJsTransaction(descriptor) { let compiledSource = preparedTypeScript ? preparedTypeScript.preparedSource : source; let typeScriptExportNames; let ownsPreparedTypeScriptGraph = false; - if (!preparedTypeScript) { - const prepared = prepareCommonJsTypeScript(filename, source); - compiledSource = prepared.source; - typeScriptExportNames = prepared.exportNames; - if (prepared.preparedTypeScriptGraph) { - activePreparedTypeScriptGraph = prepared.preparedTypeScriptGraph; - ownsPreparedTypeScriptGraph = true; + try { + if (!preparedTypeScript) { + const prepared = prepareCommonJsTypeScript(filename, source); + compiledSource = prepared.source; + typeScriptExportNames = prepared.exportNames; + if (prepared.preparedTypeScriptGraph) { + activePreparedTypeScriptGraph = prepared.preparedTypeScriptGraph; + ownsPreparedTypeScriptGraph = true; + } } + } catch (err) { + discardCjsModuleLoad(cacheKey, parentModule, mod); + throw err; } const childRequire = makeRequire( dirname, diff --git a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs index a8f808df..c7781d85 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs @@ -8674,7 +8674,6 @@ fn analyze_cjs_reexport_specifier_names( && let physical_path = canonical_cjs_analysis_path(ctx, &logical_path) && !seen.contains(&physical_path) && is_cjs_analysis_source_path(&physical_path) - && let Ok(source) = std::fs::read_to_string(&physical_path) { let child_filename = if NodeFileResolver::has_exec_argv_flag(ctx, "--preserve-symlinks") { @@ -8683,7 +8682,29 @@ fn analyze_cjs_reexport_specifier_names( physical_path }; #[cfg(feature = "typescript-runtime")] + if is_typescript_module_path(&child_filename) + && let Ok(Some(exports)) = + cached_cjs_typescript_export_names_for_filename(ctx, &child_filename) + { + for name in exports { + add_unique(&mut names, name); + } + continue; + } + let Ok(source) = std::fs::read_to_string(&child_filename) else { + continue; + }; + #[cfg(feature = "typescript-runtime")] let source = if is_typescript_module_path(&child_filename) { + if typescript_module_path_is_esm(ctx, &child_filename, &source) { + continue; + } + if let Some(prepared) = prepared_typescript + .as_deref() + .and_then(|graph| graph.get(&child_filename)) + { + prepared.prepared_source.clone() + } else { let original_source = source; let Ok(output) = crate::internal::typescript::transform_module( original_source.clone(), @@ -8712,6 +8733,7 @@ fn analyze_cjs_reexport_specifier_names( ); } output.code + } } else { source }; @@ -8897,6 +8919,14 @@ fn source_uses_esm_format(source: String) -> bool { source_looks_like_esm(&source) || has_cjs_wrapper_lexical_redeclaration(&source) } +#[cfg(feature = "typescript-runtime")] +fn typescript_module_path_is_esm(ctx: &Ctx<'_>, filename: &str, source: &str) -> bool { + filename.ends_with(".mts") + || (!filename.ends_with(".cts") + && (package_scope_type(ctx, filename).as_deref() == Some("module") + || typescript_source_looks_like_esm(source))) +} + fn is_node_modules_package_scope(dir: &std::path::Path) -> bool { let Some(parent) = dir.parent() else { return false; @@ -9113,7 +9143,8 @@ impl Loader for CjsCompatLoader { let source_path = module_source_filesystem_path(ctx, path); let source = read_module_source_or_throw(ctx, path, &source_path)?; #[cfg(feature = "typescript-runtime")] - let raw_typescript_looks_esm = is_typescript && source_looks_like_esm(&source); + let raw_typescript_looks_esm = + is_typescript && typescript_module_path_is_esm(ctx, &fs_abs_path, &source); #[cfg(not(feature = "typescript-runtime"))] let raw_typescript_looks_esm = false; #[cfg(feature = "typescript-runtime")] @@ -9925,6 +9956,39 @@ fn source_looks_like_esm(source: &str) -> bool { || source_has_top_level_await(source, false) } +fn typescript_source_looks_like_esm(source: &str) -> bool { + let mut type_only_ranges = Vec::new(); + let _ = scan_code_positions(source, true, |i, _| { + let keyword_end = if parse_ident_name(source, i, "import").is_some() { + i + "import".len() + } else if parse_ident_name(source, i, "export").is_some() { + i + "export".len() + } else { + return ControlFlow::Continue(None); + }; + let type_start = skip_ws_comments(source, keyword_end); + if parse_ident_name(source, type_start, "type").is_none() { + return ControlFlow::Continue(None); + } + let end = module_statement_end(source, keyword_end); + type_only_ranges.push((i, end)); + ControlFlow::Continue(Some(end)) + }); + if type_only_ranges.is_empty() { + return source_looks_like_esm(source); + } + + let mut runtime_source = source.as_bytes().to_vec(); + for (start, end) in type_only_ranges { + for byte in &mut runtime_source[start..end] { + if !matches!(*byte, b'\n' | b'\r') { + *byte = b' '; + } + } + } + source_looks_like_esm(std::str::from_utf8(&runtime_source).expect("source began as UTF-8")) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct StaticModuleEdge { specifier: String, @@ -12231,6 +12295,19 @@ import "./dep.js" withあ; assert!(!source_looks_like_esm("const text = 'import.meta.url';")); } + #[test] + fn typescript_esm_detection_ignores_type_only_module_statements() { + assert!(!typescript_source_looks_like_esm( + "import type { Missing } from './missing.mts'; module.exports = 42;" + )); + assert!(!typescript_source_looks_like_esm( + "export type Answer = number; module.exports = 42;" + )); + assert!(typescript_source_looks_like_esm( + "import type { Missing } from './missing.mts'; export default 42;" + )); + } + #[test] fn esm_syntax_detection_handles_top_level_await_around_templates() { assert!(source_looks_like_esm( diff --git a/examples/runtime/typescript-runtime/src/typescript-runtime.js b/examples/runtime/typescript-runtime/src/typescript-runtime.js index a0782836..69de93ac 100644 --- a/examples/runtime/typescript-runtime/src/typescript-runtime.js +++ b/examples/runtime/typescript-runtime/src/typescript-runtime.js @@ -143,6 +143,84 @@ export async function run() { const reexportChildHasPhantom = 'phantom' in reexportChildNamespace; const reexportChildImportTransformCount = getTransformCount(); + resetTransformCount(); + fs.writeFileSync( + '/typescript-runtime/cached-reexport-child.cts', + 'type Hidden = typeof exports.phantom; exports.answer = 42;', + ); + require('/typescript-runtime/cached-reexport-child.cts'); + fs.writeFileSync( + '/typescript-runtime/cached-reexport-parent.cts', + `const child = require('./cached-reexport-child.cts'); + Object.keys(child).forEach(function (key) { + if (key === 'default' || key === '__esModule') return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { return child[key]; }, + }); + });`, + ); + const cachedChildReexportValue = (await import('/typescript-runtime/cached-reexport-parent.cts')).answer; + const cachedChildReexportTransformCount = getTransformCount(); + + fs.writeFileSync( + '/typescript-runtime/esm-reexport-child.mts', + 'export const answer: number = 42;', + ); + resetTransformCount(); + fs.writeFileSync( + '/typescript-runtime/esm-reexport-parent.cts', + `if (false) { + const child = require('./esm-reexport-child.mts'); + Object.keys(child).forEach(function (key) { + if (key === 'default' || key === '__esModule') return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { return child[key]; }, + }); + }); + } + module.exports = 42;`, + ); + const esmChildReexportValue = (await import('/typescript-runtime/esm-reexport-parent.cts')).default; + const esmChildReexportTransformCount = getTransformCount(); + + fs.writeFileSync( + '/typescript-runtime/import-type-commonjs.ts', + 'import type { Missing } from "./missing.mts"; const value: Missing = 42; module.exports = value;', + ); + const importTypeCommonJsValue = (await import('/typescript-runtime/import-type-commonjs.ts')).default; + + const recoverableFilename = '/typescript-runtime/recoverable-prepare.cts'; + fs.writeFileSync( + '/typescript-runtime/recoverable-parent.cjs', + `module.exports = function (filename) { + const before = module.children.filter((child) => child.filename === filename).length; + try { + const value = require(filename); + return { + value, + before, + after: module.children.filter((child) => child.filename === filename).length, + cached: filename in require.cache, + }; + } catch (error) { + return { + error: error.code, + before, + after: module.children.filter((child) => child.filename === filename).length, + cached: filename in require.cache, + }; + } + };`, + ); + const recoverableLoad = require('/typescript-runtime/recoverable-parent.cjs'); + fs.writeFileSync(recoverableFilename, 'enum Direction { Up, Down }'); + const recoverableResolved = require.resolve(recoverableFilename); + const recoverableFailure = recoverableLoad(recoverableResolved); + fs.writeFileSync(recoverableFilename, 'const value: number = 42; module.exports = value;'); + const recoverableSuccess = recoverableLoad(recoverableResolved); + resetTransformCount(); fs.writeFileSync( '/typescript-runtime/transform-count-esm.mts', @@ -320,6 +398,18 @@ export async function run() { reexportChildTransformValue, reexportChildHasPhantom, reexportChildImportTransformCount, + cachedChildReexportValue, + cachedChildReexportTransformCount, + esmChildReexportValue, + esmChildReexportTransformCount, + importTypeCommonJsValue, + recoverablePrepareError: recoverableFailure.error, + recoverableCachedAfterFailure: recoverableFailure.cached, + recoverableChildrenBefore: recoverableFailure.before, + recoverableChildrenAfterFailure: recoverableFailure.after, + recoverablePrepareValue: recoverableSuccess.value, + recoverableCachedAfterSuccess: recoverableSuccess.cached, + recoverableChildrenAfterSuccess: recoverableSuccess.after, requiredMtsDefault: requiredMtsNamespace.default, importedMtsDefault: importedMtsNamespace.default, importedMtsLive: importedMtsNamespace.live, diff --git a/tests/runtime/typescript_runtime.rs b/tests/runtime/typescript_runtime.rs index f20d15cd..56f16d3d 100644 --- a/tests/runtime/typescript_runtime.rs +++ b/tests/runtime/typescript_runtime.rs @@ -90,6 +90,28 @@ async fn strip_typescript_types_matches_node_contract( assert_eq!(report["reexportChildTransformValue"], 42); assert_eq!(report["reexportChildHasPhantom"], false); assert_eq!(report["reexportChildImportTransformCount"], 2); + assert_eq!(report["cachedChildReexportValue"], 42); + assert_eq!(report["cachedChildReexportTransformCount"], 2); + assert_eq!(report["esmChildReexportValue"], 42); + assert_eq!(report["esmChildReexportTransformCount"], 1); + assert_eq!(report["importTypeCommonJsValue"], 42); + assert_eq!( + report["recoverablePrepareError"], + "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX" + ); + assert_eq!(report["recoverableCachedAfterFailure"], false); + assert_eq!( + report["recoverableChildrenAfterFailure"], + report["recoverableChildrenBefore"] + ); + assert_eq!(report["recoverablePrepareValue"], 42); + assert_eq!(report["recoverableCachedAfterSuccess"], true); + assert_eq!( + report["recoverableChildrenAfterSuccess"].as_u64(), + report["recoverableChildrenBefore"] + .as_u64() + .map(|value| value + 1) + ); assert_eq!(report["requiredMtsDefault"], 42); assert_eq!(report["importedMtsDefault"], 42); assert_eq!(report["importedMtsLive"], 1); From 682657e3b8858b1310e4bf41a187ccb734c27b0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Sat, 29 Aug 2026 17:54:58 +0200 Subject: [PATCH 3/5] fix(module): classify TypeScript format from syntax tree --- crates/wasm-rquickjs/skeleton/Cargo.lock | 3 + crates/wasm-rquickjs/skeleton/Cargo.toml_ | 11 +- .../skeleton/src/internal/module_loading.rs | 127 ++++++----------- .../skeleton/src/internal/typescript.rs | 132 +++++++++++++++++- .../src/typescript-runtime.js | 46 +++++- tests/runtime/typescript_runtime.rs | 5 + 6 files changed, 236 insertions(+), 88 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/Cargo.lock b/crates/wasm-rquickjs/skeleton/Cargo.lock index e9c0fb14..98d8a856 100644 --- a/crates/wasm-rquickjs/skeleton/Cargo.lock +++ b/crates/wasm-rquickjs/skeleton/Cargo.lock @@ -2071,6 +2071,9 @@ dependencies = [ "signature", "subtle", "swc_common", + "swc_ecma_ast", + "swc_ecma_parser", + "swc_ecma_visit", "swc_ts_fast_strip", "unicode-segmentation", "url", diff --git a/crates/wasm-rquickjs/skeleton/Cargo.toml_ b/crates/wasm-rquickjs/skeleton/Cargo.toml_ index 711c1856..4b52e28e 100644 --- a/crates/wasm-rquickjs/skeleton/Cargo.toml_ +++ b/crates/wasm-rquickjs/skeleton/Cargo.toml_ @@ -81,7 +81,13 @@ sqlite = ["dep:rusqlite"] timezone = ["dep:chrono-tz"] # TypeScript -typescript-runtime = ["dep:swc_common", "dep:swc_ts_fast_strip"] +typescript-runtime = [ + "dep:swc_common", + "dep:swc_ecma_ast", + "dep:swc_ecma_parser", + "dep:swc_ecma_visit", + "dep:swc_ts_fast_strip", +] typescript-transform-runtime = ["typescript-runtime"] # Test-harness-only transform counting. This is intentionally excluded from every runtime tier. typescript-transform-observability = ["typescript-runtime"] @@ -173,6 +179,9 @@ unicode-segmentation = "1.12" # TypeScript swc_common = { version = "24.0.0", optional = true } +swc_ecma_ast = { version = "27.0.0", optional = true } +swc_ecma_parser = { version = "43.0.0", default-features = false, features = ["typescript", "unstable"], optional = true } +swc_ecma_visit = { version = "27.0.0", optional = true } swc_ts_fast_strip = { version = "56.0.0", optional = true } # HTTP diff --git a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs index c7781d85..97ce2ecd 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs @@ -3722,9 +3722,7 @@ impl NodeFileResolver { semantics: FileCandidateSemantics, ) -> bool { match semantics { - FileCandidateSemantics::ModuleResolution => { - Self::module_resolution_is_file(normalized) - } + FileCandidateSemantics::ModuleResolution => Self::module_resolution_is_file(normalized), FileCandidateSemantics::DirectFilesystem => std::path::Path::new(normalized).is_file(), } } @@ -8696,7 +8694,11 @@ fn analyze_cjs_reexport_specifier_names( }; #[cfg(feature = "typescript-runtime")] let source = if is_typescript_module_path(&child_filename) { - if typescript_module_path_is_esm(ctx, &child_filename, &source) { + if typescript_module_path_is_esm( + &child_filename, + &source, + package_scope_type(ctx, &child_filename).as_deref(), + ) { continue; } if let Some(prepared) = prepared_typescript @@ -8705,34 +8707,34 @@ fn analyze_cjs_reexport_specifier_names( { prepared.prepared_source.clone() } else { - let original_source = source; - let Ok(output) = crate::internal::typescript::transform_module( - original_source.clone(), - &child_filename, - false, - match std::path::Path::new(&child_filename) - .extension() - .and_then(|extension| extension.to_str()) - { - Some("mts") => Some(true), - Some("cts") => Some(false), - _ => None, - }, - ) else { - continue; - }; - if let Some(graph) = prepared_typescript.as_deref_mut() { - let _ = record_typescript_module_transform(ctx); - graph.insert( - child_filename.clone(), - PreparedCjsTypeScript { - original_source, - prepared_source: output.code.clone(), - export_names: Vec::new(), + let original_source = source; + let Ok(output) = crate::internal::typescript::transform_module( + original_source.clone(), + &child_filename, + false, + match std::path::Path::new(&child_filename) + .extension() + .and_then(|extension| extension.to_str()) + { + Some("mts") => Some(true), + Some("cts") => Some(false), + _ => None, }, - ); - } - output.code + ) else { + continue; + }; + if let Some(graph) = prepared_typescript.as_deref_mut() { + let _ = record_typescript_module_transform(ctx); + graph.insert( + child_filename.clone(), + PreparedCjsTypeScript { + original_source, + prepared_source: output.code.clone(), + export_names: Vec::new(), + }, + ); + } + output.code } } else { source @@ -8920,11 +8922,14 @@ fn source_uses_esm_format(source: String) -> bool { } #[cfg(feature = "typescript-runtime")] -fn typescript_module_path_is_esm(ctx: &Ctx<'_>, filename: &str, source: &str) -> bool { - filename.ends_with(".mts") - || (!filename.ends_with(".cts") - && (package_scope_type(ctx, filename).as_deref() == Some("module") - || typescript_source_looks_like_esm(source))) +fn typescript_module_path_is_esm(filename: &str, source: &str, package_type: Option<&str>) -> bool { + if filename.ends_with(".mts") || package_type == Some("module") { + return true; + } + if filename.ends_with(".cts") || package_type == Some("commonjs") { + return false; + } + crate::internal::typescript::source_uses_esm_format(source, filename).unwrap_or(true) } fn is_node_modules_package_scope(dir: &std::path::Path) -> bool { @@ -9143,8 +9148,8 @@ impl Loader for CjsCompatLoader { let source_path = module_source_filesystem_path(ctx, path); let source = read_module_source_or_throw(ctx, path, &source_path)?; #[cfg(feature = "typescript-runtime")] - let raw_typescript_looks_esm = - is_typescript && typescript_module_path_is_esm(ctx, &fs_abs_path, &source); + let raw_typescript_looks_esm = is_typescript + && typescript_module_path_is_esm(&fs_abs_path, &source, package_type.as_deref()); #[cfg(not(feature = "typescript-runtime"))] let raw_typescript_looks_esm = false; #[cfg(feature = "typescript-runtime")] @@ -9956,39 +9961,6 @@ fn source_looks_like_esm(source: &str) -> bool { || source_has_top_level_await(source, false) } -fn typescript_source_looks_like_esm(source: &str) -> bool { - let mut type_only_ranges = Vec::new(); - let _ = scan_code_positions(source, true, |i, _| { - let keyword_end = if parse_ident_name(source, i, "import").is_some() { - i + "import".len() - } else if parse_ident_name(source, i, "export").is_some() { - i + "export".len() - } else { - return ControlFlow::Continue(None); - }; - let type_start = skip_ws_comments(source, keyword_end); - if parse_ident_name(source, type_start, "type").is_none() { - return ControlFlow::Continue(None); - } - let end = module_statement_end(source, keyword_end); - type_only_ranges.push((i, end)); - ControlFlow::Continue(Some(end)) - }); - if type_only_ranges.is_empty() { - return source_looks_like_esm(source); - } - - let mut runtime_source = source.as_bytes().to_vec(); - for (start, end) in type_only_ranges { - for byte in &mut runtime_source[start..end] { - if !matches!(*byte, b'\n' | b'\r') { - *byte = b' '; - } - } - } - source_looks_like_esm(std::str::from_utf8(&runtime_source).expect("source began as UTF-8")) -} - #[derive(Debug, Clone, PartialEq, Eq)] struct StaticModuleEdge { specifier: String, @@ -12295,19 +12267,6 @@ import "./dep.js" withあ; assert!(!source_looks_like_esm("const text = 'import.meta.url';")); } - #[test] - fn typescript_esm_detection_ignores_type_only_module_statements() { - assert!(!typescript_source_looks_like_esm( - "import type { Missing } from './missing.mts'; module.exports = 42;" - )); - assert!(!typescript_source_looks_like_esm( - "export type Answer = number; module.exports = 42;" - )); - assert!(typescript_source_looks_like_esm( - "import type { Missing } from './missing.mts'; export default 42;" - )); - } - #[test] fn esm_syntax_detection_handles_top_level_await_around_templates() { assert!(source_looks_like_esm( diff --git a/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs b/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs index f9f519dd..aedfe0bf 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs @@ -2,10 +2,16 @@ use std::io::Write; use std::sync::{Arc, Mutex}; use swc_common::{ - GLOBALS, Globals, SourceMap, + FileName, GLOBALS, Globals, SourceMap, errors::{HANDLER, Handler}, sync::Lrc, }; +use swc_ecma_ast::{ + ArrowExpr, AwaitExpr, Decl, EsVersion, Function, MetaPropExpr, MetaPropKind, ModuleDecl, + ModuleItem, +}; +use swc_ecma_parser::{Parser, StringInput, Syntax, TsSyntax, lexer::Lexer}; +use swc_ecma_visit::{Visit, VisitWith}; use swc_ts_fast_strip::{ErrorCode, Mode, Options, operate}; #[derive(Copy, Clone)] @@ -22,6 +28,99 @@ pub(crate) fn runtime_mode() -> TypeScriptMode { } } +pub(crate) fn source_uses_esm_format(source: &str, filename: &str) -> Result { + let source_map: Lrc = Default::default(); + let source_file = source_map.new_source_file( + FileName::Custom(filename.to_string()).into(), + source.to_string(), + ); + let lexer = Lexer::new( + Syntax::Typescript(TsSyntax { + tsx: filename.ends_with(".tsx"), + ..Default::default() + }), + EsVersion::EsNext, + StringInput::from(&*source_file), + None, + ); + let mut parser = Parser::new_from(lexer); + let module = parser.parse_module().map_err(|_| ())?; + if !parser.take_errors().is_empty() { + return Err(()); + } + + if module.body.iter().any(module_item_has_runtime_module_decl) { + return Ok(true); + } + + let mut syntax = RuntimeModuleSyntax::default(); + module.visit_with(&mut syntax); + Ok(syntax.found) +} + +fn module_item_has_runtime_module_decl(item: &ModuleItem) -> bool { + let ModuleItem::ModuleDecl(decl) = item else { + return false; + }; + match decl { + ModuleDecl::Import(import) => !import.type_only, + ModuleDecl::ExportAll(export) => !export.type_only, + ModuleDecl::ExportNamed(export) => !export.type_only, + ModuleDecl::ExportDecl(export) => declaration_has_runtime(&export.decl), + ModuleDecl::ExportDefaultDecl(export) => { + !matches!(export.decl, swc_ecma_ast::DefaultDecl::TsInterfaceDecl(_)) + } + ModuleDecl::ExportDefaultExpr(_) => true, + ModuleDecl::TsNamespaceExport(_) => true, + ModuleDecl::TsImportEquals(_) | ModuleDecl::TsExportAssignment(_) => false, + } +} + +fn declaration_has_runtime(decl: &Decl) -> bool { + match decl { + Decl::Class(decl) => !decl.declare, + Decl::Fn(decl) => !decl.declare, + Decl::Var(decl) => !decl.declare, + Decl::Using(_) => true, + Decl::TsInterface(_) | Decl::TsTypeAlias(_) => false, + Decl::TsEnum(decl) => !decl.declare, + Decl::TsModule(decl) => !decl.declare, + } +} + +#[derive(Default)] +struct RuntimeModuleSyntax { + found: bool, + function_depth: usize, +} + +impl Visit for RuntimeModuleSyntax { + fn visit_meta_prop_expr(&mut self, expression: &MetaPropExpr) { + if expression.kind == MetaPropKind::ImportMeta { + self.found = true; + } + } + + fn visit_await_expr(&mut self, expression: &AwaitExpr) { + if self.function_depth == 0 { + self.found = true; + } + expression.visit_children_with(self); + } + + fn visit_function(&mut self, function: &Function) { + self.function_depth += 1; + function.visit_children_with(self); + self.function_depth -= 1; + } + + fn visit_arrow_expr(&mut self, expression: &ArrowExpr) { + self.function_depth += 1; + expression.visit_children_with(self); + self.function_depth -= 1; + } +} + pub(crate) fn transform_module( source: String, filename: &str, @@ -142,7 +241,36 @@ fn typescript_error_code(code: ErrorCode) -> &'static str { #[cfg(test)] mod tests { - use super::{TypeScriptMode, transform}; + use super::{TypeScriptMode, source_uses_esm_format, transform}; + + #[test] + fn module_format_uses_typescript_ast_semantics() { + for source in [ + "import type { Missing } from './missing.mts'; module.exports = 42;", + "export type Answer = number; module.exports = 42;", + "export interface Options { value: number } module.exports = 42;", + "export declare const phantom: number; module.exports = 42;", + "declare namespace Example { type Answer = number } module.exports = 42;", + ] { + assert_eq!(source_uses_esm_format(source, "input.ts"), Ok(false)); + } + for source in [ + "import { type Missing } from './missing.mts'; module.exports = 42;", + "export { type Missing }; module.exports = 42;", + "import type { Missing } from './missing.mts'; export default 42;", + "globalThis.url = import.meta.url;", + "await Promise.resolve();", + ] { + assert_eq!(source_uses_esm_format(source, "input.ts"), Ok(true)); + } + assert_eq!( + source_uses_esm_format( + "async function run() { await Promise.resolve(); }", + "input.ts" + ), + Ok(false) + ); + } #[test] fn strip_preserves_source_positions() { diff --git a/examples/runtime/typescript-runtime/src/typescript-runtime.js b/examples/runtime/typescript-runtime/src/typescript-runtime.js index 69de93ac..11b485db 100644 --- a/examples/runtime/typescript-runtime/src/typescript-runtime.js +++ b/examples/runtime/typescript-runtime/src/typescript-runtime.js @@ -187,9 +187,48 @@ export async function run() { fs.writeFileSync( '/typescript-runtime/import-type-commonjs.ts', - 'import type { Missing } from "./missing.mts"; const value: Missing = 42; module.exports = value;', + `import type { Missing } from "./missing.mts"; + export interface Options { value: Missing } + export declare const phantom: Missing; + const value: Missing = 42; + module.exports = value;`, ); + resetTransformCount(); const importTypeCommonJsValue = (await import('/typescript-runtime/import-type-commonjs.ts')).default; + const importTypeCommonJsTransformCount = getTransformCount(); + + fs.mkdirSync('/typescript-runtime/type-commonjs', { recursive: true }); + fs.writeFileSync('/typescript-runtime/type-commonjs/package.json', '{"type":"commonjs"}'); + fs.writeFileSync( + '/typescript-runtime/type-commonjs/value.ts', + `export interface Options { value: number } + export declare const phantom: number; + module.exports = 42;`, + ); + resetTransformCount(); + const typeCommonJsValue = (await import('/typescript-runtime/type-commonjs/value.ts')).default; + const typeCommonJsTransformCount = getTransformCount(); + + fs.writeFileSync( + '/typescript-runtime/type-only-reexport-child.ts', + `export interface Options { value: number } + export declare const phantom: number; + exports.answer = 42;`, + ); + fs.writeFileSync( + '/typescript-runtime/type-only-reexport-parent.cts', + `const child = require('./type-only-reexport-child.ts'); + Object.keys(child).forEach(function (key) { + if (key === 'default' || key === '__esModule') return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { return child[key]; }, + }); + });`, + ); + resetTransformCount(); + const typeOnlyReexportValue = (await import('/typescript-runtime/type-only-reexport-parent.cts')).answer; + const typeOnlyReexportTransformCount = getTransformCount(); const recoverableFilename = '/typescript-runtime/recoverable-prepare.cts'; fs.writeFileSync( @@ -403,6 +442,11 @@ export async function run() { esmChildReexportValue, esmChildReexportTransformCount, importTypeCommonJsValue, + importTypeCommonJsTransformCount, + typeCommonJsValue, + typeCommonJsTransformCount, + typeOnlyReexportValue, + typeOnlyReexportTransformCount, recoverablePrepareError: recoverableFailure.error, recoverableCachedAfterFailure: recoverableFailure.cached, recoverableChildrenBefore: recoverableFailure.before, diff --git a/tests/runtime/typescript_runtime.rs b/tests/runtime/typescript_runtime.rs index 56f16d3d..e73df99d 100644 --- a/tests/runtime/typescript_runtime.rs +++ b/tests/runtime/typescript_runtime.rs @@ -95,6 +95,11 @@ async fn strip_typescript_types_matches_node_contract( assert_eq!(report["esmChildReexportValue"], 42); assert_eq!(report["esmChildReexportTransformCount"], 1); assert_eq!(report["importTypeCommonJsValue"], 42); + assert_eq!(report["importTypeCommonJsTransformCount"], 1); + assert_eq!(report["typeCommonJsValue"], 42); + assert_eq!(report["typeCommonJsTransformCount"], 1); + assert_eq!(report["typeOnlyReexportValue"], 42); + assert_eq!(report["typeOnlyReexportTransformCount"], 2); assert_eq!( report["recoverablePrepareError"], "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX" From ae809574d69f9d5f831fea89dc34a45f50db310f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Sat, 29 Aug 2026 18:02:19 +0200 Subject: [PATCH 4/5] fix: complete TypeScript module format detection --- .../skeleton/src/internal/module_loading.rs | 10 ++- .../skeleton/src/internal/typescript.rs | 85 ++++++++++++++++++- .../src/typescript-runtime.js | 48 +++++++++++ tests/runtime/typescript_runtime.rs | 4 + 4 files changed, 143 insertions(+), 4 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs index 97ce2ecd..b3b61ef3 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs @@ -8923,10 +8923,16 @@ fn source_uses_esm_format(source: String) -> bool { #[cfg(feature = "typescript-runtime")] fn typescript_module_path_is_esm(filename: &str, source: &str, package_type: Option<&str>) -> bool { - if filename.ends_with(".mts") || package_type == Some("module") { + if filename.ends_with(".mts") { return true; } - if filename.ends_with(".cts") || package_type == Some("commonjs") { + if filename.ends_with(".cts") { + return false; + } + if package_type == Some("module") { + return true; + } + if package_type == Some("commonjs") { return false; } crate::internal::typescript::source_uses_esm_format(source, filename).unwrap_or(true) diff --git a/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs b/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs index aedfe0bf..076a5084 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs @@ -7,8 +7,8 @@ use swc_common::{ sync::Lrc, }; use swc_ecma_ast::{ - ArrowExpr, AwaitExpr, Decl, EsVersion, Function, MetaPropExpr, MetaPropKind, ModuleDecl, - ModuleItem, + ArrowExpr, AwaitExpr, Decl, EsVersion, ForOfStmt, Function, MetaPropExpr, MetaPropKind, + ModuleDecl, ModuleItem, ObjectPatProp, Pat, Stmt, UsingDecl, VarDeclKind, }; use swc_ecma_parser::{Parser, StringInput, Syntax, TsSyntax, lexer::Lexer}; use swc_ecma_visit::{Visit, VisitWith}; @@ -52,12 +52,60 @@ pub(crate) fn source_uses_esm_format(source: &str, filename: &str) -> Result bool { + let ModuleItem::Stmt(Stmt::Decl(decl)) = item else { + return false; + }; + match decl { + Decl::Class(decl) => is_cjs_wrapper_binding(decl.ident.sym.as_ref()), + Decl::Var(decl) if matches!(decl.kind, VarDeclKind::Let | VarDeclKind::Const) => decl + .decls + .iter() + .any(|declarator| pattern_binds_cjs_wrapper(&declarator.name)), + _ => false, + } +} + +fn pattern_binds_cjs_wrapper(pattern: &Pat) -> bool { + match pattern { + Pat::Ident(binding) => is_cjs_wrapper_binding(binding.id.sym.as_ref()), + Pat::Array(pattern) => pattern + .elems + .iter() + .flatten() + .any(pattern_binds_cjs_wrapper), + Pat::Rest(pattern) => pattern_binds_cjs_wrapper(&pattern.arg), + Pat::Object(pattern) => pattern.props.iter().any(|property| match property { + ObjectPatProp::KeyValue(property) => pattern_binds_cjs_wrapper(&property.value), + ObjectPatProp::Assign(property) => { + is_cjs_wrapper_binding(property.key.id.sym.as_ref()) + } + ObjectPatProp::Rest(property) => pattern_binds_cjs_wrapper(&property.arg), + }), + Pat::Assign(pattern) => pattern_binds_cjs_wrapper(&pattern.left), + Pat::Invalid(_) | Pat::Expr(_) => false, + } +} + +fn is_cjs_wrapper_binding(name: &str) -> bool { + CJS_WRAPPER_BINDINGS.contains(&name) +} + fn module_item_has_runtime_module_decl(item: &ModuleItem) -> bool { let ModuleItem::ModuleDecl(decl) = item else { return false; @@ -108,6 +156,20 @@ impl Visit for RuntimeModuleSyntax { expression.visit_children_with(self); } + fn visit_for_of_stmt(&mut self, statement: &ForOfStmt) { + if self.function_depth == 0 && statement.is_await { + self.found = true; + } + statement.visit_children_with(self); + } + + fn visit_using_decl(&mut self, declaration: &UsingDecl) { + if self.function_depth == 0 && declaration.is_await { + self.found = true; + } + declaration.visit_children_with(self); + } + fn visit_function(&mut self, function: &Function) { self.function_depth += 1; function.visit_children_with(self); @@ -260,6 +322,10 @@ mod tests { "import type { Missing } from './missing.mts'; export default 42;", "globalThis.url = import.meta.url;", "await Promise.resolve();", + "for await (const item of items) { consume(item); }", + "await using resource = acquire();", + "const { value: module } = input;", + "class exports {}", ] { assert_eq!(source_uses_esm_format(source, "input.ts"), Ok(true)); } @@ -270,6 +336,21 @@ mod tests { ), Ok(false) ); + assert_eq!( + source_uses_esm_format( + "async function run() { for await (const item of items) { consume(item); } }", + "input.ts" + ), + Ok(false) + ); + assert_eq!( + source_uses_esm_format( + "async function run() { await using resource = acquire(); }", + "input.ts" + ), + Ok(false) + ); + assert_eq!(source_uses_esm_format("var require = load;", "input.ts"), Ok(false)); } #[test] diff --git a/examples/runtime/typescript-runtime/src/typescript-runtime.js b/examples/runtime/typescript-runtime/src/typescript-runtime.js index 11b485db..04287509 100644 --- a/examples/runtime/typescript-runtime/src/typescript-runtime.js +++ b/examples/runtime/typescript-runtime/src/typescript-runtime.js @@ -230,6 +230,50 @@ export async function run() { const typeOnlyReexportValue = (await import('/typescript-runtime/type-only-reexport-parent.cts')).answer; const typeOnlyReexportTransformCount = getTransformCount(); + fs.mkdirSync('/typescript-runtime/type-module-cts', { recursive: true }); + fs.writeFileSync('/typescript-runtime/type-module-cts/package.json', '{"type":"module"}'); + fs.writeFileSync( + '/typescript-runtime/type-module-cts/child.cts', + 'type Answer = number; exports.answer = 42 as Answer;', + ); + fs.writeFileSync( + '/typescript-runtime/type-module-cts/parent.cts', + `const child = require('./child.cts'); + Object.keys(child).forEach(function (key) { + if (key === 'default' || key === '__esModule') return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { return child[key]; }, + }); + });`, + ); + resetTransformCount(); + const typeModuleCtsReexportValue = (await import('/typescript-runtime/type-module-cts/parent.cts')).answer; + const typeModuleCtsReexportTransformCount = getTransformCount(); + + fs.writeFileSync( + '/typescript-runtime/lexical-esm-reexport-child.ts', + `const { value: module } = { value: 1 }; + export const answer: number = module + 41;`, + ); + fs.writeFileSync( + '/typescript-runtime/lexical-esm-reexport-parent.cts', + `if (false) { + const child = require('./lexical-esm-reexport-child.ts'); + Object.keys(child).forEach(function (key) { + if (key === 'default' || key === '__esModule') return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { return child[key]; }, + }); + }); + } + module.exports = 42;`, + ); + resetTransformCount(); + const lexicalEsmChildReexportValue = (await import('/typescript-runtime/lexical-esm-reexport-parent.cts')).default; + const lexicalEsmChildReexportTransformCount = getTransformCount(); + const recoverableFilename = '/typescript-runtime/recoverable-prepare.cts'; fs.writeFileSync( '/typescript-runtime/recoverable-parent.cjs', @@ -447,6 +491,10 @@ export async function run() { typeCommonJsTransformCount, typeOnlyReexportValue, typeOnlyReexportTransformCount, + typeModuleCtsReexportValue, + typeModuleCtsReexportTransformCount, + lexicalEsmChildReexportValue, + lexicalEsmChildReexportTransformCount, recoverablePrepareError: recoverableFailure.error, recoverableCachedAfterFailure: recoverableFailure.cached, recoverableChildrenBefore: recoverableFailure.before, diff --git a/tests/runtime/typescript_runtime.rs b/tests/runtime/typescript_runtime.rs index e73df99d..d6a13dab 100644 --- a/tests/runtime/typescript_runtime.rs +++ b/tests/runtime/typescript_runtime.rs @@ -100,6 +100,10 @@ async fn strip_typescript_types_matches_node_contract( assert_eq!(report["typeCommonJsTransformCount"], 1); assert_eq!(report["typeOnlyReexportValue"], 42); assert_eq!(report["typeOnlyReexportTransformCount"], 2); + assert_eq!(report["typeModuleCtsReexportValue"], 42); + assert_eq!(report["typeModuleCtsReexportTransformCount"], 2); + assert_eq!(report["lexicalEsmChildReexportValue"], 42); + assert_eq!(report["lexicalEsmChildReexportTransformCount"], 1); assert_eq!( report["recoverablePrepareError"], "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX" From d107dc6cdaffdbe1f4e069a1a6b4c3d7a16472c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Sat, 29 Aug 2026 18:13:32 +0200 Subject: [PATCH 5/5] test: cover TypeScript format edge cases --- .../skeleton/src/internal/typescript.rs | 33 +++-- .../src/typescript-runtime.js | 125 +++++++++++++++--- tests/runtime/typescript_runtime.rs | 12 +- 3 files changed, 143 insertions(+), 27 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs b/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs index 076a5084..0ee72ba0 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs @@ -72,11 +72,14 @@ fn module_item_has_cjs_wrapper_lexical_declaration(item: &ModuleItem) -> bool { return false; }; match decl { - Decl::Class(decl) => is_cjs_wrapper_binding(decl.ident.sym.as_ref()), - Decl::Var(decl) if matches!(decl.kind, VarDeclKind::Let | VarDeclKind::Const) => decl - .decls - .iter() - .any(|declarator| pattern_binds_cjs_wrapper(&declarator.name)), + Decl::Class(decl) => !decl.declare && is_cjs_wrapper_binding(decl.ident.sym.as_ref()), + Decl::Var(decl) + if !decl.declare && matches!(decl.kind, VarDeclKind::Let | VarDeclKind::Const) => + { + decl.decls + .iter() + .any(|declarator| pattern_binds_cjs_wrapper(&declarator.name)) + } _ => false, } } @@ -92,9 +95,7 @@ fn pattern_binds_cjs_wrapper(pattern: &Pat) -> bool { Pat::Rest(pattern) => pattern_binds_cjs_wrapper(&pattern.arg), Pat::Object(pattern) => pattern.props.iter().any(|property| match property { ObjectPatProp::KeyValue(property) => pattern_binds_cjs_wrapper(&property.value), - ObjectPatProp::Assign(property) => { - is_cjs_wrapper_binding(property.key.id.sym.as_ref()) - } + ObjectPatProp::Assign(property) => is_cjs_wrapper_binding(property.key.id.sym.as_ref()), ObjectPatProp::Rest(property) => pattern_binds_cjs_wrapper(&property.arg), }), Pat::Assign(pattern) => pattern_binds_cjs_wrapper(&pattern.left), @@ -350,7 +351,21 @@ mod tests { ), Ok(false) ); - assert_eq!(source_uses_esm_format("var require = load;", "input.ts"), Ok(false)); + assert_eq!( + source_uses_esm_format("var require = load;", "input.ts"), + Ok(false) + ); + assert_eq!( + source_uses_esm_format( + "declare const require: unknown; module.exports = 42;", + "input.ts" + ), + Ok(false) + ); + assert_eq!( + source_uses_esm_format("declare class module {} module.exports = 42;", "input.ts"), + Ok(false) + ); } #[test] diff --git a/examples/runtime/typescript-runtime/src/typescript-runtime.js b/examples/runtime/typescript-runtime/src/typescript-runtime.js index 04287509..851fb9f0 100644 --- a/examples/runtime/typescript-runtime/src/typescript-runtime.js +++ b/examples/runtime/typescript-runtime/src/typescript-runtime.js @@ -253,26 +253,107 @@ export async function run() { fs.writeFileSync( '/typescript-runtime/lexical-esm-reexport-child.ts', - `const { value: module } = { value: 1 }; - export const answer: number = module + 41;`, + 'const { value: module } = { value: 1 };', ); - fs.writeFileSync( - '/typescript-runtime/lexical-esm-reexport-parent.cts', - `if (false) { - const child = require('./lexical-esm-reexport-child.ts'); - Object.keys(child).forEach(function (key) { - if (key === 'default' || key === '__esModule') return; - Object.defineProperty(exports, key, { - enumerable: true, - get: function () { return child[key]; }, - }); + const writeAnalysisOnlyReexport = (parent, child) => fs.writeFileSync( + parent, + `throw new Error('ANALYSIS_ONLY'); + const child = require('${child}'); + Object.keys(child).forEach(function (key) { + if (key === 'default' || key === '__esModule') return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { return child[key]; }, }); - } - module.exports = 42;`, + });`, ); - resetTransformCount(); - const lexicalEsmChildReexportValue = (await import('/typescript-runtime/lexical-esm-reexport-parent.cts')).default; - const lexicalEsmChildReexportTransformCount = getTransformCount(); + const observeAnalysisOnlyReexport = async (parent) => { + resetTransformCount(); + try { + await import(parent); + } catch (error) { + return { error: error.message, transformCount: getTransformCount() }; + } + throw new Error(`expected analysis-only parent ${parent} to throw`); + }; + writeAnalysisOnlyReexport( + '/typescript-runtime/lexical-esm-reexport-parent.cts', + './lexical-esm-reexport-child.ts', + ); + const lexicalEsmChildReexport = await observeAnalysisOnlyReexport( + '/typescript-runtime/lexical-esm-reexport-parent.cts', + ); + const lexicalEsmChildReexportValue = lexicalEsmChildReexport.error; + const lexicalEsmChildReexportTransformCount = lexicalEsmChildReexport.transformCount; + fs.writeFileSync( + '/typescript-runtime/top-level-for-await-child.ts', + 'const marker: number = 1; for await (const item of []) { void item; }', + ); + writeAnalysisOnlyReexport( + '/typescript-runtime/top-level-for-await-parent.cts', + './top-level-for-await-child.ts', + ); + const topLevelForAwaitReexport = await observeAnalysisOnlyReexport( + '/typescript-runtime/top-level-for-await-parent.cts', + ); + const topLevelForAwaitReexportValue = topLevelForAwaitReexport.error; + const topLevelForAwaitReexportTransformCount = topLevelForAwaitReexport.transformCount; + + fs.writeFileSync( + '/typescript-runtime/nested-for-await-child.ts', + 'const marker: number = 1; async function run() { for await (const item of []) { void item; } } module.exports = marker + 41;', + ); + writeAnalysisOnlyReexport( + '/typescript-runtime/nested-for-await-parent.cts', + './nested-for-await-child.ts', + ); + const nestedForAwaitReexport = await observeAnalysisOnlyReexport( + '/typescript-runtime/nested-for-await-parent.cts', + ); + const nestedForAwaitReexportValue = nestedForAwaitReexport.error; + const nestedForAwaitReexportTransformCount = nestedForAwaitReexport.transformCount; + + fs.writeFileSync( + '/typescript-runtime/top-level-await-using-child.ts', + 'const marker: number = 1; await using resource = acquire();', + ); + writeAnalysisOnlyReexport( + '/typescript-runtime/top-level-await-using-parent.cts', + './top-level-await-using-child.ts', + ); + const topLevelAwaitUsingReexport = await observeAnalysisOnlyReexport( + '/typescript-runtime/top-level-await-using-parent.cts', + ); + const topLevelAwaitUsingReexportValue = topLevelAwaitUsingReexport.error; + const topLevelAwaitUsingReexportTransformCount = topLevelAwaitUsingReexport.transformCount; + + fs.writeFileSync( + '/typescript-runtime/nested-await-using-child.ts', + 'const marker: number = 1; async function run() { await using resource = acquire(); } module.exports = marker + 41;', + ); + writeAnalysisOnlyReexport( + '/typescript-runtime/nested-await-using-parent.cts', + './nested-await-using-child.ts', + ); + const nestedAwaitUsingReexport = await observeAnalysisOnlyReexport( + '/typescript-runtime/nested-await-using-parent.cts', + ); + const nestedAwaitUsingReexportValue = nestedAwaitUsingReexport.error; + const nestedAwaitUsingReexportTransformCount = nestedAwaitUsingReexport.transformCount; + + fs.writeFileSync( + '/typescript-runtime/declare-wrapper-child.ts', + 'declare const require: unknown; declare class module {} module.exports = 42;', + ); + writeAnalysisOnlyReexport( + '/typescript-runtime/declare-wrapper-parent.cts', + './declare-wrapper-child.ts', + ); + const declareWrapperReexport = await observeAnalysisOnlyReexport( + '/typescript-runtime/declare-wrapper-parent.cts', + ); + const declareWrapperReexportValue = declareWrapperReexport.error; + const declareWrapperReexportTransformCount = declareWrapperReexport.transformCount; const recoverableFilename = '/typescript-runtime/recoverable-prepare.cts'; fs.writeFileSync( @@ -495,6 +576,16 @@ export async function run() { typeModuleCtsReexportTransformCount, lexicalEsmChildReexportValue, lexicalEsmChildReexportTransformCount, + topLevelForAwaitReexportValue, + topLevelForAwaitReexportTransformCount, + nestedForAwaitReexportValue, + nestedForAwaitReexportTransformCount, + topLevelAwaitUsingReexportValue, + topLevelAwaitUsingReexportTransformCount, + nestedAwaitUsingReexportValue, + nestedAwaitUsingReexportTransformCount, + declareWrapperReexportValue, + declareWrapperReexportTransformCount, recoverablePrepareError: recoverableFailure.error, recoverableCachedAfterFailure: recoverableFailure.cached, recoverableChildrenBefore: recoverableFailure.before, diff --git a/tests/runtime/typescript_runtime.rs b/tests/runtime/typescript_runtime.rs index d6a13dab..a50d38f1 100644 --- a/tests/runtime/typescript_runtime.rs +++ b/tests/runtime/typescript_runtime.rs @@ -102,8 +102,18 @@ async fn strip_typescript_types_matches_node_contract( assert_eq!(report["typeOnlyReexportTransformCount"], 2); assert_eq!(report["typeModuleCtsReexportValue"], 42); assert_eq!(report["typeModuleCtsReexportTransformCount"], 2); - assert_eq!(report["lexicalEsmChildReexportValue"], 42); + assert_eq!(report["lexicalEsmChildReexportValue"], "ANALYSIS_ONLY"); assert_eq!(report["lexicalEsmChildReexportTransformCount"], 1); + assert_eq!(report["topLevelForAwaitReexportValue"], "ANALYSIS_ONLY"); + assert_eq!(report["topLevelForAwaitReexportTransformCount"], 1); + assert_eq!(report["nestedForAwaitReexportValue"], "ANALYSIS_ONLY"); + assert_eq!(report["nestedForAwaitReexportTransformCount"], 2); + assert_eq!(report["topLevelAwaitUsingReexportValue"], "ANALYSIS_ONLY"); + assert_eq!(report["topLevelAwaitUsingReexportTransformCount"], 1); + assert_eq!(report["nestedAwaitUsingReexportValue"], "ANALYSIS_ONLY"); + assert_eq!(report["nestedAwaitUsingReexportTransformCount"], 2); + assert_eq!(report["declareWrapperReexportValue"], "ANALYSIS_ONLY"); + assert_eq!(report["declareWrapperReexportTransformCount"], 2); assert_eq!( report["recoverablePrepareError"], "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX"