From e8de9d41c5960d093cf96547ccd4953519471f80 Mon Sep 17 00:00:00 2001 From: Calvin Prewitt Date: Tue, 28 Jan 2025 14:37:54 -0600 Subject: [PATCH 1/5] JSPI and Asyncify --- .../js-component-bindgen-component/src/lib.rs | 16 + .../wit/js-component-bindgen.wit | 23 +- .../src/function_bindgen.rs | 18 +- crates/js-component-bindgen/src/intrinsics.rs | 132 +++++ crates/js-component-bindgen/src/lib.rs | 2 +- .../src/transpile_bindgen.rs | 173 +++++- crates/js-component-bindgen/src/ts_bindgen.rs | 96 +++- docs/src/transpiling.md | 4 + package-lock.json | 152 +++-- package.json | 2 +- src/cmd/opt.js | 52 +- src/cmd/transpile.js | 80 ++- src/jco.js | 10 + test/async.browser.js | 227 ++++++++ test/async.js | 143 +++++ test/cli.js | 218 ++++++-- .../test-pages/something__test.async.html | 100 ++++ .../components/async_call.component.wasm | Bin 0 -> 21690 bytes test/helpers.js | 518 +++++++++++++++++- test/test.js | 4 + xtask/src/build/jco.rs | 1 + xtask/src/generate/wasi_types.rs | 1 + 22 files changed, 1756 insertions(+), 216 deletions(-) create mode 100644 test/async.browser.js create mode 100644 test/async.js create mode 100644 test/fixtures/browser/test-pages/something__test.async.html create mode 100644 test/fixtures/components/async_call.component.wasm diff --git a/crates/js-component-bindgen-component/src/lib.rs b/crates/js-component-bindgen-component/src/lib.rs index 9535ad2e8..f7b7537ff 100644 --- a/crates/js-component-bindgen-component/src/lib.rs +++ b/crates/js-component-bindgen-component/src/lib.rs @@ -53,6 +53,20 @@ 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 } + } + AsyncMode::Asyncify(AsyncImportsExports { imports, exports }) => { + js_component_bindgen::AsyncMode::Asyncify { imports, exports } + } + } + } +} + struct JsComponentBindgenComponent; export!(JsComponentBindgenComponent); @@ -76,6 +90,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 +177,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..61fcc0e8b 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,31 @@ 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) or Asyncify. + 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), + /// use Asyncify + asyncify(async-imports-exports), } variant wit { @@ -96,6 +114,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) or Asyncify. + 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..456d15c9e 100644 --- a/crates/js-component-bindgen/src/intrinsics.rs +++ b/crates/js-component-bindgen/src/intrinsics.rs @@ -5,6 +5,10 @@ use std::fmt::Write; #[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)] pub enum Intrinsic { + AsyncifyAsyncInstantiate, + AsyncifySyncInstantiate, + AsyncifyWrapExport, + AsyncifyWrapImport, Base64Compile, ClampGuest, ComponentError, @@ -23,6 +27,7 @@ pub enum Intrinsic { HasOwnProperty, I32ToF32, I64ToF64, + Imports, InstantiateCore, IsLE, ResourceTableFlag, @@ -114,6 +119,117 @@ pub fn render_intrinsics( for i in intrinsics.iter() { match i { + Intrinsic::AsyncifyAsyncInstantiate => output.push_str(" + const asyncifyModules = []; + let asyncifyPromise; + let asyncifyResolved; + async function asyncifyInstantiate(module, imports) { + const instance = await instantiateCore(module, imports); + const memory = instance.exports.memory || (imports && imports.env && imports.env.memory); + const realloc = instance.exports.cabi_realloc || instance.exports.cabi_export_realloc; + if (instance.exports.asyncify_get_state && memory) { + let address; + if (realloc) { + address = realloc(0, 0, 4, 1024); + new Int32Array(memory.buffer, address).set([address + 8, address + 1024]); + } else { + address = 16; + new Int32Array(memory.buffer, address).set([address + 8, address + 1024]); + } + asyncifyModules.push({ instance, memory, address }); + } + return instance; + } + function asyncifyState() { + return asyncifyModules[0]?.instance.exports.asyncify_get_state(); + } + function asyncifyAssertNoneState() { + let state = asyncifyState(); + if (state !== 0) { + throw new Error(`reentrancy not supported, expected asyncify state '0' but found '${state}'`); + } + } + "), + + Intrinsic::AsyncifySyncInstantiate => output.push_str(" + const asyncifyModules = []; + let asyncifyPromise; + let asyncifyResolved; + function asyncifyInstantiate(module, imports) { + const instance = instantiateCore(module, imports); + const memory = instance.exports.memory || (imports && imports.env && imports.env.memory); + const realloc = instance.exports.cabi_realloc || instance.exports.cabi_export_realloc; + if (instance.exports.asyncify_get_state && memory) { + let address; + if (realloc) { + address = realloc(0, 0, 4, 1024); + new Int32Array(memory.buffer, address).set([address + 8, address + 1024]); + } else { + address = 16; + new Int32Array(memory.buffer, address).set([address + 8, address + 1024]); + } + asyncifyModules.push({ instance, memory, address }); + } + return instance; + } + function asyncifyState() { + return asyncifyModules[0]?.instance.exports.asyncify_get_state(); + } + function asyncifyAssertNoneState() { + let state = asyncifyState(); + if (state !== 0) { + throw new Error(`reentrancy not supported, expected asyncify state '0' but found '${state}'`); + } + } + "), + + Intrinsic::AsyncifyWrapExport => output.push_str(" + function asyncifyWrapExport(fn) { + return async (...args) => { + if (asyncifyModules.length === 0) { + throw new Error(`none of the Wasm modules were processed with wasm-opt asyncify`); + } + asyncifyAssertNoneState(); + let result = fn(...args); + while (asyncifyState() === 1) { + asyncifyModules.forEach(({ instance }) => { + instance.exports.asyncify_stop_unwind(); + }); + asyncifyResolved = await asyncifyPromise; + asyncifyPromise = undefined; + asyncifyAssertNoneState(); + asyncifyModules.forEach(({ instance, address }) => { + instance.exports.asyncify_start_rewind(address); + }); + result = fn(...args); + } + asyncifyAssertNoneState(); + return result; + }; + } + "), + + Intrinsic::AsyncifyWrapImport => output.push_str(" + function asyncifyWrapImport(fn) { + return (...args) => { + if (asyncifyState() === 2) { + asyncifyModules.forEach(({ instance }) => { + instance.exports.asyncify_stop_rewind(); + }); + const ret = asyncifyResolved; + asyncifyResolved = undefined; + return ret; + } + asyncifyAssertNoneState(); + let value = fn(...args); + asyncifyModules.forEach(({ instance, address }) => { + instance.exports.asyncify_start_unwind(address); + }); + asyncifyPromise = value; + }; + } + "), + Intrinsic::Base64Compile => if !no_nodejs_compat { output.push_str(" const base64Compile = str => WebAssembly.compile(typeof Buffer !== 'undefined' ? Buffer.from(str, 'base64') : Uint8Array.from(atob(str), b => b.charCodeAt(0))); @@ -285,6 +401,8 @@ pub fn render_intrinsics( const i64ToF64 = i => (i64ToF64I[0] = i, i64ToF64F[0]); "), + Intrinsic::Imports => {}, + Intrinsic::InstantiateCore => if !instantiation { output.push_str(" const instantiateCore = WebAssembly.instantiate; @@ -654,6 +772,14 @@ impl Intrinsic { pub fn get_global_names() -> &'static [&'static str] { &[ // Intrinsic list exactly as below + "asyncifyAssertNoneState", + "asyncifyInstantiate", + "asyncifyModules", + "asyncifyPromise", + "asyncifyResolved", + "asyncifyState", + "asyncifyWrapExport", + "asyncifyWrapImport", "base64Compile", "clampGuest", "ComponentError", @@ -671,6 +797,7 @@ impl Intrinsic { "hasOwnProperty", "i32ToF32", "i64ToF64", + "imports", "instantiateCore", "isLE", "resourceCallBorrows", @@ -733,6 +860,10 @@ impl Intrinsic { pub fn name(&self) -> &'static str { match self { + Intrinsic::AsyncifyAsyncInstantiate => "asyncifyInstantiate", + Intrinsic::AsyncifySyncInstantiate => "asyncifyInstantiate", + Intrinsic::AsyncifyWrapExport => "asyncifyWrapExport", + Intrinsic::AsyncifyWrapImport => "asyncifyWrapImport", Intrinsic::Base64Compile => "base64Compile", Intrinsic::ClampGuest => "clampGuest", Intrinsic::ComponentError => "ComponentError", @@ -751,6 +882,7 @@ impl Intrinsic { Intrinsic::HasOwnProperty => "hasOwnProperty", Intrinsic::I32ToF32 => "i32ToF32", Intrinsic::I64ToF64 => "i64ToF64", + Intrinsic::Imports => "imports", Intrinsic::InstantiateCore => "instantiateCore", Intrinsic::IsLE => "isLE", Intrinsic::ResourceCallBorrows => "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..35788d8e5 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,23 @@ 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) or Asyncify. + pub async_mode: Option, +} + +#[derive(Default, Clone, Debug)] +pub enum AsyncMode { + #[default] + Sync, + JavaScriptPromiseIntegration { + imports: Vec, + exports: Vec, + }, + Asyncify { + imports: Vec, + exports: Vec, + }, } #[derive(Default, Clone, Debug)] @@ -117,6 +134,12 @@ 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)>, + + use_asyncify: bool, } #[allow(clippy::too_many_arguments)] @@ -130,6 +153,20 @@ pub fn transpile_bindgen( opts: TranspileOpts, files: &mut Files, ) -> (Vec, Vec<(String, Export)>) { + let (use_asyncify, async_imports, async_exports) = match opts.async_mode.clone() { + None | Some(AsyncMode::Sync) => (false, Default::default(), Default::default()), + Some(AsyncMode::JavaScriptPromiseIntegration { imports, exports }) => ( + false, + imports.into_iter().collect(), + exports.into_iter().collect(), + ), + Some(AsyncMode::Asyncify { imports, exports }) => ( + true, + imports.into_iter().collect(), + exports.into_iter().collect(), + ), + }; + let mut bindgen = JsBindgen { local_names: LocalNames::default(), src: Source::default(), @@ -137,6 +174,8 @@ pub fn transpile_bindgen( core_module_cnt: 0, opts: &opts, all_intrinsics: BTreeSet::new(), + all_core_exported_funcs: Vec::new(), + use_asyncify, }; bindgen .local_names @@ -157,6 +196,9 @@ pub fn transpile_bindgen( translation: component, component: &component.component, types, + use_asyncify, + async_imports, + async_exports, imports: Default::default(), exports: Default::default(), lowering_options: Default::default(), @@ -216,7 +258,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 +268,33 @@ 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(); + + let async_wrap_fn = if self.use_asyncify { + &self.intrinsic(Intrinsic::AsyncifyWrapExport) + } else { + "WebAssembly.promising" + }; + 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} = {async_wrap_fn}({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 +367,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 +436,7 @@ impl<'a> JsBindgen<'a> { let gen = (function* init () {{ {}\ {}\ + {}\ }})(); let promise, resolve, reject; function runNext (value) {{ @@ -396,6 +467,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 +515,9 @@ struct Instantiator<'a, 'b> { /// Instance flags which references have been emitted externally at least once. used_instance_flags: RefCell>, defined_resource_classes: BTreeSet, + use_asyncify: bool, + async_imports: HashSet, + async_exports: HashSet, lowering_options: PrimaryMap, } @@ -1018,7 +1093,12 @@ impl<'a> Instantiator<'a, '_> { uwriteln!( self.src.js_init, "({{ exports: exports{iu32} }} = yield {instantiate}(yield module{}{imports}));", - idx.as_u32() + idx.as_u32(), + instantiate = if self.use_asyncify { + self.gen.intrinsic(Intrinsic::AsyncifyAsyncInstantiate) + } else { + instantiate + }, ) } @@ -1026,7 +1106,12 @@ impl<'a> Instantiator<'a, '_> { uwriteln!( self.src.js_init, "({{ exports: exports{iu32} }} = {instantiate}(module{}{imports}));", - idx.as_u32() + idx.as_u32(), + instantiate = if self.use_asyncify { + self.gen.intrinsic(Intrinsic::AsyncifySyncInstantiate) + } else { + instantiate + }, ) } } @@ -1097,6 +1182,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 +1274,24 @@ 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 { + if self.use_asyncify { + uwrite!( + self.src.js, + "\nconst trampoline{} = {}(async function", + trampoline.as_u32(), + self.gen.intrinsic(Intrinsic::AsyncifyWrapImport), + ); + } else { + 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 +1305,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 +1675,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 +1770,7 @@ impl<'a> Instantiator<'a, '_> { }, src: source::Source::default(), resolve: self.resolve, + is_async, }; abi::call( self.resolve, @@ -1930,14 +2051,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 +2128,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 +2144,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..68998e9c4 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,15 @@ 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()) + } + Some(AsyncMode::Asyncify { imports, exports }) => { + (imports.into_iter().collect(), exports.into_iter().collect()) + } + }; let mut bindgen = TsBindgen { src: Source::default(), interface_names: LocalNames::default(), @@ -64,6 +76,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 +387,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 +407,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 +415,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 +434,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 +448,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 +472,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 +503,7 @@ impl TsBindgen { resolve: &Resolve, id: InterfaceId, files: &mut Files, + is_world_export: bool, ) -> String { let iface = resolve .interfaces @@ -525,6 +552,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 +574,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 +777,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 +802,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 +818,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 +845,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 +884,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 +906,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..2dcddb732 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 two options are `jspi` (JavaScript Promise Integration) and `asyncify` (Binaryen's `wasm-opt --asyncify`). +* `--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/opt.js b/src/cmd/opt.js index 844186390..e6bcfcf87 100644 --- a/src/cmd/opt.js +++ b/src/cmd/opt.js @@ -62,7 +62,7 @@ ${table( /** * * @param {Uint8Array} componentBytes - * @param {{ quiet: boolean, optArgs?: string[], noVerify?: boolean }} options? + * @param {{ quiet: boolean, asyncMode?: string, optArgs?: string[], noVerify?: boolean }} opts? * @returns {Promise<{ component: Uint8Array, compressionInfo: { beforeBytes: number, afterBytes: number }[] >} */ export async function optimizeComponent(componentBytes, opts) { @@ -86,23 +86,26 @@ export async function optimizeComponent(componentBytes, opts) { spinner.text = spinnerText(); } - const optimizedCoreModules = await Promise.all( - coreModules.map(async ([coreModuleStart, coreModuleEnd]) => { - const optimized = wasmOpt( - componentBytes.subarray(coreModuleStart, coreModuleEnd), - opts?.optArgs - ); - if (spinner) { - completed++; - spinner.text = spinnerText(); - } - return optimized; - }) - ); + const args = opts?.optArgs ? [...opts.optArgs] : ['-Oz', '--low-memory-unused', '--enable-bulk-memory', '--strip-debug']; + if (opts?.asyncMode === 'asyncify') args.push('--asyncify'); + + const optimizedCoreModules = await Promise.all(coreModules.map(async ([coreModuleStart, coreModuleEnd]) => { + const optimized = wasmOpt(componentBytes.subarray(coreModuleStart, coreModuleEnd), args); + if (spinner) { + completed++; + spinner.text = spinnerText(); + } + return optimized; + })); + + // With the optional asyncify pass, the size may increase rather than shrink + const previousModulesTotalSize = coreModules.reduce((total, [coreModuleStart, coreModuleEnd]) => total + (coreModuleEnd - coreModuleStart), 0); + const optimizedModulesTotalSize = optimizedCoreModules.reduce((total, buf) => total + buf.byteLength, 0); + const sizeChange = optimizedModulesTotalSize - previousModulesTotalSize; - let outComponentBytes = new Uint8Array(componentBytes.byteLength); - let nextReadPos = 0, - nextWritePos = 0; + // Adds an extra 100 bytes to be safe. Sometimes an extra byte appears to be required. + let outComponentBytes = new Uint8Array(componentBytes.byteLength + sizeChange + 100); + let nextReadPos = 0, nextWritePos = 0; for (let i = 0; i < coreModules.length; i++) { const [coreModuleStart, coreModuleEnd] = coreModules[i]; const optimizedCoreModule = optimizedCoreModules[i]; @@ -134,11 +137,12 @@ export async function optimizeComponent(componentBytes, opts) { } outComponentBytes.set( - componentBytes.subarray(nextReadPos, componentBytes.byteLength), + componentBytes.subarray(nextReadPos), nextWritePos ); nextWritePos += componentBytes.byteLength - nextReadPos; + // truncate to the bytes written outComponentBytes = outComponentBytes.subarray(0, nextWritePos); // verify it still parses ok @@ -165,16 +169,12 @@ export async function optimizeComponent(componentBytes, opts) { } /** - * @param {Uint8Array} source + * @param {Uint8Array} source + * @param {Array} args * @returns {Promise} */ -async function wasmOpt( - source, - args = ["-O1", "--low-memory-unused", "--enable-bulk-memory"] -) { - const wasmOptPath = fileURLToPath( - import.meta.resolve("binaryen/bin/wasm-opt") - ); +async function wasmOpt(source, args) { + const wasmOptPath = fileURLToPath(import.meta.resolve('binaryen/bin/wasm-opt')); try { return await spawnIOTmp(wasmOptPath, source, [...args, "-o"]); diff --git a/src/cmd/transpile.js b/src/cmd/transpile.js index 1282be092..c6185d624 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 DEFAULT_ASYNC_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 DEFAULT_ASYNC_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.defaultAsyncImports) + opts.asyncImports = DEFAULT_ASYNC_IMPORTS.concat(opts.asyncImports || []); + if (opts.defaultAsyncExports) + opts.asyncExports = DEFAULT_ASYNC_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.defaultAsyncImports) + opts.asyncImports = DEFAULT_ASYNC_IMPORTS.concat(opts.asyncImports || []); + if (opts.defaultAsyncExports) + opts.asyncExports = DEFAULT_ASYNC_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, @@ -141,11 +185,13 @@ async function wasm2Js (source) { * js?: bool, * minify?: bool, * optimize?: bool, + * preoptimized?: bool, * namespacedExports?: bool, * outDir?: string, * multiMemory?: bool, * experimentalIdlImports?: bool, * optArgs?: string[], + * wasmOptBinPath?: string, * }} opts * @returns {Promise<{ files: { [filename: string]: Uint8Array }, imports: string[], exports: [string, 'function' | 'instance'][] }>} */ @@ -155,20 +201,27 @@ export async function transpileComponent (component, opts = {}) { let spinner; const showSpinner = getShowSpinner(); - if (opts.optimize) { + + // We must perform optimization if it's requested, or if the component has *not* + // already been built with already optimized code before wizer init, if using asyncify. + // + // Preoptimized is generally used internally (users cannot pass it in as a CLI option), + // but they may pass in asyncMode + if (opts.optimize || !opts.preoptimized && opts.asyncMode === 'asyncify') { if (showSpinner) setShowSpinner(true); ({ component } = await optimizeComponent(component, opts)); } if (opts.wasiShim !== false) { + const maybeAsync = ''; // TODO: !opts.asyncMode || opts.asyncMode === 'sync' ? '' : 'async/'; opts.map = Object.assign({ - 'wasi:cli/*': '@bytecodealliance/preview2-shim/cli#*', - 'wasi:clocks/*': '@bytecodealliance/preview2-shim/clocks#*', - 'wasi:filesystem/*': '@bytecodealliance/preview2-shim/filesystem#*', - 'wasi:http/*': '@bytecodealliance/preview2-shim/http#*', - 'wasi:io/*': '@bytecodealliance/preview2-shim/io#*', - 'wasi:random/*': '@bytecodealliance/preview2-shim/random#*', - 'wasi:sockets/*': '@bytecodealliance/preview2-shim/sockets#*', + 'wasi:cli/*': `@bytecodealliance/preview2-shim/${maybeAsync}cli#*`, + 'wasi:clocks/*': `@bytecodealliance/preview2-shim/${maybeAsync}clocks#*`, + 'wasi:filesystem/*': `@bytecodealliance/preview2-shim/${maybeAsync}filesystem#*`, + 'wasi:http/*': `@bytecodealliance/preview2-shim/${maybeAsync}http#*`, + 'wasi:io/*': `@bytecodealliance/preview2-shim/${maybeAsync}io#*`, + 'wasi:random/*': `@bytecodealliance/preview2-shim/${maybeAsync}random#*`, + 'wasi:sockets/*': `@bytecodealliance/preview2-shim/${maybeAsync}sockets#*`, }, opts.map || {}); } @@ -183,10 +236,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 1d4b72402..a017f31a9 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', 'asyncify']).preset('sync')) + .option('--default-async-imports', 'EXPERIMENTAL: default async component imports from WASI interfaces') + .option('--default-async-exports', 'EXPERIMENTAL: default 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', 'asyncify']).preset('sync')) + .option('--default-async-imports', 'EXPERIMENTAL: default async component imports from WASI interfaces') + .option('--default-async-exports', 'EXPERIMENTAL: default 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..bc1417546 --- /dev/null +++ b/test/async.browser.js @@ -0,0 +1,227 @@ +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 {} + }); + + test("Transpile async (browser, asyncify)", async () => { + const componentName = "async-call"; + const { + instance, + cleanup: componentCleanup, + outputDir, + } = await setupAsyncTest({ + asyncMode: "asyncify", + 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`; + + // 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(); + + // 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(); + }); + + 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..d888c6bf5 --- /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")); + }); + + 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 (NodeJS, Asyncify)", async () => { + const { instance, cleanup } = await setupAsyncTest({ + asyncMode: "asyncify", + 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(); + }); + }); +} diff --git a/test/cli.js b/test/cli.js index 5aae2cdde..7df6e24fa 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,87 @@ 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 with Async Mode for Asyncify", 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=asyncify", + "--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 +203,7 @@ export async function cliTest(_fixtures) { "--minify", "--base64-cutoff=0", "-o", - outDir + outDir, ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/${name}.js`); @@ -154,20 +223,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 +248,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 +267,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 +288,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 +312,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;")); @@ -268,7 +346,7 @@ export async function cliTest(_fixtures) { `test/fixtures/wit/deps/ts-check/ts-check.wit`, "--stub", "-o", - outDir + outDir, ); strictEqual(stderr, ""); { @@ -278,7 +356,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 +378,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 +398,7 @@ export async function cliTest(_fixtures) { "--name", name, "-o", - outDir + outDir, ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/${name}.js`); @@ -341,7 +419,7 @@ export async function cliTest(_fixtures) { "--name", name, "-o", - outDir + outDir, ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/${name}.js`); @@ -354,14 +432,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:")); @@ -373,7 +451,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"); @@ -383,7 +461,7 @@ export async function cliTest(_fixtures) { "print", `test/fixtures/components/flavorful.component.wasm`, "-o", - outFile + outFile, ); strictEqual(stderr, ""); strictEqual(stdout, ""); @@ -394,7 +472,7 @@ export async function cliTest(_fixtures) { "parse", outFile, "-o", - outFile + outFile, ); strictEqual(stderr, ""); strictEqual(stdout, ""); @@ -409,7 +487,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`); @@ -420,7 +498,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 {")); @@ -437,7 +515,7 @@ export async function cliTest(_fixtures) { "-m", "processed-by=dummy-gen@test", "-o", - outFile + outFile, ); strictEqual(stderr, ""); strictEqual(stdout, ""); @@ -454,7 +532,7 @@ export async function cliTest(_fixtures) { "new", outFile, "-o", - outFile + outFile, ); strictEqual(stderr, ""); strictEqual(stdout, ""); @@ -469,7 +547,7 @@ export async function cliTest(_fixtures) { jcoPath, "metadata-show", outFile, - "--json" + "--json", ); strictEqual(stderr, ""); const meta = JSON.parse(stdout); @@ -494,7 +572,7 @@ export async function cliTest(_fixtures) { "test/fixtures/modules/exitcode.wasm", "--wasi-reactor", "-o", - outFile + outFile, ); strictEqual(stderr, ""); { @@ -509,7 +587,7 @@ export async function cliTest(_fixtures) { jcoPath, "metadata-show", "test/fixtures/modules/exitcode.wasm", - "--json" + "--json", ); strictEqual(stderr, ""); deepStrictEqual(JSON.parse(stdout), [ @@ -522,7 +600,7 @@ export async function cliTest(_fixtures) { }); test("Componentize", async () => { - const args = [ + const { stdout, stderr } = await exec( jcoPath, "componentize", "test/fixtures/componentize/source.js", @@ -532,13 +610,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( @@ -550,13 +623,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`); @@ -565,3 +638,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..e056d88b3 --- /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 0000000000000000000000000000000000000000..a52483904a23a4cb7b3ca0507f60ee81b5e8cb7a GIT binary patch literal 21690 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 literal 0 HcmV?d00001 diff --git a/test/helpers.js b/test/helpers.js index c3236fba4..dce5f9aa4 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,462 @@ 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: _asyncMode, testFn, jco, component } = args; + const asyncMode = _asyncMode || "asyncify"; + 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 || {}), + }; + + // If we used a pre-optimized build, then we can set that before transpiling + if (["yes", "true"].includes(env.TEST_CUSTOM_ENGINE_PREOPTIMIZED)) { + log("using preoptimized engine build!"); + transpileOpts.preoptimized = true; + } + + 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)?; From 5fe83ebb3ca2999923af2e1d2e7cb6818425bc44 Mon Sep 17 00:00:00 2001 From: Calvin Prewitt Date: Tue, 28 Jan 2025 17:05:18 -0600 Subject: [PATCH 2/5] skips tests in JSPI when the NodeJS version doesn't support it --- test/async.browser.js | 178 +++++++++++++++++++++--------------------- test/async.js | 58 +++++++------- 2 files changed, 120 insertions(+), 116 deletions(-) diff --git a/test/async.browser.js b/test/async.browser.js index bc1417546..de63b16c0 100644 --- a/test/async.browser.js +++ b/test/async.browser.js @@ -130,98 +130,100 @@ export async function asyncBrowserTest(_fixtures) { await componentCleanup(); }); - 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", + 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"], + 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}`, + }); + 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(); }); - - // 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 index d888c6bf5..0742f69fc 100644 --- a/test/async.js +++ b/test/async.js @@ -68,41 +68,43 @@ export async function asyncTest(_fixtures) { ok(source.toString().includes("export { test")); }); - 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", + 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", - ], + 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 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"); + strictEqual(instance.runSync(), "called sync"); + strictEqual(await instance.runAsync(), "called async"); - await cleanup(); - }); + await cleanup(); + }); + } test("Transpile async (NodeJS, Asyncify)", async () => { const { instance, cleanup } = await setupAsyncTest({ From 9a2014c503555ea69920df830acea8f9b9c3d7b2 Mon Sep 17 00:00:00 2001 From: Victor Adossi <123968127+vados-cosmonic@users.noreply.github.com> Date: Wed, 29 Jan 2025 23:14:12 +0900 Subject: [PATCH 3/5] fix(tests): guest types check (#6) * fix(tests): guest types check Signed-off-by: Victor Adossi * fix: re-comment check from upstream jco Signed-off-by: Victor Adossi --------- Signed-off-by: Victor Adossi --- test/cli.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/test/cli.js b/test/cli.js index 7df6e24fa..93071b6ff 100644 --- a/test/cli.js +++ b/test/cli.js @@ -336,7 +336,12 @@ export async function cliTest(_fixtures) { ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/interfaces/test-flavorful-test.d.ts`, "utf8"); - ok(source.includes("declare module 'test:flavorful/test' {")); + // 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("export namespace TestFlavorfulTest {")); }); test("TypeScript naming checks", async () => { @@ -551,6 +556,13 @@ export async function cliTest(_fixtures) { ); 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, [ [ From 2bb83fd03c1493d5ce6db9b28f078b89b4c05e89 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Wed, 29 Jan 2025 23:44:36 +0900 Subject: [PATCH 4/5] fix(tests): guest-types test Signed-off-by: Victor Adossi --- test/cli.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/cli.js b/test/cli.js index 93071b6ff..e83a36a21 100644 --- a/test/cli.js +++ b/test/cli.js @@ -254,7 +254,7 @@ export async function cliTest(_fixtures) { const source = await readFile(`${outDir}/flavorful.d.ts`, "utf8"); ok(source.includes("export const test")); const iface = await readFile(`${outDir}/interfaces/test-flavorful-test.d.ts`, "utf8"); - ok(iface.includes("export namespace TestFlavorfulTest {")); + ok(source.includes("declare module 'test:flavorful/test' {")); }); test("Type generation (specific features)", async () => { @@ -337,7 +337,7 @@ 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 + // 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 From 4027d11beae2eba17ba9f8f4b21c38c3f317e78a Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 30 Jan 2025 00:16:54 +0900 Subject: [PATCH 5/5] fix(tests): fix iface file check Signed-off-by: Victor Adossi --- test/cli.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/cli.js b/test/cli.js index e83a36a21..86bd9a7fa 100644 --- a/test/cli.js +++ b/test/cli.js @@ -254,7 +254,7 @@ export async function cliTest(_fixtures) { const source = await readFile(`${outDir}/flavorful.d.ts`, "utf8"); ok(source.includes("export const test")); const iface = await readFile(`${outDir}/interfaces/test-flavorful-test.d.ts`, "utf8"); - ok(source.includes("declare module 'test:flavorful/test' {")); + ok(iface.includes("export namespace TestFlavorfulTest {")); }); test("Type generation (specific features)", async () => { @@ -341,7 +341,7 @@ export async function cliTest(_fixtures) { // module declarations if necessary) // // see: https://github.com/bytecodealliance/jco/pull/528 - ok(source.includes("export namespace TestFlavorfulTest {")); + ok(source.includes("declare module 'test:flavorful/test' {")); }); test("TypeScript naming checks", async () => {