1#![deny(unsafe_code)]
12#![allow(clippy::missing_errors_doc)]
13#![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
829pub 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#[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}