diff --git a/Cargo.lock b/Cargo.lock index bbb90d90..58fd0f94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -273,9 +273,9 @@ checksum = "fb4a24b1aaf0fd0ce8b45161144d6f42cd91677fd5940fd431183eb023b3a2b8" [[package]] name = "crossbeam-channel" -version = "0.5.2" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e54ea8bc3fb1ee042f5aace6e3c6e025d3874866da222930f70ce62aceba0bfa" +checksum = "c2dd04ddaf88237dc3b8d8f9a3c1004b506b54b3313403944054d23c0870c521" dependencies = [ "cfg-if", "crossbeam-utils", @@ -865,6 +865,7 @@ dependencies = [ name = "parcel_css_node" version = "0.1.0" dependencies = [ + "crossbeam-channel", "cssparser", "jemallocator", "js-sys", @@ -873,6 +874,7 @@ dependencies = [ "napi-derive", "parcel_css", "parcel_sourcemap", + "rayon", "serde", "serde-wasm-bindgen", "serde_bytes", diff --git a/README.md b/README.md index 1ca94f71..9be13b2c 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,25 @@ let {code, map} = css.bundle({ }); ``` +The `bundleAsync` API is an asynchronous version of `bundle`, which also accepts a custom `resolver` object. This allows you to provide custom JavaScript functions for resolving `@import` specifiers to file paths, and reading files from the file system (or another source). The `read` and `resolve` functions are both optional, and may either return a string synchronously, or a Promise for asynchronous resolution. + +```js +let {code, map} = await css.bundleAsync({ + filename: 'style.css', + minify: true, + resolver: { + read(filePath) { + return fs.readFileSync(filePath, 'utf8'); + }, + resolve(specifier, from) { + return path.resolve(path.dirname(from), specifier); + } + } +}); +``` + +Note that using a custom resolver can slow down bundling significantly, especially when reading files asynchronously. Use `readFileSync` rather than `readFile` if possible for better performance, or omit either of the methods if you don't need to override the default behavior. + ### From Rust See the Rust API docs on [docs.rs](https://docs.rs/parcel_css). diff --git a/node/Cargo.toml b/node/Cargo.toml index f72db4ad..a86acf29 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -19,8 +19,10 @@ parcel_sourcemap = { version = "2.1.0", features = ["json"] } jemallocator = { version = "0.3.2", features = ["disable_initial_exec_tls"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -napi = {version = "2.2.0", default-features = false, features = ["napi4", "compat-mode", "serde-json"]} +napi = {version = "2.2.0", default-features = false, features = ["napi4", "napi5", "compat-mode", "serde-json"]} napi-derive = "2" +crossbeam-channel = "0.5.6" +rayon = "1.5.1" [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3" diff --git a/node/index.d.ts b/node/index.d.ts index 7f37cf17..2d45a010 100644 --- a/node/index.d.ts +++ b/node/index.d.ts @@ -45,6 +45,22 @@ export interface TransformOptions { export type BundleOptions = Omit; +export interface BundleAsyncOptions extends BundleOptions { + resolver?: Resolver; +} + +/** Custom resolver to use when loading CSS files. */ +export interface Resolver { + /** Read the given file and return its contents as a string. */ + read?: (file: string) => string | Promise; + + /** + * Resolve the given CSS import specifier from the provided originating file to a + * path which gets passed to `read()`. + */ + resolve?: (specifier: string, originatingFile: string) => string | Promise; +} + export interface Drafts { /** Whether to enable CSS nesting. */ nesting?: boolean, @@ -226,3 +242,8 @@ export declare function browserslistToTargets(browserslist: string[]): Targets; * Bundles a CSS file and its dependencies, inlining @import rules. */ export declare function bundle(options: BundleOptions): TransformResult; + +/** + * Bundles a CSS file and its dependencies asynchronously, inlining @import rules. + */ +export declare function bundleAsync(options: BundleAsyncOptions): TransformResult; diff --git a/node/index.mjs b/node/index.mjs index c7e219e7..44d29368 100644 --- a/node/index.mjs +++ b/node/index.mjs @@ -1,4 +1,4 @@ import index from './index.js'; -const { transform, transformStyleAttribute, bundle, browserslistToTargets } = index; -export { transform, transformStyleAttribute, bundle, browserslistToTargets }; +const { transform, transformStyleAttribute, bundle, bundleAsync, browserslistToTargets } = index; +export { transform, transformStyleAttribute, bundle, bundleAsync, browserslistToTargets }; diff --git a/node/src/lib.rs b/node/src/lib.rs index 1e5d77ee..9664b4e2 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -13,8 +13,13 @@ use parcel_css::targets::Browsers; use parcel_sourcemap::SourceMap; use serde::{Deserialize, Serialize}; use std::collections::HashSet; -use std::path::Path; -use std::sync::{Arc, RwLock}; +use std::ffi::c_void; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::{Arc, Mutex, RwLock}; + +#[cfg(not(target_arch = "wasm32"))] +mod threadsafe_function; // --------------------------------------------- @@ -46,7 +51,7 @@ pub fn transform_style_attribute(config_val: JsValue) -> Result { #[cfg(not(target_arch = "wasm32"))] impl<'i> TransformResult<'i> { - fn into_js(self, ctx: CallContext) -> napi::Result { + fn into_js(self, env: Env) -> napi::Result { // Manually construct buffers so we avoid a copy and work around // https://github.com/napi-rs/napi-rs/issues/1124. - let mut obj = ctx.env.create_object()?; - let buf = ctx.env.create_buffer_with_data(self.code)?; + let mut obj = env.create_object()?; + let buf = env.create_buffer_with_data(self.code)?; obj.set_named_property("code", buf.into_raw())?; obj.set_named_property( "map", if let Some(map) = self.map { - let buf = ctx.env.create_buffer_with_data(map)?; + let buf = env.create_buffer_with_data(map)?; buf.into_raw().into_unknown() } else { - ctx.env.get_null()?.into_unknown() + env.get_null()?.into_unknown() }, )?; - obj.set_named_property("exports", ctx.env.to_js_value(&self.exports)?)?; - obj.set_named_property("references", ctx.env.to_js_value(&self.references)?)?; - obj.set_named_property("dependencies", ctx.env.to_js_value(&self.dependencies)?)?; - obj.set_named_property("warnings", ctx.env.to_js_value(&self.warnings)?)?; + obj.set_named_property("exports", env.to_js_value(&self.exports)?)?; + obj.set_named_property("references", env.to_js_value(&self.references)?)?; + obj.set_named_property("dependencies", env.to_js_value(&self.dependencies)?)?; + obj.set_named_property("warnings", env.to_js_value(&self.warnings)?)?; Ok(obj.into_unknown()) } } @@ -97,8 +102,8 @@ fn transform(ctx: CallContext) -> napi::Result { let res = compile(code, &config); match res { - Ok(res) => res.into_js(ctx), - Err(err) => err.throw(ctx, Some(code)), + Ok(res) => res.into_js(*ctx.env), + Err(err) => err.throw(*ctx.env, Some(code)), } } @@ -112,41 +117,326 @@ fn transform_style_attribute(ctx: CallContext) -> napi::Result { match res { Ok(res) => res.into_js(ctx), - Err(err) => err.throw(ctx, Some(code)), + Err(err) => err.throw(*ctx.env, Some(code)), } } #[cfg(not(target_arch = "wasm32"))] -#[js_function(1)] -fn bundle(ctx: CallContext) -> napi::Result { - let opts = ctx.get::(0)?; - let config: BundleConfig = ctx.env.from_js_value(opts)?; - let fs = FileProvider::new(); - let res = compile_bundle(&fs, &config); +mod bundle { + use super::*; + use crossbeam_channel::{self, Receiver, Sender}; + use napi::{Env, JsFunction, JsString, NapiRaw, NapiValue}; + use threadsafe_function::{ThreadSafeCallContext, ThreadsafeFunction, ThreadsafeFunctionCallMode}; + + #[js_function(1)] + pub fn bundle(ctx: CallContext) -> napi::Result { + let opts = ctx.get::(0)?; + let config: BundleConfig = ctx.env.from_js_value(opts)?; + let fs = FileProvider::new(); + let res = compile_bundle(&fs, &config); + + match res { + Ok(res) => res.into_js(*ctx.env), + Err(err) => { + let code = match &err { + CompileError::ParseError(Error { + loc: Some(ErrorLocation { filename, .. }), + .. + }) + | CompileError::PrinterError(Error { + loc: Some(ErrorLocation { filename, .. }), + .. + }) + | CompileError::MinifyError(Error { + loc: Some(ErrorLocation { filename, .. }), + .. + }) + | CompileError::BundleError(Error { + loc: Some(ErrorLocation { filename, .. }), + .. + }) => Some(fs.read(Path::new(filename))?), + _ => None, + }; + err.throw(*ctx.env, code) + } + } + } - match res { - Ok(res) => res.into_js(ctx), - Err(err) => { - let code = match &err { - CompileError::ParseError(Error { - loc: Some(ErrorLocation { filename, .. }), - .. - }) - | CompileError::PrinterError(Error { - loc: Some(ErrorLocation { filename, .. }), - .. - }) - | CompileError::MinifyError(Error { - loc: Some(ErrorLocation { filename, .. }), - .. + // A SourceProvider which calls JavaScript functions to resolve and read files. + struct JsSourceProvider { + resolve: Option>, + read: Option>, + inputs: Mutex>, + } + + unsafe impl Sync for JsSourceProvider {} + unsafe impl Send for JsSourceProvider {} + + // Allocate a single channel per thread to communicate with the JS thread. + thread_local! { + static CHANNEL: (Sender>, Receiver>) = crossbeam_channel::unbounded(); + } + + impl SourceProvider for JsSourceProvider { + type Error = napi::Error; + + fn read<'a>(&'a self, file: &Path) -> Result<&'a str, Self::Error> { + let source = if let Some(read) = &self.read { + CHANNEL.with(|channel| { + let message = ReadMessage { + file: file.to_str().unwrap().to_owned(), + tx: channel.0.clone(), + }; + + read.call(message, ThreadsafeFunctionCallMode::Blocking); + channel.1.recv().unwrap() }) - | CompileError::BundleError(Error { - loc: Some(ErrorLocation { filename, .. }), - .. - }) => Some(fs.read(Path::new(filename))?), - _ => None, + } else { + Ok(std::fs::read_to_string(file)?) }; - err.throw(ctx, code) + + match source { + Ok(source) => { + // cache the result + let ptr = Box::into_raw(Box::new(source)); + self.inputs.lock().unwrap().push(ptr); + // SAFETY: this is safe because the pointer is not dropped + // until the JsSourceProvider is, and we never remove from the + // list of pointers stored in the vector. + Ok(unsafe { &*ptr }) + } + Err(e) => Err(e), + } + } + + fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { + if let Some(resolve) = &self.resolve { + return CHANNEL.with(|channel| { + let message = ResolveMessage { + specifier: specifier.to_owned(), + originating_file: originating_file.to_str().unwrap().to_owned(), + tx: channel.0.clone(), + }; + + resolve.call(message, ThreadsafeFunctionCallMode::Blocking); + let result = channel.1.recv().unwrap(); + match result { + Ok(result) => Ok(PathBuf::from_str(&result).unwrap()), + Err(e) => Err(e), + } + }); + } + + Ok(originating_file.with_file_name(specifier)) + } + } + + struct ResolveMessage { + specifier: String, + originating_file: String, + tx: Sender>, + } + + struct ReadMessage { + file: String, + tx: Sender>, + } + + fn await_promise(env: Env, result: JsUnknown, tx: Sender>) -> napi::Result<()> { + // If the result is a promise, wait for it to resolve, and send the result to the channel. + // Otherwise, send the result immediately. + if result.is_promise()? { + let result: JsObject = result.try_into()?; + let then: JsFunction = result.get_named_property("then")?; + let tx2 = tx.clone(); + let cb = env.create_function_from_closure("callback", move |ctx| { + let res = ctx.get::(0)?.into_utf8()?; + let s = res.into_owned()?; + tx.send(Ok(s)).unwrap(); + ctx.env.get_undefined() + })?; + let eb = env.create_function_from_closure("error_callback", move |ctx| { + // TODO: need a way to convert a JsUnknown to an Error + tx2.send(Err(napi::Error::from_reason("Promise rejected"))).unwrap(); + ctx.env.get_undefined() + })?; + then.call(Some(&result), &[cb, eb])?; + } else { + let result: JsString = result.try_into()?; + let utf8 = result.into_utf8()?; + let s = utf8.into_owned()?; + tx.send(Ok(s)).unwrap(); + } + + Ok(()) + } + + fn resolve_on_js_thread(ctx: ThreadSafeCallContext) -> napi::Result<()> { + let specifier = ctx.env.create_string(&ctx.value.specifier)?; + let originating_file = ctx.env.create_string(&ctx.value.originating_file)?; + let result = ctx.callback.call(None, &[specifier, originating_file])?; + await_promise(ctx.env, result, ctx.value.tx) + } + + fn handle_error(tx: Sender>, res: napi::Result<()>) -> napi::Result<()> { + match res { + Ok(_) => Ok(()), + Err(e) => { + tx.send(Err(e)).expect("send error"); + Ok(()) + } + } + } + + fn resolve_on_js_thread_wrapper(ctx: ThreadSafeCallContext) -> napi::Result<()> { + let tx = ctx.value.tx.clone(); + handle_error(tx, resolve_on_js_thread(ctx)) + } + + fn read_on_js_thread(ctx: ThreadSafeCallContext) -> napi::Result<()> { + let file = ctx.env.create_string(&ctx.value.file)?; + let result = ctx.callback.call(None, &[file])?; + await_promise(ctx.env, result, ctx.value.tx) + } + + fn read_on_js_thread_wrapper(ctx: ThreadSafeCallContext) -> napi::Result<()> { + let tx = ctx.value.tx.clone(); + handle_error(tx, read_on_js_thread(ctx)) + } + + #[cfg(not(target_arch = "wasm32"))] + #[js_function(1)] + pub fn bundle_async(ctx: CallContext) -> napi::Result { + let opts = ctx.get::(0)?; + let config: BundleConfig = ctx.env.from_js_value(&opts)?; + + if let Ok(resolver) = opts.get_named_property::("resolver") { + let read = if resolver.has_named_property("read")? { + let read = resolver.get_named_property::("read")?; + Some(ThreadsafeFunction::create( + ctx.env.raw(), + unsafe { read.raw() }, + 0, + read_on_js_thread_wrapper, + )?) + } else { + None + }; + + let resolve = if resolver.has_named_property("resolve")? { + let resolve = resolver.get_named_property::("resolve")?; + Some(ThreadsafeFunction::create( + ctx.env.raw(), + unsafe { resolve.raw() }, + 0, + resolve_on_js_thread_wrapper, + )?) + } else { + None + }; + + let provider = JsSourceProvider { + resolve, + read, + inputs: Mutex::new(Vec::new()), + }; + + run_bundle_task(provider, config, *ctx.env) + } else { + let provider = FileProvider::new(); + run_bundle_task(provider, config, *ctx.env) + } + } + + struct TSFNValue(napi::sys::napi_threadsafe_function); + unsafe impl Send for TSFNValue {} + + // Runs bundling on a background thread managed by rayon. This is similar to AsyncTask from napi-rs, however, + // because we call back into the JS thread, which might call other tasks in the node threadpool (e.g. fs.readFile), + // we may end up deadlocking if the number of rayon threads exceeds node's threadpool size. Therefore, we must + // run bundling from a thread not managed by Node. + fn run_bundle_task( + provider: P, + config: BundleConfig, + env: Env, + ) -> napi::Result { + // Create a promise. + let mut raw_promise = std::ptr::null_mut(); + let mut deferred = std::ptr::null_mut(); + let status = unsafe { napi::sys::napi_create_promise(env.raw(), &mut deferred, &mut raw_promise) }; + assert_eq!(napi::Status::from(status), napi::Status::Ok); + + // Create a threadsafe function so we can call back into the JS thread when we are done. + let async_resource_name = env.create_string("run_bundle_task").unwrap(); + let mut tsfn = std::ptr::null_mut(); + napi::check_status! {unsafe { + napi::sys::napi_create_threadsafe_function( + env.raw(), + std::ptr::null_mut(), + std::ptr::null_mut(), + async_resource_name.raw(), + 0, + 1, + std::ptr::null_mut(), + None, + deferred as *mut c_void, + Some(bundle_task_cb), + &mut tsfn, + ) + }}?; + + // Wrap raw pointer so it is Send compatible. + let tsfn_value = TSFNValue(tsfn); + + // Run bundling task in rayon threadpool. + rayon::spawn(move || { + let provider = provider; + let result = compile_bundle(unsafe { std::mem::transmute::<&'_ P, &'static P>(&provider) }, &config) + .map_err(|e| e.into()); + resolve_task(result, tsfn_value); + }); + + Ok(unsafe { JsUnknown::from_raw_unchecked(env.raw(), raw_promise) }) + } + + fn resolve_task(result: napi::Result>, tsfn_value: TSFNValue) { + // Call back into the JS thread via a threadsafe function. This results in bundle_task_cb being called. + let status = unsafe { + napi::sys::napi_call_threadsafe_function( + tsfn_value.0, + Box::into_raw(Box::from(result)) as *mut c_void, + napi::sys::ThreadsafeFunctionCallMode::nonblocking, + ) + }; + assert_eq!(napi::Status::from(status), napi::Status::Ok); + + let status = unsafe { + napi::sys::napi_release_threadsafe_function(tsfn_value.0, napi::sys::ThreadsafeFunctionReleaseMode::release) + }; + assert_eq!(napi::Status::from(status), napi::Status::Ok); + } + + extern "C" fn bundle_task_cb( + env: napi::sys::napi_env, + _js_callback: napi::sys::napi_value, + context: *mut c_void, + data: *mut c_void, + ) { + let deferred = context as napi::sys::napi_deferred; + let value = unsafe { Box::from_raw(data as *mut napi::Result>) }; + let value = value.and_then(|res| res.into_js(unsafe { Env::from_raw(env) })); + + // Resolve or reject the promise based on the result. + match value { + Ok(res) => { + let status = unsafe { napi::sys::napi_resolve_deferred(env, deferred, res.raw()) }; + assert_eq!(napi::Status::from(status), napi::Status::Ok); + } + Err(e) => { + let status = + unsafe { napi::sys::napi_reject_deferred(env, deferred, napi::JsError::from(e).into_value(env)) }; + assert_eq!(napi::Status::from(status), napi::Status::Ok); + } } } } @@ -156,7 +446,8 @@ fn bundle(ctx: CallContext) -> napi::Result { fn init(mut exports: JsObject) -> napi::Result<()> { exports.create_named_method("transform", transform)?; exports.create_named_method("transformStyleAttribute", transform_style_attribute)?; - exports.create_named_method("bundle", bundle)?; + exports.create_named_method("bundle", bundle::bundle)?; + exports.create_named_method("bundleAsync", bundle::bundle_async)?; Ok(()) } @@ -241,7 +532,7 @@ struct Drafts { custom_media: bool, } -fn compile<'i>(code: &'i str, config: &Config) -> Result, CompileError<'i>> { +fn compile<'i>(code: &'i str, config: &Config) -> Result, CompileError<'i, std::io::Error>> { let drafts = config.drafts.as_ref(); let warnings = Some(Arc::new(RwLock::new(Vec::new()))); @@ -330,10 +621,10 @@ fn compile<'i>(code: &'i str, config: &Config) -> Result, Co }) } -fn compile_bundle<'i>( - fs: &'i FileProvider, +fn compile_bundle<'i, P: SourceProvider>( + fs: &'i P, config: &BundleConfig, -) -> Result, CompileError<'i>> { +) -> Result, CompileError<'i, P::Error>> { let mut source_map = if config.source_map.unwrap_or_default() { Some(SourceMap::new("/")) } else { @@ -447,7 +738,10 @@ impl<'i> AttrResult<'i> { } } -fn compile_attr<'i>(code: &'i str, config: &AttrConfig) -> Result, CompileError<'i>> { +fn compile_attr<'i>( + code: &'i str, + config: &AttrConfig, +) -> Result, CompileError<'i, std::io::Error>> { let warnings = if config.error_recovery { Some(Arc::new(RwLock::new(Vec::new()))) } else { @@ -489,16 +783,16 @@ fn compile_attr<'i>(code: &'i str, config: &AttrConfig) -> Result }) } -enum CompileError<'i> { +enum CompileError<'i, E: std::error::Error> { ParseError(Error>), MinifyError(Error), PrinterError(Error), SourceMapError(parcel_sourcemap::SourceMapError), - BundleError(Error>), + BundleError(Error>), PatternError(PatternParseError), } -impl<'i> std::fmt::Display for CompileError<'i> { +impl<'i, E: std::error::Error> std::fmt::Display for CompileError<'i, E> { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { CompileError::ParseError(err) => err.kind.fmt(f), @@ -511,16 +805,16 @@ impl<'i> std::fmt::Display for CompileError<'i> { } } -impl<'i> CompileError<'i> { +impl<'i, E: std::error::Error> CompileError<'i, E> { #[cfg(not(target_arch = "wasm32"))] - fn throw(self, ctx: CallContext, code: Option<&str>) -> napi::Result { + fn throw(self, env: Env, code: Option<&str>) -> napi::Result { let reason = self.to_string(); let data = match &self { - CompileError::ParseError(Error { kind, .. }) => ctx.env.to_js_value(kind)?, - CompileError::PrinterError(Error { kind, .. }) => ctx.env.to_js_value(kind)?, - CompileError::MinifyError(Error { kind, .. }) => ctx.env.to_js_value(kind)?, - CompileError::BundleError(Error { kind, .. }) => ctx.env.to_js_value(kind)?, - _ => ctx.env.get_null()?.into_unknown(), + CompileError::ParseError(Error { kind, .. }) => env.to_js_value(kind)?, + CompileError::PrinterError(Error { kind, .. }) => env.to_js_value(kind)?, + CompileError::MinifyError(Error { kind, .. }) => env.to_js_value(kind)?, + CompileError::BundleError(Error { kind, .. }) => env.to_js_value(kind)?, + _ => env.get_null()?.into_unknown(), }; match self { @@ -529,65 +823,65 @@ impl<'i> CompileError<'i> { | CompileError::MinifyError(Error { loc, .. }) | CompileError::BundleError(Error { loc, .. }) => { // Generate an error with location information. - let syntax_error = ctx.env.get_global()?.get_named_property::("SyntaxError")?; - let reason = ctx.env.create_string_from_std(reason)?; + let syntax_error = env.get_global()?.get_named_property::("SyntaxError")?; + let reason = env.create_string_from_std(reason)?; let mut obj = syntax_error.new_instance(&[reason])?; if let Some(loc) = loc { - let line = ctx.env.create_int32((loc.line + 1) as i32)?; - let col = ctx.env.create_int32(loc.column as i32)?; - let filename = ctx.env.create_string_from_std(loc.filename)?; + let line = env.create_int32((loc.line + 1) as i32)?; + let col = env.create_int32(loc.column as i32)?; + let filename = env.create_string_from_std(loc.filename)?; obj.set_named_property("fileName", filename)?; if let Some(code) = code { - let source = ctx.env.create_string(code)?; + let source = env.create_string(code)?; obj.set_named_property("source", source)?; } - let mut loc = ctx.env.create_object()?; + let mut loc = env.create_object()?; loc.set_named_property("line", line)?; loc.set_named_property("column", col)?; obj.set_named_property("loc", loc)?; } obj.set_named_property("data", data)?; - ctx.env.throw(obj)?; - Ok(ctx.env.get_undefined()?.into_unknown()) + env.throw(obj)?; + Ok(env.get_undefined()?.into_unknown()) } _ => Err(self.into()), } } } -impl<'i> From>> for CompileError<'i> { - fn from(e: Error>) -> CompileError<'i> { +impl<'i, E: std::error::Error> From>> for CompileError<'i, E> { + fn from(e: Error>) -> CompileError<'i, E> { CompileError::ParseError(e) } } -impl<'i> From> for CompileError<'i> { - fn from(err: Error) -> CompileError<'i> { +impl<'i, E: std::error::Error> From> for CompileError<'i, E> { + fn from(err: Error) -> CompileError<'i, E> { CompileError::MinifyError(err) } } -impl<'i> From> for CompileError<'i> { - fn from(err: Error) -> CompileError<'i> { +impl<'i, E: std::error::Error> From> for CompileError<'i, E> { + fn from(err: Error) -> CompileError<'i, E> { CompileError::PrinterError(err) } } -impl<'i> From for CompileError<'i> { - fn from(e: parcel_sourcemap::SourceMapError) -> CompileError<'i> { +impl<'i, E: std::error::Error> From for CompileError<'i, E> { + fn from(e: parcel_sourcemap::SourceMapError) -> CompileError<'i, E> { CompileError::SourceMapError(e) } } -impl<'i> From>> for CompileError<'i> { - fn from(e: Error>) -> CompileError<'i> { +impl<'i, E: std::error::Error> From>> for CompileError<'i, E> { + fn from(e: Error>) -> CompileError<'i, E> { CompileError::BundleError(e) } } #[cfg(not(target_arch = "wasm32"))] -impl<'i> From> for napi::Error { - fn from(e: CompileError) -> napi::Error { +impl<'i, E: std::error::Error> From> for napi::Error { + fn from(e: CompileError<'i, E>) -> napi::Error { match e { CompileError::SourceMapError(e) => napi::Error::from_reason(e.to_string()), CompileError::PatternError(e) => napi::Error::from_reason(e.to_string()), @@ -597,8 +891,8 @@ impl<'i> From> for napi::Error { } #[cfg(target_arch = "wasm32")] -impl<'i> From> for wasm_bindgen::JsValue { - fn from(e: CompileError) -> wasm_bindgen::JsValue { +impl<'i, E: std::error::Error> From> for wasm_bindgen::JsValue { + fn from(e: CompileError<'i, E>) -> wasm_bindgen::JsValue { match e { CompileError::SourceMapError(e) => js_sys::Error::new(&e.to_string()).into(), CompileError::PatternError(e) => js_sys::Error::new(&e.to_string()).into(), diff --git a/node/src/threadsafe_function.rs b/node/src/threadsafe_function.rs new file mode 100644 index 00000000..a3acb4bc --- /dev/null +++ b/node/src/threadsafe_function.rs @@ -0,0 +1,284 @@ +// Fork of threadsafe_function from napi-rs that allows calling JS function manually rather than +// only returning args. This enables us to use the return value of the function. + +#![allow(clippy::single_component_path_imports)] + +use std::convert::Into; +use std::ffi::CString; +use std::marker::PhantomData; +use std::os::raw::c_void; +use std::ptr; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Arc; + +use napi::{check_status, sys, Env, Result, Status}; +use napi::{JsError, JsFunction, NapiValue}; + +/// ThreadSafeFunction Context object +/// the `value` is the value passed to `call` method +pub struct ThreadSafeCallContext { + pub env: Env, + pub value: T, + pub callback: JsFunction, +} + +#[repr(u8)] +pub enum ThreadsafeFunctionCallMode { + NonBlocking, + Blocking, +} + +impl From for sys::napi_threadsafe_function_call_mode { + fn from(value: ThreadsafeFunctionCallMode) -> Self { + match value { + ThreadsafeFunctionCallMode::Blocking => sys::ThreadsafeFunctionCallMode::blocking, + ThreadsafeFunctionCallMode::NonBlocking => sys::ThreadsafeFunctionCallMode::nonblocking, + } + } +} + +/// Communicate with the addon's main thread by invoking a JavaScript function from other threads. +/// +/// ## Example +/// An example of using `ThreadsafeFunction`: +/// +/// ```rust +/// #[macro_use] +/// extern crate napi_derive; +/// +/// use std::thread; +/// +/// use napi::{ +/// threadsafe_function::{ +/// ThreadSafeCallContext, ThreadsafeFunctionCallMode, ThreadsafeFunctionReleaseMode, +/// }, +/// CallContext, Error, JsFunction, JsNumber, JsUndefined, Result, Status, +/// }; +/// +/// #[js_function(1)] +/// pub fn test_threadsafe_function(ctx: CallContext) -> Result { +/// let func = ctx.get::(0)?; +/// +/// let tsfn = +/// ctx +/// .env +/// .create_threadsafe_function(&func, 0, |ctx: ThreadSafeCallContext>| { +/// ctx.value +/// .iter() +/// .map(|v| ctx.env.create_uint32(*v)) +/// .collect::>>() +/// })?; +/// +/// let tsfn_cloned = tsfn.clone(); +/// +/// thread::spawn(move || { +/// let output: Vec = vec![0, 1, 2, 3]; +/// // It's okay to call a threadsafe function multiple times. +/// tsfn.call(Ok(output.clone()), ThreadsafeFunctionCallMode::Blocking); +/// }); +/// +/// thread::spawn(move || { +/// let output: Vec = vec![3, 2, 1, 0]; +/// // It's okay to call a threadsafe function multiple times. +/// tsfn_cloned.call(Ok(output.clone()), ThreadsafeFunctionCallMode::NonBlocking); +/// }); +/// +/// ctx.env.get_undefined() +/// } +/// ``` +pub struct ThreadsafeFunction { + raw_tsfn: sys::napi_threadsafe_function, + aborted: Arc, + ref_count: Arc, + _phantom: PhantomData, +} + +impl Clone for ThreadsafeFunction { + fn clone(&self) -> Self { + if !self.aborted.load(Ordering::Acquire) { + let acquire_status = unsafe { sys::napi_acquire_threadsafe_function(self.raw_tsfn) }; + debug_assert!( + acquire_status == sys::Status::napi_ok, + "Acquire threadsafe function failed in clone" + ); + } + + Self { + raw_tsfn: self.raw_tsfn, + aborted: Arc::clone(&self.aborted), + ref_count: Arc::clone(&self.ref_count), + _phantom: PhantomData, + } + } +} + +unsafe impl Send for ThreadsafeFunction {} +unsafe impl Sync for ThreadsafeFunction {} + +impl ThreadsafeFunction { + /// See [napi_create_threadsafe_function](https://nodejs.org/api/n-api.html#n_api_napi_create_threadsafe_function) + /// for more information. + pub(crate) fn create) -> Result<()>>( + env: sys::napi_env, + func: sys::napi_value, + max_queue_size: usize, + callback: R, + ) -> Result { + let mut async_resource_name = ptr::null_mut(); + let s = "napi_rs_threadsafe_function"; + let len = s.len(); + let s = CString::new(s)?; + check_status!(unsafe { sys::napi_create_string_utf8(env, s.as_ptr(), len, &mut async_resource_name) })?; + + let initial_thread_count = 1usize; + let mut raw_tsfn = ptr::null_mut(); + let ptr = Box::into_raw(Box::new(callback)) as *mut c_void; + check_status!(unsafe { + sys::napi_create_threadsafe_function( + env, + func, + ptr::null_mut(), + async_resource_name, + max_queue_size, + initial_thread_count, + ptr, + Some(thread_finalize_cb::), + ptr, + Some(call_js_cb::), + &mut raw_tsfn, + ) + })?; + + let aborted = Arc::new(AtomicBool::new(false)); + let aborted_ptr = Arc::into_raw(aborted.clone()) as *mut c_void; + check_status!(unsafe { sys::napi_add_env_cleanup_hook(env, Some(cleanup_cb), aborted_ptr) })?; + + Ok(ThreadsafeFunction { + raw_tsfn, + aborted, + ref_count: Arc::new(AtomicUsize::new(initial_thread_count)), + _phantom: PhantomData, + }) + } +} + +impl ThreadsafeFunction { + /// See [napi_call_threadsafe_function](https://nodejs.org/api/n-api.html#n_api_napi_call_threadsafe_function) + /// for more information. + pub fn call(&self, value: T, mode: ThreadsafeFunctionCallMode) -> Status { + if self.aborted.load(Ordering::Acquire) { + return Status::Closing; + } + unsafe { + sys::napi_call_threadsafe_function(self.raw_tsfn, Box::into_raw(Box::new(value)) as *mut _, mode.into()) + } + .into() + } +} + +impl Drop for ThreadsafeFunction { + fn drop(&mut self) { + if !self.aborted.load(Ordering::Acquire) && self.ref_count.load(Ordering::Acquire) > 0usize { + let release_status = unsafe { + sys::napi_release_threadsafe_function(self.raw_tsfn, sys::ThreadsafeFunctionReleaseMode::release) + }; + assert!( + release_status == sys::Status::napi_ok, + "Threadsafe Function release failed" + ); + } + } +} + +unsafe extern "C" fn cleanup_cb(cleanup_data: *mut c_void) { + let aborted = Arc::::from_raw(cleanup_data.cast()); + aborted.store(true, Ordering::SeqCst); +} + +unsafe extern "C" fn thread_finalize_cb( + _raw_env: sys::napi_env, + finalize_data: *mut c_void, + _finalize_hint: *mut c_void, +) where + R: 'static + Send + FnMut(ThreadSafeCallContext) -> Result<()>, +{ + // cleanup + drop(Box::::from_raw(finalize_data.cast())); +} + +unsafe extern "C" fn call_js_cb( + raw_env: sys::napi_env, + js_callback: sys::napi_value, + context: *mut c_void, + data: *mut c_void, +) where + R: 'static + Send + FnMut(ThreadSafeCallContext) -> Result<()>, +{ + // env and/or callback can be null when shutting down + if raw_env.is_null() || js_callback.is_null() { + return; + } + + let ctx: &mut R = &mut *context.cast::(); + let val: Result = Ok(*Box::::from_raw(data.cast())); + + let mut recv = ptr::null_mut(); + sys::napi_get_undefined(raw_env, &mut recv); + + let ret = val.and_then(|v| { + (ctx)(ThreadSafeCallContext { + env: Env::from_raw(raw_env), + value: v, + callback: JsFunction::from_raw(raw_env, js_callback).unwrap(), // TODO: unwrap + }) + }); + + let status = match ret { + Ok(()) => sys::Status::napi_ok, + Err(e) => sys::napi_fatal_exception(raw_env, JsError::from(e).into_value(raw_env)), + }; + if status == sys::Status::napi_ok { + return; + } + if status == sys::Status::napi_pending_exception { + let mut error_result = ptr::null_mut(); + assert_eq!( + sys::napi_get_and_clear_last_exception(raw_env, &mut error_result), + sys::Status::napi_ok + ); + + // When shutting down, napi_fatal_exception sometimes returns another exception + let stat = sys::napi_fatal_exception(raw_env, error_result); + assert!(stat == sys::Status::napi_ok || stat == sys::Status::napi_pending_exception); + } else { + let error_code: Status = status.into(); + let error_code_string = format!("{:?}", error_code); + let mut error_code_value = ptr::null_mut(); + assert_eq!( + sys::napi_create_string_utf8( + raw_env, + error_code_string.as_ptr() as *const _, + error_code_string.len(), + &mut error_code_value, + ), + sys::Status::napi_ok, + ); + let error_msg = "Call JavaScript callback failed in thread safe function"; + let mut error_msg_value = ptr::null_mut(); + assert_eq!( + sys::napi_create_string_utf8( + raw_env, + error_msg.as_ptr() as *const _, + error_msg.len(), + &mut error_msg_value, + ), + sys::Status::napi_ok, + ); + let mut error_value = ptr::null_mut(); + assert_eq!( + sys::napi_create_error(raw_env, error_code_value, error_msg_value, &mut error_value), + sys::Status::napi_ok, + ); + assert_eq!(sys::napi_fatal_exception(raw_env, error_value), sys::Status::napi_ok); + } +} diff --git a/src/bundler.rs b/src/bundler.rs index 36b94885..581095e1 100644 --- a/src/bundler.rs +++ b/src/bundler.rs @@ -79,12 +79,15 @@ struct BundleStyleSheet<'i, 'o> { /// See [FileProvider](FileProvider) for an implementation that uses the /// file system. pub trait SourceProvider: Send + Sync { + /// A custom error. + type Error: std::error::Error + Send + Sync; + /// Reads the contents of the given file path to a string. - fn read<'a>(&'a self, file: &Path) -> std::io::Result<&'a str>; + fn read<'a>(&'a self, file: &Path) -> Result<&'a str, Self::Error>; /// Resolves the given import specifier to a file path given the file /// which the import originated from. - fn resolve(&self, specifier: &str, originating_file: &Path) -> Result>; + fn resolve(&self, specifier: &str, originating_file: &Path) -> Result; } /// Provides an implementation of [SourceProvider](SourceProvider) @@ -106,7 +109,9 @@ unsafe impl Sync for FileProvider {} unsafe impl Send for FileProvider {} impl SourceProvider for FileProvider { - fn read<'a>(&'a self, file: &Path) -> std::io::Result<&'a str> { + type Error = std::io::Error; + + fn read<'a>(&'a self, file: &Path) -> Result<&'a str, Self::Error> { let source = fs::read_to_string(file)?; let ptr = Box::into_raw(Box::new(source)); self.inputs.lock().unwrap().push(ptr); @@ -116,7 +121,7 @@ impl SourceProvider for FileProvider { Ok(unsafe { &*ptr }) } - fn resolve(&self, specifier: &str, originating_file: &Path) -> Result> { + fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { // Assume the specifier is a releative file path and join it with current path. Ok(originating_file.with_file_name(specifier)) } @@ -132,9 +137,7 @@ impl Drop for FileProvider { /// An error that could occur during bundling. #[derive(Debug, Serialize)] -pub enum BundleErrorKind<'i> { - /// An I/O error occurred. - IOError(#[serde(skip)] std::io::Error), +pub enum BundleErrorKind<'i, T: std::error::Error> { /// A parser error occurred. ParserError(ParserError<'i>), /// An unsupported `@import` condition was encountered. @@ -143,9 +146,11 @@ pub enum BundleErrorKind<'i> { UnsupportedLayerCombination, /// Unsupported media query boolean logic was encountered. UnsupportedMediaBooleanLogic, + /// A custom resolver error. + ResolverError(#[serde(skip)] T), } -impl<'i> From>> for Error> { +impl<'i, T: std::error::Error> From>> for Error> { fn from(err: Error>) -> Self { Error { kind: BundleErrorKind::ParserError(err.kind), @@ -154,20 +159,20 @@ impl<'i> From>> for Error> { } } -impl<'i> std::fmt::Display for BundleErrorKind<'i> { +impl<'i, T: std::error::Error> std::fmt::Display for BundleErrorKind<'i, T> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { use BundleErrorKind::*; match self { - IOError(err) => write!(f, "IO error: {}", err), ParserError(err) => err.fmt(f), UnsupportedImportCondition => write!(f, "Unsupported import condition"), UnsupportedLayerCombination => write!(f, "Unsupported layer combination in @import"), UnsupportedMediaBooleanLogic => write!(f, "Unsupported boolean logic in @import media query"), + ResolverError(err) => std::fmt::Display::fmt(&err, f), } } } -impl<'i> BundleErrorKind<'i> { +impl<'i, T: std::error::Error> BundleErrorKind<'i, T> { #[deprecated(note = "use `BundleErrorKind::to_string()` or `std::fmt::Display` instead")] #[allow(missing_docs)] pub fn reason(&self) -> String { @@ -190,7 +195,10 @@ impl<'a, 'o, 's, P: SourceProvider> Bundler<'a, 'o, 's, P> { } /// Bundles the given entry file and all dependencies into a single style sheet. - pub fn bundle<'e>(&mut self, entry: &'e Path) -> Result, Error>> { + pub fn bundle<'e>( + &mut self, + entry: &'e Path, + ) -> Result, Error>> { // Phase 1: load and parse all files. This is done in parallel. self.load_file( &entry, @@ -231,7 +239,7 @@ impl<'a, 'o, 's, P: SourceProvider> Bundler<'a, 'o, 's, P> { entry.key().to_str().unwrap().into() } - fn load_file(&self, file: &Path, rule: ImportRule<'a>) -> Result>> { + fn load_file(&self, file: &Path, rule: ImportRule<'a>) -> Result>> { // Check if we already loaded this file. let mut stylesheets = self.stylesheets.lock().unwrap(); let source_index = match self.source_indexes.get(file) { @@ -304,7 +312,7 @@ impl<'a, 'o, 's, P: SourceProvider> Bundler<'a, 'o, 's, P> { drop(stylesheets); // ensure we aren't holding the lock anymore let code = self.fs.read(file).map_err(|e| Error { - kind: BundleErrorKind::IOError(e), + kind: BundleErrorKind::ResolverError(e), loc: Some(ErrorLocation::new(rule.loc, self.find_filename(rule.loc.source_index))), })?; @@ -389,7 +397,7 @@ impl<'a, 'o, 's, P: SourceProvider> Bundler<'a, 'o, 's, P> { }, ), Err(err) => Err(Error { - kind: err.kind, + kind: BundleErrorKind::ResolverError(err), loc: Some(ErrorLocation::new( import.loc, self.find_filename(import.loc.source_index), @@ -531,11 +539,13 @@ mod tests { } impl SourceProvider for TestProvider { - fn read<'a>(&'a self, file: &Path) -> std::io::Result<&'a str> { + type Error = std::io::Error; + + fn read<'a>(&'a self, file: &Path) -> Result<&'a str, Self::Error> { Ok(self.map.get(file).unwrap()) } - fn resolve(&self, specifier: &str, originating_file: &Path) -> Result> { + fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { Ok(originating_file.with_file_name(specifier)) } } @@ -546,26 +556,28 @@ mod tests { } impl SourceProvider for CustomProvider { + type Error = std::io::Error; + /// Read files from in-memory map. - fn read<'a>(&'a self, file: &Path) -> std::io::Result<&'a str> { + fn read<'a>(&'a self, file: &Path) -> Result<&'a str, Self::Error> { Ok(self.map.get(file).unwrap()) } /// Resolve by stripping a `foo:` prefix off any import. Specifiers without /// this prefix fail with an error. - fn resolve(&self, specifier: &str, _originating_file: &Path) -> Result> { + fn resolve(&self, specifier: &str, _originating_file: &Path) -> Result { if specifier.starts_with("foo:") { Ok(Path::new(&specifier["foo:".len()..]).to_path_buf()) } else { - let kind = BundleErrorKind::IOError(std::io::Error::new( + let err = std::io::Error::new( std::io::ErrorKind::NotFound, format!( "Failed to resolve `{}`, specifier does not start with `foo:`.", &specifier ), - )); + ); - Err(Error { kind, loc: None }) + Err(err) } } } @@ -631,7 +643,11 @@ mod tests { .code } - fn error_test(fs: P, entry: &str, maybe_cb: Option ()>>) { + fn error_test( + fs: P, + entry: &str, + maybe_cb: Option) -> ()>>, + ) { let mut bundler = Bundler::new(&fs, None, ParserOptions::default()); let res = bundler.bundle(Path::new(entry)); match res { @@ -1383,7 +1399,7 @@ mod tests { "/a.css", Some(Box::new(|err| { let kind = match err { - BundleErrorKind::IOError(ref error) => error.kind(), + BundleErrorKind::ResolverError(ref error) => error.kind(), _ => unreachable!(), }; assert!(matches!(kind, std::io::ErrorKind::NotFound)); diff --git a/test-bundle.mjs b/test-bundle.mjs new file mode 100644 index 00000000..725a67a2 --- /dev/null +++ b/test-bundle.mjs @@ -0,0 +1,296 @@ +import path from 'path'; +import fs from 'fs'; +import css from './node/index.js'; + +await (async function testResolver() { + const inMemoryFs = new Map(Object.entries({ + 'foo.css': ` + @import 'root:bar.css'; + + .foo { color: red; } + `.trim(), + + 'bar.css': ` + @import 'root:hello/world.css'; + + .bar { color: green; } + `.trim(), + + 'hello/world.css': ` + .baz { color: blue; } + `.trim(), + })); + + const { code: buffer } = await css.bundleAsync({ + filename: 'foo.css', + resolver: { + read(file) { + const result = inMemoryFs.get(path.normalize(file)); + if (!result) throw new Error(`Could not find ${file} in ${Array.from(inMemoryFs.keys()).join(', ')}.`); + return result; + }, + + resolve(specifier) { + return specifier.slice('root:'.length); + }, + }, + }); + const code = buffer.toString('utf-8').trim(); + + const expected = ` +.baz { + color: #00f; +} + +.bar { + color: green; +} + +.foo { + color: red; +} + `.trim(); + if (code !== expected) throw new Error(`\`testResolver()\` failed. Expected:\n${expected}\n\nGot:\n${code}`); +})(); + +await (async function testOnlyCustomRead() { + const inMemoryFs = new Map(Object.entries({ + 'foo.css': ` + @import 'hello/world.css'; + + .foo { color: red; } + `.trim(), + + 'hello/world.css': ` + @import '../bar.css'; + + .bar { color: green; } + `.trim(), + + 'bar.css': ` + .baz { color: blue; } + `.trim(), + })); + + const { code: buffer } = await css.bundleAsync({ + filename: 'foo.css', + resolver: { + read(file) { + const result = inMemoryFs.get(path.normalize(file)); + if (!result) throw new Error(`Could not find ${file} in ${Array.from(inMemoryFs.keys()).join(', ')}.`); + return result; + }, + }, + }); + const code = buffer.toString('utf-8').trim(); + + const expected = ` +.baz { + color: #00f; +} + +.bar { + color: green; +} + +.foo { + color: red; +} + `.trim(); + if (code !== expected) throw new Error(`\`testOnlyCustomRead()\` failed. Expected:\n${expected}\n\nGot:\n${code}`); +})(); + +await (async function testOnlyCustomResolve() { + const root = path.join('tests', 'testdata'); + const { code: buffer } = await css.bundleAsync({ + filename: path.join(root, 'foo.css'), + resolver: { + resolve(specifier) { + // Strip `root:` prefix off specifier and resolve it as an absolute path + // in the test data root. + return path.join(root, specifier.slice('root:'.length)); + }, + }, + }); + const code = buffer.toString('utf-8').trim(); + + const expected = ` +.baz { + color: #00f; +} + +.bar { + color: green; +} + +.foo { + color: red; +} + `.trim(); + if (code !== expected) throw new Error(`\`testOnlyCustomResolve()\` failed. Expected:\n${expected}\n\nGot:\n${code}`); +})(); + +await (async function testAsyncRead() { + const root = path.join('tests', 'testdata'); + const { code: buffer } = await css.bundleAsync({ + filename: path.join(root, 'foo.css'), + resolver: { + async read(file) { + return await fs.promises.readFile(file, 'utf8'); + }, + resolve(specifier) { + // Strip `root:` prefix off specifier and resolve it as an absolute path + // in the test data root. + return path.join(root, specifier.slice('root:'.length)); + }, + }, + }); + const code = buffer.toString('utf-8').trim(); + + const expected = ` +.baz { + color: #00f; +} + +.bar { + color: green; +} + +.foo { + color: red; +} + `.trim(); + if (code !== expected) throw new Error(`\`testAsyncRead()\` failed. Expected:\n${expected}\n\nGot:\n${code}`); +})(); + +(async function testReadThrow() { + let error = undefined; + try { + await css.bundleAsync({ + filename: 'foo.css', + resolver: { + read(file) { + throw new Error(`Oh noes! Failed to read \`${file}\`.`); + } + }, + }); + } catch (err) { + error = err; + } + + if (!error) throw new Error(`\`testReadThrow()\` failed. Expected \`bundleAsync()\` to throw, but it did not.`); + // TODO: need support for napi-rs to propagate errors. + // if (!error.message.includes(`\`read()\` threw error:`) || !error.message.includes(`Oh noes! Failed to read \`foo.css\`.`)) { + // throw new Error(`\`testReadThrow()\` failed. Expected \`bundleAsync()\` to throw a specific error message, but it threw a different error:\n${error.message}`); + // } +})(); + +(async function testAsyncReadThrow() { + let error = undefined; + try { + await css.bundleAsync({ + filename: 'foo.css', + resolver: { + async read(file) { + throw new Error(`Oh noes! Failed to read \`${file}\`.`); + } + }, + }); + } catch (err) { + error = err; + } + + if (!error) throw new Error(`\`testReadThrow()\` failed. Expected \`bundleAsync()\` to throw, but it did not.`); + // TODO: need support for napi-rs to propagate errors. + // if (!error.message.includes(`\`read()\` threw error:`) || !error.message.includes(`Oh noes! Failed to read \`foo.css\`.`)) { + // throw new Error(`\`testReadThrow()\` failed. Expected \`bundleAsync()\` to throw a specific error message, but it threw a different error:\n${error.message}`); + // } +})(); + +await (async function testResolveThrow() { + let error = undefined; + try { + await css.bundleAsync({ + filename: 'tests/testdata/foo.css', + resolver: { + resolve(specifier, originatingFile) { + throw new Error(`Oh noes! Failed to resolve \`${specifier}\` from \`${originatingFile}\`.`); + } + }, + }); + } catch (err) { + error = err; + } + + if (!error) throw new Error(`\`testResolveThrow()\` failed. Expected \`bundleAsync()\` to throw, but it did not.`); + // TODO: need support for napi-rs to propagate errors. + // if (!error.message.includes(`\`resolve()\` threw error:`) || !error.message.includes(`Oh noes! Failed to resolve \`root:hello/world.css\` from \`tests/testdata/css/foo.css\`.`)) { + // throw new Error(`\`testResolveThrow()\` failed. Expected \`bundleAsync()\` to throw a specific error message, but it threw a different error:\n${error.message}`); + // } +})(); + +await (async function testAsyncResolveThrow() { + let error = undefined; + try { + await css.bundleAsync({ + filename: 'tests/testdata/foo.css', + resolver: { + async resolve(specifier, originatingFile) { + throw new Error(`Oh noes! Failed to resolve \`${specifier}\` from \`${originatingFile}\`.`); + } + }, + }); + } catch (err) { + error = err; + } + + if (!error) throw new Error(`\`testResolveThrow()\` failed. Expected \`bundleAsync()\` to throw, but it did not.`); + // TODO: need support for napi-rs to propagate errors. + // if (!error.message.includes(`\`resolve()\` threw error:`) || !error.message.includes(`Oh noes! Failed to resolve \`root:hello/world.css\` from \`tests/testdata/css/foo.css\`.`)) { + // throw new Error(`\`testResolveThrow()\` failed. Expected \`bundleAsync()\` to throw a specific error message, but it threw a different error:\n${error.message}`); + // } +})(); + +await (async function testReadReturnNonString() { + let error = undefined; + try { + await css.bundleAsync({ + filename: 'foo.css', + resolver: { + read() { + return 1234; // Returns a non-string value. + } + }, + }); + } catch (err) { + error = err; + } + + if (!error) throw new Error(`\`testReadReturnNonString()\` failed. Expected \`bundleAsync()\` to throw, but it did not.`); + if (!error.message.includes(`InvalidArg, expect String, got: Number`)) { + throw new Error(`\`testReadReturnNonString()\` failed. Expected \`bundleAsync()\` to throw a specific error message, but it threw a different error:\n${error.message}`); + } +})(); + +await (async function testResolveReturnNonString() { + let error = undefined; + try { + await css.bundleAsync({ + filename: 'tests/testdata/foo.css', + resolver: { + resolve() { + return 1234; // Returns a non-string value. + } + }, + }); + } catch (err) { + error = err; + } + + if (!error) throw new Error(`\`testResolveReturnNonString()\` failed. Expected \`bundleAsync()\` to throw, but it did not.`); + if (!error.message.includes(`InvalidArg, expect String, got: Number`)) { + throw new Error(`\`testResolveReturnNonString()\` failed. Expected \`bundleAsync()\` to throw a specific error message, but it threw a different error:\n${error.message}`); + } +})(); + +console.log('PASSED!'); diff --git a/tests/testdata/baz.css b/tests/testdata/baz.css new file mode 100644 index 00000000..095c8a79 --- /dev/null +++ b/tests/testdata/baz.css @@ -0,0 +1 @@ +.baz { color: blue; } \ No newline at end of file diff --git a/tests/testdata/foo.css b/tests/testdata/foo.css new file mode 100644 index 00000000..95099612 --- /dev/null +++ b/tests/testdata/foo.css @@ -0,0 +1,3 @@ +@import 'root:hello/world.css'; + +.foo { color: red; } diff --git a/tests/testdata/hello/world.css b/tests/testdata/hello/world.css new file mode 100644 index 00000000..bc1763bb --- /dev/null +++ b/tests/testdata/hello/world.css @@ -0,0 +1,3 @@ +@import 'root:baz.css'; + +.bar { color: green; }