islands-runtime 0.1.1

The shared WASM runtime for islands.rs: reactive Signal/Scope/effect primitives and idempotent island mounting, emitted as islands_core.{js,wasm}.
Documentation
//! The route to bundle navigation manifest: schema, in-memory cache, and the
//! one-time fetch (contract step 1).
//!
//! The server emits `GET /static/nav-manifest.json` as
//! `{"routes": {"/": {"js": "...", "wasm": "...", "css": "..."}, ...}}` where
//! every entry's `js` URL is always present (and already carries `/static/` plus
//! any `ISLANDS_ASSET_PREFIX` — the client joins nothing). `wasm` and `css` may
//! be omitted. The nav layer fetches this once on init, caches it in memory, and
//! resolves each navigated URL's pathname to its entry. A cold cache miss
//! triggers a blocking fetch before the nav can resolve.

use std::cell::RefCell;
use std::collections::HashMap;

use serde::Deserialize;
use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;
use wasm_bindgen_futures::JsFuture;

/// Path the server serves the navigation manifest at. Fetched verbatim — the
/// server already prefixed it. Kept as a basename-bearing literal so xtask's
/// `--release` rewrite pass leaves it untouched (it is not an asset reference).
pub(crate) const NAV_MANIFEST_PATH: &str = "/static/nav-manifest.json";

/// The content-negotiation header a client nav fetch sends so the server can
/// render streaming routes inline (non-streamed) for the morph (contract step 3
/// / AC-V13). Value is `"1"`.
pub(crate) const NAV_HEADER_NAME: &str = "X-Islands-Nav";
/// The value sent for [`NAV_HEADER_NAME`].
pub(crate) const NAV_HEADER_VALUE: &str = "1";

/// One route's resolved asset URLs. `js` is the page bundle's ES-module shim to
/// `import()`; `wasm`/`css` are advisory (the JS shim fetches its own `.wasm`,
/// and `<link>`s are reconciled by the head morph), so they are optional and the
/// nav layer only strictly needs `js`.
///
/// `wasm`/`css` are part of the served wire schema and are deserialized even
/// though forward nav reads only `js`; the prefetch tier (Phase 3f) and any
/// future `<link rel=preload>` warming consult them. They are kept here so the
/// schema round-trips faithfully rather than silently dropping fields.
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct NavEntry {
    /// The page bundle's JS module URL — always present; `import()`ed on nav.
    pub js: String,
    /// The page WASM URL, when the server chose to advertise it.
    #[serde(default)]
    #[allow(dead_code)]
    pub wasm: Option<String>,
    /// The page CSS URL, when the server chose to advertise it.
    #[serde(default)]
    #[allow(dead_code)]
    pub css: Option<String>,
}

/// The full route to bundle map, deserialized from `/static/nav-manifest.json`.
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct NavManifest {
    /// Map from route pathname (e.g. `"/about"`) to that route's asset URLs.
    pub routes: HashMap<String, NavEntry>,
}

impl NavManifest {
    /// Parse a manifest from its JSON text, surfacing the raw `serde_json` error.
    ///
    /// This is the pure, target-agnostic parse used by the native tests — it
    /// never touches `JsValue` (constructing one aborts on a non-wasm target),
    /// so the wire schema can be exercised under a plain `cargo test`.
    pub(crate) fn parse(json: &str) -> Result<NavManifest, serde_json::Error> {
        serde_json::from_str(json)
    }

    /// Parse a manifest from its JSON text, mapping any error to a `JsValue` for
    /// the browser fetch path.
    pub(crate) fn from_json(json: &str) -> Result<NavManifest, JsValue> {
        NavManifest::parse(json)
            .map_err(|error| JsValue::from_str(&format!("nav-manifest parse error: {error}")))
    }

    /// Look up the entry for a route pathname, if present.
    pub(crate) fn entry_for(&self, pathname: &str) -> Option<&NavEntry> {
        self.routes.get(pathname)
    }
}

thread_local! {
    /// The in-memory manifest cache. `None` until the first successful fetch;
    /// `Some` thereafter so subsequent navs resolve without a round trip.
    static MANIFEST_CACHE: RefCell<Option<NavManifest>> = const { RefCell::new(None) };
}

/// Whether the manifest has already been fetched and cached.
pub(crate) fn is_cached() -> bool {
    MANIFEST_CACHE.with(|cache| cache.borrow().is_some())
}

/// Run `with_entry` against the cached entry for `pathname`, returning its
/// result, or `None` when the manifest is not cached or has no such route.
///
/// Takes a closure rather than returning a borrowed `&NavEntry` because the
/// entry lives behind the thread-local `RefCell`; cloning out the small URL set
/// is what the caller does (`resolved_entry`).
pub(crate) fn resolved_entry(pathname: &str) -> Option<NavEntry> {
    MANIFEST_CACHE.with(|cache| {
        cache
            .borrow()
            .as_ref()
            .and_then(|manifest| manifest.entry_for(pathname).cloned())
    })
}

/// Store a freshly fetched manifest in the cache, replacing any prior value.
fn store(manifest: NavManifest) {
    MANIFEST_CACHE.with(|cache| *cache.borrow_mut() = Some(manifest));
}

/// Fetch `/static/nav-manifest.json`, parse it, and populate the cache.
///
/// Idempotent in effect: a second successful fetch simply overwrites the cache
/// with an identical value. Called lazily from the first navigation's cold-miss
/// path (there is no eager init prefetch). A 404 caches an empty manifest (the
/// app wired no nav manifest); other failures propagate so the caller falls back
/// to a full document load rather than navigate blind.
pub(crate) async fn fetch_and_cache() -> Result<(), JsValue> {
    let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
    let response_value = JsFuture::from(window.fetch_with_str(NAV_MANIFEST_PATH)).await?;
    let response: web_sys::Response = response_value.dyn_into()?;
    // A 404 means the app simply did not wire a nav manifest (e.g. it does not use
    // client navigation). That is not an error: cache an empty manifest so we
    // neither refetch nor log it. Same-origin clicks then resolve to no entry and
    // fall back to a full document load — exactly the non-nav behavior.
    if response.status() == 404 {
        store(NavManifest {
            routes: HashMap::new(),
        });
        return Ok(());
    }
    if !response.ok() {
        return Err(JsValue::from_str(&format!(
            "nav-manifest fetch failed: HTTP {}",
            response.status()
        )));
    }
    let text_value = JsFuture::from(response.text()?).await?;
    let json = text_value
        .as_string()
        .ok_or_else(|| JsValue::from_str("nav-manifest body was not a string"))?;
    store(NavManifest::from_json(&json)?);
    Ok(())
}

/// Ensure the manifest is cached, fetching it if a prior init prefetch has not
/// landed yet (the cold-miss blocking fetch of contract step 1).
pub(crate) async fn ensure_cached() -> Result<(), JsValue> {
    if is_cached() {
        return Ok(());
    }
    fetch_and_cache().await
}

#[cfg(test)]
mod tests {
    //! Native deserialization tests for the manifest schema. The fetch path is
    //! browser-only (compiled, not run here); the JSON shape is what we can and
    //! must pin natively so a server/client schema drift is caught.
    //!
    //! These call [`NavManifest::parse`] (the `serde_json`-error form) rather
    //! than `from_json` (the `JsValue`-error form): constructing a `JsValue`
    //! aborts on a non-wasm target, so the native tests stay off that path.

    use super::NavManifest;

    #[test]
    fn parses_full_entry_with_all_urls() {
        let json = r#"{
            "routes": {
                "/": {
                    "js": "/static/page-home/page_home.abc123.js",
                    "wasm": "/static/page-home/page_home.def456.wasm",
                    "css": "/static/page-home/page-home.ghi789.css"
                }
            }
        }"#;
        let manifest = NavManifest::parse(json).expect("parse");
        let entry = manifest.entry_for("/").expect("route present");
        assert_eq!(entry.js, "/static/page-home/page_home.abc123.js");
        assert_eq!(
            entry.wasm.as_deref(),
            Some("/static/page-home/page_home.def456.wasm")
        );
        assert_eq!(
            entry.css.as_deref(),
            Some("/static/page-home/page-home.ghi789.css")
        );
    }

    #[test]
    fn parses_entry_with_only_js() {
        // wasm and css are optional; js alone is a valid entry.
        let json = r#"{ "routes": { "/about": { "js": "/static/page-about/page_about.js" } } }"#;
        let manifest = NavManifest::parse(json).expect("parse");
        let entry = manifest.entry_for("/about").expect("route present");
        assert_eq!(entry.js, "/static/page-about/page_about.js");
        assert!(entry.wasm.is_none());
        assert!(entry.css.is_none());
    }

    #[test]
    fn unknown_route_resolves_to_none() {
        let json = r#"{ "routes": { "/": { "js": "/static/page-home/page_home.js" } } }"#;
        let manifest = NavManifest::parse(json).expect("parse");
        assert!(manifest.entry_for("/missing").is_none());
    }

    #[test]
    fn empty_routes_map_is_valid() {
        let manifest = NavManifest::parse(r#"{ "routes": {} }"#).expect("parse");
        assert!(manifest.entry_for("/").is_none());
    }

    #[test]
    fn malformed_json_is_an_error() {
        assert!(NavManifest::parse("not json").is_err());
        // Missing the required `js` field on an entry is an error.
        assert!(NavManifest::parse(r#"{ "routes": { "/": {} } }"#).is_err());
    }
}