From 63211f94202f2ce9ac7db555390bec88ec067279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Tue, 1 Sep 2026 17:00:07 +0200 Subject: [PATCH 01/13] Implement TypeScript runtime stack source maps --- .../skeleton/src/builtin/execution.rs | 24 +++- .../skeleton/src/builtin/internal/errors.js | 129 +++++++++++------ .../skeleton/src/builtin/module.js | 136 ++++++++++++++++-- .../skeleton/src/builtin/typescript.rs | 7 +- .../skeleton/src/builtin/util.js | 63 ++++---- .../skeleton/src/internal/module_loading.rs | 23 ++- .../skeleton/src/internal/typescript.rs | 14 ++ .../src/typescript-runtime.js | 14 ++ .../src/typescript-transform-runtime.js | 104 ++++++++++++++ tests/runtime/typescript_runtime.rs | 45 ++++++ 10 files changed, 471 insertions(+), 88 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/execution.rs b/crates/wasm-rquickjs/skeleton/src/builtin/execution.rs index cb3ec0d0f..3bc32ba44 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/execution.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/execution.rs @@ -4,7 +4,7 @@ use crate::internal::runtime_services::{ use futures::future::{Either, pending, poll_fn, select}; use futures::task::AtomicWaker; -use rquickjs::{CatchResultExt, Ctx, Module, Promise, async_with}; +use rquickjs::{CatchResultExt, Ctx, Function, Module, Promise, Value, async_with}; use serde::Deserialize; use std::cell::{Cell, RefCell}; use std::collections::{HashMap, VecDeque}; @@ -157,10 +157,10 @@ fn transform_typescript_execution_source(source: String, name: &str) -> Result) { ) } else { format!( - "globalThis.__wasmRquickjsExecutionResult = (async () => __wasmRquickjsSerializeExecutionResult(await (async () => {{ {}\n}})()))();", + "globalThis.__wasmRquickjsExecutionResult = (async () => __wasmRquickjsSerializeExecutionResult(await (async () => {{\n{}\n}})()))();", options.source.unwrap_or_default() ) }; @@ -481,6 +481,22 @@ async fn run_job(options: ExecutionOptions, job: Rc) { } let execution = async { async_with!(runtime.ctx => |ctx| { + if options.language == ExecutionLanguage::Typescript + && let Ok(register_source_map) = ctx.globals().get::<_, Function>( + "__wasm_rquickjs_register_transformed_source_map", + ) + { + register_source_map.call::<_, ()>(( + name.as_str(), + source.as_str(), + Value::new_null(ctx.clone()), + 0, + 1, + )) + .map_err(|error| { + format!("failed to register TypeScript source map: {error:?}") + })?; + } Module::evaluate(ctx.clone(), name, source).catch(&ctx) .map_err(|e| crate::internal::format_caught_error(e))?.finish::<()>().catch(&ctx) .map_err(|e| crate::internal::format_caught_error(e))?; diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js b/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js index 1097af4f9..55ad9f632 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js @@ -14,6 +14,54 @@ import { inspect, format } from "__wasm_rquickjs_builtin/internal/util/inspect"; const _callSiteWithFnPattern = /^\s*at\s+(.+?)\s+\((.+):(\d+):(\d+)\)\s*$/; const _callSiteNoFnPattern = /^\s*at\s+(.+):(\d+):(\d+)\s*$/; +function _remapSourceMappedLocation(fileName, lineNumber, columnNumber) { + const mapper = globalThis.__wasm_rquickjs_remap_source_mapped_position; + if (typeof mapper !== 'function') return undefined; + try { + return mapper(fileName, lineNumber, columnNumber); + } catch { + return undefined; + } +} + +function _remapSourceMappedStack(stackString) { + if (typeof stackString !== 'string') return stackString; + if (globalThis.__wasm_rquickjs_suppress_source_map_stack === true) return stackString; + const lines = stackString.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + let match = line.match(_callSiteWithFnPattern); + let fileName; + let lineNumber; + let columnNumber; + if (match) { + fileName = match[2]; + lineNumber = parseInt(match[3], 10); + columnNumber = parseInt(match[4], 10); + } else { + match = line.match(_callSiteNoFnPattern); + if (!match) continue; + fileName = match[1]; + lineNumber = parseInt(match[2], 10); + columnNumber = parseInt(match[3], 10); + } + const origin = _remapSourceMappedLocation(fileName, lineNumber, columnNumber); + if (!origin || typeof origin.fileName !== 'string') continue; + const location = `${fileName}:${lineNumber}:${columnNumber}`; + const offset = line.lastIndexOf(location); + if (offset === -1) continue; + const mapped = `${origin.fileName}:${origin.lineNumber}:${origin.columnNumber}`; + lines[i] = line.slice(0, offset) + mapped + line.slice(offset + location.length); + } + return lines.join('\n'); +} + +Object.defineProperty(globalThis, '__wasm_rquickjs_remap_source_mapped_stack', { + value: _remapSourceMappedStack, + writable: false, + configurable: false, +}); + function _makeCallSite(functionName, fileName, lineNumber, columnNumber) { return { getThis() { return undefined; }, @@ -45,6 +93,7 @@ function _makeCallSite(functionName, fileName, lineNumber, columnNumber) { function _parseStackStringToCallSites(stackString) { if (typeof stackString !== 'string') return []; + stackString = _remapSourceMappedStack(stackString); const lines = stackString.split('\n'); const sites = []; for (let i = 0; i < lines.length; i++) { @@ -81,6 +130,7 @@ const nativeErrorStackDescriptor = Object.getOwnPropertyDescriptor(Error.prototy const NativeError = Error; const nativeErrorToString = Error.prototype.toString; const materializedErrorStacks = new WeakSet(); +let errorStackShimInstalled = false; function materializeOwnStack(errorInstance) { if (!errorInstance || (typeof errorInstance !== "object" && typeof errorInstance !== "function")) { @@ -115,14 +165,15 @@ function materializeOwnStack(errorInstance) { } try { - Object.defineProperty(errorInstance, "stack", _dataDesc(stackValue)); + Object.defineProperty(errorInstance, "stack", _dataDesc(_remapSourceMappedStack(stackValue))); materializedErrorStacks.add(errorInstance); } catch { // Best effort only. } } -function installErrorStackShimForNonConfigurablePrototype() { +function installErrorStackShim() { + if (errorStackShimInstalled) return; const ErrorShimPrototype = Object.create(NativeError.prototype); Object.defineProperty(ErrorShimPrototype, "stack", { @@ -149,6 +200,25 @@ function installErrorStackShimForNonConfigurablePrototype() { const ErrorShim = function Error() { const ctorTarget = new.target || ErrorShim; const errorInstance = Reflect.construct(NativeError, arguments, NativeError); + if (typeof NativeError.captureStackTrace === 'function') { + const hadSuppression = Object.prototype.hasOwnProperty.call( + globalThis, + '__wasm_rquickjs_suppress_source_map_stack', + ); + const previousSuppression = globalThis.__wasm_rquickjs_suppress_source_map_stack; + globalThis.__wasm_rquickjs_suppress_source_map_stack = true; + try { + NativeError.captureStackTrace(errorInstance, ErrorShim); + } catch { + // Keep the native constructor stack if captureStackTrace rejects. + } finally { + if (hadSuppression) { + globalThis.__wasm_rquickjs_suppress_source_map_stack = previousSuppression; + } else { + delete globalThis.__wasm_rquickjs_suppress_source_map_stack; + } + } + } materializeOwnStack(errorInstance); // Error.prepareStackTrace support (V8 compat). @@ -161,7 +231,7 @@ function installErrorStackShimForNonConfigurablePrototype() { const prepareStackTrace = globalThis.Error && globalThis.Error.prepareStackTrace; const result = typeof prepareStackTrace === "function" ? prepareStackTrace(errorInstance, _parseStackStringToCallSites(rawStack)) - : rawStack; + : _remapSourceMappedStack(rawStack); Object.defineProperty(errorInstance, "stack", _dataDesc(result)); return result; }, @@ -197,45 +267,20 @@ function installErrorStackShimForNonConfigurablePrototype() { }); globalThis.Error = ErrorShim; + errorStackShimInstalled = true; } -if (nativeErrorStackDescriptor && nativeErrorStackDescriptor.configurable === false) { - try { - installErrorStackShimForNonConfigurablePrototype(); - } catch { - // Keep the runtime default behavior if shimming fails. - } -} else { - try { - Object.defineProperty(Error.prototype, "stack", { - configurable: true, - enumerable: false, - get: function getErrorStack() { - if (this === Error.prototype) { - return undefined; - } - - const own = Object.getOwnPropertyDescriptor(this, "stack"); - if (own) { - if (Object.prototype.hasOwnProperty.call(own, "value")) { - return own.value; - } - if (typeof own.get === "function" && own.get !== getErrorStack) { - return own.get.call(this); - } - return undefined; - } - return undefined; - }, - set(value) { - Object.defineProperty(this, "stack", _dataDesc(value)); - materializedErrorStacks.add(this); - }, - }); - } catch { - // Keep best-effort compatibility if the runtime forbids reconfiguration. - } -} +Object.defineProperty(globalThis, '__wasm_rquickjs_install_source_map_error_stack_shim', { + value() { + try { + installErrorStackShim(); + } catch { + // Keep the runtime default behavior if shimming fails. + } + }, + writable: false, + configurable: false, +}); // --------------------------------------------------------------------------- // Global Error.captureStackTrace & Error.stackTraceLimit (V8 compat) @@ -290,8 +335,10 @@ if (nativeErrorStackDescriptor && nativeErrorStackDescriptor.configurable === fa }); } } else { - // No prepareStackTrace set — just use the native implementation as-is. nativeCaptureStackTrace(targetObject, constructorOpt); + if (typeof targetObject.stack === 'string') { + targetObject.stack = _remapSourceMappedStack(targetObject.stack); + } } }; } diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 5d9d58e95..c5c83fa29 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -1208,16 +1208,31 @@ function rustHasExecArgvFlag(flag) { return wasmRquickjsModuleGlobalThis.__wasm_rquickjs_module_has_exec_argv_flag(flag); } +function hasActiveExecArgvFlag(flag) { + if (rustHasExecArgvFlag(flag)) return true; + const activeProcess = wasmRquickjsModuleGlobalThis.process; + if (!activeProcess || !Array.isArray(activeProcess.execArgv)) return false; + const prefixed = flag + '='; + return activeProcess.execArgv.some((arg) => { + arg = String(arg); + return arg === flag || arg.startsWith(prefixed); + }); +} + function isExperimentalTransformTypesEnabled() { - return rustHasExecArgvFlag('--experimental-transform-types'); + return hasActiveExecArgvFlag('--experimental-transform-types'); } function isSourceMapsEnabled() { - if (rustHasExecArgvFlag('--no-enable-source-maps')) { + if (hasActiveExecArgvFlag('--no-enable-source-maps')) { return false; } - return rustHasExecArgvFlag('--enable-source-maps') || isExperimentalTransformTypesEnabled(); + return hasActiveExecArgvFlag('--enable-source-maps') || + isExperimentalTransformTypesEnabled() || + (wasmRquickjsModuleGlobalThis.process && + wasmRquickjsModuleGlobalThis.process.features && + wasmRquickjsModuleGlobalThis.process.features.typescript === 'transform'); } function getSimpleSourceMapRegistry() { @@ -1247,6 +1262,15 @@ function getCjsLineOffsetRegistry() { return registry; } +function getForcedSourceMapLineOffsetRegistry() { + let registry = globalThis.__wasm_rquickjs_forced_source_map_line_offsets; + if (!registry || typeof registry !== 'object') { + registry = Object.create(null); + globalThis.__wasm_rquickjs_forced_source_map_line_offsets = registry; + } + return registry; +} + const cjsLineOffset = 6; function derefWeakRef(ref) { @@ -1487,6 +1511,7 @@ class SourceMap { if (options.lineLengths !== undefined) { this.lineLengths = Array.prototype.slice.call(options.lineLengths); } + this._originalLineOffset = Number(options.originalLineOffset) || 0; this._decodedMappings = decodeSourceMapPayload(this.payload, options.sourceBasePath); } @@ -1506,7 +1531,7 @@ class SourceMap { return { name: match.name, fileName: match.originalSource, - lineNumber: match.originalLine + 1, + lineNumber: match.originalLine + 1 - this._originalLineOffset, columnNumber: match.originalColumn + (generatedColumn - match.generatedColumn) + 1, }; } @@ -1542,7 +1567,7 @@ function decodeInlineSourceMap(url) { } } -function registerSourceMapForCjs(filename, source, moduleObject) { +function registerSourceMapForCjs(filename, source, moduleObject, options = undefined) { const registry = getSimpleSourceMapRegistry(); const owners = getCjsSourceMapOwnerRegistry(); if (!isSourceMapsEnabled()) { @@ -1588,7 +1613,10 @@ function registerSourceMapForCjs(filename, source, moduleObject) { registry[filename] = new SourceMap(payload, { lineLengths: sourceMapLineLengths(source), sourceBasePath, + originalLineOffset: options && options.originalLineOffset, }); + const installErrorStackShim = globalThis.__wasm_rquickjs_install_source_map_error_stack_shim; + if (typeof installErrorStackShim === 'function') installErrorStackShim(); if (moduleObject) { const ownerRef = makeWeakRef(moduleObject); if (ownerRef !== undefined) { @@ -1601,6 +1629,91 @@ function registerSourceMapForCjs(filename, source, moduleObject) { } } +function registerSourceMapForTransformedSource( + filename, + source, + alias, + lineOffset = 0, + originalLineOffset = 0, + forceLineOffset = false, +) { + filename = String(filename); + registerSourceMapForCjs(filename, String(source), undefined, { originalLineOffset }); + if (forceLineOffset) { + const installErrorStackShim = globalThis.__wasm_rquickjs_install_source_map_error_stack_shim; + if (typeof installErrorStackShim === 'function') installErrorStackShim(); + } + const registry = getSimpleSourceMapRegistry(); + const offsets = getCjsLineOffsetRegistry(); + const forcedOffsets = getForcedSourceMapLineOffsetRegistry(); + lineOffset = Number(lineOffset); + if (Number.isFinite(lineOffset) && lineOffset > 0) offsets[filename] = lineOffset; + else delete offsets[filename]; + if (forceLineOffset) forcedOffsets[filename] = true; + else delete forcedOffsets[filename]; + if (alias !== undefined && alias !== null) { + alias = String(alias); + if (registry[filename] !== undefined) registry[alias] = registry[filename]; + else delete registry[alias]; + if (Number.isFinite(lineOffset) && lineOffset > 0) offsets[alias] = lineOffset; + else delete offsets[alias]; + if (forceLineOffset) forcedOffsets[alias] = true; + else delete forcedOffsets[alias]; + } +} + +function remapSourceMappedPosition( + scriptName, + lineNumber, + columnNumber, + hasCjsWrapperOffset = false, +) { + scriptName = String(scriptName); + lineNumber = Number(lineNumber); + columnNumber = Number(columnNumber); + if (!Number.isFinite(lineNumber) || !Number.isFinite(columnNumber)) return undefined; + + const registry = getSimpleSourceMapRegistry(); + const sourceMap = registry[scriptName]; + const lineOffsets = getCjsLineOffsetRegistry(); + const forcedOffsets = getForcedSourceMapLineOffsetRegistry(); + const lineOffset = lineOffsets[scriptName]; + const forcedLineOffset = forcedOffsets[scriptName] === true; + if (typeof lineOffset === 'number' && Number.isFinite(lineOffset) && lineOffset > 0 && + (forcedLineOffset || + hasCjsWrapperOffset === true || + (sourceMap && Array.isArray(sourceMap._decodedMappings) && + lineNumber - 1 >= sourceMap._decodedMappings.length))) { + lineNumber -= lineOffset; + } + if (lineNumber <= 0) return undefined; + if (!isSourceMapsEnabled() || !sourceMap || typeof sourceMap.findOrigin !== 'function') { + return forcedLineOffset + ? { fileName: scriptName, lineNumber, columnNumber } + : undefined; + } + + const origin = sourceMap.findOrigin(lineNumber, columnNumber); + if (!origin || typeof origin.fileName !== 'string' || + !Number.isFinite(origin.lineNumber) || !Number.isFinite(origin.columnNumber)) { + return undefined; + } + return origin; +} + +Object.defineProperties(globalThis, { + __wasm_rquickjs_register_transformed_source_map: { + value: registerSourceMapForTransformedSource, + writable: false, + configurable: false, + }, + __wasm_rquickjs_remap_source_mapped_position: { + value: remapSourceMappedPosition, + writable: false, + configurable: false, + }, +}); + function isTypeScriptFilename(filename) { return filename.endsWith('.ts') || filename.endsWith('.cts') || filename.endsWith('.mts'); } @@ -1653,10 +1766,12 @@ function transpileTypeScriptModule(filename, source, module = undefined) { // Rust owns the transform semantics. This adapter only applies CommonJS // loader policy; the Rust filesystem loader applies the same service for ESM. const output = JSON.parse(transformTypeScriptModuleNative( - String(source), filename, module + String(source), filename, isSourceMapsEnabled(), module )); recordTypeScriptModuleTransform(); - return output.code; + if (!output.sourceMap) return output.code; + const encoded = buffer.Buffer.from(output.sourceMap, 'utf8').toString('base64'); + return output.code + `\n//# sourceMappingURL=data:application/json;base64,${encoded}`; } function prepareCommonJsTypeScript(filename, source) { @@ -2349,7 +2464,7 @@ function wrapForCompile(script, dynamicImportBindings) { return activeWrapper[0] + script + activeWrapper[1]; } -function compileCjs(filename, source, isPreparedTypeScript = false) { +function compileCjs(filename, source, isPreparedTypeScript = false, moduleObject = undefined) { if (source.length > 0 && source.charCodeAt(0) === 0xFEFF) { source = source.slice(1); } @@ -2361,6 +2476,7 @@ function compileCjs(filename, source, isPreparedTypeScript = false) { if (!isPreparedTypeScript) { source = transpileTypeScriptModule(filename, source, false); } + registerSourceMapForCjs(filename, source, moduleObject); source = stripV8OptimizationIntrinsics(source); const strippedImportAttributes = wasmRquickjsModuleGlobalThis.__wasm_rquickjs_prepare_cjs_source( source, @@ -2402,14 +2518,13 @@ function callCompiledCjsFunction(mod, compiledFn, source, filename, dirname, chi function compileModuleInto(mod, source, filename, requireOverride) { filename = filename === undefined || filename === null ? mod.filename : filename; source = String(source); - registerSourceMapForCjs(filename, source, mod); const requireParentFilename = filename === '' && mod && typeof mod.filename === 'string' ? mod.filename : filename; const dirname = pathModule.dirname(filename); const requireDirname = pathModule.dirname(requireParentFilename); const childRequire = requireOverride || makeRequire(requireDirname, mod, requireParentFilename); - const compiledFn = compileCjs(filename, source); + const compiledFn = compileCjs(filename, source, false, mod); return callCompiledCjsFunction(mod, compiledFn, source, filename, dirname, childRequire); } @@ -3066,6 +3181,7 @@ function loadCommonJsTransaction(descriptor) { filename, compiledSource, true, + mod, ); } catch (err) { // Normalize QuickJS SyntaxError messages for ESM keywords in CJS context diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/typescript.rs b/crates/wasm-rquickjs/skeleton/src/builtin/typescript.rs index 26db648a3..be517d98c 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/typescript.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/typescript.rs @@ -73,18 +73,21 @@ pub mod native_module { ctx: Ctx<'_>, source: String, filename: String, + source_map: bool, module: Option, ) -> rquickjs::Result { #[cfg(feature = "typescript-runtime")] { serialize_transform_result( &ctx, - crate::internal::typescript::transform_module(source, &filename, false, module), + crate::internal::typescript::transform_module( + source, &filename, source_map, module, + ), ) } #[cfg(not(feature = "typescript-runtime"))] { - let _ = (source, filename, module); + let _ = (source, filename, source_map, module); Err(rquickjs::Exception::throw_message( &ctx, "TypeScript runtime support is not enabled", diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/util.js b/crates/wasm-rquickjs/skeleton/src/builtin/util.js index 76e0a67b5..8cabdd886 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/util.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/util.js @@ -1267,16 +1267,9 @@ function _isSourceMapsEnabledFromExecArgv() { } return _hasExecArgvFlag('--enable-source-maps') || - _hasExecArgvFlag('--experimental-transform-types'); -} - -function _getSimpleSourceMapRegistry() { - const registry = globalThis.__wasm_rquickjs_simple_source_maps; - if (!registry || typeof registry !== 'object') { - return null; - } - - return registry; + _hasExecArgvFlag('--experimental-transform-types') || + (typeof process !== 'undefined' && process.features && + process.features.typescript === 'transform'); } function _getCjsLineOffsetRegistry() { @@ -1314,28 +1307,25 @@ function _normalizeCallSiteLineNumber(callSite) { } function _mapCallSiteWithSimpleSourceMap(callSite) { - const registry = _getSimpleSourceMapRegistry(); - if (!registry) { - return callSite; - } - - const sourceMap = registry[callSite.scriptName]; - if (!sourceMap || !sourceMap.generatedLineToOriginalLine) { - return callSite; + const mapper = globalThis.__wasm_rquickjs_remap_source_mapped_position; + if (typeof mapper !== 'function') return _normalizeCallSiteLineNumber(callSite); + let origin; + try { + origin = mapper(callSite.scriptName, callSite.lineNumber, callSite.columnNumber); + } catch (_) { + return _normalizeCallSiteLineNumber(callSite); } - - const mappedLine = sourceMap.generatedLineToOriginalLine[callSite.lineNumber]; - if (typeof mappedLine !== 'number' || !Number.isFinite(mappedLine)) { - return callSite; + if (!origin || typeof origin.fileName !== 'string') { + return _normalizeCallSiteLineNumber(callSite); } const mappedCallSite = Object.create(null); mappedCallSite.functionName = callSite.functionName; mappedCallSite.scriptId = callSite.scriptId; - mappedCallSite.scriptName = callSite.scriptName; - mappedCallSite.lineNumber = mappedLine; - mappedCallSite.columnNumber = callSite.columnNumber; - mappedCallSite.column = callSite.column; + mappedCallSite.scriptName = origin.fileName; + mappedCallSite.lineNumber = origin.lineNumber; + mappedCallSite.columnNumber = origin.columnNumber; + mappedCallSite.column = origin.columnNumber; return mappedCallSite; } @@ -1392,7 +1382,22 @@ export function getCallSites(frameCount = 10, options) { const shouldMapSourceLocations = normalizedOptions.sourceMap === true || (_isSourceMapsEnabledFromExecArgv() && normalizedOptions.sourceMap !== false); - const stack = _captureGetCallSitesStack(getCallSites, frameCount); + const hadSuppression = Object.prototype.hasOwnProperty.call( + globalThis, + '__wasm_rquickjs_suppress_source_map_stack', + ); + const previousSuppression = globalThis.__wasm_rquickjs_suppress_source_map_stack; + globalThis.__wasm_rquickjs_suppress_source_map_stack = true; + let stack; + try { + stack = _captureGetCallSitesStack(getCallSites, frameCount); + } finally { + if (hadSuppression) { + globalThis.__wasm_rquickjs_suppress_source_map_stack = previousSuppression; + } else { + delete globalThis.__wasm_rquickjs_suppress_source_map_stack; + } + } const lines = stack.split('\n'); const callSites = []; @@ -1413,10 +1418,10 @@ export function getCallSites(frameCount = 10, options) { continue; } - parsedCallSite = _normalizeCallSiteLineNumber(parsedCallSite); - if (shouldMapSourceLocations) { parsedCallSite = _mapCallSiteWithSimpleSourceMap(parsedCallSite); + } else { + parsedCallSite = _normalizeCallSiteLineNumber(parsedCallSite); } callSites.push(parsedCallSite); diff --git a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs index 8c5a11d7e..fee1d047b 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs @@ -6599,10 +6599,17 @@ fn transform_typescript_module_source<'js>( fs_path: &str, source: String, ) -> rquickjs::Result { + let source_map = cfg!(feature = "typescript-transform-runtime") + && ctx + .globals() + .get::<_, Function>("__wasm_rquickjs_module_has_exec_argv_flag") + .ok() + .and_then(|has_flag| has_flag.call::<_, bool>(("--no-enable-source-maps",)).ok()) + != Some(true); match crate::internal::typescript::transform_module( source, fs_path, - false, + source_map, match std::path::Path::new(fs_path) .extension() .and_then(|extension| extension.to_str()) @@ -6614,7 +6621,7 @@ fn transform_typescript_module_source<'js>( ) { Ok(output) => { record_typescript_module_transform(ctx)?; - Ok(output.code) + Ok(output.into_code_with_inline_source_map()) } Err(error) => { let constructor_name = match error.kind { @@ -11333,6 +11340,18 @@ fn declare_esm_file_module_from_source<'js>( source, processed.dynamic_import_binding_names.as_ref(), ); + if let Ok(register_source_map) = + globals.get::<_, Function>("__wasm_rquickjs_register_transformed_source_map") + { + register_source_map.call::<_, ()>(( + fs_abs_path.as_str(), + injected.as_str(), + module_id, + 1, + 0, + true, + ))?; + } match declare_module_with_import_meta(ctx, module_id, &injected, &init) { Ok(module) => { if has_top_level_await { diff --git a/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs b/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs index 6a831315e..448376b25 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs @@ -1,6 +1,7 @@ use std::io::Write; use std::sync::{Arc, Mutex}; +use base64ct::Encoding; use swc_common::{ FileName, GLOBALS, Globals, SourceMap, errors::{HANDLER, Handler}, @@ -233,6 +234,19 @@ pub(crate) struct TypeScriptOutput { pub(crate) source_map: Option, } +impl TypeScriptOutput { + pub(crate) fn into_code_with_inline_source_map(self) -> String { + let Some(source_map) = self.source_map else { + return self.code; + }; + let encoded = base64ct::Base64::encode_string(source_map.as_bytes()); + format!( + "{}\n//# sourceMappingURL=data:application/json;base64,{encoded}", + self.code + ) + } +} + #[derive(Debug)] pub(crate) struct TypeScriptError { pub(crate) code: &'static str, diff --git a/examples/runtime/typescript-runtime/src/typescript-runtime.js b/examples/runtime/typescript-runtime/src/typescript-runtime.js index 213706ae5..8e6c03675 100644 --- a/examples/runtime/typescript-runtime/src/typescript-runtime.js +++ b/examples/runtime/typescript-runtime/src/typescript-runtime.js @@ -625,6 +625,19 @@ export async function run() { language: 'typescript', source: largeSource, }); + fs.writeFileSync( + '/typescript-runtime/strip-stack.mts', + `export function failStrip(): never { + const value: number = 42; + throw new Error('strip-typescript-stack-' + value); + }`, + ); + let stripRuntimeStack; + try { + (await import('/typescript-runtime/strip-stack.mts')).failStrip(); + } catch (error) { + stripRuntimeStack = error.stack; + } return JSON.stringify({ stripped, @@ -758,5 +771,6 @@ export async function run() { entryRunner: entryRunner.value, commonJsEntryRunner: commonJsEntryRunner.value, largeInlineRunner: largeInlineRunner.value, + stripRuntimeStack, }); } 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 c83b780d6..9c03f579f 100644 --- a/examples/runtime/typescript-transform-runtime/src/typescript-transform-runtime.js +++ b/examples/runtime/typescript-transform-runtime/src/typescript-transform-runtime.js @@ -96,6 +96,103 @@ export async function run() { language: 'typescript', source: largeSource, }); + fs.writeFileSync( + '/typescript-transform-runtime/stack-esm.mts', + `enum StackShift { Value } + export function failEsm(): never { + throw new Error('esm-typescript-stack'); + }`, + ); + let esmRuntimeStack; + try { + (await import('/typescript-transform-runtime/stack-esm.mts')).failEsm(); + } catch (error) { + esmRuntimeStack = error.stack; + } + fs.writeFileSync( + '/typescript-transform-runtime/stack-cjs.cts', + `enum StackShift { Value } + exports.failCjs = function failCjs(): never { + throw new Error('cjs-typescript-stack'); + };`, + ); + let cjsRuntimeStack; + const cjsStackModule = require('/typescript-transform-runtime/stack-cjs.cts'); + try { + cjsStackModule.failCjs(); + } catch (error) { + cjsRuntimeStack = error.stack; + } + let importedCjsRuntimeStack; + try { + (await import('/typescript-transform-runtime/stack-cjs.cts')).default.failCjs(); + } catch (error) { + importedCjsRuntimeStack = error.stack; + } + delete require.cache[require.resolve('/typescript-transform-runtime/stack-cjs.cts')]; + fs.writeFileSync( + '/typescript-transform-runtime/stack-cjs.cts', + `enum StackShift { Value } + const marker: number = StackShift.Value; + exports.failCjs = function failCjs(): never { + throw new Error('rewritten-cjs-typescript-stack-' + marker); + };`, + ); + fs.writeFileSync( + '/typescript-transform-runtime/stack-caller.cjs', + `const target = require('./stack-cjs.cts'); + module.exports = function callTypeScript() { target.failCjs(); };`, + ); + let rewrittenCjsRuntimeStack; + try { + require('/typescript-transform-runtime/stack-caller.cjs')(); + } catch (error) { + rewrittenCjsRuntimeStack = error.stack; + } + process.execArgv.push('--no-enable-source-maps'); + fs.writeFileSync( + '/typescript-transform-runtime/stack-disabled.mts', + `enum StackShift { Value } + export function failDisabled(): never { + throw new Error('disabled-typescript-stack'); + }`, + ); + let disabledRuntimeStack; + try { + (await import('/typescript-transform-runtime/stack-disabled.mts')).failDisabled(); + } catch (error) { + disabledRuntimeStack = error.stack; + } finally { + process.execArgv.pop(); + } + fs.writeFileSync( + '/typescript-transform-runtime/stack-entry.mts', + `enum StackShift { Value } + export async function run(): Promise { + await Promise.resolve(); + throw new Error('entry-typescript-stack'); + }`, + ); + let executionEntryStack; + try { + await runJavaScript({ + cwd: '/typescript-transform-runtime', + entry: './stack-entry.mts', + }); + } catch (error) { + executionEntryStack = error.message; + } + let executionInlineStack; + try { + await runJavaScript({ + language: 'typescript', + source: `enum StackShift { Value } + await Promise.resolve(); + throw new Error('inline-typescript-stack');`, + }); + } catch (error) { + executionInlineStack = error.message; + } return JSON.stringify({ processFeature: process.features.typescript, transformObservability: typeof globalThis.__wasm_rquickjs_get_typescript_module_transform_count, @@ -109,5 +206,12 @@ export async function run() { commonJsNodeModulesTypeScriptErrorName, executionInline: executionInline.value, largeInlineExecution: largeInlineExecution.value, + esmRuntimeStack, + cjsRuntimeStack, + importedCjsRuntimeStack, + rewrittenCjsRuntimeStack, + disabledRuntimeStack, + executionEntryStack, + executionInlineStack, }); } diff --git a/tests/runtime/typescript_runtime.rs b/tests/runtime/typescript_runtime.rs index d5be02b8d..38096cd85 100644 --- a/tests/runtime/typescript_runtime.rs +++ b/tests/runtime/typescript_runtime.rs @@ -270,6 +270,13 @@ async fn strip_typescript_types_matches_node_contract( assert_eq!(report["entryRunner"], 42); assert_eq!(report["commonJsEntryRunner"], 42); assert_eq!(report["largeInlineRunner"], 42); + assert!( + report["stripRuntimeStack"] + .as_str() + .is_some_and(|stack| stack.contains("strip-stack.mts:3:")), + "strip mode did not preserve original coordinates: {}", + report["stripRuntimeStack"] + ); assert!( report["unsupported"] .as_str() @@ -311,5 +318,43 @@ async fn typescript_transform_runtime_is_immutable( assert_eq!(report["commonJsNodeModulesTypeScriptErrorName"], "Error"); assert_eq!(report["executionInline"], 1); assert_eq!(report["largeInlineExecution"], 1); + for (field, file, line) in [ + ("esmRuntimeStack", "stack-esm.mts", 3), + ("cjsRuntimeStack", "stack-cjs.cts", 3), + ("importedCjsRuntimeStack", "stack-cjs.cts", 3), + ("executionEntryStack", "stack-entry.mts", 4), + ( + "executionInlineStack", + "__wasm_rquickjs_execution_inline.mjs", + 3, + ), + ] { + let stack = report[field] + .as_str() + .unwrap_or_else(|| panic!("missing {field}")); + assert!( + stack.contains(&format!("{file}:{line}:")), + "{field} did not map to the original TypeScript location: {stack}" + ); + } + let rewritten_cjs_stack = report["rewrittenCjsRuntimeStack"] + .as_str() + .expect("missing rewrittenCjsRuntimeStack"); + assert!( + rewritten_cjs_stack.contains("stack-cjs.cts:4:"), + "rewritten CJS map was stale: {rewritten_cjs_stack}" + ); + assert!( + rewritten_cjs_stack.contains("stack-caller.cjs:2:"), + "mixed JavaScript frame was not preserved: {rewritten_cjs_stack}" + ); + let disabled_stack = report["disabledRuntimeStack"] + .as_str() + .expect("missing disabledRuntimeStack"); + assert!(disabled_stack.contains("stack-disabled.mts:")); + assert!( + !disabled_stack.contains("stack-disabled.mts:3:"), + "--no-enable-source-maps unexpectedly remapped the stack: {disabled_stack}" + ); Ok(()) } From d7e3467710cb9724d0f7fb67f5b84edc96840d3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Tue, 1 Sep 2026 18:04:24 +0200 Subject: [PATCH 02/13] Address TypeScript source map review findings --- .../skeleton/src/builtin/execution.rs | 51 +++++-- .../skeleton/src/builtin/internal/errors.js | 110 +++++++++++--- .../skeleton/src/builtin/module.js | 108 ++++++++----- .../skeleton/src/builtin/util.js | 28 +--- .../skeleton/src/internal/module_loading.rs | 15 +- .../skeleton/src/internal/typescript.rs | 12 ++ .../src/typescript-runtime.js | 11 ++ .../src/typescript-transform-runtime.js | 142 +++++++++++++++++- tests/node_compat/config.jsonc | 4 +- tests/node_compat/report.md | 4 +- tests/runtime/typescript_runtime.rs | 62 ++++++++ 11 files changed, 442 insertions(+), 105 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/execution.rs b/crates/wasm-rquickjs/skeleton/src/builtin/execution.rs index 3bc32ba44..e776caa7b 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/execution.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/execution.rs @@ -152,12 +152,16 @@ fn execution_control_error(job: &ExecutionJob, deadline: Option) -> Opt } #[cfg(feature = "typescript-runtime")] -fn transform_typescript_execution_source(source: String, name: &str) -> Result { +fn transform_typescript_execution_source( + source: String, + name: &str, + source_map: bool, +) -> Result { crate::internal::typescript::transform( source, name, crate::internal::typescript::runtime_mode(), - cfg!(feature = "typescript-transform-runtime"), + source_map, Some(true), ) .map(|output| output.into_code_with_inline_source_map()) @@ -165,10 +169,24 @@ fn transform_typescript_execution_source(source: String, name: &str) -> Result Result { +fn transform_typescript_execution_source( + _source: String, + _name: &str, + _source_map: bool, +) -> Result { Err("TypeScript runtime support is not enabled".to_string()) } +#[cfg(feature = "typescript-runtime")] +fn execution_source_maps_enabled(ctx: &Ctx<'_>) -> bool { + crate::internal::typescript::source_maps_enabled(ctx) +} + +#[cfg(not(feature = "typescript-runtime"))] +fn execution_source_maps_enabled(_ctx: &Ctx<'_>) -> bool { + false +} + #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct PollResult { @@ -439,10 +457,11 @@ async fn run_job(options: ExecutionOptions, job: Rc) { }))) .await; - let wrapper_name = cwd.join(if entry.is_some() { - "__wasm_rquickjs_execution_entry.mjs" - } else { + let is_inline = entry.is_none(); + let wrapper_name = cwd.join(if is_inline { "__wasm_rquickjs_execution_inline.mjs" + } else { + "__wasm_rquickjs_execution_entry.mjs" }); let name = wrapper_name.to_string_lossy().into_owned(); let mut source = if let Some(entry) = entry { @@ -462,12 +481,17 @@ async fn run_job(options: ExecutionOptions, job: Rc) { options.source.unwrap_or_default() ) }; + let source_maps_enabled = if options.language == ExecutionLanguage::Typescript { + async_with!(runtime.ctx => |ctx| { execution_source_maps_enabled(&ctx) }).await + } else { + false + }; if options.language == ExecutionLanguage::Typescript { if let Some(error) = execution_control_error(&job, deadline) { job.complete(Err(error.to_string())); return; } - source = match transform_typescript_execution_source(source, &name) { + source = match transform_typescript_execution_source(source, &name, source_maps_enabled) { Ok(source) => source, Err(error) => { job.complete(Err(error)); @@ -486,12 +510,21 @@ async fn run_job(options: ExecutionOptions, job: Rc) { "__wasm_rquickjs_register_transformed_source_map", ) { + let (line_offset, original_line_offset, force_line_offset) = + if source_maps_enabled { + (0, usize::from(is_inline), false) + } else if is_inline { + (1, 0, true) + } else { + (0, 0, false) + }; register_source_map.call::<_, ()>(( name.as_str(), source.as_str(), Value::new_null(ctx.clone()), - 0, - 1, + line_offset, + original_line_offset, + force_line_offset, )) .map_err(|error| { format!("failed to register TypeScript source map: {error:?}") diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js b/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js index 55ad9f632..5551e2542 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js @@ -14,6 +14,10 @@ import { inspect, format } from "__wasm_rquickjs_builtin/internal/util/inspect"; const _callSiteWithFnPattern = /^\s*at\s+(.+?)\s+\((.+):(\d+):(\d+)\)\s*$/; const _callSiteNoFnPattern = /^\s*at\s+(.+):(\d+):(\d+)\s*$/; +function _isInternalErrorFrame(fileName) { + return fileName === '__wasm_rquickjs_builtin/internal/errors'; +} + function _remapSourceMappedLocation(fileName, lineNumber, columnNumber) { const mapper = globalThis.__wasm_rquickjs_remap_source_mapped_position; if (typeof mapper !== 'function') return undefined; @@ -45,6 +49,11 @@ function _remapSourceMappedStack(stackString) { lineNumber = parseInt(match[2], 10); columnNumber = parseInt(match[3], 10); } + if (_isInternalErrorFrame(fileName)) { + lines.splice(i, 1); + i -= 1; + continue; + } const origin = _remapSourceMappedLocation(fileName, lineNumber, columnNumber); if (!origin || typeof origin.fileName !== 'string') continue; const location = `${fileName}:${lineNumber}:${columnNumber}`; @@ -93,18 +102,19 @@ function _makeCallSite(functionName, fileName, lineNumber, columnNumber) { function _parseStackStringToCallSites(stackString) { if (typeof stackString !== 'string') return []; - stackString = _remapSourceMappedStack(stackString); const lines = stackString.split('\n'); const sites = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; let m = line.match(_callSiteWithFnPattern); if (m) { + if (_isInternalErrorFrame(m[2])) continue; sites.push(_makeCallSite(m[1], m[2], parseInt(m[3], 10), parseInt(m[4], 10))); continue; } m = line.match(_callSiteNoFnPattern); if (m) { + if (_isInternalErrorFrame(m[1])) continue; sites.push(_makeCallSite(null, m[1], parseInt(m[2], 10), parseInt(m[3], 10))); } } @@ -132,6 +142,17 @@ const nativeErrorToString = Error.prototype.toString; const materializedErrorStacks = new WeakSet(); let errorStackShimInstalled = false; +function constructNativeErrorWithoutPrepare(NativeConstructor, args) { + const activeError = globalThis.Error; + const prepareStackTrace = activeError && activeError.prepareStackTrace; + if (typeof prepareStackTrace === 'function') activeError.prepareStackTrace = undefined; + try { + return Reflect.construct(NativeConstructor, args, NativeConstructor); + } finally { + if (typeof prepareStackTrace === 'function') activeError.prepareStackTrace = prepareStackTrace; + } +} + function materializeOwnStack(errorInstance) { if (!errorInstance || (typeof errorInstance !== "object" && typeof errorInstance !== "function")) { return; @@ -199,26 +220,7 @@ function installErrorStackShim() { const ErrorShim = function Error() { const ctorTarget = new.target || ErrorShim; - const errorInstance = Reflect.construct(NativeError, arguments, NativeError); - if (typeof NativeError.captureStackTrace === 'function') { - const hadSuppression = Object.prototype.hasOwnProperty.call( - globalThis, - '__wasm_rquickjs_suppress_source_map_stack', - ); - const previousSuppression = globalThis.__wasm_rquickjs_suppress_source_map_stack; - globalThis.__wasm_rquickjs_suppress_source_map_stack = true; - try { - NativeError.captureStackTrace(errorInstance, ErrorShim); - } catch { - // Keep the native constructor stack if captureStackTrace rejects. - } finally { - if (hadSuppression) { - globalThis.__wasm_rquickjs_suppress_source_map_stack = previousSuppression; - } else { - delete globalThis.__wasm_rquickjs_suppress_source_map_stack; - } - } - } + const errorInstance = constructNativeErrorWithoutPrepare(NativeError, arguments); materializeOwnStack(errorInstance); // Error.prepareStackTrace support (V8 compat). @@ -253,7 +255,7 @@ function installErrorStackShim() { Object.setPrototypeOf(ErrorShim, NativeError); ErrorShim.prototype = ErrorShimPrototype; Object.defineProperty(ErrorShimPrototype, "constructor", { - value: NativeError, + value: ErrorShim, writable: true, configurable: true, enumerable: false, @@ -270,6 +272,70 @@ function installErrorStackShim() { errorStackShimInstalled = true; } +function installNativeErrorSubclassShim(name) { + const NativeConstructor = globalThis[name]; + if (typeof NativeConstructor !== 'function') return; + const ShimPrototype = Object.create(NativeConstructor.prototype); + const Shim = function(...args) { + const ctorTarget = new.target || Shim; + const errorInstance = constructNativeErrorWithoutPrepare(NativeConstructor, args); + materializeOwnStack(errorInstance); + const rawStack = errorInstance.stack; + Object.defineProperty(errorInstance, 'stack', { + get() { + const prepareStackTrace = globalThis.Error && globalThis.Error.prepareStackTrace; + const result = typeof prepareStackTrace === 'function' + ? prepareStackTrace(errorInstance, _parseStackStringToCallSites(rawStack)) + : _remapSourceMappedStack(rawStack); + Object.defineProperty(errorInstance, 'stack', _dataDesc(result)); + return result; + }, + set(value) { + Object.defineProperty(errorInstance, 'stack', _dataDesc(value)); + }, + configurable: true, + enumerable: false, + }); + const targetPrototype = (ctorTarget && ctorTarget.prototype) || ShimPrototype; + if (Object.getPrototypeOf(errorInstance) !== targetPrototype) { + Object.setPrototypeOf(errorInstance, targetPrototype); + } + return errorInstance; + }; + Object.setPrototypeOf(Shim, NativeConstructor); + Shim.prototype = ShimPrototype; + Object.defineProperty(ShimPrototype, 'constructor', { + value: Shim, + writable: true, + configurable: true, + enumerable: false, + }); + Object.defineProperty(Shim, Symbol.hasInstance, { + value(value) { + return value instanceof NativeConstructor; + }, + configurable: true, + }); + globalThis[name] = Shim; +} + +try { + installErrorStackShim(); + for (const name of [ + 'TypeError', + 'RangeError', + 'ReferenceError', + 'SyntaxError', + 'EvalError', + 'URIError', + 'AggregateError', + ]) { + installNativeErrorSubclassShim(name); + } +} catch { + // Keep the runtime default behavior if shimming fails. +} + Object.defineProperty(globalThis, '__wasm_rquickjs_install_source_map_error_stack_shim', { value() { try { diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index c5c83fa29..ff7db1931 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -1567,6 +1567,40 @@ function decodeInlineSourceMap(url) { } } +function storeSourceMap(filename, source, payload, sourceBasePath, moduleObject, options) { + const registry = getSimpleSourceMapRegistry(); + const owners = getCjsSourceMapOwnerRegistry(); + if (!isSourceMapsEnabled() || payload === null || typeof payload !== 'object') { + delete registry[filename]; + delete owners[filename]; + return; + } + registry[filename] = new SourceMap(payload, { + lineLengths: sourceMapLineLengths(source), + sourceBasePath, + originalLineOffset: options && options.originalLineOffset, + }); + const installErrorStackShim = globalThis.__wasm_rquickjs_install_source_map_error_stack_shim; + if (typeof installErrorStackShim === 'function') installErrorStackShim(); + if (moduleObject) { + const ownerRef = makeWeakRef(moduleObject); + if (ownerRef !== undefined) owners[filename] = ownerRef; + else delete owners[filename]; + } else { + delete owners[filename]; + } +} + +function registerSourceMapPayload(filename, source, sourceMap, moduleObject) { + let payload = null; + try { + payload = typeof sourceMap === 'string' ? JSON.parse(sourceMap) : sourceMap; + } catch (_) { + payload = null; + } + storeSourceMap(filename, source, payload, pathModule.dirname(filename), moduleObject); +} + function registerSourceMapForCjs(filename, source, moduleObject, options = undefined) { const registry = getSimpleSourceMapRegistry(); const owners = getCjsSourceMapOwnerRegistry(); @@ -1610,23 +1644,7 @@ function registerSourceMapForCjs(filename, source, moduleObject, options = undef delete owners[filename]; return; } - registry[filename] = new SourceMap(payload, { - lineLengths: sourceMapLineLengths(source), - sourceBasePath, - originalLineOffset: options && options.originalLineOffset, - }); - const installErrorStackShim = globalThis.__wasm_rquickjs_install_source_map_error_stack_shim; - if (typeof installErrorStackShim === 'function') installErrorStackShim(); - if (moduleObject) { - const ownerRef = makeWeakRef(moduleObject); - if (ownerRef !== undefined) { - owners[filename] = ownerRef; - } else { - delete owners[filename]; - } - } else { - delete owners[filename]; - } + storeSourceMap(filename, source, payload, sourceBasePath, moduleObject, options); } function registerSourceMapForTransformedSource( @@ -1680,10 +1698,7 @@ function remapSourceMappedPosition( const lineOffset = lineOffsets[scriptName]; const forcedLineOffset = forcedOffsets[scriptName] === true; if (typeof lineOffset === 'number' && Number.isFinite(lineOffset) && lineOffset > 0 && - (forcedLineOffset || - hasCjsWrapperOffset === true || - (sourceMap && Array.isArray(sourceMap._decodedMappings) && - lineNumber - 1 >= sourceMap._decodedMappings.length))) { + (forcedLineOffset || hasCjsWrapperOffset === true)) { lineNumber -= lineOffset; } if (lineNumber <= 0) return undefined; @@ -1702,6 +1717,11 @@ function remapSourceMappedPosition( } Object.defineProperties(globalThis, { + __wasm_rquickjs_source_maps_enabled: { + value: isSourceMapsEnabled, + writable: false, + configurable: false, + }, __wasm_rquickjs_register_transformed_source_map: { value: registerSourceMapForTransformedSource, writable: false, @@ -1759,9 +1779,9 @@ if (testObservabilityEnabledNative()) { }); } -function transpileTypeScriptModule(filename, source, module = undefined) { +function transformTypeScriptModuleOutput(filename, source, module = undefined) { if (!isTypeScriptFilename(filename)) { - return source; + return { code: source, sourceMap: null }; } // Rust owns the transform semantics. This adapter only applies CommonJS // loader policy; the Rust filesystem loader applies the same service for ESM. @@ -1769,15 +1789,17 @@ function transpileTypeScriptModule(filename, source, module = undefined) { String(source), filename, isSourceMapsEnabled(), module )); recordTypeScriptModuleTransform(); + return output; +} + +function codeWithInlineSourceMap(output) { if (!output.sourceMap) return output.code; const encoded = buffer.Buffer.from(output.sourceMap, 'utf8').toString('base64'); return output.code + `\n//# sourceMappingURL=data:application/json;base64,${encoded}`; } -function prepareCommonJsTypeScript(filename, source) { - return isTypeScriptFilename(filename) - ? transpileTypeScriptModule(filename, source, false) - : source; +function transpileTypeScriptModule(filename, source, module = undefined) { + return codeWithInlineSourceMap(transformTypeScriptModuleOutput(filename, source, module)); } function clearPreparedTypeScriptGraph(graph) { @@ -2464,7 +2486,13 @@ function wrapForCompile(script, dynamicImportBindings) { return activeWrapper[0] + script + activeWrapper[1]; } -function compileCjs(filename, source, isPreparedTypeScript = false, moduleObject = undefined) { +function compileCjs( + filename, + source, + isPreparedTypeScript = false, + moduleObject = undefined, + sourceMap = undefined, +) { if (source.length > 0 && source.charCodeAt(0) === 0xFEFF) { source = source.slice(1); } @@ -2474,9 +2502,12 @@ function compileCjs(filename, source, isPreparedTypeScript = false, moduleObject } if (!isPreparedTypeScript) { - source = transpileTypeScriptModule(filename, source, false); + const output = transformTypeScriptModuleOutput(filename, source, false); + source = output.code; + sourceMap = output.sourceMap; } - registerSourceMapForCjs(filename, source, moduleObject); + if (sourceMap) registerSourceMapPayload(filename, source, sourceMap, moduleObject); + else registerSourceMapForCjs(filename, source, moduleObject); source = stripV8OptimizationIntrinsics(source); const strippedImportAttributes = wasmRquickjsModuleGlobalThis.__wasm_rquickjs_prepare_cjs_source( source, @@ -3153,11 +3184,19 @@ function loadCommonJsTransaction(descriptor) { } const dirname = pathModule.dirname(filename); let compiledSource; + let preparedSourceForCache; + let typeScriptSourceMap; let typeScriptExportNames; try { - compiledSource = preparedTypeScript - ? preparedTypeScript.preparedSource - : prepareCommonJsTypeScript(filename, source); + if (preparedTypeScript) { + compiledSource = preparedTypeScript.preparedSource; + preparedSourceForCache = compiledSource; + } else { + const output = transformTypeScriptModuleOutput(filename, source, false); + compiledSource = output.code; + typeScriptSourceMap = output.sourceMap; + preparedSourceForCache = codeWithInlineSourceMap(output); + } typeScriptExportNames = preparedTypeScript && preparedTypeScript.exportNames; } catch (err) { discardCjsModuleLoad(cacheKey, parentModule, mod); @@ -3182,6 +3221,7 @@ function loadCommonJsTransaction(descriptor) { compiledSource, true, mod, + typeScriptSourceMap, ); } catch (err) { // Normalize QuickJS SyntaxError messages for ESM keywords in CJS context @@ -3242,7 +3282,7 @@ function loadCommonJsTransaction(descriptor) { captureCjsTypeScriptPreparedSource( mod, source, - compiledSource, + preparedSourceForCache, ); } cjsEsmDefaultSnapshotEligible = true; diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/util.js b/crates/wasm-rquickjs/skeleton/src/builtin/util.js index 8cabdd886..43bf9867d 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/util.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/util.js @@ -1245,31 +1245,9 @@ function _isInternalUtilCallSite(scriptName) { scriptName.indexOf('/builtin/util.js') !== -1; } -function _hasExecArgvFlag(flag) { - if (typeof process === 'undefined' || !Array.isArray(process.execArgv)) { - return false; - } - - const prefixed = flag + '='; - for (let i = 0; i < process.execArgv.length; i++) { - const arg = String(process.execArgv[i]); - if (arg === flag || arg.indexOf(prefixed) === 0) { - return true; - } - } - - return false; -} - function _isSourceMapsEnabledFromExecArgv() { - if (_hasExecArgvFlag('--no-enable-source-maps')) { - return false; - } - - return _hasExecArgvFlag('--enable-source-maps') || - _hasExecArgvFlag('--experimental-transform-types') || - (typeof process !== 'undefined' && process.features && - process.features.typescript === 'transform'); + const isEnabled = globalThis.__wasm_rquickjs_source_maps_enabled; + return typeof isEnabled === 'function' && isEnabled(); } function _getCjsLineOffsetRegistry() { @@ -1311,7 +1289,7 @@ function _mapCallSiteWithSimpleSourceMap(callSite) { if (typeof mapper !== 'function') return _normalizeCallSiteLineNumber(callSite); let origin; try { - origin = mapper(callSite.scriptName, callSite.lineNumber, callSite.columnNumber); + origin = mapper(callSite.scriptName, callSite.lineNumber, callSite.columnNumber, true); } catch (_) { return _normalizeCallSiteLineNumber(callSite); } diff --git a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs index fee1d047b..5ef543731 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs @@ -6599,13 +6599,7 @@ fn transform_typescript_module_source<'js>( fs_path: &str, source: String, ) -> rquickjs::Result { - let source_map = cfg!(feature = "typescript-transform-runtime") - && ctx - .globals() - .get::<_, Function>("__wasm_rquickjs_module_has_exec_argv_flag") - .ok() - .and_then(|has_flag| has_flag.call::<_, bool>(("--no-enable-source-maps",)).ok()) - != Some(true); + let source_map = crate::internal::typescript::source_maps_enabled(ctx); match crate::internal::typescript::transform_module( source, fs_path, @@ -8784,7 +8778,7 @@ fn analyze_cjs_reexport_specifier_names( let Ok(output) = crate::internal::typescript::transform_module( original_source.clone(), &child_filename, - false, + crate::internal::typescript::source_maps_enabled(ctx), match std::path::Path::new(&child_filename) .extension() .and_then(|extension| extension.to_str()) @@ -8796,18 +8790,19 @@ fn analyze_cjs_reexport_specifier_names( ) else { continue; }; + let prepared_source = output.into_code_with_inline_source_map(); 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(), + prepared_source: prepared_source.clone(), export_names: Vec::new(), }, ); } - output.code + prepared_source } } else { source diff --git a/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs b/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs index 448376b25..3b0fceb89 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs @@ -2,6 +2,7 @@ use std::io::Write; use std::sync::{Arc, Mutex}; use base64ct::Encoding; +use rquickjs::{Ctx, Function as JsFunction}; use swc_common::{ FileName, GLOBALS, Globals, SourceMap, errors::{HANDLER, Handler}, @@ -29,6 +30,17 @@ pub(crate) fn runtime_mode() -> TypeScriptMode { } } +pub(crate) fn source_maps_enabled(ctx: &Ctx<'_>) -> bool { + if !cfg!(feature = "typescript-transform-runtime") { + return false; + } + ctx.globals() + .get::<_, JsFunction>("__wasm_rquickjs_source_maps_enabled") + .ok() + .and_then(|is_enabled| is_enabled.call::<_, bool>(()).ok()) + .unwrap_or(false) +} + 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( diff --git a/examples/runtime/typescript-runtime/src/typescript-runtime.js b/examples/runtime/typescript-runtime/src/typescript-runtime.js index 8e6c03675..2d9c58abe 100644 --- a/examples/runtime/typescript-runtime/src/typescript-runtime.js +++ b/examples/runtime/typescript-runtime/src/typescript-runtime.js @@ -638,6 +638,16 @@ export async function run() { } catch (error) { stripRuntimeStack = error.stack; } + let stripInlineExecutionStack; + try { + await runJavaScript({ + language: 'typescript', + source: `const value: number = 42; + throw new Error('strip-inline-stack-' + value);`, + }); + } catch (error) { + stripInlineExecutionStack = error.message; + } return JSON.stringify({ stripped, @@ -772,5 +782,6 @@ export async function run() { commonJsEntryRunner: commonJsEntryRunner.value, largeInlineRunner: largeInlineRunner.value, stripRuntimeStack, + stripInlineExecutionStack, }); } 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 9c03f579f..3b7fb1025 100644 --- a/examples/runtime/typescript-transform-runtime/src/typescript-transform-runtime.js +++ b/examples/runtime/typescript-transform-runtime/src/typescript-transform-runtime.js @@ -1,8 +1,10 @@ import fs from 'node:fs'; -import { createRequire } from 'node:module'; +import module, { createRequire } from 'node:module'; import { runJavaScript } from 'wasm-rquickjs:execution'; export async function run() { + const errorConstructorBefore = Error; + const typeErrorConstructorBefore = TypeError; fs.mkdirSync('/typescript-transform-runtime', { recursive: true }); fs.writeFileSync( '/typescript-transform-runtime/transformed.mts', @@ -96,6 +98,27 @@ export async function run() { language: 'typescript', source: largeSource, }); + const measuredSource = `enum Direction { Up, Down } + export default Direction.Down; + /*${'x'.repeat(64 * 1024)}*/`; + function measureTransform(sourceMap) { + const samples = []; + for (let i = 0; i < 6; i++) { + const started = performance.now(); + module.stripTypeScriptTypes(measuredSource, { + mode: 'transform', + sourceMap, + sourceUrl: 'measured.ts', + }); + if (i > 0) samples.push(performance.now() - started); + } + samples.sort((left, right) => left - right); + return samples[2]; + } + const transformLatencyMs = { + withoutSourceMap: measureTransform(false), + withSourceMap: measureTransform(true), + }; fs.writeFileSync( '/typescript-transform-runtime/stack-esm.mts', `enum StackShift { Value } @@ -109,6 +132,71 @@ export async function run() { } catch (error) { esmRuntimeStack = error.stack; } + fs.writeFileSync( + '/typescript-transform-runtime/stack-errors.mts', + `enum StackShift { Value } + export class CustomStackError extends Error {} + export function failTypeError(): never { + throw new TypeError('type-error-typescript-stack'); + } + export function failCustomError(): never { + throw new CustomStackError('custom-error-typescript-stack'); + } + export function captureGeneratedSite() { + const previous = Error.prepareStackTrace; + try { + Error.prepareStackTrace = (_error, sites) => sites[0]; + return new Error('prepared-typescript-stack').stack; + } finally { + Error.prepareStackTrace = previous; + } + } + export function failSyntaxError(): never { + throw new SyntaxError('syntax-error-typescript-stack'); + }`, + ); + const stackErrorsModule = await import('/typescript-transform-runtime/stack-errors.mts'); + let typeErrorRuntimeStack; + try { + stackErrorsModule.failTypeError(); + } catch (error) { + typeErrorRuntimeStack = error.stack; + } + let customErrorRuntimeStack; + try { + stackErrorsModule.failCustomError(); + } catch (error) { + customErrorRuntimeStack = error.stack; + } + let syntaxErrorRuntimeStack; + try { + stackErrorsModule.failSyntaxError(); + } catch (error) { + syntaxErrorRuntimeStack = error.stack; + } + const generatedSite = stackErrorsModule.captureGeneratedSite(); + const generatedSiteFile = generatedSite.getFileName(); + const generatedSiteLine = generatedSite.getLineNumber(); + const generatedSiteColumn = generatedSite.getColumnNumber(); + const preparedSourceMap = module.findSourceMap(generatedSiteFile); + const preparedOrigin = preparedSourceMap && + preparedSourceMap.findOrigin(generatedSiteLine, generatedSiteColumn); + const errorConstructorsStable = Error === errorConstructorBefore && + TypeError === typeErrorConstructorBefore && + new Error().constructor === Error && + new TypeError().constructor === TypeError; + fs.writeFileSync( + '/typescript-transform-runtime/stack-sites.mts', + `import { getCallSites } from 'node:util'; + enum StackShift { Value } + export function captureSites() { + return { + mapped: getCallSites(1)[0], + generated: getCallSites(1, { sourceMap: false })[0], + }; + }`, + ); + const callSites = (await import('/typescript-transform-runtime/stack-sites.mts')).captureSites(); fs.writeFileSync( '/typescript-transform-runtime/stack-cjs.cts', `enum StackShift { Value } @@ -129,6 +217,30 @@ export async function run() { } catch (error) { importedCjsRuntimeStack = error.stack; } + fs.writeFileSync( + '/typescript-transform-runtime/stack-reexport-child.cts', + `enum StackShift { Value } + exports.failPrepared = function failPrepared(): never { + throw new Error('prepared-reexport-typescript-stack'); + };`, + ); + fs.writeFileSync( + '/typescript-transform-runtime/stack-reexport-parent.cts', + `const child = require('./stack-reexport-child.cts'); + Object.keys(child).forEach(function (key) { + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { return child[key]; }, + }); + });`, + ); + const reexportStackModule = await import('/typescript-transform-runtime/stack-reexport-parent.cts'); + let reexportPreparedRuntimeStack; + try { + reexportStackModule.default.failPrepared(); + } catch (error) { + reexportPreparedRuntimeStack = error.stack; + } delete require.cache[require.resolve('/typescript-transform-runtime/stack-cjs.cts')]; fs.writeFileSync( '/typescript-transform-runtime/stack-cjs.cts', @@ -150,6 +262,14 @@ export async function run() { rewrittenCjsRuntimeStack = error.stack; } process.execArgv.push('--no-enable-source-maps'); + fs.writeFileSync( + '/typescript-transform-runtime/stack-disabled-sites.mts', + `import { getCallSites } from 'node:util'; + enum StackShift { Value } + export function captureDisabledSite() { + return getCallSites(1)[0]; + }`, + ); fs.writeFileSync( '/typescript-transform-runtime/stack-disabled.mts', `enum StackShift { Value } @@ -158,10 +278,16 @@ export async function run() { }`, ); let disabledRuntimeStack; + let disabledCallSite; try { (await import('/typescript-transform-runtime/stack-disabled.mts')).failDisabled(); } catch (error) { disabledRuntimeStack = error.stack; + } + try { + disabledCallSite = (await import( + '/typescript-transform-runtime/stack-disabled-sites.mts' + )).captureDisabledSite(); } finally { process.execArgv.pop(); } @@ -206,6 +332,7 @@ export async function run() { commonJsNodeModulesTypeScriptErrorName, executionInline: executionInline.value, largeInlineExecution: largeInlineExecution.value, + transformLatencyMs, esmRuntimeStack, cjsRuntimeStack, importedCjsRuntimeStack, @@ -213,5 +340,18 @@ export async function run() { disabledRuntimeStack, executionEntryStack, executionInlineStack, + typeErrorRuntimeStack, + customErrorRuntimeStack, + syntaxErrorRuntimeStack, + errorConstructorsStable, + generatedSite: { + fileName: generatedSiteFile, + lineNumber: generatedSiteLine, + columnNumber: generatedSiteColumn, + }, + preparedOrigin, + callSites, + disabledCallSite, + reexportPreparedRuntimeStack, }); } diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 14d769a2e..ead8bddca 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -343,7 +343,7 @@ "block_09_error_stacktracelimit_should_not_influence_callsite_size": {}, "block_10_block_10": { "category": "known-gap", - "reason": "TypeScript transform source maps are not applied to QuickJS stack call-site locations" + "reason": "simulated child_process TypeScript CLI execution does not publish its transform map to util.getCallSites" }, "block_11_block_11": {}, "block_12_block_12": {} @@ -8276,7 +8276,7 @@ "block_00_it_should_throw_with_invalid_args": { "category": "runnable" }, "block_01_findsourcemap_should_return_undefined_when_no_source_map_is_": { "category": "runnable" }, "block_02_non_exceptional_case": { "category": "runnable" }, - "block_03_source_map_attached_to_error": { "category": "engine-difference", "reason": "native QuickJS Error.prepareStackTrace CallSite positions include the CJS wrapper offset" }, + "block_03_source_map_attached_to_error": { "category": "engine-difference", "reason": "QuickJS custom Error.prepareStackTrace does not expose the throwing CommonJS CallSite" }, "block_04_sourcemap_can_be_instantiated_with_source_map_v3_object_as_p": { "category": "runnable" }, "block_05_error_when_receiving_a_malformed_mappings": { "category": "runnable" }, "block_06_sourcemap_can_be_instantiated_with_index_source_map_v3_objec": { "category": "runnable" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 79d40fb54..710992473 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -955,7 +955,6 @@ Secondary full-public compatibility, including public tests that are currently e | SourceTextModule evaluation timeout does not interrupt an infinite loop | 1 | `parallel/test-vm-module-basic.js#block_02_statement_02` | | SourceTextModule identifiers are not incremented per VM context like Node | 1 | `parallel/test-vm-module-basic.js#block_03_check_the_generated_identifier_for_each_module` | | Timeout listener bookkeeping on keep-alive sockets is not Node-compatible | 1 | `parallel/test-http-client-timeout-option-listeners.js` | -| TypeScript transform source maps are not applied to QuickJS stack call-site locations | 1 | `parallel/test-util-getcallsites.js#block_10_block_10` | | URL inspect output uses the URL string instead of Node's structured URL representation | 1 | `parallel/test-whatwg-url-custom-inspect.js` | | WASI UDP ping-pong over loopback does not reliably deliver datagrams in the local runtime despite Node-compatible hostname resolution | 1 | `sequential/test-dgram-pingpong.js` | | WASM child emulation does not support --experimental-test-module-mocks CLI flag | 1 | `parallel/test-runner-module-mocking.js#test_11_node_modules_can_be_used_by_both_module_systems` | @@ -1269,6 +1268,7 @@ Secondary full-public compatibility, including public tests that are currently e | setDefaultHeaders:false still injects/default-normalizes headers (Host/Content-Length/casing/duplicates) | 1 | `parallel/test-http-dont-set-default-headers-with-set-header.js` | | setImmediate queue turn semantics are unstable and can trap in the timeout scheduler | 1 | `parallel/test-timers-immediate-queue.js` | | setInterval scheduling incorrectly includes callback execution time | 1 | `sequential/test-timers-set-interval-excludes-callback-duration.js` | +| simulated child_process TypeScript CLI execution does not publish its transform map to util.getCallSites | 1 | `parallel/test-util-getcallsites.js#block_10_block_10` | | snapshot update/read flow via node:test is incomplete in WASM child emulation | 1 | `parallel/test-runner-snapshot-file-tests.js#test_01_t_assert_filesnapshot_update_read_flow` | | spawn() stdio handling is incomplete: non-requested stderr stream is still created | 1 | `sequential/test-child-process-exit.js` | | spawn() timeout validation path hangs in WASM child emulation | 1 | `parallel/test-child-process-spawn-timeout-kill-signal.js#block_02_block_02` | @@ -1569,6 +1569,7 @@ Secondary full-public compatibility, including public tests that are currently e | v8.writeHeapSnapshot is a V8-specific API and is unavailable in QuickJS | 2 | `parallel/test-permission-fs-write-v8.js#block_00_block_00`, `parallel/test-permission-fs-write-v8.js#block_01_block_01` | | GC observability used by common/gc.onGC is not available in the QuickJS/WASM runtime | 1 | `parallel/test-net-connect-memleak.js` | | QuickJS await/promise-hook semantics differ from V8, so AsyncLocalStorage runStores context is lost across await boundaries | 1 | `parallel/test-diagnostics-channel-tracing-channel-promise-run-stores.js` | +| QuickJS custom Error.prepareStackTrace does not expose the throwing CommonJS CallSite | 1 | `parallel/test-source-map-api.js#block_03_source_map_attached_to_error` | | QuickJS private-field TypeError message text differs from V8 | 1 | `parallel/test-runner-mocking.js#test_21_mocks_a_constructor` | | SourceTextModule cachedData depends on V8 code cache internals unavailable in QuickJS | 1 | `parallel/test-vm-module-cached-data.js` | | asserts V8-specific syntax error stderr text/format that differs in QuickJS | 1 | `es-module/test-require-module-errors.js` | @@ -1577,7 +1578,6 @@ Secondary full-public compatibility, including public tests that are currently e | depends on V8 native syntax and runtime flags not available in QuickJS | 1 | `parallel/test-v8-flags.js` | | depends on engine-specific ArrayBuffer OOM RangeError message text in skip path | 1 | `sequential/test-buffer-creation-regression.js` | | expects V8 heap space statistics that QuickJS does not expose | 1 | `parallel/test-v8-stats.js` | -| native QuickJS Error.prepareStackTrace CallSite positions include the CJS wrapper offset | 1 | `parallel/test-source-map-api.js#block_03_source_map_attached_to_error` | | uses V8 natives syntax intrinsics (`%DebugPrint`, `%HaveSameMap`, `%CollectGarbage`) unavailable in QuickJS | 1 | `parallel/test-http-same-map.js` | | uses v8.getHeapSnapshot, which is V8-specific and unavailable in QuickJS | 1 | `parallel/test-http2-ping-settings-heapdump.js` | | v8.cachedDataVersionTag depends on V8 internals unavailable in QuickJS | 1 | `parallel/test-v8-version-tag.js` | diff --git a/tests/runtime/typescript_runtime.rs b/tests/runtime/typescript_runtime.rs index 38096cd85..99a791034 100644 --- a/tests/runtime/typescript_runtime.rs +++ b/tests/runtime/typescript_runtime.rs @@ -277,6 +277,13 @@ async fn strip_typescript_types_matches_node_contract( "strip mode did not preserve original coordinates: {}", report["stripRuntimeStack"] ); + assert!( + report["stripInlineExecutionStack"] + .as_str() + .is_some_and(|stack| stack.contains("__wasm_rquickjs_execution_inline.mjs:2:")), + "strip inline execution did not remove its wrapper offset: {}", + report["stripInlineExecutionStack"] + ); assert!( report["unsupported"] .as_str() @@ -318,11 +325,28 @@ async fn typescript_transform_runtime_is_immutable( assert_eq!(report["commonJsNodeModulesTypeScriptErrorName"], "Error"); assert_eq!(report["executionInline"], 1); assert_eq!(report["largeInlineExecution"], 1); + for field in ["withoutSourceMap", "withSourceMap"] { + let latency = report["transformLatencyMs"][field] + .as_f64() + .unwrap_or_else(|| panic!("missing {field} transform latency")); + assert!( + latency <= 25.0, + "64 KiB {field} transform exceeded the GOL-417 bound: {latency:.3} ms" + ); + } for (field, file, line) in [ ("esmRuntimeStack", "stack-esm.mts", 3), ("cjsRuntimeStack", "stack-cjs.cts", 3), ("importedCjsRuntimeStack", "stack-cjs.cts", 3), ("executionEntryStack", "stack-entry.mts", 4), + ("typeErrorRuntimeStack", "stack-errors.mts", 4), + ("customErrorRuntimeStack", "stack-errors.mts", 7), + ("syntaxErrorRuntimeStack", "stack-errors.mts", 19), + ( + "reexportPreparedRuntimeStack", + "stack-reexport-child.cts", + 3, + ), ( "executionInlineStack", "__wasm_rquickjs_execution_inline.mjs", @@ -336,6 +360,10 @@ async fn typescript_transform_runtime_is_immutable( stack.contains(&format!("{file}:{line}:")), "{field} did not map to the original TypeScript location: {stack}" ); + assert!( + !stack.contains("__wasm_rquickjs_builtin/internal/errors"), + "{field} leaked Error shim frames: {stack}" + ); } let rewritten_cjs_stack = report["rewrittenCjsRuntimeStack"] .as_str() @@ -356,5 +384,39 @@ async fn typescript_transform_runtime_is_immutable( !disabled_stack.contains("stack-disabled.mts:3:"), "--no-enable-source-maps unexpectedly remapped the stack: {disabled_stack}" ); + assert_eq!(report["errorConstructorsStable"], true); + assert!( + report["generatedSite"]["fileName"] + .as_str() + .is_some_and(|file| file.ends_with("stack-errors.mts")), + "unexpected generated custom prepare call site: {}", + report["generatedSite"] + ); + assert_ne!(report["generatedSite"]["lineNumber"], 13); + assert!( + report["preparedOrigin"]["fileName"] + .as_str() + .is_some_and(|file| file.ends_with("stack-errors.mts")) + ); + assert_eq!(report["preparedOrigin"]["lineNumber"], 14); + assert!( + report["callSites"]["mapped"]["scriptName"] + .as_str() + .is_some_and(|file| file.ends_with("stack-sites.mts")) + ); + assert_eq!(report["callSites"]["mapped"]["lineNumber"], 5); + assert_eq!(report["callSites"]["mapped"]["columnNumber"], 26); + assert!( + report["callSites"]["generated"]["scriptName"] + .as_str() + .is_some_and(|file| file.ends_with("stack-sites.mts")) + ); + assert_ne!(report["callSites"]["generated"]["lineNumber"], 6); + assert!( + report["disabledCallSite"]["scriptName"] + .as_str() + .is_some_and(|file| file.ends_with("stack-disabled-sites.mts")) + ); + assert_ne!(report["disabledCallSite"]["lineNumber"], 4); Ok(()) } From 795d49d11934126dfd89100e7a195aa19cb1d5b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Tue, 1 Sep 2026 18:53:09 +0200 Subject: [PATCH 03/13] Preserve Error semantics with source maps --- .../skeleton/src/builtin/execution.rs | 6 +- .../skeleton/src/builtin/internal/errors.js | 91 +++++++++++++------ .../skeleton/src/builtin/module.js | 30 +++++- .../src/typescript-runtime.js | 26 ++++++ .../src/typescript-transform-runtime.js | 51 +++++++++++ tests/runtime/typescript_runtime.rs | 41 ++++++++- 6 files changed, 212 insertions(+), 33 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/execution.rs b/crates/wasm-rquickjs/skeleton/src/builtin/execution.rs index e776caa7b..8acda1d47 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/execution.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/execution.rs @@ -505,13 +505,13 @@ async fn run_job(options: ExecutionOptions, job: Rc) { } let execution = async { async_with!(runtime.ctx => |ctx| { - if options.language == ExecutionLanguage::Typescript + if (options.language == ExecutionLanguage::Typescript || is_inline) && let Ok(register_source_map) = ctx.globals().get::<_, Function>( "__wasm_rquickjs_register_transformed_source_map", ) { let (line_offset, original_line_offset, force_line_offset) = - if source_maps_enabled { + if options.language == ExecutionLanguage::Typescript && source_maps_enabled { (0, usize::from(is_inline), false) } else if is_inline { (1, 0, true) @@ -527,7 +527,7 @@ async fn run_job(options: ExecutionOptions, job: Rc) { force_line_offset, )) .map_err(|error| { - format!("failed to register TypeScript source map: {error:?}") + format!("failed to register execution source map: {error:?}") })?; } Module::evaluate(ctx.clone(), name, source).catch(&ctx) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js b/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js index 5551e2542..51552cecd 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js @@ -141,17 +141,7 @@ const NativeError = Error; const nativeErrorToString = Error.prototype.toString; const materializedErrorStacks = new WeakSet(); let errorStackShimInstalled = false; - -function constructNativeErrorWithoutPrepare(NativeConstructor, args) { - const activeError = globalThis.Error; - const prepareStackTrace = activeError && activeError.prepareStackTrace; - if (typeof prepareStackTrace === 'function') activeError.prepareStackTrace = undefined; - try { - return Reflect.construct(NativeConstructor, args, NativeConstructor); - } finally { - if (typeof prepareStackTrace === 'function') activeError.prepareStackTrace = prepareStackTrace; - } -} +let errorSubclassShimsInstalled = false; function materializeOwnStack(errorInstance) { if (!errorInstance || (typeof errorInstance !== "object" && typeof errorInstance !== "function")) { @@ -220,7 +210,7 @@ function installErrorStackShim() { const ErrorShim = function Error() { const ctorTarget = new.target || ErrorShim; - const errorInstance = constructNativeErrorWithoutPrepare(NativeError, arguments); + const errorInstance = Reflect.construct(NativeError, arguments, NativeError); materializeOwnStack(errorInstance); // Error.prepareStackTrace support (V8 compat). @@ -253,6 +243,40 @@ function installErrorStackShim() { }; Object.setPrototypeOf(ErrorShim, NativeError); + Object.defineProperty(ErrorShim, "name", { + value: NativeError.name, + configurable: true, + }); + Object.defineProperty(ErrorShim, "length", { + value: NativeError.length, + configurable: true, + }); + + // Keep the public V8 hook on the shim. QuickJS eagerly invokes the native + // Error hook while constructing, whereas Node invokes it when .stack is + // read. A separate slot lets us preserve Node's lazy behavior without + // mutating a user-defined property for every Error construction. + let prepareStackTraceValue = NativeError.prepareStackTrace; + NativeError.prepareStackTrace = undefined; + Object.defineProperty(ErrorShim, "prepareStackTrace", { + get() { return prepareStackTraceValue; }, + set(value) { prepareStackTraceValue = value; }, + configurable: true, + enumerable: false, + }); + + const nativeStackTraceLimitDescriptor = Object.getOwnPropertyDescriptor( + NativeError, + "stackTraceLimit", + ); + if (nativeStackTraceLimitDescriptor) { + Object.defineProperty(ErrorShim, "stackTraceLimit", { + get() { return NativeError.stackTraceLimit; }, + set(value) { NativeError.stackTraceLimit = value; }, + configurable: nativeStackTraceLimitDescriptor.configurable, + enumerable: nativeStackTraceLimitDescriptor.enumerable, + }); + } ErrorShim.prototype = ErrorShimPrototype; Object.defineProperty(ErrorShimPrototype, "constructor", { value: ErrorShim, @@ -263,6 +287,9 @@ function installErrorStackShim() { Object.defineProperty(ErrorShim, Symbol.hasInstance, { value(value) { + if (this !== ErrorShim) { + return Function.prototype[Symbol.hasInstance].call(this, value); + } return value instanceof NativeError; }, configurable: true, @@ -278,7 +305,7 @@ function installNativeErrorSubclassShim(name) { const ShimPrototype = Object.create(NativeConstructor.prototype); const Shim = function(...args) { const ctorTarget = new.target || Shim; - const errorInstance = constructNativeErrorWithoutPrepare(NativeConstructor, args); + const errorInstance = Reflect.construct(NativeConstructor, args, NativeConstructor); materializeOwnStack(errorInstance); const rawStack = errorInstance.stack; Object.defineProperty(errorInstance, 'stack', { @@ -302,7 +329,15 @@ function installNativeErrorSubclassShim(name) { } return errorInstance; }; - Object.setPrototypeOf(Shim, NativeConstructor); + Object.setPrototypeOf(Shim, globalThis.Error); + Object.defineProperty(Shim, 'name', { + value: NativeConstructor.name, + configurable: true, + }); + Object.defineProperty(Shim, 'length', { + value: NativeConstructor.length, + configurable: true, + }); Shim.prototype = ShimPrototype; Object.defineProperty(ShimPrototype, 'constructor', { value: Shim, @@ -312,6 +347,9 @@ function installNativeErrorSubclassShim(name) { }); Object.defineProperty(Shim, Symbol.hasInstance, { value(value) { + if (this !== Shim) { + return Function.prototype[Symbol.hasInstance].call(this, value); + } return value instanceof NativeConstructor; }, configurable: true, @@ -319,8 +357,8 @@ function installNativeErrorSubclassShim(name) { globalThis[name] = Shim; } -try { - installErrorStackShim(); +function installNativeErrorSubclassShims() { + if (errorSubclassShimsInstalled) return; for (const name of [ 'TypeError', 'RangeError', @@ -332,14 +370,22 @@ try { ]) { installNativeErrorSubclassShim(name); } -} catch { - // Keep the runtime default behavior if shimming fails. + errorSubclassShimsInstalled = true; +} + +if (nativeErrorStackDescriptor && nativeErrorStackDescriptor.configurable === false) { + try { + installErrorStackShim(); + } catch { + // Keep the runtime default behavior if shimming fails. + } } Object.defineProperty(globalThis, '__wasm_rquickjs_install_source_map_error_stack_shim', { value() { try { installErrorStackShim(); + installNativeErrorSubclassShims(); } catch { // Keep the runtime default behavior if shimming fails. } @@ -370,14 +416,7 @@ Object.defineProperty(globalThis, '__wasm_rquickjs_install_source_map_error_stac // may not see it through the ErrorShim prototype chain. const currentPrepare = globalThis.Error && globalThis.Error.prepareStackTrace; if (typeof currentPrepare === 'function') { - // Temporarily clear prepareStackTrace so the native implementation - // produces a raw string stack (not CallSite objects). - globalThis.Error.prepareStackTrace = undefined; - try { - nativeCaptureStackTrace(targetObject, constructorOpt); - } finally { - globalThis.Error.prepareStackTrace = currentPrepare; - } + nativeCaptureStackTrace(targetObject, constructorOpt); // Read the raw stack string, parse into CallSites, and install // a lazy getter that calls prepareStackTrace on first access. diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index ff7db1931..8851b5406 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -1230,11 +1230,24 @@ function isSourceMapsEnabled() { return hasActiveExecArgvFlag('--enable-source-maps') || isExperimentalTransformTypesEnabled() || + (processModule.features && processModule.features.typescript === 'transform') || (wasmRquickjsModuleGlobalThis.process && wasmRquickjsModuleGlobalThis.process.features && wasmRquickjsModuleGlobalThis.process.features.typescript === 'transform'); } +// Transform-mode source maps are active before user modules execute. Install +// their Error hooks here, after node:process has initialized, so constructors +// do not change identity on the first transformed module load. Strip-only and +// source-map-disabled runtimes keep their normal Error subclasses. +if (isSourceMapsEnabled() && + processModule.features && + processModule.features.typescript === 'transform') { + const installErrorStackShim = + wasmRquickjsModuleGlobalThis.__wasm_rquickjs_install_source_map_error_stack_shim; + if (typeof installErrorStackShim === 'function') installErrorStackShim(); +} + function getSimpleSourceMapRegistry() { let registry = globalThis.__wasm_rquickjs_simple_source_maps; if (!registry || typeof registry !== 'object') { @@ -1271,6 +1284,9 @@ function getForcedSourceMapLineOffsetRegistry() { return registry; } +// `_evalWithFilename` compiles the CommonJS function wrapper with six lines +// ahead of the user source. QuickJS reports those wrapper-relative positions +// to util.getCallSites(), so remove them before querying the source map. const cjsLineOffset = 6; function derefWeakRef(ref) { @@ -1512,6 +1528,7 @@ class SourceMap { this.lineLengths = Array.prototype.slice.call(options.lineLengths); } this._originalLineOffset = Number(options.originalLineOffset) || 0; + this._generatedLineOffset = Number(options.generatedLineOffset) || 0; this._decodedMappings = decodeSourceMapPayload(this.payload, options.sourceBasePath); } @@ -1523,7 +1540,7 @@ class SourceMap { } findOrigin(lineNumber, columnNumber) { - const generatedLine = Number(lineNumber) - 1; + const generatedLine = Number(lineNumber) - 1 - this._generatedLineOffset; const generatedColumn = Number(columnNumber) - 1; if (!Number.isFinite(generatedLine) || !Number.isFinite(generatedColumn)) return {}; const match = findSourceMapMapping(this._decodedMappings, generatedLine, generatedColumn); @@ -1551,7 +1568,11 @@ function findSourceMap(path) { } function sourceMapLineLengths(source) { - return String(source).split(/\r\n|[\n\r\u2028\u2029]/).map(line => line.length); + const lines = String(source).split(/\r\n|[\n\r\u2028\u2029]/); + while (lines.length > 0 && /^\s*\/\/[#@]\s*sourceMappingURL=/.test(lines[lines.length - 1])) { + lines.pop(); + } + return lines.map(line => line.length); } function decodeInlineSourceMap(url) { @@ -1667,6 +1688,9 @@ function registerSourceMapForTransformedSource( lineOffset = Number(lineOffset); if (Number.isFinite(lineOffset) && lineOffset > 0) offsets[filename] = lineOffset; else delete offsets[filename]; + if (registry[filename] !== undefined) { + registry[filename]._generatedLineOffset = Number.isFinite(lineOffset) ? lineOffset : 0; + } if (forceLineOffset) forcedOffsets[filename] = true; else delete forcedOffsets[filename]; if (alias !== undefined && alias !== null) { @@ -1699,7 +1723,7 @@ function remapSourceMappedPosition( const forcedLineOffset = forcedOffsets[scriptName] === true; if (typeof lineOffset === 'number' && Number.isFinite(lineOffset) && lineOffset > 0 && (forcedLineOffset || hasCjsWrapperOffset === true)) { - lineNumber -= lineOffset; + if (!sourceMap || !forcedLineOffset) lineNumber -= lineOffset; } if (lineNumber <= 0) return undefined; if (!isSourceMapsEnabled() || !sourceMap || typeof sourceMap.findOrigin !== 'function') { diff --git a/examples/runtime/typescript-runtime/src/typescript-runtime.js b/examples/runtime/typescript-runtime/src/typescript-runtime.js index 2d9c58abe..8555c98ba 100644 --- a/examples/runtime/typescript-runtime/src/typescript-runtime.js +++ b/examples/runtime/typescript-runtime/src/typescript-runtime.js @@ -648,6 +648,29 @@ export async function run() { } catch (error) { stripInlineExecutionStack = error.message; } + let plainInlineExecutionStack; + try { + await runJavaScript({ + source: `const value = 42; + throw new Error('plain-inline-stack-' + value);`, + }); + } catch (error) { + plainInlineExecutionStack = error.message; + } + const errorConstructorMetadata = [ + Error, + TypeError, + RangeError, + ReferenceError, + SyntaxError, + EvalError, + URIError, + AggregateError, + ].map((Constructor) => ({ name: Constructor.name, length: Constructor.length })); + class NarrowTypeError extends TypeError {} + const errorConstructorRelationships = Error.isPrototypeOf(TypeError) && + !(new TypeError('base') instanceof NarrowTypeError) && + new NarrowTypeError('narrow') instanceof NarrowTypeError; return JSON.stringify({ stripped, @@ -783,5 +806,8 @@ export async function run() { largeInlineRunner: largeInlineRunner.value, stripRuntimeStack, stripInlineExecutionStack, + plainInlineExecutionStack, + errorConstructorMetadata, + errorConstructorRelationships, }); } 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 3b7fb1025..c6f7a1cb6 100644 --- a/examples/runtime/typescript-transform-runtime/src/typescript-transform-runtime.js +++ b/examples/runtime/typescript-transform-runtime/src/typescript-transform-runtime.js @@ -181,6 +181,51 @@ export async function run() { const preparedSourceMap = module.findSourceMap(generatedSiteFile); const preparedOrigin = preparedSourceMap && preparedSourceMap.findOrigin(generatedSiteLine, generatedSiteColumn); + const errorConstructorMetadata = [ + Error, + TypeError, + RangeError, + ReferenceError, + SyntaxError, + EvalError, + URIError, + AggregateError, + ].map((Constructor) => ({ name: Constructor.name, length: Constructor.length })); + class NarrowTypeError extends TypeError {} + const errorConstructorRelationships = Error.isPrototypeOf(TypeError) && + !(new TypeError('base') instanceof NarrowTypeError) && + new NarrowTypeError('narrow') instanceof NarrowTypeError; + + const originalPrepareDescriptor = Object.getOwnPropertyDescriptor(Error, 'prepareStackTrace'); + const originalPrepareValue = Error.prepareStackTrace; + Object.defineProperty(Error, 'prepareStackTrace', { + value: () => 'non-writable-prepare', + writable: false, + configurable: true, + }); + const nonWritablePrepareStack = new TypeError('non-writable').stack; + Object.defineProperty(Error, 'prepareStackTrace', originalPrepareDescriptor); + Error.prepareStackTrace = originalPrepareValue; + + let prepareSetterCalls = 0; + Object.defineProperty(Error, 'prepareStackTrace', { + get() { return undefined; }, + set() { prepareSetterCalls++; }, + configurable: true, + }); + new TypeError('accessor-backed'); + Object.defineProperty(Error, 'prepareStackTrace', originalPrepareDescriptor); + Error.prepareStackTrace = originalPrepareValue; + + let nestedPrepareCalls = 0; + Error.prepareStackTrace = () => { + nestedPrepareCalls++; + new TypeError('nested'); + return 'nested-prepare'; + }; + const nestedPrepareStack = new Error('outer').stack; + Object.defineProperty(Error, 'prepareStackTrace', originalPrepareDescriptor); + Error.prepareStackTrace = originalPrepareValue; const errorConstructorsStable = Error === errorConstructorBefore && TypeError === typeErrorConstructorBefore && new Error().constructor === Error && @@ -344,6 +389,12 @@ export async function run() { customErrorRuntimeStack, syntaxErrorRuntimeStack, errorConstructorsStable, + errorConstructorMetadata, + errorConstructorRelationships, + nonWritablePrepareStack, + prepareSetterCalls, + nestedPrepareCalls, + nestedPrepareStack, generatedSite: { fileName: generatedSiteFile, lineNumber: generatedSiteLine, diff --git a/tests/runtime/typescript_runtime.rs b/tests/runtime/typescript_runtime.rs index 99a791034..6769f84bf 100644 --- a/tests/runtime/typescript_runtime.rs +++ b/tests/runtime/typescript_runtime.rs @@ -284,6 +284,27 @@ async fn strip_typescript_types_matches_node_contract( "strip inline execution did not remove its wrapper offset: {}", report["stripInlineExecutionStack"] ); + assert!( + report["plainInlineExecutionStack"] + .as_str() + .is_some_and(|stack| stack.contains("__wasm_rquickjs_execution_inline.mjs:2:")), + "plain JavaScript inline execution retained its wrapper offset: {}", + report["plainInlineExecutionStack"] + ); + assert_eq!( + report["errorConstructorMetadata"], + serde_json::json!([ + { "name": "Error", "length": 1 }, + { "name": "TypeError", "length": 1 }, + { "name": "RangeError", "length": 1 }, + { "name": "ReferenceError", "length": 1 }, + { "name": "SyntaxError", "length": 1 }, + { "name": "EvalError", "length": 1 }, + { "name": "URIError", "length": 1 }, + { "name": "AggregateError", "length": 2 }, + ]) + ); + assert_eq!(report["errorConstructorRelationships"], true); assert!( report["unsupported"] .as_str() @@ -385,6 +406,24 @@ async fn typescript_transform_runtime_is_immutable( "--no-enable-source-maps unexpectedly remapped the stack: {disabled_stack}" ); assert_eq!(report["errorConstructorsStable"], true); + assert_eq!( + report["errorConstructorMetadata"], + serde_json::json!([ + { "name": "Error", "length": 1 }, + { "name": "TypeError", "length": 1 }, + { "name": "RangeError", "length": 1 }, + { "name": "ReferenceError", "length": 1 }, + { "name": "SyntaxError", "length": 1 }, + { "name": "EvalError", "length": 1 }, + { "name": "URIError", "length": 1 }, + { "name": "AggregateError", "length": 2 }, + ]) + ); + assert_eq!(report["errorConstructorRelationships"], true); + assert_eq!(report["nonWritablePrepareStack"], "non-writable-prepare"); + assert_eq!(report["prepareSetterCalls"], 0); + assert_eq!(report["nestedPrepareCalls"], 1); + assert_eq!(report["nestedPrepareStack"], "nested-prepare"); assert!( report["generatedSite"]["fileName"] .as_str() @@ -398,7 +437,7 @@ async fn typescript_transform_runtime_is_immutable( .as_str() .is_some_and(|file| file.ends_with("stack-errors.mts")) ); - assert_eq!(report["preparedOrigin"]["lineNumber"], 14); + assert_eq!(report["preparedOrigin"]["lineNumber"], 13); assert!( report["callSites"]["mapped"]["scriptName"] .as_str() From 9eed94edb9fcdaaf91a2b2667537e6b647278ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Tue, 1 Sep 2026 23:02:58 +0200 Subject: [PATCH 04/13] Fix captured CallSite compatibility (GOL-419) --- .../skeleton/src/builtin/internal/errors.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js b/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js index 51552cecd..b23c833b8 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js @@ -410,17 +410,30 @@ Object.defineProperty(globalThis, '__wasm_rquickjs_install_source_map_error_stac { const nativeCaptureStackTrace = globalThis.Error.captureStackTrace; if (typeof nativeCaptureStackTrace === 'function') { + function captureRawStackTrace(targetObject, constructorOpt) { + // QuickJS stores its native prepare hook in the context behind the + // original Error constructor. The public ErrorShim deliberately + // keeps the Node-facing hook in a separate slot, so suppress the + // native slot directly without touching a user-defined descriptor. + const nativePrepare = NativeError.prepareStackTrace; + NativeError.prepareStackTrace = undefined; + try { + nativeCaptureStackTrace(targetObject, constructorOpt); + return targetObject.stack; + } finally { + NativeError.prepareStackTrace = nativePrepare; + } + } + globalThis.Error.captureStackTrace = function captureStackTrace(targetObject, constructorOpt) { // If prepareStackTrace is set at the JS level (e.g., on the ErrorShim), // we need to handle it ourselves since the native captureStackTrace // may not see it through the ErrorShim prototype chain. const currentPrepare = globalThis.Error && globalThis.Error.prepareStackTrace; if (typeof currentPrepare === 'function') { - nativeCaptureStackTrace(targetObject, constructorOpt); - // Read the raw stack string, parse into CallSites, and install // a lazy getter that calls prepareStackTrace on first access. - const rawStack = targetObject.stack; + const rawStack = captureRawStackTrace(targetObject, constructorOpt); if (typeof rawStack === 'string') { const callSites = _parseStackStringToCallSites(rawStack); Object.defineProperty(targetObject, "stack", { From d64191fac6f87ee943a6ef45bccb8aa21759601c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Wed, 2 Sep 2026 17:36:40 +0200 Subject: [PATCH 05/13] Address source map ownership review (GOL-419) --- .../skeleton/src/builtin/internal/errors.js | 53 +-- .../skeleton/src/builtin/internal/mod.rs | 5 + .../src/builtin/internal/source_map_url.js | 399 ++++++++++++++++++ .../skeleton/src/builtin/module.js | 179 ++++---- .../wasm-rquickjs/skeleton/src/builtin/vm.js | 83 +--- .../skeleton/src/internal/typescript.rs | 3 + .../src/node-compat-runner.js | 3 + examples/runtime/source-map/src/source-map.js | 26 +- .../src/typescript-transform-runtime.js | 35 +- .../v8_stack_trace/src/v8_stack_trace.js | 24 ++ .../v8_stack_trace/wit/v8_stack_trace.wit | 1 + tests/node_compat/config.jsonc | 2 +- tests/node_compat/report.md | 15 +- tests/runtime/typescript_runtime.rs | 7 +- tests/runtime/v8_stack_trace.rs | 17 + 15 files changed, 653 insertions(+), 199 deletions(-) create mode 100644 crates/wasm-rquickjs/skeleton/src/builtin/internal/source_map_url.js diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js b/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js index b23c833b8..c8fee0e6b 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js @@ -426,37 +426,28 @@ Object.defineProperty(globalThis, '__wasm_rquickjs_install_source_map_error_stac } globalThis.Error.captureStackTrace = function captureStackTrace(targetObject, constructorOpt) { - // If prepareStackTrace is set at the JS level (e.g., on the ErrorShim), - // we need to handle it ourselves since the native captureStackTrace - // may not see it through the ErrorShim prototype chain. - const currentPrepare = globalThis.Error && globalThis.Error.prepareStackTrace; - if (typeof currentPrepare === 'function') { - // Read the raw stack string, parse into CallSites, and install - // a lazy getter that calls prepareStackTrace on first access. - const rawStack = captureRawStackTrace(targetObject, constructorOpt); - if (typeof rawStack === 'string') { - const callSites = _parseStackStringToCallSites(rawStack); - Object.defineProperty(targetObject, "stack", { - get() { - const prepare = globalThis.Error && globalThis.Error.prepareStackTrace; - const result = typeof prepare === "function" - ? prepare(targetObject, callSites) - : rawStack; - Object.defineProperty(targetObject, "stack", _dataDesc(result)); - return result; - }, - set(value) { - Object.defineProperty(targetObject, "stack", _dataDesc(value)); - }, - configurable: true, - enumerable: false, - }); - } - } else { - nativeCaptureStackTrace(targetObject, constructorOpt); - if (typeof targetObject.stack === 'string') { - targetObject.stack = _remapSourceMappedStack(targetObject.stack); - } + // Always capture the native text without its hidden hook. The + // public hook is intentionally selected only when `.stack` is + // first read, matching V8 when prepareStackTrace changes after + // captureStackTrace() returns. + const rawStack = captureRawStackTrace(targetObject, constructorOpt); + if (typeof rawStack === 'string') { + const callSites = _parseStackStringToCallSites(rawStack); + Object.defineProperty(targetObject, "stack", { + get() { + const prepare = globalThis.Error && globalThis.Error.prepareStackTrace; + const result = typeof prepare === "function" + ? prepare(targetObject, callSites) + : _remapSourceMappedStack(rawStack); + Object.defineProperty(targetObject, "stack", _dataDesc(result)); + return result; + }, + set(value) { + Object.defineProperty(targetObject, "stack", _dataDesc(value)); + }, + configurable: true, + enumerable: false, + }); } }; } diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/mod.rs b/crates/wasm-rquickjs/skeleton/src/builtin/internal/mod.rs index 398dd2c8c..d8eac8e31 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/internal/mod.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/mod.rs @@ -5,6 +5,7 @@ pub fn add_to_resolver(resolver: BuiltinResolver) -> BuiltinResolver { .with_module("__wasm_rquickjs_builtin/internal/http") .with_module("internal/http") .with_module("__wasm_rquickjs_builtin/internal/errors") + .with_module("__wasm_rquickjs_builtin/internal/source_map_url") .with_module("__wasm_rquickjs_builtin/internal/fs/utils") .with_module("__wasm_rquickjs_builtin/internal/fs/shared") .with_module("__wasm_rquickjs_builtin/internal/normalize_encoding") @@ -47,6 +48,10 @@ pub fn module_loader() -> BuiltinLoader { "__wasm_rquickjs_builtin/internal/errors", include_str!("errors.js"), ) + .with_module( + "__wasm_rquickjs_builtin/internal/source_map_url", + include_str!("source_map_url.js"), + ) .with_module( "__wasm_rquickjs_builtin/internal/fs/utils", include_str!("fs/utils.js"), diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/source_map_url.js b/crates/wasm-rquickjs/skeleton/src/builtin/internal/source_map_url.js new file mode 100644 index 000000000..dc81eb863 --- /dev/null +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/source_map_url.js @@ -0,0 +1,399 @@ +function isIdentifierChar(ch) { + return ( + ch === 0x5f || + ch === 0x24 || + (ch >= 0x30 && ch <= 0x39) || + (ch >= 0x41 && ch <= 0x5a) || + (ch >= 0x61 && ch <= 0x7a) || + ch >= 0x80 + ); +} + +function skipStringLiteral(source, index, quote) { + index++; + while (index < source.length) { + const ch = source.charCodeAt(index); + if (ch === 0x5c) { + index += 2; + continue; + } + index++; + if (ch === quote) break; + } + return index; +} + +function skipTemplateLiteral(source, index) { + index++; + while (index < source.length) { + const ch = source.charCodeAt(index); + if (ch === 0x5c) { + index += 2; + continue; + } + index++; + if (ch === 0x60) break; + } + return index; +} + +function skipWhitespaceAndComments(source, index) { + while (index < source.length) { + const ch = source.charCodeAt(index); + if ( + ch === 0x20 || + ch === 0x09 || + ch === 0x0a || + ch === 0x0d || + ch === 0x0b || + ch === 0x0c + ) { + index++; + continue; + } + if (ch === 0x2f && source.charCodeAt(index + 1) === 0x2f) { + index += 2; + while ( + index < source.length && + source.charCodeAt(index) !== 0x0a && + source.charCodeAt(index) !== 0x0d + ) + index++; + continue; + } + if (ch === 0x2f && source.charCodeAt(index + 1) === 0x2a) { + index += 2; + while ( + index + 1 < source.length && + !( + source.charCodeAt(index) === 0x2a && + source.charCodeAt(index + 1) === 0x2f + ) + ) + index++; + index = Math.min(index + 2, source.length); + continue; + } + break; + } + return index; +} + +function previousSignificantChar(source, index) { + index--; + while (index >= 0) { + const ch = source.charCodeAt(index); + if ( + ch === 0x20 || + ch === 0x09 || + ch === 0x0a || + ch === 0x0d || + ch === 0x0b || + ch === 0x0c + ) { + index--; + continue; + } + if (ch === 0x2f && source.charCodeAt(index - 1) === 0x2a) { + const start = source.lastIndexOf("/*", index - 2); + if (start >= 0) { + index = start - 1; + continue; + } + } + return ch; + } + return 0; +} + +function previousSignificantWord(source, index) { + index--; + while (index >= 0) { + const ch = source.charCodeAt(index); + if ( + ch === 0x20 || + ch === 0x09 || + ch === 0x0a || + ch === 0x0d || + ch === 0x0b || + ch === 0x0c + ) { + index--; + continue; + } + if (ch === 0x2f && source.charCodeAt(index - 1) === 0x2a) { + const start = source.lastIndexOf("/*", index - 2); + if (start >= 0) { + index = start - 1; + continue; + } + } + break; + } + const end = index + 1; + while (index >= 0 && isIdentifierChar(source.charCodeAt(index))) index--; + return end === index + 1 ? "" : source.slice(index + 1, end); +} + +function skipRegexLiteral(source, index) { + index++; + let inClass = false; + while (index < source.length) { + const ch = source.charCodeAt(index); + if (ch === 0x5c) { + index += 2; + continue; + } + if (ch === 0x5b) inClass = true; + else if (ch === 0x5d) inClass = false; + else if (ch === 0x2f && !inClass) { + index++; + while ( + index < source.length && + isIdentifierChar(source.charCodeAt(index)) + ) + index++; + break; + } + index++; + } + return index; +} + +function isLikelyRegexLiteral(source, index) { + const end = skipRegexLiteral(source, index); + if (end >= source.length) return true; + if (end === index + 1) return false; + const next = source.charCodeAt(end); + return ( + next === 0x20 || + next === 0x09 || + next === 0x0a || + next === 0x0d || + next === 0x2e || + next === 0x3b || + next === 0x2c || + next === 0x29 || + next === 0x5d || + next === 0x7d + ); +} + +function previousWordBeforeMatchingParen(source, closeIndex) { + let depth = 1; + let index = closeIndex - 1; + while (index >= 0) { + const ch = source.charCodeAt(index); + if (ch === 0x29) depth++; + else if (ch === 0x28) { + depth--; + if (depth === 0) return previousSignificantWord(source, index); + } + index--; + } + return ""; +} + +function regexCanFollowParen(source, index) { + if (previousSignificantChar(source, index) !== 0x29) return false; + const word = previousWordBeforeMatchingParen(source, index - 1); + return word === "if" || word === "while" || word === "for" || word === "with"; +} + +function regexCanFollow(source, index) { + const previous = previousSignificantChar(source, index); + if ( + previous === 0 || + previous === 0x28 || + previous === 0x5b || + previous === 0x7b || + previous === 0x2c || + previous === 0x3b || + previous === 0x3a || + previous === 0x3d || + previous === 0x21 || + previous === 0x3f || + previous === 0x26 || + previous === 0x7c || + previous === 0x2b || + previous === 0x2d || + previous === 0x2a || + previous === 0x2f || + previous === 0x25 || + previous === 0x7e || + previous === 0x5e || + previous === 0x3c || + previous === 0x3e + ) + return true; + const word = previousSignificantWord(source, index); + return ( + word === "return" || + word === "throw" || + word === "case" || + word === "delete" || + word === "void" || + word === "typeof" || + word === "yield" || + word === "await" || + word === "else" || + word === "do" || + word === "in" || + word === "instanceof" || + word === "of" + ); +} + +function findTemplateExpressionEnd(source, start) { + let index = start; + let depth = 0; + while (index < source.length) { + const ch = source.charCodeAt(index); + if (ch === 0x27 || ch === 0x22) { + index = skipStringLiteral(source, index, ch); + continue; + } + if (ch === 0x60) { + index = skipTemplateLiteral(source, index); + continue; + } + if (ch === 0x2f && source.charCodeAt(index + 1) === 0x2f) { + index += 2; + while ( + index < source.length && + source.charCodeAt(index) !== 0x0a && + source.charCodeAt(index) !== 0x0d + ) + index++; + continue; + } + if (ch === 0x2f && source.charCodeAt(index + 1) === 0x2a) { + index = skipWhitespaceAndComments(source, index); + continue; + } + if (ch === 0x2f && regexCanFollow(source, index)) { + index = skipRegexLiteral(source, index); + continue; + } + if (ch === 0x7b) depth++; + else if (ch === 0x7d) { + if (depth === 0) return index; + depth--; + } + index++; + } + return -1; +} + +function sourceMapURLFromComment(comment, blockComment) { + const prefixLength = blockComment ? 3 : 3; + let index = prefixLength; + const separator = comment.charCodeAt(index); + if ( + separator !== 0x09 && + separator !== 0x0b && + separator !== 0x0c && + separator !== 0x20 && + separator !== 0xa0 + ) + return undefined; + index++; + if (!comment.startsWith("sourceMappingURL=", index)) return undefined; + const value = comment.slice(index + 17, blockComment ? -2 : undefined).trim(); + return value || undefined; +} + +export function extractSourceMapURL(code, options = undefined) { + const source = String(code); + if (source.indexOf("sourceMappingURL=") === -1) return undefined; + const allowBlockComments = options && options.blockComments === true; + let result; + + function scan(start, end) { + let index = start; + while (index < end) { + const ch = source.charCodeAt(index); + if (ch === 0x27 || ch === 0x22) { + index = skipStringLiteral(source, index, ch); + continue; + } + if (ch === 0x60) { + index++; + while (index < end) { + const templateCh = source.charCodeAt(index); + if (templateCh === 0x5c) { + index += 2; + continue; + } + if (templateCh === 0x60) { + index++; + break; + } + if (templateCh === 0x24 && source.charCodeAt(index + 1) === 0x7b) { + const expressionStart = index + 2; + const expressionEnd = findTemplateExpressionEnd( + source, + expressionStart, + ); + if (expressionEnd === -1) return; + scan(expressionStart, expressionEnd); + index = expressionEnd + 1; + continue; + } + index++; + } + continue; + } + if (ch !== 0x2f) { + index++; + continue; + } + const next = source.charCodeAt(index + 1); + if (next === 0x2f) { + let lineEnd = index + 2; + while ( + lineEnd < end && + source.charCodeAt(lineEnd) !== 0x0a && + source.charCodeAt(lineEnd) !== 0x0d + ) + lineEnd++; + const marker = source.charCodeAt(index + 2); + if (marker === 0x23 || marker === 0x40) { + const value = sourceMapURLFromComment( + source.slice(index, lineEnd), + false, + ); + if (value !== undefined) result = value; + } + index = lineEnd; + continue; + } + if (next === 0x2a) { + const close = source.indexOf("*/", index + 2); + const blockEnd = close === -1 ? end : Math.min(close + 2, end); + const marker = source.charCodeAt(index + 2); + if (allowBlockComments && (marker === 0x23 || marker === 0x40)) { + const value = sourceMapURLFromComment( + source.slice(index, blockEnd), + true, + ); + if (value !== undefined) result = value; + } + index = blockEnd; + continue; + } + if ( + regexCanFollow(source, index) || + (regexCanFollowParen(source, index) && + isLikelyRegexLiteral(source, index)) + ) { + index = skipRegexLiteral(source, index); + continue; + } + index++; + } + } + + scan(0, source.length); + return result; +} diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 8851b5406..693d12290 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -61,6 +61,7 @@ import * as internalWebstreamsUtil from '__wasm_rquickjs_builtin/internal/webstr import * as internalStreamsAddAbortSignal from '__wasm_rquickjs_builtin/internal/streams/add-abort-signal'; import * as internalStreamsState from '__wasm_rquickjs_builtin/internal/streams/state'; import * as internalTestBinding from '__wasm_rquickjs_builtin/internal/test/binding'; +import { extractSourceMapURL } from '__wasm_rquickjs_builtin/internal/source_map_url'; import { eval_with_filename as _evalWithFilename, require_esm as _requireEsm } from '__wasm_rquickjs_builtin/vm_native'; import { transform_typescript as transformTypeScriptNative, @@ -1208,32 +1209,28 @@ function rustHasExecArgvFlag(flag) { return wasmRquickjsModuleGlobalThis.__wasm_rquickjs_module_has_exec_argv_flag(flag); } -function hasActiveExecArgvFlag(flag) { - if (rustHasExecArgvFlag(flag)) return true; - const activeProcess = wasmRquickjsModuleGlobalThis.process; - if (!activeProcess || !Array.isArray(activeProcess.execArgv)) return false; - const prefixed = flag + '='; - return activeProcess.execArgv.some((arg) => { - arg = String(arg); - return arg === flag || arg.startsWith(prefixed); - }); -} - -function isExperimentalTransformTypesEnabled() { - return hasActiveExecArgvFlag('--experimental-transform-types'); +let sourceMapsSupportEnabled = + (processModule.features && processModule.features.typescript === 'transform') || + (wasmRquickjsModuleGlobalThis.process && + wasmRquickjsModuleGlobalThis.process.features && + wasmRquickjsModuleGlobalThis.process.features.typescript === 'transform'); +const sourceMapsSupportDefault = sourceMapsSupportEnabled; + +function configureSourceMapsFromStartupArgs(args) { + let enabled = sourceMapsSupportDefault; + if (Array.isArray(args)) { + for (const value of args) { + const arg = String(value); + if (arg === '--no-enable-source-maps') enabled = false; + else if (arg === '--enable-source-maps' || + arg === '--experimental-transform-types') enabled = true; + } + } + sourceMapsSupportEnabled = enabled; } function isSourceMapsEnabled() { - if (hasActiveExecArgvFlag('--no-enable-source-maps')) { - return false; - } - - return hasActiveExecArgvFlag('--enable-source-maps') || - isExperimentalTransformTypesEnabled() || - (processModule.features && processModule.features.typescript === 'transform') || - (wasmRquickjsModuleGlobalThis.process && - wasmRquickjsModuleGlobalThis.process.features && - wasmRquickjsModuleGlobalThis.process.features.typescript === 'transform'); + return sourceMapsSupportEnabled; } // Transform-mode source maps are active before user modules execute. Install @@ -1257,15 +1254,6 @@ function getSimpleSourceMapRegistry() { return registry; } -function getCjsSourceMapOwnerRegistry() { - let registry = globalThis.__wasm_rquickjs_cjs_source_map_owners; - if (!registry || typeof registry !== 'object') { - registry = Object.create(null); - globalThis.__wasm_rquickjs_cjs_source_map_owners = registry; - } - return registry; -} - function getCjsLineOffsetRegistry() { let registry = globalThis.__wasm_rquickjs_cjs_line_offsets; if (!registry || typeof registry !== 'object') { @@ -1315,6 +1303,39 @@ function makeWeakRef(value) { } } +const cjsSourceMapSymbol = Symbol('wasm-rquickjs.cjsSourceMap'); +const thrownErrorSourceMapSymbol = Symbol('wasm-rquickjs.thrownErrorSourceMap'); +const weakSourceMapEntrySymbol = Symbol('wasm-rquickjs.weakSourceMapEntry'); +let cjsSourceMapStoresUntilSweep = 32; + +function sourceMapFromRegistry(path) { + const registry = getSimpleSourceMapRegistry(); + const entry = registry[path]; + if (!entry || entry[weakSourceMapEntrySymbol] !== true) return entry; + const sourceMap = derefWeakRef(entry.ref); + if (sourceMap !== undefined) return sourceMap; + delete registry[path]; + delete getCjsLineOffsetRegistry()[path]; + delete getForcedSourceMapLineOffsetRegistry()[path]; + return undefined; +} + +function sweepReleasedSourceMaps() { + const registry = getSimpleSourceMapRegistry(); + for (const path of Object.keys(registry)) sourceMapFromRegistry(path); +} + +function retainSourceMapForThrownError(error, filename) { + if (!error || (typeof error !== 'object' && typeof error !== 'function')) return; + const sourceMap = sourceMapFromRegistry(filename); + if (sourceMap === undefined) return; + try { + Object.defineProperty(error, thrownErrorSourceMapSymbol, { value: sourceMap }); + } catch (_) { + // Stack remapping remains best-effort for non-extensible thrown values. + } +} + const sourceMapVlqChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; const sourceMapVlqMap = Object.create(null); for (let i = 0; i < sourceMapVlqChars.length; i++) { @@ -1556,15 +1577,7 @@ class SourceMap { function findSourceMap(path) { path = String(path); - const owners = getCjsSourceMapOwnerRegistry(); - const ownerRef = owners[path]; - if (ownerRef !== undefined && derefWeakRef(ownerRef) === undefined) { - delete owners[path]; - delete getSimpleSourceMapRegistry()[path]; - return undefined; - } - const registry = getSimpleSourceMapRegistry(); - return registry[path]; + return sourceMapFromRegistry(path); } function sourceMapLineLengths(source) { @@ -1590,13 +1603,11 @@ function decodeInlineSourceMap(url) { function storeSourceMap(filename, source, payload, sourceBasePath, moduleObject, options) { const registry = getSimpleSourceMapRegistry(); - const owners = getCjsSourceMapOwnerRegistry(); if (!isSourceMapsEnabled() || payload === null || typeof payload !== 'object') { delete registry[filename]; - delete owners[filename]; return; } - registry[filename] = new SourceMap(payload, { + const sourceMap = new SourceMap(payload, { lineLengths: sourceMapLineLengths(source), sourceBasePath, originalLineOffset: options && options.originalLineOffset, @@ -1604,11 +1615,30 @@ function storeSourceMap(filename, source, payload, sourceBasePath, moduleObject, const installErrorStackShim = globalThis.__wasm_rquickjs_install_source_map_error_stack_shim; if (typeof installErrorStackShim === 'function') installErrorStackShim(); if (moduleObject) { - const ownerRef = makeWeakRef(moduleObject); - if (ownerRef !== undefined) owners[filename] = ownerRef; - else delete owners[filename]; + const sourceMapRef = makeWeakRef(sourceMap); + if (sourceMapRef !== undefined) { + try { + Object.defineProperty(moduleObject, cjsSourceMapSymbol, { + value: sourceMap, + configurable: true, + }); + registry[filename] = { + [weakSourceMapEntrySymbol]: true, + ref: sourceMapRef, + }; + } catch (_) { + registry[filename] = sourceMap; + } + } else { + registry[filename] = sourceMap; + } + cjsSourceMapStoresUntilSweep--; + if (cjsSourceMapStoresUntilSweep === 0) { + sweepReleasedSourceMaps(); + cjsSourceMapStoresUntilSweep = 32; + } } else { - delete owners[filename]; + registry[filename] = sourceMap; } } @@ -1624,23 +1654,15 @@ function registerSourceMapPayload(filename, source, sourceMap, moduleObject) { function registerSourceMapForCjs(filename, source, moduleObject, options = undefined) { const registry = getSimpleSourceMapRegistry(); - const owners = getCjsSourceMapOwnerRegistry(); if (!isSourceMapsEnabled()) { delete registry[filename]; - delete owners[filename]; return; } const sourceText = String(source); - const directiveRe = /\/\/[#@]\s*sourceMappingURL=([^\r\n]+)|\/\*[#@]\s*sourceMappingURL=([\s\S]*?)\*\//g; - let match; - let url = null; - while ((match = directiveRe.exec(sourceText)) !== null) { - url = (match[1] !== undefined ? match[1] : match[2]).trim(); - } - if (url === null) { + const url = extractSourceMapURL(sourceText, { blockComments: true }); + if (url === undefined) { delete registry[filename]; - delete owners[filename]; return; } @@ -1662,7 +1684,6 @@ function registerSourceMapForCjs(filename, source, moduleObject, options = undef } if (payload === null) { delete registry[filename]; - delete owners[filename]; return; } storeSourceMap(filename, source, payload, sourceBasePath, moduleObject, options); @@ -1688,8 +1709,9 @@ function registerSourceMapForTransformedSource( lineOffset = Number(lineOffset); if (Number.isFinite(lineOffset) && lineOffset > 0) offsets[filename] = lineOffset; else delete offsets[filename]; - if (registry[filename] !== undefined) { - registry[filename]._generatedLineOffset = Number.isFinite(lineOffset) ? lineOffset : 0; + const sourceMap = sourceMapFromRegistry(filename); + if (sourceMap !== undefined) { + sourceMap._generatedLineOffset = Number.isFinite(lineOffset) ? lineOffset : 0; } if (forceLineOffset) forcedOffsets[filename] = true; else delete forcedOffsets[filename]; @@ -1715,8 +1737,7 @@ function remapSourceMappedPosition( columnNumber = Number(columnNumber); if (!Number.isFinite(lineNumber) || !Number.isFinite(columnNumber)) return undefined; - const registry = getSimpleSourceMapRegistry(); - const sourceMap = registry[scriptName]; + const sourceMap = sourceMapFromRegistry(scriptName); const lineOffsets = getCjsLineOffsetRegistry(); const forcedOffsets = getForcedSourceMapLineOffsetRegistry(); const lineOffset = lineOffsets[scriptName]; @@ -1756,6 +1777,14 @@ Object.defineProperties(globalThis, { writable: false, configurable: false, }, + // The compatibility runner calls this before loading a test file to model + // immutable Node startup flags. Product code uses module.setSourceMapsSupport + // and cannot toggle behavior by mutating the informational execArgv array. + __wasm_rquickjs_configure_source_maps_from_startup_args: { + value: configureSourceMapsFromStartupArgs, + writable: false, + configurable: false, + }, }); function isTypeScriptFilename(filename) { @@ -1816,10 +1845,18 @@ function transformTypeScriptModuleOutput(filename, source, module = undefined) { return output; } +function appendInlineSourceMap(code, sourceMap) { + if (!sourceMap) return code; + // Keep this wire format in sync with + // TypeScriptOutput::into_code_with_inline_source_map. Runtime coverage + // decodes maps emitted through both the Rust ESM and JavaScript CJS/public + // transformation paths and asserts their original coordinates. + const encoded = buffer.Buffer.from(sourceMap, 'utf8').toString('base64'); + return code + `\n//# sourceMappingURL=data:application/json;base64,${encoded}`; +} + function codeWithInlineSourceMap(output) { - if (!output.sourceMap) return output.code; - const encoded = buffer.Buffer.from(output.sourceMap, 'utf8').toString('base64'); - return output.code + `\n//# sourceMappingURL=data:application/json;base64,${encoded}`; + return appendInlineSourceMap(output.code, output.sourceMap); } function transpileTypeScriptModule(filename, source, module = undefined) { @@ -1864,11 +1901,9 @@ export function stripTypeScriptTypes(code, options = undefined) { const transformed = JSON.parse(transformTypeScriptNative( code, sourceUrl === undefined ? '' : sourceUrl, mode, sourceMap, undefined )); - let result = transformed.code; - if (sourceMap) { - const encoded = buffer.Buffer.from(transformed.sourceMap, 'utf8').toString('base64'); - result += `\n//# sourceMappingURL=data:application/json;base64,${encoded}`; - } + let result = sourceMap + ? appendInlineSourceMap(transformed.code, transformed.sourceMap) + : transformed.code; if (sourceUrl !== undefined) { result += `\n\n//# sourceURL=${sourceUrl}`; } @@ -3291,6 +3326,7 @@ function loadCommonJsTransaction(descriptor) { try { callCompiledCjsFunction(mod, compiledFn, source, filename, dirname, childRequire); } catch (err) { + retainSourceMapForThrownError(err, filename); discardCjsModuleLoad(cacheKey, parentModule, mod); maybeSetArrowMessageOnSyntaxError(err, filename, source); throw err; @@ -4747,6 +4783,7 @@ function setSourceMapsSupport(enabled, options) { if (generatedCode !== undefined && typeof generatedCode !== 'boolean') { throw new ERR_INVALID_ARG_TYPE('options.generatedCode', 'boolean', generatedCode); } + sourceMapsSupportEnabled = enabled; } const globalPaths = []; diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/vm.js b/crates/wasm-rquickjs/skeleton/src/builtin/vm.js index 1ea7b74b5..e462c42c1 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/vm.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/vm.js @@ -3,6 +3,7 @@ import { eval_with_filename as evalWithFilename, } from '__wasm_rquickjs_builtin/vm_native'; import * as pathModule from 'node:path'; +import { extractSourceMapURL } from '__wasm_rquickjs_builtin/internal/source_map_url'; let contextIdCounter = 1; const contextIds = new WeakMap(); @@ -2657,88 +2658,6 @@ function rewriteDynamicImports(code, replacementOpenSource) { return { code: out + code.slice(last), changed }; } -function extractSourceMapURL(code) { - const text = String(code); - let url; - - if (text.indexOf('sourceMappingURL=') === -1) { - return undefined; - } - - function isSourceMapURLSeparator(ch) { - return ch === 0x09 || - ch === 0x0b || - ch === 0x0c || - ch === 0x20 || - ch === 0xa0; - } - - function scan(start, end) { - for (let i = start; i < end; i++) { - const ch = text.charCodeAt(i); - if (ch === 0x27 || ch === 0x22) { - i = skipStringLiteral(text, i, ch) - 1; - continue; - } - if (ch === 0x60) { - i++; - while (i < end) { - const templateCh = text.charCodeAt(i); - if (templateCh === 0x5c) { - i += 2; - continue; - } - if (templateCh === 0x60) break; - if (templateCh === 0x24 && text.charCodeAt(i + 1) === 0x7b) { - const expressionStart = i + 2; - const expressionEnd = findTemplateExpressionEnd(text, expressionStart); - if (expressionEnd === -1) return; - scan(expressionStart, expressionEnd); - i = expressionEnd + 1; - continue; - } - i++; - } - continue; - } - if (ch === 0x2f && text.charCodeAt(i + 1) === 0x2a) { - i = skipWhitespaceAndComments(text, i) - 1; - continue; - } - if (ch !== 0x2f || text.charCodeAt(i + 1) !== 0x2f) { - if (ch === 0x2f && (regexCanFollow(text, i) || (regexCanFollowParen(text, i) && isLikelyRegexLiteral(text, i)))) { - i = skipRegexLiteral(text, i) - 1; - } - continue; - } - - const marker = text.charCodeAt(i + 2); - if (marker === 0x23 || marker === 0x40) { - const separator = text.charCodeAt(i + 3); - if (isSourceMapURLSeparator(separator) && - text.startsWith('sourceMappingURL=', i + 4)) { - let lineEnd = i + 21; - while (lineEnd < end) { - const endChar = text.charCodeAt(lineEnd); - if (endChar === 0x0a || endChar === 0x0d) break; - lineEnd++; - } - const value = text.slice(i + 21, lineEnd).trim(); - if (value.length > 0) { - url = value; - } - } - } - - i += 2; - while (i < end && text.charCodeAt(i) !== 0x0a && text.charCodeAt(i) !== 0x0d) i++; - } - } - - scan(0, text.length); - return url; -} - export class Script { constructor(code, options) { scriptBrandSet.add(this); diff --git a/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs b/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs index 3b0fceb89..344450876 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs @@ -251,6 +251,9 @@ impl TypeScriptOutput { let Some(source_map) = self.source_map else { return self.code; }; + // This directive is the cross-language transform contract. Keep it in + // sync with module.js::appendInlineSourceMap; runtime tests decode and + // verify both the Rust ESM and JavaScript CJS/public paths. let encoded = base64ct::Base64::encode_string(source_map.as_bytes()); format!( "{}\n//# sourceMappingURL=data:application/json;base64,{encoded}", diff --git a/examples/runtime/node-compat-runner/src/node-compat-runner.js b/examples/runtime/node-compat-runner/src/node-compat-runner.js index a22ecdcc9..c86627e3a 100644 --- a/examples/runtime/node-compat-runner/src/node-compat-runner.js +++ b/examples/runtime/node-compat-runner/src/node-compat-runner.js @@ -162,6 +162,9 @@ function applyTestFlagsToProcess(testPath) { for (var i = 0; i < flags.length; i++) { globalThis.process.execArgv.push(flags[i]); } + if (typeof globalThis.__wasm_rquickjs_configure_source_maps_from_startup_args === 'function') { + globalThis.__wasm_rquickjs_configure_source_maps_from_startup_args(flags); + } globalThis.__wasm_rquickjs_package_conditions = packageConditionsFromFlags(flags); return flags; } diff --git a/examples/runtime/source-map/src/source-map.js b/examples/runtime/source-map/src/source-map.js index 818fdb81d..7bbd0b326 100644 --- a/examples/runtime/source-map/src/source-map.js +++ b/examples/runtime/source-map/src/source-map.js @@ -14,9 +14,8 @@ function writeJson(path, value) { } export function testSourceMapApi() { - const originalExecArgv = process.execArgv.slice(); try { - process.execArgv = originalExecArgv.concat('--enable-source-maps'); + module.setSourceMapsSupport(true); const previousLine = new module.SourceMap({ sources: ['previous.js'], @@ -78,6 +77,27 @@ export function testSourceMapApi() { require(blockDirective); assert(module.findSourceMap(blockDirective).findEntry(0, 0).originalSource.endsWith('/right.js'), 'last block directive wins'); + const lexicalDirective = '/source-map-lexical-directive.cjs'; + fs.writeFileSync(lexicalDirective, [ + 'module.exports = 1;', + '//# sourceMappingURL=right.map', + 'const stringDecoy = "//# sourceMappingURL=wrong-map.json";', + 'const templateDecoy = `//# sourceMappingURL=wrong-map.json`;', + 'const regexDecoy = /[//# sourceMappingURL=wrong-map.json]/;', + ].join('\n')); + writeJson('/right.map', { + version: 3, + sources: ['lexically-selected.js'], + names: [], + mappings: 'AAAA', + }); + require(lexicalDirective); + assert( + module.findSourceMap(lexicalDirective).findEntry(0, 0).originalSource + .endsWith('/lexically-selected.js'), + 'source map directives inside literals are ignored', + ); + const customExtension = '/source-map-custom-extension.probe'; const customMap = '/custom-extension.map'; fs.writeFileSync(customExtension, 'not JavaScript'); @@ -108,6 +128,6 @@ export function testSourceMapApi() { console.log(e && e.stack ? e.stack : String(e)); return false; } finally { - process.execArgv = originalExecArgv; + module.setSourceMapsSupport(false); } } 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 c6f7a1cb6..941bb6ed9 100644 --- a/examples/runtime/typescript-transform-runtime/src/typescript-transform-runtime.js +++ b/examples/runtime/typescript-transform-runtime/src/typescript-transform-runtime.js @@ -306,7 +306,7 @@ export async function run() { } catch (error) { rewrittenCjsRuntimeStack = error.stack; } - process.execArgv.push('--no-enable-source-maps'); + module.setSourceMapsSupport(false); fs.writeFileSync( '/typescript-transform-runtime/stack-disabled-sites.mts', `import { getCallSites } from 'node:util'; @@ -334,8 +334,37 @@ export async function run() { '/typescript-transform-runtime/stack-disabled-sites.mts' )).captureDisabledSite(); } finally { - process.execArgv.pop(); + module.setSourceMapsSupport(true); } + const reclaimableSourceMapPaths = []; + for (let i = 0; i < 96; i++) { + const filename = `/typescript-transform-runtime/gc-map-${i}.cts`; + reclaimableSourceMapPaths.push(filename); + fs.writeFileSync( + filename, + `enum Marker { Value } exports.value = Marker.Value;`, + ); + require(filename); + const resolved = require.resolve(filename); + const loadedModule = require.cache[resolved]; + delete require.cache[resolved]; + if (loadedModule && loadedModule.parent && Array.isArray(loadedModule.parent.children)) { + const index = loadedModule.parent.children.indexOf(loadedModule); + if (index !== -1) loadedModule.parent.children.splice(index, 1); + } + } + if (typeof gc !== 'function') throw new Error('gc test hook is unavailable'); + gc(); + await new Promise((resolve) => setTimeout(resolve, 0)); + gc(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const retainedCjsSourceMaps = reclaimableSourceMapPaths.filter( + (filename) => module.findSourceMap(filename) !== undefined, + ).length; + // WeakRef targets observed by the periodic sweep remain alive through the + // current QuickJS job. The sweep batch therefore defines the strict upper + // bound until the next exported call/job boundary. + const cjsSourceMapsReclaimed = retainedCjsSourceMaps <= 32; fs.writeFileSync( '/typescript-transform-runtime/stack-entry.mts', `enum StackShift { Value } @@ -404,5 +433,7 @@ export async function run() { callSites, disabledCallSite, reexportPreparedRuntimeStack, + cjsSourceMapsReclaimed, + retainedCjsSourceMaps, }); } diff --git a/examples/runtime/v8_stack_trace/src/v8_stack_trace.js b/examples/runtime/v8_stack_trace/src/v8_stack_trace.js index e9c48cb3b..9489501a2 100644 --- a/examples/runtime/v8_stack_trace/src/v8_stack_trace.js +++ b/examples/runtime/v8_stack_trace/src/v8_stack_trace.js @@ -117,6 +117,30 @@ export const testCallSiteMethods = () => { } }; +export const testLatePrepareStackTrace = () => { + const originalPrepare = Error.prepareStackTrace; + try { + Error.prepareStackTrace = undefined; + const lateTarget = {}; + Error.captureStackTrace(lateTarget); + Error.prepareStackTrace = () => 'late-prepare'; + assert.strictEqual(lateTarget.stack, 'late-prepare'); + + Error.prepareStackTrace = () => 'capture-time-prepare'; + const removedTarget = {}; + Error.captureStackTrace(removedTarget); + Error.prepareStackTrace = undefined; + assert.strictEqual(typeof removedTarget.stack, 'string'); + assert.notStrictEqual(removedTarget.stack, 'capture-time-prepare'); + return true; + } catch (e) { + console.error('testLatePrepareStackTrace FAIL:', e.message); + return false; + } finally { + Error.prepareStackTrace = originalPrepare; + } +}; + // Test 5: constructorOpt parameter strips frames export const testConstructorOpt = () => { try { diff --git a/examples/runtime/v8_stack_trace/wit/v8_stack_trace.wit b/examples/runtime/v8_stack_trace/wit/v8_stack_trace.wit index 49f9fcbed..9404d0c74 100644 --- a/examples/runtime/v8_stack_trace/wit/v8_stack_trace.wit +++ b/examples/runtime/v8_stack_trace/wit/v8_stack_trace.wit @@ -5,6 +5,7 @@ world v8-stack-trace { export test-capture-stack-trace-basic: func() -> bool; export test-prepare-stack-trace: func() -> bool; export test-call-site-methods: func() -> bool; + export test-late-prepare-stack-trace: func() -> bool; export test-constructor-opt: func() -> bool; export test-stack-trace-limit: func() -> bool; export test-depd-pattern: func() -> bool; diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index ead8bddca..ca4ac7504 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -8276,7 +8276,7 @@ "block_00_it_should_throw_with_invalid_args": { "category": "runnable" }, "block_01_findsourcemap_should_return_undefined_when_no_source_map_is_": { "category": "runnable" }, "block_02_non_exceptional_case": { "category": "runnable" }, - "block_03_source_map_attached_to_error": { "category": "engine-difference", "reason": "QuickJS custom Error.prepareStackTrace does not expose the throwing CommonJS CallSite" }, + "block_03_source_map_attached_to_error": { "category": "runnable" }, "block_04_sourcemap_can_be_instantiated_with_source_map_v3_object_as_p": { "category": "runnable" }, "block_05_error_when_receiving_a_malformed_mappings": { "category": "runnable" }, "block_06_sourcemap_can_be_instantiated_with_index_source_map_v3_objec": { "category": "runnable" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 710992473..6ceece384 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,21 +8,21 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3174/4387 (72.4%) +**Primary compatibility (CI-enforced):** 3175/4388 (72.4%) When comparing revisions, read the runnable count and secondary full-public percentage alongside the primary percentage. Reclassifying a test into an excluded category can increase the primary percentage without increasing runnable coverage. | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3174 | 72.4% | 55.2% | 46.2% | +| ✅ passing (runnable) | 3175 | 72.4% | 55.2% | 46.2% | | 🧩 known gap | 1213 | 27.6% | 21.1% | 17.6% | | 🚫 WASI-impossible (excluded) | 1195 | — | 20.8% | 17.4% | -| ⚙️ engine difference (excluded) | 168 | — | 2.9% | 2.4% | +| ⚙️ engine difference (excluded) | 167 | — | 2.9% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1123 | — | — | 16.3% | | **Total** | **6873** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3174/5750 (55.2%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3175/5750 (55.2%)**. ## Inventory by Module @@ -59,7 +59,7 @@ Secondary full-public compatibility, including public tests that are currently e | net | 223 | 148 | 38 | 19 | 1 | 0 | 17 | 79.6% | 71.8% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| other | 614 | 187 | 145 | 86 | 13 | 0 | 183 | 56.3% | 43.4% | +| other | 614 | 188 | 145 | 86 | 12 | 0 | 183 | 56.5% | 43.6% | | path | 16 | 16 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | | perf_hooks | 41 | 3 | 34 | 2 | 0 | 0 | 2 | 8.1% | 7.7% | | permission | 55 | 4 | 38 | 9 | 2 | 0 | 2 | 9.5% | 7.5% | @@ -474,7 +474,7 @@ Secondary full-public compatibility, including public tests that are currently e | `test-snapshot-typescript.js` | 2 | 0 | 0 | 0 | 2 | 0 | 0 | | `test-snapshot-umd.js` | 2 | 0 | 0 | 0 | 2 | 0 | 0 | | `test-snapshot-warning.js` | 3 | 0 | 0 | 0 | 3 | 0 | 0 | -| `test-source-map-api.js` | 9 | 8 | 0 | 0 | 1 | 0 | 0 | +| `test-source-map-api.js` | 9 | 9 | 0 | 0 | 0 | 0 | 0 | | `test-source-map-enable.js` | 23 | 23 | 0 | 0 | 0 | 0 | 0 | | `test-sqlite-database-sync.js` | 5 | 5 | 0 | 0 | 0 | 0 | 0 | | `test-sqlite-session.js` | 14 | 13 | 1 | 0 | 0 | 0 | 0 | @@ -1543,7 +1543,7 @@ Secondary full-public compatibility, including public tests that are currently e | wasi:http does not expose custom HTTP reason phrases (status messages) | 1 | `parallel/test-http-response-status-message.js` | | wasi:http normalizes response header names, so raw header case preservation assertions cannot be satisfied | 1 | `parallel/test-http-write-head.js` | -### engine difference (168) +### engine difference (167) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -1569,7 +1569,6 @@ Secondary full-public compatibility, including public tests that are currently e | v8.writeHeapSnapshot is a V8-specific API and is unavailable in QuickJS | 2 | `parallel/test-permission-fs-write-v8.js#block_00_block_00`, `parallel/test-permission-fs-write-v8.js#block_01_block_01` | | GC observability used by common/gc.onGC is not available in the QuickJS/WASM runtime | 1 | `parallel/test-net-connect-memleak.js` | | QuickJS await/promise-hook semantics differ from V8, so AsyncLocalStorage runStores context is lost across await boundaries | 1 | `parallel/test-diagnostics-channel-tracing-channel-promise-run-stores.js` | -| QuickJS custom Error.prepareStackTrace does not expose the throwing CommonJS CallSite | 1 | `parallel/test-source-map-api.js#block_03_source_map_attached_to_error` | | QuickJS private-field TypeError message text differs from V8 | 1 | `parallel/test-runner-mocking.js#test_21_mocks_a_constructor` | | SourceTextModule cachedData depends on V8 code cache internals unavailable in QuickJS | 1 | `parallel/test-vm-module-cached-data.js` | | asserts V8-specific syntax error stderr text/format that differs in QuickJS | 1 | `es-module/test-require-module-errors.js` | diff --git a/tests/runtime/typescript_runtime.rs b/tests/runtime/typescript_runtime.rs index 6769f84bf..3d4217e3d 100644 --- a/tests/runtime/typescript_runtime.rs +++ b/tests/runtime/typescript_runtime.rs @@ -403,9 +403,14 @@ async fn typescript_transform_runtime_is_immutable( assert!(disabled_stack.contains("stack-disabled.mts:")); assert!( !disabled_stack.contains("stack-disabled.mts:3:"), - "--no-enable-source-maps unexpectedly remapped the stack: {disabled_stack}" + "disabled source-map support unexpectedly remapped the stack: {disabled_stack}" ); assert_eq!(report["errorConstructorsStable"], true); + assert_eq!( + report["cjsSourceMapsReclaimed"], true, + "CJS source maps were retained after their modules were reclaimed: retained={}", + report["retainedCjsSourceMaps"] + ); assert_eq!( report["errorConstructorMetadata"], serde_json::json!([ diff --git a/tests/runtime/v8_stack_trace.rs b/tests/runtime/v8_stack_trace.rs index a1567fe3f..aa13004ed 100644 --- a/tests/runtime/v8_stack_trace.rs +++ b/tests/runtime/v8_stack_trace.rs @@ -79,6 +79,23 @@ async fn v8_stack_trace_call_site_methods( Ok(()) } +#[test] +async fn v8_stack_trace_late_prepare( + #[tagged_as("v8_stack_trace")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-late-prepare-stack-trace", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + #[test] async fn v8_stack_trace_constructor_opt( #[tagged_as("v8_stack_trace")] compiled_test: &CompiledTest, From a2881800740e3a277380ed708ab24ed124ccabd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Thu, 3 Sep 2026 10:17:32 +0200 Subject: [PATCH 06/13] Normalize source map startup state (GOL-419) --- crates/wasm-rquickjs/skeleton/src/builtin/module.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 693d12290..eed9a0b88 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -1209,11 +1209,12 @@ function rustHasExecArgvFlag(flag) { return wasmRquickjsModuleGlobalThis.__wasm_rquickjs_module_has_exec_argv_flag(flag); } -let sourceMapsSupportEnabled = +let sourceMapsSupportEnabled = Boolean( (processModule.features && processModule.features.typescript === 'transform') || (wasmRquickjsModuleGlobalThis.process && wasmRquickjsModuleGlobalThis.process.features && - wasmRquickjsModuleGlobalThis.process.features.typescript === 'transform'); + wasmRquickjsModuleGlobalThis.process.features.typescript === 'transform') +); const sourceMapsSupportDefault = sourceMapsSupportEnabled; function configureSourceMapsFromStartupArgs(args) { From 34bde87d299b8af8536b8ac68a9e06c6ec161860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Fri, 4 Sep 2026 08:52:23 +0200 Subject: [PATCH 07/13] Regenerate v8 stack trace declarations (GOL-419) --- tests/goldenfiles/generated_types_v8_stack_trace_exports.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/goldenfiles/generated_types_v8_stack_trace_exports.d.ts b/tests/goldenfiles/generated_types_v8_stack_trace_exports.d.ts index fd0f6df05..ccab74e91 100644 --- a/tests/goldenfiles/generated_types_v8_stack_trace_exports.d.ts +++ b/tests/goldenfiles/generated_types_v8_stack_trace_exports.d.ts @@ -3,6 +3,7 @@ declare module 'v8-stack-trace' { export function testCaptureStackTraceBasic(): Promise; export function testPrepareStackTrace(): Promise; export function testCallSiteMethods(): Promise; + export function testLatePrepareStackTrace(): Promise; export function testConstructorOpt(): Promise; export function testStackTraceLimit(): Promise; export function testDepdPattern(): Promise; From b28990c9433f45d4780e4affe6060dd03d7609c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Fri, 4 Sep 2026 10:53:01 +0200 Subject: [PATCH 08/13] Fix source map runtime fidelity (GOL-419) --- .../skeleton/src/builtin/internal/errors.js | 49 ++++++++++++---- .../src/builtin/internal/source_map_url.js | 28 +++------ .../skeleton/src/builtin/module.js | 11 ++-- .../src/module-resolution.js | 2 + examples/runtime/source-map/src/source-map.js | 58 ++++++++++++++++++- 5 files changed, 108 insertions(+), 40 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js b/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js index c8fee0e6b..b2247f4f3 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js @@ -18,6 +18,10 @@ function _isInternalErrorFrame(fileName) { return fileName === '__wasm_rquickjs_builtin/internal/errors'; } +function _isInternalErrorStackLine(line) { + return /^\s*at construct \(native\)\s*$/.test(line); +} + function _remapSourceMappedLocation(fileName, lineNumber, columnNumber) { const mapper = globalThis.__wasm_rquickjs_remap_source_mapped_position; if (typeof mapper !== 'function') return undefined; @@ -34,6 +38,11 @@ function _remapSourceMappedStack(stackString) { const lines = stackString.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; + if (_isInternalErrorStackLine(line)) { + lines.splice(i, 1); + i -= 1; + continue; + } let match = line.match(_callSiteWithFnPattern); let fileName; let lineNumber; @@ -138,11 +147,23 @@ function _dataDesc(value) { // Node (i.e. `delete err.stack` makes subsequent `err.stack` reads undefined). const nativeErrorStackDescriptor = Object.getOwnPropertyDescriptor(Error.prototype, "stack"); const NativeError = Error; +const nativeErrorCaptureStackTrace = NativeError.captureStackTrace; const nativeErrorToString = Error.prototype.toString; const materializedErrorStacks = new WeakSet(); let errorStackShimInstalled = false; let errorSubclassShimsInstalled = false; +function recaptureNativeErrorStack(errorInstance, constructorOpt) { + if (typeof nativeErrorCaptureStackTrace !== 'function') return; + const nativePrepare = NativeError.prepareStackTrace; + NativeError.prepareStackTrace = undefined; + try { + nativeErrorCaptureStackTrace(errorInstance, constructorOpt); + } finally { + NativeError.prepareStackTrace = nativePrepare; + } +} + function materializeOwnStack(errorInstance) { if (!errorInstance || (typeof errorInstance !== "object" && typeof errorInstance !== "function")) { return; @@ -211,6 +232,7 @@ function installErrorStackShim() { const ErrorShim = function Error() { const ctorTarget = new.target || ErrorShim; const errorInstance = Reflect.construct(NativeError, arguments, NativeError); + recaptureNativeErrorStack(errorInstance, ctorTarget); materializeOwnStack(errorInstance); // Error.prepareStackTrace support (V8 compat). @@ -306,6 +328,7 @@ function installNativeErrorSubclassShim(name) { const Shim = function(...args) { const ctorTarget = new.target || Shim; const errorInstance = Reflect.construct(NativeConstructor, args, NativeConstructor); + recaptureNativeErrorStack(errorInstance, ctorTarget); materializeOwnStack(errorInstance); const rawStack = errorInstance.stack; Object.defineProperty(errorInstance, 'stack', { @@ -373,12 +396,14 @@ function installNativeErrorSubclassShims() { errorSubclassShimsInstalled = true; } -if (nativeErrorStackDescriptor && nativeErrorStackDescriptor.configurable === false) { - try { - installErrorStackShim(); - } catch { - // Keep the runtime default behavior if shimming fails. - } +try { + // Install before guest code can retain a constructor reference. Source-map + // support can be enabled later, and enabling it must not replace any of the + // public Error constructors at an observable boundary. + installErrorStackShim(); + installNativeErrorSubclassShims(); +} catch { + // Keep the runtime default behavior if shimming fails. } Object.defineProperty(globalThis, '__wasm_rquickjs_install_source_map_error_stack_shim', { @@ -408,8 +433,7 @@ Object.defineProperty(globalThis, '__wasm_rquickjs_install_source_map_error_stac // libraries like depd that set Error.prepareStackTrace and then call // Error.captureStackTrace get proper CallSite objects. { - const nativeCaptureStackTrace = globalThis.Error.captureStackTrace; - if (typeof nativeCaptureStackTrace === 'function') { + if (typeof nativeErrorCaptureStackTrace === 'function') { function captureRawStackTrace(targetObject, constructorOpt) { // QuickJS stores its native prepare hook in the context behind the // original Error constructor. The public ErrorShim deliberately @@ -418,7 +442,7 @@ Object.defineProperty(globalThis, '__wasm_rquickjs_install_source_map_error_stac const nativePrepare = NativeError.prepareStackTrace; NativeError.prepareStackTrace = undefined; try { - nativeCaptureStackTrace(targetObject, constructorOpt); + nativeErrorCaptureStackTrace(targetObject, constructorOpt); return targetObject.stack; } finally { NativeError.prepareStackTrace = nativePrepare; @@ -432,12 +456,15 @@ Object.defineProperty(globalThis, '__wasm_rquickjs_install_source_map_error_stac // captureStackTrace() returns. const rawStack = captureRawStackTrace(targetObject, constructorOpt); if (typeof rawStack === 'string') { - const callSites = _parseStackStringToCallSites(rawStack); + let callSites; Object.defineProperty(targetObject, "stack", { get() { const prepare = globalThis.Error && globalThis.Error.prepareStackTrace; const result = typeof prepare === "function" - ? prepare(targetObject, callSites) + ? prepare( + targetObject, + callSites ??= _parseStackStringToCallSites(rawStack), + ) : _remapSourceMappedStack(rawStack); Object.defineProperty(targetObject, "stack", _dataDesc(result)); return result; diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/source_map_url.js b/crates/wasm-rquickjs/skeleton/src/builtin/internal/source_map_url.js index dc81eb863..eace7c103 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/internal/source_map_url.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/source_map_url.js @@ -285,9 +285,8 @@ function findTemplateExpressionEnd(source, start) { return -1; } -function sourceMapURLFromComment(comment, blockComment) { - const prefixLength = blockComment ? 3 : 3; - let index = prefixLength; +function sourceMapURLFromComment(comment) { + let index = 3; const separator = comment.charCodeAt(index); if ( separator !== 0x09 && @@ -299,14 +298,16 @@ function sourceMapURLFromComment(comment, blockComment) { return undefined; index++; if (!comment.startsWith("sourceMappingURL=", index)) return undefined; - const value = comment.slice(index + 17, blockComment ? -2 : undefined).trim(); - return value || undefined; + const valueStart = index + 17; + let valueEnd = valueStart; + while (valueEnd < comment.length && !/\s/.test(comment[valueEnd])) valueEnd++; + if (valueEnd === valueStart || comment.slice(valueEnd).trim() !== "") return undefined; + return comment.slice(valueStart, valueEnd); } -export function extractSourceMapURL(code, options = undefined) { +export function extractSourceMapURL(code) { const source = String(code); if (source.indexOf("sourceMappingURL=") === -1) return undefined; - const allowBlockComments = options && options.blockComments === true; let result; function scan(start, end) { @@ -359,10 +360,7 @@ export function extractSourceMapURL(code, options = undefined) { lineEnd++; const marker = source.charCodeAt(index + 2); if (marker === 0x23 || marker === 0x40) { - const value = sourceMapURLFromComment( - source.slice(index, lineEnd), - false, - ); + const value = sourceMapURLFromComment(source.slice(index, lineEnd)); if (value !== undefined) result = value; } index = lineEnd; @@ -371,14 +369,6 @@ export function extractSourceMapURL(code, options = undefined) { if (next === 0x2a) { const close = source.indexOf("*/", index + 2); const blockEnd = close === -1 ? end : Math.min(close + 2, end); - const marker = source.charCodeAt(index + 2); - if (allowBlockComments && (marker === 0x23 || marker === 0x40)) { - const value = sourceMapURLFromComment( - source.slice(index, blockEnd), - true, - ); - if (value !== undefined) result = value; - } index = blockEnd; continue; } diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index eed9a0b88..2b95d48ea 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -1582,11 +1582,9 @@ function findSourceMap(path) { } function sourceMapLineLengths(source) { - const lines = String(source).split(/\r\n|[\n\r\u2028\u2029]/); - while (lines.length > 0 && /^\s*\/\/[#@]\s*sourceMappingURL=/.test(lines[lines.length - 1])) { - lines.pop(); - } - return lines.map(line => line.length); + return String(source) + .split(/\r\n|[\n\r\u2028\u2029]/) + .map(line => line.length); } function decodeInlineSourceMap(url) { @@ -1661,7 +1659,7 @@ function registerSourceMapForCjs(filename, source, moduleObject, options = undef } const sourceText = String(source); - const url = extractSourceMapURL(sourceText, { blockComments: true }); + const url = extractSourceMapURL(sourceText); if (url === undefined) { delete registry[filename]; return; @@ -3237,7 +3235,6 @@ function loadCommonJsTransaction(descriptor) { source = preparedTypeScript ? preparedTypeScript.originalSource : fsModule.readFileSync(filename, 'utf8'); - registerSourceMapForCjs(filename, source, mod); } catch (err) { discardCjsModuleLoad(cacheKey, parentModule, mod); throw err; diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index 8fef88392..e866670dc 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -7247,6 +7247,8 @@ export const testVmMainContextDefaultLoader = async () => { assert.strictEqual(new vm.Script('/[//# sourceMappingURL=regex.map]/;').sourceMapURL, undefined); assert.strictEqual(new vm.Script('/*\n//# sourceMappingURL=inside-block.map\n*/').sourceMapURL, undefined); assert.strictEqual(new vm.Script('1 + 1\n/*# sourceMappingURL=block.map */').sourceMapURL, undefined); + assert.strictEqual(new vm.Script('1 + 1\n//# sourceMappingURL=bad.map trailing').sourceMapURL, undefined); + assert.strictEqual(new vm.Script('1 + 1\n//# sourceMappingURL=good.map \t').sourceMapURL, 'good.map'); assert.strictEqual(new vm.Script('1 + 1\n//# sourceMappingURL=script.map').sourceMapURL, 'script.map'); assert.strictEqual(new vm.Script('1;\n//# sourceMappingURL=semi.map').sourceMapURL, 'semi.map'); assert.strictEqual(new vm.Script('1 + 1\n//#\tsourceMappingURL=tab.map').sourceMapURL, 'tab.map'); diff --git a/examples/runtime/source-map/src/source-map.js b/examples/runtime/source-map/src/source-map.js index 7bbd0b326..60c0999bb 100644 --- a/examples/runtime/source-map/src/source-map.js +++ b/examples/runtime/source-map/src/source-map.js @@ -15,6 +15,26 @@ function writeJson(path, value) { export function testSourceMapApi() { try { + const errorConstructorNames = [ + 'Error', + 'TypeError', + 'RangeError', + 'ReferenceError', + 'SyntaxError', + 'EvalError', + 'URIError', + 'AggregateError', + ]; + const errorConstructorsBefore = errorConstructorNames.map(name => { + const Constructor = globalThis[name]; + return { + Constructor, + prototype: Constructor.prototype, + prototypeConstructor: Constructor.prototype.constructor, + name: Constructor.name, + length: Constructor.length, + }; + }); module.setSourceMapsSupport(true); const previousLine = new module.SourceMap({ @@ -37,6 +57,31 @@ export function testSourceMapApi() { const expectedLineLengths = lineLengthSource.split('\n').map(line => line.length).join(','); assert(module.findSourceMap(withTrailingNewline).lineLengths.join(',') === expectedLineLengths, 'line lengths'); + const withoutTrailingNewline = '/source-map-line-lengths-no-trailing-newline.cjs'; + const noTrailingNewlineSource = 'module.exports = 1;\n//# sourceMappingURL=line-lengths.map'; + fs.writeFileSync(withoutTrailingNewline, noTrailingNewlineSource); + require(withoutTrailingNewline); + const expectedNoTrailingNewlineLengths = noTrailingNewlineSource + .split('\n') + .map(line => line.length) + .join(','); + assert( + module.findSourceMap(withoutTrailingNewline).lineLengths.join(',') === + expectedNoTrailingNewlineLengths, + 'line lengths retain a final directive without a trailing newline', + ); + + const errorConstructorsStable = errorConstructorNames.every((name, index) => { + const before = errorConstructorsBefore[index]; + const Constructor = globalThis[name]; + return Constructor === before.Constructor && + Constructor.prototype === before.prototype && + Constructor.prototype.constructor === before.prototypeConstructor && + Constructor.name === before.name && + Constructor.length === before.length; + }); + assert(errorConstructorsStable, 'enabling source maps preserves Error constructor identity and metadata'); + const rawOffsets = '/source-map-raw-offsets.cjs'; fs.writeFileSync(rawOffsets, '\n\n\n\n\n\n\nmodule.exports = 1;\n//# sourceMappingURL=raw-offsets.map\n'); writeJson('/raw-offsets.map', { @@ -75,15 +120,16 @@ export function testSourceMapApi() { mappings: 'AAAA', }); require(blockDirective); - assert(module.findSourceMap(blockDirective).findEntry(0, 0).originalSource.endsWith('/right.js'), 'last block directive wins'); + assert(module.findSourceMap(blockDirective).findEntry(0, 0).originalSource.endsWith('/wrong.js'), 'block directives are ignored'); const lexicalDirective = '/source-map-lexical-directive.cjs'; fs.writeFileSync(lexicalDirective, [ 'module.exports = 1;', - '//# sourceMappingURL=right.map', + '//@ sourceMappingURL=right.map \t', 'const stringDecoy = "//# sourceMappingURL=wrong-map.json";', 'const templateDecoy = `//# sourceMappingURL=wrong-map.json`;', 'const regexDecoy = /[//# sourceMappingURL=wrong-map.json]/;', + '//# sourceMappingURL=wrong-map.json trailing-garbage', ].join('\n')); writeJson('/right.map', { version: 3, @@ -91,11 +137,17 @@ export function testSourceMapApi() { names: [], mappings: 'AAAA', }); + writeJson('/wrong-map.json', { + version: 3, + sources: ['textually-selected.js'], + names: [], + mappings: 'AAAA', + }); require(lexicalDirective); assert( module.findSourceMap(lexicalDirective).findEntry(0, 0).originalSource .endsWith('/lexically-selected.js'), - 'source map directives inside literals are ignored', + 'only the last valid lexical line directive wins', ); const customExtension = '/source-map-custom-extension.probe'; From 62fa2891a59a071c970d01948024bd79fac5ebd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Fri, 4 Sep 2026 15:00:16 +0200 Subject: [PATCH 09/13] Match Node inline source map output (GOL-419) --- .../skeleton/src/builtin/module.js | 9 ++++---- .../skeleton/src/internal/typescript.rs | 2 +- .../src/typescript-transform-runtime.js | 22 ------------------- tests/runtime/typescript_runtime.rs | 18 +++------------ 4 files changed, 9 insertions(+), 42 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/module.js b/crates/wasm-rquickjs/skeleton/src/builtin/module.js index 2b95d48ea..c50979bb1 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/module.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/module.js @@ -1851,7 +1851,7 @@ function appendInlineSourceMap(code, sourceMap) { // decodes maps emitted through both the Rust ESM and JavaScript CJS/public // transformation paths and asserts their original coordinates. const encoded = buffer.Buffer.from(sourceMap, 'utf8').toString('base64'); - return code + `\n//# sourceMappingURL=data:application/json;base64,${encoded}`; + return code + `\n\n//# sourceMappingURL=data:application/json;base64,${encoded}`; } function codeWithInlineSourceMap(output) { @@ -1900,9 +1900,10 @@ export function stripTypeScriptTypes(code, options = undefined) { const transformed = JSON.parse(transformTypeScriptNative( code, sourceUrl === undefined ? '' : sourceUrl, mode, sourceMap, undefined )); - let result = sourceMap - ? appendInlineSourceMap(transformed.code, transformed.sourceMap) - : transformed.code; + if (sourceMap) { + return appendInlineSourceMap(transformed.code, transformed.sourceMap); + } + let result = transformed.code; if (sourceUrl !== undefined) { result += `\n\n//# sourceURL=${sourceUrl}`; } diff --git a/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs b/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs index 344450876..af2759933 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/typescript.rs @@ -256,7 +256,7 @@ impl TypeScriptOutput { // verify both the Rust ESM and JavaScript CJS/public paths. let encoded = base64ct::Base64::encode_string(source_map.as_bytes()); format!( - "{}\n//# sourceMappingURL=data:application/json;base64,{encoded}", + "{}\n\n//# sourceMappingURL=data:application/json;base64,{encoded}", self.code ) } 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 941bb6ed9..2da4d2673 100644 --- a/examples/runtime/typescript-transform-runtime/src/typescript-transform-runtime.js +++ b/examples/runtime/typescript-transform-runtime/src/typescript-transform-runtime.js @@ -98,27 +98,6 @@ export async function run() { language: 'typescript', source: largeSource, }); - const measuredSource = `enum Direction { Up, Down } - export default Direction.Down; - /*${'x'.repeat(64 * 1024)}*/`; - function measureTransform(sourceMap) { - const samples = []; - for (let i = 0; i < 6; i++) { - const started = performance.now(); - module.stripTypeScriptTypes(measuredSource, { - mode: 'transform', - sourceMap, - sourceUrl: 'measured.ts', - }); - if (i > 0) samples.push(performance.now() - started); - } - samples.sort((left, right) => left - right); - return samples[2]; - } - const transformLatencyMs = { - withoutSourceMap: measureTransform(false), - withSourceMap: measureTransform(true), - }; fs.writeFileSync( '/typescript-transform-runtime/stack-esm.mts', `enum StackShift { Value } @@ -406,7 +385,6 @@ export async function run() { commonJsNodeModulesTypeScriptErrorName, executionInline: executionInline.value, largeInlineExecution: largeInlineExecution.value, - transformLatencyMs, esmRuntimeStack, cjsRuntimeStack, importedCjsRuntimeStack, diff --git a/tests/runtime/typescript_runtime.rs b/tests/runtime/typescript_runtime.rs index 3d4217e3d..a684478eb 100644 --- a/tests/runtime/typescript_runtime.rs +++ b/tests/runtime/typescript_runtime.rs @@ -36,12 +36,9 @@ async fn strip_typescript_types_matches_node_contract( }; let report: serde_json::Value = serde_json::from_str(&json)?; assert_eq!(report["stripped"], "const value = 1;"); - assert!( - report["transformed"] - .as_str() - .is_some_and(|output| output.contains("MathUtil") - && output.contains("sourceMappingURL=data:application/json;base64,") - && output.ends_with("//# sourceURL=input.ts")) + assert_eq!( + report["transformed"], + "(function(MathUtil) {\n MathUtil.add = (a, b)=>a + b;\n})(MathUtil || (MathUtil = {}));\nvar MathUtil;\n\n\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbImlucHV0LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJVQUNZO2FBQ0ssTUFBTSxDQUFDLEdBQVcsSUFBYyxJQUFJO0FBQ25ELEdBRlUsYUFBQSJ9" ); assert_eq!( report["sourceMap"], @@ -346,15 +343,6 @@ async fn typescript_transform_runtime_is_immutable( assert_eq!(report["commonJsNodeModulesTypeScriptErrorName"], "Error"); assert_eq!(report["executionInline"], 1); assert_eq!(report["largeInlineExecution"], 1); - for field in ["withoutSourceMap", "withSourceMap"] { - let latency = report["transformLatencyMs"][field] - .as_f64() - .unwrap_or_else(|| panic!("missing {field} transform latency")); - assert!( - latency <= 25.0, - "64 KiB {field} transform exceeded the GOL-417 bound: {latency:.3} ms" - ); - } for (field, file, line) in [ ("esmRuntimeStack", "stack-esm.mts", 3), ("cjsRuntimeStack", "stack-cjs.cts", 3), From 3f64e783b9221d2f0b6e88c81bd13fbd37b947df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Fri, 4 Sep 2026 16:37:25 +0200 Subject: [PATCH 10/13] Avoid QuickJS error backtrace traps (GOL-419) --- .../skeleton/src/builtin/internal/errors.js | 472 ++++++------------ .../v8_stack_trace/src/v8_stack_trace.js | 66 +++ .../v8_stack_trace/wit/v8_stack_trace.wit | 2 + ...enerated_types_v8_stack_trace_exports.d.ts | 2 + tests/runtime/v8_stack_trace.rs | 34 ++ 5 files changed, 252 insertions(+), 324 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js b/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js index b2247f4f3..5587fa67f 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js @@ -74,6 +74,67 @@ function _remapSourceMappedStack(stackString) { return lines.join('\n'); } +function _formatNativeCallSites(error, callSites) { + if (!Array.isArray(callSites)) return ''; + let header; + try { + header = nativeErrorToString.call(error); + } catch { + header = 'Error'; + } + const lines = [header]; + for (const callSite of callSites) { + let functionName; + let fileName; + let lineNumber; + let columnNumber; + try { + functionName = callSite.getFunctionName(); + fileName = callSite.getFileName(); + lineNumber = callSite.getLineNumber(); + columnNumber = callSite.getColumnNumber(); + } catch { + continue; + } + let text; + if (fileName) { + const location = `${fileName}:${lineNumber}:${columnNumber}`; + text = functionName ? `${functionName} (${location})` : location; + } else { + let isNative = false; + try { + isNative = callSite.isNative(); + } catch { + // Use the generic fallback below. + } + text = functionName || ''; + if (isNative) text += ' (native)'; + } + lines.push(` at ${text}`); + } + return lines.join('\n'); +} + +function _prepareSourceMappedStack(error, callSites) { + return _remapSourceMappedStack(_formatNativeCallSites(error, callSites)); +} + +const nativeCallSiteCaptures = new WeakMap(); + +function _captureNativeCallSites(error, callSites) { + if (error && (typeof error === 'object' || typeof error === 'function')) { + nativeCallSiteCaptures.set(error, callSites); + } + return ''; +} + +function _takeNativeCallSites(error) { + const callSites = nativeCallSiteCaptures.get(error); + if (!callSites) return undefined; + nativeCallSiteCaptures.delete(error); + return callSites; +} + Object.defineProperty(globalThis, '__wasm_rquickjs_remap_source_mapped_stack', { value: _remapSourceMappedStack, writable: false, @@ -109,25 +170,30 @@ function _makeCallSite(functionName, fileName, lineNumber, columnNumber) { }; } -function _parseStackStringToCallSites(stackString) { - if (typeof stackString !== 'string') return []; - const lines = stackString.split('\n'); - const sites = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - let m = line.match(_callSiteWithFnPattern); - if (m) { - if (_isInternalErrorFrame(m[2])) continue; - sites.push(_makeCallSite(m[1], m[2], parseInt(m[3], 10), parseInt(m[4], 10))); - continue; - } - m = line.match(_callSiteNoFnPattern); - if (m) { - if (_isInternalErrorFrame(m[1])) continue; - sites.push(_makeCallSite(null, m[1], parseInt(m[2], 10), parseInt(m[3], 10))); - } +function _toCompatibleCallSite(callSite) { + const requiredMethods = [ + 'getThis', 'getTypeName', 'getFunction', 'getFunctionName', + 'getMethodName', 'getFileName', 'getLineNumber', 'getColumnNumber', + 'getEvalOrigin', 'isToplevel', 'isEval', 'isNative', 'isConstructor', + 'isAsync', 'isPromiseAll', 'getPromiseIndex', 'getScriptNameOrSourceURL', + 'toString', + ]; + if (requiredMethods.every((name) => typeof callSite[name] === 'function')) { + return callSite; + } + let functionName; + let fileName; + let lineNumber; + let columnNumber; + try { + functionName = callSite.getFunctionName(); + fileName = callSite.getFileName(); + lineNumber = callSite.getLineNumber(); + columnNumber = callSite.getColumnNumber(); + } catch { + // Return a complete neutral CallSite when the native object is partial. } - return sites; + return _makeCallSite(functionName, fileName, lineNumber, columnNumber); } // Helper to create property descriptors immune to Object.prototype pollution. @@ -143,277 +209,47 @@ function _dataDesc(value) { return d; } -// Normalize `Error.prototype.stack` so deleting an instance stack works like -// Node (i.e. `delete err.stack` makes subsequent `err.stack` reads undefined). -const nativeErrorStackDescriptor = Object.getOwnPropertyDescriptor(Error.prototype, "stack"); const NativeError = Error; +const nativeErrorToString = NativeError.prototype.toString; +const nativeErrorPrepareStackTraceDescriptor = Object.getOwnPropertyDescriptor( + NativeError, + "prepareStackTrace", +); const nativeErrorCaptureStackTrace = NativeError.captureStackTrace; -const nativeErrorToString = Error.prototype.toString; -const materializedErrorStacks = new WeakSet(); -let errorStackShimInstalled = false; -let errorSubclassShimsInstalled = false; - -function recaptureNativeErrorStack(errorInstance, constructorOpt) { - if (typeof nativeErrorCaptureStackTrace !== 'function') return; - const nativePrepare = NativeError.prepareStackTrace; - NativeError.prepareStackTrace = undefined; - try { - nativeErrorCaptureStackTrace(errorInstance, constructorOpt); - } finally { - NativeError.prepareStackTrace = nativePrepare; - } -} - -function materializeOwnStack(errorInstance) { - if (!errorInstance || (typeof errorInstance !== "object" && typeof errorInstance !== "function")) { - return; - } - - const own = Object.getOwnPropertyDescriptor(errorInstance, "stack"); - if (own && Object.prototype.hasOwnProperty.call(own, "value") && own.configurable === true) { - materializedErrorStacks.add(errorInstance); - return; - } - - let stackValue; - try { - if (own && typeof own.get === "function") { - stackValue = own.get.call(errorInstance); - } else if (nativeErrorStackDescriptor && typeof nativeErrorStackDescriptor.get === "function") { - stackValue = nativeErrorStackDescriptor.get.call(errorInstance); - } else if (nativeErrorStackDescriptor && Object.prototype.hasOwnProperty.call(nativeErrorStackDescriptor, "value")) { - stackValue = nativeErrorStackDescriptor.value; - } - } catch { - stackValue = undefined; - } - - if (stackValue === undefined || stackValue === "") { - try { - stackValue = nativeErrorToString.call(errorInstance); - } catch { - stackValue = undefined; - } - } - - try { - Object.defineProperty(errorInstance, "stack", _dataDesc(_remapSourceMappedStack(stackValue))); - materializedErrorStacks.add(errorInstance); - } catch { - // Best effort only. - } -} - -function installErrorStackShim() { - if (errorStackShimInstalled) return; - const ErrorShimPrototype = Object.create(NativeError.prototype); - - Object.defineProperty(ErrorShimPrototype, "stack", { - configurable: true, - enumerable: false, - get() { - const own = Object.getOwnPropertyDescriptor(this, "stack"); - if (!own) { - return undefined; - } - if (Object.prototype.hasOwnProperty.call(own, "value")) { - return own.value; - } - if (typeof own.get === "function") { - return own.get.call(this); - } - return undefined; - }, - set(value) { - Object.defineProperty(this, "stack", _dataDesc(value)); - }, - }); - - const ErrorShim = function Error() { - const ctorTarget = new.target || ErrorShim; - const errorInstance = Reflect.construct(NativeError, arguments, NativeError); - recaptureNativeErrorStack(errorInstance, ctorTarget); - materializeOwnStack(errorInstance); - - // Error.prepareStackTrace support (V8 compat). - // Replace the materialized .stack data property with a lazy getter that - // checks Error.prepareStackTrace on first access, then materializes the - // result as a plain data property so subsequent reads have no overhead. - const rawStack = errorInstance.stack; - Object.defineProperty(errorInstance, "stack", { - get() { - const prepareStackTrace = globalThis.Error && globalThis.Error.prepareStackTrace; - const result = typeof prepareStackTrace === "function" - ? prepareStackTrace(errorInstance, _parseStackStringToCallSites(rawStack)) - : _remapSourceMappedStack(rawStack); - Object.defineProperty(errorInstance, "stack", _dataDesc(result)); - return result; - }, - set(value) { - Object.defineProperty(errorInstance, "stack", _dataDesc(value)); - }, - configurable: true, - enumerable: false, - }); - - const targetPrototype = (ctorTarget && ctorTarget.prototype) || ErrorShimPrototype; - if (Object.getPrototypeOf(errorInstance) !== targetPrototype) { - Object.setPrototypeOf(errorInstance, targetPrototype); - } - - return errorInstance; - }; - - Object.setPrototypeOf(ErrorShim, NativeError); - Object.defineProperty(ErrorShim, "name", { - value: NativeError.name, - configurable: true, - }); - Object.defineProperty(ErrorShim, "length", { - value: NativeError.length, - configurable: true, - }); - - // Keep the public V8 hook on the shim. QuickJS eagerly invokes the native - // Error hook while constructing, whereas Node invokes it when .stack is - // read. A separate slot lets us preserve Node's lazy behavior without - // mutating a user-defined property for every Error construction. - let prepareStackTraceValue = NativeError.prepareStackTrace; - NativeError.prepareStackTrace = undefined; - Object.defineProperty(ErrorShim, "prepareStackTrace", { - get() { return prepareStackTraceValue; }, - set(value) { prepareStackTraceValue = value; }, +const initialPublicPrepareStackTrace = NativeError.prepareStackTrace; + +function _dispatchPrepareStackTrace(error, callSites) { + const prepare = NativeError.prepareStackTrace; + if (typeof prepare === 'function') { + return prepare(error, callSites.map(_toCompatibleCallSite)); + } + return _prepareSourceMappedStack(error, callSites); +} + +if (nativeErrorPrepareStackTraceDescriptor && + typeof nativeErrorPrepareStackTraceDescriptor.set === 'function') { + // Keep QuickJS's eager native backtrace construction behind a stable hidden + // dispatcher. The public property remains an ordinary Node-shaped data + // property, so defineProperty(), delete, seal, and freeze need no proxy + // synchronization. Explicit captureStackTrace below supplies Node's lazy + // hook selection; ordinary Error construction retains QuickJS's eager + // prepare timing. + Object.defineProperty(NativeError, 'prepareStackTrace', { + value: initialPublicPrepareStackTrace, + writable: true, configurable: true, enumerable: false, }); - - const nativeStackTraceLimitDescriptor = Object.getOwnPropertyDescriptor( + nativeErrorPrepareStackTraceDescriptor.set.call( NativeError, - "stackTraceLimit", + _dispatchPrepareStackTrace, ); - if (nativeStackTraceLimitDescriptor) { - Object.defineProperty(ErrorShim, "stackTraceLimit", { - get() { return NativeError.stackTraceLimit; }, - set(value) { NativeError.stackTraceLimit = value; }, - configurable: nativeStackTraceLimitDescriptor.configurable, - enumerable: nativeStackTraceLimitDescriptor.enumerable, - }); - } - ErrorShim.prototype = ErrorShimPrototype; - Object.defineProperty(ErrorShimPrototype, "constructor", { - value: ErrorShim, - writable: true, - configurable: true, - enumerable: false, - }); - - Object.defineProperty(ErrorShim, Symbol.hasInstance, { - value(value) { - if (this !== ErrorShim) { - return Function.prototype[Symbol.hasInstance].call(this, value); - } - return value instanceof NativeError; - }, - configurable: true, - }); - - globalThis.Error = ErrorShim; - errorStackShimInstalled = true; -} - -function installNativeErrorSubclassShim(name) { - const NativeConstructor = globalThis[name]; - if (typeof NativeConstructor !== 'function') return; - const ShimPrototype = Object.create(NativeConstructor.prototype); - const Shim = function(...args) { - const ctorTarget = new.target || Shim; - const errorInstance = Reflect.construct(NativeConstructor, args, NativeConstructor); - recaptureNativeErrorStack(errorInstance, ctorTarget); - materializeOwnStack(errorInstance); - const rawStack = errorInstance.stack; - Object.defineProperty(errorInstance, 'stack', { - get() { - const prepareStackTrace = globalThis.Error && globalThis.Error.prepareStackTrace; - const result = typeof prepareStackTrace === 'function' - ? prepareStackTrace(errorInstance, _parseStackStringToCallSites(rawStack)) - : _remapSourceMappedStack(rawStack); - Object.defineProperty(errorInstance, 'stack', _dataDesc(result)); - return result; - }, - set(value) { - Object.defineProperty(errorInstance, 'stack', _dataDesc(value)); - }, - configurable: true, - enumerable: false, - }); - const targetPrototype = (ctorTarget && ctorTarget.prototype) || ShimPrototype; - if (Object.getPrototypeOf(errorInstance) !== targetPrototype) { - Object.setPrototypeOf(errorInstance, targetPrototype); - } - return errorInstance; - }; - Object.setPrototypeOf(Shim, globalThis.Error); - Object.defineProperty(Shim, 'name', { - value: NativeConstructor.name, - configurable: true, - }); - Object.defineProperty(Shim, 'length', { - value: NativeConstructor.length, - configurable: true, - }); - Shim.prototype = ShimPrototype; - Object.defineProperty(ShimPrototype, 'constructor', { - value: Shim, - writable: true, - configurable: true, - enumerable: false, - }); - Object.defineProperty(Shim, Symbol.hasInstance, { - value(value) { - if (this !== Shim) { - return Function.prototype[Symbol.hasInstance].call(this, value); - } - return value instanceof NativeConstructor; - }, - configurable: true, - }); - globalThis[name] = Shim; -} - -function installNativeErrorSubclassShims() { - if (errorSubclassShimsInstalled) return; - for (const name of [ - 'TypeError', - 'RangeError', - 'ReferenceError', - 'SyntaxError', - 'EvalError', - 'URIError', - 'AggregateError', - ]) { - installNativeErrorSubclassShim(name); - } - errorSubclassShimsInstalled = true; -} - -try { - // Install before guest code can retain a constructor reference. Source-map - // support can be enabled later, and enabling it must not replace any of the - // public Error constructors at an observable boundary. - installErrorStackShim(); - installNativeErrorSubclassShims(); -} catch { - // Keep the runtime default behavior if shimming fails. } Object.defineProperty(globalThis, '__wasm_rquickjs_install_source_map_error_stack_shim', { value() { - try { - installErrorStackShim(); - installNativeErrorSubclassShims(); - } catch { - // Keep the runtime default behavior if shimming fails. - } + // The dispatcher reads the source-map registry dynamically, so enabling + // source maps later does not replace a public constructor or property. }, writable: false, configurable: false, @@ -422,60 +258,48 @@ Object.defineProperty(globalThis, '__wasm_rquickjs_install_source_map_error_stac // --------------------------------------------------------------------------- // Global Error.captureStackTrace & Error.stackTraceLimit (V8 compat) // --------------------------------------------------------------------------- -// QuickJS natively provides Error.captureStackTrace, Error.prepareStackTrace, -// Error.stackTraceLimit, and native CallSite objects. However, the ErrorShim -// above replaces globalThis.Error, which can interfere with the native -// prepareStackTrace getter/setter chain when Error.captureStackTrace is called. -// -// We wrap the native captureStackTrace so that it checks the JS-level -// Error.prepareStackTrace (which may be set on the ErrorShim) and, if set, -// parses the raw stack string into our JS CallSite objects. This ensures -// libraries like depd that set Error.prepareStackTrace and then call -// Error.captureStackTrace get proper CallSite objects. +// QuickJS invokes prepareStackTrace while captureStackTrace runs. Node defers +// it until the first `.stack` read. Capture the native CallSites with an +// internal hook, then select the public hook lazily and normalize incomplete +// QuickJS CallSites to the V8-compatible shape expected by user code. { - if (typeof nativeErrorCaptureStackTrace === 'function') { - function captureRawStackTrace(targetObject, constructorOpt) { - // QuickJS stores its native prepare hook in the context behind the - // original Error constructor. The public ErrorShim deliberately - // keeps the Node-facing hook in a separate slot, so suppress the - // native slot directly without touching a user-defined descriptor. - const nativePrepare = NativeError.prepareStackTrace; - NativeError.prepareStackTrace = undefined; + if (typeof nativeErrorCaptureStackTrace === 'function' && + nativeErrorPrepareStackTraceDescriptor && + typeof nativeErrorPrepareStackTraceDescriptor.set === 'function') { + function captureCallSites(targetObject, constructorOpt) { + nativeErrorPrepareStackTraceDescriptor.set.call( + NativeError, + _captureNativeCallSites, + ); try { nativeErrorCaptureStackTrace(targetObject, constructorOpt); - return targetObject.stack; + return _takeNativeCallSites(targetObject) ?? []; } finally { - NativeError.prepareStackTrace = nativePrepare; + nativeErrorPrepareStackTraceDescriptor.set.call( + NativeError, + _dispatchPrepareStackTrace, + ); } } globalThis.Error.captureStackTrace = function captureStackTrace(targetObject, constructorOpt) { - // Always capture the native text without its hidden hook. The - // public hook is intentionally selected only when `.stack` is - // first read, matching V8 when prepareStackTrace changes after - // captureStackTrace() returns. - const rawStack = captureRawStackTrace(targetObject, constructorOpt); - if (typeof rawStack === 'string') { - let callSites; - Object.defineProperty(targetObject, "stack", { - get() { - const prepare = globalThis.Error && globalThis.Error.prepareStackTrace; - const result = typeof prepare === "function" - ? prepare( - targetObject, - callSites ??= _parseStackStringToCallSites(rawStack), - ) - : _remapSourceMappedStack(rawStack); - Object.defineProperty(targetObject, "stack", _dataDesc(result)); - return result; - }, - set(value) { - Object.defineProperty(targetObject, "stack", _dataDesc(value)); - }, - configurable: true, - enumerable: false, - }); - } + const callSites = captureCallSites(targetObject, constructorOpt) + .map(_toCompatibleCallSite); + Object.defineProperty(targetObject, "stack", { + get() { + const prepare = globalThis.Error && globalThis.Error.prepareStackTrace; + const result = typeof prepare === "function" + ? prepare(targetObject, callSites) + : _prepareSourceMappedStack(targetObject, callSites); + Object.defineProperty(targetObject, "stack", _dataDesc(result)); + return result; + }, + set(value) { + Object.defineProperty(targetObject, "stack", _dataDesc(value)); + }, + configurable: true, + enumerable: false, + }); }; } } diff --git a/examples/runtime/v8_stack_trace/src/v8_stack_trace.js b/examples/runtime/v8_stack_trace/src/v8_stack_trace.js index 9489501a2..0a0205ab2 100644 --- a/examples/runtime/v8_stack_trace/src/v8_stack_trace.js +++ b/examples/runtime/v8_stack_trace/src/v8_stack_trace.js @@ -141,6 +141,72 @@ export const testLatePrepareStackTrace = () => { } }; +export const testDefaultErrorStackHeaders = () => { + const originalPrepare = Error.prepareStackTrace; + try { + Error.prepareStackTrace = undefined; + assert.match(new Error('base').stack, /^Error: base(?:\n|$)/); + assert.match(new TypeError('native').stack, /^TypeError: native(?:\n|$)/); + assert.match(new Error().stack, /^Error(?:\n|$)/); + class CustomError extends Error {} + CustomError.prototype.name = 'CustomError'; + assert.match(new CustomError('custom').stack, /^CustomError: custom(?:\n|$)/); + class EmptyNameError extends Error {} + EmptyNameError.prototype.name = ''; + assert.match(new EmptyNameError('message-only').stack, /^message-only(?:\n|$)/); + const originalToString = Error.prototype.toString; + Error.prototype.toString = () => 'POISON'; + const protectedHeader = new Error('protected').stack; + Error.prototype.toString = originalToString; + assert.match(protectedHeader, /^Error: protected(?:\n|$)/); + return true; + } catch (e) { + console.error('testDefaultErrorStackHeaders FAIL:', e.message); + return false; + } finally { + Error.prepareStackTrace = originalPrepare; + } +}; + +export const testPrepareStackTraceDescriptors = () => { + try { + const customPrepare = () => 'custom-prepare'; + Error.prepareStackTrace = customPrepare; + Object.defineProperty(Error, 'prepareStackTrace', { enumerable: false }); + assert.strictEqual(Error.prepareStackTrace, customPrepare); + assert.strictEqual(new Error('partial-descriptor').stack, 'custom-prepare'); + + let getterCalls = 0; + Object.defineProperty(Error, 'prepareStackTrace', { + get() { + getterCalls++; + return customPrepare; + }, + configurable: true, + }); + assert.strictEqual(getterCalls, 0); + assert.strictEqual(new TypeError('accessor').stack, 'custom-prepare'); + + delete Error.prepareStackTrace; + assert.match(new Error('deleted').stack, /^Error: deleted(?:\n|$)/); + + Object.defineProperty(Error, 'prepareStackTrace', { + value: customPrepare, + writable: true, + configurable: true, + }); + Object.freeze(Error); + const descriptor = Object.getOwnPropertyDescriptor(Error, 'prepareStackTrace'); + assert.strictEqual(descriptor.writable, false); + assert.strictEqual(descriptor.configurable, false); + assert.strictEqual(new Error('frozen').stack, 'custom-prepare'); + return true; + } catch (e) { + console.error('testPrepareStackTraceDescriptors FAIL:', e.message); + return false; + } +}; + // Test 5: constructorOpt parameter strips frames export const testConstructorOpt = () => { try { diff --git a/examples/runtime/v8_stack_trace/wit/v8_stack_trace.wit b/examples/runtime/v8_stack_trace/wit/v8_stack_trace.wit index 9404d0c74..7548a5275 100644 --- a/examples/runtime/v8_stack_trace/wit/v8_stack_trace.wit +++ b/examples/runtime/v8_stack_trace/wit/v8_stack_trace.wit @@ -6,6 +6,8 @@ world v8-stack-trace { export test-prepare-stack-trace: func() -> bool; export test-call-site-methods: func() -> bool; export test-late-prepare-stack-trace: func() -> bool; + export test-default-error-stack-headers: func() -> bool; + export test-prepare-stack-trace-descriptors: func() -> bool; export test-constructor-opt: func() -> bool; export test-stack-trace-limit: func() -> bool; export test-depd-pattern: func() -> bool; diff --git a/tests/goldenfiles/generated_types_v8_stack_trace_exports.d.ts b/tests/goldenfiles/generated_types_v8_stack_trace_exports.d.ts index ccab74e91..e2d448c9b 100644 --- a/tests/goldenfiles/generated_types_v8_stack_trace_exports.d.ts +++ b/tests/goldenfiles/generated_types_v8_stack_trace_exports.d.ts @@ -4,6 +4,8 @@ declare module 'v8-stack-trace' { export function testPrepareStackTrace(): Promise; export function testCallSiteMethods(): Promise; export function testLatePrepareStackTrace(): Promise; + export function testDefaultErrorStackHeaders(): Promise; + export function testPrepareStackTraceDescriptors(): Promise; export function testConstructorOpt(): Promise; export function testStackTraceLimit(): Promise; export function testDepdPattern(): Promise; diff --git a/tests/runtime/v8_stack_trace.rs b/tests/runtime/v8_stack_trace.rs index aa13004ed..24a04109b 100644 --- a/tests/runtime/v8_stack_trace.rs +++ b/tests/runtime/v8_stack_trace.rs @@ -96,6 +96,40 @@ async fn v8_stack_trace_late_prepare( Ok(()) } +#[test] +async fn v8_stack_trace_default_error_stack_headers( + #[tagged_as("v8_stack_trace")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-default-error-stack-headers", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + +#[test] +async fn v8_stack_trace_prepare_stack_trace_descriptors( + #[tagged_as("v8_stack_trace")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-prepare-stack-trace-descriptors", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + #[test] async fn v8_stack_trace_constructor_opt( #[tagged_as("v8_stack_trace")] compiled_test: &CompiledTest, From 21b01ae010d7466cc8e973a8878a0d2618443572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Fri, 4 Sep 2026 18:51:27 +0200 Subject: [PATCH 11/13] Preserve Error stack compatibility (GOL-419) --- .../skeleton/src/builtin/internal/errors.js | 89 +++++++++++++------ .../src/builtin/internal/util/inspect.js | 22 +++++ .../v8_stack_trace/src/v8_stack_trace.js | 83 +++++++++++++++++ .../v8_stack_trace/wit/v8_stack_trace.wit | 3 + ...enerated_types_v8_stack_trace_exports.d.ts | 3 + tests/runtime/v8_stack_trace.rs | 57 ++++++++++++ 6 files changed, 228 insertions(+), 29 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js b/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js index 5587fa67f..6238145d0 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/errors.js @@ -90,6 +90,9 @@ function _formatNativeCallSites(error, callSites) { let columnNumber; try { functionName = callSite.getFunctionName(); + if (!functionName && typeof callSite.getMethodName === 'function') { + functionName = callSite.getMethodName(); + } fileName = callSite.getFileName(); lineNumber = callSite.getLineNumber(); columnNumber = callSite.getColumnNumber(); @@ -141,29 +144,44 @@ Object.defineProperty(globalThis, '__wasm_rquickjs_remap_source_mapped_stack', { configurable: false, }); -function _makeCallSite(functionName, fileName, lineNumber, columnNumber) { +function _callSiteMethod(callSite, name, fallback) { + if (callSite && typeof callSite[name] === 'function') { + try { + return callSite[name](); + } catch { + // Use the compatibility fallback below. + } + } + return fallback; +} + +function _makeCallSite(functionName, fileName, lineNumber, columnNumber, callSite) { return { - getThis() { return undefined; }, - getTypeName() { return null; }, - getFunction() { return undefined; }, + getThis() { return _callSiteMethod(callSite, 'getThis', undefined); }, + getTypeName() { return _callSiteMethod(callSite, 'getTypeName', null); }, + getFunction() { return _callSiteMethod(callSite, 'getFunction', undefined); }, getFunctionName() { return functionName || null; }, - getMethodName() { return null; }, + getMethodName() { return _callSiteMethod(callSite, 'getMethodName', null); }, getFileName() { return fileName || null; }, getLineNumber() { return lineNumber | 0; }, getColumnNumber() { return columnNumber | 0; }, - getEvalOrigin() { return undefined; }, - isToplevel() { return true; }, - isEval() { return false; }, - isNative() { return false; }, - isConstructor() { return false; }, - isAsync() { return false; }, - isPromiseAll() { return false; }, - getPromiseIndex() { return null; }, + getEvalOrigin() { return _callSiteMethod(callSite, 'getEvalOrigin', undefined); }, + isToplevel() { return _callSiteMethod(callSite, 'isToplevel', true); }, + isEval() { return _callSiteMethod(callSite, 'isEval', false); }, + isNative() { return _callSiteMethod(callSite, 'isNative', false); }, + isConstructor() { return _callSiteMethod(callSite, 'isConstructor', false); }, + isAsync() { return _callSiteMethod(callSite, 'isAsync', false); }, + isPromiseAll() { return _callSiteMethod(callSite, 'isPromiseAll', false); }, + getPromiseIndex() { return _callSiteMethod(callSite, 'getPromiseIndex', null); }, getScriptNameOrSourceURL() { return fileName || null; }, toString() { - const name = functionName || ''; if (fileName) { - return `${name} (${fileName}:${lineNumber}:${columnNumber})`; + const location = `${fileName}:${lineNumber}:${columnNumber}`; + return functionName ? `${functionName} (${location})` : location; + } + const name = functionName || ''; + if (_callSiteMethod(callSite, 'isNative', false)) { + return `${name} (native)`; } return name; }, @@ -171,29 +189,28 @@ function _makeCallSite(functionName, fileName, lineNumber, columnNumber) { } function _toCompatibleCallSite(callSite) { - const requiredMethods = [ - 'getThis', 'getTypeName', 'getFunction', 'getFunctionName', - 'getMethodName', 'getFileName', 'getLineNumber', 'getColumnNumber', - 'getEvalOrigin', 'isToplevel', 'isEval', 'isNative', 'isConstructor', - 'isAsync', 'isPromiseAll', 'getPromiseIndex', 'getScriptNameOrSourceURL', - 'toString', - ]; - if (requiredMethods.every((name) => typeof callSite[name] === 'function')) { - return callSite; - } let functionName; let fileName; let lineNumber; let columnNumber; try { functionName = callSite.getFunctionName(); + if (!functionName && typeof callSite.getMethodName === 'function') { + functionName = callSite.getMethodName(); + } fileName = callSite.getFileName(); lineNumber = callSite.getLineNumber(); columnNumber = callSite.getColumnNumber(); } catch { // Return a complete neutral CallSite when the native object is partial. } - return _makeCallSite(functionName, fileName, lineNumber, columnNumber); + return _makeCallSite( + functionName, + fileName, + lineNumber, + columnNumber, + callSite, + ); } // Helper to create property descriptors immune to Object.prototype pollution. @@ -217,13 +234,27 @@ const nativeErrorPrepareStackTraceDescriptor = Object.getOwnPropertyDescriptor( ); const nativeErrorCaptureStackTrace = NativeError.captureStackTrace; const initialPublicPrepareStackTrace = NativeError.prepareStackTrace; +const preparedNativeStacks = new WeakMap(); + +export function isPreparedNativeStack(error, stack) { + return error !== null && + (typeof error === 'object' || typeof error === 'function') && + preparedNativeStacks.get(error) === stack; +} function _dispatchPrepareStackTrace(error, callSites) { + if (error && (typeof error === 'object' || typeof error === 'function') && + preparedNativeStacks.has(error)) { + return preparedNativeStacks.get(error); + } const prepare = NativeError.prepareStackTrace; - if (typeof prepare === 'function') { - return prepare(error, callSites.map(_toCompatibleCallSite)); + const result = typeof prepare === 'function' + ? prepare(error, callSites.map(_toCompatibleCallSite)) + : _prepareSourceMappedStack(error, callSites); + if (error && (typeof error === 'object' || typeof error === 'function')) { + preparedNativeStacks.set(error, result); } - return _prepareSourceMappedStack(error, callSites); + return result; } if (nativeErrorPrepareStackTraceDescriptor && diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/util/inspect.js b/crates/wasm-rquickjs/skeleton/src/builtin/internal/util/inspect.js index 33972a9a5..68d09e40d 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/internal/util/inspect.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/util/inspect.js @@ -1652,6 +1652,28 @@ function formatError( } } + // QuickJS prepares native Error stacks during construction, before a guest + // can replace `name`. Refresh only the recognizable native summary line so + // inspect() retains Node's current-name formatting without rewriting a + // manually assigned stack. + if ( + typeof stack === "string" && + nativeErrorConstructorNames.has(constructor) && + codes.isPreparedNativeStack(err, stack) + ) { + const stackStart = stack.indexOf("\n at"); + if (stackStart !== -1) { + const initialHeader = stack.slice(0, stackStart); + const nativeHeader = err.message ? `${constructor}: ${err.message}` : constructor; + if (initialHeader === nativeHeader) { + const currentHeader = Error.prototype.toString.call(err); + if (typeof currentHeader === "string" && currentHeader !== initialHeader) { + stack = currentHeader + stack.slice(stackStart); + } + } + } + } + // QuickJS may drop the summary line and return only stack frames for some // tampered error-name cases. Reconstruct the header from toString() so // improveStack() can normalize the first line like Node does. diff --git a/examples/runtime/v8_stack_trace/src/v8_stack_trace.js b/examples/runtime/v8_stack_trace/src/v8_stack_trace.js index 0a0205ab2..3e9d8b7f1 100644 --- a/examples/runtime/v8_stack_trace/src/v8_stack_trace.js +++ b/examples/runtime/v8_stack_trace/src/v8_stack_trace.js @@ -1,4 +1,10 @@ import assert from 'node:assert'; +import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import module, { createRequire } from 'node:module'; +import { inspect } from 'node:util'; + +const require = createRequire(import.meta.url); // Test 1: Error.captureStackTrace exists as a function export const testCaptureStackTraceExists = () => { @@ -159,6 +165,20 @@ export const testDefaultErrorStackHeaders = () => { const protectedHeader = new Error('protected').stack; Error.prototype.toString = originalToString; assert.match(protectedHeader, /^Error: protected(?:\n|$)/); + function captureNamedFrame() { + return new Error('named-frame').stack; + } + assert.match(captureNamedFrame().split('\n')[1], /^\s*at captureNamedFrame \(.+:\d+:\d+\)$/); + const originalObjectToString = Object.prototype.toString; + let protectedCallSiteStack; + try { + Object.prototype.toString = () => 'FAKE'; + protectedCallSiteStack = new Error('protected-call-site').stack; + } finally { + Object.prototype.toString = originalObjectToString; + } + assert.doesNotMatch(protectedCallSiteStack, /FAKE/); + assert.match(protectedCallSiteStack.split('\n')[1], /^\s*at .+:\d+:\d+/); return true; } catch (e) { console.error('testDefaultErrorStackHeaders FAIL:', e.message); @@ -168,6 +188,69 @@ export const testDefaultErrorStackHeaders = () => { } }; +export const testErrorInspectUsesCurrentName = () => { + try { + const error = new RangeError('foo'); + error.name = 404; + assert.match(inspect(error), /^404 \[RangeError\]: foo(?:\n|$)/); + + const manualStackError = new RangeError('foo'); + manualStackError.stack = 'RangeError: foo\n at manual (x.js:1:2)'; + manualStackError.name = 404; + assert.strictEqual( + inspect(manualStackError), + 'RangeError: foo\n at manual (x.js:1:2)', + ); + return true; + } catch (e) { + console.error('testErrorInspectUsesCurrentName FAIL:', e.message); + return false; + } +}; + +export const testAssertionListenerStackFrame = () => { + const emitter = new EventEmitter(); + emitter.on('failure', assert); + try { + emitter.emit('failure', false); + } catch (error) { + return String(error.stack).split('\n')[1] || ''; + } + return ''; +}; + +export const testCjsCallSiteLineOffset = () => { + const originalPrepare = Error.prepareStackTrace; + const filename = '/v8-call-site-line-offset.cjs'; + const mapFilename = '/v8-call-site-line-offset.cjs.map'; + try { + module.setSourceMapsSupport(true); + Error.prepareStackTrace = (_error, sites) => sites[0]; + fs.writeFileSync(filename, [ + 'function branch() {', + " throw Error('site');", + '}', + 'branch();', + '//# sourceMappingURL=v8-call-site-line-offset.cjs.map', + ].join('\n')); + fs.writeFileSync(mapFilename, JSON.stringify({ + version: 3, + sources: ['v8-call-site-line-offset.ts'], + names: [], + mappings: 'AAAA;AACA;AACA;AACA', + })); + try { + require(filename); + } catch (error) { + return String(error.stack.getLineNumber()); + } + return ''; + } finally { + delete require.cache[filename]; + Error.prepareStackTrace = originalPrepare; + } +}; + export const testPrepareStackTraceDescriptors = () => { try { const customPrepare = () => 'custom-prepare'; diff --git a/examples/runtime/v8_stack_trace/wit/v8_stack_trace.wit b/examples/runtime/v8_stack_trace/wit/v8_stack_trace.wit index 7548a5275..e217d6934 100644 --- a/examples/runtime/v8_stack_trace/wit/v8_stack_trace.wit +++ b/examples/runtime/v8_stack_trace/wit/v8_stack_trace.wit @@ -7,6 +7,9 @@ world v8-stack-trace { export test-call-site-methods: func() -> bool; export test-late-prepare-stack-trace: func() -> bool; export test-default-error-stack-headers: func() -> bool; + export test-error-inspect-uses-current-name: func() -> bool; + export test-assertion-listener-stack-frame: func() -> string; + export test-cjs-call-site-line-offset: func() -> string; export test-prepare-stack-trace-descriptors: func() -> bool; export test-constructor-opt: func() -> bool; export test-stack-trace-limit: func() -> bool; diff --git a/tests/goldenfiles/generated_types_v8_stack_trace_exports.d.ts b/tests/goldenfiles/generated_types_v8_stack_trace_exports.d.ts index e2d448c9b..d484b719f 100644 --- a/tests/goldenfiles/generated_types_v8_stack_trace_exports.d.ts +++ b/tests/goldenfiles/generated_types_v8_stack_trace_exports.d.ts @@ -5,6 +5,9 @@ declare module 'v8-stack-trace' { export function testCallSiteMethods(): Promise; export function testLatePrepareStackTrace(): Promise; export function testDefaultErrorStackHeaders(): Promise; + export function testErrorInspectUsesCurrentName(): Promise; + export function testAssertionListenerStackFrame(): Promise; + export function testCjsCallSiteLineOffset(): Promise; export function testPrepareStackTraceDescriptors(): Promise; export function testConstructorOpt(): Promise; export function testStackTraceLimit(): Promise; diff --git a/tests/runtime/v8_stack_trace.rs b/tests/runtime/v8_stack_trace.rs index 24a04109b..cab916c33 100644 --- a/tests/runtime/v8_stack_trace.rs +++ b/tests/runtime/v8_stack_trace.rs @@ -113,6 +113,63 @@ async fn v8_stack_trace_default_error_stack_headers( Ok(()) } +#[test] +async fn v8_stack_trace_error_inspect_uses_current_name( + #[tagged_as("v8_stack_trace")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-error-inspect-uses-current-name", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::Bool(true))); + Ok(()) +} + +#[test] +async fn v8_stack_trace_assertion_listener_stack_frame( + #[tagged_as("v8_stack_trace")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-assertion-listener-stack-frame", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + let Some(Val::String(frame)) = r else { + anyhow::bail!("expected a stack-frame string, got {r:?}"); + }; + assert!( + frame.contains(" (") && frame.ends_with(')'), + "expected a parenthesized stack frame, got {frame:?}" + ); + Ok(()) +} + +#[test] +async fn v8_stack_trace_cjs_call_site_line_offset( + #[tagged_as("v8_stack_trace")] compiled_test: &CompiledTest, +) -> anyhow::Result<()> { + let (r, output) = invoke_and_capture_output( + compiled_test.wasm_path(), + None, + "test-cjs-call-site-line-offset", + &[], + ) + .await; + let r = r?; + println!("Output:\n{}", output); + assert_eq!(r, Some(Val::String("2".into()))); + Ok(()) +} + #[test] async fn v8_stack_trace_prepare_stack_trace_descriptors( #[tagged_as("v8_stack_trace")] compiled_test: &CompiledTest, From 0f649c8880f9c11f1825778c95d6d530178cbd7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Fri, 4 Sep 2026 19:54:20 +0200 Subject: [PATCH 12/13] Preserve constructor inference after stack remapping (GOL-419) --- .../skeleton/src/builtin/internal/binding/util.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/binding/util.js b/crates/wasm-rquickjs/skeleton/src/builtin/internal/binding/util.js index 3aa0b28ce..f91df33e1 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/internal/binding/util.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/binding/util.js @@ -320,6 +320,7 @@ function findGlobalConstructorNameByPrototype(value) { const inspectNewCallPattern = /\b(?:[A-Za-z_$][A-Za-z0-9_$]*\.)?inspect\s*\(\s*new\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/; const stackLocationPattern = /\(?(.+):(\d+):(\d+)\)?\s*$/; +const bareStackLocationPattern = /^\s*at\s+(.+):(\d+):(\d+)\s*$/; function inferConstructorNameFromCallsite() { const currentModule = globalThis.__wasm_rquickjs_current_module; @@ -341,11 +342,18 @@ function inferConstructorNameFromCallsite() { const sourceLines = currentModule.source.split("\n"); const stackLines = stack.split("\n"); for (let i = stackLines.length - 1; i >= 1; i--) { - if (!stackLines[i].includes("anonymous (") && !stackLines[i].includes(" (")) { + const stackLine = stackLines[i]; + const isAnonymousFrame = stackLine.includes("anonymous (") || + stackLine.includes(" ("); + const bareLocation = bareStackLocationPattern.exec(stackLine); + if ( + !isAnonymousFrame && + (bareLocation === null || bareLocation[1] !== currentModule.filename) + ) { continue; } - const locationMatch = stackLocationPattern.exec(stackLines[i]); + const locationMatch = stackLocationPattern.exec(stackLine); if (locationMatch === null) { continue; } From b7d952e365d88449c32b151831c7f7c7d2eb1146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Da=CC=81vid=20Istva=CC=81n=20Bi=CC=81ro=CC=81?= Date: Fri, 4 Sep 2026 20:06:34 +0200 Subject: [PATCH 13/13] Remove source-shaped constructor inference (GOL-419) --- .../src/builtin/internal/binding/util.js | 75 +------------------ .../src/builtin/internal/util/inspect.js | 2 +- tests/node_compat/config.jsonc | 5 +- tests/node_compat/report.md | 15 ++-- 4 files changed, 14 insertions(+), 83 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/binding/util.js b/crates/wasm-rquickjs/skeleton/src/builtin/internal/binding/util.js index f91df33e1..aa74c4bf7 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/internal/binding/util.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/binding/util.js @@ -318,69 +318,6 @@ function findGlobalConstructorNameByPrototype(value) { return ""; } -const inspectNewCallPattern = /\b(?:[A-Za-z_$][A-Za-z0-9_$]*\.)?inspect\s*\(\s*new\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/; -const stackLocationPattern = /\(?(.+):(\d+):(\d+)\)?\s*$/; -const bareStackLocationPattern = /^\s*at\s+(.+):(\d+):(\d+)\s*$/; - -function inferConstructorNameFromCallsite() { - const currentModule = globalThis.__wasm_rquickjs_current_module; - if ( - currentModule === undefined || - currentModule === null || - typeof currentModule.source !== "string" - ) { - return ""; - } - - let stack; - try { - stack = String(new Error().stack || ""); - } catch { - return ""; - } - - const sourceLines = currentModule.source.split("\n"); - const stackLines = stack.split("\n"); - for (let i = stackLines.length - 1; i >= 1; i--) { - const stackLine = stackLines[i]; - const isAnonymousFrame = stackLine.includes("anonymous (") || - stackLine.includes(" ("); - const bareLocation = bareStackLocationPattern.exec(stackLine); - if ( - !isAnonymousFrame && - (bareLocation === null || bareLocation[1] !== currentModule.filename) - ) { - continue; - } - - const locationMatch = stackLocationPattern.exec(stackLine); - if (locationMatch === null) { - continue; - } - - const lineNumber = Number(locationMatch[2]); - if (!Number.isInteger(lineNumber) || lineNumber < 1 || lineNumber > sourceLines.length) { - continue; - } - - const snippet = [ - sourceLines[lineNumber - 4], - sourceLines[lineNumber - 3], - sourceLines[lineNumber - 2], - sourceLines[lineNumber - 1], - ] - .filter((line) => typeof line === "string") - .join(" "); - - const constructorMatch = inspectNewCallPattern.exec(snippet); - if (constructorMatch !== null) { - return constructorMatch[1]; - } - } - - return ""; -} - function trackNullPrototypeConstructor(target, proto) { if (!isObjectLike(target)) { return; @@ -428,7 +365,7 @@ Reflect.setPrototypeOf = function setPrototypeOf(target, proto) { } }; -export function getConstructorName(value, allowCallsiteFallback = false) { +export function getConstructorName(value) { if (!isObjectLike(value)) { return "Object"; } @@ -453,16 +390,6 @@ export function getConstructorName(value, allowCallsiteFallback = false) { return globalConstructorName; } - // QuickJS does not expose V8's hidden-class constructor-name recovery API. - // When inspecting `new Foo()` objects whose prototype was replaced with a - // null-prototype object, infer `Foo` from the user callsite as a fallback. - if (allowCallsiteFallback) { - const callsiteConstructorName = inferConstructorNameFromCallsite(); - if (callsiteConstructorName !== "") { - return callsiteConstructorName; - } - } - return "Object"; } diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/internal/util/inspect.js b/crates/wasm-rquickjs/skeleton/src/builtin/internal/util/inspect.js index 68d09e40d..3046bf181 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/internal/util/inspect.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/internal/util/inspect.js @@ -1207,7 +1207,7 @@ function getConstructorName( return null; } - const res = internalGetConstructorName(tmp, recurseTimes === 0); + const res = internalGetConstructorName(tmp); if (recurseTimes > ctx.depth && ctx.depth !== null) { return `${res} `; diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index ca4ac7504..03b4d9bdc 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -438,7 +438,10 @@ "block_70_verify_non_enumerable_keys_get_escaped": { "category": "runnable" }, "block_71_check_for_special_colors": { "category": "runnable" }, "block_72_non_indices_array_properties_are_sorted_as_well": { "category": "runnable" }, - "block_73_manipulate_the_prototype_in_weird_ways": { "category": "runnable" }, + "block_73_manipulate_the_prototype_in_weird_ways": { + "category": "known-gap", + "reason": "requires V8 hidden-class constructor-name recovery after the prototype constructor is detached" + }, "block_74_check_that_the_fallback_always_works": { "category": "runnable" }, "block_75_check_the_getter_option": { "category": "runnable" }, "block_76_check_compact_number_mode": { "category": "runnable" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 6ceece384..86db66bbf 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,21 +8,21 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3175/4388 (72.4%) +**Primary compatibility (CI-enforced):** 3174/4388 (72.3%) When comparing revisions, read the runnable count and secondary full-public percentage alongside the primary percentage. Reclassifying a test into an excluded category can increase the primary percentage without increasing runnable coverage. | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3175 | 72.4% | 55.2% | 46.2% | -| 🧩 known gap | 1213 | 27.6% | 21.1% | 17.6% | +| ✅ passing (runnable) | 3174 | 72.3% | 55.2% | 46.2% | +| 🧩 known gap | 1214 | 27.7% | 21.1% | 17.7% | | 🚫 WASI-impossible (excluded) | 1195 | — | 20.8% | 17.4% | | ⚙️ engine difference (excluded) | 167 | — | 2.9% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1123 | — | — | 16.3% | | **Total** | **6873** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3175/5750 (55.2%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3174/5750 (55.2%)**. ## Inventory by Module @@ -81,7 +81,7 @@ Secondary full-public compatibility, including public tests that are currently e | trace_events | 35 | 15 | 10 | 6 | 0 | 0 | 4 | 60.0% | 48.4% | | tty | 5 | 0 | 3 | 0 | 0 | 0 | 2 | 0.0% | 0.0% | | url | 29 | 28 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| util | 174 | 88 | 9 | 0 | 0 | 0 | 77 | 90.7% | 90.7% | +| util | 174 | 87 | 10 | 0 | 0 | 0 | 77 | 89.7% | 89.7% | | v8 | 45 | 14 | 1 | 0 | 30 | 0 | 0 | 93.3% | 31.1% | | vm | 128 | 73 | 39 | 3 | 13 | 0 | 0 | 65.2% | 57.0% | | webcrypto | 107 | 43 | 21 | 1 | 0 | 0 | 42 | 67.2% | 66.2% | @@ -584,7 +584,7 @@ Secondary full-public compatibility, including public tests that are currently e | `test-util-format.js` | 5 | 0 | 5 | 0 | 0 | 0 | 0 | | `test-util-getcallsites.js` | 13 | 12 | 1 | 0 | 0 | 0 | 0 | | `test-util-inspect-getters-accessing-this.js` | 2 | 2 | 0 | 0 | 0 | 0 | 0 | -| `test-util-inspect.js` | 99 | 48 | 2 | 0 | 0 | 0 | 49 | +| `test-util-inspect.js` | 99 | 47 | 3 | 0 | 0 | 0 | 49 | | `test-util-isDeepStrictEqual.js` | 2 | 2 | 0 | 0 | 0 | 0 | 0 | | `test-util-promisify.js` | 19 | 0 | 0 | 0 | 0 | 0 | 19 | | `test-util-types.js` | 3 | 0 | 0 | 0 | 0 | 0 | 3 | @@ -686,7 +686,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1213) +### known gap (1214) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -1210,6 +1210,7 @@ Secondary full-public compatibility, including public tests that are currently e | request/response pause-resume flow control does not complete with Node-compatible behavior | 1 | `parallel/test-http-pause.js` | | requires ERR_INVALID_ARG_TYPE validation on resolve methods (not yet implemented) | 1 | `parallel/test-dns-resolvens-typeerror.js` | | requires HTTP server functionality, we only support clients | 1 | `parallel/test-diagnostic-channel-http-response-created.js` | +| requires V8 hidden-class constructor-name recovery after the prototype constructor is detached | 1 | `parallel/test-util-inspect.js#block_73_manipulate_the_prototype_in_weird_ways` | | requires V8-style GC/finalization behavior for rapidly churned HTTP client requests; current QuickJS/WASM runtime does not collect all watched request objects reliably | 1 | `parallel/test-gc-http-client-connaborted.js` | | requires V8-style GC/finalization behavior for rapidly churned net sockets with timeouts; current QuickJS/WASM runtime does not collect all watched socket objects reliably | 1 | `parallel/test-gc-net-timeout.js` | | requires actual TCP socket reuse with remotePort identity tracking via server; wasi:http creates new connections per request | 1 | `parallel/test-http-agent-scheduling.js` |