diff --git a/src/ast/ast_result.rs b/src/ast/ast_result.rs index 1441338beed..6de298458c9 100644 --- a/src/ast/ast_result.rs +++ b/src/ast/ast_result.rs @@ -92,6 +92,26 @@ pub struct Ast<'a> { pub has_commonjs_export_names: bool, pub has_import_meta: bool, pub import_meta_ref: Ref, + + /// First non-ambient TypeScript construct with runtime semantics (not + /// erasable by type stripping). `node:module`'s `stripTypeScriptTypes` + /// rejects these in `'strip'` mode, matching Node's amaro/swc strip-only + /// behavior. `declare` contexts never set this. + pub ts_runtime_syntax: Option, + /// An identifier-named `module Foo {}` declaration was parsed. Node's + /// `stripTypeScriptTypes` rejects the `module` keyword in both modes + /// (string-named `declare module "foo"` is ambient and allowed). + pub uses_ts_module_keyword: bool, +} + +/// TypeScript syntax with runtime semantics; see `Ast::ts_runtime_syntax`. +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub enum TsRuntimeSyntax { + Enum, + Namespace, + ParameterProperty, + ImportEquals, + ExportAssignment, } // `parts`/`symbols`/`import_records` are now `ArenaVec`s and need an allocator, @@ -137,6 +157,8 @@ impl<'a> Ast<'a> { has_commonjs_export_names: false, has_import_meta: false, import_meta_ref: Ref::NONE, + ts_runtime_syntax: None, + uses_ts_module_keyword: false, } } } diff --git a/src/ast/lib.rs b/src/ast/lib.rs index 52c5f00174f..ade1b7ab707 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -3193,7 +3193,7 @@ pub mod target; pub use ast_result::{ Ast, CommonJSNamedExport, CommonJSNamedExports, ConstValuesMap, NamedExports, NamedImports, - TopLevelSymbolToParts, TsEnumsMap, + TopLevelSymbolToParts, TsEnumsMap, TsRuntimeSyntax, }; pub use import_record::{ Flags as ImportRecordFlags, ImportRecord, PrintMode as ImportRecordPrintMode, diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 214e4347cdb..9f272c23bf8 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -242,6 +242,10 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { pub latest_return_had_semicolon: bool, pub has_import_meta: bool, pub has_es_module_syntax: bool, + /// See `Ast::ts_runtime_syntax`; records the first occurrence. + pub ts_runtime_syntax: Option, + /// See `Ast::uses_ts_module_keyword`. + pub uses_ts_module_keyword: bool, pub top_level_await_keyword: bun_ast::Range, pub fn_or_arrow_data_parse: FnOrArrowDataParse, pub fn_or_arrow_data_visit: FnOrArrowDataVisit, @@ -8283,6 +8287,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // independently. `compute_character_frequency` is fully un-gated // (lexer.all_comments + CharFreq.scan live). impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_ONLY> { + /// Record the first non-ambient TS runtime-syntax construct in the file; + /// see `Ast::ts_runtime_syntax`. + #[inline] + pub fn record_ts_runtime_syntax(&mut self, kind: bun_ast::TsRuntimeSyntax) { + if self.ts_runtime_syntax.is_none() { + self.ts_runtime_syntax = Some(kind); + } + } + pub fn to_ast( &mut self, parts: &mut ListManaged<'a, js_ast::Part>, @@ -8862,6 +8875,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O has_top_level_return: false, redirect_import_record_index: None, target: js_ast::Target::Browser, + ts_runtime_syntax: self.ts_runtime_syntax, + uses_ts_module_keyword: self.uses_ts_module_keyword, })) } @@ -9148,6 +9163,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O latest_return_had_semicolon: false, has_import_meta: false, has_es_module_syntax: false, + ts_runtime_syntax: None, + uses_ts_module_keyword: false, top_level_await_keyword: bun_ast::Range::NONE, fn_or_arrow_data_parse, fn_or_arrow_data_visit: FnOrArrowDataVisit::default(), diff --git a/src/js_parser/parse/parse_stmt.rs b/src/js_parser/parse/parse_stmt.rs index 205fdce7557..97760397d80 100644 --- a/src/js_parser/parse/parse_stmt.rs +++ b/src/js_parser/parse/parse_stmt.rs @@ -1342,6 +1342,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.lexer.next()?; let value = p.parse_expr(Level::Lowest)?; p.lexer.expect_or_insert_semicolon()?; + if !opts.is_typescript_declare { + p.record_ts_runtime_syntax(js_ast::TsRuntimeSyntax::ExportAssignment); + } return Ok(p.s(S::ExportEquals { value }, loc)); } p.lexer.unexpected()?; @@ -1760,6 +1763,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O && (p.lexer.token == T::TIdentifier || (p.lexer.token == T::TStringLiteral && opts.is_typescript_declare)) { + if matches!(ts_stmt, js_lexer::TypescriptStmtKeyword::TsStmtModule) + && p.lexer.token == T::TIdentifier + { + p.uses_ts_module_keyword = true; + } return Ok(Some(p.parse_type_script_namespace_stmt(loc, opts)?)); } } diff --git a/src/js_parser/parse/parse_typescript.rs b/src/js_parser/parse/parse_typescript.rs index 35a9c4290b6..885718b9342 100644 --- a/src/js_parser/parse/parse_typescript.rs +++ b/src/js_parser/parse/parse_typescript.rs @@ -421,6 +421,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .insert(name.ref_.expect("infallible: ref bound"), ns_member_data); } + p.record_ts_runtime_syntax(bun_ast::TsRuntimeSyntax::Namespace); + // S::Namespace.stmts is `StoreSlice` (arena slice). BumpVec → bump slice. let stmts_slice: &'a mut [Stmt] = stmts.into_bump_slice_mut(); Ok(p.s( @@ -509,6 +511,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return Ok(p.s(S::TypeScript {}, loc)); } + p.record_ts_runtime_syntax(bun_ast::TsRuntimeSyntax::ImportEquals); + let ref_ = p .declare_symbol(SymbolKind::Constant, default_name_loc, default_name) .expect("unreachable"); @@ -717,6 +721,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let prev = p.scopes_in_order_for_enum.insert(loc, scope_order_clone); debug_assert!(prev.is_none()); + p.record_ts_runtime_syntax(bun_ast::TsRuntimeSyntax::Enum); + Ok(p.s( S::Enum { name, diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index ef873c88926..ef1d62e8fd1 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -1046,6 +1046,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O to_add += 1; } } + if to_add > 0 { + // `declare class` statements never reach the visit + // pass, so this only fires for real (runtime) + // parameter properties. + self.record_ts_runtime_syntax(bun_ast::TsRuntimeSyntax::ParameterProperty); + } // if this is an expression, we can move statements after super() because there will be 0 decorators let mut super_index: Option = None; diff --git a/src/jsc/ErrorCode.rs b/src/jsc/ErrorCode.rs index ac8c10a52b1..148c9e1d38a 100644 --- a/src/jsc/ErrorCode.rs +++ b/src/jsc/ErrorCode.rs @@ -709,9 +709,13 @@ impl ErrorCode { pub const FS_CP_SYMLINK_TO_SUBDIRECTORY: ErrorCode = ErrorCode(325); /// `ERR_DIR_CONCURRENT_OPERATION` (instanceof Error) pub const DIR_CONCURRENT_OPERATION: ErrorCode = ErrorCode(326); + /// `ERR_INVALID_TYPESCRIPT_SYNTAX` (instanceof SyntaxError) + pub const INVALID_TYPESCRIPT_SYNTAX: ErrorCode = ErrorCode(327); + /// `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` (instanceof SyntaxError) + pub const UNSUPPORTED_TYPESCRIPT_SYNTAX: ErrorCode = ErrorCode(328); /// == C++ `NODE_ERROR_COUNT`. - pub const COUNT: u16 = 327; + pub const COUNT: u16 = 329; } // ────────────────────────────────────────────────────────────────────────── @@ -961,6 +965,9 @@ impl ErrorCode { pub const ERR_MYSQL_CONNECTION_CLOSED: ErrorCode = ErrorCode::MYSQL_CONNECTION_CLOSED; pub const ERR_MYSQL_CONNECTION_FAILED: ErrorCode = ErrorCode::MYSQL_CONNECTION_FAILED; pub const ERR_MYSQL_CONNECTION_REFUSED: ErrorCode = ErrorCode::MYSQL_CONNECTION_REFUSED; + pub const ERR_INVALID_TYPESCRIPT_SYNTAX: ErrorCode = ErrorCode::INVALID_TYPESCRIPT_SYNTAX; + pub const ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX: ErrorCode = + ErrorCode::UNSUPPORTED_TYPESCRIPT_SYNTAX; pub const ERR_MYSQL_CONNECTION_TIMEOUT: ErrorCode = ErrorCode::MYSQL_CONNECTION_TIMEOUT; pub const ERR_MYSQL_IDLE_TIMEOUT: ErrorCode = ErrorCode::MYSQL_IDLE_TIMEOUT; pub const ERR_MYSQL_LIFETIME_TIMEOUT: ErrorCode = ErrorCode::MYSQL_LIFETIME_TIMEOUT; @@ -1425,6 +1432,8 @@ static CODE_STR: [&str; ErrorCode::COUNT as usize] = [ "ERR_FS_CP_EEXIST", "ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY", "ERR_DIR_CONCURRENT_OPERATION", + "ERR_INVALID_TYPESCRIPT_SYNTAX", + "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX", ]; // ────────────────────────────────────────────────────────────────────────── diff --git a/src/jsc/NodeModuleModule.rs b/src/jsc/NodeModuleModule.rs index 473818c587e..4a91d80774a 100644 --- a/src/jsc/NodeModuleModule.rs +++ b/src/jsc/NodeModuleModule.rs @@ -2,11 +2,15 @@ use crate::{ self as jsc, ErrorableString, JSArray, JSGlobalObject, JSValue, JsError, JsResult, StringJsc, Strong, VirtualMachineRef as VirtualMachine, }; +use bun_alloc::Arena; use bun_ast::Loader; use bun_bundler::options::DEFAULT_LOADERS; +use bun_bundler::transpiler::{MacroJSCtx, ParseOptions, Transpiler}; use bun_core::{OwnedString, String as BunString, strings}; +use bun_js_printer as JSPrinter; use bun_options_types::LoaderExt as _; use bun_options_types::schema::api; +use bun_resolver::package_json::MacroMap as MacroRemap; // `bun.schema.api.Loader` — bindgen-emitted schema enum. // Mirrored as a transparent `u8` because the schema enum is *open* @@ -126,6 +130,276 @@ pub fn _stat(path: &[u8]) -> i32 { } } +// The C++ caller (NodeModuleModule.cpp `jsFunctionStripTypeScriptTypes`) +// validates the arguments Node-style and passes plain coerced data. +#[unsafe(no_mangle)] +pub(crate) extern "C" fn NodeModuleModule__stripTypeScriptTypes( + global: &JSGlobalObject, + code: BunString, + transform_mode: bool, + source_map: bool, + source_url: BunString, +) -> JSValue { + jsc::host_fn::to_js_host_call(global, || { + strip_typescript_types(global, code, transform_mode, source_map, source_url) + }) +} + +/// `module.stripTypeScriptTypes(code, options)`: transpile a TypeScript +/// source string with Bun's transpiler. +/// +/// In `'strip'` mode (`transform_mode == false`), TypeScript syntax with +/// runtime semantics (enums, instantiated namespaces, parameter properties, +/// `import =`, `export =`) throws `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`, and an +/// identifier-named `module` declaration throws in both modes, matching +/// Node's amaro/swc contract. Unlike Node, the output is re-printed from the +/// AST rather than blanked in place, so original line/column positions are +/// not preserved and comments are dropped. +fn strip_typescript_types( + global: &JSGlobalObject, + code: BunString, + transform_mode: bool, + source_map: bool, + source_url: BunString, +) -> JsResult { + let mut log = bun_ast::Log::init(); + let arena = Arena::new(); + // SAFETY: `arena` outlives every use through `transpiler` in this fn body; + // `Transpiler<'static>` forces the borrow to 'static, so launder through a + // raw ptr (same pattern as JSTranspiler / TransformTask). + let arena_ref: &'static Arena = unsafe { bun_ptr::detach_lifetime_ref(&arena) }; + + // SAFETY: VirtualMachine::get() returns the live singleton on the JS thread. + let vm = crate::virtual_machine::VirtualMachine::get().as_mut(); + + let transform = api::TransformOptions { + // `using` / `await using` are only kept verbatim (not lowered) when + // targeting Bun; JavaScriptCore implements them natively. + target: Some(api::Target::Bun), + ..Default::default() + }; + let mut transpiler = + match Transpiler::init(arena_ref, &raw mut log, transform, Some(vm.transpiler.env)) { + Ok(t) => t, + Err(err) => return Err(global.throw_error(err, "Failed to create transpiler")), + }; + // `parse()` lazily allocates `macro_context`, whose `data` pointer is + // only freed by an explicit `deinit()`; this one-shot transpiler must + // reclaim it on every return path (mirrors `TransformTask::run`). + let _macro_ctx_guard = + scopeguard::guard(core::ptr::addr_of_mut!(transpiler.macro_context), |slot| { + // SAFETY: `slot` points at the stack-owned `transpiler`, which is + // still alive when this guard drops (declared after it), and the + // parser's `&mut MacroContext` borrow ended with `parse()`. + if let Some(ctx) = unsafe { (*slot).take() } { + ctx.deinit(); + } + }); + // `LoadAllWithoutInlining` skips the implicit `process.env.NODE_ENV` / + // `process.env.BUN_ENV` / `process.browser` defines, so `process.env.*` + // reads survive the transform verbatim. + transpiler.options.env.behavior = api::DotEnvBehavior::LoadAllWithoutInlining; + transpiler.options.env.disable_default_env_files = true; + if let Err(err) = transpiler.configure_defines() { + return Err(global.throw_error(err, "Failed to configure transpiler")); + } + // A plain type-stripping transform: no macros, no dead-code elimination, + // no import trimming (Node keeps value imports even when only used as + // types), no minification. + transpiler.options.no_macros = true; + transpiler.options.dead_code_elimination = false; + transpiler.options.tree_shaking = false; + transpiler.options.trim_unused_imports = Some(false); + transpiler.options.inlining = false; + transpiler.options.minify_whitespace = false; + transpiler.options.minify_syntax = false; + transpiler.options.minify_identifiers = false; + transpiler.options.auto_import_jsx = false; + transpiler.options.transform_only = false; + transpiler.options.hot_module_reloading = false; + transpiler.options.react_fast_refresh = false; + + let mut ast_memory_allocator = bun_ast::ASTMemoryAllocator::borrowing(&arena); + let _ast_scope = ast_memory_allocator.enter(); + + // Borrowed view; must stay alive until printing finishes because the + // arena-allocated `Source` (and AST string slices) point into it. + let code_utf8 = code.to_utf8(); + let source: &bun_ast::Source = arena_ref.alloc(bun_ast::Source::init_path_string( + Loader::Ts.stdin_name(), + code_utf8.slice(), + )); + + let parse_options = ParseOptions { + arena: arena_ref, + macro_remappings: MacroRemap::default(), + dirname_fd: bun_sys::Fd::INVALID, + file_descriptor: None, + loader: Loader::Ts, + jsx: transpiler.options.jsx.clone(), + path: source.path, + virtual_source: Some(source), + replace_exports: Default::default(), + experimental_decorators: false, + emit_decorator_metadata: false, + macro_js_ctx: MacroJSCtx::ZERO, + file_hash: None, + file_fd_ptr: None, + inject_jest_globals: false, + set_breakpoint_on_first_line: false, + remove_cjs_module_wrapper: false, + dont_bundle_twice: false, + allow_commonjs: false, + module_type: Default::default(), + runtime_transpiler_cache: None, + keep_json_and_toml_as_one_statement: false, + allow_bytecode_cache: false, + }; + + let parse_result = transpiler.parse(parse_options, None); + + if log.errors > 0 { + // Node maps parser errors to ERR_INVALID_TYPESCRIPT_SYNTAX (a + // SyntaxError); the message text comes from Bun's parser. + let text: &[u8] = log + .msgs + .iter() + .find(|m| matches!(m.kind, bun_ast::Kind::Err)) + .map(|m| m.data.text.as_ref()) + .unwrap_or(b"Failed to parse TypeScript"); + return Err(jsc::ErrorCode::ERR_INVALID_TYPESCRIPT_SYNTAX + .throw(global, format_args!("{}", bstr::BStr::new(text)))); + } + let Some(parse_result) = parse_result else { + return Err(jsc::ErrorCode::ERR_INVALID_TYPESCRIPT_SYNTAX + .throw(global, format_args!("Failed to parse TypeScript"))); + }; + + // amaro rejects the `module` keyword in both modes. + if parse_result.ast.uses_ts_module_keyword { + return Err(jsc::ErrorCode::ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX.throw( + global, + format_args!("`module` keyword is not supported. Use `namespace` instead."), + )); + } + if !transform_mode { + if let Some(kind) = parse_result.ast.ts_runtime_syntax { + let what = match kind { + bun_ast::TsRuntimeSyntax::Enum => "TypeScript enum", + bun_ast::TsRuntimeSyntax::Namespace => "TypeScript namespace declaration", + bun_ast::TsRuntimeSyntax::ParameterProperty => "TypeScript parameter property", + bun_ast::TsRuntimeSyntax::ImportEquals => "TypeScript import equals declaration", + bun_ast::TsRuntimeSyntax::ExportAssignment => "TypeScript export assignment", + }; + return Err(jsc::ErrorCode::ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX.throw( + global, + format_args!("{what} is not supported in strip-only mode"), + )); + } + } + + let was_empty = parse_result.empty; + // A leading `#!` line is not part of the printed AST; carry it through + // like the bundler's entry-point handling (Node preserves hashbangs in + // both modes). The slice points into the source text (`code_utf8`). + let hashbang_store = parse_result.ast.hashbang; + let hashbang: &[u8] = hashbang_store.slice(); + let mut printer = JSPrinter::BufferPrinter::init(JSPrinter::BufferWriter::init()); + let mut map_vlq = bun_core::MutableString::init_empty(); + if !was_empty { + if source_map { + let mut capture = VlqCapture { vlq: &mut map_vlq }; + let handler = JSPrinter::SourceMapHandler::for_(&mut capture); + if let Err(err) = transpiler.print_with_source_map( + &arena, + parse_result, + &mut printer, + JSPrinter::Format::EsmAscii, + handler, + None, + ) { + return Err(global.throw_error(err, "Failed to print code")); + } + } else if let Err(err) = transpiler.print( + &arena, + parse_result, + &mut printer, + JSPrinter::Format::EsmAscii, + ) { + return Err(global.throw_error(err, "Failed to print code")); + } + } + let printed: &[u8] = printer.ctx.written(); + + let source_url_utf8 = source_url.to_utf8(); + let mut out: Vec = Vec::with_capacity(hashbang.len() + 1 + printed.len() + 64); + if !hashbang.is_empty() { + out.extend_from_slice(hashbang); + if !printed.is_empty() { + out.push(b'\n'); + } + } + out.extend_from_slice(printed); + if source_map { + // Node's shape: {"version":3,"sources":[],"names":[], + // "mappings":"..."}; an empty input produces "sources":[]. + let mut json = bun_core::MutableString::init_empty(); + bun_core::handle_oom(json.append(b"{\"version\":3,\"sources\":[")); + if !was_empty { + bun_core::handle_oom(bun_core::quote_for_json( + source_url_utf8.slice(), + &mut json, + true, + )); + } + bun_core::handle_oom(json.append(b"],\"names\":[],\"mappings\":")); + let mut mappings: Vec = Vec::with_capacity(map_vlq.list.len() + 1); + if !hashbang.is_empty() && !map_vlq.list.is_empty() { + // The prepended hashbang occupies generated line 0. + mappings.push(b';'); + } + mappings.extend_from_slice(map_vlq.list.as_slice()); + bun_core::handle_oom(bun_core::quote_for_json(&mappings, &mut json, true)); + bun_core::handle_oom(json.append(b"}")); + + out.extend_from_slice(b"\n\n//# sourceMappingURL=data:application/json;base64,"); + let json_bytes = json.list.as_slice(); + let old_len = out.len(); + out.resize(old_len + bun_base64::encode_len(json_bytes), 0); + let written = bun_base64::encode(&mut out[old_len..], json_bytes); + out.truncate(old_len + written); + } else if !source_url_utf8.slice().is_empty() { + out.extend_from_slice(b"\n\n//# sourceURL="); + out.extend_from_slice(source_url_utf8.slice()); + } + + let mut result = BunString::clone_utf8(&out); + result.transfer_to_js(global) +} + +struct VlqCapture<'m> { + vlq: &'m mut bun_core::MutableString, +} + +impl JSPrinter::OnSourceMapChunk for VlqCapture<'_> { + fn on_source_map_chunk( + &mut self, + chunk: bun_sourcemap::Chunk, + _source: &bun_ast::Source, + ) -> Result<(), bun_core::Error> { + // Target is Bun, so `chunk.buffer` holds an InternalSourceMap blob; + // re-encode it to a standard VLQ "mappings" string. An empty buffer + // (source-map feature flag disabled) yields empty mappings. + if !chunk.buffer.list.is_empty() { + let ism = bun_sourcemap::InternalSourceMap { + data: chunk.buffer.list.as_ptr(), + }; + ism.append_vlq_to(self.vlq); + } + Ok(()) + } +} + pub enum CustomLoader { Loader(Loader), Custom(Strong), diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index a35e294457c..6444d34b9aa 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -338,5 +338,7 @@ const errors: ErrorCodeMapping = [ ["ERR_FS_CP_EEXIST", Error], ["ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY", Error], ["ERR_DIR_CONCURRENT_OPERATION", Error], + ["ERR_INVALID_TYPESCRIPT_SYNTAX", SyntaxError], + ["ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX", SyntaxError], ]; export default errors; diff --git a/src/jsc/modules/NodeModuleModule.cpp b/src/jsc/modules/NodeModuleModule.cpp index 1749ab1b64f..009956a8a19 100644 --- a/src/jsc/modules/NodeModuleModule.cpp +++ b/src/jsc/modules/NodeModuleModule.cpp @@ -20,6 +20,7 @@ #include "ZigGlobalObject.h" #include "headers.h" #include "ErrorCode.h" +#include "NodeValidator.h" #include "GeneratedNodeModuleModule.h" #include "ZigGeneratedClasses.h" @@ -879,6 +880,87 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionGetCompileCacheDir, return JSC::JSValue::encode(JSC::jsUndefined()); } +extern "C" JSC::EncodedJSValue NodeModuleModule__stripTypeScriptTypes(JSGlobalObject*, + BunString code, bool transformMode, bool sourceMap, BunString sourceUrl); + +JSC_DEFINE_HOST_FUNCTION(jsFunctionStripTypeScriptTypes, + (JSGlobalObject * globalObject, + JSC::CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue codeValue = callFrame->argument(0); + Bun::V::validateString(scope, globalObject, codeValue, "code"_s); + RETURN_IF_EXCEPTION(scope, {}); + + bool transformMode = false; + bool sourceMapEnabled = false; + String sourceUrl = emptyString(); + + JSValue optionsValue = callFrame->argument(1); + if (!optionsValue.isUndefined()) { + Bun::V::validateObject(scope, globalObject, optionsValue, "options"_s); + RETURN_IF_EXCEPTION(scope, {}); + + JSObject* options = asObject(optionsValue); + // Property read order matches Node's destructuring: sourceMap, + // sourceUrl, then mode. + JSValue sourceMapValue = options->get(globalObject, Identifier::fromString(vm, "sourceMap"_s)); + RETURN_IF_EXCEPTION(scope, {}); + JSValue sourceUrlValue = options->get(globalObject, Identifier::fromString(vm, "sourceUrl"_s)); + RETURN_IF_EXCEPTION(scope, {}); + JSValue modeValue = options->get(globalObject, Identifier::fromString(vm, "mode"_s)); + RETURN_IF_EXCEPTION(scope, {}); + + // validateOneOf(mode, 'options.mode', ['strip', 'transform']) + if (!modeValue.isUndefined()) { + bool isTransform = false; + bool isValid = false; + if (modeValue.isString()) { + auto modeView = asString(modeValue)->view(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + if (modeView == "strip"_s) { + isValid = true; + } else if (modeView == "transform"_s) { + isValid = true; + isTransform = true; + } + } + if (!isValid) { + return Bun::ERR::INVALID_ARG_VALUE(scope, globalObject, "options.mode"_s, modeValue, "must be one of: 'strip', 'transform'"_s); + } + transformMode = isTransform; + } + + if (!sourceMapValue.isUndefined()) { + Bun::V::validateBoolean(scope, globalObject, sourceMapValue, "options.sourceMap"_s); + RETURN_IF_EXCEPTION(scope, {}); + sourceMapEnabled = sourceMapValue.asBoolean(); + } + + if (!sourceUrlValue.isUndefined()) { + Bun::V::validateString(scope, globalObject, sourceUrlValue, "options.sourceUrl"_s); + RETURN_IF_EXCEPTION(scope, {}); + sourceUrl = asString(sourceUrlValue)->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + } + + // Source maps are only valid in transform mode; strip mode preserves + // no positions so Node rejects sourceMap: true there. + if (!transformMode && sourceMapEnabled) { + return Bun::ERR::INVALID_ARG_VALUE(scope, globalObject, "options.sourceMap"_s, sourceMapValue, "must be one of: false, undefined"_s); + } + } + + String code = asString(codeValue)->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + BunString codeBun = Bun::toString(code); + BunString sourceUrlBun = Bun::toString(sourceUrl); + + RELEASE_AND_RETURN(scope, NodeModuleModule__stripTypeScriptTypes(globalObject, codeBun, transformMode, sourceMapEnabled, sourceUrlBun)); +} + static JSValue getModuleObject(VM& vm, JSObject* moduleObject) { return moduleObject; @@ -910,6 +992,7 @@ prototype getModulePrototypeObject PropertyCallback register jsFunctionRegister Function 1 runMain moduleRunMain CustomAccessor SourceMap getSourceMapFunction PropertyCallback +stripTypeScriptTypes jsFunctionStripTypeScriptTypes Function 1 syncBuiltinESMExports jsFunctionSyncBuiltinESMExports Function 0 wrap jsFunctionWrap Function 1 wrapper nodeModuleWrapper CustomAccessor diff --git a/test/js/node/module/strip-typescript-types.test.ts b/test/js/node/module/strip-typescript-types.test.ts new file mode 100644 index 00000000000..ff93d6f2adb --- /dev/null +++ b/test/js/node/module/strip-typescript-types.test.ts @@ -0,0 +1,262 @@ +// https://github.com/oven-sh/bun/issues/32196 +import { expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; +import { stripTypeScriptTypes } from "node:module"; + +function errorFrom(fn: () => unknown): any { + try { + fn(); + } catch (e) { + return e; + } + throw new Error("expected function to throw"); +} + +test("named ESM import from node:module works (issue repro)", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `import { stripTypeScriptTypes } from 'node:module'; console.log(typeof stripTypeScriptTypes);`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("function\n"); + expect(exitCode).toBe(0); +}); + +test("is exported from require('node:module') and require('module')", () => { + expect(require("node:module").stripTypeScriptTypes).toBe(stripTypeScriptTypes); + expect(require("module").stripTypeScriptTypes).toBe(stripTypeScriptTypes); + expect(typeof stripTypeScriptTypes).toBe("function"); +}); + +test("strips type annotations", () => { + expect(stripTypeScriptTypes("const x: number = 1;")).toBe("const x = 1;\n"); + expect(stripTypeScriptTypes("")).toBe(""); + expect(stripTypeScriptTypes("interface A { x: number }\nconst y = 1;")).toBe("const y = 1;\n"); + expect(stripTypeScriptTypes("function id(x: T): T { return x satisfies T as T; }")).toBe( + "function id(x) {\n return x;\n}\n", + ); +}); + +test("stripped output evaluates", () => { + const out = stripTypeScriptTypes("const x: number = 2;\nconst y = x * 21;\nresult(y);"); + let captured: unknown; + new Function("result", out)((v: unknown) => (captured = v)); + expect(captured).toBe(42); +}); + +test("keeps value imports that are only used as types", () => { + // Node's strip mode does not know T is type-only, so the import survives. + expect(stripTypeScriptTypes('import { T } from "x"; const a: T = 1;')).toBe('import { T } from "x";\nconst a = 1;\n'); + // `import type` is erasable. + expect(stripTypeScriptTypes('import type { T } from "x"; const a: T = 1;')).toBe("const a = 1;\n"); +}); + +test("does not inline process.env", () => { + expect(stripTypeScriptTypes("console.log(process.env.NODE_ENV);")).toBe("console.log(process.env.NODE_ENV);\n"); +}); + +test("preserves a leading hashbang", () => { + const src = "#!/usr/bin/env node\nconst x: number = 1;"; + expect(stripTypeScriptTypes(src)).toBe("#!/usr/bin/env node\nconst x = 1;\n"); + expect(stripTypeScriptTypes(src, { mode: "transform" })).toBe("#!/usr/bin/env node\nconst x = 1;\n"); + const withUrl = stripTypeScriptTypes(src, { sourceUrl: "cli.ts" }); + expect(withUrl).toBe("#!/usr/bin/env node\nconst x = 1;\n\n\n//# sourceURL=cli.ts"); +}); + +test("source map accounts for the hashbang line", () => { + const out = stripTypeScriptTypes("#!/usr/bin/env node\nconst x: number = 1;", { + mode: "transform", + sourceMap: true, + }); + expect(out).toStartWith("#!/usr/bin/env node\nconst x = 1;\n"); + const base64 = out.split("base64,")[1]; + const map = JSON.parse(Buffer.from(base64, "base64").toString()); + // generated line 0 is the hashbang; mappings begin on line 1, matching + // Node's output for the same input + expect(map.mappings).toBe(";AACA,MAAM,IAAY"); +}); + +test("validates code argument", () => { + for (const bad of [42, null, undefined, {}, Symbol()] as const) { + const err = errorFrom(() => stripTypeScriptTypes(bad as any)); + expect(err).toBeInstanceOf(TypeError); + expect(err.code).toBe("ERR_INVALID_ARG_TYPE"); + expect(err.message).toStartWith('The "code" argument must be of type string.'); + } + expect(errorFrom(() => stripTypeScriptTypes(42 as any)).message).toBe( + 'The "code" argument must be of type string. Received type number (42)', + ); +}); + +test("validates options argument", () => { + for (const bad of [null, [], "strip", 42, () => {}] as const) { + const err = errorFrom(() => stripTypeScriptTypes("", bad as any)); + expect(err).toBeInstanceOf(TypeError); + expect(err.code).toBe("ERR_INVALID_ARG_TYPE"); + expect(err.message).toStartWith('The "options" argument must be of type object.'); + } + // undefined means "use defaults" + expect(stripTypeScriptTypes("", undefined)).toBe(""); +}); + +test("validates options.mode", () => { + for (const bad of ["bogus", 42, null, true] as const) { + const err = errorFrom(() => stripTypeScriptTypes("", { mode: bad as any })); + expect(err).toBeInstanceOf(TypeError); + expect(err.code).toBe("ERR_INVALID_ARG_VALUE"); + expect(err.message).toStartWith("The property 'options.mode' must be one of: 'strip', 'transform'."); + } + expect(errorFrom(() => stripTypeScriptTypes("", { mode: "bogus" as any })).message).toBe( + "The property 'options.mode' must be one of: 'strip', 'transform'. Received 'bogus'", + ); + // undefined falls back to 'strip' + expect(stripTypeScriptTypes("let a: string;", { mode: undefined })).toBe("let a;\n"); +}); + +test("validates options.sourceMap", () => { + const err = errorFrom(() => stripTypeScriptTypes("", { sourceMap: "yes" as any })); + expect(err).toBeInstanceOf(TypeError); + expect(err.code).toBe("ERR_INVALID_ARG_TYPE"); + expect(err.message).toBe(`The "options.sourceMap" property must be of type boolean. Received type string ('yes')`); + + // sourceMap: true is rejected in strip mode + const stripErr = errorFrom(() => stripTypeScriptTypes("", { sourceMap: true })); + expect(stripErr).toBeInstanceOf(TypeError); + expect(stripErr.code).toBe("ERR_INVALID_ARG_VALUE"); + expect(stripErr.message).toBe("The property 'options.sourceMap' must be one of: false, undefined. Received true"); + + // false/undefined are fine in strip mode + expect(stripTypeScriptTypes("let x: number = 1", { sourceMap: false })).toBe("let x = 1;\n"); + expect(stripTypeScriptTypes("let x: number = 1", { sourceMap: undefined })).toBe("let x = 1;\n"); +}); + +test("validates options.sourceUrl", () => { + const err = errorFrom(() => stripTypeScriptTypes("", { sourceUrl: 42 as any })); + expect(err).toBeInstanceOf(TypeError); + expect(err.code).toBe("ERR_INVALID_ARG_TYPE"); + expect(err.message).toBe(`The "options.sourceUrl" property must be of type string. Received type number (42)`); + expect(errorFrom(() => stripTypeScriptTypes("", { sourceUrl: null as any })).code).toBe("ERR_INVALID_ARG_TYPE"); +}); + +test.each([ + ["enum", "enum E { A }", "TypeScript enum is not supported in strip-only mode"], + ["const enum", "const enum E { A }", "TypeScript enum is not supported in strip-only mode"], + [ + "namespace", + "namespace N { export const x = 1 }", + "TypeScript namespace declaration is not supported in strip-only mode", + ], + [ + "parameter properties", + "class C { constructor(public x: number) {} }", + "TypeScript parameter property is not supported in strip-only mode", + ], + [ + "import equals", + 'import x = require("y");', + "TypeScript import equals declaration is not supported in strip-only mode", + ], + ["export assignment", "export = 1;", "TypeScript export assignment is not supported in strip-only mode"], + ["module keyword", "module N { export const x = 1 }", "`module` keyword is not supported. Use `namespace` instead."], +])("strip mode rejects %s", (_label, code, message) => { + const err = errorFrom(() => stripTypeScriptTypes(code)); + expect(err).toBeInstanceOf(SyntaxError); + expect(err.code).toBe("ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX"); + expect(err.message).toBe(message); +}); + +test("module keyword is rejected in transform mode too", () => { + const err = errorFrom(() => stripTypeScriptTypes("module N { export const x = 1 }", { mode: "transform" })); + expect(err).toBeInstanceOf(SyntaxError); + expect(err.code).toBe("ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX"); + expect(err.message).toBe("`module` keyword is not supported. Use `namespace` instead."); +}); + +test.each([ + ["declare enum", "declare enum E { A }"], + ["declare namespace containing an enum", "declare namespace N { enum E { A } }"], + ["type-only namespace", "namespace N { export type T = 1 }"], + ['declare module "name"', 'declare module "foo" { export = 1 }'], + ["declare class parameter properties", "declare class C { constructor(public x: number); }"], + ["declare global", "declare global { interface W {} }"], + ["import type equals", 'import type x = require("y");'], +])("strip mode allows ambient %s", (_label, code) => { + expect(stripTypeScriptTypes(code)).toBe(""); +}); + +test("strip mode reports invalid syntax as ERR_INVALID_TYPESCRIPT_SYNTAX", () => { + for (const code of ["const const", "const x =
;"]) { + const err = errorFrom(() => stripTypeScriptTypes(code)); + expect(err).toBeInstanceOf(SyntaxError); + expect(err.code).toBe("ERR_INVALID_TYPESCRIPT_SYNTAX"); + } +}); + +test("transform mode lowers enums", () => { + const out = stripTypeScriptTypes("enum E { A, B }\nresult(E);", { mode: "transform" }); + let captured: any; + new Function("result", out)((e: any) => (captured = e)); + expect(captured.A).toBe(0); + expect(captured.B).toBe(1); + expect(captured[0]).toBe("A"); +}); + +test("transform mode lowers namespaces", () => { + const out = stripTypeScriptTypes("namespace N { export const x = 42 }\nresult(N.x);", { mode: "transform" }); + let captured: unknown; + new Function("result", out)((v: unknown) => (captured = v)); + expect(captured).toBe(42); +}); + +test("transform mode lowers parameter properties", () => { + const out = stripTypeScriptTypes("class C { constructor(public x: number) {} }\nresult(new C(7).x);", { + mode: "transform", + }); + let captured: unknown; + new Function("result", out)((v: unknown) => (captured = v)); + expect(captured).toBe(7); +}); + +test("transform mode lowers export assignment and import equals", () => { + const out = stripTypeScriptTypes("import y = require('y');\nexport = y;", { mode: "transform" }); + const mod = { exports: {} as unknown }; + new Function("module", "exports", "require", out)(mod, mod.exports, (id: string) => `required:${id}`); + expect(mod.exports).toBe("required:y"); +}); + +test("sourceUrl appends a sourceURL comment", () => { + const out = stripTypeScriptTypes("const x: number = 1;", { sourceUrl: "foo.ts" }); + expect(out).toBe("const x = 1;\n\n\n//# sourceURL=foo.ts"); + // empty sourceUrl appends nothing + expect(stripTypeScriptTypes("const x: number = 1;", { sourceUrl: "" })).toBe("const x = 1;\n"); +}); + +test("transform mode emits an inline source map", () => { + const out = stripTypeScriptTypes("enum E { A }\nconst q: number = 1;", { + mode: "transform", + sourceMap: true, + sourceUrl: "foo.ts", + }); + const match = out.match(/\n\n\/\/# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)$/); + expect(match).not.toBeNull(); + const map = JSON.parse(Buffer.from(match![1], "base64").toString()); + expect(map.version).toBe(3); + expect(map.sources).toEqual(["foo.ts"]); + expect(map.names).toEqual([]); + expect(typeof map.mappings).toBe("string"); + expect(map.mappings.length).toBeGreaterThan(0); + // when a source map is generated, no sourceURL comment is appended + expect(out).not.toContain("//# sourceURL="); +}); + +test("source map without sourceUrl uses an empty source name", () => { + const out = stripTypeScriptTypes("const x: number = 1;", { mode: "transform", sourceMap: true }); + const base64 = out.split("base64,")[1]; + const map = JSON.parse(Buffer.from(base64, "base64").toString()); + expect(map.sources).toEqual([""]); +});