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. diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs index 0daeb23e82..46d1f07b14 100644 --- a/crates/perry-api-manifest/src/entries/part_4.rs +++ b/crates/perry-api-manifest/src/entries/part_4.rs @@ -1104,6 +1104,14 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ method("bun", "hash", false, None), method("bun", "file", false, None), method("bun", "write", 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 130638c209..9cf1a86123 100644 --- a/crates/perry-codegen/src/ext_registry.rs +++ b/crates/perry-codegen/src/ext_registry.rs @@ -712,6 +712,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() { @@ -1009,6 +1029,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 dd183e07ab..a28dc97ea8 100644 --- a/crates/perry-codegen/src/lower_call/native_table/bun.rs +++ b/crates/perry-codegen/src/lower_call/native_table/bun.rs @@ -8,6 +8,17 @@ use super::*; /// `Bun.stdin` / `Bun.stdout` / `Bun.stderr` are property reads (handled by /// `js_native_module_property_by_name`), not rows here. pub(crate) const BUN_ROWS: &[NativeModSig] = &[ + NativeModSig { + module: "bun", + has_receiver: false, + method: "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 { match (module, prop) { // Bun global/module surface (#9599). - ("bun", "Glob" | "file" | "fileURLToPath" | "hash" | "pathToFileURL" | "stringWidth") => { - Some(1) - } + ( + "bun", + "Glob" | "file" | "fileURLToPath" | "hash" | "pathToFileURL" | "serve" | "stringWidth", + ) => Some(1), ("bun", "write") => Some(2), // bun:ffi (#6562). ("bun:ffi", "dlopen") => Some(2), @@ -291,6 +292,7 @@ static CALLABLE_EXPORT_ARITY_TABLE: &[(&str, &[(&str, u32)])] = &[ ("fileURLToPath", 1), ("hash", 1), ("pathToFileURL", 1), + ("serve", 1), ("stringWidth", 1), ("write", 2), ], diff --git a/crates/perry-runtime/src/object/native_module/callable_export_check.rs b/crates/perry-runtime/src/object/native_module/callable_export_check.rs index 72388da1d9..eb4d5b87e9 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_check.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_check.rs @@ -38,7 +38,14 @@ pub(crate) fn is_native_module_callable_export_reference(module: &str, prop: &st if module == "bun" && matches!( prop, - "Glob" | "file" | "fileURLToPath" | "hash" | "pathToFileURL" | "stringWidth" | "write" + "Glob" + | "file" + | "fileURLToPath" + | "hash" + | "pathToFileURL" + | "serve" + | "stringWidth" + | "write" ) { return true; diff --git a/crates/perry-runtime/src/object/native_module/callable_export_table.rs b/crates/perry-runtime/src/object/native_module/callable_export_table.rs index 833a53f044..d55428289d 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 @@ -89,6 +89,7 @@ pub(super) static CALLABLE_EXPORT_TABLE: &[(&str, &[&str])] = &[ "fileURLToPath", "hash", "pathToFileURL", + "serve", "stringWidth", "write", ], diff --git a/crates/perry-runtime/src/object/native_module/module_keys.rs b/crates/perry-runtime/src/object/native_module/module_keys.rs index 3e7757628e..a2926d28fb 100644 --- a/crates/perry-runtime/src/object/native_module/module_keys.rs +++ b/crates/perry-runtime/src/object/native_module/module_keys.rs @@ -1635,6 +1635,7 @@ pub(crate) fn native_module_enumerable_keys(module_name: &str) -> Option<&'stati b"hash", b"isStandaloneExecutable", b"pathToFileURL", + b"serve", b"stderr", b"stdin", b"stdout", 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 3eaeed3cb3..9f8f258d73 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 @@ -272,6 +272,30 @@ pub(crate) unsafe fn nm_dispatch_bun(ctx: &NmCtx, module_name: &str, method_name typed_kind ); match (module_name, method_name) { + ("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", "file") => crate::bun_compat::js_bun_file(arg(0)), 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 b2f97d863f..5d97e2255f 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: 2070 entries across 136 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -363,6 +363,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 8aad6cdfee..4f1f8f32e0 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: 3023 entries across 138 modules. ## Modules @@ -425,6 +425,7 @@ Total: 3022 entries across 138 modules. - `fileURLToPath` — module - `hash` — module - `pathToFileURL` — module +- `serve` — module - `stringWidth` — module - `unsupported` — module - `write` — module