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
@@ -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;
|
||||
mod import_map;
|
||||
pub mod module_loader;
|
||||
pub mod runtime;
|
||||
pub mod ops;
|
||||
|
||||
@@ -11,6 +11,7 @@ use deno_core::ModuleSourceCode;
|
||||
use deno_core::ModuleSpecifier;
|
||||
use deno_core::RequestedModuleType;
|
||||
|
||||
use crate::import_map::ImportMap;
|
||||
use crate::ops::ObscuraState;
|
||||
|
||||
pub struct ObscuraModuleLoader {
|
||||
@@ -27,6 +28,7 @@ pub struct ObscuraModuleLoader {
|
||||
/// Directly-constructed loaders still use Obscura's network policy and
|
||||
/// connection pool; they simply have an isolated cookie jar.
|
||||
standalone_client: Option<Arc<obscura_net::ObscuraHttpClient>>,
|
||||
import_map: Rc<RefCell<ImportMap>>,
|
||||
}
|
||||
|
||||
impl ObscuraModuleLoader {
|
||||
@@ -35,6 +37,15 @@ impl ObscuraModuleLoader {
|
||||
}
|
||||
|
||||
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(
|
||||
Arc::new(obscura_net::CookieJar::new()),
|
||||
proxy_url.as_deref(),
|
||||
@@ -44,6 +55,7 @@ impl ObscuraModuleLoader {
|
||||
proxy_url,
|
||||
page_state: None,
|
||||
standalone_client: Some(standalone_client),
|
||||
import_map,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,12 +63,14 @@ impl ObscuraModuleLoader {
|
||||
base_url: &str,
|
||||
proxy_url: Option<String>,
|
||||
page_state: &Rc<RefCell<ObscuraState>>,
|
||||
import_map: Rc<RefCell<ImportMap>>,
|
||||
) -> Self {
|
||||
ObscuraModuleLoader {
|
||||
base_url: base_url.to_string(),
|
||||
proxy_url,
|
||||
page_state: Some(Rc::downgrade(page_state)),
|
||||
standalone_client: None,
|
||||
import_map,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,9 +86,17 @@ impl ModuleLoader for ObscuraModuleLoader {
|
||||
referrer: &str,
|
||||
_kind: deno_core::ResolutionKind,
|
||||
) -> 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()
|
||||
|| referrer.starts_with('<')
|
||||
|| referrer == "."
|
||||
|| referrer == "about:blank"
|
||||
{
|
||||
&self.base_url
|
||||
@@ -82,7 +104,13 @@ impl ModuleLoader for ObscuraModuleLoader {
|
||||
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(
|
||||
|
||||
@@ -13,6 +13,8 @@ use obscura_net::{CallbackRegistry, CookieJar, ObscuraHttpClient, RequestInfo, R
|
||||
use obscura_net::StealthHttpClient;
|
||||
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>>>>;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -123,6 +125,9 @@ pub struct ObscuraState {
|
||||
/// read by CSSOM geometry and screenshot paint.
|
||||
#[cfg(feature = "render")]
|
||||
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 {
|
||||
@@ -158,6 +163,7 @@ impl ObscuraState {
|
||||
viewport: (1280.0, 720.0),
|
||||
#[cfg(feature = "render")]
|
||||
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()
|
||||
}
|
||||
|
||||
#[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
|
||||
/// label is unknown (the JS constructor turns "" into a RangeError).
|
||||
#[op2]
|
||||
@@ -2254,6 +2283,7 @@ pub fn build_extension() -> Extension {
|
||||
op_url_parse(),
|
||||
op_url_set(),
|
||||
op_url_resolve(),
|
||||
op_add_import_map(),
|
||||
op_encoding_for_label(),
|
||||
op_text_decode(),
|
||||
op_url_encode_query(),
|
||||
|
||||
@@ -9,6 +9,7 @@ use obscura_dom::DomTree;
|
||||
/// isolate handle without taking a direct dependency on deno_core.
|
||||
pub use deno_core::v8::IsolateHandle;
|
||||
|
||||
use crate::import_map::ImportMap;
|
||||
use crate::module_loader::ObscuraModuleLoader;
|
||||
#[cfg(feature = "render")]
|
||||
use crate::ops::{clamp_scroll_offset, document_base_url, ensure_prepared_render};
|
||||
@@ -43,12 +44,20 @@ pub struct ObscuraJsRuntime {
|
||||
state: Rc<RefCell<ObscuraState>>,
|
||||
object_store: HashMap<String, String>,
|
||||
object_counter: u64,
|
||||
import_map: Rc<RefCell<ImportMap>>,
|
||||
/// Thread-safe handle to this runtime's V8 isolate, captured at
|
||||
/// 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.
|
||||
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`]).
|
||||
/// Holds the cancel channel and the watchdog thread; pass it back to
|
||||
/// `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 {
|
||||
let state = Rc::new(RefCell::new(ObscuraState::new()));
|
||||
let state_clone = state.clone();
|
||||
let import_map = state.borrow().import_map.clone();
|
||||
|
||||
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
|
||||
@@ -166,10 +176,23 @@ impl ObscuraJsRuntime {
|
||||
state,
|
||||
object_store: HashMap::new(),
|
||||
object_counter: 0,
|
||||
import_map,
|
||||
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>) {
|
||||
self.state.borrow_mut().cookie_jar = Some(jar);
|
||||
}
|
||||
@@ -747,6 +770,11 @@ impl ObscuraJsRuntime {
|
||||
self.object_store.clear();
|
||||
}
|
||||
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 specifier = deno_core::ModuleSpecifier::parse(url)
|
||||
.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
|
||||
// for the loop to go fully idle: a page timer (setInterval) keeps the
|
||||
// loop busy forever and would otherwise burn the whole budget (#374).
|
||||
self.drive_module_eval(module_id, budget_ms, &format!("Module {}", url))
|
||||
.await
|
||||
Ok(PreparedModule {
|
||||
module_id,
|
||||
description: format!("Module {}", url),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let budget = tokio::time::Duration::from_millis(budget_ms);
|
||||
let specifier = deno_core::ModuleSpecifier::parse(
|
||||
&format!("{}#inline-module-{}", base_url, self.object_counter),
|
||||
)
|
||||
.unwrap_or_else(|_| deno_core::ModuleSpecifier::parse("about:blank").unwrap());
|
||||
let prepared = self
|
||||
.prepare_inline_module(code, base_url, budget_ms)
|
||||
.await?;
|
||||
self.evaluate_prepared_module(prepared, budget_ms).await
|
||||
}
|
||||
|
||||
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(
|
||||
budget,
|
||||
@@ -853,34 +896,95 @@ impl ObscuraJsRuntime {
|
||||
// keeps the loop busy forever, and waiting for idle burned the whole
|
||||
// budget on this preamble module and starved the module that mounts the
|
||||
// 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> {
|
||||
self.runtime
|
||||
.execute_script("<script>", source.to_string())
|
||||
.map_err(|e| format!("JS error: {}", e))?;
|
||||
pub async fn evaluate_prepared_module(
|
||||
&mut self,
|
||||
prepared: PreparedModule,
|
||||
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(())
|
||||
}
|
||||
|
||||
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 {
|
||||
self.execute_script(_name, source)
|
||||
self.execute_script(name, source)
|
||||
} 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(
|
||||
&mut self,
|
||||
name: &str,
|
||||
source: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<(), String> {
|
||||
if timeout.is_zero() {
|
||||
self.runtime
|
||||
.execute_script("<script>", source.to_string())
|
||||
.map_err(|e| format!("JS error: {}", e))?;
|
||||
return Ok(());
|
||||
return self.execute_classic_script(name, source);
|
||||
}
|
||||
|
||||
let isolate_handle = self.runtime.v8_isolate().thread_safe_handle();
|
||||
@@ -911,9 +1015,7 @@ impl ObscuraJsRuntime {
|
||||
}
|
||||
});
|
||||
|
||||
let result = self
|
||||
.runtime
|
||||
.execute_script("<script>", source.to_string());
|
||||
let result = self.execute_classic_script(name, source);
|
||||
|
||||
{
|
||||
let (lock, cvar) = &*pair;
|
||||
@@ -924,15 +1026,13 @@ impl ObscuraJsRuntime {
|
||||
let _ = watchdog.join();
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
Ok(()) => Ok(()),
|
||||
Err(msg) => {
|
||||
if msg.contains("Uncaught Error: execution terminated") {
|
||||
tracing::warn!("Script killed after {}s timeout", timeout.as_secs());
|
||||
self.runtime.execute_script("<reset>", "undefined".to_string()).ok();
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("JS error: {}", msg))
|
||||
Err(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7166,6 +7266,78 @@ mod tests {
|
||||
(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")]
|
||||
async fn entry_module_http_failure_is_not_evaluated_as_empty_source() {
|
||||
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")]
|
||||
async fn inline_module_graph_error_propagates() {
|
||||
let mut rt = ObscuraJsRuntime::with_base_url("https://example.com/");
|
||||
|
||||
Reference in new issue
Block a user