islands-build 0.1.3

Layout-agnostic build pipeline for islands.rs apps: WASM bundling, the V8 module-namespace patch, per-page CSS, and content-hash manifests. Composed by a thin xtask in any workspace.
Documentation
//! The V8 module-namespace patch — a first-class, tested, public part of the
//! build contract.
//!
//! Each page's wasm-bindgen-emitted JS imports `* as import1` from the shared
//! `islands-core` URL and passes that namespace directly to
//! `WebAssembly.instantiate` as the importObject value. V8 rejects
//! module-namespace objects there with `Import #N "<url>": module is not an
//! object or function`. We patch the emitted JS so the namespace is spread into a
//! plain prototype-less object — same shape, V8 accepts it. **Skipping this is
//! fatal: the page WASM does not load.**
//!
//! Under a content-hashing build, the hashing pass rewrites basenames inside every
//! JS file (e.g. `islands_core.js` → `islands_core.a1b2c3d4.js`); the patch token
//! [`PATCH_TOKEN`] MUST survive that rewrite, and the importObject *key* for the
//! runtime is surgically restored to its unhashed form (see
//! [`rewrite_basenames_in_js`]) so it matches the WASM's compiled import-module
//! name.

use std::fs;
use std::path::Path;

use anyhow::{Context, Result};

/// The runtime's fixed, never-hashed import URL — baked into every page WASM's
/// import section by wasm-bindgen's `raw_module = "..."`.
pub const RUNTIME_IMPORT_URL: &str = "/static/islands-core/islands_core.js";

/// Literal substring present in every patched page JS. Build verification and the
/// content-hash pass both assert this token is present (and survives hashing).
pub const PATCH_TOKEN: &str = "Object.assign(Object.create(null), import1)";

/// The literal pre-patch line wasm-bindgen emits for the page→core import value.
const NEEDLE: &str = "\"/static/islands-core/islands_core.js\": import1,";

/// The post-patch replacement line.
const PATCHED: &str =
    "\"/static/islands-core/islands_core.js\": Object.assign(Object.create(null), import1),";

/// Apply the V8 namespace patch to a page's emitted JS file, in place.
///
/// Returns `Ok(true)` if patched, `Ok(false)` if the needle was absent (already
/// patched, or wasm-bindgen's output shape changed). Callers that just emitted a
/// fresh bundle should follow up with [`assert_patched`] to turn the second case
/// into a loud failure rather than a silently non-loading bundle.
pub fn patch_page_js(js_path: &Path) -> Result<bool> {
    let content =
        fs::read_to_string(js_path).with_context(|| format!("reading {}", js_path.display()))?;
    if !content.contains(NEEDLE) {
        return Ok(false);
    }
    let new_content = content.replacen(NEEDLE, PATCHED, 1);
    fs::write(js_path, new_content)
        .with_context(|| format!("writing patched {}", js_path.display()))?;
    Ok(true)
}

/// In-memory analogue of [`patch_page_js`] for unit tests.
pub fn patch_page_source(source: &str) -> String {
    if !source.contains(NEEDLE) {
        return source.to_owned();
    }
    source.replacen(NEEDLE, PATCHED, 1)
}

/// Loud guard against wasm-bindgen output-shape drift: assert a freshly emitted
/// page JS actually carries the patch. If neither the patched token nor the raw
/// needle is present, wasm-bindgen changed its emitted shape and [`NEEDLE`] must
/// be updated — fail with a clear message instead of shipping a bundle V8 will
/// reject at runtime.
pub fn assert_patched(js_path: &Path) -> Result<()> {
    let content =
        fs::read_to_string(js_path).with_context(|| format!("reading {}", js_path.display()))?;
    if content.contains(PATCH_TOKEN) {
        return Ok(());
    }
    anyhow::bail!(
        "V8 namespace patch missing from {} — wasm-bindgen's emitted import shape changed. \
         Neither the patch token (`{PATCH_TOKEN}`) nor the expected needle (`{NEEDLE}`) is \
         present. Update islands_build::patch::NEEDLE to match the new wasm-bindgen output.",
        js_path.display()
    )
}

/// In-memory analogue of a content-hashing build's basename rewrite: replace every
/// `original → hashed` basename, then surgically restore the importObject KEY for
/// the runtime to its unhashed form (so the WASM's two-level import still
/// resolves). Mirrors [`crate::hashing`]'s rewrite step.
///
/// Each basename is replaced only where it appears as a *whole filename token* — not
/// flanked by other filename characters. A naive substring replace corrupts a longer
/// basename that contains a shorter one (e.g. `worker.js` inside `pasifico_worker.js`),
/// and because `rename_map` arrives in `HashMap` order the corruption is
/// nondeterministic: when the shorter name is replaced first, the longer reference is
/// rewritten to a hashed filename that never exists, so the asset 404s at runtime.
/// Whole-token matching makes the rewrite correct and order-independent.
pub fn rewrite_basenames_in_js(source: &str, rename_map: &[(String, String)]) -> String {
    let mut content = source.to_owned();
    for (original, hashed) in rename_map {
        content = replace_whole_basename(&content, original, hashed);
    }
    for (original, hashed) in rename_map {
        if !original.contains("islands_core.js") {
            continue;
        }
        let rewritten_key_line = format!(
            r#""/static/islands-core/{hashed}": Object.assign(Object.create(null), import1)"#
        );
        let original_key_line = format!(
            r#""/static/islands-core/{original}": Object.assign(Object.create(null), import1)"#
        );
        content = content.replace(&rewritten_key_line, &original_key_line);
    }
    content
}

/// Whether `c` can be part of a filename token. A basename flanked by one of these
/// is a sub-token of a longer name (e.g. `worker.js` inside `pasifico_worker.js`),
/// not a standalone cross-asset reference.
fn is_filename_char(c: char) -> bool {
    c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')
}

/// Replace every *whole-filename* occurrence of `original` with `hashed`, leaving any
/// occurrence flanked by another filename character (a sub-token of a longer basename)
/// untouched. A real cross-asset reference is always a complete path component —
/// preceded by `/`, a quote, whitespace, or the start of input — so whole-token
/// matching keeps every genuine reference while making the rewrite independent of the
/// order `rename_map` is iterated: no `original` can match inside another basename or
/// inside an already-substituted hashed name.
fn replace_whole_basename(content: &str, original: &str, hashed: &str) -> String {
    if original.is_empty() {
        return content.to_owned();
    }
    let mut result = String::with_capacity(content.len());
    let mut cursor = 0;
    while let Some(offset) = content[cursor..].find(original) {
        let start = cursor + offset;
        let end = start + original.len();
        let preceded = content[..start]
            .chars()
            .next_back()
            .is_some_and(is_filename_char);
        let followed = content[end..].chars().next().is_some_and(is_filename_char);
        result.push_str(&content[cursor..start]);
        result.push_str(if preceded || followed { original } else { hashed });
        cursor = end;
    }
    result.push_str(&content[cursor..]);
    result
}

/// Apply the snippet-namespace patch to the runtime's own JS, in place.
///
/// The `nav` feature's `inline_js` glue makes wasm-bindgen emit
/// `import * as importN from "./snippets/.../inlineK.js"` inside `islands_core.js`
/// and pass that raw module namespace to `WebAssembly.instantiate` — the same V8
/// rejection [`patch_page_js`] dodges. This wraps each such importObject *value*;
/// the *key* (snippet specifier) is left untouched so it keeps matching the WASM's
/// baked import-module name (snippets are excluded from the content-hashing pass
/// for the same reason). Returns `Ok(true)` if anything was wrapped.
pub fn patch_runtime_snippets(js_path: &Path) -> Result<bool> {
    let content =
        fs::read_to_string(js_path).with_context(|| format!("reading {}", js_path.display()))?;
    let patched = wrap_snippet_namespace_imports(&content);
    if patched == content {
        return Ok(false);
    }
    fs::write(js_path, &patched)
        .with_context(|| format!("writing patched {}", js_path.display()))?;
    Ok(true)
}

/// In-memory analogue of [`patch_runtime_snippets`]: wrap every snippet
/// importObject value line. Idempotent; returns the source unchanged when there
/// are no snippet imports (no `inline_js`, or already patched).
pub fn wrap_snippet_namespace_imports(source: &str) -> String {
    let mut changed = false;
    let mut lines: Vec<String> = Vec::with_capacity(source.lines().count());
    for line in source.lines() {
        match wrap_snippet_import_value(line) {
            Some(wrapped) => {
                lines.push(wrapped);
                changed = true;
            }
            None => lines.push(line.to_owned()),
        }
    }
    if !changed {
        return source.to_owned();
    }
    let mut output = lines.join("\n");
    if source.ends_with('\n') {
        output.push('\n');
    }
    output
}

/// If `line` is a snippet importObject *value* entry of the shape
/// `    "<…/snippets/…>.js": importN,`, return it with the bare `importN`
/// namespace wrapped in `Object.assign(Object.create(null), importN)`. `None` for
/// any other line — including the `import * as …` statement (no `": "` value
/// separator), a single-quoted named import, or an already-wrapped value.
fn wrap_snippet_import_value(line: &str) -> Option<String> {
    if !line.contains("/snippets/") {
        return None;
    }
    let separator = "\": ";
    let separator_index = line.find(separator)?;
    let key_through_separator = &line[..separator_index + separator.len()];
    let value_part = line[separator_index + separator.len()..].trim_end();
    let identifier = value_part.strip_suffix(',')?;
    // Must be a bare `import<digits>` namespace identifier — not an already
    // wrapped `Object.assign(...)` value.
    let digits = identifier.strip_prefix("import")?;
    if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
        return None;
    }
    Some(format!(
        "{key_through_separator}Object.assign(Object.create(null), {identifier}),"
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn patches_the_page_core_import_value() {
        let source = format!("    {NEEDLE}\n");
        let patched = patch_page_source(&source);
        assert!(patched.contains(PATCH_TOKEN), "got: {patched}");
    }

    #[test]
    fn page_patch_is_idempotent_in_memory() {
        let once = patch_page_source(&format!("    {NEEDLE}\n"));
        let twice = patch_page_source(&once);
        assert_eq!(once, twice);
    }

    #[test]
    fn wraps_snippet_importobject_namespace_value() {
        let source = "    \"./snippets/islands-runtime-abc/inline0.js\": import1,\n";
        let output = wrap_snippet_namespace_imports(source);
        assert!(
            output.contains(
                "\"./snippets/islands-runtime-abc/inline0.js\": Object.assign(Object.create(null), import1),"
            ),
            "snippet importObject value must be namespace-wrapped; got: {output}"
        );
    }

    #[test]
    fn leaves_import_statement_and_non_snippet_lines_untouched() {
        let source = concat!(
            "import * as import1 from \"./snippets/islands-runtime-abc/inline0.js\";\n",
            "    \"__wbindgen_placeholder__\": import1,\n",
        );
        assert_eq!(wrap_snippet_namespace_imports(source), source);
    }

    #[test]
    fn snippet_wrap_is_idempotent() {
        let source = "    \"./snippets/x/inline0.js\": import2,\n";
        let once = wrap_snippet_namespace_imports(source);
        let twice = wrap_snippet_namespace_imports(&once);
        assert_eq!(once, twice, "re-running the wrap must be a no-op");
    }

    #[test]
    fn page_patch_token_survives_basename_rewrite() {
        // The patch token must survive the content-hash rewrite, and the
        // importObject key for the runtime must be restored to its unhashed form.
        let patched = patch_page_source(&format!("    {NEEDLE}\n"));
        let rename_map = vec![(
            "islands_core.js".to_owned(),
            "islands_core.deadbeef.js".to_owned(),
        )];
        let after = rewrite_basenames_in_js(&patched, &rename_map);
        assert!(after.contains(PATCH_TOKEN), "patch token lost: {after}");
        assert!(
            after.contains("/static/islands-core/islands_core.js\":"),
            "runtime importObject key must be restored unhashed: {after}"
        );
    }

    #[test]
    fn rewrite_is_deterministic_for_substring_basenames() {
        // `worker.js` is a substring of `pasifico_worker.js`. A naive substring
        // replace rewrites the glue import differently depending on which entry it
        // processes first; the whole-token rewrite must produce the same correct
        // result regardless of `rename_map` order.
        let source = "import init, { start } from \"./pasifico_worker.js\";\n";
        let worker = ("worker.js".to_owned(), "worker.aaaaaaaa.js".to_owned());
        let glue = (
            "pasifico_worker.js".to_owned(),
            "pasifico_worker.bbbbbbbb.js".to_owned(),
        );

        let forward = rewrite_basenames_in_js(source, &[worker.clone(), glue.clone()]);
        let reverse = rewrite_basenames_in_js(source, &[glue, worker]);

        let expected = "import init, { start } from \"./pasifico_worker.bbbbbbbb.js\";\n";
        assert_eq!(
            forward, expected,
            "shorter-first order corrupted the longer basename reference"
        );
        assert_eq!(reverse, expected, "longer-first order changed the result");
        assert_eq!(forward, reverse, "rewrite must be order-independent");
    }

    #[test]
    fn snippet_key_stays_unhashed_through_rewrite() {
        let wrapped = wrap_snippet_namespace_imports("    \"./snippets/r/inline0.js\": import1,\n");
        let rename_map = vec![(
            "islands_core.js".to_owned(),
            "islands_core.deadbeef.js".to_owned(),
        )];
        let after = rewrite_basenames_in_js(&wrapped, &rename_map);
        assert!(
            after.contains(
                "\"./snippets/r/inline0.js\": Object.assign(Object.create(null), import1),"
            ),
            "snippet key must stay unhashed and the wrap must survive; got: {after}"
        );
    }
}