From be2a764a07c07fb2e751f509607b654af66707fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 3 Sep 2026 13:34:03 +0200 Subject: [PATCH 1/5] feat(bun): add runtime transpiler and build subset --- Cargo.lock | 173 +- Cargo.toml | 2 + changelog.d/9602-bun-runtime-compiler.md | 7 + .../perry-api-manifest/src/entries/part_4.rs | 7 + crates/perry-codegen/src/ext_registry.rs | 3 + .../perry-codegen/src/lower_call/builtin.rs | 9 + .../src/lower_call/native_table/bun.rs | 54 + crates/perry-ext-typescript/Cargo.toml | 4 + crates/perry-ext-typescript/src/bun.rs | 1417 +++++++++++++++++ crates/perry-ext-typescript/src/lib.rs | 2 + .../src/js_transform/local_natives.rs | 2 + crates/perry-hir/src/lower/expr_new/member.rs | 2 +- crates/perry-hir/src/lower/tests.rs | 27 + .../callable_export_arity_table.rs | 7 +- .../native_module/callable_export_check.rs | 2 + .../native_module/callable_export_table.rs | 2 + .../native_module/constructor_exports.rs | 4 + .../src/object/native_module/module_keys.rs | 2 + .../tests/issue_9602_bun_runtime_compiler.rs | 111 ++ docs/api/perry.d.ts | 8 +- docs/src/api/reference.md | 12 +- 21 files changed, 1844 insertions(+), 13 deletions(-) create mode 100644 changelog.d/9602-bun-runtime-compiler.md create mode 100644 crates/perry-ext-typescript/src/bun.rs create mode 100644 crates/perry/tests/issue_9602_bun_runtime_compiler.rs diff --git a/Cargo.lock b/Cargo.lock index d036a0ac70..afa558e162 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", @@ -5582,6 +5627,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" @@ -5823,7 +5874,7 @@ dependencies = [ "atty", "clap", "console 0.16.4", - "dashmap", + "dashmap 6.2.1", "dialoguer 0.11.0", "dotenvy", "hex", @@ -6249,16 +6300,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]] @@ -6315,7 +6370,7 @@ dependencies = [ name = "perry-ffi" version = "0.5.1520" dependencies = [ - "dashmap", + "dashmap 6.2.1", "once_cell", "perry-runtime", ] @@ -6439,7 +6494,7 @@ dependencies = [ "clap", "cron", "ctr", - "dashmap", + "dashmap 6.2.1", "ecb", "ed25519-dalek", "ed448-goldilocks", @@ -6739,6 +6794,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" @@ -7356,6 +7421,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" @@ -7628,6 +7699,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" @@ -8625,7 +8702,7 @@ dependencies = [ "bytes", "cfg-if", "chrono", - "crc", + "crc 3.4.0", "crossbeam-queue", "either", "event-listener", @@ -8698,7 +8775,7 @@ dependencies = [ "byteorder", "bytes", "chrono", - "crc", + "crc 3.4.0", "digest 0.11.3", "dotenvy", "either", @@ -8728,7 +8805,7 @@ dependencies = [ "bitflags 2.12.1", "byteorder", "chrono", - "crc", + "crc 3.4.0", "dotenvy", "etcetera", "futures-channel", @@ -8905,6 +8982,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" @@ -9010,6 +9116,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" @@ -9074,6 +9195,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" @@ -9161,6 +9306,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 af7d0c6dca..108a9daadd 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", "deepEquals", 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 cbab9a0ee5..1af5eff871 100644 --- a/crates/perry-codegen/src/lower_call/native_table/bun.rs +++ b/crates/perry-codegen/src/lower_call/native_table/bun.rs @@ -26,6 +26,60 @@ pub(crate) const BUN_ROWS: &[NativeModSig] = &[ args: &[NA_F64], ret: NR_F64, }, + 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 a3556b9656..70f58d061e 100644 --- a/crates/perry-hir/src/lower/expr_new/member.rs +++ b/crates/perry-hir/src/lower/expr_new/member.rs @@ -39,7 +39,7 @@ pub(crate) fn lower_new_member_native( module == "bun" && (export.is_none() || export == Some("default")) }); if (is_global_bun || is_bun_namespace) - && matches!(prop_ident.sym.as_ref(), "Glob" | "Terminal") + && matches!(prop_ident.sym.as_ref(), "Glob" | "Terminal" | "Transpiler") { return Ok(Some(Expr::NativeMethodCall { module: "bun".to_string(), 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 0dcf89cf3f..53a019c5e6 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 @@ -5,8 +5,9 @@ fn native_callable_export_arity_reference(module: &str, prop: &str) -> Option Some(1), ("bun", "deepEquals" | "generateHeapSnapshot" | "spawn" | "write") => Some(2), ("bun", "wrapAnsi") => Some(3), @@ -291,6 +292,8 @@ static CALLABLE_EXPORT_ARITY_TABLE: &[(&str, &[(&str, u32)])] = &[ &[ ("Glob", 1), ("Terminal", 1), + ("Transpiler", 1), + ("build", 1), ("deepEquals", 2), ("file", 1), ("fileURLToPath", 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 c48e1a5a3b..2446428618 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 @@ -40,6 +40,8 @@ pub(crate) fn is_native_module_callable_export_reference(module: &str, prop: &st prop, "Glob" | "Terminal" + | "Transpiler" + | "build" | "deepEquals" | "file" | "fileURLToPath" 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 c09d5dbc2f..7e894c29ed 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 @@ -86,6 +86,8 @@ pub(super) static CALLABLE_EXPORT_TABLE: &[(&str, &[&str])] = &[ &[ "Glob", "Terminal", + "Transpiler", + "build", "deepEquals", "file", "fileURLToPath", 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 47eb293406..1e72f81c72 100644 --- a/crates/perry-runtime/src/object/native_module/module_keys.rs +++ b/crates/perry-runtime/src/object/native_module/module_keys.rs @@ -1633,7 +1633,9 @@ pub(crate) fn native_module_enumerable_keys(module_name: &str) -> Option<&'stati b"JSONL", b"TOML", b"Terminal", + b"Transpiler", b"YAML", + b"build", b"deepEquals", b"file", b"fileURLToPath", 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 From 6c5f82e50e7819b1c222102f176cef883fd91812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 3 Sep 2026 13:37:31 +0200 Subject: [PATCH 2/5] feat(bun): expose serve over native HTTP --- .../perry-api-manifest/src/entries/part_4.rs | 8 + crates/perry-codegen/src/ext_registry.rs | 32 + .../src/lower_call/native_table/bun.rs | 11 + .../perry-ext-http/src/server/bun_server.rs | 753 ++++++++++++++++++ .../perry-ext-http/src/server/dispatch_ext.rs | 18 +- .../src/server/handle_dispatch.rs | 26 + crates/perry-ext-http/src/server/mod.rs | 3 + crates/perry-ext-http/src/server/server.rs | 19 + .../src/lower/expr_call/module_static.rs | 1 + .../callable_export_arity_table.rs | 3 +- .../native_module/callable_export_check.rs | 1 + .../native_module/callable_export_table.rs | 1 + .../src/object/native_module/module_keys.rs | 1 + .../native_module_dispatch/dispatch_a_c.rs | 24 + .../perry-stdlib/src/common/dispatch/init.rs | 9 + .../src/fetch/bun_server_bridge.rs | 104 +++ crates/perry-stdlib/src/fetch/mod.rs | 6 + crates/perry/tests/issue_6560_bun_globals.rs | 4 +- crates/perry/tests/issue_9599_bun_platform.rs | 13 +- crates/perry/tests/issue_9603_bun_serve.rs | 143 ++++ docs/api/perry.d.ts | 3 + docs/src/api/reference.md | 2 + 22 files changed, 1169 insertions(+), 16 deletions(-) create mode 100644 crates/perry-ext-http/src/server/bun_server.rs create mode 100644 crates/perry-stdlib/src/fetch/bun_server_bridge.rs create mode 100644 crates/perry/tests/issue_9603_bun_serve.rs diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs index 108a9daadd..87c1c205f0 100644 --- a/crates/perry-api-manifest/src/entries/part_4.rs +++ b/crates/perry-api-manifest/src/entries/part_4.rs @@ -1121,6 +1121,14 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ method("bun", "write", false, None), method("bun", "spawn", false, None), method("bun", "Terminal", false, None), + method_sig( + "bun", + "serve", + false, + None, + &[p_any("options")], + TypeSpec::Any, + ), method("bun", "pathToFileURL", false, None), method("bun", "fileURLToPath", false, None), // #8537 — OpenCode compatibility coverage added these dispatch rows diff --git a/crates/perry-codegen/src/ext_registry.rs b/crates/perry-codegen/src/ext_registry.rs index d88efcad51..9b06a877f4 100644 --- a/crates/perry-codegen/src/ext_registry.rs +++ b/crates/perry-codegen/src/ext_registry.rs @@ -715,6 +715,26 @@ thread_local! { /// ~30 ns per emission, fully amortized by the surrounding format! /// strings. pub(crate) fn record_ffi_call(symbol: &str) { + // #9603: `js_bun_serve` is implemented by perry-ext-http but crosses a + // narrow JSON bridge into perry-stdlib's Fetch registries to construct + // Request values and consume Response values. It therefore has two + // providers, unlike the ordinary one-symbol/one-owner registry rows. + if symbol == "js_bun_serve" { + if provider_recording_permitted() { + let mut guard = USED_PROVIDERS.lock().expect("USED_PROVIDERS poisoned"); + let providers = guard.get_or_insert_with(HashSet::new); + providers.insert(OwnerKind::WellKnown("http")); + providers.insert(OwnerKind::Stdlib { + feature: Some("web-fetch"), + }); + } + MODULE_CAPTURE.with(|cell| { + if let Some(set) = cell.borrow_mut().as_mut() { + set.insert("js_bun_serve"); + } + }); + return; + } for (name, owner) in FFI_REGISTRY { if *name == symbol { if provider_recording_permitted() { @@ -1012,6 +1032,18 @@ mod tests { ); } + #[test] + fn bun_serve_routes_to_http_and_fetch_providers() { + let _guard = ProviderTestGuard::new(); + let _ = take_used_providers(); + record_ffi_call("js_bun_serve"); + let got = take_used_providers(); + assert!(got.contains(&OwnerKind::WellKnown("http"))); + assert!(got.contains(&OwnerKind::Stdlib { + feature: Some("web-fetch") + })); + } + /// #3954 regression: HTTP-suite native-table rows can emit newer /// `perry-ext-http` or `perry-ext-net` /// symbols without the module-import path being visible to collection. 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 1af5eff871..3fb6fd1758 100644 --- a/crates/perry-codegen/src/lower_call/native_table/bun.rs +++ b/crates/perry-codegen/src/lower_call/native_table/bun.rs @@ -35,6 +35,17 @@ pub(crate) const BUN_ROWS: &[NativeModSig] = &[ args: &[NA_F64], ret: NR_PTR, }, + NativeModSig { + module: "bun", + has_receiver: false, + method: "serve", + class_filter: None, + // The listener lives in perry-ext-http and returns a native server + // handle, reusing the same event-loop pump as node:http. + runtime: "js_bun_serve", + args: &[NA_F64], + ret: NR_PTR, + }, NativeModSig { module: "bun", has_receiver: false, diff --git a/crates/perry-ext-http/src/server/bun_server.rs b/crates/perry-ext-http/src/server/bun_server.rs new file mode 100644 index 0000000000..bf1de40321 --- /dev/null +++ b/crates/perry-ext-http/src/server/bun_server.rs @@ -0,0 +1,753 @@ +//! Bun-compatible `serve()` facade over Perry's native HTTP server. + +use std::collections::HashMap; +use std::os::raw::c_int; +use std::sync::Mutex; + +use lazy_static::lazy_static; +use perry_ffi::{ + alloc_null_proto_object, alloc_string, get_handle, get_handle_mut, register_handle, ErrorKind, + GcRootVisitor, JsClosure, JsValue, RawClosureHeader, StringHeader, TransientRootScope, +}; + +use crate::server::request::{handle_to_pointer_f64, with_implicit_this, IncomingMessage}; +use crate::server::response::ServerResponse; +use crate::server::server::{ + finalize_or_park_request, synthesize_default_response_if_needed, HttpPendingRequest, HttpServer, +}; +use crate::server::types::{ + js_handle_clear_side_tables, js_promise_run_microtasks, js_value_is_closure, + read_string_header, PTR_MASK, TAG_NULL, TAG_UNDEFINED, +}; + +#[repr(C)] +struct Promise { + _opaque: [u8; 0], +} + +// Standalone ext-http unit tests do not link the selected stdlib provider. +// The Perry integration test exercises the real cross-crate bridge. +#[cfg(test)] +unsafe fn js_bun_http_request_from_json(_snapshot_ptr: *const StringHeader) -> f64 { + f64::from_bits(TAG_UNDEFINED) +} + +#[cfg(test)] +unsafe fn js_bun_http_response_snapshot_json(_response_handle: f64) -> *mut StringHeader { + std::ptr::null_mut() +} + +#[cfg(not(test))] +extern "C" { + fn js_bun_http_request_from_json(snapshot_ptr: *const StringHeader) -> f64; + fn js_bun_http_response_snapshot_json(response_handle: f64) -> *mut StringHeader; +} + +extern "C" { + fn js_value_is_promise(value: f64) -> i32; + fn js_promise_state(ptr: *mut Promise) -> i32; + fn js_promise_value(ptr: *mut Promise) -> f64; + fn js_promise_reason(ptr: *mut Promise) -> f64; + fn js_promise_resolved(value: f64) -> *mut Promise; + fn js_try_push() -> *mut c_int; + fn js_try_end(); + fn js_get_exception() -> f64; + fn js_clear_exception(); + fn js_jsvalue_to_string(value: f64) -> *mut StringHeader; + fn js_object_get_field_by_name( + obj: *const perry_ffi::ObjectHeader, + key: *const StringHeader, + ) -> JsValue; + fn perry_sjlj_try( + env: *mut core::ffi::c_void, + body: unsafe extern "C" fn(*mut core::ffi::c_void), + ctx: *mut core::ffi::c_void, + ) -> c_int; +} + +#[derive(Clone, Copy)] +enum PromiseStage { + Fetch, + Error, +} + +struct PendingPromise { + server_handle: i64, + request_handle: i64, + response_handle: i64, + fetch_request: f64, + promise: i64, + stage: PromiseStage, +} + +struct ResponseSnapshot { + status: u16, + status_text: String, + headers: Vec<(String, String)>, + body: Vec, +} + +impl ResponseSnapshot { + fn from_json(json: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(json).ok()?; + let status = value.get("status")?.as_u64()?.try_into().ok()?; + let status_text = value.get("status_text")?.as_str()?.to_string(); + let headers = value + .get("headers")? + .as_array()? + .iter() + .map(|entry| { + let pair = entry.as_array()?; + Some(( + pair.first()?.as_str()?.to_string(), + pair.get(1)?.as_str()?.to_string(), + )) + }) + .collect::>>()?; + let body = value + .get("body")? + .as_array()? + .iter() + .map(|byte| byte.as_u64().and_then(|byte| byte.try_into().ok())) + .collect::>>()?; + Some(Self { + status, + status_text, + headers, + body, + }) + } +} + +lazy_static! { + static ref PENDING_PROMISES: Mutex> = Mutex::new(Vec::new()); + static ref REQUEST_IPS: Mutex> = + Mutex::new(HashMap::new()); +} + +#[repr(C)] +struct InlineArgsHeader { + length: u32, + capacity: u32, + args: [u64; 1], +} + +struct ClosureCallResult { + value: f64, + thrown: Option, +} + +fn arm_trap_and_run R>(env: *mut c_int, f: F) -> Option { + struct Ctx { + f: Option, + ret: Option, + } + unsafe extern "C" fn invoke R, R>(raw: *mut core::ffi::c_void) { + let ctx = unsafe { &mut *(raw as *mut Ctx) }; + let f = ctx.f.take().expect("sjlj trampoline invoked body twice"); + ctx.ret = Some(f()); + } + let mut ctx = Ctx { + f: Some(f), + ret: None, + }; + let rc = unsafe { + perry_sjlj_try( + env as *mut core::ffi::c_void, + invoke::, + &mut ctx as *mut Ctx<_, R> as *mut core::ffi::c_void, + ) + }; + if rc == 0 { + Some( + ctx.ret + .take() + .expect("sjlj trampoline returned 0 without a body result"), + ) + } else { + None + } +} + +unsafe fn call_catching(f: impl FnOnce() -> f64) -> ClosureCallResult { + let trap = js_try_push(); + match arm_trap_and_run(trap, f) { + Some(value) => { + js_try_end(); + ClosureCallResult { + value, + thrown: None, + } + } + None => { + let exception = js_get_exception(); + js_clear_exception(); + js_try_end(); + ClosureCallResult { + value: f64::from_bits(TAG_UNDEFINED), + thrown: Some(exception), + } + } + } +} + +pub(crate) fn scan_pending_roots(visitor: &mut GcRootVisitor<'_>) { + if let Ok(mut pending) = PENDING_PROMISES.lock() { + for entry in pending.iter_mut() { + visitor.visit_i64_slot(&mut entry.promise); + } + } +} + +pub(crate) fn is_bun_server(handle: i64) -> bool { + get_handle::(handle) + .map(|server| server.is_bun_server) + .unwrap_or(false) +} + +fn field(options: &perry_ffi::TransientRootedNanbox, name: &str) -> JsValue { + // Allocate the key first, then re-read the rooted options pointer: key + // allocation may run a moving collection. + let key = alloc_string(name); + let object = + JsValue::from_bits(options.get().to_bits()).as_pointer::(); + if object.is_null() { + JsValue::UNDEFINED + } else { + unsafe { js_object_get_field_by_name(object, key.as_raw()) } + } +} + +fn field_is_present(options: &perry_ffi::TransientRootedNanbox, name: &str) -> bool { + let value = field(options, name); + !value.is_undefined() && !value.is_null() && value != JsValue::FALSE +} + +/// `Bun.serve(options)` — create and synchronously bind an HTTP server. +#[no_mangle] +pub unsafe extern "C" fn js_bun_serve(options: f64) -> i64 { + let options_value = JsValue::from_bits(options.to_bits()); + if !options_value.is_pointer() { + perry_ffi::throw_with_code( + "Bun.serve requires an options object", + "ERR_INVALID_ARG_TYPE", + ErrorKind::TypeError, + ); + } + + let scope = TransientRootScope::enter(); + let options = scope.root_nanbox(options); + if ["tls", "key", "cert", "ca"] + .iter() + .any(|name| field_is_present(&options, name)) + { + perry_ffi::throw_with_code( + "Bun.serve TLS options are not supported by Perry yet", + "ERR_NOT_SUPPORTED", + ErrorKind::Error, + ); + } + + let fetch = scope.root_nanbox(f64::from_bits(field(&options, "fetch").bits())); + if js_value_is_closure(fetch.get().to_bits() as i64) == 0 { + perry_ffi::throw_with_code( + "Bun.serve requires a fetch function", + "ERR_INVALID_ARG_TYPE", + ErrorKind::TypeError, + ); + } + let error = scope.root_nanbox(f64::from_bits(field(&options, "error").bits())); + if !JsValue::from_bits(error.get().to_bits()).is_undefined() + && js_value_is_closure(error.get().to_bits() as i64) == 0 + { + perry_ffi::throw_with_code( + "Bun.serve error must be a function", + "ERR_INVALID_ARG_TYPE", + ErrorKind::TypeError, + ); + } + + let development = field(&options, "development"); + let idle_timeout = field(&options, "idleTimeout"); + let mut server = HttpServer::with_handler((fetch.get().to_bits() & PTR_MASK) as i64); + server.is_bun_server = true; + server.bun_error_handler = if js_value_is_closure(error.get().to_bits() as i64) != 0 { + (error.get().to_bits() & PTR_MASK) as i64 + } else { + 0 + }; + server.bun_development = development.to_bool(); + if idle_timeout.is_number() { + let seconds = idle_timeout.to_number(); + if seconds.is_finite() && seconds >= 0.0 { + server.idle_timeout = seconds * 1_000.0; + } + } + + crate::server::ensure_gc_scanner_registered(); + let handle = register_handle(server); + let args = InlineArgsHeader { + length: 1, + capacity: 1, + args: [options.get().to_bits()], + }; + crate::server::server::js_node_http_server_listen(handle, &args as *const _ as i64); + if !get_handle::(handle) + .map(|server| server.listening) + .unwrap_or(false) + { + perry_ffi::drop_handle(handle); + perry_ffi::throw_with_code( + "Bun.serve failed to bind the requested address", + "ERR_SERVER_NOT_RUNNING", + ErrorKind::Error, + ); + } + handle +} + +fn fetch_request_id(request: f64) -> usize { + (request.to_bits() & PTR_MASK) as usize +} + +fn request_url(server_handle: i64, request: &IncomingMessage) -> String { + if request.url.starts_with("http://") || request.url.starts_with("https://") { + return request.url.clone(); + } + let authority = request.headers.get("host").cloned().unwrap_or_else(|| { + get_handle::(server_handle) + .map(|server| { + let host = if server.bound_host.contains(':') && !server.bound_host.starts_with('[') + { + format!("[{}]", server.bound_host) + } else { + server.bound_host.clone() + }; + format!("{}:{}", host, server.bound_port) + }) + .unwrap_or_else(|| "127.0.0.1".to_string()) + }); + let path = if request.url.starts_with('/') { + request.url.clone() + } else { + format!("/{}", request.url) + }; + format!("http://{authority}{path}") +} + +fn make_fetch_request(server_handle: i64, request_handle: i64) -> Option { + let request = get_handle::(request_handle)?; + let body = if request.body_bytes.is_empty() { + None + } else { + Some(request.body_bytes.clone()) + }; + let snapshot = serde_json::json!({ + "url": request_url(server_handle, request), + "method": request.method, + "headers": request.raw_headers, + "body": body, + }); + let json = serde_json::to_string(&snapshot).ok()?; + let json = alloc_string(&json); + let fetch_request = unsafe { js_bun_http_request_from_json(json.as_raw()) }; + if JsValue::from_bits(fetch_request.to_bits()).is_undefined() { + return None; + } + REQUEST_IPS.lock().ok()?.insert( + fetch_request_id(fetch_request), + ( + server_handle as usize, + request.remote_address.clone(), + request.remote_port, + ), + ); + Some(fetch_request) +} + +fn handler_for(server_handle: i64, error: bool) -> i64 { + get_handle::(server_handle) + .map(|server| { + if error { + server.bun_error_handler + } else { + server.handler + } + }) + .unwrap_or(0) +} + +fn invoke_fetch(server_handle: i64, request: f64) -> ClosureCallResult { + let scope = TransientRootScope::enter(); + let handler = scope.root_addr(handler_for(server_handle, false)); + if handler.get() == 0 { + return ClosureCallResult { + value: f64::from_bits(TAG_UNDEFINED), + thrown: Some(f64::from_bits( + perry_ffi::error_value_with_code( + "Bun.serve fetch handler is unavailable", + "ERR_INVALID_STATE", + ErrorKind::Error, + ) + .bits(), + )), + }; + } + let closure = unsafe { JsClosure::from_raw(handler.get() as *const RawClosureHeader) }; + let server = handle_to_pointer_f64(server_handle); + unsafe { call_catching(|| with_implicit_this(server, || closure.call2(request, server))) } +} + +fn invoke_error(server_handle: i64, reason: f64) -> Option { + let scope = TransientRootScope::enter(); + let reason = scope.root_nanbox(reason); + let handler = scope.root_addr(handler_for(server_handle, true)); + if handler.get() == 0 { + return None; + } + let closure = unsafe { JsClosure::from_raw(handler.get() as *const RawClosureHeader) }; + let server = handle_to_pointer_f64(server_handle); + Some(unsafe { call_catching(|| with_implicit_this(server, || closure.call1(reason.get()))) }) +} + +fn apply_response(response_handle: i64, value: f64) -> bool { + let snapshot_ptr = unsafe { js_bun_http_response_snapshot_json(value) }; + let Some(snapshot_json) = read_string_header(snapshot_ptr) else { + return false; + }; + let Some(snapshot) = ResponseSnapshot::from_json(&snapshot_json) else { + return false; + }; + let Some(response) = get_handle_mut::(response_handle) else { + return false; + }; + response.status_code = snapshot.status; + response.status_message = (!snapshot.status_text.is_empty()).then_some(snapshot.status_text); + response.headers.clear(); + response.header_value_lists.clear(); + response.raw_header_names.clear(); + response.header_order.clear(); + for (name, value) in snapshot.headers { + let lower = name.to_ascii_lowercase(); + if let Some(previous) = response.headers.get(&lower).cloned() { + response + .header_value_lists + .entry(lower.clone()) + .or_insert_with(|| vec![previous]) + .push(value.clone()); + } else { + response.header_order.push(lower.clone()); + response.raw_header_names.insert(lower.clone(), name); + } + response.headers.insert(lower, value); + } + response.buffered_body = snapshot.body; + synthesize_default_response_if_needed(response_handle); + true +} + +fn remove_request_ip(fetch_request: f64) { + if let Ok(mut ips) = REQUEST_IPS.lock() { + ips.remove(&fetch_request_id(fetch_request)); + } +} + +fn error_message(reason: f64) -> String { + let scope = TransientRootScope::enter(); + let reason = scope.root_nanbox(reason); + let ptr = unsafe { js_jsvalue_to_string(reason.get()) }; + read_string_header(ptr).unwrap_or_else(|| "Internal Server Error".to_string()) +} + +fn apply_default_error(response_handle: i64, reason: f64) { + let message = error_message(reason); + if let Some(response) = get_handle_mut::(response_handle) { + response.status_code = 500; + response.headers.insert( + "content-type".to_string(), + "text/plain;charset=utf-8".to_string(), + ); + response + .raw_header_names + .insert("content-type".to_string(), "Content-Type".to_string()); + response.header_order.push("content-type".to_string()); + response.buffered_body = message.into_bytes(); + } + synthesize_default_response_if_needed(response_handle); +} + +fn queue_promise( + context: &HttpPendingRequest, + fetch_request: f64, + promise: *mut Promise, + stage: PromiseStage, +) { + let entry = PendingPromise { + server_handle: context.server_handle, + request_handle: context.request_handle, + response_handle: context.response_handle, + fetch_request, + promise: promise as i64, + stage, + }; + match PENDING_PROMISES.lock() { + Ok(mut pending) => pending.push(entry), + Err(_) => { + let reason = f64::from_bits( + perry_ffi::error_value_with_code( + "Bun.serve promise queue is unavailable", + "ERR_INVALID_STATE", + ErrorKind::Error, + ) + .bits(), + ); + apply_default_error(context.response_handle, reason); + remove_request_ip(fetch_request); + } + } +} + +fn settle_value(context: &HttpPendingRequest, fetch_request: f64, value: f64, stage: PromiseStage) { + let js_value = JsValue::from_bits(value.to_bits()); + if js_value.is_pointer() && unsafe { js_value_is_promise(value) } != 0 { + let promise = js_value.as_pointer::(); + if !promise.is_null() { + match unsafe { js_promise_state(promise) } { + 1 => { + let value = unsafe { js_promise_value(promise) }; + settle_value(context, fetch_request, value, stage); + } + 2 => { + let reason = unsafe { js_promise_reason(promise) }; + settle_failure(context, fetch_request, reason, stage); + } + _ => queue_promise(context, fetch_request, promise, stage), + } + return; + } + } + + if apply_response(context.response_handle, value) { + remove_request_ip(fetch_request); + } else { + let reason = f64::from_bits( + perry_ffi::error_value_with_code( + "Bun.serve handlers must return a Response", + "ERR_INVALID_RETURN_VALUE", + ErrorKind::TypeError, + ) + .bits(), + ); + settle_failure(context, fetch_request, reason, stage); + } +} + +fn settle_failure( + context: &HttpPendingRequest, + fetch_request: f64, + reason: f64, + stage: PromiseStage, +) { + if matches!(stage, PromiseStage::Error) { + apply_default_error(context.response_handle, reason); + remove_request_ip(fetch_request); + return; + } + match invoke_error(context.server_handle, reason) { + Some(call) => { + if let Some(thrown) = call.thrown { + apply_default_error(context.response_handle, thrown); + remove_request_ip(fetch_request); + } else { + settle_value(context, fetch_request, call.value, PromiseStage::Error); + } + } + None => { + apply_default_error(context.response_handle, reason); + remove_request_ip(fetch_request); + } + } +} + +pub(crate) fn process_request(pending: HttpPendingRequest) { + unsafe { + js_handle_clear_side_tables(pending.request_handle); + js_handle_clear_side_tables(pending.response_handle); + } + let Some(fetch_request) = make_fetch_request(pending.server_handle, pending.request_handle) + else { + let reason = f64::from_bits( + perry_ffi::error_value_with_code( + "Bun.serve could not construct the Request", + "ERR_INVALID_STATE", + ErrorKind::Error, + ) + .bits(), + ); + settle_failure( + &pending, + f64::from_bits(TAG_UNDEFINED), + reason, + PromiseStage::Fetch, + ); + finalize_or_park_request(&pending); + return; + }; + + let call = invoke_fetch(pending.server_handle, fetch_request); + if let Some(reason) = call.thrown { + settle_failure(&pending, fetch_request, reason, PromiseStage::Fetch); + } else { + let scope = TransientRootScope::enter(); + let result = scope.root_nanbox(call.value); + unsafe { + js_promise_run_microtasks(); + } + settle_value(&pending, fetch_request, result.get(), PromiseStage::Fetch); + } + finalize_or_park_request(&pending); +} + +/// Poll promise-returning fetch/error handlers without blocking the JS thread. +pub(crate) fn process_pending_promises() -> i32 { + let mut settled = Vec::new(); + if let Ok(mut pending) = PENDING_PROMISES.lock() { + let mut index = 0; + while index < pending.len() { + let promise = pending[index].promise as *mut Promise; + let state = if promise.is_null() { + 2 + } else { + unsafe { js_promise_state(promise) } + }; + let handles_alive = get_handle::(pending[index].request_handle) + .is_some() + && get_handle::(pending[index].response_handle).is_some(); + if state != 0 || !handles_alive { + settled.push((pending.remove(index), state, handles_alive)); + } else { + index += 1; + } + } + } + + let count = settled.len() as i32; + for (entry, state, handles_alive) in settled { + if !handles_alive { + remove_request_ip(entry.fetch_request); + continue; + } + let context = HttpPendingRequest { + server_handle: entry.server_handle, + request_handle: entry.request_handle, + response_handle: entry.response_handle, + skip_default_response: false, + h2_stream_handle: 0, + h2_stream_headers: Vec::new(), + is_check_continue: false, + }; + let promise = entry.promise as *mut Promise; + if state == 1 { + settle_value( + &context, + entry.fetch_request, + unsafe { js_promise_value(promise) }, + entry.stage, + ); + } else { + let reason = if promise.is_null() { + f64::from_bits( + perry_ffi::error_value_with_code( + "Bun.serve handler promise became unavailable", + "ERR_INVALID_STATE", + ErrorKind::Error, + ) + .bits(), + ) + } else { + unsafe { js_promise_reason(promise) } + }; + settle_failure(&context, entry.fetch_request, reason, entry.stage); + } + } + count +} + +/// `server.requestIP(request)`. +#[no_mangle] +pub extern "C" fn js_bun_server_request_ip(server_handle: i64, request: f64) -> f64 { + let Some((address, port)) = REQUEST_IPS.lock().ok().and_then(|ips| { + ips.get(&fetch_request_id(request)) + .filter(|(owner, _, _)| *owner == server_handle as usize) + .map(|(_, address, port)| (address.clone(), *port)) + }) else { + return f64::from_bits(TAG_NULL); + }; + let family = if address.contains(':') { + "IPv6" + } else { + "IPv4" + }; + let scope = TransientRootScope::enter(); + let address = scope.root_nanbox(f64::from_bits( + JsValue::from_string_ptr(alloc_string(&address).as_raw()).bits(), + )); + let family = scope.root_nanbox(f64::from_bits( + JsValue::from_string_ptr(alloc_string(family).as_raw()).bits(), + )); + f64::from_bits( + alloc_null_proto_object(&[ + ("address", JsValue::from_bits(address.get().to_bits())), + ("port", JsValue::from_number(port as f64)), + ("family", JsValue::from_bits(family.get().to_bits())), + ]) + .bits(), + ) +} + +fn cancel_pending_for_server(server_handle: i64) { + let mut requests = Vec::new(); + if let Ok(mut pending) = PENDING_PROMISES.lock() { + pending.retain(|entry| { + if entry.server_handle == server_handle { + requests.push(entry.fetch_request); + false + } else { + true + } + }); + } + for request in requests { + remove_request_ip(request); + } +} + +/// `server.stop(closeActiveConnections?)`. +#[no_mangle] +pub unsafe extern "C" fn js_bun_server_stop(server_handle: i64, close_active: f64) -> f64 { + crate::server::server::js_node_http_server_close(server_handle, 0); + if JsValue::from_bits(close_active.to_bits()).to_bool() { + cancel_pending_for_server(server_handle); + crate::server::server::js_node_http_server_close_all_connections(server_handle); + } + let promise = js_promise_resolved(f64::from_bits(TAG_UNDEFINED)); + f64::from_bits(JsValue::from_object_ptr(promise).bits()) +} + +pub(crate) fn hostname(handle: i64) -> Option { + get_handle::(handle) + .filter(|server| server.is_bun_server) + .map(|server| server.bound_host.clone()) +} + +pub(crate) fn port(handle: i64) -> Option { + get_handle::(handle) + .filter(|server| server.is_bun_server) + .map(|server| server.bound_port) +} + +pub(crate) fn development(handle: i64) -> Option { + get_handle::(handle) + .filter(|server| server.is_bun_server) + .map(|server| server.bun_development) +} diff --git a/crates/perry-ext-http/src/server/dispatch_ext.rs b/crates/perry-ext-http/src/server/dispatch_ext.rs index 51478a678b..a13ea8f317 100644 --- a/crates/perry-ext-http/src/server/dispatch_ext.rs +++ b/crates/perry-ext-http/src/server/dispatch_ext.rs @@ -99,6 +99,14 @@ fn is_http_server_method(name: &str) -> bool { ) } +fn is_bun_server_method(name: &str) -> bool { + matches!(name, "stop" | "requestIP") +} + +fn is_bun_server_property(name: &str) -> bool { + is_bun_server_method(name) || matches!(name, "hostname" | "port" | "development" | "protocol") +} + fn is_http_server_property(name: &str) -> bool { is_http_server_method(name) || matches!( @@ -344,8 +352,9 @@ unsafe extern "C" fn http_server_method_dispatch_ext( if name.is_empty() { return 0; } - let value = if is_http_server_method(name) - && crate::server::handle_dispatch::js_ext_http_server_is_handle(handle) != 0 + let value = if crate::server::handle_dispatch::js_ext_http_server_is_handle(handle) != 0 + && (is_http_server_method(name) + || (is_bun_server_method(name) && crate::server::bun_server::is_bun_server(handle))) { Some( crate::server::handle_dispatch::js_ext_http_server_dispatch_method( @@ -409,8 +418,9 @@ unsafe extern "C" fn http_server_property_dispatch_ext( if name.is_empty() { return 0; } - let value = if is_http_server_property(name) - && crate::server::handle_dispatch::js_ext_http_server_is_handle(handle) != 0 + let value = if crate::server::handle_dispatch::js_ext_http_server_is_handle(handle) != 0 + && (is_http_server_property(name) + || (is_bun_server_property(name) && crate::server::bun_server::is_bun_server(handle))) { Some( crate::server::handle_dispatch::js_ext_http_server_dispatch_property( diff --git a/crates/perry-ext-http/src/server/handle_dispatch.rs b/crates/perry-ext-http/src/server/handle_dispatch.rs index 398677b020..35eab6317a 100644 --- a/crates/perry-ext-http/src/server/handle_dispatch.rs +++ b/crates/perry-ext-http/src/server/handle_dispatch.rs @@ -226,6 +226,8 @@ fn http_server_method_bytes(name: &str) -> Option<&'static [u8]> { "setTimeout" => Some(b"setTimeout"), "ref" => Some(b"ref"), "unref" => Some(b"unref"), + "stop" => Some(b"stop"), + "requestIP" => Some(b"requestIP"), "setTicketKeys" => Some(b"setTicketKeys"), "@@__perry_wk_asyncDispose" => Some(b"@@__perry_wk_asyncDispose"), _ => None, @@ -602,6 +604,18 @@ pub unsafe extern "C" fn js_ext_http_server_dispatch_method( } self_ref } + "stop" if crate::server::bun_server::is_bun_server(handle) => { + crate::server::bun_server::js_bun_server_stop( + handle, + args.first().copied().unwrap_or(undef), + ) + } + "requestIP" if crate::server::bun_server::is_bun_server(handle) => { + crate::server::bun_server::js_bun_server_request_ip( + handle, + args.first().copied().unwrap_or(undef), + ) + } "@@__perry_wk_asyncDispose" => { if server_is_listening(handle, is_https, is_h2) { if is_h2 { @@ -642,6 +656,18 @@ pub unsafe extern "C" fn js_ext_http_server_dispatch_property( let is_https = get_handle::(handle).is_some(); let is_h2 = get_handle::(handle).is_some(); match property.as_str() { + "hostname" => crate::server::bun_server::hostname(handle) + .map(|hostname| string_ptr_value(alloc_string(&hostname).as_raw())) + .unwrap_or(undef), + "port" => crate::server::bun_server::port(handle) + .map(|port| port as f64) + .unwrap_or(undef), + "development" => crate::server::bun_server::development(handle) + .map(bool_value) + .unwrap_or(undef), + "protocol" if crate::server::bun_server::is_bun_server(handle) => { + string_ptr_value(alloc_string("http").as_raw()) + } "listening" => bool_value(server_is_listening(handle, is_https, is_h2)), "ALPNProtocols" if is_https => get_handle::(handle) .and_then(|server| server.alpn_protocols.as_ref()) diff --git a/crates/perry-ext-http/src/server/mod.rs b/crates/perry-ext-http/src/server/mod.rs index 897cb56f94..c15f1f6340 100644 --- a/crates/perry-ext-http/src/server/mod.rs +++ b/crates/perry-ext-http/src/server/mod.rs @@ -52,6 +52,7 @@ use std::sync::Once; use perry_ffi::{gc_register_mutable_root_scanner_named, iter_handles_of_mut, GcRootVisitor}; +mod bun_server; mod cluster_bind; mod dispatch_ext; mod handle_dispatch; @@ -124,6 +125,7 @@ fn scan_http_server_roots(visitor: &mut GcRootVisitor<'_>) { fn scan_base_server_roots(server: &mut HttpServer, visitor: &mut GcRootVisitor<'_>) { visitor.visit_i64_slot(&mut server.handler); + visitor.visit_i64_slot(&mut server.bun_error_handler); scan_listener_roots(&mut server.listeners, visitor); scan_listener_roots(&mut server.once_listeners, visitor); // #4903 — listen callbacks queued for the deferred `'listening'` @@ -189,6 +191,7 @@ fn scan_http_server_roots(visitor: &mut GcRootVisitor<'_>) { // Closures parked in the HTTP/2 pending-event queue between a JS-side // `session.close/settings/ping(cb)` and the main-thread drain. http2_server::scan_h2_pending_event_roots(visitor); + bun_server::scan_pending_roots(visitor); } #[cfg(test)] diff --git a/crates/perry-ext-http/src/server/server.rs b/crates/perry-ext-http/src/server/server.rs index 6cb0a0212b..31833c79a9 100644 --- a/crates/perry-ext-http/src/server/server.rs +++ b/crates/perry-ext-http/src/server/server.rs @@ -160,6 +160,14 @@ pub struct HttpServer { /// `true` (refed), matching Node where a fresh server holds the loop /// open once it's listening. pub refed: bool, + /// `Bun.serve` marks its backing `HttpServer` so pending requests use the + /// Fetch Request/Response adapter instead of Node's `(req, res)` callback. + pub is_bun_server: bool, + /// Optional `Bun.serve({ error })` callback. `handler` stores the required + /// `fetch` callback; both raw closure addresses are pinned by the scanner. + pub bun_error_handler: i64, + /// Observable `Server.development` option. + pub bun_development: bool, } impl HttpServer { @@ -195,6 +203,9 @@ impl HttpServer { keep_alive_initial_delay: 0.0, connections_checking_interval_destroyed: false, refed: true, + is_bun_server: false, + bun_error_handler: 0, + bun_development: false, } } } @@ -1505,6 +1516,10 @@ pub extern "C" fn js_node_http_server_process_pending() -> i32 { // `perry_ffi::drain_quarantined_handles`.) perry_ffi::drain_quarantined_handles(); + // Settle `Bun.serve` fetch/error promises before the ordinary in-flight + // reaper observes their ServerResponse handles. + count += crate::server::bun_server::process_pending_promises(); + // #4728 — finalize any async-handler requests that have flushed their // response since the last tick (or timed out) before draining new ones. reap_in_flight_requests(); @@ -1675,6 +1690,10 @@ pub(crate) fn try_recv_pending_nonblocking(server_handle: i64) -> Option Option Some(1), ("bun", "deepEquals" | "generateHeapSnapshot" | "spawn" | "write") => Some(2), @@ -301,6 +301,7 @@ static CALLABLE_EXPORT_ARITY_TABLE: &[(&str, &[(&str, u32)])] = &[ ("generateHeapSnapshot", 2), ("hash", 1), ("pathToFileURL", 1), + ("serve", 1), ("spawn", 2), ("stringWidth", 1), ("stripANSI", 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 2446428618..3e910004b4 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 @@ -49,6 +49,7 @@ pub(crate) fn is_native_module_callable_export_reference(module: &str, prop: &st | "generateHeapSnapshot" | "hash" | "pathToFileURL" + | "serve" | "spawn" | "stringWidth" | "stripANSI" 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 7e894c29ed..00dc01e261 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 @@ -95,6 +95,7 @@ pub(super) static CALLABLE_EXPORT_TABLE: &[(&str, &[&str])] = &[ "generateHeapSnapshot", "hash", "pathToFileURL", + "serve", "spawn", "stringWidth", "stripANSI", 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 1e72f81c72..7a0c7ebd39 100644 --- a/crates/perry-runtime/src/object/native_module/module_keys.rs +++ b/crates/perry-runtime/src/object/native_module/module_keys.rs @@ -1645,6 +1645,7 @@ pub(crate) fn native_module_enumerable_keys(module_name: &str) -> Option<&'stati b"isStandaloneExecutable", b"pathToFileURL", b"semver", + b"serve", b"spawn", b"stderr", b"stdin", diff --git a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs index a4dfad2cea..9499eff762 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs @@ -274,6 +274,30 @@ pub(crate) unsafe fn nm_dispatch_bun(ctx: &NmCtx, module_name: &str, method_name match (module_name, method_name) { ("bun", "spawn") => crate::bun_compat::js_bun_spawn(arg(0), arg(1)), ("bun", "Terminal") => crate::bun_compat::js_bun_terminal_new(arg(0)), + ("bun", "serve") => { + let ptr = + crate::value::JS_NATIVE_HTTP_DISPATCH.load(std::sync::atomic::Ordering::SeqCst); + if ptr.is_null() { + f64::from_bits(JSValue::undefined().bits()) + } else { + let dispatch: unsafe extern "C" fn( + *const u8, + usize, + *const u8, + usize, + *const f64, + usize, + ) -> f64 = std::mem::transmute(ptr); + dispatch( + module_name.as_ptr(), + module_name.len(), + method_name.as_ptr(), + method_name.len(), + args_ptr, + args_len, + ) + } + } ("bun", "stringWidth") => crate::bun_compat::js_bun_string_width(arg(0), arg(1)), ("bun", "hash") => crate::bun_compat::js_bun_hash(arg(0), arg(1)), ("bun", "deepEquals") => crate::bun_compat::js_bun_deep_equals(arg(0), arg(1), arg(2)), diff --git a/crates/perry-stdlib/src/common/dispatch/init.rs b/crates/perry-stdlib/src/common/dispatch/init.rs index 1cf933d6e4..548e049ee3 100644 --- a/crates/perry-stdlib/src/common/dispatch/init.rs +++ b/crates/perry-stdlib/src/common/dispatch/init.rs @@ -220,6 +220,7 @@ unsafe extern "C" fn js_node_http_native_dispatch( ) -> f64 { use perry_runtime::JSValue; extern "C" { + fn js_bun_serve(options: f64) -> i64; fn js_node_http_create_server_with_options(first_arg: f64, second_arg: f64) -> i64; fn js_node_http_outgoing_message_new() -> i64; fn js_node_https_create_server(opts_f64: f64, handler: i64) -> i64; @@ -245,6 +246,14 @@ unsafe extern "C" fn js_node_http_native_dispatch( undefined } }; + if module == "bun" && method == "serve" { + let handle = js_bun_serve(arg(0)); + return if handle == 0 { + undefined + } else { + perry_runtime::js_nanbox_pointer(handle) + }; + } if module == "http" && method == "OutgoingMessage" { let handle = js_node_http_outgoing_message_new(); return if handle == 0 { diff --git a/crates/perry-stdlib/src/fetch/bun_server_bridge.rs b/crates/perry-stdlib/src/fetch/bun_server_bridge.rs new file mode 100644 index 0000000000..a52bdf862b --- /dev/null +++ b/crates/perry-stdlib/src/fetch/bun_server_bridge.rs @@ -0,0 +1,104 @@ +//! Registry bridge for the `Bun.serve` adapter in `perry-ext-http`. +//! +//! Fetch `Request` and `Response` values are numeric handles owned by this +//! module, so the external HTTP provider cannot safely inspect their private +//! registries. These two FFI helpers exchange copied request/response snapshots +//! as JSON while keeping registry access here. + +use super::*; + +#[derive(serde::Deserialize)] +struct BunHttpRequestSnapshot { + url: String, + method: String, + headers: Vec<(String, String)>, + body: Option>, +} + +#[derive(serde::Serialize)] +struct BunHttpResponseSnapshot { + status: u16, + status_text: String, + headers: Vec<(String, String)>, + body: Vec, +} + +/// Construct a Fetch `Request` from a server-side HTTP request snapshot. +/// +/// Returns `undefined` when `snapshot_ptr` is null or malformed. +/// +/// # Safety +/// `snapshot_ptr` must be null or a live Perry `StringHeader`. +#[no_mangle] +pub unsafe extern "C" fn js_bun_http_request_from_json(snapshot_ptr: *const StringHeader) -> f64 { + let Some(snapshot_json) = string_from_header(snapshot_ptr) else { + return f64::from_bits(TAG_UNDEFINED); + }; + let Ok(snapshot) = serde_json::from_str::(&snapshot_json) else { + return f64::from_bits(TAG_UNDEFINED); + }; + + let mut headers = HeadersStore::default(); + for (name, value) in snapshot.headers { + headers.append(&name, &value); + } + + // Allocating the default AbortSignal can collect, so do it before taking + // the request-registry lock (see fetch::gc's locking contract). + let signal = body_metadata::signal_or_default(f64::from_bits(TAG_UNDEFINED)); + let id = alloc_fetch_handle_id(); + let record = RequestRecord { + url: snapshot.url, + method: snapshot.method, + body: snapshot.body, + body_used: false, + headers, + destination: String::new(), + referrer: "about:client".to_string(), + referrer_policy: String::new(), + mode: "cors".to_string(), + credentials: "same-origin".to_string(), + cache: "default".to_string(), + redirect: "follow".to_string(), + integrity: String::new(), + keepalive: false, + duplex: "half".to_string(), + signal, + cached_headers_id: None, + }; + gc::ensure_gc_registered(); + REQUEST_REGISTRY.lock().unwrap().insert(id, record); + handle_to_f64(id) +} + +/// Consume a Fetch `Response` and return its observable wire snapshot as JSON. +/// +/// Returns null for a non-Response handle or an already-consumed body. Header +/// mutations made through `response.headers` are included. +#[no_mangle] +pub extern "C" fn js_bun_http_response_snapshot_json(response_handle: f64) -> *mut StringHeader { + let response_id = handle_id(response_handle); + if !FETCH_RESPONSES.lock().unwrap().contains_key(&response_id) { + return std::ptr::null_mut(); + } + let Ok(body) = consume_response_body(response_handle) else { + return std::ptr::null_mut(); + }; + let snapshot = { + let guard = FETCH_RESPONSES.lock().unwrap(); + let Some(response) = guard.get(&response_id) else { + return std::ptr::null_mut(); + }; + let headers = response_headers_snapshot(response); + BunHttpResponseSnapshot { + status: response.status, + status_text: response.status_text.clone(), + headers: headers.entries, + body, + } + }; + let Ok(json) = serde_json::to_string(&snapshot) else { + return std::ptr::null_mut(); + }; + js_string_from_bytes(json.as_ptr(), json.len() as u32) +} diff --git a/crates/perry-stdlib/src/fetch/mod.rs b/crates/perry-stdlib/src/fetch/mod.rs index 5e10e72dd7..fc5ab32d7b 100644 --- a/crates/perry-stdlib/src/fetch/mod.rs +++ b/crates/perry-stdlib/src/fetch/mod.rs @@ -39,6 +39,12 @@ pub use dispatch::*; mod body_metadata; pub use body_metadata::*; +// Bridge used by perry-ext-http's Bun.serve adapter. The ext crate owns the +// listener, while this module owns the Fetch Request/Response registries; the +// small JSON ABI keeps those ownership boundaries intact. +mod bun_server_bridge; +pub use bun_server_bridge::*; + // GC root scanner for the heap values the Fetch registries hold (#8163): // the two bound-method caches and `RequestRecord::signal`. Same // child-module/`use super::*` contract as `headers`. diff --git a/crates/perry/tests/issue_6560_bun_globals.rs b/crates/perry/tests/issue_6560_bun_globals.rs index d138baedd4..1c7722f0c9 100644 --- a/crates/perry/tests/issue_6560_bun_globals.rs +++ b/crates/perry/tests/issue_6560_bun_globals.rs @@ -272,13 +272,13 @@ fn unsupported_bun_calls_fail_explicitly() { let stdout = compile_and_run( r#" try { - Bun.serve({ port: 0 }); + Bun.spawn(["echo", "hello"]); } catch (error: any) { console.log(error.name, error.message); } "#, ); - assert_eq!(stdout, "Error Bun.serve is not supported by Perry\n"); + assert_eq!(stdout, "Error Bun.spawn is not supported by Perry\n"); } #[test] diff --git a/crates/perry/tests/issue_9599_bun_platform.rs b/crates/perry/tests/issue_9599_bun_platform.rs index c4b6aa9efa..4023a218df 100644 --- a/crates/perry/tests/issue_9599_bun_platform.rs +++ b/crates/perry/tests/issue_9599_bun_platform.rs @@ -98,14 +98,9 @@ console.log(Bun?.stringWidth?.("abcde")); console.log(typeof Bun.version, Bun.version.length > 0); console.log(Bun.isStandaloneExecutable); console.log(typeof Bun.notImplemented); +console.log(typeof Bun.serve); const keys = Object.keys(Bun); -console.log(keys.includes("stringWidth"), keys.includes("version")); - -try { - Bun.serve(); -} catch (error: any) { - console.log(error.message === "Bun.serve is not supported by Perry"); -} +console.log(keys.includes("stringWidth"), keys.includes("version"), keys.includes("serve")); function scoped() { const Bun = { stringWidth: (_value: string) => 99 }; @@ -131,8 +126,8 @@ function string true true undefined -true true -true +function +true true true 99 "; assert_eq!(run(&output, dir.path()), expected); diff --git a/crates/perry/tests/issue_9603_bun_serve.rs b/crates/perry/tests/issue_9603_bun_serve.rs new file mode 100644 index 0000000000..8a65362262 --- /dev/null +++ b/crates/perry/tests/issue_9603_bun_serve.rs @@ -0,0 +1,143 @@ +//! #9603 — Bun.serve facade backed by Perry's native HTTP server. + +use std::io::Read; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile(dir: &std::path::Path, source: &str) -> PathBuf { + let entry = dir.join("main.ts"); + let output = dir.join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + let compile = Command::new(perry_bin()) + .current_dir(dir) + .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) + ); + output +} + +fn run_with_timeout(bin: &std::path::Path, secs: u64) -> (String, String) { + let mut child = Command::new(bin) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn compiled binary"); + let mut stdout_pipe = child.stdout.take().expect("piped stdout"); + let mut stderr_pipe = child.stderr.take().expect("piped stderr"); + let stdout_reader = std::thread::spawn(move || { + let mut output = String::new(); + let _ = stdout_pipe.read_to_string(&mut output); + output + }); + let stderr_reader = std::thread::spawn(move || { + let mut output = String::new(); + let _ = stderr_pipe.read_to_string(&mut output); + output + }); + + let deadline = Instant::now() + Duration::from_secs(secs); + loop { + match child.try_wait().expect("try_wait") { + Some(status) => { + let stdout = stdout_reader.join().unwrap_or_default(); + let stderr = stderr_reader.join().unwrap_or_default(); + assert!( + status.success(), + "compiled Bun.serve fixture failed\nstatus: {status:?}\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + return (stdout, stderr); + } + None if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + let stdout = stdout_reader.join().unwrap_or_default(); + let stderr = stderr_reader.join().unwrap_or_default(); + panic!("Bun.serve fixture hung for >{secs}s\nstdout:\n{stdout}\nstderr:\n{stderr}"); + } + None => std::thread::sleep(Duration::from_millis(50)), + } + } +} + +#[test] +fn named_serve_handles_fetch_responses_errors_and_lifecycle() { + let dir = tempfile::tempdir().expect("tempdir"); + let bin = compile( + dir.path(), + r#" +import { serve } from "bun"; + +const server = serve({ + hostname: "127.0.0.1", + port: 0, + idleTimeout: 0, + development: false, + async fetch(request: Request, activeServer: any) { + const url = new URL(request.url); + if (url.pathname === "/error") { + throw new Error("boom"); + } + await new Promise((resolve) => setTimeout(resolve, 5)); + const address = activeServer.requestIP(request)?.address ?? "unknown"; + return new Response(`${request.method}:${address}`, { + status: 201, + headers: { "x-bun": "perry" }, + }); + }, + async error(error: Error) { + return new Response(error.message, { status: 503 }); + }, +}); + +console.log(server.hostname, server.port > 0, server.development, server.protocol); +const response = await fetch(`http://127.0.0.1:${server.port}/`); +console.log(response.status, response.headers.get("x-bun"), await response.text()); +const failed = await fetch(`http://127.0.0.1:${server.port}/error`); +console.log(failed.status, await failed.text()); +server.unref(); +server.ref(); +await server.stop(true); +console.log("stopped"); +"#, + ); + let (stdout, stderr) = run_with_timeout(&bin, 30); + assert_eq!( + stdout, "127.0.0.1 true false http\n201 perry GET:127.0.0.1\n503 boom\nstopped\n", + "unexpected stderr:\n{stderr}" + ); +} + +#[test] +fn tls_options_fail_with_an_explicit_diagnostic() { + let dir = tempfile::tempdir().expect("tempdir"); + let bin = compile( + dir.path(), + r#" +import { serve } from "bun"; +try { + serve({ port: 0, tls: { key: "x", cert: "x" }, fetch: () => new Response("x") }); +} catch (error: any) { + console.log(error.code, error.message); +} +"#, + ); + let (stdout, stderr) = run_with_timeout(&bin, 30); + assert_eq!( + stdout, "ERR_NOT_SUPPORTED Bun.serve TLS options are not supported by Perry yet\n", + "unexpected stderr:\n{stderr}" + ); +} diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 61c93f4fb9..0a1d5de6ae 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,7 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST // Coverage: 2072 entries across 136 modules +// Coverage: 2070 entries across 136 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -369,6 +370,8 @@ declare module "bun" { /** stdlib */ export function pathToFileURL(...args: any[]): any; /** stdlib */ + export function serve(options: any): any; + /** stdlib */ export function stringWidth(...args: any[]): any; /** stdlib */ export function unsupported(...args: any[]): any; diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 8148ac77c8..d2acb4f0bc 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -3,6 +3,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: 3029 entries across 138 modules. +Total: 3023 entries across 138 modules. ## Modules @@ -433,6 +434,7 @@ Total: 3029 entries across 138 modules. - `pathToFileURL` — module - `scan` — instance *(class: `Transpiler`)* - `scanImports` — instance *(class: `Transpiler`)* +- `serve` — module - `stringWidth` — module - `transform` — instance *(class: `Transpiler`)* - `transformSync` — instance *(class: `Transpiler`)* From f913aada19c5b098e7a930740209a0813e637aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 3 Sep 2026 14:00:04 +0200 Subject: [PATCH 3/5] docs(changelog): record Bun serve support --- changelog.d/9625-bun-serve.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog.d/9625-bun-serve.md diff --git a/changelog.d/9625-bun-serve.md b/changelog.d/9625-bun-serve.md new file mode 100644 index 0000000000..4140bf52a6 --- /dev/null +++ b/changelog.d/9625-bun-serve.md @@ -0,0 +1,8 @@ +### Bun compatibility + +- **`Bun.serve` and `import { serve } from "bun"` now run on Perry's native + HTTP server.** The facade supports ephemeral ports, hostname binding, + Fetch `Request`/`Response` handlers (including async returns and the `error` + callback), `requestIP`, observable server properties, and + `stop`/`ref`/`unref` lifecycle methods. TLS options fail explicitly with + `ERR_NOT_SUPPORTED` until native TLS support is added. From e27db70cc38fa1f8f2ed510ecb83cd709097dd4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 3 Sep 2026 15:07:58 +0200 Subject: [PATCH 4/5] fix(gates): raise the ext-typescript handle floor to the band top (#9219 shape), root array_from_values' array and pending values, split lower/tests.rs for the file cap --- crates/perry-ext-typescript/src/bun.rs | 31 +++++++++++++--- crates/perry-hir/src/lower/tests.rs | 33 +---------------- .../tests/nullish_over_optional_chain.rs | 37 +++++++++++++++++++ 3 files changed, 64 insertions(+), 37 deletions(-) create mode 100644 crates/perry-hir/src/lower/tests/nullish_over_optional_chain.rs diff --git a/crates/perry-ext-typescript/src/bun.rs b/crates/perry-ext-typescript/src/bun.rs index bb9878793b..9a47cd891e 100644 --- a/crates/perry-ext-typescript/src/bun.rs +++ b/crates/perry-ext-typescript/src/bun.rs @@ -42,6 +42,12 @@ use swc_ecma_transforms_typescript::{tsx, typescript, Config as TypeScriptConfig use swc_ecma_visit::{Visit, VisitWith}; const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; +/// Mirror of `perry_runtime::value::addr_class::HANDLE_BAND_MAX` — this crate +/// links only `perry-ffi`, so the constant cannot be imported. #9219: a bare +/// `>= 0x10000` floor admits the fetch/zlib/proxy handle bands, which are real +/// addresses on Linux (macOS happens to hide it), so anything below the band +/// top must not be treated as a heap pointer. +const HANDLE_BAND_MAX: usize = 0x100000; const PLUGIN_FILE_PREFIX: &str = "perry-bun-plugin:"; extern "C" { @@ -707,7 +713,7 @@ 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 { + } else if bits >> 48 == 0 && bits as usize >= HANDLE_BAND_MAX { bits as i64 } else { 0 @@ -1256,11 +1262,26 @@ fn run_build(options_value: f64) -> Result, Vec> 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) }; + // Every `js_array_push` can grow the array and drive a collection, which + // moves both the array and the elements still waiting in `values`. Root all + // of them and re-read through the handles at each use rather than holding + // raw addresses across the loop. + let scope = TransientRootScope::enter(); + let rooted: Vec<_> = values + .into_iter() + .map(|value| scope.root_nanbox(f64::from_bits(value.bits()))) + .collect(); + let array = scope.root_addr(unsafe { js_array_alloc(rooted.len() as u32) } as i64); + for value in &rooted { + let grown = unsafe { + js_array_push( + array.get() as *mut ArrayHeader, + JsValue::from_bits(value.get().to_bits()), + ) + }; + debug_assert!(!grown.is_null()); } - JsValue::from_object_ptr(array) + JsValue::from_object_ptr(array.get() as *mut ArrayHeader) } extern "C" fn bun_build_output_text(closure: *const RawClosureHeader) -> f64 { diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 0f415d26b7..7c755d7702 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -1973,35 +1973,4 @@ fn unresolved_new_names_the_identifier_and_defers_to_a_runtime_global_lookup() { mod capture_stash; mod mixin_parent_chain; -/// `const masks = opts?.masks ?? null` must not be declared `Null`. The -/// AST-level `??` rule used to answer the right operand's type whenever the -/// left inferred `Any` — and an optional chain always does — so the binding -/// was typed `Null`, which downstream read as "holds no pointer". -#[test] -fn nullish_coalescing_over_an_optional_chain_is_not_typed_by_its_right_operand() { - let source = r#" - function add(opts: { masks: number[] } | null) { - const masks = opts?.masks ?? null; - return masks; - } - "#; - let module = perry_parser::parse_typescript(source, "coalesce.ts").expect("source parses"); - let hir = super::lower_module(&module, "coalesce", "coalesce.ts").expect("source lowers"); - let add = hir - .functions - .iter() - .find(|f| f.name == "add") - .expect("`add` lowers"); - let masks_ty = add - .body - .iter() - .find_map(|stmt| match stmt { - Stmt::Let { name, ty, .. } if name == "masks" => Some(ty), - _ => None, - }) - .expect("`masks` lowers to a Let"); - assert!( - !matches!(masks_ty, Type::Null | Type::Void), - "`opts?.masks ?? null` is an array on the non-null path; got {masks_ty:?}" - ); -} +mod nullish_over_optional_chain; diff --git a/crates/perry-hir/src/lower/tests/nullish_over_optional_chain.rs b/crates/perry-hir/src/lower/tests/nullish_over_optional_chain.rs new file mode 100644 index 0000000000..637e7334c5 --- /dev/null +++ b/crates/perry-hir/src/lower/tests/nullish_over_optional_chain.rs @@ -0,0 +1,37 @@ +//! `??` over an optional chain must not take its right operand's type +//! (#9610-adjacent typing rule). Split from `tests.rs` for the 2000-line cap. + +use super::*; + +/// `const masks = opts?.masks ?? null` must not be declared `Null`. The +/// AST-level `??` rule used to answer the right operand's type whenever the +/// left inferred `Any` — and an optional chain always does — so the binding +/// was typed `Null`, which downstream read as "holds no pointer". +#[test] +fn nullish_coalescing_over_an_optional_chain_is_not_typed_by_its_right_operand() { + let source = r#" + function add(opts: { masks: number[] } | null) { + const masks = opts?.masks ?? null; + return masks; + } + "#; + let module = perry_parser::parse_typescript(source, "coalesce.ts").expect("source parses"); + let hir = super::lower_module(&module, "coalesce", "coalesce.ts").expect("source lowers"); + let add = hir + .functions + .iter() + .find(|f| f.name == "add") + .expect("`add` lowers"); + let masks_ty = add + .body + .iter() + .find_map(|stmt| match stmt { + Stmt::Let { name, ty, .. } if name == "masks" => Some(ty), + _ => None, + }) + .expect("`masks` lowers to a Let"); + assert!( + !matches!(masks_ty, Type::Null | Type::Void), + "`opts?.masks ?? null` is an array on the non-null path; got {masks_ty:?}" + ); +} From 4d91080da81f2fb92293f4b363acfbc1988ea8c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 3 Sep 2026 15:21:21 +0200 Subject: [PATCH 5/5] fix(gates): justify the ext-typescript HANDLE_BAND_MAX mirror in the addr-class allowlist --- scripts/addr_class_allowlist.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/addr_class_allowlist.txt b/scripts/addr_class_allowlist.txt index 7ca573b098..e3511c2ac8 100644 --- a/scripts/addr_class_allowlist.txt +++ b/scripts/addr_class_allowlist.txt @@ -169,3 +169,4 @@ crates/perry-ext-ratelimit/src/lib.rs | (obj as usize) >= 0x100000 | #7272: same crates/perry-runtime/src/arena/page_meta.rs | let header = addr as *const GcHeader; | promoted-page-run expansion: `addr` starts at a `first_header` recorded by arena/promote.rs's linear block iteration (its grandfathered sibling entry above) and advances by `GcHeader::size` from there, so every address is a block-interior header, never a NaN-box payload; the parse stops at the first implausible size exactly as the arena walkers do crates/perry-runtime/src/arena/tests_promoted_runs.rs | * | arena promoted-run tests: header addresses are offsets into a buffer the test itself allocated and initialised, never NaN-box payloads -- same discipline as the arena/tests.rs entry above crates/perry-runtime/src/box/release_tests.rs | * | async-box release tests: the closure whose GcHeader is read is allocated by the test itself, and the test needs a MUTABLE header to toggle GC_FLAG_MARKED and restore it -- try_read_gc_header yields a shared ref, so it cannot express this. Same discipline as the arena/tests_promoted_runs.rs entry above. +crates/perry-ext-typescript/src/bun.rs | const HANDLE_BAND_MAX: usize = 0x100000; | #9219: raw_heap_address must reject the fetch/zlib/proxy handle bands (real addresses on Linux; macOS hides it), but this crate links only perry-ffi and cannot import value::addr_class::HANDLE_BAND_MAX. The literal is a documented mirror of that constant, used solely as the >= floor — no other band arithmetic here. Delete it if perry-ffi ever re-exports the predicate.