Skip to main content

asupersync_browser_core/
lib.rs

1//! Canonical Rust-side wasm-bindgen boundary for the shipped Browser Edition.
2//!
3//! `asupersync-browser-core` is the sole workspace crate that owns the live
4//! v1 ABI/export surface consumed by `@asupersync/browser-core` and the
5//! higher-level JS/TS packages.
6//!
7//! The sibling `asupersync-wasm` crate is retained as a non-canonical scaffold
8//! for future or alternative binding strategies. It is not the current owner of
9//! the shipped JS/WASM boundary.
10//!
11#![deny(unsafe_code)]
12#![allow(clippy::missing_errors_doc)]
13// wasm-bindgen requires String at the JS boundary; impl functions mirror those signatures.
14#![allow(clippy::needless_pass_by_value)]
15
16pub mod error;
17mod exports;
18pub mod types;
19
20pub use exports::{
21    abi_fingerprint, abi_version, browser_operator_snapshot, fetch_request, runtime_close,
22    runtime_create, scope_close, scope_enter, task_cancel, task_join, task_spawn, websocket_cancel,
23    websocket_close, websocket_open, websocket_recv, websocket_send,
24};
25
26use crate::error::dispatch_error_json;
27use crate::types::{
28    BrowserOperatorConsoleSnapshot, decode_json_payload, decode_optional_consumer_version,
29    encode_json_payload,
30};
31use asupersync::io::{FetchAuthority, FetchMethod};
32use asupersync::types::WasmDispatcherDiagnostics;
33use asupersync::types::{
34    WASM_ABI_MAJOR_VERSION, WASM_ABI_MINOR_VERSION, WASM_ABI_SIGNATURE_FINGERPRINT_V1,
35    WasmAbiCancellation, WasmAbiErrorCode, WasmAbiFailure, WasmAbiOutcomeEnvelope,
36    WasmAbiRecoverability, WasmAbiValue, WasmAbiVersion, WasmDispatchError, WasmExportDispatcher,
37    WasmFetchRequest, WasmHandleRef, WasmScopeEnterRequest, WasmTaskCancelRequest,
38    WasmTaskSpawnRequest,
39};
40use std::cell::RefCell;
41use std::collections::{HashMap, VecDeque};
42#[cfg(target_arch = "wasm32")]
43use std::rc::Rc;
44#[cfg(target_arch = "wasm32")]
45use wasm_bindgen::closure::Closure;
46#[cfg(target_arch = "wasm32")]
47use wasm_bindgen::{JsCast, JsValue};
48#[cfg(target_arch = "wasm32")]
49use wasm_bindgen_futures::{JsFuture, spawn_local};
50#[cfg(target_arch = "wasm32")]
51use web_sys::{
52    AbortController, BinaryType, CloseEvent, Event, MessageEvent, RequestCredentials, RequestInit,
53    Response, Url, WebSocket, WorkerGlobalScope,
54};
55
56thread_local! {
57    static DISPATCHER: RefCell<WasmExportDispatcher> = RefCell::new(WasmExportDispatcher::new());
58}
59#[cfg(target_arch = "wasm32")]
60thread_local! {
61    static INFLIGHT_FETCHES: RefCell<HashMap<WasmHandleRef, AbortController>> = RefCell::new(HashMap::new());
62}
63thread_local! {
64    static INFLIGHT_WEBSOCKETS: RefCell<HashMap<WasmHandleRef, BrowserWebSocketHostState>> = RefCell::new(HashMap::new());
65}
66
67#[derive(Debug, Clone, serde::Deserialize)]
68struct BrowserWebSocketOpenRequest {
69    scope: WasmHandleRef,
70    url: String,
71    protocols: Option<Vec<String>>,
72}
73
74#[derive(Debug, Clone, serde::Deserialize)]
75struct BrowserWebSocketSendRequest {
76    socket: WasmHandleRef,
77    value: WasmAbiValue,
78}
79
80#[derive(Debug, Clone, serde::Deserialize)]
81struct BrowserWebSocketRecvRequest {
82    socket: WasmHandleRef,
83}
84
85#[derive(Debug, Clone, serde::Deserialize)]
86struct BrowserWebSocketCloseRequest {
87    socket: WasmHandleRef,
88    reason: Option<String>,
89}
90
91#[derive(Debug, Clone, serde::Deserialize)]
92struct BrowserWebSocketCancelRequest {
93    socket: WasmHandleRef,
94    kind: String,
95    message: Option<String>,
96}
97
98#[derive(Debug, Clone, Default, serde::Deserialize)]
99#[serde(rename_all = "camelCase")]
100struct BrowserFetchAuthorityConfig {
101    #[serde(default)]
102    allowed_origins: Vec<String>,
103    #[serde(default)]
104    allowed_methods: Vec<String>,
105    #[serde(default)]
106    allow_credentials: bool,
107    #[serde(default)]
108    max_header_count: usize,
109}
110
111#[derive(Debug, Clone, Default, serde::Deserialize)]
112#[serde(rename_all = "camelCase")]
113struct BrowserRuntimeCreateRequest {
114    #[serde(default)]
115    fetch_authority: BrowserFetchAuthorityConfig,
116    #[serde(default)]
117    consumer_version: Option<WasmAbiVersion>,
118}
119
120#[cfg(target_arch = "wasm32")]
121struct BrowserWebSocketHostState {
122    socket: WebSocket,
123    inbox: Rc<RefCell<VecDeque<WasmAbiOutcomeEnvelope>>>,
124    _on_message: Closure<dyn FnMut(MessageEvent)>,
125    _on_close: Closure<dyn FnMut(CloseEvent)>,
126    _on_error: Closure<dyn FnMut(Event)>,
127}
128
129#[cfg(target_arch = "wasm32")]
130impl Drop for BrowserWebSocketHostState {
131    fn drop(&mut self) {
132        self.socket.set_onmessage(None);
133        self.socket.set_onclose(None);
134        self.socket.set_onerror(None);
135        let _ = self.socket.close();
136    }
137}
138
139#[cfg(not(target_arch = "wasm32"))]
140struct BrowserWebSocketHostState {
141    inbox: VecDeque<WasmAbiOutcomeEnvelope>,
142    closed: bool,
143}
144
145fn parse_json<T: serde::de::DeserializeOwned>(raw: &str, field: &str) -> Result<T, String> {
146    decode_json_payload(raw, field)
147}
148
149fn encode_json<T: serde::Serialize>(value: &T, field: &str) -> Result<String, String> {
150    encode_json_payload(value, field)
151}
152
153fn parse_consumer_version(raw: Option<String>) -> Result<Option<WasmAbiVersion>, String> {
154    decode_optional_consumer_version(raw)
155}
156
157fn to_error_string(err: WasmDispatchError) -> String {
158    dispatch_error_json(&err)
159}
160
161fn with_dispatcher<R>(
162    f: impl FnOnce(&mut WasmExportDispatcher) -> Result<R, WasmDispatchError>,
163) -> Result<R, String> {
164    DISPATCHER.with(|dispatcher| {
165        let mut dispatcher = dispatcher.borrow_mut();
166        f(&mut dispatcher).map_err(to_error_string)
167    })
168}
169
170fn dispatcher_handle_is_live(handle: &WasmHandleRef) -> bool {
171    DISPATCHER.with(|dispatcher| dispatcher.borrow().handles().get(handle).is_ok())
172}
173
174#[cfg(target_arch = "wasm32")]
175fn cleanup_released_fetches() {
176    INFLIGHT_FETCHES.with(|inflight| {
177        inflight
178            .borrow_mut()
179            .retain(|handle, _| dispatcher_handle_is_live(handle));
180    });
181}
182
183#[cfg(not(target_arch = "wasm32"))]
184const fn cleanup_released_fetches() {}
185
186fn cleanup_released_websockets() {
187    INFLIGHT_WEBSOCKETS.with(|sockets| {
188        sockets
189            .borrow_mut()
190            .retain(|handle, _| dispatcher_handle_is_live(handle));
191    });
192}
193
194fn cleanup_released_host_state() {
195    cleanup_released_fetches();
196    cleanup_released_websockets();
197}
198
199#[cfg(target_arch = "wasm32")]
200fn canonicalize_browser_http_url(raw: &str) -> Result<(String, String), String> {
201    let url = Url::new(raw).map_err(|error| {
202        format!(
203            "fetch URL is not a valid absolute browser URL: {}",
204            js_value_message(&error)
205        )
206    })?;
207    if !matches!(url.protocol().as_str(), "http:" | "https:") {
208        return Err(format!(
209            "fetch URL must use http or https, got {}",
210            url.protocol()
211        ));
212    }
213    if !url.username().is_empty() || !url.password().is_empty() {
214        return Err("fetch URL must not contain embedded credentials".to_string());
215    }
216    let origin = url.origin();
217    if origin == "null" {
218        return Err("fetch URL must have a tuple origin".to_string());
219    }
220    Ok((url.href(), origin))
221}
222
223#[cfg(not(target_arch = "wasm32"))]
224fn canonicalize_browser_http_url(raw: &str) -> Result<(String, String), String> {
225    let trimmed = raw.trim();
226    let scheme_end = trimmed
227        .find("://")
228        .ok_or_else(|| "fetch URL must be absolute".to_string())?;
229    let scheme = trimmed[..scheme_end].to_ascii_lowercase();
230    if !matches!(scheme.as_str(), "http" | "https") {
231        return Err(format!("fetch URL must use http or https, got {scheme}"));
232    }
233
234    let rest = trimmed[scheme_end + 3..].replace('\\', "/");
235    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
236    let authority = &rest[..authority_end];
237    if authority.is_empty() {
238        return Err("fetch URL must include a host".to_string());
239    }
240    if authority.contains('@') {
241        return Err("fetch URL must not contain embedded credentials".to_string());
242    }
243
244    let (host, port) = if let Some(ipv6) = authority.strip_prefix('[') {
245        let closing = ipv6
246            .find(']')
247            .ok_or_else(|| "fetch URL contains an unterminated IPv6 host".to_string())?;
248        let host = format!("[{}]", ipv6[..closing].to_ascii_lowercase());
249        let suffix = &ipv6[closing + 1..];
250        let port = if suffix.is_empty() {
251            None
252        } else {
253            Some(
254                suffix
255                    .strip_prefix(':')
256                    .ok_or_else(|| "fetch URL has an invalid IPv6 authority".to_string())?,
257            )
258        };
259        (host, port)
260    } else if let Some((host, port)) = authority.rsplit_once(':') {
261        (host.to_ascii_lowercase(), Some(port))
262    } else {
263        (authority.to_ascii_lowercase(), None)
264    };
265    if host.is_empty() {
266        return Err("fetch URL must include a host".to_string());
267    }
268
269    let port = port
270        .map(|value| {
271            value
272                .parse::<u16>()
273                .map_err(|_| "fetch URL has an invalid port".to_string())
274        })
275        .transpose()?;
276    let include_port =
277        port.filter(|value| !matches!((scheme.as_str(), value), ("http", 80) | ("https", 443)));
278    let canonical_authority =
279        include_port.map_or_else(|| host.clone(), |value| format!("{host}:{value}"));
280    let suffix = &rest[authority_end..];
281    let canonical_suffix = if suffix.is_empty() {
282        "/".to_string()
283    } else if suffix.starts_with(['?', '#']) {
284        format!("/{suffix}")
285    } else {
286        suffix.to_string()
287    };
288    let origin = format!("{scheme}://{canonical_authority}");
289    Ok((format!("{origin}{canonical_suffix}"), origin))
290}
291
292fn normalize_fetch_method(method: &str) -> Result<String, String> {
293    let normalized = method.trim().to_ascii_uppercase();
294    if normalized.is_empty() {
295        return Err("fetch method must not be empty".to_string());
296    }
297    if FetchMethod::from_http_token(&normalized).is_some() {
298        Ok(normalized)
299    } else {
300        Err(format!("unsupported fetch method: {normalized}"))
301    }
302}
303
304fn normalize_fetch_request(request: WasmFetchRequest) -> Result<WasmFetchRequest, String> {
305    if request.url.trim().is_empty() {
306        return Err("fetch URL must not be empty".to_string());
307    }
308    let method = normalize_fetch_method(&request.method)?;
309    let (url, _) = canonicalize_browser_http_url(&request.url)?;
310    if matches!(method.as_str(), "GET" | "HEAD") && request.body.is_some() {
311        return Err(format!(
312            "fetch method {method} does not permit a request body"
313        ));
314    }
315    Ok(WasmFetchRequest {
316        method,
317        url,
318        ..request
319    })
320}
321
322fn canonicalize_fetch_authority(
323    config: BrowserFetchAuthorityConfig,
324) -> Result<FetchAuthority, String> {
325    let mut authority = FetchAuthority::deny_all().with_max_header_count(config.max_header_count);
326    for origin in config.allowed_origins {
327        let canonical_origin = if origin == "*" {
328            origin
329        } else {
330            canonicalize_browser_http_url(&origin)?.1
331        };
332        authority = authority.grant_origin(canonical_origin);
333    }
334    for method in config.allowed_methods {
335        let normalized = normalize_fetch_method(&method)?;
336        let method = FetchMethod::from_http_token(&normalized)
337            .ok_or_else(|| format!("unsupported fetch authority method: {normalized}"))?;
338        authority = authority.grant_method(method);
339    }
340    if config.allow_credentials {
341        authority = authority.with_credentials_allowed();
342    }
343    Ok(authority)
344}
345
346const fn fetch_pending_outcome(handle: WasmHandleRef) -> WasmAbiOutcomeEnvelope {
347    WasmAbiOutcomeEnvelope::Ok {
348        value: WasmAbiValue::Handle(handle),
349    }
350}
351
352#[allow(clippy::missing_const_for_fn)]
353fn fetch_error_outcome(
354    message: String,
355    recoverability: WasmAbiRecoverability,
356) -> WasmAbiOutcomeEnvelope {
357    WasmAbiOutcomeEnvelope::Err {
358        failure: WasmAbiFailure {
359            code: WasmAbiErrorCode::InternalFailure,
360            recoverability,
361            message,
362        },
363    }
364}
365
366fn cancelled_outcome(
367    kind: &str,
368    phase: &str,
369    message: Option<String>,
370    origin_task: Option<String>,
371) -> WasmAbiOutcomeEnvelope {
372    WasmAbiOutcomeEnvelope::Cancelled {
373        cancellation: WasmAbiCancellation {
374            kind: kind.to_string(),
375            phase: phase.to_string(),
376            origin_region: "browser".to_string(),
377            origin_task,
378            timestamp_nanos: 0,
379            message,
380            truncated: false,
381        },
382    }
383}
384
385#[cfg(target_arch = "wasm32")]
386fn take_inflight_fetch(handle: &WasmHandleRef) -> Option<AbortController> {
387    INFLIGHT_FETCHES.with(|inflight| inflight.borrow_mut().remove(handle))
388}
389
390#[cfg(target_arch = "wasm32")]
391fn register_inflight_fetch(handle: WasmHandleRef, controller: AbortController) {
392    INFLIGHT_FETCHES.with(|inflight| {
393        inflight.borrow_mut().insert(handle, controller);
394    });
395}
396
397#[cfg(target_arch = "wasm32")]
398fn js_value_message(value: &JsValue) -> String {
399    value
400        .as_string()
401        .or_else(|| {
402            js_sys::JSON::stringify(value)
403                .ok()
404                .and_then(|json| json.as_string())
405        })
406        .unwrap_or_else(|| "non-string JS error".to_string())
407}
408
409#[cfg(target_arch = "wasm32")]
410fn js_error_name(value: &JsValue) -> Option<String> {
411    js_sys::Reflect::get(value, &JsValue::from_str("name"))
412        .ok()
413        .and_then(|name| name.as_string())
414}
415
416#[cfg(target_arch = "wasm32")]
417fn abort_cancelled_outcome(message: String) -> WasmAbiOutcomeEnvelope {
418    cancelled_outcome("abort_signal", "cancelling", Some(message), None)
419}
420
421fn normalize_websocket_url(url: &str) -> Result<String, String> {
422    let normalized = url.trim();
423    if normalized.is_empty() {
424        return Err("websocket URL must not be empty".to_string());
425    }
426    let (scheme, rest) = normalized
427        .split_once("://")
428        .ok_or_else(|| format!("websocket URL must start with ws:// or wss://: {normalized}"))?;
429    if !(scheme.eq_ignore_ascii_case("ws") || scheme.eq_ignore_ascii_case("wss")) {
430        return Err(format!(
431            "websocket URL must start with ws:// or wss://: {normalized}"
432        ));
433    }
434    Ok(format!("{}://{rest}", scheme.to_ascii_lowercase()))
435}
436
437const fn websocket_pending_outcome(handle: WasmHandleRef) -> WasmAbiOutcomeEnvelope {
438    WasmAbiOutcomeEnvelope::Ok {
439        value: WasmAbiValue::Handle(handle),
440    }
441}
442
443const fn websocket_idle_outcome() -> WasmAbiOutcomeEnvelope {
444    WasmAbiOutcomeEnvelope::Ok {
445        value: WasmAbiValue::Unit,
446    }
447}
448
449const fn websocket_send_outcome() -> WasmAbiOutcomeEnvelope {
450    WasmAbiOutcomeEnvelope::Ok {
451        value: WasmAbiValue::Unit,
452    }
453}
454
455fn spawn_websocket_handle(
456    scope: WasmHandleRef,
457    consumer_version: Option<WasmAbiVersion>,
458) -> Result<WasmHandleRef, String> {
459    let spawn = WasmTaskSpawnRequest {
460        scope,
461        label: Some("browser-websocket".to_string()),
462        cancel_kind: Some("abort_signal".to_string()),
463    };
464    with_dispatcher(|dispatcher| dispatcher.task_spawn(&spawn, consumer_version))
465}
466
467fn finalize_websocket_handle(
468    handle: &WasmHandleRef,
469    outcome: WasmAbiOutcomeEnvelope,
470    consumer_version: Option<WasmAbiVersion>,
471) -> Result<WasmAbiOutcomeEnvelope, String> {
472    with_dispatcher(|dispatcher| dispatcher.task_join(handle, outcome, consumer_version))
473}
474
475fn cancel_websocket_handle(
476    request: &WasmTaskCancelRequest,
477    consumer_version: Option<WasmAbiVersion>,
478) -> Result<WasmAbiOutcomeEnvelope, String> {
479    with_dispatcher(|dispatcher| dispatcher.task_cancel(request, consumer_version))
480}
481
482fn with_websocket_state_mut<R>(
483    handle: &WasmHandleRef,
484    f: impl FnOnce(&mut BrowserWebSocketHostState) -> Result<R, String>,
485) -> Result<R, String> {
486    INFLIGHT_WEBSOCKETS.with(|sockets| {
487        let mut sockets = sockets.borrow_mut();
488        let state = sockets
489            .get_mut(handle)
490            .ok_or_else(|| format!("unknown websocket handle: {handle:?}"))?;
491        f(state)
492    })
493}
494
495fn take_websocket_state(handle: &WasmHandleRef) -> Option<BrowserWebSocketHostState> {
496    INFLIGHT_WEBSOCKETS.with(|sockets| sockets.borrow_mut().remove(handle))
497}
498
499fn insert_websocket_state(handle: WasmHandleRef, state: BrowserWebSocketHostState) {
500    INFLIGHT_WEBSOCKETS.with(|sockets| {
501        sockets.borrow_mut().insert(handle, state);
502    });
503}
504
505#[cfg(target_arch = "wasm32")]
506fn finalize_fetch_outcome(handle: WasmHandleRef, outcome: WasmAbiOutcomeEnvelope) {
507    if take_inflight_fetch(&handle).is_none() {
508        return;
509    }
510    if matches!(outcome, WasmAbiOutcomeEnvelope::Cancelled { .. }) {
511        let _ = with_dispatcher(|dispatcher| dispatcher.apply_abort(&handle));
512    }
513    let _ = with_dispatcher(|dispatcher| dispatcher.fetch_complete(&handle, outcome));
514}
515
516#[cfg(target_arch = "wasm32")]
517fn host_fetch_with_str_and_init(url: &str, init: &RequestInit) -> Result<js_sys::Promise, String> {
518    if let Some(window) = web_sys::window() {
519        return Ok(window.fetch_with_str_and_init(url, init));
520    }
521
522    if let Ok(worker) = js_sys::global().dyn_into::<WorkerGlobalScope>() {
523        return Ok(worker.fetch_with_str_and_init(url, init));
524    }
525
526    Err("window or WorkerGlobalScope fetch host is not available in this host context".to_string())
527}
528
529#[cfg(target_arch = "wasm32")]
530async fn run_browser_fetch(
531    request: WasmFetchRequest,
532    signal: web_sys::AbortSignal,
533) -> WasmAbiOutcomeEnvelope {
534    let init = RequestInit::new();
535    init.set_method(&request.method);
536    init.set_signal(Some(&signal));
537    init.set_credentials(if request.credentials {
538        RequestCredentials::Include
539    } else {
540        RequestCredentials::Omit
541    });
542    if let Some(body) = request.body {
543        let body = js_sys::Uint8Array::from(body.as_slice());
544        init.set_body(&body.into());
545    }
546
547    let fetch_promise = match host_fetch_with_str_and_init(&request.url, &init) {
548        Ok(fetch_promise) => fetch_promise,
549        Err(message) => {
550            return fetch_error_outcome(message, WasmAbiRecoverability::Permanent);
551        }
552    };
553    match JsFuture::from(fetch_promise).await {
554        Ok(response_value) => {
555            let status = response_value
556                .dyn_into::<Response>()
557                .ok()
558                .map(|response| u64::from(response.status()));
559            let value = status.map_or(WasmAbiValue::Unit, WasmAbiValue::U64);
560            WasmAbiOutcomeEnvelope::Ok { value }
561        }
562        Err(error) => {
563            let message = js_value_message(&error);
564            if js_error_name(&error).as_deref() == Some("AbortError") {
565                abort_cancelled_outcome(format!("fetch aborted by AbortSignal: {message}"))
566            } else {
567                fetch_error_outcome(
568                    format!("browser fetch rejected: {message}"),
569                    WasmAbiRecoverability::Transient,
570                )
571            }
572        }
573    }
574}
575
576#[cfg(target_arch = "wasm32")]
577fn spawn_browser_fetch(handle: WasmHandleRef, request: WasmFetchRequest) -> Result<(), String> {
578    let controller = AbortController::new().map_err(|err| {
579        format!(
580            "failed to create AbortController for fetch handle {:?}: {}",
581            handle,
582            js_value_message(&err)
583        )
584    })?;
585    let signal = controller.signal();
586    register_inflight_fetch(handle, controller);
587    spawn_local(async move {
588        let outcome = run_browser_fetch(request, signal).await;
589        finalize_fetch_outcome(handle, outcome);
590    });
591    Ok(())
592}
593
594#[cfg(target_arch = "wasm32")]
595fn websocket_outcome_from_message_event(event: MessageEvent) -> WasmAbiOutcomeEnvelope {
596    let payload = event.data();
597    if let Some(text) = payload.as_string() {
598        return WasmAbiOutcomeEnvelope::Ok {
599            value: WasmAbiValue::String(text),
600        };
601    }
602    if let Ok(buffer) = payload.dyn_into::<js_sys::ArrayBuffer>() {
603        let bytes = js_sys::Uint8Array::new(&buffer).to_vec();
604        return WasmAbiOutcomeEnvelope::Ok {
605            value: WasmAbiValue::Bytes(bytes),
606        };
607    }
608    fetch_error_outcome(
609        "websocket message payload type is unsupported".to_string(),
610        WasmAbiRecoverability::Unknown,
611    )
612}
613
614#[cfg(target_arch = "wasm32")]
615fn setup_browser_websocket(
616    handle: WasmHandleRef,
617    request: &BrowserWebSocketOpenRequest,
618) -> Result<(), String> {
619    let socket = if let Some(protocols) = request.protocols.as_ref() {
620        if protocols.is_empty() {
621            WebSocket::new(&request.url)
622        } else {
623            let js_protocols = js_sys::Array::new();
624            for protocol in protocols {
625                js_protocols.push(&JsValue::from_str(protocol));
626            }
627            WebSocket::new_with_str_sequence(&request.url, &js_protocols)
628        }
629    } else {
630        WebSocket::new(&request.url)
631    }
632    .map_err(|err| {
633        format!(
634            "failed to construct browser WebSocket: {}",
635            js_value_message(&err)
636        )
637    })?;
638    socket.set_binary_type(BinaryType::Arraybuffer);
639
640    let inbox = Rc::new(RefCell::new(VecDeque::new()));
641    let inbox_for_message = Rc::clone(&inbox);
642    let on_message = Closure::wrap(Box::new(move |event: MessageEvent| {
643        inbox_for_message
644            .borrow_mut()
645            .push_back(websocket_outcome_from_message_event(event));
646    }) as Box<dyn FnMut(MessageEvent)>);
647
648    let inbox_for_close = Rc::clone(&inbox);
649    let on_close = Closure::wrap(Box::new(move |event: CloseEvent| {
650        let message = if event.reason().is_empty() {
651            format!("websocket closed with code {}", event.code())
652        } else {
653            format!(
654                "websocket closed with code {} ({})",
655                event.code(),
656                event.reason()
657            )
658        };
659        inbox_for_close.borrow_mut().push_back(cancelled_outcome(
660            "websocket_close",
661            "completed",
662            Some(message),
663            None,
664        ));
665    }) as Box<dyn FnMut(CloseEvent)>);
666
667    let inbox_for_error = Rc::clone(&inbox);
668    let on_error = Closure::wrap(Box::new(move |_event: Event| {
669        inbox_for_error.borrow_mut().push_back(fetch_error_outcome(
670            "browser websocket error event".to_string(),
671            WasmAbiRecoverability::Transient,
672        ));
673    }) as Box<dyn FnMut(Event)>);
674
675    socket.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
676    socket.set_onclose(Some(on_close.as_ref().unchecked_ref()));
677    socket.set_onerror(Some(on_error.as_ref().unchecked_ref()));
678
679    INFLIGHT_WEBSOCKETS.with(|sockets| {
680        sockets.borrow_mut().insert(
681            handle,
682            BrowserWebSocketHostState {
683                socket,
684                inbox,
685                _on_message: on_message,
686                _on_close: on_close,
687                _on_error: on_error,
688            },
689        );
690    });
691
692    Ok(())
693}
694
695#[cfg(not(target_arch = "wasm32"))]
696#[allow(clippy::unnecessary_wraps)]
697fn setup_browser_websocket(
698    handle: WasmHandleRef,
699    request: &BrowserWebSocketOpenRequest,
700) -> Result<(), String> {
701    let _requested_protocols = request.protocols.as_ref().map(std::vec::Vec::len);
702    INFLIGHT_WEBSOCKETS.with(|sockets| {
703        sockets.borrow_mut().insert(
704            handle,
705            BrowserWebSocketHostState {
706                inbox: VecDeque::new(),
707                closed: false,
708            },
709        );
710    });
711    Ok(())
712}
713
714#[cfg(target_arch = "wasm32")]
715fn send_browser_websocket_message(
716    handle: &WasmHandleRef,
717    value: WasmAbiValue,
718) -> Result<(), String> {
719    with_websocket_state_mut(handle, |state| match value {
720        WasmAbiValue::String(text) => state.socket.send_with_str(&text).map_err(|err| {
721            format!(
722                "websocket send_with_str failed for {:?}: {}",
723                handle,
724                js_value_message(&err)
725            )
726        }),
727        WasmAbiValue::Bytes(bytes) => state.socket.send_with_u8_array(&bytes).map_err(|err| {
728            format!(
729                "websocket send_with_u8_array failed for {:?}: {}",
730                handle,
731                js_value_message(&err)
732            )
733        }),
734        other => Err(format!(
735            "websocket send requires string/bytes payload, got {other:?}"
736        )),
737    })
738}
739
740#[cfg(not(target_arch = "wasm32"))]
741fn send_browser_websocket_message(
742    handle: &WasmHandleRef,
743    value: WasmAbiValue,
744) -> Result<(), String> {
745    with_websocket_state_mut(handle, |state| {
746        if state.closed {
747            return Err(format!("websocket handle {handle:?} is already closed"));
748        }
749        match value {
750            WasmAbiValue::String(text) => state.inbox.push_back(WasmAbiOutcomeEnvelope::Ok {
751                value: WasmAbiValue::String(text),
752            }),
753            WasmAbiValue::Bytes(bytes) => state.inbox.push_back(WasmAbiOutcomeEnvelope::Ok {
754                value: WasmAbiValue::Bytes(bytes),
755            }),
756            other => {
757                return Err(format!(
758                    "websocket send requires string/bytes payload, got {other:?}"
759                ));
760            }
761        }
762        Ok(())
763    })
764}
765
766fn recv_browser_websocket_message(
767    handle: &WasmHandleRef,
768) -> Result<WasmAbiOutcomeEnvelope, String> {
769    with_websocket_state_mut(handle, |state| {
770        #[cfg(target_arch = "wasm32")]
771        let next = state.inbox.borrow_mut().pop_front();
772        #[cfg(not(target_arch = "wasm32"))]
773        let next = state.inbox.pop_front();
774
775        Ok(next.unwrap_or_else(websocket_idle_outcome))
776    })
777}
778
779const MAX_WEBSOCKET_CLOSE_REASON_BYTES: usize = 123;
780
781fn validate_websocket_close_reason(reason: &str) -> Result<(), String> {
782    if reason.len() > MAX_WEBSOCKET_CLOSE_REASON_BYTES {
783        return Err(format!(
784            "websocket close reason exceeds {MAX_WEBSOCKET_CLOSE_REASON_BYTES} bytes"
785        ));
786    }
787    Ok(())
788}
789
790#[cfg(target_arch = "wasm32")]
791fn close_browser_websocket_socket(
792    state: &mut BrowserWebSocketHostState,
793    reason: Option<&str>,
794) -> Result<(), String> {
795    if let Some(reason) = reason {
796        validate_websocket_close_reason(reason)?;
797        state
798            .socket
799            .close_with_code_and_reason(1000, reason)
800            .map_err(|err| format!("websocket close failed: {}", js_value_message(&err)))?;
801    } else {
802        state
803            .socket
804            .close()
805            .map_err(|err| format!("websocket close failed: {}", js_value_message(&err)))?;
806    }
807    Ok(())
808}
809
810#[cfg(not(target_arch = "wasm32"))]
811#[allow(clippy::unnecessary_wraps)]
812fn close_browser_websocket_socket(
813    state: &mut BrowserWebSocketHostState,
814    reason: Option<&str>,
815) -> Result<(), String> {
816    if let Some(reason) = reason {
817        validate_websocket_close_reason(reason)?;
818        state.inbox.push_back(cancelled_outcome(
819            "websocket_close",
820            "completed",
821            Some(reason.to_string()),
822            None,
823        ));
824    }
825    state.closed = true;
826    Ok(())
827}
828
829/// Reset helper for host-side deterministic tests.
830pub fn reset_dispatcher_for_tests() {
831    DISPATCHER.with(|dispatcher| {
832        *dispatcher.borrow_mut() = WasmExportDispatcher::new();
833    });
834    #[cfg(target_arch = "wasm32")]
835    INFLIGHT_FETCHES.with(|fetches| {
836        fetches.borrow_mut().clear();
837    });
838    INFLIGHT_WEBSOCKETS.with(|sockets| {
839        sockets.borrow_mut().clear();
840    });
841}
842
843/// Host-side diagnostics helper for export-boundary tests.
844#[must_use]
845pub fn dispatcher_diagnostics_for_tests() -> WasmDispatcherDiagnostics {
846    DISPATCHER.with(|dispatcher| dispatcher.borrow().diagnostic_snapshot())
847}
848
849fn runtime_create_impl(request_json: Option<String>) -> Result<String, String> {
850    let request: BrowserRuntimeCreateRequest = request_json.map_or_else(
851        || Ok(BrowserRuntimeCreateRequest::default()),
852        |request_json| parse_json(&request_json, "runtime_create.request"),
853    )?;
854    let fetch_authority = canonicalize_fetch_authority(request.fetch_authority)?;
855    let consumer_version = request.consumer_version;
856    let handle = with_dispatcher(|dispatcher| {
857        let handle = dispatcher.runtime_create(consumer_version)?;
858        if let Err(error) = dispatcher.register_runtime_fetch_authority(&handle, fetch_authority) {
859            let _ = dispatcher.runtime_close(&handle, consumer_version);
860            return Err(error);
861        }
862        Ok(handle)
863    })?;
864    encode_json(&handle, "runtime_create.response")
865}
866
867fn browser_operator_snapshot_impl() -> Result<String, String> {
868    cleanup_released_host_state();
869    let snapshot = DISPATCHER.with(|dispatcher| {
870        let diagnostics = dispatcher.borrow().diagnostic_snapshot();
871        BrowserOperatorConsoleSnapshot::from_dispatcher_diagnostics(&diagnostics)
872    });
873    encode_json(&snapshot, "browser_operator_snapshot.response")
874}
875
876fn runtime_close_impl(
877    handle_json: String,
878    consumer_version_json: Option<String>,
879) -> Result<String, String> {
880    let handle: WasmHandleRef = parse_json(&handle_json, "runtime_close.request")?;
881    let consumer_version = parse_consumer_version(consumer_version_json)?;
882    let outcome =
883        with_dispatcher(|dispatcher| dispatcher.runtime_close(&handle, consumer_version))?;
884    cleanup_released_host_state();
885    encode_json(&outcome, "runtime_close.response")
886}
887
888fn scope_enter_impl(
889    request_json: String,
890    consumer_version_json: Option<String>,
891) -> Result<String, String> {
892    let request: WasmScopeEnterRequest = parse_json(&request_json, "scope_enter.request")?;
893    let consumer_version = parse_consumer_version(consumer_version_json)?;
894    let handle = with_dispatcher(|dispatcher| dispatcher.scope_enter(&request, consumer_version))?;
895    encode_json(&handle, "scope_enter.response")
896}
897
898fn scope_close_impl(
899    handle_json: String,
900    consumer_version_json: Option<String>,
901) -> Result<String, String> {
902    let handle: WasmHandleRef = parse_json(&handle_json, "scope_close.request")?;
903    let consumer_version = parse_consumer_version(consumer_version_json)?;
904    let outcome = with_dispatcher(|dispatcher| dispatcher.scope_close(&handle, consumer_version))?;
905    cleanup_released_host_state();
906    encode_json(&outcome, "scope_close.response")
907}
908
909fn task_spawn_impl(
910    request_json: String,
911    consumer_version_json: Option<String>,
912) -> Result<String, String> {
913    let request: WasmTaskSpawnRequest = parse_json(&request_json, "task_spawn.request")?;
914    let consumer_version = parse_consumer_version(consumer_version_json)?;
915    let handle = with_dispatcher(|dispatcher| dispatcher.task_spawn(&request, consumer_version))?;
916    encode_json(&handle, "task_spawn.response")
917}
918
919fn task_join_impl(
920    handle_json: String,
921    outcome_json: String,
922    consumer_version_json: Option<String>,
923) -> Result<String, String> {
924    let handle: WasmHandleRef = parse_json(&handle_json, "task_join.request.handle")?;
925    let outcome: WasmAbiOutcomeEnvelope = parse_json(&outcome_json, "task_join.request.outcome")?;
926    let consumer_version = parse_consumer_version(consumer_version_json)?;
927    let joined =
928        with_dispatcher(|dispatcher| dispatcher.task_join(&handle, outcome, consumer_version))?;
929    encode_json(&joined, "task_join.response")
930}
931
932fn task_cancel_impl(
933    request_json: String,
934    consumer_version_json: Option<String>,
935) -> Result<String, String> {
936    let request: WasmTaskCancelRequest = parse_json(&request_json, "task_cancel.request")?;
937    let consumer_version = parse_consumer_version(consumer_version_json)?;
938    let outcome = with_dispatcher(|dispatcher| dispatcher.task_cancel(&request, consumer_version))?;
939
940    #[cfg(target_arch = "wasm32")]
941    if let Some(controller) = take_inflight_fetch(&request.task) {
942        controller.abort();
943    }
944
945    encode_json(&outcome, "task_cancel.response")
946}
947
948fn fetch_request_impl(
949    request_json: String,
950    consumer_version_json: Option<String>,
951) -> Result<String, String> {
952    let request: WasmFetchRequest = parse_json(&request_json, "fetch_request.request")?;
953    let request = normalize_fetch_request(request)?;
954    let consumer_version = parse_consumer_version(consumer_version_json)?;
955    let handle =
956        with_dispatcher(|dispatcher| dispatcher.fetch_request(&request, consumer_version))?;
957    #[cfg(target_arch = "wasm32")]
958    if let Err(setup_err) = spawn_browser_fetch(handle, request.clone()) {
959        let setup_outcome = fetch_error_outcome(
960            format!("failed to start browser fetch: {setup_err}"),
961            WasmAbiRecoverability::Permanent,
962        );
963        let _ =
964            with_dispatcher(|dispatcher| dispatcher.fetch_complete(&handle, setup_outcome.clone()));
965        return encode_json(&setup_outcome, "fetch_request.response");
966    }
967    encode_json(&fetch_pending_outcome(handle), "fetch_request.response")
968}
969
970fn websocket_open_impl(
971    request_json: String,
972    consumer_version_json: Option<String>,
973) -> Result<String, String> {
974    let request: BrowserWebSocketOpenRequest = parse_json(&request_json, "websocket_open.request")?;
975    let url = normalize_websocket_url(&request.url)?;
976    let request = BrowserWebSocketOpenRequest { url, ..request };
977    let consumer_version = parse_consumer_version(consumer_version_json)?;
978    let handle = spawn_websocket_handle(request.scope, consumer_version)?;
979    if let Err(setup_err) = setup_browser_websocket(handle, &request) {
980        let setup_outcome = fetch_error_outcome(
981            format!("failed to start browser websocket: {setup_err}"),
982            WasmAbiRecoverability::Permanent,
983        );
984        let _ = finalize_websocket_handle(&handle, setup_outcome.clone(), consumer_version);
985        return encode_json(&setup_outcome, "websocket_open.response");
986    }
987    encode_json(
988        &websocket_pending_outcome(handle),
989        "websocket_open.response",
990    )
991}
992
993fn websocket_send_impl(
994    request_json: String,
995    _consumer_version_json: Option<String>,
996) -> Result<String, String> {
997    let request: BrowserWebSocketSendRequest = parse_json(&request_json, "websocket_send.request")?;
998    send_browser_websocket_message(&request.socket, request.value)?;
999    encode_json(&websocket_send_outcome(), "websocket_send.response")
1000}
1001
1002fn websocket_recv_impl(
1003    request_json: String,
1004    _consumer_version_json: Option<String>,
1005) -> Result<String, String> {
1006    let request: BrowserWebSocketRecvRequest = parse_json(&request_json, "websocket_recv.request")?;
1007    let outcome = recv_browser_websocket_message(&request.socket)?;
1008    encode_json(&outcome, "websocket_recv.response")
1009}
1010
1011fn websocket_close_impl(
1012    request_json: String,
1013    consumer_version_json: Option<String>,
1014) -> Result<String, String> {
1015    let request: BrowserWebSocketCloseRequest =
1016        parse_json(&request_json, "websocket_close.request")?;
1017    let consumer_version = parse_consumer_version(consumer_version_json)?;
1018    let close_reason = request.reason.clone();
1019    let mut state = take_websocket_state(&request.socket)
1020        .ok_or_else(|| format!("unknown websocket handle: {:?}", request.socket))?;
1021    if let Err(err) = close_browser_websocket_socket(&mut state, close_reason.as_deref()) {
1022        insert_websocket_state(request.socket, state);
1023        return Err(err);
1024    }
1025    let outcome = if let Some(reason) = close_reason {
1026        cancelled_outcome(
1027            "websocket_close",
1028            "completed",
1029            Some(reason),
1030            Some(format!("{:?}", request.socket)),
1031        )
1032    } else {
1033        websocket_send_outcome()
1034    };
1035    let closed = finalize_websocket_handle(&request.socket, outcome, consumer_version)?;
1036    encode_json(&closed, "websocket_close.response")
1037}
1038
1039fn websocket_cancel_impl(
1040    request_json: String,
1041    consumer_version_json: Option<String>,
1042) -> Result<String, String> {
1043    let request: BrowserWebSocketCancelRequest =
1044        parse_json(&request_json, "websocket_cancel.request")?;
1045    let consumer_version = parse_consumer_version(consumer_version_json)?;
1046    let cancel_message = request.message.clone();
1047    let cancel = WasmTaskCancelRequest {
1048        task: request.socket,
1049        kind: request.kind.clone(),
1050        message: cancel_message.clone(),
1051    };
1052    let _ = cancel_websocket_handle(&cancel, consumer_version)?;
1053    if let Some(mut state) = take_websocket_state(&request.socket)
1054        && let Err(err) = close_browser_websocket_socket(&mut state, cancel_message.as_deref())
1055    {
1056        insert_websocket_state(request.socket, state);
1057        return Err(err);
1058    }
1059    let cancelled = cancelled_outcome(
1060        &request.kind,
1061        "cancelling",
1062        request.message,
1063        Some(format!("{:?}", request.socket)),
1064    );
1065    let joined = finalize_websocket_handle(&request.socket, cancelled, consumer_version)?;
1066    encode_json(&joined, "websocket_cancel.response")
1067}
1068
1069fn abi_version_impl() -> Result<String, String> {
1070    let version = WasmAbiVersion {
1071        major: WASM_ABI_MAJOR_VERSION,
1072        minor: WASM_ABI_MINOR_VERSION,
1073    };
1074    encode_json(&version, "abi_version.response")
1075}
1076
1077const fn abi_fingerprint_impl() -> u64 {
1078    WASM_ABI_SIGNATURE_FINGERPRINT_V1
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::{
1084        BrowserFetchAuthorityConfig, canonicalize_browser_http_url, canonicalize_fetch_authority,
1085    };
1086    use asupersync::io::FetchMethod;
1087
1088    #[test]
1089    fn fetch_url_canonicalization_matches_browser_special_url_boundaries() {
1090        let (url, origin) =
1091            canonicalize_browser_http_url(r"HTTPS://API.EXAMPLE.COM:443\records?limit=1")
1092                .expect("special URL must canonicalize");
1093        assert_eq!(url, "https://api.example.com/records?limit=1");
1094        assert_eq!(origin, "https://api.example.com");
1095
1096        let error = canonicalize_browser_http_url("https://user:secret@api.example.com/data")
1097            .expect_err("embedded credentials must fail closed");
1098        assert!(error.contains("embedded credentials"));
1099    }
1100
1101    #[test]
1102    fn fetch_authority_origins_and_methods_are_canonicalized_once() {
1103        let authority = canonicalize_fetch_authority(BrowserFetchAuthorityConfig {
1104            allowed_origins: vec!["HTTPS://API.EXAMPLE.COM:443/base".to_string()],
1105            allowed_methods: vec![" get ".to_string()],
1106            allow_credentials: false,
1107            max_header_count: 0,
1108        })
1109        .expect("authority must canonicalize");
1110
1111        assert_eq!(
1112            authority.allowed_origins,
1113            vec!["https://api.example.com".to_string()]
1114        );
1115        assert_eq!(authority.allowed_methods, vec![FetchMethod::Get]);
1116        assert!(!authority.allow_credentials);
1117    }
1118}
1119
1120#[cfg(target_arch = "wasm32")]
1121fn into_js_error(err: String) -> JsValue {
1122    JsValue::from_str(&err)
1123}