diff --git a/crates/js-component-bindgen-component/src/lib.rs b/crates/js-component-bindgen-component/src/lib.rs index e16bd44dc..e3b0d3677 100644 --- a/crates/js-component-bindgen-component/src/lib.rs +++ b/crates/js-component-bindgen-component/src/lib.rs @@ -42,6 +42,19 @@ impl From for js_component_bindgen::InstantiationMode { } } +impl From for js_component_bindgen::StaticWasmSourceImportsMode { + fn from(value: StaticWasmSourceImportsMode) -> Self { + match value { + StaticWasmSourceImportsMode::ProposedStandardImportSource => { + js_component_bindgen::StaticWasmSourceImportsMode::ProposedStandardImportSource + } + StaticWasmSourceImportsMode::NonStandardImport => { + js_component_bindgen::StaticWasmSourceImportsMode::NonStandardImport + } + } + } +} + impl From for js_component_bindgen::BindingsMode { fn from(value: BindingsMode) -> Self { match value { @@ -53,6 +66,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); @@ -64,6 +91,9 @@ impl Guest for JsComponentBindgenComponent { name: options.name, no_typescript: options.no_typescript.unwrap_or(false), instantiation: options.instantiation.map(Into::into), + cache_wasm_compile: options.cache_wasm_compile.unwrap_or(false), + static_wasm_source_imports: options.static_wasm_source_imports.map(Into::into), + esm_imports: options.esm_imports.unwrap_or(false), map: options.map.map(|map| map.into_iter().collect()), no_nodejs_compat: options.no_nodejs_compat.unwrap_or(false), base64_cutoff: options.base64_cutoff.unwrap_or(5000) as usize, @@ -75,6 +105,7 @@ impl Guest for JsComponentBindgenComponent { no_namespaced_exports: options.no_namespaced_exports.unwrap_or(false), multi_memory: options.multi_memory.unwrap_or(false), import_bindings: options.import_bindings.map(Into::into), + async_mode: options.async_mode.map(Into::into), }; let js_component_bindgen::Transpiled { @@ -152,6 +183,9 @@ impl Guest for JsComponentBindgenComponent { no_typescript: false, no_nodejs_compat: false, instantiation: opts.instantiation.map(Into::into), + cache_wasm_compile: false, + static_wasm_source_imports: None, + esm_imports: opts.esm_imports.unwrap_or(false), map: opts.map.map(|map| map.into_iter().collect()), tla_compat: opts.tla_compat.unwrap_or(false), valid_lifting_optimization: false, @@ -160,6 +194,7 @@ impl Guest for JsComponentBindgenComponent { no_namespaced_exports: false, multi_memory: false, import_bindings: None, + 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 631e5deef..b23d39761 100644 --- a/crates/js-component-bindgen-component/wit/js-component-bindgen.wit +++ b/crates/js-component-bindgen-component/wit/js-component-bindgen.wit @@ -9,6 +9,11 @@ world js-component-bindgen { sync, } + variant static-wasm-source-imports-mode { + proposed-standard-import-source, + non-standard-import, + } + variant bindings-mode { js, hybrid, @@ -28,6 +33,18 @@ world js-component-bindgen { /// of the direct importable native ESM output. instantiation: option, + /// If `instantiation` is set, on the first `instantiate` call the compiled + /// Wasm modules are cached for subsequent `instantiate` calls. + cache-wasm-compile: option, + + /// Static import of Wasm module with the proposed standard source phase + /// imports or use the non-standard import syntax. + static-wasm-source-imports: option, + + /// If `instantiation` is set, instead of providing an import object, use + /// ESM imports. + esm-imports: option, + /// Import bindings generation mode import-bindings: option, @@ -62,6 +79,24 @@ world js-component-bindgen { /// 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 { @@ -88,9 +123,15 @@ world js-component-bindgen { %world: option, tla-compat: option, instantiation: option, + /// If `instantiation` is set, instead of providing an import object, use + /// ESM imports. + esm-imports: option, map: option, /// Features that should be enabled as part of feature gating features: 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 3d0687ad7..e2f92976b 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..4dde0e36c 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,139 @@ 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 +423,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 +794,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 +819,7 @@ impl Intrinsic { "hasOwnProperty", "i32ToF32", "i64ToF64", + "imports", "instantiateCore", "isLE", "resourceCallBorrows", @@ -733,6 +882,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 +904,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 587babb79..fe05204a9 100644 --- a/crates/js-component-bindgen/src/lib.rs +++ b/crates/js-component-bindgen/src/lib.rs @@ -8,7 +8,9 @@ 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, StaticWasmSourceImportsMode, 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 085a42f0c..a64d67aea 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}; @@ -43,6 +43,15 @@ pub struct TranspileOpts { /// Provide a custom JS instantiation API for the component instead /// of the direct importable native ESM output. pub instantiation: Option, + /// If `instantiation` is set, on the first `instantiate` call the compiled + /// Wasm modules are cached for subsequent `instantiate` calls. + pub cache_wasm_compile: bool, + /// Static import of Wasm module with the proposed standard source phase + /// imports or use the non-standard import syntax. + pub static_wasm_source_imports: Option, + /// If `instantiation` is set, instead of providing an import object, use + /// ESM imports. + pub esm_imports: bool, /// Configure how import bindings are provided, as high-level JS bindings, /// or as hybrid optimized bindings. pub import_bindings: Option, @@ -68,6 +77,30 @@ pub struct TranspileOpts { /// Whether to output core Wasm utilizing multi-memory or to polyfill /// this handling. pub multi_memory: 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)] +pub enum StaticWasmSourceImportsMode { + #[default] + ProposedStandardImportSource, + NonStandardImport, } #[derive(Default, Clone, Debug)] @@ -115,6 +148,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)] @@ -128,6 +167,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(), @@ -135,6 +188,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 @@ -155,6 +210,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(), @@ -224,17 +282,65 @@ 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 self.opts.static_wasm_source_imports.is_none() + && 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(); + let mut module_cache_declarations = source::Source::default(); for i in 0..self.core_module_cnt { let local_name = format!("module{}", i); let mut name_idx = core_file_name(name, i as u32); - if self.opts.instantiation.is_some() { - uwriteln!( - compilation_promises, - "const {local_name} = getCoreModule('{name_idx}');" - ); + if let Some(mode) = &self.opts.static_wasm_source_imports { + match mode { + StaticWasmSourceImportsMode::ProposedStandardImportSource => { + uwriteln!(output, "import source {local_name} from './{name_idx}';",); + } + StaticWasmSourceImportsMode::NonStandardImport => { + uwriteln!(output, "import {local_name} from './{name_idx}';",); + } + } + } else if self.opts.instantiation.is_some() { + if self.opts.cache_wasm_compile { + let cache_name = self.local_names.create_once(&format!("cached{local_name}")); + uwriteln!(module_cache_declarations, "let {cache_name};"); + uwriteln!( + compilation_promises, + "const {local_name} = {cache_name} || ({cache_name} = getCoreModule('{name_idx}'));" + ); + } else { + uwriteln!( + compilation_promises, + "const {local_name} = getCoreModule('{name_idx}');" + ); + } } else if files.get_size(&name_idx).unwrap() < self.opts.base64_cutoff { assert!(removed.insert(i)); let data = files.remove(&name_idx).unwrap(); @@ -268,14 +374,27 @@ impl<'a> JsBindgen<'a> { ); if let Some(instantiation) = &self.opts.instantiation { + if self.opts.esm_imports { + self.esm_bindgen + .render_imports(&mut output, None, &mut self.local_names); + } + if self.opts.cache_wasm_compile { + uwrite!(output, "\n{}", &module_cache_declarations as &str); + } + uwrite!( output, "\ - export function instantiate(getCoreModule, imports, instantiateCore = {}) {{ + export function instantiate(getCoreModule{}, instantiateCore = {}) {{ {} {} {} ", + if self.opts.esm_imports { + "" + } else { + ", imports = {}" + }, match instantiation { InstantiationMode::Async => "WebAssembly.instantiate", InstantiationMode::Sync => @@ -285,17 +404,22 @@ impl<'a> JsBindgen<'a> { &intrinsic_definitions as &str, &compilation_promises as &str, ); - } - let imports_object = if self.opts.instantiation.is_some() { - Some("imports") + if !self.opts.esm_imports { + let imports_obj = &self.intrinsic(Intrinsic::Imports); + self.esm_bindgen.render_imports( + &mut output, + Some(imports_obj), + &mut self.local_names, + ); + } } else { - None - }; - self.esm_bindgen - .render_imports(&mut output, imports_object, &mut self.local_names); + self.esm_bindgen + .render_imports(&mut output, None, &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(), @@ -364,6 +488,7 @@ impl<'a> JsBindgen<'a> { let gen = (function* init () {{ {}\ {}\ + {}\ }})(); let promise, resolve, reject; function runNext (value) {{ @@ -394,6 +519,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( @@ -441,6 +567,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, } @@ -562,7 +691,7 @@ impl<'a> Instantiator<'a, '_> { if let Some(InstantiationMode::Async) = self.gen.opts.instantiation { // To avoid uncaught promise rejection errors, we attach an intermediate // Promise.all with a rejection handler, if there are multiple promises. - if self.modules.len() > 1 { + if self.modules.len() > 1 && self.gen.opts.static_wasm_source_imports.is_none() { self.src.js_init.push_str("Promise.all(["); for i in 0..self.modules.len() { if i > 0 { @@ -1015,8 +1144,18 @@ impl<'a> Instantiator<'a, '_> { Some(InstantiationMode::Async) | None => { uwriteln!( self.src.js_init, - "({{ exports: exports{iu32} }} = yield {instantiate}(yield module{}{imports}));", - idx.as_u32() + "({{ exports: exports{iu32} }} = yield {instantiate}({maybe_async_module}module{}{imports}));", + idx.as_u32(), + instantiate = if self.use_asyncify { + self.gen.intrinsic(Intrinsic::AsyncifyAsyncInstantiate) + } else { + instantiate + }, + maybe_async_module = if self.gen.opts.static_wasm_source_imports.is_some() { + "" + } else { + "yield " + }, ) } @@ -1024,7 +1163,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 + }, ) } } @@ -1098,6 +1242,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); + let mut resource_map = ResourceMap::new(); self.create_resource_fn_map(func, func_ty, &mut resource_map); @@ -1156,7 +1311,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, @@ -1170,8 +1342,13 @@ 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()); @@ -1521,6 +1698,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())); @@ -1615,6 +1793,7 @@ impl<'a> Instantiator<'a, '_> { }, src: source::Source::default(), resolve: self.resolve, + is_async, }; abi::call( self.resolve, @@ -1895,14 +2074,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 { @@ -1915,7 +2130,7 @@ impl<'a> Instantiator<'a, '_> { let method_name = func.item_name().to_lower_camel_case(); uwrite!( self.src.js, - "\n{local_name}.{method_name} = function {}", + "\n{local_name}.{method_name} = {maybe_async}function {}", if !is_js_reserved_word(&method_name) { method_name.to_string() } else { @@ -1936,7 +2151,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 { @@ -1953,6 +2167,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 8bd22994e..15f769f62 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::*; @@ -29,6 +29,9 @@ struct TsBindgen { import_object: Source, /// TypeScript definitions which will become the export object export_object: Source, + + async_imports: HashSet, + async_exports: HashSet, } /// Used to generate a `*.d.ts` file for each imported and exported interface for @@ -53,12 +56,23 @@ 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(), local_names: LocalNames::default(), import_object: Source::default(), export_object: Source::default(), + async_imports, + async_exports, }; let world = &resolve.worlds[id]; @@ -256,7 +270,7 @@ pub fn ts_bindgen( // With the current representation of a "world" this is an import object // per-imported-interface where the type of that field is defined by the // interface itbindgen. - if opts.instantiation.is_some() { + if opts.instantiation.is_some() && !opts.esm_imports { uwriteln!(bindgen.src, "export interface ImportObject {{"); bindgen.src.push_str(&bindgen.import_object); uwriteln!(bindgen.src, "}}"); @@ -282,6 +296,11 @@ pub fn ts_bindgen( // Generate the TypeScript definition of the `instantiate` function // which is the main workhorse of the generated bindings. + let maybe_imports_obj = if opts.esm_imports { + "" + } else { + "\nimports?: ImportObject," + }; match opts.instantiation { Some(InstantiationMode::Async) => { uwriteln!( @@ -307,13 +326,7 @@ pub fn ts_bindgen( * on the web, for example. */ export function instantiate( - getCoreModule: (path: string) => WebAssembly.Module, - imports: ImportObject, - instantiateCore?: (module: WebAssembly.Module, imports: Record) => WebAssembly.Instance - ): {camel}; - export function instantiate( - getCoreModule: (path: string) => WebAssembly.Module | Promise, - imports: ImportObject, + getCoreModule?: (path: string) => WebAssembly.Module | Promise,{maybe_imports_obj} instantiateCore?: (module: WebAssembly.Module, imports: Record) => WebAssembly.Instance | Promise ): {camel} | Promise<{camel}>; ", @@ -344,8 +357,7 @@ pub fn ts_bindgen( * `WebAssembly.Module` constructor on the web, for example. */ export function instantiate( - getCoreModule: (path: string) => WebAssembly.Module, - imports: ImportObject, + getCoreModule: (path: string) => WebAssembly.Module,{maybe_imports_obj} instantiateCore?: (module: WebAssembly.Module, imports: Record) => WebAssembly.Instance ): {camel}; ", @@ -368,7 +380,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},", @@ -388,7 +400,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; } @@ -396,7 +408,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},", @@ -415,7 +427,7 @@ impl TsBindgen { ) { uwriteln!(self.import_object, "{}: {{", maybe_quote_id(import_name)); let mut gen = self.ts_interface(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, "}},"); @@ -429,7 +441,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, true); if instantiation { uwriteln!( self.export_object, @@ -453,14 +465,26 @@ impl TsBindgen { fn export_funcs( &mut self, resolve: &Resolve, - _world: WorldId, + world: WorldId, funcs: &[(String, &Function)], _files: &mut Files, declaration: bool, ) { + let async_exports = self.async_exports.clone(); + let id_name = &resolve.worlds[world].name; let mut gen = self.ts_interface(resolve, false); 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); @@ -472,6 +496,7 @@ impl TsBindgen { resolve: &Resolve, id: InterfaceId, files: &mut Files, + is_world_export: bool, ) -> String { let iface = resolve .interfaces @@ -520,6 +545,11 @@ impl TsBindgen { return local_name; } + let async_funcs = if is_world_export { + self.async_exports.clone() + } else { + self.async_imports.clone() + }; let mut gen = self.ts_interface(resolve, false); uwriteln!(gen.src, "export namespace {camel} {{"); @@ -530,7 +560,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() { @@ -729,7 +768,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 @@ -754,11 +793,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 @@ -766,29 +809,33 @@ 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(_) => iface.src.push_str("constructor"), } } 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 { ',' }; @@ -825,6 +872,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 { @@ -843,6 +894,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..f35b15e2f 100644 --- a/docs/src/transpiling.md +++ b/docs/src/transpiling.md @@ -49,9 +49,13 @@ Options include: * `--map`: Provide custom mappings for world imports. Supports both wildcard mappings (`*` similarly as in the package.json "exports" field) as well as `#` mappings for targetting exported interfaces. For example, the WASI mappings are internally defined with mappings like `--map wasi:filesystem/*=@bytecodealliance/preview2-shim/filesystem#*` to map `import as * filesystem from 'wasi:filesystem/types'` to `import { types } from '@bytecodealliance/preview2-shim/filesystem`. * `--no-nodejs-compat`: Disables Node.js compat in the output to load core Wasm with FS methods. * `--instantiation [mode]`: Instead of a direct ES module, export an `instantiate` function which can take the imports as an argument instead of implicit imports. The `instantiate` function can be async (with `--instantiation` or `--instantiation async`), or sync (with `--instantiation sync`). +* `--esm-imports`: If `--instantiation` is set, use ESM imports instead of providing an import object. * `--valid-lifting-optimization`: Internal validations are removed assuming that core Wasm binaries are valid components, providing a minor output size saving. * `--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]`: 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 `: Specify the component imports as `async`. Used with `--async-mode`. +* `--async-exports `: Specify the component exports as `async`. Used with `--async-mode`. ## Browser Support diff --git a/package.json b/package.json index e20b1f070..fbe7702b7 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 120000", - "test": "node --stack-trace-limit=100 node_modules/mocha/bin/mocha.js -u tdd test/test.js --timeout 120000", + "test": "node --experimental-wasm-jspi --stack-trace-limit=100 node_modules/mocha/bin/mocha.js -u tdd test/test.js --timeout 120000", "prepublishOnly": "cargo xtask build release && npm run test" }, "files": [ diff --git a/packages/preview2-shim/lib/browser-async/cli.js b/packages/preview2-shim/lib/browser-async/cli.js new file mode 100644 index 000000000..cf7c47fd8 --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/cli.js @@ -0,0 +1,136 @@ +import { InputStream, OutputStream } from './io/streams.js'; +import { _setCwd as fsSetCwd } from './filesystem.js'; + +const textDecoder = new TextDecoder(); + +let stdinStream, stdoutStream, stderrStream; +let _env = [], _args = [], _cwd = "/"; +export function _setEnv (envObj) { + _env = Object.entries(envObj); +} +export function _setArgs (args) { + _args = args; +} +export function _setCwd (cwd) { + fsSetCwd(_cwd = cwd); +} +export function _setStdin (stream) { + stdinStream = stream; +} + + +export const environment = { + getEnvironment () { + return _env; + }, + getArguments () { + return _args; + }, + initialCwd () { + return _cwd; + } +}; + +class ComponentExit extends Error { + constructor(code) { + super(`Component exited ${code === 0 ? 'successfully' : 'with error'}`); + this.exitError = true; + this.code = code; + } +} + +export const exit = { + exit (status) { + throw new ComponentExit(status.tag === 'err' ? 1 : 0); + }, + exitWithCode (code) { + throw new ComponentExit(code); + } +}; + +export const stdin = { + InputStream, + getStdin () { + if (!stdinStream) { + stdinStream = new InputStream(); + } + return stdinStream; + } +}; + +export const stdout = { + OutputStream, + getStdout () { + if (!stdoutStream) { + stdoutStream = new OutputStream( + new WritableStream({ + write: (contents) => { + // console.log() inserts a '\n' (which is 10) so try to skip that + if (contents[contents.length - 1] === 10) { + contents = contents.subarray(0, contents.length - 1); + } + console.log(textDecoder.decode(contents)); + }, + }) + ); + } + return stdoutStream; + } +}; + +export const stderr = { + OutputStream, + getStderr () { + if (!stderrStream) { + stderrStream = new OutputStream( + new WritableStream({ + write: (contents) => { + // console.log() inserts a '\n' (which is 10) so try to skip that + if (contents[contents.length - 1] === 10) { + contents = contents.subarray(0, contents.length - 1); + } + console.error(textDecoder.decode(contents)); + }, + }) + ); + } + return stderrStream; + } +}; + +class TerminalInput {} +class TerminalOutput {} + +const terminalStdoutInstance = new TerminalOutput(); +const terminalStderrInstance = new TerminalOutput(); +const terminalStdinInstance = new TerminalInput(); + +export const terminalInput = { + TerminalInput +}; + +export const terminalOutput = { + TerminalOutput +}; + +export const terminalStderr = { + TerminalOutput, + getTerminalStderr () { + return terminalStderrInstance; + } +}; + +export const terminalStdin = { + TerminalInput, + getTerminalStdin () { + return terminalStdinInstance; + } +}; + +export const terminalStdout = { + TerminalOutput, + getTerminalStdout () { + return terminalStdoutInstance; + } +}; + diff --git a/packages/preview2-shim/lib/browser-async/clocks.js b/packages/preview2-shim/lib/browser-async/clocks.js new file mode 100644 index 000000000..1047a210a --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/clocks.js @@ -0,0 +1,5 @@ +// wasi:clocks@0.2.2 interfaces + +export * as monotonicClock from './clocks/monotonic-clock.js'; +export * as timezone from './clocks/timezone.js'; +export * as wallClock from './clocks/wall-clock.js'; diff --git a/packages/preview2-shim/lib/browser-async/clocks/monotonic-clock.js b/packages/preview2-shim/lib/browser-async/clocks/monotonic-clock.js new file mode 100644 index 000000000..7b7fe9d32 --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/clocks/monotonic-clock.js @@ -0,0 +1,66 @@ +// wasi:clocks/monotonic-clock@0.2.0 interface + +import { Pollable } from "../io/poll.js"; + +/** + * An instant in time, in nanoseconds. An instant is relative to an unspecified + * initial value, and can only be compared to instances from the same monotonic-clock. + * + * @typedef {bigint} Instant + */ + +/** + * A duration of time, in nanoseconds. + * + * @typedef {bigint} Duration + */ + +/** + * Read the current value of the clock. + * + * The clock is monotonic, therefore calling this function repeatedly will produce a + * sequence of non-decreasing values. + * + * @returns {Instant} + */ +export const now = () => { + // performance.now() is in milliseconds, convert to nanoseconds + return BigInt(Math.floor(performance.now() * 1e6)); +}; + +/** + * Query the resolution of the clock. Returns the duration of time corresponding to a + * clock tick. + * + * @returns {Duration} + */ +export const resolution = () => { + // millisecond accuracy + return BigInt(1e6); +}; + +/** + * Create a `Pollable` which will resolve once the specified instant occured. + * + * @param {Instant} when + * @returns {Pollable} + */ +export const subscribeInstant = (when) => subscribeDuration(when - now()); + +/** + * Create a `Pollable` which will resolve once the given duration has elapsed, starting + * at the time at which this function was called. occured. + * + * Implemented with `setTimeout` that is specified in millisecond resolution. + * + * @param {Duration} when + * @returns {Pollable} + */ +export const subscribeDuration = (when) => { + if (when < 0) return new Pollable(); + return new Pollable( + new Promise((resolve) => { + setTimeout(resolve, Math.ceil(Number(when) / 1e6)); + }), + ); +}; diff --git a/packages/preview2-shim/lib/browser-async/clocks/timezone.js b/packages/preview2-shim/lib/browser-async/clocks/timezone.js new file mode 100644 index 000000000..917c68160 --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/clocks/timezone.js @@ -0,0 +1,25 @@ +// wasi:clocks/timezone@0.2.2 interface + +/** + * @typedef {{ + * utcOffset: number, + * name: string, + * inDaylightSavingTime: boolean, + * }} TimezoneDisplay + */ + +/** + * @param {Datetime} _when + * @returns {TimezoneDisplay} + */ +export const display = (_when) => { + throw 'unimplemented'; +}; + +/** + * @param {Datetime} _when + * @returns {number} + */ +export const utcOffset = (_when) => { + throw 'unimplemented'; +}; diff --git a/packages/preview2-shim/lib/browser-async/clocks/wall-clock.js b/packages/preview2-shim/lib/browser-async/clocks/wall-clock.js new file mode 100644 index 000000000..2ef8de532 --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/clocks/wall-clock.js @@ -0,0 +1,39 @@ +// wasi:clocks/wall-clock@0.2.0 interface + +/** + * A time and date in seconds plus nanoseconds. + * + * @typdef{{seconds: bigint, nanoseconds: number}} Datetime + */ + +/** + * Read the current value of the clock. + * + * This clock is not monotonic, therefore calling this function repeatedly will + * not necessarily produce a sequence of non-decreasing values. + * + * The returned timestamps represent the number of seconds since + * 1970-01-01T00:00:00Z, also known as POSIX's Seconds Since the Epoch, also + * known as Unix Time. + * + * The nanoseconds field of the output is always less than 1000000000. + * + * @returns {Datetime} + */ +export const now = () => { + const now = Date.now(); // in milliseconds + const seconds = BigInt(Math.floor(now / 1e3)); + const nanoseconds = (now % 1e3) * 1e6; + return { seconds, nanoseconds }; +}; + +/** + * Query the resolution of the clock. + * + * The nanoseconds field of the output is always less than 1000000000. + * + * @returns {Datetime} + */ +export const resolution = () => { + return { seconds: 0n, nanoseconds: 1e6 }; +}; diff --git a/packages/preview2-shim/lib/browser-async/filesystem.js b/packages/preview2-shim/lib/browser-async/filesystem.js new file mode 100644 index 000000000..f37a09436 --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/filesystem.js @@ -0,0 +1,291 @@ +import { InputStream } from './io/streams.js'; +import { environment } from './cli.js'; + +let _cwd = "/"; + +export function _setCwd (cwd) { + _cwd = cwd; +} + +export function _setFileData (fileData) { + _fileData = fileData; + _rootPreopen[0] = new Descriptor(fileData); + const cwd = environment.initialCwd(); + _setCwd(cwd || '/'); +} + +export function _getFileData () { + return JSON.stringify(_fileData); +} + +let _fileData = { dir: {} }; + +const timeZero = { + seconds: BigInt(0), + nanoseconds: 0 +}; + +function getChildEntry (parentEntry, subpath, openFlags) { + if (subpath === '.' && _rootPreopen && descriptorGetEntry(_rootPreopen[0]) === parentEntry) { + subpath = _cwd; + if (subpath.startsWith('/') && subpath !== '/') + subpath = subpath.slice(1); + } + let entry = parentEntry; + let segmentIdx; + do { + if (!entry || !entry.dir) throw 'not-directory'; + segmentIdx = subpath.indexOf('/'); + const segment = segmentIdx === -1 ? subpath : subpath.slice(0, segmentIdx); + if (segment === '..') throw 'no-entry'; + if (segment === '.' || segment === ''); + else if (!entry.dir[segment] && openFlags.create) + entry = entry.dir[segment] = openFlags.directory ? { dir: {} } : { source: new Uint8Array([]) }; + else + entry = entry.dir[segment]; + subpath = subpath.slice(segmentIdx + 1); + } while (segmentIdx !== -1) + if (!entry) throw 'no-entry'; + return entry; +} + +function getSource (fileEntry) { + if (typeof fileEntry.source === 'string') { + fileEntry.source = new TextEncoder().encode(fileEntry.source); + } + return fileEntry.source; +} + +class DirectoryEntryStream { + constructor (entries) { + this.idx = 0; + this.entries = entries; + } + readDirectoryEntry () { + if (this.idx === this.entries.length) + return null; + const [name, entry] = this.entries[this.idx]; + this.idx += 1; + return { + name, + type: entry.dir ? 'directory' : 'regular-file' + }; + } +} + +class Descriptor { + #stream; + #entry; + #mtime = 0; + + _getEntry (descriptor) { + return descriptor.#entry; + } + + constructor (entry, isStream) { + if (isStream) + this.#stream = entry; + else + this.#entry = entry; + } + + readViaStream(offset) { + let buf = getSource(this.#entry).subarray(Number(offset)); + return new InputStream( + new ReadableStream({ + pull: (controller) => { + if (buf.byteLength === 0) { + buf = null; + controller.close(); + } else { + controller.enqueue(buf.slice(0, 4096)); // max 4KB; slice() to copy + buf = buf.subarray(4096); + } + }, + cancel: () => { + buf = null; + }, + }), + ); + } + + writeViaStream(_offset) { + throw 'unimplemented'; + //const entry = this.#entry; + //let offset = Number(_offset); + //return new OutputStream({ + // write (buf) { + // const newSource = new Uint8Array(buf.byteLength + entry.source.byteLength); + // newSource.set(entry.source, 0); + // newSource.set(buf, offset); + // offset += buf.byteLength; + // entry.source = newSource; + // return buf.byteLength; + // } + //}); + } + + appendViaStream() { + console.log(`[filesystem] APPEND STREAM`); + } + + advise(descriptor, offset, length, advice) { + console.log(`[filesystem] ADVISE`, descriptor, offset, length, advice); + } + + syncData() { + console.log(`[filesystem] SYNC DATA`); + } + + getFlags() { + console.log(`[filesystem] FLAGS FOR`); + } + + getType() { + if (this.#stream) return 'fifo'; + if (this.#entry.dir) return 'directory'; + if (this.#entry.source) return 'regular-file'; + return 'unknown'; + } + + setSize(size) { + console.log(`[filesystem] SET SIZE`, size); + } + + setTimes(dataAccessTimestamp, dataModificationTimestamp) { + console.log(`[filesystem] SET TIMES`, dataAccessTimestamp, dataModificationTimestamp); + } + + read(length, offset) { + const source = getSource(this.#entry); + return [source.slice(offset, offset + length), offset + length >= source.byteLength]; + } + + write(buffer, offset) { + if (offset !== 0) throw 'invalid-seek'; + this.#entry.source = buffer; + return buffer.byteLength; + } + + readDirectory() { + if (!this.#entry?.dir) + throw 'bad-descriptor'; + return new DirectoryEntryStream(Object.entries(this.#entry.dir).sort(([a], [b]) => a > b ? 1 : -1)); + } + + sync() { + console.log(`[filesystem] SYNC`); + } + + createDirectoryAt(path) { + const entry = getChildEntry(this.#entry, path, { create: true, directory: true }); + if (entry.source) throw 'exist'; + } + + stat() { + let type = 'unknown', size = BigInt(0); + if (this.#entry.source) { + type = 'regular-file'; + const source = getSource(this.#entry); + size = BigInt(source.byteLength); + } + else if (this.#entry.dir) { + type = 'directory'; + } + return { + type, + linkCount: BigInt(0), + size, + dataAccessTimestamp: timeZero, + dataModificationTimestamp: timeZero, + statusChangeTimestamp: timeZero, + } + } + + statAt(_pathFlags, path) { + const entry = getChildEntry(this.#entry, path, { create: false, directory: false }); + let type = 'unknown', size = BigInt(0); + if (entry.source) { + type = 'regular-file'; + const source = getSource(entry); + size = BigInt(source.byteLength); + } + else if (entry.dir) { + type = 'directory'; + } + return { + type, + linkCount: BigInt(0), + size, + dataAccessTimestamp: timeZero, + dataModificationTimestamp: timeZero, + statusChangeTimestamp: timeZero, + }; + } + + setTimesAt() { + console.log(`[filesystem] SET TIMES AT`); + } + + linkAt() { + console.log(`[filesystem] LINK AT`); + } + + openAt(_pathFlags, path, openFlags, _descriptorFlags, _modes) { + const childEntry = getChildEntry(this.#entry, path, openFlags); + return new Descriptor(childEntry); + } + + readlinkAt() { + console.log(`[filesystem] READLINK AT`); + } + + removeDirectoryAt() { + console.log(`[filesystem] REMOVE DIR AT`); + } + + renameAt() { + console.log(`[filesystem] RENAME AT`); + } + + symlinkAt() { + console.log(`[filesystem] SYMLINK AT`); + } + + unlinkFileAt() { + console.log(`[filesystem] UNLINK FILE AT`); + } + + isSameObject(other) { + return other === this; + } + + metadataHash() { + let upper = BigInt(0); + upper += BigInt(this.#mtime); + return { upper, lower: BigInt(0) }; + } + + metadataHashAt(_pathFlags, _path) { + let upper = BigInt(0); + upper += BigInt(this.#mtime); + return { upper, lower: BigInt(0) }; + } +} +const descriptorGetEntry = Descriptor.prototype._getEntry; +delete Descriptor.prototype._getEntry; + +let _preopens = [[new Descriptor(_fileData), '/']], _rootPreopen = _preopens[0]; + +export const preopens = { + getDirectories () { + return _preopens; + } +} + +export const types = { + Descriptor, + DirectoryEntryStream +}; + +export { types as filesystemTypes } diff --git a/packages/preview2-shim/lib/browser-async/http.js b/packages/preview2-shim/lib/browser-async/http.js new file mode 100644 index 000000000..7d5929419 --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/http.js @@ -0,0 +1,5 @@ +// wasi:http@0.2.0 interfaces + +export * as types from './http/types.js'; +export * as incomingHandler from './http/incoming-handler.js'; +export * as outgoingHandler from './http/outgoing-handler.js'; diff --git a/packages/preview2-shim/lib/browser-async/http/incoming-handler.js b/packages/preview2-shim/lib/browser-async/http/incoming-handler.js new file mode 100644 index 000000000..078a02b0c --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/http/incoming-handler.js @@ -0,0 +1,18 @@ +// wasi:http/incoming-handler@0.2.0 interface + +import { IncomingRequest, ResponseOutparam } from "./types.js"; + +export const handle = async (_request, _responseOut) => {}; + +//type wasiHandle = (request: IncomingRequest, responseOut: ResponseOutparam) => Promise; + +export const getHandler = (handle) => async (req) => { + const responseOut = new ResponseOutparam(); + await handle(IncomingRequest.fromRequest(req), responseOut); + const result = await responseOut.promise; + if (result.tag === "ok") { + return result.val.toResponse(); + } else { + throw result; // error + } +}; diff --git a/packages/preview2-shim/lib/browser-async/http/outgoing-handler.js b/packages/preview2-shim/lib/browser-async/http/outgoing-handler.js new file mode 100644 index 000000000..1bc119e70 --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/http/outgoing-handler.js @@ -0,0 +1,7 @@ +// wasi:http/outgoing-handler@0.2.0 interface + +import { FutureIncomingResponse } from "./types.js"; + +export const handle = (request, _options) => { + return new FutureIncomingResponse(request); +}; diff --git a/packages/preview2-shim/lib/browser-async/http/types.js b/packages/preview2-shim/lib/browser-async/http/types.js new file mode 100644 index 000000000..8ef6730e8 --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/http/types.js @@ -0,0 +1,721 @@ +// wasi:http/types@0.2.0 interface + +//import { Duration } from "../clocks/monotonic-clock.js"; +import { InputStream, OutputStream } from "../io/streams.js"; +import { Pollable } from "../io/poll.js"; + +//export type Result = { tag: 'ok', val: T } | { tag: 'err', val: E }; +const symbolDispose = Symbol.dispose || Symbol.for("dispose"); + +//export type Method = MethodGet | MethodHead | MethodPost | MethodPut | MethodDelete | MethodConnect | MethodOptions | MethodTrace | MethodPatch | MethodOther; +//export interface MethodGet { +// tag: 'get', +//} +//export interface MethodHead { +// tag: 'head', +//} +//export interface MethodPost { +// tag: 'post', +//} +//export interface MethodPut { +// tag: 'put', +//} +//export interface MethodDelete { +// tag: 'delete', +//} +//export interface MethodConnect { +// tag: 'connect', +//} +//export interface MethodOptions { +// tag: 'options', +//} +//export interface MethodTrace { +// tag: 'trace', +//} +//export interface MethodPatch { +// tag: 'patch', +//} +//export interface MethodOther { +// tag: 'other', +// val: string, +//} +// +// +//export type Scheme = SchemeHttp | SchemeHttps | SchemeOther; +//export interface SchemeHttp { +// tag: 'HTTP', +//} +//export interface SchemeHttps { +// tag: 'HTTPS', +//} +//export interface SchemeOther { +// tag: 'other', +// val: string, +//} +// +// +//export interface DnsErrorPayload { +// rcode?: string, +// infoCode?: number, +//} +// +// +//export interface TlsAlertReceivedPayload { +// alertId?: number, +// alertMessage?: string, +//} +// +// +//export interface FieldSizePayload { +// fieldName?: string, +// fieldSize?: number, +//} +// +// +//export type ErrorCode = ErrorCodeDnsTimeout | ErrorCodeDnsError | ErrorCodeDestinationNotFound | ErrorCodeDestinationUnavailable | ErrorCodeDestinationIpProhibited | ErrorCodeDestinationIpUnroutable | ErrorCodeConnectionRefused | ErrorCodeConnectionTerminated | ErrorCodeConnectionTimeout | ErrorCodeConnectionReadTimeout | ErrorCodeConnectionWriteTimeout | ErrorCodeConnectionLimitReached | ErrorCodeTlsProtocolError | ErrorCodeTlsCertificateError | ErrorCodeTlsAlertReceived | ErrorCodeHttpRequestDenied | ErrorCodeHttpRequestLengthRequired | ErrorCodeHttpRequestBodySize | ErrorCodeHttpRequestMethodInvalid | ErrorCodeHttpRequestUriInvalid | ErrorCodeHttpRequestUriTooLong | ErrorCodeHttpRequestHeaderSectionSize | ErrorCodeHttpRequestHeaderSize | ErrorCodeHttpRequestTrailerSectionSize | ErrorCodeHttpRequestTrailerSize | ErrorCodeHttpResponseIncomplete | ErrorCodeHttpResponseHeaderSectionSize | ErrorCodeHttpResponseHeaderSize | ErrorCodeHttpResponseBodySize | ErrorCodeHttpResponseTrailerSectionSize | ErrorCodeHttpResponseTrailerSize | ErrorCodeHttpResponseTransferCoding | ErrorCodeHttpResponseContentCoding | ErrorCodeHttpResponseTimeout | ErrorCodeHttpUpgradeFailed | ErrorCodeHttpProtocolError | ErrorCodeLoopDetected | ErrorCodeConfigurationError | ErrorCodeInternalError; +//export interface ErrorCodeDnsTimeout { +// tag: 'DNS-timeout', +//} +//export interface ErrorCodeDnsError { +// tag: 'DNS-error', +// val: DnsErrorPayload, +//} +//export interface ErrorCodeDestinationNotFound { +// tag: 'destination-not-found', +//} +//export interface ErrorCodeDestinationUnavailable { +// tag: 'destination-unavailable', +//} +//export interface ErrorCodeDestinationIpProhibited { +// tag: 'destination-IP-prohibited', +//} +//export interface ErrorCodeDestinationIpUnroutable { +// tag: 'destination-IP-unroutable', +//} +//export interface ErrorCodeConnectionRefused { +// tag: 'connection-refused', +//} +//export interface ErrorCodeConnectionTerminated { +// tag: 'connection-terminated', +//} +//export interface ErrorCodeConnectionTimeout { +// tag: 'connection-timeout', +//} +//export interface ErrorCodeConnectionReadTimeout { +// tag: 'connection-read-timeout', +//} +//export interface ErrorCodeConnectionWriteTimeout { +// tag: 'connection-write-timeout', +//} +//export interface ErrorCodeConnectionLimitReached { +// tag: 'connection-limit-reached', +//} +//export interface ErrorCodeTlsProtocolError { +// tag: 'TLS-protocol-error', +//} +//export interface ErrorCodeTlsCertificateError { +// tag: 'TLS-certificate-error', +//} +//export interface ErrorCodeTlsAlertReceived { +// tag: 'TLS-alert-received', +// val: TlsAlertReceivedPayload, +//} +//export interface ErrorCodeHttpRequestDenied { +// tag: 'HTTP-request-denied', +//} +//export interface ErrorCodeHttpRequestLengthRequired { +// tag: 'HTTP-request-length-required', +//} +//export interface ErrorCodeHttpRequestBodySize { +// tag: 'HTTP-request-body-size', +// val: bigint | undefined, +//} +//export interface ErrorCodeHttpRequestMethodInvalid { +// tag: 'HTTP-request-method-invalid', +//} +//export interface ErrorCodeHttpRequestUriInvalid { +// tag: 'HTTP-request-URI-invalid', +//} +//export interface ErrorCodeHttpRequestUriTooLong { +// tag: 'HTTP-request-URI-too-long', +//} +//export interface ErrorCodeHttpRequestHeaderSectionSize { +// tag: 'HTTP-request-header-section-size', +// val: number | undefined, +//} +//export interface ErrorCodeHttpRequestHeaderSize { +// tag: 'HTTP-request-header-size', +// val: FieldSizePayload | undefined, +//} +//export interface ErrorCodeHttpRequestTrailerSectionSize { +// tag: 'HTTP-request-trailer-section-size', +// val: number | undefined, +//} +//export interface ErrorCodeHttpRequestTrailerSize { +// tag: 'HTTP-request-trailer-size', +// val: FieldSizePayload, +//} +//export interface ErrorCodeHttpResponseIncomplete { +// tag: 'HTTP-response-incomplete', +//} +//export interface ErrorCodeHttpResponseHeaderSectionSize { +// tag: 'HTTP-response-header-section-size', +// val: number | undefined, +//} +//export interface ErrorCodeHttpResponseHeaderSize { +// tag: 'HTTP-response-header-size', +// val: FieldSizePayload, +//} +//export interface ErrorCodeHttpResponseBodySize { +// tag: 'HTTP-response-body-size', +// val: bigint | undefined, +//} +//export interface ErrorCodeHttpResponseTrailerSectionSize { +// tag: 'HTTP-response-trailer-section-size', +// val: number | undefined, +//} +//export interface ErrorCodeHttpResponseTrailerSize { +// tag: 'HTTP-response-trailer-size', +// val: FieldSizePayload, +//} +//export interface ErrorCodeHttpResponseTransferCoding { +// tag: 'HTTP-response-transfer-coding', +// val: string | undefined, +//} +//export interface ErrorCodeHttpResponseContentCoding { +// tag: 'HTTP-response-content-coding', +// val: string | undefined, +//} +//export interface ErrorCodeHttpResponseTimeout { +// tag: 'HTTP-response-timeout', +//} +//export interface ErrorCodeHttpUpgradeFailed { +// tag: 'HTTP-upgrade-failed', +//} +//export interface ErrorCodeHttpProtocolError { +// tag: 'HTTP-protocol-error', +//} +//export interface ErrorCodeLoopDetected { +// tag: 'loop-detected', +//} +//export interface ErrorCodeConfigurationError { +// tag: 'configuration-error', +//} +//// This is a catch-all error for anything that doesn't fit cleanly into a +//// more specific case. It also includes an optional string for an +//// unstructured description of the error. Users should not depend on the +//// string for diagnosing errors, as it's not required to be consistent +//// between implementations. +//export interface ErrorCodeInternalError { +// tag: 'internal-error', +// val: string | undefined, +//} +// +// +//export type HeaderError = HeaderErrorInvalidSyntax | HeaderErrorForbidden | HeaderErrorImmutable; +//export interface HeaderErrorInvalidSyntax { +// tag: 'invalid-syntax', +//} +//export interface HeaderErrorForbidden { +// tag: 'forbidden', +//} +//export interface HeaderErrorImmutable { +// tag: 'immutable', +//} +// +// +//export type FieldKey = string; +//export type FieldValue = Uint8Array; + +/** + * @typedef {string} FieldKey + */ + +/** + * @typedef {Uint8Array} FieldValue + */ + +export class Fields { + headers; + immutable; + + /** + * @param {Headers|undefined} headers + * @param {boolean|undefined} immutable + */ + constructor(headers = new Headers(), immutable = false) { + this.headers = headers; + this.immutable = immutable; + } + + /** + * @param {Array<[FieldKey, FieldValue]>} entries + * @returns {Fields} + */ + static fromList(entries) { + const fields = new Fields(); + const dec = new TextDecoder(); + for (const [key, val] of entries) { + fields.headers.append(key, dec.decode(val)); + } + return fields; + } + + /** + * @param {FieldKey} name + * @returns {Array} + */ + get(name) { + const enc = new TextEncoder(); + return ( + this.headers + .get(name) + ?.split(", ") + .map((val) => enc.encode(val)) || [] + ); + } + /** + * @param {FieldKey} name + * @returns {boolean} + */ + has(name) { + return this.headers.has(name); + } + /** + * @param {FieldKey} name + * @param {Array} value + */ + set(name, value) { + if (this.immutable) { + throw { tag: "immutable" }; + } + const dec = new TextDecoder(); + this.headers.set(name, value.map((val) => dec.decode(val)).join(", ")); + } + /** + * @param {FieldKey} name + */ + delete(name) { + if (this.immutable) { + throw { tag: "immutable" }; + } + this.headers.delete(name); + } + /** + * @param {FieldKey} name + * @param {FieldValue} value + */ + append(name, value) { + if (this.immutable) { + throw { tag: "immutable" }; + } + const dec = new TextDecoder(); + this.headers.append(name, dec.decode(value)); + } + /** + * @returns {Array<[FieldKey, FieldValue]>} + */ + entries() { + const entries = []; + const enc = new TextEncoder(); + this.headers.forEach((val, key) => { + entries.push([key, enc.encode(val)]); + }); + return entries; + } + /** + * @returns {Fields} + */ + clone() { + const fields = new Fields(); + this.headers.forEach((val, key) => { + fields.headers.set(key, val); + }); + return fields; + } +} + +//export type Headers = Fields; +//export type Trailers = Fields; + +export class IncomingRequest { + #method; + #pathWithQuery; + #scheme; + #authority; + #headers; + #body; + + /** + * @param {Method} method + * @param {string|undefined} pathWithQuery + * @param {Scheme|undefined} scheme + * @param {string|undefined} authority + * @param {Fields|undefined} headers + * @param {IncomingBody|undefined} body + */ + constructor(method, pathWithQuery, scheme, authority, headers, body) { + this.#method = method; + this.#pathWithQuery = pathWithQuery; + this.#scheme = scheme; + this.#authority = authority; + this.#headers = headers || new Fields(); + this.#body = body; + } + + /** + * @returns {Method} + */ + method() { + return this.#method; + } + /** + * @returns {string|undefined} + */ + pathWithQuery() { + return this.#pathWithQuery; + } + /** + * @returns {Scheme|undefined} + */ + scheme() { + return this.#scheme; + } + /** + * @returns {string|undefined} + */ + authority() { + return this.#authority; + } + /** + * @returns {Fields} + */ + headers() { + return this.#headers; + } + /** + * @returns {IncomingBody} + */ + consume() { + if (this.#body) { + return this.#body; + } + throw undefined; + } + [symbolDispose]() {} + + /** + * @param {Request} req + * @returns {IncomingRequest} + */ + static fromRequest(req) { + const method = { tag: req.method.toLowerCase() }; + const url = new URL(req.url); + const scheme = { tag: url.protocol.slice(0, -1).toUpperCase() }; + const authority = url.host; + const pathWithQuery = `${url.pathname}${url.search}${url.hash}`; + const headers = new Fields(req.headers, true); + const body = new IncomingBody(new InputStream(req.body)); + + return new IncomingRequest( + method, + pathWithQuery, + scheme, + authority, + headers, + body, + ); + } +} + +export class OutgoingRequest { + #headers; + #method; + #pathWithQuery; + #scheme; + #authority; + #body; + + /** + * @param {Fields} headers + */ + constructor(headers) { + headers.immutable = true; + this.#headers = headers; + this.#body = new OutgoingBody(); + } + /** + * @returns {OutgoingBody} + */ + body() { + return this.#body; + } + /** + * @returns {Method} + */ + method() { + return this.#method || { tag: "get" }; + } + /** + * @param {Method} method + */ + setMethod(method) { + this.#method = method; + } + /** + * @returns {string|undefined} + */ + pathWithQuery() { + return this.#pathWithQuery; + } + /** + * @param {string|undefined} pathWithQuery + */ + setPathWithQuery(pathWithQuery) { + this.#pathWithQuery = pathWithQuery; + } + scheme() { + return this.#scheme; + } + setScheme(scheme) { + this.#scheme = scheme; + } + authority() { + return this.#authority; + } + setAuthority(authority) { + this.#authority = authority; + } + headers() { + return this.#headers; + } + + toRequest() { + if ((this.#scheme && this.#scheme.tag === "other") || !this.#authority) { + throw { tag: "destination-not-found" }; + } + const path = this.#pathWithQuery + ? this.#pathWithQuery.startsWith("/") + ? this.#pathWithQuery + : `/${this.#pathWithQuery}` + : ""; + + const method = this.#method ? this.#method.tag : "get"; + const body = + method === "get" || method === "head" + ? undefined + : this.#body.stream.readable; + // see: https://fetch.spec.whatwg.org/#ref-for-dom-requestinit-duplex + // see: https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests#half_duplex + const duplex = body ? "half" : undefined; + return new Request( + `${this.#scheme ? this.#scheme.tag : "HTTPS"}://${this.#authority}${path}`, + { + method, + headers: this.#headers.headers, + body, + duplex, + }, + ); + } +} + +// TODO +export class RequestOptions { + constructor() {} + connectTimeout() { + return; + } + setConnectTimeout(_duration) { + return; + } + firstByteTimeout() { + return; + } + setFirstByteTimeout(_duration) { + return; + } + betweenBytesTimeout() { + return; + } + setBetweenBytesTimeout(_duration) { + return; + } +} + +export class ResponseOutparam { + promise; /** Promise> */ + resolve; /** (result: Result) => void */ + + constructor() { + this.promise = new Promise((resolve) => { + this.resolve = resolve; + }); + } + + static set(param, response) { + param.resolve(response); + } +} + +//export type StatusCode = number; + +export class IncomingResponse { + #statusCode; + #headers; + #body; + + constructor(statusCode, headers, body) { + this.#statusCode = statusCode; + this.#headers = headers; + this.#body = body; + } + + status() { + return this.#statusCode; + } + headers() { + return this.#headers; + } + consume() { + return this.#body; + } +} + +export class IncomingBody { + #stream; + + constructor(stream) { + this.#stream = stream; + } + stream() { + return this.#stream; + } + static finish(_body) { + return new FutureTrailers(); + } + [symbolDispose]() {} +} + +export class FutureTrailers { + #trailers; + #errCode; + + constructor(trailers, errCode) { + this.#trailers = trailers; + this.#errCode = errCode; + } + subscribe() { + return new Pollable(); + } + get() { + if (this.#errCode) { + return { tag: "ok", val: { tag: "err", val: this.#errCode } }; + } + return { tag: "ok", val: { tag: "ok", val: this.#trailers } }; + } +} + +export class OutgoingResponse { + #headers; + #statusCode; + #body; + + constructor(headers) { + this.#headers = headers; + this.#statusCode = 200; + this.#body = new OutgoingBody(); + } + statusCode() { + return this.#statusCode; + } + setStatusCode(statusCode) { + this.#statusCode = statusCode; + } + headers() { + return this.#headers; + } + body() { + return this.#body; + } + [symbolDispose]() {} + + toResponse() { + return new Response(this.#body.stream.readable, { + status: this.#statusCode, + headers: this.#headers.headers, + }); + } +} + +export class OutgoingBody { + finished; + stream; + + constructor() { + this.finished = false; + this.stream = new OutputStream(); + } + write() { + return this.stream; + } + static finish(body, trailers) { + // trailers not supported + if (trailers) { + throw { tag: "HTTP-request-trailer-section-size" }; + } + body.stream.close(); + body.finished = true; + } + [symbolDispose]() { + OutgoingBody.finish(this); + } +} + +export class FutureIncomingResponse { + #promise; + #resolvedResponse; + #ready = false; + #error; + + constructor(request) { + try { + this.#promise = fetch(request.toRequest()).then((response) => { + this.#ready = true; + this.#resolvedResponse = response; + }); + } catch (err) { + console.error(err); + this.#promise = Promise.resolve(); + this.#ready = true; + // TODO better error handling + this.#error = { tag: "internal-error", val: err.toString() }; + } + } + + subscribe() { + return new Pollable(this.#promise); + } + get() { + if (!this.#ready) return; + if (this.#error) return { tag: "err", val: this.#error }; + + const res = this.#resolvedResponse; + + return { + tag: "ok", + val: { + tag: "ok", + val: new IncomingResponse( + res.status, + new Fields(res.headers, true), + new IncomingBody(new InputStream(res.body)), + ), + }, + }; + } +} + +export const httpErrorCode = (_err) => { + return; +}; diff --git a/packages/preview2-shim/lib/browser-async/index.js b/packages/preview2-shim/lib/browser-async/index.js new file mode 100644 index 000000000..58a86db6c --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/index.js @@ -0,0 +1,17 @@ +import * as cli from "./cli.js"; +import * as clocks from "./clocks.js"; +import * as filesystem from "./filesystem.js"; +import * as http from "./http.js"; +import * as io from "./io.js"; +import * as random from "./random.js"; +import * as sockets from "./sockets.js"; + +export { + cli, + clocks, + filesystem, + http, + io, + random, + sockets, +} diff --git a/packages/preview2-shim/lib/browser-async/io.js b/packages/preview2-shim/lib/browser-async/io.js new file mode 100644 index 000000000..8208ab47b --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/io.js @@ -0,0 +1,7 @@ +// wasi:io@0.2.0 interfaces + +import { IoError } from './io/error.js'; +export const error = { Error: IoError }; + +export * as poll from './io/poll.js'; +export * as streams from './io/streams.js'; diff --git a/packages/preview2-shim/lib/browser-async/io/error.js b/packages/preview2-shim/lib/browser-async/io/error.js new file mode 100644 index 000000000..8df66fe87 --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/io/error.js @@ -0,0 +1,18 @@ +// wasi:io/error@0.2.0 interface + +// A resource which represents some error information. +// +// The only method provided by this resource is to-debug-string, +// which provides some human-readable information about the error. +export class IoError extends Error { + #msg; + constructor(msg) { + super(msg); + this.#msg; + } + // Returns a string that is suitable to assist humans in debugging + // this error. + toDebugString() { + return this.#msg; + } +}; diff --git a/packages/preview2-shim/lib/browser-async/io/poll.js b/packages/preview2-shim/lib/browser-async/io/poll.js new file mode 100644 index 000000000..1d266eb70 --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/io/poll.js @@ -0,0 +1,78 @@ +// wasi:io/poll@0.2.0 interface + +// Pollable represents a single I/O event which may be ready, or not. +export class Pollable { + #ready = false; + #promise; + + /** + * Sets the pollable to ready whether the promise is resolved or + * rejected. + * + * @param {Promise|undefined|null} promise + */ + constructor(promise) { + const setReady = () => { + this.#ready = true; + }; + this.#promise = (promise || Promise.resolve()).then(setReady, setReady); + } + + /** + * Return the readiness of a pollable. This function never blocks. + * + * Returns `true` when the pollable is ready, and `false` otherwise. + * + * @returns {boolean} + */ + ready() { + return this.#ready; + } + + /** + * Returns immediately if the pollable is ready, and otherwise blocks + * until ready. + * + * This function is equivalent to calling `poll.poll` on a list + * containing only this pollable. + */ + async block() { + await this.#promise; + } +} + +/** + * Poll for completion on a set of pollables. + * + * This function takes a list of pollables, which identify I/O + * sources of interest, and waits until one or more of the events + * is ready for I/O. + * + * The result list contains one or more indices of handles + * in the argument list that is ready for I/O. + * + * @param {Array} inList + * @returns {Promise} + */ +export const poll = async (inList) => { + if (inList.length === 1) { + // handle this common case faster + await inList[0].block(); + return new Uint32Array(1); // zero initialized of length 1 + } + + // wait until at least one is ready + await Promise.race(inList.map((pollable) => pollable.block())); + + // allocate a Uint32Array list as if all are ready + const ready = new Uint32Array(inList.length); + let pos = 0; + for (let i = 0; i < inList.length; i++) { + if (inList[i].ready()) { + ready[pos] = i; + pos++; + } + } + + return ready.subarray(0, pos); +}; diff --git a/packages/preview2-shim/lib/browser-async/io/streams.js b/packages/preview2-shim/lib/browser-async/io/streams.js new file mode 100644 index 000000000..c67b18280 --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/io/streams.js @@ -0,0 +1,268 @@ +// wasi:io/streams@0.2.0 interface + +import { Pollable } from "./poll.js"; +import { IoError } from "./error.js"; + +export class InputStream { + #closed = false; + #reader = null; + #buffer = new Uint8Array(); + + /** + * @param {ReadableStream|undefined} stream + */ + constructor(stream) { + if (stream) { + this.#reader = stream.getReader(); + } else { + this.#closed = true; + } + } + + async #fillBuffer() { + const { value, done } = await this.#reader.read(); + if (done) this.#closed = done; + this.#buffer = value || new Uint8Array(0); + } + + /** + * @param {number|bigint} len + * @returns {Uint8Array} + */ + read(len) { + if (this.#buffer.byteLength === 0 && this.#closed) throw { tag: 'closed' }; + const n = Number(len); + if (n >= this.#buffer.byteLength) { + // read all that is in the buffer and reset buffer + const buf = this.#buffer; + this.#buffer = new Uint8Array(0); + return buf; + } else { + // read portion of the buffer and advance the buffer for next read + const buf = this.#buffer.subarray(0, n); + this.#buffer = this.#buffer.subarray(n); + return buf; + } + } + + /** + * @param {number|bigint} len + * @returns {Promise} + */ + async blockingRead(len) { + // if buffer has data, read that first + if (this.#buffer.byteLength > 0) return this.read(len); + if (this.#buffer.byteLength === 0 && this.#closed) throw { tag: 'closed' }; + await this.#fillBuffer(); + return this.read(len); + } + + /** + * @param {number|bigint} len + * @returns {bigint} + */ + skip(len) { + if (this.#buffer.byteLength === 0 && this.#closed) throw { tag: 'closed' }; + const n = Number(len); + if (n >= this.#buffer.byteLength) { + // skip all in buffer + const skipped = BigInt(this.#buffer.byteLength); + this.#buffer = new Uint8Array(0); + return skipped; + } else { + // skip part of the buffer + this.#buffer = this.#buffer.subarray(n); + return len; + } + } + + /** + * @param {number|bigint} len + * @returns {Promise} + */ + async blockingSkip(len) { + // if buffer has data, skip that first + if (this.#buffer.byteLength > 0) return this.skip(len); + await this.#fillBuffer(); + return this.skip(len); + } + + /** + * @returns {Pollable} + */ + subscribe() { + // return ready pollable if has bytes in buffer + if (this.#buffer.byteLength > 0) return new Pollable(); + return new Pollable(this.#fillBuffer()); + } +} + +export class OutputStream { + #readable; + #readableController; + #writer; + #prevWritePromise; + #prevWriteError; + #closed = false; + + /** + * @param {WritableStream|undefined} stream + */ + constructor(stream) { + if (stream) { + this.#writer = stream.getWriter(); + } else { + // enqueue a ReadableStream internally + this.readable = new ReadableStream({ + start: (controller) => { + this.#readableController = controller; + }, + cancel: () => { + this.#closed = true; + }, + type: 'bytes', + autoAllocateChunkSize: 4096, + }); + } + } + + /** + * @returns {ReadableStream|undefined} + */ + getReadableStream() { + return this.#readable; + } + + close() { + this.#closed = true; + if (this.#readableController) { + this.#readableController.close(); + } else { + this.#writer.close(); + } + } + + /** + * @returns {bigint} + */ + checkWrite() { + if (this.#closed) throw { tag: 'closed' }; + if (this.#prevWriteError) { + const err = this.#prevWriteError; + this.#prevWriteError = null; + throw err; + } + if (this.#prevWritePromise) return 0n; // not ready, waiting on previous write + return 4096n; // TODO for WritableStream + } + + /** + * @param {Uint8Array} contents + */ + write(contents) { + if (this.#closed) throw { tag: 'closed' }; + if (this.#readableController) { + this.#readableController?.enqueue(contents); + } else if (this.#prevWritePromise) { + throw new Error("waiting for previous write to finish"); + } else { + this.#prevWritePromise = this.#writer.write(contents).then( + () => { + this.#prevWritePromise = null; + }, + (err) => { + this.#prevWriteError = { tag: 'last-operation-failed', val: new IoError(err.toString()) }; + this.#prevWritePromise = null; + }, + ); + } + } + + /** + * @param {Uint8Array} contents + * @returns {Promise} + */ + async blockingWriteAndFlush(contents) { + if (this.#readableController) { + this.#readableController?.enqueue(contents); + } else if (this.#prevWritePromise) { + throw new Error("waiting for previous write to finish"); + } else { + try { + await this.#writer.write(contents); + } catch (err) { + throw { tag: 'last-operation-failed', val: new IoError(err.toString()) }; + } + } + } + + flush() { + if (this.#closed) throw { tag: 'closed' }; + if (this.#prevWriteError) { + const err = this.#prevWriteError; + this.#prevWriteError = null; + throw err; + } + } + + /** + * @returns {Promise} + */ + async blockingFlush() { + if (this.#closed) throw { tag: 'closed' }; + if (this.#prevWritePromise) { + await this.#prevWritePromise; + if (this.#prevWriteError) { + const err = this.#prevWriteError; + this.#prevWriteError = null; + throw err; + } + } + } + + /** + * @returns {Pollable} + */ + subscribe() { + return new Pollable(this.#prevWritePromise); + } + + /** + * @param {number|bigint} len + */ + writeZeroes(len) { + this.write(new Uint8Array(Number(len))); + } + + /** + * @param {number|bigint} len + * @returns {Promise} + */ + async blockingWriteZeroesAndFlush(len) { + await this.blockingWriteAndFlush(new Uint8Array(Number(len))); + } + + /** + * @param {InputStream} src + * @param {number|bigint} len + * @returns {bigint} + */ + splice(src, len) { + const n = this.checkWrite(); + const contents = src.read(Number(len) < n ? len : n); + this.write(contents); + return BigInt(contents.byteLength); + } + + /** + * @param {InputStream} src + * @param {number|bigint} len + * @returns {Promise} + */ + async blockingSplice(src, len) { + const n = this.checkWrite(); + const contents = await src.blockingRead(len < n ? len : n); + await this.blockingWriteAndFlush(contents); + return BigInt(contents.byteLength); + } +} diff --git a/packages/preview2-shim/lib/browser-async/random.js b/packages/preview2-shim/lib/browser-async/random.js new file mode 100644 index 000000000..c9f765c04 --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/random.js @@ -0,0 +1,4 @@ +// wasi:random@0.2.0 interfaces + +export * as random from './random/random.js'; +export * as insecure from './random/insecure.js'; diff --git a/packages/preview2-shim/lib/browser-async/random/insecure.js b/packages/preview2-shim/lib/browser-async/random/insecure.js new file mode 100644 index 000000000..47239a7b8 --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/random/insecure.js @@ -0,0 +1,12 @@ +// wasi:random/insecure@0.2.0 interface +// The insecure interface for insecure pseudo-random numbers. + +import { getRandomBytes, getRandomU64 } from "./random.js"; + +// Return len insecure pseudo-random bytes. +// In this case, just reuse the wasi:random/random interface. +export const getInsecureRandomBytes = getRandomBytes; + +// Return an insecure pseudo-random u64 value. +// In this case, just reuse the wasi:random/random interface. +export const getInsecureRandomU64 = getRandomU64; diff --git a/packages/preview2-shim/lib/browser-async/random/random.js b/packages/preview2-shim/lib/browser-async/random/random.js new file mode 100644 index 000000000..50bab1daa --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/random/random.js @@ -0,0 +1,37 @@ +// wasi:random/random@0.2.0 interface +// WASI Random is a random data API. + +const MAX_BYTES = 65536; + +/** + * Return len cryptographically-secure random or pseudo-random bytes. + * + * @param {number|bigint} len + * @returns {Uint8Array} + */ +export const getRandomBytes = (len) => { + const bytes = new Uint8Array(Number(len)); + + if (len > MAX_BYTES) { + // this is the max bytes crypto.getRandomValues + // can do at once see https://developer.mozilla.org/en-US/docs/Web/API/window.crypto.getRandomValues + for (let pos = 0; pos < bytes.byteLength; pos += MAX_BYTES) { + // buffer.slice automatically checks if the end is past the end of + // the buffer so we don't have to here + crypto.getRandomValues(bytes.subarray(pos, pos + MAX_BYTES)); + } + } else { + crypto.getRandomValues(bytes); + } + + return bytes; +}; + +/** + * Return a cryptographically-secure random or pseudo-random u64 value. + * + * @returns {bigint} + */ +export const getRandomU64 = () => { + return crypto.getRandomValues(new BigUint64Array(1))[0]; +}; diff --git a/packages/preview2-shim/lib/browser-async/sockets.js b/packages/preview2-shim/lib/browser-async/sockets.js new file mode 100644 index 000000000..6dda749c7 --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/sockets.js @@ -0,0 +1,186 @@ +export const instanceNetwork = { + instanceNetwork () { + console.log(`[sockets] instance network`); + } +}; + +export const ipNameLookup = { + dropResolveAddressStream () { + + }, + subscribe () { + + }, + resolveAddresses () { + + }, + resolveNextAddress () { + + }, + nonBlocking () { + + }, + setNonBlocking () { + + }, +}; + +export const network = { + dropNetwork () { + + } +}; + +export const tcpCreateSocket = { + createTcpSocket () { + + } +}; + +export const tcp = { + subscribe () { + + }, + dropTcpSocket() { + + }, + bind() { + + }, + connect() { + + }, + listen() { + + }, + accept() { + + }, + localAddress() { + + }, + remoteAddress() { + + }, + addressFamily() { + + }, + setListenBacklogSize() { + + }, + keepAlive() { + + }, + setKeepAlive() { + + }, + noDelay() { + + }, + setNoDelay() { + + }, + unicastHopLimit() { + + }, + setUnicastHopLimit() { + + }, + receiveBufferSize() { + + }, + setReceiveBufferSize() { + + }, + sendBufferSize() { + + }, + setSendBufferSize() { + + }, + nonBlocking() { + + }, + setNonBlocking() { + + }, + shutdown() { + + } +}; + +export const udp = { + subscribe () { + + }, + + dropUdpSocket () { + + }, + + bind () { + + }, + + connect () { + + }, + + receive () { + + }, + + send () { + + }, + + localAddress () { + + }, + + remoteAddress () { + + }, + + addressFamily () { + + }, + + unicastHopLimit () { + + }, + + setUnicastHopLimit () { + + }, + + receiveBufferSize () { + + }, + + setReceiveBufferSize () { + + }, + + sendBufferSize () { + + }, + + setSendBufferSize () { + + }, + + nonBlocking () { + + }, + + setNonBlocking () { + + } +}; + +export const udpCreateSocket = { + createUdpSocket () { + + } +}; diff --git a/packages/preview2-shim/lib/browser/cli.js b/packages/preview2-shim/lib/browser/cli.js index a2654118d..9238fa0e9 100644 --- a/packages/preview2-shim/lib/browser/cli.js +++ b/packages/preview2-shim/lib/browser/cli.js @@ -75,7 +75,7 @@ const stdinStream = new InputStream({ // TODO } }); -let textDecoder = new TextDecoder(); +const textDecoder = new TextDecoder(); const stdoutStream = new OutputStream({ write (contents) { if (contents[contents.length - 1] == 10) { diff --git a/packages/preview2-shim/lib/browser/random.js b/packages/preview2-shim/lib/browser/random.js index 0c473858e..cd99fb4ab 100644 --- a/packages/preview2-shim/lib/browser/random.js +++ b/packages/preview2-shim/lib/browser/random.js @@ -30,7 +30,7 @@ export const random = { if (len > MAX_BYTES) { // this is the max bytes crypto.getRandomValues // can do at once see https://developer.mozilla.org/en-US/docs/Web/API/window.crypto.getRandomValues - for (var generated = 0; generated < len; generated += MAX_BYTES) { + for (let generated = 0; generated < len; generated += MAX_BYTES) { // buffer.slice automatically checks if the end is past the end of // the buffer so we don't have to here crypto.getRandomValues(bytes.subarray(generated, generated + MAX_BYTES)); diff --git a/packages/preview2-shim/package.json b/packages/preview2-shim/package.json index 54ed9b969..fdcbf6992 100644 --- a/packages/preview2-shim/package.json +++ b/packages/preview2-shim/package.json @@ -15,10 +15,18 @@ "types": "./types/*.d.ts", "node": "./lib/nodejs/*.js", "default": "./lib/browser/*.js" + }, + "./async": { + "types": "./types-async/index.d.ts", + "default": "./lib/browser-async/index.js" + }, + "./async/*": { + "types": "./types/*.d.ts", + "default": "./lib/browser-async/*.js" } }, "scripts": { - "test": "node --expose-gc ../../node_modules/mocha/bin/mocha.js -u tdd test/test.js --timeout 30000" + "test": "node --experimental-wasm-jspi --expose-gc ../../node_modules/mocha/bin/mocha.js -u tdd test/test.js --timeout 30000" }, "files": [ "types", diff --git a/src/cmd/opt.js b/src/cmd/opt.js index 3a1ceeb6e..8b16b1383 100644 --- a/src/cmd/opt.js +++ b/src/cmd/opt.js @@ -47,7 +47,7 @@ ${table([...compressionInfo.map(({ beforeBytes, afterBytes }, i) => { /** * * @param {Uint8Array} componentBytes - * @param {{ quiet: boolean, optArgs?: string[] }} options? + * @param {{ quiet: boolean, asyncMode?: string, optArgs?: string[] }} opts? * @returns {Promise<{ component: Uint8Array, compressionInfo: { beforeBytes: number, afterBytes: number }[] >} */ export async function optimizeComponent (componentBytes, opts) { @@ -67,8 +67,11 @@ export async function optimizeComponent (componentBytes, opts) { spinner.text = spinnerText(); } + 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), opts?.optArgs); + const optimized = wasmOpt(componentBytes.subarray(coreModuleStart, coreModuleEnd), args); if (spinner) { completed++; spinner.text = spinnerText(); @@ -76,7 +79,13 @@ export async function optimizeComponent (componentBytes, opts) { return optimized; })); - let outComponentBytes = new Uint8Array(componentBytes.byteLength); + // 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; + + // 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]; @@ -104,11 +113,11 @@ export async function optimizeComponent (componentBytes, opts) { nextReadPos = coreModuleEnd; } - outComponentBytes.set(componentBytes.subarray(nextReadPos, componentBytes.byteLength), nextWritePos); + outComponentBytes.set(componentBytes.subarray(nextReadPos), nextWritePos); nextWritePos += componentBytes.byteLength - nextReadPos; - nextReadPos += componentBytes.byteLength - nextReadPos; - outComponentBytes = outComponentBytes.subarray(0, outComponentBytes.length + nextWritePos - nextReadPos); + // truncate to the bytes written + outComponentBytes = outComponentBytes.subarray(0, nextWritePos); // verify it still parses ok try { @@ -130,9 +139,10 @@ export async function optimizeComponent (componentBytes, opts) { /** * @param {Uint8Array} source + * @param {Array} args * @returns {Promise} */ -async function wasmOpt(source, args = ['-O1', '--low-memory-unused', '--enable-bulk-memory']) { +async function wasmOpt(source, args) { const wasmOptPath = fileURLToPath(import.meta.resolve('binaryen/bin/wasm-opt')); try { diff --git a/src/cmd/transpile.js b/src/cmd/transpile.js index 6a58dda17..7e9d543e1 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'); @@ -25,7 +41,11 @@ export async function types (witPath, opts) { * name?: string, * worldName?: string, * instantiation?: 'async' | 'sync', + * esmImports?: bool, * tlaCompat?: bool, + * asyncMode?: string, + * asyncImports?: string[], + * asyncExports?: string[], * outDir?: string, * features?: string[] | 'all', * }} opts @@ -51,12 +71,29 @@ 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, + esmImports: opts.esmImports, tlaCompat: opts.tlaCompat ?? false, world: opts.worldName, features, + asyncMode, }).map(([name, file]) => [`${outDir}${name}`, file])); } @@ -98,6 +135,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'); } @@ -124,8 +167,14 @@ async function wasm2Js (source) { * @param {{ * name: string, * instantiation?: 'async' | 'sync', + * cacheWasmCompile?: bool, + * staticWasmSourceImports?: 'proposed-standard-import-source' | 'non-standard-import', + * esmImports?: bool, * importBindings?: 'js' | 'optimized' | 'hybrid' | 'direct-optimized', * map?: Record, + * asyncMode?: string, + * asyncImports?: string[], + * asyncExports?: string[], * validLiftingOptimization?: bool, * tracing?: bool, * nodejsCompat?: bool, @@ -144,24 +193,25 @@ async function wasm2Js (source) { */ export async function transpileComponent (component, opts = {}) { await $init; - if (opts.instantiation) opts.wasiShim = false; + if (opts.instantiation && !opts.esmImports) opts.wasiShim = false; // TODO double check let spinner; const showSpinner = getShowSpinner(); - if (opts.optimize) { + if (opts.optimize || opts.asyncMode === 'asyncify') { if (showSpinner) setShowSpinner(true); ({ component } = await optimizeComponent(component, opts)); } if (opts.wasiShim !== false) { + const maybeAsync = !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 || {}); } @@ -176,10 +226,29 @@ export async function transpileComponent (component, opts = {}) { instantiation = { tag: 'async' }; } + let staticWasmSourceImports = null; + if (opts.staticWasmSourceImports) { + staticWasmSourceImports = { tag: opts.staticWasmSourceImports }; + } + + 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, + cacheWasmCompile: opts.cacheWasmCompile, + staticWasmSourceImports, + esmImports: opts.esmImports, + 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 9237c5435..d3429c710 100755 --- a/src/jco.js +++ b/src/jco.js @@ -51,6 +51,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]', 'use async imports and exports').choices(['sync', 'jspi', 'asyncify']).preset('sync')) + .option('--default-async-imports', 'default async component imports from WASI interfaces') + .option('--default-async-exports', 'default async component exports from WASI interfaces') + .option('--async-imports ', 'async component imports (examples: "wasi:io/poll@0.2.0#poll", "wasi:io/poll#[method]pollable.block")') + .option('--async-exports ', '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') @@ -60,6 +65,9 @@ program.command('transpile') .option('--stub', 'generate a stub implementation from a WIT file directly') .option('--js', 'output JS instead of core WebAssembly') .addOption(new Option('-I, --instantiation [mode]', 'output for custom module instantiation').choices(['async', 'sync']).preset('async')) + .option('--cache-wasm-compile', 'first `instantiate` call caches the compiled Wasm modules for subsequent `instantiate` calls') + .addOption(new Option('--static-wasm-source-imports [mode]', 'static import of Wasm module').choices(['proposed-standard-import-source', 'non-standard-import'])) + .option('--esm-imports', 'if `--instantiation` is set, use ESM imports instead of providing an import object') .option('-q, --quiet', 'disable output summary') .option('--no-namespaced-exports', 'disable namespaced exports for typescript compatibility') .option('--multi-memory', 'optimized output for Wasm multi-memory') @@ -75,6 +83,12 @@ 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')) + .option('--esm-imports', 'if `--instantiation` is set, use ESM imports instead of providing an import object') + .addOption(new Option('--async-mode [mode]', 'use async imports and exports').choices(['sync', 'jspi', 'asyncify']).preset('sync')) + .option('--default-async-imports', 'default async component imports from WASI interfaces') + .option('--default-async-exports', 'default async component exports from WASI interfaces') + .option('--async-imports ', 'async component imports (examples: "wasi:io/poll@0.2.0#poll", "wasi:io/poll#[method]pollable.block")') + .option('--async-exports ', '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/cli.js b/test/cli.js index 806aed85b..25fb97b1e 100644 --- a/test/cli.js +++ b/test/cli.js @@ -16,6 +16,8 @@ const multiMemory = execArgv.includes("--experimental-wasm-multi-memory") ? ["--multi-memory"] : []; +const AsyncFunction = (async () => {}).constructor; + export async function cliTest(_fixtures) { suite("CLI", () => { var tmpDir; @@ -119,6 +121,86 @@ export async function cliTest(_fixtures) { 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( diff --git a/test/fixtures/components/async_call.component.wasm b/test/fixtures/components/async_call.component.wasm new file mode 100644 index 000000000..a52483904 Binary files /dev/null and b/test/fixtures/components/async_call.component.wasm differ diff --git a/xtask/src/build/jco.rs b/xtask/src/build/jco.rs index 8f2577eae..f05e81b23 100644 --- a/xtask/src/build/jco.rs +++ b/xtask/src/build/jco.rs @@ -75,6 +75,9 @@ fn transpile(component_path: &str, name: String, optimize: bool) -> Result<()> { name, no_typescript: false, instantiation: None, + cache_wasm_compile: false, + static_wasm_source_imports: None, + esm_imports: false, map: Some(import_map), no_nodejs_compat: false, base64_cutoff: 5000_usize, @@ -84,6 +87,7 @@ fn transpile(component_path: &str, name: String, optimize: bool) -> Result<()> { no_namespaced_exports: true, multi_memory: true, import_bindings: Some(BindingsMode::Js), + 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 f4942d06c..c9a078f0d 100644 --- a/xtask/src/generate/wasi_types.rs +++ b/xtask/src/generate/wasi_types.rs @@ -30,6 +30,9 @@ pub(crate) fn run() -> Result<()> { no_typescript: false, no_nodejs_compat: false, instantiation: None, + cache_wasm_compile: false, + static_wasm_source_imports: None, + esm_imports: false, map: None, tla_compat: false, valid_lifting_optimization: false, @@ -38,6 +41,7 @@ pub(crate) fn run() -> Result<()> { no_namespaced_exports: true, multi_memory: false, import_bindings: Some(BindingsMode::Js), + async_mode: None, }; let files = generate_types(name, resolve, world, opts)?;