Support current document import maps
This commit is contained in:
1 parent
98cdcdee11
commit
34373c309e
7 files changed
+1338
-136
No files matched your search
+442
-104
@@ -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() {
|
||||||
|
|||||||
Vendored
+26
-1
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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/");
|
||||||
|
|||||||
Reference in new issue
Block a user