1use std::fs;
20use std::path::Path;
21
22use anyhow::{Context, Result};
23
24pub const RUNTIME_IMPORT_URL: &str = "/static/islands-core/islands_core.js";
27
28pub const PATCH_TOKEN: &str = "Object.assign(Object.create(null), import1)";
31
32const NEEDLE: &str = "\"/static/islands-core/islands_core.js\": import1,";
34
35const PATCHED: &str =
37 "\"/static/islands-core/islands_core.js\": Object.assign(Object.create(null), import1),";
38
39pub 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
57pub 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
65pub 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
84pub 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
116fn is_filename_char(c: char) -> bool {
120 c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')
121}
122
123fn 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
152pub 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
173pub 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
198fn 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 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 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 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}