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.
88///
89/// Each basename is replaced only where it appears as a *whole filename token* — not
90/// flanked by other filename characters. A naive substring replace corrupts a longer
91/// basename that contains a shorter one (e.g. `worker.js` inside `pasifico_worker.js`),
92/// and because `rename_map` arrives in `HashMap` order the corruption is
93/// nondeterministic: when the shorter name is replaced first, the longer reference is
94/// rewritten to a hashed filename that never exists, so the asset 404s at runtime.
95/// Whole-token matching makes the rewrite correct and order-independent.
96pub fn rewrite_basenames_in_js(source: &str, rename_map: &[(String, String)]) -> String {
97    let mut content = source.to_owned();
98    for (original, hashed) in rename_map {
99        content = replace_whole_basename(&content, original, hashed);
100    }
101    for (original, hashed) in rename_map {
102        if !original.contains("islands_core.js") {
103            continue;
104        }
105        let rewritten_key_line = format!(
106            r#""/static/islands-core/{hashed}": Object.assign(Object.create(null), import1)"#
107        );
108        let original_key_line = format!(
109            r#""/static/islands-core/{original}": Object.assign(Object.create(null), import1)"#
110        );
111        content = content.replace(&rewritten_key_line, &original_key_line);
112    }
113    content
114}
115
116/// Whether `c` can be part of a filename token. A basename flanked by one of these
117/// is a sub-token of a longer name (e.g. `worker.js` inside `pasifico_worker.js`),
118/// not a standalone cross-asset reference.
119fn is_filename_char(c: char) -> bool {
120    c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')
121}
122
123/// Replace every *whole-filename* occurrence of `original` with `hashed`, leaving any
124/// occurrence flanked by another filename character (a sub-token of a longer basename)
125/// untouched. A real cross-asset reference is always a complete path component —
126/// preceded by `/`, a quote, whitespace, or the start of input — so whole-token
127/// matching keeps every genuine reference while making the rewrite independent of the
128/// order `rename_map` is iterated: no `original` can match inside another basename or
129/// inside an already-substituted hashed name.
130fn replace_whole_basename(content: &str, original: &str, hashed: &str) -> String {
131    if original.is_empty() {
132        return content.to_owned();
133    }
134    let mut result = String::with_capacity(content.len());
135    let mut cursor = 0;
136    while let Some(offset) = content[cursor..].find(original) {
137        let start = cursor + offset;
138        let end = start + original.len();
139        let preceded = content[..start]
140            .chars()
141            .next_back()
142            .is_some_and(is_filename_char);
143        let followed = content[end..].chars().next().is_some_and(is_filename_char);
144        result.push_str(&content[cursor..start]);
145        result.push_str(if preceded || followed { original } else { hashed });
146        cursor = end;
147    }
148    result.push_str(&content[cursor..]);
149    result
150}
151
152/// Apply the snippet-namespace patch to the runtime's own JS, in place.
153///
154/// The `nav` feature's `inline_js` glue makes wasm-bindgen emit
155/// `import * as importN from "./snippets/.../inlineK.js"` inside `islands_core.js`
156/// and pass that raw module namespace to `WebAssembly.instantiate` — the same V8
157/// rejection [`patch_page_js`] dodges. This wraps each such importObject *value*;
158/// the *key* (snippet specifier) is left untouched so it keeps matching the WASM's
159/// baked import-module name (snippets are excluded from the content-hashing pass
160/// for the same reason). Returns `Ok(true)` if anything was wrapped.
161pub fn patch_runtime_snippets(js_path: &Path) -> Result<bool> {
162    let content =
163        fs::read_to_string(js_path).with_context(|| format!("reading {}", js_path.display()))?;
164    let patched = wrap_snippet_namespace_imports(&content);
165    if patched == content {
166        return Ok(false);
167    }
168    fs::write(js_path, &patched)
169        .with_context(|| format!("writing patched {}", js_path.display()))?;
170    Ok(true)
171}
172
173/// In-memory analogue of [`patch_runtime_snippets`]: wrap every snippet
174/// importObject value line. Idempotent; returns the source unchanged when there
175/// are no snippet imports (no `inline_js`, or already patched).
176pub fn wrap_snippet_namespace_imports(source: &str) -> String {
177    let mut changed = false;
178    let mut lines: Vec<String> = Vec::with_capacity(source.lines().count());
179    for line in source.lines() {
180        match wrap_snippet_import_value(line) {
181            Some(wrapped) => {
182                lines.push(wrapped);
183                changed = true;
184            }
185            None => lines.push(line.to_owned()),
186        }
187    }
188    if !changed {
189        return source.to_owned();
190    }
191    let mut output = lines.join("\n");
192    if source.ends_with('\n') {
193        output.push('\n');
194    }
195    output
196}
197
198/// If `line` is a snippet importObject *value* entry of the shape
199/// `    "<…/snippets/…>.js": importN,`, return it with the bare `importN`
200/// namespace wrapped in `Object.assign(Object.create(null), importN)`. `None` for
201/// any other line — including the `import * as …` statement (no `": "` value
202/// separator), a single-quoted named import, or an already-wrapped value.
203fn wrap_snippet_import_value(line: &str) -> Option<String> {
204    if !line.contains("/snippets/") {
205        return None;
206    }
207    let separator = "\": ";
208    let separator_index = line.find(separator)?;
209    let key_through_separator = &line[..separator_index + separator.len()];
210    let value_part = line[separator_index + separator.len()..].trim_end();
211    let identifier = value_part.strip_suffix(',')?;
212    // Must be a bare `import<digits>` namespace identifier — not an already
213    // wrapped `Object.assign(...)` value.
214    let digits = identifier.strip_prefix("import")?;
215    if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
216        return None;
217    }
218    Some(format!(
219        "{key_through_separator}Object.assign(Object.create(null), {identifier}),"
220    ))
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn patches_the_page_core_import_value() {
229        let source = format!("    {NEEDLE}\n");
230        let patched = patch_page_source(&source);
231        assert!(patched.contains(PATCH_TOKEN), "got: {patched}");
232    }
233
234    #[test]
235    fn page_patch_is_idempotent_in_memory() {
236        let once = patch_page_source(&format!("    {NEEDLE}\n"));
237        let twice = patch_page_source(&once);
238        assert_eq!(once, twice);
239    }
240
241    #[test]
242    fn wraps_snippet_importobject_namespace_value() {
243        let source = "    \"./snippets/islands-runtime-abc/inline0.js\": import1,\n";
244        let output = wrap_snippet_namespace_imports(source);
245        assert!(
246            output.contains(
247                "\"./snippets/islands-runtime-abc/inline0.js\": Object.assign(Object.create(null), import1),"
248            ),
249            "snippet importObject value must be namespace-wrapped; got: {output}"
250        );
251    }
252
253    #[test]
254    fn leaves_import_statement_and_non_snippet_lines_untouched() {
255        let source = concat!(
256            "import * as import1 from \"./snippets/islands-runtime-abc/inline0.js\";\n",
257            "    \"__wbindgen_placeholder__\": import1,\n",
258        );
259        assert_eq!(wrap_snippet_namespace_imports(source), source);
260    }
261
262    #[test]
263    fn snippet_wrap_is_idempotent() {
264        let source = "    \"./snippets/x/inline0.js\": import2,\n";
265        let once = wrap_snippet_namespace_imports(source);
266        let twice = wrap_snippet_namespace_imports(&once);
267        assert_eq!(once, twice, "re-running the wrap must be a no-op");
268    }
269
270    #[test]
271    fn page_patch_token_survives_basename_rewrite() {
272        // The patch token must survive the content-hash rewrite, and the
273        // importObject key for the runtime must be restored to its unhashed form.
274        let patched = patch_page_source(&format!("    {NEEDLE}\n"));
275        let rename_map = vec![(
276            "islands_core.js".to_owned(),
277            "islands_core.deadbeef.js".to_owned(),
278        )];
279        let after = rewrite_basenames_in_js(&patched, &rename_map);
280        assert!(after.contains(PATCH_TOKEN), "patch token lost: {after}");
281        assert!(
282            after.contains("/static/islands-core/islands_core.js\":"),
283            "runtime importObject key must be restored unhashed: {after}"
284        );
285    }
286
287    #[test]
288    fn rewrite_is_deterministic_for_substring_basenames() {
289        // `worker.js` is a substring of `pasifico_worker.js`. A naive substring
290        // replace rewrites the glue import differently depending on which entry it
291        // processes first; the whole-token rewrite must produce the same correct
292        // result regardless of `rename_map` order.
293        let source = "import init, { start } from \"./pasifico_worker.js\";\n";
294        let worker = ("worker.js".to_owned(), "worker.aaaaaaaa.js".to_owned());
295        let glue = (
296            "pasifico_worker.js".to_owned(),
297            "pasifico_worker.bbbbbbbb.js".to_owned(),
298        );
299
300        let forward = rewrite_basenames_in_js(source, &[worker.clone(), glue.clone()]);
301        let reverse = rewrite_basenames_in_js(source, &[glue, worker]);
302
303        let expected = "import init, { start } from \"./pasifico_worker.bbbbbbbb.js\";\n";
304        assert_eq!(
305            forward, expected,
306            "shorter-first order corrupted the longer basename reference"
307        );
308        assert_eq!(reverse, expected, "longer-first order changed the result");
309        assert_eq!(forward, reverse, "rewrite must be order-independent");
310    }
311
312    #[test]
313    fn snippet_key_stays_unhashed_through_rewrite() {
314        let wrapped = wrap_snippet_namespace_imports("    \"./snippets/r/inline0.js\": import1,\n");
315        let rename_map = vec![(
316            "islands_core.js".to_owned(),
317            "islands_core.deadbeef.js".to_owned(),
318        )];
319        let after = rewrite_basenames_in_js(&wrapped, &rename_map);
320        assert!(
321            after.contains(
322                "\"./snippets/r/inline0.js\": Object.assign(Object.create(null), import1),"
323            ),
324            "snippet key must stay unhashed and the wrap must survive; got: {after}"
325        );
326    }
327}