Skip to main content

openrtc/
lib.rs

1#![allow(deprecated)]
2
3pub mod application_crypto;
4pub mod application_crypto_streams;
5pub mod client;
6pub mod connection;
7pub mod coordination;
8pub mod explicit_transfer_crypto;
9pub(crate) mod generated;
10pub mod heartbeat;
11#[cfg(feature = "iroh-carrier-core")]
12pub mod iroh_carrier;
13#[cfg(feature = "iroh-carrier-core")]
14pub mod iroh_carrier_bootstrap;
15#[cfg(feature = "iroh-carrier-core")]
16pub mod iroh_carrier_kind;
17#[cfg(feature = "iroh-carrier-core")]
18pub mod iroh_carrier_proof;
19pub(crate) mod iroh_connection_policy;
20pub mod key_agreement;
21pub mod lifecycle_reason;
22pub(crate) mod native_moq_policy;
23pub mod native_protocol;
24#[cfg(not(target_arch = "wasm32"))]
25pub(crate) mod native_send_policy;
26pub(crate) mod native_webrtc_policy;
27#[cfg(feature = "iroh-carrier-core")]
28pub mod packet_carrier_transport;
29pub mod presence;
30pub(crate) mod presence_policy;
31pub mod protocol_config;
32pub mod route_policy;
33pub mod runtime_policy;
34pub mod session_token;
35pub mod signaling;
36pub mod stream_metadata;
37pub(crate) mod transport_generation;
38pub(crate) mod transport_label;
39
40#[cfg(all(not(target_arch = "wasm32"), feature = "transport-lan"))]
41pub mod local_discovery;
42
43#[cfg(all(target_arch = "wasm32", feature = "transport-webrtc"))]
44compile_error!(
45    "feature `transport-webrtc` is native-only and must not be enabled for wasm32 targets"
46);
47
48#[cfg(all(target_arch = "wasm32", feature = "transport-moq"))]
49compile_error!("feature `transport-moq` is native-only and must not be enabled for wasm32 targets");
50
51/// Test constants — use these instead of hardcoding project IDs in tests.
52/// Unit tests that don't hit real Firestore should use TEST_PROJECT_ID.
53/// Live/integration tests must use LIVE_PROJECT_ID ("pluto-rtc-prod").
54#[cfg(test)]
55pub mod test_constants {
56    pub const TEST_PROJECT_ID: &str = "test-project";
57    pub const TEST_API_KEY: &str = "pk_test_0000000000000000000000000000000000000000";
58}
59
60/// Re-export for downstream crates' tests.
61pub const LIVE_PROJECT_ID: &str = "pluto-rtc-prod";
62
63/// Validate the only public platform credential accepted by an OpenRTC 2.0
64/// client constructor. The API key identifies the developer application; it is
65/// not a secret and does not authorize a live avenue by itself.
66pub fn validate_v2_public_api_key(api_key: &str) -> anyhow::Result<&str> {
67    let trimmed = api_key.trim();
68    let valid_prefix = trimmed.starts_with("pk_live_") || trimmed.starts_with("pk_test_");
69    let suffix = trimmed.get(8..).unwrap_or_default();
70    if !valid_prefix || suffix.len() != 40 || !suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
71        anyhow::bail!("OpenRTC 2.0 requires a public pk_live_ or pk_test_ API key");
72    }
73    Ok(trimmed)
74}
75
76pub fn app_tag_from_api_key(api_key: &str) -> String {
77    let trimmed = api_key.trim();
78    if trimmed.is_empty() {
79        return "app_anonymous".to_string();
80    }
81
82    let suffix_len = trimmed.len().min(16);
83    format!("app_{}", &trimmed[trimmed.len() - suffix_len..])
84}
85
86pub fn space_app_tag_from_keys(api_key: &str, space_key: &str) -> String {
87    let input = format!("{}:{}", api_key.trim(), space_key.trim());
88    let digest = <sha2::Sha256 as sha2::Digest>::digest(input.as_bytes());
89    format!("space::{}", hex::encode(digest))
90}
91
92#[cfg(test)]
93mod v2_constructor_contract_tests {
94    use super::*;
95
96    #[test]
97    fn rust_v2_constructor_is_provider_neutral_and_side_effect_free() {
98        let api_key = test_constants::TEST_API_KEY;
99        let client = client::Client::new_v2(api_key.to_string()).expect("valid public API key");
100
101        assert_eq!(client.app_tag(), app_tag_from_api_key(api_key));
102        assert!(client::Client::new_v2("firebase-project-id".to_string()).is_err());
103    }
104}
105
106#[cfg(not(target_arch = "wasm32"))]
107pub fn ensure_default_rustls_provider() {
108    if rustls::crypto::CryptoProvider::get_default().is_none() {
109        let _ = rustls::crypto::ring::default_provider().install_default();
110    }
111}
112
113#[cfg(not(target_arch = "wasm32"))]
114pub mod adapters;
115
116#[cfg(not(target_arch = "wasm32"))]
117pub mod native_coordination_gateway;
118
119#[cfg(not(target_arch = "wasm32"))]
120pub mod native_v2;
121
122pub mod connection_manager;
123pub mod logging;
124
125pub mod protocol_registry;
126
127#[cfg(not(target_arch = "wasm32"))]
128pub mod runtime_manager;
129
130#[cfg(not(target_arch = "wasm32"))]
131pub mod transport;
132
133#[cfg(not(target_arch = "wasm32"))]
134pub use client::EndpointHandle;
135
136#[cfg(all(
137    not(target_arch = "wasm32"),
138    not(any(target_os = "ios", target_os = "android"))
139))]
140pub mod sso;
141
142#[cfg(not(target_arch = "wasm32"))]
143pub mod native_node;
144
145#[cfg(not(target_arch = "wasm32"))]
146pub mod native_device;
147
148#[cfg(all(
149    test,
150    not(target_arch = "wasm32"),
151    feature = "iroh-protocols-wasm",
152    any(feature = "iroh-transport-webrtc", feature = "iroh-transport-moq")
153))]
154mod native_carrier_protocol_test;
155#[cfg(all(not(target_arch = "wasm32"), feature = "iroh-transport-moq"))]
156pub mod native_moq_carrier;
157#[cfg(all(not(target_arch = "wasm32"), feature = "iroh-transport-webrtc"))]
158pub mod native_webrtc_carrier;
159
160#[cfg(all(target_arch = "wasm32", feature = "iroh-protocols-wasm"))]
161mod wasm_docs_persistence;
162#[cfg(all(target_arch = "wasm32", feature = "iroh-protocols-wasm"))]
163mod wasm_indexeddb_blob_store;
164#[cfg(all(target_arch = "wasm32", feature = "iroh-transport-moq"))]
165pub mod wasm_moq_carrier;
166#[cfg(target_arch = "wasm32")]
167pub mod wasm_node;
168#[cfg(all(target_arch = "wasm32", feature = "iroh-transport-webrtc"))]
169pub mod wasm_webrtc_carrier;
170
171#[cfg(target_arch = "wasm32")]
172#[macro_export]
173macro_rules! console_log {
174    ($($t:tt)*) => (web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format_args!($($t)*).to_string())))
175}
176
177// WASM entry point bindings
178#[cfg(target_arch = "wasm32")]
179pub mod wasm_api {
180    use crate::client::Client;
181    use crate::session_token::split_compound_ticket;
182    use crate::wasm_node::{
183        into_js_readable_stream, peer_uni_stream_from_send, BiStream, PeerUniStream,
184    };
185    use iroh_tickets::endpoint::EndpointTicket;
186    #[cfg(any(
187        feature = "iroh-protocols-wasm",
188        feature = "iroh-transport-webrtc",
189        feature = "iroh-transport-moq"
190    ))]
191    use std::rc::Rc;
192    use std::str::FromStr;
193    use std::sync::{Arc, Mutex};
194    #[cfg(any(feature = "iroh-transport-webrtc", feature = "iroh-transport-moq"))]
195    use std::{cell::RefCell, collections::HashMap};
196    use wasm_bindgen::prelude::*;
197    #[cfg(any(feature = "iroh-transport-webrtc", feature = "iroh-transport-moq"))]
198    use wasm_bindgen_futures::spawn_local;
199    use wasm_streams::readable::sys::ReadableStream as JsReadableStream;
200
201    #[cfg(feature = "iroh-transport-webrtc")]
202    #[derive(Debug, Clone)]
203    struct WasmWebRtcCarrierAttempt {
204        connection_id: String,
205        remote_endpoint_id: iroh::EndpointId,
206        bootstrap: crate::iroh_carrier_bootstrap::CarrierBootstrapFrame,
207        generation: crate::client::WasmPeerDataGeneration,
208        role: &'static str,
209        offer_started: bool,
210        remote_ready: bool,
211        completion_started: bool,
212        retry_count: u8,
213        external_fallback_allowed: bool,
214        inbound_authorization_expires_at_ms: Option<f64>,
215    }
216
217    #[cfg(feature = "iroh-transport-moq")]
218    #[derive(Debug, Clone)]
219    struct WasmMoqCarrierAttempt {
220        connection_id: String,
221        remote_endpoint_id: iroh::EndpointId,
222        bootstrap: crate::iroh_carrier_bootstrap::CarrierBootstrapFrame,
223        generation: crate::client::WasmPeerDataGeneration,
224        role: &'static str,
225        retry_count: u8,
226        external_fallback_allowed: bool,
227        inbound_authorization_expires_at_ms: Option<f64>,
228    }
229
230    #[cfg(all(feature = "iroh-transport-webrtc", feature = "iroh-transport-moq"))]
231    #[derive(Debug, Clone, Copy)]
232    struct WasmRemoteCarrierCapabilities {
233        webrtc: bool,
234        webrtc_external: bool,
235        moq: bool,
236        moq_external: bool,
237    }
238
239    #[cfg(any(feature = "iroh-transport-webrtc", feature = "iroh-transport-moq"))]
240    fn emit_wasm_carrier_action(
241        handler: &Rc<RefCell<Option<js_sys::Function>>>,
242        action: serde_json::Value,
243    ) {
244        let Some(handler) = handler.borrow().as_ref().cloned() else {
245            return;
246        };
247        // `serde_json::Value::Object` is a Serde map. The default
248        // serde-wasm-bindgen serializer turns maps into JavaScript `Map`s,
249        // while the browser adapter consumes a discriminated plain object.
250        // Keep this internal ABI JSON-compatible so `action.type` and the
251        // remaining carrier fields are visible to TypeScript.
252        let Ok(value) = serde::Serialize::serialize(
253            &action,
254            &serde_wasm_bindgen::Serializer::json_compatible(),
255        ) else {
256            return;
257        };
258        if let Err(error) = handler.call1(&JsValue::UNDEFINED, &value) {
259            web_sys::console::error_2(
260                &JsValue::from_str("[OpenRTC][WASM carrier] action handler failed"),
261                &error,
262            );
263        }
264    }
265
266    #[cfg(any(feature = "iroh-transport-webrtc", feature = "iroh-transport-moq"))]
267    fn wasm_carrier_failure_code(error: &anyhow::Error) -> &'static str {
268        let message = format!("{error:#}");
269        if message.contains("base generation changed before candidate acknowledgement")
270            || message.contains("incumbent generation changed")
271            || message.contains("replacement incumbent is stale")
272        {
273            "carrier-base-generation-stale"
274        } else if message.contains("authorization epoch changed") {
275            "carrier-authorization-stale"
276        } else if message.contains("stale before atomic commit")
277            || message.contains("became stale during atomic commit")
278        {
279            "carrier-logical-generation-stale"
280        } else if message.contains("attempt was retired") || message.contains("retired upgrade") {
281            "carrier-attempt-retired"
282        } else {
283            "carrier-proof-failed"
284        }
285    }
286
287    #[wasm_bindgen]
288    pub struct WasmClient {
289        inner: Arc<Client>,
290        identity_credential: Arc<Mutex<Option<String>>>,
291        last_auth_log: Arc<Mutex<Option<(bool, usize)>>>,
292        #[cfg(feature = "iroh-protocols-wasm")]
293        persistent_protocols:
294            Rc<tokio::sync::Mutex<Option<crate::wasm_docs_persistence::WasmPersistentDocsActor>>>,
295        #[cfg(feature = "iroh-transport-webrtc")]
296        wasm_webrtc_carrier_sessions:
297            Rc<RefCell<HashMap<String, crate::wasm_webrtc_carrier::WasmWebRtcCarrierSession>>>,
298        #[cfg(feature = "iroh-transport-webrtc")]
299        wasm_webrtc_carrier_attempts: Rc<RefCell<HashMap<String, WasmWebRtcCarrierAttempt>>>,
300        #[cfg(feature = "iroh-transport-webrtc")]
301        wasm_webrtc_remote_external: Rc<RefCell<HashMap<String, bool>>>,
302        #[cfg(any(feature = "iroh-transport-webrtc", feature = "iroh-transport-moq"))]
303        wasm_carrier_action_handler: Rc<RefCell<Option<js_sys::Function>>>,
304        #[cfg(feature = "iroh-transport-moq")]
305        wasm_moq_carrier_sessions:
306            Rc<RefCell<HashMap<String, crate::wasm_moq_carrier::WasmMoqCarrierSession>>>,
307        #[cfg(feature = "iroh-transport-moq")]
308        wasm_moq_carrier_attempts: Rc<RefCell<HashMap<String, WasmMoqCarrierAttempt>>>,
309        #[cfg(feature = "iroh-transport-moq")]
310        wasm_moq_remote_external: Rc<RefCell<HashMap<String, bool>>>,
311        #[cfg(all(feature = "iroh-transport-webrtc", feature = "iroh-transport-moq"))]
312        wasm_remote_carrier_capabilities:
313            Rc<RefCell<HashMap<String, WasmRemoteCarrierCapabilities>>>,
314    }
315
316    #[cfg(feature = "iroh-transport-webrtc")]
317    async fn fail_wasm_webrtc_attempt(
318        inner: Arc<Client>,
319        attempts: Rc<RefCell<HashMap<String, WasmWebRtcCarrierAttempt>>>,
320        sessions: Rc<
321            RefCell<HashMap<String, crate::wasm_webrtc_carrier::WasmWebRtcCarrierSession>>,
322        >,
323        handler: Rc<RefCell<Option<js_sys::Function>>>,
324        attempt: WasmWebRtcCarrierAttempt,
325        failure_code: &'static str,
326        notify_peer: bool,
327    ) {
328        let kind = crate::client::IrohPathKind::IrohWebRtc;
329        if !inner
330            .retire_wasm_carrier_upgrade(
331                &attempt.connection_id,
332                kind,
333                &attempt.bootstrap.upgrade_id,
334            )
335            .await
336        {
337            return;
338        }
339        if attempts
340            .borrow()
341            .get(&attempt.connection_id)
342            .is_some_and(|current| current.bootstrap.upgrade_id == attempt.bootstrap.upgrade_id)
343        {
344            attempts.borrow_mut().remove(&attempt.connection_id);
345        }
346        sessions.borrow_mut().remove(&attempt.bootstrap.upgrade_id);
347        if notify_peer {
348            if let Ok(failed) = crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::failed_from(
349                &attempt.bootstrap,
350                failure_code,
351            ) {
352                emit_wasm_carrier_action(
353                    &handler,
354                    serde_json::json!({
355                        "type": "send-control",
356                        "connectionId": attempt.connection_id,
357                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
358                        "envelope": failed,
359                    }),
360                );
361            }
362        }
363        emit_wasm_carrier_action(
364            &handler,
365            serde_json::json!({
366                "type": "retire-webrtc",
367                "connectionId": attempt.connection_id,
368                "upgradeId": attempt.bootstrap.upgrade_id,
369                "failureCode": failure_code,
370            }),
371        );
372        let retry_pending = attempt.retry_count == 0
373            && matches!(
374                failure_code,
375                "data-channel-failed"
376                    | "ice-failed"
377                    | "signaling-failed"
378                    | "carrier-base-generation-stale"
379            );
380        if !attempt.external_fallback_allowed && attempt.role == "initiator" && !retry_pending {
381            emit_wasm_carrier_action(
382                &handler,
383                serde_json::json!({
384                    "type": "advance-carrier",
385                    "connectionId": attempt.connection_id,
386                    "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
387                    "failedRoute": "iroh-webrtc",
388                }),
389            );
390        }
391        if attempt.external_fallback_allowed
392            && attempt.role == "initiator"
393            && matches!(
394                inner
395                    .iroh_path_kind(&attempt.remote_endpoint_id.to_string())
396                    .await,
397                crate::client::IrohPathKind::Relay | crate::client::IrohPathKind::Unknown
398            )
399        {
400            emit_wasm_carrier_action(
401                &handler,
402                serde_json::json!({
403                    "type": "fallback-external-webrtc",
404                    "connectionId": attempt.connection_id,
405                    "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
406                    "failureCode": failure_code,
407                }),
408            );
409        }
410    }
411
412    #[cfg(feature = "iroh-transport-webrtc")]
413    impl WasmClient {
414        fn schedule_wasm_webrtc_carrier_watchdog(
415            &self,
416            bootstrap: crate::iroh_carrier_bootstrap::CarrierBootstrapFrame,
417        ) {
418            let inner = self.inner.clone();
419            let attempts = self.wasm_webrtc_carrier_attempts.clone();
420            let sessions = self.wasm_webrtc_carrier_sessions.clone();
421            let handler = self.wasm_carrier_action_handler.clone();
422            spawn_local(async move {
423                gloo_timers::future::sleep(std::time::Duration::from_secs(30)).await;
424                let attempt = attempts
425                    .borrow()
426                    .values()
427                    .find(|attempt| attempt.bootstrap.upgrade_id == bootstrap.upgrade_id)
428                    .cloned();
429                if let Some(attempt) = attempt {
430                    if !inner
431                        .wasm_carrier_upgrade_is_current(
432                            &attempt.connection_id,
433                            crate::client::IrohPathKind::IrohWebRtc,
434                            &attempt.bootstrap.upgrade_id,
435                        )
436                        .await
437                    {
438                        return;
439                    }
440                    fail_wasm_webrtc_attempt(
441                        inner,
442                        attempts,
443                        sessions,
444                        handler,
445                        attempt,
446                        "carrier-timeout",
447                        true,
448                    )
449                    .await;
450                }
451            });
452        }
453
454        async fn fail_wasm_webrtc_carrier_attempt(
455            &self,
456            attempt: WasmWebRtcCarrierAttempt,
457            failure_code: &'static str,
458            notify_peer: bool,
459        ) {
460            fail_wasm_webrtc_attempt(
461                self.inner.clone(),
462                self.wasm_webrtc_carrier_attempts.clone(),
463                self.wasm_webrtc_carrier_sessions.clone(),
464                self.wasm_carrier_action_handler.clone(),
465                attempt,
466                failure_code,
467                notify_peer,
468            )
469            .await;
470        }
471
472        fn spawn_outbound_wasm_webrtc_carrier_completion(&self, attempt: WasmWebRtcCarrierAttempt) {
473            let inner = self.inner.clone();
474            let attempts = self.wasm_webrtc_carrier_attempts.clone();
475            let sessions = self.wasm_webrtc_carrier_sessions.clone();
476            let handler = self.wasm_carrier_action_handler.clone();
477            spawn_local(async move {
478                let kind = crate::client::IrohPathKind::IrohWebRtc;
479                let result = inner
480                    .complete_outbound_wasm_carrier_upgrade(
481                        &attempt.connection_id,
482                        &attempt.remote_endpoint_id.to_string(),
483                        &attempt.bootstrap.upgrade_id,
484                        attempt.generation,
485                        kind,
486                    )
487                    .await;
488                if let Err(error) = result {
489                    let failure_code = wasm_carrier_failure_code(&error);
490                    web_sys::console::error_1(&JsValue::from_str(&format!(
491                        "[OpenRTC][WebRTC carrier] outbound candidate completion failed connection_id={} upgrade_id={} failure_code={} error={error:#}",
492                        attempt.connection_id, attempt.bootstrap.upgrade_id, failure_code,
493                    )));
494                    fail_wasm_webrtc_attempt(
495                        inner,
496                        attempts,
497                        sessions,
498                        handler,
499                        attempt,
500                        failure_code,
501                        true,
502                    )
503                    .await;
504                    return;
505                }
506                inner
507                    .retire_wasm_carrier_upgrade(
508                        &attempt.connection_id,
509                        kind,
510                        &attempt.bootstrap.upgrade_id,
511                    )
512                    .await;
513                emit_wasm_carrier_action(
514                    &handler,
515                    serde_json::json!({
516                        "type": "selected",
517                        "connectionId": attempt.connection_id,
518                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
519                        "upgradeId": attempt.bootstrap.upgrade_id,
520                        "family": "iroh",
521                        "carrier": "webrtc",
522                        "implementation": "iroh-carrier",
523                        "transportGeneration": attempt.generation.transport_generation.saturating_add(1),
524                        "routeGeneration": 0,
525                    }),
526                );
527            });
528        }
529
530        fn take_ready_outbound_wasm_webrtc_carrier_attempt(
531            &self,
532            connection_id: &str,
533            upgrade_id: &str,
534        ) -> Option<WasmWebRtcCarrierAttempt> {
535            if !self
536                .wasm_webrtc_carrier_sessions
537                .borrow()
538                .contains_key(upgrade_id)
539            {
540                return None;
541            }
542            let mut attempts = self.wasm_webrtc_carrier_attempts.borrow_mut();
543            let attempt = attempts.get_mut(connection_id).filter(|attempt| {
544                attempt.bootstrap.upgrade_id == upgrade_id
545                    && attempt.role == "initiator"
546                    && attempt.remote_ready
547                    && !attempt.completion_started
548            })?;
549            attempt.completion_started = true;
550            Some(attempt.clone())
551        }
552
553        fn spawn_inbound_wasm_webrtc_carrier_completion(&self, attempt: WasmWebRtcCarrierAttempt) {
554            let inner = self.inner.clone();
555            let attempts = self.wasm_webrtc_carrier_attempts.clone();
556            let sessions = self.wasm_webrtc_carrier_sessions.clone();
557            let handler = self.wasm_carrier_action_handler.clone();
558            spawn_local(async move {
559                let kind = crate::client::IrohPathKind::IrohWebRtc;
560                let node = inner.iroh_node.read().await.as_ref().cloned();
561                let result = async {
562                    let node = node.ok_or_else(|| anyhow::anyhow!("Iroh node is unavailable"))?;
563                    let candidate = node
564                        .wait_for_inbound_replacement_candidate(
565                            attempt.remote_endpoint_id,
566                            crate::iroh_carrier_kind::EXPERIMENTAL_WEBRTC_TRANSPORT_ID,
567                            std::time::Duration::from_secs(40),
568                        )
569                        .await?;
570                    let proof = inner.wasm_candidate_proof_probe(
571                        &attempt.connection_id,
572                        &attempt.bootstrap.upgrade_id,
573                        attempt.bootstrap.base.transport_generation,
574                        attempt.bootstrap.base.route_generation,
575                        kind,
576                        crate::iroh_carrier_kind::EXPERIMENTAL_WEBRTC_TRANSPORT_ID,
577                    )?;
578                    let (send, probe, ack) = inner
579                        .receive_inbound_wasm_carrier_candidate_proof(
580                            &attempt.connection_id,
581                            &candidate,
582                            &proof,
583                        )
584                        .await?;
585                    anyhow::ensure!(
586                        inner
587                            .current_wasm_peer_data_generation(&attempt.connection_id, None)
588                            .await
589                            == Some(attempt.generation),
590                        "browser WebRTC inbound carrier base generation changed before candidate acknowledgement"
591                    );
592                    inner
593                        .send_inbound_wasm_carrier_candidate_ack(send, &ack)
594                        .await?;
595                    let (commit_send, committed) = inner
596                        .receive_inbound_wasm_carrier_commit(
597                            &attempt.connection_id,
598                            &candidate,
599                            &probe,
600                        )
601                        .await?;
602                    let incumbent = inner
603                        .commit_proven_wasm_carrier_candidate(
604                            &attempt.connection_id,
605                            &attempt.bootstrap.upgrade_id,
606                            attempt.generation,
607                            kind,
608                            candidate,
609                        )
610                        .await?;
611                    inner
612                        .send_inbound_wasm_carrier_candidate_ack(commit_send, &committed)
613                        .await?;
614                    incumbent.close(0u8.into(), b"wasm-custom-transport-upgrade");
615                    Ok::<(), anyhow::Error>(())
616                }
617                .await;
618                if let Some(expires_at_ms) = attempt.inbound_authorization_expires_at_ms {
619                    if let Some(node) = inner.iroh_node.read().await.as_ref().cloned() {
620                        node.revoke_inbound_replacement_if_current(
621                            attempt.remote_endpoint_id,
622                            crate::iroh_carrier_kind::EXPERIMENTAL_WEBRTC_TRANSPORT_ID,
623                            expires_at_ms,
624                        )
625                        .await;
626                    }
627                }
628                if let Err(error) = result {
629                    let failure_code = wasm_carrier_failure_code(&error);
630                    web_sys::console::error_1(&JsValue::from_str(&format!(
631                        "[OpenRTC][WebRTC carrier] inbound candidate completion failed connection_id={} upgrade_id={} failure_code={} error={error:#}",
632                        attempt.connection_id, attempt.bootstrap.upgrade_id, failure_code,
633                    )));
634                    fail_wasm_webrtc_attempt(
635                        inner,
636                        attempts,
637                        sessions,
638                        handler,
639                        attempt,
640                        failure_code,
641                        true,
642                    )
643                    .await;
644                    return;
645                }
646                inner
647                    .retire_wasm_carrier_upgrade(
648                        &attempt.connection_id,
649                        kind,
650                        &attempt.bootstrap.upgrade_id,
651                    )
652                    .await;
653                emit_wasm_carrier_action(
654                    &handler,
655                    serde_json::json!({
656                        "type": "selected",
657                        "connectionId": attempt.connection_id,
658                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
659                        "upgradeId": attempt.bootstrap.upgrade_id,
660                        "family": "iroh",
661                        "carrier": "webrtc",
662                        "implementation": "iroh-carrier",
663                        "transportGeneration": attempt.generation.transport_generation.saturating_add(1),
664                        "routeGeneration": 0,
665                    }),
666                );
667            });
668        }
669    }
670
671    #[cfg(feature = "iroh-transport-moq")]
672    fn wasm_moq_carrier_namespaces(
673        local_endpoint_id: &str,
674        remote_endpoint_id: &str,
675        carrier_session_id: &str,
676    ) -> (String, String, &'static str) {
677        let (first, second) = if local_endpoint_id <= remote_endpoint_id {
678            (local_endpoint_id, remote_endpoint_id)
679        } else {
680            (remote_endpoint_id, local_endpoint_id)
681        };
682        let base = format!("openrtc/iroh-carrier/moq/{first}/{second}/{carrier_session_id}");
683        (
684            format!("{base}/from/{local_endpoint_id}"),
685            format!("{base}/from/{remote_endpoint_id}"),
686            "iroh-packets",
687        )
688    }
689
690    #[cfg(feature = "iroh-transport-moq")]
691    async fn fail_wasm_moq_attempt(
692        inner: Arc<Client>,
693        attempts: Rc<RefCell<HashMap<String, WasmMoqCarrierAttempt>>>,
694        sessions: Rc<RefCell<HashMap<String, crate::wasm_moq_carrier::WasmMoqCarrierSession>>>,
695        handler: Rc<RefCell<Option<js_sys::Function>>>,
696        attempt: WasmMoqCarrierAttempt,
697        failure_code: &'static str,
698        notify_peer: bool,
699    ) {
700        let kind = crate::client::IrohPathKind::IrohMoq;
701        if !inner
702            .retire_wasm_carrier_upgrade(
703                &attempt.connection_id,
704                kind,
705                &attempt.bootstrap.upgrade_id,
706            )
707            .await
708        {
709            return;
710        }
711        if attempts
712            .borrow()
713            .get(&attempt.connection_id)
714            .is_some_and(|current| current.bootstrap.upgrade_id == attempt.bootstrap.upgrade_id)
715        {
716            attempts.borrow_mut().remove(&attempt.connection_id);
717        }
718        sessions.borrow_mut().remove(&attempt.bootstrap.upgrade_id);
719        if notify_peer {
720            if let Ok(failed) = crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::failed_from(
721                &attempt.bootstrap,
722                failure_code,
723            ) {
724                emit_wasm_carrier_action(
725                    &handler,
726                    serde_json::json!({
727                        "type": "send-control",
728                        "connectionId": attempt.connection_id,
729                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
730                        "envelope": failed,
731                    }),
732                );
733            }
734        }
735        emit_wasm_carrier_action(
736            &handler,
737            serde_json::json!({
738                "type": "retire-moq",
739                "connectionId": attempt.connection_id,
740                "upgradeId": attempt.bootstrap.upgrade_id,
741                "failureCode": failure_code,
742            }),
743        );
744        let retry_pending = attempt.role == "initiator"
745            && attempt.retry_count == 0
746            && failure_code == "carrier-base-generation-stale";
747        if !attempt.external_fallback_allowed && attempt.role == "initiator" && !retry_pending {
748            emit_wasm_carrier_action(
749                &handler,
750                serde_json::json!({
751                    "type": "advance-carrier",
752                    "connectionId": attempt.connection_id,
753                    "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
754                    "failedRoute": "iroh-moq",
755                }),
756            );
757        }
758        if attempt.external_fallback_allowed
759            && attempt.role == "initiator"
760            && !retry_pending
761            && matches!(
762                inner
763                    .iroh_path_kind(&attempt.remote_endpoint_id.to_string())
764                    .await,
765                crate::client::IrohPathKind::Relay | crate::client::IrohPathKind::Unknown
766            )
767        {
768            emit_wasm_carrier_action(
769                &handler,
770                serde_json::json!({
771                    "type": "fallback-external-moq",
772                    "connectionId": attempt.connection_id,
773                    "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
774                    "failureCode": failure_code,
775                }),
776            );
777        }
778    }
779
780    #[cfg(feature = "iroh-transport-moq")]
781    impl WasmClient {
782        fn schedule_wasm_moq_carrier_watchdog(
783            &self,
784            bootstrap: crate::iroh_carrier_bootstrap::CarrierBootstrapFrame,
785        ) {
786            let inner = self.inner.clone();
787            let attempts = self.wasm_moq_carrier_attempts.clone();
788            let sessions = self.wasm_moq_carrier_sessions.clone();
789            let handler = self.wasm_carrier_action_handler.clone();
790            spawn_local(async move {
791                gloo_timers::future::sleep(std::time::Duration::from_secs(30)).await;
792                let attempt = attempts
793                    .borrow()
794                    .values()
795                    .find(|attempt| attempt.bootstrap.upgrade_id == bootstrap.upgrade_id)
796                    .cloned();
797                if let Some(attempt) = attempt {
798                    if !inner
799                        .wasm_carrier_upgrade_is_current(
800                            &attempt.connection_id,
801                            crate::client::IrohPathKind::IrohMoq,
802                            &attempt.bootstrap.upgrade_id,
803                        )
804                        .await
805                    {
806                        return;
807                    }
808                    fail_wasm_moq_attempt(
809                        inner,
810                        attempts,
811                        sessions,
812                        handler,
813                        attempt,
814                        "carrier-timeout",
815                        true,
816                    )
817                    .await;
818                }
819            });
820        }
821
822        async fn fail_wasm_moq_carrier_attempt(
823            &self,
824            attempt: WasmMoqCarrierAttempt,
825            failure_code: &'static str,
826            notify_peer: bool,
827        ) {
828            fail_wasm_moq_attempt(
829                self.inner.clone(),
830                self.wasm_moq_carrier_attempts.clone(),
831                self.wasm_moq_carrier_sessions.clone(),
832                self.wasm_carrier_action_handler.clone(),
833                attempt,
834                failure_code,
835                notify_peer,
836            )
837            .await;
838        }
839
840        fn spawn_outbound_wasm_moq_carrier_completion(&self, attempt: WasmMoqCarrierAttempt) {
841            let inner = self.inner.clone();
842            let attempts = self.wasm_moq_carrier_attempts.clone();
843            let sessions = self.wasm_moq_carrier_sessions.clone();
844            let handler = self.wasm_carrier_action_handler.clone();
845            spawn_local(async move {
846                let kind = crate::client::IrohPathKind::IrohMoq;
847                let result = inner
848                    .complete_outbound_wasm_carrier_upgrade(
849                        &attempt.connection_id,
850                        &attempt.remote_endpoint_id.to_string(),
851                        &attempt.bootstrap.upgrade_id,
852                        attempt.generation,
853                        kind,
854                    )
855                    .await;
856                if let Err(error) = result {
857                    let failure_code = wasm_carrier_failure_code(&error);
858                    web_sys::console::error_1(&JsValue::from_str(&format!(
859                        "[OpenRTC][MoQ carrier] outbound candidate completion failed connection_id={} upgrade_id={} failure_code={} error={error:#}",
860                        attempt.connection_id, attempt.bootstrap.upgrade_id, failure_code,
861                    )));
862                    fail_wasm_moq_attempt(
863                        inner,
864                        attempts,
865                        sessions,
866                        handler,
867                        attempt,
868                        failure_code,
869                        true,
870                    )
871                    .await;
872                    return;
873                }
874                inner
875                    .retire_wasm_carrier_upgrade(
876                        &attempt.connection_id,
877                        kind,
878                        &attempt.bootstrap.upgrade_id,
879                    )
880                    .await;
881                emit_wasm_carrier_action(
882                    &handler,
883                    serde_json::json!({
884                        "type": "selected",
885                        "connectionId": attempt.connection_id,
886                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
887                        "upgradeId": attempt.bootstrap.upgrade_id,
888                        "family": "iroh",
889                        "carrier": "moq",
890                        "implementation": "iroh-carrier",
891                        "transportGeneration": attempt.generation.transport_generation.saturating_add(1),
892                        "routeGeneration": 0,
893                    }),
894                );
895            });
896        }
897
898        fn spawn_inbound_wasm_moq_carrier_completion(&self, attempt: WasmMoqCarrierAttempt) {
899            let inner = self.inner.clone();
900            let attempts = self.wasm_moq_carrier_attempts.clone();
901            let sessions = self.wasm_moq_carrier_sessions.clone();
902            let handler = self.wasm_carrier_action_handler.clone();
903            spawn_local(async move {
904                let kind = crate::client::IrohPathKind::IrohMoq;
905                let node = inner.iroh_node.read().await.as_ref().cloned();
906                let result = async {
907                    let node = node.ok_or_else(|| anyhow::anyhow!("Iroh node is unavailable"))?;
908                    let candidate = node
909                        .wait_for_inbound_replacement_candidate(
910                            attempt.remote_endpoint_id,
911                            crate::iroh_carrier_kind::EXPERIMENTAL_MOQ_TRANSPORT_ID,
912                            std::time::Duration::from_secs(40),
913                        )
914                        .await?;
915                    let proof = inner.wasm_candidate_proof_probe(
916                        &attempt.connection_id,
917                        &attempt.bootstrap.upgrade_id,
918                        attempt.bootstrap.base.transport_generation,
919                        attempt.bootstrap.base.route_generation,
920                        kind,
921                        crate::iroh_carrier_kind::EXPERIMENTAL_MOQ_TRANSPORT_ID,
922                    )?;
923                    let (send, probe, ack) = inner
924                        .receive_inbound_wasm_carrier_candidate_proof(
925                            &attempt.connection_id,
926                            &candidate,
927                            &proof,
928                        )
929                        .await?;
930                    anyhow::ensure!(
931                        inner
932                            .current_wasm_peer_data_generation(&attempt.connection_id, None)
933                            .await
934                            == Some(attempt.generation),
935                        "browser MoQ inbound carrier base generation changed before candidate acknowledgement"
936                    );
937                    inner
938                        .send_inbound_wasm_carrier_candidate_ack(send, &ack)
939                        .await?;
940                    let (commit_send, committed) = inner
941                        .receive_inbound_wasm_carrier_commit(
942                            &attempt.connection_id,
943                            &candidate,
944                            &probe,
945                        )
946                        .await?;
947                    let incumbent = inner
948                        .commit_proven_wasm_carrier_candidate(
949                            &attempt.connection_id,
950                            &attempt.bootstrap.upgrade_id,
951                            attempt.generation,
952                            kind,
953                            candidate,
954                        )
955                        .await?;
956                    inner
957                        .send_inbound_wasm_carrier_candidate_ack(commit_send, &committed)
958                        .await?;
959                    incumbent.close(0u8.into(), b"wasm-custom-transport-upgrade");
960                    Ok::<(), anyhow::Error>(())
961                }
962                .await;
963                if let Some(expires_at_ms) = attempt.inbound_authorization_expires_at_ms {
964                    if let Some(node) = inner.iroh_node.read().await.as_ref().cloned() {
965                        node.revoke_inbound_replacement_if_current(
966                            attempt.remote_endpoint_id,
967                            crate::iroh_carrier_kind::EXPERIMENTAL_MOQ_TRANSPORT_ID,
968                            expires_at_ms,
969                        )
970                        .await;
971                    }
972                }
973                if let Err(error) = result {
974                    let failure_code = wasm_carrier_failure_code(&error);
975                    web_sys::console::error_1(&JsValue::from_str(&format!(
976                        "[OpenRTC][MoQ carrier] inbound candidate completion failed connection_id={} upgrade_id={} failure_code={} error={error:#}",
977                        attempt.connection_id, attempt.bootstrap.upgrade_id, failure_code,
978                    )));
979                    fail_wasm_moq_attempt(
980                        inner,
981                        attempts,
982                        sessions,
983                        handler,
984                        attempt,
985                        failure_code,
986                        true,
987                    )
988                    .await;
989                    return;
990                }
991                inner
992                    .retire_wasm_carrier_upgrade(
993                        &attempt.connection_id,
994                        kind,
995                        &attempt.bootstrap.upgrade_id,
996                    )
997                    .await;
998                emit_wasm_carrier_action(
999                    &handler,
1000                    serde_json::json!({
1001                        "type": "selected",
1002                        "connectionId": attempt.connection_id,
1003                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
1004                        "upgradeId": attempt.bootstrap.upgrade_id,
1005                        "family": "iroh",
1006                        "carrier": "moq",
1007                        "implementation": "iroh-carrier",
1008                        "transportGeneration": attempt.generation.transport_generation.saturating_add(1),
1009                        "routeGeneration": 0,
1010                    }),
1011                );
1012            });
1013        }
1014
1015        async fn handle_wasm_moq_bootstrap(
1016            &self,
1017            connection_id: String,
1018            remote_endpoint_id: String,
1019            bootstrap: crate::iroh_carrier_bootstrap::CarrierBootstrapFrame,
1020        ) -> Result<bool, JsValue> {
1021            let endpoint_id = remote_endpoint_id
1022                .parse::<iroh::EndpointId>()
1023                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1024            let kind = crate::client::IrohPathKind::IrohMoq;
1025            match bootstrap.action {
1026                crate::iroh_carrier_bootstrap::CarrierBootstrapAction::Request => {
1027                    let local_endpoint_id = self
1028                        .inner
1029                        .current_node_id()
1030                        .await
1031                        .ok_or_else(|| JsValue::from_str("local endpoint id is unavailable"))?;
1032                    if local_endpoint_id.as_str() >= remote_endpoint_id.as_str()
1033                        || !self.inner.is_iroh_moq_carrier_enabled().await
1034                    {
1035                        return Ok(true);
1036                    }
1037                    if !matches!(
1038                        self.inner.iroh_path_kind(&remote_endpoint_id).await,
1039                        crate::client::IrohPathKind::Relay | crate::client::IrohPathKind::Unknown
1040                    ) {
1041                        return Ok(true);
1042                    }
1043                    let base_connection = self
1044                        .inner
1045                        .get_connection(endpoint_id)
1046                        .await
1047                        .ok_or_else(|| JsValue::from_str("MoQ carrier base is unavailable"))?;
1048                    let generation = self
1049                        .inner
1050                        .current_wasm_peer_data_generation(&connection_id, None)
1051                        .await
1052                        .ok_or_else(|| {
1053                            JsValue::from_str("MoQ carrier generation is unavailable")
1054                        })?;
1055                    if crate::transport_generation::for_connection(&base_connection)
1056                        != generation.transport_stable_id
1057                    {
1058                        let failed =
1059                            crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::failed_from(
1060                                &bootstrap,
1061                                "carrier-base-generation-stale",
1062                            )
1063                            .map_err(|error| JsValue::from_str(&error.to_string()))?;
1064                        emit_wasm_carrier_action(
1065                            &self.wasm_carrier_action_handler,
1066                            serde_json::json!({
1067                                "type": "send-control",
1068                                "connectionId": connection_id,
1069                                "remoteEndpointId": remote_endpoint_id,
1070                                "envelope": failed,
1071                            }),
1072                        );
1073                        return Ok(true);
1074                    }
1075                    if !self
1076                        .inner
1077                        .reserve_wasm_carrier_upgrade(&connection_id, kind, &bootstrap.upgrade_id)
1078                        .await
1079                    {
1080                        return Ok(true);
1081                    }
1082                    let node = self
1083                        .inner
1084                        .iroh_node
1085                        .read()
1086                        .await
1087                        .as_ref()
1088                        .cloned()
1089                        .ok_or_else(|| JsValue::from_str("Iroh node is unavailable"))?;
1090                    let authorization_expiry = node
1091                        .authorize_pending_inbound_replacement(
1092                            endpoint_id,
1093                            crate::iroh_carrier_kind::EXPERIMENTAL_MOQ_TRANSPORT_ID,
1094                            std::time::Duration::from_secs(45),
1095                        )
1096                        .await;
1097                    let fallback_allowed = self.inner.wasm_moq_external_fallback_allowed().await
1098                        && self
1099                            .wasm_moq_remote_external
1100                            .borrow()
1101                            .get(&connection_id)
1102                            .copied()
1103                            .unwrap_or(false);
1104                    let previous = self.wasm_moq_carrier_attempts.borrow_mut().insert(
1105                        connection_id.clone(),
1106                        WasmMoqCarrierAttempt {
1107                            connection_id: connection_id.clone(),
1108                            remote_endpoint_id: endpoint_id,
1109                            bootstrap: bootstrap.clone(),
1110                            generation,
1111                            role: "responder",
1112                            retry_count: bootstrap.attempt.saturating_sub(1),
1113                            external_fallback_allowed: fallback_allowed,
1114                            inbound_authorization_expires_at_ms: Some(authorization_expiry),
1115                        },
1116                    );
1117                    if let Some(previous) = previous {
1118                        self.wasm_moq_carrier_sessions
1119                            .borrow_mut()
1120                            .remove(&previous.bootstrap.upgrade_id);
1121                    }
1122                    let (publish_namespace, subscribe_namespace, track_name) =
1123                        wasm_moq_carrier_namespaces(
1124                            &local_endpoint_id,
1125                            &remote_endpoint_id,
1126                            &bootstrap.carrier_session_id,
1127                        );
1128                    emit_wasm_carrier_action(
1129                        &self.wasm_carrier_action_handler,
1130                        serde_json::json!({
1131                            "type": "prepare-moq",
1132                            "connectionId": connection_id,
1133                            "remoteEndpointId": remote_endpoint_id,
1134                            "role": "responder",
1135                            "upgradeId": bootstrap.upgrade_id,
1136                            "carrierSessionId": bootstrap.carrier_session_id,
1137                            "transportGeneration": generation.transport_generation.saturating_add(1),
1138                            "publishNamespace": publish_namespace,
1139                            "subscribeNamespace": subscribe_namespace,
1140                            "trackName": track_name,
1141                        }),
1142                    );
1143                    self.schedule_wasm_moq_carrier_watchdog(bootstrap);
1144                }
1145                crate::iroh_carrier_bootstrap::CarrierBootstrapAction::Ready => {
1146                    let attempt = self
1147                        .wasm_moq_carrier_attempts
1148                        .borrow()
1149                        .get(&connection_id)
1150                        .filter(|attempt| bootstrap.is_response_to(&attempt.bootstrap))
1151                        .cloned();
1152                    if let Some(attempt) = attempt {
1153                        emit_wasm_carrier_action(
1154                            &self.wasm_carrier_action_handler,
1155                            serde_json::json!({
1156                                "type": "activate-moq",
1157                                "connectionId": attempt.connection_id,
1158                                "upgradeId": attempt.bootstrap.upgrade_id,
1159                            }),
1160                        );
1161                    }
1162                }
1163                crate::iroh_carrier_bootstrap::CarrierBootstrapAction::Failed => {
1164                    let attempt = self
1165                        .wasm_moq_carrier_attempts
1166                        .borrow()
1167                        .get(&connection_id)
1168                        .filter(|attempt| bootstrap.is_response_to(&attempt.bootstrap))
1169                        .cloned();
1170                    if let Some(attempt) = attempt {
1171                        let failure_code = match bootstrap.failure_code.as_deref() {
1172                            Some("carrier-base-generation-stale") => {
1173                                "carrier-base-generation-stale"
1174                            }
1175                            _ => "peer-rejected-carrier",
1176                        };
1177                        let should_retry = attempt.role == "initiator"
1178                            && attempt.retry_count == 0
1179                            && failure_code == "carrier-base-generation-stale";
1180                        let remote_endpoint_id = attempt.remote_endpoint_id.to_string();
1181                        let remote_supports_external = self
1182                            .wasm_moq_remote_external
1183                            .borrow()
1184                            .get(&connection_id)
1185                            .copied()
1186                            .unwrap_or(false);
1187                        self.fail_wasm_moq_carrier_attempt(attempt, failure_code, false)
1188                            .await;
1189                        if should_retry {
1190                            // Retry once on the current admitted base. The MoQ
1191                            // relay and admitted peer control stream are the only
1192                            // resources touched; gateway coordination is not.
1193                            gloo_timers::future::sleep(std::time::Duration::from_millis(500)).await;
1194                            let _ = self
1195                                .begin_iroh_moq_carrier_attempt(
1196                                    connection_id,
1197                                    remote_endpoint_id,
1198                                    remote_supports_external,
1199                                    1,
1200                                )
1201                                .await;
1202                        }
1203                    }
1204                }
1205            }
1206            Ok(true)
1207        }
1208    }
1209
1210    #[wasm_bindgen]
1211    impl WasmClient {
1212        /// OpenRTC 2.0 WASM transport constructor. It accepts only the public
1213        /// API key, derives the app identity locally, and never initializes a
1214        /// Firebase project or performs network work.
1215        #[wasm_bindgen(js_name = newV2)]
1216        pub fn new_v2(api_key: String) -> Result<WasmClient, JsValue> {
1217            let api_key = crate::validate_v2_public_api_key(&api_key)
1218                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1219            let app_tag = crate::app_tag_from_api_key(api_key);
1220            let identity_credential: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
1221            let credential_state = identity_credential.clone();
1222            let identity_credential_provider = Box::new(move || -> Option<String> {
1223                credential_state.lock().ok().and_then(|guard| guard.clone())
1224            });
1225
1226            Ok(Self {
1227                inner: Arc::new(Client::new_provider_neutral(
1228                    app_tag,
1229                    identity_credential_provider,
1230                )),
1231                identity_credential,
1232                last_auth_log: Arc::new(Mutex::new(None)),
1233                #[cfg(feature = "iroh-protocols-wasm")]
1234                persistent_protocols: Rc::new(tokio::sync::Mutex::new(None)),
1235                #[cfg(feature = "iroh-transport-webrtc")]
1236                wasm_webrtc_carrier_sessions: Rc::new(RefCell::new(HashMap::new())),
1237                #[cfg(feature = "iroh-transport-webrtc")]
1238                wasm_webrtc_carrier_attempts: Rc::new(RefCell::new(HashMap::new())),
1239                #[cfg(feature = "iroh-transport-webrtc")]
1240                wasm_webrtc_remote_external: Rc::new(RefCell::new(HashMap::new())),
1241                #[cfg(any(feature = "iroh-transport-webrtc", feature = "iroh-transport-moq"))]
1242                wasm_carrier_action_handler: Rc::new(RefCell::new(None)),
1243                #[cfg(feature = "iroh-transport-moq")]
1244                wasm_moq_carrier_sessions: Rc::new(RefCell::new(HashMap::new())),
1245                #[cfg(feature = "iroh-transport-moq")]
1246                wasm_moq_carrier_attempts: Rc::new(RefCell::new(HashMap::new())),
1247                #[cfg(feature = "iroh-transport-moq")]
1248                wasm_moq_remote_external: Rc::new(RefCell::new(HashMap::new())),
1249                #[cfg(all(feature = "iroh-transport-webrtc", feature = "iroh-transport-moq"))]
1250                wasm_remote_carrier_capabilities: Rc::new(RefCell::new(HashMap::new())),
1251            })
1252        }
1253
1254        /// Install the browser mechanism callback for Rust-owned carrier
1255        /// actions. The callback must enqueue actions in order; it must not
1256        /// choose attempts, retry, or promote a transport itself.
1257        #[cfg(any(feature = "iroh-transport-webrtc", feature = "iroh-transport-moq"))]
1258        #[wasm_bindgen(js_name = __setIrohCarrierActionHandler)]
1259        pub fn set_iroh_carrier_action_handler(&self, handler: Option<js_sys::Function>) {
1260            *self.wasm_carrier_action_handler.borrow_mut() = handler;
1261        }
1262
1263        #[cfg(feature = "iroh-transport-webrtc")]
1264        #[wasm_bindgen(js_name = __configureIrohWebRtcCarrier)]
1265        pub async fn configure_iroh_webrtc_carrier(
1266            &self,
1267            enabled: bool,
1268            implementation: Option<String>,
1269            privacy_mode: bool,
1270        ) -> Result<(), JsValue> {
1271            let implementation = if !enabled {
1272                None
1273            } else {
1274                Some(match implementation.as_deref().unwrap_or("iroh-carrier") {
1275                    "external" => crate::client::TransportImplementation::External,
1276                    "iroh" | "iroh-carrier" => crate::client::TransportImplementation::IrohCarrier,
1277                    "auto" => crate::client::TransportImplementation::Auto,
1278                    _ => {
1279                        return Err(JsValue::from_str(
1280                            "WebRTC implementation must be external, iroh-carrier, or auto",
1281                        ))
1282                    }
1283                })
1284            };
1285            self.inner
1286                .configure_wasm_webrtc_implementation(implementation, privacy_mode)
1287                .await;
1288            Ok(())
1289        }
1290
1291        /// Apply privacy, optimization, and exact-route ordering before any
1292        /// browser carrier attempt. The Rust peer actor remains the selector.
1293        #[cfg(any(feature = "iroh-transport-webrtc", feature = "iroh-transport-moq"))]
1294        #[wasm_bindgen(js_name = __configureIrohRoutePolicy)]
1295        pub async fn configure_iroh_route_policy(
1296            &self,
1297            relay_only: bool,
1298            optimize_for: Option<String>,
1299            route_priority: JsValue,
1300        ) -> Result<(), JsValue> {
1301            let optimize_for = match optimize_for.as_deref().unwrap_or("balanced") {
1302                "balanced" => crate::route_policy::TransportOptimization::Balanced,
1303                "lowest-latency" => crate::route_policy::TransportOptimization::LowestLatency,
1304                _ => {
1305                    return Err(JsValue::from_str(
1306                        "transport optimization must be balanced or lowest-latency",
1307                    ))
1308                }
1309            };
1310            let route_priority = if route_priority.is_null() || route_priority.is_undefined() {
1311                Vec::new()
1312            } else {
1313                serde_wasm_bindgen::from_value(route_priority)
1314                    .map_err(|error| JsValue::from_str(&error.to_string()))?
1315            };
1316            self.inner
1317                .configure_wasm_route_policy(relay_only, optimize_for, route_priority)
1318                .await;
1319            Ok(())
1320        }
1321
1322        /// Start at most one mutually supported carrier according to the
1323        /// Rust-owned exact-route policy.
1324        #[cfg(all(feature = "iroh-transport-webrtc", feature = "iroh-transport-moq"))]
1325        #[wasm_bindgen(js_name = __beginPreferredIrohCarrier)]
1326        pub async fn begin_preferred_iroh_carrier(
1327            &self,
1328            connection_id: String,
1329            remote_endpoint_id: String,
1330            remote_supports_webrtc: bool,
1331            remote_supports_webrtc_external: bool,
1332            remote_supports_moq: bool,
1333            remote_supports_moq_external: bool,
1334        ) -> Result<bool, JsValue> {
1335            let capabilities = WasmRemoteCarrierCapabilities {
1336                webrtc: remote_supports_webrtc,
1337                webrtc_external: remote_supports_webrtc_external,
1338                moq: remote_supports_moq,
1339                moq_external: remote_supports_moq_external,
1340            };
1341            self.wasm_remote_carrier_capabilities
1342                .borrow_mut()
1343                .insert(connection_id.clone(), capabilities);
1344            let ranked = self
1345                .inner
1346                .ranked_wasm_iroh_carriers(remote_supports_webrtc, remote_supports_moq)
1347                .await;
1348            let has_next = ranked.len() > 1;
1349            match ranked.first() {
1350                Some(crate::route_policy::KnownRoute::IrohWebRtc) => {
1351                    self.begin_iroh_webrtc_carrier(
1352                        connection_id,
1353                        remote_endpoint_id,
1354                        remote_supports_webrtc_external && !has_next,
1355                    )
1356                    .await
1357                }
1358                Some(crate::route_policy::KnownRoute::IrohMoq) => {
1359                    self.begin_iroh_moq_carrier(
1360                        connection_id,
1361                        remote_endpoint_id,
1362                        remote_supports_moq_external && !has_next,
1363                    )
1364                    .await
1365                }
1366                _ => Ok(false),
1367            }
1368        }
1369
1370        /// Advance to the next configured carrier after Rust has retired a
1371        /// terminal attempt. Browser JavaScript only executes this decision.
1372        #[cfg(all(feature = "iroh-transport-webrtc", feature = "iroh-transport-moq"))]
1373        #[wasm_bindgen(js_name = __advancePreferredIrohCarrier)]
1374        pub async fn advance_preferred_iroh_carrier(
1375            &self,
1376            connection_id: String,
1377            remote_endpoint_id: String,
1378            failed_route: String,
1379        ) -> Result<bool, JsValue> {
1380            let Some(capabilities) = self
1381                .wasm_remote_carrier_capabilities
1382                .borrow()
1383                .get(&connection_id)
1384                .copied()
1385            else {
1386                return Ok(false);
1387            };
1388            let failed = crate::route_policy::normalize_route(&failed_route)
1389                .ok_or_else(|| JsValue::from_str("unknown failed carrier route"))?;
1390            let ranked = self
1391                .inner
1392                .ranked_wasm_iroh_carriers(capabilities.webrtc, capabilities.moq)
1393                .await;
1394            let Some(next_index) = ranked
1395                .iter()
1396                .position(|route| *route == failed)
1397                .map(|index| index + 1)
1398                .filter(|index| *index < ranked.len())
1399            else {
1400                return Ok(false);
1401            };
1402            let next = ranked[next_index];
1403            let allow_external = next_index + 1 == ranked.len();
1404            match next {
1405                crate::route_policy::KnownRoute::IrohWebRtc => {
1406                    self.begin_iroh_webrtc_carrier(
1407                        connection_id,
1408                        remote_endpoint_id,
1409                        capabilities.webrtc_external && allow_external,
1410                    )
1411                    .await
1412                }
1413                crate::route_policy::KnownRoute::IrohMoq => {
1414                    self.begin_iroh_moq_carrier(
1415                        connection_id,
1416                        remote_endpoint_id,
1417                        capabilities.moq_external && allow_external,
1418                    )
1419                    .await
1420                }
1421                _ => Ok(false),
1422            }
1423        }
1424
1425        #[cfg(feature = "iroh-transport-moq")]
1426        #[wasm_bindgen(js_name = __configureIrohMoqCarrier)]
1427        pub async fn configure_iroh_moq_carrier(
1428            &self,
1429            enabled: bool,
1430            implementation: Option<String>,
1431            relay_url: Option<String>,
1432        ) -> Result<(), JsValue> {
1433            let implementation = if !enabled {
1434                None
1435            } else {
1436                Some(match implementation.as_deref().unwrap_or("external") {
1437                    "external" => crate::client::TransportImplementation::External,
1438                    "iroh" | "iroh-carrier" => crate::client::TransportImplementation::IrohCarrier,
1439                    "auto" => crate::client::TransportImplementation::Auto,
1440                    _ => {
1441                        return Err(JsValue::from_str(
1442                            "MoQ implementation must be external, iroh-carrier, or auto",
1443                        ))
1444                    }
1445                })
1446            };
1447            let relay_url = relay_url.map(|value| value.trim().to_string());
1448            if implementation.is_some()
1449                && implementation.is_some_and(|value| value.allows_iroh_carrier())
1450                && relay_url.as_deref().unwrap_or_default().is_empty()
1451            {
1452                return Err(JsValue::from_str(
1453                    "MoQ Iroh carrier requires an explicit relay URL",
1454                ));
1455            }
1456            self.inner
1457                .configure_wasm_moq_implementation(implementation, relay_url)
1458                .await;
1459            Ok(())
1460        }
1461
1462        /// Start a deterministic Draft 14 MoQ object-datagram carrier attempt
1463        /// after the admitted handshake reports peer support.
1464        #[cfg(feature = "iroh-transport-moq")]
1465        #[wasm_bindgen(js_name = __beginIrohMoqCarrier)]
1466        pub async fn begin_iroh_moq_carrier(
1467            &self,
1468            connection_id: String,
1469            remote_endpoint_id: String,
1470            remote_supports_external: bool,
1471        ) -> Result<bool, JsValue> {
1472            self.begin_iroh_moq_carrier_attempt(
1473                connection_id,
1474                remote_endpoint_id,
1475                remote_supports_external,
1476                0,
1477            )
1478            .await
1479        }
1480
1481        async fn begin_iroh_moq_carrier_attempt(
1482            &self,
1483            connection_id: String,
1484            remote_endpoint_id: String,
1485            remote_supports_external: bool,
1486            retry_count: u8,
1487        ) -> Result<bool, JsValue> {
1488            self.wasm_moq_remote_external
1489                .borrow_mut()
1490                .insert(connection_id.clone(), remote_supports_external);
1491            if !self.inner.is_iroh_moq_carrier_enabled().await {
1492                return Ok(false);
1493            }
1494            let local_endpoint_id = self
1495                .inner
1496                .current_node_id()
1497                .await
1498                .ok_or_else(|| JsValue::from_str("local Iroh endpoint id is unavailable"))?;
1499            if local_endpoint_id.as_str() <= remote_endpoint_id.as_str()
1500                || !matches!(
1501                    self.inner.iroh_path_kind(&remote_endpoint_id).await,
1502                    crate::client::IrohPathKind::Relay | crate::client::IrohPathKind::Unknown
1503                )
1504            {
1505                return Ok(false);
1506            }
1507            let endpoint_id = remote_endpoint_id
1508                .parse::<iroh::EndpointId>()
1509                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1510            self.inner
1511                .get_connection(endpoint_id)
1512                .await
1513                .ok_or_else(|| JsValue::from_str("MoQ carrier base is unavailable"))?;
1514            let generation = self
1515                .inner
1516                .current_wasm_peer_data_generation(&connection_id, None)
1517                .await
1518                .ok_or_else(|| JsValue::from_str("MoQ carrier generation is unavailable"))?;
1519            let bootstrap = crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::request(
1520                crate::iroh_carrier_bootstrap::CarrierBootstrapKind::MoqDraft14,
1521                crate::iroh_carrier_bootstrap::CarrierGenerationFence {
1522                    transport_stable_id: generation.transport_stable_id,
1523                    transport_generation: generation.transport_generation,
1524                    route_generation: generation.route_generation,
1525                },
1526                retry_count.saturating_add(1),
1527            )
1528            .map_err(|error| JsValue::from_str(&error.to_string()))?;
1529            let kind = crate::client::IrohPathKind::IrohMoq;
1530            if !self
1531                .inner
1532                .reserve_wasm_carrier_upgrade(&connection_id, kind, &bootstrap.upgrade_id)
1533                .await
1534            {
1535                return Ok(false);
1536            }
1537            let fallback_allowed =
1538                self.inner.wasm_moq_external_fallback_allowed().await && remote_supports_external;
1539            let previous = self.wasm_moq_carrier_attempts.borrow_mut().insert(
1540                connection_id.clone(),
1541                WasmMoqCarrierAttempt {
1542                    connection_id: connection_id.clone(),
1543                    remote_endpoint_id: endpoint_id,
1544                    bootstrap: bootstrap.clone(),
1545                    generation,
1546                    role: "initiator",
1547                    retry_count,
1548                    external_fallback_allowed: fallback_allowed,
1549                    inbound_authorization_expires_at_ms: None,
1550                },
1551            );
1552            if let Some(previous) = previous {
1553                self.wasm_moq_carrier_sessions
1554                    .borrow_mut()
1555                    .remove(&previous.bootstrap.upgrade_id);
1556            }
1557            let (publish_namespace, subscribe_namespace, track_name) = wasm_moq_carrier_namespaces(
1558                &local_endpoint_id,
1559                &remote_endpoint_id,
1560                &bootstrap.carrier_session_id,
1561            );
1562            emit_wasm_carrier_action(
1563                &self.wasm_carrier_action_handler,
1564                serde_json::json!({
1565                    "type": "prepare-moq",
1566                    "connectionId": connection_id,
1567                    "remoteEndpointId": remote_endpoint_id,
1568                    "role": "initiator",
1569                    "upgradeId": bootstrap.upgrade_id,
1570                    "carrierSessionId": bootstrap.carrier_session_id,
1571                    "transportGeneration": generation.transport_generation.saturating_add(1),
1572                    "publishNamespace": publish_namespace,
1573                    "subscribeNamespace": subscribe_namespace,
1574                    "trackName": track_name,
1575                }),
1576            );
1577            self.schedule_wasm_moq_carrier_watchdog(bootstrap);
1578            Ok(true)
1579        }
1580
1581        #[cfg(feature = "iroh-transport-moq")]
1582        #[wasm_bindgen(js_name = __irohMoqCarrierPrepared)]
1583        pub async fn iroh_moq_carrier_prepared(
1584            &self,
1585            connection_id: String,
1586            upgrade_id: String,
1587        ) -> Result<(), JsValue> {
1588            let attempt = self
1589                .wasm_moq_carrier_attempts
1590                .borrow()
1591                .get(&connection_id)
1592                .filter(|attempt| attempt.bootstrap.upgrade_id == upgrade_id)
1593                .cloned()
1594                .ok_or_else(|| JsValue::from_str("MoQ carrier attempt is stale"))?;
1595            if !self
1596                .inner
1597                .wasm_carrier_upgrade_is_current(
1598                    &connection_id,
1599                    crate::client::IrohPathKind::IrohMoq,
1600                    &upgrade_id,
1601                )
1602                .await
1603            {
1604                return Err(JsValue::from_str("MoQ carrier attempt was retired"));
1605            }
1606            if attempt.role == "initiator" {
1607                emit_wasm_carrier_action(
1608                    &self.wasm_carrier_action_handler,
1609                    serde_json::json!({
1610                        "type": "send-control",
1611                        "connectionId": connection_id,
1612                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
1613                        "envelope": attempt.bootstrap,
1614                    }),
1615                );
1616            } else {
1617                let ready = crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::ready_from(
1618                    &attempt.bootstrap,
1619                )
1620                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1621                emit_wasm_carrier_action(
1622                    &self.wasm_carrier_action_handler,
1623                    serde_json::json!({
1624                        "type": "send-control",
1625                        "connectionId": connection_id,
1626                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
1627                        "envelope": ready,
1628                    }),
1629                );
1630                emit_wasm_carrier_action(
1631                    &self.wasm_carrier_action_handler,
1632                    serde_json::json!({
1633                        "type": "activate-moq",
1634                        "connectionId": attempt.connection_id,
1635                        "upgradeId": upgrade_id,
1636                    }),
1637                );
1638            }
1639            Ok(())
1640        }
1641
1642        #[cfg(feature = "iroh-transport-moq")]
1643        #[wasm_bindgen(js_name = __irohMoqCarrierFailed)]
1644        pub async fn iroh_moq_carrier_failed(
1645            &self,
1646            connection_id: String,
1647            upgrade_id: String,
1648            failure_code: String,
1649        ) {
1650            let attempt = self
1651                .wasm_moq_carrier_attempts
1652                .borrow()
1653                .get(&connection_id)
1654                .filter(|attempt| attempt.bootstrap.upgrade_id == upgrade_id)
1655                .cloned();
1656            if let Some(attempt) = attempt {
1657                let failure_code = match failure_code.as_str() {
1658                    "relay-failed" => "relay-failed",
1659                    "draft14-datagram-unavailable" => "draft14-datagram-unavailable",
1660                    "carrier-backpressure" => "carrier-backpressure",
1661                    _ => "browser-adapter-failed",
1662                };
1663                self.fail_wasm_moq_carrier_attempt(attempt, failure_code, true)
1664                    .await;
1665            }
1666        }
1667
1668        #[cfg(feature = "iroh-transport-moq")]
1669        #[wasm_bindgen(js_name = __retireIrohMoqCarrier)]
1670        pub async fn retire_iroh_moq_carrier(
1671            &self,
1672            connection_id: String,
1673            terminal_reason: Option<String>,
1674        ) {
1675            self.retire_iroh_moq_carrier_with_reason(connection_id, terminal_reason.as_deref())
1676                .await;
1677        }
1678
1679        #[cfg(feature = "iroh-transport-moq")]
1680        async fn retire_iroh_moq_carrier_with_reason(
1681            &self,
1682            connection_id: String,
1683            terminal_reason: Option<&str>,
1684        ) {
1685            let attempt = self
1686                .wasm_moq_carrier_attempts
1687                .borrow_mut()
1688                .remove(&connection_id);
1689            if let Some(attempt) = attempt {
1690                self.inner
1691                    .retire_wasm_carrier_upgrade(
1692                        &connection_id,
1693                        crate::client::IrohPathKind::IrohMoq,
1694                        &attempt.bootstrap.upgrade_id,
1695                    )
1696                    .await;
1697                let carrier = self
1698                    .wasm_moq_carrier_sessions
1699                    .borrow_mut()
1700                    .remove(&attempt.bootstrap.upgrade_id);
1701                if let (Some(reason), Some(session)) = (terminal_reason, carrier.as_ref()) {
1702                    let _ = session.send_terminal(reason).await;
1703                }
1704                emit_wasm_carrier_action(
1705                    &self.wasm_carrier_action_handler,
1706                    serde_json::json!({
1707                        "type": "retire-moq",
1708                        "connectionId": connection_id,
1709                        "upgradeId": attempt.bootstrap.upgrade_id,
1710                        "failureCode": "logical-connection-retired",
1711                    }),
1712                );
1713            }
1714            self.wasm_moq_remote_external
1715                .borrow_mut()
1716                .remove(&connection_id);
1717            #[cfg(feature = "iroh-transport-webrtc")]
1718            self.wasm_remote_carrier_capabilities
1719                .borrow_mut()
1720                .remove(&connection_id);
1721        }
1722
1723        /// Start the deterministic browser-side WebRTC carrier attempt after
1724        /// an admitted handshake reports internal-carrier support.
1725        #[cfg(feature = "iroh-transport-webrtc")]
1726        #[wasm_bindgen(js_name = __beginIrohWebRtcCarrier)]
1727        pub async fn begin_iroh_webrtc_carrier(
1728            &self,
1729            connection_id: String,
1730            remote_endpoint_id: String,
1731            remote_supports_external: bool,
1732        ) -> Result<bool, JsValue> {
1733            self.begin_iroh_webrtc_carrier_attempt(
1734                connection_id,
1735                remote_endpoint_id,
1736                remote_supports_external,
1737                0,
1738            )
1739            .await
1740        }
1741
1742        async fn begin_iroh_webrtc_carrier_attempt(
1743            &self,
1744            connection_id: String,
1745            remote_endpoint_id: String,
1746            remote_supports_external: bool,
1747            retry_count: u8,
1748        ) -> Result<bool, JsValue> {
1749            self.wasm_webrtc_remote_external
1750                .borrow_mut()
1751                .insert(connection_id.clone(), remote_supports_external);
1752            if !self.inner.is_iroh_webrtc_carrier_enabled().await {
1753                return Ok(false);
1754            }
1755            let local_endpoint_id = self
1756                .inner
1757                .current_node_id()
1758                .await
1759                .ok_or_else(|| JsValue::from_str("local Iroh endpoint id is unavailable"))?;
1760            if local_endpoint_id.as_str() <= remote_endpoint_id.as_str()
1761                || !matches!(
1762                    self.inner.iroh_path_kind(&remote_endpoint_id).await,
1763                    crate::client::IrohPathKind::Relay | crate::client::IrohPathKind::Unknown
1764                )
1765            {
1766                return Ok(false);
1767            }
1768            let endpoint_id = remote_endpoint_id
1769                .parse::<iroh::EndpointId>()
1770                .map_err(|error| JsValue::from_str(&error.to_string()))?;
1771            self.inner
1772                .get_connection(endpoint_id)
1773                .await
1774                .ok_or_else(|| JsValue::from_str("WebRTC carrier base is unavailable"))?;
1775            let generation = self
1776                .inner
1777                .current_wasm_peer_data_generation(&connection_id, None)
1778                .await
1779                .ok_or_else(|| JsValue::from_str("WebRTC carrier generation is unavailable"))?;
1780            let bootstrap = crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::request(
1781                crate::iroh_carrier_bootstrap::CarrierBootstrapKind::WebRtc,
1782                crate::iroh_carrier_bootstrap::CarrierGenerationFence {
1783                    transport_stable_id: generation.transport_stable_id,
1784                    transport_generation: generation.transport_generation,
1785                    route_generation: generation.route_generation,
1786                },
1787                1,
1788            )
1789            .map_err(|error| JsValue::from_str(&error.to_string()))?;
1790            let kind = crate::client::IrohPathKind::IrohWebRtc;
1791            if !self
1792                .inner
1793                .reserve_wasm_carrier_upgrade(&connection_id, kind, &bootstrap.upgrade_id)
1794                .await
1795            {
1796                return Ok(false);
1797            }
1798            let fallback_allowed = self.inner.wasm_webrtc_external_fallback_allowed().await
1799                && remote_supports_external;
1800            let previous = self.wasm_webrtc_carrier_attempts.borrow_mut().insert(
1801                connection_id.clone(),
1802                WasmWebRtcCarrierAttempt {
1803                    connection_id: connection_id.clone(),
1804                    remote_endpoint_id: endpoint_id,
1805                    bootstrap: bootstrap.clone(),
1806                    generation,
1807                    role: "initiator",
1808                    offer_started: false,
1809                    remote_ready: false,
1810                    completion_started: false,
1811                    retry_count,
1812                    external_fallback_allowed: fallback_allowed,
1813                    inbound_authorization_expires_at_ms: None,
1814                },
1815            );
1816            if let Some(previous) = previous {
1817                self.wasm_webrtc_carrier_sessions
1818                    .borrow_mut()
1819                    .remove(&previous.bootstrap.upgrade_id);
1820            }
1821            emit_wasm_carrier_action(
1822                &self.wasm_carrier_action_handler,
1823                serde_json::json!({
1824                    "type": "prepare-webrtc",
1825                    "connectionId": connection_id,
1826                    "remoteEndpointId": remote_endpoint_id,
1827                    "role": "initiator",
1828                    "upgradeId": bootstrap.upgrade_id,
1829                    "carrierSessionId": bootstrap.carrier_session_id,
1830                    "transportGeneration": generation.transport_generation.saturating_add(1),
1831                }),
1832            );
1833            self.schedule_wasm_webrtc_carrier_watchdog(bootstrap);
1834            Ok(true)
1835        }
1836
1837        /// Notify Rust that the main-thread peer connection and packet
1838        /// DataChannel exist, but negotiation has not started yet.
1839        #[cfg(feature = "iroh-transport-webrtc")]
1840        #[wasm_bindgen(js_name = __irohWebRtcCarrierPrepared)]
1841        pub async fn iroh_webrtc_carrier_prepared(
1842            &self,
1843            connection_id: String,
1844            upgrade_id: String,
1845        ) -> Result<(), JsValue> {
1846            let attempt = self
1847                .wasm_webrtc_carrier_attempts
1848                .borrow()
1849                .get(&connection_id)
1850                .filter(|attempt| attempt.bootstrap.upgrade_id == upgrade_id)
1851                .cloned()
1852                .ok_or_else(|| JsValue::from_str("WebRTC carrier attempt is stale"))?;
1853            let kind = crate::client::IrohPathKind::IrohWebRtc;
1854            if !self
1855                .inner
1856                .wasm_carrier_upgrade_is_current(&connection_id, kind, &upgrade_id)
1857                .await
1858            {
1859                return Err(JsValue::from_str("WebRTC carrier attempt was retired"));
1860            }
1861            if attempt.role == "initiator" {
1862                emit_wasm_carrier_action(
1863                    &self.wasm_carrier_action_handler,
1864                    serde_json::json!({
1865                        "type": "send-control",
1866                        "connectionId": connection_id,
1867                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
1868                        "envelope": attempt.bootstrap,
1869                    }),
1870                );
1871            } else {
1872                // The initiator must not send its offer until this responder
1873                // has installed the matching attempt. This explicit ready
1874                // signal also makes browser-to-browser carrier negotiation use
1875                // the same ordering contract as the native responder.
1876                emit_wasm_carrier_action(
1877                    &self.wasm_carrier_action_handler,
1878                    serde_json::json!({
1879                        "type": "send-control",
1880                        "connectionId": attempt.connection_id,
1881                        "remoteEndpointId": attempt.remote_endpoint_id.to_string(),
1882                        "envelope": {
1883                            "type": "#pluto-signal",
1884                            "content": {
1885                                "transport": "iroh-webrtc",
1886                                "type": "renegotiate",
1887                                "negotiationId": upgrade_id,
1888                            }
1889                        },
1890                    }),
1891                );
1892            }
1893            Ok(())
1894        }
1895
1896        #[cfg(feature = "iroh-transport-webrtc")]
1897        #[wasm_bindgen(js_name = __irohWebRtcCarrierFailed)]
1898        pub async fn iroh_webrtc_carrier_failed(
1899            &self,
1900            connection_id: String,
1901            upgrade_id: String,
1902            failure_code: String,
1903        ) {
1904            let attempt = self
1905                .wasm_webrtc_carrier_attempts
1906                .borrow()
1907                .get(&connection_id)
1908                .filter(|attempt| attempt.bootstrap.upgrade_id == upgrade_id)
1909                .cloned();
1910            if let Some(attempt) = attempt {
1911                let failure_code = match failure_code.as_str() {
1912                    "data-channel-failed" => "data-channel-failed",
1913                    "ice-failed" => "ice-failed",
1914                    "signaling-failed" => "signaling-failed",
1915                    "carrier-backpressure" => "carrier-backpressure",
1916                    "carrier-base-generation-stale" => "carrier-base-generation-stale",
1917                    _ => "browser-adapter-failed",
1918                };
1919                let should_retry = attempt.role == "initiator"
1920                    && attempt.retry_count == 0
1921                    && matches!(
1922                        failure_code,
1923                        "data-channel-failed"
1924                            | "ice-failed"
1925                            | "signaling-failed"
1926                            | "carrier-base-generation-stale"
1927                    );
1928                let remote_endpoint_id = attempt.remote_endpoint_id.to_string();
1929                let remote_supports_external = self
1930                    .wasm_webrtc_remote_external
1931                    .borrow()
1932                    .get(&connection_id)
1933                    .copied()
1934                    .unwrap_or(false);
1935                self.fail_wasm_webrtc_carrier_attempt(attempt, failure_code, true)
1936                    .await;
1937                if should_retry {
1938                    // Browser ICE can report one terminal transition while
1939                    // the network monitor catches up after resume. Retry once
1940                    // on the current admitted base; this sends only peer
1941                    // control frames and cannot touch gateway coordination.
1942                    gloo_timers::future::sleep(std::time::Duration::from_millis(500)).await;
1943                    let _ = self
1944                        .begin_iroh_webrtc_carrier_attempt(
1945                            connection_id,
1946                            remote_endpoint_id,
1947                            remote_supports_external,
1948                            1,
1949                        )
1950                        .await;
1951                }
1952            }
1953        }
1954
1955        #[cfg(feature = "iroh-transport-webrtc")]
1956        #[wasm_bindgen(js_name = __retireIrohWebRtcCarrier)]
1957        pub async fn retire_iroh_webrtc_carrier(
1958            &self,
1959            connection_id: String,
1960            terminal_reason: Option<String>,
1961        ) {
1962            let attempt = self
1963                .wasm_webrtc_carrier_attempts
1964                .borrow_mut()
1965                .remove(&connection_id);
1966            if let Some(attempt) = attempt {
1967                self.inner
1968                    .retire_wasm_carrier_upgrade(
1969                        &connection_id,
1970                        crate::client::IrohPathKind::IrohWebRtc,
1971                        &attempt.bootstrap.upgrade_id,
1972                    )
1973                    .await;
1974                let carrier = self
1975                    .wasm_webrtc_carrier_sessions
1976                    .borrow_mut()
1977                    .remove(&attempt.bootstrap.upgrade_id);
1978                if let (Some(reason), Some(session)) =
1979                    (terminal_reason.as_deref(), carrier.as_ref())
1980                {
1981                    let _ = session.send_terminal(reason).await;
1982                }
1983                emit_wasm_carrier_action(
1984                    &self.wasm_carrier_action_handler,
1985                    serde_json::json!({
1986                        "type": "retire-webrtc",
1987                        "connectionId": connection_id,
1988                        "upgradeId": attempt.bootstrap.upgrade_id,
1989                        "failureCode": "logical-connection-retired",
1990                    }),
1991                );
1992            }
1993            self.wasm_webrtc_remote_external
1994                .borrow_mut()
1995                .remove(&connection_id);
1996            #[cfg(feature = "iroh-transport-moq")]
1997            self.wasm_remote_carrier_capabilities
1998                .borrow_mut()
1999                .remove(&connection_id);
2000        }
2001
2002        /// Ingest one admitted carrier bootstrap or signaling frame. Returning
2003        /// `true` means the frame belongs exclusively to the internal carrier.
2004        #[cfg(feature = "iroh-transport-webrtc")]
2005        #[wasm_bindgen(js_name = __handleIrohCarrierControl)]
2006        pub async fn handle_iroh_carrier_control(
2007            &self,
2008            connection_id: String,
2009            remote_endpoint_id: String,
2010            frame: JsValue,
2011        ) -> Result<bool, JsValue> {
2012            let frame: serde_json::Value = serde_wasm_bindgen::from_value(frame)
2013                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2014            if frame.get("type").and_then(serde_json::Value::as_str) == Some("#pluto-signal")
2015                && frame
2016                    .get("content")
2017                    .and_then(|content| content.get("transport"))
2018                    .and_then(serde_json::Value::as_str)
2019                    == Some("iroh-webrtc")
2020            {
2021                let negotiation_id = frame
2022                    .get("content")
2023                    .and_then(|content| content.get("negotiationId"))
2024                    .and_then(serde_json::Value::as_str)
2025                    .unwrap_or_default();
2026                let current = self
2027                    .wasm_webrtc_carrier_attempts
2028                    .borrow()
2029                    .get(&connection_id)
2030                    .is_some_and(|attempt| {
2031                        attempt.bootstrap.upgrade_id == negotiation_id
2032                            && attempt.remote_endpoint_id.to_string() == remote_endpoint_id
2033                    });
2034                if current {
2035                    let signal_type = frame
2036                        .get("content")
2037                        .and_then(|content| content.get("type"))
2038                        .and_then(serde_json::Value::as_str);
2039                    let start_offer = if signal_type == Some("renegotiate") {
2040                        self.wasm_webrtc_carrier_attempts
2041                            .borrow_mut()
2042                            .get_mut(&connection_id)
2043                            .filter(|attempt| {
2044                                attempt.bootstrap.upgrade_id == negotiation_id
2045                                    && attempt.role == "initiator"
2046                                    && !attempt.offer_started
2047                            })
2048                            .map(|attempt| {
2049                                attempt.offer_started = true;
2050                            })
2051                            .is_some()
2052                    } else {
2053                        false
2054                    };
2055                    if start_offer {
2056                        emit_wasm_carrier_action(
2057                            &self.wasm_carrier_action_handler,
2058                            serde_json::json!({
2059                                "type": "start-webrtc",
2060                                "connectionId": connection_id,
2061                                "upgradeId": negotiation_id,
2062                            }),
2063                        );
2064                        return Ok(true);
2065                    }
2066                    emit_wasm_carrier_action(
2067                        &self.wasm_carrier_action_handler,
2068                        serde_json::json!({
2069                            "type": "apply-webrtc-signal",
2070                            "connectionId": connection_id,
2071                            "upgradeId": negotiation_id,
2072                            "signal": frame,
2073                        }),
2074                    );
2075                }
2076                return Ok(true);
2077            }
2078
2079            let Some(bootstrap) =
2080                crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::from_json(&frame)
2081            else {
2082                return Ok(false);
2083            };
2084            #[cfg(feature = "iroh-transport-moq")]
2085            if bootstrap.carrier == crate::iroh_carrier_bootstrap::CarrierBootstrapKind::MoqDraft14
2086            {
2087                return self
2088                    .handle_wasm_moq_bootstrap(connection_id, remote_endpoint_id, bootstrap)
2089                    .await;
2090            }
2091            if bootstrap.carrier != crate::iroh_carrier_bootstrap::CarrierBootstrapKind::WebRtc {
2092                return Ok(false);
2093            }
2094            let endpoint_id = remote_endpoint_id
2095                .parse::<iroh::EndpointId>()
2096                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2097            let kind = crate::client::IrohPathKind::IrohWebRtc;
2098            match bootstrap.action {
2099                crate::iroh_carrier_bootstrap::CarrierBootstrapAction::Request => {
2100                    let local_endpoint_id = self
2101                        .inner
2102                        .current_node_id()
2103                        .await
2104                        .ok_or_else(|| JsValue::from_str("local endpoint id is unavailable"))?;
2105                    if local_endpoint_id.as_str() >= remote_endpoint_id.as_str()
2106                        || !self.inner.is_iroh_webrtc_carrier_enabled().await
2107                    {
2108                        return Ok(true);
2109                    }
2110                    if !matches!(
2111                        self.inner.iroh_path_kind(&remote_endpoint_id).await,
2112                        crate::client::IrohPathKind::Relay | crate::client::IrohPathKind::Unknown
2113                    ) {
2114                        return Ok(true);
2115                    }
2116                    let base_connection = self
2117                        .inner
2118                        .get_connection(endpoint_id)
2119                        .await
2120                        .ok_or_else(|| JsValue::from_str("WebRTC carrier base is unavailable"))?;
2121                    let generation = self
2122                        .inner
2123                        .current_wasm_peer_data_generation(&connection_id, None)
2124                        .await
2125                        .ok_or_else(|| {
2126                            JsValue::from_str("WebRTC carrier generation is unavailable")
2127                        })?;
2128                    if crate::transport_generation::for_connection(&base_connection)
2129                        != generation.transport_stable_id
2130                    {
2131                        // The physical accept owner has a newer base than the
2132                        // logical record. Reject before starting ICE so the
2133                        // initiator's one stale-only retry targets the admitted
2134                        // generation instead of creating an asymmetric carrier.
2135                        let failed =
2136                            crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::failed_from(
2137                                &bootstrap,
2138                                "carrier-base-generation-stale",
2139                            )
2140                            .map_err(|error| JsValue::from_str(&error.to_string()))?;
2141                        emit_wasm_carrier_action(
2142                            &self.wasm_carrier_action_handler,
2143                            serde_json::json!({
2144                                "type": "send-control",
2145                                "connectionId": connection_id,
2146                                "remoteEndpointId": remote_endpoint_id,
2147                                "envelope": failed,
2148                            }),
2149                        );
2150                        return Ok(true);
2151                    }
2152                    if !self
2153                        .inner
2154                        .reserve_wasm_carrier_upgrade(&connection_id, kind, &bootstrap.upgrade_id)
2155                        .await
2156                    {
2157                        return Ok(true);
2158                    }
2159                    let node = self
2160                        .inner
2161                        .iroh_node
2162                        .read()
2163                        .await
2164                        .as_ref()
2165                        .cloned()
2166                        .ok_or_else(|| JsValue::from_str("Iroh node is unavailable"))?;
2167                    let authorization_expiry = node
2168                        .authorize_pending_inbound_replacement(
2169                            endpoint_id,
2170                            crate::iroh_carrier_kind::EXPERIMENTAL_WEBRTC_TRANSPORT_ID,
2171                            std::time::Duration::from_secs(45),
2172                        )
2173                        .await;
2174                    let fallback_allowed = self.inner.wasm_webrtc_external_fallback_allowed().await
2175                        && self
2176                            .wasm_webrtc_remote_external
2177                            .borrow()
2178                            .get(&connection_id)
2179                            .copied()
2180                            .unwrap_or(false);
2181                    let previous = self.wasm_webrtc_carrier_attempts.borrow_mut().insert(
2182                        connection_id.clone(),
2183                        WasmWebRtcCarrierAttempt {
2184                            connection_id: connection_id.clone(),
2185                            remote_endpoint_id: endpoint_id,
2186                            bootstrap: bootstrap.clone(),
2187                            generation,
2188                            role: "responder",
2189                            offer_started: false,
2190                            remote_ready: false,
2191                            completion_started: false,
2192                            retry_count: bootstrap.attempt.saturating_sub(1),
2193                            external_fallback_allowed: fallback_allowed,
2194                            inbound_authorization_expires_at_ms: Some(authorization_expiry),
2195                        },
2196                    );
2197                    if let Some(previous) = previous {
2198                        self.wasm_webrtc_carrier_sessions
2199                            .borrow_mut()
2200                            .remove(&previous.bootstrap.upgrade_id);
2201                    }
2202                    emit_wasm_carrier_action(
2203                        &self.wasm_carrier_action_handler,
2204                        serde_json::json!({
2205                            "type": "prepare-webrtc",
2206                            "connectionId": connection_id,
2207                            "remoteEndpointId": remote_endpoint_id,
2208                            "role": "responder",
2209                            "upgradeId": bootstrap.upgrade_id,
2210                            "carrierSessionId": bootstrap.carrier_session_id,
2211                            "transportGeneration": generation.transport_generation.saturating_add(1),
2212                        }),
2213                    );
2214                    self.schedule_wasm_webrtc_carrier_watchdog(bootstrap);
2215                }
2216                crate::iroh_carrier_bootstrap::CarrierBootstrapAction::Ready => {
2217                    let upgrade_id = {
2218                        let mut attempts = self.wasm_webrtc_carrier_attempts.borrow_mut();
2219                        attempts
2220                            .get_mut(&connection_id)
2221                            .filter(|attempt| bootstrap.is_response_to(&attempt.bootstrap))
2222                            .map(|attempt| {
2223                                attempt.remote_ready = true;
2224                                attempt.bootstrap.upgrade_id.clone()
2225                            })
2226                    };
2227                    let attempt = upgrade_id.as_deref().and_then(|upgrade_id| {
2228                        self.take_ready_outbound_wasm_webrtc_carrier_attempt(
2229                            &connection_id,
2230                            upgrade_id,
2231                        )
2232                    });
2233                    if let Some(attempt) = attempt {
2234                        self.spawn_outbound_wasm_webrtc_carrier_completion(attempt);
2235                    }
2236                }
2237                crate::iroh_carrier_bootstrap::CarrierBootstrapAction::Failed => {
2238                    let attempt = self
2239                        .wasm_webrtc_carrier_attempts
2240                        .borrow()
2241                        .get(&connection_id)
2242                        .filter(|attempt| bootstrap.is_response_to(&attempt.bootstrap))
2243                        .cloned();
2244                    if let Some(attempt) = attempt {
2245                        let failure_code = match bootstrap.failure_code.as_deref() {
2246                            Some("carrier-base-generation-stale") => {
2247                                "carrier-base-generation-stale"
2248                            }
2249                            _ => "peer-rejected-carrier",
2250                        };
2251                        let should_retry = attempt.role == "initiator"
2252                            && attempt.retry_count == 0
2253                            && failure_code == "carrier-base-generation-stale";
2254                        let remote_endpoint_id = attempt.remote_endpoint_id.to_string();
2255                        let remote_supports_external = self
2256                            .wasm_webrtc_remote_external
2257                            .borrow()
2258                            .get(&connection_id)
2259                            .copied()
2260                            .unwrap_or(false);
2261                        self.fail_wasm_webrtc_carrier_attempt(attempt, failure_code, false)
2262                            .await;
2263                        if should_retry {
2264                            gloo_timers::future::sleep(std::time::Duration::from_millis(500)).await;
2265                            let _ = self
2266                                .begin_iroh_webrtc_carrier_attempt(
2267                                    connection_id,
2268                                    remote_endpoint_id,
2269                                    remote_supports_external,
2270                                    1,
2271                                )
2272                                .await;
2273                        }
2274                    }
2275                }
2276            }
2277            Ok(true)
2278        }
2279
2280        #[cfg(all(feature = "iroh-transport-moq", not(feature = "iroh-transport-webrtc")))]
2281        #[wasm_bindgen(js_name = __handleIrohCarrierControl)]
2282        pub async fn handle_iroh_carrier_control_moq_only(
2283            &self,
2284            connection_id: String,
2285            remote_endpoint_id: String,
2286            frame: JsValue,
2287        ) -> Result<bool, JsValue> {
2288            let frame: serde_json::Value = serde_wasm_bindgen::from_value(frame)
2289                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2290            let Some(bootstrap) =
2291                crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::from_json(&frame)
2292            else {
2293                return Ok(false);
2294            };
2295            if bootstrap.carrier != crate::iroh_carrier_bootstrap::CarrierBootstrapKind::MoqDraft14
2296            {
2297                return Ok(false);
2298            }
2299            self.handle_wasm_moq_bootstrap(connection_id, remote_endpoint_id, bootstrap)
2300                .await
2301        }
2302
2303        /// Internal browser-adapter boundary. It attaches an already negotiated
2304        /// unreliable DataChannel to the pre-bind Iroh custom transport; it
2305        /// does not authorize, dial, select, or retry a replacement.
2306        #[cfg(feature = "iroh-transport-webrtc")]
2307        #[wasm_bindgen(js_name = __attachIrohWebRtcCarrier)]
2308        pub async fn attach_iroh_webrtc_carrier(
2309            &self,
2310            connection_id: String,
2311            remote_endpoint_id: String,
2312            channel: web_sys::RtcDataChannel,
2313            upgrade_id: String,
2314        ) -> Result<(), JsValue> {
2315            let endpoint_id = remote_endpoint_id
2316                .parse::<iroh::EndpointId>()
2317                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2318            let attempt = self
2319                .wasm_webrtc_carrier_attempts
2320                .borrow()
2321                .get(&connection_id)
2322                .filter(|attempt| {
2323                    attempt.bootstrap.upgrade_id == upgrade_id
2324                        && attempt.remote_endpoint_id == endpoint_id
2325                })
2326                .cloned()
2327                .ok_or_else(|| JsValue::from_str("WebRTC carrier attempt is stale"))?;
2328            if !self
2329                .inner
2330                .wasm_carrier_upgrade_is_current(
2331                    &connection_id,
2332                    crate::client::IrohPathKind::IrohWebRtc,
2333                    &upgrade_id,
2334                )
2335                .await
2336            {
2337                return Err(JsValue::from_str("WebRTC carrier attempt was retired"));
2338            }
2339            let packet_session = self
2340                .inner
2341                .activate_iroh_packet_carrier(
2342                    crate::iroh_carrier_kind::IrohCarrierKind::WebRtc,
2343                    endpoint_id,
2344                )
2345                .await
2346                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2347            let expected = attempt
2348                .bootstrap
2349                .frame_expectation()
2350                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2351            let application_key = self
2352                .inner
2353                .application_crypto_key_for_connection(Some(&connection_id))
2354                .ok_or_else(|| {
2355                    JsValue::from_str("WebRTC carrier requires the admitted application crypto key")
2356                })?;
2357            let terminal_client = self.inner.clone();
2358            let terminal_connection_id = connection_id.clone();
2359            let terminal_generation = attempt.generation.transport_generation.saturating_add(1);
2360            let on_terminal: Rc<dyn Fn(&'static str)> = Rc::new(move |reason| {
2361                let client = terminal_client.clone();
2362                let connection_id = terminal_connection_id.clone();
2363                spawn_local(async move {
2364                    let _ = client
2365                        .close_current_iroh_carrier_generation_with_reason(
2366                            &connection_id,
2367                            endpoint_id,
2368                            crate::client::IrohPathKind::IrohWebRtc,
2369                            terminal_generation,
2370                            reason,
2371                        )
2372                        .await;
2373                });
2374            });
2375            let carrier = crate::wasm_webrtc_carrier::WasmWebRtcCarrierSession::attach(
2376                channel,
2377                packet_session,
2378                expected,
2379                application_key,
2380                on_terminal,
2381            )?;
2382            self.wasm_webrtc_carrier_sessions
2383                .borrow_mut()
2384                .insert(upgrade_id.clone(), carrier);
2385            if attempt.role == "responder" {
2386                let ready = crate::iroh_carrier_bootstrap::CarrierBootstrapFrame::ready_from(
2387                    &attempt.bootstrap,
2388                )
2389                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2390                emit_wasm_carrier_action(
2391                    &self.wasm_carrier_action_handler,
2392                    serde_json::json!({
2393                        "type": "send-control",
2394                        "connectionId": connection_id,
2395                        "remoteEndpointId": remote_endpoint_id,
2396                        "envelope": ready,
2397                    }),
2398                );
2399                self.spawn_inbound_wasm_webrtc_carrier_completion(attempt);
2400            } else if let Some(attempt) =
2401                self.take_ready_outbound_wasm_webrtc_carrier_attempt(&connection_id, &upgrade_id)
2402            {
2403                self.spawn_outbound_wasm_webrtc_carrier_completion(attempt);
2404            }
2405            Ok(())
2406        }
2407
2408        /// Attach the exact directed Draft 14 MoQ object-datagram duplex for a
2409        /// generation-current Rust-owned attempt. This boundary cannot create
2410        /// an attempt or select a replacement.
2411        #[cfg(feature = "iroh-transport-moq")]
2412        #[wasm_bindgen(js_name = __attachIrohMoqCarrier)]
2413        pub async fn attach_iroh_moq_carrier(
2414            &self,
2415            connection_id: String,
2416            remote_endpoint_id: String,
2417            datagrams: JsValue,
2418            upgrade_id: String,
2419        ) -> Result<(), JsValue> {
2420            let endpoint_id = remote_endpoint_id
2421                .parse::<iroh::EndpointId>()
2422                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2423            let attempt = self
2424                .wasm_moq_carrier_attempts
2425                .borrow()
2426                .get(&connection_id)
2427                .filter(|attempt| {
2428                    attempt.bootstrap.upgrade_id == upgrade_id
2429                        && attempt.remote_endpoint_id == endpoint_id
2430                })
2431                .cloned()
2432                .ok_or_else(|| JsValue::from_str("MoQ carrier attempt is stale"))?;
2433            if !self
2434                .inner
2435                .wasm_carrier_upgrade_is_current(
2436                    &connection_id,
2437                    crate::client::IrohPathKind::IrohMoq,
2438                    &upgrade_id,
2439                )
2440                .await
2441            {
2442                return Err(JsValue::from_str("MoQ carrier attempt was retired"));
2443            }
2444            let packet_session = self
2445                .inner
2446                .activate_iroh_packet_carrier(
2447                    crate::iroh_carrier_kind::IrohCarrierKind::Moq,
2448                    endpoint_id,
2449                )
2450                .await
2451                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2452            let expected = attempt
2453                .bootstrap
2454                .frame_expectation()
2455                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2456            let application_key = self
2457                .inner
2458                .application_crypto_key_for_connection(Some(&connection_id))
2459                .ok_or_else(|| {
2460                    JsValue::from_str("MoQ carrier requires an installed application key")
2461                })?;
2462            let terminal_client = self.inner.clone();
2463            let terminal_connection_id = connection_id.clone();
2464            let terminal_generation = attempt.generation.transport_generation.saturating_add(1);
2465            let on_terminal: Rc<dyn Fn(&'static str)> = Rc::new(move |reason| {
2466                let client = terminal_client.clone();
2467                let connection_id = terminal_connection_id.clone();
2468                spawn_local(async move {
2469                    let _ = client
2470                        .close_current_iroh_carrier_generation_with_reason(
2471                            &connection_id,
2472                            endpoint_id,
2473                            crate::client::IrohPathKind::IrohMoq,
2474                            terminal_generation,
2475                            reason,
2476                        )
2477                        .await;
2478                });
2479            });
2480            let carrier = crate::wasm_moq_carrier::WasmMoqCarrierSession::attach(
2481                datagrams,
2482                packet_session,
2483                expected,
2484                application_key,
2485                on_terminal,
2486            )?;
2487            self.wasm_moq_carrier_sessions
2488                .borrow_mut()
2489                .insert(upgrade_id, carrier);
2490            if attempt.role == "initiator" {
2491                self.spawn_outbound_wasm_moq_carrier_completion(attempt);
2492            } else {
2493                self.spawn_inbound_wasm_moq_carrier_completion(attempt);
2494            }
2495            Ok(())
2496        }
2497
2498        #[wasm_bindgen(js_name = setIdentityCredential)]
2499        pub fn set_identity_credential(&self, credential: Option<String>) {
2500            let has_credential = credential
2501                .as_ref()
2502                .map(|value| !value.is_empty())
2503                .unwrap_or(false);
2504            let credential_len = credential.as_ref().map(|value| value.len()).unwrap_or(0);
2505
2506            if let Ok(mut guard) = self.identity_credential.lock() {
2507                *guard = credential.filter(|value| !value.is_empty());
2508            }
2509
2510            let should_log = if let Ok(mut guard) = self.last_auth_log.lock() {
2511                let next = (has_credential, credential_len);
2512                if guard.as_ref() == Some(&next) {
2513                    false
2514                } else {
2515                    *guard = Some(next);
2516                    true
2517                }
2518            } else {
2519                true
2520            };
2521
2522            if should_log {
2523                web_sys::console::log_1(&JsValue::from_str(&format!(
2524                    "[OPENRTC][WASM-IDENTITY] credential updated present={} len={}",
2525                    has_credential, credential_len
2526                )));
2527            }
2528        }
2529
2530        /// Clear the provider-owned identity assertion without routing an
2531        /// optional string through the wasm-bindgen ABI. Some generated
2532        /// bindings retain the previous string length when `null` is passed
2533        /// for `Option<String>`, which can turn a legitimate logout into a
2534        /// null-pointer trap before Rust receives the call.
2535        #[wasm_bindgen(js_name = clearIdentityCredential)]
2536        pub fn clear_identity_credential(&self) {
2537            self.set_identity_credential(None);
2538        }
2539
2540        /// Normalize and rank eligible route labels using the Rust-owned pure
2541        /// policy. This does not dial, retry, promote, demote, or mutate state.
2542        #[wasm_bindgen(js_name = rankRoutes)]
2543        pub fn rank_routes(
2544            &self,
2545            configured_priority: Vec<String>,
2546            candidates: Vec<String>,
2547        ) -> Vec<String> {
2548            crate::route_policy::rank_routes(&configured_priority, &candidates)
2549        }
2550
2551        pub async fn init_iroh(&self, secret_key: Option<Vec<u8>>) -> Result<String, JsValue> {
2552            let started_at = js_sys::Date::now();
2553            web_sys::console::log_1(&JsValue::from_str(&format!(
2554                "[OPENRTC][WASM-API] init_iroh called has_secret_key={} secret_key_len={}",
2555                secret_key.as_ref().is_some(),
2556                secret_key.as_ref().map(|k| k.len()).unwrap_or(0)
2557            )));
2558            match self.inner.init_iroh(secret_key, vec![]).await {
2559                Ok(node_id) => {
2560                    self.inner.clone().start_wasm_accept_bridge();
2561                    let elapsed = js_sys::Date::now() - started_at;
2562                    web_sys::console::log_1(&JsValue::from_str(&format!(
2563                        "[OPENRTC][WASM-API] init_iroh success elapsed_ms={:.0} node_id={}",
2564                        elapsed, node_id
2565                    )));
2566                    Ok(node_id)
2567                }
2568                Err(err) => {
2569                    let elapsed = js_sys::Date::now() - started_at;
2570                    web_sys::console::error_1(&JsValue::from_str(&format!(
2571                        "[OPENRTC][WASM-API] init_iroh failed elapsed_ms={:.0} error={}",
2572                        elapsed, err
2573                    )));
2574                    Err(JsValue::from_str(&err.to_string()))
2575                }
2576            }
2577        }
2578
2579        /// Local-harness-only endpoint initialization. The Rust runtime rejects
2580        /// every non-loopback or non-HTTPS relay URL before binding.
2581        #[wasm_bindgen(js_name = initIrohWithTestRelay)]
2582        pub async fn init_iroh_with_test_relay(
2583            &self,
2584            secret_key: Option<Vec<u8>>,
2585            test_relay_url: Option<String>,
2586        ) -> Result<String, JsValue> {
2587            let node_id = self
2588                .inner
2589                .init_iroh_with_test_relay(secret_key, vec![], test_relay_url.as_deref())
2590                .await
2591                .map_err(|error| JsValue::from_str(&error.to_string()))?;
2592            // The local relay changes only relay selection. It must retain the
2593            // normal browser connection lifecycle, including the accepted
2594            // transport bridge that promotes an inbound replacement generation.
2595            self.inner.clone().start_wasm_accept_bridge();
2596            Ok(node_id)
2597        }
2598
2599        pub async fn iroh_secret_key(&self) -> Result<Vec<u8>, JsValue> {
2600            let node_guard = self.inner.iroh_node.read().await;
2601            if let Some(node) = node_guard.as_ref() {
2602                Ok(node.secret_key())
2603            } else {
2604                Err(JsValue::from_str("Iroh node not initialized"))
2605            }
2606        }
2607
2608        pub async fn node_addr(&self) -> Result<String, JsValue> {
2609            let node_guard = self.inner.iroh_node.read().await;
2610            if let Some(node) = node_guard.as_ref() {
2611                let addr = node
2612                    .node_addr()
2613                    .await
2614                    .map_err(|e| JsValue::from_str(&e.to_string()))?;
2615                serde_json::to_string(&addr).map_err(|e| JsValue::from_str(&e.to_string()))
2616            } else {
2617                Err(JsValue::from_str("Iroh node not initialized"))
2618            }
2619        }
2620
2621        pub async fn endpoint_ticket(&self) -> Result<String, JsValue> {
2622            self.inner
2623                .endpoint_ticket()
2624                .await
2625                .map_err(|e| JsValue::from_str(&e.to_string()))
2626        }
2627
2628        /// Build a compound ticket with an embedded session token.
2629        /// Registers the token on the canonical Rust client and returns the compound ticket string.
2630        /// `scope`: logical label (e.g. "share"). `max_connections`: 0 = unlimited.
2631        pub async fn endpoint_ticket_with_token(
2632            &self,
2633            grant_scope: String,
2634            max_connections: u32,
2635        ) -> Result<String, JsValue> {
2636            self.inner
2637                .endpoint_ticket_with_token(&grant_scope, max_connections)
2638                .await
2639                .map_err(|e| JsValue::from_str(&e.to_string()))
2640        }
2641
2642        /// Register a session token on the canonical Rust client.
2643        pub fn register_session_token(
2644            &self,
2645            token: String,
2646            grant_scope: String,
2647            max_connections: u32,
2648        ) {
2649            self.inner
2650                .register_session_token(token, grant_scope, max_connections);
2651        }
2652
2653        /// Register a session token with an absolute Unix-millisecond expiry.
2654        pub fn register_session_token_with_expiry_ms(
2655            &self,
2656            token: String,
2657            grant_scope: String,
2658            max_connections: u32,
2659            expires_at_ms: u64,
2660        ) {
2661            self.inner.register_session_token_with_expiry_ms(
2662                token,
2663                grant_scope,
2664                max_connections,
2665                expires_at_ms,
2666            );
2667        }
2668
2669        /// Mark a connection as requiring application crypto on native outbound paths.
2670        #[wasm_bindgen(js_name = setConnectionApplicationCryptoRequired)]
2671        pub fn set_connection_application_crypto_required(
2672            &self,
2673            connection_id: String,
2674        ) -> Result<(), JsValue> {
2675            self.inner
2676                .set_connection_application_crypto_required(&connection_id);
2677            Ok(())
2678        }
2679
2680        /// Install a negotiated per-connection application crypto key for native send paths.
2681        #[wasm_bindgen(js_name = setConnectionApplicationCryptoKey)]
2682        pub async fn set_connection_application_crypto_key(
2683            &self,
2684            connection_id: String,
2685            key: Vec<u8>,
2686        ) -> Result<(), JsValue> {
2687            if key.len() != crate::application_crypto::APPLICATION_KEY_BYTES {
2688                return Err(JsValue::from_str("application crypto key must be 32 bytes"));
2689            }
2690            let mut key_bytes = [0u8; crate::application_crypto::APPLICATION_KEY_BYTES];
2691            key_bytes.copy_from_slice(&key);
2692            self.inner
2693                .set_connection_application_crypto_key(&connection_id, key_bytes);
2694            self.inner
2695                .emit_current_wasm_connection_state(&connection_id)
2696                .await;
2697            Ok(())
2698        }
2699
2700        /// Retire the negotiated application key and ephemeral agreement for a
2701        /// logical connection. Connection ids may be reused after an ACL revoke
2702        /// and regrant, so lifecycle cleanup must clear the WASM runtime together
2703        /// with the TypeScript crypto indexes before a fresh handshake begins.
2704        #[wasm_bindgen(js_name = clearConnectionApplicationCryptoKey)]
2705        pub fn clear_connection_application_crypto_key(&self, connection_id: String) {
2706            self.inner
2707                .clear_connection_application_crypto_key(&connection_id);
2708        }
2709
2710        /// Validate (and consume one use of) a session token.
2711        /// Returns the scope string on success, throws on failure.
2712        /// If the registry is empty, always succeeds (backward-compat gate).
2713        pub fn validate_session_token(&self, token: String) -> Result<String, JsValue> {
2714            self.inner
2715                .validate_session_token(&token)
2716                .map_err(|e| JsValue::from_str(&e))
2717        }
2718
2719        /// Validate and record token admission for a specific connection.
2720        pub async fn validate_session_token_for_connection(
2721            &self,
2722            token: String,
2723            connection_id: String,
2724        ) -> Result<String, JsValue> {
2725            self.inner
2726                .validate_session_token_for_connection(&token, &connection_id)
2727                .await
2728                .map_err(|e| JsValue::from_str(&e))
2729        }
2730
2731        pub async fn validate_session_token_for_connection_with_payload(
2732            &self,
2733            token: String,
2734            connection_id: String,
2735            token_payload: Option<String>,
2736        ) -> Result<String, JsValue> {
2737            self.inner
2738                .validate_session_token_for_connection_with_payload(
2739                    &token,
2740                    &connection_id,
2741                    token_payload.as_deref(),
2742                )
2743                .await
2744                .map_err(|e| JsValue::from_str(&e))
2745        }
2746
2747        /// Present a session token to the remote host over the SDK-owned
2748        /// native main stream before application traffic starts.
2749        /// Returns the approved scope string once the host acknowledges admission.
2750        ///
2751        /// Requires a managed transport record: call [`Self::connect_device`]
2752        /// (or another dial that runs `ensure_connected_addr`) before presenting.
2753        pub async fn present_session_token_to_host(
2754            &self,
2755            endpoint_id: String,
2756            token: String,
2757        ) -> Result<String, JsValue> {
2758            self.present_session_token_to_host_with_payload(endpoint_id, token, None)
2759                .await
2760        }
2761
2762        pub async fn present_session_token_to_host_with_payload(
2763            &self,
2764            endpoint_id: String,
2765            token: String,
2766            token_payload: Option<String>,
2767        ) -> Result<String, JsValue> {
2768            self.present_session_token_to_host_with_payload_and_device_id(
2769                endpoint_id,
2770                token,
2771                token_payload,
2772                None,
2773            )
2774            .await
2775        }
2776
2777        pub async fn present_session_token_to_host_with_payload_and_device_id(
2778            &self,
2779            endpoint_id: String,
2780            token: String,
2781            token_payload: Option<String>,
2782            device_id: Option<String>,
2783        ) -> Result<String, JsValue> {
2784            crate::console_log!(
2785                "[OpenRTC][session-admission][wasm-present] endpoint_id={} claimed_local_device_id={}",
2786                endpoint_id,
2787                device_id.as_deref().unwrap_or("<none>")
2788            );
2789            let endpoint_id_parsed: iroh::EndpointId = endpoint_id
2790                .parse()
2791                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
2792            let local_node_id = self.inner.current_node_id().await.ok_or_else(|| {
2793                JsValue::from_str("missing local node id for session-token presentation")
2794            })?;
2795            let connection_id =
2796                crate::client::Client::deterministic_connection_id(&local_node_id, &endpoint_id);
2797            let approval_scope = self
2798                .inner
2799                .present_and_accept_session_token_with_local_claim(
2800                    endpoint_id_parsed,
2801                    &connection_id,
2802                    &token,
2803                    token_payload.as_deref(),
2804                    None,
2805                    device_id,
2806                )
2807                .await
2808                .map_err(|e| JsValue::from_str(&e))?;
2809            self.inner
2810                .emit_current_wasm_connection_state(&connection_id)
2811                .await;
2812
2813            Ok(approval_scope)
2814        }
2815
2816        /// Report whether this runtime has the current transport-generation
2817        /// outbound admission proof required by an endpoint ticket.
2818        pub async fn remote_session_admission_ready_for_ticket(
2819            &self,
2820            endpoint_ticket: String,
2821        ) -> Result<bool, JsValue> {
2822            self.inner
2823                .remote_session_admission_ready_for_ticket(&endpoint_ticket)
2824                .await
2825                .map_err(|error| JsValue::from_str(&error.to_string()))
2826        }
2827
2828        /// Fence one browser-relayed reciprocal presentation in the Rust
2829        /// admission owner before the adapter writes it to the stream.
2830        #[allow(clippy::too_many_arguments)]
2831        pub async fn prepare_inline_reciprocal_session_admission(
2832            &self,
2833            endpoint_id: String,
2834            expected_transport_stable_id: u64,
2835            stream_instance_id: String,
2836            presentation_id: String,
2837            token: String,
2838            token_payload: String,
2839            device_id: String,
2840            stream_contract: String,
2841        ) -> Result<bool, JsValue> {
2842            let endpoint_id: iroh::EndpointId = endpoint_id
2843                .parse()
2844                .map_err(|error| JsValue::from_str(&format!("{error}")))?;
2845            let stream_contract = match stream_contract.trim() {
2846                "one-shot-admission" => {
2847                    crate::native_protocol::SessionTokenStreamContract::OneShotAdmission
2848                }
2849                "persistent-control" => {
2850                    crate::native_protocol::SessionTokenStreamContract::PersistentControl
2851                }
2852                other => {
2853                    return Err(JsValue::from_str(&format!(
2854                        "unsupported reciprocal stream contract: {other}"
2855                    )))
2856                }
2857            };
2858            self.inner
2859                .prepare_inline_reciprocal_session_admission(
2860                    endpoint_id,
2861                    expected_transport_stable_id,
2862                    stream_instance_id.as_str(),
2863                    presentation_id.as_str(),
2864                    token.as_str(),
2865                    token_payload.as_str(),
2866                    device_id.as_str(),
2867                    stream_contract,
2868                )
2869                .await
2870                .map_err(|error| JsValue::from_str(&error))?;
2871            Ok(true)
2872        }
2873
2874        /// Commit an inline reciprocal session admission only when the ACK
2875        /// belongs to the exact Rust-owned transcript and physical generation.
2876        pub async fn confirm_inline_reciprocal_session_admission(
2877            &self,
2878            endpoint_id: String,
2879            expected_transport_stable_id: u64,
2880            stream_instance_id: String,
2881            presentation_id: String,
2882            accepted: bool,
2883            approval_scope: Option<String>,
2884        ) -> Result<bool, JsValue> {
2885            let endpoint_id: iroh::EndpointId = endpoint_id
2886                .parse()
2887                .map_err(|error| JsValue::from_str(&format!("{error}")))?;
2888            self.inner
2889                .confirm_inline_reciprocal_session_admission(
2890                    endpoint_id,
2891                    expected_transport_stable_id,
2892                    stream_instance_id.as_str(),
2893                    presentation_id.as_str(),
2894                    accepted,
2895                    approval_scope.as_deref(),
2896                )
2897                .await
2898                .map_err(|error| JsValue::from_str(&error))?;
2899            self.inner
2900                .emit_current_wasm_connection_state(
2901                    &crate::client::Client::deterministic_connection_id(
2902                        &self.inner.current_node_id().await.ok_or_else(|| {
2903                            JsValue::from_str(
2904                                "missing local node id after reciprocal admission ACK",
2905                            )
2906                        })?,
2907                        &endpoint_id.to_string(),
2908                    ),
2909                )
2910                .await;
2911            Ok(true)
2912        }
2913
2914        /// Revoke a single token by value.
2915        pub fn revoke_session_token(&self, token: String) -> Result<JsValue, JsValue> {
2916            serde_wasm_bindgen::to_value(&self.inner.revoke_session_token(&token))
2917                .map_err(|error| JsValue::from_str(&error.to_string()))
2918        }
2919
2920        /// Revoke all tokens that match the given scope and disconnect affected peers.
2921        pub async fn revoke_tokens_by_scope(
2922            &self,
2923            grant_scope: String,
2924        ) -> Result<JsValue, JsValue> {
2925            let affected = self.inner.begin_revoke_tokens_by_scope(&grant_scope);
2926            // `Client` has made the terminal authorization decision but keeps
2927            // the exact carrier generation alive long enough to send its
2928            // authenticated terminal frame. This is teardown only: it performs
2929            // no gateway operation and cannot create or retry a carrier.
2930            for connection_id in &affected {
2931                #[cfg(feature = "iroh-transport-webrtc")]
2932                self.retire_iroh_webrtc_carrier(
2933                    connection_id.clone(),
2934                    Some(crate::lifecycle_reason::REASON_SESSION_TOKEN_REVOKED.to_string()),
2935                )
2936                .await;
2937                #[cfg(feature = "iroh-transport-moq")]
2938                self.retire_iroh_moq_carrier_with_reason(
2939                    connection_id.clone(),
2940                    Some(crate::lifecycle_reason::REASON_SESSION_TOKEN_REVOKED),
2941                )
2942                .await;
2943            }
2944            self.inner
2945                .finish_revoke_tokens_by_scope(&grant_scope, &affected)
2946                .await;
2947            serde_wasm_bindgen::to_value(&affected).map_err(|e| JsValue::from_str(&e.to_string()))
2948        }
2949
2950        /// Clear all short-lived session tokens and admission state.
2951        pub fn clear_session_tokens(&self) {
2952            self.inner.clear_session_tokens();
2953        }
2954
2955        pub fn endpoint_id_from_ticket(&self, ticket: String) -> Result<String, JsValue> {
2956            let (iroh_ticket, _token_suffix) = split_compound_ticket(ticket.trim());
2957            let parsed = EndpointTicket::from_str(iroh_ticket)
2958                .map_err(|e| JsValue::from_str(&format!("Invalid endpoint ticket: {}", e)))?;
2959            Ok(parsed.endpoint_addr().id.to_string())
2960        }
2961
2962        /// Deprecated: product code must dial through [`Self::connect_device`], which
2963        /// registers the connection and starts the wasm connect-event bridge.
2964        /// This raw stream export remains for legacy harness callers only.
2965        pub async fn connect(&self, ticket: String) -> Result<JsReadableStream, JsValue> {
2966            web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(
2967                "[OPENRTC][WASM-API] WasmClient.connect() is deprecated; use connect_device() for managed product dials.",
2968            ));
2969            let (iroh_ticket, _token_suffix) = split_compound_ticket(ticket.trim());
2970            let parsed = EndpointTicket::from_str(iroh_ticket)
2971                .map_err(|e| JsValue::from_str(&format!("Invalid endpoint ticket: {}", e)))?;
2972            let endpoint_addr = parsed.endpoint_addr().clone();
2973            let endpoint_id = endpoint_addr.id;
2974            let stream = {
2975                let node_guard = self.inner.iroh_node.read().await;
2976                if let Some(node) = node_guard.as_ref() {
2977                    node.connect_addr(endpoint_id, endpoint_addr)
2978                } else {
2979                    return Err(JsValue::from_str("Iroh node not initialized"));
2980                }
2981            };
2982            Ok(into_js_readable_stream(stream))
2983        }
2984
2985        pub async fn disconnect(&self, endpoint_id: String) -> Result<(), JsValue> {
2986            let endpoint_id: iroh::EndpointId = endpoint_id
2987                .parse()
2988                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
2989            let node_guard = self.inner.iroh_node.read().await;
2990            if let Some(node) = node_guard.as_ref() {
2991                node.disconnect(endpoint_id)
2992                    .await
2993                    .map_err(|e| JsValue::from_str(&e.to_string()))
2994            } else {
2995                Err(JsValue::from_str("Iroh node not initialized"))
2996            }
2997        }
2998
2999        /// Drop the iroh transport to a peer with a **transient** reason — a
3000        /// simulated network flap, as opposed to [`disconnect`] which signals a
3001        /// user/manual disconnect.
3002        ///
3003        /// `disconnect()` (and the generic close) reports `disconnected by user`,
3004        /// which `lifecycle_reason` classifies as `ManualDisconnect` — terminal and
3005        /// sticky: the remote will NOT auto-reconnect and WebRTC is retired
3006        /// immediately. That is correct for a real user action, but wrong for a
3007        /// transient transport drop. This variant uses a transient reason code
3008        /// (`network-change-forced-reconnect`, `is_transient_reconnect()`), so both
3009        /// peers treat the drop as a recoverable transition and auto-reconnect —
3010        /// the browser equivalent of the native test harness's `irohDisconnect`.
3011        pub async fn disconnect_transient(&self, endpoint_id: String) -> Result<(), JsValue> {
3012            let endpoint_id: iroh::EndpointId = endpoint_id
3013                .parse()
3014                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
3015            self.inner
3016                .disconnect_with_reason(
3017                    endpoint_id,
3018                    crate::lifecycle_reason::REASON_NETWORK_CHANGE_RECONNECT,
3019                )
3020                .await
3021                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3022            // `disconnect_with_reason` has only an `&Client`; wake from this
3023            // WASM boundary, which owns the Arc and the single browser
3024            // desired-peer actor. This is a recoverable edge change, not a new
3025            // provider revision, so the actor would otherwise stay idle after
3026            // a previously healthy peer is marked replacement-pending.
3027            self.inner.wake_browser_auto_connect();
3028            Ok(())
3029        }
3030
3031        pub async fn is_connected(&self, endpoint_id: String) -> Result<bool, JsValue> {
3032            let endpoint_id: iroh::EndpointId = endpoint_id
3033                .parse()
3034                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
3035            Ok(self.inner.is_connected(endpoint_id).await)
3036        }
3037
3038        pub fn runtime_policy(&self) -> Result<JsValue, JsValue> {
3039            serde_wasm_bindgen::to_value(&self.inner.runtime_policy_snapshot())
3040                .map_err(|e| JsValue::from_str(&e.to_string()))
3041        }
3042
3043        pub async fn add_peer_scope(&self, id: String, scope: String) -> Result<JsValue, JsValue> {
3044            let scopes = self.inner.add_peer_scope(&id, &scope).await;
3045            serde_wasm_bindgen::to_value(&scopes).map_err(|e| JsValue::from_str(&e.to_string()))
3046        }
3047
3048        pub async fn release_peer_scope(
3049            &self,
3050            id: String,
3051            scope: Option<String>,
3052        ) -> Result<JsValue, JsValue> {
3053            let scopes = self.inner.release_peer_scope(&id, scope.as_deref()).await;
3054            serde_wasm_bindgen::to_value(&scopes).map_err(|e| JsValue::from_str(&e.to_string()))
3055        }
3056
3057        pub async fn peer_scopes(&self, id: String) -> Result<JsValue, JsValue> {
3058            let scopes = self.inner.peer_scopes(&id).await;
3059            serde_wasm_bindgen::to_value(&scopes).map_err(|e| JsValue::from_str(&e.to_string()))
3060        }
3061
3062        pub async fn same_peer(&self, left: String, right: String) -> Result<bool, JsValue> {
3063            Ok(self.inner.same_peer(&left, &right).await)
3064        }
3065
3066        pub async fn peer_snapshot(&self, id: String) -> Result<JsValue, JsValue> {
3067            let snapshot = self.inner.peer_snapshot(&id).await;
3068            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
3069        }
3070
3071        // U9: the `peer_snapshots()` wasm binding was retired; `peer_sessions()`
3072        // (below) is the single settled Rust projection exposed to TS.
3073
3074        pub async fn peer_session(&self, id: String) -> Result<JsValue, JsValue> {
3075            let snapshot = self.inner.peer_session(&id).await;
3076            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
3077        }
3078
3079        pub async fn peer_sessions(&self) -> Result<JsValue, JsValue> {
3080            let snapshots = self.inner.peer_sessions().await;
3081            serde_wasm_bindgen::to_value(&snapshots).map_err(|e| JsValue::from_str(&e.to_string()))
3082        }
3083
3084        pub async fn connection_state(&self, connection_id: String) -> Result<JsValue, JsValue> {
3085            let snapshot = self.inner.connection_state(&connection_id).await;
3086            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
3087        }
3088
3089        pub async fn connection_states(&self) -> Result<JsValue, JsValue> {
3090            let snapshots = self.inner.connection_states().await;
3091            serde_wasm_bindgen::to_value(&snapshots).map_err(|e| JsValue::from_str(&e.to_string()))
3092        }
3093
3094        pub async fn wait_for_settled_peer(
3095            &self,
3096            id: String,
3097            timeout_ms: Option<u32>,
3098        ) -> Result<JsValue, JsValue> {
3099            let snapshot = self
3100                .inner
3101                .wait_for_settled_peer(&id, timeout_ms.map(|value| value as u64))
3102                .await;
3103            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
3104        }
3105
3106        pub async fn resolve_peer_connection_records(
3107            &self,
3108            id: String,
3109        ) -> Result<JsValue, JsValue> {
3110            let records = self.inner.resolve_peer_connection_records(&id).await;
3111            serde_wasm_bindgen::to_value(&records).map_err(|e| JsValue::from_str(&e.to_string()))
3112        }
3113
3114        pub async fn list_managed_connections(&self) -> Result<JsValue, JsValue> {
3115            let records = self.inner.list_managed_connections().await;
3116            serde_wasm_bindgen::to_value(&records).map_err(|e| JsValue::from_str(&e.to_string()))
3117        }
3118
3119        pub async fn bind_connection_device_id(
3120            &self,
3121            connection_id: String,
3122            device_id: String,
3123        ) -> Result<JsValue, JsValue> {
3124            let snapshot = self
3125                .inner
3126                .bind_connection_device_id(&connection_id, &device_id)
3127                .await;
3128            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
3129        }
3130
3131        pub async fn bind_node_device_id(
3132            &self,
3133            node_id: String,
3134            device_id: String,
3135        ) -> Result<(), JsValue> {
3136            self.inner.bind_node_device_id(&node_id, &device_id).await;
3137            Ok(())
3138        }
3139
3140        pub async fn reject_connection_admission(
3141            &self,
3142            connection_id: String,
3143            reason: String,
3144        ) -> Result<JsValue, JsValue> {
3145            self.inner
3146                .reject_session_connection(&connection_id, &reason);
3147            self.inner
3148                .emit_current_wasm_connection_state(&connection_id)
3149                .await;
3150            let snapshot = self.inner.connection_state(&connection_id).await;
3151            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
3152        }
3153
3154        pub async fn report_managed_connection_settled(
3155            &self,
3156            connection_id: String,
3157            settled: bool,
3158            device_id: Option<String>,
3159            transport_stable_id: Option<u64>,
3160            transport_generation: Option<u64>,
3161            route_generation: Option<u64>,
3162        ) -> Result<JsValue, JsValue> {
3163            let snapshot = match (transport_stable_id, transport_generation, route_generation) {
3164                (Some(transport_stable_id), Some(transport_generation), Some(route_generation)) => {
3165                    self.inner
3166                        .report_managed_connection_settled_for_transport(
3167                            &connection_id,
3168                            settled,
3169                            transport_stable_id,
3170                            transport_generation,
3171                            route_generation,
3172                        )
3173                        .await
3174                }
3175                _ => None,
3176            };
3177            let _ = device_id;
3178            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
3179        }
3180
3181        pub async fn report_transport_status(
3182            &self,
3183            connection_id: String,
3184            active_transport: String,
3185            parallel_transport: Option<String>,
3186            transport_stable_id: Option<u64>,
3187            transport_generation: Option<u64>,
3188            route_generation: Option<u64>,
3189        ) -> Result<JsValue, JsValue> {
3190            let snapshot = match (transport_stable_id, transport_generation, route_generation) {
3191                (Some(transport_stable_id), Some(transport_generation), Some(route_generation)) => {
3192                    self.inner
3193                        .report_transport_status_for_generation(
3194                            &connection_id,
3195                            &active_transport,
3196                            parallel_transport.as_deref(),
3197                            transport_stable_id,
3198                            transport_generation,
3199                            route_generation,
3200                        )
3201                        .await
3202                }
3203                _ => None,
3204            };
3205            serde_wasm_bindgen::to_value(&snapshot).map_err(|e| JsValue::from_str(&e.to_string()))
3206        }
3207
3208        pub async fn is_current_transport_stable_id(
3209            &self,
3210            endpoint_id: String,
3211            transport_stable_id: u64,
3212        ) -> Result<bool, JsValue> {
3213            let endpoint_id = endpoint_id
3214                .parse::<iroh::EndpointId>()
3215                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3216            Ok(self
3217                .inner
3218                .is_current_transport_stable_id(endpoint_id, transport_stable_id)
3219                .await)
3220        }
3221
3222        pub async fn open_bi(&self, endpoint_id: String) -> Result<BiStream, JsValue> {
3223            let endpoint_id: iroh::EndpointId = endpoint_id
3224                .parse()
3225                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
3226            self.inner
3227                .assert_raw_peer_stream_allowed(&endpoint_id)
3228                .await
3229                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3230            // Parity with the native `Client::open_bi`: register/finalize the
3231            // connection_manager record for the (already-alive) transport before
3232            // handing out a raw peer stream, so browser raw-stream opens
3233            // (explicit transfer, native-main signaling) participate in
3234            // connection lifecycle / close tracking. Idempotent, and a no-op when
3235            // the transport is not alive. `Client::open_bi` itself is native-only
3236            // (it returns native iroh stream types), so the wasm binding cannot
3237            // delegate to it and must mirror its guard + record + open sequence.
3238            self.inner
3239                .ensure_connection_manager_record_before_peer_stream(&endpoint_id)
3240                .await
3241                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3242            let (send, recv) = self
3243                .inner
3244                .open_bi_internal(endpoint_id)
3245                .await
3246                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3247            Ok(BiStream::from_parts(send, recv, endpoint_id.to_string()))
3248        }
3249
3250        /// Open a raw bi-stream for the SDK-owned native-main **control plane**
3251        /// (WebRTC signaling: SDP / ICE candidates / renegotiate, and the
3252        /// bootstrap application-route handshake).
3253        ///
3254        /// Unlike [`open_bi`], this is deliberately **exempt** from the
3255        /// application-crypto raw-open guard (`assert_raw_peer_stream_allowed`).
3256        /// That guard protects application *data* — but the control plane is not
3257        /// application data:
3258        ///   1. Signaling bootstraps the very application route (and key
3259        ///      agreement) it would otherwise depend on, so it cannot require app
3260        ///      crypto that has not been negotiated yet.
3261        ///   2. It is already authenticated by the iroh QUIC TLS that binds the
3262        ///      sender's node id.
3263        ///   3. The receiver classifies the stream by its `[0x00][len]["main"]`
3264        ///      native-main label and routes it to the signal handler; bytes sent
3265        ///      here can never be delivered as application data, so this cannot be
3266        ///      abused to smuggle unencrypted app payloads past the guard.
3267        ///
3268        /// This binding is intentionally named for native-main. It is not a
3269        /// general-purpose raw-stream escape hatch: JS callers must immediately
3270        /// write the `[0x00][len]["main"]` native-main label and then framed
3271        /// control payloads. Application data must continue to use `open_peer_bi`
3272        /// / `open_peer_uni` so the application-crypto guard stays fail-closed.
3273        ///
3274        /// Without this, a peer whose inbound native-main control writer was lost
3275        /// (e.g. after a rapid disconnect/reconnect flap) and that requires app
3276        /// crypto could not (re)open a signaling stream at all, stranding the
3277        /// edge on base Iroh because SDP offers/answers can never be exchanged.
3278        pub async fn open_native_main_control_bi(
3279            &self,
3280            endpoint_id: String,
3281        ) -> Result<BiStream, JsValue> {
3282            let endpoint_id: iroh::EndpointId = endpoint_id
3283                .parse()
3284                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
3285            self.inner
3286                .ensure_connection_manager_record_before_peer_stream(&endpoint_id)
3287                .await
3288                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3289            let (send, recv) = self
3290                .inner
3291                .open_bi_internal(endpoint_id)
3292                .await
3293                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3294            Ok(BiStream::from_parts(send, recv, endpoint_id.to_string()))
3295        }
3296
3297        /// Send one complete SDK-owned native-main control frame on a fresh
3298        /// signal stream. Rust owns the QUIC FIN and waits for the peer to
3299        /// acknowledge it so a successful JS promise means the frame reached
3300        /// the remote stream router, not merely the browser WritableStream.
3301        pub async fn send_native_main_control_frame(
3302            &self,
3303            endpoint_id: String,
3304            frame: Vec<u8>,
3305        ) -> Result<(), JsValue> {
3306            let endpoint_id: iroh::EndpointId = endpoint_id
3307                .parse()
3308                .map_err(|error| JsValue::from_str(&format!("{error}")))?;
3309            self.inner
3310                .ensure_connection_manager_record_before_peer_stream(&endpoint_id)
3311                .await
3312                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3313            let (mut send, _recv) = self
3314                .inner
3315                .open_bi_internal(endpoint_id)
3316                .await
3317                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3318            let label = b"signal";
3319            send.write_all(&[0x00])
3320                .await
3321                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3322            send.write_all(&(label.len() as u32).to_be_bytes())
3323                .await
3324                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3325            send.write_all(label)
3326                .await
3327                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3328            send.write_all(&frame)
3329                .await
3330                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3331            crate::application_crypto_streams::PeerSendStream::plain(send)
3332                .finish_and_wait_for_peer(std::time::Duration::from_secs(2))
3333                .await
3334                .map_err(|error| JsValue::from_str(&error.to_string()))
3335        }
3336
3337        pub async fn open_peer_bi(
3338            &self,
3339            id: String,
3340            timeout_ms: Option<u32>,
3341        ) -> Result<BiStream, JsValue> {
3342            let (_connection_id, remote_node_id, send, recv) = self
3343                .inner
3344                .open_peer_bi(&id, timeout_ms.map(|value| value as u64))
3345                .await
3346                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3347            Ok(BiStream::from_peer_parts(send, recv, remote_node_id))
3348        }
3349
3350        /// Send a complete protected application frame on a fresh peer stream.
3351        /// Rust owns the QUIC FIN so JS readable cancellation cannot reset the
3352        /// one-shot stream before the remote runtime admits it.
3353        pub async fn send_peer_application_frame(
3354            &self,
3355            id: String,
3356            frame: Vec<u8>,
3357            timeout_ms: Option<u32>,
3358        ) -> Result<(), JsValue> {
3359            self.inner
3360                .send_peer_application_frame(&id, &frame, timeout_ms.map(|value| value as u64))
3361                .await
3362                .map_err(|error| JsValue::from_str(&error.to_string()))
3363        }
3364
3365        /// Open a settled peer stream for explicit file transfer.
3366        ///
3367        /// The runtime writes the plaintext explicit-file protocol byte (`0x02`)
3368        /// before returning the send stream, then wraps only the transfer body
3369        /// with application crypto when a key is active for the peer.
3370        pub async fn open_peer_bi_explicit_file_sender(
3371            &self,
3372            id: String,
3373            timeout_ms: Option<u32>,
3374        ) -> Result<PeerUniStream, JsValue> {
3375            let (_connection_id, _remote_node_id, send) = self
3376                .inner
3377                .open_peer_bi_explicit_file_sender(&id, timeout_ms.map(|value| value as u64))
3378                .await
3379                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3380            Ok(peer_uni_stream_from_send(send))
3381        }
3382
3383        /// Open a bi-stream to a peer that is transport-connected but may not yet
3384        /// be settled (auth-ready). Use for latency probes and other transport-level
3385        /// diagnostics where `settled_ready` is not required.
3386        pub async fn open_peer_bi_transport_only(
3387            &self,
3388            id: String,
3389            timeout_ms: Option<u32>,
3390        ) -> Result<BiStream, JsValue> {
3391            let (_connection_id, remote_node_id, send, recv) = self
3392                .inner
3393                .open_peer_bi_transport_only(&id, timeout_ms.map(|value| value as u64))
3394                .await
3395                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3396            Ok(BiStream::from_parts(send, recv, remote_node_id))
3397        }
3398
3399        /// Open a settled peer bi-stream for a native host protocol and write
3400        /// the standard OpenRTC channel envelope before returning it to JS.
3401        ///
3402        /// This is intentionally narrower than `open_peer_bi_transport_only`:
3403        /// native Plutonium drive-view hosts authorize with OpenRTC session
3404        /// admission + drive scopes. The channel envelope is written through the
3405        /// protected peer stream, so keyed and unkeyed sessions use the same wire
3406        /// contract and never expose a plaintext product label beside encrypted
3407        /// payloads.
3408        pub async fn open_peer_native_bi(
3409            &self,
3410            id: String,
3411            label: String,
3412            timeout_ms: Option<u32>,
3413        ) -> Result<BiStream, JsValue> {
3414            if label != "drive-view" {
3415                return Err(JsValue::from_str(
3416                    "unsupported native peer stream label; only drive-view is allowed",
3417                ));
3418            }
3419
3420            let (_connection_id, remote_node_id, mut send, recv) = self
3421                .inner
3422                .open_peer_bi(&id, timeout_ms.map(|value| value as u64))
3423                .await
3424                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3425            let envelope = crate::stream_metadata::encode_channel_envelope(&label, None)
3426                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3427            send.write_all(&envelope)
3428                .await
3429                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3430
3431            Ok(BiStream::from_peer_parts(send, recv, remote_node_id))
3432        }
3433
3434        pub async fn open_uni(&self, endpoint_id: String) -> Result<PeerUniStream, JsValue> {
3435            let endpoint_id: iroh::EndpointId = endpoint_id
3436                .parse()
3437                .map_err(|e| JsValue::from_str(&format!("{}", e)))?;
3438            self.inner
3439                .assert_raw_peer_stream_allowed(&endpoint_id)
3440                .await
3441                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3442            // Parity with the native `Client::open_uni`, which also finalizes the
3443            // connection_manager record before opening a raw uni peer stream.
3444            self.inner
3445                .ensure_connection_manager_record_before_peer_stream(&endpoint_id)
3446                .await
3447                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3448            let node_guard = self.inner.iroh_node.read().await;
3449            if let Some(node) = node_guard.as_ref() {
3450                let send = node
3451                    .open_uni(endpoint_id.clone())
3452                    .await
3453                    .map_err(|e| JsValue::from_str(&e.to_string()))?;
3454                Ok(peer_uni_stream_from_send(
3455                    crate::application_crypto_streams::PeerSendStream::plain(send),
3456                ))
3457            } else {
3458                Err(JsValue::from_str("Iroh node not initialized"))
3459            }
3460        }
3461
3462        pub async fn open_peer_uni(
3463            &self,
3464            id: String,
3465            timeout_ms: Option<u32>,
3466        ) -> Result<PeerUniStream, JsValue> {
3467            let (_connection_id, _remote_node_id, send) = self
3468                .inner
3469                .open_peer_uni(&id, timeout_ms.map(|value| value as u64))
3470                .await
3471                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3472            Ok(peer_uni_stream_from_send(send))
3473        }
3474
3475        /// Send `data` to `peer_id` using the best available transport.
3476        ///
3477        /// Native route order is edge-aware: proven upgraded routes stay first,
3478        /// relay/unknown iroh can probe upgraded transports first, and direct
3479        /// iroh/LAN/BLE paths stay primary with upgraded transports as fallback.
3480        ///
3481        /// On WASM this always routes through iroh (relay or QUIC as iroh determines).
3482        /// Use the TypeScript `Connection.sendTyped()` for full transport priority on the
3483        /// browser side — this binding is primarily for symmetry and native callers.
3484        pub async fn send_peer(&self, id: String, data: Vec<u8>) -> Result<(), JsValue> {
3485            self.inner
3486                .send_peer(&id, &data)
3487                .await
3488                .map_err(|e| JsValue::from_str(&e.to_string()))
3489        }
3490
3491        /// Returns the current iroh path kind for a connected peer.
3492        ///
3493        /// Returns one of `"direct-quic"`, `"relay"`, or `"unknown"`.
3494        pub async fn iroh_path_kind(&self, peer_id: String) -> String {
3495            match self.inner.iroh_path_kind(&peer_id).await {
3496                crate::client::IrohPathKind::DirectQuic => "direct-quic".to_string(),
3497                crate::client::IrohPathKind::DirectLan => "direct-lan".to_string(),
3498                crate::client::IrohPathKind::Relay => "relay".to_string(),
3499                crate::client::IrohPathKind::Ble => "ble".to_string(),
3500                crate::client::IrohPathKind::IrohWebRtc => "iroh-webrtc".to_string(),
3501                crate::client::IrohPathKind::IrohMoq => "iroh-moq".to_string(),
3502                crate::client::IrohPathKind::Unknown => "unknown".to_string(),
3503            }
3504        }
3505
3506        /// Returns the current iroh transport RTT in milliseconds, when iroh has
3507        /// selected a live path and published path stats.
3508        pub async fn iroh_transport_rtt_ms(&self, peer_id: String) -> Option<u32> {
3509            self.inner
3510                .iroh_transport_rtt_ms(&peer_id)
3511                .await
3512                .map(|value| value.min(u32::MAX as u64) as u32)
3513        }
3514
3515        pub async fn incoming_streams(&self) -> Result<JsReadableStream, JsValue> {
3516            let (node, stream) = {
3517                let node_guard = self.inner.iroh_node.read().await;
3518                if let Some(node) = node_guard.as_ref() {
3519                    (node.clone(), node.incoming_streams_stream())
3520                } else {
3521                    return Err(JsValue::from_str("Iroh node not initialized"));
3522                }
3523            };
3524
3525            use futures::StreamExt;
3526            let mapped_stream = stream.filter_map(move |incoming| {
3527                let node = node.clone();
3528                async move {
3529                    node.incoming_stream_is_current(&incoming)
3530                        .await
3531                        .then(|| crate::wasm_node::BiStream::incoming_to_js_value(incoming))
3532                }
3533            });
3534
3535            Ok(wasm_streams::ReadableStream::from_stream(mapped_stream).into_raw())
3536        }
3537
3538        pub async fn update_presence(
3539            &self,
3540            user_id: String,
3541            device_name: String,
3542            ticket: String,
3543            metadata: Option<String>,
3544            ttl_ms: Option<u64>,
3545        ) -> Result<(), JsValue> {
3546            self.inner
3547                .update_presence_with_ttl(
3548                    &user_id,
3549                    &device_name,
3550                    &ticket,
3551                    ttl_ms.unwrap_or(300_000),
3552                    metadata.as_deref(),
3553                )
3554                .await
3555                .map_err(|e| JsValue::from_str(&e.to_string()))
3556        }
3557
3558        pub async fn send_message(
3559            &self,
3560            target_id: String,
3561            payload: String,
3562            state: Option<String>,
3563            reply_payload: Option<String>,
3564        ) -> Result<String, JsValue> {
3565            self.inner
3566                .send_message(
3567                    &target_id,
3568                    &payload,
3569                    state.as_deref(),
3570                    reply_payload.as_deref(),
3571                )
3572                .await
3573                .map_err(|e| JsValue::from_str(&e.to_string()))
3574        }
3575
3576        pub async fn set_offline(&self, user_id: String) -> Result<(), JsValue> {
3577            self.inner
3578                .set_offline(&user_id)
3579                .await
3580                .map_err(|e| JsValue::from_str(&e.to_string()))
3581        }
3582
3583        pub async fn update_device(
3584            &self,
3585            user_id: String,
3586            device_id: String,
3587            device_name: Option<String>,
3588            capabilities: Option<JsValue>,
3589            metadata: Option<String>,
3590        ) -> Result<(), JsValue> {
3591            let parsed_capabilities = match capabilities {
3592                Some(value) if !value.is_null() && !value.is_undefined() => Some(
3593                    serde_wasm_bindgen::from_value::<crate::signaling::DeviceCapabilities>(value)
3594                        .map_err(|e| JsValue::from_str(&e.to_string()))?,
3595                ),
3596                _ => None,
3597            };
3598
3599            self.inner
3600                .update_device(
3601                    &user_id,
3602                    &device_id,
3603                    device_name.as_deref(),
3604                    parsed_capabilities,
3605                    metadata.as_deref(),
3606                )
3607                .await
3608                .map_err(|e| JsValue::from_str(&e.to_string()))
3609        }
3610
3611        pub async fn delete_device(
3612            &self,
3613            user_id: String,
3614            device_id: String,
3615        ) -> Result<(), JsValue> {
3616            self.inner
3617                .delete_device(&user_id, &device_id)
3618                .await
3619                .map_err(|e| JsValue::from_str(&e.to_string()))
3620        }
3621
3622        pub fn force_reconnect_snapshot(&self) {
3623            self.inner.clone().force_reconnect_snapshot();
3624        }
3625
3626        pub fn stop_presence_loop(&self) {
3627            self.inner.stop_presence_loop();
3628        }
3629
3630        pub fn stop_auto_connect(&self) {
3631            self.inner.stop_auto_connect();
3632            self.inner.stop_browser_auto_connect();
3633        }
3634
3635        pub fn start_auto_connect(
3636            &self,
3637            user_id: String,
3638            local_device_id: String,
3639        ) -> Result<(), JsValue> {
3640            self.inner
3641                .start_browser_auto_connect(user_id, local_device_id)
3642                .map_err(|error| JsValue::from_str(&error.to_string()))
3643        }
3644
3645        pub fn submit_browser_desired_peers(
3646            &self,
3647            revision: u32,
3648            peers_json: String,
3649        ) -> Result<bool, JsValue> {
3650            self.inner
3651                .submit_browser_desired_peers(u64::from(revision), &peers_json)
3652                .map_err(|error| JsValue::from_str(&error.to_string()))
3653        }
3654
3655        pub fn wake_browser_auto_connect(&self) -> bool {
3656            self.inner.wake_browser_auto_connect()
3657        }
3658
3659        pub async fn set_auto_connect_excluded(&self, device_id: String, excluded: bool) {
3660            if excluded {
3661                self.inner.exclude_peer_and_publish(&device_id).await;
3662            } else {
3663                self.inner.unexclude_peer_and_publish(&device_id).await;
3664            }
3665            self.inner.wake_browser_auto_connect();
3666        }
3667
3668        pub fn is_auto_connect_excluded(&self, device_id: String) -> bool {
3669            self.inner.is_auto_connect_excluded(&device_id)
3670        }
3671
3672        pub async fn disconnect_device(
3673            &self,
3674            device_id: String,
3675            node_id_hint: Option<String>,
3676        ) -> Result<JsValue, JsValue> {
3677            let retired = self
3678                .inner
3679                .disconnect_device(&device_id, node_id_hint.as_deref())
3680                .await;
3681            serde_wasm_bindgen::to_value(&retired).map_err(|e| JsValue::from_str(&e.to_string()))
3682        }
3683
3684        pub fn stop_auth_scoped_activity(&self) {
3685            self.inner.stop_auth_scoped_activity();
3686            self.inner.stop_browser_auto_connect();
3687        }
3688
3689        pub fn start_presence_loop(
3690            &self,
3691            user_id: String,
3692            device_name: String,
3693            ticket: String,
3694            metadata: Option<String>,
3695        ) {
3696            self.inner
3697                .clone()
3698                .start_signaling_loop(user_id, device_name, ticket, metadata);
3699        }
3700
3701        pub async fn search_devices(&self, user_id: String) -> Result<JsValue, JsValue> {
3702            let devices = self
3703                .inner
3704                .search_devices(&user_id)
3705                .await
3706                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3707            serde_wasm_bindgen::to_value(&devices).map_err(|e| JsValue::from_str(&e.to_string()))
3708        }
3709
3710        pub async fn devices_with_status(&self, user_id: String) -> Result<JsValue, JsValue> {
3711            let devices = self
3712                .inner
3713                .devices_with_status(&user_id)
3714                .await
3715                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3716            serde_wasm_bindgen::to_value(&devices).map_err(|e| JsValue::from_str(&e.to_string()))
3717        }
3718
3719        pub async fn connect_device(
3720            &self,
3721            device_id: Option<String>,
3722            endpoint_ticket: String,
3723        ) -> Result<JsValue, JsValue> {
3724            let result = self
3725                .inner
3726                .connect_device(device_id.as_deref(), &endpoint_ticket)
3727                .await
3728                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3729            serde_wasm_bindgen::to_value(&result).map_err(|e| JsValue::from_str(&e.to_string()))
3730        }
3731
3732        pub async fn create_session(&self, session_json: String) -> Result<(), JsValue> {
3733            let session: crate::signaling::SignalingSession =
3734                serde_json::from_str(&session_json)
3735                    .map_err(|e| JsValue::from_str(&e.to_string()))?;
3736            self.inner
3737                .create_session(session)
3738                .await
3739                .map_err(|e| JsValue::from_str(&e.to_string()))
3740        }
3741
3742        pub async fn update_session(
3743            &self,
3744            session_id: String,
3745            update_json: String,
3746        ) -> Result<(), JsValue> {
3747            let update_data: serde_json::Value = serde_json::from_str(&update_json)
3748                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3749            self.inner
3750                .update_session(&session_id, update_data)
3751                .await
3752                .map_err(|e| JsValue::from_str(&e.to_string()))
3753        }
3754
3755        // --- Room Management API ---
3756
3757        pub async fn create_room(
3758            &self,
3759            room_id: String,
3760            user_id: String,
3761            ticket_str: String,
3762            my_node_id: String,
3763            tag: String,
3764            max_members: Option<u32>,
3765        ) -> Result<bool, JsValue> {
3766            self.inner
3767                .room
3768                .create_room(
3769                    &room_id,
3770                    &user_id,
3771                    &ticket_str,
3772                    &my_node_id,
3773                    &tag,
3774                    max_members,
3775                )
3776                .await
3777                .map_err(|e| JsValue::from_str(&e.to_string()))
3778        }
3779
3780        pub async fn join_room(
3781            &self,
3782            room_id: String,
3783            user_id: String,
3784            ticket_str: String,
3785            my_node_id: String,
3786            tag: String,
3787        ) -> Result<(), JsValue> {
3788            self.inner
3789                .room
3790                .join_room(&room_id, &user_id, &ticket_str, &my_node_id, &tag)
3791                .await
3792                .map_err(|e| JsValue::from_str(&e.to_string()))
3793        }
3794
3795        pub async fn get_members(
3796            &self,
3797            room_id: String,
3798            my_node_id: String,
3799            tag: String,
3800        ) -> Result<String, JsValue> {
3801            let members = self
3802                .inner
3803                .room
3804                .get_members(&room_id, &my_node_id, &tag)
3805                .await
3806                .map_err(|e| JsValue::from_str(&e.to_string()))?;
3807            serde_json::to_string(&members).map_err(|e| JsValue::from_str(&e.to_string()))
3808        }
3809
3810        pub async fn leave_room(
3811            &self,
3812            room_id: String,
3813            my_node_id: String,
3814            tag: String,
3815        ) -> Result<(), JsValue> {
3816            self.inner
3817                .room
3818                .leave_room(&room_id, &my_node_id, &tag)
3819                .await
3820                .map_err(|e| JsValue::from_str(&e.to_string()))
3821        }
3822    }
3823
3824    #[cfg(feature = "iroh-protocols-wasm")]
3825    #[wasm_bindgen]
3826    impl WasmClient {
3827        /// Hydrate the upstream iroh docs, blobs, and gossip protocols from a
3828        /// host persistence adapter. This reuses the already-bound OpenRTC
3829        /// endpoint and its single router.
3830        #[wasm_bindgen(js_name = __initPersistentIrohProtocols)]
3831        pub async fn init_persistent_iroh_protocols(
3832            &self,
3833            replica_store: JsValue,
3834        ) -> Result<(), JsValue> {
3835            let mut guard = self.persistent_protocols.lock().await;
3836            if guard.is_some() {
3837                return Ok(());
3838            }
3839            let node = self
3840                .inner
3841                .iroh_node
3842                .read()
3843                .await
3844                .as_ref()
3845                .cloned()
3846                .ok_or_else(|| {
3847                    JsValue::from_str("OpenRTC endpoint must be initialized before protocols")
3848                })?;
3849            let store = crate::wasm_docs_persistence::JsReplicaStore::new(replica_store)
3850                .map_err(|error| JsValue::from_str(&error.to_string()))?;
3851            let actor = crate::wasm_docs_persistence::WasmPersistentDocsActor::hydrate(
3852                store,
3853                node.endpoint().clone(),
3854            )
3855            .await
3856            .map_err(|error| JsValue::from_str(&format!("{error:#}")))?;
3857            node.install_standard_protocols(
3858                actor.docs_protocol(),
3859                actor.blobs_protocol(),
3860                actor.gossip_protocol(),
3861            )
3862            .await
3863            .map_err(|error| JsValue::from_str(&format!("{error:#}")))?;
3864            *guard = Some(actor);
3865            Ok(())
3866        }
3867
3868        #[wasm_bindgen(js_name = __importPersistentIrohAuthor)]
3869        pub async fn import_persistent_iroh_author(
3870            &self,
3871            author_secret: Vec<u8>,
3872        ) -> Result<String, JsValue> {
3873            let guard = self.persistent_protocols.lock().await;
3874            let actor = persistent_protocols(&guard)?;
3875            actor
3876                .import_author(author_secret)
3877                .await
3878                .map_err(js_protocol_error)
3879        }
3880
3881        #[wasm_bindgen(js_name = __importPersistentIrohNamespace)]
3882        pub async fn import_persistent_iroh_namespace(
3883            &self,
3884            capability_kind: String,
3885            capability: Vec<u8>,
3886            generation: u64,
3887            share_revision: u64,
3888        ) -> Result<String, JsValue> {
3889            let mut guard = self.persistent_protocols.lock().await;
3890            let actor = persistent_protocols_mut(&mut guard)?;
3891            actor
3892                .import_namespace(
3893                    capability_kind.as_str(),
3894                    capability,
3895                    generation,
3896                    share_revision,
3897                )
3898                .await
3899                .map_err(js_protocol_error)
3900        }
3901
3902        #[wasm_bindgen(js_name = __createPersistentIrohNamespace)]
3903        pub async fn create_persistent_iroh_namespace(
3904            &self,
3905            generation: u64,
3906            share_revision: u64,
3907        ) -> Result<JsValue, JsValue> {
3908            let mut guard = self.persistent_protocols.lock().await;
3909            let descriptor = persistent_protocols_mut(&mut guard)?
3910                .create_namespace(generation, share_revision)
3911                .await
3912                .map_err(js_protocol_error)?;
3913            serde_wasm_bindgen::to_value(&descriptor)
3914                .map_err(|error| JsValue::from_str(&error.to_string()))
3915        }
3916
3917        #[wasm_bindgen(js_name = __importPersistentIrohTicket)]
3918        pub async fn import_persistent_iroh_ticket(
3919            &self,
3920            ticket: String,
3921            generation: u64,
3922            share_revision: u64,
3923        ) -> Result<String, JsValue> {
3924            let mut guard = self.persistent_protocols.lock().await;
3925            persistent_protocols_mut(&mut guard)?
3926                .import_ticket(&ticket, generation, share_revision)
3927                .await
3928                .map_err(js_protocol_error)
3929        }
3930
3931        #[wasm_bindgen(js_name = __sharePersistentIrohNamespace)]
3932        pub async fn share_persistent_iroh_namespace(
3933            &self,
3934            namespace_id: String,
3935            writable: bool,
3936        ) -> Result<String, JsValue> {
3937            let guard = self.persistent_protocols.lock().await;
3938            persistent_protocols(&guard)?
3939                .share(&namespace_id, writable)
3940                .await
3941                .map_err(js_protocol_error)
3942        }
3943
3944        #[wasm_bindgen(js_name = __removePersistentIrohNamespace)]
3945        pub async fn remove_persistent_iroh_namespace(
3946            &self,
3947            namespace_id: String,
3948            generation: u64,
3949            share_revision: u64,
3950        ) -> Result<(), JsValue> {
3951            let mut guard = self.persistent_protocols.lock().await;
3952            persistent_protocols_mut(&mut guard)?
3953                .remove_namespace(&namespace_id, generation, share_revision)
3954                .await
3955                .map_err(js_protocol_error)
3956        }
3957
3958        #[wasm_bindgen(js_name = __putPersistentIrohBytes)]
3959        pub async fn put_persistent_iroh_bytes(
3960            &self,
3961            namespace_id: String,
3962            key: Vec<u8>,
3963            value: Vec<u8>,
3964        ) -> Result<JsValue, JsValue> {
3965            let guard = self.persistent_protocols.lock().await;
3966            let receipt = persistent_protocols(&guard)?
3967                .set_bytes(&namespace_id, key, value)
3968                .await
3969                .map_err(js_protocol_error)?;
3970            serde_wasm_bindgen::to_value(&receipt)
3971                .map_err(|error| JsValue::from_str(&error.to_string()))
3972        }
3973
3974        #[wasm_bindgen(js_name = __setPersistentIrohHash)]
3975        pub async fn set_persistent_iroh_hash(
3976            &self,
3977            namespace_id: String,
3978            key: Vec<u8>,
3979            content_hash: String,
3980            content_length: u64,
3981        ) -> Result<String, JsValue> {
3982            let guard = self.persistent_protocols.lock().await;
3983            persistent_protocols(&guard)?
3984                .set_hash(&namespace_id, key, &content_hash, content_length)
3985                .await
3986                .map_err(js_protocol_error)
3987        }
3988
3989        #[wasm_bindgen(js_name = __deletePersistentIrohPrefix)]
3990        pub async fn delete_persistent_iroh_prefix(
3991            &self,
3992            namespace_id: String,
3993            prefix: Vec<u8>,
3994        ) -> Result<JsValue, JsValue> {
3995            let guard = self.persistent_protocols.lock().await;
3996            let receipt = persistent_protocols(&guard)?
3997                .delete_prefix(&namespace_id, prefix)
3998                .await
3999                .map_err(js_protocol_error)?;
4000            serde_wasm_bindgen::to_value(&receipt)
4001                .map_err(|error| JsValue::from_str(&error.to_string()))
4002        }
4003
4004        #[wasm_bindgen(js_name = __queryPersistentIrohNamespace)]
4005        pub async fn query_persistent_iroh_namespace(
4006            &self,
4007            namespace_id: String,
4008            key_prefix: Vec<u8>,
4009        ) -> Result<JsValue, JsValue> {
4010            let guard = self.persistent_protocols.lock().await;
4011            let entries = persistent_protocols(&guard)?
4012                .query(&namespace_id, key_prefix)
4013                .await
4014                .map_err(js_protocol_error)?;
4015            serde_wasm_bindgen::to_value(&entries)
4016                .map_err(|error| JsValue::from_str(&error.to_string()))
4017        }
4018
4019        #[wasm_bindgen(js_name = __hydratePersistentIrohBlob)]
4020        pub async fn hydrate_persistent_iroh_blob(
4021            &self,
4022            content_hash: String,
4023        ) -> Result<(), JsValue> {
4024            let guard = self.persistent_protocols.lock().await;
4025            persistent_protocols(&guard)?
4026                .hydrate_blob(&content_hash)
4027                .await
4028                .map_err(js_protocol_error)
4029        }
4030
4031        #[wasm_bindgen(js_name = __acknowledgePersistentIrohOutbox)]
4032        pub async fn acknowledge_persistent_iroh_outbox(
4033            &self,
4034            operation_id: String,
4035        ) -> Result<(), JsValue> {
4036            let guard = self.persistent_protocols.lock().await;
4037            persistent_protocols(&guard)?
4038                .acknowledge_outbox(&operation_id)
4039                .await
4040                .map_err(js_protocol_error)
4041        }
4042
4043        #[wasm_bindgen(js_name = __flushPersistentIrohProtocols)]
4044        pub async fn flush_persistent_iroh_protocols(&self) -> Result<(), JsValue> {
4045            let guard = self.persistent_protocols.lock().await;
4046            persistent_protocols(&guard)?
4047                .flush()
4048                .await
4049                .map_err(js_protocol_error)
4050        }
4051
4052        #[wasm_bindgen(js_name = __shutdownPersistentIrohProtocols)]
4053        pub async fn shutdown_persistent_iroh_protocols(&self) -> Result<(), JsValue> {
4054            if let Some(actor) = self.persistent_protocols.lock().await.take() {
4055                actor.shutdown().await.map_err(js_protocol_error)?;
4056            }
4057            Ok(())
4058        }
4059    }
4060
4061    #[cfg(feature = "iroh-protocols-wasm")]
4062    fn persistent_protocols(
4063        guard: &Option<crate::wasm_docs_persistence::WasmPersistentDocsActor>,
4064    ) -> Result<&crate::wasm_docs_persistence::WasmPersistentDocsActor, JsValue> {
4065        guard
4066            .as_ref()
4067            .ok_or_else(|| JsValue::from_str("persistent Iroh protocols are not initialized"))
4068    }
4069
4070    #[cfg(feature = "iroh-protocols-wasm")]
4071    fn persistent_protocols_mut(
4072        guard: &mut Option<crate::wasm_docs_persistence::WasmPersistentDocsActor>,
4073    ) -> Result<&mut crate::wasm_docs_persistence::WasmPersistentDocsActor, JsValue> {
4074        guard
4075            .as_mut()
4076            .ok_or_else(|| JsValue::from_str("persistent Iroh protocols are not initialized"))
4077    }
4078
4079    #[cfg(feature = "iroh-protocols-wasm")]
4080    fn js_protocol_error(error: impl std::fmt::Display) -> JsValue {
4081        JsValue::from_str(&format!("{error:#}"))
4082    }
4083
4084    #[wasm_bindgen(start)]
4085    pub fn start() {
4086        console_error_panic_hook::set_once();
4087    }
4088}