Skip to main content

bun_runtime/
web_api.rs

1// @trace REQ-ENG-006
2// WebSocket + Performance + TextEncoder/TextDecoder + atob/btoa + queueMicrotask
3use ::std::cell::RefCell;
4use ::std::ffi::CString;
5use ::std::io::{Read, Write};
6use ::std::net::TcpStream;
7use ::std::net::ToSocketAddrs;
8use ::std::ptr::NonNull;
9use ::std::sync::atomic::{AtomicU64, Ordering};
10use ::std::sync::{Arc, Mutex};
11use ::std::time::Duration;
12use bun_core::ZBox;
13
14// @trace REQ-ENG-006 [code:bun_uws] — RFC 6455 codec primitives reused for
15// both the plain ws:// (via WebSocketClient) and the wss:// (TLS-driven) path.
16use bun_uws::ws_codec::apply_mask;
17
18use mozjs::conversions::unsafe_jsstr_to_string;
19use mozjs::jsapi::*;
20use mozjs::jsval::{BooleanValue, Int32Value, JSVal, NullValue, ObjectValue, StringValue, UndefinedValue};
21use mozjs::realm::AutoRealm;
22use mozjs::rooted;
23use mozjs::rust::wrappers2::{
24    CallOriginalPromiseResolve, CallOriginalPromiseThen, JS_DefineFunction, JS_DefineProperty3,
25    JS_NewPlainObject, NewArrayObject1,
26};
27
28use crate::gc_store::{gc_store_get, gc_store_insert, gc_store_remove};
29
30// @trace REQ-ENG-005 [algorithm:base64] base64 via workspace bun_base64 (SIMD-accelerated)
31
32// ── WebSocket client ──
33// @trace REQ-ENG-006 [api:WebSocket] [code:bun_uws] — RFC 6455 framing and the
34// plain-text (ws://) client handshake are delegated to `bun_uws::ws_client`
35// (WebSocketClient / parse_ws_url / RecvOutcome) and `bun_uws::ws_codec` /
36// `ws_handshake`. The wss:// (TLS) variant drives `bao_boringssl_bridge`'s
37// TlsConnection over the TCP socket and reuses the same `bun_uws` codec /
38// handshake primitives so the two schemes share one wire-format code path.
39//
40// @trace REQ-STL-001 — the wss:// TLS handshake applies the page's
41// StealthProfile through the exact same application path fetch() uses
42// (`stealth_http::stealth_profile_to_ssl_config` →
43// `bun_http::configure_http_client_with_alpn`), so a page's WebSocket and
44// its fetch present an identical JA3/JA4 fingerprint.
45//
46// Async model (root fix for ScriptThread blocking): `new WebSocket(..)` never
47// connects on the JS thread. The constructor captures the thread's stealth
48// profile, spawns a background worker that performs the full blocking connect
49// (TCP + TLS + RFC 6455 handshake, ≤10s), and returns immediately with
50// readyState=CONNECTING. The worker's outcome lands in an
51// `Arc<Mutex<Option<..>>>` slot — the ONLY cross-thread channel (no JSObject
52// pointers cross threads; BCE-20260621-001 rule). The JS-thread drain pump
53// (`ws_pump_all`, wired into `timers::drain_and_check` /
54// `timers::drain_one_pass` / the servo node-realm evaluate entry) consumes
55// the slot (onopen/onerror) and pumps inbound frames (onmessage/onclose)
56// with the sockets in non-blocking mode.
57
58#[derive(Debug)]
59#[allow(dead_code)]
60enum WsMessage {
61    Text(String),
62    Binary(Vec<u8>),
63    Close,
64}
65
66/// A TLS-over-TCP adapter implementing `std::io::{Read, Write}`. It owns the
67/// raw `TcpStream` plus a BoringSSL `TlsConnection` and transparently drives
68/// the TLS state machine (handshake + record decrypt/encrypt) on every I/O.
69///
70/// `bun_uws`'s `ws_handshake::client_handshake<S: Read + Write>` and
71/// `ws_codec::FrameDecoder` consume this directly, so the wss:// path reuses
72/// the exact same RFC 6455 code as the ws:// path.
73struct TlsStream {
74    tcp: TcpStream,
75    tls: bao_boringssl_bridge::connection::TlsConnection,
76    /// Decrypted plaintext not yet handed to the reader. `Read` callers (the
77    /// WS handshake reads byte-at-a-time) may take less than one TLS record
78    /// per read(); the surplus must survive across calls (BCE-20260814-WS-TLS:
79    /// the prior adapter dropped it, corrupting the handshake).
80    pending_plain: Vec<u8>,
81    pending_off: usize,
82}
83
84impl TlsStream {
85    /// Pump the TLS state machine: flush any pending outgoing ciphertext to the
86    /// socket, then process inbound records until the TLS layer has decrypted
87    /// data ready (or WouldBlock). Returns the decrypted plaintext bytes.
88    fn pump_inbound(&mut self) -> ::std::io::Result<Vec<u8>> {
89        loop {
90            // Drain any ciphertext BoringSSL wants to send first so a
91            // mid-handshake flight isn't stranded in the write BIO.
92            self.flush_outgoing()?;
93            let res = self.tls.process().map_err(|e| {
94                ::std::io::Error::new(::std::io::ErrorKind::InvalidData, e.to_string())
95            })?;
96            if !res.plaintext.is_empty() {
97                let mut joined = Vec::new();
98                for chunk in res.plaintext {
99                    joined.extend_from_slice(&chunk);
100                }
101                return Ok(joined);
102            }
103            // No decrypted data yet — read more ciphertext from the socket.
104            let mut buf = [0u8; 16_384];
105            match self.tcp.read(&mut buf) {
106                Ok(0) => {
107                    return Err(::std::io::Error::new(
108                        ::std::io::ErrorKind::UnexpectedEof,
109                        "tls peer closed",
110                    ));
111                }
112                Ok(n) => self.tls.feed(&buf[..n]),
113                Err(ref e)
114                    if e.kind() == ::std::io::ErrorKind::WouldBlock
115                        || e.kind() == ::std::io::ErrorKind::TimedOut =>
116                {
117                    return Err(::std::io::Error::from(::std::io::ErrorKind::WouldBlock));
118                }
119                Err(e) => return Err(e),
120            }
121        }
122    }
123
124    /// Write any pending ciphertext from BoringSSL's write BIO to the socket.
125    fn flush_outgoing(&mut self) -> ::std::io::Result<()> {
126        let outgoing = self.tls.take_outgoing();
127        if outgoing.is_empty() {
128            return Ok(());
129        }
130        self.tcp.write_all(&outgoing)
131    }
132}
133
134impl ::std::io::Read for TlsStream {
135    fn read(&mut self, buf: &mut [u8]) -> ::std::io::Result<usize> {
136        // Serve buffered plaintext first; only pump the TLS state machine
137        // when the buffer is drained (records can exceed the caller's buf).
138        if self.pending_off >= self.pending_plain.len() {
139            self.pending_plain = self.pump_inbound()?;
140            self.pending_off = 0;
141        }
142        let avail = &self.pending_plain[self.pending_off..];
143        let n = avail.len().min(buf.len());
144        buf[..n].copy_from_slice(&avail[..n]);
145        self.pending_off += n;
146        Ok(n)
147    }
148}
149
150impl ::std::io::Write for TlsStream {
151    fn write(&mut self, buf: &[u8]) -> ::std::io::Result<usize> {
152        let written = self
153            .tls
154            .write(buf)
155            .map_err(|e| ::std::io::Error::new(::std::io::ErrorKind::InvalidData, e.to_string()))?;
156        self.flush_outgoing()?;
157        Ok(written)
158    }
159    fn flush(&mut self) -> ::std::io::Result<()> {
160        self.tcp.flush()
161    }
162}
163
164/// Connection backend — plain ws:// over TCP, or wss:// over TLS.
165enum WsConn {
166    /// Plain WebSocket reusing `bun_uws::WebSocketClient` (RFC 6455 codec +
167    /// handshake + masked client→server frames, all owned by bun_uws).
168    Plain(bun_uws::ws_client::WebSocketClient),
169    /// TLS WebSocket: a `TlsStream` driven through `bun_uws`'s codec/handshake.
170    Tls {
171        stream: TlsStream,
172        decoder: bun_uws::ws_codec::FrameDecoder,
173        closed: bool,
174    },
175}
176
177impl WsConn {
178    /// Connect to a `ws://` or `wss://` URL, applying the caller's stealth
179    /// profile to the wss:// TLS handshake. Runs entirely on the caller's
180    /// thread — the JS bridge only invokes this on a background worker.
181    fn connect(
182        url: &str,
183        profile: &::std::option::Option<bao_stealth::StealthProfile>,
184    ) -> ::std::result::Result<Self, String> {
185        let (scheme, rest) = if let Some(r) = url.strip_prefix("ws://") {
186            ("ws", r)
187        } else if let Some(r) = url.strip_prefix("wss://") {
188            ("wss", r)
189        } else {
190            // Fall back to ws:// semantics for bare hosts (preserves the prior
191            // behavior where a scheme-less URL was treated as ws://).
192            ("ws", url)
193        };
194
195        let (host, port, path) = split_authority_and_path(rest, scheme);
196        if scheme == "wss" {
197            Self::connect_tls(&host, port, &path, profile)
198        } else {
199            // ws:// — delegate to bun_uws::WebSocketClient (reconstructs the
200            // canonical URL because bun_uws::parse_ws_url is scheme-strict).
201            let canonical = if url.starts_with("ws://") || url.starts_with("wss://") {
202                url.to_string()
203            } else {
204                format!("ws://{}", url)
205            };
206            let client = bun_uws::ws_client::WebSocketClient::connect(&canonical)
207                .map_err(|e| format!("ws connect: {}", e))?;
208            Ok(WsConn::Plain(client))
209        }
210    }
211
212    fn connect_tls(
213        host: &str,
214        port: u16,
215        path: &str,
216        profile: &::std::option::Option<bao_stealth::StealthProfile>,
217    ) -> ::std::result::Result<Self, String> {
218        let addr = format!("{}:{}", host, port);
219        let socket_addr = addr
220            .to_socket_addrs()
221            .map_err(|e| format!("invalid address: {}", e))?
222            .next()
223            .ok_or_else(|| format!("no address for {}", addr))?;
224        let mut tcp = TcpStream::connect_timeout(&socket_addr, Duration::from_secs(10))
225            .map_err(|e| format!("connect failed: {}", e))?;
226        tcp.set_nonblocking(false).ok();
227        tcp.set_read_timeout(Some(Duration::from_secs(10))).ok();
228
229        // Build the BoringSSL client connection and drive the TLS handshake.
230        let tls_client = bao_boringssl_bridge::client::TlsClient::new()
231            .map_err(|e| format!("tls client init: {}", e))?;
232        let mut tls =
233            bao_boringssl_bridge::connection::TlsConnection::new_client(&tls_client, host)
234                .map_err(|e| format!("tls conn: {}", e))?;
235
236        // STEALTH (REQ-STL-001): apply the page's TLS fingerprint through the
237        // same application path fetch() uses — cipher list / TLS 1.3 suites /
238        // curves / sigalgs, plus SNI and ALPN(http/1.1) (what a browser
239        // offers on a WebSocket TLS connection). Must run BEFORE the first
240        // `process()` call so the config lands in the ClientHello.
241        let ssl_config = crate::stealth_http::stealth_profile_to_ssl_config(profile);
242        let host_c = CString::new(host).map_err(|_| format!("invalid host: {}", host))?;
243        {
244            let ssl = tls.ssl_ptr();
245            if !ssl.is_null() {
246                // SAFETY: `ssl_ptr` returns the live SSL handle of this
247                // connection; `configure_http_client_with_alpn` only issues
248                // SSL_set_* configuration calls on it.
249                bun_http::configure_http_client_with_alpn(
250                    unsafe { &mut *ssl },
251                    host_c.as_ptr(),
252                    bun_http::AlpnOffer::H1,
253                    Some(&ssl_config),
254                );
255
256                // TLS session resumption: offer the cached session for this
257                // origin before the handshake starts (same precondition as
258                // the stealth config above — the ClientHello has not been
259                // serialized yet). Salt semantics match the bun_http fetch
260                // path: no stealth profile → salt 0 (the default-profile
261                // pool shared across stacks); a profile → the SSLConfig
262                // content hash, so sessions that short-circuit parameter
263                // negotiation never cross profiles.
264                let profile_salt = if profile.is_some() {
265                    ssl_config.content_hash()
266                } else {
267                    0
268                };
269                bao_boringssl_bridge::session_cache::offer_session(ssl, host, port, profile_salt);
270            }
271        }
272
273        // Complete the TLS handshake by pumping records until active.
274        // BCE-20260814-WS-TLS: the flight produced by `process()` MUST be
275        // flushed to the socket BEFORE blocking on read — the prior order
276        // (take_outgoing → process → read) left the ClientHello stranded in
277        // the write BIO while waiting for a ServerHello that could never
278        // arrive (both sides reading → deadlock, surfaced as "handshake
279        // stalled" after the 10s timeout).
280        loop {
281            match tls.process() {
282                Ok(res) => {
283                    use bao_boringssl_bridge::connection::TlsState;
284                    // Flush every flight the state machine just produced
285                    // (ClientHello / Finished) before waiting on the peer.
286                    loop {
287                        let outgoing = tls.take_outgoing();
288                        if outgoing.is_empty() {
289                            break;
290                        }
291                        if tcp.write_all(&outgoing).is_err() {
292                            return Err("tls handshake write failed".to_string());
293                        }
294                    }
295                    if res.state == TlsState::Active || res.state == TlsState::PeerClosed {
296                        break;
297                    }
298                    // Still handshaking — read more ciphertext from the socket.
299                    let mut buf = [0u8; 16_384];
300                    match tcp.read(&mut buf) {
301                        Ok(n) if n > 0 => tls.feed(&buf[..n]),
302                        _ => {
303                            return Err("tls handshake stalled".to_string());
304                        }
305                    }
306                }
307                Err(e) => return Err(format!("tls handshake: {}", e)),
308            }
309        }
310
311        let mut stream = TlsStream {
312            tcp,
313            tls,
314            pending_plain: Vec::new(),
315            pending_off: 0,
316        };
317        // RFC 6455 client handshake over the TLS stream (bun_uws-owned).
318        bun_uws::ws_handshake::client_handshake(&mut stream, host, &path)
319            .map_err(|e| format!("ws handshake: {:?}", e))?;
320        Ok(WsConn::Tls {
321            stream,
322            decoder: bun_uws::ws_codec::FrameDecoder::new(),
323            closed: false,
324        })
325    }
326
327    fn send_text(&mut self, text: &str) -> ::std::result::Result<(), String> {
328        match self {
329            WsConn::Plain(c) => c.send_text(text).map_err(|e| format!("send failed: {}", e)),
330            WsConn::Tls { stream, .. } => {
331                let payload = text.as_bytes();
332                let key = bun_uws::ws_codec::gen_mask_key();
333                let mut frame = Vec::with_capacity(payload.len() + 14);
334                frame.push(0x81); // FIN + text opcode
335                push_masked_len(&mut frame, payload.len());
336                frame.extend_from_slice(&key);
337                let mut masked = payload.to_vec();
338                apply_mask(&mut masked, &key);
339                // BCE-20260814-WS-TLS: the masked payload was never appended
340                // to the frame — every wss:// send() transmitted header+key
341                // only, dropping the message body (peer then misparsed the
342                // next frame's bytes as this frame's payload).
343                frame.extend_from_slice(&masked);
344                stream
345                    .write_all(&frame)
346                    .map_err(|e| format!("send failed: {}", e))
347            }
348        }
349    }
350
351    fn read_message(&mut self) -> ::std::result::Result<WsMessage, String> {
352        match self {
353            WsConn::Plain(c) => match c.recv().map_err(|e| format!("recv: {}", e))? {
354                bun_uws::ws_client::RecvOutcome::Message(opcode, payload) => match opcode {
355                    bun_uws::ws_codec::Opcode::Text => Ok(WsMessage::Text(
356                        String::from_utf8_lossy(&payload).into_owned(),
357                    )),
358                    bun_uws::ws_codec::Opcode::Binary => Ok(WsMessage::Binary(payload)),
359                    _ => Ok(WsMessage::Binary(payload)),
360                },
361                bun_uws::ws_client::RecvOutcome::Closed => Ok(WsMessage::Close),
362                bun_uws::ws_client::RecvOutcome::Timeout => Err("wouldblock".to_string()),
363            },
364            WsConn::Tls {
365                stream,
366                decoder,
367                closed,
368            } => {
369                if *closed {
370                    return Ok(WsMessage::Close);
371                }
372                let header = match decoder.decode_frame(stream) {
373                    Ok(Some(h)) => h,
374                    Ok(None) => return Err("wouldblock".to_string()),
375                    Err(ref e)
376                        if e.kind() == ::std::io::ErrorKind::WouldBlock
377                            || e.kind() == ::std::io::ErrorKind::TimedOut =>
378                    {
379                        return Err("wouldblock".to_string());
380                    }
381                    Err(ref e) if e.kind() == ::std::io::ErrorKind::UnexpectedEof => {
382                        *closed = true;
383                        return Ok(WsMessage::Close);
384                    }
385                    Err(e) => return Err(format!("recv: {}", e)),
386                };
387                let mut payload = if header.mask {
388                    let mask_key = decoder.take_mask();
389                    let mut p = decoder.take_payload(&header);
390                    apply_mask(&mut p, &mask_key);
391                    p
392                } else {
393                    decoder.take_payload(&header)
394                };
395                match header.opcode {
396                    bun_uws::ws_codec::Opcode::Text => Ok(WsMessage::Text(
397                        String::from_utf8_lossy(&payload).into_owned(),
398                    )),
399                    bun_uws::ws_codec::Opcode::Binary => Ok(WsMessage::Binary(payload)),
400                    bun_uws::ws_codec::Opcode::Close => {
401                        *closed = true;
402                        Ok(WsMessage::Close)
403                    }
404                    bun_uws::ws_codec::Opcode::Ping => {
405                        // Echo pong (RFC 6455 §5.5.2) using bun_uws codec mask.
406                        let key = bun_uws::ws_codec::gen_mask_key();
407                        let mut frame = vec![0x8A]; // FIN + pong
408                        push_masked_len(&mut frame, payload.len());
409                        frame.extend_from_slice(&key);
410                        apply_mask(&mut payload, &key);
411                        frame.extend_from_slice(&payload);
412                        stream
413                            .write_all(&frame)
414                            .map_err(|e| format!("pong: {}", e))?;
415                        self.read_message()
416                    }
417                    bun_uws::ws_codec::Opcode::Pong | bun_uws::ws_codec::Opcode::Continuation => {
418                        self.read_message()
419                    }
420                }
421            }
422        }
423    }
424
425    /// Send a masked close frame (code 1000). Deliberately does NOT delegate
426    /// to `WebSocketClient::close()` for the Plain variant — that method
427    /// `shutdown(Both)`s the socket immediately, killing the TCP connection
428    /// before the peer's Close reply can arrive (no close handshake). Here
429    /// only the frame is sent; the socket stays readable so the drain pump
430    /// can observe the peer's Close reply and fire onclose.
431    fn close(&mut self) -> ::std::result::Result<(), String> {
432        let frame = encode_masked_close_frame();
433        match self {
434            WsConn::Plain(c) => {
435                if c.is_closed() {
436                    return Ok(());
437                }
438                c.stream_mut()
439                    .write_all(&frame)
440                    .map_err(|e| format!("close failed: {}", e))
441            }
442            WsConn::Tls { stream, .. } => {
443                // Send the close frame only. Do NOT set the internal `closed`
444                // flag here — `read_message` uses it to short-circuit, which
445                // would discard inbound frames still in flight (post-close
446                // messages are delivered until the close handshake completes,
447                // browser semantics). The flag flips when the peer's Close
448                // frame is actually read.
449                let _ = stream.write_all(&frame);
450                Ok(())
451            }
452        }
453    }
454
455    /// Switch the underlying socket between blocking and non-blocking so the
456    /// initial drain loop can poll for buffered frames without hanging.
457    fn set_nonblocking(&mut self, nonblocking: bool) {
458        match self {
459            WsConn::Plain(c) => {
460                let _ = c.stream_mut().set_nonblocking(nonblocking);
461            }
462            WsConn::Tls { stream, .. } => {
463                let _ = stream.tcp.set_nonblocking(nonblocking);
464            }
465        }
466    }
467}
468
469/// Split `host[:port]/path` from the scheme-stripped remainder. Default port
470/// is 80 for ws://, 443 for wss://.
471fn split_authority_and_path(rest: &str, scheme: &str) -> (String, u16, String) {
472    let default_port = if scheme == "wss" { 443 } else { 80 };
473    let (authority, path) = match rest.find('/') {
474        Some(i) => (&rest[..i], rest[i..].to_string()),
475        None => (rest, "/".to_string()),
476    };
477    let (host, port) = match authority.rfind(':') {
478        Some(i) => (
479            authority[..i].to_string(),
480            authority[i + 1..].parse::<u16>().unwrap_or(default_port),
481        ),
482        None => (authority.to_string(), default_port),
483    };
484    (host, port, path)
485}
486
487/// Masked close frame (FIN + Close, code 1000), client→server layout.
488fn encode_masked_close_frame() -> Vec<u8> {
489    let key = bun_uws::ws_codec::gen_mask_key();
490    let mut frame = vec![0x88];
491    let payload = 1000u16.to_be_bytes();
492    push_masked_len(&mut frame, payload.len());
493    frame.extend_from_slice(&key);
494    let mut masked = payload.to_vec();
495    apply_mask(&mut masked, &key);
496    frame.extend_from_slice(&masked);
497    frame
498}
499
500/// Append the masked-length + (caller-supplied) mask bytes layout for a
501/// client→server frame, matching `bun_uws::ws_codec::FrameEncoder::encode_frame`.
502fn push_masked_len(frame: &mut Vec<u8>, len: usize) {
503    let mask_bit = 0x80u8;
504    if len < 126 {
505        frame.push((len as u8) | mask_bit);
506    } else if len <= u16::MAX as usize {
507        frame.push(126u8 | mask_bit);
508        frame.extend_from_slice(&(len as u16).to_be_bytes());
509    } else {
510        frame.push(127u8 | mask_bit);
511        frame.extend_from_slice(&(len as u64).to_be_bytes());
512    }
513}
514
515// ── JS bridge ──
516
517/// Global counter for generating unique GcStore keys for WebSocket objects.
518static WS_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
519
520/// Per-connection registry entry. Everything except `connect_slot`'s payload
521/// is confined to the thread that constructed the WebSocket (the JS thread);
522/// the background connect worker communicates exclusively through the
523/// `Arc<Mutex<..>>` slot — no JSObject pointers cross threads
524/// (BCE-20260621-001 rule).
525struct WsEntry {
526    /// Live connection once the background connect completed and onopen
527    /// dispatched. Polled non-blocking by `ws_pump_all`.
528    client: ::std::option::Option<WsConn>,
529    /// Present while the background connect worker is in flight. The worker
530    /// writes `Some(Ok(..))` / `Some(Err(..))` exactly once; the JS-thread
531    /// drain pump consumes it. `None` + `client: None` = dead entry.
532    connect_slot: ::std::option::Option<
533        Arc<Mutex<::std::option::Option<::std::result::Result<WsConn, String>>>>,
534    >,
535    /// JS called close() while still CONNECTING — when the connect lands, the
536    /// pump closes it immediately instead of firing onopen.
537    close_requested: bool,
538    /// JS called close() on an open connection — the close frame was sent;
539    /// the pump fires onclose when the peer's Close reply (or transport
540    /// error) lands (browser close-handshake semantics).
541    close_initiated: bool,
542    /// The realm global the WebSocket JS object lives in (captured at
543    /// construction). Every dispatch AutoRealms into it (realm-per-context
544    /// model, c943b1cc) so the GcStore property lookup and handler call run
545    /// in the right compartment.
546    realm_global: *mut JSObject,
547    js_obj_key: String,
548}
549
550impl WsEntry {
551    fn is_live(&self) -> bool {
552        self.connect_slot.is_some() || self.client.is_some()
553    }
554}
555
556thread_local! {
557    static WS_CONNECTIONS: RefCell<Vec<WsEntry>> = const { RefCell::new(Vec::new()) };
558}
559
560/// True while any WebSocket on this thread is connecting or open. Wired into
561/// the event-loop liveness checks (`timers::drain_and_check` return value) so
562/// the eval loop keeps draining while WS traffic is in flight.
563pub fn ws_has_pending() -> bool {
564    WS_CONNECTIONS.with(|c| c.borrow().iter().any(|e| e.is_live()))
565}
566
567pub fn install_websocket_constructor(
568    cx: &mut mozjs::context::JSContext,
569    global: mozjs::rust::Handle<*mut JSObject>,
570) {
571    unsafe {
572        let ws_fun = JS_NewFunction(
573            cx.raw_cx(),
574            Some(websocket_constructor),
575            1,
576            JSFUN_CONSTRUCTOR,
577            c"WebSocket".as_ptr(),
578        );
579        if !ws_fun.is_null() {
580            let ctor_obj = JS_GetFunctionObject(ws_fun);
581            if !ctor_obj.is_null() {
582                let val = mozjs::jsval::ObjectValue(ctor_obj);
583                rooted!(&in(cx) let v = val);
584                JS_DefineProperty(
585                    cx.raw_cx(),
586                    global.into(),
587                    c"WebSocket".as_ptr(),
588                    v.handle().into(),
589                    (JSPROP_ENUMERATE | JSPROP_PERMANENT) as u32,
590                );
591
592                rooted!(&in(cx) let ctor_root = ctor_obj);
593                for (name, value) in &[
594                    ("CONNECTING", 0i32),
595                    ("OPEN", 1),
596                    ("CLOSING", 2),
597                    ("CLOSED", 3),
598                ] {
599                    let c_name = ZBox::from_bytes(name.as_bytes());
600                    rooted!(&in(cx) let iv = Int32Value(*value));
601                    JS_DefineProperty(
602                        cx.raw_cx(),
603                        ctor_root.handle().into(),
604                        c_name.as_ptr(),
605                        iv.handle().into(),
606                        (JSPROP_ENUMERATE | JSPROP_READONLY) as u32,
607                    );
608                }
609            }
610        }
611    }
612}
613
614/// Fire an `onXXX` handler stored on the WebSocket object. `realm_global` is
615/// the realm global captured at construction; dispatch AutoRealms into it
616/// (the drain pump runs with no realm entered — realm-per-context model).
617/// `this` for the call is the WebSocket object (browser semantics).
618///
619/// # Safety
620/// `cx` must be a live JSContext on the current thread; `realm_global` must
621/// be the (always-rooted) global of a live realm on that context.
622unsafe fn ws_trigger_event(
623    cx: *mut JSContext,
624    realm_global: *mut JSObject,
625    ws_obj_key: &str,
626    event_name: &str,
627    data_val: Option<JSVal>,
628) {
629    if realm_global.is_null() {
630        return;
631    }
632    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
633    let cx_ref = &mut wrapped_cx;
634    rooted!(&in(cx_ref) let global_root = realm_global);
635    let mut realm = AutoRealm::new_from_handle(cx_ref, global_root.handle());
636    let cx_ref: &mut mozjs::context::JSContext = &mut realm;
637
638    // Root the event data FIRST — gc_store_get / JS_GetProperty below can
639    // trigger GC and an unrooted JSVal argument would dangle. Always root
640    // (undefined placeholder when the event carries no data) so the guard
641    // lives for the whole frame.
642    let has_data = data_val.is_some();
643    let dv_in = data_val.unwrap_or_else(UndefinedValue);
644    rooted!(&in(cx_ref) let data_root = dv_in);
645
646    let ws_obj = match gc_store_get(cx, ws_obj_key) {
647        Some(obj) => obj,
648        None => return,
649    };
650    rooted!(&in(cx_ref) let ws_obj_root = ws_obj);
651    let mut handler_val = UndefinedValue();
652    let c_name = ZBox::from_bytes(event_name.as_bytes());
653    JS_GetProperty(
654        cx,
655        ws_obj_root.handle().into(),
656        c_name.as_ptr(),
657        MutableHandle::<Value> {
658            _phantom_0: ::std::marker::PhantomData,
659            ptr: &mut handler_val,
660        },
661    );
662    if handler_val.is_object() {
663        rooted!(&in(cx_ref) let handler_obj_root = handler_val.to_object());
664        if JS_ObjectIsFunction(handler_obj_root.get()) {
665            rooted!(&in(cx_ref) let handler_jsval = ObjectValue(handler_obj_root.get()));
666
667            rooted!(&in(cx_ref) let event_obj = mozjs_sys::jsapi::JS_NewPlainObject(cx));
668            if !event_obj.get().is_null() {
669                if has_data {
670                    JS_DefineProperty(
671                        cx,
672                        event_obj.handle().into(),
673                        c"data".as_ptr(),
674                        data_root.handle().into(),
675                        JSPROP_ENUMERATE as u32,
676                    );
677                }
678                let ev_val = ObjectValue(event_obj.get());
679                let call_args = HandleValueArray {
680                    length_: 1,
681                    elements_: &ev_val,
682                };
683                let mut rval = UndefinedValue();
684                let ok = JS_CallFunctionValue(
685                    cx,
686                    ws_obj_root.handle().into(),
687                    handler_jsval.handle().into(),
688                    &call_args,
689                    MutableHandle::<Value> {
690                        _phantom_0: ::std::marker::PhantomData,
691                        ptr: &mut rval,
692                    },
693                );
694                if !ok {
695                    // BCE (P0 browser startup panic, servo error.rs:74): the
696                    // user handler threw. The old `let _ =` left the pending
697                    // exception on the ScriptThread context (browser mode
698                    // kills the ScriptThread via servo's
699                    // `assert!(!JS_IsExceptionPending)`; node mode silently
700                    // swallowed the throw). Capture, clear, and route it —
701                    // same contract as timers.rs fire_callback.
702                    let mut exn = UndefinedValue();
703                    JS_GetPendingException(
704                        cx,
705                        MutableHandle::<Value> {
706                            _phantom_0: ::std::marker::PhantomData,
707                            ptr: &mut exn,
708                        },
709                    );
710                    JS_ClearPendingException(cx);
711                    rooted!(&in(cx_ref) let reason_root = exn);
712                    if !exn.is_undefined() {
713                        crate::uncaught::route_uncaught_exception(cx, exn);
714                    }
715                }
716            }
717        }
718    }
719}
720
721unsafe extern "C" fn ws_send(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
722    let args = CallArgs::from_vp(vp, argc);
723    if argc == 0 {
724        JS_ReportErrorUTF8(cx, c"WebSocket.send() requires a message argument".as_ptr());
725        return false;
726    }
727    let msg_val = *args.get(0).ptr;
728
729    let wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
730    rooted!(&in(wrapped_cx) let this_obj = args.thisv().to_object());
731    let mut idx_val = Int32Value(-1);
732    JS_GetProperty(
733        cx,
734        this_obj.handle().into(),
735        c"_wsIdx".as_ptr(),
736        MutableHandle::<Value> {
737            _phantom_0: ::std::marker::PhantomData,
738            ptr: &mut idx_val,
739        },
740    );
741    let idx = idx_val.to_int32() as usize;
742
743    let send_result = WS_CONNECTIONS.with(|c| {
744        let mut conns = c.borrow_mut();
745        match conns.get_mut(idx) {
746            // Browser parity: send() while CONNECTING/CLOSED throws
747            // InvalidStateError (never silently drops the message).
748            Some(e) if e.client.is_some() => {
749                let s = unsafe_jsstr_to_string(cx, NonNull::new_unchecked(msg_val.to_string()));
750                e.client.as_mut().unwrap().send_text(&s)
751            }
752            Some(e) if e.connect_slot.is_some() => {
753                Err("InvalidStateError: WebSocket is still connecting".to_string())
754            }
755            _ => Err("InvalidStateError: WebSocket is already closed".to_string()),
756        }
757    });
758
759    if let Err(e) = send_result {
760        let msg = format!("WebSocket send failed: {}", e);
761        let c_msg = ZBox::from_bytes(msg.as_bytes());
762        JS_ReportErrorUTF8(cx, c"%s".as_ptr(), c_msg.as_ptr());
763        return false;
764    }
765    args.rval().set(UndefinedValue());
766    true
767}
768
769unsafe extern "C" fn ws_close_fn(cx: *mut JSContext, _argc: u32, vp: *mut JSVal) -> bool {
770    let args = CallArgs::from_vp(vp, _argc);
771    let wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
772    rooted!(&in(wrapped_cx) let this_obj = args.thisv().to_object());
773
774    let mut idx_val = Int32Value(-1);
775    JS_GetProperty(
776        cx,
777        this_obj.handle().into(),
778        c"_wsIdx".as_ptr(),
779        MutableHandle::<Value> {
780            _phantom_0: ::std::marker::PhantomData,
781            ptr: &mut idx_val,
782        },
783    );
784    let idx = idx_val.to_int32() as usize;
785
786    WS_CONNECTIONS.with(|c| {
787        let mut conns = c.borrow_mut();
788        if let Some(e) = conns.get_mut(idx) {
789            if e.client.is_some() && !e.close_initiated {
790                // Send the close frame once; keep the socket alive so the
791                // pump can see the peer's Close reply and fire onclose
792                // (close handshake).
793                if let Some(client) = &mut e.client {
794                    let _ = client.close();
795                }
796                e.close_initiated = true;
797            } else if e.connect_slot.is_some() {
798                // Still CONNECTING — flag it; the pump closes immediately
799                // when the background connect lands (no onopen).
800                e.close_requested = true;
801            }
802        }
803    });
804
805    rooted!(&in(wrapped_cx) let closing_val = Int32Value(2));
806    JS_SetProperty(
807        cx,
808        this_obj.handle().into(),
809        c"readyState".as_ptr(),
810        closing_val.handle().into(),
811    );
812    args.rval().set(UndefinedValue());
813    true
814}
815
816#[allow(unsafe_op_in_unsafe_fn)]
817unsafe extern "C" fn websocket_constructor(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
818    let args = CallArgs::from_vp(vp, argc);
819    if argc == 0 {
820        JS_ReportErrorUTF8(cx, c"WebSocket requires a URL argument".as_ptr());
821        return false;
822    }
823    let url_val = *args.get(0).ptr;
824    if !url_val.is_string() {
825        JS_ReportErrorUTF8(cx, c"WebSocket URL must be a string".as_ptr());
826        return false;
827    }
828    let url = unsafe_jsstr_to_string(cx, NonNull::new_unchecked(url_val.to_string()));
829
830    let wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
831    rooted!(&in(wrapped_cx) let ws_obj = mozjs_sys::jsapi::JS_NewPlainObject(cx));
832    if ws_obj.get().is_null() {
833        args.rval().set(UndefinedValue());
834        return true;
835    }
836
837    {
838        let c_url = ZBox::from_bytes(url.as_bytes());
839        let js_str = JS_NewStringCopyZ(cx, c_url.as_ptr());
840        if !js_str.is_null() {
841            rooted!(&in(wrapped_cx) let v = StringValue(&*js_str));
842            JS_DefineProperty(
843                cx,
844                ws_obj.handle().into(),
845                c"url".as_ptr(),
846                v.handle().into(),
847                JSPROP_ENUMERATE as u32,
848            );
849        }
850    }
851
852    rooted!(&in(wrapped_cx) let state_val = Int32Value(0));
853    JS_DefineProperty(
854        cx,
855        ws_obj.handle().into(),
856        c"readyState".as_ptr(),
857        state_val.handle().into(),
858        JSPROP_ENUMERATE as u32,
859    );
860
861    rooted!(&in(wrapped_cx) let ba_val = Int32Value(0));
862    JS_DefineProperty(
863        cx,
864        ws_obj.handle().into(),
865        c"bufferedAmount".as_ptr(),
866        ba_val.handle().into(),
867        JSPROP_ENUMERATE as u32,
868    );
869
870    for name in &["onopen", "onmessage", "onerror", "onclose"] {
871        let c_name = ZBox::from_bytes(name.as_bytes());
872        rooted!(&in(wrapped_cx) let ud = UndefinedValue());
873        JS_DefineProperty(
874            cx,
875            ws_obj.handle().into(),
876            c_name.as_ptr(),
877            ud.handle().into(),
878            JSPROP_ENUMERATE as u32,
879        );
880    }
881
882    mozjs_sys::jsapi::JS_DefineFunction(
883        cx,
884        ws_obj.handle().into(),
885        c"send".as_ptr(),
886        Some(ws_send),
887        1,
888        JSPROP_ENUMERATE as u32,
889    );
890    mozjs_sys::jsapi::JS_DefineFunction(
891        cx,
892        ws_obj.handle().into(),
893        c"close".as_ptr(),
894        Some(ws_close_fn),
895        0,
896        JSPROP_ENUMERATE as u32,
897    );
898
899    // Permission parity with fetch(): the page's net scope governs
900    // WebSocket egress too (Permission sandbox).
901    {
902        let (scheme, rest) = if let Some(r) = url.strip_prefix("ws://") {
903            ("ws", r)
904        } else if let Some(r) = url.strip_prefix("wss://") {
905            ("wss", r)
906        } else {
907            ("ws", url.as_str())
908        };
909        let (host, _, _) = split_authority_and_path(rest, scheme);
910        if let ::std::result::Result::Err(e) = crate::permission_bridge::check_net(&host) {
911            let c_msg = ZBox::from_bytes(e.as_bytes());
912            JS_ReportErrorUTF8(cx, c"%s".as_ptr(), c_msg.as_ptr());
913            return false;
914        }
915    }
916
917    // Store the JS WebSocket object in GcStore for GC safety.
918    let ws_id = WS_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
919    let ws_key = format!("ws_{}", ws_id);
920    gc_store_insert(cx, &ws_key, ws_obj.get());
921
922    // Capture the realm global (dispatch AutoRealm target) and the thread's
923    // stealth profile HERE, on the JS thread — both are thread-local state.
924    let realm_global = CurrentGlobalOrNull(cx);
925    let profile = crate::fetch_api::get_fetch_stealth_profile();
926
927    // Register the entry with a pending connect slot; _wsIdx must exist
928    // immediately (send()/close() may be called while CONNECTING).
929    let slot: Arc<Mutex<::std::option::Option<::std::result::Result<WsConn, String>>>> =
930        Arc::new(Mutex::new(None));
931    let ws_idx = WS_CONNECTIONS.with(|c| {
932        let mut conns = c.borrow_mut();
933        conns.push(WsEntry {
934            client: None,
935            connect_slot: Some(Arc::clone(&slot)),
936            close_requested: false,
937            close_initiated: false,
938            realm_global,
939            js_obj_key: ws_key.clone(),
940        });
941        conns.len() - 1
942    });
943    rooted!(&in(wrapped_cx) let idx_val = Int32Value(ws_idx as i32));
944    JS_DefineProperty(
945        cx,
946        ws_obj.handle().into(),
947        c"_wsIdx".as_ptr(),
948        idx_val.handle().into(),
949        0,
950    );
951
952    // Background connect: the full blocking sequence (TCP + TLS with the
953    // page's stealth fingerprint + RFC 6455 handshake, ≤10s) runs OFF the JS
954    // thread. The Arc<Mutex> slot is the only cross-thread channel; the
955    // JS-thread drain pump (`ws_pump_all`) consumes the outcome and fires
956    // onopen / onerror(+onclose). No JSObject pointer crosses threads.
957    let url_owned = url.clone();
958    ::std::thread::spawn(move || {
959        let result = WsConn::connect(&url_owned, &profile);
960        if let Ok(mut guard) = slot.lock() {
961            *guard = Some(result);
962        }
963        // If the JS thread is gone (process teardown), the slot leaks —
964        // bounded by the 10s connect timeout.
965    });
966
967    // readyState stays 0 (CONNECTING). The constructor returns immediately —
968    // connect failures surface as onerror + onclose (browser semantics),
969    // never a constructor throw, never a silent swallow.
970    args.rval().set(mozjs::jsval::ObjectValue(ws_obj.get()));
971    true
972}
973
974// ── WebSocket drain pump ──
975// Runs on the JS thread from the event-loop drain paths. Consumes completed
976// background connects and pumps inbound frames. Never blocks (try_lock +
977// non-blocking sockets); JS handlers run OUTSIDE the WS_CONNECTIONS borrow so
978// they can call send()/close() reentrantly.
979
980/// One action peeled off the registry per iteration (JS calls happen after
981/// the borrow is dropped).
982enum PumpAction {
983    /// Background connect finished (slot outcome consumed).
984    ConnectDone(usize, ::std::result::Result<WsConn, String>),
985    /// Text frame received on an open connection.
986    TextMessage(usize, String),
987    /// Binary frame received on an open connection.
988    BinaryMessage(usize, Vec<u8>),
989    /// Close frame received / transport error — connection is dead.
990    /// `msg` is Some for a transport error (fires onerror first), None for a
991    /// clean close handshake.
992    Closed(usize, ::std::option::Option<String>),
993}
994
995/// Pump all WebSockets on this thread. Called from `timers::drain_and_check`,
996/// `timers::drain_one_pass`, and the servo node-realm evaluate entry.
997pub fn ws_pump_all(raw_cx: *mut JSContext) {
998    loop {
999        let action = WS_CONNECTIONS.with(|c| {
1000            let mut conns = c.borrow_mut();
1001            for (idx, e) in conns.iter_mut().enumerate() {
1002                // 1. Completed background connects (try_lock — the worker
1003                //    may still hold the lock writing its outcome). Take the
1004                //    slot out first so the MutexGuard borrow ends before the
1005                //    assignment below.
1006                if e.connect_slot.is_some() {
1007                    let taken = e
1008                        .connect_slot
1009                        .as_ref()
1010                        .unwrap()
1011                        .try_lock()
1012                        .ok()
1013                        .and_then(|mut guard| guard.take());
1014                    if let ::std::option::Option::Some(res) = taken {
1015                        e.connect_slot = ::std::option::Option::None;
1016                        return ::std::option::Option::Some(PumpAction::ConnectDone(idx, res));
1017                    }
1018                    continue;
1019                }
1020                // 2. Inbound frames on open connections (non-blocking).
1021                //    Frames received after a locally-initiated close are
1022                //    still delivered (browser semantics: messages queue
1023                //    until the close handshake completes).
1024                let ::std::option::Option::Some(client) = &mut e.client else {
1025                    continue;
1026                };
1027                match client.read_message() {
1028                    Ok(WsMessage::Text(t)) => {
1029                        return ::std::option::Option::Some(PumpAction::TextMessage(idx, t))
1030                    }
1031                    Ok(WsMessage::Binary(b)) => {
1032                        return ::std::option::Option::Some(PumpAction::BinaryMessage(idx, b))
1033                    }
1034                    Ok(WsMessage::Close) => {
1035                        // Echo the close handshake unless we already sent
1036                        // our close frame (close_initiated).
1037                        if !e.close_initiated {
1038                            let _ = client.close();
1039                        }
1040                        e.client = ::std::option::Option::None;
1041                        return ::std::option::Option::Some(PumpAction::Closed(idx, None));
1042                    }
1043                    Err(err) if err == "wouldblock" => continue,
1044                    Err(err) => {
1045                        // Transport error — explicit surface, never silent.
1046                        let _ = client.close();
1047                        e.client = ::std::option::Option::None;
1048                        return ::std::option::Option::Some(PumpAction::Closed(
1049                            idx,
1050                            ::std::option::Option::Some(err),
1051                        ));
1052                    }
1053                }
1054            }
1055            ::std::option::Option::None
1056        });
1057
1058        match action {
1059            ::std::option::Option::Some(PumpAction::ConnectDone(idx, res)) => unsafe {
1060                ws_connect_dispatch(raw_cx, idx, res);
1061            },
1062            ::std::option::Option::Some(PumpAction::TextMessage(idx, text)) => unsafe {
1063                ws_message_dispatch(raw_cx, idx, ::std::option::Option::Some(text), None);
1064            },
1065            ::std::option::Option::Some(PumpAction::BinaryMessage(idx, bytes)) => unsafe {
1066                ws_message_dispatch(raw_cx, idx, None, ::std::option::Option::Some(bytes));
1067            },
1068            ::std::option::Option::Some(PumpAction::Closed(idx, err)) => unsafe {
1069                ws_closed_dispatch(raw_cx, idx, err);
1070            },
1071            ::std::option::Option::None => break,
1072        }
1073    }
1074}
1075
1076/// Snapshot of the dispatch-relevant fields of one entry.
1077struct WsDispatchInfo {
1078    realm_global: *mut JSObject,
1079    js_obj_key: String,
1080    is_open: bool,
1081}
1082
1083fn ws_entry_info(idx: usize) -> ::std::option::Option<WsDispatchInfo> {
1084    WS_CONNECTIONS.with(|c| {
1085        c.borrow().get(idx).map(|e| WsDispatchInfo {
1086            realm_global: e.realm_global,
1087            js_obj_key: e.js_obj_key.clone(),
1088            is_open: e.client.is_some(),
1089        })
1090    })
1091}
1092
1093/// Set `readyState` on the stored WebSocket object (inside its realm).
1094///
1095/// # Safety
1096/// `raw_cx` must be a live JSContext on the current thread.
1097unsafe fn ws_set_ready_state(
1098    raw_cx: *mut JSContext,
1099    realm_global: *mut JSObject,
1100    ws_key: &str,
1101    state: i32,
1102) {
1103    if realm_global.is_null() {
1104        return;
1105    }
1106    // Enter the realm FIRST — gc_store_get resolves through
1107    // CurrentGlobalOrNull, which is null while the drain pump runs.
1108    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(raw_cx));
1109    let cx_ref = &mut wrapped_cx;
1110    rooted!(&in(cx_ref) let global_root = realm_global);
1111    let mut realm = AutoRealm::new_from_handle(cx_ref, global_root.handle());
1112    let cx_ref: &mut mozjs::context::JSContext = &mut realm;
1113    let ws_obj = match gc_store_get(raw_cx, ws_key) {
1114        Some(o) => o,
1115        None => return,
1116    };
1117    rooted!(&in(cx_ref) let ws_obj_root = ws_obj);
1118    rooted!(&in(cx_ref) let state_val = Int32Value(state));
1119    JS_SetProperty(
1120        raw_cx,
1121        ws_obj_root.handle().into(),
1122        c"readyState".as_ptr(),
1123        state_val.handle().into(),
1124    );
1125}
1126
1127/// Background connect completed: install the connection and fire onopen, or
1128/// surface the failure as onerror + onclose (explicit, never silent).
1129///
1130/// # Safety
1131/// `raw_cx` must be a live JSContext on the current thread.
1132unsafe fn ws_connect_dispatch(
1133    raw_cx: *mut JSContext,
1134    idx: usize,
1135    res: ::std::result::Result<WsConn, String>,
1136) {
1137    let info = match ws_entry_info(idx) {
1138        Some(i) => i,
1139        None => return,
1140    };
1141    match res {
1142        Ok(mut client) => {
1143            // Non-blocking from here on — the drain pump polls this socket.
1144            client.set_nonblocking(true);
1145            enum Landed {
1146                Open,
1147                CloseNow,
1148            }
1149            let landed = WS_CONNECTIONS.with(|c| {
1150                let mut conns = c.borrow_mut();
1151                match conns.get_mut(idx) {
1152                    // close() was called while CONNECTING: never open it.
1153                    ::std::option::Option::Some(e) if e.close_requested => {
1154                        e.close_requested = false;
1155                        Landed::CloseNow
1156                    }
1157                    ::std::option::Option::Some(e) => {
1158                        e.client = ::std::option::Option::Some(client);
1159                        Landed::Open
1160                    }
1161                    ::std::option::Option::None => Landed::CloseNow,
1162                }
1163            });
1164            match landed {
1165                Landed::Open => {
1166                    ws_set_ready_state(raw_cx, info.realm_global, &info.js_obj_key, 1);
1167                    ws_trigger_event(raw_cx, info.realm_global, &info.js_obj_key, "onopen", None);
1168                }
1169                Landed::CloseNow => {
1170                    // `client` was not installed; dropping it closes the
1171                    // socket (TcpStream Drop).
1172                    ws_set_ready_state(raw_cx, info.realm_global, &info.js_obj_key, 3);
1173                    ws_trigger_event(raw_cx, info.realm_global, &info.js_obj_key, "onclose", None);
1174                    gc_store_remove(raw_cx, &info.js_obj_key);
1175                }
1176            }
1177        }
1178        Err(msg) => {
1179            // Connect failed: CLOSED + onerror(with the reason) + onclose.
1180            ws_set_ready_state(raw_cx, info.realm_global, &info.js_obj_key, 3);
1181            // Enter the realm before creating the error string — allocation
1182            // needs a valid zone (the pump runs with no realm entered).
1183            let data = if !info.realm_global.is_null() {
1184                let mut wrapped_cx =
1185                    mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(raw_cx));
1186                let cx_ref = &mut wrapped_cx;
1187                rooted!(&in(cx_ref) let global_root = info.realm_global);
1188                let mut realm = AutoRealm::new_from_handle(cx_ref, global_root.handle());
1189                let _cx_in_realm: &mut mozjs::context::JSContext = &mut realm;
1190                let c_msg = ZBox::from_bytes(msg.as_bytes());
1191                let js_str = JS_NewStringCopyZ(raw_cx, c_msg.as_ptr());
1192                if !js_str.is_null() {
1193                    ::std::option::Option::Some(StringValue(&*js_str))
1194                } else {
1195                    ::std::option::Option::None
1196                }
1197            } else {
1198                ::std::option::Option::None
1199            };
1200            ws_trigger_event(raw_cx, info.realm_global, &info.js_obj_key, "onerror", data);
1201            ws_trigger_event(raw_cx, info.realm_global, &info.js_obj_key, "onclose", None);
1202            gc_store_remove(raw_cx, &info.js_obj_key);
1203        }
1204    }
1205}
1206
1207/// Inbound frame on an open connection: fire onmessage. Text frames arrive
1208/// as strings; binary frames as an Array of byte values (explicit — binary
1209/// was previously dropped silently).
1210///
1211/// # Safety
1212/// `raw_cx` must be a live JSContext on the current thread.
1213unsafe fn ws_message_dispatch(
1214    raw_cx: *mut JSContext,
1215    idx: usize,
1216    text: ::std::option::Option<String>,
1217    binary: ::std::option::Option<Vec<u8>>,
1218) {
1219    let info = match ws_entry_info(idx) {
1220        Some(i) => i,
1221        None => return,
1222    };
1223    if !info.is_open {
1224        return;
1225    }
1226    if info.realm_global.is_null() {
1227        return;
1228    }
1229    // Enter the realm BEFORE any JS value creation — JS_NewStringCopyZ /
1230    // JS_NewUint8Array allocate in the current zone, and the drain pump runs
1231    // with no realm entered (invalid zone → SIGSEGV in the allocator).
1232    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(raw_cx));
1233    let cx_ref = &mut wrapped_cx;
1234    rooted!(&in(cx_ref) let global_root = info.realm_global);
1235    let mut realm = AutoRealm::new_from_handle(cx_ref, global_root.handle());
1236    let cx_ref: &mut mozjs::context::JSContext = &mut realm;
1237    let data = if let ::std::option::Option::Some(t) = text {
1238        let c_text = ZBox::from_bytes(t.as_bytes());
1239        let js_str = JS_NewStringCopyZ(raw_cx, c_text.as_ptr());
1240        if js_str.is_null() {
1241            return;
1242        }
1243        StringValue(&*js_str)
1244    } else if let ::std::option::Option::Some(bytes) = binary {
1245        let arr = mozjs_sys::jsapi::JS_NewUint8Array(raw_cx, bytes.len());
1246        if arr.is_null() {
1247            return;
1248        }
1249        rooted!(&in(cx_ref) let arr_root = arr);
1250        if !bytes.is_empty() {
1251            let mut is_shared = false;
1252            // SAFETY: same pattern as bun_api.rs — data pointer of the
1253            // just-created, rooted Uint8Array; copied before any GC point.
1254            let data_ptr = mozjs_sys::jsapi::JS_GetUint8ArrayData(
1255                arr_root.get(),
1256                &mut is_shared,
1257                ::std::ptr::null(),
1258            );
1259            if !data_ptr.is_null() {
1260                ::std::ptr::copy_nonoverlapping(bytes.as_ptr(), data_ptr, bytes.len());
1261            }
1262        }
1263        ObjectValue(arr_root.get())
1264    } else {
1265        return;
1266    };
1267    ws_trigger_event(
1268        raw_cx,
1269        info.realm_global,
1270        &info.js_obj_key,
1271        "onmessage",
1272        ::std::option::Option::Some(data),
1273    );
1274}
1275
1276/// Connection is dead (close handshake finished or transport error): CLOSED +
1277/// onclose (+ onerror first for a transport error). Terminal cleanup of the
1278/// GcStore root.
1279///
1280/// # Safety
1281/// `raw_cx` must be a live JSContext on the current thread.
1282unsafe fn ws_closed_dispatch(
1283    raw_cx: *mut JSContext,
1284    idx: usize,
1285    err: ::std::option::Option<String>,
1286) {
1287    let info = match ws_entry_info(idx) {
1288        Some(i) => i,
1289        None => return,
1290    };
1291    ws_set_ready_state(raw_cx, info.realm_global, &info.js_obj_key, 3);
1292    if let ::std::option::Option::Some(msg) = err {
1293        // Enter the realm before creating the error string (valid zone for
1294        // allocation — the pump runs with no realm entered).
1295        let data = if !info.realm_global.is_null() {
1296            let mut wrapped_cx =
1297                mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(raw_cx));
1298            let cx_ref = &mut wrapped_cx;
1299            rooted!(&in(cx_ref) let global_root = info.realm_global);
1300            let mut realm = AutoRealm::new_from_handle(cx_ref, global_root.handle());
1301            let _cx_in_realm: &mut mozjs::context::JSContext = &mut realm;
1302            let c_msg = ZBox::from_bytes(msg.as_bytes());
1303            let js_str = JS_NewStringCopyZ(raw_cx, c_msg.as_ptr());
1304            if !js_str.is_null() {
1305                ::std::option::Option::Some(StringValue(&*js_str))
1306            } else {
1307                ::std::option::Option::None
1308            }
1309        } else {
1310            ::std::option::Option::None
1311        };
1312        ws_trigger_event(raw_cx, info.realm_global, &info.js_obj_key, "onerror", data);
1313    }
1314    ws_trigger_event(raw_cx, info.realm_global, &info.js_obj_key, "onclose", None);
1315    gc_store_remove(raw_cx, &info.js_obj_key);
1316}
1317
1318// ── Performance ──
1319
1320pub fn install_performance(
1321    cx: &mut mozjs::context::JSContext,
1322    global: mozjs::rust::Handle<*mut JSObject>,
1323) {
1324    unsafe {
1325        rooted!(&in(cx) let perf_obj = JS_NewPlainObject(cx));
1326        if perf_obj.get().is_null() {
1327            return;
1328        }
1329        JS_DefineFunction(
1330            cx,
1331            perf_obj.handle(),
1332            c"now".as_ptr(),
1333            Some(performance_now),
1334            0,
1335            JSPROP_ENUMERATE as u32,
1336        );
1337        JS_DefineProperty3(
1338            cx,
1339            global,
1340            c"performance".as_ptr(),
1341            perf_obj.handle(),
1342            JSPROP_ENUMERATE as u32,
1343        );
1344    }
1345}
1346
1347#[allow(unsafe_op_in_unsafe_fn)]
1348unsafe extern "C" fn performance_now(_cx: *mut JSContext, _argc: u32, vp: *mut JSVal) -> bool {
1349    let args = CallArgs::from_vp(vp, _argc);
1350    let now = ::std::time::SystemTime::now()
1351        .duration_since(::std::time::UNIX_EPOCH)
1352        .unwrap_or_default();
1353    let ms = now.as_secs_f64() * 1000.0;
1354    args.rval().set(mozjs::jsval::DoubleValue(ms));
1355    true
1356}
1357
1358// ── TextEncoder / TextDecoder ──
1359
1360pub fn install_web_encodings(
1361    cx: &mut mozjs::context::JSContext,
1362    global: mozjs::rust::Handle<*mut JSObject>,
1363) {
1364    unsafe {
1365        let te_fun = JS_NewFunction(
1366            cx.raw_cx(),
1367            Some(text_encoder_constructor),
1368            0,
1369            JSFUN_CONSTRUCTOR,
1370            c"TextEncoder".as_ptr(),
1371        );
1372        if !te_fun.is_null() {
1373            let te_obj = JS_GetFunctionObject(te_fun);
1374            if !te_obj.is_null() {
1375                rooted!(&in(cx) let te_obj_r = te_obj);
1376                rooted!(&in(cx) let proto = JS_NewPlainObject(cx));
1377                if !proto.get().is_null() {
1378                    JS_DefineFunction(
1379                        cx,
1380                        proto.handle(),
1381                        c"encode".as_ptr(),
1382                        Some(text_encoder_encode),
1383                        1,
1384                        JSPROP_ENUMERATE as u32,
1385                    );
1386                    JS_DefineFunction(
1387                        cx,
1388                        proto.handle(),
1389                        c"encodeInto".as_ptr(),
1390                        Some(text_encoder_encode_into),
1391                        2,
1392                        JSPROP_ENUMERATE as u32,
1393                    );
1394                    JS_DefineProperty3(
1395                        cx,
1396                        te_obj_r.handle(),
1397                        c"prototype".as_ptr(),
1398                        proto.handle(),
1399                        JSPROP_PERMANENT as u32,
1400                    );
1401                }
1402                JS_DefineProperty3(
1403                    cx,
1404                    global,
1405                    c"TextEncoder".as_ptr(),
1406                    te_obj_r.handle(),
1407                    (JSPROP_ENUMERATE | JSPROP_PERMANENT) as u32,
1408                );
1409            }
1410        }
1411
1412        let td_fun = JS_NewFunction(
1413            cx.raw_cx(),
1414            Some(text_decoder_constructor),
1415            1,
1416            JSFUN_CONSTRUCTOR,
1417            c"TextDecoder".as_ptr(),
1418        );
1419        if !td_fun.is_null() {
1420            let td_obj = JS_GetFunctionObject(td_fun);
1421            if !td_obj.is_null() {
1422                rooted!(&in(cx) let td_obj_r = td_obj);
1423                rooted!(&in(cx) let proto = JS_NewPlainObject(cx));
1424                if !proto.get().is_null() {
1425                    JS_DefineFunction(
1426                        cx,
1427                        proto.handle(),
1428                        c"decode".as_ptr(),
1429                        Some(text_decoder_decode),
1430                        1,
1431                        JSPROP_ENUMERATE as u32,
1432                    );
1433                    JS_DefineProperty3(
1434                        cx,
1435                        td_obj_r.handle(),
1436                        c"prototype".as_ptr(),
1437                        proto.handle(),
1438                        JSPROP_PERMANENT as u32,
1439                    );
1440                }
1441                JS_DefineProperty3(
1442                    cx,
1443                    global,
1444                    c"TextDecoder".as_ptr(),
1445                    td_obj_r.handle(),
1446                    (JSPROP_ENUMERATE | JSPROP_PERMANENT) as u32,
1447                );
1448            }
1449        }
1450    }
1451}
1452
1453pub fn install_atob_btoa(
1454    cx: &mut mozjs::context::JSContext,
1455    global: mozjs::rust::Handle<*mut JSObject>,
1456) {
1457    unsafe {
1458        JS_DefineFunction(
1459            cx,
1460            global,
1461            c"atob".as_ptr(),
1462            Some(atob_fn),
1463            1,
1464            JSPROP_ENUMERATE as u32,
1465        );
1466        JS_DefineFunction(
1467            cx,
1468            global,
1469            c"btoa".as_ptr(),
1470            Some(btoa_fn),
1471            1,
1472            JSPROP_ENUMERATE as u32,
1473        );
1474    }
1475}
1476
1477#[allow(unsafe_op_in_unsafe_fn)]
1478unsafe extern "C" fn atob_fn(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
1479    let args = CallArgs::from_vp(vp, argc);
1480    if argc == 0 || !(*args.get(0).ptr).is_string() {
1481        args.rval().set(UndefinedValue());
1482        return true;
1483    }
1484    let s = unsafe_jsstr_to_string(
1485        cx,
1486        ::std::ptr::NonNull::new_unchecked((*args.get(0).ptr).to_string()),
1487    );
1488    // HTML spec forgivable-base64 decode preamble (mirrors servo
1489    // base64_atob): strip HTML space, drop trailing padding on a %4==0
1490    // input, reject len%4==1, reject non-alphabet characters.
1491    let is_html_space = |c: char| matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0C');
1492    let cleaned: String = s.chars().filter(|&c| !is_html_space(c)).collect();
1493    let mut input: &str = &cleaned;
1494    if input.len() % 4 == 0 {
1495        if input.ends_with("==") {
1496            input = &input[..input.len() - 2];
1497        } else if input.ends_with('=') {
1498            input = &input[..input.len() - 1];
1499        }
1500    }
1501    if input.len() % 4 == 1 || input.chars().any(|c| !c.is_ascii_alphanumeric() && c != '+' && c != '/')
1502    {
1503        JS_ReportErrorUTF8(
1504            cx,
1505            c"Failed to decode base64: InvalidCharacterError".as_ptr(),
1506        );
1507        return false;
1508    }
1509    match bun_base64::decode_alloc(input.as_bytes()) {
1510        Ok(bytes) => {
1511            // BCE (atob binary truncation, 2026-08-18): two stacked defects
1512            // made any NUL-bearing payload truncate to its first zero byte
1513            // (a 117k-char WAV base64 decoded to 8-9 bytes — "RIFF" plus a
1514            // couple of size-field bytes, cut at the 0x00s):
1515            //   1. `String::from_utf8_lossy(&bytes)` treated raw binary as
1516            //      UTF-8, corrupting every non-ASCII byte into U+FFFD.
1517            //   2. `JS_NewStringCopyZ` copies up to the first NUL.
1518            // HTML spec / servo's own base64_atob semantics: each decoded
1519            // octet maps to ONE code unit (latin-1) — same contract as
1520            // bun_api::js_string_from_child_bytes. Copy via explicit-length
1521            // JS_NewUCStringCopyN so 0x00 survives as a plain code unit.
1522            let units: Vec<u16> = bytes.iter().map(|&b| b as u16).collect();
1523            let js_str = if units.is_empty() {
1524                JS_NewStringCopyN(cx, c"".as_ptr(), 0)
1525            } else {
1526                JS_NewUCStringCopyN(cx, units.as_ptr(), units.len())
1527            };
1528            if js_str.is_null() {
1529                args.rval().set(UndefinedValue());
1530            } else {
1531                args.rval().set(StringValue(&*js_str));
1532            }
1533        }
1534        Err(_) => {
1535            JS_ReportErrorUTF8(
1536                cx,
1537                c"Failed to decode base64: InvalidCharacterError".as_ptr(),
1538            );
1539            return false;
1540        }
1541    }
1542    true
1543}
1544
1545#[allow(unsafe_op_in_unsafe_fn)]
1546unsafe extern "C" fn btoa_fn(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
1547    let args = CallArgs::from_vp(vp, argc);
1548    if argc == 0 || !(*args.get(0).ptr).is_string() {
1549        args.rval().set(UndefinedValue());
1550        return true;
1551    }
1552    // BCE (btoa latin1 corruption, found by the atob round-trip regression):
1553    // the input's UTF-8 bytes used to be encoded directly, so a code unit
1554    // like 0xE0 encoded as the TWO bytes C0 A0 instead of the single octet
1555    // E0 — every >0x7F character corrupted the encoding. HTML spec / servo
1556    // base64_btoa: throw InvalidCharacterError on any code point > U+00FF;
1557    // otherwise each code point encodes as ONE octet. `chars()` iterates
1558    // code points (== code units for the ≤0xFF domain we keep here).
1559    let s = unsafe_jsstr_to_string(
1560        cx,
1561        ::std::ptr::NonNull::new_unchecked((*args.get(0).ptr).to_string()),
1562    );
1563    if s.chars().any(|c| c > '\u{FF}') {
1564        JS_ReportErrorUTF8(
1565            cx,
1566            c"Failed to encode base64: InvalidCharacterError".as_ptr(),
1567        );
1568        return false;
1569    }
1570    let octets: Vec<u8> = s.chars().map(|c| c as u8).collect();
1571    let encoded_bytes = bun_base64::encode_alloc(&octets);
1572    let encoded = ::std::str::from_utf8(&encoded_bytes).unwrap_or("");
1573    let c_str = ZBox::from_bytes(encoded.as_bytes());
1574    let js_str = JS_NewStringCopyZ(cx, c_str.as_ptr());
1575    if js_str.is_null() {
1576        args.rval().set(UndefinedValue());
1577    } else {
1578        args.rval().set(StringValue(&*js_str));
1579    }
1580    true
1581}
1582
1583pub fn install_queue_microtask(
1584    cx: &mut mozjs::context::JSContext,
1585    global: mozjs::rust::Handle<*mut JSObject>,
1586) {
1587    unsafe {
1588        JS_DefineFunction(
1589            cx,
1590            global,
1591            c"queueMicrotask".as_ptr(),
1592            Some(queue_microtask_fn),
1593            1,
1594            JSPROP_ENUMERATE as u32,
1595        );
1596    }
1597}
1598
1599#[allow(unsafe_op_in_unsafe_fn)]
1600unsafe extern "C" fn text_encoder_constructor(
1601    cx: *mut JSContext,
1602    argc: u32,
1603    vp: *mut JSVal,
1604) -> bool {
1605    let args = CallArgs::from_vp(vp, argc);
1606    let obj = mozjs_sys::jsapi::JS_NewPlainObject(cx);
1607    if obj.is_null() {
1608        args.rval().set(UndefinedValue());
1609        return true;
1610    }
1611    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
1612    rooted!(&in(wrapped_cx) let obj_r = obj);
1613    let encoding_str = JS_NewStringCopyZ(cx, c"utf-8".as_ptr());
1614    if !encoding_str.is_null() {
1615        let val = StringValue(&*encoding_str);
1616        rooted!(&in(wrapped_cx) let val_root = val);
1617        JS_DefineProperty(
1618            cx,
1619            obj_r.handle().into(),
1620            c"encoding".as_ptr(),
1621            val_root.handle().into(),
1622            (JSPROP_ENUMERATE | JSPROP_READONLY) as u32,
1623        );
1624    }
1625
1626    JS_DefineFunction(
1627        &mut wrapped_cx,
1628        obj_r.handle(),
1629        c"encode".as_ptr(),
1630        Some(text_encoder_encode),
1631        1,
1632        JSPROP_ENUMERATE as u32,
1633    );
1634    JS_DefineFunction(
1635        &mut wrapped_cx,
1636        obj_r.handle(),
1637        c"encodeInto".as_ptr(),
1638        Some(text_encoder_encode_into),
1639        2,
1640        JSPROP_ENUMERATE as u32,
1641    );
1642
1643    args.rval().set(ObjectValue(obj));
1644    true
1645}
1646
1647#[allow(unsafe_op_in_unsafe_fn)]
1648unsafe extern "C" fn text_encoder_encode(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
1649    let args = CallArgs::from_vp(vp, argc);
1650    let input = if argc > 0 {
1651        let v = *args.get(0).ptr;
1652        if v.is_string() {
1653            crate::js_to_rust_string(cx, v)
1654        } else {
1655            String::new()
1656        }
1657    } else {
1658        String::new()
1659    };
1660
1661    let bytes = input.as_bytes();
1662    let wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
1663
1664    // @trace REQ-ENG-005 [api:TextEncoder.encode] — Return a real SM
1665    // Uint8Array (not a plain Array) so callers see .byteLength/.buffer and
1666    // pass `instanceof Uint8Array`. Buffer.test.js drives this via
1667    // `new TextEncoder().encode(str).byteLength`.
1668    let u8_obj = mozjs_sys::jsapi::JS_NewUint8Array(cx, bytes.len());
1669    if u8_obj.is_null() {
1670        args.rval().set(UndefinedValue());
1671        return true;
1672    }
1673    if !bytes.is_empty() {
1674        rooted!(&in(wrapped_cx) let arr = u8_obj);
1675        let mut is_shared = false;
1676        let data_ptr =
1677            mozjs_sys::jsapi::JS_GetUint8ArrayData(arr.get(), &mut is_shared, ::std::ptr::null());
1678        if !data_ptr.is_null() {
1679            ::std::ptr::copy_nonoverlapping(bytes.as_ptr(), data_ptr, bytes.len());
1680        }
1681        args.rval().set(ObjectValue(arr.get()));
1682    } else {
1683        args.rval().set(ObjectValue(u8_obj));
1684    }
1685    true
1686}
1687
1688#[allow(unsafe_op_in_unsafe_fn)]
1689unsafe extern "C" fn text_encoder_encode_into(
1690    _cx: *mut JSContext,
1691    _argc: u32,
1692    vp: *mut JSVal,
1693) -> bool {
1694    let args = CallArgs::from_vp(vp, _argc);
1695    args.rval().set(UndefinedValue());
1696    true
1697}
1698
1699#[allow(unsafe_op_in_unsafe_fn)]
1700unsafe extern "C" fn text_decoder_constructor(
1701    cx: *mut JSContext,
1702    argc: u32,
1703    vp: *mut JSVal,
1704) -> bool {
1705    let args = CallArgs::from_vp(vp, argc);
1706    let obj = mozjs_sys::jsapi::JS_NewPlainObject(cx);
1707    if obj.is_null() {
1708        args.rval().set(UndefinedValue());
1709        return true;
1710    }
1711    let encoding = if argc > 0 {
1712        let v = *args.get(0).ptr;
1713        if v.is_string() {
1714            crate::js_to_rust_string(cx, v)
1715        } else {
1716            "utf-8".to_string()
1717        }
1718    } else {
1719        "utf-8".to_string()
1720    };
1721    let encoding_lower = encoding.to_lowercase();
1722    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
1723    rooted!(&in(wrapped_cx) let obj_r = obj);
1724    let encoding_str = JS_NewStringCopyZ(cx, ZBox::from_bytes(encoding_lower.as_bytes()).as_ptr());
1725    if !encoding_str.is_null() {
1726        let val = StringValue(&*encoding_str);
1727        rooted!(&in(wrapped_cx) let val_root = val);
1728        JS_DefineProperty(
1729            cx,
1730            obj_r.handle().into(),
1731            c"encoding".as_ptr(),
1732            val_root.handle().into(),
1733            (JSPROP_ENUMERATE | JSPROP_READONLY) as u32,
1734        );
1735    }
1736    rooted!(&in(wrapped_cx) let fatal_val = BooleanValue(false));
1737    JS_DefineProperty(
1738        cx,
1739        obj_r.handle().into(),
1740        c"fatal".as_ptr(),
1741        fatal_val.handle().into(),
1742        (JSPROP_ENUMERATE | JSPROP_READONLY) as u32,
1743    );
1744    rooted!(&in(wrapped_cx) let bom_val = BooleanValue(false));
1745    JS_DefineProperty(
1746        cx,
1747        obj_r.handle().into(),
1748        c"ignoreBOM".as_ptr(),
1749        bom_val.handle().into(),
1750        (JSPROP_ENUMERATE | JSPROP_READONLY) as u32,
1751    );
1752
1753    JS_DefineFunction(
1754        &mut wrapped_cx,
1755        obj_r.handle(),
1756        c"decode".as_ptr(),
1757        Some(text_decoder_decode),
1758        1,
1759        JSPROP_ENUMERATE as u32,
1760    );
1761
1762    args.rval().set(ObjectValue(obj));
1763    true
1764}
1765
1766#[allow(unsafe_op_in_unsafe_fn)]
1767unsafe extern "C" fn text_decoder_decode(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
1768    let args = CallArgs::from_vp(vp, argc);
1769    if argc == 0 {
1770        let empty = JS_NewStringCopyZ(cx, c"".as_ptr());
1771        args.rval().set(if empty.is_null() {
1772            UndefinedValue()
1773        } else {
1774            StringValue(&*empty)
1775        });
1776        return true;
1777    }
1778
1779    let input = *args.get(0).ptr;
1780    // Primitive input (number/string/null) is not a BufferSource — spec
1781    // TypeError. Guard BEFORE to_object(): to_object() on a non-object
1782    // asserts.
1783    if !input.is_object() {
1784        mozjs::error::throw_type_error(
1785            cx,
1786            c"The provided value is not an instance of ArrayBuffer or ArrayBufferView".as_ref(),
1787        );
1788        return false;
1789    }
1790
1791    // WHATWG BufferSource extraction: ArrayBufferView (Uint8Array & every
1792    // other view, byteOffset-adjusted) OR ArrayBuffer. Both take the direct
1793    // data-pointer path — the previous generic length+GetElement loop read
1794    // view ELEMENTS one by one and produced "" for a bare ArrayBuffer (it
1795    // has no `length` property). Anything that is not a BufferSource is a
1796    // TypeError per spec (decode() with no args returns "" above).
1797    let bytes: Vec<u8> = {
1798        let wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
1799        rooted!(&in(wrapped_cx) let obj = input.to_object());
1800        let mut len: usize = 0;
1801        let mut is_shared = false;
1802        let mut data: *mut u8 = ::std::ptr::null_mut();
1803        let unwrapped =
1804            mozjs_sys::jsapi::JS_GetObjectAsArrayBufferView(obj.get(), &mut len, &mut is_shared, &mut data);
1805        if !unwrapped.is_null() && !data.is_null() {
1806            // Detached views surface as length 0 + null data (handled below);
1807            // a live view yields the byteOffset-adjusted pointer directly.
1808            ::std::slice::from_raw_parts(data, len).to_vec()
1809        } else if !unwrapped.is_null() {
1810            Vec::new() // detached view: empty byte sequence per spec
1811        } else {
1812            let ab_unwrapped =
1813                mozjs_sys::jsapi::JS::GetObjectAsArrayBuffer(obj.get(), &mut len, &mut data);
1814            if !ab_unwrapped.is_null() && !data.is_null() {
1815                ::std::slice::from_raw_parts(data, len).to_vec()
1816            } else if !ab_unwrapped.is_null() {
1817                Vec::new() // detached ArrayBuffer
1818            } else {
1819                mozjs::error::throw_type_error(
1820                    cx,
1821                    c"The provided value is not an instance of ArrayBuffer or ArrayBufferView"
1822                        .as_ref(),
1823                );
1824                return false;
1825            }
1826        }
1827    };
1828
1829    // TextDecoder defaults to fatal:false — invalid sequences become U+FFFD
1830    // replacement characters instead of throwing.
1831    let decoded = String::from_utf8_lossy(&bytes).into_owned();
1832
1833    let utf16: Vec<u16> = decoded.encode_utf16().collect();
1834    let js_str = JS_NewUCStringCopyN(cx, utf16.as_ptr(), utf16.len());
1835    args.rval().set(if js_str.is_null() {
1836        UndefinedValue()
1837    } else {
1838        StringValue(&*js_str)
1839    });
1840    true
1841}
1842
1843#[allow(unsafe_op_in_unsafe_fn)]
1844unsafe extern "C" fn queue_microtask_fn(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
1845    let args = CallArgs::from_vp(vp, argc);
1846    if argc == 0 || !(*args.get(0).ptr).is_object() {
1847        return true;
1848    }
1849    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
1850    let cx = &mut wrapped_cx;
1851
1852    rooted!(&in(cx) let callback = (*args.get(0).ptr).to_object());
1853    rooted!(&in(cx) let undef_val = UndefinedValue());
1854    let resolved = CallOriginalPromiseResolve(cx, undef_val.handle());
1855    if resolved.is_null() {
1856        args.rval().set(UndefinedValue());
1857        return true;
1858    }
1859    rooted!(&in(cx) let promise = resolved);
1860    rooted!(&in(cx) let null_reject = ::std::ptr::null_mut::<JSObject>());
1861    CallOriginalPromiseThen(
1862        cx,
1863        promise.handle(),
1864        callback.handle(),
1865        null_reject.handle(),
1866    );
1867    args.rval().set(UndefinedValue());
1868    true
1869}
1870
1871// ═══════════════════════════════════════════════════════════════════════════
1872// crypto.subtle — WebCrypto surface bridged onto the REAL primitives the
1873// node:crypto layer already uses (bao_crypto + bun_sha_hmac + bun_base64).
1874// @trace REQ-ENG-006 [api:crypto.subtle]
1875//
1876// The subtle object is the SAME object globals::install_crypto_global put on
1877// globalThis.crypto (so require("crypto").subtle — aliased by node_crypto —
1878// upgrades with it). Installed at the tail of the web-API phase; defining the
1879// methods on the existing object preserves every alias.
1880// ═══════════════════════════════════════════════════════════════════════════
1881
1882/// Define the full WebCrypto method set on globalThis.crypto.subtle.
1883pub fn install_crypto_subtle(cx: &mut mozjs::context::JSContext, global: mozjs::rust::Handle<*mut JSObject>) {
1884    unsafe {
1885        rooted!(&in(cx) let global_root = global.get());
1886        let mut crypto_val = UndefinedValue();
1887        // BCE (P0 browser startup panic, servo error.rs:74): a capability
1888        // probe that hits a throwing getter returns false WITH the exception
1889        // pending; an unconsumed pending exception detonates servo's
1890        // `assert!(!JS_IsExceptionPending)` in `throw_dom_exception` on the
1891        // next error path, killing the ScriptThread at page init. Read as
1892        // "not available" and consume the exception.
1893        let crypto_found = JS_GetProperty(
1894            cx.raw_cx(),
1895            global_root.handle().into(),
1896            c"crypto".as_ptr(),
1897            MutableHandle::<Value> {
1898                _phantom_0: ::std::marker::PhantomData,
1899                ptr: &mut crypto_val,
1900            },
1901        );
1902        if !crypto_found {
1903            JS_ClearPendingException(cx.raw_cx());
1904            return;
1905        }
1906        if !crypto_val.is_object() {
1907            return;
1908        }
1909        rooted!(&in(cx) let crypto_obj = crypto_val.to_object());
1910        let mut subtle_val = UndefinedValue();
1911        // BCE (error.rs:74): same probe contract as `crypto` above.
1912        let subtle_found = JS_GetProperty(
1913            cx.raw_cx(),
1914            crypto_obj.handle().into(),
1915            c"subtle".as_ptr(),
1916            MutableHandle::<Value> {
1917                _phantom_0: ::std::marker::PhantomData,
1918                ptr: &mut subtle_val,
1919            },
1920        );
1921        if !subtle_found {
1922            JS_ClearPendingException(cx.raw_cx());
1923            return;
1924        }
1925        if !subtle_val.is_object() {
1926            return;
1927        }
1928        rooted!(&in(cx) let subtle = subtle_val.to_object());
1929        for (name, op, nargs) in [
1930            ("encrypt", subtle_encrypt as unsafe extern "C" fn(*mut JSContext, u32, *mut JSVal) -> bool, 3),
1931            ("decrypt", subtle_decrypt, 3),
1932            ("generateKey", subtle_generate_key, 3),
1933            ("importKey", subtle_import_key, 5),
1934            ("sign", subtle_sign, 3),
1935            ("verify", subtle_verify, 4),
1936            // digest: the pre-existing globals.rs implementation returned the
1937            // raw bytes instead of a Promise (spec violation — every
1938            // `subtle.digest().then` threw). Redefined here on the SAME subtle
1939            // object with Promise semantics over the real BoringSSL hashers.
1940            ("digest", subtle_digest, 2),
1941        ] {
1942            JS_DefineFunction(
1943                cx,
1944                subtle.handle(),
1945                ZBox::from_bytes(name.as_bytes()).as_ptr(),
1946                Some(op),
1947                nargs,
1948                JSPROP_ENUMERATE as u32,
1949            );
1950        }
1951    }
1952}
1953
1954// ── subtle helpers ──────────────────────────────────────────────────────────
1955
1956/// Constant-time byte equality (signature verification must not leak).
1957fn ct_eq(a: &[u8], b: &[u8]) -> bool {
1958    if a.len() != b.len() {
1959        return false;
1960    }
1961    let mut diff: u8 = 0;
1962    for (x, y) in a.iter().zip(b.iter()) {
1963        diff |= x ^ y;
1964    }
1965    diff == 0
1966}
1967
1968/// Extract BufferSource bytes: TypedArray/DataView fast path, then BARE
1969/// ArrayBuffer (subtle results are bare ArrayBuffers — the node_crypto
1970/// extractor misses those), then empty.
1971#[allow(unsafe_op_in_unsafe_fn)]
1972unsafe fn subtle_bytes(cx: *mut JSContext, val: JSVal) -> Vec<u8> {
1973    if !val.is_object() {
1974        return Vec::new();
1975    }
1976    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
1977    let cx_ref = &mut wrapped_cx;
1978    rooted!(&in(cx_ref) let obj_root = val.to_object());
1979    let mut length: usize = 0;
1980    let mut is_shared = false;
1981    let mut data_ptr: *mut u8 = ::std::ptr::null_mut();
1982    let u8_unwrapped = mozjs_sys::jsapi::JS_GetObjectAsUint8Array(
1983        obj_root.get(),
1984        &mut length,
1985        &mut is_shared,
1986        &mut data_ptr,
1987    );
1988    if !u8_unwrapped.is_null() && !data_ptr.is_null() && length > 0 {
1989        return ::std::slice::from_raw_parts(data_ptr, length).to_vec();
1990    }
1991    let mut view_length: usize = 0;
1992    let mut view_shared = false;
1993    let mut view_data: *mut u8 = ::std::ptr::null_mut();
1994    let view_unwrapped = mozjs_sys::jsapi::JS_GetObjectAsArrayBufferView(
1995        obj_root.get(),
1996        &mut view_length,
1997        &mut view_shared,
1998        &mut view_data,
1999    );
2000    if !view_unwrapped.is_null() && !view_data.is_null() && view_length > 0 {
2001        return ::std::slice::from_raw_parts(view_data, view_length).to_vec();
2002    }
2003    // Bare ArrayBuffer (length via ByteLength; data ptr valid while rooted,
2004    // copied before any further JSAPI call).
2005    let ab_len = mozjs_sys::jsapi::JS::GetArrayBufferByteLength(obj_root.get());
2006    if ab_len > 0 {
2007        let mut ab_shared = false;
2008        let ab_data = mozjs_sys::jsapi::JS::GetArrayBufferData(
2009            obj_root.get(),
2010            &mut ab_shared,
2011            ::std::ptr::null(),
2012        );
2013        if !ab_data.is_null() {
2014            return ::std::slice::from_raw_parts(ab_data, ab_len).to_vec();
2015        }
2016    }
2017    Vec::new()
2018}
2019
2020/// Read a string-valued property off a JS object.
2021#[allow(unsafe_op_in_unsafe_fn)]
2022unsafe fn subtle_str_prop(cx: *mut JSContext, obj: *mut JSObject, name: &str) -> Option<String> {
2023    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
2024    let cx_ref = &mut wrapped_cx;
2025    rooted!(&in(cx_ref) let obj_r = obj);
2026    let c_name = ZBox::from_bytes(name.as_bytes());
2027    let mut v = UndefinedValue();
2028    // BCE (error.rs:74): caller-supplied algorithm objects can carry
2029    // throwing getters; a failed probe must consume its pending exception
2030    // (browser mode runs this on the servo ScriptThread context).
2031    bao_stealth::engine_props::get_property_clearing(cx, obj_r.handle().into(), c_name.as_cstr(), &mut v);
2032    if v.is_string() {
2033        Some(crate::js_to_rust_string(cx, v))
2034    } else {
2035        None
2036    }
2037}
2038
2039/// Read a numeric property as u32.
2040#[allow(unsafe_op_in_unsafe_fn)]
2041unsafe fn subtle_u32_prop(cx: *mut JSContext, obj: *mut JSObject, name: &str) -> Option<u32> {
2042    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
2043    let cx_ref = &mut wrapped_cx;
2044    rooted!(&in(cx_ref) let obj_r = obj);
2045    let c_name = ZBox::from_bytes(name.as_bytes());
2046    let mut v = UndefinedValue();
2047    // BCE (error.rs:74): clearing probe — see subtle_str_prop.
2048    bao_stealth::engine_props::get_property_clearing(cx, obj_r.handle().into(), c_name.as_cstr(), &mut v);
2049    if v.is_int32() && v.to_int32() >= 0 {
2050        Some(v.to_int32() as u32)
2051    } else if v.is_double() && v.to_double() >= 0.0 {
2052        Some(v.to_double() as u32)
2053    } else {
2054        None
2055    }
2056}
2057
2058/// Read a BufferSource-valued property (iv / additionalData / data).
2059#[allow(unsafe_op_in_unsafe_fn)]
2060unsafe fn subtle_bytes_prop(cx: *mut JSContext, obj: *mut JSObject, name: &str) -> Option<Vec<u8>> {
2061    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
2062    let cx_ref = &mut wrapped_cx;
2063    rooted!(&in(cx_ref) let obj_r = obj);
2064    let c_name = ZBox::from_bytes(name.as_bytes());
2065    let mut v = UndefinedValue();
2066    // BCE (error.rs:74): clearing probe — see subtle_str_prop.
2067    bao_stealth::engine_props::get_property_clearing(cx, obj_r.handle().into(), c_name.as_cstr(), &mut v);
2068    if v.is_object() {
2069        Some(subtle_bytes(cx, v))
2070    } else {
2071        None
2072    }
2073}
2074
2075/// Copy bytes into a fresh ArrayBuffer value.
2076#[allow(unsafe_op_in_unsafe_fn)]
2077unsafe fn bytes_to_arraybuffer_val(cx: *mut JSContext, bytes: &[u8]) -> JSVal {
2078    // glue::NewArrayBufferWithContents takes ownership of a malloc'd buffer
2079    // (JS frees it) — copy into a fresh malloc block, zero-copy from there.
2080    if bytes.is_empty() {
2081        let ab = mozjs_sys::jsapi::JS::NewArrayBuffer(cx, 0);
2082        return if ab.is_null() { UndefinedValue() } else { ObjectValue(ab) };
2083    }
2084    let buf = libc::malloc(bytes.len()) as *mut u8;
2085    if buf.is_null() {
2086        return UndefinedValue();
2087    }
2088    ::std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf, bytes.len());
2089    let ab = mozjs_sys::jsapi::glue::NewArrayBufferWithContents(cx, bytes.len(), buf as *mut core::ffi::c_void);
2090    if ab.is_null() {
2091        libc::free(buf as *mut core::ffi::c_void);
2092        return UndefinedValue();
2093    }
2094    ObjectValue(ab)
2095}
2096
2097/// Build a CryptoKey JS object. `material` names the hidden bytes slot:
2098/// `_raw` for symmetric keys, `_der` (pkcs8 for private, spki for public)
2099/// for asymmetric.
2100#[allow(unsafe_op_in_unsafe_fn)]
2101unsafe fn make_crypto_key(
2102    cx: *mut JSContext,
2103    ktype: &str,
2104    alg_name: &str,
2105    extra_alg: &[(&str, String)],
2106    extractable: bool,
2107    usages: *mut JSObject,
2108    material_slot: &str,
2109    material: &[u8],
2110) -> *mut JSObject {
2111    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
2112    let cx_ref = &mut wrapped_cx;
2113    rooted!(&in(cx_ref) let key = JS_NewPlainObject(cx_ref));
2114    if key.get().is_null() {
2115        return ::std::ptr::null_mut();
2116    }
2117    let kh = key.handle().into();
2118
2119    let c_t = ZBox::from_bytes(ktype.as_bytes());
2120    let t_js = JS_NewStringCopyZ(cx, c_t.as_ptr());
2121    if !t_js.is_null() {
2122        rooted!(&in(cx_ref) let tv = StringValue(&*t_js));
2123        JS_DefineProperty(cx, kh, c"type".as_ptr(), tv.handle().into(), (JSPROP_ENUMERATE | JSPROP_READONLY) as u32);
2124    }
2125    rooted!(&in(cx_ref) let ev = BooleanValue(extractable));
2126    JS_DefineProperty(cx, kh, c"extractable".as_ptr(), ev.handle().into(), (JSPROP_ENUMERATE | JSPROP_READONLY) as u32);
2127    rooted!(&in(cx_ref) let uv = ObjectValue(usages));
2128    JS_DefineProperty(cx, kh, c"usages".as_ptr(), uv.handle().into(), (JSPROP_ENUMERATE | JSPROP_READONLY) as u32);
2129
2130    // algorithm: { name, ...extras }
2131    rooted!(&in(cx_ref) let alg_obj = JS_NewPlainObject(cx_ref));
2132    if !alg_obj.get().is_null() {
2133        let ah = alg_obj.handle().into();
2134        let c_n = ZBox::from_bytes(alg_name.as_bytes());
2135        let n_js = JS_NewStringCopyZ(cx, c_n.as_ptr());
2136        if !n_js.is_null() {
2137            rooted!(&in(cx_ref) let nv = StringValue(&*n_js));
2138            JS_DefineProperty(cx, ah, c"name".as_ptr(), nv.handle().into(), (JSPROP_ENUMERATE | JSPROP_READONLY) as u32);
2139        }
2140        for (k, v) in extra_alg {
2141            if *k == "length" {
2142                // Numeric algorithm member (AES key length) per spec.
2143                if let Ok(n) = v.parse::<i32>() {
2144                    rooted!(&in(cx_ref) let nv = Int32Value(n));
2145                    JS_DefineProperty(
2146                        cx,
2147                        ah,
2148                        ZBox::from_bytes(k.as_bytes()).as_ptr(),
2149                        nv.handle().into(),
2150                        (JSPROP_ENUMERATE | JSPROP_READONLY) as u32,
2151                    );
2152                }
2153                continue;
2154            }
2155            let c_v = ZBox::from_bytes(v.as_bytes());
2156            let v_js = JS_NewStringCopyZ(cx, c_v.as_ptr());
2157            if !v_js.is_null() {
2158                rooted!(&in(cx_ref) let vv = StringValue(&*v_js));
2159                JS_DefineProperty(
2160                    cx,
2161                    ah,
2162                    ZBox::from_bytes(k.as_bytes()).as_ptr(),
2163                    vv.handle().into(),
2164                    (JSPROP_ENUMERATE | JSPROP_READONLY) as u32,
2165                );
2166            }
2167        }
2168        rooted!(&in(cx_ref) let av = ObjectValue(alg_obj.get()));
2169        JS_DefineProperty(cx, kh, c"algorithm".as_ptr(), av.handle().into(), (JSPROP_ENUMERATE | JSPROP_READONLY) as u32);
2170    }
2171
2172    // Hidden material slots (Uint8Array over copied bytes).
2173    let stash = |slot: &str, bytes: &[u8]| {
2174        let u8v = mozjs_sys::jsapi::JS_NewUint8Array(cx, bytes.len());
2175        if u8v.is_null() {
2176            return;
2177        }
2178        let mut len: usize = 0;
2179        let mut shared = false;
2180        let mut data: *mut u8 = ::std::ptr::null_mut();
2181        let unwrapped = mozjs_sys::jsapi::JS_GetObjectAsUint8Array(u8v, &mut len, &mut shared, &mut data);
2182        if unwrapped.is_null() || data.is_null() || len < bytes.len() {
2183            return;
2184        }
2185        ::std::ptr::copy_nonoverlapping(bytes.as_ptr(), data, bytes.len());
2186        rooted!(&in(cx_ref) let mv = ObjectValue(u8v));
2187        JS_DefineProperty(
2188            cx,
2189            kh,
2190            ZBox::from_bytes(slot.as_bytes()).as_ptr(),
2191            mv.handle().into(),
2192            0,
2193        );
2194    };
2195    stash(material_slot, material);
2196    key.get()
2197}
2198
2199/// Build the WebCrypto CryptoKeyPair `{ privateKey, publicKey }` that
2200/// generateKey resolves with for asymmetric algorithms. Each half carries
2201/// its own `_der` material: pkcs8 on the private key, spki on the public.
2202#[allow(unsafe_op_in_unsafe_fn)]
2203unsafe fn make_crypto_key_pair(
2204    cx: *mut JSContext,
2205    alg_name: &str,
2206    extra_alg: &[(&str, String)],
2207    extractable: bool,
2208    usages: *mut JSObject,
2209    private_der: &[u8],
2210    public_der: &[u8],
2211) -> *mut JSObject {
2212    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
2213    let cx_ref = &mut wrapped_cx;
2214    rooted!(&in(cx_ref) let pair = JS_NewPlainObject(cx_ref));
2215    if pair.get().is_null() {
2216        return ::std::ptr::null_mut();
2217    }
2218    let ph = pair.handle().into();
2219    let private_key = make_crypto_key(cx, "private", alg_name, extra_alg, extractable, usages, "_der", private_der);
2220    let public_key = make_crypto_key(cx, "public", alg_name, extra_alg, extractable, usages, "_der", public_der);
2221    if private_key.is_null() || public_key.is_null() {
2222        return ::std::ptr::null_mut();
2223    }
2224    rooted!(&in(cx_ref) let pv = ObjectValue(private_key));
2225    JS_DefineProperty(cx, ph, c"privateKey".as_ptr(), pv.handle().into(), (JSPROP_ENUMERATE | JSPROP_READONLY) as u32);
2226    rooted!(&in(cx_ref) let uv = ObjectValue(public_key));
2227    JS_DefineProperty(cx, ph, c"publicKey".as_ptr(), uv.handle().into(), (JSPROP_ENUMERATE | JSPROP_READONLY) as u32);
2228    pair.get()
2229}
2230
2231/// Caller-supplied JSVal coerced to an object — None on non-object input
2232/// (a rejected promise) instead of the JSVal::to_object debug assert abort
2233/// that a stray `undefined` key argument used to trigger.
2234unsafe fn subtle_arg_object(val: JSVal) -> Option<*mut JSObject> {
2235    if val.is_object() {
2236        Some(val.to_object())
2237    } else {
2238        None
2239    }
2240}
2241
2242/// Read a hidden bytes slot off a CryptoKey.
2243#[allow(unsafe_op_in_unsafe_fn)]
2244unsafe fn key_material(cx: *mut JSContext, key: *mut JSObject, slot: &str) -> Option<Vec<u8>> {
2245    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
2246    let cx_ref = &mut wrapped_cx;
2247    rooted!(&in(cx_ref) let key_r = key);
2248    let mut v = UndefinedValue();
2249    JS_GetProperty(
2250        cx,
2251        key_r.handle().into(),
2252        ZBox::from_bytes(slot.as_bytes()).as_ptr(),
2253        MutableHandle::<Value> {
2254            _phantom_0: ::std::marker::PhantomData,
2255            ptr: &mut v,
2256        },
2257    );
2258    if v.is_object() {
2259        Some(subtle_bytes(cx, v))
2260    } else {
2261        None
2262    }
2263}
2264
2265/// Fresh pending Promise, set as the method's rval. Expands to a block
2266/// evaluating to the rooted promise object (null on allocation failure).
2267macro_rules! subtle_promise {
2268    ($cx:expr, $cx_ref:expr, $args:expr) => {{
2269        rooted!(&in($cx_ref) let null_global = ::std::ptr::null_mut::<JSObject>());
2270        let promise = mozjs_sys::jsapi::JS::NewPromiseObject($cx, null_global.handle().into());
2271        if promise.is_null() {
2272            $args.rval().set(UndefinedValue());
2273            ::std::ptr::null_mut::<JSObject>()
2274        } else {
2275            $args.rval().set(ObjectValue(promise));
2276            promise
2277        }
2278    }};
2279}
2280
2281// ── subtle.digest (Promise-returning redefinition over real hashers) ───────
2282
2283#[allow(unsafe_op_in_unsafe_fn)]
2284unsafe extern "C" fn subtle_digest(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
2285    let args = CallArgs::from_vp(vp, argc);
2286    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
2287    let cx_ref = &mut wrapped_cx;
2288    let promise = subtle_promise!(cx, cx_ref, &args);
2289    if promise.is_null() {
2290        return true;
2291    }
2292    rooted!(&in(cx_ref) let promise_root = promise);
2293
2294    let result: ::std::result::Result<Vec<u8>, String> = (|| {
2295        use bun_sha_hmac::sha::hashers;
2296        if argc < 2 {
2297            return Err("digest(algorithm, data) requires 2 arguments".to_string());
2298        }
2299        let alg_val = *args.get(0).ptr;
2300        let name = if alg_val.is_string() {
2301            crate::js_to_rust_string(cx, alg_val).to_uppercase()
2302        } else if alg_val.is_object() {
2303            let obj = alg_val.to_object();
2304            subtle_str_prop(cx, obj, "name").unwrap_or_default().to_uppercase()
2305        } else {
2306            return Err("digest algorithm must be a string or {name}".to_string());
2307        };
2308        let data = subtle_bytes(cx, *args.get(1).ptr);
2309        match name.as_str() {
2310            "SHA-1" | "SHA1" => {
2311                let mut out = [0u8; hashers::SHA1::DIGEST];
2312                hashers::SHA1::hash(&data, &mut out);
2313                Ok(out.to_vec())
2314            }
2315            "SHA-256" | "SHA256" => {
2316                let mut out = [0u8; hashers::SHA256::DIGEST];
2317                hashers::SHA256::hash(&data, &mut out);
2318                Ok(out.to_vec())
2319            }
2320            "SHA-384" | "SHA384" => {
2321                let mut out = [0u8; hashers::SHA384::DIGEST];
2322                hashers::SHA384::hash(&data, &mut out);
2323                Ok(out.to_vec())
2324            }
2325            "SHA-512" | "SHA512" => {
2326                let mut out = [0u8; hashers::SHA512::DIGEST];
2327                hashers::SHA512::hash(&data, &mut out);
2328                Ok(out.to_vec())
2329            }
2330            other => Err(format!("subtle.digest: unsupported algorithm {}", other)),
2331        }
2332    })();
2333
2334    match result {
2335        Ok(bytes) => {
2336            let v = bytes_to_arraybuffer_val(cx, &bytes);
2337            subtle_resolve(cx, promise_root.get(), v);
2338        }
2339        Err(msg) => subtle_reject(cx, promise_root.get(), &format!("subtle.digest: {}", msg)),
2340    }
2341    true
2342}
2343
2344/// Reject the promise with a REAL TypeError from the realm's constructor.
2345#[allow(unsafe_op_in_unsafe_fn)]
2346unsafe fn subtle_reject(cx: *mut JSContext, promise: *mut JSObject, msg: &str) {
2347    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
2348    let cx_ref = &mut wrapped_cx;
2349    rooted!(&in(cx_ref) let promise_root = promise);
2350    let global = CurrentGlobalOrNull(cx);
2351    let err_obj = if !global.is_null() {
2352        rooted!(&in(cx_ref) let global_root = global);
2353        let c_msg = ZBox::from_bytes(msg.as_bytes());
2354        let msg_js = JS_NewStringCopyZ(cx, c_msg.as_ptr());
2355        let mut err = UndefinedValue();
2356        if !msg_js.is_null() {
2357            rooted!(&in(cx_ref) let mv = StringValue(&*msg_js));
2358            let elems = [*mv.handle()];
2359            let call_args = HandleValueArray {
2360                length_: 1,
2361                elements_: elems.as_ptr(),
2362            };
2363            let mut type_error_fn = UndefinedValue();
2364            JS_GetProperty(
2365                cx,
2366                global_root.handle().into(),
2367                c"TypeError".as_ptr(),
2368                MutableHandle::<Value> {
2369                    _phantom_0: ::std::marker::PhantomData,
2370                    ptr: &mut type_error_fn,
2371                },
2372            );
2373            if type_error_fn.is_object() {
2374                rooted!(&in(cx_ref) let fn_val = type_error_fn);
2375                rooted!(&in(cx_ref) let undef_this = ::std::ptr::null_mut::<JSObject>());
2376                if JS_CallFunctionValue(
2377                    cx,
2378                    undef_this.handle().into(),
2379                    fn_val.handle().into(),
2380                    &call_args,
2381                    MutableHandle::<Value> {
2382                        _phantom_0: ::std::marker::PhantomData,
2383                        ptr: &mut err,
2384                    },
2385                ) && err.is_object() {
2386                    err.to_object()
2387                } else {
2388                    JS_ClearPendingException(cx);
2389                    ::std::ptr::null_mut()
2390                }
2391            } else {
2392                ::std::ptr::null_mut()
2393            }
2394        } else {
2395            ::std::ptr::null_mut()
2396        }
2397    } else {
2398        ::std::ptr::null_mut()
2399    };
2400    if err_obj.is_null() {
2401        // Degraded shape only when the realm has no TypeError at all — the
2402        // message still reaches the rejection.
2403        rooted!(&in(cx_ref) let obj = JS_NewPlainObject(cx_ref));
2404        if !obj.get().is_null() {
2405            let c_msg = ZBox::from_bytes(msg.as_bytes());
2406            let m_js = JS_NewStringCopyZ(cx, c_msg.as_ptr());
2407            if !m_js.is_null() {
2408                rooted!(&in(cx_ref) let mv = StringValue(&*m_js));
2409                JS_DefineProperty(cx, obj.handle().into(), c"message".as_ptr(), mv.handle().into(), JSPROP_ENUMERATE as u32);
2410            }
2411            let c_n = ZBox::from_bytes("TypeError".as_bytes());
2412            let n_js = JS_NewStringCopyZ(cx, c_n.as_ptr());
2413            if !n_js.is_null() {
2414                rooted!(&in(cx_ref) let nv = StringValue(&*n_js));
2415                JS_DefineProperty(cx, obj.handle().into(), c"name".as_ptr(), nv.handle().into(), JSPROP_ENUMERATE as u32);
2416            }
2417            rooted!(&in(cx_ref) let ev = ObjectValue(obj.get()));
2418            mozjs_sys::jsapi::JS::RejectPromise(cx, promise_root.handle().into(), ev.handle().into());
2419            return;
2420        }
2421        return;
2422    }
2423    rooted!(&in(cx_ref) let ev = ObjectValue(err_obj));
2424    mozjs_sys::jsapi::JS::RejectPromise(cx, promise_root.handle().into(), ev.handle().into());
2425}
2426
2427/// Resolve the promise with a value.
2428#[allow(unsafe_op_in_unsafe_fn)]
2429unsafe fn subtle_resolve(cx: *mut JSContext, promise: *mut JSObject, val: JSVal) {
2430    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
2431    let cx_ref = &mut wrapped_cx;
2432    rooted!(&in(cx_ref) let promise_root = promise);
2433    rooted!(&in(cx_ref) let v = val);
2434    mozjs_sys::jsapi::JS::ResolvePromise(cx, promise_root.handle().into(), v.handle().into());
2435}
2436
2437/// algo.name string from the first argument (algorithm identifier object).
2438#[allow(unsafe_op_in_unsafe_fn)]
2439unsafe fn subtle_algo_name(cx: *mut JSContext, val: JSVal) -> ::std::result::Result<(String, *mut JSObject), String> {
2440    // WebCrypto AlgorithmIdentifier is `(Algorithm or DOMString)`: a bare
2441    // string like 'HMAC' is the exact equivalent of {name: 'HMAC'} with no
2442    // extra params (browsers and Node webcrypto both coerce). Synthesize the
2443    // object so every downstream subtle_*_prop(alg_obj, …) lookup behaves
2444    // exactly as if the caller passed the object form.
2445    if val.is_string() {
2446        let name = crate::js_to_rust_string(cx, val);
2447        if name.is_empty() {
2448            return Err("algorithm name is required".to_string());
2449        }
2450        let mut wrapped_cx =
2451            mozjs::context::JSContext::from_ptr(::std::ptr::NonNull::new_unchecked(cx));
2452        let cx_ref = &mut wrapped_cx;
2453        let obj = JS_NewPlainObject(cx_ref);
2454        if obj.is_null() {
2455            return Err("algorithm object allocation failed".to_string());
2456        }
2457        rooted!(&in(cx_ref) let obj_root = obj);
2458        let js_name = JS_NewStringCopyN(
2459            cx,
2460            name.as_ptr() as *const ::std::os::raw::c_char,
2461            name.len(),
2462        );
2463        if js_name.is_null() {
2464            return Err("algorithm name string allocation failed".to_string());
2465        }
2466        rooted!(&in(cx_ref) let name_val = mozjs::jsval::StringValue(&*js_name));
2467        JS_DefineProperty(
2468            cx,
2469            obj_root.handle().into(),
2470            c"name".as_ptr(),
2471            name_val.handle().into(),
2472            JSPROP_ENUMERATE as u32,
2473        );
2474        return Ok((name, obj_root.get()));
2475    }
2476    if !val.is_object() {
2477        return Err("algorithm identifier must be a string or an object".to_string());
2478    }
2479    let obj = val.to_object();
2480    let name = subtle_str_prop(cx, obj, "name")
2481        .ok_or_else(|| "algorithm.name is required".to_string())?;
2482    Ok((name, obj))
2483}
2484
2485/// Map an algorithm name + symmetric key length to the cipher algorithm.
2486fn aes_cipher_algo(name: &str, key_len: usize) -> ::std::result::Result<bao_crypto::cipher::CipherAlgorithm, String> {
2487    let bits = key_len * 8;
2488    let qualified = match name {
2489        "AES-GCM" => match bits {
2490            128 => "aes-128-gcm",
2491            192 => "aes-192-gcm",
2492            256 => "aes-256-gcm",
2493            _ => return Err(format!("invalid AES-GCM key length: {} bits", bits)),
2494        },
2495        "AES-CBC" => match bits {
2496            128 => "aes-128-cbc",
2497            192 => "aes-192-cbc",
2498            256 => "aes-256-cbc",
2499            _ => return Err(format!("invalid AES-CBC key length: {} bits", bits)),
2500        },
2501        other => return Err(format!("unsupported cipher algorithm: {}", other)),
2502    };
2503    bao_crypto::cipher::parse_algorithm(qualified).map_err(|e| e.to_string())
2504}
2505
2506// ── subtle.encrypt / subtle.decrypt ─────────────────────────────────────────
2507
2508#[allow(unsafe_op_in_unsafe_fn)]
2509unsafe extern "C" fn subtle_encrypt(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
2510    let args = CallArgs::from_vp(vp, argc);
2511    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
2512    let cx_ref = &mut wrapped_cx;
2513    let promise = subtle_promise!(cx, cx_ref, &args);
2514    if promise.is_null() {
2515        return true;
2516    }
2517    rooted!(&in(cx_ref) let promise_root = promise);
2518
2519    let result: ::std::result::Result<Vec<u8>, String> = (|| {
2520        if argc < 3 {
2521            return Err("encrypt(algorithm, key, data) requires 3 arguments".to_string());
2522        }
2523        let (name, alg_obj) = subtle_algo_name(cx, *args.get(0).ptr)?;
2524        let key_obj = subtle_arg_object(*args.get(1).ptr)
2525            .ok_or("encrypt: key must be a CryptoKey".to_string())?;
2526        let data = subtle_bytes(cx, *args.get(2).ptr);
2527        let raw = key_material(cx, key_obj, "_raw")
2528            .ok_or("encrypt: not a symmetric CryptoKey".to_string())?;
2529
2530        match name.as_str() {
2531            "AES-GCM" => {
2532                let iv = subtle_bytes_prop(cx, alg_obj, "iv")
2533                    .ok_or("AES-GCM requires an iv".to_string())?;
2534                let aad = subtle_bytes_prop(cx, alg_obj, "additionalData");
2535                if let Some(tl) = subtle_u32_prop(cx, alg_obj, "tagLength") {
2536                    if tl != 128 {
2537                        return Err(format!("AES-GCM tagLength {} is not supported (128 only)", tl));
2538                    }
2539                }
2540                let algo = aes_cipher_algo("AES-GCM", raw.len())?;
2541                let out = bao_crypto::cipher::encrypt(algo, &raw, &iv, aad.as_deref(), &data)
2542                    .map_err(|e| e.to_string())?;
2543                let mut combined = out.ciphertext;
2544                combined.extend_from_slice(&out.auth_tag);
2545                Ok(combined)
2546            }
2547            "AES-CBC" => {
2548                let iv = subtle_bytes_prop(cx, alg_obj, "iv")
2549                    .ok_or("AES-CBC requires an iv".to_string())?;
2550                let algo = aes_cipher_algo("AES-CBC", raw.len())?;
2551                let mut ctx = bao_crypto::cipher::CipherCtx::new(
2552                    algo,
2553                    &raw,
2554                    &iv,
2555                    bao_crypto::cipher::Direction::Encrypt,
2556                )
2557                .map_err(|e| e.to_string())?;
2558                let mut out = ctx.update(&data).map_err(|e| e.to_string())?;
2559                out.extend_from_slice(&ctx.final_ex().map_err(|e| e.to_string())?);
2560                Ok(out)
2561            }
2562            other => Err(format!("subtle.encrypt: unsupported algorithm {}", other)),
2563        }
2564    })();
2565
2566    match result {
2567        Ok(bytes) => {
2568            let v = bytes_to_arraybuffer_val(cx, &bytes);
2569            subtle_resolve(cx, promise_root.get(), v);
2570        }
2571        Err(msg) => subtle_reject(cx, promise_root.get(), &format!("subtle.encrypt: {}", msg)),
2572    }
2573    true
2574}
2575
2576#[allow(unsafe_op_in_unsafe_fn)]
2577unsafe extern "C" fn subtle_decrypt(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
2578    let args = CallArgs::from_vp(vp, argc);
2579    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
2580    let cx_ref = &mut wrapped_cx;
2581    let promise = subtle_promise!(cx, cx_ref, &args);
2582    if promise.is_null() {
2583        return true;
2584    }
2585    rooted!(&in(cx_ref) let promise_root = promise);
2586
2587    let result: ::std::result::Result<Vec<u8>, String> = (|| {
2588        if argc < 3 {
2589            return Err("decrypt(algorithm, key, data) requires 3 arguments".to_string());
2590        }
2591        let (name, alg_obj) = subtle_algo_name(cx, *args.get(0).ptr)?;
2592        let key_obj = subtle_arg_object(*args.get(1).ptr)
2593            .ok_or("decrypt: key must be a CryptoKey".to_string())?;
2594        let data = subtle_bytes(cx, *args.get(2).ptr);
2595        let raw = key_material(cx, key_obj, "_raw")
2596            .ok_or("decrypt: not a symmetric CryptoKey".to_string())?;
2597
2598        match name.as_str() {
2599            "AES-GCM" => {
2600                let iv = subtle_bytes_prop(cx, alg_obj, "iv")
2601                    .ok_or("AES-GCM requires an iv".to_string())?;
2602                let aad = subtle_bytes_prop(cx, alg_obj, "additionalData");
2603                let tag_len = subtle_u32_prop(cx, alg_obj, "tagLength")
2604                    .map_or(16usize, |bits| bits as usize / 8);
2605                if tag_len != 16 {
2606                    return Err(format!("AES-GCM tagLength {} bits is not supported (128 only)", tag_len * 8));
2607                }
2608                if data.len() < tag_len {
2609                    return Err("AES-GCM ciphertext shorter than the auth tag".to_string());
2610                }
2611                let split = data.len() - tag_len;
2612                let algo = aes_cipher_algo("AES-GCM", raw.len())?;
2613                bao_crypto::cipher::decrypt(
2614                    algo,
2615                    &raw,
2616                    &iv,
2617                    aad.as_deref(),
2618                    &data[..split],
2619                    &data[split..],
2620                )
2621                .map_err(|_| "decryption failed (authentication or parameters)".to_string())
2622            }
2623            "AES-CBC" => {
2624                let iv = subtle_bytes_prop(cx, alg_obj, "iv")
2625                    .ok_or("AES-CBC requires an iv".to_string())?;
2626                let algo = aes_cipher_algo("AES-CBC", raw.len())?;
2627                let mut ctx = bao_crypto::cipher::CipherCtx::new(
2628                    algo,
2629                    &raw,
2630                    &iv,
2631                    bao_crypto::cipher::Direction::Decrypt,
2632                )
2633                .map_err(|e| e.to_string())?;
2634                let mut out = ctx.update(&data).map_err(|e| e.to_string())?;
2635                out.extend_from_slice(&ctx.final_ex().map_err(|e| e.to_string())?);
2636                Ok(out)
2637            }
2638            other => Err(format!("subtle.decrypt: unsupported algorithm {}", other)),
2639        }
2640    })();
2641
2642    match result {
2643        Ok(bytes) => {
2644            let v = bytes_to_arraybuffer_val(cx, &bytes);
2645            subtle_resolve(cx, promise_root.get(), v);
2646        }
2647        Err(msg) => subtle_reject(cx, promise_root.get(), &format!("subtle.decrypt: {}", msg)),
2648    }
2649    true
2650}
2651
2652// ── subtle.generateKey ──────────────────────────────────────────────────────
2653
2654#[allow(unsafe_op_in_unsafe_fn)]
2655unsafe extern "C" fn subtle_generate_key(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
2656    let args = CallArgs::from_vp(vp, argc);
2657    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
2658    let cx_ref = &mut wrapped_cx;
2659    let promise = subtle_promise!(cx, cx_ref, &args);
2660    if promise.is_null() {
2661        return true;
2662    }
2663    rooted!(&in(cx_ref) let promise_root = promise);
2664
2665    let result: ::std::result::Result<*mut JSObject, String> = (|| {
2666        if argc < 3 {
2667            return Err("generateKey(algorithm, extractable, usages) requires 3 arguments".to_string());
2668        }
2669        let (name, alg_obj) = subtle_algo_name(cx, *args.get(0).ptr)?;
2670        let extractable = (*args.get(1).ptr).to_boolean();
2671        let usages = subtle_arg_object(*args.get(2).ptr)
2672            .ok_or("generateKey: usages must be an array".to_string())?;
2673
2674        match name.as_str() {
2675            "AES-GCM" | "AES-CBC" => {
2676                let bits = subtle_u32_prop(cx, alg_obj, "length")
2677                    .ok_or("AES generateKey requires algorithm.length".to_string())?;
2678                if !matches!(bits, 128 | 192 | 256) {
2679                    return Err(format!("invalid AES key length {} (128/192/256)", bits));
2680                }
2681                let mut raw = vec![0u8; bits as usize / 8];
2682                bao_crypto::random::rand_bytes(&mut raw).map_err(|e| e.to_string())?;
2683                Ok(make_crypto_key(
2684                    cx,
2685                    "secret",
2686                    &name,
2687                    &[("length", bits.to_string())],
2688                    extractable,
2689                    usages,
2690                    "_raw",
2691                    &raw,
2692                ))
2693            }
2694            "RSA-RSASSA-PKCS1-v1_5" => {
2695                let bits = subtle_u32_prop(cx, alg_obj, "modulusLength")
2696                    .ok_or("RSA generateKey requires modulusLength".to_string())? as usize;
2697                let hash = subtle_str_prop(cx, alg_obj, "hash")
2698                    .or_else(|| subtle_str_prop(cx, alg_obj, "hash.name"))
2699                    .unwrap_or_else(|| "SHA-256".to_string());
2700                let kp = bao_crypto::keypair::generate_key_pair(&bao_crypto::keypair::KeyPairType::Rsa { bits })
2701                    .map_err(|e| e.to_string())?;
2702                Ok(make_crypto_key_pair(
2703                    cx,
2704                    &name,
2705                    &[("hash", hash)],
2706                    extractable,
2707                    usages,
2708                    &kp.private_key_der,
2709                    &kp.public_key_der,
2710                ))
2711            }
2712            "ECDSA" => {
2713                let curve = subtle_str_prop(cx, alg_obj, "namedCurve")
2714                    .or_else(|| subtle_str_prop(cx, alg_obj, "namedCurve.name"))
2715                    .ok_or("ECDSA generateKey requires namedCurve".to_string())?;
2716                let ec_curve = match curve.as_str() {
2717                    "P-256" => bao_crypto::keypair::EcCurve::P256,
2718                    "P-384" => bao_crypto::keypair::EcCurve::P384,
2719                    other => return Err(format!("unsupported ECDSA curve {}", other)),
2720                };
2721                let kp = bao_crypto::keypair::generate_key_pair(&bao_crypto::keypair::KeyPairType::Ec { curve: ec_curve })
2722                    .map_err(|e| e.to_string())?;
2723                Ok(make_crypto_key_pair(
2724                    cx,
2725                    &name,
2726                    &[("namedCurve", curve)],
2727                    extractable,
2728                    usages,
2729                    &kp.private_key_der,
2730                    &kp.public_key_der,
2731                ))
2732            }
2733            other => Err(format!("subtle.generateKey: unsupported algorithm {}", other)),
2734        }
2735    })();
2736
2737    match result {
2738        Ok(key) if !key.is_null() => {
2739            let v = ObjectValue(key);
2740            subtle_resolve(cx, promise_root.get(), v);
2741        }
2742        Ok(_) => subtle_reject(cx, promise_root.get(), "subtle.generateKey: key construction failed"),
2743        Err(msg) => subtle_reject(cx, promise_root.get(), &format!("subtle.generateKey: {}", msg)),
2744    }
2745    true
2746}
2747
2748// ── subtle.importKey ────────────────────────────────────────────────────────
2749
2750#[allow(unsafe_op_in_unsafe_fn)]
2751unsafe extern "C" fn subtle_import_key(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
2752    let args = CallArgs::from_vp(vp, argc);
2753    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
2754    let cx_ref = &mut wrapped_cx;
2755    let promise = subtle_promise!(cx, cx_ref, &args);
2756    if promise.is_null() {
2757        return true;
2758    }
2759    rooted!(&in(cx_ref) let promise_root = promise);
2760
2761    let result: ::std::result::Result<*mut JSObject, String> = (|| {
2762        if argc < 5 {
2763            return Err("importKey(format, keyData, algorithm, extractable, usages) requires 5 arguments".to_string());
2764        }
2765        let format = crate::js_to_rust_string(cx, *args.get(0).ptr);
2766        let (name, _alg_obj) = subtle_algo_name(cx, *args.get(2).ptr)?;
2767        let extractable = (*args.get(3).ptr).to_boolean();
2768        let usages = subtle_arg_object(*args.get(4).ptr)
2769            .ok_or("importKey: usages must be an array".to_string())?;
2770
2771        match format.as_str() {
2772            "raw" => {
2773                let raw = subtle_bytes(cx, *args.get(1).ptr);
2774                if raw.is_empty() {
2775                    return Err("raw import requires key bytes".to_string());
2776                }
2777                match name.as_str() {
2778                    "AES-GCM" | "AES-CBC" => {
2779                        if !matches!(raw.len() * 8, 128 | 192 | 256) {
2780                            return Err(format!("invalid AES key length {} bits", raw.len() * 8));
2781                        }
2782                        Ok(make_crypto_key(cx, "secret", &name, &[("length", (raw.len() * 8).to_string())], extractable, usages, "_raw", &raw))
2783                    }
2784                    "HMAC" => {
2785                        let hash = subtle_str_prop(cx, _alg_obj, "hash")
2786                            .or_else(|| subtle_str_prop(cx, _alg_obj, "hash.name"))
2787                            .ok_or("HMAC import requires algorithm.hash".to_string())?;
2788                        Ok(make_crypto_key(cx, "secret", "HMAC", &[("hash", hash)], extractable, usages, "_raw", &raw))
2789                    }
2790                    other => Err(format!("subtle.importKey raw: unsupported algorithm {}", other)),
2791                }
2792            }
2793            "pkcs8" => {
2794                let der = subtle_bytes(cx, *args.get(1).ptr);
2795                if der.is_empty() {
2796                    return Err("pkcs8 import requires DER bytes".to_string());
2797                }
2798                let extras: Vec<(String, String)> = match name.as_str() {
2799                    "RSA-RSASSA-PKCS1-v1_5" => {
2800                        let hash = subtle_str_prop(cx, _alg_obj, "hash")
2801                            .or_else(|| subtle_str_prop(cx, _alg_obj, "hash.name"))
2802                            .unwrap_or_else(|| "SHA-256".to_string());
2803                        vec![("hash".to_string(), hash)]
2804                    }
2805                    "ECDSA" => {
2806                        let curve = subtle_str_prop(cx, _alg_obj, "namedCurve")
2807                            .or_else(|| subtle_str_prop(cx, _alg_obj, "namedCurve.name"))
2808                            .unwrap_or_else(|| "P-256".to_string());
2809                        vec![("namedCurve".to_string(), curve)]
2810                    }
2811                    other => return Err(format!("subtle.importKey pkcs8: unsupported algorithm {}", other)),
2812                };
2813                let extras_ref: Vec<(&str, String)> = extras.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
2814                Ok(make_crypto_key(cx, "private", &name, &extras_ref, extractable, usages, "_der", &der))
2815            }
2816            "spki" => {
2817                let der = subtle_bytes(cx, *args.get(1).ptr);
2818                if der.is_empty() {
2819                    return Err("spki import requires DER bytes".to_string());
2820                }
2821                let extras: Vec<(String, String)> = match name.as_str() {
2822                    "RSA-RSASSA-PKCS1-v1_5" => {
2823                        let hash = subtle_str_prop(cx, _alg_obj, "hash")
2824                            .or_else(|| subtle_str_prop(cx, _alg_obj, "hash.name"))
2825                            .unwrap_or_else(|| "SHA-256".to_string());
2826                        vec![("hash".to_string(), hash)]
2827                    }
2828                    "ECDSA" => {
2829                        let curve = subtle_str_prop(cx, _alg_obj, "namedCurve")
2830                            .or_else(|| subtle_str_prop(cx, _alg_obj, "namedCurve.name"))
2831                            .unwrap_or_else(|| "P-256".to_string());
2832                        vec![("namedCurve".to_string(), curve)]
2833                    }
2834                    other => return Err(format!("subtle.importKey spki: unsupported algorithm {}", other)),
2835                };
2836                let extras_ref: Vec<(&str, String)> = extras.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
2837                Ok(make_crypto_key(cx, "public", &name, &extras_ref, extractable, usages, "_der", &der))
2838            }
2839            "jwk" => {
2840                // Symmetric oct keys: { kty: "oct", k: <base64url> }. Asymmetric
2841                // JWK import requires JWK→DER assembly which is NOT wired —
2842                // explicit NotSupported error, never a silent fallback.
2843                if !(*args.get(1).ptr).is_object() {
2844                    return Err("jwk import requires a JWK object".to_string());
2845                }
2846                let jwk = (*args.get(1).ptr).to_object();
2847                let kty = subtle_str_prop(cx, jwk, "kty").unwrap_or_default();
2848                if kty != "oct" {
2849                    return Err(format!("jwk import for kty \"{}\" is not supported (oct only); use pkcs8/spki DER import", kty));
2850                }
2851                let k = subtle_str_prop(cx, jwk, "k").ok_or("oct JWK requires the k field".to_string())?;
2852                let src = k.as_bytes();
2853                let upper = bun_base64::decode_lenient_len(src.len());
2854                let mut out = vec![0u8; upper];
2855                let n = bun_base64::decode_lenient(&mut out, src, true);
2856                out.truncate(n);
2857                if out.is_empty() {
2858                    return Err("oct JWK k field decoded to zero bytes".to_string());
2859                }
2860                match name.as_str() {
2861                    "AES-GCM" | "AES-CBC" => {
2862                        if !matches!(out.len() * 8, 128 | 192 | 256) {
2863                            return Err(format!("invalid AES key length {} bits from JWK", out.len() * 8));
2864                        }
2865                        Ok(make_crypto_key(cx, "secret", &name, &[("length", (out.len() * 8).to_string())], extractable, usages, "_raw", &out))
2866                    }
2867                    "HMAC" => {
2868                        let hash = subtle_str_prop(cx, _alg_obj, "hash")
2869                            .or_else(|| subtle_str_prop(cx, _alg_obj, "hash.name"))
2870                            .ok_or("HMAC import requires algorithm.hash".to_string())?;
2871                        Ok(make_crypto_key(cx, "secret", "HMAC", &[("hash", hash)], extractable, usages, "_raw", &out))
2872                    }
2873                    other => Err(format!("subtle.importKey jwk: unsupported algorithm {}", other)),
2874                }
2875            }
2876            other => Err(format!("subtle.importKey: unsupported format {}", other)),
2877        }
2878    })();
2879
2880    match result {
2881        Ok(key) if !key.is_null() => {
2882            let v = ObjectValue(key);
2883            subtle_resolve(cx, promise_root.get(), v);
2884        }
2885        Ok(_) => subtle_reject(cx, promise_root.get(), "subtle.importKey: key construction failed"),
2886        Err(msg) => subtle_reject(cx, promise_root.get(), &format!("subtle.importKey: {}", msg)),
2887    }
2888    true
2889}
2890
2891// ── subtle.sign / subtle.verify ─────────────────────────────────────────────
2892
2893fn subtle_hmac_algorithm(hash: &str) -> ::std::result::Result<bun_sha_hmac::sha::evp::Algorithm, String> {
2894    use bun_sha_hmac::sha::evp::Algorithm;
2895    Ok(match hash.to_uppercase().as_str() {
2896        "SHA-1" => Algorithm::Sha1,
2897        "SHA-224" => Algorithm::Sha224,
2898        "SHA-256" => Algorithm::Sha256,
2899        "SHA-384" => Algorithm::Sha384,
2900        "SHA-512" => Algorithm::Sha512,
2901        other => return Err(format!("unsupported HMAC hash {}", other)),
2902    })
2903}
2904
2905fn subtle_rsa_hash(hash: &str) -> ::std::result::Result<bao_crypto::sign::RsaHash, String> {
2906    Ok(match hash.to_uppercase().as_str() {
2907        "SHA-256" => bao_crypto::sign::RsaHash::Sha256,
2908        "SHA-384" => bao_crypto::sign::RsaHash::Sha384,
2909        "SHA-512" => bao_crypto::sign::RsaHash::Sha512,
2910        other => return Err(format!("unsupported RSA hash {}", other)),
2911    })
2912}
2913
2914#[allow(unsafe_op_in_unsafe_fn)]
2915unsafe extern "C" fn subtle_sign(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
2916    let args = CallArgs::from_vp(vp, argc);
2917    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
2918    let cx_ref = &mut wrapped_cx;
2919    let promise = subtle_promise!(cx, cx_ref, &args);
2920    if promise.is_null() {
2921        return true;
2922    }
2923    rooted!(&in(cx_ref) let promise_root = promise);
2924
2925    let result: ::std::result::Result<Vec<u8>, String> = (|| {
2926        if argc < 3 {
2927            return Err("sign(algorithm, key, data) requires 3 arguments".to_string());
2928        }
2929        let (name, alg_obj) = subtle_algo_name(cx, *args.get(0).ptr)?;
2930        let key_obj = subtle_arg_object(*args.get(1).ptr)
2931            .ok_or("sign: key must be a CryptoKey".to_string())?;
2932        // BCE-root discipline: the handle must outlive the GetProperty call —
2933        // a rooted! binding inside a block expression un-roots at the block's
2934        // closing brace, leaving JS_GetProperty with a dangling Handle.
2935        rooted!(&in(cx_ref) let key_obj_root = key_obj);
2936        let data = subtle_bytes(cx, *args.get(2).ptr);
2937
2938        let key_alg = {
2939            let mut v = UndefinedValue();
2940            JS_GetProperty(
2941                cx,
2942                key_obj_root.handle().into(),
2943                c"algorithm".as_ptr(),
2944                MutableHandle::<Value> {
2945                    _phantom_0: ::std::marker::PhantomData,
2946                    ptr: &mut v,
2947                },
2948            );
2949            if !v.is_object() {
2950                return Err("sign: not a CryptoKey".to_string());
2951            }
2952            v.to_object()
2953        };
2954        let ktype = subtle_str_prop(cx, key_obj, "type").unwrap_or_default();
2955
2956        match name.as_str() {
2957            "HMAC" => {
2958                let raw = key_material(cx, key_obj, "_raw")
2959                    .ok_or("sign: not a symmetric CryptoKey".to_string())?;
2960                // The hash is a property of the KEY's algorithm (what the key
2961                // was imported with); an explicit hash on the algorithm
2962                // argument (object form only — the string form carries no
2963                // params) wins, mirroring the object-form behavior.
2964                let hash = subtle_str_prop(cx, alg_obj, "hash")
2965                    .or_else(|| subtle_str_prop(cx, key_alg, "hash"))
2966                    .unwrap_or_else(|| "SHA-256".to_string());
2967                let algo = subtle_hmac_algorithm(&hash)?;
2968                let mut out = [0u8; bun_sha_hmac::hmac::EVP_MAX_MD_SIZE];
2969                let mac = bun_sha_hmac::hmac::generate(&raw, &data, algo, &mut out)
2970                    .ok_or("HMAC computation failed".to_string())?;
2971                Ok(mac.to_vec())
2972            }
2973            "RSA-RSASSA-PKCS1-v1_5" | "RSASSA-PKCS1-v1_5" => {
2974                if ktype != "private" {
2975                    return Err("sign: not a private CryptoKey".to_string());
2976                }
2977                let der = key_material(cx, key_obj, "_der")
2978                    .ok_or("sign: not a private CryptoKey".to_string())?;
2979                let hash = subtle_str_prop(cx, key_alg, "hash")
2980                    .unwrap_or_else(|| "SHA-256".to_string());
2981                let signer = bao_crypto::sign::Signer::from_pkcs8_der(
2982                    &bao_crypto::sign::SignAlgorithm::RsaPkcs1v15 { hash: subtle_rsa_hash(&hash)? },
2983                    &der,
2984                )
2985                .map_err(|e| e.to_string())?;
2986                signer
2987                    .sign(&data, bao_crypto::sign::SignatureFormat::Der)
2988                    .map_err(|e| e.to_string())
2989            }
2990            "ECDSA" => {
2991                if ktype != "private" {
2992                    return Err("sign: not a private CryptoKey".to_string());
2993                }
2994                let der = key_material(cx, key_obj, "_der")
2995                    .ok_or("sign: not a private CryptoKey".to_string())?;
2996                let curve = subtle_str_prop(cx, key_alg, "namedCurve").unwrap_or_else(|| "P-256".to_string());
2997                let algo = match curve.as_str() {
2998                    "P-256" => bao_crypto::sign::SignAlgorithm::EcdsaP256,
2999                    "P-384" => bao_crypto::sign::SignAlgorithm::EcdsaP384,
3000                    other => return Err(format!("unsupported ECDSA curve {}", other)),
3001                };
3002                let signer = bao_crypto::sign::Signer::from_pkcs8_der(&algo, &der)
3003                    .map_err(|e| e.to_string())?;
3004                // WebCrypto ECDSA signatures are raw r||s.
3005                signer
3006                    .sign(&data, bao_crypto::sign::SignatureFormat::Raw)
3007                    .map_err(|e| e.to_string())
3008            }
3009            other => Err(format!("subtle.sign: unsupported algorithm {}", other)),
3010        }
3011    })();
3012
3013    match result {
3014        Ok(bytes) => {
3015            let v = bytes_to_arraybuffer_val(cx, &bytes);
3016            subtle_resolve(cx, promise_root.get(), v);
3017        }
3018        Err(msg) => subtle_reject(cx, promise_root.get(), &format!("subtle.sign: {}", msg)),
3019    }
3020    true
3021}
3022
3023#[allow(unsafe_op_in_unsafe_fn)]
3024unsafe extern "C" fn subtle_verify(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
3025    let args = CallArgs::from_vp(vp, argc);
3026    let mut wrapped_cx = mozjs::context::JSContext::from_ptr(NonNull::new_unchecked(cx));
3027    let cx_ref = &mut wrapped_cx;
3028    let promise = subtle_promise!(cx, cx_ref, &args);
3029    if promise.is_null() {
3030        return true;
3031    }
3032    rooted!(&in(cx_ref) let promise_root = promise);
3033
3034    let result: ::std::result::Result<bool, String> = (|| {
3035        if argc < 4 {
3036            return Err("verify(algorithm, key, signature, data) requires 4 arguments".to_string());
3037        }
3038        let (name, _alg_obj) = subtle_algo_name(cx, *args.get(0).ptr)?;
3039        let key_obj = subtle_arg_object(*args.get(1).ptr)
3040            .ok_or("verify: key must be a CryptoKey".to_string())?;
3041        // Same root discipline as subtle_sign — no block-scoped roots in args.
3042        rooted!(&in(cx_ref) let key_obj_root = key_obj);
3043        let signature = subtle_bytes(cx, *args.get(2).ptr);
3044        let data = subtle_bytes(cx, *args.get(3).ptr);
3045
3046        let key_alg = {
3047            let mut v = UndefinedValue();
3048            JS_GetProperty(
3049                cx,
3050                key_obj_root.handle().into(),
3051                c"algorithm".as_ptr(),
3052                MutableHandle::<Value> {
3053                    _phantom_0: ::std::marker::PhantomData,
3054                    ptr: &mut v,
3055                },
3056            );
3057            if !v.is_object() {
3058                return Err("verify: not a CryptoKey".to_string());
3059            }
3060            v.to_object()
3061        };
3062        let ktype = subtle_str_prop(cx, key_obj, "type").unwrap_or_default();
3063
3064        match name.as_str() {
3065            "HMAC" => {
3066                let raw = key_material(cx, key_obj, "_raw")
3067                    .ok_or("verify: not a symmetric CryptoKey".to_string())?;
3068                let hash = subtle_str_prop(cx, key_alg, "hash").unwrap_or_else(|| "SHA-256".to_string());
3069                let algo = subtle_hmac_algorithm(&hash)?;
3070                let mut out = [0u8; bun_sha_hmac::hmac::EVP_MAX_MD_SIZE];
3071                let Some(mac) = bun_sha_hmac::hmac::generate(&raw, &data, algo, &mut out) else {
3072                    return Err("HMAC computation failed".to_string());
3073                };
3074                Ok(ct_eq(mac, &signature))
3075            }
3076            "RSA-RSASSA-PKCS1-v1_5" | "RSASSA-PKCS1-v1_5" => {
3077                let hash = subtle_str_prop(cx, key_alg, "hash").unwrap_or_else(|| "SHA-256".to_string());
3078                let algo = bao_crypto::sign::SignAlgorithm::RsaPkcs1v15 { hash: subtle_rsa_hash(&hash)? };
3079                let verifier = if ktype == "private" {
3080                    let der = key_material(cx, key_obj, "_der")
3081                        .ok_or("verify: key carries no DER material".to_string())?;
3082                    bao_crypto::verify::Verifier::from_pkcs8_der(&algo, &der)
3083                } else {
3084                    let der = key_material(cx, key_obj, "_der")
3085                        .ok_or("verify: key carries no DER material".to_string())?;
3086                    bao_crypto::verify::Verifier::from_public_der(&algo, &der)
3087                }
3088                .map_err(|e| e.to_string())?;
3089                verifier
3090                    .verify(&data, &signature, bao_crypto::sign::SignatureFormat::Der)
3091                    .map_err(|e| e.to_string())
3092            }
3093            "ECDSA" => {
3094                let curve = subtle_str_prop(cx, key_alg, "namedCurve").unwrap_or_else(|| "P-256".to_string());
3095                let algo = match curve.as_str() {
3096                    "P-256" => bao_crypto::sign::SignAlgorithm::EcdsaP256,
3097                    "P-384" => bao_crypto::sign::SignAlgorithm::EcdsaP384,
3098                    other => return Err(format!("unsupported ECDSA curve {}", other)),
3099                };
3100                let verifier = if ktype == "private" {
3101                    let der = key_material(cx, key_obj, "_der")
3102                        .ok_or("verify: key carries no DER material".to_string())?;
3103                    bao_crypto::verify::Verifier::from_pkcs8_der(&algo, &der)
3104                } else {
3105                    let der = key_material(cx, key_obj, "_der")
3106                        .ok_or("verify: key carries no DER material".to_string())?;
3107                    bao_crypto::verify::Verifier::from_public_der(&algo, &der)
3108                }
3109                .map_err(|e| e.to_string())?;
3110                // WebCrypto ECDSA signatures are raw r||s.
3111                verifier
3112                    .verify(&data, &signature, bao_crypto::sign::SignatureFormat::Raw)
3113                    .map_err(|e| e.to_string())
3114            }
3115            other => Err(format!("subtle.verify: unsupported algorithm {}", other)),
3116        }
3117    })();
3118
3119    match result {
3120        Ok(ok) => {
3121            let v = BooleanValue(ok);
3122            subtle_resolve(cx, promise_root.get(), v);
3123        }
3124        Err(msg) => subtle_reject(cx, promise_root.get(), &format!("subtle.verify: {}", msg)),
3125    }
3126    true
3127}
3128
3129// ═══════════════════════════════════════════════════════════════════════════
3130// localStorage — CLI mode. Persisted store at ~/.bao/localstorage.json
3131// (browser pages keep servo's own localStorage; this is the CLI surface).
3132// @trace REQ-ENG-006 [api:localStorage]
3133// ═══════════════════════════════════════════════════════════════════════════
3134
3135fn localstorage_path() -> ::std::path::PathBuf {
3136    let home = ::std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
3137    let dir = ::std::path::Path::new(&home).join(".bao");
3138    let _ = ::std::fs::create_dir_all(&dir);
3139    dir.join("localstorage.json")
3140}
3141
3142::std::thread_local! {
3143    static LS_STORE: RefCell<Option<::std::collections::BTreeMap<String, String>>> =
3144        const { RefCell::new(None) };
3145}
3146
3147/// Lazy-load the persisted store (missing/corrupt file → empty map; a corrupt
3148/// file is reported to stderr but never breaks the API — next mutation
3149/// rewrites the file with valid JSON).
3150fn ls_with_store<R>(f: impl FnOnce(&mut ::std::collections::BTreeMap<String, String>) -> R) -> R {
3151    LS_STORE.with(|cell| {
3152        let mut guard = cell.borrow_mut();
3153        if guard.is_none() {
3154            let map = ::std::fs::read_to_string(localstorage_path())
3155                .ok()
3156                .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
3157                .and_then(|v| {
3158                    let obj = v.as_object()?;
3159                    let mut m = ::std::collections::BTreeMap::new();
3160                    for (k, val) in obj {
3161                        if let Some(s) = val.as_str() {
3162                            m.insert(k.clone(), s.to_string());
3163                        }
3164                    }
3165                    Some(m)
3166                })
3167                .unwrap_or_default();
3168            *guard = Some(map);
3169        }
3170        f(guard.as_mut().unwrap())
3171    })
3172}
3173
3174fn ls_persist() {
3175    LS_STORE.with(|cell| {
3176        if let Some(map) = cell.borrow().as_ref() {
3177            if let Ok(text) = serde_json::to_string_pretty(map) {
3178                let tmp = localstorage_path().with_extension("json.tmp");
3179                if ::std::fs::write(&tmp, text).is_ok() {
3180                    let _ = ::std::fs::rename(&tmp, localstorage_path());
3181                }
3182            }
3183        }
3184    });
3185}
3186
3187/// Install localStorage on the global (CLI surface; browser pages keep
3188/// servo's own implementation, so installation only fills a missing slot).
3189pub fn install_local_storage(cx: &mut mozjs::context::JSContext, global: mozjs::rust::Handle<*mut JSObject>) {
3190    unsafe {
3191        rooted!(&in(cx) let global_root = global.get());
3192        let mut existing = UndefinedValue();
3193        let found = JS_GetProperty(
3194            cx.raw_cx(),
3195            global_root.handle().into(),
3196            c"localStorage".as_ptr(),
3197            MutableHandle::<Value> {
3198                _phantom_0: ::std::marker::PhantomData,
3199                ptr: &mut existing,
3200            },
3201        );
3202        // BCE (browser startup panic, servo error.rs:74): the probe's failure
3203        // return MUST be consumed. On an opaque-origin page (data:/about:
3204        // blank) servo's `window.localStorage` getter THROWS SecurityError —
3205        // JS_GetProperty returns false with the exception pending. The old
3206        // code ignored the return, leaving the pending exception on the
3207        // context; the next servo error path (`throw_dom_exception`) then
3208        // tripped `assert!(!JS_IsExceptionPending)` and killed Script#1 at
3209        // startup (pipeline never ready, CDP never listening). This is a
3210        // Rust-side capability probe, so a throwing getter means "servo owns
3211        // storage here, possibly origin-blocked" — clear the exception and
3212        // leave the page's storage semantics to servo.
3213        if !found {
3214            JS_ClearPendingException(cx.raw_cx());
3215            return; // browser page context — servo's storage stays
3216        }
3217        if existing.is_object() {
3218            return; // browser page context — servo's storage stays
3219        }
3220        rooted!(&in(cx) let ls = JS_NewPlainObject(cx));
3221        if ls.get().is_null() {
3222            return;
3223        }
3224        let lh = ls.handle();
3225        for (name, op, nargs) in [
3226            ("getItem", ls_get_item as unsafe extern "C" fn(*mut JSContext, u32, *mut JSVal) -> bool, 1),
3227            ("setItem", ls_set_item, 2),
3228            ("removeItem", ls_remove_item, 1),
3229            ("clear", ls_clear, 0),
3230            ("key", ls_key, 1),
3231        ] {
3232            JS_DefineFunction(
3233                cx,
3234                lh,
3235                ZBox::from_bytes(name.as_bytes()).as_ptr(),
3236                Some(op),
3237                nargs,
3238                JSPROP_ENUMERATE as u32,
3239            );
3240        }
3241        // length getter (JS_DefineProperty1: getter/setter native variant)
3242        JS_DefineProperty1(
3243            cx.raw_cx(),
3244            ls.handle().into(),
3245            c"length".as_ptr(),
3246            Some(ls_length_getter),
3247            None,
3248            (JSPROP_ENUMERATE | JSPROP_READONLY) as u32,
3249        );
3250        rooted!(&in(cx) let ls_root = ls.get());
3251        JS_DefineProperty3(
3252            cx,
3253            global,
3254            c"localStorage".as_ptr(),
3255            ls_root.handle(),
3256            (JSPROP_ENUMERATE | JSPROP_PERMANENT) as u32,
3257        );
3258    }
3259}
3260
3261#[allow(unsafe_op_in_unsafe_fn)]
3262unsafe extern "C" fn ls_get_item(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
3263    let args = CallArgs::from_vp(vp, argc);
3264    if argc < 1 || !(*args.get(0).ptr).is_string() {
3265        args.rval().set(UndefinedValue());
3266        return true;
3267    }
3268    let key = crate::js_to_rust_string(cx, *args.get(0).ptr);
3269    ls_with_store(|m| {
3270        let v = m.get(&key).cloned();
3271        match v {
3272            Some(s) => {
3273                let c_s = ZBox::from_bytes(s.as_bytes());
3274                let js = JS_NewStringCopyZ(cx, c_s.as_ptr());
3275                args.rval().set(if js.is_null() {
3276                    UndefinedValue()
3277                } else {
3278                    StringValue(&*js)
3279                });
3280            }
3281            None => args.rval().set(NullValue()),
3282        }
3283    });
3284    true
3285}
3286
3287#[allow(unsafe_op_in_unsafe_fn)]
3288unsafe extern "C" fn ls_set_item(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
3289    let args = CallArgs::from_vp(vp, argc);
3290    if argc < 2 {
3291        let c_m = ZBox::from_bytes("localStorage.setItem(key, value) requires 2 arguments".as_bytes());
3292        JS_ReportErrorUTF8(cx, c"%s".as_ptr(), c_m.as_ptr());
3293        return false;
3294    }
3295    let key = crate::js_to_rust_string(cx, *args.get(0).ptr);
3296    let val = crate::js_to_rust_string(cx, *args.get(1).ptr);
3297    ls_with_store(|m| {
3298        m.insert(key, val);
3299    });
3300    ls_persist();
3301    args.rval().set(UndefinedValue());
3302    true
3303}
3304
3305#[allow(unsafe_op_in_unsafe_fn)]
3306unsafe extern "C" fn ls_remove_item(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
3307    let args = CallArgs::from_vp(vp, argc);
3308    if argc < 1 {
3309        args.rval().set(UndefinedValue());
3310        return true;
3311    }
3312    let key = crate::js_to_rust_string(cx, *args.get(0).ptr);
3313    ls_with_store(|m| {
3314        m.remove(&key);
3315    });
3316    ls_persist();
3317    args.rval().set(UndefinedValue());
3318    true
3319}
3320
3321#[allow(unsafe_op_in_unsafe_fn)]
3322unsafe extern "C" fn ls_clear(_cx: *mut JSContext, _argc: u32, vp: *mut JSVal) -> bool {
3323    let args = CallArgs::from_vp(vp, _argc);
3324    ls_with_store(|m| {
3325        m.clear();
3326    });
3327    ls_persist();
3328    args.rval().set(UndefinedValue());
3329    true
3330}
3331
3332#[allow(unsafe_op_in_unsafe_fn)]
3333unsafe extern "C" fn ls_key(cx: *mut JSContext, argc: u32, vp: *mut JSVal) -> bool {
3334    let args = CallArgs::from_vp(vp, argc);
3335    if argc < 1 || !(*args.get(0).ptr).is_int32() {
3336        args.rval().set(NullValue());
3337        return true;
3338    }
3339    let idx = (*args.get(0).ptr).to_int32();
3340    if idx < 0 {
3341        args.rval().set(NullValue());
3342        return true;
3343    }
3344    let val = ls_with_store(|m| {
3345        m.keys().nth(idx as usize).cloned()
3346    });
3347    match val {
3348        Some(k) => {
3349            let c_k = ZBox::from_bytes(k.as_bytes());
3350            let js = JS_NewStringCopyZ(cx, c_k.as_ptr());
3351            args.rval().set(if js.is_null() {
3352                NullValue()
3353            } else {
3354                StringValue(&*js)
3355            });
3356        }
3357        None => args.rval().set(NullValue()),
3358    }
3359    true
3360}
3361
3362#[allow(unsafe_op_in_unsafe_fn)]
3363unsafe extern "C" fn ls_length(_cx: *mut JSContext, _argc: u32, vp: *mut JSVal) -> bool {
3364    let args = CallArgs::from_vp(vp, _argc);
3365    let n = ls_with_store(|m| m.len());
3366    args.rval().set(Int32Value(n as i32));
3367    true
3368}
3369
3370#[allow(unsafe_op_in_unsafe_fn)]
3371unsafe extern "C" fn ls_length_getter(cx: *mut JSContext, _argc: u32, vp: *mut JSVal) -> bool {
3372    ls_length(cx, _argc, vp)
3373}
3374
3375// ═══════════════════════════════════════════════════════════════════════════
3376// EventSource — SSE client over fetch(). Pure-JS class: fetch + TextDecoder
3377// + timers are all real runtime surfaces; the SSE framing/retry state
3378// machine follows the WHATWG spec's event-stream parsing algorithm.
3379//
3380// Streaming note: fetch() currently materialises the full body, so events
3381// arrive in one batch when the response completes (then auto-reconnect fires
3382// per the retry interval). The parser itself is the real spec algorithm and
3383// upgrades unchanged once fetch lands response streaming.
3384// @trace REQ-ENG-006 [api:EventSource]
3385//
3386// Also registers the CLI-mode window/document explicit gates (DOMParser
3387// style): referencing/typeof works, every METHOD call throws the honest
3388// "browser context required" error — no silent empty-DOM fakes.
3389// ═══════════════════════════════════════════════════════════════════════════
3390
3391pub fn install_event_source(cx: &mut mozjs::context::JSContext, global: mozjs::rust::Handle<*mut JSObject>) {
3392    let src = r#"
3393(function() {
3394  var _g = globalThis;
3395
3396  // ── EventSource ──
3397  var CONNECTING = 0, OPEN = 1, CLOSED = 2;
3398
3399  function EventSource(url) {
3400    if (!(this instanceof EventSource)) return new EventSource(url);
3401    this.url = String(url);
3402    this.readyState = CONNECTING;
3403    this._retry = 3000;
3404    this._lastEventId = '';
3405    this._timer = null;
3406    this._listeners = {};
3407    this.onopen = null;
3408    this.onmessage = null;
3409    this.onerror = null;
3410    var self = this;
3411    this._connect();
3412  }
3413  EventSource.CONNECTING = CONNECTING;
3414  EventSource.OPEN = OPEN;
3415  EventSource.CLOSED = CLOSED;
3416
3417  EventSource.prototype._fire = function(type, ev) {
3418    ev = ev || {};
3419    ev.type = type;
3420    var handler = this['on' + type];
3421    if (typeof handler === 'function') handler.call(this, ev);
3422    var list = this._listeners[type];
3423    if (list) {
3424      for (var i = 0; i < list.length; i++) list[i].call(this, ev);
3425    }
3426  };
3427
3428  EventSource.prototype._connect = function() {
3429    var self = this;
3430    if (this.readyState === CLOSED) return;
3431    this.readyState = CONNECTING;
3432    var headers = { 'Accept': 'text/event-stream' };
3433    if (this._lastEventId) headers['Last-Event-ID'] = this._lastEventId;
3434    fetch(this.url, { headers: headers })
3435      .then(function(resp) {
3436        if (!resp.ok) {
3437          self._fail('EventSource: HTTP ' + resp.status + ' for ' + self.url);
3438          return null;
3439        }
3440        // Spec: the response MIME type must be text/event-stream.
3441        var ct = resp.headers && resp.headers.get && resp.headers.get('content-type');
3442        if (ct && String(ct).indexOf('text/event-stream') === -1) {
3443          self._fail('EventSource: response Content-Type "' + ct + '" is not text/event-stream');
3444          return null;
3445        }
3446        self.readyState = OPEN;
3447        self._fire('open');
3448        return resp.text();
3449      })
3450      .then(function(text) {
3451        if (text === null || text === undefined) return;
3452        self._parse(text);
3453        // Server closed the stream — spec: schedule reconnect.
3454        self._scheduleReconnect();
3455      })
3456      .catch(function(err) {
3457        self._fail(err && err.message ? err.message : String(err));
3458      });
3459  };
3460
3461  EventSource.prototype._fail = function(msg) {
3462    if (this.readyState === CLOSED) return;
3463    this._fire('error', { message: msg });
3464    this._scheduleReconnect();
3465  };
3466
3467  EventSource.prototype._scheduleReconnect = function() {
3468    var self = this;
3469    if (this.readyState === CLOSED) return;
3470    this.readyState = CONNECTING;
3471    if (this._timer) clearTimeout(this._timer);
3472    this._timer = setTimeout(function() { self._connect(); }, this._retry);
3473  };
3474
3475  // WHATWG event-stream parsing: lines split on CR/LF/CRLF, fields
3476  // event/data/id/retry, blank line dispatches the accumulated event.
3477  EventSource.prototype._parse = function(text) {
3478    var lines = text.split(/\r\n|\r|\n/);
3479    var dataLines = [], eventName = '', lastId = this._lastEventId;
3480    for (var i = 0; i < lines.length; i++) {
3481      var line = lines[i];
3482      if (line === '') {
3483        if (dataLines.length > 0) {
3484          var ev = { data: dataLines.join('\n'), lastEventId: lastId };
3485          if (eventName !== '' && eventName !== 'message') {
3486            ev.lastEventId = lastId;
3487            this._fire(eventName, ev);
3488          } else {
3489            this._fire('message', ev);
3490          }
3491        }
3492        dataLines = [];
3493        eventName = '';
3494        continue;
3495      }
3496      if (line.charCodeAt(0) === 0x3a /* ':' */) continue; // comment
3497      var colon = line.indexOf(':');
3498      var field, value;
3499      if (colon === -1) { field = line; value = ''; }
3500      else {
3501        field = line.slice(0, colon);
3502        value = line.slice(colon + 1);
3503        if (value.charCodeAt(0) === 0x20 /* ' ' */) value = value.slice(1);
3504      }
3505      if (field === 'event') eventName = value;
3506      else if (field === 'data') dataLines.push(value);
3507      else if (field === 'id') { if (value.indexOf('\0') === -1) lastId = value; }
3508      else if (field === 'retry') {
3509        var n = parseInt(value, 10);
3510        if (!isNaN(n)) this._retry = n;
3511      }
3512    }
3513    this._lastEventId = lastId;
3514    // A trailing non-blank data block without the final blank line is NOT
3515    // dispatched (spec: incomplete event at stream end).
3516  };
3517
3518  EventSource.prototype.close = function() {
3519    if (this._timer) { clearTimeout(this._timer); this._timer = null; }
3520    this.readyState = CLOSED;
3521  };
3522
3523  EventSource.prototype.addEventListener = function(type, cb) {
3524    if (typeof cb !== 'function') return;
3525    if (!this._listeners[type]) this._listeners[type] = [];
3526    this._listeners[type].push(cb);
3527  };
3528  EventSource.prototype.removeEventListener = function(type, cb) {
3529    var list = this._listeners[type];
3530    if (!list) return;
3531    var i = list.indexOf(cb);
3532    if (i !== -1) list.splice(i, 1);
3533  };
3534
3535  _g.EventSource = EventSource;
3536
3537  // ── window/document: CLI leaves them UNDEFINED (team-lead ruling, Node
3538  // parity). An "exists but every property is a throwing placeholder" gate
3539  // is itself the silent-fake shape (feature detection sees a window that
3540  // lies about existing); honest MISSING — typeof window === 'undefined' —
3541  // matches Node and the DOMParser philosophy. Browser pages keep servo's
3542  // real window/document; bare references in CLI throw ReferenceError, which
3543  // IS the explicit signal.
3544})();
3545"#;
3546    unsafe {
3547        let raw = cx.raw_cx();
3548        let mut rval = UndefinedValue();
3549        let opts = mozjs::glue::NewCompileOptions(raw, c"event_source".as_ptr(), 1);
3550        if !opts.is_null() {
3551            let mut src_text = mozjs::rust::transform_str_to_source_text(src);
3552            mozjs_sys::jsapi::JS::Evaluate2(
3553                raw,
3554                opts,
3555                &mut src_text,
3556                MutableHandle::<Value> {
3557                    _phantom_0: ::std::marker::PhantomData,
3558                    ptr: &mut rval,
3559                },
3560            );
3561            libc::free(opts as *mut _);
3562        }
3563        let _ = global;
3564    }
3565}
3566
3567#[cfg(test)]
3568mod tests {
3569    use super::*;
3570
3571    #[test]
3572    fn parse_ws_url_ws() {
3573        let (host, port, path) = split_authority_and_path("example.com/chat", "ws");
3574        assert_eq!(host, "example.com");
3575        assert_eq!(port, 80);
3576        assert_eq!(path, "/chat");
3577    }
3578
3579    // @trace REQ-ENG-006 [api:WebSocket wss://] — wss:// is now supported
3580    // (default port 443); the prior behaviour rejected it outright.
3581    #[test]
3582    fn parse_ws_url_wss_default_port() {
3583        let (host, port, path) = split_authority_and_path("example.com/secure", "wss");
3584        assert_eq!(host, "example.com");
3585        assert_eq!(port, 443);
3586        assert_eq!(path, "/secure");
3587    }
3588
3589    #[test]
3590    fn parse_ws_url_with_port() {
3591        let (host, port, path) = split_authority_and_path("localhost:8080/ws", "ws");
3592        assert_eq!(host, "localhost");
3593        assert_eq!(port, 8080);
3594        assert_eq!(path, "/ws");
3595    }
3596
3597    #[test]
3598    fn parse_ws_url_default_path() {
3599        let (_, _, path) = split_authority_and_path("host/", "ws");
3600        assert_eq!(path, "/");
3601    }
3602
3603    #[test]
3604    fn parse_ws_url_no_path_defaults_to_slash() {
3605        let (_, _, path) = split_authority_and_path("host", "ws");
3606        assert_eq!(path, "/");
3607    }
3608
3609    #[test]
3610    fn parse_ws_url_empty_string() {
3611        let (host, port, path) = split_authority_and_path("", "ws");
3612        assert_eq!(host, "");
3613        assert_eq!(port, 80);
3614        assert_eq!(path, "/");
3615    }
3616
3617    #[test]
3618    fn parse_ws_url_ipv4_with_port() {
3619        let (host, port, path) = split_authority_and_path("127.0.0.1:9222/json", "ws");
3620        assert_eq!(host, "127.0.0.1");
3621        assert_eq!(port, 9222);
3622        assert_eq!(path, "/json");
3623    }
3624
3625    #[test]
3626    fn parse_ws_url_query_string() {
3627        let (host, port, path) = split_authority_and_path("example.com/ws?token=abc", "ws");
3628        assert_eq!(host, "example.com");
3629        assert_eq!(port, 80);
3630        assert!(path.starts_with("/ws"));
3631    }
3632
3633    #[test]
3634    fn parse_ws_url_deep_path() {
3635        let (host, _, path) = split_authority_and_path("host/a/b/c/d", "ws");
3636        assert_eq!(host, "host");
3637        assert_eq!(path, "/a/b/c/d");
3638    }
3639
3640    // @trace REQ-ENG-006 [code:bun_uws] — frame length encoding now shares
3641    // bun_uws::ws_codec's layout via push_masked_len (the client→server masked
3642    // length bytes). The masking key itself is per-frame random in the live
3643    // path, so these unit tests verify only the length-byte shape.
3644    #[test]
3645    fn push_masked_len_empty() {
3646        let mut frame = Vec::new();
3647        push_masked_len(&mut frame, 0);
3648        assert_eq!(frame.len(), 1);
3649        assert_eq!(frame[0] & 0x7F, 0); // length = 0
3650    }
3651
3652    #[test]
3653    fn push_masked_len_short() {
3654        let mut frame = Vec::new();
3655        push_masked_len(&mut frame, 5);
3656        assert_eq!(frame.len(), 1);
3657        assert_eq!(frame[0] & 0x7F, 5); // length = 5
3658    }
3659
3660    #[test]
3661    fn push_masked_len_medium() {
3662        let mut frame = Vec::new();
3663        let payload = vec![0u8; 200];
3664        push_masked_len(&mut frame, payload.len());
3665        // 1 byte + 2 bytes extended length
3666        assert_eq!(frame.len(), 3);
3667        assert_eq!(frame[0] & 0x7F, 126); // 126 signals 16-bit length
3668        let ext_len = u16::from_be_bytes([frame[1], frame[2]]);
3669        assert_eq!(ext_len, 200);
3670    }
3671
3672    #[test]
3673    fn push_masked_len_large() {
3674        let mut frame = Vec::new();
3675        let payload = vec![0u8; 70000];
3676        push_masked_len(&mut frame, payload.len());
3677        // 1 byte + 8 bytes extended length
3678        assert_eq!(frame.len(), 9);
3679        assert_eq!(frame[0] & 0x7F, 127); // 127 signals 64-bit length
3680    }
3681
3682    #[test]
3683    fn ws_message_debug_variants() {
3684        let text = WsMessage::Text("hello".to_string());
3685        let binary = WsMessage::Binary(vec![1, 2, 3]);
3686        let close = WsMessage::Close;
3687        assert!(format!("{:?}", text).contains("Text"));
3688        assert!(format!("{:?}", binary).contains("Binary"));
3689        assert!(format!("{:?}", close).contains("Close"));
3690    }
3691
3692    // Async WS state machine: liveness transitions of a registry entry.
3693    // dead (no client, no slot) → connecting (slot) → open (client) → dead.
3694    #[test]
3695    fn ws_entry_liveness_transitions() {
3696        let connecting = WsEntry {
3697            client: None,
3698            connect_slot: Some(Arc::new(Mutex::new(None))),
3699            close_requested: false,
3700            close_initiated: false,
3701            realm_global: ::std::ptr::null_mut(),
3702            js_obj_key: "ws_test".to_string(),
3703        };
3704        assert!(connecting.is_live(), "connecting entry is live");
3705
3706        let dead = WsEntry {
3707            client: None,
3708            connect_slot: None,
3709            close_requested: false,
3710            close_initiated: false,
3711            realm_global: ::std::ptr::null_mut(),
3712            js_obj_key: "ws_test".to_string(),
3713        };
3714        assert!(
3715            !dead.is_live(),
3716            "dead entry (post-close/failure) is not live"
3717        );
3718    }
3719
3720    // Stealth plumbing: the wss path consumes the exact same profile source
3721    // fetch() uses. `get_fetch_stealth_profile` must round-trip what
3722    // `set_fetch_stealth_profile` stored (default-None when unset).
3723    #[test]
3724    fn fetch_stealth_profile_getter_roundtrip() {
3725        let saved = crate::fetch_api::get_fetch_stealth_profile();
3726        crate::fetch_api::set_fetch_stealth_profile(Some(
3727            bao_stealth::StealthProfile::firefox_default(),
3728        ));
3729        let got = crate::fetch_api::get_fetch_stealth_profile();
3730        crate::fetch_api::set_fetch_stealth_profile(saved);
3731        let p = got.expect("profile must round-trip");
3732        assert!(
3733            !p.tls.cipher_suites.is_empty(),
3734            "firefox profile must carry cipher suites (wss stealth source)"
3735        );
3736    }
3737}