Skip to main content

islands_build/
patch.rs

1//! The V8 module-namespace patch — a first-class, tested, public part of the
2//! build contract.
3//!
4//! Each page's wasm-bindgen-emitted JS imports `* as import1` from the shared
5//! `islands-core` URL and passes that namespace directly to
6//! `WebAssembly.instantiate` as the importObject value. V8 rejects
7//! module-namespace objects there with `Import #N "<url>": module is not an
8//! object or function`. We patch the emitted JS so the namespace is spread into a
9//! plain prototype-less object — same shape, V8 accepts it. **Skipping this is
10//! fatal: the page WASM does not load.**
11//!
12//! Under a content-hashing build, the hashing pass rewrites basenames inside every
13//! JS file (e.g. `islands_core.js` → `islands_core.a1b2c3d4.js`); the patch token
14//! [`PATCH_TOKEN`] MUST survive that rewrite, and the importObject *key* for the
15//! runtime is surgically restored to its unhashed form (see
16//! [`rewrite_basenames_in_js`]) so it matches the WASM's compiled import-module
17//! name.
18
19use std::fs;
20use std::path::Path;
21
22use anyhow::{Context, Result};
23
24/// The runtime's fixed, never-hashed import URL — baked into every page WASM's
25/// import section by wasm-bindgen's `raw_module = "..."`.
26pub const RUNTIME_IMPORT_URL: &str = "/static/islands-core/islands_core.js";
27
28/// Literal substring present in every patched page JS. Build verification and the
29/// content-hash pass both assert this token is present (and survives hashing).
30pub const PATCH_TOKEN: &str = "Object.assign(Object.create(null), import1)";
31
32/// The literal pre-patch line wasm-bindgen emits for the page→core import value.
33const NEEDLE: &str = "\"/static/islands-core/islands_core.js\": import1,";
34
35/// The post-patch replacement line.
36const PATCHED: &str =
37    "\"/static/islands-core/islands_core.js\": Object.assign(Object.create(null), import1),";
38
39/// Apply the V8 namespace patch to a page's emitted JS file, in place.
40///
41/// Returns `Ok(true)` if patched, `Ok(false)` if the needle was absent (already
42/// patched, or wasm-bindgen's output shape changed). Callers that just emitted a
43/// fresh bundle should follow up with [`assert_patched`] to turn the second case
44/// into a loud failure rather than a silently non-loading bundle.
45pub fn patch_page_js(js_path: &Path) -> Result<bool> {
46    let content =
47        fs::read_to_string(js_path).with_context(|| format!("reading {}", js_path.display()))?;
48    if !content.contains(NEEDLE) {
49        return Ok(false);
50    }
51    let new_content = content.replacen(NEEDLE, PATCHED, 1);
52    fs::write(js_path, new_content)
53        .with_context(|| format!("writing patched {}", js_path.display()))?;
54    Ok(true)
55}
56
57/// In-memory analogue of [`patch_page_js`] for unit tests.
58pub fn patch_page_source(source: &str) -> String {
59    if !source.contains(NEEDLE) {
60        return source.to_owned();
61    }
62    source.replacen(NEEDLE, PATCHED, 1)
63}
64
65/// Loud guard against wasm-bindgen output-shape drift: assert a freshly emitted
66/// page JS actually carries the patch. If neither the patched token nor the raw
67/// needle is present, wasm-bindgen changed its emitted shape and [`NEEDLE`] must
68/// be updated — fail with a clear message instead of shipping a bundle V8 will
69/// reject at runtime.
70pub fn assert_patched(js_path: &Path) -> Result<()> {
71    let content =
72        fs::read_to_string(js_path).with_context(|| format!("reading {}", js_path.display()))?;
73    if content.contains(PATCH_TOKEN) {
74        return Ok(());
75    }
76    anyhow::bail!(
77        "V8 namespace patch missing from {} — wasm-bindgen's emitted import shape changed. \
78         Neither the patch token (`{PATCH_TOKEN}`) nor the expected needle (`{NEEDLE}`) is \
79         present. Update islands_build::patch::NEEDLE to match the new wasm-bindgen output.",
80        js_path.display()
81    )
82}
83
84/// In-memory analogue of a content-hashing build's basename rewrite: replace every
85/// `original → hashed` basename, then surgically restore the importObject KEY for
86/// the runtime to its unhashed form (so the WASM's two-level import still
87/// resolves). Mirrors [`crate::hashing`]'s rewrite step.
88pub fn rewrite_basenames_in_js(source: &str, rename_map: &[(String, String)]) -> String {
89    let mut content = source.to_owned();
90    for (original, hashed) in rename_map {
91        if content.contains(original.as_str()) {
92            content = content.replace(original.as_str(), hashed.as_str());
93        }
94    }
95    for (original, hashed) in rename_map {
96        if !original.contains("islands_core.js") {
97            continue;
98        }
99        let rewritten_key_line = format!(
100            r#""/static/islands-core/{hashed}": Object.assign(Object.create(null), import1)"#
101        );
102        let original_key_line = format!(
103            r#""/static/islands-core/{original}": Object.assign(Object.create(null), import1)"#
104        );
105        content = content.replace(&rewritten_key_line, &original_key_line);
106    }
107    content
108}
109
110/// Apply the snippet-namespace patch to the runtime's own JS, in place.
111///
112/// The `nav` feature's `inline_js` glue makes wasm-bindgen emit
113/// `import * as importN from "./snippets/.../inlineK.js"` inside `islands_core.js`
114/// and pass that raw module namespace to `WebAssembly.instantiate` — the same V8
115/// rejection [`patch_page_js`] dodges. This wraps each such importObject *value*;
116/// the *key* (snippet specifier) is left untouched so it keeps matching the WASM's
117/// baked import-module name (snippets are excluded from the content-hashing pass
118/// for the same reason). Returns `Ok(true)` if anything was wrapped.
119pub fn patch_runtime_snippets(js_path: &Path) -> Result<bool> {
120    let content =
121        fs::read_to_string(js_path).with_context(|| format!("reading {}", js_path.display()))?;
122    let patched = wrap_snippet_namespace_imports(&content);
123    if patched == content {
124        return Ok(false);
125    }
126    fs::write(js_path, &patched)
127        .with_context(|| format!("writing patched {}", js_path.display()))?;
128    Ok(true)
129}
130
131/// In-memory analogue of [`patch_runtime_snippets`]: wrap every snippet
132/// importObject value line. Idempotent; returns the source unchanged when there
133/// are no snippet imports (no `inline_js`, or already patched).
134pub fn wrap_snippet_namespace_imports(source: &str) -> String {
135    let mut changed = false;
136    let mut lines: Vec<String> = Vec::with_capacity(source.lines().count());
137    for line in source.lines() {
138        match wrap_snippet_import_value(line) {
139            Some(wrapped) => {
140                lines.push(wrapped);
141                changed = true;
142            }
143            None => lines.push(line.to_owned()),
144        }
145    }
146    if !changed {
147        return source.to_owned();
148    }
149    let mut output = lines.join("\n");
150    if source.ends_with('\n') {
151        output.push('\n');
152    }
153    output
154}
155
156/// If `line` is a snippet importObject *value* entry of the shape
157/// `    "<…/snippets/…>.js": importN,`, return it with the bare `importN`
158/// namespace wrapped in `Object.assign(Object.create(null), importN)`. `None` for
159/// any other line — including the `import * as …` statement (no `": "` value
160/// separator), a single-quoted named import, or an already-wrapped value.
161fn wrap_snippet_import_value(line: &str) -> Option<String> {
162    if !line.contains("/snippets/") {
163        return None;
164    }
165    let separator = "\": ";
166    let separator_index = line.find(separator)?;
167    let key_through_separator = &line[..separator_index + separator.len()];
168    let value_part = line[separator_index + separator.len()..].trim_end();
169    let identifier = value_part.strip_suffix(',')?;
170    // Must be a bare `import<digits>` namespace identifier — not an already
171    // wrapped `Object.assign(...)` value.
172    let digits = identifier.strip_prefix("import")?;
173    if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
174        return None;
175    }
176    Some(format!(
177        "{key_through_separator}Object.assign(Object.create(null), {identifier}),"
178    ))
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn patches_the_page_core_import_value() {
187        let source = format!("    {NEEDLE}\n");
188        let patched = patch_page_source(&source);
189        assert!(patched.contains(PATCH_TOKEN), "got: {patched}");
190    }
191
192    #[test]
193    fn page_patch_is_idempotent_in_memory() {
194        let once = patch_page_source(&format!("    {NEEDLE}\n"));
195        let twice = patch_page_source(&once);
196        assert_eq!(once, twice);
197    }
198
199    #[test]
200    fn wraps_snippet_importobject_namespace_value() {
201        let source = "    \"./snippets/islands-runtime-abc/inline0.js\": import1,\n";
202        let output = wrap_snippet_namespace_imports(source);
203        assert!(
204            output.contains(
205                "\"./snippets/islands-runtime-abc/inline0.js\": Object.assign(Object.create(null), import1),"
206            ),
207            "snippet importObject value must be namespace-wrapped; got: {output}"
208        );
209    }
210
211    #[test]
212    fn leaves_import_statement_and_non_snippet_lines_untouched() {
213        let source = concat!(
214            "import * as import1 from \"./snippets/islands-runtime-abc/inline0.js\";\n",
215            "    \"__wbindgen_placeholder__\": import1,\n",
216        );
217        assert_eq!(wrap_snippet_namespace_imports(source), source);
218    }
219
220    #[test]
221    fn snippet_wrap_is_idempotent() {
222        let source = "    \"./snippets/x/inline0.js\": import2,\n";
223        let once = wrap_snippet_namespace_imports(source);
224        let twice = wrap_snippet_namespace_imports(&once);
225        assert_eq!(once, twice, "re-running the wrap must be a no-op");
226    }
227
228    #[test]
229    fn page_patch_token_survives_basename_rewrite() {
230        // The patch token must survive the content-hash rewrite, and the
231        // importObject key for the runtime must be restored to its unhashed form.
232        let patched = patch_page_source(&format!("    {NEEDLE}\n"));
233        let rename_map = vec![(
234            "islands_core.js".to_owned(),
235            "islands_core.deadbeef.js".to_owned(),
236        )];
237        let after = rewrite_basenames_in_js(&patched, &rename_map);
238        assert!(after.contains(PATCH_TOKEN), "patch token lost: {after}");
239        assert!(
240            after.contains("/static/islands-core/islands_core.js\":"),
241            "runtime importObject key must be restored unhashed: {after}"
242        );
243    }
244
245    #[test]
246    fn snippet_key_stays_unhashed_through_rewrite() {
247        let wrapped = wrap_snippet_namespace_imports("    \"./snippets/r/inline0.js\": import1,\n");
248        let rename_map = vec![(
249            "islands_core.js".to_owned(),
250            "islands_core.deadbeef.js".to_owned(),
251        )];
252        let after = rewrite_basenames_in_js(&wrapped, &rename_map);
253        assert!(
254            after.contains(
255                "\"./snippets/r/inline0.js\": Object.assign(Object.create(null), import1),"
256            ),
257            "snippet key must stay unhashed and the wrap must survive; got: {after}"
258        );
259    }
260}