Skip to main content

openrtc/
lib.rs

1#![allow(deprecated)]
2
3pub mod application_crypto;
4pub mod application_crypto_streams;
5pub mod broadcast;
6pub mod client;
7pub mod coordination;
8pub mod datagrams;
9pub mod explicit_transfer_crypto;
10pub(crate) mod generated;
11pub mod heartbeat;
12#[cfg(feature = "iroh-carrier-core")]
13pub mod iroh_carrier;
14#[cfg(feature = "iroh-carrier-core")]
15pub mod iroh_carrier_bootstrap;
16// Carrier IDs are dependency-free protocol metadata. Admission code must be
17// able to classify reserved IDs even when no custom carrier is compiled.
18pub mod iroh_carrier_kind;
19#[cfg(feature = "iroh-carrier-core")]
20pub mod iroh_carrier_proof;
21pub(crate) mod iroh_connection_policy;
22pub mod key_agreement;
23pub mod lifecycle_reason;
24#[cfg(feature = "managed-group-encryption")]
25#[allow(
26    dead_code,
27    reason = "gated managed-room controller; consumed only by managed gateway adapters"
28)]
29pub(crate) mod managed_group_controller;
30#[cfg(feature = "managed-group-encryption")]
31#[allow(
32    dead_code,
33    reason = "gated managed-room crypto owner; enabled only after the tracked persistence and router milestones"
34)]
35pub(crate) mod managed_group_crypto;
36pub mod media;
37pub mod native_protocol;
38pub mod offline;
39#[cfg(feature = "iroh-carrier-core")]
40pub mod packet_carrier_transport;
41pub mod presence;
42pub(crate) mod presence_policy;
43pub mod protocol_config;
44pub mod route_policy;
45pub mod runtime_policy;
46pub mod session_token;
47pub mod signaling;
48pub mod sparse_fanout;
49pub mod stream_metadata;
50pub(crate) mod transport_generation;
51pub(crate) mod transport_label;
52
53#[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
54pub mod local_discovery;
55
56/// Test constants — use these instead of hardcoding project IDs in tests.
57/// Unit tests that don't hit real Firestore should use TEST_PROJECT_ID.
58/// Live/integration tests must use LIVE_PROJECT_ID ("pluto-rtc-prod").
59#[cfg(test)]
60pub mod test_constants {
61    pub const TEST_PROJECT_ID: &str = "test-project";
62    pub const TEST_API_KEY: &str = "pk_test_0000000000000000000000000000000000000000";
63}
64
65/// Re-export for downstream crates' tests.
66pub const LIVE_PROJECT_ID: &str = "pluto-rtc-prod";
67
68/// Validate the only public platform credential accepted by an OpenRTC 2.0
69/// client constructor. The API key identifies the developer application; it is
70/// not a secret and does not authorize a live avenue by itself.
71pub fn validate_api_key(api_key: &str) -> anyhow::Result<&str> {
72    let trimmed = api_key.trim();
73    let valid_prefix = trimmed.starts_with("pk_live_") || trimmed.starts_with("pk_test_");
74    let suffix = trimmed.get(8..).unwrap_or_default();
75    if !valid_prefix || suffix.len() != 40 || !suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
76        anyhow::bail!("OpenRTC 2.0 requires a public pk_live_ or pk_test_ API key");
77    }
78    Ok(trimmed)
79}
80
81pub fn app_tag_from_api_key(api_key: &str) -> String {
82    let trimmed = api_key.trim();
83    if trimmed.is_empty() {
84        return "app_anonymous".to_string();
85    }
86
87    let suffix_len = trimmed.len().min(16);
88    format!("app_{}", &trimmed[trimmed.len() - suffix_len..])
89}
90
91pub fn space_app_tag(api_key: &str, space_key: &str) -> String {
92    let input = format!("{}:{}", api_key.trim(), space_key.trim());
93    let digest = <sha2::Sha256 as sha2::Digest>::digest(input.as_bytes());
94    format!("space::{}", hex::encode(digest))
95}
96
97#[cfg(test)]
98mod constructor_contract_tests {
99    use super::*;
100
101    #[test]
102    fn rust_constructor_is_provider_neutral_and_side_effect_free() {
103        let api_key = test_constants::TEST_API_KEY;
104        let client = client::Client::new(api_key.to_string()).expect("valid public API key");
105
106        assert_eq!(client.app_tag(), app_tag_from_api_key(api_key));
107        assert!(client::Client::new("firebase-project-id".to_string()).is_err());
108    }
109}
110
111#[cfg(not(target_arch = "wasm32"))]
112pub fn ensure_rustls() {
113    if rustls::crypto::CryptoProvider::get_default().is_none() {
114        let _ = rustls::crypto::ring::default_provider().install_default();
115    }
116}
117
118#[cfg(not(target_arch = "wasm32"))]
119pub mod adapters;
120
121#[cfg(not(target_arch = "wasm32"))]
122pub(crate) mod native_coordination_gateway;
123
124#[cfg(not(target_arch = "wasm32"))]
125pub mod native;
126
127pub use client::Client;
128#[cfg(not(target_arch = "wasm32"))]
129pub use native::ControlPlane;
130
131pub mod connection_manager;
132
133pub mod protocol_registry;
134
135#[cfg(not(target_arch = "wasm32"))]
136pub mod runtime_manager;
137
138#[cfg(not(target_arch = "wasm32"))]
139pub mod transport;
140
141#[cfg(not(target_arch = "wasm32"))]
142pub use client::EndpointHandle;
143
144#[cfg(all(
145    not(target_arch = "wasm32"),
146    not(any(target_os = "ios", target_os = "android"))
147))]
148pub mod sso;
149
150#[cfg(not(target_arch = "wasm32"))]
151pub mod native_node;
152
153#[cfg(not(target_arch = "wasm32"))]
154pub mod native_device;
155
156#[cfg(all(
157    test,
158    not(target_arch = "wasm32"),
159    feature = "iroh-protocols-wasm",
160    any(feature = "transport-webrtc", feature = "transport-moq")
161))]
162mod native_carrier_protocol_test;
163#[cfg(all(not(target_arch = "wasm32"), feature = "transport-moq"))]
164pub mod native_moq_carrier;
165#[cfg(all(not(target_arch = "wasm32"), feature = "transport-webrtc"))]
166pub mod native_webrtc_carrier;
167
168#[cfg(all(target_arch = "wasm32", feature = "iroh-protocols-wasm"))]
169mod wasm_docs_persistence;
170#[cfg(all(target_arch = "wasm32", feature = "iroh-protocols-wasm"))]
171mod wasm_indexeddb_blob_store;
172#[cfg(all(target_arch = "wasm32", feature = "transport-moq"))]
173pub mod wasm_moq_carrier;
174#[cfg(target_arch = "wasm32")]
175pub mod wasm_node;
176#[cfg(all(target_arch = "wasm32", feature = "transport-webrtc"))]
177pub mod wasm_webrtc_carrier;
178
179#[cfg(target_arch = "wasm32")]
180#[macro_export]
181macro_rules! console_log {
182    ($($t:tt)*) => (web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format_args!($($t)*).to_string())))
183}
184
185// WASM entry point bindings
186#[cfg(target_arch = "wasm32")]
187pub mod wasm_api {
188    use crate::client::Client;
189    use crate::session_token::split_ticket;
190    use crate::wasm_node::{peer_uni_stream_from_send, BiStream, PeerUniStream};
191    use iroh_tickets::endpoint::EndpointTicket;
192    use std::cell::RefCell;
193    #[cfg(any(
194        feature = "iroh-protocols-wasm",
195        feature = "transport-webrtc",
196        feature = "transport-moq",
197        feature = "managed-group-encryption"
198    ))]
199    use std::collections::HashMap;
200    #[cfg(any(
201        feature = "iroh-protocols-wasm",
202        feature = "transport-webrtc",
203        feature = "transport-moq",
204        feature = "managed-group-encryption"
205    ))]
206    use std::rc::Rc;
207    use std::str::FromStr;
208    use std::sync::{Arc, Mutex};
209    use wasm_bindgen::prelude::*;
210    #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
211    use wasm_bindgen_futures::spawn_local;
212    use wasm_streams::readable::sys::ReadableStream as JsReadableStream;
213
214    async fn send_native_signal_control_frame(
215        inner: &Client,
216        endpoint_id: String,
217        frame: Vec<u8>,
218    ) -> Result<(), JsValue> {
219        let endpoint_id: iroh::EndpointId = endpoint_id
220            .parse()
221            .map_err(|error| JsValue::from_str(&format!("{error}")))?;
222        inner
223            .ensure_connection_manager_record_before_peer_stream(&endpoint_id)
224            .await
225            .map_err(|error| JsValue::from_str(&error.to_string()))?;
226        let (mut send, _recv) = inner
227            .open_bi_internal(endpoint_id)
228            .await
229            .map_err(|error| JsValue::from_str(&error.to_string()))?;
230        send.write_all(&[0x00])
231            .await
232            .map_err(|error| JsValue::from_str(&error.to_string()))?;
233        send.write_all(&(b"signal".len() as u32).to_be_bytes())
234            .await
235            .map_err(|error| JsValue::from_str(&error.to_string()))?;
236        send.write_all(b"signal")
237            .await
238            .map_err(|error| JsValue::from_str(&error.to_string()))?;
239        send.write_all(&frame)
240            .await
241            .map_err(|error| JsValue::from_str(&error.to_string()))?;
242        crate::application_crypto_streams::PeerSendStream::plain(send)
243            .finish_and_wait_for_peer(std::time::Duration::from_secs(2))
244            .await
245            .map_err(|error| JsValue::from_str(&error.to_string()))
246    }
247
248    #[cfg(feature = "transport-webrtc")]
249    #[derive(Debug, Clone)]
250    struct WasmWebRtcCarrierAttempt {
251        connection_id: String,
252        remote_endpoint_id: iroh::EndpointId,
253        bootstrap: crate::iroh_carrier_bootstrap::CarrierBootstrapFrame,
254        generation: crate::client::WasmPeerDataGeneration,
255        role: &'static str,
256        prepared: bool,
257        retry_sent: bool,
258        offer_started: bool,
259        remote_ready: bool,
260        completion_started: bool,
261        retry_count: u8,
262        inbound_authorization_expires_at_ms: Option<f64>,
263    }
264
265    #[cfg(feature = "transport-moq")]
266    #[derive(Debug, Clone)]
267    struct WasmMoqCarrierAttempt {
268        connection_id: String,
269        remote_endpoint_id: iroh::EndpointId,
270        bootstrap: crate::iroh_carrier_bootstrap::CarrierBootstrapFrame,
271        generation: crate::client::WasmPeerDataGeneration,
272        role: &'static str,
273        prepared: bool,
274        retry_sent: bool,
275        retry_count: u8,
276        inbound_authorization_expires_at_ms: Option<f64>,
277    }
278
279    #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
280    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
281    struct WasmRemoteCarrierCapabilities {
282        webrtc: bool,
283        moq: bool,
284    }
285
286    #[wasm_bindgen]
287    pub struct WasmPeerDatagramPolicy {
288        inner: RefCell<crate::datagrams::PeerDatagramPolicy>,
289    }
290
291    #[wasm_bindgen]
292    impl WasmPeerDatagramPolicy {
293        #[wasm_bindgen(js_name = setIncomingMaxAge)]
294        pub fn set_incoming_max_age(&self, value: Option<u32>, now_ms: f64) {
295            self.inner
296                .borrow_mut()
297                .set_incoming_max_age_ms(value.map(u64::from), now_ms.max(0.0) as u64);
298        }
299
300        #[wasm_bindgen(js_name = setOutgoingMaxAge)]
301        pub fn set_outgoing_max_age(&self, value: Option<u32>) {
302            self.inner
303                .borrow_mut()
304                .set_outgoing_max_age_ms(value.map(u64::from));
305        }
306
307        #[wasm_bindgen(js_name = setIncomingMaxBufferedDatagrams)]
308        pub fn set_incoming_max_buffered_datagrams(&self, value: u32) -> Result<(), JsValue> {
309            self.inner
310                .borrow_mut()
311                .set_incoming_max_buffered(value as usize)
312                .map_err(|error| JsValue::from_str(&error.to_string()))
313        }
314
315        #[wasm_bindgen(js_name = setOutgoingMaxBufferedDatagrams)]
316        pub fn set_outgoing_max_buffered_datagrams(&self, value: u32) -> Result<(), JsValue> {
317            self.inner
318                .borrow_mut()
319                .set_outgoing_max_buffered(value as usize)
320                .map_err(|error| JsValue::from_str(&error.to_string()))
321        }
322
323        #[wasm_bindgen(js_name = pushIncoming)]
324        pub fn push_incoming(&self, payload: Vec<u8>, now_ms: f64) -> Result<(), JsValue> {
325            self.inner
326                .borrow_mut()
327                .push_incoming(payload, now_ms.max(0.0) as u64)
328                .map_err(|error| JsValue::from_str(&error.to_string()))
329        }
330
331        #[wasm_bindgen(js_name = popIncoming)]
332        pub fn pop_incoming(&self, now_ms: f64) -> Option<Vec<u8>> {
333            self.inner.borrow_mut().pop_incoming(now_ms.max(0.0) as u64)
334        }
335
336        #[wasm_bindgen(js_name = pushOutgoing)]
337        pub fn push_outgoing(&self, payload: Vec<u8>, now_ms: f64) -> Result<bool, JsValue> {
338            self.inner
339                .borrow_mut()
340                .push_outgoing(payload, now_ms.max(0.0) as u64)
341                .map_err(|error| JsValue::from_str(&error.to_string()))
342        }
343
344        #[wasm_bindgen(js_name = popOutgoing)]
345        pub fn pop_outgoing(&self, now_ms: f64) -> Result<JsValue, JsValue> {
346            let Some(outgoing) = self.inner.borrow_mut().pop_outgoing(now_ms.max(0.0) as u64)
347            else {
348                return Ok(JsValue::NULL);
349            };
350            let result = js_sys::Object::new();
351            js_sys::Reflect::set(
352                &result,
353                &JsValue::from_str("payload"),
354                &js_sys::Uint8Array::from(outgoing.payload.as_slice()),
355            )?;
356            js_sys::Reflect::set(
357                &result,
358                &JsValue::from_str("remainingMaxAgeMs"),
359                &outgoing
360                    .remaining_max_age_ms
361                    .map(|value| JsValue::from_f64(value as f64))
362                    .unwrap_or(JsValue::NULL),
363            )?;
364            Ok(result.into())
365        }
366
367        #[wasm_bindgen(js_name = recordSent)]
368        pub fn record_sent(&self, bytes: usize) {
369            self.inner.borrow_mut().record_sent(bytes);
370        }
371
372        #[wasm_bindgen(js_name = recordExpiredOutgoing)]
373        pub fn record_expired_outgoing(&self) {
374            self.inner.borrow_mut().record_expired_outgoing();
375        }
376
377        #[wasm_bindgen(js_name = recordSendFailure)]
378        pub fn record_send_failure(&self) {
379            self.inner.borrow_mut().record_send_failure();
380        }
381
382        #[wasm_bindgen(js_name = getStats)]
383        pub fn get_stats(&self, now_ms: f64) -> Result<JsValue, JsValue> {
384            let stats = self.inner.borrow_mut().stats(now_ms.max(0.0) as u64);
385            serde::Serialize::serialize(&stats, &serde_wasm_bindgen::Serializer::json_compatible())
386                .map_err(|error| JsValue::from_str(&error.to_string()))
387        }
388
389        #[wasm_bindgen(js_name = closeIncoming)]
390        pub fn close_incoming(&self) {
391            self.inner.borrow_mut().close_incoming();
392        }
393
394        #[wasm_bindgen(js_name = closeOutgoing)]
395        pub fn close_outgoing(&self) {
396            self.inner.borrow_mut().close_outgoing();
397        }
398
399        pub fn close(&self) {
400            self.inner.borrow_mut().close();
401        }
402    }
403
404    #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
405    fn emit_wasm_carrier_action(
406        handler: &Rc<RefCell<Option<js_sys::Function>>>,
407        action: serde_json::Value,
408    ) {
409        let Some(handler) = handler.borrow().as_ref().cloned() else {
410            return;
411        };
412        // `serde_json::Value::Object` is a Serde map. The default
413        // serde-wasm-bindgen serializer turns maps into JavaScript `Map`s,
414        // while the browser adapter consumes a discriminated plain object.
415        // Keep this internal ABI JSON-compatible so `action.type` and the
416        // remaining carrier fields are visible to TypeScript.
417        let Ok(value) = serde::Serialize::serialize(
418            &action,
419            &serde_wasm_bindgen::Serializer::json_compatible(),
420        ) else {
421            return;
422        };
423        if let Err(error) = handler.call1(&JsValue::UNDEFINED, &value) {
424            web_sys::console::error_2(
425                &JsValue::from_str("[OpenRTC][WASM carrier] action handler failed"),
426                &error,
427            );
428        }
429    }
430
431    #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
432    fn wasm_carrier_failure_code(error: &anyhow::Error) -> &'static str {
433        let message = format!("{error:#}");
434        if message.contains("base generation changed before candidate acknowledgement")
435            || message.contains("incumbent generation changed")
436            || message.contains("replacement incumbent is stale")
437        {
438            "carrier-base-generation-stale"
439        } else if message.contains("authorization epoch changed") {
440            "carrier-authorization-stale"
441        } else if message.contains("stale before atomic commit")
442            || message.contains("became stale during atomic commit")
443        {
444            "carrier-logical-generation-stale"
445        } else if message.contains("attempt was retired") || message.contains("retired upgrade") {
446            "carrier-attempt-retired"
447        } else {
448            "carrier-proof-failed"
449        }
450    }
451
452    #[wasm_bindgen]
453    pub struct WasmClient {
454        inner: Arc<Client>,
455        portable_media: RefCell<crate::media::PortableMediaSession>,
456        broadcast_sessions: RefCell<HashMap<String, crate::broadcast::BroadcastSession>>,
457        broadcast_signers: RefCell<HashMap<String, crate::broadcast::BroadcastPublisherSigner>>,
458        identity_credential: Arc<Mutex<Option<String>>>,
459        last_auth_log: Arc<Mutex<Option<(bool, usize)>>>,
460        #[cfg(feature = "iroh-protocols-wasm")]
461        persistent_protocols:
462            Rc<tokio::sync::Mutex<Option<crate::wasm_docs_persistence::WasmPersistentDocsActor>>>,
463        #[cfg(feature = "managed-group-encryption")]
464        managed_group_controllers:
465            Rc<RefCell<HashMap<String, crate::managed_group_controller::ManagedGroupController>>>,
466        #[cfg(feature = "transport-webrtc")]
467        wasm_webrtc_carrier_sessions:
468            Rc<RefCell<HashMap<String, crate::wasm_webrtc_carrier::WasmWebRtcCarrierSession>>>,
469        #[cfg(feature = "transport-webrtc")]
470        wasm_webrtc_carrier_attempts: Rc<RefCell<HashMap<String, WasmWebRtcCarrierAttempt>>>,
471        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
472        wasm_carrier_action_handler: Rc<RefCell<Option<js_sys::Function>>>,
473        #[cfg(feature = "transport-moq")]
474        wasm_moq_carrier_sessions:
475            Rc<RefCell<HashMap<String, crate::wasm_moq_carrier::WasmMoqCarrierSession>>>,
476        #[cfg(feature = "transport-moq")]
477        wasm_moq_carrier_attempts: Rc<RefCell<HashMap<String, WasmMoqCarrierAttempt>>>,
478        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
479        wasm_remote_carrier_capabilities:
480            Rc<RefCell<HashMap<String, WasmRemoteCarrierCapabilities>>>,
481        /// Serializes capability observation, carrier selection, and logical
482        /// peer retirement. Browser message delivery may start overlapping
483        /// async calls on the single WASM thread; one Rust owner must still
484        /// decide their order so a late handshake cannot resurrect a retired
485        /// peer or start a carrier from stale capability arguments.
486        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
487        wasm_carrier_peer_lifecycle: Rc<tokio::sync::Mutex<()>>,
488    }
489
490    #[cfg(feature = "transport-webrtc")]
491    async fn fail_wasm_webrtc_attempt(
492        inner: Arc<Client>,
493        attempts: Rc<RefCell<HashMap<String, WasmWebRtcCarrierAttempt>>>,
494        sessions: Rc<
495            RefCell<HashMap<String, crate::wasm_webrtc_carrier::WasmWebRtcCarrierSession>>,
496        >,
497        handler: Rc<RefCell<Option<js_sys::Function>>>,
498        attempt: WasmWebRtcCarrierAttempt,
499        failure_code: &'static str,
500        notify_peer: bool,
501    ) {
502        let kind = crate::client::IrohPathKind::WebRtc;
503        if !inner
504            .retire_wasm_carrier_upgrade(
505                &attempt.connection_id,
506                kind,
507                &attempt.bootstrap.upgrade_id,
508                attempt.generation,
509            )
510            .await
511        {
512            return;
513        }
514        if attempts
515            .borrow()
516            .get(&attempt.connection_id)
517            .is_some_and(|current| current.bootstrap.upgrade_id == attempt.bootstrap.upgrade_id)
518        {
519            attempts.borrow_mut().remove(&attempt.connection_id);
520        }
521        sessions.borrow_mut().remove(&attempt.bootstrap.upgrade_id);
522        if notify_peer {
523            if let Ok(failed) = crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::failed_from(
524                &attempt.bootstrap,
525                failure_code,
526            ) {
527                emit_wasm_carrier_action(
528                    &handler,
529                    serde_json::json!({
530                        "type": "send-control",
531                        "connectionId": attempt.connection_id,
532                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
533                        "envelope": failed,
534                    }),
535                );
536            }
537        }
538        emit_wasm_carrier_action(
539            &handler,
540            serde_json::json!({
541                "type": "retire-webrtc",
542                "connectionId": attempt.connection_id,
543                "upgradeId": attempt.bootstrap.upgrade_id,
544                "failureCode": failure_code,
545            }),
546        );
547        let retry_pending = attempt.retry_count == 0
548            && matches!(
549                failure_code,
550                "data-channel-failed"
551                    | "ice-failed"
552                    | "signaling-failed"
553                    | "carrier-base-generation-stale"
554            );
555        if attempt.role == "initiator" && !retry_pending {
556            emit_wasm_carrier_action(
557                &handler,
558                serde_json::json!({
559                    "type": "advance-carrier",
560                    "connectionId": attempt.connection_id,
561                    "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
562                    "failedRoute": "webrtc",
563                }),
564            );
565        }
566    }
567
568    #[cfg(feature = "transport-webrtc")]
569    async fn retire_selected_wasm_webrtc_carrier(
570        inner: Arc<Client>,
571        attempts: Rc<RefCell<HashMap<String, WasmWebRtcCarrierAttempt>>>,
572        sessions: Rc<
573            RefCell<HashMap<String, crate::wasm_webrtc_carrier::WasmWebRtcCarrierSession>>,
574        >,
575        handler: Rc<RefCell<Option<js_sys::Function>>>,
576        attempt: &WasmWebRtcCarrierAttempt,
577        failure_code: &'static str,
578        lifecycle_reason: &'static str,
579    ) -> bool {
580        if !inner
581            .close_current_iroh_carrier_generation_with_reason(
582                &attempt.connection_id,
583                attempt.remote_endpoint_id,
584                crate::client::IrohPathKind::WebRtc,
585                attempt.generation.transport_generation.saturating_add(1),
586                lifecycle_reason,
587            )
588            .await
589        {
590            return false;
591        }
592        web_sys::console::warn_1(&JsValue::from_str(&format!(
593            "[OpenRTC][WebRTC carrier] selected mechanism ended connection_id={} upgrade_id={} failure_code={failure_code}",
594            attempt.connection_id, attempt.bootstrap.upgrade_id,
595        )));
596        if attempts
597            .borrow()
598            .get(&attempt.connection_id)
599            .is_some_and(|current| current.bootstrap.upgrade_id == attempt.bootstrap.upgrade_id)
600        {
601            attempts.borrow_mut().remove(&attempt.connection_id);
602        }
603        sessions.borrow_mut().remove(&attempt.bootstrap.upgrade_id);
604        emit_wasm_carrier_action(
605            &handler,
606            serde_json::json!({
607                "type": "retire-webrtc",
608                "connectionId": attempt.connection_id,
609                "upgradeId": attempt.bootstrap.upgrade_id,
610                "failureCode": failure_code,
611            }),
612        );
613        true
614    }
615
616    #[cfg(feature = "transport-webrtc")]
617    impl WasmClient {
618        fn schedule_wasm_webrtc_carrier_watchdog(
619            &self,
620            bootstrap: crate::iroh_carrier_bootstrap::CarrierBootstrapFrame,
621        ) {
622            let inner = self.inner.clone();
623            let attempts = self.wasm_webrtc_carrier_attempts.clone();
624            let sessions = self.wasm_webrtc_carrier_sessions.clone();
625            let handler = self.wasm_carrier_action_handler.clone();
626            let lifecycle = self.wasm_carrier_peer_lifecycle.clone();
627            spawn_local(async move {
628                gloo_timers::future::sleep(std::time::Duration::from_secs(30)).await;
629                let _lifecycle = lifecycle.lock().await;
630                let attempt = attempts
631                    .borrow()
632                    .values()
633                    .find(|attempt| attempt.bootstrap.upgrade_id == bootstrap.upgrade_id)
634                    .cloned();
635                if let Some(attempt) = attempt {
636                    if !inner
637                        .wasm_carrier_upgrade_is_current(
638                            &attempt.connection_id,
639                            crate::client::IrohPathKind::WebRtc,
640                            &attempt.bootstrap.upgrade_id,
641                            attempt.generation,
642                        )
643                        .await
644                    {
645                        return;
646                    }
647                    fail_wasm_webrtc_attempt(
648                        inner,
649                        attempts,
650                        sessions,
651                        handler,
652                        attempt,
653                        "carrier-timeout",
654                        true,
655                    )
656                    .await;
657                }
658            });
659        }
660
661        async fn fail_wasm_webrtc_carrier_attempt(
662            &self,
663            attempt: WasmWebRtcCarrierAttempt,
664            failure_code: &'static str,
665            notify_peer: bool,
666        ) {
667            fail_wasm_webrtc_attempt(
668                self.inner.clone(),
669                self.wasm_webrtc_carrier_attempts.clone(),
670                self.wasm_webrtc_carrier_sessions.clone(),
671                self.wasm_carrier_action_handler.clone(),
672                attempt,
673                failure_code,
674                notify_peer,
675            )
676            .await;
677        }
678
679        fn spawn_outbound_wasm_webrtc_carrier_completion(&self, attempt: WasmWebRtcCarrierAttempt) {
680            let inner = self.inner.clone();
681            let attempts = self.wasm_webrtc_carrier_attempts.clone();
682            let sessions = self.wasm_webrtc_carrier_sessions.clone();
683            let handler = self.wasm_carrier_action_handler.clone();
684            let lifecycle = self.wasm_carrier_peer_lifecycle.clone();
685            spawn_local(async move {
686                let kind = crate::client::IrohPathKind::WebRtc;
687                let result = inner
688                    .complete_outbound_wasm_carrier_upgrade(
689                        &attempt.connection_id,
690                        &attempt.remote_endpoint_id.to_string(),
691                        &attempt.bootstrap.upgrade_id,
692                        attempt.generation,
693                        kind,
694                    )
695                    .await;
696                let _lifecycle = lifecycle.lock().await;
697                if let Err(error) = result {
698                    let failure_code = wasm_carrier_failure_code(&error);
699                    web_sys::console::error_1(&JsValue::from_str(&format!(
700                        "[OpenRTC][WebRTC carrier] outbound candidate completion failed connection_id={} upgrade_id={} failure_code={} error={error:#}",
701                        attempt.connection_id, attempt.bootstrap.upgrade_id, failure_code,
702                    )));
703                    fail_wasm_webrtc_attempt(
704                        inner,
705                        attempts,
706                        sessions,
707                        handler,
708                        attempt,
709                        failure_code,
710                        true,
711                    )
712                    .await;
713                    return;
714                }
715                if !inner
716                    .retire_wasm_carrier_upgrade(
717                        &attempt.connection_id,
718                        kind,
719                        &attempt.bootstrap.upgrade_id,
720                        attempt.generation,
721                    )
722                    .await
723                {
724                    return;
725                }
726                emit_wasm_carrier_action(
727                    &handler,
728                    serde_json::json!({
729                        "type": "selected",
730                        "connectionId": attempt.connection_id,
731                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
732                        "upgradeId": attempt.bootstrap.upgrade_id,
733                        "family": "iroh",
734                        "carrier": "webrtc",
735                        "transportGeneration": attempt.generation.transport_generation.saturating_add(1),
736                        "routeGeneration": 0,
737                    }),
738                );
739            });
740        }
741
742        fn take_ready_outbound_wasm_webrtc_carrier_attempt(
743            &self,
744            connection_id: &str,
745            upgrade_id: &str,
746        ) -> Option<WasmWebRtcCarrierAttempt> {
747            if !self
748                .wasm_webrtc_carrier_sessions
749                .borrow()
750                .contains_key(upgrade_id)
751            {
752                return None;
753            }
754            let mut attempts = self.wasm_webrtc_carrier_attempts.borrow_mut();
755            let attempt = attempts.get_mut(connection_id).filter(|attempt| {
756                attempt.bootstrap.upgrade_id == upgrade_id
757                    && attempt.role == "initiator"
758                    && attempt.remote_ready
759                    && !attempt.completion_started
760            })?;
761            attempt.completion_started = true;
762            Some(attempt.clone())
763        }
764
765        fn spawn_inbound_wasm_webrtc_carrier_completion(&self, attempt: WasmWebRtcCarrierAttempt) {
766            let inner = self.inner.clone();
767            let attempts = self.wasm_webrtc_carrier_attempts.clone();
768            let sessions = self.wasm_webrtc_carrier_sessions.clone();
769            let handler = self.wasm_carrier_action_handler.clone();
770            let lifecycle = self.wasm_carrier_peer_lifecycle.clone();
771            spawn_local(async move {
772                let kind = crate::client::IrohPathKind::WebRtc;
773                let node = inner.iroh_node.read().await.as_ref().cloned();
774                let result = async {
775                    let node = node.ok_or_else(|| anyhow::anyhow!("Iroh node is unavailable"))?;
776                    let authorization_expires_at_ms = attempt
777                        .inbound_authorization_expires_at_ms
778                        .ok_or_else(|| anyhow::anyhow!("browser WebRTC inbound authorization is missing"))?;
779                    let candidate = node
780                        .wait_for_inbound_replacement_candidate(
781                            attempt.remote_endpoint_id,
782                            crate::iroh_carrier_kind::EXPERIMENTAL_WEBRTC_TRANSPORT_ID,
783                            authorization_expires_at_ms,
784                            std::time::Duration::from_secs(40),
785                        )
786                        .await?;
787                    let policy_epoch = inner
788                        .require_current_wasm_carrier_upgrade_fence(
789                            &attempt.connection_id,
790                            kind,
791                            &attempt.bootstrap.upgrade_id,
792                            attempt.generation,
793                        )
794                        .await?;
795                    let authorization_fence = inner
796                        .capture_wasm_iroh_carrier_authorization_fence(
797                            &attempt.connection_id,
798                            attempt.generation,
799                            kind,
800                            policy_epoch,
801                        )?;
802                    let proof = inner.wasm_candidate_proof_probe(
803                        &attempt.connection_id,
804                        &attempt.bootstrap.upgrade_id,
805                        attempt.bootstrap.base.transport_generation,
806                        attempt.bootstrap.base.route_generation,
807                        kind,
808                        crate::iroh_carrier_kind::EXPERIMENTAL_WEBRTC_TRANSPORT_ID,
809                    )?;
810                    let (send, probe, ack) = inner
811                        .receive_inbound_wasm_carrier_candidate_proof(
812                            &attempt.connection_id,
813                            &candidate,
814                            &proof,
815                        )
816                        .await?;
817                    anyhow::ensure!(
818                        inner
819                            .current_wasm_peer_data_generation(&attempt.connection_id, None)
820                            .await
821                            == Some(attempt.generation),
822                        "browser WebRTC inbound carrier base generation changed before candidate acknowledgement"
823                    );
824                    inner
825                        .send_inbound_wasm_carrier_candidate_ack(send, &ack)
826                        .await?;
827                    let (commit_send, committed) = inner
828                        .receive_inbound_wasm_carrier_commit(
829                            &attempt.connection_id,
830                            &candidate,
831                            &probe,
832                        )
833                        .await?;
834                    let committed_replacement = inner
835                        .commit_proven_wasm_carrier_candidate(
836                            &attempt.connection_id,
837                            &attempt.bootstrap.upgrade_id,
838                            attempt.generation,
839                            kind,
840                            authorization_fence,
841                            candidate,
842                        )
843                        .await?;
844                    inner
845                        .send_inbound_wasm_carrier_candidate_ack(commit_send, &committed)
846                        .await?;
847                    let replacement_transport_stable_id = committed_replacement
848                        .logical_result()
849                        .transport_stable_id
850                        .ok_or_else(|| {
851                            anyhow::anyhow!(
852                                "browser WebRTC replacement has no stable ID"
853                            )
854                        })?;
855                    committed_replacement.finish(b"wasm-custom-transport-upgrade");
856                    inner
857                        .publish_committed_wasm_carrier_route(
858                            &attempt.connection_id,
859                            kind,
860                            replacement_transport_stable_id,
861                        )
862                        .await?;
863                    Ok::<(), anyhow::Error>(())
864                }
865                .await;
866                if let Some(expires_at_ms) = attempt.inbound_authorization_expires_at_ms {
867                    if let Some(node) = inner.iroh_node.read().await.as_ref().cloned() {
868                        node.revoke_inbound_replacement_if_current(
869                            attempt.remote_endpoint_id,
870                            crate::iroh_carrier_kind::EXPERIMENTAL_WEBRTC_TRANSPORT_ID,
871                            expires_at_ms,
872                        )
873                        .await;
874                    }
875                }
876                let _lifecycle = lifecycle.lock().await;
877                if let Err(error) = result {
878                    let failure_code = wasm_carrier_failure_code(&error);
879                    web_sys::console::error_1(&JsValue::from_str(&format!(
880                        "[OpenRTC][WebRTC carrier] inbound candidate completion failed connection_id={} upgrade_id={} failure_code={} error={error:#}",
881                        attempt.connection_id, attempt.bootstrap.upgrade_id, failure_code,
882                    )));
883                    fail_wasm_webrtc_attempt(
884                        inner,
885                        attempts,
886                        sessions,
887                        handler,
888                        attempt,
889                        failure_code,
890                        true,
891                    )
892                    .await;
893                    return;
894                }
895                if !inner
896                    .retire_wasm_carrier_upgrade(
897                        &attempt.connection_id,
898                        kind,
899                        &attempt.bootstrap.upgrade_id,
900                        attempt.generation,
901                    )
902                    .await
903                {
904                    return;
905                }
906                emit_wasm_carrier_action(
907                    &handler,
908                    serde_json::json!({
909                        "type": "selected",
910                        "connectionId": attempt.connection_id,
911                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
912                        "upgradeId": attempt.bootstrap.upgrade_id,
913                        "family": "iroh",
914                        "carrier": "webrtc",
915                        "transportGeneration": attempt.generation.transport_generation.saturating_add(1),
916                        "routeGeneration": 0,
917                    }),
918                );
919            });
920        }
921    }
922
923    #[cfg(feature = "transport-moq")]
924    fn wasm_moq_carrier_namespaces(
925        local_endpoint_id: &str,
926        remote_endpoint_id: &str,
927        carrier_session_id: &str,
928    ) -> (String, String, &'static str) {
929        let (first, second) = if local_endpoint_id <= remote_endpoint_id {
930            (local_endpoint_id, remote_endpoint_id)
931        } else {
932            (remote_endpoint_id, local_endpoint_id)
933        };
934        let base = format!("openrtc/iroh-carrier/moq/{first}/{second}/{carrier_session_id}");
935        (
936            format!("{base}/from/{local_endpoint_id}"),
937            format!("{base}/from/{remote_endpoint_id}"),
938            "iroh-packets",
939        )
940    }
941
942    #[cfg(feature = "transport-moq")]
943    async fn fail_wasm_moq_attempt(
944        inner: Arc<Client>,
945        attempts: Rc<RefCell<HashMap<String, WasmMoqCarrierAttempt>>>,
946        sessions: Rc<RefCell<HashMap<String, crate::wasm_moq_carrier::WasmMoqCarrierSession>>>,
947        handler: Rc<RefCell<Option<js_sys::Function>>>,
948        attempt: WasmMoqCarrierAttempt,
949        failure_code: &'static str,
950        notify_peer: bool,
951    ) {
952        let kind = crate::client::IrohPathKind::Moq;
953        if !inner
954            .retire_wasm_carrier_upgrade(
955                &attempt.connection_id,
956                kind,
957                &attempt.bootstrap.upgrade_id,
958                attempt.generation,
959            )
960            .await
961        {
962            return;
963        }
964        if attempts
965            .borrow()
966            .get(&attempt.connection_id)
967            .is_some_and(|current| current.bootstrap.upgrade_id == attempt.bootstrap.upgrade_id)
968        {
969            attempts.borrow_mut().remove(&attempt.connection_id);
970        }
971        sessions.borrow_mut().remove(&attempt.bootstrap.upgrade_id);
972        if notify_peer {
973            if let Ok(failed) = crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::failed_from(
974                &attempt.bootstrap,
975                failure_code,
976            ) {
977                emit_wasm_carrier_action(
978                    &handler,
979                    serde_json::json!({
980                        "type": "send-control",
981                        "connectionId": attempt.connection_id,
982                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
983                        "envelope": failed,
984                    }),
985                );
986            }
987        }
988        emit_wasm_carrier_action(
989            &handler,
990            serde_json::json!({
991                "type": "retire-moq",
992                "connectionId": attempt.connection_id,
993                "upgradeId": attempt.bootstrap.upgrade_id,
994                "failureCode": failure_code,
995            }),
996        );
997        let retry_pending = attempt.role == "initiator"
998            && attempt.retry_count == 0
999            && failure_code == "carrier-base-generation-stale";
1000        if attempt.role == "initiator" && !retry_pending {
1001            emit_wasm_carrier_action(
1002                &handler,
1003                serde_json::json!({
1004                    "type": "advance-carrier",
1005                    "connectionId": attempt.connection_id,
1006                    "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
1007                    "failedRoute": "moq",
1008                }),
1009            );
1010        }
1011    }
1012
1013    #[cfg(feature = "transport-moq")]
1014    async fn retire_selected_wasm_moq_carrier(
1015        inner: Arc<Client>,
1016        attempts: Rc<RefCell<HashMap<String, WasmMoqCarrierAttempt>>>,
1017        sessions: Rc<RefCell<HashMap<String, crate::wasm_moq_carrier::WasmMoqCarrierSession>>>,
1018        handler: Rc<RefCell<Option<js_sys::Function>>>,
1019        attempt: &WasmMoqCarrierAttempt,
1020        failure_code: &'static str,
1021        lifecycle_reason: &'static str,
1022    ) -> bool {
1023        if !inner
1024            .close_current_iroh_carrier_generation_with_reason(
1025                &attempt.connection_id,
1026                attempt.remote_endpoint_id,
1027                crate::client::IrohPathKind::Moq,
1028                attempt.generation.transport_generation.saturating_add(1),
1029                lifecycle_reason,
1030            )
1031            .await
1032        {
1033            return false;
1034        }
1035        web_sys::console::warn_1(&JsValue::from_str(&format!(
1036            "[OpenRTC][MoQ carrier] selected mechanism ended connection_id={} upgrade_id={} failure_code={failure_code}",
1037            attempt.connection_id, attempt.bootstrap.upgrade_id,
1038        )));
1039        if attempts
1040            .borrow()
1041            .get(&attempt.connection_id)
1042            .is_some_and(|current| current.bootstrap.upgrade_id == attempt.bootstrap.upgrade_id)
1043        {
1044            attempts.borrow_mut().remove(&attempt.connection_id);
1045        }
1046        sessions.borrow_mut().remove(&attempt.bootstrap.upgrade_id);
1047        emit_wasm_carrier_action(
1048            &handler,
1049            serde_json::json!({
1050                "type": "retire-moq",
1051                "connectionId": attempt.connection_id,
1052                "upgradeId": attempt.bootstrap.upgrade_id,
1053                "failureCode": failure_code,
1054            }),
1055        );
1056        true
1057    }
1058
1059    #[cfg(feature = "transport-moq")]
1060    impl WasmClient {
1061        fn schedule_wasm_moq_carrier_watchdog(
1062            &self,
1063            bootstrap: crate::iroh_carrier_bootstrap::CarrierBootstrapFrame,
1064        ) {
1065            let inner = self.inner.clone();
1066            let attempts = self.wasm_moq_carrier_attempts.clone();
1067            let sessions = self.wasm_moq_carrier_sessions.clone();
1068            let handler = self.wasm_carrier_action_handler.clone();
1069            let lifecycle = self.wasm_carrier_peer_lifecycle.clone();
1070            spawn_local(async move {
1071                gloo_timers::future::sleep(std::time::Duration::from_secs(30)).await;
1072                let _lifecycle = lifecycle.lock().await;
1073                let attempt = attempts
1074                    .borrow()
1075                    .values()
1076                    .find(|attempt| attempt.bootstrap.upgrade_id == bootstrap.upgrade_id)
1077                    .cloned();
1078                if let Some(attempt) = attempt {
1079                    if !inner
1080                        .wasm_carrier_upgrade_is_current(
1081                            &attempt.connection_id,
1082                            crate::client::IrohPathKind::Moq,
1083                            &attempt.bootstrap.upgrade_id,
1084                            attempt.generation,
1085                        )
1086                        .await
1087                    {
1088                        return;
1089                    }
1090                    fail_wasm_moq_attempt(
1091                        inner,
1092                        attempts,
1093                        sessions,
1094                        handler,
1095                        attempt,
1096                        "carrier-timeout",
1097                        true,
1098                    )
1099                    .await;
1100                }
1101            });
1102        }
1103
1104        async fn fail_wasm_moq_carrier_attempt(
1105            &self,
1106            attempt: WasmMoqCarrierAttempt,
1107            failure_code: &'static str,
1108            notify_peer: bool,
1109        ) {
1110            fail_wasm_moq_attempt(
1111                self.inner.clone(),
1112                self.wasm_moq_carrier_attempts.clone(),
1113                self.wasm_moq_carrier_sessions.clone(),
1114                self.wasm_carrier_action_handler.clone(),
1115                attempt,
1116                failure_code,
1117                notify_peer,
1118            )
1119            .await;
1120        }
1121
1122        fn spawn_outbound_wasm_moq_carrier_completion(&self, attempt: WasmMoqCarrierAttempt) {
1123            let inner = self.inner.clone();
1124            let attempts = self.wasm_moq_carrier_attempts.clone();
1125            let sessions = self.wasm_moq_carrier_sessions.clone();
1126            let handler = self.wasm_carrier_action_handler.clone();
1127            let lifecycle = self.wasm_carrier_peer_lifecycle.clone();
1128            spawn_local(async move {
1129                let kind = crate::client::IrohPathKind::Moq;
1130                let result = inner
1131                    .complete_outbound_wasm_carrier_upgrade(
1132                        &attempt.connection_id,
1133                        &attempt.remote_endpoint_id.to_string(),
1134                        &attempt.bootstrap.upgrade_id,
1135                        attempt.generation,
1136                        kind,
1137                    )
1138                    .await;
1139                let _lifecycle = lifecycle.lock().await;
1140                if let Err(error) = result {
1141                    let failure_code = wasm_carrier_failure_code(&error);
1142                    web_sys::console::error_1(&JsValue::from_str(&format!(
1143                        "[OpenRTC][MoQ carrier] outbound candidate completion failed connection_id={} upgrade_id={} failure_code={} error={error:#}",
1144                        attempt.connection_id, attempt.bootstrap.upgrade_id, failure_code,
1145                    )));
1146                    fail_wasm_moq_attempt(
1147                        inner,
1148                        attempts,
1149                        sessions,
1150                        handler,
1151                        attempt,
1152                        failure_code,
1153                        true,
1154                    )
1155                    .await;
1156                    return;
1157                }
1158                if !inner
1159                    .retire_wasm_carrier_upgrade(
1160                        &attempt.connection_id,
1161                        kind,
1162                        &attempt.bootstrap.upgrade_id,
1163                        attempt.generation,
1164                    )
1165                    .await
1166                {
1167                    return;
1168                }
1169                emit_wasm_carrier_action(
1170                    &handler,
1171                    serde_json::json!({
1172                        "type": "selected",
1173                        "connectionId": attempt.connection_id,
1174                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
1175                        "upgradeId": attempt.bootstrap.upgrade_id,
1176                        "family": "iroh",
1177                        "carrier": "moq",
1178                        "transportGeneration": attempt.generation.transport_generation.saturating_add(1),
1179                        "routeGeneration": 0,
1180                    }),
1181                );
1182            });
1183        }
1184
1185        fn spawn_inbound_wasm_moq_carrier_completion(&self, attempt: WasmMoqCarrierAttempt) {
1186            let inner = self.inner.clone();
1187            let attempts = self.wasm_moq_carrier_attempts.clone();
1188            let sessions = self.wasm_moq_carrier_sessions.clone();
1189            let handler = self.wasm_carrier_action_handler.clone();
1190            let lifecycle = self.wasm_carrier_peer_lifecycle.clone();
1191            spawn_local(async move {
1192                let kind = crate::client::IrohPathKind::Moq;
1193                let node = inner.iroh_node.read().await.as_ref().cloned();
1194                let result = async {
1195                    let node = node.ok_or_else(|| anyhow::anyhow!("Iroh node is unavailable"))?;
1196                    let authorization_expires_at_ms = attempt
1197                        .inbound_authorization_expires_at_ms
1198                        .ok_or_else(|| anyhow::anyhow!("browser MoQ inbound authorization is missing"))?;
1199                    let candidate = node
1200                        .wait_for_inbound_replacement_candidate(
1201                            attempt.remote_endpoint_id,
1202                            crate::iroh_carrier_kind::EXPERIMENTAL_MOQ_TRANSPORT_ID,
1203                            authorization_expires_at_ms,
1204                            std::time::Duration::from_secs(40),
1205                        )
1206                        .await?;
1207                    let policy_epoch = inner
1208                        .require_current_wasm_carrier_upgrade_fence(
1209                            &attempt.connection_id,
1210                            kind,
1211                            &attempt.bootstrap.upgrade_id,
1212                            attempt.generation,
1213                        )
1214                        .await?;
1215                    let authorization_fence = inner
1216                        .capture_wasm_iroh_carrier_authorization_fence(
1217                            &attempt.connection_id,
1218                            attempt.generation,
1219                            kind,
1220                            policy_epoch,
1221                        )?;
1222                    let proof = inner.wasm_candidate_proof_probe(
1223                        &attempt.connection_id,
1224                        &attempt.bootstrap.upgrade_id,
1225                        attempt.bootstrap.base.transport_generation,
1226                        attempt.bootstrap.base.route_generation,
1227                        kind,
1228                        crate::iroh_carrier_kind::EXPERIMENTAL_MOQ_TRANSPORT_ID,
1229                    )?;
1230                    let (send, probe, ack) = inner
1231                        .receive_inbound_wasm_carrier_candidate_proof(
1232                            &attempt.connection_id,
1233                            &candidate,
1234                            &proof,
1235                        )
1236                        .await?;
1237                    anyhow::ensure!(
1238                        inner
1239                            .current_wasm_peer_data_generation(&attempt.connection_id, None)
1240                            .await
1241                            == Some(attempt.generation),
1242                        "browser MoQ inbound carrier base generation changed before candidate acknowledgement"
1243                    );
1244                    inner
1245                        .send_inbound_wasm_carrier_candidate_ack(send, &ack)
1246                        .await?;
1247                    let (commit_send, committed) = inner
1248                        .receive_inbound_wasm_carrier_commit(
1249                            &attempt.connection_id,
1250                            &candidate,
1251                            &probe,
1252                        )
1253                        .await?;
1254                    let committed_replacement = inner
1255                        .commit_proven_wasm_carrier_candidate(
1256                            &attempt.connection_id,
1257                            &attempt.bootstrap.upgrade_id,
1258                            attempt.generation,
1259                            kind,
1260                            authorization_fence,
1261                            candidate,
1262                        )
1263                        .await?;
1264                    inner
1265                        .send_inbound_wasm_carrier_candidate_ack(commit_send, &committed)
1266                        .await?;
1267                    let replacement_transport_stable_id = committed_replacement
1268                        .logical_result()
1269                        .transport_stable_id
1270                        .ok_or_else(|| {
1271                            anyhow::anyhow!("browser MoQ replacement has no stable ID")
1272                        })?;
1273                    committed_replacement.finish(b"wasm-custom-transport-upgrade");
1274                    inner
1275                        .publish_committed_wasm_carrier_route(
1276                            &attempt.connection_id,
1277                            kind,
1278                            replacement_transport_stable_id,
1279                        )
1280                        .await?;
1281                    Ok::<(), anyhow::Error>(())
1282                }
1283                .await;
1284                if let Some(expires_at_ms) = attempt.inbound_authorization_expires_at_ms {
1285                    if let Some(node) = inner.iroh_node.read().await.as_ref().cloned() {
1286                        node.revoke_inbound_replacement_if_current(
1287                            attempt.remote_endpoint_id,
1288                            crate::iroh_carrier_kind::EXPERIMENTAL_MOQ_TRANSPORT_ID,
1289                            expires_at_ms,
1290                        )
1291                        .await;
1292                    }
1293                }
1294                let _lifecycle = lifecycle.lock().await;
1295                if let Err(error) = result {
1296                    let failure_code = wasm_carrier_failure_code(&error);
1297                    web_sys::console::error_1(&JsValue::from_str(&format!(
1298                        "[OpenRTC][MoQ carrier] inbound candidate completion failed connection_id={} upgrade_id={} failure_code={} error={error:#}",
1299                        attempt.connection_id, attempt.bootstrap.upgrade_id, failure_code,
1300                    )));
1301                    fail_wasm_moq_attempt(
1302                        inner,
1303                        attempts,
1304                        sessions,
1305                        handler,
1306                        attempt,
1307                        failure_code,
1308                        true,
1309                    )
1310                    .await;
1311                    return;
1312                }
1313                if !inner
1314                    .retire_wasm_carrier_upgrade(
1315                        &attempt.connection_id,
1316                        kind,
1317                        &attempt.bootstrap.upgrade_id,
1318                        attempt.generation,
1319                    )
1320                    .await
1321                {
1322                    return;
1323                }
1324                emit_wasm_carrier_action(
1325                    &handler,
1326                    serde_json::json!({
1327                        "type": "selected",
1328                        "connectionId": attempt.connection_id,
1329                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
1330                        "upgradeId": attempt.bootstrap.upgrade_id,
1331                        "family": "iroh",
1332                        "carrier": "moq",
1333                        "transportGeneration": attempt.generation.transport_generation.saturating_add(1),
1334                        "routeGeneration": 0,
1335                    }),
1336                );
1337            });
1338        }
1339
1340        async fn handle_wasm_moq_bootstrap(
1341            &self,
1342            connection_id: String,
1343            remote_endpoint_id: String,
1344            bootstrap: crate::iroh_carrier_bootstrap::CarrierBootstrapFrame,
1345        ) -> Result<bool, JsValue> {
1346            let endpoint_id = remote_endpoint_id
1347                .parse::<iroh::EndpointId>()
1348                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1349            let kind = crate::client::IrohPathKind::Moq;
1350            match bootstrap.action {
1351                crate::iroh_carrier_bootstrap::CarrierBootstrapAction::Request => {
1352                    let local_endpoint_id = self
1353                        .inner
1354                        .current_node_id()
1355                        .await
1356                        .ok_or_else(|| JsValue::from_str("local endpoint id is unavailable"))?;
1357                    if local_endpoint_id.as_str() >= remote_endpoint_id.as_str()
1358                        || !self.inner.is_moq_carrier_enabled().await
1359                    {
1360                        return Ok(true);
1361                    }
1362                    if !crate::iroh_connection_policy::custom_carrier_base_allows(
1363                        self.inner.iroh_path_kind(&remote_endpoint_id).await,
1364                        crate::client::IrohPathKind::Moq,
1365                        false,
1366                    ) {
1367                        return Ok(true);
1368                    }
1369                    let base_connection = self
1370                        .inner
1371                        .get_connection(endpoint_id)
1372                        .await
1373                        .ok_or_else(|| JsValue::from_str("MoQ carrier base is unavailable"))?;
1374                    let generation = self
1375                        .inner
1376                        .current_wasm_peer_data_generation(&connection_id, None)
1377                        .await
1378                        .ok_or_else(|| {
1379                            JsValue::from_str("MoQ carrier generation is unavailable")
1380                        })?;
1381                    if crate::transport_generation::for_connection(&base_connection)
1382                        != generation.transport_stable_id
1383                    {
1384                        let failed =
1385                            crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::failed_from(
1386                                &bootstrap,
1387                                "carrier-base-generation-stale",
1388                            )
1389                            .map_err(|error| JsValue::from_str(&error.to_string()))?;
1390                        emit_wasm_carrier_action(
1391                            &self.wasm_carrier_action_handler,
1392                            serde_json::json!({
1393                                "type": "send-control",
1394                                "connectionId": connection_id,
1395                                "remoteEndpointId": remote_endpoint_id,
1396                                "envelope": failed,
1397                            }),
1398                        );
1399                        return Ok(true);
1400                    }
1401                    if !matches!(
1402                        self.inner
1403                            .reserve_wasm_carrier_upgrade(
1404                                &connection_id,
1405                                kind,
1406                                &bootstrap.upgrade_id,
1407                                generation,
1408                            )
1409                            .await,
1410                        crate::client::WasmCarrierUpgradeReservation::Reserved
1411                    ) {
1412                        return Ok(true);
1413                    }
1414                    let node = self
1415                        .inner
1416                        .iroh_node
1417                        .read()
1418                        .await
1419                        .as_ref()
1420                        .cloned()
1421                        .ok_or_else(|| JsValue::from_str("Iroh node is unavailable"))?;
1422                    let authorization_expiry = node
1423                        .authorize_pending_inbound_replacement(
1424                            endpoint_id,
1425                            crate::iroh_carrier_kind::EXPERIMENTAL_MOQ_TRANSPORT_ID,
1426                            std::time::Duration::from_secs(45),
1427                        )
1428                        .await;
1429                    let previous = self.wasm_moq_carrier_attempts.borrow_mut().insert(
1430                        connection_id.clone(),
1431                        WasmMoqCarrierAttempt {
1432                            connection_id: connection_id.clone(),
1433                            remote_endpoint_id: endpoint_id,
1434                            bootstrap: bootstrap.clone(),
1435                            generation,
1436                            role: "responder",
1437                            prepared: false,
1438                            retry_sent: false,
1439                            retry_count: bootstrap.attempt.saturating_sub(1),
1440                            inbound_authorization_expires_at_ms: Some(authorization_expiry),
1441                        },
1442                    );
1443                    if let Some(previous) = previous {
1444                        self.wasm_moq_carrier_sessions
1445                            .borrow_mut()
1446                            .remove(&previous.bootstrap.upgrade_id);
1447                    }
1448                    let (publish_namespace, subscribe_namespace, track_name) =
1449                        wasm_moq_carrier_namespaces(
1450                            &local_endpoint_id,
1451                            &remote_endpoint_id,
1452                            &bootstrap.carrier_session_id,
1453                        );
1454                    emit_wasm_carrier_action(
1455                        &self.wasm_carrier_action_handler,
1456                        serde_json::json!({
1457                            "type": "prepare-moq",
1458                            "connectionId": connection_id,
1459                            "remoteEndpointId": remote_endpoint_id,
1460                            "role": "responder",
1461                            "upgradeId": bootstrap.upgrade_id,
1462                            "carrierSessionId": bootstrap.carrier_session_id,
1463                            "transportGeneration": generation.transport_generation.saturating_add(1),
1464                            "publishNamespace": publish_namespace,
1465                            "subscribeNamespace": subscribe_namespace,
1466                            "trackName": track_name,
1467                        }),
1468                    );
1469                    self.schedule_wasm_moq_carrier_watchdog(bootstrap);
1470                }
1471                crate::iroh_carrier_bootstrap::CarrierBootstrapAction::Ready => {
1472                    let attempt = self
1473                        .wasm_moq_carrier_attempts
1474                        .borrow()
1475                        .get(&connection_id)
1476                        .filter(|attempt| bootstrap.is_response_to(&attempt.bootstrap))
1477                        .cloned();
1478                    if let Some(attempt) = attempt {
1479                        emit_wasm_carrier_action(
1480                            &self.wasm_carrier_action_handler,
1481                            serde_json::json!({
1482                                "type": "activate-moq",
1483                                "connectionId": attempt.connection_id,
1484                                "upgradeId": attempt.bootstrap.upgrade_id,
1485                            }),
1486                        );
1487                    }
1488                }
1489                crate::iroh_carrier_bootstrap::CarrierBootstrapAction::Failed => {
1490                    let attempt = self
1491                        .wasm_moq_carrier_attempts
1492                        .borrow()
1493                        .get(&connection_id)
1494                        .filter(|attempt| bootstrap.is_response_to(&attempt.bootstrap))
1495                        .cloned();
1496                    if let Some(attempt) = attempt {
1497                        let failure_code = crate::client::wasm_peer_carrier_failure_code(
1498                            bootstrap.failure_code.as_deref(),
1499                        );
1500                        if crate::client::should_rearm_wasm_carrier_event_retry(
1501                            failure_code,
1502                            attempt.role == "initiator",
1503                            false,
1504                        ) {
1505                            if let Some(current) = self
1506                                .wasm_moq_carrier_attempts
1507                                .borrow_mut()
1508                                .get_mut(&connection_id)
1509                                .filter(|current| {
1510                                    current.bootstrap.upgrade_id == attempt.bootstrap.upgrade_id
1511                                })
1512                            {
1513                                // The peer's current base is not admitted yet.
1514                                // Re-arm this exact attempt so the next Rust
1515                                // settlement/capability event resends it through
1516                                // `begin_iroh_moq_carrier_attempt`; do not create
1517                                // a timer or competing lifecycle.
1518                                current.retry_sent = false;
1519                            }
1520                            return Ok(true);
1521                        }
1522                        let should_retry = attempt.role == "initiator"
1523                            && attempt.retry_count == 0
1524                            && failure_code == "carrier-base-generation-stale";
1525                        let remote_endpoint_id = attempt.remote_endpoint_id.to_string();
1526                        self.fail_wasm_moq_carrier_attempt(attempt, failure_code, false)
1527                            .await;
1528                        if should_retry {
1529                            // Retry once on the current admitted base. The MoQ
1530                            // relay and admitted peer control stream are the only
1531                            // resources touched; gateway coordination is not.
1532                            gloo_timers::future::sleep(std::time::Duration::from_millis(500)).await;
1533                            let _ = self
1534                                .begin_iroh_moq_carrier_attempt(
1535                                    connection_id,
1536                                    remote_endpoint_id,
1537                                    1,
1538                                )
1539                                .await;
1540                        }
1541                    }
1542                }
1543            }
1544            Ok(true)
1545        }
1546    }
1547
1548    #[wasm_bindgen]
1549    impl WasmClient {
1550        /// OpenRTC WASM transport constructor. It accepts only the public
1551        /// API key, derives the app identity locally, and never initializes a
1552        /// Firebase project or performs network work.
1553        #[wasm_bindgen(constructor)]
1554        pub fn new(api_key: String) -> Result<WasmClient, JsValue> {
1555            let api_key = crate::validate_api_key(&api_key)
1556                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1557            let app_tag = crate::app_tag_from_api_key(api_key);
1558            let identity_credential: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
1559            let credential_state = identity_credential.clone();
1560            let identity_credential_provider = Box::new(move || -> Option<String> {
1561                credential_state.lock().ok().and_then(|guard| guard.clone())
1562            });
1563
1564            Ok(Self {
1565                inner: Arc::new(Client::new_provider_neutral(
1566                    app_tag,
1567                    identity_credential_provider,
1568                )),
1569                portable_media: RefCell::new(crate::media::PortableMediaSession::default()),
1570                broadcast_sessions: RefCell::new(HashMap::new()),
1571                broadcast_signers: RefCell::new(HashMap::new()),
1572                identity_credential,
1573                last_auth_log: Arc::new(Mutex::new(None)),
1574                #[cfg(feature = "iroh-protocols-wasm")]
1575                persistent_protocols: Rc::new(tokio::sync::Mutex::new(None)),
1576                #[cfg(feature = "managed-group-encryption")]
1577                managed_group_controllers: Rc::new(RefCell::new(HashMap::new())),
1578                #[cfg(feature = "transport-webrtc")]
1579                wasm_webrtc_carrier_sessions: Rc::new(RefCell::new(HashMap::new())),
1580                #[cfg(feature = "transport-webrtc")]
1581                wasm_webrtc_carrier_attempts: Rc::new(RefCell::new(HashMap::new())),
1582                #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
1583                wasm_carrier_action_handler: Rc::new(RefCell::new(None)),
1584                #[cfg(feature = "transport-moq")]
1585                wasm_moq_carrier_sessions: Rc::new(RefCell::new(HashMap::new())),
1586                #[cfg(feature = "transport-moq")]
1587                wasm_moq_carrier_attempts: Rc::new(RefCell::new(HashMap::new())),
1588                #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
1589                wasm_remote_carrier_capabilities: Rc::new(RefCell::new(HashMap::new())),
1590                #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
1591                wasm_carrier_peer_lifecycle: Rc::new(tokio::sync::Mutex::new(())),
1592            })
1593        }
1594
1595        /// Private managed-room bridge. The browser host supplies an opaque
1596        /// persisted snapshot and a wrapping key recovered through its
1597        /// non-extractable WebCrypto store; public consumers never see either.
1598        #[cfg(feature = "managed-group-encryption")]
1599        #[wasm_bindgen(js_name = __initManagedRoomGroup)]
1600        pub fn init_managed_room_group(
1601            &self,
1602            avenue_key: String,
1603            device_id: String,
1604            wrapping_key: Vec<u8>,
1605            sealed_state: Option<Vec<u8>>,
1606        ) -> Result<JsValue, JsValue> {
1607            let wrapping_key: [u8; 32] = wrapping_key
1608                .try_into()
1609                .map_err(|_| JsValue::from_str("managed room wrapping key must be 32 bytes"))?;
1610            let mut controllers = self.managed_group_controllers.borrow_mut();
1611            if !controllers.contains_key(&avenue_key) {
1612                let controller = crate::managed_group_controller::ManagedGroupController::new(
1613                    device_id,
1614                    wrapping_key,
1615                    sealed_state.as_deref(),
1616                )
1617                .map_err(|error| JsValue::from_str(&format!("{error:#}")))?;
1618                controllers.insert(avenue_key.clone(), controller);
1619            }
1620            let controller = controllers
1621                .get(&avenue_key)
1622                .ok_or_else(|| JsValue::from_str("managed room group initialization failed"))?;
1623            let action = controller
1624                .publish_key_package()
1625                .map_err(|error| JsValue::from_str(&format!("{error:#}")))?;
1626            serde::Serialize::serialize(&action, &serde_wasm_bindgen::Serializer::json_compatible())
1627                .map_err(|error| JsValue::from_str(&error.to_string()))
1628        }
1629
1630        #[cfg(feature = "managed-group-encryption")]
1631        #[wasm_bindgen(js_name = __handleManagedRoomPreparePage)]
1632        pub fn handle_managed_room_prepare_page(
1633            &self,
1634            avenue_key: String,
1635            page: JsValue,
1636        ) -> Result<JsValue, JsValue> {
1637            let page = serde_wasm_bindgen::from_value(page)
1638                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1639            let mut controllers = self.managed_group_controllers.borrow_mut();
1640            let controller = controllers
1641                .get_mut(&avenue_key)
1642                .ok_or_else(|| JsValue::from_str("managed room group is not initialized"))?;
1643            let actions = controller
1644                .handle_prepare_page(page)
1645                .map_err(|error| JsValue::from_str(&format!("{error:#}")))?;
1646            serde::Serialize::serialize(
1647                &actions,
1648                &serde_wasm_bindgen::Serializer::json_compatible(),
1649            )
1650            .map_err(|error| JsValue::from_str(&error.to_string()))
1651        }
1652
1653        #[cfg(feature = "managed-group-encryption")]
1654        #[wasm_bindgen(js_name = __handleManagedRoomArtifactChunk)]
1655        pub fn handle_managed_room_artifact_chunk(
1656            &self,
1657            avenue_key: String,
1658            chunk: JsValue,
1659        ) -> Result<JsValue, JsValue> {
1660            let chunk = serde_wasm_bindgen::from_value(chunk)
1661                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1662            let mut controllers = self.managed_group_controllers.borrow_mut();
1663            let controller = controllers
1664                .get_mut(&avenue_key)
1665                .ok_or_else(|| JsValue::from_str("managed room group is not initialized"))?;
1666            let actions = controller
1667                .handle_artifact_chunk(chunk)
1668                .map_err(|error| JsValue::from_str(&format!("{error:#}")))?;
1669            serde::Serialize::serialize(
1670                &actions,
1671                &serde_wasm_bindgen::Serializer::json_compatible(),
1672            )
1673            .map_err(|error| JsValue::from_str(&error.to_string()))
1674        }
1675
1676        #[cfg(feature = "managed-group-encryption")]
1677        #[wasm_bindgen(js_name = __sealManagedRoomPayload)]
1678        #[allow(clippy::too_many_arguments)]
1679        pub fn seal_managed_room_payload(
1680            &self,
1681            avenue_key: String,
1682            architecture_epoch: u64,
1683            encryption_epoch: u64,
1684            message_id: String,
1685            channel: String,
1686            priority: u8,
1687            zone_id: Option<String>,
1688            payload: Vec<u8>,
1689        ) -> Result<JsValue, JsValue> {
1690            let protected = self
1691                .managed_group_controllers
1692                .borrow_mut()
1693                .get_mut(&avenue_key)
1694                .ok_or_else(|| JsValue::from_str("managed room group is not initialized"))?
1695                .seal_payload(
1696                    architecture_epoch,
1697                    encryption_epoch,
1698                    &message_id,
1699                    &channel,
1700                    priority,
1701                    zone_id.as_deref(),
1702                    &payload,
1703                )
1704                .map_err(|error| JsValue::from_str(&format!("{error:#}")))?;
1705            serde_wasm_bindgen::to_value(&serde_json::json!({
1706                "payload": base64::Engine::encode(
1707                    &base64::engine::general_purpose::URL_SAFE_NO_PAD,
1708                    protected.data,
1709                ),
1710                "sealedState": base64::Engine::encode(
1711                    &base64::engine::general_purpose::URL_SAFE_NO_PAD,
1712                    protected.sealed_state,
1713                ),
1714            }))
1715            .map_err(|error| JsValue::from_str(&error.to_string()))
1716        }
1717
1718        #[cfg(feature = "managed-group-encryption")]
1719        #[wasm_bindgen(js_name = __openManagedRoomPayload)]
1720        #[allow(clippy::too_many_arguments)]
1721        pub fn open_managed_room_payload(
1722            &self,
1723            avenue_key: String,
1724            architecture_epoch: u64,
1725            encryption_epoch: u64,
1726            message_id: String,
1727            channel: String,
1728            priority: u8,
1729            zone_id: Option<String>,
1730            ciphertext: Vec<u8>,
1731        ) -> Result<JsValue, JsValue> {
1732            let protected = self
1733                .managed_group_controllers
1734                .borrow_mut()
1735                .get_mut(&avenue_key)
1736                .ok_or_else(|| JsValue::from_str("managed room group is not initialized"))?
1737                .open_payload(
1738                    architecture_epoch,
1739                    encryption_epoch,
1740                    &message_id,
1741                    &channel,
1742                    priority,
1743                    zone_id.as_deref(),
1744                    &ciphertext,
1745                )
1746                .map_err(|error| JsValue::from_str(&format!("{error:#}")))?;
1747            serde_wasm_bindgen::to_value(&serde_json::json!({
1748                "payload": base64::Engine::encode(
1749                    &base64::engine::general_purpose::URL_SAFE_NO_PAD,
1750                    protected.data,
1751                ),
1752                "sealedState": base64::Engine::encode(
1753                    &base64::engine::general_purpose::URL_SAFE_NO_PAD,
1754                    protected.sealed_state,
1755                ),
1756            }))
1757            .map_err(|error| JsValue::from_str(&error.to_string()))
1758        }
1759
1760        #[cfg(feature = "managed-group-encryption")]
1761        #[wasm_bindgen(js_name = __forgetManagedRoomGroup)]
1762        pub fn forget_managed_room_group(&self, avenue_key: String) {
1763            self.managed_group_controllers
1764                .borrow_mut()
1765                .remove(&avenue_key);
1766        }
1767
1768        /// Request one public, one-shot device-binding challenge from the
1769        /// shared lifecycle owner. JavaScript may pass these bytes to the
1770        /// existing sign-only device-key callback, but never receives the key.
1771        #[wasm_bindgen(js_name = __prepareOpenRtcBroadcastGrant)]
1772        pub fn prepare_openrtc_broadcast_grant(
1773            &self,
1774            grant_token: String,
1775            issuer_public_key: Vec<u8>,
1776            now_ms: f64,
1777        ) -> Result<JsValue, JsValue> {
1778            let issuer_public_key: [u8; 32] = issuer_public_key
1779                .try_into()
1780                .map_err(|_| JsValue::from_str("broadcast issuer key must be 32 bytes"))?;
1781            let issuer = ed25519_dalek::VerifyingKey::from_bytes(&issuer_public_key)
1782                .map_err(|_| JsValue::from_str("broadcast issuer key is invalid"))?;
1783            if !now_ms.is_finite()
1784                || now_ms < 0.0
1785                || now_ms.fract() != 0.0
1786                || now_ms > crate::media::MAX_JAVASCRIPT_SAFE_INTEGER as f64
1787            {
1788                return Err(JsValue::from_str(
1789                    "broadcast now must be a non-negative JavaScript-safe integer",
1790                ));
1791            }
1792            let challenge = self
1793                .inner
1794                .broadcasts()
1795                .prepare_grant_verification(&grant_token, &issuer, now_ms as u64)
1796                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1797            let object = js_sys::Object::new();
1798            js_sys::Reflect::set(
1799                &object,
1800                &JsValue::from_str("handle"),
1801                &JsValue::from_str(&challenge.handle),
1802            )?;
1803            js_sys::Reflect::set(
1804                &object,
1805                &JsValue::from_str("signingBytes"),
1806                &js_sys::Uint8Array::from(challenge.signing_bytes.as_slice()),
1807            )?;
1808            js_sys::Reflect::set(
1809                &object,
1810                &JsValue::from_str("expiresAtMs"),
1811                &JsValue::from_f64(challenge.expires_at_ms as f64),
1812            )?;
1813            Ok(object.into())
1814        }
1815
1816        /// Internal browser adapter boundary. Authorization and lifecycle are
1817        /// still decided by the shared Rust core; JavaScript receives only the
1818        /// mechanics commands for the admitted generation.
1819        #[wasm_bindgen(js_name = __openOpenRtcBroadcast)]
1820        pub fn open_openrtc_broadcast(
1821            &self,
1822            grant_token: String,
1823            issuer_public_key: Vec<u8>,
1824            binding_challenge_handle: String,
1825            binding_signature: Vec<u8>,
1826            publication_signer_handle: Option<String>,
1827            now_ms: f64,
1828        ) -> Result<JsValue, JsValue> {
1829            let issuer_public_key: [u8; 32] = issuer_public_key
1830                .try_into()
1831                .map_err(|_| JsValue::from_str("broadcast issuer key must be 32 bytes"))?;
1832            let issuer = ed25519_dalek::VerifyingKey::from_bytes(&issuer_public_key)
1833                .map_err(|_| JsValue::from_str("broadcast issuer key is invalid"))?;
1834            if !now_ms.is_finite()
1835                || now_ms < 0.0
1836                || now_ms.fract() != 0.0
1837                || now_ms > crate::media::MAX_JAVASCRIPT_SAFE_INTEGER as f64
1838            {
1839                return Err(JsValue::from_str(
1840                    "broadcast now must be a non-negative JavaScript-safe integer",
1841                ));
1842            }
1843            let now_ms = now_ms as u64;
1844            let grant = self
1845                .inner
1846                .broadcasts()
1847                .complete_grant_verification(
1848                    &grant_token,
1849                    &issuer,
1850                    &binding_challenge_handle,
1851                    &binding_signature,
1852                    now_ms,
1853                )
1854                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1855            let grant_generation = grant.grant_generation();
1856            let session = match publication_signer_handle {
1857                Some(handle) => {
1858                    let signers = self.broadcast_signers.borrow();
1859                    let signer = signers
1860                        .get(&handle)
1861                        .ok_or_else(|| JsValue::from_str("broadcast signer handle is invalid"))?;
1862                    self.inner
1863                        .broadcasts()
1864                        .open_publisher(grant, signer, now_ms)
1865                }
1866                None => self.inner.broadcasts().open(grant, now_ms),
1867            }
1868            .map_err(|error| JsValue::from_str(&error.to_string()))?;
1869            let handle_id = format!("{}:{grant_generation}", session.id());
1870            let result = serde_json::json!({
1871                "id": session.id(),
1872                "handleId": handle_id,
1873                "role": session.role(),
1874                "state": session.state(),
1875                "budget": session.budget(),
1876                "sourceSlots": session.source_slots(),
1877            });
1878            self.broadcast_sessions
1879                .borrow_mut()
1880                .insert(handle_id, session);
1881            serde::Serialize::serialize(&result, &serde_wasm_bindgen::Serializer::json_compatible())
1882                .map_err(|error| JsValue::from_str(&error.to_string()))
1883        }
1884
1885        /// Create a Rust/WASM-owned sign-only handle before requesting a
1886        /// publisher grant. JavaScript receives only the opaque handle and
1887        /// public key; private key bytes never cross the wasm-bindgen ABI.
1888        #[wasm_bindgen(js_name = __createOpenRtcBroadcastPublisherSigner)]
1889        pub fn create_openrtc_broadcast_publisher_signer(&self) -> Result<JsValue, JsValue> {
1890            let signer = crate::broadcast::BroadcastPublisherSigner::generate()
1891                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1892            let mut handle_bytes = [0_u8; 16];
1893            getrandom::getrandom(&mut handle_bytes)
1894                .map_err(|_| JsValue::from_str("broadcast signer handle entropy failed"))?;
1895            let handle = hex::encode(handle_bytes);
1896            let public_key = signer.verifying_key();
1897            let object = js_sys::Object::new();
1898            js_sys::Reflect::set(
1899                &object,
1900                &JsValue::from_str("handle"),
1901                &JsValue::from_str(&handle),
1902            )?;
1903            js_sys::Reflect::set(
1904                &object,
1905                &JsValue::from_str("publicKey"),
1906                &js_sys::Uint8Array::from(public_key.as_slice()),
1907            )?;
1908            self.broadcast_signers.borrow_mut().insert(handle, signer);
1909            Ok(object.into())
1910        }
1911
1912        #[wasm_bindgen(js_name = __releaseOpenRtcBroadcastPublisherSigner)]
1913        pub fn release_openrtc_broadcast_publisher_signer(&self, handle: String) -> bool {
1914            self.broadcast_signers
1915                .borrow_mut()
1916                .remove(&handle)
1917                .is_some()
1918        }
1919
1920        #[wasm_bindgen(js_name = __takeOpenRtcBroadcastActions)]
1921        pub fn take_openrtc_broadcast_actions(
1922            &self,
1923            handle_id: String,
1924            max: usize,
1925        ) -> Result<JsValue, JsValue> {
1926            let sessions = self.broadcast_sessions.borrow();
1927            let session = sessions
1928                .get(&handle_id)
1929                .ok_or_else(|| JsValue::from_str("broadcast session is unavailable"))?;
1930            serde::Serialize::serialize(
1931                &session.take_actions(max),
1932                &serde_wasm_bindgen::Serializer::json_compatible(),
1933            )
1934            .map_err(|error| JsValue::from_str(&error.to_string()))
1935        }
1936
1937        #[wasm_bindgen(js_name = __getOpenRtcBroadcastStats)]
1938        pub fn get_openrtc_broadcast_stats(&self, handle_id: String) -> Result<JsValue, JsValue> {
1939            let sessions = self.broadcast_sessions.borrow();
1940            let stats = sessions
1941                .get(&handle_id)
1942                .ok_or_else(|| JsValue::from_str("broadcast session is unavailable"))?
1943                .stats();
1944            serde::Serialize::serialize(&stats, &serde_wasm_bindgen::Serializer::json_compatible())
1945                .map_err(|error| JsValue::from_str(&error.to_string()))
1946        }
1947
1948        #[wasm_bindgen(js_name = __observeOpenRtcBroadcastAdapter)]
1949        pub fn observe_openrtc_broadcast_adapter(
1950            &self,
1951            handle_id: String,
1952            observation: JsValue,
1953        ) -> Result<(), JsValue> {
1954            let observation = serde_wasm_bindgen::from_value(observation)
1955                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1956            self.broadcast_sessions
1957                .borrow()
1958                .get(&handle_id)
1959                .ok_or_else(|| JsValue::from_str("broadcast session is unavailable"))?
1960                .observe(observation)
1961                .map_err(|error| JsValue::from_str(&error.to_string()))
1962        }
1963
1964        #[wasm_bindgen(js_name = __closeOpenRtcBroadcast)]
1965        pub fn close_openrtc_broadcast(&self, handle_id: String) -> Result<(), JsValue> {
1966            let session = self
1967                .broadcast_sessions
1968                .borrow_mut()
1969                .remove(&handle_id)
1970                .ok_or_else(|| JsValue::from_str("broadcast session is unavailable"))?;
1971            session.close();
1972            Ok(())
1973        }
1974
1975        /// Admit one browser publication through the broadcast role/source
1976        /// owner. Codec work stays at the existing browser media edge.
1977        #[wasm_bindgen(js_name = __beginOpenRtcBroadcastPublication)]
1978        #[allow(clippy::too_many_arguments)]
1979        pub fn begin_openrtc_broadcast_publication(
1980            &self,
1981            handle_id: String,
1982            source_slot: String,
1983            publication_id: Option<String>,
1984            kind: String,
1985            codec: String,
1986            clock_rate: u32,
1987            coded_width: Option<u32>,
1988            coded_height: Option<u32>,
1989            channels: Option<u16>,
1990        ) -> Result<JsValue, JsValue> {
1991            let publication_id = match publication_id {
1992                Some(value) if !value.is_empty() => {
1993                    value
1994                        .parse()
1995                        .map_err(|error: crate::media::MediaProtocolError| {
1996                            JsValue::from_str(&error.to_string())
1997                        })?
1998                }
1999                _ => {
2000                    let mut bytes = [0_u8; 16];
2001                    getrandom::getrandom(&mut bytes)
2002                        .map_err(|_| JsValue::from_str("media publication entropy failed"))?;
2003                    crate::media::PublicationId::from_bytes(bytes)
2004                }
2005            };
2006            let kind: crate::media::MediaKind =
2007                kind.parse()
2008                    .map_err(|error: crate::media::MediaProtocolError| {
2009                        JsValue::from_str(&error.to_string())
2010                    })?;
2011            if !matches!(
2012                kind,
2013                crate::media::MediaKind::Audio | crate::media::MediaKind::Video
2014            ) {
2015                return Err(JsValue::from_str(
2016                    "browser broadcast publications must be audio or video",
2017                ));
2018            }
2019            let codec: crate::media::MediaCodec =
2020                codec
2021                    .parse()
2022                    .map_err(|error: crate::media::MediaProtocolError| {
2023                        JsValue::from_str(&error.to_string())
2024                    })?;
2025            let publication = self
2026                .broadcast_sessions
2027                .borrow()
2028                .get(&handle_id)
2029                .ok_or_else(|| JsValue::from_str("broadcast session is unavailable"))?
2030                .begin_publication(
2031                    &source_slot,
2032                    crate::media::MediaPublicationConfig {
2033                        publication_id,
2034                        media_generation: 0,
2035                        kind,
2036                        codec,
2037                        clock_rate,
2038                        coded_width,
2039                        coded_height,
2040                        channels,
2041                    },
2042                )
2043                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2044            serde::Serialize::serialize(
2045                &serde_json::json!({
2046                    "publicationId": publication.publication_id.to_string(),
2047                    "mediaGeneration": publication.media_generation,
2048                    "control": Vec::<u8>::new(),
2049                }),
2050                &serde_wasm_bindgen::Serializer::json_compatible(),
2051            )
2052            .map_err(|error| JsValue::from_str(&error.to_string()))
2053        }
2054
2055        /// Queue one encoded browser sample only after Rust role, generation,
2056        /// source, signature, and hard-backpressure checks succeed.
2057        #[wasm_bindgen(js_name = __publishOpenRtcBroadcastSample)]
2058        #[allow(clippy::too_many_arguments)]
2059        pub fn publish_openrtc_broadcast_sample(
2060            &self,
2061            handle_id: String,
2062            publication_id: String,
2063            timestamp_us: f64,
2064            duration_us: u32,
2065            keyframe: bool,
2066            discardable: bool,
2067            payload: Vec<u8>,
2068        ) -> Result<(), JsValue> {
2069            if !timestamp_us.is_finite()
2070                || timestamp_us < 0.0
2071                || timestamp_us.fract() != 0.0
2072                || timestamp_us > crate::media::MAX_JAVASCRIPT_SAFE_INTEGER as f64
2073            {
2074                return Err(JsValue::from_str(
2075                    "media timestamp must be a non-negative JavaScript-safe integer",
2076                ));
2077            }
2078            let publication_id =
2079                publication_id
2080                    .parse()
2081                    .map_err(|error: crate::media::MediaProtocolError| {
2082                        JsValue::from_str(&error.to_string())
2083                    })?;
2084            self.broadcast_sessions
2085                .borrow()
2086                .get(&handle_id)
2087                .ok_or_else(|| JsValue::from_str("broadcast session is unavailable"))?
2088                .publish(
2089                    publication_id,
2090                    crate::media::EncodedMediaSample {
2091                        timestamp_us: timestamp_us as u64,
2092                        duration_us,
2093                        keyframe,
2094                        discardable,
2095                        payload,
2096                    },
2097                )
2098                .map_err(|error| JsValue::from_str(&error.to_string()))
2099        }
2100
2101        #[wasm_bindgen(js_name = __retireOpenRtcBroadcastPublication)]
2102        pub fn retire_openrtc_broadcast_publication(
2103            &self,
2104            handle_id: String,
2105            publication_id: String,
2106        ) -> Result<(), JsValue> {
2107            let publication_id =
2108                publication_id
2109                    .parse()
2110                    .map_err(|error: crate::media::MediaProtocolError| {
2111                        JsValue::from_str(&error.to_string())
2112                    })?;
2113            self.broadcast_sessions
2114                .borrow()
2115                .get(&handle_id)
2116                .ok_or_else(|| JsValue::from_str("broadcast session is unavailable"))?
2117                .retire_publication(publication_id)
2118                .map_err(|error| JsValue::from_str(&error.to_string()))
2119        }
2120
2121        #[wasm_bindgen(js_name = __pauseOpenRtcBroadcastPublication)]
2122        pub fn pause_openrtc_broadcast_publication(
2123            &self,
2124            handle_id: String,
2125            publication_id: String,
2126        ) -> Result<(), JsValue> {
2127            let publication_id =
2128                publication_id
2129                    .parse()
2130                    .map_err(|error: crate::media::MediaProtocolError| {
2131                        JsValue::from_str(&error.to_string())
2132                    })?;
2133            self.broadcast_sessions
2134                .borrow()
2135                .get(&handle_id)
2136                .ok_or_else(|| JsValue::from_str("broadcast session is unavailable"))?
2137                .pause_publication(publication_id)
2138                .map_err(|error| JsValue::from_str(&error.to_string()))
2139        }
2140
2141        /// Verify source authorization, signature, generation, integrity, and
2142        /// replay before portable bytes reach the browser decoder.
2143        #[wasm_bindgen(js_name = __acceptOpenRtcBroadcastObject)]
2144        pub fn accept_openrtc_broadcast_object(
2145            &self,
2146            handle_id: String,
2147            source_slot: String,
2148            encoded: Vec<u8>,
2149        ) -> Result<JsValue, JsValue> {
2150            let accepted = self
2151                .broadcast_sessions
2152                .borrow()
2153                .get(&handle_id)
2154                .ok_or_else(|| JsValue::from_str("broadcast session is unavailable"))?
2155                .accept_media_for_source(&source_slot, &encoded)
2156                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2157            serde::Serialize::serialize(
2158                &accepted,
2159                &serde_wasm_bindgen::Serializer::json_compatible(),
2160            )
2161            .map_err(|error| JsValue::from_str(&error.to_string()))
2162        }
2163
2164        /// Begin or replace one browser publication in the shared Rust media
2165        /// state machine. Publication IDs remain stable while Rust alone
2166        /// advances media generation and resets sequence state.
2167        #[wasm_bindgen(js_name = __beginOpenRtcMediaPublication)]
2168        #[allow(clippy::too_many_arguments)]
2169        pub fn begin_openrtc_media_publication(
2170            &self,
2171            publication_id: Option<String>,
2172            kind: String,
2173            codec: String,
2174            clock_rate: u32,
2175            coded_width: Option<u32>,
2176            coded_height: Option<u32>,
2177            channels: Option<u16>,
2178        ) -> Result<JsValue, JsValue> {
2179            let publication_id = match publication_id {
2180                Some(value) if !value.is_empty() => {
2181                    value
2182                        .parse()
2183                        .map_err(|error: crate::media::MediaProtocolError| {
2184                            JsValue::from_str(&error.to_string())
2185                        })?
2186                }
2187                _ => {
2188                    let mut bytes = [0_u8; 16];
2189                    getrandom::getrandom(&mut bytes)
2190                        .map_err(|_| JsValue::from_str("media publication entropy failed"))?;
2191                    crate::media::PublicationId::from_bytes(bytes)
2192                }
2193            };
2194            let kind: crate::media::MediaKind =
2195                kind.parse()
2196                    .map_err(|error: crate::media::MediaProtocolError| {
2197                        JsValue::from_str(&error.to_string())
2198                    })?;
2199            if !matches!(
2200                kind,
2201                crate::media::MediaKind::Audio | crate::media::MediaKind::Video
2202            ) {
2203                return Err(JsValue::from_str(
2204                    "browser media publications must be audio or video",
2205                ));
2206            }
2207            let codec: crate::media::MediaCodec =
2208                codec
2209                    .parse()
2210                    .map_err(|error: crate::media::MediaProtocolError| {
2211                        JsValue::from_str(&error.to_string())
2212                    })?;
2213            let publication = crate::media::MediaPublicationConfig {
2214                publication_id,
2215                media_generation: 0,
2216                kind,
2217                codec,
2218                clock_rate,
2219                coded_width,
2220                coded_height,
2221                channels,
2222            };
2223            let (publication, control) = self
2224                .portable_media
2225                .borrow_mut()
2226                .begin_publication(publication)
2227                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2228
2229            let object = js_sys::Object::new();
2230            js_sys::Reflect::set(
2231                &object,
2232                &JsValue::from_str("publicationId"),
2233                &JsValue::from_str(&publication_id.to_string()),
2234            )?;
2235            js_sys::Reflect::set(
2236                &object,
2237                &JsValue::from_str("mediaGeneration"),
2238                &JsValue::from_f64(publication.media_generation as f64),
2239            )?;
2240            js_sys::Reflect::set(
2241                &object,
2242                &JsValue::from_str("control"),
2243                &js_sys::Uint8Array::from(control.as_slice()),
2244            )?;
2245            Ok(object.into())
2246        }
2247
2248        /// Encode one browser-produced sample using Rust-owned publication,
2249        /// generation, codec, and sequence state.
2250        #[wasm_bindgen(js_name = __encodeOpenRtcMediaSample)]
2251        pub fn encode_openrtc_media_sample(
2252            &self,
2253            publication_id: String,
2254            timestamp_us: f64,
2255            duration_us: u32,
2256            keyframe: bool,
2257            discardable: bool,
2258            payload: Vec<u8>,
2259        ) -> Result<Vec<u8>, JsValue> {
2260            fn safe_u64(value: f64, field: &str) -> Result<u64, JsValue> {
2261                if !value.is_finite()
2262                    || value < 0.0
2263                    || value.fract() != 0.0
2264                    || value > crate::media::MAX_JAVASCRIPT_SAFE_INTEGER as f64
2265                {
2266                    return Err(JsValue::from_str(&format!(
2267                        "media {field} must be a non-negative JavaScript-safe integer"
2268                    )));
2269                }
2270                Ok(value as u64)
2271            }
2272            let publication_id: crate::media::PublicationId =
2273                publication_id
2274                    .parse()
2275                    .map_err(|error: crate::media::MediaProtocolError| {
2276                        JsValue::from_str(&error.to_string())
2277                    })?;
2278            self.portable_media
2279                .borrow_mut()
2280                .encode_sample(
2281                    publication_id,
2282                    crate::media::EncodedMediaSample {
2283                        timestamp_us: safe_u64(timestamp_us, "timestamp")?,
2284                        duration_us,
2285                        keyframe,
2286                        discardable,
2287                        payload,
2288                    },
2289                )
2290                .map_err(|error| JsValue::from_str(&error.to_string()))
2291        }
2292
2293        #[wasm_bindgen(js_name = __pauseOpenRtcMediaPublication)]
2294        pub fn pause_openrtc_media_publication(
2295            &self,
2296            publication_id: String,
2297        ) -> Result<(), JsValue> {
2298            let publication_id: crate::media::PublicationId =
2299                publication_id
2300                    .parse()
2301                    .map_err(|error: crate::media::MediaProtocolError| {
2302                        JsValue::from_str(&error.to_string())
2303                    })?;
2304            self.portable_media
2305                .borrow_mut()
2306                .pause_publication(publication_id)
2307                .map_err(|error| JsValue::from_str(&error.to_string()))
2308        }
2309
2310        #[wasm_bindgen(js_name = __retireOpenRtcMediaPublication)]
2311        pub fn retire_openrtc_media_publication(
2312            &self,
2313            publication_id: String,
2314        ) -> Result<(), JsValue> {
2315            let publication_id: crate::media::PublicationId =
2316                publication_id
2317                    .parse()
2318                    .map_err(|error: crate::media::MediaProtocolError| {
2319                        JsValue::from_str(&error.to_string())
2320                    })?;
2321            self.portable_media
2322                .borrow_mut()
2323                .retire_publication(publication_id);
2324            Ok(())
2325        }
2326
2327        #[wasm_bindgen(js_name = __retireOpenRtcMediaReceiver)]
2328        pub fn retire_openrtc_media_receiver(
2329            &self,
2330            publication_id: String,
2331            media_generation: u32,
2332        ) -> Result<(), JsValue> {
2333            let publication_id: crate::media::PublicationId =
2334                publication_id
2335                    .parse()
2336                    .map_err(|error: crate::media::MediaProtocolError| {
2337                        JsValue::from_str(&error.to_string())
2338                    })?;
2339            self.portable_media
2340                .borrow_mut()
2341                .retire_receiver(publication_id, media_generation);
2342            Ok(())
2343        }
2344
2345        /// Decode and replay-check one portable media chunk. A duplicate,
2346        /// retired generation, length mismatch, or integrity failure is
2347        /// rejected before browser decoders can observe the payload.
2348        #[wasm_bindgen(js_name = __decodeOpenRtcMediaChunk)]
2349        pub fn decode_openrtc_media_chunk(&self, encoded: Vec<u8>) -> Result<JsValue, JsValue> {
2350            let chunk = self
2351                .portable_media
2352                .borrow_mut()
2353                .decode_chunk(&encoded)
2354                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2355            crate::media::ensure_javascript_safe_integer(chunk.sequence, "sequence")
2356                .and_then(|_| {
2357                    crate::media::ensure_javascript_safe_integer(chunk.timestamp_us, "timestamp")
2358                })
2359                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2360
2361            let object = js_sys::Object::new();
2362            let set = |name: &str, value: JsValue| -> Result<(), JsValue> {
2363                js_sys::Reflect::set(&object, &JsValue::from_str(name), &value).map(|_| ())
2364            };
2365            set(
2366                "publicationId",
2367                JsValue::from_str(&chunk.publication_id.to_string()),
2368            )?;
2369            set(
2370                "mediaGeneration",
2371                JsValue::from_f64(chunk.media_generation as f64),
2372            )?;
2373            set("sequence", JsValue::from_f64(chunk.sequence as f64))?;
2374            set("timestampUs", JsValue::from_f64(chunk.timestamp_us as f64))?;
2375            set("durationUs", JsValue::from_f64(chunk.duration_us as f64))?;
2376            set(
2377                "kind",
2378                JsValue::from_str(match chunk.kind {
2379                    crate::media::MediaKind::Audio => "audio",
2380                    crate::media::MediaKind::Video => "video",
2381                    crate::media::MediaKind::Screen => "screen",
2382                    crate::media::MediaKind::Data => "data",
2383                }),
2384            )?;
2385            set(
2386                "codec",
2387                JsValue::from_str(match chunk.codec {
2388                    crate::media::MediaCodec::Opus => "opus",
2389                    crate::media::MediaCodec::H264 => "h264",
2390                    crate::media::MediaCodec::Vp8 => "vp8",
2391                    crate::media::MediaCodec::Vp9 => "vp9",
2392                    crate::media::MediaCodec::Av1 => "av1",
2393                    crate::media::MediaCodec::Pcm => "pcm",
2394                    crate::media::MediaCodec::Opaque => "opaque",
2395                }),
2396            )?;
2397            set("keyframe", JsValue::from_bool(chunk.keyframe))?;
2398            set("discardable", JsValue::from_bool(chunk.discardable))?;
2399            set(
2400                "payload",
2401                js_sys::Uint8Array::from(chunk.payload.as_slice()).into(),
2402            )?;
2403            Ok(object.into())
2404        }
2405
2406        #[wasm_bindgen(js_name = __decodeOpenRtcMediaControl)]
2407        pub fn decode_openrtc_media_control(&self, encoded: Vec<u8>) -> Result<JsValue, JsValue> {
2408            let control = self
2409                .portable_media
2410                .borrow_mut()
2411                .decode_control(&encoded)
2412                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2413            // Keep the WASM boundary explicit and browser-friendly. The Rust
2414            // wire representation is allowed to evolve independently of the
2415            // TypeScript property convention, and PublicationId must never
2416            // depend on newtype-array serde behavior in JavaScript.
2417            let portable = match control {
2418                crate::media::MediaControlFrame::Publish {
2419                    publication_id,
2420                    media_generation,
2421                    kind,
2422                    codec,
2423                    clock_rate,
2424                    coded_width,
2425                    coded_height,
2426                    channels,
2427                } => serde_json::json!({
2428                    "type": "publish",
2429                    "publicationId": publication_id.to_string(),
2430                    "mediaGeneration": media_generation,
2431                    "kind": kind,
2432                    "codec": codec,
2433                    "clockRate": clock_rate,
2434                    "codedWidth": coded_width,
2435                    "codedHeight": coded_height,
2436                    "channels": channels,
2437                }),
2438                crate::media::MediaControlFrame::SetEnabled {
2439                    publication_id,
2440                    media_generation,
2441                    enabled,
2442                } => serde_json::json!({
2443                    "type": "set-enabled",
2444                    "publicationId": publication_id.to_string(),
2445                    "mediaGeneration": media_generation,
2446                    "enabled": enabled,
2447                }),
2448                crate::media::MediaControlFrame::RequestKeyframe {
2449                    publication_id,
2450                    media_generation,
2451                } => serde_json::json!({
2452                    "type": "request-keyframe",
2453                    "publicationId": publication_id.to_string(),
2454                    "mediaGeneration": media_generation,
2455                }),
2456                crate::media::MediaControlFrame::Stop {
2457                    publication_id,
2458                    media_generation,
2459                    reason,
2460                } => serde_json::json!({
2461                    "type": "stop",
2462                    "publicationId": publication_id.to_string(),
2463                    "mediaGeneration": media_generation,
2464                    "reason": reason,
2465                }),
2466            };
2467            serde::Serialize::serialize(
2468                &portable,
2469                &serde_wasm_bindgen::Serializer::json_compatible(),
2470            )
2471            .map_err(|error| JsValue::from_str(&error.to_string()))
2472        }
2473
2474        /// Install the browser mechanism callback for Rust-owned carrier
2475        /// actions. The callback must enqueue actions in order; it must not
2476        /// choose attempts, retry, or promote a transport itself.
2477        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2478        #[wasm_bindgen(js_name = __setIrohCarrierActionHandler)]
2479        pub fn set_iroh_carrier_action_handler(&self, handler: Option<js_sys::Function>) {
2480            *self.wasm_carrier_action_handler.borrow_mut() = handler;
2481        }
2482
2483        /// Internal bootstrap diagnostic for the one Rust-owned browser
2484        /// carrier configuration. JavaScript uses this only as an awaited
2485        /// readiness barrier; it does not select a carrier or inspect a peer.
2486        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2487        #[wasm_bindgen(js_name = __getIrohCarrierReadiness)]
2488        pub async fn get_iroh_carrier_readiness(&self) -> Result<JsValue, JsValue> {
2489            let readiness = serde_json::json!({
2490                "webrtcConfigured": self.inner.is_webrtc_carrier_configured().await,
2491                "moqConfigured": self.inner.is_moq_carrier_configured().await,
2492                "webrtcAvailable": self.inner.is_webrtc_carrier_enabled().await,
2493                "moqAvailable": self.inner.is_moq_carrier_enabled().await,
2494                "actionHandlerInstalled": self.wasm_carrier_action_handler.borrow().is_some(),
2495            });
2496            serde::Serialize::serialize(
2497                &readiness,
2498                &serde_wasm_bindgen::Serializer::json_compatible(),
2499            )
2500            .map_err(|error| JsValue::from_str(&error.to_string()))
2501        }
2502
2503        #[cfg(feature = "transport-webrtc")]
2504        #[wasm_bindgen(js_name = __configureWebRtcCarrier)]
2505        pub async fn configure_iroh_webrtc_carrier(
2506            &self,
2507            enabled: bool,
2508            privacy_mode: bool,
2509        ) -> Result<(), JsValue> {
2510            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2511            self.inner.set_webrtc_carrier(enabled, privacy_mode).await;
2512            Ok(())
2513        }
2514
2515        /// Apply privacy, optimization, and exact-route ordering before any
2516        /// browser carrier attempt. The Rust peer actor remains the selector.
2517        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2518        #[wasm_bindgen(js_name = __configureIrohRoutePolicy)]
2519        pub async fn configure_iroh_route_policy(
2520            &self,
2521            relay_only: bool,
2522            optimize_for: Option<String>,
2523            route_priority: JsValue,
2524        ) -> Result<(), JsValue> {
2525            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2526            let optimize_for = match optimize_for.as_deref().unwrap_or("balanced") {
2527                "balanced" => crate::route_policy::RouteOptimization::Balanced,
2528                "lowest-latency" => crate::route_policy::RouteOptimization::LowestLatency,
2529                _ => {
2530                    return Err(JsValue::from_str(
2531                        "transport optimization must be balanced or lowest-latency",
2532                    ))
2533                }
2534            };
2535            let route_priority = if route_priority.is_null() || route_priority.is_undefined() {
2536                Vec::new()
2537            } else {
2538                serde_wasm_bindgen::from_value(route_priority)
2539                    .map_err(|error| JsValue::from_str(&error.to_string()))?
2540            };
2541            self.inner
2542                .configure_wasm_route_policy(relay_only, optimize_for, route_priority)
2543                .await;
2544            Ok(())
2545        }
2546
2547        /// Submit the currently configured global carrier policy to the Rust
2548        /// peer-session owner. This is the only browser policy-switch command:
2549        /// JavaScript supplies no peer-specific winner and closes no route.
2550        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2551        #[wasm_bindgen(js_name = __applyIrohCarrierPolicy)]
2552        pub async fn apply_iroh_carrier_policy(&self) -> Result<u32, JsValue> {
2553            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2554            let capabilities: Vec<_> = self
2555                .wasm_remote_carrier_capabilities
2556                .borrow()
2557                .iter()
2558                .map(|(connection_id, capabilities)| (connection_id.clone(), *capabilities))
2559                .collect();
2560            let mut reconciled = 0u32;
2561            for (connection_id, capabilities) in capabilities {
2562                let Some(remote_endpoint_id) = self
2563                    .inner
2564                    .wasm_iroh_carrier_remote_endpoint_id(&connection_id)
2565                    .await
2566                else {
2567                    continue;
2568                };
2569                if self
2570                    .begin_preferred_iroh_carrier_locked(
2571                        connection_id,
2572                        remote_endpoint_id,
2573                        capabilities.webrtc,
2574                        capabilities.moq,
2575                    )
2576                    .await?
2577                {
2578                    reconciled = reconciled.saturating_add(1);
2579                }
2580            }
2581            Ok(reconciled)
2582        }
2583
2584        /// Consume a browser availability transition as typed input to the
2585        /// existing Rust peer-session owner. A very short offline interval can
2586        /// leave WebTransport or WebRTC apparently open while its selected
2587        /// Iroh path is no longer usable. Retire only the exact current custom
2588        /// generation; the canonical browser actor still owns redial, policy,
2589        /// admission, and the next carrier selection.
2590        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2591        #[wasm_bindgen(js_name = __reconcileIrohCarriersAfterNetworkChange)]
2592        pub async fn reconcile_iroh_carriers_after_network_change(&self) -> u32 {
2593            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2594            let mut reconciled = 0u32;
2595            let mut recoveries = Vec::new();
2596            #[cfg(feature = "transport-webrtc")]
2597            {
2598                let attempts: Vec<_> = self
2599                    .wasm_webrtc_carrier_attempts
2600                    .borrow()
2601                    .values()
2602                    .cloned()
2603                    .collect();
2604                for attempt in attempts {
2605                    if retire_selected_wasm_webrtc_carrier(
2606                        self.inner.clone(),
2607                        self.wasm_webrtc_carrier_attempts.clone(),
2608                        self.wasm_webrtc_carrier_sessions.clone(),
2609                        self.wasm_carrier_action_handler.clone(),
2610                        &attempt,
2611                        "network-change",
2612                        crate::lifecycle_reason::REASON_NETWORK_CHANGE_RECONNECT,
2613                    )
2614                    .await
2615                    {
2616                        reconciled = reconciled.saturating_add(1);
2617                        recoveries.push((
2618                            attempt.connection_id.clone(),
2619                            attempt.remote_endpoint_id,
2620                            attempt.generation.transport_generation.saturating_add(1),
2621                        ));
2622                    }
2623                }
2624            }
2625            #[cfg(feature = "transport-moq")]
2626            {
2627                let attempts: Vec<_> = self
2628                    .wasm_moq_carrier_attempts
2629                    .borrow()
2630                    .values()
2631                    .cloned()
2632                    .collect();
2633                for attempt in attempts {
2634                    if retire_selected_wasm_moq_carrier(
2635                        self.inner.clone(),
2636                        self.wasm_moq_carrier_attempts.clone(),
2637                        self.wasm_moq_carrier_sessions.clone(),
2638                        self.wasm_carrier_action_handler.clone(),
2639                        &attempt,
2640                        "network-change",
2641                        crate::lifecycle_reason::REASON_NETWORK_CHANGE_RECONNECT,
2642                    )
2643                    .await
2644                    {
2645                        reconciled = reconciled.saturating_add(1);
2646                        recoveries.push((
2647                            attempt.connection_id.clone(),
2648                            attempt.remote_endpoint_id,
2649                            attempt.generation.transport_generation.saturating_add(1),
2650                        ));
2651                    }
2652                }
2653            }
2654            for (connection_id, remote_endpoint_id, transport_generation) in recoveries {
2655                let recovered = self
2656                    .inner
2657                    .recover_retired_wasm_carrier_generation(
2658                        &connection_id,
2659                        remote_endpoint_id,
2660                        transport_generation,
2661                    )
2662                    .await;
2663                if !recovered {
2664                    web_sys::console::warn_1(&JsValue::from_str(&format!(
2665                        "[OpenRTC][Iroh carrier] base recovery remained pending connection_id={connection_id} transport_generation={transport_generation}"
2666                    )));
2667                }
2668            }
2669            if reconciled > 0 {
2670                // Retiring a half-open custom carrier also closes its exact
2671                // underlying Iroh generation. The desired-peer revision did
2672                // not change, so no coordination update is guaranteed to wake
2673                // the serial browser actor. Submit the availability edge to
2674                // that existing owner after settlement; it alone decides
2675                // whether and when to redial the retained desired peer.
2676                self.inner.wake_browser_auto_connect();
2677            }
2678            reconciled
2679        }
2680
2681        /// Forget carrier capabilities only when the logical peer is retired.
2682        /// Physical WebRTC/MoQ mechanism replacement must retain this knowledge
2683        /// so the Rust lifecycle owner can select the next carrier deterministically.
2684        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2685        #[wasm_bindgen(js_name = __forgetIrohCarrierPeer)]
2686        pub async fn forget_iroh_carrier_peer(&self, connection_id: String) {
2687            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2688            self.forget_iroh_carrier_peer_locked(&connection_id);
2689        }
2690
2691        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2692        fn forget_iroh_carrier_peer_locked(&self, connection_id: &str) {
2693            self.wasm_remote_carrier_capabilities
2694                .borrow_mut()
2695                .remove(connection_id);
2696            self.inner
2697                .forget_iroh_carrier_peer_policy_epoch(connection_id);
2698        }
2699
2700        /// Retire every browser carrier owned by one logical peer and forget
2701        /// its capability revision as one serialized lifecycle transition.
2702        /// Individual carrier retirement APIs remain available for typed
2703        /// mechanism failures, which intentionally retain peer capabilities.
2704        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2705        #[wasm_bindgen(js_name = __retireIrohCarrierPeer)]
2706        pub async fn retire_iroh_carrier_peer(
2707            &self,
2708            connection_id: String,
2709            terminal_reason: Option<String>,
2710        ) {
2711            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2712            #[cfg(feature = "transport-webrtc")]
2713            self.retire_iroh_webrtc_carrier_locked(
2714                connection_id.clone(),
2715                terminal_reason.as_deref(),
2716            )
2717            .await;
2718            #[cfg(feature = "transport-moq")]
2719            self.retire_iroh_moq_carrier_locked(connection_id.clone(), terminal_reason.as_deref())
2720                .await;
2721            self.forget_iroh_carrier_peer_locked(&connection_id);
2722        }
2723
2724        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2725        fn has_matching_in_flight_wasm_carrier_attempt(
2726            &self,
2727            connection_id: &str,
2728            capabilities: WasmRemoteCarrierCapabilities,
2729        ) -> bool {
2730            #[cfg(feature = "transport-webrtc")]
2731            if capabilities.webrtc
2732                && self
2733                    .wasm_webrtc_carrier_attempts
2734                    .borrow()
2735                    .get(connection_id)
2736                    .is_some_and(|attempt| {
2737                        attempt.role == "responder" && !attempt.completion_started
2738                    })
2739            {
2740                return true;
2741            }
2742            #[cfg(feature = "transport-moq")]
2743            if capabilities.moq
2744                && self
2745                    .wasm_moq_carrier_attempts
2746                    .borrow()
2747                    .get(connection_id)
2748                    .is_some_and(|attempt| attempt.role == "responder")
2749            {
2750                return true;
2751            }
2752            false
2753        }
2754
2755        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2756        /// Start at most one mutually supported carrier according to the
2757        /// Rust-owned exact-route policy.
2758        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2759        #[wasm_bindgen(js_name = __beginPreferredIrohCarrier)]
2760        pub async fn begin_preferred_iroh_carrier(
2761            &self,
2762            connection_id: String,
2763            remote_endpoint_id: String,
2764            remote_supports_webrtc: bool,
2765            remote_supports_moq: bool,
2766        ) -> Result<bool, JsValue> {
2767            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2768            self.begin_preferred_iroh_carrier_locked(
2769                connection_id,
2770                remote_endpoint_id,
2771                remote_supports_webrtc,
2772                remote_supports_moq,
2773            )
2774            .await
2775        }
2776
2777        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2778        async fn begin_preferred_iroh_carrier_locked(
2779            &self,
2780            connection_id: String,
2781            remote_endpoint_id: String,
2782            remote_supports_webrtc: bool,
2783            remote_supports_moq: bool,
2784        ) -> Result<bool, JsValue> {
2785            let capabilities = WasmRemoteCarrierCapabilities {
2786                webrtc: remote_supports_webrtc,
2787                moq: remote_supports_moq,
2788            };
2789            let previous_capabilities = self
2790                .wasm_remote_carrier_capabilities
2791                .borrow()
2792                .get(&connection_id)
2793                .copied();
2794            if previous_capabilities != Some(capabilities) {
2795                // Remote capability knowledge belongs to this connection.
2796                // The global epoch is reserved for local route-policy/config
2797                // changes; otherwise a third peer joining can invalidate an
2798                // unrelated browser carrier proof.
2799                let matching_first_attempt = previous_capabilities.is_none()
2800                    && self
2801                        .has_matching_in_flight_wasm_carrier_attempt(&connection_id, capabilities);
2802                if crate::client::should_bump_remote_carrier_peer_policy_epoch(
2803                    previous_capabilities.is_some(),
2804                    true,
2805                    matching_first_attempt,
2806                ) {
2807                    self.inner
2808                        .bump_wasm_iroh_carrier_peer_policy_epoch(&connection_id)
2809                        .await;
2810                }
2811                self.wasm_remote_carrier_capabilities
2812                    .borrow_mut()
2813                    .insert(connection_id.clone(), capabilities);
2814            }
2815            if self
2816                .inner
2817                .reconcile_wasm_iroh_carrier_policy(
2818                    &connection_id,
2819                    remote_supports_webrtc,
2820                    remote_supports_moq,
2821                )
2822                .await
2823                .map_err(|error| JsValue::from_str(&error.to_string()))?
2824            {
2825                return Ok(true);
2826            }
2827            let ranked = self
2828                .inner
2829                .ranked_wasm_iroh_carriers(remote_supports_webrtc, remote_supports_moq)
2830                .await;
2831            match ranked.first() {
2832                #[cfg(feature = "transport-webrtc")]
2833                Some(crate::route_policy::KnownRoute::WebRtc) => {
2834                    self.begin_iroh_webrtc_carrier_attempt(connection_id, remote_endpoint_id, 0)
2835                        .await
2836                }
2837                #[cfg(feature = "transport-moq")]
2838                Some(crate::route_policy::KnownRoute::Moq) => {
2839                    self.begin_iroh_moq_carrier_attempt(connection_id, remote_endpoint_id, 0)
2840                        .await
2841                }
2842                _ => Ok(false),
2843            }
2844        }
2845
2846        /// Advance to the next configured carrier after Rust has retired a
2847        /// terminal attempt. Browser JavaScript only executes this decision.
2848        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
2849        #[wasm_bindgen(js_name = __advancePreferredIrohCarrier)]
2850        pub async fn advance_preferred_iroh_carrier(
2851            &self,
2852            connection_id: String,
2853            remote_endpoint_id: String,
2854            failed_route: String,
2855        ) -> Result<bool, JsValue> {
2856            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2857            let Some(capabilities) = self
2858                .wasm_remote_carrier_capabilities
2859                .borrow()
2860                .get(&connection_id)
2861                .copied()
2862            else {
2863                return Ok(false);
2864            };
2865            let failed = crate::route_policy::normalize_route(&failed_route)
2866                .ok_or_else(|| JsValue::from_str("unknown failed carrier route"))?;
2867            let ranked = self
2868                .inner
2869                .ranked_wasm_iroh_carriers(capabilities.webrtc, capabilities.moq)
2870                .await;
2871            let Some(next_index) = ranked
2872                .iter()
2873                .position(|route| *route == failed)
2874                .map(|index| index + 1)
2875                .filter(|index| *index < ranked.len())
2876            else {
2877                return Ok(false);
2878            };
2879            let next = ranked[next_index];
2880            match next {
2881                #[cfg(feature = "transport-webrtc")]
2882                crate::route_policy::KnownRoute::WebRtc => {
2883                    self.begin_iroh_webrtc_carrier_attempt(connection_id, remote_endpoint_id, 0)
2884                        .await
2885                }
2886                #[cfg(feature = "transport-moq")]
2887                crate::route_policy::KnownRoute::Moq => {
2888                    self.begin_iroh_moq_carrier_attempt(connection_id, remote_endpoint_id, 0)
2889                        .await
2890                }
2891                _ => Ok(false),
2892            }
2893        }
2894
2895        #[cfg(feature = "transport-moq")]
2896        #[wasm_bindgen(js_name = __configureMoqCarrier)]
2897        pub async fn configure_iroh_moq_carrier(
2898            &self,
2899            enabled: bool,
2900            relay_url: Option<String>,
2901        ) -> Result<(), JsValue> {
2902            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2903            let relay_url = relay_url.map(|value| value.trim().to_string());
2904            if enabled && relay_url.as_deref().unwrap_or_default().is_empty() {
2905                return Err(JsValue::from_str(
2906                    "MoQ Iroh carrier requires an explicit relay URL",
2907                ));
2908            }
2909            self.inner.set_moq_carrier(enabled, relay_url).await;
2910            Ok(())
2911        }
2912
2913        /// Start a deterministic Draft 14 MoQ reliable object-stream carrier attempt
2914        /// after the admitted handshake reports peer support.
2915        #[cfg(feature = "transport-moq")]
2916        #[wasm_bindgen(js_name = __beginMoqCarrier)]
2917        pub async fn begin_iroh_moq_carrier(
2918            &self,
2919            connection_id: String,
2920            remote_endpoint_id: String,
2921        ) -> Result<bool, JsValue> {
2922            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
2923            self.begin_iroh_moq_carrier_attempt(connection_id, remote_endpoint_id, 0)
2924                .await
2925        }
2926
2927        #[cfg(feature = "transport-moq")]
2928        async fn begin_iroh_moq_carrier_attempt(
2929            &self,
2930            connection_id: String,
2931            remote_endpoint_id: String,
2932            retry_count: u8,
2933        ) -> Result<bool, JsValue> {
2934            if !self.inner.is_moq_carrier_enabled().await {
2935                return Ok(false);
2936            }
2937            let local_endpoint_id = self
2938                .inner
2939                .current_node_id()
2940                .await
2941                .ok_or_else(|| JsValue::from_str("local Iroh endpoint id is unavailable"))?;
2942            if local_endpoint_id.as_str() <= remote_endpoint_id.as_str()
2943                || !crate::iroh_connection_policy::custom_carrier_base_allows(
2944                    self.inner.iroh_path_kind(&remote_endpoint_id).await,
2945                    crate::client::IrohPathKind::Moq,
2946                    false,
2947                )
2948            {
2949                return Ok(false);
2950            }
2951            let endpoint_id = remote_endpoint_id
2952                .parse::<iroh::EndpointId>()
2953                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2954            self.inner
2955                .get_connection(endpoint_id)
2956                .await
2957                .ok_or_else(|| JsValue::from_str("MoQ carrier base is unavailable"))?;
2958            let generation = self
2959                .inner
2960                .current_wasm_peer_data_generation(&connection_id, None)
2961                .await
2962                .ok_or_else(|| JsValue::from_str("MoQ carrier generation is unavailable"))?;
2963            let bootstrap = crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::request(
2964                crate::iroh_carrier_bootstrap::CarrierBootstrapKind::MoqDraft14,
2965                crate::iroh_carrier_bootstrap::CarrierGenerationFence {
2966                    transport_stable_id: generation.transport_stable_id,
2967                    transport_generation: generation.transport_generation,
2968                    route_generation: generation.route_generation,
2969                },
2970                retry_count.saturating_add(1),
2971            )
2972            .map_err(|error| JsValue::from_str(&error.to_string()))?;
2973            let kind = crate::client::IrohPathKind::Moq;
2974            if !matches!(
2975                self.inner
2976                    .reserve_wasm_carrier_upgrade(
2977                        &connection_id,
2978                        kind,
2979                        &bootstrap.upgrade_id,
2980                        generation,
2981                    )
2982                    .await,
2983                crate::client::WasmCarrierUpgradeReservation::Reserved
2984            ) {
2985                let pending = self
2986                    .wasm_moq_carrier_attempts
2987                    .borrow_mut()
2988                    .get_mut(&connection_id)
2989                    .filter(|attempt| {
2990                        attempt.role == "initiator"
2991                            && attempt.generation == generation
2992                            && attempt.prepared
2993                            && !attempt.retry_sent
2994                    })
2995                    .map(|attempt| {
2996                        attempt.retry_sent = true;
2997                        attempt.clone()
2998                    });
2999                if let Some(pending) = pending {
3000                    // Admission can become current just before the replacement
3001                    // native-main writer is installed. A later trusted
3002                    // handshake is typed retry input for the same fenced
3003                    // attempt, so resend its idempotent bootstrap instead of
3004                    // waiting for a timer or creating another lifecycle owner.
3005                    emit_wasm_carrier_action(
3006                        &self.wasm_carrier_action_handler,
3007                        serde_json::json!({
3008                            "type": "send-control",
3009                            "connectionId": connection_id,
3010                            "remoteEndpointId": pending.remote_endpoint_id.to_string(),
3011                            "envelope": pending.bootstrap,
3012                        }),
3013                    );
3014                    return Ok(true);
3015                }
3016                return Ok(false);
3017            }
3018            let previous = self.wasm_moq_carrier_attempts.borrow_mut().insert(
3019                connection_id.clone(),
3020                WasmMoqCarrierAttempt {
3021                    connection_id: connection_id.clone(),
3022                    remote_endpoint_id: endpoint_id,
3023                    bootstrap: bootstrap.clone(),
3024                    generation,
3025                    role: "initiator",
3026                    prepared: false,
3027                    retry_sent: false,
3028                    retry_count,
3029                    inbound_authorization_expires_at_ms: None,
3030                },
3031            );
3032            if let Some(previous) = previous {
3033                self.wasm_moq_carrier_sessions
3034                    .borrow_mut()
3035                    .remove(&previous.bootstrap.upgrade_id);
3036            }
3037            let (publish_namespace, subscribe_namespace, track_name) = wasm_moq_carrier_namespaces(
3038                &local_endpoint_id,
3039                &remote_endpoint_id,
3040                &bootstrap.carrier_session_id,
3041            );
3042            emit_wasm_carrier_action(
3043                &self.wasm_carrier_action_handler,
3044                serde_json::json!({
3045                    "type": "prepare-moq",
3046                    "connectionId": connection_id,
3047                    "remoteEndpointId": remote_endpoint_id,
3048                    "role": "initiator",
3049                    "upgradeId": bootstrap.upgrade_id,
3050                    "carrierSessionId": bootstrap.carrier_session_id,
3051                    "transportGeneration": generation.transport_generation.saturating_add(1),
3052                    "publishNamespace": publish_namespace,
3053                    "subscribeNamespace": subscribe_namespace,
3054                    "trackName": track_name,
3055                }),
3056            );
3057            self.schedule_wasm_moq_carrier_watchdog(bootstrap);
3058            Ok(true)
3059        }
3060
3061        #[cfg(feature = "transport-moq")]
3062        #[wasm_bindgen(js_name = __moqCarrierPrepared)]
3063        pub async fn iroh_moq_carrier_prepared(
3064            &self,
3065            connection_id: String,
3066            upgrade_id: String,
3067        ) -> Result<(), JsValue> {
3068            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3069            let attempt = {
3070                let mut attempts = self.wasm_moq_carrier_attempts.borrow_mut();
3071                let attempt = attempts
3072                    .get_mut(&connection_id)
3073                    .filter(|attempt| attempt.bootstrap.upgrade_id == upgrade_id)
3074                    .ok_or_else(|| JsValue::from_str("MoQ carrier attempt is stale"))?;
3075                attempt.prepared = true;
3076                attempt.clone()
3077            };
3078            if !self
3079                .inner
3080                .wasm_carrier_upgrade_is_current(
3081                    &connection_id,
3082                    crate::client::IrohPathKind::Moq,
3083                    &upgrade_id,
3084                    attempt.generation,
3085                )
3086                .await
3087            {
3088                return Err(JsValue::from_str("MoQ carrier attempt was retired"));
3089            }
3090            if attempt.role == "initiator" {
3091                emit_wasm_carrier_action(
3092                    &self.wasm_carrier_action_handler,
3093                    serde_json::json!({
3094                        "type": "send-control",
3095                        "connectionId": connection_id,
3096                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
3097                        "envelope": attempt.bootstrap,
3098                    }),
3099                );
3100            } else {
3101                // Relay setup is not packet-carrier readiness. Activate the
3102                // directed duplex first; `attach_iroh_moq_carrier` emits the
3103                // protected Ready only after both browser pumps are installed.
3104                // Otherwise a native initiator can dial during this gap and
3105                // lose its first Iroh handshake packet before WASM is reading.
3106                emit_wasm_carrier_action(
3107                    &self.wasm_carrier_action_handler,
3108                    serde_json::json!({
3109                        "type": "activate-moq",
3110                        "connectionId": attempt.connection_id,
3111                        "upgradeId": upgrade_id,
3112                    }),
3113                );
3114            }
3115            Ok(())
3116        }
3117
3118        #[cfg(feature = "transport-moq")]
3119        #[wasm_bindgen(js_name = __moqCarrierFailed)]
3120        pub async fn iroh_moq_carrier_failed(
3121            &self,
3122            connection_id: String,
3123            upgrade_id: String,
3124            failure_code: String,
3125        ) {
3126            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3127            let attempt = self
3128                .wasm_moq_carrier_attempts
3129                .borrow()
3130                .get(&connection_id)
3131                .filter(|attempt| attempt.bootstrap.upgrade_id == upgrade_id)
3132                .cloned();
3133            if let Some(attempt) = attempt {
3134                let failure_code = match failure_code.as_str() {
3135                    "relay-failed" => "relay-failed",
3136                    "draft14-datagram-unavailable" => "draft14-datagram-unavailable",
3137                    "carrier-backpressure" => "carrier-backpressure",
3138                    _ => "browser-adapter-failed",
3139                };
3140                if retire_selected_wasm_moq_carrier(
3141                    self.inner.clone(),
3142                    self.wasm_moq_carrier_attempts.clone(),
3143                    self.wasm_moq_carrier_sessions.clone(),
3144                    self.wasm_carrier_action_handler.clone(),
3145                    &attempt,
3146                    failure_code,
3147                    crate::lifecycle_reason::REASON_IROH_CARRIER_FAILED,
3148                )
3149                .await
3150                {
3151                    return;
3152                }
3153                self.fail_wasm_moq_carrier_attempt(attempt, failure_code, true)
3154                    .await;
3155            }
3156        }
3157
3158        #[cfg(feature = "transport-moq")]
3159        #[wasm_bindgen(js_name = __retireMoqCarrier)]
3160        pub async fn retire_iroh_moq_carrier(
3161            &self,
3162            connection_id: String,
3163            terminal_reason: Option<String>,
3164        ) {
3165            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3166            self.retire_iroh_moq_carrier_locked(connection_id, terminal_reason.as_deref())
3167                .await;
3168        }
3169
3170        #[cfg(feature = "transport-moq")]
3171        async fn retire_iroh_moq_carrier_locked(
3172            &self,
3173            connection_id: String,
3174            terminal_reason: Option<&str>,
3175        ) {
3176            let attempt = self
3177                .wasm_moq_carrier_attempts
3178                .borrow_mut()
3179                .remove(&connection_id);
3180            if let Some(attempt) = attempt {
3181                self.inner
3182                    .retire_wasm_carrier_upgrade(
3183                        &connection_id,
3184                        crate::client::IrohPathKind::Moq,
3185                        &attempt.bootstrap.upgrade_id,
3186                        attempt.generation,
3187                    )
3188                    .await;
3189                let carrier = self
3190                    .wasm_moq_carrier_sessions
3191                    .borrow_mut()
3192                    .remove(&attempt.bootstrap.upgrade_id);
3193                if let (Some(reason), Some(session)) = (terminal_reason, carrier.as_ref()) {
3194                    let _ = session.send_terminal(reason).await;
3195                }
3196                emit_wasm_carrier_action(
3197                    &self.wasm_carrier_action_handler,
3198                    serde_json::json!({
3199                        "type": "retire-moq",
3200                        "connectionId": connection_id,
3201                        "upgradeId": attempt.bootstrap.upgrade_id,
3202                        "failureCode": "logical-connection-retired",
3203                    }),
3204                );
3205            }
3206        }
3207
3208        /// Start the deterministic browser-side WebRTC carrier attempt after
3209        /// an admitted handshake reports internal-carrier support.
3210        #[cfg(feature = "transport-webrtc")]
3211        #[wasm_bindgen(js_name = __beginWebRtcCarrier)]
3212        pub async fn begin_iroh_webrtc_carrier(
3213            &self,
3214            connection_id: String,
3215            remote_endpoint_id: String,
3216        ) -> Result<bool, JsValue> {
3217            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3218            self.begin_iroh_webrtc_carrier_attempt(connection_id, remote_endpoint_id, 0)
3219                .await
3220        }
3221
3222        #[cfg(feature = "transport-webrtc")]
3223        async fn begin_iroh_webrtc_carrier_attempt(
3224            &self,
3225            connection_id: String,
3226            remote_endpoint_id: String,
3227            retry_count: u8,
3228        ) -> Result<bool, JsValue> {
3229            if !self.inner.is_webrtc_carrier_enabled().await {
3230                return Ok(false);
3231            }
3232            let local_endpoint_id = self
3233                .inner
3234                .current_node_id()
3235                .await
3236                .ok_or_else(|| JsValue::from_str("local Iroh endpoint id is unavailable"))?;
3237            if local_endpoint_id.as_str() <= remote_endpoint_id.as_str()
3238                || !crate::iroh_connection_policy::custom_carrier_base_allows(
3239                    self.inner.iroh_path_kind(&remote_endpoint_id).await,
3240                    crate::client::IrohPathKind::WebRtc,
3241                    false,
3242                )
3243            {
3244                return Ok(false);
3245            }
3246            let endpoint_id = remote_endpoint_id
3247                .parse::<iroh::EndpointId>()
3248                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3249            self.inner
3250                .get_connection(endpoint_id)
3251                .await
3252                .ok_or_else(|| JsValue::from_str("WebRTC carrier base is unavailable"))?;
3253            let generation = self
3254                .inner
3255                .current_wasm_peer_data_generation(&connection_id, None)
3256                .await
3257                .ok_or_else(|| JsValue::from_str("WebRTC carrier generation is unavailable"))?;
3258            let bootstrap = crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::request(
3259                crate::iroh_carrier_bootstrap::CarrierBootstrapKind::WebRtc,
3260                crate::iroh_carrier_bootstrap::CarrierGenerationFence {
3261                    transport_stable_id: generation.transport_stable_id,
3262                    transport_generation: generation.transport_generation,
3263                    route_generation: generation.route_generation,
3264                },
3265                1,
3266            )
3267            .map_err(|error| JsValue::from_str(&error.to_string()))?;
3268            let kind = crate::client::IrohPathKind::WebRtc;
3269            if !matches!(
3270                self.inner
3271                    .reserve_wasm_carrier_upgrade(
3272                        &connection_id,
3273                        kind,
3274                        &bootstrap.upgrade_id,
3275                        generation,
3276                    )
3277                    .await,
3278                crate::client::WasmCarrierUpgradeReservation::Reserved
3279            ) {
3280                let pending = self
3281                    .wasm_webrtc_carrier_attempts
3282                    .borrow_mut()
3283                    .get_mut(&connection_id)
3284                    .filter(|attempt| {
3285                        attempt.role == "initiator"
3286                            && attempt.generation == generation
3287                            && attempt.prepared
3288                            && !attempt.retry_sent
3289                            && !attempt.completion_started
3290                    })
3291                    .map(|attempt| {
3292                        attempt.retry_sent = true;
3293                        attempt.clone()
3294                    });
3295                if let Some(pending) = pending {
3296                    // See the MoQ path above. Reuse the exact pending upgrade
3297                    // ID and generation so duplicate trusted events cannot
3298                    // widen authority or create competing attempts.
3299                    emit_wasm_carrier_action(
3300                        &self.wasm_carrier_action_handler,
3301                        serde_json::json!({
3302                            "type": "send-control",
3303                            "connectionId": connection_id,
3304                            "remoteEndpointId": pending.remote_endpoint_id.to_string(),
3305                            "envelope": pending.bootstrap,
3306                        }),
3307                    );
3308                    return Ok(true);
3309                }
3310                return Ok(false);
3311            }
3312            let previous = self.wasm_webrtc_carrier_attempts.borrow_mut().insert(
3313                connection_id.clone(),
3314                WasmWebRtcCarrierAttempt {
3315                    connection_id: connection_id.clone(),
3316                    remote_endpoint_id: endpoint_id,
3317                    bootstrap: bootstrap.clone(),
3318                    generation,
3319                    role: "initiator",
3320                    prepared: false,
3321                    retry_sent: false,
3322                    offer_started: false,
3323                    remote_ready: false,
3324                    completion_started: false,
3325                    retry_count,
3326                    inbound_authorization_expires_at_ms: None,
3327                },
3328            );
3329            if let Some(previous) = previous {
3330                self.wasm_webrtc_carrier_sessions
3331                    .borrow_mut()
3332                    .remove(&previous.bootstrap.upgrade_id);
3333            }
3334            emit_wasm_carrier_action(
3335                &self.wasm_carrier_action_handler,
3336                serde_json::json!({
3337                    "type": "prepare-webrtc",
3338                    "connectionId": connection_id,
3339                    "remoteEndpointId": remote_endpoint_id,
3340                    "role": "initiator",
3341                    "upgradeId": bootstrap.upgrade_id,
3342                    "carrierSessionId": bootstrap.carrier_session_id,
3343                    "transportGeneration": generation.transport_generation.saturating_add(1),
3344                }),
3345            );
3346            self.schedule_wasm_webrtc_carrier_watchdog(bootstrap);
3347            Ok(true)
3348        }
3349
3350        /// Notify Rust that the main-thread peer connection and packet
3351        /// DataChannel exist, but negotiation has not started yet.
3352        #[cfg(feature = "transport-webrtc")]
3353        #[wasm_bindgen(js_name = __webRtcCarrierPrepared)]
3354        pub async fn iroh_webrtc_carrier_prepared(
3355            &self,
3356            connection_id: String,
3357            upgrade_id: String,
3358        ) -> Result<(), JsValue> {
3359            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3360            let attempt = {
3361                let mut attempts = self.wasm_webrtc_carrier_attempts.borrow_mut();
3362                let attempt = attempts
3363                    .get_mut(&connection_id)
3364                    .filter(|attempt| attempt.bootstrap.upgrade_id == upgrade_id)
3365                    .ok_or_else(|| JsValue::from_str("WebRTC carrier attempt is stale"))?;
3366                attempt.prepared = true;
3367                attempt.clone()
3368            };
3369            let kind = crate::client::IrohPathKind::WebRtc;
3370            if !self
3371                .inner
3372                .wasm_carrier_upgrade_is_current(
3373                    &connection_id,
3374                    kind,
3375                    &upgrade_id,
3376                    attempt.generation,
3377                )
3378                .await
3379            {
3380                return Err(JsValue::from_str("WebRTC carrier attempt was retired"));
3381            }
3382            if attempt.role == "initiator" {
3383                emit_wasm_carrier_action(
3384                    &self.wasm_carrier_action_handler,
3385                    serde_json::json!({
3386                        "type": "send-control",
3387                        "connectionId": connection_id,
3388                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
3389                        "envelope": attempt.bootstrap,
3390                    }),
3391                );
3392            } else {
3393                // The initiator must not send its offer until this responder
3394                // has installed the matching attempt. This explicit ready
3395                // signal also makes browser-to-browser carrier negotiation use
3396                // the same ordering contract as the native responder.
3397                emit_wasm_carrier_action(
3398                    &self.wasm_carrier_action_handler,
3399                    serde_json::json!({
3400                        "type": "send-control",
3401                        "connectionId": attempt.connection_id,
3402                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
3403                        "envelope": {
3404                            "type": "#pluto-signal",
3405                            "content": {
3406                                "transport": "webrtc",
3407                                "type": "renegotiate",
3408                                "negotiationId": upgrade_id,
3409                            }
3410                        },
3411                    }),
3412                );
3413            }
3414            Ok(())
3415        }
3416
3417        #[cfg(feature = "transport-webrtc")]
3418        #[wasm_bindgen(js_name = __webRtcCarrierFailed)]
3419        pub async fn iroh_webrtc_carrier_failed(
3420            &self,
3421            connection_id: String,
3422            upgrade_id: String,
3423            failure_code: String,
3424        ) {
3425            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3426            let attempt = self
3427                .wasm_webrtc_carrier_attempts
3428                .borrow()
3429                .get(&connection_id)
3430                .filter(|attempt| attempt.bootstrap.upgrade_id == upgrade_id)
3431                .cloned();
3432            if let Some(attempt) = attempt {
3433                let failure_code = match failure_code.as_str() {
3434                    "data-channel-failed" => "data-channel-failed",
3435                    "ice-failed" => "ice-failed",
3436                    "signaling-failed" => "signaling-failed",
3437                    "carrier-backpressure" => "carrier-backpressure",
3438                    "carrier-base-generation-stale" => "carrier-base-generation-stale",
3439                    _ => "browser-adapter-failed",
3440                };
3441                if retire_selected_wasm_webrtc_carrier(
3442                    self.inner.clone(),
3443                    self.wasm_webrtc_carrier_attempts.clone(),
3444                    self.wasm_webrtc_carrier_sessions.clone(),
3445                    self.wasm_carrier_action_handler.clone(),
3446                    &attempt,
3447                    failure_code,
3448                    crate::lifecycle_reason::REASON_IROH_CARRIER_FAILED,
3449                )
3450                .await
3451                {
3452                    return;
3453                }
3454                let should_retry = attempt.role == "initiator"
3455                    && attempt.retry_count == 0
3456                    && matches!(
3457                        failure_code,
3458                        "data-channel-failed"
3459                            | "ice-failed"
3460                            | "signaling-failed"
3461                            | "carrier-base-generation-stale"
3462                    );
3463                let remote_endpoint_id = attempt.remote_endpoint_id.to_string();
3464                self.fail_wasm_webrtc_carrier_attempt(attempt, failure_code, true)
3465                    .await;
3466                if should_retry {
3467                    // Browser ICE can report one terminal transition while
3468                    // the network monitor catches up after resume. Retry once
3469                    // on the current admitted base; this sends only peer
3470                    // control frames and cannot touch gateway coordination.
3471                    gloo_timers::future::sleep(std::time::Duration::from_millis(500)).await;
3472                    let _ = self
3473                        .begin_iroh_webrtc_carrier_attempt(connection_id, remote_endpoint_id, 1)
3474                        .await;
3475                }
3476            }
3477        }
3478
3479        #[cfg(feature = "transport-webrtc")]
3480        #[wasm_bindgen(js_name = __retireWebRtcCarrier)]
3481        pub async fn retire_iroh_webrtc_carrier(
3482            &self,
3483            connection_id: String,
3484            terminal_reason: Option<String>,
3485        ) {
3486            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3487            self.retire_iroh_webrtc_carrier_locked(connection_id, terminal_reason.as_deref())
3488                .await;
3489        }
3490
3491        #[cfg(feature = "transport-webrtc")]
3492        async fn retire_iroh_webrtc_carrier_locked(
3493            &self,
3494            connection_id: String,
3495            terminal_reason: Option<&str>,
3496        ) {
3497            let attempt = self
3498                .wasm_webrtc_carrier_attempts
3499                .borrow_mut()
3500                .remove(&connection_id);
3501            if let Some(attempt) = attempt {
3502                self.inner
3503                    .retire_wasm_carrier_upgrade(
3504                        &connection_id,
3505                        crate::client::IrohPathKind::WebRtc,
3506                        &attempt.bootstrap.upgrade_id,
3507                        attempt.generation,
3508                    )
3509                    .await;
3510                let carrier = self
3511                    .wasm_webrtc_carrier_sessions
3512                    .borrow_mut()
3513                    .remove(&attempt.bootstrap.upgrade_id);
3514                if let (Some(reason), Some(session)) = (terminal_reason, carrier.as_ref()) {
3515                    let _ = session.send_terminal(reason).await;
3516                }
3517                emit_wasm_carrier_action(
3518                    &self.wasm_carrier_action_handler,
3519                    serde_json::json!({
3520                        "type": "retire-webrtc",
3521                        "connectionId": connection_id,
3522                        "upgradeId": attempt.bootstrap.upgrade_id,
3523                        "failureCode": "logical-connection-retired",
3524                    }),
3525                );
3526            }
3527        }
3528
3529        /// Ingest one admitted carrier bootstrap or signaling frame. Returning
3530        /// `true` means the frame belongs exclusively to the internal carrier.
3531        #[cfg(feature = "transport-webrtc")]
3532        #[wasm_bindgen(js_name = __handleIrohCarrierControl)]
3533        pub async fn handle_iroh_carrier_control(
3534            &self,
3535            connection_id: String,
3536            remote_endpoint_id: String,
3537            frame: JsValue,
3538        ) -> Result<bool, JsValue> {
3539            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3540            let frame: serde_json::Value = serde_wasm_bindgen::from_value(frame)
3541                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3542            if frame.get("type").and_then(serde_json::Value::as_str) == Some("#pluto-signal")
3543                && frame
3544                    .get("content")
3545                    .and_then(|content| content.get("transport"))
3546                    .and_then(serde_json::Value::as_str)
3547                    == Some("webrtc")
3548            {
3549                let negotiation_id = frame
3550                    .get("content")
3551                    .and_then(|content| content.get("negotiationId"))
3552                    .and_then(serde_json::Value::as_str)
3553                    .unwrap_or_default();
3554                let current = self
3555                    .wasm_webrtc_carrier_attempts
3556                    .borrow()
3557                    .get(&connection_id)
3558                    .is_some_and(|attempt| {
3559                        attempt.bootstrap.upgrade_id == negotiation_id
3560                            && attempt.remote_endpoint_id.to_string() == remote_endpoint_id
3561                    });
3562                if current {
3563                    let signal_type = frame
3564                        .get("content")
3565                        .and_then(|content| content.get("type"))
3566                        .and_then(serde_json::Value::as_str);
3567                    let start_offer = if signal_type == Some("renegotiate") {
3568                        self.wasm_webrtc_carrier_attempts
3569                            .borrow_mut()
3570                            .get_mut(&connection_id)
3571                            .filter(|attempt| {
3572                                attempt.bootstrap.upgrade_id == negotiation_id
3573                                    && attempt.role == "initiator"
3574                                    && !attempt.offer_started
3575                            })
3576                            .map(|attempt| {
3577                                attempt.offer_started = true;
3578                            })
3579                            .is_some()
3580                    } else {
3581                        false
3582                    };
3583                    if start_offer {
3584                        emit_wasm_carrier_action(
3585                            &self.wasm_carrier_action_handler,
3586                            serde_json::json!({
3587                                "type": "start-webrtc",
3588                                "connectionId": connection_id,
3589                                "upgradeId": negotiation_id,
3590                            }),
3591                        );
3592                        return Ok(true);
3593                    }
3594                    emit_wasm_carrier_action(
3595                        &self.wasm_carrier_action_handler,
3596                        serde_json::json!({
3597                            "type": "apply-webrtc-signal",
3598                            "connectionId": connection_id,
3599                            "upgradeId": negotiation_id,
3600                            "signal": frame,
3601                        }),
3602                    );
3603                }
3604                return Ok(true);
3605            }
3606
3607            let Some(bootstrap) =
3608                crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::from_json(&frame)
3609            else {
3610                return Ok(false);
3611            };
3612            #[cfg(feature = "transport-moq")]
3613            if bootstrap.carrier == crate::iroh_carrier_bootstrap::CarrierBootstrapKind::MoqDraft14
3614            {
3615                return self
3616                    .handle_wasm_moq_bootstrap(connection_id, remote_endpoint_id, bootstrap)
3617                    .await;
3618            }
3619            if bootstrap.carrier != crate::iroh_carrier_bootstrap::CarrierBootstrapKind::WebRtc {
3620                return Ok(false);
3621            }
3622            let endpoint_id = remote_endpoint_id
3623                .parse::<iroh::EndpointId>()
3624                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3625            let kind = crate::client::IrohPathKind::WebRtc;
3626            match bootstrap.action {
3627                crate::iroh_carrier_bootstrap::CarrierBootstrapAction::Request => {
3628                    let local_endpoint_id = self
3629                        .inner
3630                        .current_node_id()
3631                        .await
3632                        .ok_or_else(|| JsValue::from_str("local endpoint id is unavailable"))?;
3633                    if local_endpoint_id.as_str() >= remote_endpoint_id.as_str()
3634                        || !self.inner.is_webrtc_carrier_enabled().await
3635                    {
3636                        return Ok(true);
3637                    }
3638                    if !crate::iroh_connection_policy::custom_carrier_base_allows(
3639                        self.inner.iroh_path_kind(&remote_endpoint_id).await,
3640                        crate::client::IrohPathKind::WebRtc,
3641                        false,
3642                    ) {
3643                        return Ok(true);
3644                    }
3645                    let base_connection = self
3646                        .inner
3647                        .get_connection(endpoint_id)
3648                        .await
3649                        .ok_or_else(|| JsValue::from_str("WebRTC carrier base is unavailable"))?;
3650                    let generation = self
3651                        .inner
3652                        .current_wasm_peer_data_generation(&connection_id, None)
3653                        .await
3654                        .ok_or_else(|| {
3655                            JsValue::from_str("WebRTC carrier generation is unavailable")
3656                        })?;
3657                    if crate::transport_generation::for_connection(&base_connection)
3658                        != generation.transport_stable_id
3659                    {
3660                        // The physical accept owner has a newer base than the
3661                        // logical record. Reject before starting ICE so the
3662                        // initiator's one stale-only retry targets the admitted
3663                        // generation instead of creating an asymmetric carrier.
3664                        let failed =
3665                            crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::failed_from(
3666                                &bootstrap,
3667                                "carrier-base-generation-stale",
3668                            )
3669                            .map_err(|error| JsValue::from_str(&error.to_string()))?;
3670                        emit_wasm_carrier_action(
3671                            &self.wasm_carrier_action_handler,
3672                            serde_json::json!({
3673                                "type": "send-control",
3674                                "connectionId": connection_id,
3675                                "remoteEndpointId": remote_endpoint_id,
3676                                "envelope": failed,
3677                            }),
3678                        );
3679                        return Ok(true);
3680                    }
3681                    if !matches!(
3682                        self.inner
3683                            .reserve_wasm_carrier_upgrade(
3684                                &connection_id,
3685                                kind,
3686                                &bootstrap.upgrade_id,
3687                                generation,
3688                            )
3689                            .await,
3690                        crate::client::WasmCarrierUpgradeReservation::Reserved
3691                    ) {
3692                        return Ok(true);
3693                    }
3694                    let node = self
3695                        .inner
3696                        .iroh_node
3697                        .read()
3698                        .await
3699                        .as_ref()
3700                        .cloned()
3701                        .ok_or_else(|| JsValue::from_str("Iroh node is unavailable"))?;
3702                    let authorization_expiry = node
3703                        .authorize_pending_inbound_replacement(
3704                            endpoint_id,
3705                            crate::iroh_carrier_kind::EXPERIMENTAL_WEBRTC_TRANSPORT_ID,
3706                            std::time::Duration::from_secs(45),
3707                        )
3708                        .await;
3709                    let previous = self.wasm_webrtc_carrier_attempts.borrow_mut().insert(
3710                        connection_id.clone(),
3711                        WasmWebRtcCarrierAttempt {
3712                            connection_id: connection_id.clone(),
3713                            remote_endpoint_id: endpoint_id,
3714                            bootstrap: bootstrap.clone(),
3715                            generation,
3716                            role: "responder",
3717                            prepared: false,
3718                            retry_sent: false,
3719                            offer_started: false,
3720                            remote_ready: false,
3721                            completion_started: false,
3722                            retry_count: bootstrap.attempt.saturating_sub(1),
3723                            inbound_authorization_expires_at_ms: Some(authorization_expiry),
3724                        },
3725                    );
3726                    if let Some(previous) = previous {
3727                        self.wasm_webrtc_carrier_sessions
3728                            .borrow_mut()
3729                            .remove(&previous.bootstrap.upgrade_id);
3730                    }
3731                    emit_wasm_carrier_action(
3732                        &self.wasm_carrier_action_handler,
3733                        serde_json::json!({
3734                            "type": "prepare-webrtc",
3735                            "connectionId": connection_id,
3736                            "remoteEndpointId": remote_endpoint_id,
3737                            "role": "responder",
3738                            "upgradeId": bootstrap.upgrade_id,
3739                            "carrierSessionId": bootstrap.carrier_session_id,
3740                            "transportGeneration": generation.transport_generation.saturating_add(1),
3741                        }),
3742                    );
3743                    self.schedule_wasm_webrtc_carrier_watchdog(bootstrap);
3744                }
3745                crate::iroh_carrier_bootstrap::CarrierBootstrapAction::Ready => {
3746                    let upgrade_id = {
3747                        let mut attempts = self.wasm_webrtc_carrier_attempts.borrow_mut();
3748                        attempts
3749                            .get_mut(&connection_id)
3750                            .filter(|attempt| bootstrap.is_response_to(&attempt.bootstrap))
3751                            .map(|attempt| {
3752                                attempt.remote_ready = true;
3753                                attempt.bootstrap.upgrade_id.clone()
3754                            })
3755                    };
3756                    let attempt = upgrade_id.as_deref().and_then(|upgrade_id| {
3757                        self.take_ready_outbound_wasm_webrtc_carrier_attempt(
3758                            &connection_id,
3759                            upgrade_id,
3760                        )
3761                    });
3762                    if let Some(attempt) = attempt {
3763                        self.spawn_outbound_wasm_webrtc_carrier_completion(attempt);
3764                    }
3765                }
3766                crate::iroh_carrier_bootstrap::CarrierBootstrapAction::Failed => {
3767                    let attempt = self
3768                        .wasm_webrtc_carrier_attempts
3769                        .borrow()
3770                        .get(&connection_id)
3771                        .filter(|attempt| bootstrap.is_response_to(&attempt.bootstrap))
3772                        .cloned();
3773                    if let Some(attempt) = attempt {
3774                        let failure_code = crate::client::wasm_peer_carrier_failure_code(
3775                            bootstrap.failure_code.as_deref(),
3776                        );
3777                        if crate::client::should_rearm_wasm_carrier_event_retry(
3778                            failure_code,
3779                            attempt.role == "initiator",
3780                            attempt.completion_started,
3781                        ) {
3782                            if let Some(current) = self
3783                                .wasm_webrtc_carrier_attempts
3784                                .borrow_mut()
3785                                .get_mut(&connection_id)
3786                                .filter(|current| {
3787                                    current.bootstrap.upgrade_id == attempt.bootstrap.upgrade_id
3788                                        && !current.completion_started
3789                                })
3790                            {
3791                                current.retry_sent = false;
3792                            }
3793                            return Ok(true);
3794                        }
3795                        let should_retry = attempt.role == "initiator"
3796                            && attempt.retry_count == 0
3797                            && failure_code == "carrier-base-generation-stale";
3798                        let remote_endpoint_id = attempt.remote_endpoint_id.to_string();
3799                        self.fail_wasm_webrtc_carrier_attempt(attempt, failure_code, false)
3800                            .await;
3801                        if should_retry {
3802                            gloo_timers::future::sleep(std::time::Duration::from_millis(500)).await;
3803                            let _ = self
3804                                .begin_iroh_webrtc_carrier_attempt(
3805                                    connection_id,
3806                                    remote_endpoint_id,
3807                                    1,
3808                                )
3809                                .await;
3810                        }
3811                    }
3812                }
3813            }
3814            Ok(true)
3815        }
3816
3817        #[cfg(all(feature = "transport-moq", not(feature = "transport-webrtc")))]
3818        #[wasm_bindgen(js_name = __handleIrohCarrierControl)]
3819        pub async fn handle_iroh_carrier_control_moq_only(
3820            &self,
3821            connection_id: String,
3822            remote_endpoint_id: String,
3823            frame: JsValue,
3824        ) -> Result<bool, JsValue> {
3825            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3826            let frame: serde_json::Value = serde_wasm_bindgen::from_value(frame)
3827                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3828            let Some(bootstrap) =
3829                crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::from_json(&frame)
3830            else {
3831                return Ok(false);
3832            };
3833            if bootstrap.carrier != crate::iroh_carrier_bootstrap::CarrierBootstrapKind::MoqDraft14
3834            {
3835                return Ok(false);
3836            }
3837            self.handle_wasm_moq_bootstrap(connection_id, remote_endpoint_id, bootstrap)
3838                .await
3839        }
3840
3841        /// Internal browser-adapter boundary. It attaches an already negotiated
3842        /// unreliable DataChannel to the pre-bind Iroh custom transport; it
3843        /// does not authorize, dial, select, or retry a replacement.
3844        #[cfg(feature = "transport-webrtc")]
3845        #[wasm_bindgen(js_name = __attachWebRtcCarrier)]
3846        pub async fn attach_iroh_webrtc_carrier(
3847            &self,
3848            connection_id: String,
3849            remote_endpoint_id: String,
3850            channel: web_sys::RtcDataChannel,
3851            upgrade_id: String,
3852        ) -> Result<(), JsValue> {
3853            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3854            let endpoint_id = remote_endpoint_id
3855                .parse::<iroh::EndpointId>()
3856                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3857            let attempt = self
3858                .wasm_webrtc_carrier_attempts
3859                .borrow()
3860                .get(&connection_id)
3861                .filter(|attempt| {
3862                    attempt.bootstrap.upgrade_id == upgrade_id
3863                        && attempt.remote_endpoint_id == endpoint_id
3864                })
3865                .cloned()
3866                .ok_or_else(|| JsValue::from_str("WebRTC carrier attempt is stale"))?;
3867            if !self
3868                .inner
3869                .wasm_carrier_upgrade_is_current(
3870                    &connection_id,
3871                    crate::client::IrohPathKind::WebRtc,
3872                    &upgrade_id,
3873                    attempt.generation,
3874                )
3875                .await
3876            {
3877                return Err(JsValue::from_str("WebRTC carrier attempt was retired"));
3878            }
3879            let packet_session = self
3880                .inner
3881                .activate_iroh_packet_carrier(
3882                    crate::iroh_carrier_kind::IrohCarrierKind::WebRtc,
3883                    endpoint_id,
3884                )
3885                .await
3886                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3887            let expected = attempt
3888                .bootstrap
3889                .frame_expectation()
3890                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3891            let application_key = self
3892                .inner
3893                .application_crypto_key_for_connection(Some(&connection_id))
3894                .ok_or_else(|| {
3895                    JsValue::from_str("WebRTC carrier requires the admitted application crypto key")
3896                })?;
3897            let terminal_client = self.inner.clone();
3898            let terminal_connection_id = connection_id.clone();
3899            let terminal_upgrade_id = upgrade_id.clone();
3900            let terminal_generation = attempt.generation.transport_generation.saturating_add(1);
3901            let terminal_attempts = self.wasm_webrtc_carrier_attempts.clone();
3902            let terminal_sessions = self.wasm_webrtc_carrier_sessions.clone();
3903            let terminal_handler = self.wasm_carrier_action_handler.clone();
3904            let terminal_lifecycle = self.wasm_carrier_peer_lifecycle.clone();
3905            let on_terminal: Rc<dyn Fn(&'static str)> = Rc::new(move |reason| {
3906                let client = terminal_client.clone();
3907                let connection_id = terminal_connection_id.clone();
3908                let upgrade_id = terminal_upgrade_id.clone();
3909                let attempts = terminal_attempts.clone();
3910                let sessions = terminal_sessions.clone();
3911                let handler = terminal_handler.clone();
3912                let lifecycle = terminal_lifecycle.clone();
3913                spawn_local(async move {
3914                    let _lifecycle = lifecycle.lock().await;
3915                    if !client
3916                        .close_current_iroh_carrier_generation_with_reason(
3917                            &connection_id,
3918                            endpoint_id,
3919                            crate::client::IrohPathKind::WebRtc,
3920                            terminal_generation,
3921                            reason,
3922                        )
3923                        .await
3924                    {
3925                        return;
3926                    }
3927                    // Selection retires the upgrade gate but deliberately
3928                    // retains the browser mechanism for the chosen physical
3929                    // generation. Its terminal callback is therefore the
3930                    // exact Rust-owned point that must release that retained
3931                    // attempt/session and name the upgrade-id-fenced browser
3932                    // object to close. A generic backend state projection is
3933                    // too late and is not generation-authoritative.
3934                    if attempts
3935                        .borrow()
3936                        .get(&connection_id)
3937                        .is_some_and(|current| current.bootstrap.upgrade_id == upgrade_id)
3938                    {
3939                        attempts.borrow_mut().remove(&connection_id);
3940                    }
3941                    sessions.borrow_mut().remove(&upgrade_id);
3942                    emit_wasm_carrier_action(
3943                        &handler,
3944                        serde_json::json!({
3945                            "type": "retire-webrtc",
3946                            "connectionId": connection_id,
3947                            "upgradeId": upgrade_id,
3948                            "failureCode": reason,
3949                        }),
3950                    );
3951                });
3952            });
3953            let carrier = crate::wasm_webrtc_carrier::WasmWebRtcCarrierSession::attach(
3954                channel,
3955                packet_session,
3956                expected,
3957                application_key,
3958                on_terminal,
3959            )?;
3960            self.wasm_webrtc_carrier_sessions
3961                .borrow_mut()
3962                .insert(upgrade_id.clone(), carrier);
3963            if attempt.role == "responder" {
3964                let ready = crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::ready_from(
3965                    &attempt.bootstrap,
3966                )
3967                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3968                emit_wasm_carrier_action(
3969                    &self.wasm_carrier_action_handler,
3970                    serde_json::json!({
3971                        "type": "send-control",
3972                        "connectionId": connection_id,
3973                        "remoteEndpointId": remote_endpoint_id,
3974                        "envelope": ready,
3975                    }),
3976                );
3977                self.spawn_inbound_wasm_webrtc_carrier_completion(attempt);
3978            } else if let Some(attempt) =
3979                self.take_ready_outbound_wasm_webrtc_carrier_attempt(&connection_id, &upgrade_id)
3980            {
3981                self.spawn_outbound_wasm_webrtc_carrier_completion(attempt);
3982            }
3983            Ok(())
3984        }
3985
3986        /// Attach the exact directed Draft 14 MoQ reliable object-stream duplex for a
3987        /// generation-current Rust-owned attempt. This boundary cannot create
3988        /// an attempt or select a replacement.
3989        #[cfg(feature = "transport-moq")]
3990        #[wasm_bindgen(js_name = __attachMoqCarrier)]
3991        pub async fn attach_iroh_moq_carrier(
3992            &self,
3993            connection_id: String,
3994            remote_endpoint_id: String,
3995            datagrams: JsValue,
3996            upgrade_id: String,
3997        ) -> Result<(), JsValue> {
3998            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
3999            let endpoint_id = remote_endpoint_id
4000                .parse::<iroh::EndpointId>()
4001                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4002            let attempt = self
4003                .wasm_moq_carrier_attempts
4004                .borrow()
4005                .get(&connection_id)
4006                .filter(|attempt| {
4007                    attempt.bootstrap.upgrade_id == upgrade_id
4008                        && attempt.remote_endpoint_id == endpoint_id
4009                })
4010                .cloned()
4011                .ok_or_else(|| JsValue::from_str("MoQ carrier attempt is stale"))?;
4012            if !self
4013                .inner
4014                .wasm_carrier_upgrade_is_current(
4015                    &connection_id,
4016                    crate::client::IrohPathKind::Moq,
4017                    &upgrade_id,
4018                    attempt.generation,
4019                )
4020                .await
4021            {
4022                return Err(JsValue::from_str("MoQ carrier attempt was retired"));
4023            }
4024            let packet_session = self
4025                .inner
4026                .activate_iroh_packet_carrier(
4027                    crate::iroh_carrier_kind::IrohCarrierKind::Moq,
4028                    endpoint_id,
4029                )
4030                .await
4031                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4032            let expected = attempt
4033                .bootstrap
4034                .frame_expectation()
4035                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4036            let application_key = self
4037                .inner
4038                .application_crypto_key_for_connection(Some(&connection_id))
4039                .ok_or_else(|| {
4040                    JsValue::from_str("MoQ carrier requires an installed application key")
4041                })?;
4042            let terminal_client = self.inner.clone();
4043            let terminal_connection_id = connection_id.clone();
4044            let terminal_upgrade_id = upgrade_id.clone();
4045            let terminal_generation = attempt.generation.transport_generation.saturating_add(1);
4046            let terminal_attempts = self.wasm_moq_carrier_attempts.clone();
4047            let terminal_sessions = self.wasm_moq_carrier_sessions.clone();
4048            let terminal_handler = self.wasm_carrier_action_handler.clone();
4049            let terminal_lifecycle = self.wasm_carrier_peer_lifecycle.clone();
4050            let on_terminal: Rc<dyn Fn(&'static str)> = Rc::new(move |reason| {
4051                let client = terminal_client.clone();
4052                let connection_id = terminal_connection_id.clone();
4053                let upgrade_id = terminal_upgrade_id.clone();
4054                let attempts = terminal_attempts.clone();
4055                let sessions = terminal_sessions.clone();
4056                let handler = terminal_handler.clone();
4057                let lifecycle = terminal_lifecycle.clone();
4058                spawn_local(async move {
4059                    let _lifecycle = lifecycle.lock().await;
4060                    if !client
4061                        .close_current_iroh_carrier_generation_with_reason(
4062                            &connection_id,
4063                            endpoint_id,
4064                            crate::client::IrohPathKind::Moq,
4065                            terminal_generation,
4066                            reason,
4067                        )
4068                        .await
4069                    {
4070                        return;
4071                    }
4072                    if attempts
4073                        .borrow()
4074                        .get(&connection_id)
4075                        .is_some_and(|current| current.bootstrap.upgrade_id == upgrade_id)
4076                    {
4077                        attempts.borrow_mut().remove(&connection_id);
4078                    }
4079                    sessions.borrow_mut().remove(&upgrade_id);
4080                    emit_wasm_carrier_action(
4081                        &handler,
4082                        serde_json::json!({
4083                            "type": "retire-moq",
4084                            "connectionId": connection_id,
4085                            "upgradeId": upgrade_id,
4086                            "failureCode": reason,
4087                        }),
4088                    );
4089                });
4090            });
4091            let carrier = crate::wasm_moq_carrier::WasmMoqCarrierSession::attach(
4092                datagrams,
4093                packet_session,
4094                expected,
4095                application_key,
4096                on_terminal,
4097            )?;
4098            if attempt.role == "initiator" {
4099                carrier
4100                    .wait_for_peer_data_bidirectional_readiness(std::time::Duration::from_secs(15))
4101                    .await?;
4102            }
4103            self.wasm_moq_carrier_sessions
4104                .borrow_mut()
4105                .insert(upgrade_id.clone(), carrier);
4106            if attempt.role == "initiator" {
4107                self.spawn_outbound_wasm_moq_carrier_completion(attempt);
4108            } else {
4109                let ready = crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::ready_from(
4110                    &attempt.bootstrap,
4111                )
4112                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4113                emit_wasm_carrier_action(
4114                    &self.wasm_carrier_action_handler,
4115                    serde_json::json!({
4116                        "type": "send-control",
4117                        "connectionId": connection_id,
4118                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
4119                        "envelope": ready,
4120                    }),
4121                );
4122                self.spawn_inbound_wasm_moq_carrier_completion(attempt);
4123            }
4124            Ok(())
4125        }
4126
4127        #[wasm_bindgen(js_name = setIdentityCredential)]
4128        pub fn set_identity_credential(&self, credential: Option<String>) {
4129            let has_credential = credential
4130                .as_ref()
4131                .map(|value| !value.is_empty())
4132                .unwrap_or(false);
4133            let credential_len = credential.as_ref().map(|value| value.len()).unwrap_or(0);
4134
4135            if let Ok(mut guard) = self.identity_credential.lock() {
4136                *guard = credential.filter(|value| !value.is_empty());
4137            }
4138
4139            let should_log = if let Ok(mut guard) = self.last_auth_log.lock() {
4140                let next = (has_credential, credential_len);
4141                if guard.as_ref() == Some(&next) {
4142                    false
4143                } else {
4144                    *guard = Some(next);
4145                    true
4146                }
4147            } else {
4148                true
4149            };
4150
4151            if should_log {
4152                web_sys::console::log_1(&JsValue::from_str(&format!(
4153                    "[OPENRTC][WASM-IDENTITY] credential updated present={} len={}",
4154                    has_credential, credential_len
4155                )));
4156            }
4157        }
4158
4159        /// Clear the provider-owned identity assertion without routing an
4160        /// optional string through the wasm-bindgen ABI. Some generated
4161        /// bindings retain the previous string length when `null` is passed
4162        /// for `Option<String>`, which can turn a legitimate logout into a
4163        /// null-pointer trap before Rust receives the call.
4164        #[wasm_bindgen(js_name = clearIdentityCredential)]
4165        pub fn clear_identity_credential(&self) {
4166            self.set_identity_credential(None);
4167        }
4168
4169        /// Normalize and rank eligible route labels using the Rust-owned pure
4170        /// policy. This does not dial, retry, promote, demote, or mutate state.
4171        #[wasm_bindgen(js_name = rankRoutes)]
4172        pub fn rank_routes(
4173            &self,
4174            configured_priority: Vec<String>,
4175            candidates: Vec<String>,
4176        ) -> Vec<String> {
4177            crate::route_policy::rank_routes(&configured_priority, &candidates)
4178        }
4179
4180        /// Apply relay eligibility before the Iroh endpoint is created.
4181        #[wasm_bindgen(js_name = setRelay)]
4182        pub async fn set_relay(&self, enabled: bool) -> Result<(), JsValue> {
4183            self.inner
4184                .set_relay(enabled)
4185                .await
4186                .map_err(|error| JsValue::from_str(&error.to_string()))
4187        }
4188
4189        pub async fn init_iroh(&self, secret_key: Option<Vec<u8>>) -> Result<String, JsValue> {
4190            let started_at = js_sys::Date::now();
4191            web_sys::console::log_1(&JsValue::from_str(&format!(
4192                "[OPENRTC][WASM-API] init_iroh called has_secret_key={} secret_key_len={}",
4193                secret_key.as_ref().is_some(),
4194                secret_key.as_ref().map(|k| k.len()).unwrap_or(0)
4195            )));
4196            match self.inner.init_iroh(secret_key, vec![]).await {
4197                Ok(node_id) => {
4198                    self.inner.clone().start_wasm_accept_bridge();
4199                    let elapsed = js_sys::Date::now() - started_at;
4200                    web_sys::console::log_1(&JsValue::from_str(&format!(
4201                        "[OPENRTC][WASM-API] init_iroh success elapsed_ms={:.0} node_id={}",
4202                        elapsed, node_id
4203                    )));
4204                    Ok(node_id)
4205                }
4206                Err(err) => {
4207                    let elapsed = js_sys::Date::now() - started_at;
4208                    web_sys::console::error_1(&JsValue::from_str(&format!(
4209                        "[OPENRTC][WASM-API] init_iroh failed elapsed_ms={:.0} error={}",
4210                        elapsed, err
4211                    )));
4212                    Err(JsValue::from_str(&err.to_string()))
4213                }
4214            }
4215        }
4216
4217        /// Local-harness-only endpoint initialization. The Rust runtime rejects
4218        /// every non-loopback or non-HTTPS relay URL before binding.
4219        #[wasm_bindgen(js_name = initIrohWithTestRelay)]
4220        pub async fn init_iroh_with_test_relay(
4221            &self,
4222            secret_key: Option<Vec<u8>>,
4223            test_relay_url: Option<String>,
4224        ) -> Result<String, JsValue> {
4225            let node_id = self
4226                .inner
4227                .init_iroh_with_test_relay(secret_key, vec![], test_relay_url.as_deref())
4228                .await
4229                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4230            // The local relay changes only relay selection. It must retain the
4231            // normal browser connection lifecycle, including the accepted
4232            // transport bridge that promotes an inbound replacement generation.
4233            self.inner.clone().start_wasm_accept_bridge();
4234            Ok(node_id)
4235        }
4236
4237        pub async fn iroh_secret_key(&self) -> Result<Vec<u8>, JsValue> {
4238            let node_guard = self.inner.iroh_node.read().await;
4239            if let Some(node) = node_guard.as_ref() {
4240                Ok(node.secret_key())
4241            } else {
4242                Err(JsValue::from_str("Iroh node not initialized"))
4243            }
4244        }
4245
4246        pub async fn node_addr(&self) -> Result<String, JsValue> {
4247            let node_guard = self.inner.iroh_node.read().await;
4248            if let Some(node) = node_guard.as_ref() {
4249                let addr = node
4250                    .node_addr()
4251                    .await
4252                    .map_err(|e| JsValue::from_str(&e.to_string()))?;
4253                serde_json::to_string(&addr).map_err(|e| JsValue::from_str(&e.to_string()))
4254            } else {
4255                Err(JsValue::from_str("Iroh node not initialized"))
4256            }
4257        }
4258
4259        pub async fn endpoint_ticket(&self) -> Result<String, JsValue> {
4260            self.inner
4261                .endpoint_ticket()
4262                .await
4263                .map_err(|e| JsValue::from_str(&e.to_string()))
4264        }
4265
4266        /// Build a compound ticket with an embedded session token.
4267        /// Registers the token on the canonical Rust client and returns the compound ticket string.
4268        /// `scope`: logical label (e.g. "share"). `max_connections`: 0 = unlimited.
4269        pub async fn endpoint_ticket_with_token(
4270            &self,
4271            grant_scope: String,
4272            max_connections: u32,
4273        ) -> Result<String, JsValue> {
4274            self.inner
4275                .endpoint_ticket_with_token(&grant_scope, max_connections)
4276                .await
4277                .map_err(|e| JsValue::from_str(&e.to_string()))
4278        }
4279
4280        /// Register a session token on the canonical Rust client.
4281        pub fn register_session_token(
4282            &self,
4283            token: String,
4284            grant_scope: String,
4285            max_connections: u32,
4286        ) {
4287            self.inner
4288                .register_session_token(token, grant_scope, max_connections);
4289        }
4290
4291        /// Register a session token with an absolute Unix-millisecond expiry.
4292        pub fn register_token_until(
4293            &self,
4294            token: String,
4295            grant_scope: String,
4296            max_connections: u32,
4297            expires_at_ms: u64,
4298        ) {
4299            self.inner
4300                .register_token_until(token, grant_scope, max_connections, expires_at_ms);
4301        }
4302
4303        /// Mark a connection as requiring application crypto on native outbound paths.
4304        #[wasm_bindgen(js_name = setConnectionApplicationCryptoRequired)]
4305        pub fn set_connection_application_crypto_required(
4306            &self,
4307            connection_id: String,
4308        ) -> Result<(), JsValue> {
4309            self.inner
4310                .set_connection_application_crypto_required(&connection_id);
4311            Ok(())
4312        }
4313
4314        /// Install a negotiated per-connection application crypto key for native send paths.
4315        #[wasm_bindgen(js_name = setConnectionApplicationCryptoKey)]
4316        pub async fn set_connection_application_crypto_key(
4317            &self,
4318            connection_id: String,
4319            key: Vec<u8>,
4320        ) -> Result<(), JsValue> {
4321            if key.len() != crate::application_crypto::APPLICATION_KEY_BYTES {
4322                return Err(JsValue::from_str("application crypto key must be 32 bytes"));
4323            }
4324            let mut key_bytes = [0u8; crate::application_crypto::APPLICATION_KEY_BYTES];
4325            key_bytes.copy_from_slice(&key);
4326            self.inner
4327                .set_connection_application_crypto_key(&connection_id, key_bytes);
4328            self.inner
4329                .emit_current_wasm_connection_state(&connection_id)
4330                .await;
4331            Ok(())
4332        }
4333
4334        /// Retire the negotiated application key and ephemeral agreement for a
4335        /// logical connection. Connection ids may be reused after an ACL revoke
4336        /// and regrant, so lifecycle cleanup must clear the WASM runtime together
4337        /// with the TypeScript crypto indexes before a fresh handshake begins.
4338        #[wasm_bindgen(js_name = clearConnectionApplicationCryptoKey)]
4339        pub fn clear_connection_application_crypto_key(&self, connection_id: String) {
4340            self.inner
4341                .clear_connection_application_crypto_key(&connection_id);
4342        }
4343
4344        /// Negotiate and confirm the current generation's application key.
4345        /// TypeScript awaits this operation only to project readiness; Rust
4346        /// owns key material, framing, retries, and generation invalidation.
4347        #[wasm_bindgen(js_name = ensureConnectionApplicationCrypto)]
4348        pub async fn ensure_connection_application_crypto(
4349            &self,
4350            connection_id: String,
4351            remote_node_id: String,
4352            timeout_ms: u64,
4353            send_frame: js_sys::Function,
4354        ) -> Result<(), JsValue> {
4355            let connection_id = connection_id.trim().to_string();
4356            if connection_id.is_empty() {
4357                return Err(JsValue::from_str("connection id is required"));
4358            }
4359            let remote_node_id = remote_node_id.trim().to_string();
4360            if remote_node_id.is_empty() {
4361                return Err(JsValue::from_str("remote node id is required"));
4362            }
4363            let remote_endpoint_id: iroh::EndpointId = remote_node_id.parse().map_err(|error| {
4364                JsValue::from_str(&format!("invalid remote endpoint id: {error}"))
4365            })?;
4366            // The native-main opener and the key-agreement waiter must capture
4367            // the same Rust-owned generation. A legacy/early browser
4368            // projection can already be Connected while its stable ID is
4369            // absent or stale; repair that projection from the physical owner
4370            // before freezing the transcript generation below.
4371            self.inner
4372                .ensure_connection_manager_record_before_peer_stream(&remote_endpoint_id)
4373                .await
4374                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4375            let timeout_ms = timeout_ms.max(1);
4376            let initial = self
4377                .inner
4378                .connection_manager
4379                .get_by_connection_id(&connection_id)
4380                .await
4381                .ok_or_else(|| JsValue::from_str("connection is not registered"))?;
4382            let initial_endpoint = initial
4383                .endpoint_id
4384                .as_deref()
4385                .or(initial.node_id.as_deref())
4386                .map(ToOwned::to_owned)
4387                .ok_or_else(|| JsValue::from_str("connection has no endpoint"))?;
4388            let initial_transport_stable_id = initial.transport_stable_id;
4389            let initial_transport_generation = initial.transport_generation;
4390            let initial_route_generation = initial.route_generation;
4391            self.inner
4392                .set_connection_application_crypto_required(&connection_id);
4393
4394            let started_at_ms = js_sys::Date::now();
4395            let retry_delays_ms = [0_u64, 250, 750];
4396            for (attempt, delay_ms) in retry_delays_ms.into_iter().enumerate() {
4397                if delay_ms > 0 {
4398                    let remaining_ms = timeout_ms
4399                        .saturating_sub((js_sys::Date::now() - started_at_ms).max(0.0) as u64);
4400                    if remaining_ms == 0 {
4401                        break;
4402                    }
4403                    gloo_timers::future::sleep(std::time::Duration::from_millis(
4404                        delay_ms.min(remaining_ms),
4405                    ))
4406                    .await;
4407                }
4408                self.inner
4409                    .assert_application_crypto_generation(
4410                        &connection_id,
4411                        &initial_endpoint,
4412                        initial_transport_stable_id,
4413                        initial_transport_generation,
4414                        initial_route_generation,
4415                    )
4416                    .await
4417                    .map_err(|error| JsValue::from_str(&error.to_string()))?;
4418                if self
4419                    .inner
4420                    .connection_application_crypto_key(&connection_id)
4421                    .is_some()
4422                    && self.inner.connection_application_crypto_is_confirmed(
4423                        &connection_id,
4424                        initial_transport_stable_id,
4425                    )
4426                {
4427                    let confirmed_transport_stable_id =
4428                        initial_transport_stable_id.ok_or_else(|| {
4429                            JsValue::from_str("confirmed connection has no stable ID")
4430                        })?;
4431                    if !self
4432                        .inner
4433                        .confirm_managed_connection_readiness_from_transport_proof(
4434                            &connection_id,
4435                            confirmed_transport_stable_id,
4436                        )
4437                        .await
4438                    {
4439                        return Err(JsValue::from_str(
4440                            "confirmed application route belongs to a retired generation",
4441                        ));
4442                    }
4443                    self.inner
4444                        .emit_current_wasm_connection_state(&connection_id)
4445                        .await;
4446                    return Ok(());
4447                }
4448                let frame = self
4449                    .inner
4450                    .application_key_handshake_frame(&connection_id, "capability-update")
4451                    .map_err(|error| JsValue::from_str(&error.to_string()))?;
4452                let frame = serde::Serialize::serialize(
4453                    &frame,
4454                    &serde_wasm_bindgen::Serializer::json_compatible(),
4455                )
4456                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4457                let send_result = send_frame.call1(&JsValue::NULL, &frame)?;
4458                wasm_bindgen_futures::JsFuture::from(js_sys::Promise::resolve(&send_result))
4459                    .await?;
4460
4461                let attempt_deadline_ms = ((attempt + 1) as u64 * timeout_ms / 3).max(1);
4462                loop {
4463                    if self
4464                        .inner
4465                        .connection_application_crypto_key(&connection_id)
4466                        .is_some()
4467                        && self.inner.connection_application_crypto_is_confirmed(
4468                            &connection_id,
4469                            initial_transport_stable_id,
4470                        )
4471                    {
4472                        let confirmed_transport_stable_id = initial_transport_stable_id
4473                            .ok_or_else(|| {
4474                                JsValue::from_str("confirmed connection has no stable ID")
4475                            })?;
4476                        if !self
4477                            .inner
4478                            .confirm_managed_connection_readiness_from_transport_proof(
4479                                &connection_id,
4480                                confirmed_transport_stable_id,
4481                            )
4482                            .await
4483                        {
4484                            return Err(JsValue::from_str(
4485                                "confirmed application route belongs to a retired generation",
4486                            ));
4487                        }
4488                        self.inner
4489                            .emit_current_wasm_connection_state(&connection_id)
4490                            .await;
4491                        return Ok(());
4492                    }
4493                    let elapsed_ms = (js_sys::Date::now() - started_at_ms).max(0.0) as u64;
4494                    if elapsed_ms >= attempt_deadline_ms || elapsed_ms >= timeout_ms {
4495                        break;
4496                    }
4497                    self.inner
4498                        .assert_application_crypto_generation(
4499                            &connection_id,
4500                            &initial_endpoint,
4501                            initial_transport_stable_id,
4502                            initial_transport_generation,
4503                            initial_route_generation,
4504                        )
4505                        .await
4506                        .map_err(|error| JsValue::from_str(&error.to_string()))?;
4507                    gloo_timers::future::sleep(std::time::Duration::from_millis(25)).await;
4508                }
4509            }
4510            let current_manager_transport_stable_id = self
4511                .inner
4512                .connection_manager
4513                .get_by_connection_id(&connection_id)
4514                .await
4515                .and_then(|record| record.transport_stable_id);
4516            let current_physical_transport_stable_id = self
4517                .inner
4518                .get_connection(remote_endpoint_id)
4519                .await
4520                .map(|connection| crate::transport_generation::for_connection(&connection));
4521            let confirmation = self
4522                .inner
4523                .connection_application_crypto_confirmation_snapshot(&connection_id);
4524            return Err(JsValue::from_str(&format!(
4525                "connection {} reciprocal application key agreement timed out after {}ms: frozen_stable_id={:?} current_manager_stable_id={:?} current_physical_stable_id={:?} confirmation={:?}",
4526                connection_id,
4527                timeout_ms,
4528                initial_transport_stable_id,
4529                current_manager_transport_stable_id,
4530                current_physical_transport_stable_id,
4531                confirmation,
4532            )));
4533        }
4534
4535        #[wasm_bindgen(js_name = handleConnectionApplicationCryptoHandshake)]
4536        pub async fn handle_connection_application_crypto_handshake(
4537            &self,
4538            connection_id: String,
4539            remote_node_id: String,
4540            transport_stable_id: Option<u64>,
4541            action: Option<String>,
4542            remote_public_key: Vec<u8>,
4543        ) -> Result<JsValue, JsValue> {
4544            if remote_public_key.len() != crate::key_agreement::PUBLIC_KEY_BYTES {
4545                return Err(JsValue::from_str(
4546                    "application key agreement public key must be 32 bytes",
4547                ));
4548            }
4549            let mut remote_public = [0_u8; crate::key_agreement::PUBLIC_KEY_BYTES];
4550            remote_public.copy_from_slice(&remote_public_key);
4551            let outcome = self
4552                .inner
4553                .accept_application_key_handshake(
4554                    &connection_id,
4555                    &remote_node_id,
4556                    transport_stable_id,
4557                    action.as_deref(),
4558                    remote_public,
4559                )
4560                .await
4561                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4562            let reply_frame = outcome
4563                .reply_action
4564                .map(|reply_action| {
4565                    self.inner
4566                        .application_key_handshake_frame(&connection_id, reply_action)
4567                })
4568                .transpose()
4569                .map_err(|error| JsValue::from_str(&error.to_string()))?;
4570            self.inner
4571                .emit_current_wasm_connection_state(&connection_id)
4572                .await;
4573            serde::Serialize::serialize(
4574                &reply_frame,
4575                &serde_wasm_bindgen::Serializer::json_compatible(),
4576            )
4577            .map_err(|error| JsValue::from_str(&error.to_string()))
4578        }
4579
4580        #[wasm_bindgen(js_name = connectionApplicationCryptoReady)]
4581        pub fn connection_application_crypto_ready(
4582            &self,
4583            connection_id: String,
4584            transport_stable_id: u64,
4585        ) -> bool {
4586            self.inner
4587                .connection_application_crypto_key(&connection_id)
4588                .is_some()
4589                && self.inner.connection_application_crypto_is_confirmed(
4590                    &connection_id,
4591                    Some(transport_stable_id),
4592                )
4593        }
4594
4595        #[wasm_bindgen(js_name = protectConnectionApplicationPayload)]
4596        pub fn protect_connection_application_payload(
4597            &self,
4598            connection_id: String,
4599            type_id: u8,
4600            payload: Vec<u8>,
4601        ) -> Result<Vec<u8>, JsValue> {
4602            self.inner
4603                .protect_outbound_application_payload_with_type(&connection_id, type_id, &payload)
4604                .map_err(|error| JsValue::from_str(&error.to_string()))
4605        }
4606
4607        #[wasm_bindgen(js_name = openConnectionApplicationPayload)]
4608        pub fn open_connection_application_payload(
4609            &self,
4610            connection_id: String,
4611            type_id: u8,
4612            payload: Vec<u8>,
4613        ) -> Result<Vec<u8>, JsValue> {
4614            self.inner
4615                .open_inbound_application_payload_with_type(&connection_id, type_id, &payload)
4616                .map_err(|error| JsValue::from_str(&error.to_string()))
4617        }
4618
4619        /// Validate (and consume one use of) a session token.
4620        /// Returns the scope string on success, throws on failure.
4621        /// If the registry is empty, always succeeds (backward-compat gate).
4622        pub fn validate_session_token(&self, token: String) -> Result<String, JsValue> {
4623            self.inner
4624                .validate_session_token(&token)
4625                .map_err(|e| JsValue::from_str(&e))
4626        }
4627
4628        /// Validate and record token admission for a specific connection.
4629        pub async fn validate_connection_token(
4630            &self,
4631            token: String,
4632            connection_id: String,
4633            token_payload: Option<String>,
4634        ) -> Result<String, JsValue> {
4635            self.inner
4636                .validate_connection_token(&token, &connection_id, token_payload.as_deref())
4637                .await
4638                .map_err(|e| JsValue::from_str(&e))
4639        }
4640
4641        /// Present a session token to the remote host over the SDK-owned
4642        /// native main stream before application traffic starts.
4643        /// Returns the approved scope string once the host acknowledges admission.
4644        ///
4645        /// Requires a managed transport record: call [`Self::connect_device`]
4646        /// (or another dial that runs `ensure_connected_addr`) before presenting.
4647        pub async fn present_session_token(
4648            &self,
4649            endpoint_id: String,
4650            token: String,
4651            token_payload: Option<String>,
4652            device_id: Option<String>,
4653        ) -> Result<String, JsValue> {
4654            crate::console_log!(
4655                "[OpenRTC][session-admission][wasm-present] endpoint_id={} claimed_local_device_id={}",
4656                endpoint_id,
4657                device_id.as_deref().unwrap_or("<none>")
4658            );
4659            let endpoint_id_parsed: iroh::EndpointId = endpoint_id
4660                .parse()
4661                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
4662            let local_node_id = self.inner.current_node_id().await.ok_or_else(|| {
4663                JsValue::from_str("missing local node id for session-token presentation")
4664            })?;
4665            let connection_id =
4666                crate::client::Client::deterministic_connection_id(&local_node_id, &endpoint_id);
4667            let approval_scope = self
4668                .inner
4669                .present_and_accept_session_token_with_local_claim(
4670                    endpoint_id_parsed,
4671                    &connection_id,
4672                    &token,
4673                    token_payload.as_deref(),
4674                    None,
4675                    device_id,
4676                )
4677                .await
4678                .map_err(|e| JsValue::from_str(&e))?;
4679            self.inner
4680                .emit_current_wasm_connection_state(&connection_id)
4681                .await;
4682
4683            Ok(approval_scope)
4684        }
4685
4686        /// Report whether this runtime has the current transport-generation
4687        /// outbound admission proof required by an endpoint ticket.
4688        pub async fn is_remote_admitted(&self, endpoint_ticket: String) -> Result<bool, JsValue> {
4689            self.inner
4690                .is_remote_admitted(&endpoint_ticket)
4691                .await
4692                .map_err(|error| JsValue::from_str(&error.to_string()))
4693        }
4694
4695        /// Fence one browser-relayed reciprocal presentation in the Rust
4696        /// admission owner before the adapter writes it to the stream.
4697        #[allow(clippy::too_many_arguments)]
4698        pub async fn prepare_reciprocal_admission(
4699            &self,
4700            endpoint_id: String,
4701            expected_transport_stable_id: u64,
4702            stream_instance_id: String,
4703            presentation_id: String,
4704            token: String,
4705            token_payload: String,
4706            device_id: String,
4707            stream_contract: String,
4708        ) -> Result<bool, JsValue> {
4709            let endpoint_id: iroh::EndpointId = endpoint_id
4710                .parse()
4711                .map_err(|error| JsValue::from_str(&format!("{error}")))?;
4712            let stream_contract = match stream_contract.trim() {
4713                "one-shot-admission" => {
4714                    crate::native_protocol::TokenStreamContract::OneShotAdmission
4715                }
4716                "persistent-control" => {
4717                    crate::native_protocol::TokenStreamContract::PersistentControl
4718                }
4719                other => {
4720                    return Err(JsValue::from_str(&format!(
4721                        "unsupported reciprocal stream contract: {other}"
4722                    )))
4723                }
4724            };
4725            self.inner
4726                .prepare_reciprocal_admission(
4727                    endpoint_id,
4728                    expected_transport_stable_id,
4729                    stream_instance_id.as_str(),
4730                    presentation_id.as_str(),
4731                    token.as_str(),
4732                    token_payload.as_str(),
4733                    device_id.as_str(),
4734                    stream_contract,
4735                )
4736                .await
4737                .map_err(|error| JsValue::from_str(&error))?;
4738            Ok(true)
4739        }
4740
4741        /// Commit an inline reciprocal session admission only when the ACK
4742        /// belongs to the exact Rust-owned transcript and physical generation.
4743        pub async fn confirm_reciprocal_admission(
4744            &self,
4745            endpoint_id: String,
4746            expected_transport_stable_id: u64,
4747            stream_instance_id: String,
4748            presentation_id: String,
4749            accepted: bool,
4750            approval_scope: Option<String>,
4751        ) -> Result<bool, JsValue> {
4752            let endpoint_id: iroh::EndpointId = endpoint_id
4753                .parse()
4754                .map_err(|error| JsValue::from_str(&format!("{error}")))?;
4755            self.inner
4756                .confirm_reciprocal_admission(
4757                    endpoint_id,
4758                    expected_transport_stable_id,
4759                    stream_instance_id.as_str(),
4760                    presentation_id.as_str(),
4761                    accepted,
4762                    approval_scope.as_deref(),
4763                )
4764                .await
4765                .map_err(|error| JsValue::from_str(&error))?;
4766            self.inner
4767                .emit_current_wasm_connection_state(
4768                    &crate::client::Client::deterministic_connection_id(
4769                        &self.inner.current_node_id().await.ok_or_else(|| {
4770                            JsValue::from_str(
4771                                "missing local node id after reciprocal admission ACK",
4772                            )
4773                        })?,
4774                        &endpoint_id.to_string(),
4775                    ),
4776                )
4777                .await;
4778            Ok(true)
4779        }
4780
4781        /// Revoke a single token by value.
4782        pub fn revoke_session_token(&self, token: String) -> Result<JsValue, JsValue> {
4783            serde_wasm_bindgen::to_value(&self.inner.revoke_session_token(&token))
4784                .map_err(|error| JsValue::from_str(&error.to_string()))
4785        }
4786
4787        /// Revoke all tokens that match the given scope and disconnect affected peers.
4788        pub async fn revoke_tokens_by_scope(
4789            &self,
4790            grant_scope: String,
4791        ) -> Result<JsValue, JsValue> {
4792            let affected = self.inner.begin_revoke_tokens_by_scope(&grant_scope);
4793            // `Client` has made the terminal authorization decision but keeps
4794            // the exact carrier generation alive long enough to send its
4795            // authenticated terminal frame. This is teardown only: it performs
4796            // no gateway operation and cannot create or retry a carrier.
4797            for connection_id in &affected {
4798                #[cfg(not(any(feature = "transport-webrtc", feature = "transport-moq")))]
4799                let _ = connection_id;
4800                #[cfg(feature = "transport-webrtc")]
4801                self.retire_iroh_webrtc_carrier(
4802                    connection_id.clone(),
4803                    Some(crate::lifecycle_reason::REASON_SESSION_TOKEN_REVOKED.to_string()),
4804                )
4805                .await;
4806                #[cfg(feature = "transport-moq")]
4807                self.retire_iroh_moq_carrier(
4808                    connection_id.clone(),
4809                    Some(crate::lifecycle_reason::REASON_SESSION_TOKEN_REVOKED.to_string()),
4810                )
4811                .await;
4812            }
4813            self.inner
4814                .finish_revoke_tokens_by_scope(&grant_scope, &affected)
4815                .await;
4816            serde_wasm_bindgen::to_value(&affected).map_err(|e| JsValue::from_str(&e.to_string()))
4817        }
4818
4819        /// Clear all short-lived session tokens and admission state.
4820        pub fn clear_session_tokens(&self) {
4821            self.inner.clear_session_tokens();
4822        }
4823
4824        pub fn endpoint_id_from_ticket(&self, ticket: String) -> Result<String, JsValue> {
4825            let (iroh_ticket, _token_suffix) = split_ticket(ticket.trim());
4826            let parsed = EndpointTicket::from_str(iroh_ticket)
4827                .map_err(|e| JsValue::from_str(&format!("Invalid endpoint ticket: {}", e)))?;
4828            Ok(parsed.endpoint_addr().id.to_string())
4829        }
4830
4831        pub async fn disconnect(&self, endpoint_id: String) -> Result<(), JsValue> {
4832            let endpoint_id: iroh::EndpointId = endpoint_id
4833                .parse()
4834                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
4835            let node_guard = self.inner.iroh_node.read().await;
4836            if let Some(node) = node_guard.as_ref() {
4837                node.disconnect(endpoint_id)
4838                    .await
4839                    .map_err(|e| JsValue::from_str(&e.to_string()))
4840            } else {
4841                Err(JsValue::from_str("Iroh node not initialized"))
4842            }
4843        }
4844
4845        /// Drop the iroh transport to a peer with a **transient** reason — a
4846        /// simulated network flap, as opposed to [`disconnect`] which signals a
4847        /// user/manual disconnect.
4848        ///
4849        /// `disconnect()` (and the generic close) reports `disconnected by user`,
4850        /// which `lifecycle_reason` classifies as `ManualDisconnect` — terminal and
4851        /// sticky: the remote will NOT auto-reconnect and WebRTC is retired
4852        /// immediately. That is correct for a real user action, but wrong for a
4853        /// transient transport drop. This variant uses a transient reason code
4854        /// (`network-change-forced-reconnect`, `is_transient()`), so both
4855        /// peers treat the drop as a recoverable transition and auto-reconnect —
4856        /// the browser equivalent of the native test harness's `irohDisconnect`.
4857        pub async fn disconnect_transient(&self, endpoint_id: String) -> Result<(), JsValue> {
4858            let endpoint_id: iroh::EndpointId = endpoint_id
4859                .parse()
4860                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
4861            self.inner
4862                .disconnect_with_reason(
4863                    endpoint_id,
4864                    crate::lifecycle_reason::REASON_NETWORK_CHANGE_RECONNECT,
4865                )
4866                .await
4867                .map_err(|e| JsValue::from_str(&e.to_string()))?;
4868            // `disconnect_with_reason` has only an `&Client`; wake from this
4869            // WASM boundary, which owns the Arc and the single browser
4870            // desired-peer actor. This is a recoverable edge change, not a new
4871            // provider revision, so the actor would otherwise stay idle after
4872            // a previously healthy peer is marked replacement-pending.
4873            self.inner.wake_browser_auto_connect();
4874            Ok(())
4875        }
4876
4877        pub async fn is_connected(&self, endpoint_id: String) -> Result<bool, JsValue> {
4878            let endpoint_id: iroh::EndpointId = endpoint_id
4879                .parse()
4880                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
4881            Ok(self.inner.is_connected(endpoint_id).await)
4882        }
4883
4884        pub fn runtime_policy(&self) -> Result<JsValue, JsValue> {
4885            serde_wasm_bindgen::to_value(&self.inner.policy_snapshot())
4886                .map_err(|e| JsValue::from_str(&e.to_string()))
4887        }
4888
4889        pub async fn add_peer_scope(&self, id: String, scope: String) -> Result<JsValue, JsValue> {
4890            let scopes = self.inner.add_peer_scope(&id, &scope).await;
4891            serde_wasm_bindgen::to_value(&scopes).map_err(|e| JsValue::from_str(&e.to_string()))
4892        }
4893
4894        pub async fn release_peer_scope(
4895            &self,
4896            id: String,
4897            scope: Option<String>,
4898        ) -> Result<JsValue, JsValue> {
4899            let scopes = self.inner.release_peer_scope(&id, scope.as_deref()).await;
4900            serde_wasm_bindgen::to_value(&scopes).map_err(|e| JsValue::from_str(&e.to_string()))
4901        }
4902
4903        pub async fn peer_scopes(&self, id: String) -> Result<JsValue, JsValue> {
4904            let scopes = self.inner.peer_scopes(&id).await;
4905            serde_wasm_bindgen::to_value(&scopes).map_err(|e| JsValue::from_str(&e.to_string()))
4906        }
4907
4908        pub async fn same_peer(&self, left: String, right: String) -> Result<bool, JsValue> {
4909            Ok(self.inner.same_peer(&left, &right).await)
4910        }
4911
4912        pub async fn peer_snapshot(&self, id: String) -> Result<JsValue, JsValue> {
4913            let snapshot = self.inner.peer_snapshot(&id).await;
4914            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
4915        }
4916
4917        // U9: the `peer_snapshots()` wasm binding was retired; `peer_sessions()`
4918        // (below) is the single settled Rust projection exposed to TS.
4919
4920        pub async fn peer_session(&self, id: String) -> Result<JsValue, JsValue> {
4921            let snapshot = self.inner.peer_session(&id).await;
4922            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
4923        }
4924
4925        pub async fn peer_sessions(&self) -> Result<JsValue, JsValue> {
4926            let snapshots = self.inner.peer_sessions().await;
4927            serde_wasm_bindgen::to_value(&snapshots).map_err(|e| JsValue::from_str(&e.to_string()))
4928        }
4929
4930        pub async fn connection_state(&self, connection_id: String) -> Result<JsValue, JsValue> {
4931            let snapshot = self.inner.connection_state(&connection_id).await;
4932            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
4933        }
4934
4935        #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
4936        async fn revisit_preferred_iroh_carrier_after_settlement(&self, connection_id: &str) {
4937            let _lifecycle = self.wasm_carrier_peer_lifecycle.lock().await;
4938            let Some(record) = self
4939                .inner
4940                .connection_manager
4941                .get_by_connection_id(connection_id)
4942                .await
4943            else {
4944                return;
4945            };
4946            // A settled custom route already owns the preferred carrier. This
4947            // wake is only for a newly admitted base-Iroh replacement.
4948            if matches!(
4949                record.active_transport.as_str(),
4950                crate::transport_label::WEBRTC | crate::transport_label::MOQ
4951            ) {
4952                return;
4953            }
4954            let Some(capabilities) = self
4955                .wasm_remote_carrier_capabilities
4956                .borrow()
4957                .get(connection_id)
4958                .copied()
4959            else {
4960                return;
4961            };
4962            let Some(remote_endpoint_id) = self
4963                .inner
4964                .wasm_iroh_carrier_remote_endpoint_id(connection_id)
4965                .await
4966            else {
4967                return;
4968            };
4969            if let Err(error) = self
4970                .begin_preferred_iroh_carrier_locked(
4971                    connection_id.to_string(),
4972                    remote_endpoint_id,
4973                    capabilities.webrtc,
4974                    capabilities.moq,
4975                )
4976                .await
4977            {
4978                web_sys::console::warn_1(&JsValue::from_str(&format!(
4979                    "[OpenRTC][Iroh carrier] settled-generation revisit failed connection_id={} error={:?}",
4980                    connection_id, error,
4981                )));
4982            }
4983        }
4984
4985        pub async fn connection_states(&self) -> Result<JsValue, JsValue> {
4986            let snapshots = self.inner.connection_states().await;
4987            serde_wasm_bindgen::to_value(&snapshots).map_err(|e| JsValue::from_str(&e.to_string()))
4988        }
4989
4990        pub async fn wait_for_peer(
4991            &self,
4992            id: String,
4993            timeout_ms: Option<u32>,
4994        ) -> Result<JsValue, JsValue> {
4995            let snapshot = self
4996                .inner
4997                .wait_for_peer(&id, timeout_ms.map(|value| value as u64))
4998                .await;
4999            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
5000        }
5001
5002        pub async fn resolve_peer_connection_records(
5003            &self,
5004            id: String,
5005        ) -> Result<JsValue, JsValue> {
5006            let records = self.inner.resolve_peer_connection_records(&id).await;
5007            serde_wasm_bindgen::to_value(&records).map_err(|e| JsValue::from_str(&e.to_string()))
5008        }
5009
5010        pub async fn list_managed_connections(&self) -> Result<JsValue, JsValue> {
5011            let records = self.inner.list_managed_connections().await;
5012            serde_wasm_bindgen::to_value(&records).map_err(|e| JsValue::from_str(&e.to_string()))
5013        }
5014
5015        pub async fn bind_connection_device_id(
5016            &self,
5017            connection_id: String,
5018            device_id: String,
5019        ) -> Result<JsValue, JsValue> {
5020            let snapshot = self
5021                .inner
5022                .bind_connection_device_id(&connection_id, &device_id)
5023                .await;
5024            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
5025        }
5026
5027        pub async fn acknowledge_session_token_response(
5028            &self,
5029            connection_id: String,
5030            remote_node_id: String,
5031            transport_stable_id: u64,
5032            claimed_device_id: Option<String>,
5033        ) -> bool {
5034            self.inner
5035                .acknowledge_wasm_session_token_response(
5036                    &connection_id,
5037                    &remote_node_id,
5038                    transport_stable_id,
5039                    claimed_device_id.as_deref(),
5040                )
5041                .await
5042        }
5043
5044        pub async fn bind_node_device_id(
5045            &self,
5046            node_id: String,
5047            device_id: String,
5048        ) -> Result<(), JsValue> {
5049            self.inner.bind_node_device_id(&node_id, &device_id).await;
5050            Ok(())
5051        }
5052
5053        pub async fn reject_connection_admission(
5054            &self,
5055            connection_id: String,
5056            reason: String,
5057        ) -> Result<JsValue, JsValue> {
5058            self.inner
5059                .reject_session_connection(&connection_id, &reason);
5060            self.inner
5061                .emit_current_wasm_connection_state(&connection_id)
5062                .await;
5063            let snapshot = self.inner.connection_state(&connection_id).await;
5064            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
5065        }
5066
5067        pub async fn report_managed_connection_settled(
5068            &self,
5069            connection_id: String,
5070            settled: bool,
5071            device_id: Option<String>,
5072            transport_stable_id: Option<u64>,
5073            transport_generation: Option<u64>,
5074            route_generation: Option<u64>,
5075        ) -> Result<JsValue, JsValue> {
5076            let snapshot = match (transport_stable_id, transport_generation, route_generation) {
5077                (Some(transport_stable_id), Some(transport_generation), Some(route_generation)) => {
5078                    self.inner
5079                        .report_managed_connection_settled_for_transport(
5080                            &connection_id,
5081                            settled,
5082                            transport_stable_id,
5083                            transport_generation,
5084                            route_generation,
5085                        )
5086                        .await
5087                }
5088                _ => None,
5089            };
5090            let _ = device_id;
5091            #[cfg(any(feature = "transport-webrtc", feature = "transport-moq"))]
5092            if settled && snapshot.is_some() {
5093                // `set_settled_if_current` above is the generation and
5094                // application-route fence. Re-enter the existing Rust carrier
5095                // selector only after that proof commits, so a transient base
5096                // reconnect can restore the configured custom route without a
5097                // browser retry loop.
5098                self.revisit_preferred_iroh_carrier_after_settlement(&connection_id)
5099                    .await;
5100            }
5101            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
5102        }
5103
5104        pub async fn report_transport_status(
5105            &self,
5106            connection_id: String,
5107            active_transport: String,
5108            parallel_transport: Option<String>,
5109            transport_stable_id: Option<u64>,
5110            transport_generation: Option<u64>,
5111            route_generation: Option<u64>,
5112        ) -> Result<JsValue, JsValue> {
5113            let snapshot = match (transport_stable_id, transport_generation, route_generation) {
5114                (Some(transport_stable_id), Some(transport_generation), Some(route_generation)) => {
5115                    self.inner
5116                        .report_transport_status_for_generation(
5117                            &connection_id,
5118                            &active_transport,
5119                            parallel_transport.as_deref(),
5120                            transport_stable_id,
5121                            transport_generation,
5122                            route_generation,
5123                        )
5124                        .await
5125                }
5126                _ => None,
5127            };
5128            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
5129        }
5130
5131        pub async fn is_current_transport_stable_id(
5132            &self,
5133            endpoint_id: String,
5134            transport_stable_id: u64,
5135        ) -> Result<bool, JsValue> {
5136            let endpoint_id = endpoint_id
5137                .parse::<iroh::EndpointId>()
5138                .map_err(|error| JsValue::from_str(&error.to_string()))?;
5139            Ok(self
5140                .inner
5141                .is_current_transport_stable_id(endpoint_id, transport_stable_id)
5142                .await)
5143        }
5144
5145        pub async fn open_bi(&self, endpoint_id: String) -> Result<BiStream, JsValue> {
5146            let endpoint_id: iroh::EndpointId = endpoint_id
5147                .parse()
5148                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
5149            self.inner
5150                .assert_raw_peer_stream_allowed(&endpoint_id)
5151                .await
5152                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5153            // Parity with the native `Client::open_bi`: register/finalize the
5154            // connection_manager record for the (already-alive) transport before
5155            // handing out a raw peer stream, so browser raw-stream opens
5156            // (explicit transfer, native-main signaling) participate in
5157            // connection lifecycle / close tracking. Idempotent, and a no-op when
5158            // the transport is not alive. `Client::open_bi` itself is native-only
5159            // (it returns native iroh stream types), so the wasm binding cannot
5160            // delegate to it and must mirror its guard + record + open sequence.
5161            self.inner
5162                .ensure_connection_manager_record_before_peer_stream(&endpoint_id)
5163                .await
5164                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5165            let (send, recv) = self
5166                .inner
5167                .open_bi_internal(endpoint_id)
5168                .await
5169                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5170            Ok(BiStream::from_parts(send, recv, endpoint_id.to_string()))
5171        }
5172
5173        /// Open a raw bi-stream for the SDK-owned native-main **control plane**
5174        /// (WebRTC signaling: SDP / ICE candidates / renegotiate, and the
5175        /// bootstrap application-route handshake).
5176        ///
5177        /// Unlike [`open_bi`], this is deliberately **exempt** from the
5178        /// application-crypto raw-open guard (`assert_raw_peer_stream_allowed`).
5179        /// That guard protects application *data* — but the control plane is not
5180        /// application data:
5181        ///   1. Signaling bootstraps the very application route (and key
5182        ///      agreement) it would otherwise depend on, so it cannot require app
5183        ///      crypto that has not been negotiated yet.
5184        ///   2. It is already authenticated by the iroh QUIC TLS that binds the
5185        ///      sender's node id.
5186        ///   3. The receiver classifies the stream by its `[0x00][len]["main"]`
5187        ///      native-main label and routes it to the signal handler; bytes sent
5188        ///      here can never be delivered as application data, so this cannot be
5189        ///      abused to smuggle unencrypted app payloads past the guard.
5190        ///
5191        /// This binding is intentionally named for native-main. It is not a
5192        /// general-purpose raw-stream escape hatch: JS callers must immediately
5193        /// write the `[0x00][len]["main"]` native-main label and then framed
5194        /// control payloads. Application data must continue to use `open_peer_bi`
5195        /// / `open_peer_uni` so the application-crypto guard stays fail-closed.
5196        ///
5197        /// Without this, a peer whose inbound native-main control writer was lost
5198        /// (e.g. after a rapid disconnect/reconnect flap) and that requires app
5199        /// crypto could not (re)open a signaling stream at all, stranding the
5200        /// edge on base Iroh because SDP offers/answers can never be exchanged.
5201        pub async fn open_native_main_control_bi(
5202            &self,
5203            endpoint_id: String,
5204        ) -> Result<BiStream, JsValue> {
5205            let endpoint_id: iroh::EndpointId = endpoint_id
5206                .parse()
5207                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
5208            self.inner
5209                .ensure_connection_manager_record_before_peer_stream(&endpoint_id)
5210                .await
5211                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5212            let (transport_stable_id, send, recv) = self
5213                .inner
5214                .open_bi_internal_with_transport_stable_id(endpoint_id)
5215                .await
5216                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5217            Ok(BiStream::from_parts_with_transport_stable_id(
5218                send,
5219                recv,
5220                endpoint_id.to_string(),
5221                transport_stable_id,
5222            ))
5223        }
5224
5225        /// Send one complete SDK-owned native-main control frame on a fresh
5226        /// signal stream. Rust owns the QUIC FIN and waits for the peer to
5227        /// acknowledge it so a successful JS promise means the frame reached
5228        /// the remote stream router, not merely the browser WritableStream.
5229        pub async fn send_native_main_control_frame(
5230            &self,
5231            endpoint_id: String,
5232            frame: Vec<u8>,
5233        ) -> Result<(), JsValue> {
5234            send_native_signal_control_frame(&self.inner, endpoint_id, frame).await
5235        }
5236
5237        pub async fn open_peer_bi(
5238            &self,
5239            id: String,
5240            timeout_ms: Option<u32>,
5241        ) -> Result<BiStream, JsValue> {
5242            let (_connection_id, remote_node_id, send, recv) = self
5243                .inner
5244                .open_peer_bi(&id, timeout_ms.map(|value| value as u64))
5245                .await
5246                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5247            Ok(BiStream::from_peer_parts(send, recv, remote_node_id))
5248        }
5249
5250        /// Send a complete protected application frame on a fresh peer stream.
5251        /// Rust owns the QUIC FIN so JS readable cancellation cannot reset the
5252        /// one-shot stream before the remote runtime admits it.
5253        pub async fn send_peer_application_frame(
5254            &self,
5255            id: String,
5256            frame: Vec<u8>,
5257            timeout_ms: Option<u32>,
5258        ) -> Result<(), JsValue> {
5259            self.inner
5260                .send_peer_application_frame(&id, &frame, timeout_ms.map(|value| value as u64))
5261                .await
5262                .map_err(|error| JsValue::from_str(&error.to_string()))
5263        }
5264
5265        /// Open a settled peer stream for explicit file transfer.
5266        ///
5267        /// The runtime writes the plaintext explicit-file protocol byte (`0x02`)
5268        /// before returning the send stream, then wraps only the transfer body
5269        /// with application crypto when a key is active for the peer.
5270        pub async fn open_peer_bi_explicit_file_sender(
5271            &self,
5272            id: String,
5273            timeout_ms: Option<u32>,
5274        ) -> Result<PeerUniStream, JsValue> {
5275            let (_connection_id, _remote_node_id, send) = self
5276                .inner
5277                .open_peer_bi_explicit_file_sender(&id, timeout_ms.map(|value| value as u64))
5278                .await
5279                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5280            Ok(peer_uni_stream_from_send(send))
5281        }
5282
5283        /// Open a bi-stream to a peer that is transport-connected but may not yet
5284        /// be settled (auth-ready). Use for latency probes and other transport-level
5285        /// diagnostics where `settled_ready` is not required.
5286        pub async fn open_peer_bi_transport_only(
5287            &self,
5288            id: String,
5289            timeout_ms: Option<u32>,
5290        ) -> Result<BiStream, JsValue> {
5291            let (_connection_id, remote_node_id, send, recv) = self
5292                .inner
5293                .open_peer_bi_transport_only(&id, timeout_ms.map(|value| value as u64))
5294                .await
5295                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5296            Ok(BiStream::from_parts(send, recv, remote_node_id))
5297        }
5298
5299        pub async fn open_uni(&self, endpoint_id: String) -> Result<PeerUniStream, JsValue> {
5300            let endpoint_id: iroh::EndpointId = endpoint_id
5301                .parse()
5302                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
5303            self.inner
5304                .assert_raw_peer_stream_allowed(&endpoint_id)
5305                .await
5306                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5307            // Parity with the native `Client::open_uni`, which also finalizes the
5308            // connection_manager record before opening a raw uni peer stream.
5309            self.inner
5310                .ensure_connection_manager_record_before_peer_stream(&endpoint_id)
5311                .await
5312                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5313            let node_guard = self.inner.iroh_node.read().await;
5314            if let Some(node) = node_guard.as_ref() {
5315                let send = node
5316                    .open_uni(endpoint_id.clone())
5317                    .await
5318                    .map_err(|e| JsValue::from_str(&e.to_string()))?;
5319                Ok(peer_uni_stream_from_send(
5320                    crate::application_crypto_streams::PeerSendStream::plain(send),
5321                ))
5322            } else {
5323                Err(JsValue::from_str("Iroh node not initialized"))
5324            }
5325        }
5326
5327        pub async fn open_peer_uni(
5328            &self,
5329            id: String,
5330            timeout_ms: Option<u32>,
5331        ) -> Result<PeerUniStream, JsValue> {
5332            let (_connection_id, _remote_node_id, send) = self
5333                .inner
5334                .open_peer_uni(&id, timeout_ms.map(|value| value as u64))
5335                .await
5336                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5337            Ok(peer_uni_stream_from_send(send))
5338        }
5339
5340        #[wasm_bindgen(js_name = sendPeerDatagram)]
5341        pub async fn send_peer_datagram(
5342            &self,
5343            id: String,
5344            payload: Vec<u8>,
5345            max_age_ms: Option<u32>,
5346        ) -> Result<bool, JsValue> {
5347            match max_age_ms {
5348                Some(max_age_ms) => {
5349                    self.inner
5350                        .send_peer_datagram_with_max_age(&id, &payload, u64::from(max_age_ms))
5351                        .await
5352                }
5353                None => self
5354                    .inner
5355                    .send_peer_datagram(&id, &payload)
5356                    .await
5357                    .map(|()| true),
5358            }
5359            .map_err(|error| JsValue::from_str(&error.to_string()))
5360        }
5361
5362        #[wasm_bindgen(js_name = receivePeerDatagram)]
5363        pub async fn receive_peer_datagram(&self, id: String) -> Result<Vec<u8>, JsValue> {
5364            self.inner
5365                .receive_peer_datagram(&id)
5366                .await
5367                .map_err(|error| JsValue::from_str(&error.to_string()))
5368        }
5369
5370        #[wasm_bindgen(js_name = peerDatagramMaxSize)]
5371        pub fn peer_datagram_max_size(&self) -> usize {
5372            Client::MAX_PEER_DATAGRAM_BYTES
5373        }
5374
5375        #[wasm_bindgen(js_name = createPeerDatagramPolicy)]
5376        pub fn create_peer_datagram_policy(&self) -> WasmPeerDatagramPolicy {
5377            WasmPeerDatagramPolicy {
5378                inner: RefCell::new(crate::datagrams::PeerDatagramPolicy::new(
5379                    Client::MAX_PEER_DATAGRAM_BYTES,
5380                )),
5381            }
5382        }
5383
5384        /// Send `data` to `peer_id` using the best available transport.
5385        ///
5386        /// Native route order is edge-aware: proven upgraded routes stay first,
5387        /// relay/unknown iroh can probe upgraded transports first, and direct
5388        /// iroh/LAN/BLE paths stay primary with upgraded transports as fallback.
5389        ///
5390        /// On WASM this always routes through iroh (relay or QUIC as iroh determines).
5391        /// Use the TypeScript `Connection.sendTyped()` for full transport priority on the
5392        /// browser side — this binding is primarily for symmetry and native callers.
5393        pub async fn send_peer(&self, id: String, data: Vec<u8>) -> Result<(), JsValue> {
5394            self.inner
5395                .send_peer(&id, &data)
5396                .await
5397                .map_err(|e| JsValue::from_str(&e.to_string()))
5398        }
5399
5400        /// Returns the current iroh path kind for a connected peer.
5401        ///
5402        /// Returns one of `"direct-quic"`, `"relay"`, or `"unknown"`.
5403        pub async fn iroh_path_kind(&self, peer_id: String) -> String {
5404            match self.inner.iroh_path_kind(&peer_id).await {
5405                crate::client::IrohPathKind::DirectQuic => "direct-quic".to_string(),
5406                crate::client::IrohPathKind::DirectLan => "direct-lan".to_string(),
5407                crate::client::IrohPathKind::Relay => "relay".to_string(),
5408                crate::client::IrohPathKind::Ble => "ble".to_string(),
5409                crate::client::IrohPathKind::WebRtc => "webrtc".to_string(),
5410                crate::client::IrohPathKind::Moq => "moq".to_string(),
5411                crate::client::IrohPathKind::Unknown => "unknown".to_string(),
5412            }
5413        }
5414
5415        /// Returns the current iroh transport RTT in milliseconds, when iroh has
5416        /// selected a live path and published path stats.
5417        pub async fn iroh_transport_rtt_ms(&self, peer_id: String) -> Option<u32> {
5418            self.inner
5419                .iroh_transport_rtt_ms(&peer_id)
5420                .await
5421                .map(|value| value.min(u32::MAX as u64) as u32)
5422        }
5423
5424        pub async fn incoming_streams(&self) -> Result<JsReadableStream, JsValue> {
5425            let (stream_owner, stream) = {
5426                let node_guard = self.inner.iroh_node.read().await;
5427                if let Some(node) = node_guard.as_ref() {
5428                    (self.inner.clone(), node.incoming_streams_stream())
5429                } else {
5430                    return Err(JsValue::from_str("Iroh node not initialized"));
5431                }
5432            };
5433
5434            use futures::StreamExt;
5435            let mapped_stream = stream.filter_map(move |incoming| {
5436                let stream_owner = stream_owner.clone();
5437                async move {
5438                    let owned = stream_owner
5439                        .incoming_stream_generation_is_owned(
5440                            incoming.endpoint_id,
5441                            incoming.transport_stable_id,
5442                        )
5443                        .await;
5444                    let admission_candidate = !owned
5445                        && matches!(
5446                            &incoming.stream,
5447                            crate::wasm_node::IncomingStreamType::Bi(_, _)
5448                        )
5449                        && stream_owner
5450                            .incoming_stream_generation_is_authenticated_reconnect_candidate(
5451                                incoming.endpoint_id,
5452                                incoming.transport_stable_id,
5453                            )
5454                            .await;
5455                    (owned || admission_candidate).then(|| {
5456                        crate::wasm_node::BiStream::incoming_to_js_value(
5457                            incoming,
5458                            admission_candidate,
5459                        )
5460                    })
5461                }
5462            });
5463
5464            Ok(wasm_streams::ReadableStream::from_stream(mapped_stream).into_raw())
5465        }
5466
5467        /// Classify a bounded plaintext stream prefix with the same Rust parser
5468        /// used by native OpenRTC. Browser code owns only incremental Web Stream
5469        /// reads and must not reproduce channel/protocol wire decisions.
5470        #[wasm_bindgen(js_name = inspectIncomingStreamPrefix)]
5471        pub fn inspect_incoming_stream_prefix(
5472            &self,
5473            bytes: Vec<u8>,
5474            reached_eof: bool,
5475        ) -> Result<JsValue, JsValue> {
5476            serde::Serialize::serialize(
5477                &crate::stream_metadata::inspect_prefix_for_binding(&bytes, reached_eof),
5478                &serde_wasm_bindgen::Serializer::json_compatible(),
5479            )
5480            .map_err(|error| JsValue::from_str(&error.to_string()))
5481        }
5482
5483        /// Decode only the optional channel envelope at the start of a stream.
5484        /// The returned consumed length lets the browser adapter rebuild the
5485        /// readable without parsing or retaining protocol state in TypeScript.
5486        #[wasm_bindgen(js_name = decodeIncomingChannelEnvelope)]
5487        pub fn decode_incoming_channel_envelope(&self, bytes: Vec<u8>) -> Result<JsValue, JsValue> {
5488            serde::Serialize::serialize(
5489                &crate::stream_metadata::decode_prefix_for_binding(&bytes),
5490                &serde_wasm_bindgen::Serializer::json_compatible(),
5491            )
5492            .map_err(|error| JsValue::from_str(&error.to_string()))
5493        }
5494
5495        pub async fn update_presence(
5496            &self,
5497            user_id: String,
5498            device_name: String,
5499            ticket: String,
5500            metadata: Option<String>,
5501            ttl_ms: Option<u64>,
5502        ) -> Result<(), JsValue> {
5503            self.inner
5504                .update_presence_with_ttl(
5505                    &user_id,
5506                    &device_name,
5507                    &ticket,
5508                    ttl_ms.unwrap_or(300_000),
5509                    metadata.as_deref(),
5510                )
5511                .await
5512                .map_err(|e| JsValue::from_str(&e.to_string()))
5513        }
5514
5515        pub async fn send_message(
5516            &self,
5517            target_id: String,
5518            payload: String,
5519            state: Option<String>,
5520            reply_payload: Option<String>,
5521        ) -> Result<String, JsValue> {
5522            self.inner
5523                .send_message(
5524                    &target_id,
5525                    &payload,
5526                    state.as_deref(),
5527                    reply_payload.as_deref(),
5528                )
5529                .await
5530                .map_err(|e| JsValue::from_str(&e.to_string()))
5531        }
5532
5533        pub async fn set_offline(&self, user_id: String) -> Result<(), JsValue> {
5534            self.inner
5535                .set_offline(&user_id)
5536                .await
5537                .map_err(|e| JsValue::from_str(&e.to_string()))
5538        }
5539
5540        pub async fn update_device(
5541            &self,
5542            user_id: String,
5543            device_id: String,
5544            device_name: Option<String>,
5545            capabilities: Option<JsValue>,
5546            metadata: Option<String>,
5547        ) -> Result<(), JsValue> {
5548            let parsed_capabilities = match capabilities {
5549                Some(value) if !value.is_null() && !value.is_undefined() => Some(
5550                    serde_wasm_bindgen::from_value::<crate::signaling::DeviceCapabilities>(value)
5551                        .map_err(|e| JsValue::from_str(&e.to_string()))?,
5552                ),
5553                _ => None,
5554            };
5555
5556            self.inner
5557                .update_device(
5558                    &user_id,
5559                    &device_id,
5560                    device_name.as_deref(),
5561                    parsed_capabilities,
5562                    metadata.as_deref(),
5563                )
5564                .await
5565                .map_err(|e| JsValue::from_str(&e.to_string()))
5566        }
5567
5568        pub async fn delete_device(
5569            &self,
5570            user_id: String,
5571            device_id: String,
5572        ) -> Result<(), JsValue> {
5573            self.inner
5574                .delete_device(&user_id, &device_id)
5575                .await
5576                .map_err(|e| JsValue::from_str(&e.to_string()))
5577        }
5578
5579        pub fn force_reconnect_snapshot(&self) {
5580            self.inner.clone().force_reconnect_snapshot();
5581        }
5582
5583        pub fn stop_presence_loop(&self) {
5584            self.inner.stop_presence_loop();
5585        }
5586
5587        pub fn stop_auto_connect(&self) {
5588            self.inner.stop_auto_connect();
5589            self.inner.stop_browser_auto_connect();
5590        }
5591
5592        pub fn start_auto_connect(
5593            &self,
5594            user_id: String,
5595            local_device_id: String,
5596        ) -> Result<(), JsValue> {
5597            self.inner
5598                .start_browser_auto_connect(user_id, local_device_id)
5599                .map_err(|error| JsValue::from_str(&error.to_string()))
5600        }
5601
5602        /// @deprecated Use `submit_browser_desired_peers_with_sparse_fanout` for sparse rooms.
5603        #[deprecated(note = "use submit_browser_desired_peers_with_sparse_fanout for sparse rooms")]
5604        pub fn submit_browser_desired_peers(
5605            &self,
5606            revision: u32,
5607            peers_json: String,
5608        ) -> Result<bool, JsValue> {
5609            self.inner
5610                .submit_browser_desired_peers(u64::from(revision), &peers_json)
5611                .map_err(|error| JsValue::from_str(&error.to_string()))
5612        }
5613
5614        pub async fn submit_browser_desired_peers_with_sparse_fanout(
5615            &self,
5616            revision: u32,
5617            peers_json: String,
5618            capability: Option<String>,
5619            local_device_id: Option<String>,
5620            local_device_key_x: Option<String>,
5621            sparse: Option<bool>,
5622        ) -> Result<bool, JsValue> {
5623            let peers_json = if sparse.unwrap_or(false) {
5624                let capability = capability
5625                    .as_deref()
5626                    .ok_or_else(|| JsValue::from_str("sparse fanout requires a capability"))?;
5627                let local_device_id = local_device_id
5628                    .as_deref()
5629                    .ok_or_else(|| JsValue::from_str("sparse fanout requires a local device id"))?;
5630                let local_device_key_x = local_device_key_x.as_deref().ok_or_else(|| {
5631                    JsValue::from_str("sparse fanout requires a local device key")
5632                })?;
5633                self.inner
5634                    .configure_sparse_fanout(
5635                        capability,
5636                        u64::from(revision),
5637                        local_device_id,
5638                        local_device_key_x,
5639                        &peers_json,
5640                        true,
5641                    )
5642                    .await
5643                    .map_err(|error| JsValue::from_str(&error.to_string()))?
5644                    .0
5645            } else {
5646                peers_json
5647            };
5648            self.inner
5649                .submit_browser_desired_peers(u64::from(revision), &peers_json)
5650                .map_err(|error| JsValue::from_str(&error.to_string()))
5651        }
5652
5653        #[wasm_bindgen(js_name = prepareSparseFanoutMessage)]
5654        pub async fn prepare_sparse_fanout_message(
5655            &self,
5656            capability: String,
5657            payload: Vec<u8>,
5658        ) -> Result<JsValue, JsValue> {
5659            let request = self
5660                .inner
5661                .prepare_sparse_fanout_message(&capability, &payload)
5662                .await
5663                .map_err(|error| JsValue::from_str(&error.to_string()))?;
5664            serde_wasm_bindgen::to_value(&request)
5665                .map_err(|error| JsValue::from_str(&error.to_string()))
5666        }
5667
5668        #[wasm_bindgen(js_name = finalizeSparseFanoutMessage)]
5669        pub async fn finalize_sparse_fanout_message(
5670            &self,
5671            request_id: String,
5672            signature: String,
5673        ) -> Result<Vec<u8>, JsValue> {
5674            self.inner
5675                .finalize_sparse_fanout_message(&request_id, &signature)
5676                .await
5677                .map_err(|error| JsValue::from_str(&error.to_string()))
5678        }
5679
5680        #[wasm_bindgen(js_name = acceptSparseFanoutMessage)]
5681        pub async fn accept_sparse_fanout_message(
5682            &self,
5683            capability: String,
5684            source_peer_id: String,
5685            encoded: Vec<u8>,
5686        ) -> Result<JsValue, JsValue> {
5687            let decision = self
5688                .inner
5689                .accept_sparse_fanout_message(&capability, &source_peer_id, &encoded)
5690                .await
5691                .map_err(|error| JsValue::from_str(&error.to_string()))?;
5692            serde_wasm_bindgen::to_value(&decision)
5693                .map_err(|error| JsValue::from_str(&error.to_string()))
5694        }
5695
5696        #[wasm_bindgen(js_name = sparseFanoutDiagnostics)]
5697        pub async fn sparse_fanout_diagnostics(
5698            &self,
5699            capability: String,
5700        ) -> Result<JsValue, JsValue> {
5701            serde_wasm_bindgen::to_value(&self.inner.sparse_fanout_diagnostics(&capability).await)
5702                .map_err(|error| JsValue::from_str(&error.to_string()))
5703        }
5704
5705        #[wasm_bindgen(js_name = recordSparseFanoutForwardQueueDrop)]
5706        pub async fn record_sparse_fanout_forward_queue_drop(
5707            &self,
5708            capability: String,
5709            count: u32,
5710        ) -> Result<(), JsValue> {
5711            self.inner
5712                .record_sparse_fanout_forward_queue_drop(&capability, u64::from(count))
5713                .await
5714                .map_err(|error| JsValue::from_str(&error.to_string()))
5715        }
5716
5717        pub fn wake_browser_auto_connect(&self) -> bool {
5718            self.inner.wake_browser_auto_connect()
5719        }
5720
5721        pub async fn set_auto_connect_excluded(&self, device_id: String, excluded: bool) {
5722            if excluded {
5723                self.inner.exclude_peer_and_publish(&device_id).await;
5724            } else {
5725                self.inner.unexclude_peer_and_publish(&device_id).await;
5726            }
5727            self.inner.wake_browser_auto_connect();
5728        }
5729
5730        pub fn is_auto_connect_excluded(&self, device_id: String) -> bool {
5731            self.inner.is_auto_connect_excluded(&device_id)
5732        }
5733
5734        pub async fn disconnect_device(
5735            &self,
5736            device_id: String,
5737            node_id_hint: Option<String>,
5738        ) -> Result<JsValue, JsValue> {
5739            let retired = self
5740                .inner
5741                .disconnect_device(&device_id, node_id_hint.as_deref())
5742                .await;
5743            serde_wasm_bindgen::to_value(&retired).map_err(|e| JsValue::from_str(&e.to_string()))
5744        }
5745
5746        pub fn stop_auth_scoped_activity(&self) {
5747            self.inner.stop_auth_scoped_activity();
5748            self.inner.stop_browser_auto_connect();
5749        }
5750
5751        pub fn start_presence_loop(
5752            &self,
5753            user_id: String,
5754            device_name: String,
5755            ticket: String,
5756            metadata: Option<String>,
5757        ) {
5758            self.inner
5759                .clone()
5760                .start_signaling_loop(user_id, device_name, ticket, metadata);
5761        }
5762
5763        pub async fn search_devices(&self, user_id: String) -> Result<JsValue, JsValue> {
5764            let devices = self
5765                .inner
5766                .search_devices(&user_id)
5767                .await
5768                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5769            serde_wasm_bindgen::to_value(&devices).map_err(|e| JsValue::from_str(&e.to_string()))
5770        }
5771
5772        pub async fn devices_with_status(&self, user_id: String) -> Result<JsValue, JsValue> {
5773            let devices = self
5774                .inner
5775                .devices_with_status(&user_id)
5776                .await
5777                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5778            serde_wasm_bindgen::to_value(&devices).map_err(|e| JsValue::from_str(&e.to_string()))
5779        }
5780
5781        pub async fn connect_device(
5782            &self,
5783            device_id: Option<String>,
5784            endpoint_ticket: String,
5785        ) -> Result<JsValue, JsValue> {
5786            let result = self
5787                .inner
5788                .connect_device(device_id.as_deref(), &endpoint_ticket)
5789                .await
5790                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5791            serde_wasm_bindgen::to_value(&result).map_err(|e| JsValue::from_str(&e.to_string()))
5792        }
5793
5794        pub async fn create_session(&self, session_json: String) -> Result<(), JsValue> {
5795            let session: crate::signaling::SignalingSession =
5796                serde_json::from_str(&session_json)
5797                    .map_err(|e| JsValue::from_str(&e.to_string()))?;
5798            self.inner
5799                .create_session(session)
5800                .await
5801                .map_err(|e| JsValue::from_str(&e.to_string()))
5802        }
5803
5804        pub async fn update_session(
5805            &self,
5806            session_id: String,
5807            update_json: String,
5808        ) -> Result<(), JsValue> {
5809            let update_data: serde_json::Value = serde_json::from_str(&update_json)
5810                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5811            self.inner
5812                .update_session(&session_id, update_data)
5813                .await
5814                .map_err(|e| JsValue::from_str(&e.to_string()))
5815        }
5816
5817        // --- Room Management API ---
5818
5819        pub async fn create_room(
5820            &self,
5821            room_id: String,
5822            user_id: String,
5823            ticket_str: String,
5824            my_node_id: String,
5825            tag: String,
5826            max_members: Option<u32>,
5827        ) -> Result<bool, JsValue> {
5828            self.inner
5829                .room
5830                .create_room(
5831                    &room_id,
5832                    &user_id,
5833                    &ticket_str,
5834                    &my_node_id,
5835                    &tag,
5836                    max_members,
5837                )
5838                .await
5839                .map_err(|e| JsValue::from_str(&e.to_string()))
5840        }
5841
5842        pub async fn join_room(
5843            &self,
5844            room_id: String,
5845            user_id: String,
5846            ticket_str: String,
5847            my_node_id: String,
5848            tag: String,
5849        ) -> Result<(), JsValue> {
5850            self.inner
5851                .room
5852                .join_room(&room_id, &user_id, &ticket_str, &my_node_id, &tag)
5853                .await
5854                .map_err(|e| JsValue::from_str(&e.to_string()))
5855        }
5856
5857        pub async fn get_members(
5858            &self,
5859            room_id: String,
5860            my_node_id: String,
5861            tag: String,
5862        ) -> Result<String, JsValue> {
5863            let members = self
5864                .inner
5865                .room
5866                .get_members(&room_id, &my_node_id, &tag)
5867                .await
5868                .map_err(|e| JsValue::from_str(&e.to_string()))?;
5869            serde_json::to_string(&members).map_err(|e| JsValue::from_str(&e.to_string()))
5870        }
5871
5872        pub async fn leave_room(
5873            &self,
5874            room_id: String,
5875            my_node_id: String,
5876            tag: String,
5877        ) -> Result<(), JsValue> {
5878            self.inner
5879                .room
5880                .leave_room(&room_id, &my_node_id, &tag)
5881                .await
5882                .map_err(|e| JsValue::from_str(&e.to_string()))
5883        }
5884    }
5885
5886    #[cfg(feature = "iroh-protocols-wasm")]
5887    #[wasm_bindgen]
5888    impl WasmClient {
5889        /// Hydrate the upstream iroh docs, blobs, and gossip protocols from a
5890        /// host persistence adapter. This reuses the already-bound OpenRTC
5891        /// endpoint and its single router.
5892        #[wasm_bindgen(js_name = __initPersistentIrohProtocols)]
5893        pub async fn init_persistent_iroh_protocols(
5894            &self,
5895            replica_store: JsValue,
5896        ) -> Result<(), JsValue> {
5897            let mut guard = self.persistent_protocols.lock().await;
5898            if guard.is_some() {
5899                return Ok(());
5900            }
5901            let node = self
5902                .inner
5903                .iroh_node
5904                .read()
5905                .await
5906                .as_ref()
5907                .cloned()
5908                .ok_or_else(|| {
5909                    JsValue::from_str("OpenRTC endpoint must be initialized before protocols")
5910                })?;
5911            let store = crate::wasm_docs_persistence::JsReplicaStore::new(replica_store)
5912                .map_err(|error| JsValue::from_str(&error.to_string()))?;
5913            let actor = crate::wasm_docs_persistence::WasmPersistentDocsActor::hydrate(
5914                store,
5915                node.endpoint().clone(),
5916            )
5917            .await
5918            .map_err(|error| JsValue::from_str(&format!("{error:#}")))?;
5919            node.install_standard_protocols(
5920                actor.docs_protocol(),
5921                actor.blobs_protocol(),
5922                actor.gossip_protocol(),
5923            )
5924            .await
5925            .map_err(|error| JsValue::from_str(&format!("{error:#}")))?;
5926            *guard = Some(actor);
5927            Ok(())
5928        }
5929
5930        /// Rejoin active upstream protocols after the browser reports that its
5931        /// existing OpenRTC endpoint may be reachable again. The actor reuses
5932        /// iroh-docs' stored useful peers and remains the only sync owner.
5933        #[wasm_bindgen(js_name = __reconcilePersistentIrohProtocols)]
5934        pub async fn reconcile_persistent_iroh_protocols(&self) -> Result<u32, JsValue> {
5935            let guard = self.persistent_protocols.lock().await;
5936            let Some(actor) = guard.as_ref() else {
5937                return Ok(0);
5938            };
5939            actor.reconcile_sync().await.map_err(js_protocol_error)
5940        }
5941
5942        #[wasm_bindgen(js_name = __importPersistentIrohAuthor)]
5943        pub async fn import_persistent_iroh_author(
5944            &self,
5945            author_secret: Vec<u8>,
5946        ) -> Result<String, JsValue> {
5947            let guard = self.persistent_protocols.lock().await;
5948            let actor = persistent_protocols(&guard)?;
5949            actor
5950                .import_author(author_secret)
5951                .await
5952                .map_err(js_protocol_error)
5953        }
5954
5955        #[wasm_bindgen(js_name = __importPersistentIrohNamespace)]
5956        pub async fn import_persistent_iroh_namespace(
5957            &self,
5958            capability_kind: String,
5959            capability: Vec<u8>,
5960            generation: u64,
5961            share_revision: u64,
5962        ) -> Result<String, JsValue> {
5963            let mut guard = self.persistent_protocols.lock().await;
5964            let actor = persistent_protocols_mut(&mut guard)?;
5965            actor
5966                .import_namespace(
5967                    capability_kind.as_str(),
5968                    capability,
5969                    generation,
5970                    share_revision,
5971                )
5972                .await
5973                .map_err(js_protocol_error)
5974        }
5975
5976        #[wasm_bindgen(js_name = __createPersistentIrohNamespace)]
5977        pub async fn create_persistent_iroh_namespace(
5978            &self,
5979            generation: u64,
5980            share_revision: u64,
5981        ) -> Result<JsValue, JsValue> {
5982            let mut guard = self.persistent_protocols.lock().await;
5983            let descriptor = persistent_protocols_mut(&mut guard)?
5984                .create_namespace(generation, share_revision)
5985                .await
5986                .map_err(js_protocol_error)?;
5987            serde_wasm_bindgen::to_value(&descriptor)
5988                .map_err(|error| JsValue::from_str(&error.to_string()))
5989        }
5990
5991        #[wasm_bindgen(js_name = __importPersistentIrohTicket)]
5992        pub async fn import_persistent_iroh_ticket(
5993            &self,
5994            ticket: String,
5995            generation: u64,
5996            share_revision: u64,
5997        ) -> Result<String, JsValue> {
5998            let mut guard = self.persistent_protocols.lock().await;
5999            persistent_protocols_mut(&mut guard)?
6000                .import_ticket(&ticket, generation, share_revision)
6001                .await
6002                .map_err(js_protocol_error)
6003        }
6004
6005        #[wasm_bindgen(js_name = __sharePersistentIrohNamespace)]
6006        pub async fn share_persistent_iroh_namespace(
6007            &self,
6008            namespace_id: String,
6009            writable: bool,
6010        ) -> Result<String, JsValue> {
6011            let guard = self.persistent_protocols.lock().await;
6012            persistent_protocols(&guard)?
6013                .share(&namespace_id, writable)
6014                .await
6015                .map_err(js_protocol_error)
6016        }
6017
6018        #[wasm_bindgen(js_name = __removePersistentIrohNamespace)]
6019        pub async fn remove_persistent_iroh_namespace(
6020            &self,
6021            namespace_id: String,
6022            generation: u64,
6023            share_revision: u64,
6024        ) -> Result<(), JsValue> {
6025            let mut guard = self.persistent_protocols.lock().await;
6026            persistent_protocols_mut(&mut guard)?
6027                .remove_namespace(&namespace_id, generation, share_revision)
6028                .await
6029                .map_err(js_protocol_error)
6030        }
6031
6032        #[wasm_bindgen(js_name = __putPersistentIrohBytes)]
6033        pub async fn put_persistent_iroh_bytes(
6034            &self,
6035            namespace_id: String,
6036            key: Vec<u8>,
6037            value: Vec<u8>,
6038        ) -> Result<JsValue, JsValue> {
6039            let guard = self.persistent_protocols.lock().await;
6040            let receipt = persistent_protocols(&guard)?
6041                .set_bytes(&namespace_id, key, value)
6042                .await
6043                .map_err(js_protocol_error)?;
6044            serde_wasm_bindgen::to_value(&receipt)
6045                .map_err(|error| JsValue::from_str(&error.to_string()))
6046        }
6047
6048        #[wasm_bindgen(js_name = __setPersistentIrohHash)]
6049        pub async fn set_persistent_iroh_hash(
6050            &self,
6051            namespace_id: String,
6052            key: Vec<u8>,
6053            content_hash: String,
6054            content_length: u64,
6055        ) -> Result<String, JsValue> {
6056            let guard = self.persistent_protocols.lock().await;
6057            persistent_protocols(&guard)?
6058                .set_hash(&namespace_id, key, &content_hash, content_length)
6059                .await
6060                .map_err(js_protocol_error)
6061        }
6062
6063        #[wasm_bindgen(js_name = __deletePersistentIrohPrefix)]
6064        pub async fn delete_persistent_iroh_prefix(
6065            &self,
6066            namespace_id: String,
6067            prefix: Vec<u8>,
6068        ) -> Result<JsValue, JsValue> {
6069            let guard = self.persistent_protocols.lock().await;
6070            let receipt = persistent_protocols(&guard)?
6071                .delete_prefix(&namespace_id, prefix)
6072                .await
6073                .map_err(js_protocol_error)?;
6074            serde_wasm_bindgen::to_value(&receipt)
6075                .map_err(|error| JsValue::from_str(&error.to_string()))
6076        }
6077
6078        #[wasm_bindgen(js_name = __queryPersistentIrohNamespace)]
6079        pub async fn query_persistent_iroh_namespace(
6080            &self,
6081            namespace_id: String,
6082            key_prefix: Vec<u8>,
6083        ) -> Result<JsValue, JsValue> {
6084            let guard = self.persistent_protocols.lock().await;
6085            let entries = persistent_protocols(&guard)?
6086                .query(&namespace_id, key_prefix)
6087                .await
6088                .map_err(js_protocol_error)?;
6089            serde_wasm_bindgen::to_value(&entries)
6090                .map_err(|error| JsValue::from_str(&error.to_string()))
6091        }
6092
6093        #[wasm_bindgen(js_name = __hydratePersistentIrohBlob)]
6094        pub async fn hydrate_persistent_iroh_blob(
6095            &self,
6096            content_hash: String,
6097        ) -> Result<(), JsValue> {
6098            let guard = self.persistent_protocols.lock().await;
6099            persistent_protocols(&guard)?
6100                .hydrate_blob(&content_hash)
6101                .await
6102                .map_err(js_protocol_error)
6103        }
6104
6105        #[wasm_bindgen(js_name = __acknowledgePersistentIrohOutbox)]
6106        pub async fn acknowledge_persistent_iroh_outbox(
6107            &self,
6108            operation_id: String,
6109        ) -> Result<(), JsValue> {
6110            let guard = self.persistent_protocols.lock().await;
6111            persistent_protocols(&guard)?
6112                .acknowledge_outbox(&operation_id)
6113                .await
6114                .map_err(js_protocol_error)
6115        }
6116
6117        #[wasm_bindgen(js_name = __flushPersistentIrohProtocols)]
6118        pub async fn flush_persistent_iroh_protocols(&self) -> Result<(), JsValue> {
6119            let guard = self.persistent_protocols.lock().await;
6120            persistent_protocols(&guard)?
6121                .flush()
6122                .await
6123                .map_err(js_protocol_error)
6124        }
6125
6126        #[wasm_bindgen(js_name = __shutdownPersistentIrohProtocols)]
6127        pub async fn shutdown_persistent_iroh_protocols(&self) -> Result<(), JsValue> {
6128            if let Some(actor) = self.persistent_protocols.lock().await.take() {
6129                actor.shutdown().await.map_err(js_protocol_error)?;
6130            }
6131            Ok(())
6132        }
6133    }
6134
6135    #[cfg(feature = "iroh-protocols-wasm")]
6136    fn persistent_protocols(
6137        guard: &Option<crate::wasm_docs_persistence::WasmPersistentDocsActor>,
6138    ) -> Result<&crate::wasm_docs_persistence::WasmPersistentDocsActor, JsValue> {
6139        guard
6140            .as_ref()
6141            .ok_or_else(|| JsValue::from_str("persistent Iroh protocols are not initialized"))
6142    }
6143
6144    #[cfg(feature = "iroh-protocols-wasm")]
6145    fn persistent_protocols_mut(
6146        guard: &mut Option<crate::wasm_docs_persistence::WasmPersistentDocsActor>,
6147    ) -> Result<&mut crate::wasm_docs_persistence::WasmPersistentDocsActor, JsValue> {
6148        guard
6149            .as_mut()
6150            .ok_or_else(|| JsValue::from_str("persistent Iroh protocols are not initialized"))
6151    }
6152
6153    #[cfg(feature = "iroh-protocols-wasm")]
6154    fn js_protocol_error(error: impl std::fmt::Display) -> JsValue {
6155        JsValue::from_str(&format!("{error:#}"))
6156    }
6157
6158    #[wasm_bindgen(start)]
6159    pub fn start() {
6160        console_error_panic_hook::set_once();
6161    }
6162}