diff --git a/Cargo.lock b/Cargo.lock index 3192d55a58..18e775d53b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -486,6 +486,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -1391,15 +1402,30 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fc9a695bca7f35f5f4c15cddc84415f66a74ea78eef08e90c5024f2b540e23" +dependencies = [ + "crc-catalog 1.1.1", +] + [[package]] name = "crc" version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ - "crc-catalog", + "crc-catalog 2.5.0", ] +[[package]] +name = "crc-catalog" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccaeedb56da03b09f598226e25e80088cb4cd25f316e6e4df7d695f0feeb1403" + [[package]] name = "crc-catalog" version = "2.5.0" @@ -1698,6 +1724,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -2404,6 +2443,12 @@ dependencies = [ "writeable", ] +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flagset" version = "0.4.7" @@ -2928,7 +2973,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" dependencies = [ "cfg-if", - "dashmap", + "dashmap 6.2.1", "futures-sink", "futures-timer", "futures-util", @@ -5547,6 +5592,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pathfinder_geometry" version = "0.5.1" @@ -5788,7 +5839,7 @@ dependencies = [ "atty", "clap", "console 0.16.4", - "dashmap", + "dashmap 6.2.1", "dialoguer 0.11.0", "dotenvy", "hex", @@ -6214,16 +6265,20 @@ dependencies = [ name = "perry-ext-typescript" version = "0.5.1520" dependencies = [ + "anyhow", "perry-ffi", "serde", "serde_json", + "swc_bundler", "swc_common", "swc_ecma_ast", "swc_ecma_codegen", + "swc_ecma_loader", "swc_ecma_parser 29.0.2", "swc_ecma_transforms_base", "swc_ecma_transforms_react", "swc_ecma_transforms_typescript", + "swc_ecma_visit", ] [[package]] @@ -6280,7 +6335,7 @@ dependencies = [ name = "perry-ffi" version = "0.5.1520" dependencies = [ - "dashmap", + "dashmap 6.2.1", "once_cell", "perry-runtime", ] @@ -6399,7 +6454,7 @@ dependencies = [ "clap", "cron", "ctr", - "dashmap", + "dashmap 6.2.1", "ecb", "ed25519-dalek", "ed448-goldilocks", @@ -6699,6 +6754,16 @@ dependencies = [ "wasmi", ] +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + [[package]] name = "phf" version = "0.11.3" @@ -7316,6 +7381,12 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "radix_fmt" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce082a9940a7ace2ad4a8b7d0b1eac6aa378895f18be598230c5f2284ac05426" + [[package]] name = "rand" version = "0.8.7" @@ -7588,6 +7659,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "rend" version = "0.4.2" @@ -8585,7 +8662,7 @@ dependencies = [ "bytes", "cfg-if", "chrono", - "crc", + "crc 3.4.0", "crossbeam-queue", "either", "event-listener", @@ -8658,7 +8735,7 @@ dependencies = [ "byteorder", "bytes", "chrono", - "crc", + "crc 3.4.0", "digest 0.11.3", "dotenvy", "either", @@ -8688,7 +8765,7 @@ dependencies = [ "bitflags 2.12.1", "byteorder", "chrono", - "crc", + "crc 3.4.0", "dotenvy", "etcetera", "futures-channel", @@ -8865,6 +8942,35 @@ dependencies = [ "serde", ] +[[package]] +name = "swc_bundler" +version = "37.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04a684b04ebc0d8f04f019a362eff0d3cf2e7bad683710cc1c25eea2fffc4b2f" +dependencies = [ + "anyhow", + "crc 2.1.0", + "indexmap", + "is-macro", + "once_cell", + "petgraph", + "radix_fmt", + "relative-path", + "rustc-hash 2.1.2", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_codegen", + "swc_ecma_loader", + "swc_ecma_parser 29.0.2", + "swc_ecma_transforms_base", + "swc_ecma_transforms_optimization", + "swc_ecma_utils", + "swc_ecma_visit", + "swc_graph_analyzer", + "tracing", +] + [[package]] name = "swc_common" version = "18.0.1" @@ -8970,6 +9076,21 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "swc_ecma_loader" +version = "18.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b36b38399698e77c3d63838c2e32e40df0d5e55e489bef529a77a349a722c7a" +dependencies = [ + "anyhow", + "pathdiff", + "rustc-hash 2.1.2", + "serde", + "swc_atoms", + "swc_common", + "tracing", +] + [[package]] name = "swc_ecma_parser" version = "29.0.2" @@ -9034,6 +9155,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "swc_ecma_transforms_optimization" +version = "34.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7678b46f04eff0937a86bf98b2db3235818e10cfd63590653482302a905c3911" +dependencies = [ + "bytes-str", + "dashmap 5.5.3", + "indexmap", + "once_cell", + "par-core", + "petgraph", + "rustc-hash 2.1.2", + "serde_json", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_parser 29.0.2", + "swc_ecma_transforms_base", + "swc_ecma_utils", + "swc_ecma_visit", + "tracing", +] + [[package]] name = "swc_ecma_transforms_react" version = "35.0.0" @@ -9121,6 +9266,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "swc_graph_analyzer" +version = "14.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5524b7dd51c772d1154feb46d177c0d938d32df8c6a1546cc71f89ce47895d2f" +dependencies = [ + "auto_impl", + "petgraph", + "rustc-hash 2.1.2", + "tracing", +] + [[package]] name = "swc_macros_common" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 7652038146..7ee6c9a59c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -365,6 +365,8 @@ swc_ecma_ast = "19.0" swc_ecma_visit = "19.0" swc_common = "18.0" swc_ecma_codegen = "21.0" +swc_bundler = "37.0" +swc_ecma_loader = "18.0" swc_ecma_transforms_base = "32.0" swc_ecma_transforms_react = "35.0" swc_ecma_transforms_typescript = "35.0" diff --git a/changelog.d/9602-bun-runtime-compiler.md b/changelog.d/9602-bun-runtime-compiler.md new file mode 100644 index 0000000000..18d7135dcc --- /dev/null +++ b/changelog.d/9602-bun-runtime-compiler.md @@ -0,0 +1,7 @@ +### Added + +- Added the native `Bun.Transpiler` and in-memory `Bun.build` subsets used by + runtime hook modules, including TypeScript/JSX lowering, import scanning, + external modules, and `onResolve`/`onLoad` plugin hooks. Build failures now + return useful filename and source-position logs instead of terminating the + process. (#9602) diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs index 0daeb23e82..728688e4fc 100644 --- a/crates/perry-api-manifest/src/entries/part_4.rs +++ b/crates/perry-api-manifest/src/entries/part_4.rs @@ -1100,6 +1100,13 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ // (Zig-std wyhash, BigInt result), and the module-level // `pathToFileURL` / `fileURLToPath` aliases of node:url. // Implementation: perry-runtime `bun_compat`. + class("bun", "Transpiler"), + method("bun", "Transpiler", false, None), + method("bun", "build", false, None), + method("bun", "transformSync", true, Some("Transpiler")), + method("bun", "transform", true, Some("Transpiler")), + method("bun", "scanImports", true, Some("Transpiler")), + method("bun", "scan", true, Some("Transpiler")), method("bun", "stringWidth", false, None), method("bun", "hash", false, None), method("bun", "file", false, None), diff --git a/crates/perry-codegen/src/ext_registry.rs b/crates/perry-codegen/src/ext_registry.rs index 130638c209..d88efcad51 100644 --- a/crates/perry-codegen/src/ext_registry.rs +++ b/crates/perry-codegen/src/ext_registry.rs @@ -644,6 +644,9 @@ const EXT_PREFIX_REGISTRY: &[(&str, &str)] = &[ ("js_node_forge_", "node-forge"), // Native runtime TypeScript transpilation subset (#8511). ("js_typescript_", "typescript"), + // Bun runtime transpilation/build subset shares the pinned SWC wrapper. + ("js_bun_transpiler_", "typescript"), + ("js_bun_build", "typescript"), ("js_qs_", "qs"), ]; diff --git a/crates/perry-codegen/src/lower_call/builtin.rs b/crates/perry-codegen/src/lower_call/builtin.rs index abef1bbf5f..3996ef9dab 100644 --- a/crates/perry-codegen/src/lower_call/builtin.rs +++ b/crates/perry-codegen/src/lower_call/builtin.rs @@ -125,6 +125,7 @@ pub(super) fn lower_builtin_new<'a>( "Decimal" => Some(&["decimal.js"]), "RateLimiterMemory" => Some(&["rate-limiter-flexible"]), "CronJob" => Some(&["cron", "node-cron"]), + "Transpiler" => Some(&["bun"]), _ => None, }; if let Some(sources) = required_sources { @@ -133,6 +134,14 @@ pub(super) fn lower_builtin_new<'a>( } } match class_name { + "Transpiler" => { + let options = adopt_leading_arg_discard_rest(ctx, args, group)?; + ctx.pending_declares + .push(("js_bun_transpiler_new".to_string(), I64, vec![DOUBLE])); + let block = ctx.block(); + let handle = block.call(I64, "js_bun_transpiler_new", &[(DOUBLE, &options)]); + Ok(Some(nanbox_pointer_inline(block, &handle))) + } "Resolver" if import_src.is_some_and(|source| { matches!( diff --git a/crates/perry-codegen/src/lower_call/native_table/bun.rs b/crates/perry-codegen/src/lower_call/native_table/bun.rs index dd183e07ab..3cb3ae4f63 100644 --- a/crates/perry-codegen/src/lower_call/native_table/bun.rs +++ b/crates/perry-codegen/src/lower_call/native_table/bun.rs @@ -8,6 +8,60 @@ use super::*; /// `Bun.stdin` / `Bun.stdout` / `Bun.stderr` are property reads (handled by /// `js_native_module_property_by_name`), not rows here. pub(crate) const BUN_ROWS: &[NativeModSig] = &[ + NativeModSig { + module: "bun", + has_receiver: false, + method: "Transpiler", + class_filter: None, + runtime: "js_bun_transpiler_new", + args: &[NA_F64], + ret: NR_PTR, + }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "build", + class_filter: None, + runtime: "js_bun_build", + args: &[NA_F64], + ret: NR_PROMISE, + }, + NativeModSig { + module: "bun", + has_receiver: true, + method: "transformSync", + class_filter: Some("Transpiler"), + runtime: "js_bun_transpiler_transform_sync", + args: &[NA_STR, NA_STR], + ret: NR_STR, + }, + NativeModSig { + module: "bun", + has_receiver: true, + method: "transform", + class_filter: Some("Transpiler"), + runtime: "js_bun_transpiler_transform", + args: &[NA_STR, NA_STR], + ret: NR_PROMISE, + }, + NativeModSig { + module: "bun", + has_receiver: true, + method: "scanImports", + class_filter: Some("Transpiler"), + runtime: "js_bun_transpiler_scan_imports", + args: &[NA_STR], + ret: NR_OBJ_FROM_JSON_STR, + }, + NativeModSig { + module: "bun", + has_receiver: true, + method: "scan", + class_filter: Some("Transpiler"), + runtime: "js_bun_transpiler_scan", + args: &[NA_STR], + ret: NR_OBJ_FROM_JSON_STR, + }, NativeModSig { module: "bun", has_receiver: false, diff --git a/crates/perry-ext-typescript/Cargo.toml b/crates/perry-ext-typescript/Cargo.toml index a23bcb2eff..0fe5f0592c 100644 --- a/crates/perry-ext-typescript/Cargo.toml +++ b/crates/perry-ext-typescript/Cargo.toml @@ -12,15 +12,19 @@ workspace = true crate-type = ["staticlib", "rlib"] [dependencies] +anyhow.workspace = true perry-ffi.workspace = true serde.workspace = true serde_json.workspace = true +swc_bundler.workspace = true swc_common.workspace = true swc_ecma_ast.workspace = true swc_ecma_codegen.workspace = true +swc_ecma_loader.workspace = true # Keep this aligned with the parser generation used by the SWC transform # crates below so native applications do not link two parser copies. swc_ecma_parser = "29.0" swc_ecma_transforms_base.workspace = true swc_ecma_transforms_react.workspace = true swc_ecma_transforms_typescript.workspace = true +swc_ecma_visit.workspace = true diff --git a/crates/perry-ext-typescript/src/bun.rs b/crates/perry-ext-typescript/src/bun.rs new file mode 100644 index 0000000000..bb9878793b --- /dev/null +++ b/crates/perry-ext-typescript/src/bun.rs @@ -0,0 +1,1417 @@ +//! Bun runtime transpiler and in-memory build compatibility (#9602). +//! +//! This lives beside the TypeScript wrapper because both APIs need the same +//! pinned SWC generation. Keeping it out of `perry-runtime` also means apps +//! which never ask for runtime compilation do not carry a bundler. + +use std::collections::HashMap; +use std::ffi::c_void; +use std::fmt; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, Once}; + +use anyhow::{anyhow, Context, Error}; +use perry_ffi::{ + alloc_closure, alloc_null_proto_object, alloc_string, closure_capture_f64, drop_handle, + gc_register_mutable_root_scanner_named, get_handle, iter_handles_of_mut, js_array_alloc, + js_array_get, js_array_length, js_array_push, json_stringify, object_field_by_name, + read_string, register_closure_arity, register_handle, set_closure_capture_f64, with_handle, + with_handle_mut, ArrayHeader, GcRootVisitor, Handle, JsClosure, JsPromise, JsString, JsValue, + Promise, RawClosureHeader, StringHeader, TransientRootScope, +}; +use serde::{Deserialize, Serialize}; +use swc_bundler::{ + BundleKind, Bundler, Config as BundleConfig, Hook, Load, ModuleData, ModuleRecord, ModuleType, + Resolve, +}; +use swc_common::{ + comments::SingleThreadedComments, sync::Lrc, FileName, Globals, Mark, SourceMap, Span, Spanned, + GLOBALS, +}; +use swc_ecma_ast::{ + Callee, Decl, EsVersion, ExportSpecifier, Expr, Lit, MemberProp, Module, ModuleDecl, + ModuleExportName, ModuleItem, Pass, Pat, Program, +}; +use swc_ecma_codegen::{text_writer::JsWriter, Config as CodegenConfig, Emitter}; +use swc_ecma_loader::resolve::Resolution; +use swc_ecma_parser::{lexer::Lexer, EsSyntax, Parser, StringInput, Syntax, TsSyntax}; +use swc_ecma_transforms_base::{helpers::Helpers, resolver}; +use swc_ecma_transforms_react::{react, Options as ReactOptions, Runtime as ReactRuntime}; +use swc_ecma_transforms_typescript::{tsx, typescript, Config as TypeScriptConfig, TsxConfig}; +use swc_ecma_visit::{Visit, VisitWith}; + +const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; +const PLUGIN_FILE_PREFIX: &str = "perry-bun-plugin:"; + +extern "C" { + fn js_get_string_pointer_unified(value: f64) -> *mut StringHeader; + fn js_regexp_test(regexp: *const c_void, value: *const StringHeader) -> i32; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BunLoader { + Js, + Jsx, + Ts, + Tsx, +} + +impl BunLoader { + fn parse(value: &str) -> Result { + match value { + "js" | "mjs" | "cjs" => Ok(Self::Js), + "jsx" => Ok(Self::Jsx), + "ts" | "mts" | "cts" => Ok(Self::Ts), + "tsx" => Ok(Self::Tsx), + other => Err(format!( + "Unsupported Bun loader '{other}'; expected js, jsx, ts, or tsx" + )), + } + } + + fn for_path(path: &str) -> Self { + let bare = path.split(['?', '#']).next().unwrap_or(path); + match Path::new(bare) + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "ts" | "mts" | "cts" => Self::Ts, + "tsx" => Self::Tsx, + "jsx" => Self::Jsx, + _ => Self::Js, + } + } + + fn is_typescript(self) -> bool { + matches!(self, Self::Ts | Self::Tsx) + } + + fn has_jsx(self) -> bool { + matches!(self, Self::Jsx | Self::Tsx) + } +} + +#[derive(Clone, Debug)] +struct BunDiagnostic { + message: String, + file: Option, + line: Option, + column: Option, +} + +impl BunDiagnostic { + fn message(message: impl Into) -> Self { + Self { + message: message.into(), + file: None, + line: None, + column: None, + } + } + + fn parse( + file: &str, + source: &str, + source_start: u32, + span: Span, + message: impl Into, + ) -> Self { + let byte_offset = span.lo.0.saturating_sub(source_start) as usize; + let prefix = source + .get(..byte_offset.min(source.len())) + .unwrap_or(source); + let line = prefix.bytes().filter(|byte| *byte == b'\n').count() as u32 + 1; + let column = prefix + .rsplit_once('\n') + .map_or(prefix.len(), |(_, tail)| tail.len()) as u32 + + 1; + Self { + message: message.into(), + file: Some(file.to_string()), + line: Some(line), + column: Some(column), + } + } + + fn render(&self) -> String { + match (&self.file, self.line, self.column) { + (Some(file), Some(line), Some(column)) => { + format!("{file}:{line}:{column}: {}", self.message) + } + _ => self.message.clone(), + } + } +} + +impl fmt::Display for BunDiagnostic { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.render()) + } +} + +impl std::error::Error for BunDiagnostic {} + +#[derive(Clone, Copy)] +struct BunTranspiler { + loader: BunLoader, + repl_mode: bool, +} + +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BunTranspilerOptions { + loader: Option, + #[serde(default)] + repl_mode: bool, +} + +fn syntax_for(loader: BunLoader, repl_mode: bool) -> Syntax { + if loader.is_typescript() { + Syntax::Typescript(TsSyntax { + tsx: loader.has_jsx(), + decorators: true, + dts: false, + no_early_errors: false, + disallow_ambiguous_jsx_like: false, + }) + } else { + Syntax::Es(EsSyntax { + jsx: loader.has_jsx(), + decorators: true, + allow_return_outside_function: repl_mode, + ..Default::default() + }) + } +} + +fn parse_bun_module( + cm: &Lrc, + file_name: FileName, + display_name: &str, + source: &str, + loader: BunLoader, + repl_mode: bool, + comments: Option<&SingleThreadedComments>, +) -> Result<(Lrc, Module), BunDiagnostic> { + let source_file = cm.new_source_file(file_name.into(), source.to_string()); + let lexer = Lexer::new( + syntax_for(loader, repl_mode), + EsVersion::EsNext, + StringInput::from(&*source_file), + comments.map(|value| value as _), + ); + let mut parser = Parser::new_from(lexer); + let module = parser.parse_module().map_err(|error| { + BunDiagnostic::parse( + display_name, + source, + source_file.start_pos.0, + error.span(), + error.kind().msg().to_string(), + ) + })?; + if let Some(error) = parser.take_errors().into_iter().next() { + return Err(BunDiagnostic::parse( + display_name, + source, + source_file.start_pos.0, + error.span(), + error.kind().msg().to_string(), + )); + } + Ok((source_file, module)) +} + +fn lower_bun_syntax( + cm: &Lrc, + comments: &SingleThreadedComments, + module: Module, + loader: BunLoader, +) -> Module { + let mut program = Program::Module(module); + let unresolved_mark = Mark::new(); + let top_level_mark = Mark::new(); + resolver(unresolved_mark, top_level_mark, true).process(&mut program); + if loader.is_typescript() { + if loader.has_jsx() { + tsx( + cm.clone(), + TypeScriptConfig::default(), + TsxConfig::default(), + comments.clone(), + unresolved_mark, + top_level_mark, + ) + .process(&mut program); + } else { + typescript(TypeScriptConfig::default(), unresolved_mark, top_level_mark) + .process(&mut program); + } + } + if loader.has_jsx() { + let mut options = ReactOptions { + runtime: Some(ReactRuntime::Classic), + ..Default::default() + }; + options.next = Some(false); + react( + cm.clone(), + Some(comments.clone()), + options, + top_level_mark, + unresolved_mark, + ) + .process(&mut program); + } + match program { + Program::Module(module) => module, + Program::Script(_) => unreachable!("Bun transpilation always parses a module"), + } +} + +fn emit_module( + cm: Lrc, + comments: Option<&SingleThreadedComments>, + module: &Module, + minify: bool, +) -> Result { + let mut output = Vec::new(); + let mut emitter = Emitter { + cfg: CodegenConfig::default() + .with_target(EsVersion::EsNext) + .with_minify(minify), + cm: cm.clone(), + comments: comments.map(|value| value as _), + wr: JsWriter::new(cm, "\n", &mut output, None), + }; + emitter + .emit_module(module) + .map_err(|error| BunDiagnostic::message(format!("Failed to emit JavaScript: {error}")))?; + String::from_utf8(output) + .map_err(|error| BunDiagnostic::message(format!("Invalid emitted UTF-8: {error}"))) +} + +fn transform_bun_source( + source: &str, + file_name: &str, + loader: BunLoader, + repl_mode: bool, +) -> Result { + let cm: Lrc = Default::default(); + let comments = SingleThreadedComments::default(); + GLOBALS.set(&Globals::new(), || { + let (_, module) = parse_bun_module( + &cm, + FileName::Custom(file_name.to_string()), + file_name, + source, + loader, + repl_mode, + Some(&comments), + )?; + let module = lower_bun_syntax(&cm, &comments, module, loader); + emit_module(cm.clone(), Some(&comments), &module, false) + }) +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +struct ScannedImport { + path: String, + kind: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +struct ScanResult { + exports: Vec, + imports: Vec, +} + +#[derive(Default)] +struct CallImportScanner { + imports: Vec<(u32, ScannedImport)>, +} + +impl CallImportScanner { + fn string_argument(call: &swc_ecma_ast::CallExpr) -> Option { + let argument = call.args.first()?; + if argument.spread.is_some() { + return None; + } + match argument.expr.as_ref() { + Expr::Lit(Lit::Str(value)) => Some(value.value.to_string_lossy().into_owned()), + _ => None, + } + } +} + +impl Visit for CallImportScanner { + fn visit_call_expr(&mut self, call: &swc_ecma_ast::CallExpr) { + let kind = match &call.callee { + Callee::Import(_) => Some("dynamic-import"), + Callee::Expr(callee) => match callee.as_ref() { + Expr::Ident(ident) if ident.sym == *"require" => Some("require-call"), + Expr::Member(member) + if matches!(member.obj.as_ref(), Expr::Ident(ident) if ident.sym == *"require") + && matches!(&member.prop, MemberProp::Ident(ident) if ident.sym == *"resolve") => + { + Some("require-resolve") + } + _ => None, + }, + _ => None, + }; + if let (Some(kind), Some(path)) = (kind, Self::string_argument(call)) { + self.imports.push(( + call.span.lo.0, + ScannedImport { + path, + kind: kind.to_string(), + }, + )); + } + call.visit_children_with(self); + } +} + +fn export_name(name: &ModuleExportName) -> String { + match name { + ModuleExportName::Ident(ident) => ident.sym.to_string(), + ModuleExportName::Str(value) => value.value.to_string_lossy().into_owned(), + } +} + +fn pattern_names(pattern: &Pat, output: &mut Vec) { + match pattern { + Pat::Ident(ident) => output.push(ident.id.sym.to_string()), + Pat::Array(array) => { + for element in array.elems.iter().flatten() { + pattern_names(element, output); + } + } + Pat::Object(object) => { + for property in &object.props { + match property { + swc_ecma_ast::ObjectPatProp::KeyValue(value) => { + pattern_names(&value.value, output) + } + swc_ecma_ast::ObjectPatProp::Assign(value) => { + output.push(value.key.sym.to_string()) + } + swc_ecma_ast::ObjectPatProp::Rest(value) => pattern_names(&value.arg, output), + } + } + } + Pat::Rest(rest) => pattern_names(&rest.arg, output), + Pat::Assign(assign) => pattern_names(&assign.left, output), + Pat::Invalid(_) | Pat::Expr(_) => {} + } +} + +fn declaration_exports(declaration: &Decl, output: &mut Vec) { + match declaration { + Decl::Class(class) => output.push(class.ident.sym.to_string()), + Decl::Fn(function) => output.push(function.ident.sym.to_string()), + Decl::Var(variable) => { + for declaration in &variable.decls { + pattern_names(&declaration.name, output); + } + } + _ => {} + } +} + +fn scan_bun_source( + source: &str, + file_name: &str, + loader: BunLoader, + repl_mode: bool, +) -> Result { + let cm: Lrc = Default::default(); + GLOBALS.set(&Globals::new(), || { + let (_, module) = parse_bun_module( + &cm, + FileName::Custom(file_name.to_string()), + file_name, + source, + loader, + repl_mode, + None, + )?; + let mut imports = Vec::new(); + let mut exports = Vec::new(); + for item in &module.body { + let ModuleItem::ModuleDecl(declaration) = item else { + continue; + }; + match declaration { + ModuleDecl::Import(import) if !import.type_only => imports.push(( + import.span.lo.0, + ScannedImport { + path: import.src.value.to_string_lossy().into_owned(), + kind: "import-statement".to_string(), + }, + )), + ModuleDecl::ExportDecl(export) => declaration_exports(&export.decl, &mut exports), + ModuleDecl::ExportNamed(export) => { + if let Some(source) = &export.src { + imports.push(( + export.span.lo.0, + ScannedImport { + path: source.value.to_string_lossy().into_owned(), + kind: "import-statement".to_string(), + }, + )); + } + if !export.type_only { + for specifier in &export.specifiers { + match specifier { + ExportSpecifier::Named(named) if !named.is_type_only => exports + .push(export_name( + named.exported.as_ref().unwrap_or(&named.orig), + )), + ExportSpecifier::Default(default) => { + exports.push(default.exported.sym.to_string()) + } + ExportSpecifier::Namespace(namespace) => { + exports.push(export_name(&namespace.name)) + } + _ => {} + } + } + } + } + ModuleDecl::ExportDefaultDecl(_) | ModuleDecl::ExportDefaultExpr(_) => { + exports.push("default".to_string()) + } + ModuleDecl::ExportAll(export) if !export.type_only => imports.push(( + export.span.lo.0, + ScannedImport { + path: export.src.value.to_string_lossy().into_owned(), + kind: "import-statement".to_string(), + }, + )), + _ => {} + } + } + let mut calls = CallImportScanner::default(); + module.visit_with(&mut calls); + imports.extend(calls.imports); + imports.sort_by_key(|(position, _)| *position); + exports.dedup(); + Ok(ScanResult { + exports, + imports: imports.into_iter().map(|(_, value)| value).collect(), + }) + }) +} + +fn read_header_string(pointer: *const StringHeader) -> String { + if pointer.is_null() { + return String::new(); + } + unsafe { read_string(JsString::from_raw(pointer as *mut StringHeader)) } + .map(str::to_owned) + .unwrap_or_default() +} + +fn transpiler_for(handle: Handle) -> Result { + get_handle::(handle) + .copied() + .ok_or_else(|| "Invalid Bun.Transpiler receiver".to_string()) +} + +fn loader_override(pointer: *const StringHeader) -> Result, String> { + if pointer.is_null() { + return Ok(None); + } + BunLoader::parse(&read_header_string(pointer)).map(Some) +} + +fn throw_transpiler_error(error: BunDiagnostic) -> ! { + perry_ffi::throw_with_code( + &error.render(), + "BUN_TRANSPILE_ERROR", + perry_ffi::ErrorKind::Error, + ) +} + +/// Construct the native state behind `new Bun.Transpiler(options)`. +#[no_mangle] +pub extern "C" fn js_bun_transpiler_new(options: f64) -> Handle { + let value = JsValue::from_bits(options.to_bits()); + let parsed = if value.is_undefined() || value.is_null() { + BunTranspilerOptions::default() + } else { + let json = json_stringify(value).unwrap_or_default(); + serde_json::from_str::(&json).unwrap_or_else(|error| { + perry_ffi::throw_with_code( + &format!("Invalid Bun.Transpiler options: {error}"), + "ERR_INVALID_ARG_VALUE", + perry_ffi::ErrorKind::TypeError, + ) + }) + }; + let loader = parsed + .loader + .as_deref() + .map(BunLoader::parse) + .transpose() + .unwrap_or_else(|message| { + perry_ffi::throw_with_code( + &message, + "ERR_INVALID_ARG_VALUE", + perry_ffi::ErrorKind::TypeError, + ) + }) + .unwrap_or(BunLoader::Js); + register_handle(BunTranspiler { + loader, + repl_mode: parsed.repl_mode, + }) +} + +/// `transpiler.transformSync(source, loader?)`. +#[no_mangle] +pub extern "C" fn js_bun_transpiler_transform_sync( + handle: Handle, + source: *const StringHeader, + loader: *const StringHeader, +) -> *mut StringHeader { + let transpiler = transpiler_for(handle).unwrap_or_else(|message| { + perry_ffi::throw_with_code( + &message, + "ERR_INVALID_THIS", + perry_ffi::ErrorKind::TypeError, + ) + }); + let loader = loader_override(loader) + .unwrap_or_else(|message| { + perry_ffi::throw_with_code( + &message, + "ERR_INVALID_ARG_VALUE", + perry_ffi::ErrorKind::TypeError, + ) + }) + .unwrap_or(transpiler.loader); + let source = read_header_string(source); + match transform_bun_source(&source, "", loader, transpiler.repl_mode) { + Ok(output) => alloc_string(&output).as_raw(), + Err(error) => throw_transpiler_error(error), + } +} + +/// Promise-returning `transpiler.transform(source, loader?)`. +#[no_mangle] +pub extern "C" fn js_bun_transpiler_transform( + handle: Handle, + source: *const StringHeader, + loader: *const StringHeader, +) -> *mut Promise { + let result = transpiler_for(handle).and_then(|transpiler| { + let loader = loader_override(loader)?.unwrap_or(transpiler.loader); + let source = read_header_string(source); + transform_bun_source(&source, "", loader, transpiler.repl_mode) + .map_err(|error| error.render()) + }); + let promise = JsPromise::new(); + let raw = promise.as_raw(); + match result { + Ok(output) => promise.resolve_string(&output), + Err(error) => promise.reject_string(&error), + } + raw +} + +fn scan_for_handle(handle: Handle, source: *const StringHeader) -> Result { + let transpiler = transpiler_for(handle)?; + scan_bun_source( + &read_header_string(source), + "", + transpiler.loader, + transpiler.repl_mode, + ) + .map_err(|error| error.render()) +} + +/// `transpiler.scanImports(source)`; codegen parses the returned JSON. +#[no_mangle] +pub extern "C" fn js_bun_transpiler_scan_imports( + handle: Handle, + source: *const StringHeader, +) -> *mut StringHeader { + let result = scan_for_handle(handle, source).unwrap_or_else(|message| { + perry_ffi::throw_with_code(&message, "BUN_TRANSPILE_ERROR", perry_ffi::ErrorKind::Error) + }); + let json = serde_json::to_string(&result.imports).unwrap_or_else(|_| "[]".to_string()); + alloc_string(&json).as_raw() +} + +/// `transpiler.scan(source)`; codegen parses the returned JSON. +#[no_mangle] +pub extern "C" fn js_bun_transpiler_scan( + handle: Handle, + source: *const StringHeader, +) -> *mut StringHeader { + let result = scan_for_handle(handle, source).unwrap_or_else(|message| { + perry_ffi::throw_with_code(&message, "BUN_TRANSPILE_ERROR", perry_ffi::ErrorKind::Error) + }); + let json = serde_json::to_string(&result) + .unwrap_or_else(|_| "{\"exports\":[],\"imports\":[]}".to_string()); + alloc_string(&json).as_raw() +} + +#[derive(Clone)] +struct PluginHook { + filter: i64, + callback: i64, + namespace: Option, +} + +#[derive(Default)] +struct BuildHookSession { + resolve: Vec, + load: Vec, +} + +static PLUGIN_GC_REGISTERED: Once = Once::new(); +static NATIVE_CLOSURE_ARITIES_REGISTERED: Once = Once::new(); + +fn ensure_build_runtime_registered() { + PLUGIN_GC_REGISTERED.call_once(|| { + gc_register_mutable_root_scanner_named( + "perry-ext-typescript:bun-build", + scan_build_hook_roots, + ); + }); + NATIVE_CLOSURE_ARITIES_REGISTERED.call_once(|| { + register_closure_arity(bun_on_resolve as *const u8, 2); + register_closure_arity(bun_on_load as *const u8, 2); + register_closure_arity(bun_build_output_text as *const u8, 0); + }); +} + +fn scan_build_hook_roots(visitor: &mut GcRootVisitor<'_>) { + iter_handles_of_mut::(|session| { + for hook in session.resolve.iter_mut().chain(session.load.iter_mut()) { + visitor.visit_i64_slot(&mut hook.filter); + visitor.visit_i64_slot(&mut hook.callback); + } + }); +} + +fn raw_heap_address(value: f64) -> i64 { + let bits = value.to_bits(); + if bits >> 48 == 0x7FFD { + (bits & POINTER_MASK) as i64 + } else if bits >> 48 == 0 && bits >= 0x10000 { + bits as i64 + } else { + 0 + } +} + +fn string_value(value: JsValue) -> Option { + if !value.is_any_string() { + return None; + } + let pointer = unsafe { js_get_string_pointer_unified(f64::from_bits(value.bits())) }; + if pointer.is_null() { + return None; + } + unsafe { read_string(JsString::from_raw(pointer)) }.map(str::to_owned) +} + +fn field(value: f64, name: &str) -> JsValue { + object_field_by_name(JsValue::from_bits(value.to_bits()), name) +} + +fn register_plugin_hook( + closure: *const RawClosureHeader, + options: f64, + callback: f64, + resolve_hook: bool, +) -> f64 { + let session = unsafe { closure_capture_f64(closure, 0) } as Handle; + let scope = TransientRootScope::enter(); + let options = scope.root_nanbox(options); + let callback = scope.root_nanbox(callback); + let filter = scope.root_nanbox(f64::from_bits(field(options.get(), "filter").bits())); + let filter = raw_heap_address(filter.get()); + let callback = raw_heap_address(callback.get()); + if filter == 0 || callback == 0 { + perry_ffi::throw_with_code( + "Bun plugin hooks require a RegExp filter and callback", + "ERR_INVALID_ARG_TYPE", + perry_ffi::ErrorKind::TypeError, + ); + } + let namespace = string_value(field(options.get(), "namespace")); + let hook = PluginHook { + filter, + callback, + namespace, + }; + let inserted = with_handle_mut::(session, |hooks| { + if resolve_hook { + hooks.resolve.push(hook); + } else { + hooks.load.push(hook); + } + }) + .is_some(); + if !inserted { + perry_ffi::throw_with_code( + "Bun plugin setup used an expired build context", + "ERR_INVALID_STATE", + perry_ffi::ErrorKind::Error, + ); + } + f64::from_bits(JsValue::UNDEFINED.bits()) +} + +extern "C" fn bun_on_resolve(closure: *const RawClosureHeader, options: f64, callback: f64) -> f64 { + register_plugin_hook(closure, options, callback, true) +} + +extern "C" fn bun_on_load(closure: *const RawClosureHeader, options: f64, callback: f64) -> f64 { + register_plugin_hook(closure, options, callback, false) +} + +fn native_closure_value(function: *const u8, session: Handle) -> JsValue { + let closure = alloc_closure(function, 1); + if closure.is_null() { + return JsValue::UNDEFINED; + } + unsafe { set_closure_capture_f64(closure, 0, session as f64) }; + JsValue::from_object_ptr(closure) +} + +fn configure_plugins(options: f64) -> Handle { + ensure_build_runtime_registered(); + let session = register_handle(BuildHookSession::default()); + let scope = TransientRootScope::enter(); + let options = scope.root_nanbox(options); + let plugins = scope.root_nanbox(f64::from_bits(field(options.get(), "plugins").bits())); + let array = JsValue::from_bits(plugins.get().to_bits()).as_pointer::(); + if array.is_null() { + return session; + } + let length = unsafe { js_array_length(array) }; + let mut callbacks = Vec::new(); + for index in 0..length { + let array = JsValue::from_bits(plugins.get().to_bits()).as_pointer::(); + let plugin = + scope.root_nanbox(f64::from_bits(unsafe { js_array_get(array, index) }.bits())); + let setup = field(plugin.get(), "setup"); + let pointer = raw_heap_address(f64::from_bits(setup.bits())); + if pointer != 0 { + callbacks.push(scope.root_addr(pointer)); + } + } + if callbacks.is_empty() { + return session; + } + let on_resolve = scope.root_nanbox(f64::from_bits( + native_closure_value(bun_on_resolve as *const u8, session).bits(), + )); + let on_load = scope.root_nanbox(f64::from_bits( + native_closure_value(bun_on_load as *const u8, session).bits(), + )); + let builder = scope.root_nanbox(f64::from_bits( + alloc_null_proto_object(&[ + ("onResolve", JsValue::from_bits(on_resolve.get().to_bits())), + ("onLoad", JsValue::from_bits(on_load.get().to_bits())), + ]) + .bits(), + )); + for callback in callbacks { + let closure = unsafe { JsClosure::from_raw(callback.get() as *const RawClosureHeader) }; + unsafe { closure.call1(builder.get()) }; + } + session +} + +fn hook_matches(hook: &PluginHook, path: &str, namespace: &str) -> bool { + if hook + .namespace + .as_deref() + .is_some_and(|expected| expected != namespace) + { + return false; + } + let scope = TransientRootScope::enter(); + let filter = scope.root_addr(hook.filter); + let path = alloc_string(path); + unsafe { js_regexp_test(filter.get() as *const c_void, path.as_raw()) != 0 } +} + +fn hooks_for(session: Handle, resolve: bool) -> Vec { + with_handle::(session, |hooks| { + if resolve { + hooks.resolve.clone() + } else { + hooks.load.clone() + } + }) + .unwrap_or_default() +} + +fn callback_args(fields: &[(&str, &str)]) -> f64 { + let values = fields + .iter() + .map(|(name, value)| { + ( + *name, + JsValue::from_string_ptr(alloc_string(value).as_raw()), + ) + }) + .collect::>(); + f64::from_bits(alloc_null_proto_object(&values).bits()) +} + +fn invoke_hook(hook: &PluginHook, args: f64) -> JsValue { + let scope = TransientRootScope::enter(); + let callback = scope.root_addr(hook.callback); + let args = scope.root_nanbox(args); + let closure = unsafe { JsClosure::from_raw(callback.get() as *const RawClosureHeader) }; + JsValue::from_bits(unsafe { closure.call1(args.get()) }.to_bits()) +} + +fn file_parts(file: &FileName) -> (String, String) { + match file { + FileName::Real(path) => ("file".to_string(), path.to_string_lossy().into_owned()), + FileName::Custom(value) => { + decode_plugin_file(value).unwrap_or_else(|| ("file".to_string(), value.to_string())) + } + other => ("file".to_string(), other.to_string()), + } +} + +fn encode_plugin_file(namespace: &str, path: &str) -> FileName { + FileName::Custom(format!("{PLUGIN_FILE_PREFIX}{namespace}\u{1f}{path}")) +} + +fn decode_plugin_file(value: &str) -> Option<(String, String)> { + let value = value.strip_prefix(PLUGIN_FILE_PREFIX)?; + let (namespace, path) = value.split_once('\u{1f}')?; + Some((namespace.to_string(), path.to_string())) +} + +struct BunResolver { + session: Handle, +} + +impl Resolve for BunResolver { + fn resolve(&self, base: &FileName, specifier: &str) -> Result { + let (base_namespace, importer) = file_parts(base); + for hook in hooks_for(self.session, true) { + if !hook_matches(&hook, specifier, &base_namespace) { + continue; + } + let resolve_dir = Path::new(&importer) + .parent() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_default(); + let args = callback_args(&[ + ("path", specifier), + ("importer", &importer), + ("namespace", &base_namespace), + ("resolveDir", &resolve_dir), + ("kind", "import-statement"), + ]); + let scope = TransientRootScope::enter(); + let result = scope.root_nanbox(f64::from_bits(invoke_hook(&hook, args).bits())); + let result_value = result.get(); + let Some(path) = string_value(field(result_value, "path")) else { + if JsValue::from_bits(result_value.to_bits()).is_undefined() + || JsValue::from_bits(result_value.to_bits()).is_null() + { + continue; + } + return Err(anyhow!("Bun onResolve result is missing a string path")); + }; + let namespace = string_value(field(result.get(), "namespace")) + .unwrap_or_else(|| "file".to_string()); + let filename = if namespace == "file" { + FileName::Real(PathBuf::from(path)) + } else { + encode_plugin_file(&namespace, &path) + }; + return Ok(Resolution { + filename, + slug: None, + }); + } + + if base_namespace != "file" { + return Err(anyhow!( + "Could not resolve '{specifier}' from {base_namespace}:{importer}" + )); + } + let base_dir = Path::new(&importer) + .parent() + .unwrap_or_else(|| Path::new(".")); + let unresolved = Path::new(specifier); + let candidate = if unresolved.is_absolute() { + unresolved.to_path_buf() + } else if specifier.starts_with('.') { + base_dir.join(unresolved) + } else { + return Err(anyhow!( + "Could not resolve package '{specifier}' from {importer}; mark it external or resolve it in a Bun plugin" + )); + }; + let candidates = if candidate.extension().is_some() { + vec![candidate] + } else { + let mut values = vec![candidate.clone()]; + for extension in ["ts", "tsx", "js", "jsx", "mjs", "cjs"] { + values.push(candidate.with_extension(extension)); + } + for extension in ["ts", "tsx", "js", "jsx", "mjs", "cjs"] { + values.push(candidate.join(format!("index.{extension}"))); + } + values + }; + let path = candidates + .into_iter() + .find(|path| path.is_file()) + .ok_or_else(|| anyhow!("Could not resolve '{specifier}' from {importer}"))?; + Ok(Resolution { + filename: FileName::Real(path), + slug: None, + }) + } +} + +struct BunLoaderHost { + cm: Lrc, + session: Handle, + diagnostics: Arc>>, +} + +impl BunLoaderHost { + fn plugin_load( + &self, + path: &str, + namespace: &str, + ) -> Result, Error> { + for hook in hooks_for(self.session, false) { + if !hook_matches(&hook, path, namespace) { + continue; + } + let args = callback_args(&[("path", path), ("namespace", namespace)]); + let scope = TransientRootScope::enter(); + let result = scope.root_nanbox(f64::from_bits(invoke_hook(&hook, args).bits())); + let result_value = result.get(); + if JsValue::from_bits(result_value.to_bits()).is_undefined() + || JsValue::from_bits(result_value.to_bits()).is_null() + { + continue; + } + let contents = string_value(field(result_value, "contents")) + .ok_or_else(|| anyhow!("Bun onLoad result is missing string contents"))?; + let loader = string_value(field(result.get(), "loader")) + .map(|loader| BunLoader::parse(&loader)) + .transpose() + .map_err(Error::msg)? + .unwrap_or_else(|| BunLoader::for_path(path)); + return Ok(Some((contents, loader))); + } + Ok(None) + } +} + +impl Load for BunLoaderHost { + fn load(&self, file: &FileName) -> Result { + let (namespace, path) = file_parts(file); + let (source, loader) = match self.plugin_load(&path, &namespace)? { + Some(value) => value, + None if namespace == "file" => { + let source = std::fs::read_to_string(&path) + .with_context(|| format!("Could not load {path}"))?; + (source, BunLoader::for_path(&path)) + } + None => return Err(anyhow!("No Bun onLoad hook handled {namespace}:{path}")), + }; + let comments = SingleThreadedComments::default(); + let parsed = parse_bun_module( + &self.cm, + file.clone(), + &path, + &source, + loader, + false, + Some(&comments), + ); + let (source_file, module) = match parsed { + Ok(parsed) => parsed, + Err(error) => { + self.diagnostics + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(error.clone()); + return Err(Error::new(error)); + } + }; + let module = lower_bun_syntax(&self.cm, &comments, module, loader); + Ok(ModuleData { + fm: source_file, + module, + helpers: Helpers::new(false), + }) + } +} + +struct BunBundlerHook; + +impl Hook for BunBundlerHook { + fn get_import_meta_props( + &self, + _span: Span, + _module_record: &ModuleRecord, + ) -> Result, Error> { + Ok(Vec::new()) + } +} + +#[derive(Default)] +struct BunBuildOptions { + entrypoints: Vec, + external: Vec, + minify: bool, +} + +fn string_array(value: JsValue, field_name: &str) -> Result, BunDiagnostic> { + let scope = TransientRootScope::enter(); + let array_value = scope.root_nanbox(f64::from_bits(value.bits())); + let array = JsValue::from_bits(array_value.get().to_bits()).as_pointer::(); + if array.is_null() { + return Err(BunDiagnostic::message(format!( + "Bun.build {field_name} must be an array of strings" + ))); + } + let length = unsafe { js_array_length(array) }; + let mut values = Vec::with_capacity(length as usize); + for index in 0..length { + let array = JsValue::from_bits(array_value.get().to_bits()).as_pointer::(); + let value = unsafe { js_array_get(array, index) }; + let value = scope.root_nanbox(f64::from_bits(value.bits())); + let value = string_value(JsValue::from_bits(value.get().to_bits())).ok_or_else(|| { + BunDiagnostic::message(format!("Bun.build {field_name}[{index}] must be a string")) + })?; + values.push(value); + } + Ok(values) +} + +fn parse_build_options(options: f64) -> Result { + let value = JsValue::from_bits(options.to_bits()); + if !value.is_pointer_or_raw() { + return Err(BunDiagnostic::message( + "Bun.build options must be an object", + )); + } + let entries = string_array(object_field_by_name(value, "entrypoints"), "entrypoints")?; + if entries.is_empty() { + return Err(BunDiagnostic::message( + "Bun.build requires at least one entrypoint", + )); + } + if let Some(target) = string_value(object_field_by_name(value, "target")) { + if target != "bun" { + return Err(BunDiagnostic::message(format!( + "Bun.build target '{target}' is not supported; expected 'bun'" + ))); + } + } + if let Some(format) = string_value(object_field_by_name(value, "format")) { + if format != "esm" { + return Err(BunDiagnostic::message(format!( + "Bun.build format '{format}' is not supported; expected 'esm'" + ))); + } + } + let external_value = object_field_by_name(value, "external"); + let external = if external_value.is_undefined() || external_value.is_null() { + Vec::new() + } else { + string_array(external_value, "external")? + }; + let minify = object_field_by_name(value, "minify"); + let minify = minify.is_bool() && minify.to_bool(); + let cwd = std::env::current_dir().map_err(|error| { + BunDiagnostic::message(format!("Could not read current directory: {error}")) + })?; + Ok(BunBuildOptions { + entrypoints: entries + .into_iter() + .map(PathBuf::from) + .map(|path| { + if path.is_absolute() { + path + } else { + cwd.join(path) + } + }) + .collect(), + external, + minify, + }) +} + +struct BuildOutput { + path: String, + contents: String, +} + +fn run_build(options_value: f64) -> Result, Vec> { + let options = parse_build_options(options_value).map_err(|error| vec![error])?; + let session = configure_plugins(options_value); + let outcome = catch_unwind(AssertUnwindSafe(|| { + let cm: Lrc = Default::default(); + let globals = Globals::new(); + let diagnostics = Arc::new(Mutex::new(Vec::::new())); + GLOBALS.set(&globals, || { + let mut bundler = Bundler::new( + &globals, + cm.clone(), + BunLoaderHost { + cm: cm.clone(), + session, + diagnostics: diagnostics.clone(), + }, + BunResolver { session }, + BundleConfig { + require: true, + disable_inliner: false, + disable_hygiene: false, + disable_fixer: false, + disable_dce: false, + external_modules: options + .external + .iter() + .map(|value| value.as_str().into()) + .collect(), + module: ModuleType::Es, + }, + Box::new(BunBundlerHook), + ); + let entries = options + .entrypoints + .iter() + .enumerate() + .map(|(index, path)| (index.to_string(), FileName::Real(path.to_path_buf()))) + .collect::>(); + let bundles = bundler.bundle(entries).map_err(|error| { + let diagnostic = diagnostics + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .first() + .cloned() + .or_else(|| { + error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .cloned() + }) + .unwrap_or_else(|| { + BunDiagnostic::message(format!("Bun.build failed: {error:#}")) + }); + vec![diagnostic] + })?; + let mut outputs = Vec::new(); + for bundle in bundles { + let name = match &bundle.kind { + BundleKind::Named { name } => name.clone(), + BundleKind::Dynamic => format!("dynamic-{}", outputs.len()), + BundleKind::Lib { name } => name.clone(), + }; + let contents = emit_module(cm.clone(), None, &bundle.module, options.minify) + .map_err(|error| vec![error])?; + let path = name + .parse::() + .ok() + .and_then(|index| options.entrypoints.get(index)) + .map(|path| path.with_extension("js").to_string_lossy().into_owned()) + .unwrap_or_else(|| format!("{name}.js")); + outputs.push(BuildOutput { path, contents }); + } + outputs.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(outputs) + }) + })); + drop_handle(session); + match outcome { + Ok(result) => result, + Err(_) => Err(vec![BunDiagnostic::message( + "Bun.build failed inside the bundler", + )]), + } +} + +fn array_from_values(values: impl IntoIterator) -> JsValue { + let values = values.into_iter().collect::>(); + let mut array = unsafe { js_array_alloc(values.len() as u32) }; + for value in values { + array = unsafe { js_array_push(array, value) }; + } + JsValue::from_object_ptr(array) +} + +extern "C" fn bun_build_output_text(closure: *const RawClosureHeader) -> f64 { + let scope = TransientRootScope::enter(); + let contents = scope.root_nanbox(unsafe { closure_capture_f64(closure, 0) }); + let promise = JsPromise::new(); + let raw = promise.as_raw(); + promise.resolve(JsValue::from_bits(contents.get().to_bits())); + f64::from_bits(JsValue::from_object_ptr(raw).bits()) +} + +fn build_output_value(output: BuildOutput) -> JsValue { + ensure_build_runtime_registered(); + let scope = TransientRootScope::enter(); + let contents = scope.root_nanbox(f64::from_bits( + JsValue::from_string_ptr(alloc_string(&output.contents).as_raw()).bits(), + )); + let text = alloc_closure(bun_build_output_text as *const u8, 1); + unsafe { set_closure_capture_f64(text, 0, contents.get()) }; + let text = scope.root_nanbox(f64::from_bits(JsValue::from_object_ptr(text).bits())); + let path = JsValue::from_string_ptr(alloc_string(&output.path).as_raw()); + alloc_null_proto_object(&[ + ("path", path), + ("size", JsValue::from_number(output.contents.len() as f64)), + ( + "kind", + JsValue::from_string_ptr(alloc_string("entry-point").as_raw()), + ), + ( + "loader", + JsValue::from_string_ptr(alloc_string("js").as_raw()), + ), + ("text", JsValue::from_bits(text.get().to_bits())), + ]) +} + +fn diagnostic_value(diagnostic: BunDiagnostic) -> JsValue { + let position = match (&diagnostic.file, diagnostic.line, diagnostic.column) { + (Some(file), Some(line), Some(column)) => alloc_null_proto_object(&[ + ( + "file", + JsValue::from_string_ptr(alloc_string(file).as_raw()), + ), + ("line", JsValue::from_number(line as f64)), + ("column", JsValue::from_number(column as f64)), + ]), + _ => JsValue::NULL, + }; + alloc_null_proto_object(&[ + ( + "message", + JsValue::from_string_ptr(alloc_string(&diagnostic.message).as_raw()), + ), + ( + "level", + JsValue::from_string_ptr(alloc_string("error").as_raw()), + ), + ("position", position), + ]) +} + +fn build_result_value(result: Result, Vec>) -> JsValue { + match result { + Ok(outputs) => { + let outputs = array_from_values(outputs.into_iter().map(build_output_value)); + alloc_null_proto_object(&[ + ("success", JsValue::TRUE), + ("outputs", outputs), + ("logs", array_from_values(std::iter::empty())), + ]) + } + Err(logs) => { + let logs = array_from_values(logs.into_iter().map(diagnostic_value)); + alloc_null_proto_object(&[ + ("success", JsValue::FALSE), + ("outputs", array_from_values(std::iter::empty())), + ("logs", logs), + ]) + } + } +} + +/// In-memory `Bun.build(options)` subset. The work is synchronous, but Bun's +/// public contract is a Promise so the completed result is wrapped here. +#[no_mangle] +pub extern "C" fn js_bun_build(options: f64) -> *mut Promise { + let scope = TransientRootScope::enter(); + let options = scope.root_nanbox(options); + let result = run_build(options.get()); + let value = scope.root_nanbox(f64::from_bits(build_result_value(result).bits())); + let promise = JsPromise::new(); + let raw = promise.as_raw(); + promise.resolve(JsValue::from_bits(value.get().to_bits())); + raw +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transpiler_erases_types() { + let output = transform_bun_source( + "const n: number = 1; export { n }", + "fixture.ts", + BunLoader::Ts, + false, + ) + .expect("transpile TypeScript"); + assert!(!output.contains(": number")); + assert!(output.contains("const n = 1")); + assert!(output.contains("export { n }")); + } + + #[test] + fn scan_reports_static_require_and_dynamic_imports_in_order() { + let scan = scan_bun_source( + "import x from 'pkg'; require('./cjs'); require.resolve('r'); import('./lazy'); export { x }", + "fixture.js", + BunLoader::Js, + true, + ) + .expect("scan JavaScript"); + assert_eq!( + scan.imports, + vec![ + ScannedImport { + path: "pkg".to_string(), + kind: "import-statement".to_string(), + }, + ScannedImport { + path: "./cjs".to_string(), + kind: "require-call".to_string(), + }, + ScannedImport { + path: "r".to_string(), + kind: "require-resolve".to_string(), + }, + ScannedImport { + path: "./lazy".to_string(), + kind: "dynamic-import".to_string(), + }, + ] + ); + assert_eq!(scan.exports, vec!["x"]); + } + + #[test] + fn loader_names_match_bun_subset() { + assert_eq!(BunLoader::parse("ts"), Ok(BunLoader::Ts)); + assert_eq!(BunLoader::parse("tsx"), Ok(BunLoader::Tsx)); + assert!(BunLoader::parse("css").is_err()); + } +} diff --git a/crates/perry-ext-typescript/src/lib.rs b/crates/perry-ext-typescript/src/lib.rs index eeb47e725a..37a8164a6c 100644 --- a/crates/perry-ext-typescript/src/lib.rs +++ b/crates/perry-ext-typescript/src/lib.rs @@ -6,6 +6,8 @@ //! exposes the audited `transpileModule` and `flattenDiagnosticMessageText` //! surface using SWC, which Perry already uses for source parsing. +mod bun; + use perry_ffi::{alloc_string, json_stringify, read_string, JsString, JsValue, StringHeader}; use serde::{Deserialize, Serialize}; use serde_json::Value; diff --git a/crates/perry-hir/src/js_transform/local_natives.rs b/crates/perry-hir/src/js_transform/local_natives.rs index 43fb9f3e5b..df2868c134 100644 --- a/crates/perry-hir/src/js_transform/local_natives.rs +++ b/crates/perry-hir/src/js_transform/local_natives.rs @@ -1371,6 +1371,7 @@ pub fn detect_native_instance_creation_with_context( ("http2", "createSecureServer") => "Http2SecureServer", ("node-cron", "schedule") => "CronJob", ("readline", "createInterface") => "Interface", + ("bun", "Transpiler") => "Transpiler", ( "axios", "get" | "post" | "put" | "delete" | "patch" | "head" | "options" | "request", @@ -1489,6 +1490,7 @@ pub fn detect_native_instance_creation_with_context( "DatabaseSync" => Some(("sqlite".to_string(), "DatabaseSync".to_string())), "StatementSync" => Some(("sqlite".to_string(), "StatementSync".to_string())), "BunSqliteDatabase" => Some(("bun:sqlite".to_string(), "Database".to_string())), + "Transpiler" => Some(("bun".to_string(), "Transpiler".to_string())), _ => None, } } diff --git a/crates/perry-hir/src/lower/expr_new/member.rs b/crates/perry-hir/src/lower/expr_new/member.rs index fd7c535665..97f327ed4a 100644 --- a/crates/perry-hir/src/lower/expr_new/member.rs +++ b/crates/perry-hir/src/lower/expr_new/member.rs @@ -30,7 +30,7 @@ pub(crate) fn lower_new_member_native( { let obj_name = obj_ident.sym.as_ref(); if obj_name == "Bun" - && prop_ident.sym.as_ref() == "Glob" + && matches!(prop_ident.sym.as_ref(), "Glob" | "Transpiler") && !ctx.shadows_unqualified_global("Bun") && ctx.lookup_native_module("Bun").is_none() { @@ -38,7 +38,7 @@ pub(crate) fn lower_new_member_native( module: "bun".to_string(), class_name: None, object: None, - method: "Glob".to_string(), + method: prop_ident.sym.to_string(), args: lower_optional_args(ctx, new_expr.args.as_deref())?, })); } diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index e887d7a56a..0f415d26b7 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -1857,6 +1857,33 @@ fn typescript_transpile_subset_lowers_to_native_dispatch_and_enums() { ); } +/// #9602: Bun's runtime compiler surface stays on native calls; neither the +/// source string nor a plugin object is handed to Perry's JS-module fallback. +#[test] +fn bun_transpiler_and_build_lower_to_native_dispatch() { + let source = r#" + import { Transpiler, build } from "bun"; + const transpiler = new Transpiler({ loader: "ts" }); + const output = transpiler.transformSync("const n: number = 1"); + const imports = transpiler.scanImports("import x from 'pkg'"); + async function bundle() { + return await build({ entrypoints: ["./entry.ts"], target: "bun", format: "esm" }); + } + console.log(output, imports, bundle); + "#; + let module = perry_parser::parse_typescript(source, "bun-runtime.ts").expect("source parses"); + let hir = super::lower_module(&module, "bun-runtime", "bun-runtime.ts").expect("source lowers"); + let dump = format!("{hir:?}"); + assert!( + dump.contains("module: \"bun\"") + && dump.contains("class_name: Some(\"Transpiler\")") + && dump.contains("method: \"transformSync\"") + && dump.contains("method: \"scanImports\"") + && dump.contains("method: \"build\""), + "Bun runtime compiler APIs must use native dispatch: {dump}" + ); +} + /// #8882: a module-level class constructing a sibling class that is declared /// inside a function body lowered LATER. This is the shape the CJS wrap /// produces for Next's `server/lib/lru-cache.js`: `LRUCache` is hoisted out of diff --git a/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs b/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs index 20933f33cd..c3ad54fc4b 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_arity_table.rs @@ -3,9 +3,11 @@ fn native_callable_export_arity_reference(module: &str, prop: &str) -> Option { match (module, prop) { // Bun global/module surface (#9599). - ("bun", "Glob" | "file" | "fileURLToPath" | "hash" | "pathToFileURL" | "stringWidth") => { - Some(1) - } + ( + "bun", + "Glob" | "Transpiler" | "build" | "file" | "fileURLToPath" | "hash" | "pathToFileURL" + | "stringWidth", + ) => Some(1), ("bun", "write") => Some(2), // bun:ffi (#6562). ("bun:ffi", "dlopen") => Some(2), @@ -287,6 +289,8 @@ static CALLABLE_EXPORT_ARITY_TABLE: &[(&str, &[(&str, u32)])] = &[ "bun", &[ ("Glob", 1), + ("Transpiler", 1), + ("build", 1), ("file", 1), ("fileURLToPath", 1), ("hash", 1), diff --git a/crates/perry-runtime/src/object/native_module/callable_export_check.rs b/crates/perry-runtime/src/object/native_module/callable_export_check.rs index 72388da1d9..b80841cd93 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_check.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_check.rs @@ -38,7 +38,15 @@ pub(crate) fn is_native_module_callable_export_reference(module: &str, prop: &st if module == "bun" && matches!( prop, - "Glob" | "file" | "fileURLToPath" | "hash" | "pathToFileURL" | "stringWidth" | "write" + "Glob" + | "Transpiler" + | "build" + | "file" + | "fileURLToPath" + | "hash" + | "pathToFileURL" + | "stringWidth" + | "write" ) { return true; diff --git a/crates/perry-runtime/src/object/native_module/callable_export_table.rs b/crates/perry-runtime/src/object/native_module/callable_export_table.rs index 833a53f044..1c617e5ed1 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_table.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_table.rs @@ -85,6 +85,8 @@ pub(super) static CALLABLE_EXPORT_TABLE: &[(&str, &[&str])] = &[ "bun", &[ "Glob", + "Transpiler", + "build", "file", "fileURLToPath", "hash", diff --git a/crates/perry-runtime/src/object/native_module/constructor_exports.rs b/crates/perry-runtime/src/object/native_module/constructor_exports.rs index 402cd26e41..f0f670e57c 100644 --- a/crates/perry-runtime/src/object/native_module/constructor_exports.rs +++ b/crates/perry-runtime/src/object/native_module/constructor_exports.rs @@ -22,6 +22,10 @@ pub(crate) fn is_native_module_constructor_export(module: &str, property: &str) !match module { "assert" | "assert/strict" => matches!(property, "doesNotReject" | "rejects"), + "bun" => matches!( + property, + "build" | "file" | "fileURLToPath" | "hash" | "pathToFileURL" | "stringWidth" | "write" + ), "buffer.Buffer" => property == "of", "console" => matches!( property, diff --git a/crates/perry-runtime/src/object/native_module/module_keys.rs b/crates/perry-runtime/src/object/native_module/module_keys.rs index 3e7757628e..022f00d73c 100644 --- a/crates/perry-runtime/src/object/native_module/module_keys.rs +++ b/crates/perry-runtime/src/object/native_module/module_keys.rs @@ -1630,6 +1630,8 @@ pub(crate) fn native_module_enumerable_keys(module_name: &str) -> Option<&'stati // "bun"` share this one enumerable native-module surface. "bun" => Some(&[ b"Glob", + b"Transpiler", + b"build", b"file", b"fileURLToPath", b"hash", diff --git a/crates/perry/tests/issue_9602_bun_runtime_compiler.rs b/crates/perry/tests/issue_9602_bun_runtime_compiler.rs new file mode 100644 index 0000000000..942d209daa --- /dev/null +++ b/crates/perry/tests/issue_9602_bun_runtime_compiler.rs @@ -0,0 +1,111 @@ +//! End-to-end regression coverage for #9602. The native `"bun"` module +//! exposes Bun's runtime transpiler plus the in-memory build/plugin subset +//! used by runtime hook modules. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn bun_transpiler_and_build_plugins_compile_and_run() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write( + &entry, + r#" +import { Transpiler, build } from "bun"; + +async function main() { + const ts = new Transpiler({ loader: "ts" }); + const js = ts.transformSync("const n: number = 1; export { n }"); + console.log(!js.includes(": number"), js.includes("export { n }")); + + const asyncJs = await ts.transform("const value: string = 'ok'"); + console.log(!asyncJs.includes(": string")); + + const repl = new Transpiler({ loader: "js", replMode: true }); + console.log(JSON.stringify(repl.scanImports('import x from "pkg"; require("./cjs")'))); + console.log(JSON.stringify(ts.scan("export const answer: number = 42"))); + + await Bun.write( + "./entry.ts", + 'import host from "host-api"; import answer from "virtual:answer"; const n: number = answer; console.log(host, n);', + ); + const result = await build({ + entrypoints: ["./entry.ts"], + target: "bun", + format: "esm", + minify: false, + external: ["host-api"], + plugins: [{ + name: "virtual", + setup(builder: any) { + builder.onResolve({ filter: /^virtual:/ }, (args: any) => ({ + path: args.path, + namespace: "v", + })); + builder.onLoad({ filter: /.*/, namespace: "v" }, () => ({ + contents: "export default 42", + loader: "js", + })); + }, + }], + }); + const bundled = await result.outputs[0].text(); + console.log(result.success, result.outputs.length, result.logs.length); + console.log(bundled.includes("42"), !bundled.includes(": number"), bundled.includes("host-api")); + + await Bun.write("./invalid.ts", "const broken: = 1"); + const failed = await build({ + entrypoints: ["./invalid.ts"], + target: "bun", + format: "esm", + }); + console.log( + failed.success, + failed.outputs.length, + failed.logs.length, + failed.logs[0].position.file.endsWith("invalid.ts"), + failed.logs[0].position.line, + ); +} +main(); +"#, + ) + .expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir.path()) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "true true\ntrue\n[{\"path\":\"pkg\",\"kind\":\"import-statement\"},{\"path\":\"./cjs\",\"kind\":\"require-call\"}]\n{\"exports\":[\"answer\"],\"imports\":[]}\ntrue 1 0\ntrue true true\nfalse 0 1 true 1\n" + ); +} diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index b2f97d863f..61c93f4fb9 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 2069 entries across 136 modules +// Coverage: 2072 entries across 136 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -342,6 +342,8 @@ declare module "buffer" { } declare module "bun" { + /** stdlib */ + export class Transpiler { [key: string]: any; } /** stdlib */ export const isStandaloneExecutable: any; /** stdlib */ @@ -355,6 +357,10 @@ declare module "bun" { /** stdlib */ export function Glob(...args: any[]): any; /** stdlib */ + export function Transpiler(...args: any[]): any; + /** stdlib */ + export function build(...args: any[]): any; + /** stdlib */ export function file(...args: any[]): any; /** stdlib */ export function fileURLToPath(...args: any[]): any; diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 8aad6cdfee..8148ac77c8 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 3022 entries across 138 modules. +Total: 3029 entries across 138 modules. ## Modules @@ -418,14 +418,24 @@ Total: 3022 entries across 138 modules. ## `bun` +### Classes + +- `Transpiler` + ### Methods - `Glob` — module +- `Transpiler` — module +- `build` — module - `file` — module - `fileURLToPath` — module - `hash` — module - `pathToFileURL` — module +- `scan` — instance *(class: `Transpiler`)* +- `scanImports` — instance *(class: `Transpiler`)* - `stringWidth` — module +- `transform` — instance *(class: `Transpiler`)* +- `transformSync` — instance *(class: `Transpiler`)* - `unsupported` — module - `write` — module