Support current document import maps

This commit is contained in:
SGavrl committed 2026-08-03 11:25:54 +00:00
1 parent 98cdcdee11
commit 34373c309e
7 files changed
+1338 -136

No files matched your search

+442 -104
View File
@@ -882,8 +882,6 @@ impl Page {
async fn execute_scripts(&mut self) { async fn execute_scripts(&mut self) {
tracing::info!("execute_scripts called, js runtime exists: {}", self.js.is_some()); tracing::info!("execute_scripts called, js runtime exists: {}", self.js.is_some());
// Compute document base URL, respecting <base href>.
let document_base = self.resolve_base_url();
// Soft deadline on the entire script-execution phase. Heavy SPAs // Soft deadline on the entire script-execution phase. Heavy SPAs
// (GitHub, Linear, CodeSandbox) ship 50+ scripts and our serial // (GitHub, Linear, CodeSandbox) ship 50+ scripts and our serial
// fetch + execute loop can blow past a Puppeteer/Playwright goto // fetch + execute loop can blow past a Puppeteer/Playwright goto
@@ -915,37 +913,80 @@ impl Page {
.as_mut() .as_mut()
.map(|js| js.arm_watchdog(std::time::Duration::from_millis(script_deadline_ms + 1000))); .map(|js| js.arm_watchdog(std::time::Duration::from_millis(script_deadline_ms + 1000)));
#[derive(Debug, Clone, Copy)]
enum ScriptKind {
Classic,
Module,
ImportMap,
}
#[derive(Debug)] #[derive(Debug)]
struct ScriptInfo { struct ScriptInfo {
src: Option<String>, src: Option<String>,
inline: String, inline: String,
is_defer: bool, is_defer: bool,
is_async: bool, is_async: bool,
is_module: bool, kind: ScriptKind,
nid: u32, nid: u32,
/// Document base URL at this element's parser encounter point.
base_url: String,
} }
let all_scripts = match &self.js { let all_scripts = match &self.js {
Some(js) => { Some(js) => {
let document_url = self.url_string();
js.with_dom(|dom| { js.with_dom(|dom| {
let script_ids = dom.query_selector_all("script").unwrap_or_default(); let script_ids = dom.query_selector_all("script").unwrap_or_default();
let mut bases_at_script = std::collections::HashMap::new();
let mut active_base = url::Url::parse(&document_url).ok();
let mut found_base = false;
for nid in dom.descendants(dom.document()) {
let Some(node) = dom.get_node(nid) else {
continue;
};
let Some(name) = node.as_element() else {
continue;
};
if name.local.as_ref() == "base" && !found_base {
if let Some(href) = node.get_attribute("href") {
found_base = true;
if let Some(resolved) = active_base
.as_ref()
.and_then(|base| base.join(href).ok())
{
active_base = Some(resolved);
}
}
} else if name.local.as_ref() == "script" {
bases_at_script.insert(
nid.raw(),
active_base
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| document_url.clone()),
);
}
}
let mut scripts = Vec::new(); let mut scripts = Vec::new();
for sid in script_ids { for sid in script_ids {
if let Some(node) = dom.get_node(sid) { if let Some(node) = dom.get_node(sid) {
let src = node.get_attribute("src").map(|s| s.to_string()); let src = node.get_attribute("src").map(|s| s.to_string());
let script_type = node.get_attribute("type").unwrap_or("").to_string(); let script_type = node
.get_attribute("type")
.unwrap_or("")
.trim()
.to_ascii_lowercase();
let is_defer = node.get_attribute("defer").is_some(); let is_defer = node.get_attribute("defer").is_some();
let is_async = node.get_attribute("async").is_some(); let is_async = node.get_attribute("async").is_some();
let is_module = script_type == "module"; let kind = match script_type.as_str() {
"module" => ScriptKind::Module,
if !script_type.is_empty() "importmap" => ScriptKind::ImportMap,
&& script_type != "text/javascript" "" | "text/javascript" | "application/javascript" => {
&& script_type != "application/javascript" ScriptKind::Classic
&& script_type != "module" }
{ _ => continue,
continue; };
}
let inline_code = if src.is_none() { let inline_code = if src.is_none() {
dom.text_content(sid) dom.text_content(sid)
@@ -953,14 +994,21 @@ impl Page {
String::new() String::new()
}; };
if src.is_some() || !inline_code.trim().is_empty() { if matches!(kind, ScriptKind::ImportMap)
|| src.is_some()
|| !inline_code.trim().is_empty()
{
scripts.push(ScriptInfo { scripts.push(ScriptInfo {
src, src,
inline: inline_code, inline: inline_code,
is_defer, is_defer,
is_async, is_async,
is_module, kind,
nid: sid.raw(), nid: sid.raw(),
base_url: bases_at_script
.get(&sid.raw())
.cloned()
.unwrap_or_else(|| document_url.clone()),
}); });
} }
} }
@@ -987,45 +1035,22 @@ impl Page {
); );
} }
let mut regular = Vec::new(); tracing::info!("Found {} parser-discovered scripts", all_scripts.len());
let mut deferred = Vec::new();
let mut async_scripts = Vec::new();
let mut module_scripts = Vec::new();
for script in all_scripts {
if script.is_module {
module_scripts.push(script);
continue;
}
if script.is_defer {
deferred.push(script);
} else if script.is_async {
async_scripts.push(script);
} else {
regular.push(script);
}
}
let scripts = regular;
tracing::info!("Found {} regular + {} deferred + {} async scripts", scripts.len(), deferred.len(), async_scripts.len());
let all_to_execute: Vec<ScriptInfo> = scripts.into_iter()
.chain(deferred.into_iter())
.chain(async_scripts.into_iter())
.collect();
let mut resolved: Vec<(usize, String)> = Vec::new();
let mut fetch_tasks: Vec<(usize, String)> = Vec::new(); let mut fetch_tasks: Vec<(usize, String)> = Vec::new();
for (i, script) in all_to_execute.iter().enumerate() { for (i, script) in all_scripts.iter().enumerate() {
if !matches!(script.kind, ScriptKind::Classic) {
continue;
}
if let Some(src_url) = &script.src { if let Some(src_url) = &script.src {
let full_url = if src_url.starts_with("http://") || src_url.starts_with("https://") { let full_url = if src_url.starts_with("http://") || src_url.starts_with("https://") {
src_url.clone() src_url.clone()
} else if let Some(base) = &document_base {
base.join(src_url).map(|u| u.to_string()).unwrap_or_else(|_| src_url.clone())
} else { } else {
src_url.clone() url::Url::parse(&script.base_url)
.ok()
.and_then(|base| base.join(src_url).ok())
.map(|url| url.to_string())
.unwrap_or_else(|| src_url.clone())
}; };
if !subresource_allowed(self.url.as_ref(), &full_url) { if !subresource_allowed(self.url.as_ref(), &full_url) {
@@ -1045,7 +1070,6 @@ impl Page {
tracing::info!("Blocked script by interception: {}", full_url); tracing::info!("Blocked script by interception: {}", full_url);
continue; continue;
} }
resolved.push((i, full_url.clone()));
fetch_tasks.push((i, full_url)); fetch_tasks.push((i, full_url));
} }
} }
@@ -1162,37 +1186,6 @@ impl Page {
} }
} }
for (i, script) in all_to_execute.iter().enumerate() {
if tokio::time::Instant::now() >= script_deadline {
tracing::warn!(
"execute_scripts: deadline reached, skipping {} remaining scripts",
all_to_execute.len() - i,
);
break;
}
if script.src.is_some() {
if let Some((url, code, resp)) = fetched.remove(&i) {
tracing::info!("Executing script ({} bytes): {}", code.len(), url);
self.record_network_event_with_body(&url, "GET", "Script", resp.status, &resp.headers, &resp.body, false);
if let Some(js) = &mut self.js {
let _ = js.execute_script("<current-script>", &format!("globalThis.__currentScriptNid={};", script.nid));
if let Err(e) = js.execute_script_guarded(&url, &code) {
tracing::warn!("Script error ({}): {}", url, e);
}
let _ = js.execute_script("<current-script>", "globalThis.__currentScriptNid=0;");
}
}
} else if !script.inline.is_empty() {
if let Some(js) = &mut self.js {
let _ = js.execute_script("<current-script>", &format!("globalThis.__currentScriptNid={};", script.nid));
if let Err(e) = js.execute_script_guarded("<inline>", &script.inline) {
tracing::warn!("Inline script error: {}", e);
}
let _ = js.execute_script("<current-script>", "globalThis.__currentScriptNid=0;");
}
}
}
// Per-module budget. Modules on an already-rendered page are // Per-module budget. Modules on an already-rendered page are
// enhancement, not the app: give them a short budget so one slow // enhancement, not the app: give them a short budget so one slow
// non-essential module (e.g. YC's bookface, whose top-level eval // non-essential module (e.g. YC's bookface, whose top-level eval
@@ -1222,37 +1215,184 @@ impl Page {
if body_nodes > 50 { short_ms } else { script_deadline_ms } if body_nodes > 50 { short_ms } else { script_deadline_ms }
}; };
for module_script in &module_scripts { enum ScheduledScript {
Classic(usize),
Module {
prepared: obscura_js::runtime::PreparedModule,
url: Option<String>,
},
}
let execute_classic = |
page: &mut Self,
script: &ScriptInfo,
fetched_script: Option<(String, String, obscura_net::Response)>,
| {
if script.src.is_some() {
if let Some((url, code, resp)) = fetched_script {
tracing::info!("Executing script ({} bytes): {}", code.len(), url);
let execution_url = resp.url.to_string();
page.record_network_event_with_body(
&url, "GET", "Script", resp.status, &resp.headers, &resp.body, false,
);
if let Some(js) = &mut page.js {
let _ = js.execute_script(
"<current-script>",
&format!("globalThis.__currentScriptNid={};", script.nid),
);
if let Err(error) = js.execute_script_guarded(&execution_url, &code) {
tracing::warn!("Script error ({}): {}", execution_url, error);
}
let _ = js.execute_script(
"<current-script>",
"globalThis.__currentScriptNid=0;",
);
}
}
} else if !script.inline.is_empty() {
if let Some(js) = &mut page.js {
let _ = js.execute_script(
"<current-script>",
&format!("globalThis.__currentScriptNid={};", script.nid),
);
if let Err(error) = js.execute_script_guarded(&script.base_url, &script.inline) {
tracing::warn!("Inline script error: {}", error);
}
let _ = js.execute_script(
"<current-script>",
"globalThis.__currentScriptNid=0;",
);
}
}
};
let mut post_parse = Vec::new();
// Process parser-discovered scripts in encounter order. Import maps
// register at their exact position; module graphs start there too, but
// evaluation of non-async modules remains post-parse.
for (index, script) in all_scripts.iter().enumerate() {
if tokio::time::Instant::now() >= script_deadline { if tokio::time::Instant::now() >= script_deadline {
tracing::warn!("execute_scripts: deadline reached, skipping remaining module scripts"); tracing::warn!(
"execute_scripts: deadline reached, skipping {} remaining scripts",
all_scripts.len() - index,
);
break; break;
} }
if let Some(ref src) = module_script.src {
let full_url = if src.starts_with("http://") || src.starts_with("https://") {
src.clone()
} else if let Some(base) = &document_base {
base.join(src).map(|u| u.to_string()).unwrap_or_else(|_| src.clone())
} else {
src.clone()
};
tracing::info!("Loading ES module: {}", full_url); match script.kind {
if let Some(js) = &mut self.js { ScriptKind::ImportMap => {
match js.load_module(&full_url, module_budget_ms).await { if script.src.is_some() {
Ok(()) => { tracing::warn!("External import maps are not supported");
tracing::info!("ES module loaded: {}", full_url); continue;
self.record_network_event(&full_url, "GET", "Script", 200, &std::collections::HashMap::new(), 0); }
} if let Some(js) = &self.js {
Err(e) => { if let Err(error) = js.add_import_map(&script.inline, &script.base_url) {
tracing::warn!("ES module error ({}): {}", full_url, e); tracing::warn!("Ignoring invalid import map: {}", error);
} }
} }
} }
} else if !module_script.inline.is_empty() { ScriptKind::Classic => {
let base = self.url_string(); if script.is_defer && !script.is_async && script.src.is_some() {
if let Some(js) = &mut self.js { post_parse.push(ScheduledScript::Classic(index));
if let Err(e) = js.load_inline_module(&module_script.inline, &base, module_budget_ms).await { } else {
tracing::warn!("Inline ES module error: {}", e); let fetched_script = fetched.remove(&index);
execute_classic(self, script, fetched_script);
}
}
ScriptKind::Module => {
let (prepared, module_url) = if let Some(src) = &script.src {
let full_url = if src.starts_with("http://")
|| src.starts_with("https://")
|| src.starts_with("data:")
{
src.clone()
} else {
url::Url::parse(&script.base_url)
.ok()
.and_then(|base| base.join(src).ok())
.map(|url| url.to_string())
.unwrap_or_else(|| src.clone())
};
tracing::info!("Preparing ES module graph: {}", full_url);
let result = match &mut self.js {
Some(js) => js.prepare_module(&full_url, module_budget_ms).await,
None => continue,
};
match result {
Ok(prepared) => (prepared, Some(full_url)),
Err(error) => {
tracing::warn!("ES module error ({}): {}", full_url, error);
continue;
}
}
} else {
let result = match &mut self.js {
Some(js) => js.prepare_inline_module(
&script.inline, &script.base_url, module_budget_ms,
).await,
None => continue,
};
match result {
Ok(prepared) => (prepared, None),
Err(error) => {
tracing::warn!("Inline ES module error: {}", error);
continue;
}
}
};
let scheduled = ScheduledScript::Module {
prepared,
url: module_url,
};
if script.is_async {
let ScheduledScript::Module { prepared, url } = scheduled else {
unreachable!();
};
let result = match &mut self.js {
Some(js) => js.evaluate_prepared_module(prepared, module_budget_ms).await,
None => continue,
};
if let Err(error) = result {
tracing::warn!("ES module evaluation error: {}", error);
} else if let Some(url) = url {
tracing::info!("ES module loaded: {}", url);
self.record_network_event(
&url, "GET", "Script", 200,
&std::collections::HashMap::new(), 0,
);
}
} else {
post_parse.push(scheduled);
}
}
}
}
for scheduled in post_parse {
if tokio::time::Instant::now() >= script_deadline {
tracing::warn!("execute_scripts: deadline reached during post-parse scripts");
break;
}
match scheduled {
ScheduledScript::Classic(index) => {
let script = &all_scripts[index];
let fetched_script = fetched.remove(&index);
execute_classic(self, script, fetched_script);
}
ScheduledScript::Module { prepared, url } => {
let result = match &mut self.js {
Some(js) => js.evaluate_prepared_module(prepared, module_budget_ms).await,
None => continue,
};
if let Err(error) = result {
tracing::warn!("ES module evaluation error: {}", error);
} else if let Some(url) = url {
tracing::info!("ES module loaded: {}", url);
self.record_network_event(
&url, "GET", "Script", 200,
&std::collections::HashMap::new(), 0,
);
} }
} }
} }
@@ -2361,6 +2501,46 @@ mod tests {
}; };
use obscura_dom::parse_html; use obscura_dom::parse_html;
fn spawn_parser_import_map_server(
expected_requests: usize,
) -> (String, std::sync::mpsc::Receiver<String>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let (request_tx, request_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
use std::io::{Read as _, Write as _};
for _ in 0..expected_requests {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0u8; 2048];
let length = stream.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..length]);
let path = request
.lines().next()
.and_then(|line| line.split_ascii_whitespace().nth(1))
.unwrap_or("/").to_string();
request_tx.send(path.clone()).unwrap();
let (status, body) = match path.as_str() {
"/app/before.js" => ("200 OK", "export const value = 'before-first-module';"),
"/app/later.js" => ("200 OK", "export const value = 'later-map';"),
"/app/async.js" => (
"200 OK",
"import('too-late')\
.then(module => globalThis.__async_before_map = module.value)\
.catch(() => globalThis.__async_before_map = 'rejected');",
),
_ => ("404 Not Found", "not found"),
};
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len(),
);
stream.write_all(response.as_bytes()).unwrap();
}
});
(format!("http://{}", address), request_rx)
}
#[test] #[test]
fn external_scripts_require_a_successful_http_status() { fn external_scripts_require_a_successful_http_status() {
assert!(script_response_is_executable(200)); assert!(script_response_is_executable(200));
@@ -2373,6 +2553,164 @@ mod tests {
assert!(!script_response_is_executable(500)); assert!(!script_response_is_executable(500));
} }
fn import_map_test_page(name: &str, base: &str, html: &str) -> super::Page {
let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network(
name.to_string(), None, false, None, None, true,
));
let mut page = super::Page::new(name.to_string(), context);
page.url = Some(url::Url::parse(&format!("{}/app/index.html", base)).unwrap());
page.dom = Some(parse_html(html));
page.init_js();
page
}
#[tokio::test(flavor = "current_thread")]
async fn parser_import_map_before_first_module_controls_resolution() {
let (base, requests) = spawn_parser_import_map_server(1);
let mut page = import_map_test_page("import-map-order", &base, r#"<html><head>
<script type="importmap">{"imports":{"ordered":"./before.js"}}</script>
<script type="module">
import { value } from "ordered";
globalThis.__parser_import_map_value = value;
</script>
<script type="importmap">{"imports":{"ordered":"./after.js"}}</script>
</head><body></body></html>"#);
page.execute_scripts().await;
assert_eq!(
page.js.as_mut().unwrap().evaluate("globalThis.__parser_import_map_value").unwrap(),
serde_json::json!("before-first-module"),
);
assert_eq!(requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(), "/app/before.js");
}
#[tokio::test(flavor = "current_thread")]
async fn later_import_map_adds_unrelated_rule_without_rebinding_resolved_rule() {
let (base, requests) = spawn_parser_import_map_server(2);
let mut page = import_map_test_page("multiple-import-map-order", &base, r#"<html><head>
<script type="importmap">{"imports":{"fixed":"./before.js"}}</script>
<script type="module">
import { value } from "fixed";
globalThis.__first_map_value = value;
</script>
<script type="importmap">{"imports":{"fixed":"./after.js","later":"./later.js"}}</script>
<script type="module">
import { value as fixed } from "fixed";
import { value as later } from "later";
globalThis.__later_map_values = [fixed, later];
</script>
</head><body></body></html>"#);
page.execute_scripts().await;
let js = page.js.as_mut().unwrap();
assert_eq!(js.evaluate("globalThis.__first_map_value").unwrap(), serde_json::json!("before-first-module"));
assert_eq!(js.evaluate("globalThis.__later_map_values").unwrap(), serde_json::json!(["before-first-module", "later-map"]));
let paths = (0..2)
.map(|_| requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap())
.collect::<Vec<_>>();
assert!(paths.contains(&"/app/before.js".to_string()), "{paths:?}");
assert!(paths.contains(&"/app/later.js".to_string()), "{paths:?}");
assert!(!paths.contains(&"/app/after.js".to_string()), "{paths:?}");
}
#[tokio::test(flavor = "current_thread")]
async fn classic_dynamic_import_does_not_see_a_later_parser_import_map() {
let (base, _requests) = spawn_parser_import_map_server(1);
let mut page = import_map_test_page("classic-before-import-map", &base, r#"<html><head>
<script>
import("too-late")
.then(() => globalThis.__classic_before_map = "resolved")
.catch(() => globalThis.__classic_before_map = "rejected");
</script>
<script type="importmap">{"imports":{"too-late":"./later.js"}}</script>
</head><body></body></html>"#);
page.execute_scripts().await;
assert_eq!(
page.js.as_mut().unwrap().evaluate("globalThis.__classic_before_map").unwrap(),
serde_json::json!("rejected"),
);
}
#[tokio::test(flavor = "current_thread")]
async fn ready_async_classic_script_runs_before_a_later_parser_import_map() {
let (base, requests) = spawn_parser_import_map_server(2);
let mut page = import_map_test_page("async-classic-before-map", &base, r#"<html><head>
<script async src="./async.js"></script>
<script type="importmap">{"imports":{"too-late":"./later.js"}}</script>
</head><body></body></html>"#);
page.execute_scripts().await;
assert_eq!(
page.js.as_mut().unwrap().evaluate("globalThis.__async_before_map").unwrap(),
serde_json::json!("rejected"),
);
assert_eq!(requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(), "/app/async.js");
assert!(requests.try_recv().is_err());
}
#[tokio::test(flavor = "current_thread")]
async fn dynamically_inserted_import_map_controls_later_dynamic_import() {
let (base, requests) = spawn_parser_import_map_server(1);
let mut page = import_map_test_page("dynamic-import-map", &base, r#"<html><head></head><body>
<script>
const map = document.createElement("script");
map.type = "importmap";
map.textContent = JSON.stringify({imports:{dynamicName:"./later.js"}});
document.head.appendChild(map);
import("dynamicName")
.then(module => globalThis.__dynamic_map_value = module.value)
.catch(error => globalThis.__dynamic_map_value = error.message);
</script>
</body></html>"#);
page.execute_scripts().await;
assert_eq!(
page.js.as_mut().unwrap().evaluate("globalThis.__dynamic_map_value").unwrap(),
serde_json::json!("later-map"),
);
assert_eq!(requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(), "/app/later.js");
}
#[tokio::test(flavor = "current_thread")]
async fn dynamic_import_map_uses_live_document_base_at_insertion() {
let (base, requests) = spawn_parser_import_map_server(1);
let mut page = import_map_test_page("dynamic-import-map-base", &base, r#"<html><head><base href="/old/"></head><body>
<script>
document.querySelector("base").setAttribute("href", "/app/");
const map = document.createElement("script");
map.type = "importmap";
map.textContent = JSON.stringify({imports:{liveBase:"./later.js"}});
document.head.appendChild(map);
import("liveBase")
.then(module => globalThis.__dynamic_map_base = module.value)
.catch(error => globalThis.__dynamic_map_base = error.message);
</script>
</body></html>"#);
page.execute_scripts().await;
assert_eq!(
page.js.as_mut().unwrap().evaluate("globalThis.__dynamic_map_base").unwrap(),
serde_json::json!("later-map"),
);
assert_eq!(requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(), "/app/later.js");
}
#[tokio::test(flavor = "current_thread")]
async fn later_base_element_does_not_rebase_an_earlier_import_map() {
let (base, requests) = spawn_parser_import_map_server(1);
let mut page = import_map_test_page("temporal-import-map-base", &base, r#"<html><head>
<script type="importmap">{"imports":{"fixed":"./before.js"}}</script>
<base href="/assets/">
<script type="module">
import { value } from "fixed";
globalThis.__temporal_base_value = value;
</script>
</head><body></body></html>"#);
page.execute_scripts().await;
assert_eq!(
page.js.as_mut().unwrap().evaluate("globalThis.__temporal_base_value").unwrap(),
serde_json::json!("before-first-module"),
);
assert_eq!(requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(), "/app/before.js");
}
#[cfg(feature = "render")] #[cfg(feature = "render")]
#[test] #[test]
fn page_screenshot_uses_the_live_window_scroll_offset() { fn page_screenshot_uses_the_live_window_scroll_offset() {
+26 -1
View File
@@ -1159,8 +1159,33 @@ class Node {
if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('childList', this._nid, [c._nid], []); if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('childList', this._nid, [c._nid], []);
if (c instanceof Element && c.tagName === 'SCRIPT') { if (c instanceof Element && c.tagName === 'SCRIPT') {
if (__startedScriptNids.has(c._nid)) return c; if (__startedScriptNids.has(c._nid)) return c;
const scriptType = c.getAttribute('type') || ''; const scriptType = (c.getAttribute('type') || '').trim().toLowerCase();
const isModule = scriptType === 'module'; const isModule = scriptType === 'module';
const isImportMap = scriptType === 'importmap';
if (isImportMap) {
__startedScriptNids.add(c._nid);
const src = c.getAttribute('src');
let error = '';
if (src) {
error = 'External import maps are not supported';
} else {
const base = c.baseURI
|| globalThis.location?.href
|| 'about:blank';
try {
error = Deno.core.ops.op_add_import_map(c.textContent || '', base) || '';
} catch (e) {
error = e && e.message ? e.message : String(e);
}
}
if (error) {
console.error('Import map error:', error);
queueMicrotask(() => {
try { c.dispatchEvent(new Event('error')); } catch (_) {}
});
}
return c;
}
if (scriptType && !isModule && scriptType !== 'text/javascript' && scriptType !== 'application/javascript') { if (scriptType && !isModule && scriptType !== 'text/javascript' && scriptType !== 'application/javascript') {
return c; return c;
} }
+455
View File
@@ -0,0 +1,455 @@
use std::collections::HashMap;
use deno_core::ModuleSpecifier;
#[derive(Default)]
pub(crate) struct ImportMap {
imports: SpecifierMap,
scopes: Vec<(String, SpecifierMap)>,
resolved_modules: Vec<ResolvedModule>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ResolvedModule {
referrer: String,
specifier: String,
as_url_is_special: bool,
}
#[derive(Default)]
struct SpecifierMap {
entries: HashMap<String, Option<ModuleSpecifier>>,
prefixes: Vec<String>,
}
impl ImportMap {
pub(crate) fn parse(input: &str, base_url: &str) -> Result<Self, String> {
let base = ModuleSpecifier::parse(base_url)
.map_err(|e| format!("Invalid import map base URL {}: {}", base_url, e))?;
let parsed: serde_json::Value =
serde_json::from_str(input).map_err(|e| format!("Invalid import map JSON: {}", e))?;
let object = parsed
.as_object()
.ok_or_else(|| "Import map top level must be an object".to_string())?;
let imports = match object.get("imports") {
Some(value) => SpecifierMap::parse(
value
.as_object()
.ok_or_else(|| "Import map \"imports\" must be an object".to_string())?,
&base,
),
None => SpecifierMap::default(),
};
let mut scopes = Vec::new();
if let Some(value) = object.get("scopes") {
let scope_object = value
.as_object()
.ok_or_else(|| "Import map \"scopes\" must be an object".to_string())?;
for (scope_prefix, scope_imports) in scope_object {
// Unlike keys and addresses in a module specifier map, scope
// prefixes use ordinary URL parsing. In particular, a bare
// relative value such as "feature/" is valid here.
let Ok(normalized_scope) = base.join(scope_prefix) else {
continue;
};
let scope_imports = scope_imports.as_object().ok_or_else(|| {
format!(
"Import map scope \"{}\" must contain an object",
scope_prefix
)
})?;
scopes.push((
normalized_scope.to_string(),
SpecifierMap::parse(scope_imports, &base),
));
}
// The most specific applicable scope is consulted first.
scopes.sort_by(|left, right| right.0.cmp(&left.0));
}
// Integrity enforcement is owned by the module fetch layer. Still
// validate the top-level shape here: a malformed integrity member
// invalidates the complete import map in Chromium/the HTML algorithm,
// rather than leaving its `imports` member active.
if let Some(value) = object.get("integrity") {
value
.as_object()
.ok_or_else(|| "Import map \"integrity\" must be an object".to_string())?;
}
Ok(Self {
imports,
scopes,
resolved_modules: Vec::new(),
})
}
pub(crate) fn merge(&mut self, mut new_map: Self) {
// Multiple import maps are merged even after module graphs have
// started. New rules which could change an already-observed
// (referrer, specifier) resolution are removed first; unrelated rules
// remain available to later graphs.
for record in &self.resolved_modules {
new_map
.imports
.remove_rules_affecting(&record.specifier, record.as_url_is_special);
for (scope_prefix, scope_imports) in &mut new_map.scopes {
if scope_applies(scope_prefix, &record.referrer) {
scope_imports
.remove_rules_affecting(&record.specifier, record.as_url_is_special);
}
}
}
self.imports.merge(new_map.imports);
for (scope_prefix, new_imports) in new_map.scopes {
if let Some((_, imports)) = self
.scopes
.iter_mut()
.find(|(existing, _)| existing == &scope_prefix)
{
imports.merge(new_imports);
} else {
self.scopes.push((scope_prefix, new_imports));
}
}
self.scopes.sort_by(|left, right| right.0.cmp(&left.0));
}
pub(crate) fn resolve(
&mut self,
specifier: &str,
referrer: &ModuleSpecifier,
) -> Result<ModuleSpecifier, String> {
let as_url = resolve_url_like(specifier, referrer);
let normalized = as_url
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| specifier.to_string());
let serialized_referrer = referrer.to_string();
for (scope_prefix, scope_imports) in &self.scopes {
if scope_applies(scope_prefix, &serialized_referrer) {
if let Some(resolved) = scope_imports.resolve_match(&normalized, as_url.as_ref())? {
self.remember_resolution(serialized_referrer, normalized, as_url.as_ref());
return Ok(resolved);
}
}
}
if let Some(resolved) = self.imports.resolve_match(&normalized, as_url.as_ref())? {
self.remember_resolution(serialized_referrer, normalized, as_url.as_ref());
return Ok(resolved);
}
match as_url {
Some(resolved) => {
self.remember_resolution(serialized_referrer, normalized, Some(&resolved));
Ok(resolved)
}
None => Err(format!(
"Bare module specifier \"{}\" was not remapped by the import map",
specifier
)),
}
}
fn remember_resolution(
&mut self,
referrer: String,
specifier: String,
as_url: Option<&ModuleSpecifier>,
) {
let resolution = ResolvedModule {
referrer,
specifier,
as_url_is_special: as_url.is_none_or(is_special_url),
};
if !self.resolved_modules.contains(&resolution) {
self.resolved_modules.push(resolution);
}
}
}
impl SpecifierMap {
fn parse(object: &serde_json::Map<String, serde_json::Value>, base: &ModuleSpecifier) -> Self {
let mut entries = HashMap::with_capacity(object.len());
let mut prefixes = Vec::new();
for (key, value) in object {
let Some(normalized_key) = normalize_key(key, base) else {
continue;
};
let address = value
.as_str()
.and_then(|address| resolve_url_like(address, base))
.filter(|address| !key.ends_with('/') || address.as_str().ends_with('/'));
if normalized_key.ends_with('/') {
prefixes.push(normalized_key.clone());
}
entries.insert(normalized_key, address);
}
prefixes.sort_by(|left, right| right.cmp(left));
Self { entries, prefixes }
}
fn merge(&mut self, new_map: Self) {
for (key, address) in new_map.entries {
if !self.entries.contains_key(&key) {
if key.ends_with('/') {
self.prefixes.push(key.clone());
}
self.entries.insert(key, address);
}
}
self.prefixes.sort_by(|left, right| right.cmp(left));
}
fn remove_rules_affecting(&mut self, resolved: &str, as_url_is_special: bool) {
self.entries.retain(|key, _| {
key != resolved
&& !(as_url_is_special && key.ends_with('/') && resolved.starts_with(key.as_str()))
});
self.prefixes
.retain(|key| self.entries.contains_key(key.as_str()));
}
fn resolve_match(
&self,
normalized: &str,
as_url: Option<&ModuleSpecifier>,
) -> Result<Option<ModuleSpecifier>, String> {
if let Some(address) = self.entries.get(normalized) {
return address.clone().map(Some).ok_or_else(|| {
format!(
"Module specifier \"{}\" is blocked by import map entry \"{}\"",
normalized, normalized
)
});
}
if as_url.is_some_and(|url| !is_special_url(url)) {
return Ok(None);
}
for key in self
.prefixes
.iter()
.filter(|key| normalized.starts_with(key.as_str()))
{
let address = self
.entries
.get(key)
.ok_or_else(|| format!("Import map prefix \"{}\" has no matching address", key))?;
let address = address.as_ref().ok_or_else(|| {
format!(
"Module specifier \"{}\" is blocked by import map prefix \"{}\"",
normalized, key
)
})?;
let after_prefix = &normalized[key.len()..];
let resolved = address.join(after_prefix).map_err(|e| {
format!(
"Module specifier \"{}\" could not resolve through import map prefix \"{}\": {}",
normalized, key, e
)
})?;
if !resolved.as_str().starts_with(address.as_str()) {
return Err(format!(
"Module specifier \"{}\" backtracks above import map prefix \"{}\"",
normalized, key
));
}
return Ok(Some(resolved));
}
Ok(None)
}
}
fn normalize_key(key: &str, base: &ModuleSpecifier) -> Option<String> {
if key.is_empty() {
return None;
}
Some(
resolve_url_like(key, base)
.map(|url| url.to_string())
.unwrap_or_else(|| key.to_string()),
)
}
fn resolve_url_like(specifier: &str, base: &ModuleSpecifier) -> Option<ModuleSpecifier> {
if specifier.starts_with('/') || specifier.starts_with("./") || specifier.starts_with("../") {
base.join(specifier).ok()
} else {
ModuleSpecifier::parse(specifier).ok()
}
}
fn scope_applies(scope_prefix: &str, referrer: &str) -> bool {
scope_prefix == referrer || (scope_prefix.ends_with('/') && referrer.starts_with(scope_prefix))
}
fn is_special_url(url: &ModuleSpecifier) -> bool {
matches!(
url.scheme(),
"ftp" | "file" | "http" | "https" | "ws" | "wss"
)
}
#[cfg(test)]
mod tests {
use super::ImportMap;
#[test]
fn exact_prefix_and_url_like_keys_are_normalized_against_the_map_base() {
let mut map = ImportMap::parse(
r#"{
"imports": {
"pkg": "../vendor/pkg.js",
"pkg/": "../vendor/pkg/",
"./local.js": "../vendor/local.js"
}
}"#,
"https://example.test/app/maps/import-map.json",
)
.unwrap();
let referrer =
deno_core::ModuleSpecifier::parse("https://example.test/app/main.js").unwrap();
assert_eq!(
map.resolve("pkg", &referrer).unwrap().as_str(),
"https://example.test/app/vendor/pkg.js",
);
assert_eq!(
map.resolve("pkg/features/a.js", &referrer)
.unwrap()
.as_str(),
"https://example.test/app/vendor/pkg/features/a.js",
);
assert_eq!(
map.resolve("./maps/dir/../local.js", &referrer)
.unwrap()
.as_str(),
"https://example.test/app/vendor/local.js",
);
}
#[test]
fn most_specific_scope_wins_then_falls_back_to_top_level_imports() {
let mut map = ImportMap::parse(
r#"{
"imports": {
"shared": "/default.js",
"fallback": "/fallback.js"
},
"scopes": {
"/feature/": { "shared": "/feature.js" },
"/feature/nested/": { "shared": "/nested.js" }
}
}"#,
"https://example.test/app/index.html",
)
.unwrap();
let nested =
deno_core::ModuleSpecifier::parse("https://example.test/feature/nested/main.js")
.unwrap();
assert_eq!(
map.resolve("shared", &nested).unwrap().as_str(),
"https://example.test/nested.js",
);
assert_eq!(
map.resolve("fallback", &nested).unwrap().as_str(),
"https://example.test/fallback.js",
);
}
#[test]
fn later_maps_add_unrelated_rules_but_cannot_change_resolved_rules() {
let mut map = ImportMap::parse(
r#"{"imports":{"fixed":"/first.js"}}"#,
"https://example.test/app/index.html",
)
.unwrap();
let referrer =
deno_core::ModuleSpecifier::parse("https://example.test/app/main.js").unwrap();
assert_eq!(
map.resolve("fixed", &referrer).unwrap().as_str(),
"https://example.test/first.js",
);
map.merge(
ImportMap::parse(
r#"{"imports":{"fixed":"/second.js","later":"/later.js"}}"#,
"https://example.test/app/index.html",
)
.unwrap(),
);
assert_eq!(
map.resolve("fixed", &referrer).unwrap().as_str(),
"https://example.test/first.js",
);
assert_eq!(
map.resolve("later", &referrer).unwrap().as_str(),
"https://example.test/later.js",
);
}
#[test]
fn later_prefix_rules_cannot_capture_an_already_resolved_specifier() {
let mut map = ImportMap::default();
let referrer =
deno_core::ModuleSpecifier::parse("https://example.test/app/main.js").unwrap();
assert_eq!(
map.resolve("./pkg/item.js", &referrer).unwrap().as_str(),
"https://example.test/app/pkg/item.js",
);
map.merge(
ImportMap::parse(
r#"{"imports":{"./pkg/":"/replacement/","new/":"/new/"}}"#,
"https://example.test/app/main.js",
)
.unwrap(),
);
assert_eq!(
map.resolve("./pkg/item.js", &referrer).unwrap().as_str(),
"https://example.test/app/pkg/item.js",
);
assert_eq!(
map.resolve("new/item.js", &referrer).unwrap().as_str(),
"https://example.test/new/item.js",
);
}
#[test]
fn bare_relative_scope_prefix_is_resolved_as_a_url() {
let mut map = ImportMap::parse(
r#"{"scopes":{"feature/":{"pkg":"/scoped.js"}}}"#,
"https://example.test/app/index.html",
)
.unwrap();
let referrer =
deno_core::ModuleSpecifier::parse("https://example.test/app/feature/main.js").unwrap();
assert_eq!(
map.resolve("pkg", &referrer).unwrap().as_str(),
"https://example.test/scoped.js",
);
}
#[test]
fn malformed_scope_or_integrity_member_invalidates_the_complete_map() {
assert!(ImportMap::parse(
r#"{"imports":{"pkg":"/pkg.js"},"scopes":{"/app/":[]}}"#,
"https://example.test/index.html",
)
.is_err());
assert!(ImportMap::parse(
r#"{"imports":{"pkg":"/pkg.js"},"integrity":[]}"#,
"https://example.test/index.html",
)
.is_err());
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod cdp_watchdog; pub mod cdp_watchdog;
mod import_map;
pub mod module_loader; pub mod module_loader;
pub mod runtime; pub mod runtime;
pub mod ops; pub mod ops;
+30 -2
View File
@@ -11,6 +11,7 @@ use deno_core::ModuleSourceCode;
use deno_core::ModuleSpecifier; use deno_core::ModuleSpecifier;
use deno_core::RequestedModuleType; use deno_core::RequestedModuleType;
use crate::import_map::ImportMap;
use crate::ops::ObscuraState; use crate::ops::ObscuraState;
pub struct ObscuraModuleLoader { pub struct ObscuraModuleLoader {
@@ -27,6 +28,7 @@ pub struct ObscuraModuleLoader {
/// Directly-constructed loaders still use Obscura's network policy and /// Directly-constructed loaders still use Obscura's network policy and
/// connection pool; they simply have an isolated cookie jar. /// connection pool; they simply have an isolated cookie jar.
standalone_client: Option<Arc<obscura_net::ObscuraHttpClient>>, standalone_client: Option<Arc<obscura_net::ObscuraHttpClient>>,
import_map: Rc<RefCell<ImportMap>>,
} }
impl ObscuraModuleLoader { impl ObscuraModuleLoader {
@@ -35,6 +37,15 @@ impl ObscuraModuleLoader {
} }
pub fn with_proxy(base_url: &str, proxy_url: Option<String>) -> Self { pub fn with_proxy(base_url: &str, proxy_url: Option<String>) -> Self {
let import_map = Rc::new(RefCell::new(ImportMap::default()));
Self::with_proxy_and_import_map(base_url, proxy_url, import_map)
}
fn with_proxy_and_import_map(
base_url: &str,
proxy_url: Option<String>,
import_map: Rc<RefCell<ImportMap>>,
) -> Self {
let standalone_client = Arc::new(obscura_net::ObscuraHttpClient::with_options( let standalone_client = Arc::new(obscura_net::ObscuraHttpClient::with_options(
Arc::new(obscura_net::CookieJar::new()), Arc::new(obscura_net::CookieJar::new()),
proxy_url.as_deref(), proxy_url.as_deref(),
@@ -44,6 +55,7 @@ impl ObscuraModuleLoader {
proxy_url, proxy_url,
page_state: None, page_state: None,
standalone_client: Some(standalone_client), standalone_client: Some(standalone_client),
import_map,
} }
} }
@@ -51,12 +63,14 @@ impl ObscuraModuleLoader {
base_url: &str, base_url: &str,
proxy_url: Option<String>, proxy_url: Option<String>,
page_state: &Rc<RefCell<ObscuraState>>, page_state: &Rc<RefCell<ObscuraState>>,
import_map: Rc<RefCell<ImportMap>>,
) -> Self { ) -> Self {
ObscuraModuleLoader { ObscuraModuleLoader {
base_url: base_url.to_string(), base_url: base_url.to_string(),
proxy_url, proxy_url,
page_state: Some(Rc::downgrade(page_state)), page_state: Some(Rc::downgrade(page_state)),
standalone_client: None, standalone_client: None,
import_map,
} }
} }
} }
@@ -72,9 +86,17 @@ impl ModuleLoader for ObscuraModuleLoader {
referrer: &str, referrer: &str,
_kind: deno_core::ResolutionKind, _kind: deno_core::ResolutionKind,
) -> Result<ModuleSpecifier, ModuleLoaderError> { ) -> Result<ModuleSpecifier, ModuleLoaderError> {
// deno_core represents the root passed to load_side_es_module with a
// synthetic "." referrer. A browser resolves <script type=module src>
// as a resource URL before it starts a graph; the document import map
// must not remap that root URL.
if referrer == "." {
return deno_core::resolve_import(specifier, &self.base_url)
.map_err(|error| error.into());
}
let base = if referrer.is_empty() let base = if referrer.is_empty()
|| referrer.starts_with('<') || referrer.starts_with('<')
|| referrer == "."
|| referrer == "about:blank" || referrer == "about:blank"
{ {
&self.base_url &self.base_url
@@ -82,7 +104,13 @@ impl ModuleLoader for ObscuraModuleLoader {
referrer referrer
}; };
deno_core::resolve_import(specifier, base).map_err(|e| e.into()) let base = ModuleSpecifier::parse(base)
.map_err(|e| io_err(format!("Invalid module referrer {}: {}", base, e)))?;
self.import_map
.try_borrow_mut()
.map_err(|_| io_err("Import map is already borrowed".to_string()))?
.resolve(specifier, &base)
.map_err(io_err)
} }
fn load( fn load(
+30
View File
@@ -13,6 +13,8 @@ use obscura_net::{CallbackRegistry, CookieJar, ObscuraHttpClient, RequestInfo, R
use obscura_net::StealthHttpClient; use obscura_net::StealthHttpClient;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::import_map::ImportMap;
pub type InterceptCallback = Arc<Mutex<Option<Box<dyn Fn(String, String, String) -> Option<(u16, String, String)> + Send + Sync>>>>; pub type InterceptCallback = Arc<Mutex<Option<Box<dyn Fn(String, String, String) -> Option<(u16, String, String)> + Send + Sync>>>>;
#[derive(Debug)] #[derive(Debug)]
@@ -123,6 +125,9 @@ pub struct ObscuraState {
/// read by CSSOM geometry and screenshot paint. /// read by CSSOM geometry and screenshot paint.
#[cfg(feature = "render")] #[cfg(feature = "render")]
pub scroll_offset: (f32, f32), pub scroll_offset: (f32, f32),
/// Window-global import-map state shared by parser-discovered scripts,
/// dynamically inserted import maps, and the module loader.
pub(crate) import_map: Rc<RefCell<ImportMap>>,
} }
impl ObscuraState { impl ObscuraState {
@@ -158,6 +163,7 @@ impl ObscuraState {
viewport: (1280.0, 720.0), viewport: (1280.0, 720.0),
#[cfg(feature = "render")] #[cfg(feature = "render")]
scroll_offset: (0.0, 0.0), scroll_offset: (0.0, 0.0),
import_map: Rc::new(RefCell::new(ImportMap::default())),
} }
} }
} }
@@ -2142,6 +2148,29 @@ fn op_url_resolve(#[string] href: &str, #[string] base: &str) -> String {
.unwrap_or_default() .unwrap_or_default()
} }
#[op2]
#[string]
fn op_add_import_map(
state: &OpState,
#[string] source: String,
#[string] base_url: String,
) -> String {
let shared = state.borrow::<SharedState>().clone();
let import_map = shared.borrow().import_map.clone();
let parsed = match ImportMap::parse(&source, &base_url) {
Ok(map) => map,
Err(error) => return error,
};
let result = match import_map.try_borrow_mut() {
Ok(mut current) => {
current.merge(parsed);
String::new()
}
Err(_) => "Import map is already borrowed".to_string(),
};
result
}
/// Canonical (lowercased) WHATWG name for a TextDecoder label, or "" if the /// Canonical (lowercased) WHATWG name for a TextDecoder label, or "" if the
/// label is unknown (the JS constructor turns "" into a RangeError). /// label is unknown (the JS constructor turns "" into a RangeError).
#[op2] #[op2]
@@ -2254,6 +2283,7 @@ pub fn build_extension() -> Extension {
op_url_parse(), op_url_parse(),
op_url_set(), op_url_set(),
op_url_resolve(), op_url_resolve(),
op_add_import_map(),
op_encoding_for_label(), op_encoding_for_label(),
op_text_decode(), op_text_decode(),
op_url_encode_query(), op_url_encode_query(),
+354 -29
View File
@@ -9,6 +9,7 @@ use obscura_dom::DomTree;
/// isolate handle without taking a direct dependency on deno_core. /// isolate handle without taking a direct dependency on deno_core.
pub use deno_core::v8::IsolateHandle; pub use deno_core::v8::IsolateHandle;
use crate::import_map::ImportMap;
use crate::module_loader::ObscuraModuleLoader; use crate::module_loader::ObscuraModuleLoader;
#[cfg(feature = "render")] #[cfg(feature = "render")]
use crate::ops::{clamp_scroll_offset, document_base_url, ensure_prepared_render}; use crate::ops::{clamp_scroll_offset, document_base_url, ensure_prepared_render};
@@ -43,12 +44,20 @@ pub struct ObscuraJsRuntime {
state: Rc<RefCell<ObscuraState>>, state: Rc<RefCell<ObscuraState>>,
object_store: HashMap<String, String>, object_store: HashMap<String, String>,
object_counter: u64, object_counter: u64,
import_map: Rc<RefCell<ImportMap>>,
/// Thread-safe handle to this runtime's V8 isolate, captured at /// Thread-safe handle to this runtime's V8 isolate, captured at
/// construction. Lets a watchdog be armed from `&self` (the CDP dispatcher /// construction. Lets a watchdog be armed from `&self` (the CDP dispatcher
/// only holds `&Page` on the hot path) and is stable for the isolate's life. /// only holds `&Page` on the hot path) and is stable for the isolate's life.
isolate_handle: IsolateHandle, isolate_handle: IsolateHandle,
} }
/// A fetched and instantiated module graph whose evaluation is intentionally
/// delayed until the HTML script scheduler reaches its post-parse turn.
pub struct PreparedModule {
module_id: deno_core::ModuleId,
description: String,
}
/// Handle to an armed V8 execution watchdog (see [`ObscuraJsRuntime::arm_watchdog`]). /// Handle to an armed V8 execution watchdog (see [`ObscuraJsRuntime::arm_watchdog`]).
/// Holds the cancel channel and the watchdog thread; pass it back to /// Holds the cancel channel and the watchdog thread; pass it back to
/// `disarm_watchdog` to stop the watchdog and learn whether it fired. /// `disarm_watchdog` to stop the watchdog and learn whether it fired.
@@ -129,9 +138,10 @@ impl ObscuraJsRuntime {
pub fn with_base_url_and_proxy(base_url: &str, proxy_url: Option<String>) -> Self { pub fn with_base_url_and_proxy(base_url: &str, proxy_url: Option<String>) -> Self {
let state = Rc::new(RefCell::new(ObscuraState::new())); let state = Rc::new(RefCell::new(ObscuraState::new()));
let state_clone = state.clone(); let state_clone = state.clone();
let import_map = state.borrow().import_map.clone();
let module_loader = Rc::new(ObscuraModuleLoader::with_page_state( let module_loader = Rc::new(ObscuraModuleLoader::with_page_state(
base_url, proxy_url, &state, base_url, proxy_url, &state, import_map.clone(),
)); ));
// Build the isolate under the process-wide creation lock so two // Build the isolate under the process-wide creation lock so two
@@ -166,10 +176,23 @@ impl ObscuraJsRuntime {
state, state,
object_store: HashMap::new(), object_store: HashMap::new(),
object_counter: 0, object_counter: 0,
import_map,
isolate_handle, isolate_handle,
} }
} }
/// Parse and merge an inline document import map. Rules which would alter
/// already-observed module resolutions are discarded while unrelated new
/// rules remain available, matching Chromium's multiple-map model.
pub fn add_import_map(&self, source: &str, base_url: &str) -> Result<(), String> {
let map = ImportMap::parse(source, base_url)?;
self.import_map
.try_borrow_mut()
.map_err(|_| "Import map is already borrowed".to_string())?
.merge(map);
Ok(())
}
pub fn set_cookie_jar(&self, jar: std::sync::Arc<obscura_net::CookieJar>) { pub fn set_cookie_jar(&self, jar: std::sync::Arc<obscura_net::CookieJar>) {
self.state.borrow_mut().cookie_jar = Some(jar); self.state.borrow_mut().cookie_jar = Some(jar);
} }
@@ -747,6 +770,11 @@ impl ObscuraJsRuntime {
self.object_store.clear(); self.object_store.clear();
} }
pub async fn load_module(&mut self, url: &str, budget_ms: u64) -> Result<(), String> { pub async fn load_module(&mut self, url: &str, budget_ms: u64) -> Result<(), String> {
let prepared = self.prepare_module(url, budget_ms).await?;
self.evaluate_prepared_module(prepared, budget_ms).await
}
pub async fn prepare_module(&mut self, url: &str, budget_ms: u64) -> Result<PreparedModule, String> {
let budget = tokio::time::Duration::from_millis(budget_ms); let budget = tokio::time::Duration::from_millis(budget_ms);
let specifier = deno_core::ModuleSpecifier::parse(url) let specifier = deno_core::ModuleSpecifier::parse(url)
.map_err(|e| format!("Invalid module URL {}: {}", url, e))?; .map_err(|e| format!("Invalid module URL {}: {}", url, e))?;
@@ -775,8 +803,10 @@ impl ObscuraJsRuntime {
// Return as soon as the module finishes evaluating rather than waiting // Return as soon as the module finishes evaluating rather than waiting
// for the loop to go fully idle: a page timer (setInterval) keeps the // for the loop to go fully idle: a page timer (setInterval) keeps the
// loop busy forever and would otherwise burn the whole budget (#374). // loop busy forever and would otherwise burn the whole budget (#374).
self.drive_module_eval(module_id, budget_ms, &format!("Module {}", url)) Ok(PreparedModule {
.await module_id,
description: format!("Module {}", url),
})
} }
/// Drive a just-started module evaluation to completion, or up to /// Drive a just-started module evaluation to completion, or up to
@@ -823,13 +853,26 @@ impl ObscuraJsRuntime {
} }
pub async fn load_inline_module(&mut self, code: &str, base_url: &str, budget_ms: u64) -> Result<(), String> { pub async fn load_inline_module(&mut self, code: &str, base_url: &str, budget_ms: u64) -> Result<(), String> {
let budget = tokio::time::Duration::from_millis(budget_ms); let prepared = self
let specifier = deno_core::ModuleSpecifier::parse( .prepare_inline_module(code, base_url, budget_ms)
&format!("{}#inline-module-{}", base_url, self.object_counter), .await?;
) self.evaluate_prepared_module(prepared, budget_ms).await
.unwrap_or_else(|_| deno_core::ModuleSpecifier::parse("about:blank").unwrap()); }
self.object_counter += 1; pub async fn prepare_inline_module(
&mut self,
code: &str,
base_url: &str,
budget_ms: u64,
) -> Result<PreparedModule, String> {
let budget = tokio::time::Duration::from_millis(budget_ms);
// Inline modules use the document base URL as their module URL. This is
// observable through import.meta.url and is also the referrer used for
// relative imports and import-map scope matching. deno_core permits
// multiple side modules with this name; the returned ModuleId keeps
// each prepared module distinct until its scheduled evaluation.
let specifier = deno_core::ModuleSpecifier::parse(base_url)
.unwrap_or_else(|_| deno_core::ModuleSpecifier::parse("about:blank").unwrap());
let module_id = match tokio::time::timeout( let module_id = match tokio::time::timeout(
budget, budget,
@@ -853,34 +896,95 @@ impl ObscuraJsRuntime {
// keeps the loop busy forever, and waiting for idle burned the whole // keeps the loop busy forever, and waiting for idle burned the whole
// budget on this preamble module and starved the module that mounts the // budget on this preamble module and starved the module that mounts the
// app, leaving #root empty (issue #374). // app, leaving #root empty (issue #374).
self.drive_module_eval(module_id, budget_ms, "Inline module").await Ok(PreparedModule {
module_id,
description: "Inline module".to_string(),
})
} }
pub fn execute_script(&mut self, _name: &str, source: &str) -> Result<(), String> { pub async fn evaluate_prepared_module(
self.runtime &mut self,
.execute_script("<script>", source.to_string()) prepared: PreparedModule,
.map_err(|e| format!("JS error: {}", e))?; budget_ms: u64,
) -> Result<(), String> {
self.drive_module_eval(prepared.module_id, budget_ms, &prepared.description)
.await
}
fn execute_classic_script(&mut self, name: &str, source: &str) -> Result<(), String> {
// JsRuntime::execute_script in deno_core 0.350 restricts `name` to a
// &'static str. Browser script URLs are runtime data, and V8 uses this
// origin as import()'s referrer, so compile in the runtime's main
// context directly instead of substituting the fixed "<script>" name.
let scope = &mut self.runtime.handle_scope();
let source = deno_core::v8::String::new(scope, source)
.ok_or_else(|| "JS error: source allocation failed".to_string())?;
let name = deno_core::v8::String::new(scope, name)
.ok_or_else(|| "JS error: script URL allocation failed".to_string())?;
let origin = deno_core::v8::ScriptOrigin::new(
scope,
name.into(),
0,
0,
false,
0,
None,
false,
false,
false,
None,
);
let scope = &mut deno_core::v8::TryCatch::new(scope);
let script = deno_core::v8::Script::compile(scope, source, Some(&origin));
let Some(script) = script else {
if scope.is_execution_terminating() {
scope.cancel_terminate_execution();
return Err("JS error: Uncaught Error: execution terminated".to_string());
}
return match scope.exception() {
Some(exception) => {
let error = deno_core::error::JsError::from_v8_exception(scope, exception);
Err(format!("JS error: {error}"))
}
None => Err("JS error: script compilation failed without an exception".to_string()),
};
};
if script.run(scope).is_none() {
if scope.is_execution_terminating() {
scope.cancel_terminate_execution();
return Err("JS error: Uncaught Error: execution terminated".to_string());
}
return match scope.exception() {
Some(exception) => {
let error = deno_core::error::JsError::from_v8_exception(scope, exception);
Err(format!("JS error: {error}"))
}
None => Err("JS error: script execution failed without an exception".to_string()),
};
}
Ok(()) Ok(())
} }
pub fn execute_script_guarded(&mut self, _name: &str, source: &str) -> Result<(), String> { pub fn execute_script(&mut self, name: &str, source: &str) -> Result<(), String> {
self.execute_classic_script(name, source)
}
pub fn execute_script_guarded(&mut self, name: &str, source: &str) -> Result<(), String> {
if source.len() < 10_000 { if source.len() < 10_000 {
self.execute_script(_name, source) self.execute_script(name, source)
} else { } else {
self.execute_script_with_timeout(source, std::time::Duration::from_secs(5)) self.execute_script_with_timeout(name, source, std::time::Duration::from_secs(5))
} }
} }
pub fn execute_script_with_timeout( pub fn execute_script_with_timeout(
&mut self, &mut self,
name: &str,
source: &str, source: &str,
timeout: std::time::Duration, timeout: std::time::Duration,
) -> Result<(), String> { ) -> Result<(), String> {
if timeout.is_zero() { if timeout.is_zero() {
self.runtime return self.execute_classic_script(name, source);
.execute_script("<script>", source.to_string())
.map_err(|e| format!("JS error: {}", e))?;
return Ok(());
} }
let isolate_handle = self.runtime.v8_isolate().thread_safe_handle(); let isolate_handle = self.runtime.v8_isolate().thread_safe_handle();
@@ -911,9 +1015,7 @@ impl ObscuraJsRuntime {
} }
}); });
let result = self let result = self.execute_classic_script(name, source);
.runtime
.execute_script("<script>", source.to_string());
{ {
let (lock, cvar) = &*pair; let (lock, cvar) = &*pair;
@@ -924,15 +1026,13 @@ impl ObscuraJsRuntime {
let _ = watchdog.join(); let _ = watchdog.join();
match result { match result {
Ok(_) => Ok(()), Ok(()) => Ok(()),
Err(e) => { Err(msg) => {
let msg = e.to_string();
if msg.contains("Uncaught Error: execution terminated") { if msg.contains("Uncaught Error: execution terminated") {
tracing::warn!("Script killed after {}s timeout", timeout.as_secs()); tracing::warn!("Script killed after {}s timeout", timeout.as_secs());
self.runtime.execute_script("<reset>", "undefined".to_string()).ok();
Ok(()) Ok(())
} else { } else {
Err(format!("JS error: {}", msg)) Err(msg)
} }
} }
} }
@@ -7166,6 +7266,78 @@ mod tests {
(format!("http://{}", address), requests_rx) (format!("http://{}", address), requests_rx)
} }
fn spawn_import_map_server() -> (String, std::sync::mpsc::Receiver<String>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let (requests_tx, requests_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
use std::io::{Read as _, Write as _};
for _ in 0..2 {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0u8; 2048];
let length = stream.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..length]);
let path = request
.lines()
.next()
.and_then(|line| line.split_ascii_whitespace().nth(1))
.unwrap_or("/")
.to_string();
requests_tx.send(path.clone()).unwrap();
let (status, body) = match path.as_str() {
"/vendor/pkg/feature.js" => ("200 OK", "export const value = 'prefix-static';"),
"/vendor/dynamic.js" => ("200 OK", "export const value = 'exact-dynamic';"),
_ => ("404 Not Found", "not found"),
};
let response = format!(
"HTTP/1.1 {status}\r\n\
Content-Type: application/javascript\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\r\n{body}",
body.len(),
);
stream.write_all(response.as_bytes()).unwrap();
}
});
(format!("http://{}", address), requests_rx)
}
fn spawn_root_module_import_map_server() -> (String, std::sync::mpsc::Receiver<String>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let (requests_tx, requests_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
use std::io::{Read as _, Write as _};
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0u8; 2048];
let length = stream.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..length]);
let path = request
.lines()
.next()
.and_then(|line| line.split_ascii_whitespace().nth(1))
.unwrap_or("/")
.to_string();
requests_tx.send(path.clone()).unwrap();
let body = if path == "/entry.js" {
"globalThis.__root_module_identity = 'entry';"
} else {
"globalThis.__root_module_identity = 'remapped';"
};
let response = format!(
"HTTP/1.1 200 OK\r\n\
Content-Type: application/javascript\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\r\n{body}",
body.len(),
);
stream.write_all(response.as_bytes()).unwrap();
});
(format!("http://{}", address), requests_rx)
}
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
async fn entry_module_http_failure_is_not_evaluated_as_empty_source() { async fn entry_module_http_failure_is_not_evaluated_as_empty_source() {
let base = spawn_one_response_server("404 Not Found", "not found"); let base = spawn_one_response_server("404 Not Found", "not found");
@@ -7302,6 +7474,159 @@ mod tests {
); );
} }
#[tokio::test(flavor = "current_thread")]
async fn import_map_resolves_prefix_static_and_exact_dynamic_imports() {
let (base, requests) = spawn_import_map_server();
let jar = std::sync::Arc::new(obscura_net::CookieJar::new());
let client = std::sync::Arc::new(obscura_net::ObscuraHttpClient::with_full_options(
jar, None, true,
));
let mut rt = ObscuraJsRuntime::with_base_url(&format!("{}/app/index.html", base));
rt.set_http_client(client);
rt.add_import_map(
r#"{
"imports": {
"pkg/": "../vendor/pkg/",
"dynamic-pkg": "../vendor/dynamic.js"
}
}"#,
&format!("{}/config/import-map.json", base),
)
.unwrap();
rt.load_inline_module(
"import { value as prefix } from 'pkg/feature.js'; \
const dynamic = (await import('dynamic-pkg')).value; \
globalThis.__import_map_values = [prefix, dynamic];",
&format!("{}/app/index.html", base),
1_000,
)
.await
.unwrap();
assert_eq!(
rt.evaluate("globalThis.__import_map_values").unwrap(),
serde_json::json!(["prefix-static", "exact-dynamic"]),
);
let paths = (0..2)
.map(|_| requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap())
.collect::<Vec<_>>();
assert!(paths.contains(&"/vendor/pkg/feature.js".to_string()), "{paths:?}");
assert!(paths.contains(&"/vendor/dynamic.js".to_string()), "{paths:?}");
}
#[tokio::test(flavor = "current_thread")]
async fn import_map_does_not_remap_external_root_module_url() {
let (base, requests) = spawn_root_module_import_map_server();
let jar = std::sync::Arc::new(obscura_net::CookieJar::new());
let client = std::sync::Arc::new(obscura_net::ObscuraHttpClient::with_full_options(
jar, None, true,
));
let mut rt = ObscuraJsRuntime::with_base_url(&format!("{}/index.html", base));
rt.set_http_client(client);
rt.add_import_map(
&format!(r#"{{"imports":{{"{base}/entry.js":"{base}/remapped.js"}}}}"#),
&format!("{}/index.html", base),
)
.unwrap();
rt.load_module(&format!("{}/entry.js", base), 1_000).await.unwrap();
assert_eq!(
rt.evaluate("globalThis.__root_module_identity").unwrap(),
serde_json::json!("entry"),
);
assert_eq!(
requests.recv_timeout(std::time::Duration::from_secs(1)).unwrap(),
"/entry.js",
);
}
#[tokio::test(flavor = "current_thread")]
async fn inline_modules_expose_document_base_as_import_meta_url() {
let mut rt = ObscuraJsRuntime::with_base_url("https://example.com/page/index.html");
rt.load_inline_module(
"globalThis.__first_inline_url = import.meta.url;",
"https://example.com/base/",
1_000,
).await.unwrap();
rt.load_inline_module(
"globalThis.__second_inline_url = import.meta.url;",
"https://example.com/base/",
1_000,
).await.unwrap();
assert_eq!(
rt.evaluate("[globalThis.__first_inline_url, globalThis.__second_inline_url]").unwrap(),
serde_json::json!(["https://example.com/base/", "https://example.com/base/"])
);
}
#[tokio::test(flavor = "current_thread")]
async fn classic_script_url_is_dynamic_import_referrer() {
use std::io::{Read as _, Write as _};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let request_thread = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0u8; 2048];
let length = stream.read(&mut request).unwrap();
let path = String::from_utf8_lossy(&request[..length])
.lines().next()
.and_then(|line| line.split_ascii_whitespace().nth(1))
.unwrap_or("/").to_string();
let body = "export const value = 'scoped-classic';";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len(),
);
stream.write_all(response.as_bytes()).unwrap();
path
});
let base = format!("http://{address}");
let jar = std::sync::Arc::new(obscura_net::CookieJar::new());
let client = std::sync::Arc::new(obscura_net::ObscuraHttpClient::with_full_options(
jar, None, true,
));
let mut rt = ObscuraJsRuntime::with_base_url(&format!("{base}/page/index.html"));
rt.set_http_client(client);
rt.set_dom(parse_html("<html><body></body></html>"));
rt.run_page_init();
rt.add_import_map(
&format!(r#"{{"scopes":{{"{base}/classic/":{{"pkg":"{base}/scoped.js"}}}}}}"#),
&format!("{base}/page/index.html"),
).unwrap();
rt.execute_script(
&format!("{base}/classic/entry.js"),
"document.documentElement.setAttribute('data-classic-op', 'ran'); \
import('pkg').then(module => { globalThis.__classic_import = module.value; });",
).unwrap();
rt.run_event_loop().await.unwrap();
assert_eq!(rt.evaluate("globalThis.__classic_import").unwrap(), serde_json::json!("scoped-classic"));
assert_eq!(
rt.evaluate("document.documentElement.getAttribute('data-classic-op')").unwrap(),
serde_json::json!("ran")
);
assert_eq!(request_thread.join().unwrap(), "/scoped.js");
}
#[test]
fn timed_out_classic_script_leaves_runtime_reusable() {
let mut rt = ObscuraJsRuntime::new();
rt.execute_script_with_timeout(
"https://example.test/hang.js",
"while (true) {}",
std::time::Duration::from_millis(20),
).unwrap();
rt.execute_script(
"https://example.test/after-timeout.js",
"globalThis.__after_timeout = true;",
).unwrap();
assert_eq!(rt.evaluate("globalThis.__after_timeout").unwrap(), serde_json::json!(true));
}
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
async fn inline_module_graph_error_propagates() { async fn inline_module_graph_error_propagates() {
let mut rt = ObscuraJsRuntime::with_base_url("https://example.com/"); let mut rt = ObscuraJsRuntime::with_base_url("https://example.com/");