diff --git a/crates/js-component-bindgen-component/src/lib.rs b/crates/js-component-bindgen-component/src/lib.rs index 9535ad2e8..fd8800b7b 100644 --- a/crates/js-component-bindgen-component/src/lib.rs +++ b/crates/js-component-bindgen-component/src/lib.rs @@ -53,6 +53,17 @@ impl From for js_component_bindgen::BindingsMode { } } +impl From for js_component_bindgen::AsyncMode { + fn from(value: AsyncMode) -> Self { + match value { + AsyncMode::Sync => js_component_bindgen::AsyncMode::Sync, + AsyncMode::Jspi(AsyncImportsExports { imports, exports }) => { + js_component_bindgen::AsyncMode::JavaScriptPromiseIntegration { imports, exports } + } + } + } +} + struct JsComponentBindgenComponent; export!(JsComponentBindgenComponent); @@ -76,6 +87,7 @@ impl Guest for JsComponentBindgenComponent { multi_memory: options.multi_memory.unwrap_or(false), import_bindings: options.import_bindings.map(Into::into), guest: options.guest.unwrap_or(false), + async_mode: options.async_mode.map(Into::into), }; let js_component_bindgen::Transpiled { @@ -162,6 +174,7 @@ impl Guest for JsComponentBindgenComponent { multi_memory: false, import_bindings: None, guest: opts.guest.unwrap_or(false), + async_mode: opts.async_mode.map(Into::into), }; let files = generate_types(name, resolve, world, opts).map_err(|e| e.to_string())?; diff --git a/crates/js-component-bindgen-component/wit/js-component-bindgen.wit b/crates/js-component-bindgen-component/wit/js-component-bindgen.wit index 13e36eb32..ef8acb17a 100644 --- a/crates/js-component-bindgen-component/wit/js-component-bindgen.wit +++ b/crates/js-component-bindgen-component/wit/js-component-bindgen.wit @@ -58,13 +58,29 @@ world js-component-bindgen { /// Whether to generate namespaced exports like `foo as "local:package/foo"`. /// These exports can break typescript builds. no-namespaced-exports: option, - + /// Whether to generate module declarations like `declare module "local:package/foo" {...`. guest: option, /// Whether to output core Wasm utilizing multi-memory or to polyfill /// this handling. multi-memory: option, + + /// Configure whether to use `async` imports or exports with + /// JavaScript Promise Integration (JSPI). + async-mode: option, + } + + record async-imports-exports { + imports: list, + exports: list, + } + + variant async-mode { + /// default to sync mode + sync, + /// use JavaScript Promise Integration (JSPI) + jspi(async-imports-exports), } variant wit { @@ -96,6 +112,9 @@ world js-component-bindgen { features: option, /// Whether to generate module declarations like `declare module "local:package/foo" {...`. guest: option, + /// Configure whether to use `async` imports or exports with + /// JavaScript Promise Integration (JSPI). + async-mode: option, } enum export-type { diff --git a/crates/js-component-bindgen/src/function_bindgen.rs b/crates/js-component-bindgen/src/function_bindgen.rs index 514a2ed46..a0fbed3f4 100644 --- a/crates/js-component-bindgen/src/function_bindgen.rs +++ b/crates/js-component-bindgen/src/function_bindgen.rs @@ -86,6 +86,7 @@ pub struct FunctionBindgen<'a> { pub callee: &'a str, pub callee_resource_dynamic: bool, pub resolve: &'a Resolve, + pub is_async: bool, } impl FunctionBindgen<'_> { @@ -1048,7 +1049,13 @@ impl Bindgen for FunctionBindgen<'_> { Instruction::CallWasm { sig, .. } => { let sig_results_length = sig.results.len(); self.bind_results(sig_results_length, results); - uwriteln!(self.src, "{}({});", self.callee, operands.join(", ")); + let maybe_async_await = if self.is_async { "await " } else { "" }; + uwriteln!( + self.src, + "{maybe_async_await}{}({});", + self.callee, + operands.join(", ") + ); if let Some(prefix) = self.tracing_prefix { let to_result_string = self.intrinsic(Intrinsic::ToResultString); @@ -1066,15 +1073,20 @@ impl Bindgen for FunctionBindgen<'_> { Instruction::CallInterface { func } => { let results_length = func.results.len(); + let maybe_async_await = if self.is_async { "await " } else { "" }; let call = if self.callee_resource_dynamic { format!( - "{}.{}({})", + "{maybe_async_await}{}.{}({})", operands[0], self.callee, operands[1..].join(", ") ) } else { - format!("{}({})", self.callee, operands.join(", ")) + format!( + "{maybe_async_await}{}({})", + self.callee, + operands.join(", ") + ) }; if self.err == ErrHandling::ResultCatchHandler { // result<_, string> allows JS error coercion only, while diff --git a/crates/js-component-bindgen/src/intrinsics.rs b/crates/js-component-bindgen/src/intrinsics.rs index 413c6da9d..5e27da73e 100644 --- a/crates/js-component-bindgen/src/intrinsics.rs +++ b/crates/js-component-bindgen/src/intrinsics.rs @@ -671,6 +671,7 @@ impl Intrinsic { "hasOwnProperty", "i32ToF32", "i64ToF64", + "imports", "instantiateCore", "isLE", "resourceCallBorrows", diff --git a/crates/js-component-bindgen/src/lib.rs b/crates/js-component-bindgen/src/lib.rs index 827915531..2c52cabd9 100644 --- a/crates/js-component-bindgen/src/lib.rs +++ b/crates/js-component-bindgen/src/lib.rs @@ -8,7 +8,7 @@ pub mod function_bindgen; pub mod intrinsics; pub mod names; pub mod source; -pub use transpile_bindgen::{BindingsMode, InstantiationMode, TranspileOpts}; +pub use transpile_bindgen::{AsyncMode, BindingsMode, InstantiationMode, TranspileOpts}; use anyhow::Result; use transpile_bindgen::transpile_bindgen; diff --git a/crates/js-component-bindgen/src/transpile_bindgen.rs b/crates/js-component-bindgen/src/transpile_bindgen.rs index 00cb6af61..63aff83a0 100644 --- a/crates/js-component-bindgen/src/transpile_bindgen.rs +++ b/crates/js-component-bindgen/src/transpile_bindgen.rs @@ -11,7 +11,7 @@ use crate::{uwrite, uwriteln}; use base64::{engine::general_purpose, Engine as _}; use heck::*; use std::cell::RefCell; -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fmt::Write; use std::mem; use wasmtime_environ::component::{ExportIndex, NameMap, NameMapNoIntern, Transcode}; @@ -70,6 +70,19 @@ pub struct TranspileOpts { pub multi_memory: bool, /// Whether to generate types for a guest module using module declarations. pub guest: bool, + /// Configure whether to use `async` imports or exports with + /// JavaScript Promise Integration (JSPI). + pub async_mode: Option, +} + +#[derive(Default, Clone, Debug)] +pub enum AsyncMode { + #[default] + Sync, + JavaScriptPromiseIntegration { + imports: Vec, + exports: Vec, + }, } #[derive(Default, Clone, Debug)] @@ -117,6 +130,10 @@ struct JsBindgen<'a> { /// List of all intrinsics emitted to `src` so far. all_intrinsics: BTreeSet, + + /// List of all core Wasm exported functions (and if is async) referenced in + /// `src` so far. + all_core_exported_funcs: Vec<(String, bool)>, } #[allow(clippy::too_many_arguments)] @@ -130,6 +147,13 @@ pub fn transpile_bindgen( opts: TranspileOpts, files: &mut Files, ) -> (Vec, Vec<(String, Export)>) { + let (async_imports, async_exports) = match opts.async_mode.clone() { + None | Some(AsyncMode::Sync) => (Default::default(), Default::default()), + Some(AsyncMode::JavaScriptPromiseIntegration { imports, exports }) => { + (imports.into_iter().collect(), exports.into_iter().collect()) + } + }; + let mut bindgen = JsBindgen { local_names: LocalNames::default(), src: Source::default(), @@ -137,6 +161,7 @@ pub fn transpile_bindgen( core_module_cnt: 0, opts: &opts, all_intrinsics: BTreeSet::new(), + all_core_exported_funcs: Vec::new(), }; bindgen .local_names @@ -157,6 +182,8 @@ pub fn transpile_bindgen( translation: component, component: &component.component, types, + async_imports, + async_exports, imports: Default::default(), exports: Default::default(), lowering_options: Default::default(), @@ -216,7 +243,7 @@ pub fn transpile_bindgen( (bindgen.esm_bindgen.import_specifiers(), exports) } -impl<'a> JsBindgen<'a> { +impl JsBindgen<'_> { fn finish_component( &mut self, name: &str, @@ -226,6 +253,28 @@ impl<'a> JsBindgen<'a> { ) { let mut output = source::Source::default(); let mut compilation_promises = source::Source::default(); + let mut core_exported_funcs = source::Source::default(); + + for (core_export_fn, is_async) in self.all_core_exported_funcs.iter() { + let local_name = self.local_names.get(core_export_fn); + if *is_async { + uwriteln!( + core_exported_funcs, + "{local_name} = WebAssembly.promising({core_export_fn});", + ); + } else { + uwriteln!(core_exported_funcs, "{local_name} = {core_export_fn};",); + } + } + + // adds a default implementation of `getCoreModule` + if matches!(self.opts.instantiation, Some(InstantiationMode::Async)) { + uwriteln!( + compilation_promises, + "if (!getCoreModule) getCoreModule = (name) => {}(new URL(`./${{name}}`, import.meta.url));", + self.intrinsic(Intrinsic::FetchCompile) + ); + } // Setup the compilation data and compilation promises let mut removed = BTreeSet::new(); @@ -298,6 +347,7 @@ impl<'a> JsBindgen<'a> { .render_imports(&mut output, imports_object, &mut self.local_names); if self.opts.instantiation.is_some() { + uwrite!(&mut self.src.js, "{}", &core_exported_funcs as &str); self.esm_bindgen.render_exports( &mut self.src.js, self.opts.instantiation.is_some(), @@ -366,6 +416,7 @@ impl<'a> JsBindgen<'a> { let gen = (function* init () {{ {}\ {}\ + {}\ }})(); let promise, resolve, reject; function runNext (value) {{ @@ -396,6 +447,7 @@ impl<'a> JsBindgen<'a> { &self.src.js as &str, &compilation_promises as &str, &self.src.js_init as &str, + &core_exported_funcs as &str, ); self.esm_bindgen.render_exports( @@ -443,6 +495,8 @@ struct Instantiator<'a, 'b> { /// Instance flags which references have been emitted externally at least once. used_instance_flags: RefCell>, defined_resource_classes: BTreeSet, + async_imports: HashSet, + async_exports: HashSet, lowering_options: PrimaryMap, } @@ -1018,7 +1072,7 @@ impl<'a> Instantiator<'a, '_> { uwriteln!( self.src.js_init, "({{ exports: exports{iu32} }} = yield {instantiate}(yield module{}{imports}));", - idx.as_u32() + idx.as_u32(), ) } @@ -1026,7 +1080,7 @@ impl<'a> Instantiator<'a, '_> { uwriteln!( self.src.js_init, "({{ exports: exports{iu32} }} = {instantiate}(module{}{imports}));", - idx.as_u32() + idx.as_u32(), ) } } @@ -1097,6 +1151,17 @@ impl<'a> Instantiator<'a, '_> { WorldItem::Type(_) => unreachable!(), }; + let is_async = self + .async_imports + .contains(&format!("{import_name}#{func_name}")) + || import_name + .find('@') + .map(|i| { + self.async_imports + .contains(&format!("{}#{func_name}", import_name.get(0..i).unwrap())) + }) + .unwrap_or(false); + // nested interfaces only currently possible through mapping let (import_specifier, maybe_iface_member) = map_import( &self.gen.opts.map, @@ -1178,7 +1243,15 @@ impl<'a> Instantiator<'a, '_> { .len(); match self.gen.opts.import_bindings { None | Some(BindingsMode::Js) | Some(BindingsMode::Hybrid) => { - uwrite!(self.src.js, "\nfunction trampoline{}", trampoline.as_u32()); + if is_async { + uwrite!( + self.src.js, + "\nconst trampoline{} = new WebAssembly.Suspending(async function", + trampoline.as_u32() + ); + } else { + uwrite!(self.src.js, "\nfunction trampoline{}", trampoline.as_u32()); + } self.bindgen( nparams, call_type, @@ -1192,8 +1265,14 @@ impl<'a> Instantiator<'a, '_> { func, &resource_map, AbiVariant::GuestImport, + is_async, ); uwriteln!(self.src.js, ""); + if is_async { + uwriteln!(self.src.js, ");"); + } else { + uwriteln!(self.src.js, ""); + } } Some(BindingsMode::Optimized) | Some(BindingsMode::DirectOptimized) => { uwriteln!(self.src.js, "let trampoline{};", trampoline.as_u32()); @@ -1556,6 +1635,7 @@ impl<'a> Instantiator<'a, '_> { func: &Function, resource_map: &ResourceMap, abi: AbiVariant, + is_async: bool, ) { let memory = opts.memory.map(|idx| format!("memory{}", idx.as_u32())); let realloc = opts.realloc.map(|idx| format!("realloc{}", idx.as_u32())); @@ -1650,6 +1730,7 @@ impl<'a> Instantiator<'a, '_> { }, src: source::Source::default(), resolve: self.resolve, + is_async, }; abi::call( self.resolve, @@ -1930,14 +2011,50 @@ impl<'a> Instantiator<'a, '_> { export_name: &String, resource_map: &ResourceMap, ) { + let is_async = self.async_exports.contains(&func.name) + || self + .async_exports + .contains(&format!("{export_name}#{}", func.name)) + || export_name + .find('@') + .map(|i| { + self.async_exports.contains(&format!( + "{}#{}", + export_name.get(0..i).unwrap(), + func.name + )) + }) + .unwrap_or(false); + + let maybe_async = if is_async { "async " } else { "" }; + + let core_export_fn = self.core_def(def); + let callee = match self + .gen + .local_names + .get_or_create(&core_export_fn, &core_export_fn) + { + (local_name, true) => local_name.to_string(), + (local_name, false) => { + let local_name = local_name.to_string(); + uwriteln!(self.src.js, "let {local_name};"); + self.gen + .all_core_exported_funcs + .push((core_export_fn.clone(), is_async)); + local_name + } + }; + match func.kind { - FunctionKind::Freestanding => uwrite!(self.src.js, "\nfunction {local_name}"), + FunctionKind::Freestanding => { + uwrite!(self.src.js, "\n{maybe_async}function {local_name}") + } FunctionKind::Method(_) => { self.ensure_local_resource_class(local_name.to_string()); let method_name = func.item_name().to_lower_camel_case(); uwrite!( self.src.js, - "\n{local_name}.prototype.{method_name} = function {}", + "\n{local_name}.prototype.{method_name} = {maybe_async}function {}", if !is_js_reserved_word(&method_name) { method_name.to_string() } else { @@ -1971,7 +2088,6 @@ impl<'a> Instantiator<'a, '_> { self.defined_resource_classes.insert(local_name.to_string()); } } - let callee = self.core_def(def); self.bindgen( func.params.len(), match func.kind { @@ -1988,6 +2104,7 @@ impl<'a> Instantiator<'a, '_> { func, resource_map, AbiVariant::GuestExport, + is_async, ); match func.kind { FunctionKind::Freestanding => self.src.js("\n"), diff --git a/crates/js-component-bindgen/src/ts_bindgen.rs b/crates/js-component-bindgen/src/ts_bindgen.rs index 9d7f4313b..ad8da317a 100644 --- a/crates/js-component-bindgen/src/ts_bindgen.rs +++ b/crates/js-component-bindgen/src/ts_bindgen.rs @@ -2,7 +2,7 @@ use crate::files::Files; use crate::function_bindgen::{array_ty, as_nullable, maybe_null}; use crate::names::{is_js_identifier, maybe_quote_id, LocalNames, RESERVED_KEYWORDS}; use crate::source::Source; -use crate::transpile_bindgen::{parse_world_key, InstantiationMode, TranspileOpts}; +use crate::transpile_bindgen::{parse_world_key, AsyncMode, InstantiationMode, TranspileOpts}; use crate::{dealias, feature_gate_allowed, uwrite, uwriteln}; use anyhow::{Context as _, Result}; use heck::*; @@ -32,6 +32,9 @@ struct TsBindgen { /// Whether or not the types should be generated for a guest module guest: bool, + + async_imports: HashSet, + async_exports: HashSet, } /// Used to generate a `*.d.ts` file for each imported and exported interface for @@ -57,6 +60,12 @@ pub fn ts_bindgen( opts: &TranspileOpts, files: &mut Files, ) -> Result<()> { + let (async_imports, async_exports) = match opts.async_mode.clone() { + None | Some(AsyncMode::Sync) => (Default::default(), Default::default()), + Some(AsyncMode::JavaScriptPromiseIntegration { imports, exports }) => { + (imports.into_iter().collect(), exports.into_iter().collect()) + } + }; let mut bindgen = TsBindgen { src: Source::default(), interface_names: LocalNames::default(), @@ -64,6 +73,8 @@ pub fn ts_bindgen( import_object: Source::default(), export_object: Source::default(), guest: opts.guest, + async_imports, + async_exports, }; let world = &resolve.worlds[id]; @@ -373,7 +384,7 @@ impl TsBindgen { files: &mut Files, ) -> String { // in case an imported type is used as an exported type - let local_name = self.generate_interface(name, resolve, id, files); + let local_name = self.generate_interface(name, resolve, id, files, false); uwriteln!( self.import_object, "{}: typeof {local_name},", @@ -393,7 +404,7 @@ impl TsBindgen { if iface_name == "*" { uwrite!(self.import_object, "{}: ", maybe_quote_id(import_name)); let name = resolve.interfaces[id].name.as_ref().unwrap(); - let local_name = self.generate_interface(name, resolve, id, files); + let local_name = self.generate_interface(name, resolve, id, files, false); uwriteln!(self.import_object, "typeof {local_name},",); return; } @@ -401,7 +412,7 @@ impl TsBindgen { uwriteln!(self.import_object, "{}: {{", maybe_quote_id(import_name)); for (iface_name, &id) in ifaces { let name = resolve.interfaces[id].name.as_ref().unwrap(); - let local_name = self.generate_interface(name, resolve, id, files); + let local_name = self.generate_interface(name, resolve, id, files, false); uwriteln!( self.import_object, "{}: typeof {local_name},", @@ -420,7 +431,7 @@ impl TsBindgen { ) { uwriteln!(self.import_object, "{}: {{", maybe_quote_id(import_name)); let mut gen = TsInterface::new(resolve, false); - gen.ts_func(func, true, false); + gen.ts_func(func, true, false, false); let src = gen.finish(); self.import_object.push_str(&src); uwriteln!(self.import_object, "}},"); @@ -434,7 +445,7 @@ impl TsBindgen { files: &mut Files, instantiation: bool, ) -> String { - let local_name = self.generate_interface(export_name, resolve, id, files); + let local_name = self.generate_interface(export_name, resolve, id, files, false); if instantiation { uwriteln!( self.export_object, @@ -458,14 +469,26 @@ impl TsBindgen { fn export_funcs( &mut self, resolve: &Resolve, - _world: WorldId, + world: WorldId, funcs: &[(String, &Function)], _files: &mut Files, declaration: bool, ) { let mut gen = TsInterface::new(resolve, false); + let async_exports = self.async_exports.clone(); + let id_name = &resolve.worlds[world].name; for (_, func) in funcs { - gen.ts_func(func, false, declaration); + let func_name = &func.name; + let is_async = async_exports.contains(func_name) + || async_exports.contains(&format!("{id_name}#{func_name}")) + || id_name + .find('@') + .map(|i| { + async_exports + .contains(&format!("{}#{func_name}", id_name.get(0..i).unwrap())) + }) + .unwrap_or(false); + gen.ts_func(func, false, declaration, is_async); } let src = gen.finish(); self.export_object.push_str(&src); @@ -477,6 +500,7 @@ impl TsBindgen { resolve: &Resolve, id: InterfaceId, files: &mut Files, + is_world_export: bool, ) -> String { let iface = resolve .interfaces @@ -525,6 +549,12 @@ impl TsBindgen { return local_name; } + let async_funcs = if is_world_export { + self.async_exports.clone() + } else { + self.async_imports.clone() + }; + let module_or_namespace = if self.guest { format!("declare module '{id_name}' {{") } else { @@ -541,7 +571,16 @@ impl TsBindgen { { continue; } - gen.ts_func(func, false, true); + let func_name = &func.name; + let is_async = is_world_export && async_funcs.contains(func_name) + || async_funcs.contains(&format!("{id_name}#{func_name}")) + || id_name + .find('@') + .map(|i| { + async_funcs.contains(&format!("{}#{func_name}", id_name.get(0..i).unwrap())) + }) + .unwrap_or(false); + gen.ts_func(func, false, true, is_async); } // Export resources for the interface for (_, ty) in resolve.interfaces[id].types.iter() { @@ -735,7 +774,7 @@ impl<'a> TsInterface<'a> { self.src.push_str("]"); } - fn ts_func(&mut self, func: &Function, default: bool, declaration: bool) { + fn ts_func(&mut self, func: &Function, default: bool, declaration: bool, is_async: bool) { let iface = if let FunctionKind::Method(ty) | FunctionKind::Static(ty) | FunctionKind::Constructor(ty) = func.kind @@ -760,11 +799,15 @@ impl<'a> TsInterface<'a> { func.item_name().to_lower_camel_case() }; + let maybe_async = if is_async { "async " } else { "" }; + if declaration { match func.kind { FunctionKind::Freestanding => { if is_js_identifier(&out_name) { - iface.src.push_str(&format!("export function {out_name}")); + iface + .src + .push_str(&format!("export {maybe_async}function {out_name}")); } else { let (local_name, _) = iface.local_names.get_or_create(&out_name, &out_name); iface @@ -772,21 +815,25 @@ impl<'a> TsInterface<'a> { .push_str(&format!("export {{ {local_name} as {out_name} }};\n")); iface .src - .push_str(&format!("declare function {local_name}")); + .push_str(&format!("declare {maybe_async}function {local_name}")); }; } FunctionKind::Method(_) => { if is_js_identifier(&out_name) { - iface.src.push_str(&out_name); + iface.src.push_str(&format!("{maybe_async}{out_name}")); } else { - iface.src.push_str(&format!("'{out_name}'")); + iface.src.push_str(&format!("{maybe_async}'{out_name}'")); } } FunctionKind::Static(_) => { if is_js_identifier(&out_name) { - iface.src.push_str(&format!("static {out_name}")) + iface + .src + .push_str(&format!("static {maybe_async}{out_name}")) } else { - iface.src.push_str(&format!("static '{out_name}'")) + iface + .src + .push_str(&format!("static {maybe_async}'{out_name}'")) } } FunctionKind::Constructor(_) => { @@ -795,9 +842,9 @@ impl<'a> TsInterface<'a> { } } } else if is_js_identifier(&out_name) { - iface.src.push_str(&out_name); + iface.src.push_str(&format!("{maybe_async}{out_name}")); } else { - iface.src.push_str(&format!("'{out_name}'")); + iface.src.push_str(&format!("{maybe_async}'{out_name}'")); } let end_character = if declaration { ';' } else { ',' }; @@ -834,6 +881,10 @@ impl<'a> TsInterface<'a> { } iface.src.push_str(": "); + if is_async { + iface.src.push_str("Promise<"); + } + if let Some((ok_ty, _)) = func.results.throws(iface.resolve) { iface.print_optional_ty(ok_ty); } else { @@ -852,6 +903,12 @@ impl<'a> TsInterface<'a> { } } } + + if is_async { + // closes `Promise<>` + iface.src.push_str(">"); + } + iface.src.push_str(format!("{}\n", end_character).as_str()); } diff --git a/docs/src/transpiling.md b/docs/src/transpiling.md index fd88b0b26..91cc4f003 100644 --- a/docs/src/transpiling.md +++ b/docs/src/transpiling.md @@ -53,6 +53,10 @@ Options include: * `--tracing`: Emit tracing calls for all function entry and exits. * `--no-namespaced-exports`: Removes exports of the type `test as "test:flavorful/test"` which are not compatible with typescript +* `--async-mode [mode]`: EXPERIMENTAL: For the component imports and exports, functions and methods on resources can be specified as `async`. The only option is `jspi` (JavaScript Promise Integration). +* `--async-imports `: EXPERIMENTAL: Specify the component imports as `async`. Used with `--async-mode`. +* `--async-exports `: EXPERIMENTAL: Specify the component exports as `async`. Used with `--async-mode`. + ## Browser Support Jco itself can be used in the browser, which provides the simpler Jco API that is just exactly the same diff --git a/package-lock.json b/package-lock.json index 7d88b341c..fca6e1e94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -406,9 +406,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.18.0.tgz", - "integrity": "sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==", + "version": "9.19.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz", + "integrity": "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==", "dev": true, "license": "MIT", "engines": { @@ -1061,9 +1061,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.17.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.14.tgz", - "integrity": "sha512-w6qdYetNL5KRBiSClK/KWai+2IMEJuAj+EujKCumalFOwXtvOXaEan9AuwcRID2IcOIAWSIfR495hBtgKlx2zg==", + "version": "20.17.16", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.16.tgz", + "integrity": "sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==", "dev": true, "license": "MIT", "dependencies": { @@ -1082,17 +1082,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.21.0.tgz", - "integrity": "sha512-eTH+UOR4I7WbdQnG4Z48ebIA6Bgi7WO8HvFEneeYBxG8qCOYgTOFPSg6ek9ITIDvGjDQzWHcoWHCDO2biByNzA==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.22.0.tgz", + "integrity": "sha512-4Uta6REnz/xEJMvwf72wdUnC3rr4jAQf5jnTkeRQ9b6soxLxhDEbS/pfMPoJLDfFPNVRdryqWUIV/2GZzDJFZw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.21.0", - "@typescript-eslint/type-utils": "8.21.0", - "@typescript-eslint/utils": "8.21.0", - "@typescript-eslint/visitor-keys": "8.21.0", + "@typescript-eslint/scope-manager": "8.22.0", + "@typescript-eslint/type-utils": "8.22.0", + "@typescript-eslint/utils": "8.22.0", + "@typescript-eslint/visitor-keys": "8.22.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1112,16 +1112,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.21.0.tgz", - "integrity": "sha512-Wy+/sdEH9kI3w9civgACwabHbKl+qIOu0uFZ9IMKzX3Jpv9og0ZBJrZExGrPpFAY7rWsXuxs5e7CPPP17A4eYA==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.22.0.tgz", + "integrity": "sha512-MqtmbdNEdoNxTPzpWiWnqNac54h8JDAmkWtJExBVVnSrSmi9z+sZUt0LfKqk9rjqmKOIeRhO4fHHJ1nQIjduIQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.21.0", - "@typescript-eslint/types": "8.21.0", - "@typescript-eslint/typescript-estree": "8.21.0", - "@typescript-eslint/visitor-keys": "8.21.0", + "@typescript-eslint/scope-manager": "8.22.0", + "@typescript-eslint/types": "8.22.0", + "@typescript-eslint/typescript-estree": "8.22.0", + "@typescript-eslint/visitor-keys": "8.22.0", "debug": "^4.3.4" }, "engines": { @@ -1137,14 +1137,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.21.0.tgz", - "integrity": "sha512-G3IBKz0/0IPfdeGRMbp+4rbjfSSdnGkXsM/pFZA8zM9t9klXDnB/YnKOBQ0GoPmoROa4bCq2NeHgJa5ydsQ4mA==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.22.0.tgz", + "integrity": "sha512-/lwVV0UYgkj7wPSw0o8URy6YI64QmcOdwHuGuxWIYznO6d45ER0wXUbksr9pYdViAofpUCNJx/tAzNukgvaaiQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.21.0", - "@typescript-eslint/visitor-keys": "8.21.0" + "@typescript-eslint/types": "8.22.0", + "@typescript-eslint/visitor-keys": "8.22.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1155,14 +1155,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.21.0.tgz", - "integrity": "sha512-95OsL6J2BtzoBxHicoXHxgk3z+9P3BEcQTpBKriqiYzLKnM2DeSqs+sndMKdamU8FosiadQFT3D+BSL9EKnAJQ==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.22.0.tgz", + "integrity": "sha512-NzE3aB62fDEaGjaAYZE4LH7I1MUwHooQ98Byq0G0y3kkibPJQIXVUspzlFOmOfHhiDLwKzMlWxaNv+/qcZurJA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.21.0", - "@typescript-eslint/utils": "8.21.0", + "@typescript-eslint/typescript-estree": "8.22.0", + "@typescript-eslint/utils": "8.22.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.0" }, @@ -1179,9 +1179,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.21.0.tgz", - "integrity": "sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.22.0.tgz", + "integrity": "sha512-0S4M4baNzp612zwpD4YOieP3VowOARgK2EkN/GBn95hpyF8E2fbMT55sRHWBq+Huaqk3b3XK+rxxlM8sPgGM6A==", "dev": true, "license": "MIT", "engines": { @@ -1193,14 +1193,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.21.0.tgz", - "integrity": "sha512-x+aeKh/AjAArSauz0GiQZsjT8ciadNMHdkUSwBB9Z6PrKc/4knM4g3UfHml6oDJmKC88a6//cdxnO/+P2LkMcg==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.22.0.tgz", + "integrity": "sha512-SJX99NAS2ugGOzpyhMza/tX+zDwjvwAtQFLsBo3GQxiGcvaKlqGBkmZ+Y1IdiSi9h4Q0Lr5ey+Cp9CGWNY/F/w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.21.0", - "@typescript-eslint/visitor-keys": "8.21.0", + "@typescript-eslint/types": "8.22.0", + "@typescript-eslint/visitor-keys": "8.22.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1220,16 +1220,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.21.0.tgz", - "integrity": "sha512-xcXBfcq0Kaxgj7dwejMbFyq7IOHgpNMtVuDveK7w3ZGwG9owKzhALVwKpTF2yrZmEwl9SWdetf3fxNzJQaVuxw==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.22.0.tgz", + "integrity": "sha512-T8oc1MbF8L+Bk2msAvCUzjxVB2Z2f+vXYfcucE2wOmYs7ZUwco5Ep0fYZw8quNwOiw9K8GYVL+Kgc2pETNTLOg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.21.0", - "@typescript-eslint/types": "8.21.0", - "@typescript-eslint/typescript-estree": "8.21.0" + "@typescript-eslint/scope-manager": "8.22.0", + "@typescript-eslint/types": "8.22.0", + "@typescript-eslint/typescript-estree": "8.22.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1244,13 +1244,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.21.0.tgz", - "integrity": "sha512-BkLMNpdV6prozk8LlyK/SOoWLmUFi+ZD+pcqti9ILCbVvHGk1ui1g4jJOc2WDLaeExz2qWwojxlPce5PljcT3w==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.22.0.tgz", + "integrity": "sha512-AWpYAXnUgvLNabGTy3uBylkgZoosva/miNd1I8Bz3SjotmQPbVqhO4Cczo8AsZ44XVErEBPr/CRSgaj8sG7g0w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.21.0", + "@typescript-eslint/types": "8.22.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -1728,9 +1728,9 @@ } }, "node_modules/chromium-bidi": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.12.0.tgz", - "integrity": "sha512-xzXveJmX826GGq1MeE5okD8XxaDT8172CXByhFJ687eY65rbjOIebdbUuQh+jXKaNyGKI14Veb3KjLLmSueaxA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-1.1.0.tgz", + "integrity": "sha512-HislCEczCuamWm3+55Lig9XKmMF13K+BGKum9rwtDAzgUAHT4h5jNwhDmD4U20VoVUG8ujnv9UZ89qiIf5uF8w==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2172,9 +2172,9 @@ } }, "node_modules/eslint": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.18.0.tgz", - "integrity": "sha512-+waTfRWQlSbpt3KWE+CjrPPYnbq9kfZIYUqapc0uBXyjTp8aYXZDsUH16m39Ryq3NjAVP4tjuF7KaukeqoCoaA==", + "version": "9.19.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.19.0.tgz", + "integrity": "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==", "dev": true, "license": "MIT", "dependencies": { @@ -2183,7 +2183,7 @@ "@eslint/config-array": "^0.19.0", "@eslint/core": "^0.10.0", "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.18.0", + "@eslint/js": "9.19.0", "@eslint/plugin-kit": "^0.2.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3753,18 +3753,18 @@ } }, "node_modules/puppeteer": { - "version": "24.1.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.1.0.tgz", - "integrity": "sha512-F+3yKILaosLToT7amR7LIkTKkKMR0EGQPjFBch+MtgS8vRPS+4cPnLJuXDVTfCj2NqfrCnShtOr7yD+9dEgHRQ==", + "version": "24.1.1", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.1.1.tgz", + "integrity": "sha512-fuhceZ5HZuDXVuaMIRxUuDHfCJLmK0pXh8FlzVQ0/+OApStevxZhU5kAVeYFOEqeCF5OoAyZjcWbdQK27xW/9A==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@puppeteer/browsers": "2.7.0", - "chromium-bidi": "0.12.0", + "chromium-bidi": "1.1.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1380148", - "puppeteer-core": "24.1.0", + "puppeteer-core": "24.1.1", "typed-query-selector": "^2.12.0" }, "bin": { @@ -3775,14 +3775,14 @@ } }, "node_modules/puppeteer-core": { - "version": "24.1.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.1.0.tgz", - "integrity": "sha512-ReefWoQgqdyl67uWEBy/TMZ4mAB7hP0JB5HIxSE8B1ot/4ningX1gmzHCOSNfMbTiS/VJHCvaZAe3oJTXph7yw==", + "version": "24.1.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.1.1.tgz", + "integrity": "sha512-7FF3gq6bpIsbq3I8mfbodXh3DCzXagoz3l2eGv1cXooYU4g0P4mcHQVHuBD4iSZPXNg8WjzlP5kmRwK9UvwF0A==", "dev": true, "license": "Apache-2.0", "dependencies": { "@puppeteer/browsers": "2.7.0", - "chromium-bidi": "0.11.0", + "chromium-bidi": "1.1.0", "debug": "^4.4.0", "devtools-protocol": "0.0.1380148", "typed-query-selector": "^2.12.0", @@ -3792,30 +3792,6 @@ "node": ">=18" } }, - "node_modules/puppeteer-core/node_modules/chromium-bidi": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.11.0.tgz", - "integrity": "sha512-6CJWHkNRoyZyjV9Rwv2lYONZf1Xm0IuDyNq97nwSsxxP3wf5Bwy15K5rOvVKMtJ127jJBmxFUanSAOjgFRxgrA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "mitt": "3.0.1", - "zod": "3.23.8" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, - "node_modules/puppeteer-core/node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/package.json b/package.json index 751164ebb..d9179869a 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "build:types:preview2-shim": "cargo xtask generate wasi-types", "lint": "eslint -c eslintrc.cjs src/**/*.js packages/*/lib/**/*.js", "test:lts": "mocha -u tdd test/test.js --timeout 240000", - "test": "node --stack-trace-limit=100 node_modules/mocha/bin/mocha.js -u tdd test/test.js --timeout 240000", + "test": "node --experimental-wasm-jspi --stack-trace-limit=100 node_modules/mocha/bin/mocha.js -u tdd test/test.js --timeout 240000", "prepublishOnly": "cargo xtask build release && npm run test" }, "files": [ diff --git a/src/cmd/transpile.js b/src/cmd/transpile.js index 1282be092..6bc109c7b 100644 --- a/src/cmd/transpile.js +++ b/src/cmd/transpile.js @@ -14,6 +14,22 @@ import { platform } from 'node:process'; const isWindows = platform === 'win32'; +const ASYNC_WASI_IMPORTS = [ + "wasi:io/poll#poll", + "wasi:io/poll#[method]pollable.block", + "wasi:io/streams#[method]input-stream.blocking-read", + "wasi:io/streams#[method]input-stream.blocking-skip", + "wasi:io/streams#[method]output-stream.blocking-flush", + "wasi:io/streams#[method]output-stream.blocking-write-and-flush", + "wasi:io/streams#[method]output-stream.blocking-write-zeroes-and-flush", + "wasi:io/streams#[method]output-stream.blocking-splice", +]; + +const ASYNC_WASI_EXPORTS = [ + "wasi:cli/run#run", + "wasi:http/incoming-handler#handle", +]; + export async function types (witPath, opts) { const files = await typesComponent(witPath, opts); await writeFiles(files, opts.quiet ? false : 'Generated Type Files'); @@ -31,6 +47,9 @@ export async function guestTypes (witPath, opts) { * worldName?: string, * instantiation?: 'async' | 'sync', * tlaCompat?: bool, + * asyncMode?: string, + * asyncImports?: string[], + * asyncExports?: string[], * outDir?: string, * features?: string[] | 'all', * guest?: bool, @@ -57,6 +76,21 @@ export async function typesComponent (witPath, opts) { features = { tag: 'list', val: opts.feature }; } + if (opts.asyncWasiImports) + opts.asyncImports = ASYNC_WASI_IMPORTS.concat(opts.asyncImports || []); + if (opts.asyncWasiExports) + opts.asyncExports = ASYNC_WASI_EXPORTS.concat(opts.asyncExports || []); + + const asyncMode = !opts.asyncMode || opts.asyncMode === 'sync' ? + null : + { + tag: opts.asyncMode, + val: { + imports: opts.asyncImports || [], + exports: opts.asyncExports || [], + }, + }; + return Object.fromEntries(generateTypes(name, { wit: { tag: 'path', val: (isWindows ? '//?/' : '') + resolve(witPath) }, instantiation, @@ -64,6 +98,7 @@ export async function typesComponent (witPath, opts) { world: opts.worldName, features, guest: opts.guest ?? false, + asyncMode, }).map(([name, file]) => [`${outDir}${name}`, file])); } @@ -105,6 +140,12 @@ export async function transpile (componentPath, opts, program) { opts.name = basename(componentPath.slice(0, -extname(componentPath).length || Infinity)); if (opts.map) opts.map = Object.fromEntries(opts.map.map(mapping => mapping.split('='))); + + if (opts.asyncWasiImports) + opts.asyncImports = ASYNC_WASI_IMPORTS.concat(opts.asyncImports || []); + if (opts.asyncWasiExports) + opts.asyncExports = ASYNC_WASI_EXPORTS.concat(opts.asyncExports || []); + const { files } = await transpileComponent(component, opts); await writeFiles(files, opts.quiet ? false : 'Transpiled JS Component Files'); } @@ -133,6 +174,9 @@ async function wasm2Js (source) { * instantiation?: 'async' | 'sync', * importBindings?: 'js' | 'optimized' | 'hybrid' | 'direct-optimized', * map?: Record, + * asyncMode?: string, + * asyncImports?: string[], + * asyncExports?: string[], * validLiftingOptimization?: bool, * tracing?: bool, * nodejsCompat?: bool, @@ -155,6 +199,7 @@ export async function transpileComponent (component, opts = {}) { let spinner; const showSpinner = getShowSpinner(); + if (opts.optimize) { if (showSpinner) setShowSpinner(true); ({ component } = await optimizeComponent(component, opts)); @@ -183,10 +228,21 @@ export async function transpileComponent (component, opts = {}) { instantiation = { tag: 'async' }; } + const asyncMode = !opts.asyncMode || opts.asyncMode === 'sync' ? + null : + { + tag: opts.asyncMode, + val: { + imports: opts.asyncImports || [], + exports: opts.asyncExports || [], + }, + }; + let { files, imports, exports } = generate(component, { name: opts.name ?? 'component', map: Object.entries(opts.map ?? {}), instantiation, + asyncMode, importBindings: opts.importBindings ? { tag: opts.importBindings } : null, validLiftingOptimization: opts.validLiftingOptimization ?? false, tracing: opts.tracing ?? false, diff --git a/src/jco.js b/src/jco.js index ff85e1e63..251c10a5a 100755 --- a/src/jco.js +++ b/src/jco.js @@ -52,6 +52,11 @@ program.command('transpile') .option('--no-typescript', 'do not output TypeScript .d.ts types') .option('--valid-lifting-optimization', 'optimize component binary validations assuming all lifted values are valid') .addOption(new Option('--import-bindings [mode]', 'bindings mode for imports').choices(['js', 'optimized', 'hybrid', 'direct-optimized']).preset('js')) + .addOption(new Option('--async-mode [mode]', 'EXPERIMENTAL: use async imports and exports').choices(['sync', 'jspi']).preset('sync')) + .option('--async-wasi-imports', 'EXPERIMENTAL: async component imports from WASI interfaces') + .option('--async-wasi-exports', 'EXPERIMENTAL: async component exports from WASI interfaces') + .option('--async-imports ', 'EXPERIMENTAL: async component imports (examples: "wasi:io/poll@0.2.0#poll", "wasi:io/poll#[method]pollable.block")') + .option('--async-exports ', 'EXPERIMENTAL: async component exports (examples: "wasi:cli/run@#run", "handle")') .option('--tracing', 'emit `tracing` calls on function entry/exit') .option('-b, --base64-cutoff ', 'set the byte size under which core Wasm binaries will be inlined as base64', myParseInt) .option('--tla-compat', 'enables compatibility for JS environments without top-level await support via an async $init promise export') @@ -76,6 +81,11 @@ program.command('types') .requiredOption('-o, --out-dir ', 'output directory') .option('--tla-compat', 'generates types for the TLA compat output with an async $init promise export') .addOption(new Option('-I, --instantiation [mode]', 'type output for custom module instantiation').choices(['async', 'sync']).preset('async')) + .addOption(new Option('--async-mode [mode]', 'EXPERIMENTAL: use async imports and exports').choices(['sync', 'jspi']).preset('sync')) + .option('--async-wasi-imports', 'EXPERIMENTAL: async component imports from WASI interfaces') + .option('--async-wasi-exports', 'EXPERIMENTAL: async component exports from WASI interfaces') + .option('--async-imports ', 'EXPERIMENTAL: async component imports (examples: "wasi:io/poll@0.2.0#poll", "wasi:io/poll#[method]pollable.block")') + .option('--async-exports ', 'EXPERIMENTAL: async component exports (examples: "wasi:cli/run@#run", "handle")') .option('-q, --quiet', 'disable output summary') .option('--feature ', 'enable one specific WIT feature (repeatable)', collectOptions, []) .option('--all-features', 'enable all features') diff --git a/test/async.browser.js b/test/async.browser.js new file mode 100644 index 000000000..28f8e5f66 --- /dev/null +++ b/test/async.browser.js @@ -0,0 +1,153 @@ +import { dirname, join, resolve } from "node:path"; +import { execArgv } from "node:process"; +import { deepStrictEqual, ok, strictEqual, fail } from "node:assert"; +import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; + +import { fileURLToPath, pathToFileURL } from "url"; +import puppeteer from "puppeteer"; + +import { + exec, + jcoPath, + getTmpDir, + setupAsyncTest, + startTestWebServer, + loadTestPage, +} from "./helpers.js"; + +const multiMemory = execArgv.includes("--experimental-wasm-multi-memory") + ? ["--multi-memory"] + : []; + +const AsyncFunction = (async () => {}).constructor; + +export async function asyncBrowserTest(_fixtures) { + suite("Async", () => { + var tmpDir; + var outDir; + var outFile; + + suiteSetup(async function () { + tmpDir = await getTmpDir(); + outDir = resolve(tmpDir, "out-component-dir"); + outFile = resolve(tmpDir, "out-component-file"); + + const modulesDir = resolve(tmpDir, "node_modules", "@bytecodealliance"); + await mkdir(modulesDir, { recursive: true }); + await symlink( + fileURLToPath(new URL("../packages/preview2-shim", import.meta.url)), + resolve(modulesDir, "preview2-shim"), + "dir", + ); + }); + + suiteTeardown(async function () { + try { + await rm(tmpDir, { recursive: true }); + } catch {} + }); + + teardown(async function () { + try { + await rm(outDir, { recursive: true }); + await rm(outFile); + } catch {} + }); + + if (typeof WebAssembly?.Suspending === "function") { + test("Transpile async (browser, JSPI)", async () => { + const componentName = "async-call"; + const { + instance, + cleanup: componentCleanup, + outputDir, + } = await setupAsyncTest({ + asyncMode: "jspi", + component: { + name: "async_call", + path: resolve("test/fixtures/components/async_call.component.wasm"), + imports: { + "something:test/test-interface": { + callAsync: async () => "called async", + callSync: () => "called sync", + }, + }, + }, + jco: { + transpile: { + extraArgs: { + asyncImports: ["something:test/test-interface#call-async"], + asyncExports: ["run-async"], + }, + }, + }, + }); + const moduleName = componentName.toLowerCase().replaceAll("-", "_"); + const moduleRelPath = `${moduleName}/${moduleName}.js`; + + strictEqual( + instance.runSync instanceof AsyncFunction, + false, + "runSync() should be a sync function", + ); + strictEqual( + instance.runAsync instanceof AsyncFunction, + true, + "runAsync() should be an async function", + ); + + // Start a test web server + const { + server, + serverPort, + cleanup: webServerCleanup, + } = await startTestWebServer({ + routes: [ + // NOTE: the goal here is to serve relative paths via the browser hash + // + // (1) browser visits test page (served by test web server) + // (2) browser requests component itself by looking at URL hash fragment + // (i.e. "#transpiled:async_call/async_call.js" -> , "/transpiled/async_call/async_call.js") + // (i.e. "/transpiled/async_call/async_call.js" -> file read of /tmp/xxxxxx/async_call/async_call.js) + { + urlPrefix: "/transpiled/", + basePathURL: pathToFileURL(`${outputDir}/`), + }, + // Serve all other files (ex. the initial HTML for the page) + { basePathURL: import.meta.url }, + ], + }); + + // Start a browser to visit the test server + const browser = await puppeteer.launch({ + args: [ + "--enable-experimental-webassembly-jspi", + "--flag-switches-begin", + "--enable-features=WebAssemblyExperimentalJSPI", + "--flag-switches-end", + ], + }); + + // Load the test page in the browser, which will trigger tests against + // the component and/or related browser polyfills + const { + page, + output: { json }, + } = await loadTestPage({ + browser, + serverPort, + path: "fixtures/browser/test-pages/something__test.async.html", + hash: `transpiled:${moduleRelPath}`, + }); + + // Check the output expected to be returned from handle of the + // guest export (this depends on the component) + deepStrictEqual(json, { responseText: "callAsync" }); + + await browser.close(); + await webServerCleanup(); + await componentCleanup(); + }); + } + }); +} diff --git a/test/async.js b/test/async.js new file mode 100644 index 000000000..2cd862661 --- /dev/null +++ b/test/async.js @@ -0,0 +1,143 @@ +import { join, resolve } from "node:path"; +import { execArgv } from "node:process"; +import { deepStrictEqual, ok, strictEqual, fail } from "node:assert"; +import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; + +import { fileURLToPath, pathToFileURL } from "url"; + +import { + exec, + jcoPath, + getTmpDir, + setupAsyncTest, +} from "./helpers.js"; + +const multiMemory = execArgv.includes("--experimental-wasm-multi-memory") + ? ["--multi-memory"] + : []; + +const AsyncFunction = (async () => {}).constructor; + +export async function asyncTest(_fixtures) { + suite("Async", () => { + var tmpDir; + var outDir; + var outFile; + + suiteSetup(async function () { + tmpDir = await getTmpDir(); + outDir = resolve(tmpDir, "out-component-dir"); + outFile = resolve(tmpDir, "out-component-file"); + + const modulesDir = resolve(tmpDir, "node_modules", "@bytecodealliance"); + await mkdir(modulesDir, { recursive: true }); + await symlink( + fileURLToPath(new URL("../packages/preview2-shim", import.meta.url)), + resolve(modulesDir, "preview2-shim"), + "dir", + ); + }); + + suiteTeardown(async function () { + try { + await rm(tmpDir, { recursive: true }); + } catch {} + }); + + teardown(async function () { + try { + await rm(outDir, { recursive: true }); + await rm(outFile); + } catch {} + }); + + test("Transpile async", async () => { + const name = "flavorful"; + const { stderr } = await exec( + jcoPath, + "transpile", + `test/fixtures/components/${name}.component.wasm`, + "--no-wasi-shim", + "--name", + name, + "-o", + outDir + ); + strictEqual(stderr, ""); + const source = await readFile(`${outDir}/${name}.js`); + ok(source.toString().includes("export { test")); + }); + + if (typeof WebAssembly?.Suspending === "function") { + test("Transpile async (NodeJS, JSPI)", async () => { + const { instance, cleanup, component } = await setupAsyncTest({ + asyncMode: "jspi", + component: { + name: "async_call", + path: resolve("test/fixtures/components/async_call.component.wasm"), + imports: { + 'something:test/test-interface': { + callAsync: async () => "called async", + callSync: () => "called sync", + }, + }, + }, + jco: { + transpile: { + extraArgs: { + asyncImports: [ + "something:test/test-interface#call-async", + ], + asyncExports: [ + "run-async", + ], + }, + }, + }, + }); + + strictEqual(instance.runSync instanceof AsyncFunction, false, "runSync() should be a sync function"); + strictEqual(instance.runAsync instanceof AsyncFunction, true, "runAsync() should be an async function"); + + strictEqual(instance.runSync(), "called sync"); + strictEqual(await instance.runAsync(), "called async"); + + await cleanup(); + }); + + test("Transpile async import and export (NodeJS, JSPI)", async () => { + const testMessage = "Hello from Async Function!"; + const { instance, cleanup, component } = await setupAsyncTest({ + asyncMode: "jspi", + component: { + name: "async_call", + path: resolve("test/fixtures/components/simple-nested.component.wasm"), + imports: { + 'calvinrp:test-async-funcs/hello': { + helloWorld: async () => await Promise.resolve(testMessage), + }, + }, + }, + jco: { + transpile: { + extraArgs: { + asyncImports: [ + "calvinrp:test-async-funcs/hello#hello-world", + ], + asyncExports: [ + "hello-world", + ], + }, + }, + }, + }); + + strictEqual(instance.hello.helloWorld instanceof AsyncFunction, true, "helloWorld() should be an async function"); + + strictEqual(await instance.hello.helloWorld(), testMessage); + + await cleanup(); + }); + } + }); +} diff --git a/test/cli.js b/test/cli.js index 68918b407..cbd0c2834 100644 --- a/test/cli.js +++ b/test/cli.js @@ -1,14 +1,7 @@ -import { resolve } from "node:path"; -import { execArgv, env } from "node:process"; +import { resolve, join } from "node:path"; +import { execArgv } from "node:process"; import { deepStrictEqual, ok, strictEqual } from "node:assert"; -import { - mkdir, - readdir, - readFile, - rm, - symlink, - writeFile, -} from "node:fs/promises"; +import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { fileURLToPath, pathToFileURL } from "url"; import { exec, jcoPath, getTmpDir } from "./helpers.js"; @@ -17,6 +10,8 @@ const multiMemory = execArgv.includes("--experimental-wasm-multi-memory") ? ["--multi-memory"] : []; +const AsyncFunction = (async () => {}).constructor; + export async function cliTest(_fixtures) { suite("CLI", () => { var tmpDir; @@ -32,7 +27,7 @@ export async function cliTest(_fixtures) { await symlink( fileURLToPath(new URL("../packages/preview2-shim", import.meta.url)), resolve(modulesDir, "preview2-shim"), - "dir" + "dir", ); }); suiteTeardown(async function () { @@ -55,12 +50,12 @@ export async function cliTest(_fixtures) { `test/fixtures/env-allow.composed.wasm`, ...multiMemory, "-o", - outDir + outDir, ); strictEqual(stderr, ""); await writeFile( `${outDir}/package.json`, - JSON.stringify({ type: "module" }) + JSON.stringify({ type: "module" }), ); const m = await import(`${pathToFileURL(outDir)}/env-allow.composed.js`); deepStrictEqual(m.testGetEnv(), [["CUSTOM", "VAL"]]); @@ -73,12 +68,12 @@ export async function cliTest(_fixtures) { `test/fixtures/stdio.composed.wasm`, ...multiMemory, "-o", - outDir + outDir, ); strictEqual(stderr, ""); await writeFile( `${outDir}/package.json`, - JSON.stringify({ type: "module" }) + JSON.stringify({ type: "module" }), ); const m = await import(`${pathToFileURL(outDir)}/stdio.composed.js`); m.testStdio(); @@ -92,12 +87,12 @@ export async function cliTest(_fixtures) { ...multiMemory, "--valid-lifting-optimization", "-o", - outDir + outDir, ); strictEqual(stderr, ""); await writeFile( `${outDir}/package.json`, - JSON.stringify({ type: "module" }) + JSON.stringify({ type: "module" }), ); const m = await import(`${pathToFileURL(outDir)}/stdio.composed.js`); m.testStdio(); @@ -113,13 +108,51 @@ export async function cliTest(_fixtures) { "--name", name, "-o", - outDir + outDir, ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/${name}.js`); ok(source.toString().includes("export { test")); }); + if (typeof WebAssembly.Suspending === "function") { + test("Transpile with Async Mode for JSPI", async () => { + const name = "async_call"; + const { stderr } = await exec( + jcoPath, + "transpile", + `test/fixtures/components/${name}.component.wasm`, + `--name=${name}`, + "--valid-lifting-optimization", + "--tla-compat", + "--instantiation=async", + "--base64-cutoff=0", + "--async-mode=jspi", + "--async-imports=something:test/test-interface#call-async", + "--async-exports=run-async", + "-o", + outDir, + ); + strictEqual(stderr, ""); + await writeFile( + `${outDir}/package.json`, + JSON.stringify({ type: "module" }), + ); + const m = await import(`${pathToFileURL(outDir)}/${name}.js`); + const inst = await m.instantiate(undefined, { + "something:test/test-interface": { + callAsync: async () => "called async", + callSync: () => "called sync", + }, + }); + strictEqual(inst.runSync instanceof AsyncFunction, false); + strictEqual(inst.runAsync instanceof AsyncFunction, true); + + strictEqual(inst.runSync(), "called sync"); + strictEqual(await inst.runAsync(), "called async"); + }); + } + test("Transpile & Optimize & Minify", async () => { const name = "flavorful"; const { stderr } = await exec( @@ -134,7 +167,7 @@ export async function cliTest(_fixtures) { "--minify", "--base64-cutoff=0", "-o", - outDir + outDir, ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/${name}.js`); @@ -154,20 +187,20 @@ export async function cliTest(_fixtures) { "--tracing", "--base64-cutoff=0", "-o", - outDir + outDir, ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/${name}.js`, "utf8"); ok(source.includes("function toResultString(")); ok( source.includes( - 'console.error(`[module="test:flavorful/test", function="f-list-in-record1"] call a' - ) + 'console.error(`[module="test:flavorful/test", function="f-list-in-record1"] call a', + ), ); ok( source.includes( - 'console.error(`[module="test:flavorful/test", function="list-of-variants"] return result=${toResultString(ret)}`);' - ) + 'console.error(`[module="test:flavorful/test", function="list-of-variants"] return result=${toResultString(ret)}`);', + ), ); }); @@ -179,7 +212,7 @@ export async function cliTest(_fixtures) { "--world-name", "test:flavorful/flavorful", "-o", - outDir + outDir, ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/flavorful.d.ts`, "utf8"); @@ -198,10 +231,13 @@ export async function cliTest(_fixtures) { "--feature", "enable-c", "-o", - outDir + outDir, ); strictEqual(stderr, ""); - const source = await readFile(`${outDir}/interfaces/test-feature-gates-unstable-foo.d.ts`, "utf8"); + const source = await readFile( + `${outDir}/interfaces/test-feature-gates-unstable-foo.d.ts`, + "utf8", + ); ok(source.includes("export function a(): void;")); ok(!source.includes("export function b(): void;")); ok(source.includes("export function c(): void;")); @@ -216,10 +252,13 @@ export async function cliTest(_fixtures) { "test:feature-gates-unstable/gated", "--all-features", "-o", - outDir + outDir, ); strictEqual(stderr, ""); - const source = await readFile(`${outDir}/interfaces/test-feature-gates-unstable-foo.d.ts`, "utf8"); + const source = await readFile( + `${outDir}/interfaces/test-feature-gates-unstable-foo.d.ts`, + "utf8", + ); ok(source.includes("export function a(): void;")); ok(source.includes("export function b(): void;")); ok(source.includes("export function c(): void;")); @@ -237,10 +276,13 @@ export async function cliTest(_fixtures) { "--feature", "enable-c", "-o", - outDir + outDir, ); strictEqual(stderr, ""); - const source = await readFile(`${outDir}/interfaces/test-feature-gates-unstable-foo.d.ts`, "utf8"); + const source = await readFile( + `${outDir}/interfaces/test-feature-gates-unstable-foo.d.ts`, + "utf8", + ); ok(source.includes("export function a(): void;")); ok(source.includes("export function b(): void;")); ok(source.includes("export function c(): void;")); @@ -258,6 +300,11 @@ export async function cliTest(_fixtures) { ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/interfaces/test-flavorful-test.d.ts`, "utf8"); + // NOTE: generation of guest types *no longer* produces an explicitly exported module + // but rather contains an typescript ambient module (w/ opt-in for producing explicit + // module declarations if necessary) + // + // see: https://github.com/bytecodealliance/jco/pull/528 ok(source.includes("declare module 'test:flavorful/test' {")); }); @@ -268,7 +315,7 @@ export async function cliTest(_fixtures) { `test/fixtures/wit/deps/ts-check/ts-check.wit`, "--stub", "-o", - outDir + outDir, ); strictEqual(stderr, ""); { @@ -278,7 +325,7 @@ export async function cliTest(_fixtures) { } { const source = await readFile( - `${outDir}/interfaces/ts-naming-blah.d.ts` + `${outDir}/interfaces/ts-naming-blah.d.ts`, ); ok(source.toString().includes("declare function _class(): void")); ok(source.toString().includes("export { _class as class }")); @@ -300,7 +347,7 @@ export async function cliTest(_fixtures) { "--js", "--base64-cutoff=0", "-o", - outDir + outDir, ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/${name}.js`, "utf8"); @@ -320,7 +367,7 @@ export async function cliTest(_fixtures) { "--name", name, "-o", - outDir + outDir, ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/${name}.js`); @@ -341,7 +388,7 @@ export async function cliTest(_fixtures) { "--name", name, "-o", - outDir + outDir, ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/${name}.js`); @@ -354,14 +401,14 @@ export async function cliTest(_fixtures) { test("Optimize", async () => { const component = await readFile( - `test/fixtures/components/flavorful.component.wasm` + `test/fixtures/components/flavorful.component.wasm`, ); const { stderr, stdout } = await exec( jcoPath, "opt", `test/fixtures/components/flavorful.component.wasm`, "-o", - outFile + outFile, ); strictEqual(stderr, ""); ok(stdout.includes("Core Module 1:")); @@ -408,7 +455,7 @@ export async function cliTest(_fixtures) { const { stderr, stdout } = await exec( jcoPath, "print", - `test/fixtures/components/flavorful.component.wasm` + `test/fixtures/components/flavorful.component.wasm`, ); strictEqual(stderr, ""); strictEqual(stdout.slice(0, 10), "(component"); @@ -418,7 +465,7 @@ export async function cliTest(_fixtures) { "print", `test/fixtures/components/flavorful.component.wasm`, "-o", - outFile + outFile, ); strictEqual(stderr, ""); strictEqual(stdout, ""); @@ -429,7 +476,7 @@ export async function cliTest(_fixtures) { "parse", outFile, "-o", - outFile + outFile, ); strictEqual(stderr, ""); strictEqual(stdout, ""); @@ -444,7 +491,7 @@ export async function cliTest(_fixtures) { `test/fixtures/wit/deps/app/app.wit`, "-o", outDir, - "--stub" + "--stub", ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/app.js`); @@ -455,7 +502,7 @@ export async function cliTest(_fixtures) { const { stderr, stdout } = await exec( jcoPath, "wit", - `test/fixtures/components/flavorful.component.wasm` + `test/fixtures/components/flavorful.component.wasm`, ); strictEqual(stderr, ""); ok(stdout.includes("world root {")); @@ -472,7 +519,7 @@ export async function cliTest(_fixtures) { "-m", "processed-by=dummy-gen@test", "-o", - outFile + outFile, ); strictEqual(stderr, ""); strictEqual(stdout, ""); @@ -489,7 +536,7 @@ export async function cliTest(_fixtures) { "new", outFile, "-o", - outFile + outFile, ); strictEqual(stderr, ""); strictEqual(stdout, ""); @@ -504,10 +551,17 @@ export async function cliTest(_fixtures) { jcoPath, "metadata-show", outFile, - "--json" + "--json", ); strictEqual(stderr, ""); const meta = JSON.parse(stdout); + // NOTE: the check below is depends on *how many* modules *and* components are + // generated by wit-component (as used by the wasm-tools rust dep in this project) + // and componentize-js. + // + // As such, this is subject to optimizations or changes in operation of + // upstream functionality and may change with upstream releases -- for example + // the addition of a "glue" or redirection-heavy module/component deepStrictEqual(meta[0].metaType, { tag: "component", val: 5 }); deepStrictEqual(meta[1].producers, [ [ @@ -529,7 +583,7 @@ export async function cliTest(_fixtures) { "test/fixtures/modules/exitcode.wasm", "--wasi-reactor", "-o", - outFile + outFile, ); strictEqual(stderr, ""); { @@ -544,7 +598,7 @@ export async function cliTest(_fixtures) { jcoPath, "metadata-show", "test/fixtures/modules/exitcode.wasm", - "--json" + "--json", ); strictEqual(stderr, ""); deepStrictEqual(JSON.parse(stdout), [ @@ -557,7 +611,7 @@ export async function cliTest(_fixtures) { }); test("Componentize", async () => { - const args = [ + const { stdout, stderr } = await exec( jcoPath, "componentize", "test/fixtures/componentize/source.js", @@ -567,13 +621,8 @@ export async function cliTest(_fixtures) { "-w", "test/fixtures/componentize/source.wit", "-o", - outFile - ]; - if (env.WEVAL_BIN_PATH) { - args.push("--weval-bin", env.WEVAL_BIN_PATH); - } - - const { stdout, stderr } = await exec(...args); + outFile, + ); strictEqual(stderr, ""); { const { stderr } = await exec( @@ -585,13 +634,13 @@ export async function cliTest(_fixtures) { "--map", "local:test/foo=./foo.js", "-o", - outDir + outDir, ); strictEqual(stderr, ""); } await writeFile( `${outDir}/package.json`, - JSON.stringify({ type: "module" }) + JSON.stringify({ type: "module" }), ); await writeFile(`${outDir}/foo.js`, `export class Bar {}`); const m = await import(`${pathToFileURL(outDir)}/componentize.js`); @@ -600,3 +649,32 @@ export async function cliTest(_fixtures) { }); }); } + +// Cache of componentize byte outputs +const CACHE_COMPONENTIZE_OUTPUT = {}; + +/** + * Small cache for componentizations to save build time by storing componentize + * output in memory + * + * @param {string} outputPath - path to where to write the component + * @param {string[]} args - arguments to be fed to `jco componentize` (*without* "compnentize" or "-o/--output") + */ +async function cachedComponentize(outputPath, args) { + const cacheKey = args.join("+"); + if (cacheKey in CACHE_COMPONENTIZE_OUTPUT) { + await writeFile(outputPath, CACHE_COMPONENTIZE_OUTPUT[cacheKey]); + return; + } + + const { stdout, stderr } = await exec( + jcoPath, + "componentize", + ...args, + "-o", + outputPath, + ); + strictEqual(stderr, ""); + + CACHE_COMPONENTIZE_OUTPUT[cacheKey] = await readFile(outputPath); +} diff --git a/test/fixtures/browser/test-pages/something__test.async.html b/test/fixtures/browser/test-pages/something__test.async.html new file mode 100644 index 000000000..6da546a3d --- /dev/null +++ b/test/fixtures/browser/test-pages/something__test.async.html @@ -0,0 +1,100 @@ + + + + diff --git a/test/fixtures/components/async_call.component.wasm b/test/fixtures/components/async_call.component.wasm new file mode 100644 index 000000000..a52483904 Binary files /dev/null and b/test/fixtures/components/async_call.component.wasm differ diff --git a/test/helpers.js b/test/helpers.js index c3236fba4..f4007a7e7 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -1,11 +1,62 @@ -import { tmpdir } from "node:os"; +import { version, env, argv, execArgv } from "node:process"; +import { createServer as createNetServer } from "node:net"; +import { createServer as createHttpServer } from "node:http"; +import { + basename, + join, + isAbsolute, + resolve, + normalize, + sep, + relative, + dirname, + extname, +} from "node:path"; +import { + cp, + mkdtemp, + writeFile, + stat, + mkdir, + readFile, +} from "node:fs/promises"; +import { ok, strictEqual } from "node:assert"; import { spawn } from "node:child_process"; -import { argv, execArgv } from "node:process"; -import { normalize, sep } from "node:path"; -import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; + +import mime from "mime"; +import { pathToFileURL } from "url"; +import { transpile } from "../src/api.js"; +import { componentize } from "../src/cmd/componentize.js"; + +// Path to the jco binary export const jcoPath = "src/jco.js"; +// Simple debug logging for tests +export function log(args, ..._rest) { + if (!env.TEST_DEBUG) { + return; + } + if (typeof args === "string") { + args = { msg: args }; + } + if (typeof args !== "object") { + return; + } + if (args.extra || _rest.length > 0) { + console.log(`[${args.level || "debug"}] ${args.msg}`, { + ...args.extra, + _rest, + }); + } else { + console.log(`[${args.level || "debug"}] ${args.msg}`); + } +} + +// Execute a NodeJS script +// +// Note: argv[0] is expected to be `node` (or some incantation that spawned this process) export async function exec(cmd, ...args) { let stdout = "", stderr = ""; @@ -44,3 +95,455 @@ export async function exec(cmd, ...args) { export async function getTmpDir() { return await mkdtemp(normalize(tmpdir() + sep)); } + +/** + * Set up an async test to be run + * + * @param {object} args - Arguments for running the async test + * @param {function} args.testFn - Arguments for running the async test + * @param {object} args.jco - JCO-related confguration for running the async test + * @param {string} [args.jcoBinPath] - path to the jco binary (or a JS script) + * @param {object} [args.transpile] - configuration related to transpilation + * @param {string[]} [args.transpile.extraArgs] - arguments to pass along to jco transpilation + * @param {object} args.component - configuration for an existing component that should be transpiled + * @param {string} args.component.name - name of the component + * @param {string} args.component.path - path to the WebAssembly binary for the existing component + * @param {object[]} args.component.import - imports that should be provided to the module at instantiation time + * @param {object} args.component.build - configuration for building an ephemeral component to be tested + * @param {object} args.component.js.source - Javascript source code for a component + * @param {object} args.component.wit.source - WIT definitions (inlined) for a component + * @param {object[]} args.component.wit.deps - Dependencies (ex. WASI) that should be included during component build + */ +export async function setupAsyncTest(args) { + const { asyncMode, testFn, jco, component } = args; + const jcoBinPath = jco?.binPath || jcoPath; + + let componentName = component.name; + let componentPath = component.path; + let componentImports = component.imports; + + if (component.path && component.build) { + throw new Error( + "Both component.path and component.build should not be specified at the same time", + ); + } + + // If this component should be built "just in time" -- i.e. created when this test is run + let componentBuildCleanup; + if (component.build) { + // Optionally use a custom pre-optimized StarlingMonkey engine + if (env.TEST_CUSTOM_ENGINE_JIT_PATH || env.TEST_CUSTOM_ENGINE_AOT_PATH) { + log("detected custom engine JIT path"); + if (component.build.componentizeOpts?.aot) { + log("detected AOT config"); + component.build.engine = env.TEST_CUSTOM_ENGINE_AOT_PATH; + } else { + log("detected JIT config"); + component.build.engine = env.TEST_CUSTOM_ENGINE_JIT_PATH; + } + } + + // Build the component + const { name, path, cleanup } = await buildComponent({ + name: componentName, + ...component.build, + }); + + componentBuildCleanup = cleanup; + componentName = name; + componentPath = path; + } + + if (!componentName) { + throw new Error("invalid/missing component name"); + } + if (!componentPath) { + throw new Error("invalid/missing component path"); + } + + // Use either a temporary directory or an subfolder in an existing directory, + // creating it if it doesn't already exist + const outputDir = component.outputDir + ? component.outputDir + : await getTmpDir(); + + // Build out the whole-test cleanup function + let cleanup = async () => { + log("[cleanup] cleaning up component..."); + if (componentBuildCleanup) { + try { + await componentBuildCleanup(); + } catch {} + } + try { + await rm(outputDir, { recursive: true }); + } catch {} + }; + + // Return early if the test was intended to run on JSPI but JSPI is not enabled + if (asyncMode == "jspi" && typeof WebAssembly?.Suspending !== "function") { + let nodeMajorVersion = parseInt(version.replace("v","").split(".")[0]); + if (nodeMajorVersion < 23) { + throw new Error("NodeJS versions <23 does not support JSPI integration, please use a NodeJS version >=23"); + } + await cleanup(); + throw new Error( + "JSPI async type skipped, but JSPI was not enabled -- please ensure test is run from an environment with JSPI integration (ex. node with the --experimental-wasm-jspi flag)", + ); + } + + // Build a directory for the transpiled component output to be put in + // (possibly inside the passed in outputDir) + const moduleOutputDir = join(outputDir, component.name); + try { + await stat(moduleOutputDir); + } catch (err) { + if (err && err.code && err.code === "ENOENT") { + await mkdir(moduleOutputDir); + } + } + + const transpileOpts = { + name: componentName, + minify: true, + validLiftingOptimization: true, + tlaCompat: true, + optimize: false, + base64Cutoff: 0, + instantiation: "async", + asyncMode, + wasiShim: true, + outDir: moduleOutputDir, + ...(jco?.transpile?.extraArgs || {}), + }; + + const componentBytes = await readFile(componentPath); + + // Perform transpilation, write out files + const { files } = await transpile(componentBytes, transpileOpts); + await Promise.all( + Object.entries(files).map(async ([name, file]) => { + await mkdir(dirname(name), { recursive: true }); + await writeFile(name, file); + }), + ); + + // Write a minimal package.json + await writeFile( + `${moduleOutputDir}/package.json`, + JSON.stringify({ type: "module" }), + ); + + // TODO: DEBUG module import not working, file is missing! + // log("WROTE EVERYTHING:", moduleOutputDir); + // await new Promise(resolve => setTimeout(resolve, 60_000)); + + // Import the transpiled JS + const esModuleOutputPath = join(moduleOutputDir, `${componentName}.js`); + const esModuleSourcePathURL = pathToFileURL(esModuleOutputPath); + const module = await import(esModuleSourcePathURL); + + // TODO: DEBUG module import not working, file is missing! + // log("PRE INSTANTIATION", { moduleOutputDir }); + // await new Promise(resolve => setTimeout(resolve, 60_000_000)); + + // Optionally instantiate the module + // + // It's useful to be able to skip instantiation of the instantiation should happen + // elsewhere (ex. in a browser window) + let instance = null; + if (!component.skipInstantiation) { + instance = await module.instantiate(undefined, componentImports || {}); + } + + return { + module, + esModuleOutputPath, + esModuleSourcePathURL, + esModuleRelativeSourcePath: relative(outputDir, esModuleOutputPath), + instance, + cleanup, + outputDir, + component: { + name: componentName, + path: componentPath, + }, + }; +} + +/** + * Helper method for building a component just in time (e.g. to use in a test) + * + */ +export async function buildComponent(args) { + if (!args) { + throw new Error("missing args"); + } + const name = args.name; + const jsSource = args.js?.source; + const witDeps = args.wit?.deps; + const witSource = args.wit?.source; + const witWorld = args.wit?.world; + if (!name) { + throw new Error( + "invalid/missing component name for in-test component build", + ); + } + if (!jsSource) { + throw new Error("invalid/missing source for in-test component build"); + } + if (!witSource) { + throw new Error("invalid/missing WIT for in-test component build"); + } + if (!witWorld) { + throw new Error("invalid/missing WIT world for in-test component build"); + } + + // Create temporary output directory + const outputDir = await getTmpDir(); + + // Write the component's JS and WIT + const jsSourcePath = join(outputDir, "component.js"); + const witOutputPath = join(outputDir, "wit"); + await mkdir(join(witOutputPath, "deps"), { recursive: true }); + const witSourcePath = join(witOutputPath, "component.wit"); + + // Write the appropriate + await Promise.all([ + await writeFile(jsSourcePath, jsSource), + await writeFile(witSourcePath, witSource), + ]); + + // Copy in additional WIT dependency files if provided + if (witDeps) { + for (const dep of witDeps) { + if (!dep.srcPath) { + throw new Error("Invalid wit dep object, missing srcPath"); + } + if (!isAbsolute(dep.srcPath)) { + throw new Error("Only absolute source paths are allowed"); + } + if (dep.destPath && isAbsolute(dep.destPath)) { + throw new Error( + "Only relative dest paths are allowed (into the wit/deps directory)", + ); + } + + const srcFileStats = await stat(dep.srcPath); + const destPath = + dep.destPath || (srcFileStats.isFile() ? basename(dep.srcPath) : "."); + const outputPath = resolve(`${outputDir}/wit/deps/${destPath}`); + + if (srcFileStats.isFile()) { + await writeFile(outputPath, await readFile(dep.srcPath)); + } else if (srcFileStats.isDirectory()) { + await cp(dep.srcPath, outputPath, { recursive: true }); + } else { + throw new Error( + "unrecognized file type for WIT dep, neither file nor directory", + ); + } + } + } + + // Build the output path to which we should write + const outputWasmPath = join(outputDir, "component.wasm"); + + // Build options for componentizing + const wit = witDeps ? witOutputPath : witSourcePath; + const options = { + sourceName: "component", + // If there were wit deps specified, we should use the whole wit dir + // otherwise we can use just the single WIT source file + wit, + worldName: witWorld, + out: outputWasmPath, + quiet: true, + // Add in optional raw options object to componentize + ...(args.componentizeOpts || {}), + }; + + // Use a custom engine if specified + if (args.engine) { + const enginePath = resolve(args.engine); + const engine = await stat(enginePath); + if (engine.isFile()) { + options.engine = enginePath; + } + } + + // Perform componentization + await componentize(jsSourcePath, options); + + return { + name, + path: outputWasmPath, + cleanup: async () => { + try { + await rm(outputDir); + } catch {} + }, + }; +} + +/** + * Load a browser page, usually triggering test output that is written + * to the HTML body of the page + * + * @param {object} args + * @param {object} args.browser - Puppeteer browser instance + * @param {object} [args.path] - Path to the HTML file to use, with root at `test` (ex. `test/browser.html` would be just `browser.html`) + * @param {string} args.hash - Hash at which to perform tests (used to identify specific tests) + */ +export async function loadTestPage(args) { + const { browser, hash } = args; + if (!browser) { + throw new Error("missing puppeteer instance browser object"); + } + if (!hash) { + throw new Error("missing hash for browser page"); + } + + const page = await browser.newPage(); + + // Pass along all output to test + if (env.TEST_DEBUG) { + page + .on("console", (message) => + log( + `[browser] ${message.type().substr(0, 3).toUpperCase()} ${message.text()}`, + ), + ) + .on("pageerror", ({ message }) => log(`[browser] ${message}`)) + .on("response", (response) => + log(`[browser] ${response.status()} ${response.url()}`), + ) + .on("requestfailed", (request) => + log(`[browser] ${request.failure().errorText} ${request.url()}`), + ); + } + + const path = args.path ? args.path : "test/browser.html"; + const serverPort = args.serverPort ? args.serverPort : 8080; + + const hashURL = `http://localhost:${serverPort}/${path}#${hash}`; + log(`[browser] attempting to navigate to [${hashURL}]`); + const hashTest = await page.goto(hashURL); + ok(hashTest.ok(), `navigated to URL [${hashURL}]`); + + const body = await page.locator("body").waitHandle(); + + let bodyHTML = await body.evaluate((el) => el.innerHTML); + // If the body HTML uses "Running" to show state, wait until it changes + if (bodyHTML == "

Running

") { + while (bodyHTML === "

Running

") { + bodyHTML = await body.evaluate((el) => el.innerHTML); + } + } + + // Attempt to parse the HTML body content as JSON + const raw = bodyHTML; + let testOutputJSON; + try { + testOutputJSON = JSON.parse(raw); + } catch (err) { + log(`failed to parse JSON for body HTML: ${err}`); + } + + return { + page, + body, + output: { + raw, + json: testOutputJSON, + }, + }; +} + +// Utility function for getting a random port +export async function getRandomPort() { + return await new Promise((resolve) => { + const server = createNetServer(); + server.listen(0, function () { + const port = this.address().port; + server.on("close", () => resolve(port)); + server.close(); + }); + }); +} + +/** + * Start a web server that serves components and related files from a + * given directory. + * + * @param {{ servePaths: { basePath: string, urlPrefix: string }[] }} args + * @returns {Promise<{ serverPort: number, server: object }>} + */ +export async function startTestWebServer(args) { + if (!args.routes) { throw new Error("missing serve paths"); } + const serverPort = await getRandomPort(); + + const server = createHttpServer(async (req, res) => { + // Build a utility fucntion for returning an error + const returnError = (e) => { + log(`[webserver] failed to find file [${fileURL}]`); + res.writeHead(404); + res.end(e.message); + }; + + // Find route to serve incoming request + const route = args.routes.find(dir => { + return !dir.urlPrefix || (dir.urlPrefix && req.url.startsWith(dir.urlPrefix)); + }); + if (!route) { + log(`[webserver] failed to find route to serve [${req.url.path}]`); + returnError(new Error(`failed to resolve url [${req.url}] with any provided routes`)); + return; + } + if (!route.basePathURL) { throw new Error("invalid/missing path in specified route"); } + + const fileURL = new URL( + `./${req.url.slice(route.urlPrefix ? route.urlPrefix.length : "")}`, + route.basePathURL, + ); + + log(`[webserver] attempting to read file on disk @ [${fileURL}]`); + + // Attempt to read the file + try { + const html = await readFile(fileURL); + res.writeHead(200, { + "content-type": mime.getType(extname(req.url)), + }); + res.end(html); + log(`[webserver] served file [${fileURL}]`); + } catch (e) { + if (e.code === "ENOENT") { + returnError(e); + } else { + log(`[webserver] ERROR [${e}]`); + res.writeHead(500); + res.end(e.message); + } + } + }); + + const served = new Promise(resolve => { + server.on('listening', () => { + resolve({ + serverPort, + server, + cleanup: async () => { + log("[cleanup] cleaning up http server..."); + server.close(() => { + log("server successfully closed"); + }); + } + }); + }); + }); + + server.listen(serverPort); + + return await served; +} diff --git a/test/test.js b/test/test.js index b08589014..79a275a63 100644 --- a/test/test.js +++ b/test/test.js @@ -25,6 +25,8 @@ const componentFixtures = env.COMPONENT_FIXTURES ) .map((f) => f.name); +import { asyncBrowserTest } from './async.browser.js'; +import { asyncTest } from './async.js'; import { browserTest } from './browser.js'; import { codegenTest } from './codegen.js'; import { runtimeTest } from './runtime.js'; @@ -43,6 +45,8 @@ await commandsTest(); await apiTest(componentFixtures); await cliTest(componentFixtures); await witTest(); +await asyncTest(); +await asyncBrowserTest(); if (platform !== 'win32') await browserTest(); diff --git a/xtask/src/build/jco.rs b/xtask/src/build/jco.rs index cdd53437e..bae63ac37 100644 --- a/xtask/src/build/jco.rs +++ b/xtask/src/build/jco.rs @@ -85,6 +85,7 @@ fn transpile(component_path: &str, name: String, optimize: bool) -> Result<()> { multi_memory: true, import_bindings: Some(BindingsMode::Js), guest: false, + async_mode: None, }; let transpiled = js_component_bindgen::transpile(&adapted_component, opts)?; diff --git a/xtask/src/generate/wasi_types.rs b/xtask/src/generate/wasi_types.rs index 12b34fa28..ab170d640 100644 --- a/xtask/src/generate/wasi_types.rs +++ b/xtask/src/generate/wasi_types.rs @@ -39,6 +39,7 @@ pub(crate) fn run() -> Result<()> { multi_memory: false, import_bindings: Some(BindingsMode::Js), guest: false, + async_mode: None, }; let files = generate_types(name, resolve, world, opts)?;