diff --git a/Cargo.lock b/Cargo.lock index 6e797390253..29d0f173718 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -511,6 +511,7 @@ version = "0.18.0" dependencies = [ "boa_engine", "boa_gc", + "boa_macros", "rustc-hash", ] diff --git a/core/engine/src/module/loader.rs b/core/engine/src/module/loader.rs index 4a5ee35a99c..0f280444dd1 100644 --- a/core/engine/src/module/loader.rs +++ b/core/engine/src/module/loader.rs @@ -48,7 +48,7 @@ pub fn resolve_module_specifier( referrer: Option<&Path>, _context: &mut Context, ) -> JsResult { - let base = base.map_or_else(|| PathBuf::from(""), PathBuf::from); + let base_path = base.map_or_else(|| PathBuf::from(""), PathBuf::from); let referrer_dir = referrer.and_then(|p| p.parent()); let specifier = specifier.to_std_string_escaped(); @@ -65,17 +65,17 @@ pub fn resolve_module_specifier( let long_path = if is_relative { if let Some(r_path) = referrer_dir { - base.join(r_path).join(short_path) + base_path.join(r_path).join(short_path) } else { return Err(JsError::from_opaque( js_string!("relative path without referrer").into(), )); } } else { - base.join(&specifier) + base_path.join(&specifier) }; - if long_path.is_relative() { + if long_path.is_relative() && base.is_some() { return Err(JsError::from_opaque( js_string!("resolved path is relative").into(), )); @@ -100,7 +100,7 @@ pub fn resolve_module_specifier( Ok(acc) })?; - if path.starts_with(&base) { + if path.starts_with(&base_path) { Ok(path) } else { Err(JsError::from_opaque( @@ -371,6 +371,39 @@ mod tests { assert_eq!(actual.map_err(|_| ()), expected.map(PathBuf::from)); } + // This tests the same cases as the previous test, but without a base path. + #[rustfmt::skip] + #[cfg(target_family = "unix")] + #[test_case(Some("hello/ref.js"), "a.js", Ok("a.js"))] + #[test_case(Some("base/ref.js"), "./b.js", Ok("base/b.js"))] + #[test_case(Some("base/other/ref.js"), "./c.js", Ok("base/other/c.js"))] + #[test_case(Some("base/other/ref.js"), "../d.js", Ok("base/d.js"))] + #[test_case(Some("base/ref.js"), "e.js", Ok("e.js"))] + #[test_case(Some("base/ref.js"), "./f.js", Ok("base/f.js"))] + #[test_case(Some("./ref.js"), "./g.js", Ok("g.js"))] + #[test_case(Some("./other/ref.js"), "./other/h.js", Ok("other/other/h.js"))] + #[test_case(Some("./other/ref.js"), "./other/../h1.js", Ok("other/h1.js"))] + #[test_case(Some("./other/ref.js"), "./../h2.js", Ok("h2.js"))] + #[test_case(None, "./i.js", Err(()))] + #[test_case(None, "j.js", Ok("j.js"))] + #[test_case(None, "other/k.js", Ok("other/k.js"))] + #[test_case(None, "other/../../l.js", Err(()))] + #[test_case(Some("/base/ref.js"), "other/../../m.js", Err(()))] + #[test_case(None, "../n.js", Err(()))] + fn resolve_test_no_base(ref_path: Option<&str>, spec: &str, expected: Result<&str, ()>) { + let mut context = Context::default(); + let spec = js_string!(spec); + let ref_path = ref_path.map(PathBuf::from); + + let actual = resolve_module_specifier( + None, + &spec, + ref_path.as_deref(), + &mut context, + ); + assert_eq!(actual.map_err(|_| ()), expected.map(PathBuf::from)); + } + #[rustfmt::skip] #[cfg(target_family = "windows")] #[test_case(Some("a:\\hello\\ref.js"), "a.js", Ok("a:\\base\\a.js"))] diff --git a/core/interop/Cargo.toml b/core/interop/Cargo.toml index a098026bd82..33e3b0ab28b 100644 --- a/core/interop/Cargo.toml +++ b/core/interop/Cargo.toml @@ -13,6 +13,7 @@ rust-version.workspace = true [dependencies] boa_engine.workspace = true boa_gc.workspace = true +boa_macros.workspace = true rustc-hash = { workspace = true, features = ["std"] } [lints] diff --git a/core/interop/src/lib.rs b/core/interop/src/lib.rs index 1ff4eca8da5..16c3beb8e7c 100644 --- a/core/interop/src/lib.rs +++ b/core/interop/src/lib.rs @@ -3,6 +3,7 @@ use boa_engine::module::SyntheticModuleInitializer; use boa_engine::value::TryFromJs; use boa_engine::{Context, JsResult, JsString, JsValue, Module, NativeFunction}; +pub use boa_macros; pub mod loaders; diff --git a/core/interop/src/loaders.rs b/core/interop/src/loaders.rs index 663210dde87..fb59f4fc603 100644 --- a/core/interop/src/loaders.rs +++ b/core/interop/src/loaders.rs @@ -3,4 +3,5 @@ pub use hashmap::HashMapModuleLoader; +pub mod embedded; pub mod hashmap; diff --git a/core/interop/src/loaders/embedded.rs b/core/interop/src/loaders/embedded.rs new file mode 100644 index 00000000000..0f89c0a2cdc --- /dev/null +++ b/core/interop/src/loaders/embedded.rs @@ -0,0 +1,139 @@ +//! Embedded module loader. Creates a `ModuleLoader` instance that contains +//! files embedded in the binary at build time. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::path::Path; + +use boa_engine::module::{ModuleLoader, Referrer}; +use boa_engine::{Context, JsNativeError, JsResult, JsString, Module, Source}; + +/// Create a module loader that embeds files from the filesystem at build +/// time. This is useful for bundling assets with the binary. +/// +/// By default, will error if the total file size exceeds 1MB. This can be +/// changed by specifying the `max_size` parameter. +/// +/// The embedded module will only contain files that have the `.js`, `.mjs`, +/// or `.cjs` extension. +#[macro_export] +macro_rules! embed_module { + ($path: literal, max_size = $max_size: literal) => { + $crate::loaders::embedded::EmbeddedModuleLoader::from_iter( + $crate::boa_macros::embed_module_inner!($path, $max_size), + ) + }; + ($path: literal) => { + embed_module!($path, max_size = 1_048_576) + }; +} + +#[derive(Debug, Clone)] +enum EmbeddedModuleEntry { + Source(JsString, &'static [u8]), + Module(Module), +} + +impl EmbeddedModuleEntry { + fn from_source(path: JsString, source: &'static [u8]) -> Self { + Self::Source(path, source) + } + + fn cache(&mut self, context: &mut Context) -> JsResult<&Module> { + if let Self::Source(path, source) = self { + let mut bytes: &[u8] = source; + let path = path.to_std_string_escaped(); + let source = Source::from_reader(&mut bytes, Some(Path::new(&path))); + match Module::parse(source, None, context) { + Ok(module) => { + *self = Self::Module(module); + } + Err(err) => { + return Err(err); + } + } + }; + + match self { + Self::Module(module) => Ok(module), + EmbeddedModuleEntry::Source(_, _) => unreachable!(), + } + } + + fn as_module(&self) -> Option<&Module> { + match self { + Self::Module(module) => Some(module), + Self::Source(_, _) => None, + } + } +} + +/// The resulting type of creating an embedded module loader. +#[derive(Debug, Clone)] +#[allow(clippy::module_name_repetitions)] +pub struct EmbeddedModuleLoader { + map: HashMap>, +} + +impl FromIterator<(&'static str, &'static [u8])> for EmbeddedModuleLoader { + fn from_iter>(iter: T) -> Self { + Self { + map: iter + .into_iter() + .map(|(path, source)| { + let p = JsString::from(path); + ( + p.clone(), + RefCell::new(EmbeddedModuleEntry::from_source(p, source)), + ) + }) + .collect(), + } + } +} + +impl ModuleLoader for EmbeddedModuleLoader { + fn load_imported_module( + &self, + referrer: Referrer, + specifier: JsString, + finish_load: Box, &mut Context)>, + context: &mut Context, + ) { + let Ok(specifier_path) = boa_engine::module::resolve_module_specifier( + None, + &specifier, + referrer.path(), + context, + ) else { + let err = JsNativeError::typ().with_message(format!( + "could not resolve module specifier `{}`", + specifier.to_std_string_escaped() + )); + finish_load(Err(err.into()), context); + return; + }; + + if let Some(module) = self + .map + .get(&JsString::from(specifier_path.to_string_lossy().as_ref())) + { + let mut embedded = module.borrow_mut(); + let module = embedded.cache(context); + + finish_load(module.cloned(), context); + } else { + let err = JsNativeError::typ().with_message(format!( + "could not find module `{}`", + specifier.to_std_string_escaped() + )); + finish_load(Err(err.into()), context); + } + } + + fn get_module(&self, specifier: JsString) -> Option { + self.map + .get(&specifier) + .and_then(|module| module.borrow().as_module().cloned()) + } +} diff --git a/core/interop/tests/embedded.rs b/core/interop/tests/embedded.rs new file mode 100644 index 00000000000..1ef0c4c4516 --- /dev/null +++ b/core/interop/tests/embedded.rs @@ -0,0 +1,67 @@ +#![allow(unused_crate_dependencies)] + +use std::rc::Rc; + +use boa_engine::builtins::promise::PromiseState; +use boa_engine::module::ModuleLoader; +use boa_engine::{js_string, Context, JsString, JsValue, Module, Source}; +use boa_interop::embed_module; + +#[test] +fn simple() { + #[cfg(target_family = "unix")] + let module_loader = Rc::new(embed_module!("tests/embedded/")); + #[cfg(target_family = "windows")] + let module_loader = Rc::new(embed_module!("tests\\embedded\\")); + + let mut context = Context::builder() + .module_loader(module_loader.clone()) + .build() + .unwrap(); + + // Resolving modules that exist but haven't been cached yet should return None. + assert_eq!(module_loader.get_module(JsString::from("/file1.js")), None); + assert_eq!( + module_loader.get_module(JsString::from("/non-existent.js")), + None + ); + + let module = Module::parse( + Source::from_bytes(b"export { bar } from '/file1.js';"), + None, + &mut context, + ) + .expect("failed to parse module"); + let promise = module.load_link_evaluate(&mut context); + context.run_jobs(); + + match promise.state() { + PromiseState::Fulfilled(value) => { + assert!( + value.is_undefined(), + "Expected undefined, got {}", + value.display() + ); + + let bar = module + .namespace(&mut context) + .get(js_string!("bar"), &mut context) + .unwrap() + .as_callable() + .cloned() + .unwrap(); + let value = bar.call(&JsValue::undefined(), &[], &mut context).unwrap(); + assert_eq!( + value.as_number(), + Some(6.), + "Expected 6, got {}", + value.display() + ); + } + PromiseState::Rejected(err) => panic!( + "promise was not fulfilled: {:?}", + err.to_string(&mut context) + ), + PromiseState::Pending => panic!("Promise was not settled"), + } +} diff --git a/core/interop/tests/embedded/dir1/file3.js b/core/interop/tests/embedded/dir1/file3.js new file mode 100644 index 00000000000..57695a2642c --- /dev/null +++ b/core/interop/tests/embedded/dir1/file3.js @@ -0,0 +1,2 @@ +// Enable this when https://github.com/boa-dev/boa/pull/3781 is fixed and merged. +export { foo } from "./file4.js"; diff --git a/core/interop/tests/embedded/dir1/file4.js b/core/interop/tests/embedded/dir1/file4.js new file mode 100644 index 00000000000..c7d1d5f7172 --- /dev/null +++ b/core/interop/tests/embedded/dir1/file4.js @@ -0,0 +1,3 @@ +export function foo() { + return 3; +} diff --git a/core/interop/tests/embedded/file1.js b/core/interop/tests/embedded/file1.js new file mode 100644 index 00000000000..10b049b34e9 --- /dev/null +++ b/core/interop/tests/embedded/file1.js @@ -0,0 +1,6 @@ +import { foo } from "./file2.js"; +import { foo as foo2 } from "./dir1/file3.js"; + +export function bar() { + return foo() + foo2() + 1; +} diff --git a/core/interop/tests/embedded/file2.js b/core/interop/tests/embedded/file2.js new file mode 100644 index 00000000000..2c990085cef --- /dev/null +++ b/core/interop/tests/embedded/file2.js @@ -0,0 +1,3 @@ +export function foo() { + return 2; +} diff --git a/core/macros/src/embedded_module_loader.rs b/core/macros/src/embedded_module_loader.rs new file mode 100644 index 00000000000..8a4963317c9 --- /dev/null +++ b/core/macros/src/embedded_module_loader.rs @@ -0,0 +1,118 @@ +//! Embedded module loader. Creates a `ModuleLoader` instance that contains +//! files embedded in the binary at build time. + +use proc_macro::TokenStream; +use std::path::PathBuf; + +use quote::quote; +use syn::{parse::Parse, LitInt, LitStr, Token}; + +struct EmbedModuleMacroInput { + path: LitStr, + max_size: u64, +} + +impl Parse for EmbedModuleMacroInput { + fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result { + let path = input.parse()?; + let _comma: Token![,] = input.parse()?; + let max_size = input.parse::()?.base10_parse()?; + + Ok(Self { path, max_size }) + } +} + +/// List all the files readable from the given directory, recursively. +fn find_all_files(dir: &mut std::fs::ReadDir, root: &PathBuf) -> Vec { + let mut files = Vec::new(); + for entry in dir { + let Ok(entry) = entry else { + continue; + }; + + let path = entry.path(); + if path.is_dir() { + let Ok(mut sub_dir) = std::fs::read_dir(&path) else { + continue; + }; + files.append(&mut find_all_files(&mut sub_dir, root)); + } else if let Ok(path) = path.strip_prefix(root) { + files.push(path.to_path_buf()); + } + } + files +} + +/// Implementation of the `embed_module_inner!` macro. +/// This should not be used directly. Use the `embed_module!` macro from the `boa_interop` +/// crate instead. +pub(crate) fn embed_module_impl(input: TokenStream) -> TokenStream { + let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default()); + + let input = syn::parse_macro_input!(input as EmbedModuleMacroInput); + + let root = manifest_dir.join(input.path.value()); + let max_size = input.max_size; + + let mut dir = match std::fs::read_dir(root.clone()) { + Ok(dir) => dir, + Err(e) => { + return syn::Error::new_spanned( + input.path.clone(), + format!("Error reading directory: {e}"), + ) + .to_compile_error() + .into(); + } + }; + + let mut total = 0; + let files = find_all_files(&mut dir, &root); + + let inner = match files.into_iter().try_fold(quote! {}, |acc, relative_path| { + let path = root.join(&relative_path); + let absolute_path = manifest_dir.join(&path).to_string_lossy().to_string(); + let Some(relative_path) = relative_path.to_str() else { + return Err(syn::Error::new_spanned( + input.path.clone(), + "Path has non-Unicode characters", + )); + }; + let relative_path = format!("{}{}", std::path::MAIN_SEPARATOR, relative_path); + + // Check the size. + let size = std::fs::metadata(&path) + .map_err(|e| { + syn::Error::new_spanned(input.path.clone(), format!("Error reading file size: {e}")) + })? + .len(); + + total += size; + if total > max_size { + return Err(syn::Error::new_spanned( + input.path.clone(), + "Total embedded file size exceeds the maximum size", + )); + } + + Ok(quote! { + #acc + + ( + #relative_path, + include_bytes!(#absolute_path).as_ref(), + ), + }) + }) { + Ok(inner) => inner, + Err(e) => return e.to_compile_error().into(), + }; + + let stream = quote! { + [ + #inner + ] + }; + + stream.into() +} diff --git a/core/macros/src/lib.rs b/core/macros/src/lib.rs index 56b19a71e08..600b566bdb3 100644 --- a/core/macros/src/lib.rs +++ b/core/macros/src/lib.rs @@ -16,6 +16,19 @@ use syn::{ }; use synstructure::{decl_derive, AddBounds, Structure}; +mod embedded_module_loader; + +/// Implementation of the inner iterator of the `embed_module!` macro. All +/// arguments are required. +/// +/// # Warning +/// This should not be used directly as is, and instead should be used through +/// the `embed_module!` macro in `boa_interop` for convenience. +#[proc_macro] +pub fn embed_module_inner(input: TokenStream) -> TokenStream { + embedded_module_loader::embed_module_impl(input) +} + struct Static { literal: LitStr, ident: Ident,