diff --git a/crates/js-component-bindgen-component/src/lib.rs b/crates/js-component-bindgen-component/src/lib.rs index 9535ad2e..f7b7537f 100644 --- a/crates/js-component-bindgen-component/src/lib.rs +++ b/crates/js-component-bindgen-component/src/lib.rs @@ -53,6 +53,20 @@ impl From for js_component_bindgen::BindingsMode { } } +impl From for js_component_bindgen::AsyncMode { + fn from(value: AsyncMode) -> Self { + match value { + AsyncMode::Sync => js_component_bindgen::AsyncMode::Sync, + AsyncMode::Jspi(AsyncImportsExports { imports, exports }) => { + js_component_bindgen::AsyncMode::JavaScriptPromiseIntegration { imports, exports } + } + AsyncMode::Asyncify(AsyncImportsExports { imports, exports }) => { + js_component_bindgen::AsyncMode::Asyncify { imports, exports } + } + } + } +} + struct JsComponentBindgenComponent; export!(JsComponentBindgenComponent); @@ -76,6 +90,7 @@ impl Guest for JsComponentBindgenComponent { multi_memory: options.multi_memory.unwrap_or(false), import_bindings: options.import_bindings.map(Into::into), guest: options.guest.unwrap_or(false), + async_mode: options.async_mode.map(Into::into), }; let js_component_bindgen::Transpiled { @@ -162,6 +177,7 @@ impl Guest for JsComponentBindgenComponent { multi_memory: false, import_bindings: None, guest: opts.guest.unwrap_or(false), + async_mode: opts.async_mode.map(Into::into), }; let files = generate_types(name, resolve, world, opts).map_err(|e| e.to_string())?; diff --git a/crates/js-component-bindgen-component/wit/js-component-bindgen.wit b/crates/js-component-bindgen-component/wit/js-component-bindgen.wit index 13e36eb3..61fcc0e8 100644 --- a/crates/js-component-bindgen-component/wit/js-component-bindgen.wit +++ b/crates/js-component-bindgen-component/wit/js-component-bindgen.wit @@ -58,13 +58,31 @@ world js-component-bindgen { /// Whether to generate namespaced exports like `foo as "local:package/foo"`. /// These exports can break typescript builds. no-namespaced-exports: option, - + /// Whether to generate module declarations like `declare module "local:package/foo" {...`. guest: option, /// Whether to output core Wasm utilizing multi-memory or to polyfill /// this handling. multi-memory: option, + + /// Configure whether to use `async` imports or exports with + /// JavaScript Promise Integration (JSPI) or Asyncify. + async-mode: option, + } + + record async-imports-exports { + imports: list, + exports: list, + } + + variant async-mode { + /// default to sync mode + sync, + /// use JavaScript Promise Integration (JSPI) + jspi(async-imports-exports), + /// use Asyncify + asyncify(async-imports-exports), } variant wit { @@ -96,6 +114,9 @@ world js-component-bindgen { features: option, /// Whether to generate module declarations like `declare module "local:package/foo" {...`. guest: option, + /// Configure whether to use `async` imports or exports with + /// JavaScript Promise Integration (JSPI) or Asyncify. + async-mode: option, } enum export-type { diff --git a/crates/js-component-bindgen/src/core.rs b/crates/js-component-bindgen/src/core.rs index 2ff1f64f..a414ec5a 100644 --- a/crates/js-component-bindgen/src/core.rs +++ b/crates/js-component-bindgen/src/core.rs @@ -39,7 +39,6 @@ use anyhow::{bail, Result}; use std::collections::{HashMap, HashSet}; use wasm_encoder::*; -use wasmparser::collections::IndexMap; use wasmparser::*; use wasmtime_environ::component::CoreDef; use wasmtime_environ::{EntityIndex, MemoryIndex, ModuleTranslation, PrimaryMap}; diff --git a/crates/js-component-bindgen/src/function_bindgen.rs b/crates/js-component-bindgen/src/function_bindgen.rs index 514a2ed4..a0fbed3f 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 413c6da9..456d15c9 100644 --- a/crates/js-component-bindgen/src/intrinsics.rs +++ b/crates/js-component-bindgen/src/intrinsics.rs @@ -5,6 +5,10 @@ use std::fmt::Write; #[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)] pub enum Intrinsic { + AsyncifyAsyncInstantiate, + AsyncifySyncInstantiate, + AsyncifyWrapExport, + AsyncifyWrapImport, Base64Compile, ClampGuest, ComponentError, @@ -23,6 +27,7 @@ pub enum Intrinsic { HasOwnProperty, I32ToF32, I64ToF64, + Imports, InstantiateCore, IsLE, ResourceTableFlag, @@ -114,6 +119,117 @@ pub fn render_intrinsics( for i in intrinsics.iter() { match i { + Intrinsic::AsyncifyAsyncInstantiate => output.push_str(" + const asyncifyModules = []; + let asyncifyPromise; + let asyncifyResolved; + async function asyncifyInstantiate(module, imports) { + const instance = await instantiateCore(module, imports); + const memory = instance.exports.memory || (imports && imports.env && imports.env.memory); + const realloc = instance.exports.cabi_realloc || instance.exports.cabi_export_realloc; + if (instance.exports.asyncify_get_state && memory) { + let address; + if (realloc) { + address = realloc(0, 0, 4, 1024); + new Int32Array(memory.buffer, address).set([address + 8, address + 1024]); + } else { + address = 16; + new Int32Array(memory.buffer, address).set([address + 8, address + 1024]); + } + asyncifyModules.push({ instance, memory, address }); + } + return instance; + } + function asyncifyState() { + return asyncifyModules[0]?.instance.exports.asyncify_get_state(); + } + function asyncifyAssertNoneState() { + let state = asyncifyState(); + if (state !== 0) { + throw new Error(`reentrancy not supported, expected asyncify state '0' but found '${state}'`); + } + } + "), + + Intrinsic::AsyncifySyncInstantiate => output.push_str(" + const asyncifyModules = []; + let asyncifyPromise; + let asyncifyResolved; + function asyncifyInstantiate(module, imports) { + const instance = instantiateCore(module, imports); + const memory = instance.exports.memory || (imports && imports.env && imports.env.memory); + const realloc = instance.exports.cabi_realloc || instance.exports.cabi_export_realloc; + if (instance.exports.asyncify_get_state && memory) { + let address; + if (realloc) { + address = realloc(0, 0, 4, 1024); + new Int32Array(memory.buffer, address).set([address + 8, address + 1024]); + } else { + address = 16; + new Int32Array(memory.buffer, address).set([address + 8, address + 1024]); + } + asyncifyModules.push({ instance, memory, address }); + } + return instance; + } + function asyncifyState() { + return asyncifyModules[0]?.instance.exports.asyncify_get_state(); + } + function asyncifyAssertNoneState() { + let state = asyncifyState(); + if (state !== 0) { + throw new Error(`reentrancy not supported, expected asyncify state '0' but found '${state}'`); + } + } + "), + + Intrinsic::AsyncifyWrapExport => output.push_str(" + function asyncifyWrapExport(fn) { + return async (...args) => { + if (asyncifyModules.length === 0) { + throw new Error(`none of the Wasm modules were processed with wasm-opt asyncify`); + } + asyncifyAssertNoneState(); + let result = fn(...args); + while (asyncifyState() === 1) { + asyncifyModules.forEach(({ instance }) => { + instance.exports.asyncify_stop_unwind(); + }); + asyncifyResolved = await asyncifyPromise; + asyncifyPromise = undefined; + asyncifyAssertNoneState(); + asyncifyModules.forEach(({ instance, address }) => { + instance.exports.asyncify_start_rewind(address); + }); + result = fn(...args); + } + asyncifyAssertNoneState(); + return result; + }; + } + "), + + Intrinsic::AsyncifyWrapImport => output.push_str(" + function asyncifyWrapImport(fn) { + return (...args) => { + if (asyncifyState() === 2) { + asyncifyModules.forEach(({ instance }) => { + instance.exports.asyncify_stop_rewind(); + }); + const ret = asyncifyResolved; + asyncifyResolved = undefined; + return ret; + } + asyncifyAssertNoneState(); + let value = fn(...args); + asyncifyModules.forEach(({ instance, address }) => { + instance.exports.asyncify_start_unwind(address); + }); + asyncifyPromise = value; + }; + } + "), + Intrinsic::Base64Compile => if !no_nodejs_compat { output.push_str(" const base64Compile = str => WebAssembly.compile(typeof Buffer !== 'undefined' ? Buffer.from(str, 'base64') : Uint8Array.from(atob(str), b => b.charCodeAt(0))); @@ -285,6 +401,8 @@ pub fn render_intrinsics( const i64ToF64 = i => (i64ToF64I[0] = i, i64ToF64F[0]); "), + Intrinsic::Imports => {}, + Intrinsic::InstantiateCore => if !instantiation { output.push_str(" const instantiateCore = WebAssembly.instantiate; @@ -654,6 +772,14 @@ impl Intrinsic { pub fn get_global_names() -> &'static [&'static str] { &[ // Intrinsic list exactly as below + "asyncifyAssertNoneState", + "asyncifyInstantiate", + "asyncifyModules", + "asyncifyPromise", + "asyncifyResolved", + "asyncifyState", + "asyncifyWrapExport", + "asyncifyWrapImport", "base64Compile", "clampGuest", "ComponentError", @@ -671,6 +797,7 @@ impl Intrinsic { "hasOwnProperty", "i32ToF32", "i64ToF64", + "imports", "instantiateCore", "isLE", "resourceCallBorrows", @@ -733,6 +860,10 @@ impl Intrinsic { pub fn name(&self) -> &'static str { match self { + Intrinsic::AsyncifyAsyncInstantiate => "asyncifyInstantiate", + Intrinsic::AsyncifySyncInstantiate => "asyncifyInstantiate", + Intrinsic::AsyncifyWrapExport => "asyncifyWrapExport", + Intrinsic::AsyncifyWrapImport => "asyncifyWrapImport", Intrinsic::Base64Compile => "base64Compile", Intrinsic::ClampGuest => "clampGuest", Intrinsic::ComponentError => "ComponentError", @@ -751,6 +882,7 @@ impl Intrinsic { Intrinsic::HasOwnProperty => "hasOwnProperty", Intrinsic::I32ToF32 => "i32ToF32", Intrinsic::I64ToF64 => "i64ToF64", + Intrinsic::Imports => "imports", Intrinsic::InstantiateCore => "instantiateCore", Intrinsic::IsLE => "isLE", Intrinsic::ResourceCallBorrows => "resourceCallBorrows", diff --git a/crates/js-component-bindgen/src/lib.rs b/crates/js-component-bindgen/src/lib.rs index 82791553..2c52cabd 100644 --- a/crates/js-component-bindgen/src/lib.rs +++ b/crates/js-component-bindgen/src/lib.rs @@ -8,7 +8,7 @@ pub mod function_bindgen; pub mod intrinsics; pub mod names; pub mod source; -pub use transpile_bindgen::{BindingsMode, InstantiationMode, TranspileOpts}; +pub use transpile_bindgen::{AsyncMode, BindingsMode, InstantiationMode, TranspileOpts}; use anyhow::Result; use transpile_bindgen::transpile_bindgen; diff --git a/crates/js-component-bindgen/src/transpile_bindgen.rs b/crates/js-component-bindgen/src/transpile_bindgen.rs index 00cb6af6..35788d8e 100644 --- a/crates/js-component-bindgen/src/transpile_bindgen.rs +++ b/crates/js-component-bindgen/src/transpile_bindgen.rs @@ -11,7 +11,7 @@ use crate::{uwrite, uwriteln}; use base64::{engine::general_purpose, Engine as _}; use heck::*; use std::cell::RefCell; -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fmt::Write; use std::mem; use wasmtime_environ::component::{ExportIndex, NameMap, NameMapNoIntern, Transcode}; @@ -70,6 +70,23 @@ pub struct TranspileOpts { pub multi_memory: bool, /// Whether to generate types for a guest module using module declarations. pub guest: bool, + /// Configure whether to use `async` imports or exports with + /// JavaScript Promise Integration (JSPI) or Asyncify. + pub async_mode: Option, +} + +#[derive(Default, Clone, Debug)] +pub enum AsyncMode { + #[default] + Sync, + JavaScriptPromiseIntegration { + imports: Vec, + exports: Vec, + }, + Asyncify { + imports: Vec, + exports: Vec, + }, } #[derive(Default, Clone, Debug)] @@ -117,6 +134,12 @@ struct JsBindgen<'a> { /// List of all intrinsics emitted to `src` so far. all_intrinsics: BTreeSet, + + /// List of all core Wasm exported functions (and if is async) referenced in + /// `src` so far. + all_core_exported_funcs: Vec<(String, bool)>, + + use_asyncify: bool, } #[allow(clippy::too_many_arguments)] @@ -130,6 +153,20 @@ pub fn transpile_bindgen( opts: TranspileOpts, files: &mut Files, ) -> (Vec, Vec<(String, Export)>) { + let (use_asyncify, async_imports, async_exports) = match opts.async_mode.clone() { + None | Some(AsyncMode::Sync) => (false, Default::default(), Default::default()), + Some(AsyncMode::JavaScriptPromiseIntegration { imports, exports }) => ( + false, + imports.into_iter().collect(), + exports.into_iter().collect(), + ), + Some(AsyncMode::Asyncify { imports, exports }) => ( + true, + imports.into_iter().collect(), + exports.into_iter().collect(), + ), + }; + let mut bindgen = JsBindgen { local_names: LocalNames::default(), src: Source::default(), @@ -137,6 +174,8 @@ pub fn transpile_bindgen( core_module_cnt: 0, opts: &opts, all_intrinsics: BTreeSet::new(), + all_core_exported_funcs: Vec::new(), + use_asyncify, }; bindgen .local_names @@ -157,6 +196,9 @@ pub fn transpile_bindgen( translation: component, component: &component.component, types, + use_asyncify, + async_imports, + async_exports, imports: Default::default(), exports: Default::default(), lowering_options: Default::default(), @@ -216,7 +258,7 @@ pub fn transpile_bindgen( (bindgen.esm_bindgen.import_specifiers(), exports) } -impl<'a> JsBindgen<'a> { +impl JsBindgen<'_> { fn finish_component( &mut self, name: &str, @@ -226,6 +268,33 @@ impl<'a> JsBindgen<'a> { ) { let mut output = source::Source::default(); let mut compilation_promises = source::Source::default(); + let mut core_exported_funcs = source::Source::default(); + + let async_wrap_fn = if self.use_asyncify { + &self.intrinsic(Intrinsic::AsyncifyWrapExport) + } else { + "WebAssembly.promising" + }; + for (core_export_fn, is_async) in self.all_core_exported_funcs.iter() { + let local_name = self.local_names.get(core_export_fn); + if *is_async { + uwriteln!( + core_exported_funcs, + "{local_name} = {async_wrap_fn}({core_export_fn});", + ); + } else { + uwriteln!(core_exported_funcs, "{local_name} = {core_export_fn};",); + } + } + + // adds a default implementation of `getCoreModule` + if matches!(self.opts.instantiation, Some(InstantiationMode::Async)) { + uwriteln!( + compilation_promises, + "if (!getCoreModule) getCoreModule = (name) => {}(new URL(`./${{name}}`, import.meta.url));", + self.intrinsic(Intrinsic::FetchCompile) + ); + } // Setup the compilation data and compilation promises let mut removed = BTreeSet::new(); @@ -298,6 +367,7 @@ impl<'a> JsBindgen<'a> { .render_imports(&mut output, imports_object, &mut self.local_names); if self.opts.instantiation.is_some() { + uwrite!(&mut self.src.js, "{}", &core_exported_funcs as &str); self.esm_bindgen.render_exports( &mut self.src.js, self.opts.instantiation.is_some(), @@ -366,6 +436,7 @@ impl<'a> JsBindgen<'a> { let gen = (function* init () {{ {}\ {}\ + {}\ }})(); let promise, resolve, reject; function runNext (value) {{ @@ -396,6 +467,7 @@ impl<'a> JsBindgen<'a> { &self.src.js as &str, &compilation_promises as &str, &self.src.js_init as &str, + &core_exported_funcs as &str, ); self.esm_bindgen.render_exports( @@ -443,6 +515,9 @@ struct Instantiator<'a, 'b> { /// Instance flags which references have been emitted externally at least once. used_instance_flags: RefCell>, defined_resource_classes: BTreeSet, + use_asyncify: bool, + async_imports: HashSet, + async_exports: HashSet, lowering_options: PrimaryMap, } @@ -1018,7 +1093,12 @@ impl<'a> Instantiator<'a, '_> { uwriteln!( self.src.js_init, "({{ exports: exports{iu32} }} = yield {instantiate}(yield module{}{imports}));", - idx.as_u32() + idx.as_u32(), + instantiate = if self.use_asyncify { + self.gen.intrinsic(Intrinsic::AsyncifyAsyncInstantiate) + } else { + instantiate + }, ) } @@ -1026,7 +1106,12 @@ impl<'a> Instantiator<'a, '_> { uwriteln!( self.src.js_init, "({{ exports: exports{iu32} }} = {instantiate}(module{}{imports}));", - idx.as_u32() + idx.as_u32(), + instantiate = if self.use_asyncify { + self.gen.intrinsic(Intrinsic::AsyncifySyncInstantiate) + } else { + instantiate + }, ) } } @@ -1097,6 +1182,17 @@ impl<'a> Instantiator<'a, '_> { WorldItem::Type(_) => unreachable!(), }; + let is_async = self + .async_imports + .contains(&format!("{import_name}#{func_name}")) + || import_name + .find('@') + .map(|i| { + self.async_imports + .contains(&format!("{}#{func_name}", import_name.get(0..i).unwrap())) + }) + .unwrap_or(false); + // nested interfaces only currently possible through mapping let (import_specifier, maybe_iface_member) = map_import( &self.gen.opts.map, @@ -1178,7 +1274,24 @@ impl<'a> Instantiator<'a, '_> { .len(); match self.gen.opts.import_bindings { None | Some(BindingsMode::Js) | Some(BindingsMode::Hybrid) => { - uwrite!(self.src.js, "\nfunction trampoline{}", trampoline.as_u32()); + if is_async { + if self.use_asyncify { + uwrite!( + self.src.js, + "\nconst trampoline{} = {}(async function", + trampoline.as_u32(), + self.gen.intrinsic(Intrinsic::AsyncifyWrapImport), + ); + } else { + uwrite!( + self.src.js, + "\nconst trampoline{} = new WebAssembly.Suspending(async function", + trampoline.as_u32() + ); + } + } else { + uwrite!(self.src.js, "\nfunction trampoline{}", trampoline.as_u32()); + } self.bindgen( nparams, call_type, @@ -1192,8 +1305,14 @@ impl<'a> Instantiator<'a, '_> { func, &resource_map, AbiVariant::GuestImport, + is_async, ); uwriteln!(self.src.js, ""); + if is_async { + uwriteln!(self.src.js, ");"); + } else { + uwriteln!(self.src.js, ""); + } } Some(BindingsMode::Optimized) | Some(BindingsMode::DirectOptimized) => { uwriteln!(self.src.js, "let trampoline{};", trampoline.as_u32()); @@ -1556,6 +1675,7 @@ impl<'a> Instantiator<'a, '_> { func: &Function, resource_map: &ResourceMap, abi: AbiVariant, + is_async: bool, ) { let memory = opts.memory.map(|idx| format!("memory{}", idx.as_u32())); let realloc = opts.realloc.map(|idx| format!("realloc{}", idx.as_u32())); @@ -1650,6 +1770,7 @@ impl<'a> Instantiator<'a, '_> { }, src: source::Source::default(), resolve: self.resolve, + is_async, }; abi::call( self.resolve, @@ -1930,14 +2051,50 @@ impl<'a> Instantiator<'a, '_> { export_name: &String, resource_map: &ResourceMap, ) { + let is_async = self.async_exports.contains(&func.name) + || self + .async_exports + .contains(&format!("{export_name}#{}", func.name)) + || export_name + .find('@') + .map(|i| { + self.async_exports.contains(&format!( + "{}#{}", + export_name.get(0..i).unwrap(), + func.name + )) + }) + .unwrap_or(false); + + let maybe_async = if is_async { "async " } else { "" }; + + let core_export_fn = self.core_def(def); + let callee = match self + .gen + .local_names + .get_or_create(&core_export_fn, &core_export_fn) + { + (local_name, true) => local_name.to_string(), + (local_name, false) => { + let local_name = local_name.to_string(); + uwriteln!(self.src.js, "let {local_name};"); + self.gen + .all_core_exported_funcs + .push((core_export_fn.clone(), is_async)); + local_name + } + }; + match func.kind { - FunctionKind::Freestanding => uwrite!(self.src.js, "\nfunction {local_name}"), + FunctionKind::Freestanding => { + uwrite!(self.src.js, "\n{maybe_async}function {local_name}") + } FunctionKind::Method(_) => { self.ensure_local_resource_class(local_name.to_string()); let method_name = func.item_name().to_lower_camel_case(); uwrite!( self.src.js, - "\n{local_name}.prototype.{method_name} = function {}", + "\n{local_name}.prototype.{method_name} = {maybe_async}function {}", if !is_js_reserved_word(&method_name) { method_name.to_string() } else { @@ -1971,7 +2128,6 @@ impl<'a> Instantiator<'a, '_> { self.defined_resource_classes.insert(local_name.to_string()); } } - let callee = self.core_def(def); self.bindgen( func.params.len(), match func.kind { @@ -1988,6 +2144,7 @@ impl<'a> Instantiator<'a, '_> { func, resource_map, AbiVariant::GuestExport, + is_async, ); match func.kind { FunctionKind::Freestanding => self.src.js("\n"), diff --git a/crates/js-component-bindgen/src/ts_bindgen.rs b/crates/js-component-bindgen/src/ts_bindgen.rs index 9d7f4313..68998e9c 100644 --- a/crates/js-component-bindgen/src/ts_bindgen.rs +++ b/crates/js-component-bindgen/src/ts_bindgen.rs @@ -2,7 +2,7 @@ use crate::files::Files; use crate::function_bindgen::{array_ty, as_nullable, maybe_null}; use crate::names::{is_js_identifier, maybe_quote_id, LocalNames, RESERVED_KEYWORDS}; use crate::source::Source; -use crate::transpile_bindgen::{parse_world_key, InstantiationMode, TranspileOpts}; +use crate::transpile_bindgen::{parse_world_key, AsyncMode, InstantiationMode, TranspileOpts}; use crate::{dealias, feature_gate_allowed, uwrite, uwriteln}; use anyhow::{Context as _, Result}; use heck::*; @@ -32,6 +32,9 @@ struct TsBindgen { /// Whether or not the types should be generated for a guest module guest: bool, + + async_imports: HashSet, + async_exports: HashSet, } /// Used to generate a `*.d.ts` file for each imported and exported interface for @@ -57,6 +60,15 @@ pub fn ts_bindgen( opts: &TranspileOpts, files: &mut Files, ) -> Result<()> { + let (async_imports, async_exports) = match opts.async_mode.clone() { + None | Some(AsyncMode::Sync) => (Default::default(), Default::default()), + Some(AsyncMode::JavaScriptPromiseIntegration { imports, exports }) => { + (imports.into_iter().collect(), exports.into_iter().collect()) + } + Some(AsyncMode::Asyncify { imports, exports }) => { + (imports.into_iter().collect(), exports.into_iter().collect()) + } + }; let mut bindgen = TsBindgen { src: Source::default(), interface_names: LocalNames::default(), @@ -64,6 +76,8 @@ pub fn ts_bindgen( import_object: Source::default(), export_object: Source::default(), guest: opts.guest, + async_imports, + async_exports, }; let world = &resolve.worlds[id]; @@ -373,7 +387,7 @@ impl TsBindgen { files: &mut Files, ) -> String { // in case an imported type is used as an exported type - let local_name = self.generate_interface(name, resolve, id, files); + let local_name = self.generate_interface(name, resolve, id, files, false); uwriteln!( self.import_object, "{}: typeof {local_name},", @@ -393,7 +407,7 @@ impl TsBindgen { if iface_name == "*" { uwrite!(self.import_object, "{}: ", maybe_quote_id(import_name)); let name = resolve.interfaces[id].name.as_ref().unwrap(); - let local_name = self.generate_interface(name, resolve, id, files); + let local_name = self.generate_interface(name, resolve, id, files, false); uwriteln!(self.import_object, "typeof {local_name},",); return; } @@ -401,7 +415,7 @@ impl TsBindgen { uwriteln!(self.import_object, "{}: {{", maybe_quote_id(import_name)); for (iface_name, &id) in ifaces { let name = resolve.interfaces[id].name.as_ref().unwrap(); - let local_name = self.generate_interface(name, resolve, id, files); + let local_name = self.generate_interface(name, resolve, id, files, false); uwriteln!( self.import_object, "{}: typeof {local_name},", @@ -420,7 +434,7 @@ impl TsBindgen { ) { uwriteln!(self.import_object, "{}: {{", maybe_quote_id(import_name)); let mut gen = TsInterface::new(resolve, false); - gen.ts_func(func, true, false); + gen.ts_func(func, true, false, false); let src = gen.finish(); self.import_object.push_str(&src); uwriteln!(self.import_object, "}},"); @@ -434,7 +448,7 @@ impl TsBindgen { files: &mut Files, instantiation: bool, ) -> String { - let local_name = self.generate_interface(export_name, resolve, id, files); + let local_name = self.generate_interface(export_name, resolve, id, files, false); if instantiation { uwriteln!( self.export_object, @@ -458,14 +472,26 @@ impl TsBindgen { fn export_funcs( &mut self, resolve: &Resolve, - _world: WorldId, + world: WorldId, funcs: &[(String, &Function)], _files: &mut Files, declaration: bool, ) { let mut gen = TsInterface::new(resolve, false); + let async_exports = self.async_exports.clone(); + let id_name = &resolve.worlds[world].name; for (_, func) in funcs { - gen.ts_func(func, false, declaration); + let func_name = &func.name; + let is_async = async_exports.contains(func_name) + || async_exports.contains(&format!("{id_name}#{func_name}")) + || id_name + .find('@') + .map(|i| { + async_exports + .contains(&format!("{}#{func_name}", id_name.get(0..i).unwrap())) + }) + .unwrap_or(false); + gen.ts_func(func, false, declaration, is_async); } let src = gen.finish(); self.export_object.push_str(&src); @@ -477,6 +503,7 @@ impl TsBindgen { resolve: &Resolve, id: InterfaceId, files: &mut Files, + is_world_export: bool, ) -> String { let iface = resolve .interfaces @@ -525,6 +552,12 @@ impl TsBindgen { return local_name; } + let async_funcs = if is_world_export { + self.async_exports.clone() + } else { + self.async_imports.clone() + }; + let module_or_namespace = if self.guest { format!("declare module '{id_name}' {{") } else { @@ -541,7 +574,16 @@ impl TsBindgen { { continue; } - gen.ts_func(func, false, true); + let func_name = &func.name; + let is_async = is_world_export && async_funcs.contains(func_name) + || async_funcs.contains(&format!("{id_name}#{func_name}")) + || id_name + .find('@') + .map(|i| { + async_funcs.contains(&format!("{}#{func_name}", id_name.get(0..i).unwrap())) + }) + .unwrap_or(false); + gen.ts_func(func, false, true, is_async); } // Export resources for the interface for (_, ty) in resolve.interfaces[id].types.iter() { @@ -735,7 +777,7 @@ impl<'a> TsInterface<'a> { self.src.push_str("]"); } - fn ts_func(&mut self, func: &Function, default: bool, declaration: bool) { + fn ts_func(&mut self, func: &Function, default: bool, declaration: bool, is_async: bool) { let iface = if let FunctionKind::Method(ty) | FunctionKind::Static(ty) | FunctionKind::Constructor(ty) = func.kind @@ -760,11 +802,15 @@ impl<'a> TsInterface<'a> { func.item_name().to_lower_camel_case() }; + let maybe_async = if is_async { "async " } else { "" }; + if declaration { match func.kind { FunctionKind::Freestanding => { if is_js_identifier(&out_name) { - iface.src.push_str(&format!("export function {out_name}")); + iface + .src + .push_str(&format!("export {maybe_async}function {out_name}")); } else { let (local_name, _) = iface.local_names.get_or_create(&out_name, &out_name); iface @@ -772,21 +818,25 @@ impl<'a> TsInterface<'a> { .push_str(&format!("export {{ {local_name} as {out_name} }};\n")); iface .src - .push_str(&format!("declare function {local_name}")); + .push_str(&format!("declare {maybe_async}function {local_name}")); }; } FunctionKind::Method(_) => { if is_js_identifier(&out_name) { - iface.src.push_str(&out_name); + iface.src.push_str(&format!("{maybe_async}{out_name}")); } else { - iface.src.push_str(&format!("'{out_name}'")); + iface.src.push_str(&format!("{maybe_async}'{out_name}'")); } } FunctionKind::Static(_) => { if is_js_identifier(&out_name) { - iface.src.push_str(&format!("static {out_name}")) + iface + .src + .push_str(&format!("static {maybe_async}{out_name}")) } else { - iface.src.push_str(&format!("static '{out_name}'")) + iface + .src + .push_str(&format!("static {maybe_async}'{out_name}'")) } } FunctionKind::Constructor(_) => { @@ -795,9 +845,9 @@ impl<'a> TsInterface<'a> { } } } else if is_js_identifier(&out_name) { - iface.src.push_str(&out_name); + iface.src.push_str(&format!("{maybe_async}{out_name}")); } else { - iface.src.push_str(&format!("'{out_name}'")); + iface.src.push_str(&format!("{maybe_async}'{out_name}'")); } let end_character = if declaration { ';' } else { ',' }; @@ -834,6 +884,10 @@ impl<'a> TsInterface<'a> { } iface.src.push_str(": "); + if is_async { + iface.src.push_str("Promise<"); + } + if let Some((ok_ty, _)) = func.results.throws(iface.resolve) { iface.print_optional_ty(ok_ty); } else { @@ -852,6 +906,12 @@ impl<'a> TsInterface<'a> { } } } + + if is_async { + // closes `Promise<>` + iface.src.push_str(">"); + } + iface.src.push_str(format!("{}\n", end_character).as_str()); } diff --git a/crates/wasm-tools-component/src/lib.rs b/crates/wasm-tools-component/src/lib.rs index 75bedcc3..8db4d61f 100644 --- a/crates/wasm-tools-component/src/lib.rs +++ b/crates/wasm-tools-component/src/lib.rs @@ -184,8 +184,12 @@ impl Guest for WasmToolsJs { } fn metadata_show(binary: Vec) -> Result, String> { - let metadata = - wasm_metadata::Metadata::from_binary(&binary).map_err(|e| format!("{:?}", e))?; + let metadata = wasm_metadata::Metadata::from_binary(&binary).map_err(|e| { + format!( + "failed to read wasm metadata from binary ({} bytes): {e:?}", + binary.len(), + ) + })?; let mut module_metadata: Vec = Vec::new(); let mut to_flatten: VecDeque = VecDeque::new(); to_flatten.push_back(metadata); diff --git a/docs/src/contributing.md b/docs/src/contributing.md index 888d31de..b60f114a 100644 --- a/docs/src/contributing.md +++ b/docs/src/contributing.md @@ -39,17 +39,38 @@ jco is effectively a monorepo consisting of the following projects: ## Building -To build jco, run: +To build `jco`, run: -``` +```console npm install npm run build ``` ## Testing -There are three test suites in jco: +There are three test suites in `jco`: * `npm run test`: Project-level transpilation, CLI & API tests. * `npm run test --workspace packages/preview2-shim`: `preview2-shim` unit tests. * `test/browser.html`: Bare-minimum browser validation test. * `cargo test`: Wasmtime preview2 conformance tests (not currently passing). + +### Test-time environment variables + +Some environment variables may be used to control test-time behavior, the list below is best-effort, and +may not represent every single available environment variable: + +| ENV Variable | Example | Description | +|-----------------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------| +| `TEST_DEBUG` | `true` | Used to control whether debugging information (logs, etc) is turned on during test | +| `TEST_CUSTOM_ENGINE_JIT_PATH` | `path/to/some-starling-monkey-build.wasm` | Path to the starling monkey build that should be used *instead* of the default (normally used to get a pre-async-optimized build) | +| `TEST_CUSTOM_ENGINE_AOT_PATH` | `path/to/weval` | Path to the weval AOT engine | +| `TEST_CUSTOM_ENGINE_PREOPTIMIZED` | `true` | Tells the tests whether the custom engine is is preoptimized | +| `TEST_DEBUG_NO_CLEANUP` | `true` | Disable cleaning up after tests (note that some cleanup is done by `node` directly, i.e. `mkdtemp`) | + +### Debugging + +While running tests, it may be helpful to enable test debugging (ex. seeing logging output of tests, headless puppeteer browser, etc). To do that you can use `TEST_DEBUG`: + +```console +TEST_DEBUG=true npm run test +``` diff --git a/docs/src/transpiling.md b/docs/src/transpiling.md index fd88b0b2..60fe340c 100644 --- a/docs/src/transpiling.md +++ b/docs/src/transpiling.md @@ -53,6 +53,10 @@ Options include: * `--tracing`: Emit tracing calls for all function entry and exits. * `--no-namespaced-exports`: Removes exports of the type `test as "test:flavorful/test"` which are not compatible with typescript +* `--async-mode [mode]`: 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 Jco itself can be used in the browser, which provides the simpler Jco API that is just exactly the same diff --git a/package.json b/package.json index 763e1bce..628138bb 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,35 @@ "name": "@bytecodealliance/jco", "version": "1.9.1", "description": "JavaScript tooling for working with WebAssembly Components", + "homepage": "https://github.com/bytecodealliance/jco#readme", "author": "Guy Bedford", - "bin": { - "jco": "src/jco.js" + "license": "(Apache-2.0 WITH LLVM-exception)", + "repository": { + "type": "git", + "url": "git+https://github.com/bytecodealliance/jco.git" + }, + "bugs": { + "url": "https://github.com/bytecodealliance/jco/issues" + }, + "keywords": [ + "Wasm", + "WebAssembly", + "Component" + ], + "type": "module", + "files": [ + "lib", + "src", + "obj/*.core*.wasm", + "obj/*.js", + "obj/*.ts", + "obj/interfaces" + ], + "imports": { + "#ora": { + "browser": "./src/ora-shim.js", + "default": "ora" + } }, "exports": { ".": { @@ -16,13 +42,21 @@ "default": "./src/browser.js" } }, - "imports": { - "#ora": { - "browser": "./src/ora-shim.js", - "default": "ora" - } + "bin": { + "jco": "src/jco.js" }, - "type": "module", + "scripts": { + "build": "cargo xtask build debug", + "build:release": "cargo xtask build release", + "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 600000", + "test": "node --experimental-wasm-jspi --stack-trace-limit=100 node_modules/mocha/bin/mocha.js -u tdd test/test.js --timeout 600000", + "prepublishOnly": "cargo xtask build release && npm run test" + }, + "workspaces": [ + "packages/preview2-shim" + ], "dependencies": { "@bytecodealliance/componentize-js": "^0.16.0", "@bytecodealliance/preview2-shim": "^0.17.1", @@ -42,39 +76,5 @@ "mocha": "^10.7.0", "puppeteer": "^24.0.1", "typescript": "^5.5.4" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/bytecodealliance/jco.git" - }, - "keywords": [ - "Wasm", - "WebAssembly", - "Component" - ], - "license": "(Apache-2.0 WITH LLVM-exception)", - "bugs": { - "url": "https://github.com/bytecodealliance/jco/issues" - }, - "homepage": "https://github.com/bytecodealliance/jco#readme", - "scripts": { - "build": "cargo xtask build debug", - "build:release": "cargo xtask build release", - "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", - "prepublishOnly": "cargo xtask build release && npm run test" - }, - "files": [ - "lib", - "src", - "obj/*.core*.wasm", - "obj/*.js", - "obj/*.ts", - "obj/interfaces" - ], - "workspaces": [ - "packages/preview2-shim" - ] + } } 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 00000000..cf7c47fd --- /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 00000000..1047a210 --- /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 00000000..7b7fe9d3 --- /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 00000000..917c6816 --- /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 00000000..2ef8de53 --- /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 00000000..f37a0943 --- /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 00000000..7d592941 --- /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 00000000..d2335fcb --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/http/incoming-handler.js @@ -0,0 +1,36 @@ +// wasi:http/incoming-handler@0.2.0 interface + +import { IncomingRequest, ResponseOutparam } from "./types.js"; + +/** + * Polyfill for handling an incoming wasi:http request (i.e. `wasi:http/incoming-handler.handle`) + * + * Generally browsers generally do not provide implementations of wasi:http/incoming-handler.handle, + * so this + * + * @param {IncomingRequest} incomingRequest + * @param {ResponseOutparam} responseOutparam + * @returns void + */ +export const handle = async (incomingRequest, responseOutparam) => { + throw new Error("Unimplemented - browsers generally do not provide implementations of wasi:http/incoming-handler.handle"); +}; + +/** + * Helper function that given a wasi:http compliant incoming-handler function, + * calls the function with native Web platform `Request`s, waits for the response to be computed, + * and returns the + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Request/Request + * @param {Function} handle - A function that adheres to the WASI HTTP spec for incoming-handler + * @returns void + */ +export const genHandler = (handle) => async (req) => { + const responseOut = new ResponseOutparam(); + await handle(IncomingRequest.fromRequest(req), responseOut); + const result = await responseOut.promise; + if (result.tag !== "ok") { + throw result; // error + } + return result.val.toResponse(); +}; 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 00000000..b986fc59 --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/http/outgoing-handler.js @@ -0,0 +1,16 @@ +// wasi:http/outgoing-handler@0.2.0 interface + +import { FutureIncomingResponse } from "./types.js"; + +/** + * Polyfill for handling an outgoing wasi:http request (i.e. `wasi:http/outgoing-handler.handle`) + * + * Generally this is resolved by making an external request (`fetch`, XMLHTTPRequest) + * + * @param {OutgoingRequest} incomingRequest + * @param {ResponseOutparam} responseOutparam + * @returns void + */ +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 00000000..2d665b8b --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/http/types.js @@ -0,0 +1,798 @@ +// 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 + */ + +/** + * HTTP fields + * (wasi:http/types#fields) + * + */ +export class Fields { + headers; + immutable; + + /** + * @param {Headers} [headers] + * @param {boolean} [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, + ); + } +} + +/** + * Helper that represents a wasi:http/types#outgoing-request, with extra functionality + * for conversions between WASI and web standard types. + */ +export class OutgoingRequest { + /** + * Headers that should be sent with the outgoing request + * @type {Fields} + */ + #headers; + /** + * Method of the outgoing request (maps to wasi:http/types#method) + * + * @type {string} + */ + #method; + /** + * Path (with query) of the outgoing request + * + * @type {string} + */ + #pathWithQuery; + /** + * Scheme of the request + * + * @type {string} + */ + #scheme; + /** + * Authority of the request + * @type {string} + */ + #authority; + /** + * Body of the request (maps to a wasi:http/types#outgoing-body) + * + * @type {OutgoingBody} + */ + #body; + + /** + * Create a new `OutgoingBody` + * + * @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, + }); + } +} + +/** + * Outgoing body of the request + */ +export class OutgoingBody { + /** + * Whether the body is finished or not + * @type {boolean} + */ + 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 NotReadyYetError extends Error { + constructor(m) { + super(m); + } +} + +/** + * Performs external request (e.x `wasi:http/outgoing-handler.handle`) + */ +export class FutureIncomingResponse { + /** Promise that represents the request in-flight */ + #promise; + /** Resolved data produced by the response only available after successful resolution */ + #resolvedResponse; + /** Whether or not the promise is resolved or not */ + #ready = false; + /** Error (if one has occurred) that maps to the wasi:http/types#error-code variant */ + #error; + + /** + * Create a new FutureIncomingResponse + * + * The HTTP request is started upon creation of the FutureIncomingResponse object. + * + * @param {OutgoingRequest} request + * @returns void + */ + constructor(request) { + try { + this.#promise = fetch(request.toRequest()).then((response) => { + this.#ready = true; + this.#resolvedResponse = response; + }); + } catch (err) { + // TODO: some mechanism for enabling/disabling console logging at the browser level? + console.error(err); + this.#promise = Promise.resolve(); + this.#ready = true; + // TODO better error handling + this.#error = { tag: "internal-error", val: err.toString() }; + } + } + + /** + * Enable subscribing to this FutureIncomingResponse + * from WASI contexts by producing a Pollable. + */ + subscribe() { + return new Pollable(this.#promise); + } + + /** + * Retrieve the repsonse from this incoming response + */ + get() { + if (!this.#ready) return { tag: "err", val: new NotReadyYetError("FutureIncomingRequest is not ready yet") }; + 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 00000000..58a86db6 --- /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 00000000..8208ab47 --- /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 00000000..8df66fe8 --- /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 00000000..a63d0bb3 --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/io/poll.js @@ -0,0 +1,96 @@ +// wasi:io/poll@0.2.0 interface + + + +// Pollable represents a single I/O event which may be ready, or not. +export class Pollable { + /** + * @type {{ ready: (boolean | function(): boolean), asyncFunc: (undefined | function(): Promise) }} + */ + #state; + + /** + * Sets the pollable to ready whether the promise is resolved or + * rejected. + * + * @param {Promise|{ ready: function(): boolean, asyncFunc: function(): Promise }|undefined|null} state + */ + constructor(state) { + if (!state) { + // always ready + this.#state = { ready: true }; + } else if (state.then) { + // is single promise that terminates into ready state + const setReady = () => { + this.#state.ready = true; + }; + this.#state = { ready: false, asyncFunc: () => state.then(setReady, setReady) }; + } else { + // can be multiple promises that could change readiness + this.#state = state; + } + } + + /** + * Return the readiness of a pollable. This function never blocks. + * + * Returns `true` when the pollable is ready, and `false` otherwise. + * + * @returns {boolean} + */ + ready() { + // `this.#state.ready` could be `true`, `false`, or a `function(): boolean` + return !this.#state.ready ? + false : this.#state.ready === true ? + true : this.#state.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() { + if (!this.ready()) { + await this.#state.asyncFunc(); + } + } +} + +/** + * 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 00000000..78a57046 --- /dev/null +++ b/packages/preview2-shim/lib/browser-async/io/streams.js @@ -0,0 +1,283 @@ +// 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() { + if (this.#buffer.byteLength === 0) { + while (true) { + const { value, done } = await this.#reader.read(); + if (done) { + this.#closed = done; + return; + } else if (!value || value.byteLength === 0) { + // try again if returned empty buffer but is still open + continue; + } else { + this.#buffer = value; + return; + } + } + } + } + + /** + * @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.#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 new Pollable({ + ready: () => this.#buffer.byteLength > 0 || this.#closed, + asyncFunc: () => 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 { + try { + // wait for previous write to finish first + if (this.#prevWritePromise) { + await this.#prevWritePromise; + } + 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 00000000..c9f765c0 --- /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 00000000..47239a7b --- /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 00000000..50bab1da --- /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 00000000..6dda749c --- /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 a2654118..9238fa0e 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 0c473858..cd99fb4a 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 54ed9b96..7744a66d 100644 --- a/packages/preview2-shim/package.json +++ b/packages/preview2-shim/package.json @@ -15,6 +15,14 @@ "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": { diff --git a/src/api.js b/src/api.js index 04d8e8c7..cfdd1f74 100644 --- a/src/api.js +++ b/src/api.js @@ -1,7 +1,17 @@ +import { $init, tools } from "../obj/wasm-tools.js"; + +const { + print: printFn, + parse: parseFn, + componentWit: componentWitFn, + componentNew: componentNewFn, + componentEmbed: componentEmbedFn, + metadataAdd: metadataAddFn, + metadataShow: metadataShowFn +} = tools; + export { optimizeComponent as opt } from './cmd/opt.js'; export { transpileComponent as transpile, typesComponent as types } from './cmd/transpile.js'; -import { $init, tools } from "../obj/wasm-tools.js"; -const { print: printFn, parse: parseFn, componentWit: componentWitFn, componentNew: componentNewFn, componentEmbed: componentEmbedFn, metadataAdd: metadataAddFn, metadataShow: metadataShowFn } = tools; /** * @param {Parameters[0]} binary diff --git a/src/cmd/componentize.js b/src/cmd/componentize.js index 3890a09b..ef1c6a67 100644 --- a/src/cmd/componentize.js +++ b/src/cmd/componentize.js @@ -2,22 +2,38 @@ import { readFile, writeFile } from 'node:fs/promises'; import { resolve, basename } from 'node:path'; import c from 'chalk-template'; -export async function componentize (jsSource, opts) { +/** + * `jco componentize` CLI command implementation + * + * @param {string} sourcePath - Path to JS source code + * @param {object} opts - ComponentizeJS options + * @param {string} opts.wit - Path to a WIT file or directory to use + * @param {string} [opts.worldName] - Name of the WIT world to target + * @param {string} [opts.aot] - Whether to use AoT (via Weval) + * @param {string} [opts.engine] - Use a custom engine build (SpiderMonkey/StarlingMonkey) when componentizing + * @param {string[]} [opts.disable] - A list of features (i.e. WASI) to disable when building + * @param {string} [opts.preview2Adapter] - Path to a custom preview2 adapter + * @param {string} opts.out - Path to which to write the WebAssembly component output + */ +export async function componentize(sourcePath, opts) { const { componentize: componentizeFn } = await eval('import("@bytecodealliance/componentize-js")'); if (opts.disable?.includes('all')) { opts.disable = ['stdio', 'random', 'clocks', 'http']; } - const source = await readFile(jsSource, 'utf8'); + const source = await readFile(sourcePath, 'utf8'); + const { component } = await componentizeFn(source, { enableAot: opts.aot, - wevalBin: opts.wevalBin, - sourceName: basename(jsSource), + sourceName: basename(sourcePath), witPath: resolve(opts.wit), worldName: opts.worldName, disableFeatures: opts.disable, enableFeatures: opts.enable, preview2Adapter: opts.preview2Adapter, + engine: opts.engine, }); await writeFile(opts.out, component); - console.log(c`{green OK} Successfully written {bold ${opts.out}}.`); + if (!opts.quiet) { + console.log(c`{green OK} Successfully written {bold ${opts.out}}.`); + } } diff --git a/src/cmd/opt.js b/src/cmd/opt.js index 3a1ceeb6..fee494e2 100644 --- a/src/cmd/opt.js +++ b/src/cmd/opt.js @@ -1,11 +1,15 @@ -import { $init, tools } from '../../obj/wasm-tools.js'; -const { metadataShow, print } = tools; -import { writeFile } from 'fs/promises'; +import { env } from 'node:process'; +import { writeFile, stat } from 'node:fs/promises'; + import { fileURLToPath } from 'url'; import c from 'chalk-template'; -import { readFile, sizeStr, fixedDigitDisplay, table, spawnIOTmp, setShowSpinner, getShowSpinner } from '../common.js'; import ora from '#ora'; +import { readFile, sizeStr, fixedDigitDisplay, table, spawnIOTmp, setShowSpinner, getShowSpinner } from '../common.js'; + +import { $init, tools } from '../../obj/wasm-tools.js'; +const { metadataShow, print } = tools; + export async function opt (componentPath, opts, program) { await $init; const varIdx = program.parent.rawArgs.indexOf('--'); @@ -45,9 +49,9 @@ ${table([...compressionInfo.map(({ beforeBytes, afterBytes }, i) => { } /** - * - * @param {Uint8Array} componentBytes - * @param {{ quiet: boolean, optArgs?: string[] }} options? + * + * @param {Uint8Array} componentBytes + * @param {{ quiet: boolean, asyncMode?: string, optArgs?: string[], wasmOptBinPath?: string }} opts? * @returns {Promise<{ component: Uint8Array, compressionInfo: { beforeBytes: number, afterBytes: number }[] >} */ export async function optimizeComponent (componentBytes, opts) { @@ -67,8 +71,25 @@ export async function optimizeComponent (componentBytes, opts) { spinner.text = spinnerText(); } + // TODO: if someone provides *more* than the default set of arguments, we need to do a fresh wasmOpt run + // and we can't depend on the pre-optimized version + + const args = opts?.optArgs ? [...opts.optArgs] : ['-Oz', '--low-memory-unused', '--enable-bulk-memory', '--strip-debug']; + if (opts?.asyncMode === 'asyncify') args.push('--asyncify'); + + // TODO: pre-asyncify builds of starling-monkey.wasm (i.e output from SM builds) + // TODO: add option for custom starling-monkey.wasm to componentize-js + // TODO: option in JCO to skip wasm-opt in the presence of pre-asyncified starling-monkey build + // - This can be detected by looking at the exports (asyncify's exports)! + + // TODO: pull down pre-optimized build of SM + const optimizedCoreModules = await Promise.all(coreModules.map(async ([coreModuleStart, coreModuleEnd]) => { - const optimized = wasmOpt(componentBytes.subarray(coreModuleStart, coreModuleEnd), opts?.optArgs); + const optimized = wasmOpt({ + moduleBytes: componentBytes.subarray(coreModuleStart, coreModuleEnd), + cliArgs: args, + wasmOptBinPath: opts?.wasmOptBinPath, + }); if (spinner) { completed++; spinner.text = spinnerText(); @@ -76,7 +97,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,17 +131,17 @@ 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 { await print(outComponentBytes); } catch (e) { - throw new Error(`Internal error performing optimization.\n${e.message}`) + throw new Error(`Internal error performing optimization.\n${e.message}`); } return { @@ -129,19 +156,39 @@ export async function optimizeComponent (componentBytes, opts) { } /** - * @param {Uint8Array} source + * Optimize a WebAssembly module, using wasm-opt + * + * NOTE: this can take minutes on a nearly empty JS compnent. + * + * @param {object} args + * @param {Uint8Array} args.moduleBytes - Wasm module bytes + * @param {Array} args.cliArgs - CLI arguments to feed to wasmOpt + * @param {string} [args.wasmOptBinPath] - Path to wasm-opt binary * @returns {Promise} */ -async function wasmOpt(source, args = ['-O1', '--low-memory-unused', '--enable-bulk-memory']) { - const wasmOptPath = fileURLToPath(import.meta.resolve('binaryen/bin/wasm-opt')); +async function wasmOpt(args) { + const { + moduleBytes, + cliArgs, + } = args; + // Get wasmOpt binary, ensure it exists + const wasmOptPath = env.WASM_OPT_BIN_PATH ?? args?.wasmOptBinPath ?? fileURLToPath(import.meta.resolve('binaryen/bin/wasm-opt')); + try { + await stat(wasmOptPath); + } catch (err) { + if (err && err.code && err.code === 'ENOENT') { + throw new Error(`Missing/invalid binary for wasm-opt [${wasmOptPath}] (do you need to specify WASM_OPT_BIN_PATH ?`); + } + throw err; + } + // Run wasm-opt try { - return await spawnIOTmp(wasmOptPath, source, [ - ...args, '-o' - ]); + return await spawnIOTmp(wasmOptPath, moduleBytes, [...cliArgs, '-o']); } catch (e) { - if (e.toString().includes('BasicBlock requested')) - return wasmOpt(source, args); + if (e.toString().includes('BasicBlock requested')) { + return wasmOpt(args); + } throw e; } } diff --git a/src/cmd/transpile.js b/src/cmd/transpile.js index 1282be09..d9572422 100644 --- a/src/cmd/transpile.js +++ b/src/cmd/transpile.js @@ -14,6 +14,22 @@ import { platform } from 'node:process'; const isWindows = platform === 'win32'; +const DEFAULT_ASYNC_IMPORTS = [ + "wasi:io/poll#poll", + "wasi:io/poll#[method]pollable.block", + "wasi:io/streams#[method]input-stream.blocking-read", + "wasi:io/streams#[method]input-stream.blocking-skip", + "wasi:io/streams#[method]output-stream.blocking-flush", + "wasi:io/streams#[method]output-stream.blocking-write-and-flush", + "wasi:io/streams#[method]output-stream.blocking-write-zeroes-and-flush", + "wasi:io/streams#[method]output-stream.blocking-splice", +]; + +const DEFAULT_ASYNC_EXPORTS = [ + "wasi:cli/run#run", + "wasi:http/incoming-handler#handle", +]; + export async function types (witPath, opts) { const files = await typesComponent(witPath, opts); await writeFiles(files, opts.quiet ? false : 'Generated Type Files'); @@ -31,6 +47,9 @@ export async function guestTypes (witPath, opts) { * worldName?: string, * instantiation?: 'async' | 'sync', * tlaCompat?: bool, + * asyncMode?: string, + * asyncImports?: string[], + * asyncExports?: string[], * outDir?: string, * features?: string[] | 'all', * guest?: bool, @@ -57,6 +76,21 @@ export async function typesComponent (witPath, opts) { features = { tag: 'list', val: opts.feature }; } + if (opts.defaultAsyncImports) + opts.asyncImports = DEFAULT_ASYNC_IMPORTS.concat(opts.asyncImports || []); + if (opts.defaultAsyncExports) + opts.asyncExports = DEFAULT_ASYNC_EXPORTS.concat(opts.asyncExports || []); + + const asyncMode = !opts.asyncMode || opts.asyncMode === 'sync' ? + null : + { + tag: opts.asyncMode, + val: { + imports: opts.asyncImports || [], + exports: opts.asyncExports || [], + }, + }; + return Object.fromEntries(generateTypes(name, { wit: { tag: 'path', val: (isWindows ? '//?/' : '') + resolve(witPath) }, instantiation, @@ -64,6 +98,7 @@ export async function typesComponent (witPath, opts) { world: opts.worldName, features, guest: opts.guest ?? false, + asyncMode, }).map(([name, file]) => [`${outDir}${name}`, file])); } @@ -105,6 +140,12 @@ export async function transpile (componentPath, opts, program) { opts.name = basename(componentPath.slice(0, -extname(componentPath).length || Infinity)); if (opts.map) opts.map = Object.fromEntries(opts.map.map(mapping => mapping.split('='))); + + if (opts.defaultAsyncImports) + opts.asyncImports = DEFAULT_ASYNC_IMPORTS.concat(opts.asyncImports || []); + if (opts.defaultAsyncExports) + opts.asyncExports = DEFAULT_ASYNC_EXPORTS.concat(opts.asyncExports || []); + const { files } = await transpileComponent(component, opts); await writeFiles(files, opts.quiet ? false : 'Transpiled JS Component Files'); } @@ -133,6 +174,9 @@ async function wasm2Js (source) { * instantiation?: 'async' | 'sync', * importBindings?: 'js' | 'optimized' | 'hybrid' | 'direct-optimized', * map?: Record, + * asyncMode?: string, + * asyncImports?: string[], + * asyncExports?: string[], * validLiftingOptimization?: bool, * tracing?: bool, * nodejsCompat?: bool, @@ -141,11 +185,13 @@ async function wasm2Js (source) { * js?: bool, * minify?: bool, * optimize?: bool, + * preoptimized?: bool, * namespacedExports?: bool, * outDir?: string, * multiMemory?: bool, * experimentalIdlImports?: bool, * optArgs?: string[], + * wasmOptBinPath?: string, * }} opts * @returns {Promise<{ files: { [filename: string]: Uint8Array }, imports: string[], exports: [string, 'function' | 'instance'][] }>} */ @@ -155,20 +201,27 @@ export async function transpileComponent (component, opts = {}) { let spinner; const showSpinner = getShowSpinner(); - if (opts.optimize) { + + // We must perform optimization if it's requested, or if the component has *not* + // already been built with already optimized code before wizer init, if using asyncify. + // + // Preoptimized is generally used internally (users cannot pass it in as a CLI option), + // but they may pass in asyncMode + if (opts.optimize || !opts.preoptimized && opts.asyncMode === 'asyncify') { if (showSpinner) setShowSpinner(true); ({ component } = await optimizeComponent(component, opts)); } if (opts.wasiShim !== false) { + const maybeAsync = !opts.asyncMode || opts.asyncMode === 'sync' ? '' : 'async/'; opts.map = Object.assign({ - 'wasi:cli/*': '@bytecodealliance/preview2-shim/cli#*', - 'wasi:clocks/*': '@bytecodealliance/preview2-shim/clocks#*', - 'wasi:filesystem/*': '@bytecodealliance/preview2-shim/filesystem#*', - 'wasi:http/*': '@bytecodealliance/preview2-shim/http#*', - 'wasi:io/*': '@bytecodealliance/preview2-shim/io#*', - 'wasi:random/*': '@bytecodealliance/preview2-shim/random#*', - 'wasi:sockets/*': '@bytecodealliance/preview2-shim/sockets#*', + 'wasi:cli/*': `@bytecodealliance/preview2-shim/${maybeAsync}cli#*`, + 'wasi:clocks/*': `@bytecodealliance/preview2-shim/${maybeAsync}clocks#*`, + 'wasi:filesystem/*': `@bytecodealliance/preview2-shim/${maybeAsync}filesystem#*`, + 'wasi:http/*': `@bytecodealliance/preview2-shim/${maybeAsync}http#*`, + 'wasi:io/*': `@bytecodealliance/preview2-shim/${maybeAsync}io#*`, + 'wasi:random/*': `@bytecodealliance/preview2-shim/${maybeAsync}random#*`, + 'wasi:sockets/*': `@bytecodealliance/preview2-shim/${maybeAsync}sockets#*`, }, opts.map || {}); } @@ -183,10 +236,21 @@ export async function transpileComponent (component, opts = {}) { instantiation = { tag: 'async' }; } + const asyncMode = !opts.asyncMode || opts.asyncMode === 'sync' ? + null : + { + tag: opts.asyncMode, + val: { + imports: opts.asyncImports || [], + exports: opts.asyncExports || [], + }, + }; + let { files, imports, exports } = generate(component, { name: opts.name ?? 'component', map: Object.entries(opts.map ?? {}), instantiation, + asyncMode, importBindings: opts.importBindings ? { tag: opts.importBindings } : null, validLiftingOptimization: opts.validLiftingOptimization ?? false, tracing: opts.tracing ?? false, diff --git a/src/common.js b/src/common.js index 657abdcb..6eb5a15d 100644 --- a/src/common.js +++ b/src/common.js @@ -77,15 +77,32 @@ async function readFileCli (file, encoding) { } export { readFileCli as readFile } -export async function spawnIOTmp (cmd, input, args) { - const tmpDir = await getTmpDir(); +/** + * Spawn a command that performs executes a given binary with + * binary output that is persisted to temporary disk + * + * Commands used with this command must take the form: + * ``` + * cmd [OPTIONS] + * ``` + * + * This may mean that `opts` should be an array that ends with a switch like + * "--output" or "-o". + * + * @param {string} cmd - Binary to execute + * @param {Uint8Array} input - Binary input to temporarily persist to disk + * @param {string[]} opts - Arguments that will be prepended between the input file and output file arguments + */ +export async function spawnIOTmp (cmd, input, opts) { + let tmpDir, inFile, outFile; try { - const inFile = resolve(tmpDir, 'in.wasm'); - let outFile = resolve(tmpDir, 'out.wasm'); + tmpDir = await getTmpDir(); + inFile = resolve(tmpDir, 'in.wasm'); + outFile = resolve(tmpDir, 'out.wasm'); await writeFile(inFile, input); - const cp = spawn(argv0, [cmd, inFile, ...args, outFile], { stdio: 'pipe' }); + const cp = spawn(argv0, [cmd, inFile, ...opts, outFile], { stdio: 'pipe' }); let stderr = ''; const p = new Promise((resolve, reject) => { @@ -105,6 +122,8 @@ export async function spawnIOTmp (cmd, input, args) { var output = await readFile(outFile); return output; } finally { - await rm(tmpDir, { recursive: true }); + if (tmpDir) { + await rm(tmpDir, { recursive: true }); + } } } diff --git a/src/jco.js b/src/jco.js index 1d4b7240..7de49076 100755 --- a/src/jco.js +++ b/src/jco.js @@ -34,6 +34,7 @@ program.command('componentize') .requiredOption('-w, --wit ', 'WIT path to build with') .option('-n, --world-name ', 'WIT world to build') .option('--aot', 'Enable Weval AOT compilation of JS') + .option('--engine ', 'Pass a custom engine (SpiderMonkey/StarlingMonkey) build when componentizing') .option('--weval-bin ', 'Specify a custom weval binary to use') .addOption(new Option('-d, --disable ', 'disable WASI features').choices(['clocks', 'http', 'random', 'stdio', 'all'])) // .addOption(new Option('-e, --enable ', 'enable WASI features').choices(['http'])) @@ -52,6 +53,11 @@ program.command('transpile') .option('--no-typescript', 'do not output TypeScript .d.ts types') .option('--valid-lifting-optimization', 'optimize component binary validations assuming all lifted values are valid') .addOption(new Option('--import-bindings [mode]', 'bindings mode for imports').choices(['js', 'optimized', 'hybrid', 'direct-optimized']).preset('js')) + .addOption(new Option('--async-mode [mode]', 'EXPERIMENTAL: use async imports and exports').choices(['sync', 'jspi', 'asyncify']).preset('sync')) + .option('--default-async-imports', 'EXPERIMENTAL: default async component imports from WASI interfaces') + .option('--default-async-exports', 'EXPERIMENTAL: default async component exports from WASI interfaces') + .option('--async-imports ', 'EXPERIMENTAL: async component imports (examples: "wasi:io/poll@0.2.0#poll", "wasi:io/poll#[method]pollable.block")') + .option('--async-exports ', 'EXPERIMENTAL: async component exports (examples: "wasi:cli/run@#run", "handle")') .option('--tracing', 'emit `tracing` calls on function entry/exit') .option('-b, --base64-cutoff ', 'set the byte size under which core Wasm binaries will be inlined as base64', myParseInt) .option('--tla-compat', 'enables compatibility for JS environments without top-level await support via an async $init promise export') @@ -76,6 +82,11 @@ program.command('types') .requiredOption('-o, --out-dir ', 'output directory') .option('--tla-compat', 'generates types for the TLA compat output with an async $init promise export') .addOption(new Option('-I, --instantiation [mode]', 'type output for custom module instantiation').choices(['async', 'sync']).preset('async')) + .addOption(new Option('--async-mode [mode]', 'EXPERIMENTAL: use async imports and exports').choices(['sync', 'jspi', 'asyncify']).preset('sync')) + .option('--default-async-imports', 'EXPERIMENTAL: default async component imports from WASI interfaces') + .option('--default-async-exports', 'EXPERIMENTAL: default async component exports from WASI interfaces') + .option('--async-imports ', 'EXPERIMENTAL: async component imports (examples: "wasi:io/poll@0.2.0#poll", "wasi:io/poll#[method]pollable.block")') + .option('--async-exports ', 'EXPERIMENTAL: async component exports (examples: "wasi:cli/run@#run", "handle")') .option('-q, --quiet', 'disable output summary') .option('--feature ', 'enable one specific WIT feature (repeatable)', collectOptions, []) .option('--all-features', 'enable all features') diff --git a/test/async.js b/test/async.js new file mode 100644 index 00000000..50b97e3b --- /dev/null +++ b/test/async.js @@ -0,0 +1,186 @@ +import { join, resolve } from "node:path"; +import { execArgv } from "node:process"; +import { deepStrictEqual, ok, strictEqual, fail } from "node:assert"; +import { + mkdir, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; + +import { fileURLToPath, pathToFileURL } from "url"; + +import { exec, jcoPath, getTmpDir, setupAsyncTest } from "./helpers.js"; + +const multiMemory = execArgv.includes("--experimental-wasm-multi-memory") + ? ["--multi-memory"] + : []; + +const AsyncFunction = (async () => {}).constructor; + +export async function asyncTest(_fixtures) { + suite("Async", () => { + var tmpDir; + var outDir; + var outFile; + + suiteSetup(async function () { + tmpDir = await getTmpDir(); + outDir = resolve(tmpDir, "out-component-dir"); + outFile = resolve(tmpDir, "out-component-file"); + + const modulesDir = resolve(tmpDir, "node_modules", "@bytecodealliance"); + await mkdir(modulesDir, { recursive: true }); + await symlink( + fileURLToPath(new URL("../packages/preview2-shim", import.meta.url)), + resolve(modulesDir, "preview2-shim"), + "dir" + ); + }); + + suiteTeardown(async function () { + try { + await rm(tmpDir, { recursive: true }); + } catch {} + }); + + teardown(async function () { + try { + await rm(outDir, { recursive: true }); + await rm(outFile); + } catch {} + }); + + test("Transpile async", async () => { + const name = "flavorful"; + const { stderr } = await exec( + jcoPath, + "transpile", + `test/fixtures/components/${name}.component.wasm`, + "--no-wasi-shim", + "--name", + name, + "-o", + outDir + ); + strictEqual(stderr, ""); + const source = await readFile(`${outDir}/${name}.js`); + ok(source.toString().includes("export { test")); + }); + + test("Transpile async (JSPI)", async () => { + const { instance, cleanup, component } = await setupAsyncTest({ + asyncMode: "jspi", + component: { + name: "async_call", + path: resolve("test/fixtures/components/async_call.component.wasm"), + imports: { + 'something:test/test-interface': { + callAsync: async () => "called async", + callSync: () => "called sync", + }, + }, + }, + jco: { + transpile: { + extraArgs: { + asyncImports: [ + "something:test/test-interface#call-async", + ], + asyncExports: [ + "run-async", + ], + }, + }, + }, + }); + + strictEqual(instance.runSync instanceof AsyncFunction, false, "runSync() should be a sync function"); + strictEqual(instance.runAsync instanceof AsyncFunction, true, "runAsync() should be an async function"); + + strictEqual(instance.runSync(), "called sync"); + strictEqual(await instance.runAsync(), "called async"); + + await cleanup(); + }); + + test("Transpile async (asyncify)", async () => { + const { instance, cleanup } = await setupAsyncTest({ + asyncMode: "asyncify", + component: { + name: "async_call", + path: resolve("test/fixtures/components/async_call.component.wasm"), + imports: { + 'something:test/test-interface': { + callAsync: async () => "called async", + callSync: () => "called sync", + }, + }, + }, + jco: { + transpile: { + extraArgs: { + asyncImports: [ + "something:test/test-interface#call-async", + ], + asyncExports: [ + "run-async", + ], + }, + }, + }, + }); + + strictEqual(instance.runSync instanceof AsyncFunction, false, "runSync() should be a sync function"); + strictEqual(instance.runAsync instanceof AsyncFunction, true, "runAsync() should be an async function"); + + strictEqual(instance.runSync(), "called sync"); + strictEqual(await instance.runAsync(), "called async"); + + await cleanup(); + }); + + // TODO: fill out `RequestOption` impl (browser-async/http/types) + // TODO: allow `Pollable` to be re-used (when poll is called again?? how is this triggered?) + // TODO: fill out browser-async sockets with "not implemented" errors (we don't have much choice but to trap here) + + test("Transpile async (asyncify)", async () => { + const { instance, cleanup } = await setupAsyncTest({ + asyncMode: "asyncify", + component: { + name: "async_call", + path: resolve("test/fixtures/components/async_call.component.wasm"), + imports: { + 'something:test/test-interface': { + callAsync: async () => "called async", + callSync: () => "called sync", + }, + }, + }, + jco: { + transpile: { + extraArgs: { + asyncImports: [ + "something:test/test-interface#call-async", + ], + asyncExports: [ + "run-async", + ], + }, + } + }, + }); + + strictEqual(instance.runSync instanceof AsyncFunction, false, "runSync() should be a sync function"); + strictEqual(instance.runAsync instanceof AsyncFunction, true, "runAsync() should be an async function"); + + strictEqual(instance.runSync(), "called sync"); + strictEqual(await instance.runAsync(), "called async"); + + await cleanup(); + }); + + }); +} + diff --git a/test/browser.html b/test/browser.html deleted file mode 100644 index 60c54677..00000000 --- a/test/browser.html +++ /dev/null @@ -1,81 +0,0 @@ - - - diff --git a/test/browser/browser-preview2.html b/test/browser/browser-preview2.html new file mode 100644 index 00000000..269e9934 --- /dev/null +++ b/test/browser/browser-preview2.html @@ -0,0 +1,54 @@ + + + diff --git a/test/browser/preview2.js b/test/browser/preview2.js new file mode 100644 index 00000000..610afb6d --- /dev/null +++ b/test/browser/preview2.js @@ -0,0 +1,274 @@ +import { env } from "node:process"; +import { deepStrictEqual, ok, strictEqual } from "node:assert"; +import { + mkdir, + readFile, + writeFile, + rm, + symlink, + mkdtemp, +} from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join, resolve, normalize, sep, extname, dirname } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import mime from "mime"; +import puppeteer from "puppeteer"; + +import { transpile } from "../../src/api.js"; +import { + log, + exec, + jcoPath, + loadTestPage, + getTmpDir, + getRandomPort, + setupAsyncTest, +} from "../helpers.js"; + +// Path to the fixutres +const FIXTURES_WASI_0_2_2_DIR = fileURLToPath( + new URL("../fixtures/wasi/0.2.2", import.meta.url), +); +const FIXTURES_COMPONENTS_JS_DIR = fileURLToPath( + new URL("../fixtures/components/js", import.meta.url), +); + +// WIT interface for a testable component (possibly dynamically generated) +// Normally the component that adheres to this interface is expected to +// be called from the browser (see browser-preview2.html) +const TEST_WIT_INTERFACE = ` +package examples:test; + +interface test { + test: func(); +} + +world component { + export test; +} +`; + +// TODO: take an argument for JSPI vs asyncify as the mode +export async function browserPreview2Test() { + suite("Browser preview2", () => { + let tmpDir, outDir, outFile, outDirUrl; + let server, browser, serverPort; + + suiteSetup(async function () { + tmpDir = await getTmpDir(); + outDir = resolve(tmpDir, "out-component-dir"); + outDirUrl = pathToFileURL(outDir + "/"); + await mkdir(outDir); + outFile = resolve(tmpDir, "out-component-file"); + + // Link the local preview2 shim directory into node_modules inside + // the output directory, to enable components to access preview2 shim + // imports when they are imported + const modulesDir = resolve(tmpDir, "node_modules", "@bytecodealliance"); + await mkdir(modulesDir, { recursive: true }); + await symlink( + fileURLToPath(new URL("../packages/preview2-shim", import.meta.url)), + resolve(modulesDir, "preview2-shim"), + "dir", + ); + }); + + // Suite teardown + suiteTeardown(async function () { + // Close the browser + await browser.close(); + // Close the ephemeral server + await new Promise((resolve) => server.close(resolve)); + + // Remove temporary directory + try { + if (env.TEST_DEBUG_NO_CLEANUP) { + log(`skipping cleanup, not removing temp dir [${tmpDir}]`); + } else { + await rm(tmpDir, { recursive: true }); + } + } catch {} + }); + + // Per-test setup + setup(async function () { + serverPort = await getRandomPort(); + + server = createServer(async (req, res) => { + let fileUrl; + // Serve special import-mapped URLs, usually on the request of the browser + // + // - /transpiled/ points to the built code in the course of the test (e.g. a transpiled component) + // - /builtin/ points to the project itself (i.e. browser shims) + // - all other files are served from one folder up + // + if (req.url.startsWith("/transpiled/")) { + // Generated + fileUrl = new URL( + `./${req.url.slice("/transpiled/".length)}`, + outDirUrl, + ); + } else if (req.url.startsWith("/builtin/")) { + // From the project + fileUrl = new URL( + `../../${req.url.slice("/builtin/".length)}`, + import.meta.url, + ); + } else { + fileUrl = new URL(`../${req.url}`, import.meta.url); + } + + // Attempt to read the file + try { + const html = await readFile(fileUrl); + res.writeHead(200, { + "content-type": mime.getType(extname(req.url)), + }); + res.end(html); + } catch (e) { + if (e.code === "ENOENT") { + log(`failed to find file [${fileUrl}]`); + res.writeHead(404); + res.end(e.message); + } else { + res.writeHead(500); + res.end(e.message); + } + } + }).listen(serverPort); + + browser = await puppeteer.launch(); + // TODO: puppeteer enable origin flag for JSPI + }); + + // Per-test teardown + teardown(async function () { + try { + await rm(outDir, { recursive: true }); + await rm(outFile); + } catch {} + }); + + // Build a component dynamically that uses incoming handler + // + // While the browser can't actualy use the incoming handler, + // we can write a test component that *does* implement an incoming + // handler and pass it to the browser shim. + // + // This test is special because as browsers (which serve as host environments) normally do not *provide* + // an wasi:http/incoming-handler implementation, they deal with components that *export* one. + // + // In this case, the browser (host environment) must be able to convert a Web platform request into + // the necessary WASI compliant shims in order for the component that is doing the exporting + // to use it. + test("[async] guest http/incoming-handler export ", async () => { + const { esModuleRelativeSourcePath, cleanup } = await setupAsyncTest({ + component: { + name: "browser_incoming_handler", + build: { + wit: { + world: "component", + // NOTE: these deps will go under wit/deps in the ephemeral component that is + // used for this test + deps: [ + { + srcPath: join(FIXTURES_WASI_0_2_2_DIR, "wasi_http@0.2.2.wit"), + }, + { srcPath: join(FIXTURES_WASI_0_2_2_DIR, "wasi_io@0.2.2.wit") }, + { + srcPath: join( + FIXTURES_WASI_0_2_2_DIR, + "wasi_clocks@0.2.2.wit", + ), + }, + { + srcPath: join( + FIXTURES_WASI_0_2_2_DIR, + "wasi_random@0.2.2.wit", + ), + }, + { + srcPath: join( + FIXTURES_WASI_0_2_2_DIR, + "wasi_filesystem@0.2.2.wit", + ), + }, + { + srcPath: join(FIXTURES_WASI_0_2_2_DIR, "wasi_cli@0.2.2.wit"), + }, + { + srcPath: join( + FIXTURES_WASI_0_2_2_DIR, + "wasi_sockets@0.2.2.wit", + ), + }, + ], + source: await readFile( + join( + FIXTURES_COMPONENTS_JS_DIR, + "browser-incoming-handler", + "component.wit", + ), + ), + }, + js: { + source: await readFile( + join( + FIXTURES_COMPONENTS_JS_DIR, + "browser-incoming-handler", + "component.js", + ), + ), + }, + }, + outputDir: outDir, + // We skip instantiation since the *browser* will instantiate the module (w/ relevant shims) + skipInstantiation: true, + }, + jco: { + transpile: { + extraArgs: ["--async-exports=wasi:http/incoming-handler#handle"], + }, + }, + }); + + // Load the test (in effect, running the test against the component)' + const { + page, + output: { json }, + } = await loadTestPage({ + browser, + serverPort, + path: "fixtures/browser/test-pages/wasi-http-incoming-handler.guest-export.async.preview2.html", + hash: `transpiled:${esModuleRelativeSourcePath}`, + }); + + // Check the output expected to be returned from handle of the + // guest export (this depends on the component) + deepStrictEqual(json, { responseText: "Hello from Javascript!" }); + + await page.close(); + await cleanup(); + }); + + // test('[async] wasi:http/types impl', async () => { + // }); + + // test('[async] wasi:io/error impl', async () => { + // }); + + // test('[async] wasi:io/poll impl', async () => { + // }); + + // test('[async] wasi:io/streams impl', async () => { + // }); + + // test('[async] wasi:random/random impl', async () => { + // }); + + // test('[async] wasi:random/insecure impl', async () => { + // }); + }); +} diff --git a/test/browser/test.js b/test/browser/test.js new file mode 100644 index 00000000..ec6b5737 --- /dev/null +++ b/test/browser/test.js @@ -0,0 +1,11 @@ +import { platform } from 'node:process'; + +// import { browserWebIdlTest } from './webidl.js'; +import { browserPreview2Test } from './preview2.js'; + +export async function browserTest() { + if (platform !== 'win32') { + // await browserWebIdlTest(); + await browserPreview2Test(); + } +} diff --git a/test/browser.js b/test/browser/webidl.js similarity index 76% rename from test/browser.js rename to test/browser/webidl.js index e8f87f13..b9787307 100644 --- a/test/browser.js +++ b/test/browser/webidl.js @@ -1,28 +1,24 @@ // import { deepStrictEqual, ok, strictEqual } from "node:assert"; -import puppeteer from "puppeteer"; +import { URL } from "node:url"; import { mkdir, readFile, writeFile, rm, symlink, mkdtemp } from "node:fs/promises"; import { createServer } from "node:http"; -import { fileURLToPath, pathToFileURL } from "url"; +import test from "node:test"; import { tmpdir } from "node:os"; import { resolve, normalize, sep, extname, dirname } from "node:path"; import { ok, strictEqual } from "node:assert"; -import { transpile } from '../src/api.js'; + +import { fileURLToPath, pathToFileURL } from "url"; import mime from 'mime'; -import { exec, jcoPath } from "./helpers.js"; - -export async function browserTest() { - suite("Browser", () => { - /** - * Securely creates a temporary directory and returns its path. - * - * The new directory is created using `fsPromises.mkdtemp()`. - */ - async function getTmpDir() { - return await mkdtemp(normalize(tmpdir() + sep)); - } +import puppeteer from "puppeteer"; + +import { transpile } from '../../src/api.js'; +import { exec, jcoPath, testBrowserPage, getTmpDir, getRandomPort } from "../helpers.js"; +export async function browserWebIdlTest() { + suite("Browser WebIDL", () => { let tmpDir, outDir, outFile, outDirUrl; - let server, browser; + let server, browser, serverPort; + suiteSetup(async function () { tmpDir = await getTmpDir(); outDir = resolve(tmpDir, "out-component-dir"); @@ -37,13 +33,14 @@ export async function browserTest() { "dir" ); - // run a local server on 8080 + // Run a local server on a random port + const serverPort = await getRandomPort(); server = createServer(async (req, res) => { let fileUrl; if (req.url.startsWith('/tmpdir/')) { fileUrl = new URL(`.${req.url.slice(7)}`, outDirUrl); } else { - fileUrl = new URL(`../${req.url}`, import.meta.url); + fileUrl = new URL(`../../${req.url}`, import.meta.url); } try { const html = await readFile(fileUrl); @@ -58,10 +55,11 @@ export async function browserTest() { res.end(e.message); } } - }).listen(8080); + }).listen(serverPort); browser = await puppeteer.launch(); }); + suiteTeardown(async function () { try { await rm(tmpDir, { recursive: true }); @@ -77,23 +75,12 @@ export async function browserTest() { } catch {} }); - async function testBrowserPage (hash) { - const page = await browser.newPage(); - - ok((await page.goto(`http://localhost:8080/test/browser.html#${hash}`)).ok()); - - const body = await page.locator('body').waitHandle(); - - let bodyHtml = await body.evaluate(el => el.innerHTML); - while (bodyHtml === '

Running

') { - bodyHtml = await body.evaluate(el => el.innerHTML); - } - strictEqual(bodyHtml, '

OK

'); - await page.close(); - } - test("Transpilation", async () => { - await testBrowserPage('transpile'); + await testBrowserPage({ + browser, + serverPort, + hash: 'transpile', + }); }); test('IDL window', async () => { @@ -128,7 +115,11 @@ export async function browserTest() { } // Run the test function in the browser from the generated tmpdir - await testBrowserPage('test:dom.js'); + await testBrowserPage({ + browser, + serverPort, + hash: 'test:dom.js', + }); }); test('IDL console', async () => { @@ -162,7 +153,12 @@ export async function browserTest() { await writeFile(outPath, source); } - await testBrowserPage('test:console.js'); + await testBrowserPage({ + browser, + serverPort, + hash: 'test:console.js', + }); }); + }); } diff --git a/test/cli.js b/test/cli.js index 5aae2cdd..7df6e24f 100644 --- a/test/cli.js +++ b/test/cli.js @@ -1,14 +1,7 @@ -import { resolve } from "node:path"; -import { execArgv, env } from "node:process"; +import { resolve, join } from "node:path"; +import { execArgv } from "node:process"; import { deepStrictEqual, ok, strictEqual } from "node:assert"; -import { - mkdir, - readdir, - readFile, - rm, - symlink, - writeFile, -} from "node:fs/promises"; +import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { fileURLToPath, pathToFileURL } from "url"; import { exec, jcoPath, getTmpDir } from "./helpers.js"; @@ -17,6 +10,8 @@ const multiMemory = execArgv.includes("--experimental-wasm-multi-memory") ? ["--multi-memory"] : []; +const AsyncFunction = (async () => {}).constructor; + export async function cliTest(_fixtures) { suite("CLI", () => { var tmpDir; @@ -32,7 +27,7 @@ export async function cliTest(_fixtures) { await symlink( fileURLToPath(new URL("../packages/preview2-shim", import.meta.url)), resolve(modulesDir, "preview2-shim"), - "dir" + "dir", ); }); suiteTeardown(async function () { @@ -55,12 +50,12 @@ export async function cliTest(_fixtures) { `test/fixtures/env-allow.composed.wasm`, ...multiMemory, "-o", - outDir + outDir, ); strictEqual(stderr, ""); await writeFile( `${outDir}/package.json`, - JSON.stringify({ type: "module" }) + JSON.stringify({ type: "module" }), ); const m = await import(`${pathToFileURL(outDir)}/env-allow.composed.js`); deepStrictEqual(m.testGetEnv(), [["CUSTOM", "VAL"]]); @@ -73,12 +68,12 @@ export async function cliTest(_fixtures) { `test/fixtures/stdio.composed.wasm`, ...multiMemory, "-o", - outDir + outDir, ); strictEqual(stderr, ""); await writeFile( `${outDir}/package.json`, - JSON.stringify({ type: "module" }) + JSON.stringify({ type: "module" }), ); const m = await import(`${pathToFileURL(outDir)}/stdio.composed.js`); m.testStdio(); @@ -92,12 +87,12 @@ export async function cliTest(_fixtures) { ...multiMemory, "--valid-lifting-optimization", "-o", - outDir + outDir, ); strictEqual(stderr, ""); await writeFile( `${outDir}/package.json`, - JSON.stringify({ type: "module" }) + JSON.stringify({ type: "module" }), ); const m = await import(`${pathToFileURL(outDir)}/stdio.composed.js`); m.testStdio(); @@ -113,13 +108,87 @@ export async function cliTest(_fixtures) { "--name", name, "-o", - outDir + outDir, ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/${name}.js`); ok(source.toString().includes("export { test")); }); + if (typeof WebAssembly.Suspending === "function") { + test("Transpile with Async Mode for JSPI", async () => { + const name = "async_call"; + const { stderr } = await exec( + jcoPath, + "transpile", + `test/fixtures/components/${name}.component.wasm`, + `--name=${name}`, + "--valid-lifting-optimization", + "--tla-compat", + "--instantiation=async", + "--base64-cutoff=0", + "--async-mode=jspi", + "--async-imports=something:test/test-interface#call-async", + "--async-exports=run-async", + "-o", + outDir, + ); + strictEqual(stderr, ""); + await writeFile( + `${outDir}/package.json`, + JSON.stringify({ type: "module" }), + ); + const m = await import(`${pathToFileURL(outDir)}/${name}.js`); + const inst = await m.instantiate(undefined, { + "something:test/test-interface": { + callAsync: async () => "called async", + callSync: () => "called sync", + }, + }); + strictEqual(inst.runSync instanceof AsyncFunction, false); + strictEqual(inst.runAsync instanceof AsyncFunction, true); + + strictEqual(inst.runSync(), "called sync"); + strictEqual(await inst.runAsync(), "called async"); + }); + } + + test("Transpile with Async Mode for Asyncify", async () => { + const name = "async_call"; + const { stderr } = await exec( + jcoPath, + "transpile", + `test/fixtures/components/${name}.component.wasm`, + `--name=${name}`, + "--valid-lifting-optimization", + "--tla-compat", + "--instantiation=async", + "--base64-cutoff=0", + "--async-mode=asyncify", + "--async-imports=something:test/test-interface#call-async", + "--async-exports=run-async", + "-o", + outDir, + ); + strictEqual(stderr, ""); + await writeFile( + `${outDir}/package.json`, + JSON.stringify({ type: "module" }), + ); + const m = await import(`${pathToFileURL(outDir)}/${name}.js`); + const inst = await m.instantiate(undefined, { + "something:test/test-interface": { + callAsync: async () => "called async", + callSync: () => "called sync", + }, + }); + strictEqual(inst.runSync instanceof AsyncFunction, false); + strictEqual(inst.runAsync instanceof AsyncFunction, true); + + strictEqual(inst.runSync(), "called sync"); + strictEqual(await inst.runAsync(), "called async"); + }); + test("Transpile & Optimize & Minify", async () => { const name = "flavorful"; const { stderr } = await exec( @@ -134,7 +203,7 @@ export async function cliTest(_fixtures) { "--minify", "--base64-cutoff=0", "-o", - outDir + outDir, ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/${name}.js`); @@ -154,20 +223,20 @@ export async function cliTest(_fixtures) { "--tracing", "--base64-cutoff=0", "-o", - outDir + outDir, ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/${name}.js`, "utf8"); ok(source.includes("function toResultString(")); ok( source.includes( - 'console.error(`[module="test:flavorful/test", function="f-list-in-record1"] call a' - ) + 'console.error(`[module="test:flavorful/test", function="f-list-in-record1"] call a', + ), ); ok( source.includes( - 'console.error(`[module="test:flavorful/test", function="list-of-variants"] return result=${toResultString(ret)}`);' - ) + 'console.error(`[module="test:flavorful/test", function="list-of-variants"] return result=${toResultString(ret)}`);', + ), ); }); @@ -179,7 +248,7 @@ export async function cliTest(_fixtures) { "--world-name", "test:flavorful/flavorful", "-o", - outDir + outDir, ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/flavorful.d.ts`, "utf8"); @@ -198,10 +267,13 @@ export async function cliTest(_fixtures) { "--feature", "enable-c", "-o", - outDir + outDir, ); strictEqual(stderr, ""); - const source = await readFile(`${outDir}/interfaces/test-feature-gates-unstable-foo.d.ts`, "utf8"); + const source = await readFile( + `${outDir}/interfaces/test-feature-gates-unstable-foo.d.ts`, + "utf8", + ); ok(source.includes("export function a(): void;")); ok(!source.includes("export function b(): void;")); ok(source.includes("export function c(): void;")); @@ -216,10 +288,13 @@ export async function cliTest(_fixtures) { "test:feature-gates-unstable/gated", "--all-features", "-o", - outDir + outDir, ); strictEqual(stderr, ""); - const source = await readFile(`${outDir}/interfaces/test-feature-gates-unstable-foo.d.ts`, "utf8"); + const source = await readFile( + `${outDir}/interfaces/test-feature-gates-unstable-foo.d.ts`, + "utf8", + ); ok(source.includes("export function a(): void;")); ok(source.includes("export function b(): void;")); ok(source.includes("export function c(): void;")); @@ -237,10 +312,13 @@ export async function cliTest(_fixtures) { "--feature", "enable-c", "-o", - outDir + outDir, ); strictEqual(stderr, ""); - const source = await readFile(`${outDir}/interfaces/test-feature-gates-unstable-foo.d.ts`, "utf8"); + const source = await readFile( + `${outDir}/interfaces/test-feature-gates-unstable-foo.d.ts`, + "utf8", + ); ok(source.includes("export function a(): void;")); ok(source.includes("export function b(): void;")); ok(source.includes("export function c(): void;")); @@ -268,7 +346,7 @@ export async function cliTest(_fixtures) { `test/fixtures/wit/deps/ts-check/ts-check.wit`, "--stub", "-o", - outDir + outDir, ); strictEqual(stderr, ""); { @@ -278,7 +356,7 @@ export async function cliTest(_fixtures) { } { const source = await readFile( - `${outDir}/interfaces/ts-naming-blah.d.ts` + `${outDir}/interfaces/ts-naming-blah.d.ts`, ); ok(source.toString().includes("declare function _class(): void")); ok(source.toString().includes("export { _class as class }")); @@ -300,7 +378,7 @@ export async function cliTest(_fixtures) { "--js", "--base64-cutoff=0", "-o", - outDir + outDir, ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/${name}.js`, "utf8"); @@ -320,7 +398,7 @@ export async function cliTest(_fixtures) { "--name", name, "-o", - outDir + outDir, ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/${name}.js`); @@ -341,7 +419,7 @@ export async function cliTest(_fixtures) { "--name", name, "-o", - outDir + outDir, ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/${name}.js`); @@ -354,14 +432,14 @@ export async function cliTest(_fixtures) { test("Optimize", async () => { const component = await readFile( - `test/fixtures/components/flavorful.component.wasm` + `test/fixtures/components/flavorful.component.wasm`, ); const { stderr, stdout } = await exec( jcoPath, "opt", `test/fixtures/components/flavorful.component.wasm`, "-o", - outFile + outFile, ); strictEqual(stderr, ""); ok(stdout.includes("Core Module 1:")); @@ -373,7 +451,7 @@ export async function cliTest(_fixtures) { const { stderr, stdout } = await exec( jcoPath, "print", - `test/fixtures/components/flavorful.component.wasm` + `test/fixtures/components/flavorful.component.wasm`, ); strictEqual(stderr, ""); strictEqual(stdout.slice(0, 10), "(component"); @@ -383,7 +461,7 @@ export async function cliTest(_fixtures) { "print", `test/fixtures/components/flavorful.component.wasm`, "-o", - outFile + outFile, ); strictEqual(stderr, ""); strictEqual(stdout, ""); @@ -394,7 +472,7 @@ export async function cliTest(_fixtures) { "parse", outFile, "-o", - outFile + outFile, ); strictEqual(stderr, ""); strictEqual(stdout, ""); @@ -409,7 +487,7 @@ export async function cliTest(_fixtures) { `test/fixtures/wit/deps/app/app.wit`, "-o", outDir, - "--stub" + "--stub", ); strictEqual(stderr, ""); const source = await readFile(`${outDir}/app.js`); @@ -420,7 +498,7 @@ export async function cliTest(_fixtures) { const { stderr, stdout } = await exec( jcoPath, "wit", - `test/fixtures/components/flavorful.component.wasm` + `test/fixtures/components/flavorful.component.wasm`, ); strictEqual(stderr, ""); ok(stdout.includes("world root {")); @@ -437,7 +515,7 @@ export async function cliTest(_fixtures) { "-m", "processed-by=dummy-gen@test", "-o", - outFile + outFile, ); strictEqual(stderr, ""); strictEqual(stdout, ""); @@ -454,7 +532,7 @@ export async function cliTest(_fixtures) { "new", outFile, "-o", - outFile + outFile, ); strictEqual(stderr, ""); strictEqual(stdout, ""); @@ -469,7 +547,7 @@ export async function cliTest(_fixtures) { jcoPath, "metadata-show", outFile, - "--json" + "--json", ); strictEqual(stderr, ""); const meta = JSON.parse(stdout); @@ -494,7 +572,7 @@ export async function cliTest(_fixtures) { "test/fixtures/modules/exitcode.wasm", "--wasi-reactor", "-o", - outFile + outFile, ); strictEqual(stderr, ""); { @@ -509,7 +587,7 @@ export async function cliTest(_fixtures) { jcoPath, "metadata-show", "test/fixtures/modules/exitcode.wasm", - "--json" + "--json", ); strictEqual(stderr, ""); deepStrictEqual(JSON.parse(stdout), [ @@ -522,7 +600,7 @@ export async function cliTest(_fixtures) { }); test("Componentize", async () => { - const args = [ + const { stdout, stderr } = await exec( jcoPath, "componentize", "test/fixtures/componentize/source.js", @@ -532,13 +610,8 @@ export async function cliTest(_fixtures) { "-w", "test/fixtures/componentize/source.wit", "-o", - outFile - ]; - if (env.WEVAL_BIN_PATH) { - args.push("--weval-bin", env.WEVAL_BIN_PATH); - } - - const { stdout, stderr } = await exec(...args); + outFile, + ); strictEqual(stderr, ""); { const { stderr } = await exec( @@ -550,13 +623,13 @@ export async function cliTest(_fixtures) { "--map", "local:test/foo=./foo.js", "-o", - outDir + outDir, ); strictEqual(stderr, ""); } await writeFile( `${outDir}/package.json`, - JSON.stringify({ type: "module" }) + JSON.stringify({ type: "module" }), ); await writeFile(`${outDir}/foo.js`, `export class Bar {}`); const m = await import(`${pathToFileURL(outDir)}/componentize.js`); @@ -565,3 +638,32 @@ export async function cliTest(_fixtures) { }); }); } + +// Cache of componentize byte outputs +const CACHE_COMPONENTIZE_OUTPUT = {}; + +/** + * Small cache for componentizations to save build time by storing componentize + * output in memory + * + * @param {string} outputPath - path to where to write the component + * @param {string[]} args - arguments to be fed to `jco componentize` (*without* "compnentize" or "-o/--output") + */ +async function cachedComponentize(outputPath, args) { + const cacheKey = args.join("+"); + if (cacheKey in CACHE_COMPONENTIZE_OUTPUT) { + await writeFile(outputPath, CACHE_COMPONENTIZE_OUTPUT[cacheKey]); + return; + } + + const { stdout, stderr } = await exec( + jcoPath, + "componentize", + ...args, + "-o", + outputPath, + ); + strictEqual(stderr, ""); + + CACHE_COMPONENTIZE_OUTPUT[cacheKey] = await readFile(outputPath); +} diff --git a/test/codegen.js b/test/codegen.js index 994da28a..7e5911b3 100644 --- a/test/codegen.js +++ b/test/codegen.js @@ -26,23 +26,27 @@ export async function readFlags (fixture) { export async function codegenTest (fixtures) { suite(`Transpiler codegen`, () => { for (const fixture of fixtures) { - const testName = fixture.replace(/(\.component)?\.(wasm|wat)$/, ''); + const name = fixture.replace(/(\.component)?\.(wasm|wat)$/, ''); - test(`${testName} transpile`, async () => { - const flags = await readFlags(`test/runtime/${testName}.ts`); - var { stderr } = await exec(jcoPath, 'transpile', `test/fixtures/components/${fixture}`, '--name', testName, ...flags, '-o', `test/output/${testName}`); - strictEqual(stderr, ''); - }); + for (const testFile of (readdirSync('test/runtime/')).filter(testFile => testFile.startsWith(`${name}.`))) { + const testName= testFile.replace(/\.ts$/, ''); - test(`${testName} lint`, async () => { - const flags = await readFlags(`test/runtime/${testName}.ts`); + test(`${testName} transpile`, async () => { + const flags = await readFlags(`test/runtime/${testFile}`); + var { stderr } = await exec(jcoPath, 'transpile', `test/fixtures/components/${fixture}`, '--name', testName, ...flags, '-o', `test/output/${testName}`); + strictEqual(stderr, ''); + }); - if (flags.includes('--js')) - return; + test(`${testName} lint`, async () => { + const flags = await readFlags(`test/runtime/${testFile}`); - var { stderr } = await exec(eslintPath, `test/output/${testName}/${testName}.js`, '-c', 'test/eslintrc.cjs'); - strictEqual(stderr, ''); - }); + if (flags.includes('--js')) + return; + + var { stderr } = await exec(eslintPath, `test/output/${testName}/${testName}.js`, '-c', 'test/eslintrc.cjs'); + strictEqual(stderr, ''); + }); + } } }); diff --git a/test/fixtures/browser/test-pages/wasi-http-incoming-handler.guest-export.async.preview2.html b/test/fixtures/browser/test-pages/wasi-http-incoming-handler.guest-export.async.preview2.html new file mode 100644 index 00000000..c3c1965d --- /dev/null +++ b/test/fixtures/browser/test-pages/wasi-http-incoming-handler.guest-export.async.preview2.html @@ -0,0 +1,116 @@ + + + + + + + diff --git a/test/fixtures/components/async_call.component.wasm b/test/fixtures/components/async_call.component.wasm new file mode 100644 index 00000000..a5248390 Binary files /dev/null and b/test/fixtures/components/async_call.component.wasm differ diff --git a/test/fixtures/components/js/browser-incoming-handler/component.js b/test/fixtures/components/js/browser-incoming-handler/component.js new file mode 100644 index 00000000..b48ac9c5 --- /dev/null +++ b/test/fixtures/components/js/browser-incoming-handler/component.js @@ -0,0 +1,24 @@ +import { + IncomingRequest, + ResponseOutparam, + OutgoingBody, + OutgoingResponse, + Fields, +} from 'wasi:http/types@0.2.2'; + +export const incomingHandler = { + handle(incomingRequest, responseOutparam) { + const outgoingResponse = new OutgoingResponse(new Fields()); + let outgoingBody = outgoingResponse.body(); + { + let outputStream = outgoingBody.write(); + outputStream.blockingWriteAndFlush( + new Uint8Array(new TextEncoder().encode('Hello from Javascript!')) + ); + outputStream[Symbol.dispose](); + } + outgoingResponse.setStatusCode(200); + OutgoingBody.finish(outgoingBody, undefined); + ResponseOutparam.set(outgoingResponse, { tag: 'ok', val: outgoingResponse }); + } +}; diff --git a/test/fixtures/components/js/browser-incoming-handler/component.wit b/test/fixtures/components/js/browser-incoming-handler/component.wit new file mode 100644 index 00000000..e876914b --- /dev/null +++ b/test/fixtures/components/js/browser-incoming-handler/component.wit @@ -0,0 +1,5 @@ +package jco:test; + +world component { + export wasi:http/incoming-handler@0.2.2; +} diff --git a/test/fixtures/wasi/0.2.2/wasi_cli@0.2.2.wit b/test/fixtures/wasi/0.2.2/wasi_cli@0.2.2.wit new file mode 100644 index 00000000..f89bf694 --- /dev/null +++ b/test/fixtures/wasi/0.2.2/wasi_cli@0.2.2.wit @@ -0,0 +1,246 @@ +package wasi:cli@0.2.2; + +@since(version = 0.2.0) +interface environment { + /// Get the POSIX-style environment variables. + /// + /// Each environment variable is provided as a pair of string variable names + /// and string value. + /// + /// Morally, these are a value import, but until value imports are available + /// in the component model, this import function should return the same + /// values each time it is called. + @since(version = 0.2.0) + get-environment: func() -> list>; + + /// Get the POSIX-style arguments to the program. + @since(version = 0.2.0) + get-arguments: func() -> list; + + /// Return a path that programs should use as their initial current working + /// directory, interpreting `.` as shorthand for this. + @since(version = 0.2.0) + initial-cwd: func() -> option; +} + +@since(version = 0.2.0) +interface exit { + /// Exit the current instance and any linked instances. + @since(version = 0.2.0) + exit: func(status: result); +} + +@since(version = 0.2.0) +interface run { + /// Run the program. + @since(version = 0.2.0) + run: func() -> result; +} + +@since(version = 0.2.0) +interface stdin { + @since(version = 0.2.0) + use wasi:io/streams@0.2.2.{input-stream}; + + @since(version = 0.2.0) + get-stdin: func() -> input-stream; +} + +@since(version = 0.2.0) +interface stdout { + @since(version = 0.2.0) + use wasi:io/streams@0.2.2.{output-stream}; + + @since(version = 0.2.0) + get-stdout: func() -> output-stream; +} + +@since(version = 0.2.0) +interface stderr { + @since(version = 0.2.0) + use wasi:io/streams@0.2.2.{output-stream}; + + @since(version = 0.2.0) + get-stderr: func() -> output-stream; +} + +/// Terminal input. +/// +/// In the future, this may include functions for disabling echoing, +/// disabling input buffering so that keyboard events are sent through +/// immediately, querying supported features, and so on. +@since(version = 0.2.0) +interface terminal-input { + /// The input side of a terminal. + @since(version = 0.2.0) + resource terminal-input; +} + +/// Terminal output. +/// +/// In the future, this may include functions for querying the terminal +/// size, being notified of terminal size changes, querying supported +/// features, and so on. +@since(version = 0.2.0) +interface terminal-output { + /// The output side of a terminal. + @since(version = 0.2.0) + resource terminal-output; +} + +/// An interface providing an optional `terminal-input` for stdin as a +/// link-time authority. +@since(version = 0.2.0) +interface terminal-stdin { + @since(version = 0.2.0) + use terminal-input.{terminal-input}; + + /// If stdin is connected to a terminal, return a `terminal-input` handle + /// allowing further interaction with it. + @since(version = 0.2.0) + get-terminal-stdin: func() -> option; +} + +/// An interface providing an optional `terminal-output` for stdout as a +/// link-time authority. +@since(version = 0.2.0) +interface terminal-stdout { + @since(version = 0.2.0) + use terminal-output.{terminal-output}; + + /// If stdout is connected to a terminal, return a `terminal-output` handle + /// allowing further interaction with it. + @since(version = 0.2.0) + get-terminal-stdout: func() -> option; +} + +/// An interface providing an optional `terminal-output` for stderr as a +/// link-time authority. +@since(version = 0.2.0) +interface terminal-stderr { + @since(version = 0.2.0) + use terminal-output.{terminal-output}; + + /// If stderr is connected to a terminal, return a `terminal-output` handle + /// allowing further interaction with it. + @since(version = 0.2.0) + get-terminal-stderr: func() -> option; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import environment; + @since(version = 0.2.0) + import exit; + @since(version = 0.2.0) + import wasi:io/error@0.2.2; + @since(version = 0.2.0) + import wasi:io/poll@0.2.2; + @since(version = 0.2.0) + import wasi:io/streams@0.2.2; + @since(version = 0.2.0) + import stdin; + @since(version = 0.2.0) + import stdout; + @since(version = 0.2.0) + import stderr; + @since(version = 0.2.0) + import terminal-input; + @since(version = 0.2.0) + import terminal-output; + @since(version = 0.2.0) + import terminal-stdin; + @since(version = 0.2.0) + import terminal-stdout; + @since(version = 0.2.0) + import terminal-stderr; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.2; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.2; + @since(version = 0.2.0) + import wasi:filesystem/types@0.2.2; + @since(version = 0.2.0) + import wasi:filesystem/preopens@0.2.2; + @since(version = 0.2.0) + import wasi:sockets/network@0.2.2; + @since(version = 0.2.0) + import wasi:sockets/instance-network@0.2.2; + @since(version = 0.2.0) + import wasi:sockets/udp@0.2.2; + @since(version = 0.2.0) + import wasi:sockets/udp-create-socket@0.2.2; + @since(version = 0.2.0) + import wasi:sockets/tcp@0.2.2; + @since(version = 0.2.0) + import wasi:sockets/tcp-create-socket@0.2.2; + @since(version = 0.2.0) + import wasi:sockets/ip-name-lookup@0.2.2; + @since(version = 0.2.0) + import wasi:random/random@0.2.2; + @since(version = 0.2.0) + import wasi:random/insecure@0.2.2; + @since(version = 0.2.0) + import wasi:random/insecure-seed@0.2.2; +} +@since(version = 0.2.0) +world command { + @since(version = 0.2.0) + import environment; + @since(version = 0.2.0) + import exit; + @since(version = 0.2.0) + import wasi:io/error@0.2.2; + @since(version = 0.2.0) + import wasi:io/poll@0.2.2; + @since(version = 0.2.0) + import wasi:io/streams@0.2.2; + @since(version = 0.2.0) + import stdin; + @since(version = 0.2.0) + import stdout; + @since(version = 0.2.0) + import stderr; + @since(version = 0.2.0) + import terminal-input; + @since(version = 0.2.0) + import terminal-output; + @since(version = 0.2.0) + import terminal-stdin; + @since(version = 0.2.0) + import terminal-stdout; + @since(version = 0.2.0) + import terminal-stderr; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.2; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.2; + @since(version = 0.2.0) + import wasi:filesystem/types@0.2.2; + @since(version = 0.2.0) + import wasi:filesystem/preopens@0.2.2; + @since(version = 0.2.0) + import wasi:sockets/network@0.2.2; + @since(version = 0.2.0) + import wasi:sockets/instance-network@0.2.2; + @since(version = 0.2.0) + import wasi:sockets/udp@0.2.2; + @since(version = 0.2.0) + import wasi:sockets/udp-create-socket@0.2.2; + @since(version = 0.2.0) + import wasi:sockets/tcp@0.2.2; + @since(version = 0.2.0) + import wasi:sockets/tcp-create-socket@0.2.2; + @since(version = 0.2.0) + import wasi:sockets/ip-name-lookup@0.2.2; + @since(version = 0.2.0) + import wasi:random/random@0.2.2; + @since(version = 0.2.0) + import wasi:random/insecure@0.2.2; + @since(version = 0.2.0) + import wasi:random/insecure-seed@0.2.2; + + @since(version = 0.2.0) + export run; +} diff --git a/test/fixtures/wasi/0.2.2/wasi_clocks@0.2.2.wit b/test/fixtures/wasi/0.2.2/wasi_clocks@0.2.2.wit new file mode 100644 index 00000000..2e3a2ed1 --- /dev/null +++ b/test/fixtures/wasi/0.2.2/wasi_clocks@0.2.2.wit @@ -0,0 +1,103 @@ +package wasi:clocks@0.2.2; + +/// WASI Monotonic Clock is a clock API intended to let users measure elapsed +/// time. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +/// +/// A monotonic clock is a clock which has an unspecified initial value, and +/// successive reads of the clock will produce non-decreasing values. +@since(version = 0.2.0) +interface monotonic-clock { + @since(version = 0.2.0) + use wasi:io/poll@0.2.2.{pollable}; + + /// 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. + @since(version = 0.2.0) + type instant = u64; + + /// A duration of time, in nanoseconds. + @since(version = 0.2.0) + type duration = u64; + + /// Read the current value of the clock. + /// + /// The clock is monotonic, therefore calling this function repeatedly will + /// produce a sequence of non-decreasing values. + @since(version = 0.2.0) + now: func() -> instant; + + /// Query the resolution of the clock. Returns the duration of time + /// corresponding to a clock tick. + @since(version = 0.2.0) + resolution: func() -> duration; + + /// Create a `pollable` which will resolve once the specified instant + /// has occurred. + @since(version = 0.2.0) + subscribe-instant: func(when: instant) -> pollable; + + /// Create a `pollable` that will resolve after the specified duration has + /// elapsed from the time this function is invoked. + @since(version = 0.2.0) + subscribe-duration: func(when: duration) -> pollable; +} + +/// WASI Wall Clock is a clock API intended to let users query the current +/// time. The name "wall" makes an analogy to a "clock on the wall", which +/// is not necessarily monotonic as it may be reset. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +/// +/// A wall clock is a clock which measures the date and time according to +/// some external reference. +/// +/// External references may be reset, so this clock is not necessarily +/// monotonic, making it unsuitable for measuring elapsed time. +/// +/// It is intended for reporting the current date and time for humans. +@since(version = 0.2.0) +interface wall-clock { + /// A time and date in seconds plus nanoseconds. + @since(version = 0.2.0) + record datetime { + seconds: u64, + nanoseconds: u32, + } + + /// 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. + /// + /// [POSIX's Seconds Since the Epoch]: https://pubs.opengroup.org/onlinepubs/9699919799/xrat/V4_xbd_chap04.html#tag_21_04_16 + /// [Unix Time]: https://en.wikipedia.org/wiki/Unix_time + @since(version = 0.2.0) + now: func() -> datetime; + + /// Query the resolution of the clock. + /// + /// The nanoseconds field of the output is always less than 1000000000. + @since(version = 0.2.0) + resolution: func() -> datetime; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/poll@0.2.2; + @since(version = 0.2.0) + import monotonic-clock; + @since(version = 0.2.0) + import wall-clock; +} diff --git a/test/fixtures/wasi/0.2.2/wasi_filesystem@0.2.2.wit b/test/fixtures/wasi/0.2.2/wasi_filesystem@0.2.2.wit new file mode 100644 index 00000000..8016e19f --- /dev/null +++ b/test/fixtures/wasi/0.2.2/wasi_filesystem@0.2.2.wit @@ -0,0 +1,583 @@ +package wasi:filesystem@0.2.2; + +/// WASI filesystem is a filesystem API primarily intended to let users run WASI +/// programs that access their files on their existing filesystems, without +/// significant overhead. +/// +/// It is intended to be roughly portable between Unix-family platforms and +/// Windows, though it does not hide many of the major differences. +/// +/// Paths are passed as interface-type `string`s, meaning they must consist of +/// a sequence of Unicode Scalar Values (USVs). Some filesystems may contain +/// paths which are not accessible by this API. +/// +/// The directory separator in WASI is always the forward-slash (`/`). +/// +/// All paths in WASI are relative paths, and are interpreted relative to a +/// `descriptor` referring to a base directory. If a `path` argument to any WASI +/// function starts with `/`, or if any step of resolving a `path`, including +/// `..` and symbolic link steps, reaches a directory outside of the base +/// directory, or reaches a symlink to an absolute or rooted path in the +/// underlying filesystem, the function fails with `error-code::not-permitted`. +/// +/// For more information about WASI path resolution and sandboxing, see +/// [WASI filesystem path resolution]. +/// +/// [WASI filesystem path resolution]: https://github.com/WebAssembly/wasi-filesystem/blob/main/path-resolution.md +@since(version = 0.2.0) +interface types { + @since(version = 0.2.0) + use wasi:io/streams@0.2.2.{input-stream, output-stream, error}; + @since(version = 0.2.0) + use wasi:clocks/wall-clock@0.2.2.{datetime}; + + /// File size or length of a region within a file. + @since(version = 0.2.0) + type filesize = u64; + + /// The type of a filesystem object referenced by a descriptor. + /// + /// Note: This was called `filetype` in earlier versions of WASI. + @since(version = 0.2.0) + enum descriptor-type { + /// The type of the descriptor or file is unknown or is different from + /// any of the other types specified. + unknown, + /// The descriptor refers to a block device inode. + block-device, + /// The descriptor refers to a character device inode. + character-device, + /// The descriptor refers to a directory inode. + directory, + /// The descriptor refers to a named pipe. + fifo, + /// The file refers to a symbolic link inode. + symbolic-link, + /// The descriptor refers to a regular file inode. + regular-file, + /// The descriptor refers to a socket. + socket, + } + + /// Descriptor flags. + /// + /// Note: This was called `fdflags` in earlier versions of WASI. + @since(version = 0.2.0) + flags descriptor-flags { + /// Read mode: Data can be read. + read, + /// Write mode: Data can be written to. + write, + /// Request that writes be performed according to synchronized I/O file + /// integrity completion. The data stored in the file and the file's + /// metadata are synchronized. This is similar to `O_SYNC` in POSIX. + /// + /// The precise semantics of this operation have not yet been defined for + /// WASI. At this time, it should be interpreted as a request, and not a + /// requirement. + file-integrity-sync, + /// Request that writes be performed according to synchronized I/O data + /// integrity completion. Only the data stored in the file is + /// synchronized. This is similar to `O_DSYNC` in POSIX. + /// + /// The precise semantics of this operation have not yet been defined for + /// WASI. At this time, it should be interpreted as a request, and not a + /// requirement. + data-integrity-sync, + /// Requests that reads be performed at the same level of integrity + /// requested for writes. This is similar to `O_RSYNC` in POSIX. + /// + /// The precise semantics of this operation have not yet been defined for + /// WASI. At this time, it should be interpreted as a request, and not a + /// requirement. + requested-write-sync, + /// Mutating directories mode: Directory contents may be mutated. + /// + /// When this flag is unset on a descriptor, operations using the + /// descriptor which would create, rename, delete, modify the data or + /// metadata of filesystem objects, or obtain another handle which + /// would permit any of those, shall fail with `error-code::read-only` if + /// they would otherwise succeed. + /// + /// This may only be set on directories. + mutate-directory, + } + + /// Flags determining the method of how paths are resolved. + @since(version = 0.2.0) + flags path-flags { + /// As long as the resolved path corresponds to a symbolic link, it is + /// expanded. + symlink-follow, + } + + /// Open flags used by `open-at`. + @since(version = 0.2.0) + flags open-flags { + /// Create file if it does not exist, similar to `O_CREAT` in POSIX. + create, + /// Fail if not a directory, similar to `O_DIRECTORY` in POSIX. + directory, + /// Fail if file already exists, similar to `O_EXCL` in POSIX. + exclusive, + /// Truncate file to size 0, similar to `O_TRUNC` in POSIX. + truncate, + } + + /// Number of hard links to an inode. + @since(version = 0.2.0) + type link-count = u64; + + /// File attributes. + /// + /// Note: This was called `filestat` in earlier versions of WASI. + @since(version = 0.2.0) + record descriptor-stat { + /// File type. + %type: descriptor-type, + /// Number of hard links to the file. + link-count: link-count, + /// For regular files, the file size in bytes. For symbolic links, the + /// length in bytes of the pathname contained in the symbolic link. + size: filesize, + /// Last data access timestamp. + /// + /// If the `option` is none, the platform doesn't maintain an access + /// timestamp for this file. + data-access-timestamp: option, + /// Last data modification timestamp. + /// + /// If the `option` is none, the platform doesn't maintain a + /// modification timestamp for this file. + data-modification-timestamp: option, + /// Last file status-change timestamp. + /// + /// If the `option` is none, the platform doesn't maintain a + /// status-change timestamp for this file. + status-change-timestamp: option, + } + + /// When setting a timestamp, this gives the value to set it to. + @since(version = 0.2.0) + variant new-timestamp { + /// Leave the timestamp set to its previous value. + no-change, + /// Set the timestamp to the current time of the system clock associated + /// with the filesystem. + now, + /// Set the timestamp to the given value. + timestamp(datetime), + } + + /// A directory entry. + record directory-entry { + /// The type of the file referred to by this directory entry. + %type: descriptor-type, + /// The name of the object. + name: string, + } + + /// Error codes returned by functions, similar to `errno` in POSIX. + /// Not all of these error codes are returned by the functions provided by this + /// API; some are used in higher-level library layers, and others are provided + /// merely for alignment with POSIX. + enum error-code { + /// Permission denied, similar to `EACCES` in POSIX. + access, + /// Resource unavailable, or operation would block, similar to `EAGAIN` and `EWOULDBLOCK` in POSIX. + would-block, + /// Connection already in progress, similar to `EALREADY` in POSIX. + already, + /// Bad descriptor, similar to `EBADF` in POSIX. + bad-descriptor, + /// Device or resource busy, similar to `EBUSY` in POSIX. + busy, + /// Resource deadlock would occur, similar to `EDEADLK` in POSIX. + deadlock, + /// Storage quota exceeded, similar to `EDQUOT` in POSIX. + quota, + /// File exists, similar to `EEXIST` in POSIX. + exist, + /// File too large, similar to `EFBIG` in POSIX. + file-too-large, + /// Illegal byte sequence, similar to `EILSEQ` in POSIX. + illegal-byte-sequence, + /// Operation in progress, similar to `EINPROGRESS` in POSIX. + in-progress, + /// Interrupted function, similar to `EINTR` in POSIX. + interrupted, + /// Invalid argument, similar to `EINVAL` in POSIX. + invalid, + /// I/O error, similar to `EIO` in POSIX. + io, + /// Is a directory, similar to `EISDIR` in POSIX. + is-directory, + /// Too many levels of symbolic links, similar to `ELOOP` in POSIX. + loop, + /// Too many links, similar to `EMLINK` in POSIX. + too-many-links, + /// Message too large, similar to `EMSGSIZE` in POSIX. + message-size, + /// Filename too long, similar to `ENAMETOOLONG` in POSIX. + name-too-long, + /// No such device, similar to `ENODEV` in POSIX. + no-device, + /// No such file or directory, similar to `ENOENT` in POSIX. + no-entry, + /// No locks available, similar to `ENOLCK` in POSIX. + no-lock, + /// Not enough space, similar to `ENOMEM` in POSIX. + insufficient-memory, + /// No space left on device, similar to `ENOSPC` in POSIX. + insufficient-space, + /// Not a directory or a symbolic link to a directory, similar to `ENOTDIR` in POSIX. + not-directory, + /// Directory not empty, similar to `ENOTEMPTY` in POSIX. + not-empty, + /// State not recoverable, similar to `ENOTRECOVERABLE` in POSIX. + not-recoverable, + /// Not supported, similar to `ENOTSUP` and `ENOSYS` in POSIX. + unsupported, + /// Inappropriate I/O control operation, similar to `ENOTTY` in POSIX. + no-tty, + /// No such device or address, similar to `ENXIO` in POSIX. + no-such-device, + /// Value too large to be stored in data type, similar to `EOVERFLOW` in POSIX. + overflow, + /// Operation not permitted, similar to `EPERM` in POSIX. + not-permitted, + /// Broken pipe, similar to `EPIPE` in POSIX. + pipe, + /// Read-only file system, similar to `EROFS` in POSIX. + read-only, + /// Invalid seek, similar to `ESPIPE` in POSIX. + invalid-seek, + /// Text file busy, similar to `ETXTBSY` in POSIX. + text-file-busy, + /// Cross-device link, similar to `EXDEV` in POSIX. + cross-device, + } + + /// File or memory access pattern advisory information. + @since(version = 0.2.0) + enum advice { + /// The application has no advice to give on its behavior with respect + /// to the specified data. + normal, + /// The application expects to access the specified data sequentially + /// from lower offsets to higher offsets. + sequential, + /// The application expects to access the specified data in a random + /// order. + random, + /// The application expects to access the specified data in the near + /// future. + will-need, + /// The application expects that it will not access the specified data + /// in the near future. + dont-need, + /// The application expects to access the specified data once and then + /// not reuse it thereafter. + no-reuse, + } + + /// A 128-bit hash value, split into parts because wasm doesn't have a + /// 128-bit integer type. + @since(version = 0.2.0) + record metadata-hash-value { + /// 64 bits of a 128-bit hash value. + lower: u64, + /// Another 64 bits of a 128-bit hash value. + upper: u64, + } + + /// A descriptor is a reference to a filesystem object, which may be a file, + /// directory, named pipe, special file, or other object on which filesystem + /// calls may be made. + @since(version = 0.2.0) + resource descriptor { + /// Return a stream for reading from a file, if available. + /// + /// May fail with an error-code describing why the file cannot be read. + /// + /// Multiple read, write, and append streams may be active on the same open + /// file and they do not interfere with each other. + /// + /// Note: This allows using `read-stream`, which is similar to `read` in POSIX. + @since(version = 0.2.0) + read-via-stream: func(offset: filesize) -> result; + /// Return a stream for writing to a file, if available. + /// + /// May fail with an error-code describing why the file cannot be written. + /// + /// Note: This allows using `write-stream`, which is similar to `write` in + /// POSIX. + @since(version = 0.2.0) + write-via-stream: func(offset: filesize) -> result; + /// Return a stream for appending to a file, if available. + /// + /// May fail with an error-code describing why the file cannot be appended. + /// + /// Note: This allows using `write-stream`, which is similar to `write` with + /// `O_APPEND` in in POSIX. + @since(version = 0.2.0) + append-via-stream: func() -> result; + /// Provide file advisory information on a descriptor. + /// + /// This is similar to `posix_fadvise` in POSIX. + @since(version = 0.2.0) + advise: func(offset: filesize, length: filesize, advice: advice) -> result<_, error-code>; + /// Synchronize the data of a file to disk. + /// + /// This function succeeds with no effect if the file descriptor is not + /// opened for writing. + /// + /// Note: This is similar to `fdatasync` in POSIX. + @since(version = 0.2.0) + sync-data: func() -> result<_, error-code>; + /// Get flags associated with a descriptor. + /// + /// Note: This returns similar flags to `fcntl(fd, F_GETFL)` in POSIX. + /// + /// Note: This returns the value that was the `fs_flags` value returned + /// from `fdstat_get` in earlier versions of WASI. + @since(version = 0.2.0) + get-flags: func() -> result; + /// Get the dynamic type of a descriptor. + /// + /// Note: This returns the same value as the `type` field of the `fd-stat` + /// returned by `stat`, `stat-at` and similar. + /// + /// Note: This returns similar flags to the `st_mode & S_IFMT` value provided + /// by `fstat` in POSIX. + /// + /// Note: This returns the value that was the `fs_filetype` value returned + /// from `fdstat_get` in earlier versions of WASI. + @since(version = 0.2.0) + get-type: func() -> result; + /// Adjust the size of an open file. If this increases the file's size, the + /// extra bytes are filled with zeros. + /// + /// Note: This was called `fd_filestat_set_size` in earlier versions of WASI. + @since(version = 0.2.0) + set-size: func(size: filesize) -> result<_, error-code>; + /// Adjust the timestamps of an open file or directory. + /// + /// Note: This is similar to `futimens` in POSIX. + /// + /// Note: This was called `fd_filestat_set_times` in earlier versions of WASI. + @since(version = 0.2.0) + set-times: func(data-access-timestamp: new-timestamp, data-modification-timestamp: new-timestamp) -> result<_, error-code>; + /// Read from a descriptor, without using and updating the descriptor's offset. + /// + /// This function returns a list of bytes containing the data that was + /// read, along with a bool which, when true, indicates that the end of the + /// file was reached. The returned list will contain up to `length` bytes; it + /// may return fewer than requested, if the end of the file is reached or + /// if the I/O operation is interrupted. + /// + /// In the future, this may change to return a `stream`. + /// + /// Note: This is similar to `pread` in POSIX. + @since(version = 0.2.0) + read: func(length: filesize, offset: filesize) -> result, bool>, error-code>; + /// Write to a descriptor, without using and updating the descriptor's offset. + /// + /// It is valid to write past the end of a file; the file is extended to the + /// extent of the write, with bytes between the previous end and the start of + /// the write set to zero. + /// + /// In the future, this may change to take a `stream`. + /// + /// Note: This is similar to `pwrite` in POSIX. + @since(version = 0.2.0) + write: func(buffer: list, offset: filesize) -> result; + /// Read directory entries from a directory. + /// + /// On filesystems where directories contain entries referring to themselves + /// and their parents, often named `.` and `..` respectively, these entries + /// are omitted. + /// + /// This always returns a new stream which starts at the beginning of the + /// directory. Multiple streams may be active on the same directory, and they + /// do not interfere with each other. + @since(version = 0.2.0) + read-directory: func() -> result; + /// Synchronize the data and metadata of a file to disk. + /// + /// This function succeeds with no effect if the file descriptor is not + /// opened for writing. + /// + /// Note: This is similar to `fsync` in POSIX. + @since(version = 0.2.0) + sync: func() -> result<_, error-code>; + /// Create a directory. + /// + /// Note: This is similar to `mkdirat` in POSIX. + @since(version = 0.2.0) + create-directory-at: func(path: string) -> result<_, error-code>; + /// Return the attributes of an open file or directory. + /// + /// Note: This is similar to `fstat` in POSIX, except that it does not return + /// device and inode information. For testing whether two descriptors refer to + /// the same underlying filesystem object, use `is-same-object`. To obtain + /// additional data that can be used do determine whether a file has been + /// modified, use `metadata-hash`. + /// + /// Note: This was called `fd_filestat_get` in earlier versions of WASI. + @since(version = 0.2.0) + stat: func() -> result; + /// Return the attributes of a file or directory. + /// + /// Note: This is similar to `fstatat` in POSIX, except that it does not + /// return device and inode information. See the `stat` description for a + /// discussion of alternatives. + /// + /// Note: This was called `path_filestat_get` in earlier versions of WASI. + @since(version = 0.2.0) + stat-at: func(path-flags: path-flags, path: string) -> result; + /// Adjust the timestamps of a file or directory. + /// + /// Note: This is similar to `utimensat` in POSIX. + /// + /// Note: This was called `path_filestat_set_times` in earlier versions of + /// WASI. + @since(version = 0.2.0) + set-times-at: func(path-flags: path-flags, path: string, data-access-timestamp: new-timestamp, data-modification-timestamp: new-timestamp) -> result<_, error-code>; + /// Create a hard link. + /// + /// Note: This is similar to `linkat` in POSIX. + @since(version = 0.2.0) + link-at: func(old-path-flags: path-flags, old-path: string, new-descriptor: borrow, new-path: string) -> result<_, error-code>; + /// Open a file or directory. + /// + /// If `flags` contains `descriptor-flags::mutate-directory`, and the base + /// descriptor doesn't have `descriptor-flags::mutate-directory` set, + /// `open-at` fails with `error-code::read-only`. + /// + /// If `flags` contains `write` or `mutate-directory`, or `open-flags` + /// contains `truncate` or `create`, and the base descriptor doesn't have + /// `descriptor-flags::mutate-directory` set, `open-at` fails with + /// `error-code::read-only`. + /// + /// Note: This is similar to `openat` in POSIX. + @since(version = 0.2.0) + open-at: func(path-flags: path-flags, path: string, open-flags: open-flags, %flags: descriptor-flags) -> result; + /// Read the contents of a symbolic link. + /// + /// If the contents contain an absolute or rooted path in the underlying + /// filesystem, this function fails with `error-code::not-permitted`. + /// + /// Note: This is similar to `readlinkat` in POSIX. + @since(version = 0.2.0) + readlink-at: func(path: string) -> result; + /// Remove a directory. + /// + /// Return `error-code::not-empty` if the directory is not empty. + /// + /// Note: This is similar to `unlinkat(fd, path, AT_REMOVEDIR)` in POSIX. + @since(version = 0.2.0) + remove-directory-at: func(path: string) -> result<_, error-code>; + /// Rename a filesystem object. + /// + /// Note: This is similar to `renameat` in POSIX. + @since(version = 0.2.0) + rename-at: func(old-path: string, new-descriptor: borrow, new-path: string) -> result<_, error-code>; + /// Create a symbolic link (also known as a "symlink"). + /// + /// If `old-path` starts with `/`, the function fails with + /// `error-code::not-permitted`. + /// + /// Note: This is similar to `symlinkat` in POSIX. + @since(version = 0.2.0) + symlink-at: func(old-path: string, new-path: string) -> result<_, error-code>; + /// Unlink a filesystem object that is not a directory. + /// + /// Return `error-code::is-directory` if the path refers to a directory. + /// Note: This is similar to `unlinkat(fd, path, 0)` in POSIX. + @since(version = 0.2.0) + unlink-file-at: func(path: string) -> result<_, error-code>; + /// Test whether two descriptors refer to the same filesystem object. + /// + /// In POSIX, this corresponds to testing whether the two descriptors have the + /// same device (`st_dev`) and inode (`st_ino` or `d_ino`) numbers. + /// wasi-filesystem does not expose device and inode numbers, so this function + /// may be used instead. + @since(version = 0.2.0) + is-same-object: func(other: borrow) -> bool; + /// Return a hash of the metadata associated with a filesystem object referred + /// to by a descriptor. + /// + /// This returns a hash of the last-modification timestamp and file size, and + /// may also include the inode number, device number, birth timestamp, and + /// other metadata fields that may change when the file is modified or + /// replaced. It may also include a secret value chosen by the + /// implementation and not otherwise exposed. + /// + /// Implementations are encourated to provide the following properties: + /// + /// - If the file is not modified or replaced, the computed hash value should + /// usually not change. + /// - If the object is modified or replaced, the computed hash value should + /// usually change. + /// - The inputs to the hash should not be easily computable from the + /// computed hash. + /// + /// However, none of these is required. + @since(version = 0.2.0) + metadata-hash: func() -> result; + /// Return a hash of the metadata associated with a filesystem object referred + /// to by a directory descriptor and a relative path. + /// + /// This performs the same hash computation as `metadata-hash`. + @since(version = 0.2.0) + metadata-hash-at: func(path-flags: path-flags, path: string) -> result; + } + + /// A stream of directory entries. + @since(version = 0.2.0) + resource directory-entry-stream { + /// Read a single directory entry from a `directory-entry-stream`. + @since(version = 0.2.0) + read-directory-entry: func() -> result, error-code>; + } + + /// Attempts to extract a filesystem-related `error-code` from the stream + /// `error` provided. + /// + /// Stream operations which return `stream-error::last-operation-failed` + /// have a payload with more information about the operation that failed. + /// This payload can be passed through to this function to see if there's + /// filesystem-related information about the error to return. + /// + /// Note that this function is fallible because not all stream-related + /// errors are filesystem-related errors. + @since(version = 0.2.0) + filesystem-error-code: func(err: borrow) -> option; +} + +@since(version = 0.2.0) +interface preopens { + @since(version = 0.2.0) + use types.{descriptor}; + + /// Return the set of preopened directories, and their path. + @since(version = 0.2.0) + get-directories: func() -> list>; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/error@0.2.2; + @since(version = 0.2.0) + import wasi:io/poll@0.2.2; + @since(version = 0.2.0) + import wasi:io/streams@0.2.2; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.2; + @since(version = 0.2.0) + import types; + @since(version = 0.2.0) + import preopens; +} diff --git a/test/fixtures/wasi/0.2.2/wasi_http@0.2.2.wit b/test/fixtures/wasi/0.2.2/wasi_http@0.2.2.wit new file mode 100644 index 00000000..3c17da20 --- /dev/null +++ b/test/fixtures/wasi/0.2.2/wasi_http@0.2.2.wit @@ -0,0 +1,721 @@ +package wasi:http@0.2.2; + +/// This interface defines all of the types and methods for implementing +/// HTTP Requests and Responses, both incoming and outgoing, as well as +/// their headers, trailers, and bodies. +@since(version = 0.2.0) +interface types { + @since(version = 0.2.0) + use wasi:clocks/monotonic-clock@0.2.2.{duration}; + @since(version = 0.2.0) + use wasi:io/streams@0.2.2.{input-stream, output-stream}; + @since(version = 0.2.0) + use wasi:io/error@0.2.2.{error as io-error}; + @since(version = 0.2.0) + use wasi:io/poll@0.2.2.{pollable}; + + /// This type corresponds to HTTP standard Methods. + @since(version = 0.2.0) + variant method { + get, + head, + post, + put, + delete, + connect, + options, + trace, + patch, + other(string), + } + + /// This type corresponds to HTTP standard Related Schemes. + @since(version = 0.2.0) + variant scheme { + HTTP, + HTTPS, + other(string), + } + + /// Defines the case payload type for `DNS-error` above: + @since(version = 0.2.0) + record DNS-error-payload { + rcode: option, + info-code: option, + } + + /// Defines the case payload type for `TLS-alert-received` above: + @since(version = 0.2.0) + record TLS-alert-received-payload { + alert-id: option, + alert-message: option, + } + + /// Defines the case payload type for `HTTP-response-{header,trailer}-size` above: + @since(version = 0.2.0) + record field-size-payload { + field-name: option, + field-size: option, + } + + /// These cases are inspired by the IANA HTTP Proxy Error Types: + /// https://www.iana.org/assignments/http-proxy-status/http-proxy-status.xhtml#table-http-proxy-error-types + @since(version = 0.2.0) + variant error-code { + DNS-timeout, + DNS-error(DNS-error-payload), + destination-not-found, + destination-unavailable, + destination-IP-prohibited, + destination-IP-unroutable, + connection-refused, + connection-terminated, + connection-timeout, + connection-read-timeout, + connection-write-timeout, + connection-limit-reached, + TLS-protocol-error, + TLS-certificate-error, + TLS-alert-received(TLS-alert-received-payload), + HTTP-request-denied, + HTTP-request-length-required, + HTTP-request-body-size(option), + HTTP-request-method-invalid, + HTTP-request-URI-invalid, + HTTP-request-URI-too-long, + HTTP-request-header-section-size(option), + HTTP-request-header-size(option), + HTTP-request-trailer-section-size(option), + HTTP-request-trailer-size(field-size-payload), + HTTP-response-incomplete, + HTTP-response-header-section-size(option), + HTTP-response-header-size(field-size-payload), + HTTP-response-body-size(option), + HTTP-response-trailer-section-size(option), + HTTP-response-trailer-size(field-size-payload), + HTTP-response-transfer-coding(option), + HTTP-response-content-coding(option), + HTTP-response-timeout, + HTTP-upgrade-failed, + HTTP-protocol-error, + loop-detected, + 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. + internal-error(option), + } + + /// This type enumerates the different kinds of errors that may occur when + /// setting or appending to a `fields` resource. + @since(version = 0.2.0) + variant header-error { + /// This error indicates that a `field-name` or `field-value` was + /// syntactically invalid when used with an operation that sets headers in a + /// `fields`. + invalid-syntax, + /// This error indicates that a forbidden `field-name` was used when trying + /// to set a header in a `fields`. + forbidden, + /// This error indicates that the operation on the `fields` was not + /// permitted because the fields are immutable. + immutable, + } + + /// Field keys are always strings. + /// + /// Field keys should always be treated as case insensitive by the `fields` + /// resource for the purposes of equality checking. + /// + /// # Deprecation + /// + /// This type has been deprecated in favor of the `field-name` type. + @since(version = 0.2.0) + @deprecated(version = 0.2.2) + type field-key = string; + + /// Field names are always strings. + /// + /// Field names should always be treated as case insensitive by the `fields` + /// resource for the purposes of equality checking. + @since(version = 0.2.1) + type field-name = field-key; + + /// Field values should always be ASCII strings. However, in + /// reality, HTTP implementations often have to interpret malformed values, + /// so they are provided as a list of bytes. + @since(version = 0.2.0) + type field-value = list; + + /// This following block defines the `fields` resource which corresponds to + /// HTTP standard Fields. Fields are a common representation used for both + /// Headers and Trailers. + /// + /// A `fields` may be mutable or immutable. A `fields` created using the + /// constructor, `from-list`, or `clone` will be mutable, but a `fields` + /// resource given by other means (including, but not limited to, + /// `incoming-request.headers`, `outgoing-request.headers`) might be be + /// immutable. In an immutable fields, the `set`, `append`, and `delete` + /// operations will fail with `header-error.immutable`. + @since(version = 0.2.0) + resource fields { + /// Construct an empty HTTP Fields. + /// + /// The resulting `fields` is mutable. + @since(version = 0.2.0) + constructor(); + /// Construct an HTTP Fields. + /// + /// The resulting `fields` is mutable. + /// + /// The list represents each name-value pair in the Fields. Names + /// which have multiple values are represented by multiple entries in this + /// list with the same name. + /// + /// The tuple is a pair of the field name, represented as a string, and + /// Value, represented as a list of bytes. + /// + /// An error result will be returned if any `field-name` or `field-value` is + /// syntactically invalid, or if a field is forbidden. + @since(version = 0.2.0) + from-list: static func(entries: list>) -> result; + /// Get all of the values corresponding to a name. If the name is not present + /// in this `fields` or is syntactically invalid, an empty list is returned. + /// However, if the name is present but empty, this is represented by a list + /// with one or more empty field-values present. + @since(version = 0.2.0) + get: func(name: field-name) -> list; + /// Returns `true` when the name is present in this `fields`. If the name is + /// syntactically invalid, `false` is returned. + @since(version = 0.2.0) + has: func(name: field-name) -> bool; + /// Set all of the values for a name. Clears any existing values for that + /// name, if they have been set. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.invalid-syntax` if the `field-name` or any of + /// the `field-value`s are syntactically invalid. + @since(version = 0.2.0) + set: func(name: field-name, value: list) -> result<_, header-error>; + /// Delete all values for a name. Does nothing if no values for the name + /// exist. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.invalid-syntax` if the `field-name` is + /// syntactically invalid. + @since(version = 0.2.0) + delete: func(name: field-name) -> result<_, header-error>; + /// Append a value for a name. Does not change or delete any existing + /// values for that name. + /// + /// Fails with `header-error.immutable` if the `fields` are immutable. + /// + /// Fails with `header-error.invalid-syntax` if the `field-name` or + /// `field-value` are syntactically invalid. + @since(version = 0.2.0) + append: func(name: field-name, value: field-value) -> result<_, header-error>; + /// Retrieve the full set of names and values in the Fields. Like the + /// constructor, the list represents each name-value pair. + /// + /// The outer list represents each name-value pair in the Fields. Names + /// which have multiple values are represented by multiple entries in this + /// list with the same name. + /// + /// The names and values are always returned in the original casing and in + /// the order in which they will be serialized for transport. + @since(version = 0.2.0) + entries: func() -> list>; + /// Make a deep copy of the Fields. Equivalent in behavior to calling the + /// `fields` constructor on the return value of `entries`. The resulting + /// `fields` is mutable. + @since(version = 0.2.0) + clone: func() -> fields; + } + + /// Headers is an alias for Fields. + @since(version = 0.2.0) + type headers = fields; + + /// Trailers is an alias for Fields. + @since(version = 0.2.0) + type trailers = fields; + + /// Represents an incoming HTTP Request. + @since(version = 0.2.0) + resource incoming-request { + /// Returns the method of the incoming request. + @since(version = 0.2.0) + method: func() -> method; + /// Returns the path with query parameters from the request, as a string. + @since(version = 0.2.0) + path-with-query: func() -> option; + /// Returns the protocol scheme from the request. + @since(version = 0.2.0) + scheme: func() -> option; + /// Returns the authority of the Request's target URI, if present. + @since(version = 0.2.0) + authority: func() -> option; + /// Get the `headers` associated with the request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// The `headers` returned are a child resource: it must be dropped before + /// the parent `incoming-request` is dropped. Dropping this + /// `incoming-request` before all children are dropped will trap. + @since(version = 0.2.0) + headers: func() -> headers; + /// Gives the `incoming-body` associated with this request. Will only + /// return success at most once, and subsequent calls will return error. + @since(version = 0.2.0) + consume: func() -> result; + } + + /// Represents an outgoing HTTP Request. + @since(version = 0.2.0) + resource outgoing-request { + /// Construct a new `outgoing-request` with a default `method` of `GET`, and + /// `none` values for `path-with-query`, `scheme`, and `authority`. + /// + /// * `headers` is the HTTP Headers for the Request. + /// + /// It is possible to construct, or manipulate with the accessor functions + /// below, an `outgoing-request` with an invalid combination of `scheme` + /// and `authority`, or `headers` which are not permitted to be sent. + /// It is the obligation of the `outgoing-handler.handle` implementation + /// to reject invalid constructions of `outgoing-request`. + @since(version = 0.2.0) + constructor(headers: headers); + /// Returns the resource corresponding to the outgoing Body for this + /// Request. + /// + /// Returns success on the first call: the `outgoing-body` resource for + /// this `outgoing-request` can be retrieved at most once. Subsequent + /// calls will return error. + @since(version = 0.2.0) + body: func() -> result; + /// Get the Method for the Request. + @since(version = 0.2.0) + method: func() -> method; + /// Set the Method for the Request. Fails if the string present in a + /// `method.other` argument is not a syntactically valid method. + @since(version = 0.2.0) + set-method: func(method: method) -> result; + /// Get the combination of the HTTP Path and Query for the Request. + /// When `none`, this represents an empty Path and empty Query. + @since(version = 0.2.0) + path-with-query: func() -> option; + /// Set the combination of the HTTP Path and Query for the Request. + /// When `none`, this represents an empty Path and empty Query. Fails is the + /// string given is not a syntactically valid path and query uri component. + @since(version = 0.2.0) + set-path-with-query: func(path-with-query: option) -> result; + /// Get the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. + @since(version = 0.2.0) + scheme: func() -> option; + /// Set the HTTP Related Scheme for the Request. When `none`, the + /// implementation may choose an appropriate default scheme. Fails if the + /// string given is not a syntactically valid uri scheme. + @since(version = 0.2.0) + set-scheme: func(scheme: option) -> result; + /// Get the authority of the Request's target URI. A value of `none` may be used + /// with Related Schemes which do not require an authority. The HTTP and + /// HTTPS schemes always require an authority. + @since(version = 0.2.0) + authority: func() -> option; + /// Set the authority of the Request's target URI. A value of `none` may be used + /// with Related Schemes which do not require an authority. The HTTP and + /// HTTPS schemes always require an authority. Fails if the string given is + /// not a syntactically valid URI authority. + @since(version = 0.2.0) + set-authority: func(authority: option) -> result; + /// Get the headers associated with the Request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `outgoing-request` is dropped, or its ownership is transferred to + /// another component by e.g. `outgoing-handler.handle`. + @since(version = 0.2.0) + headers: func() -> headers; + } + + /// Parameters for making an HTTP Request. Each of these parameters is + /// currently an optional timeout applicable to the transport layer of the + /// HTTP protocol. + /// + /// These timeouts are separate from any the user may use to bound a + /// blocking call to `wasi:io/poll.poll`. + @since(version = 0.2.0) + resource request-options { + /// Construct a default `request-options` value. + @since(version = 0.2.0) + constructor(); + /// The timeout for the initial connect to the HTTP Server. + @since(version = 0.2.0) + connect-timeout: func() -> option; + /// Set the timeout for the initial connect to the HTTP Server. An error + /// return value indicates that this timeout is not supported. + @since(version = 0.2.0) + set-connect-timeout: func(duration: option) -> result; + /// The timeout for receiving the first byte of the Response body. + @since(version = 0.2.0) + first-byte-timeout: func() -> option; + /// Set the timeout for receiving the first byte of the Response body. An + /// error return value indicates that this timeout is not supported. + @since(version = 0.2.0) + set-first-byte-timeout: func(duration: option) -> result; + /// The timeout for receiving subsequent chunks of bytes in the Response + /// body stream. + @since(version = 0.2.0) + between-bytes-timeout: func() -> option; + /// Set the timeout for receiving subsequent chunks of bytes in the Response + /// body stream. An error return value indicates that this timeout is not + /// supported. + @since(version = 0.2.0) + set-between-bytes-timeout: func(duration: option) -> result; + } + + /// Represents the ability to send an HTTP Response. + /// + /// This resource is used by the `wasi:http/incoming-handler` interface to + /// allow a Response to be sent corresponding to the Request provided as the + /// other argument to `incoming-handler.handle`. + @since(version = 0.2.0) + resource response-outparam { + /// Set the value of the `response-outparam` to either send a response, + /// or indicate an error. + /// + /// This method consumes the `response-outparam` to ensure that it is + /// called at most once. If it is never called, the implementation + /// will respond with an error. + /// + /// The user may provide an `error` to `response` to allow the + /// implementation determine how to respond with an HTTP error response. + @since(version = 0.2.0) + set: static func(param: response-outparam, response: result); + } + + /// This type corresponds to the HTTP standard Status Code. + @since(version = 0.2.0) + type status-code = u16; + + /// Represents an incoming HTTP Response. + @since(version = 0.2.0) + resource incoming-response { + /// Returns the status code from the incoming response. + @since(version = 0.2.0) + status: func() -> status-code; + /// Returns the headers from the incoming response. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `incoming-response` is dropped. + @since(version = 0.2.0) + headers: func() -> headers; + /// Returns the incoming body. May be called at most once. Returns error + /// if called additional times. + @since(version = 0.2.0) + consume: func() -> result; + } + + /// Represents an incoming HTTP Request or Response's Body. + /// + /// A body has both its contents - a stream of bytes - and a (possibly + /// empty) set of trailers, indicating that the full contents of the + /// body have been received. This resource represents the contents as + /// an `input-stream` and the delivery of trailers as a `future-trailers`, + /// and ensures that the user of this interface may only be consuming either + /// the body contents or waiting on trailers at any given time. + @since(version = 0.2.0) + resource incoming-body { + /// Returns the contents of the body, as a stream of bytes. + /// + /// Returns success on first call: the stream representing the contents + /// can be retrieved at most once. Subsequent calls will return error. + /// + /// The returned `input-stream` resource is a child: it must be dropped + /// before the parent `incoming-body` is dropped, or consumed by + /// `incoming-body.finish`. + /// + /// This invariant ensures that the implementation can determine whether + /// the user is consuming the contents of the body, waiting on the + /// `future-trailers` to be ready, or neither. This allows for network + /// backpressure is to be applied when the user is consuming the body, + /// and for that backpressure to not inhibit delivery of the trailers if + /// the user does not read the entire body. + @since(version = 0.2.0) + %stream: func() -> result; + /// Takes ownership of `incoming-body`, and returns a `future-trailers`. + /// This function will trap if the `input-stream` child is still alive. + @since(version = 0.2.0) + finish: static func(this: incoming-body) -> future-trailers; + } + + /// Represents a future which may eventually return trailers, or an error. + /// + /// In the case that the incoming HTTP Request or Response did not have any + /// trailers, this future will resolve to the empty set of trailers once the + /// complete Request or Response body has been received. + @since(version = 0.2.0) + resource future-trailers { + /// Returns a pollable which becomes ready when either the trailers have + /// been received, or an error has occurred. When this pollable is ready, + /// the `get` method will return `some`. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Returns the contents of the trailers, or an error which occurred, + /// once the future is ready. + /// + /// The outer `option` represents future readiness. Users can wait on this + /// `option` to become `some` using the `subscribe` method. + /// + /// The outer `result` is used to retrieve the trailers or error at most + /// once. It will be success on the first call in which the outer option + /// is `some`, and error on subsequent calls. + /// + /// The inner `result` represents that either the HTTP Request or Response + /// body, as well as any trailers, were received successfully, or that an + /// error occurred receiving them. The optional `trailers` indicates whether + /// or not trailers were present in the body. + /// + /// When some `trailers` are returned by this method, the `trailers` + /// resource is immutable, and a child. Use of the `set`, `append`, or + /// `delete` methods will return an error, and the resource must be + /// dropped before the parent `future-trailers` is dropped. + @since(version = 0.2.0) + get: func() -> option, error-code>>>; + } + + /// Represents an outgoing HTTP Response. + @since(version = 0.2.0) + resource outgoing-response { + /// Construct an `outgoing-response`, with a default `status-code` of `200`. + /// If a different `status-code` is needed, it must be set via the + /// `set-status-code` method. + /// + /// * `headers` is the HTTP Headers for the Response. + @since(version = 0.2.0) + constructor(headers: headers); + /// Get the HTTP Status Code for the Response. + @since(version = 0.2.0) + status-code: func() -> status-code; + /// Set the HTTP Status Code for the Response. Fails if the status-code + /// given is not a valid http status code. + @since(version = 0.2.0) + set-status-code: func(status-code: status-code) -> result; + /// Get the headers associated with the Request. + /// + /// The returned `headers` resource is immutable: `set`, `append`, and + /// `delete` operations will fail with `header-error.immutable`. + /// + /// This headers resource is a child: it must be dropped before the parent + /// `outgoing-request` is dropped, or its ownership is transferred to + /// another component by e.g. `outgoing-handler.handle`. + @since(version = 0.2.0) + headers: func() -> headers; + /// Returns the resource corresponding to the outgoing Body for this Response. + /// + /// Returns success on the first call: the `outgoing-body` resource for + /// this `outgoing-response` can be retrieved at most once. Subsequent + /// calls will return error. + @since(version = 0.2.0) + body: func() -> result; + } + + /// Represents an outgoing HTTP Request or Response's Body. + /// + /// A body has both its contents - a stream of bytes - and a (possibly + /// empty) set of trailers, inducating the full contents of the body + /// have been sent. This resource represents the contents as an + /// `output-stream` child resource, and the completion of the body (with + /// optional trailers) with a static function that consumes the + /// `outgoing-body` resource, and ensures that the user of this interface + /// may not write to the body contents after the body has been finished. + /// + /// If the user code drops this resource, as opposed to calling the static + /// method `finish`, the implementation should treat the body as incomplete, + /// and that an error has occurred. The implementation should propagate this + /// error to the HTTP protocol by whatever means it has available, + /// including: corrupting the body on the wire, aborting the associated + /// Request, or sending a late status code for the Response. + @since(version = 0.2.0) + resource outgoing-body { + /// Returns a stream for writing the body contents. + /// + /// The returned `output-stream` is a child resource: it must be dropped + /// before the parent `outgoing-body` resource is dropped (or finished), + /// otherwise the `outgoing-body` drop or `finish` will trap. + /// + /// Returns success on the first call: the `output-stream` resource for + /// this `outgoing-body` may be retrieved at most once. Subsequent calls + /// will return error. + @since(version = 0.2.0) + write: func() -> result; + /// Finalize an outgoing body, optionally providing trailers. This must be + /// called to signal that the response is complete. If the `outgoing-body` + /// is dropped without calling `outgoing-body.finalize`, the implementation + /// should treat the body as corrupted. + /// + /// Fails if the body's `outgoing-request` or `outgoing-response` was + /// constructed with a Content-Length header, and the contents written + /// to the body (via `write`) does not match the value given in the + /// Content-Length. + @since(version = 0.2.0) + finish: static func(this: outgoing-body, trailers: option) -> result<_, error-code>; + } + + /// Represents a future which may eventually return an incoming HTTP + /// Response, or an error. + /// + /// This resource is returned by the `wasi:http/outgoing-handler` interface to + /// provide the HTTP Response corresponding to the sent Request. + @since(version = 0.2.0) + resource future-incoming-response { + /// Returns a pollable which becomes ready when either the Response has + /// been received, or an error has occurred. When this pollable is ready, + /// the `get` method will return `some`. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Returns the incoming HTTP Response, or an error, once one is ready. + /// + /// The outer `option` represents future readiness. Users can wait on this + /// `option` to become `some` using the `subscribe` method. + /// + /// The outer `result` is used to retrieve the response or error at most + /// once. It will be success on the first call in which the outer option + /// is `some`, and error on subsequent calls. + /// + /// The inner `result` represents that either the incoming HTTP Response + /// status and headers have received successfully, or that an error + /// occurred. Errors may also occur while consuming the response body, + /// but those will be reported by the `incoming-body` and its + /// `output-stream` child. + @since(version = 0.2.0) + get: func() -> option>>; + } + + /// Attempts to extract a http-related `error` from the wasi:io `error` + /// provided. + /// + /// Stream operations which return + /// `wasi:io/stream/stream-error::last-operation-failed` have a payload of + /// type `wasi:io/error/error` with more information about the operation + /// that failed. This payload can be passed through to this function to see + /// if there's http-related information about the error to return. + /// + /// Note that this function is fallible because not all io-errors are + /// http-related errors. + @since(version = 0.2.0) + http-error-code: func(err: borrow) -> option; +} + +/// This interface defines a handler of incoming HTTP Requests. It should +/// be exported by components which can respond to HTTP Requests. +@since(version = 0.2.0) +interface incoming-handler { + @since(version = 0.2.0) + use types.{incoming-request, response-outparam}; + + /// This function is invoked with an incoming HTTP Request, and a resource + /// `response-outparam` which provides the capability to reply with an HTTP + /// Response. The response is sent by calling the `response-outparam.set` + /// method, which allows execution to continue after the response has been + /// sent. This enables both streaming to the response body, and performing other + /// work. + /// + /// The implementor of this function must write a response to the + /// `response-outparam` before returning, or else the caller will respond + /// with an error on its behalf. + @since(version = 0.2.0) + handle: func(request: incoming-request, response-out: response-outparam); +} + +/// This interface defines a handler of outgoing HTTP Requests. It should be +/// imported by components which wish to make HTTP Requests. +@since(version = 0.2.0) +interface outgoing-handler { + @since(version = 0.2.0) + use types.{outgoing-request, request-options, future-incoming-response, error-code}; + + /// This function is invoked with an outgoing HTTP Request, and it returns + /// a resource `future-incoming-response` which represents an HTTP Response + /// which may arrive in the future. + /// + /// The `options` argument accepts optional parameters for the HTTP + /// protocol's transport layer. + /// + /// This function may return an error if the `outgoing-request` is invalid + /// or not allowed to be made. Otherwise, protocol errors are reported + /// through the `future-incoming-response`. + @since(version = 0.2.0) + handle: func(request: outgoing-request, options: option) -> result; +} + +/// The `wasi:http/imports` world imports all the APIs for HTTP proxies. +/// It is intended to be `include`d in other worlds. +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import wasi:io/poll@0.2.2; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.2; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.2; + @since(version = 0.2.0) + import wasi:random/random@0.2.2; + @since(version = 0.2.0) + import wasi:io/error@0.2.2; + @since(version = 0.2.0) + import wasi:io/streams@0.2.2; + @since(version = 0.2.0) + import wasi:cli/stdout@0.2.2; + @since(version = 0.2.0) + import wasi:cli/stderr@0.2.2; + @since(version = 0.2.0) + import wasi:cli/stdin@0.2.2; + @since(version = 0.2.0) + import types; + @since(version = 0.2.0) + import outgoing-handler; +} +/// The `wasi:http/proxy` world captures a widely-implementable intersection of +/// hosts that includes HTTP forward and reverse proxies. Components targeting +/// this world may concurrently stream in and out any number of incoming and +/// outgoing HTTP requests. +@since(version = 0.2.0) +world proxy { + @since(version = 0.2.0) + import wasi:io/poll@0.2.2; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.2; + @since(version = 0.2.0) + import wasi:io/error@0.2.2; + @since(version = 0.2.0) + import wasi:io/streams@0.2.2; + @since(version = 0.2.0) + import types; + @since(version = 0.2.0) + import wasi:clocks/wall-clock@0.2.2; + @since(version = 0.2.0) + import wasi:random/random@0.2.2; + @since(version = 0.2.0) + import wasi:cli/stdout@0.2.2; + @since(version = 0.2.0) + import wasi:cli/stderr@0.2.2; + @since(version = 0.2.0) + import wasi:cli/stdin@0.2.2; + @since(version = 0.2.0) + import outgoing-handler; + + @since(version = 0.2.0) + export incoming-handler; +} diff --git a/test/fixtures/wasi/0.2.2/wasi_io@0.2.2.wit b/test/fixtures/wasi/0.2.2/wasi_io@0.2.2.wit new file mode 100644 index 00000000..a5a852ea --- /dev/null +++ b/test/fixtures/wasi/0.2.2/wasi_io@0.2.2.wit @@ -0,0 +1,331 @@ +package wasi:io@0.2.2; + +@since(version = 0.2.0) +interface error { + /// 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. + /// + /// In the `wasi:io` package, this resource is returned through the + /// `wasi:io/streams/stream-error` type. + /// + /// To provide more specific error information, other interfaces may + /// offer functions to "downcast" this error into more specific types. For example, + /// errors returned from streams derived from filesystem types can be described using + /// the filesystem's own error-code type. This is done using the function + /// `wasi:filesystem/types/filesystem-error-code`, which takes a `borrow` + /// parameter and returns an `option`. + /// + /// The set of functions which can "downcast" an `error` into a more + /// concrete type is open. + @since(version = 0.2.0) + resource error { + /// Returns a string that is suitable to assist humans in debugging + /// this error. + /// + /// WARNING: The returned string should not be consumed mechanically! + /// It may change across platforms, hosts, or other implementation + /// details. Parsing this string is a major platform-compatibility + /// hazard. + @since(version = 0.2.0) + to-debug-string: func() -> string; + } +} + +/// A poll API intended to let users wait for I/O events on multiple handles +/// at once. +@since(version = 0.2.0) +interface poll { + /// `pollable` represents a single I/O event which may be ready, or not. + @since(version = 0.2.0) + resource pollable { + /// Return the readiness of a pollable. This function never blocks. + /// + /// Returns `true` when the pollable is ready, and `false` otherwise. + @since(version = 0.2.0) + ready: func() -> bool; + /// `block` 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. + @since(version = 0.2.0) + block: func(); + } + + /// 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. + /// + /// This function traps if either: + /// - the list is empty, or: + /// - the list contains more elements than can be indexed with a `u32` value. + /// + /// A timeout can be implemented by adding a pollable from the + /// wasi-clocks API to the list. + /// + /// This function does not return a `result`; polling in itself does not + /// do any I/O so it doesn't fail. If any of the I/O sources identified by + /// the pollables has an error, it is indicated by marking the source as + /// being ready for I/O. + @since(version = 0.2.0) + poll: func(in: list>) -> list; +} + +/// WASI I/O is an I/O abstraction API which is currently focused on providing +/// stream types. +/// +/// In the future, the component model is expected to add built-in stream types; +/// when it does, they are expected to subsume this API. +@since(version = 0.2.0) +interface streams { + @since(version = 0.2.0) + use error.{error}; + @since(version = 0.2.0) + use poll.{pollable}; + + /// An error for input-stream and output-stream operations. + @since(version = 0.2.0) + variant stream-error { + /// The last operation (a write or flush) failed before completion. + /// + /// More information is available in the `error` payload. + /// + /// After this, the stream will be closed. All future operations return + /// `stream-error::closed`. + last-operation-failed(error), + /// The stream is closed: no more input will be accepted by the + /// stream. A closed output-stream will return this error on all + /// future operations. + closed, + } + + /// An input bytestream. + /// + /// `input-stream`s are *non-blocking* to the extent practical on underlying + /// platforms. I/O operations always return promptly; if fewer bytes are + /// promptly available than requested, they return the number of bytes promptly + /// available, which could even be zero. To wait for data to be available, + /// use the `subscribe` function to obtain a `pollable` which can be polled + /// for using `wasi:io/poll`. + @since(version = 0.2.0) + resource input-stream { + /// Perform a non-blocking read from the stream. + /// + /// When the source of a `read` is binary data, the bytes from the source + /// are returned verbatim. When the source of a `read` is known to the + /// implementation to be text, bytes containing the UTF-8 encoding of the + /// text are returned. + /// + /// This function returns a list of bytes containing the read data, + /// when successful. The returned list will contain up to `len` bytes; + /// it may return fewer than requested, but not more. The list is + /// empty when no bytes are available for reading at this time. The + /// pollable given by `subscribe` will be ready when more bytes are + /// available. + /// + /// This function fails with a `stream-error` when the operation + /// encounters an error, giving `last-operation-failed`, or when the + /// stream is closed, giving `closed`. + /// + /// When the caller gives a `len` of 0, it represents a request to + /// read 0 bytes. If the stream is still open, this call should + /// succeed and return an empty list, or otherwise fail with `closed`. + /// + /// The `len` parameter is a `u64`, which could represent a list of u8 which + /// is not possible to allocate in wasm32, or not desirable to allocate as + /// as a return value by the callee. The callee may return a list of bytes + /// less than `len` in size while more bytes are available for reading. + @since(version = 0.2.0) + read: func(len: u64) -> result, stream-error>; + /// Read bytes from a stream, after blocking until at least one byte can + /// be read. Except for blocking, behavior is identical to `read`. + @since(version = 0.2.0) + blocking-read: func(len: u64) -> result, stream-error>; + /// Skip bytes from a stream. Returns number of bytes skipped. + /// + /// Behaves identical to `read`, except instead of returning a list + /// of bytes, returns the number of bytes consumed from the stream. + @since(version = 0.2.0) + skip: func(len: u64) -> result; + /// Skip bytes from a stream, after blocking until at least one byte + /// can be skipped. Except for blocking behavior, identical to `skip`. + @since(version = 0.2.0) + blocking-skip: func(len: u64) -> result; + /// Create a `pollable` which will resolve once either the specified stream + /// has bytes available to read or the other end of the stream has been + /// closed. + /// The created `pollable` is a child resource of the `input-stream`. + /// Implementations may trap if the `input-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + /// An output bytestream. + /// + /// `output-stream`s are *non-blocking* to the extent practical on + /// underlying platforms. Except where specified otherwise, I/O operations also + /// always return promptly, after the number of bytes that can be written + /// promptly, which could even be zero. To wait for the stream to be ready to + /// accept data, the `subscribe` function to obtain a `pollable` which can be + /// polled for using `wasi:io/poll`. + /// + /// Dropping an `output-stream` while there's still an active write in + /// progress may result in the data being lost. Before dropping the stream, + /// be sure to fully flush your writes. + @since(version = 0.2.0) + resource output-stream { + /// Check readiness for writing. This function never blocks. + /// + /// Returns the number of bytes permitted for the next call to `write`, + /// or an error. Calling `write` with more bytes than this function has + /// permitted will trap. + /// + /// When this function returns 0 bytes, the `subscribe` pollable will + /// become ready when this function will report at least 1 byte, or an + /// error. + @since(version = 0.2.0) + check-write: func() -> result; + /// Perform a write. This function never blocks. + /// + /// When the destination of a `write` is binary data, the bytes from + /// `contents` are written verbatim. When the destination of a `write` is + /// known to the implementation to be text, the bytes of `contents` are + /// transcoded from UTF-8 into the encoding of the destination and then + /// written. + /// + /// Precondition: check-write gave permit of Ok(n) and contents has a + /// length of less than or equal to n. Otherwise, this function will trap. + /// + /// returns Err(closed) without writing if the stream has closed since + /// the last call to check-write provided a permit. + @since(version = 0.2.0) + write: func(contents: list) -> result<_, stream-error>; + /// Perform a write of up to 4096 bytes, and then flush the stream. Block + /// until all of these operations are complete, or an error occurs. + /// + /// This is a convenience wrapper around the use of `check-write`, + /// `subscribe`, `write`, and `flush`, and is implemented with the + /// following pseudo-code: + /// + /// ```text + /// let pollable = this.subscribe(); + /// while !contents.is_empty() { + /// // Wait for the stream to become writable + /// pollable.block(); + /// let Ok(n) = this.check-write(); // eliding error handling + /// let len = min(n, contents.len()); + /// let (chunk, rest) = contents.split_at(len); + /// this.write(chunk ); // eliding error handling + /// contents = rest; + /// } + /// this.flush(); + /// // Wait for completion of `flush` + /// pollable.block(); + /// // Check for any errors that arose during `flush` + /// let _ = this.check-write(); // eliding error handling + /// ``` + @since(version = 0.2.0) + blocking-write-and-flush: func(contents: list) -> result<_, stream-error>; + /// Request to flush buffered output. This function never blocks. + /// + /// This tells the output-stream that the caller intends any buffered + /// output to be flushed. the output which is expected to be flushed + /// is all that has been passed to `write` prior to this call. + /// + /// Upon calling this function, the `output-stream` will not accept any + /// writes (`check-write` will return `ok(0)`) until the flush has + /// completed. The `subscribe` pollable will become ready when the + /// flush has completed and the stream can accept more writes. + @since(version = 0.2.0) + flush: func() -> result<_, stream-error>; + /// Request to flush buffered output, and block until flush completes + /// and stream is ready for writing again. + @since(version = 0.2.0) + blocking-flush: func() -> result<_, stream-error>; + /// Create a `pollable` which will resolve once the output-stream + /// is ready for more writing, or an error has occurred. When this + /// pollable is ready, `check-write` will return `ok(n)` with n>0, or an + /// error. + /// + /// If the stream is closed, this pollable is always ready immediately. + /// + /// The created `pollable` is a child resource of the `output-stream`. + /// Implementations may trap if the `output-stream` is dropped before + /// all derived `pollable`s created with this function are dropped. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Write zeroes to a stream. + /// + /// This should be used precisely like `write` with the exact same + /// preconditions (must use check-write first), but instead of + /// passing a list of bytes, you simply pass the number of zero-bytes + /// that should be written. + @since(version = 0.2.0) + write-zeroes: func(len: u64) -> result<_, stream-error>; + /// Perform a write of up to 4096 zeroes, and then flush the stream. + /// Block until all of these operations are complete, or an error + /// occurs. + /// + /// This is a convenience wrapper around the use of `check-write`, + /// `subscribe`, `write-zeroes`, and `flush`, and is implemented with + /// the following pseudo-code: + /// + /// ```text + /// let pollable = this.subscribe(); + /// while num_zeroes != 0 { + /// // Wait for the stream to become writable + /// pollable.block(); + /// let Ok(n) = this.check-write(); // eliding error handling + /// let len = min(n, num_zeroes); + /// this.write-zeroes(len); // eliding error handling + /// num_zeroes -= len; + /// } + /// this.flush(); + /// // Wait for completion of `flush` + /// pollable.block(); + /// // Check for any errors that arose during `flush` + /// let _ = this.check-write(); // eliding error handling + /// ``` + @since(version = 0.2.0) + blocking-write-zeroes-and-flush: func(len: u64) -> result<_, stream-error>; + /// Read from one stream and write to another. + /// + /// The behavior of splice is equivalent to: + /// 1. calling `check-write` on the `output-stream` + /// 2. calling `read` on the `input-stream` with the smaller of the + /// `check-write` permitted length and the `len` provided to `splice` + /// 3. calling `write` on the `output-stream` with that read data. + /// + /// Any error reported by the call to `check-write`, `read`, or + /// `write` ends the splice and reports that error. + /// + /// This function returns the number of bytes transferred; it may be less + /// than `len`. + @since(version = 0.2.0) + splice: func(src: borrow, len: u64) -> result; + /// Read from one stream and write to another, with blocking. + /// + /// This is similar to `splice`, except that it blocks until the + /// `output-stream` is ready for writing, and the `input-stream` + /// is ready for reading, before performing the `splice`. + @since(version = 0.2.0) + blocking-splice: func(src: borrow, len: u64) -> result; + } +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import error; + @since(version = 0.2.0) + import poll; + @since(version = 0.2.0) + import streams; +} diff --git a/test/fixtures/wasi/0.2.2/wasi_random@0.2.2.wit b/test/fixtures/wasi/0.2.2/wasi_random@0.2.2.wit new file mode 100644 index 00000000..a121a6f0 --- /dev/null +++ b/test/fixtures/wasi/0.2.2/wasi_random@0.2.2.wit @@ -0,0 +1,92 @@ +package wasi:random@0.2.2; + +/// The insecure-seed interface for seeding hash-map DoS resistance. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +@since(version = 0.2.0) +interface insecure-seed { + /// Return a 128-bit value that may contain a pseudo-random value. + /// + /// The returned value is not required to be computed from a CSPRNG, and may + /// even be entirely deterministic. Host implementations are encouraged to + /// provide pseudo-random values to any program exposed to + /// attacker-controlled content, to enable DoS protection built into many + /// languages' hash-map implementations. + /// + /// This function is intended to only be called once, by a source language + /// to initialize Denial Of Service (DoS) protection in its hash-map + /// implementation. + /// + /// # Expected future evolution + /// + /// This will likely be changed to a value import, to prevent it from being + /// called multiple times and potentially used for purposes other than DoS + /// protection. + @since(version = 0.2.0) + insecure-seed: func() -> tuple; +} + +/// The insecure interface for insecure pseudo-random numbers. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +@since(version = 0.2.0) +interface insecure { + /// Return `len` insecure pseudo-random bytes. + /// + /// This function is not cryptographically secure. Do not use it for + /// anything related to security. + /// + /// There are no requirements on the values of the returned bytes, however + /// implementations are encouraged to return evenly distributed values with + /// a long period. + @since(version = 0.2.0) + get-insecure-random-bytes: func(len: u64) -> list; + + /// Return an insecure pseudo-random `u64` value. + /// + /// This function returns the same type of pseudo-random data as + /// `get-insecure-random-bytes`, represented as a `u64`. + @since(version = 0.2.0) + get-insecure-random-u64: func() -> u64; +} + +/// WASI Random is a random data API. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +@since(version = 0.2.0) +interface random { + /// Return `len` cryptographically-secure random or pseudo-random bytes. + /// + /// This function must produce data at least as cryptographically secure and + /// fast as an adequately seeded cryptographically-secure pseudo-random + /// number generator (CSPRNG). It must not block, from the perspective of + /// the calling program, under any circumstances, including on the first + /// request and on requests for numbers of bytes. The returned data must + /// always be unpredictable. + /// + /// This function must always return fresh data. Deterministic environments + /// must omit this function, rather than implementing it with deterministic + /// data. + @since(version = 0.2.0) + get-random-bytes: func(len: u64) -> list; + + /// Return a cryptographically-secure random or pseudo-random `u64` value. + /// + /// This function returns the same type of data as `get-random-bytes`, + /// represented as a `u64`. + @since(version = 0.2.0) + get-random-u64: func() -> u64; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import random; + @since(version = 0.2.0) + import insecure; + @since(version = 0.2.0) + import insecure-seed; +} diff --git a/test/fixtures/wasi/0.2.2/wasi_sockets@0.2.2.wit b/test/fixtures/wasi/0.2.2/wasi_sockets@0.2.2.wit new file mode 100644 index 00000000..33a1a278 --- /dev/null +++ b/test/fixtures/wasi/0.2.2/wasi_sockets@0.2.2.wit @@ -0,0 +1,933 @@ +package wasi:sockets@0.2.2; + +@since(version = 0.2.0) +interface network { + /// An opaque resource that represents access to (a subset of) the network. + /// This enables context-based security for networking. + /// There is no need for this to map 1:1 to a physical network interface. + @since(version = 0.2.0) + resource network; + + /// Error codes. + /// + /// In theory, every API can return any error code. + /// In practice, API's typically only return the errors documented per API + /// combined with a couple of errors that are always possible: + /// - `unknown` + /// - `access-denied` + /// - `not-supported` + /// - `out-of-memory` + /// - `concurrency-conflict` + /// + /// See each individual API for what the POSIX equivalents are. They sometimes differ per API. + @since(version = 0.2.0) + enum error-code { + /// Unknown error + unknown, + /// Access denied. + /// + /// POSIX equivalent: EACCES, EPERM + access-denied, + /// The operation is not supported. + /// + /// POSIX equivalent: EOPNOTSUPP + not-supported, + /// One of the arguments is invalid. + /// + /// POSIX equivalent: EINVAL + invalid-argument, + /// Not enough memory to complete the operation. + /// + /// POSIX equivalent: ENOMEM, ENOBUFS, EAI_MEMORY + out-of-memory, + /// The operation timed out before it could finish completely. + timeout, + /// This operation is incompatible with another asynchronous operation that is already in progress. + /// + /// POSIX equivalent: EALREADY + concurrency-conflict, + /// Trying to finish an asynchronous operation that: + /// - has not been started yet, or: + /// - was already finished by a previous `finish-*` call. + /// + /// Note: this is scheduled to be removed when `future`s are natively supported. + not-in-progress, + /// The operation has been aborted because it could not be completed immediately. + /// + /// Note: this is scheduled to be removed when `future`s are natively supported. + would-block, + /// The operation is not valid in the socket's current state. + invalid-state, + /// A new socket resource could not be created because of a system limit. + new-socket-limit, + /// A bind operation failed because the provided address is not an address that the `network` can bind to. + address-not-bindable, + /// A bind operation failed because the provided address is already in use or because there are no ephemeral ports available. + address-in-use, + /// The remote address is not reachable + remote-unreachable, + /// The TCP connection was forcefully rejected + connection-refused, + /// The TCP connection was reset. + connection-reset, + /// A TCP connection was aborted. + connection-aborted, + /// The size of a datagram sent to a UDP socket exceeded the maximum + /// supported size. + datagram-too-large, + /// Name does not exist or has no suitable associated IP addresses. + name-unresolvable, + /// A temporary failure in name resolution occurred. + temporary-resolver-failure, + /// A permanent failure in name resolution occurred. + permanent-resolver-failure, + } + + @since(version = 0.2.0) + enum ip-address-family { + /// Similar to `AF_INET` in POSIX. + ipv4, + /// Similar to `AF_INET6` in POSIX. + ipv6, + } + + @since(version = 0.2.0) + type ipv4-address = tuple; + + @since(version = 0.2.0) + type ipv6-address = tuple; + + @since(version = 0.2.0) + variant ip-address { + ipv4(ipv4-address), + ipv6(ipv6-address), + } + + @since(version = 0.2.0) + record ipv4-socket-address { + /// sin_port + port: u16, + /// sin_addr + address: ipv4-address, + } + + @since(version = 0.2.0) + record ipv6-socket-address { + /// sin6_port + port: u16, + /// sin6_flowinfo + flow-info: u32, + /// sin6_addr + address: ipv6-address, + /// sin6_scope_id + scope-id: u32, + } + + @since(version = 0.2.0) + variant ip-socket-address { + ipv4(ipv4-socket-address), + ipv6(ipv6-socket-address), + } +} + +/// This interface provides a value-export of the default network handle.. +@since(version = 0.2.0) +interface instance-network { + @since(version = 0.2.0) + use network.{network}; + + /// Get a handle to the default network. + @since(version = 0.2.0) + instance-network: func() -> network; +} + +@since(version = 0.2.0) +interface ip-name-lookup { + @since(version = 0.2.0) + use wasi:io/poll@0.2.2.{pollable}; + @since(version = 0.2.0) + use network.{network, error-code, ip-address}; + + @since(version = 0.2.0) + resource resolve-address-stream { + /// Returns the next address from the resolver. + /// + /// This function should be called multiple times. On each call, it will + /// return the next address in connection order preference. If all + /// addresses have been exhausted, this function returns `none`. + /// + /// This function never returns IPv4-mapped IPv6 addresses. + /// + /// # Typical errors + /// - `name-unresolvable`: Name does not exist or has no suitable associated IP addresses. (EAI_NONAME, EAI_NODATA, EAI_ADDRFAMILY) + /// - `temporary-resolver-failure`: A temporary failure in name resolution occurred. (EAI_AGAIN) + /// - `permanent-resolver-failure`: A permanent failure in name resolution occurred. (EAI_FAIL) + /// - `would-block`: A result is not available yet. (EWOULDBLOCK, EAGAIN) + @since(version = 0.2.0) + resolve-next-address: func() -> result, error-code>; + /// Create a `pollable` which will resolve once the stream is ready for I/O. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + /// Resolve an internet host name to a list of IP addresses. + /// + /// Unicode domain names are automatically converted to ASCII using IDNA encoding. + /// If the input is an IP address string, the address is parsed and returned + /// as-is without making any external requests. + /// + /// See the wasi-socket proposal README.md for a comparison with getaddrinfo. + /// + /// This function never blocks. It either immediately fails or immediately + /// returns successfully with a `resolve-address-stream` that can be used + /// to (asynchronously) fetch the results. + /// + /// # Typical errors + /// - `invalid-argument`: `name` is a syntactically invalid domain name or IP address. + /// + /// # References: + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + resolve-addresses: func(network: borrow, name: string) -> result; +} + +@since(version = 0.2.0) +interface tcp { + @since(version = 0.2.0) + use wasi:io/streams@0.2.2.{input-stream, output-stream}; + @since(version = 0.2.0) + use wasi:io/poll@0.2.2.{pollable}; + @since(version = 0.2.0) + use wasi:clocks/monotonic-clock@0.2.2.{duration}; + @since(version = 0.2.0) + use network.{network, error-code, ip-socket-address, ip-address-family}; + + @since(version = 0.2.0) + enum shutdown-type { + /// Similar to `SHUT_RD` in POSIX. + receive, + /// Similar to `SHUT_WR` in POSIX. + send, + /// Similar to `SHUT_RDWR` in POSIX. + both, + } + + /// A TCP socket resource. + /// + /// The socket can be in one of the following states: + /// - `unbound` + /// - `bind-in-progress` + /// - `bound` (See note below) + /// - `listen-in-progress` + /// - `listening` + /// - `connect-in-progress` + /// - `connected` + /// - `closed` + /// See + /// for more information. + /// + /// Note: Except where explicitly mentioned, whenever this documentation uses + /// the term "bound" without backticks it actually means: in the `bound` state *or higher*. + /// (i.e. `bound`, `listen-in-progress`, `listening`, `connect-in-progress` or `connected`) + /// + /// In addition to the general error codes documented on the + /// `network::error-code` type, TCP socket methods may always return + /// `error(invalid-state)` when in the `closed` state. + @since(version = 0.2.0) + resource tcp-socket { + /// Bind the socket to a specific network on the provided IP address and port. + /// + /// If the IP address is zero (`0.0.0.0` in IPv4, `::` in IPv6), it is left to the implementation to decide which + /// network interface(s) to bind to. + /// If the TCP/UDP port is zero, the socket will be bound to a random free port. + /// + /// Bind can be attempted multiple times on the same socket, even with + /// different arguments on each iteration. But never concurrently and + /// only as long as the previous bind failed. Once a bind succeeds, the + /// binding can't be changed anymore. + /// + /// # Typical errors + /// - `invalid-argument`: The `local-address` has the wrong address family. (EAFNOSUPPORT, EFAULT on Windows) + /// - `invalid-argument`: `local-address` is not a unicast address. (EINVAL) + /// - `invalid-argument`: `local-address` is an IPv4-mapped IPv6 address. (EINVAL) + /// - `invalid-state`: The socket is already bound. (EINVAL) + /// - `address-in-use`: No ephemeral ports available. (EADDRINUSE, ENOBUFS on Windows) + /// - `address-in-use`: Address is already in use. (EADDRINUSE) + /// - `address-not-bindable`: `local-address` is not an address that the `network` can bind to. (EADDRNOTAVAIL) + /// - `not-in-progress`: A `bind` operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// When binding to a non-zero port, this bind operation shouldn't be affected by the TIME_WAIT + /// state of a recently closed socket on the same local address. In practice this means that the SO_REUSEADDR + /// socket option should be set implicitly on all platforms, except on Windows where this is the default behavior + /// and SO_REUSEADDR performs something different entirely. + /// + /// Unlike in POSIX, in WASI the bind operation is async. This enables + /// interactive WASI hosts to inject permission prompts. Runtimes that + /// don't want to make use of this ability can simply call the native + /// `bind` as part of either `start-bind` or `finish-bind`. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-bind: func(network: borrow, local-address: ip-socket-address) -> result<_, error-code>; + @since(version = 0.2.0) + finish-bind: func() -> result<_, error-code>; + /// Connect to a remote endpoint. + /// + /// On success: + /// - the socket is transitioned into the `connected` state. + /// - a pair of streams is returned that can be used to read & write to the connection + /// + /// After a failed connection attempt, the socket will be in the `closed` + /// state and the only valid action left is to `drop` the socket. A single + /// socket can not be used to connect more than once. + /// + /// # Typical errors + /// - `invalid-argument`: The `remote-address` has the wrong address family. (EAFNOSUPPORT) + /// - `invalid-argument`: `remote-address` is not a unicast address. (EINVAL, ENETUNREACH on Linux, EAFNOSUPPORT on MacOS) + /// - `invalid-argument`: `remote-address` is an IPv4-mapped IPv6 address. (EINVAL, EADDRNOTAVAIL on Illumos) + /// - `invalid-argument`: The IP address in `remote-address` is set to INADDR_ANY (`0.0.0.0` / `::`). (EADDRNOTAVAIL on Windows) + /// - `invalid-argument`: The port in `remote-address` is set to 0. (EADDRNOTAVAIL on Windows) + /// - `invalid-argument`: The socket is already attached to a different network. The `network` passed to `connect` must be identical to the one passed to `bind`. + /// - `invalid-state`: The socket is already in the `connected` state. (EISCONN) + /// - `invalid-state`: The socket is already in the `listening` state. (EOPNOTSUPP, EINVAL on Windows) + /// - `timeout`: Connection timed out. (ETIMEDOUT) + /// - `connection-refused`: The connection was forcefully rejected. (ECONNREFUSED) + /// - `connection-reset`: The connection was reset. (ECONNRESET) + /// - `connection-aborted`: The connection was aborted. (ECONNABORTED) + /// - `remote-unreachable`: The remote address is not reachable. (EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `address-in-use`: Tried to perform an implicit bind, but there were no ephemeral ports available. (EADDRINUSE, EADDRNOTAVAIL on Linux, EAGAIN on BSD) + /// - `not-in-progress`: A connect operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// The POSIX equivalent of `start-connect` is the regular `connect` syscall. + /// Because all WASI sockets are non-blocking this is expected to return + /// EINPROGRESS, which should be translated to `ok()` in WASI. + /// + /// The POSIX equivalent of `finish-connect` is a `poll` for event `POLLOUT` + /// with a timeout of 0 on the socket descriptor. Followed by a check for + /// the `SO_ERROR` socket option, in case the poll signaled readiness. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-connect: func(network: borrow, remote-address: ip-socket-address) -> result<_, error-code>; + @since(version = 0.2.0) + finish-connect: func() -> result, error-code>; + /// Start listening for new connections. + /// + /// Transitions the socket into the `listening` state. + /// + /// Unlike POSIX, the socket must already be explicitly bound. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not bound to any local address. (EDESTADDRREQ) + /// - `invalid-state`: The socket is already in the `connected` state. (EISCONN, EINVAL on BSD) + /// - `invalid-state`: The socket is already in the `listening` state. + /// - `address-in-use`: Tried to perform an implicit bind, but there were no ephemeral ports available. (EADDRINUSE) + /// - `not-in-progress`: A listen operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// Unlike in POSIX, in WASI the listen operation is async. This enables + /// interactive WASI hosts to inject permission prompts. Runtimes that + /// don't want to make use of this ability can simply call the native + /// `listen` as part of either `start-listen` or `finish-listen`. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-listen: func() -> result<_, error-code>; + @since(version = 0.2.0) + finish-listen: func() -> result<_, error-code>; + /// Accept a new client socket. + /// + /// The returned socket is bound and in the `connected` state. The following properties are inherited from the listener socket: + /// - `address-family` + /// - `keep-alive-enabled` + /// - `keep-alive-idle-time` + /// - `keep-alive-interval` + /// - `keep-alive-count` + /// - `hop-limit` + /// - `receive-buffer-size` + /// - `send-buffer-size` + /// + /// On success, this function returns the newly accepted client socket along with + /// a pair of streams that can be used to read & write to the connection. + /// + /// # Typical errors + /// - `invalid-state`: Socket is not in the `listening` state. (EINVAL) + /// - `would-block`: No pending connections at the moment. (EWOULDBLOCK, EAGAIN) + /// - `connection-aborted`: An incoming connection was pending, but was terminated by the client before this listener could accept it. (ECONNABORTED) + /// - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + accept: func() -> result, error-code>; + /// Get the bound local address. + /// + /// POSIX mentions: + /// > If the socket has not been bound to a local name, the value + /// > stored in the object pointed to by `address` is unspecified. + /// + /// WASI is stricter and requires `local-address` to return `invalid-state` when the socket hasn't been bound yet. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not bound to any local address. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + local-address: func() -> result; + /// Get the remote address. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not connected to a remote address. (ENOTCONN) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + remote-address: func() -> result; + /// Whether the socket is in the `listening` state. + /// + /// Equivalent to the SO_ACCEPTCONN socket option. + @since(version = 0.2.0) + is-listening: func() -> bool; + /// Whether this is a IPv4 or IPv6 socket. + /// + /// Equivalent to the SO_DOMAIN socket option. + @since(version = 0.2.0) + address-family: func() -> ip-address-family; + /// Hints the desired listen queue size. Implementations are free to ignore this. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// + /// # Typical errors + /// - `not-supported`: (set) The platform does not support changing the backlog size after the initial listen. + /// - `invalid-argument`: (set) The provided value was 0. + /// - `invalid-state`: (set) The socket is in the `connect-in-progress` or `connected` state. + @since(version = 0.2.0) + set-listen-backlog-size: func(value: u64) -> result<_, error-code>; + /// Enables or disables keepalive. + /// + /// The keepalive behavior can be adjusted using: + /// - `keep-alive-idle-time` + /// - `keep-alive-interval` + /// - `keep-alive-count` + /// These properties can be configured while `keep-alive-enabled` is false, but only come into effect when `keep-alive-enabled` is true. + /// + /// Equivalent to the SO_KEEPALIVE socket option. + @since(version = 0.2.0) + keep-alive-enabled: func() -> result; + @since(version = 0.2.0) + set-keep-alive-enabled: func(value: bool) -> result<_, error-code>; + /// Amount of time the connection has to be idle before TCP starts sending keepalive packets. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the TCP_KEEPIDLE socket option. (TCP_KEEPALIVE on MacOS) + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + keep-alive-idle-time: func() -> result; + @since(version = 0.2.0) + set-keep-alive-idle-time: func(value: duration) -> result<_, error-code>; + /// The time between keepalive packets. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the TCP_KEEPINTVL socket option. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + keep-alive-interval: func() -> result; + @since(version = 0.2.0) + set-keep-alive-interval: func(value: duration) -> result<_, error-code>; + /// The maximum amount of keepalive packets TCP should send before aborting the connection. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the TCP_KEEPCNT socket option. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + keep-alive-count: func() -> result; + @since(version = 0.2.0) + set-keep-alive-count: func(value: u32) -> result<_, error-code>; + /// Equivalent to the IP_TTL & IPV6_UNICAST_HOPS socket options. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The TTL value must be 1 or higher. + @since(version = 0.2.0) + hop-limit: func() -> result; + @since(version = 0.2.0) + set-hop-limit: func(value: u8) -> result<_, error-code>; + /// The kernel buffer space reserved for sends/receives on this socket. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the SO_RCVBUF and SO_SNDBUF socket options. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + receive-buffer-size: func() -> result; + @since(version = 0.2.0) + set-receive-buffer-size: func(value: u64) -> result<_, error-code>; + @since(version = 0.2.0) + send-buffer-size: func() -> result; + @since(version = 0.2.0) + set-send-buffer-size: func(value: u64) -> result<_, error-code>; + /// Create a `pollable` which can be used to poll for, or block on, + /// completion of any of the asynchronous operations of this socket. + /// + /// When `finish-bind`, `finish-listen`, `finish-connect` or `accept` + /// return `error(would-block)`, this pollable can be used to wait for + /// their success or failure, after which the method can be retried. + /// + /// The pollable is not limited to the async operation that happens to be + /// in progress at the time of calling `subscribe` (if any). Theoretically, + /// `subscribe` only has to be called once per socket and can then be + /// (re)used for the remainder of the socket's lifetime. + /// + /// See + /// for more information. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + /// Initiate a graceful shutdown. + /// + /// - `receive`: The socket is not expecting to receive any data from + /// the peer. The `input-stream` associated with this socket will be + /// closed. Any data still in the receive queue at time of calling + /// this method will be discarded. + /// - `send`: The socket has no more data to send to the peer. The `output-stream` + /// associated with this socket will be closed and a FIN packet will be sent. + /// - `both`: Same effect as `receive` & `send` combined. + /// + /// This function is idempotent; shutting down a direction more than once + /// has no effect and returns `ok`. + /// + /// The shutdown function does not close (drop) the socket. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not in the `connected` state. (ENOTCONN) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + shutdown: func(shutdown-type: shutdown-type) -> result<_, error-code>; + } +} + +@since(version = 0.2.0) +interface tcp-create-socket { + @since(version = 0.2.0) + use network.{network, error-code, ip-address-family}; + @since(version = 0.2.0) + use tcp.{tcp-socket}; + + /// Create a new TCP socket. + /// + /// Similar to `socket(AF_INET or AF_INET6, SOCK_STREAM, IPPROTO_TCP)` in POSIX. + /// On IPv6 sockets, IPV6_V6ONLY is enabled by default and can't be configured otherwise. + /// + /// This function does not require a network capability handle. This is considered to be safe because + /// at time of creation, the socket is not bound to any `network` yet. Up to the moment `bind`/`connect` + /// is called, the socket is effectively an in-memory configuration object, unable to communicate with the outside world. + /// + /// All sockets are non-blocking. Use the wasi-poll interface to block on asynchronous operations. + /// + /// # Typical errors + /// - `not-supported`: The specified `address-family` is not supported. (EAFNOSUPPORT) + /// - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + create-tcp-socket: func(address-family: ip-address-family) -> result; +} + +@since(version = 0.2.0) +interface udp { + @since(version = 0.2.0) + use wasi:io/poll@0.2.2.{pollable}; + @since(version = 0.2.0) + use network.{network, error-code, ip-socket-address, ip-address-family}; + + /// A received datagram. + @since(version = 0.2.0) + record incoming-datagram { + /// The payload. + /// + /// Theoretical max size: ~64 KiB. In practice, typically less than 1500 bytes. + data: list, + /// The source address. + /// + /// This field is guaranteed to match the remote address the stream was initialized with, if any. + /// + /// Equivalent to the `src_addr` out parameter of `recvfrom`. + remote-address: ip-socket-address, + } + + /// A datagram to be sent out. + @since(version = 0.2.0) + record outgoing-datagram { + /// The payload. + data: list, + /// The destination address. + /// + /// The requirements on this field depend on how the stream was initialized: + /// - with a remote address: this field must be None or match the stream's remote address exactly. + /// - without a remote address: this field is required. + /// + /// If this value is None, the send operation is equivalent to `send` in POSIX. Otherwise it is equivalent to `sendto`. + remote-address: option, + } + + /// A UDP socket handle. + @since(version = 0.2.0) + resource udp-socket { + /// Bind the socket to a specific network on the provided IP address and port. + /// + /// If the IP address is zero (`0.0.0.0` in IPv4, `::` in IPv6), it is left to the implementation to decide which + /// network interface(s) to bind to. + /// If the port is zero, the socket will be bound to a random free port. + /// + /// # Typical errors + /// - `invalid-argument`: The `local-address` has the wrong address family. (EAFNOSUPPORT, EFAULT on Windows) + /// - `invalid-state`: The socket is already bound. (EINVAL) + /// - `address-in-use`: No ephemeral ports available. (EADDRINUSE, ENOBUFS on Windows) + /// - `address-in-use`: Address is already in use. (EADDRINUSE) + /// - `address-not-bindable`: `local-address` is not an address that the `network` can bind to. (EADDRNOTAVAIL) + /// - `not-in-progress`: A `bind` operation is not in progress. + /// - `would-block`: Can't finish the operation, it is still in progress. (EWOULDBLOCK, EAGAIN) + /// + /// # Implementors note + /// Unlike in POSIX, in WASI the bind operation is async. This enables + /// interactive WASI hosts to inject permission prompts. Runtimes that + /// don't want to make use of this ability can simply call the native + /// `bind` as part of either `start-bind` or `finish-bind`. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + start-bind: func(network: borrow, local-address: ip-socket-address) -> result<_, error-code>; + @since(version = 0.2.0) + finish-bind: func() -> result<_, error-code>; + /// Set up inbound & outbound communication channels, optionally to a specific peer. + /// + /// This function only changes the local socket configuration and does not generate any network traffic. + /// On success, the `remote-address` of the socket is updated. The `local-address` may be updated as well, + /// based on the best network path to `remote-address`. + /// + /// When a `remote-address` is provided, the returned streams are limited to communicating with that specific peer: + /// - `send` can only be used to send to this destination. + /// - `receive` will only return datagrams sent from the provided `remote-address`. + /// + /// This method may be called multiple times on the same socket to change its association, but + /// only the most recently returned pair of streams will be operational. Implementations may trap if + /// the streams returned by a previous invocation haven't been dropped yet before calling `stream` again. + /// + /// The POSIX equivalent in pseudo-code is: + /// ```text + /// if (was previously connected) { + /// connect(s, AF_UNSPEC) + /// } + /// if (remote_address is Some) { + /// connect(s, remote_address) + /// } + /// ``` + /// + /// Unlike in POSIX, the socket must already be explicitly bound. + /// + /// # Typical errors + /// - `invalid-argument`: The `remote-address` has the wrong address family. (EAFNOSUPPORT) + /// - `invalid-argument`: The IP address in `remote-address` is set to INADDR_ANY (`0.0.0.0` / `::`). (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-argument`: The port in `remote-address` is set to 0. (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-state`: The socket is not bound. + /// - `address-in-use`: Tried to perform an implicit bind, but there were no ephemeral ports available. (EADDRINUSE, EADDRNOTAVAIL on Linux, EAGAIN on BSD) + /// - `remote-unreachable`: The remote address is not reachable. (ECONNRESET, ENETRESET, EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `connection-refused`: The connection was refused. (ECONNREFUSED) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + %stream: func(remote-address: option) -> result, error-code>; + /// Get the current bound address. + /// + /// POSIX mentions: + /// > If the socket has not been bound to a local name, the value + /// > stored in the object pointed to by `address` is unspecified. + /// + /// WASI is stricter and requires `local-address` to return `invalid-state` when the socket hasn't been bound yet. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not bound to any local address. + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + local-address: func() -> result; + /// Get the address the socket is currently streaming to. + /// + /// # Typical errors + /// - `invalid-state`: The socket is not streaming to a specific remote address. (ENOTCONN) + /// + /// # References + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + remote-address: func() -> result; + /// Whether this is a IPv4 or IPv6 socket. + /// + /// Equivalent to the SO_DOMAIN socket option. + @since(version = 0.2.0) + address-family: func() -> ip-address-family; + /// Equivalent to the IP_TTL & IPV6_UNICAST_HOPS socket options. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The TTL value must be 1 or higher. + @since(version = 0.2.0) + unicast-hop-limit: func() -> result; + @since(version = 0.2.0) + set-unicast-hop-limit: func(value: u8) -> result<_, error-code>; + /// The kernel buffer space reserved for sends/receives on this socket. + /// + /// If the provided value is 0, an `invalid-argument` error is returned. + /// Any other value will never cause an error, but it might be silently clamped and/or rounded. + /// I.e. after setting a value, reading the same setting back may return a different value. + /// + /// Equivalent to the SO_RCVBUF and SO_SNDBUF socket options. + /// + /// # Typical errors + /// - `invalid-argument`: (set) The provided value was 0. + @since(version = 0.2.0) + receive-buffer-size: func() -> result; + @since(version = 0.2.0) + set-receive-buffer-size: func(value: u64) -> result<_, error-code>; + @since(version = 0.2.0) + send-buffer-size: func() -> result; + @since(version = 0.2.0) + set-send-buffer-size: func(value: u64) -> result<_, error-code>; + /// Create a `pollable` which will resolve once the socket is ready for I/O. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + @since(version = 0.2.0) + resource incoming-datagram-stream { + /// Receive messages on the socket. + /// + /// This function attempts to receive up to `max-results` datagrams on the socket without blocking. + /// The returned list may contain fewer elements than requested, but never more. + /// + /// This function returns successfully with an empty list when either: + /// - `max-results` is 0, or: + /// - `max-results` is greater than 0, but no results are immediately available. + /// This function never returns `error(would-block)`. + /// + /// # Typical errors + /// - `remote-unreachable`: The remote address is not reachable. (ECONNRESET, ENETRESET on Windows, EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `connection-refused`: The connection was refused. (ECONNREFUSED) + /// + /// # References + /// - + /// - + /// - + /// - + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + receive: func(max-results: u64) -> result, error-code>; + /// Create a `pollable` which will resolve once the stream is ready to receive again. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } + + @since(version = 0.2.0) + resource outgoing-datagram-stream { + /// Check readiness for sending. This function never blocks. + /// + /// Returns the number of datagrams permitted for the next call to `send`, + /// or an error. Calling `send` with more datagrams than this function has + /// permitted will trap. + /// + /// When this function returns ok(0), the `subscribe` pollable will + /// become ready when this function will report at least ok(1), or an + /// error. + /// + /// Never returns `would-block`. + check-send: func() -> result; + /// Send messages on the socket. + /// + /// This function attempts to send all provided `datagrams` on the socket without blocking and + /// returns how many messages were actually sent (or queued for sending). This function never + /// returns `error(would-block)`. If none of the datagrams were able to be sent, `ok(0)` is returned. + /// + /// This function semantically behaves the same as iterating the `datagrams` list and sequentially + /// sending each individual datagram until either the end of the list has been reached or the first error occurred. + /// If at least one datagram has been sent successfully, this function never returns an error. + /// + /// If the input list is empty, the function returns `ok(0)`. + /// + /// Each call to `send` must be permitted by a preceding `check-send`. Implementations must trap if + /// either `check-send` was not called or `datagrams` contains more items than `check-send` permitted. + /// + /// # Typical errors + /// - `invalid-argument`: The `remote-address` has the wrong address family. (EAFNOSUPPORT) + /// - `invalid-argument`: The IP address in `remote-address` is set to INADDR_ANY (`0.0.0.0` / `::`). (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-argument`: The port in `remote-address` is set to 0. (EDESTADDRREQ, EADDRNOTAVAIL) + /// - `invalid-argument`: The socket is in "connected" mode and `remote-address` is `some` value that does not match the address passed to `stream`. (EISCONN) + /// - `invalid-argument`: The socket is not "connected" and no value for `remote-address` was provided. (EDESTADDRREQ) + /// - `remote-unreachable`: The remote address is not reachable. (ECONNRESET, ENETRESET on Windows, EHOSTUNREACH, EHOSTDOWN, ENETUNREACH, ENETDOWN, ENONET) + /// - `connection-refused`: The connection was refused. (ECONNREFUSED) + /// - `datagram-too-large`: The datagram is too large. (EMSGSIZE) + /// + /// # References + /// - + /// - + /// - + /// - + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + send: func(datagrams: list) -> result; + /// Create a `pollable` which will resolve once the stream is ready to send again. + /// + /// Note: this function is here for WASI 0.2 only. + /// It's planned to be removed when `future` is natively supported in Preview3. + @since(version = 0.2.0) + subscribe: func() -> pollable; + } +} + +@since(version = 0.2.0) +interface udp-create-socket { + @since(version = 0.2.0) + use network.{network, error-code, ip-address-family}; + @since(version = 0.2.0) + use udp.{udp-socket}; + + /// Create a new UDP socket. + /// + /// Similar to `socket(AF_INET or AF_INET6, SOCK_DGRAM, IPPROTO_UDP)` in POSIX. + /// On IPv6 sockets, IPV6_V6ONLY is enabled by default and can't be configured otherwise. + /// + /// This function does not require a network capability handle. This is considered to be safe because + /// at time of creation, the socket is not bound to any `network` yet. Up to the moment `bind` is called, + /// the socket is effectively an in-memory configuration object, unable to communicate with the outside world. + /// + /// All sockets are non-blocking. Use the wasi-poll interface to block on asynchronous operations. + /// + /// # Typical errors + /// - `not-supported`: The specified `address-family` is not supported. (EAFNOSUPPORT) + /// - `new-socket-limit`: The new socket resource could not be created because of a system limit. (EMFILE, ENFILE) + /// + /// # References: + /// - + /// - + /// - + /// - + @since(version = 0.2.0) + create-udp-socket: func(address-family: ip-address-family) -> result; +} + +@since(version = 0.2.0) +world imports { + @since(version = 0.2.0) + import network; + @since(version = 0.2.0) + import instance-network; + @since(version = 0.2.0) + import wasi:io/poll@0.2.2; + @since(version = 0.2.0) + import udp; + @since(version = 0.2.0) + import udp-create-socket; + @since(version = 0.2.0) + import wasi:io/error@0.2.2; + @since(version = 0.2.0) + import wasi:io/streams@0.2.2; + @since(version = 0.2.0) + import wasi:clocks/monotonic-clock@0.2.2; + @since(version = 0.2.0) + import tcp; + @since(version = 0.2.0) + import tcp-create-socket; + @since(version = 0.2.0) + import ip-name-lookup; +} diff --git a/test/helpers.js b/test/helpers.js index c736cef5..5ac62d33 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -1,11 +1,59 @@ -import { tmpdir } from "node:os"; +import { env, argv, execArgv } from "node:process"; +import { createServer } from "node:net"; +import { + basename, + join, + isAbsolute, + resolve, + normalize, + sep, + relative, + dirname, +} from "node:path"; +import { + cp, + mkdtemp, + writeFile, + stat, + mkdir, + readFile, +} from "node:fs/promises"; +import { ok, strictEqual } from "node:assert"; import { spawn } from "node:child_process"; -import { argv, execArgv } from "node:process"; -import { normalize, sep } from "node:path"; -import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; + +import { pathToFileURL } from "url"; +import { transpile } from "../src/api.js"; +import { componentize } from "../src/cmd/componentize.js"; + +// Path to the jco binary export const jcoPath = "src/jco.js"; +// Simple debug logging for tests +export function log(args, ..._rest) { + if (!env.TEST_DEBUG) { + return; + } + if (typeof args === "string") { + args = { msg: args }; + } + if (typeof args !== "object") { + return; + } + if (args.extra || _rest.length > 0) { + console.log(`[${args.level || "debug"}] ${args.msg}`, { + ...args.extra, + _rest, + }); + } else { + console.log(`[${args.level || "debug"}] ${args.msg}`); + } +} + +// Execute a NodeJS script +// +// Note: argv[0] is expected to be `node` (or some incantation that spawned this process) export async function exec(cmd, ...args) { let stdout = "", stderr = ""; @@ -21,7 +69,7 @@ export async function exec(cmd, ...args) { }); cp.on("error", reject); cp.on("exit", (code) => - code === 0 ? resolve() : reject(new Error((stderr || stdout).toString())) + code === 0 ? resolve() : reject(new Error((stderr || stdout).toString())), ); }); return { stdout, stderr }; @@ -35,3 +83,382 @@ export async function exec(cmd, ...args) { export async function getTmpDir() { return await mkdtemp(normalize(tmpdir() + sep)); } + +/** + * Set up an async test to be run + * + * @param {object} args - Arguments for running the async test + * @param {function} args.testFn - Arguments for running the async test + * @param {object} args.jco - JCO-related confguration for running the async test + * @param {string} [args.jcoBinPath] - path to the jco binary (or a JS script) + * @param {object} [args.transpile] - configuration related to transpilation + * @param {string[]} [args.transpile.extraArgs] - arguments to pass along to jco transpilation + * @param {object} args.component - configuration for an existing component that should be transpiled + * @param {string} args.component.name - name of the component + * @param {string} args.component.path - path to the WebAssembly binary for the existing component + * @param {object[]} args.component.import - imports that should be provided to the module at instantiation time + * @param {object} args.component.build - configuration for building an ephemeral component to be tested + * @param {object} args.component.js.source - Javascript source code for a component + * @param {object} args.component.wit.source - WIT definitions (inlined) for a component + * @param {object[]} args.component.wit.deps - Dependencies (ex. WASI) that should be included during component build + */ +export async function setupAsyncTest(args) { + const { asyncMode: _asyncMode, testFn, jco, component } = args; + const asyncMode = _asyncMode || "asyncify"; + const jcoBinPath = jco?.binPath || jcoPath; + + let componentName = component.name; + let componentPath = component.path; + let componentImports = component.imports; + + if (component.path && component.build) { + throw new Error( + "Both component.path and component.build should not be specified at the same time", + ); + } + + // If this component should be built "just in time" -- i.e. created when this test is run + let componentBuildCleanup; + if (component.build) { + // Optionally use a custom pre-optimized StarlingMonkey engine + if (env.TEST_CUSTOM_ENGINE_JIT_PATH || env.TEST_CUSTOM_ENGINE_AOT_PATH) { + log("detected custom engine JIT path"); + if (component.build.componentizeOpts?.aot) { + log("detected AOT config"); + component.build.engine = env.TEST_CUSTOM_ENGINE_AOT_PATH; + } else { + log("detected JIT config"); + component.build.engine = env.TEST_CUSTOM_ENGINE_JIT_PATH; + } + } + + // Build the component + const { name, path, cleanup } = await buildComponent({ + name: componentName, + ...component.build, + }); + + componentBuildCleanup = cleanup; + componentName = name; + componentPath = path; + } + + if (!componentName) { + throw new Error("invalid/missing component name"); + } + if (!componentPath) { + throw new Error("invalid/missing component path"); + } + + // Use either a temporary directory or an subfolder in an existing directory, + // creating it if it doesn't already exist + const outputDir = component.outputDir + ? component.outputDir + : await getTmpDir(); + + // Build out the whole-test cleanup function + let cleanup = async () => { + if (componentBuildCleanup) { + try { + await componentBuildCleanup(); + } catch {} + } + try { + await rm(outputDir, { recursive: true }); + } catch {} + }; + + // Return early if the test was intended to run on JSPI but JSPI is not enabled + if (asyncMode == "jspi" && typeof WebAssembly?.Suspending !== "function") { + await cleanup(); + throw new Error( + "JSPI async type skipped, but JSPI was not enabled -- please ensure test is run from an environment with JSPI integration (ex. node with the --experimental-wasm-jspi flag)", + ); + } + + // Build a directory for the transpiled component output to be put in + // (possibly inside the passed in outputDir) + const moduleOutputDir = join(outputDir, component.name); + try { + await stat(moduleOutputDir); + } catch (err) { + if (err && err.code && err.code === "ENOENT") { + await mkdir(moduleOutputDir); + } + } + + const transpileOpts = { + name: componentName, + minify: true, + validLiftingOptimization: true, + tlaCompat: true, + optimize: false, + base64Cutoff: 0, + instantiation: "async", + asyncMode, + wasiShim: true, + outDir: moduleOutputDir, + ...(jco?.transpile?.extraArgs || {}), + }; + + // If we used a pre-optimized build, then we can set that before transpiling + if (["yes", "true"].includes(env.TEST_CUSTOM_ENGINE_PREOPTIMIZED)) { + log("using preoptimized engine build!"); + transpileOpts.preoptimized = true; + } + + // log("EXEC ARGS?", transpileExecArgs); + // log(`EXECable\njco ${transpileExecArgs.join(" ")}`); + // await new Promise(resolve => setTimeout(resolve, 60_000)); + + const componentBytes = await readFile(componentPath); + + // Perform transpilation, write out files + const { files } = await transpile(componentBytes, transpileOpts); + await Promise.all( + Object.entries(files).map(async ([name, file]) => { + await mkdir(dirname(name), { recursive: true }); + await writeFile(name, file); + }), + ); + + // Write a minimal package.json + await writeFile( + `${moduleOutputDir}/package.json`, + JSON.stringify({ type: "module" }), + ); + + // TODO: DEBUG module import not working, file is missing! + // log("WROTE EVERYTHING:", moduleOutputDir); + // await new Promise(resolve => setTimeout(resolve, 60_000)); + + // Import the transpiled JS + const esModuleOutputPath = join(moduleOutputDir, `${componentName}.js`); + const esModuleSourcePath = pathToFileURL(esModuleOutputPath); + const module = await import(esModuleSourcePath); + + // TODO: DEBUG module import not working, file is missing! + // log("PRE INSTANTIATION", { moduleOutputDir }); + // await new Promise(resolve => setTimeout(resolve, 60_000_000)); + + // Optionally instantiate the module + // + // It's useful to be able to skip instantiation of the instantiation should happen + // elsewhere (ex. in a browser window) + let instance = null; + if (!component.skipInstantiation) { + instance = await module.instantiate(undefined, componentImports || {}); + } + + return { + module, + esModuleSourcePath, + esModuleRelativeSourcePath: relative(outputDir, esModuleOutputPath), + instance, + cleanup, + component: { + name: componentName, + path: componentPath, + }, + }; +} + +/** + * Helper method for building a component just in time (e.g. to use in a test) + * + */ +export async function buildComponent(args) { + if (!args) { + throw new Error("missing args"); + } + const name = args.name; + const jsSource = args.js?.source; + const witDeps = args.wit?.deps; + const witSource = args.wit?.source; + const witWorld = args.wit?.world; + if (!name) { + throw new Error( + "invalid/missing component name for in-test component build", + ); + } + if (!jsSource) { + throw new Error("invalid/missing source for in-test component build"); + } + if (!witSource) { + throw new Error("invalid/missing WIT for in-test component build"); + } + if (!witWorld) { + throw new Error("invalid/missing WIT world for in-test component build"); + } + + // Create temporary output directory + const outputDir = await getTmpDir(); + + // Write the component's JS and WIT + const jsSourcePath = join(outputDir, "component.js"); + const witOutputPath = join(outputDir, "wit"); + await mkdir(join(witOutputPath, "deps"), { recursive: true }); + const witSourcePath = join(witOutputPath, "component.wit"); + + // Write the appropriate + await Promise.all([ + await writeFile(jsSourcePath, jsSource), + await writeFile(witSourcePath, witSource), + ]); + + // Copy in additional WIT dependency files if provided + if (witDeps) { + for (const dep of witDeps) { + if (!dep.srcPath) { + throw new Error("Invalid wit dep object, missing srcPath"); + } + if (!isAbsolute(dep.srcPath)) { + throw new Error("Only absolute source paths are allowed"); + } + if (dep.destPath && isAbsolute(dep.destPath)) { + throw new Error( + "Only relative dest paths are allowed (into the wit/deps directory)", + ); + } + + const srcFileStats = await stat(dep.srcPath); + const destPath = + dep.destPath || (srcFileStats.isFile() ? basename(dep.srcPath) : "."); + const outputPath = resolve(`${outputDir}/wit/deps/${destPath}`); + + if (srcFileStats.isFile()) { + await writeFile(outputPath, await readFile(dep.srcPath)); + } else if (srcFileStats.isDirectory()) { + await cp(dep.srcPath, outputPath, { recursive: true }); + } else { + throw new Error( + "unrecognized file type for WIT dep, neither file nor directory", + ); + } + } + } + + // Build the output path to which we should write + const outputWasmPath = join(outputDir, "component.wasm"); + + // Build options for componentizing + const wit = witDeps ? witOutputPath : witSourcePath; + const options = { + sourceName: "component", + // If there were wit deps specified, we should use the whole wit dir + // otherwise we can use just the single WIT source file + wit, + worldName: witWorld, + out: outputWasmPath, + quiet: true, + // Add in optional raw options object to componentize + ...(args.componentizeOpts || {}), + }; + + // Use a custom engine if specified + if (args.engine) { + const enginePath = resolve(args.engine); + const engine = await stat(enginePath); + if (engine.isFile()) { + options.engine = enginePath; + } + } + + // Perform componentization + await componentize(jsSourcePath, options); + + return { + name, + path: outputWasmPath, + cleanup: async () => { + try { + await rm(outputDir); + } catch {} + }, + }; +} + +/** + * Load a browser page, usually triggering test output that is written + * to the HTML body of the page + * + * @param {object} args + * @param {object} args.browser - Puppeteer browser instance + * @param {object} [args.path] - Path to the HTML file to use, with root at `test` (ex. `test/browser.html` would be just `browser.html`) + * @param {string} args.hash - Hash at which to perform tests (used to identify specific tests) + */ +export async function loadTestPage(args) { + const { browser, hash } = args; + if (!browser) { + throw new Error("missing puppeteer instance browser object"); + } + if (!hash) { + throw new Error("missing hash for browser page"); + } + + const page = await browser.newPage(); + + // Pass along all output to test + if (env.TEST_DEBUG) { + page + .on("console", (message) => + log( + `[browser] ${message.type().substr(0, 3).toUpperCase()} ${message.text()}`, + ), + ) + .on("pageerror", ({ message }) => log(`[browser] ${message}`)) + .on("response", (response) => + log(`[browser] ${response.status()} ${response.url()}`), + ) + .on("requestfailed", (request) => + log(`[browser] ${request.failure().errorText} ${request.url()}`), + ); + } + + const path = args.path ? args.path : "test/browser.html"; + const serverPort = args.serverPort ? args.serverPort : 8080; + + const hashURL = `http://localhost:${serverPort}/${path}#${hash}`; + const hashTest = await page.goto(hashURL); + ok(hashTest.ok(), `navigated to URL [${hashURL}]`); + + const body = await page.locator("body").waitHandle(); + + let bodyHTML = await body.evaluate((el) => el.innerHTML); + // If the body HTML uses "Running" to show state, wait until it changes + if (bodyHTML == "

Running

") { + while (bodyHTML === "

Running

") { + bodyHTML = await body.evaluate((el) => el.innerHTML); + } + } + + // Attempt to parse the HTML body content as JSON + const raw = bodyHTML; + let testOutputJSON; + try { + testOutputJSON = JSON.parse(raw); + } catch (err) { + log(`failed to parse JSON for body HTML: ${err}`); + } + + return { + page, + body, + output: { + raw, + json: testOutputJSON, + }, + }; +} + +// Utility function for getting a random port +export async function getRandomPort() { + return await new Promise((resolve) => { + const server = createServer(); + server.listen(0, function () { + const port = this.address().port; + server.on("close", () => resolve(port)); + server.close(); + }); + }); +} diff --git a/test/test.js b/test/test.js index 8cf3155f..8780eef0 100644 --- a/test/test.js +++ b/test/test.js @@ -1,14 +1,14 @@ /* * Customize COMPONENT_FIXTURES env vars to use alternative test components - * + * * COMPONENT_FIXTURES is a comma-separated list of component names ending in * ".component.wasm". - * + * * Each of these components will then be passed through code generation and linting. - * + * * If a local runtime host.ts file is present for the component name in test/runtime/[name]/host.ts * then the runtime test will be performed against that execution. - * + * * When the runtime test is present, the flags in the runtime host.ts file will be used * as the flags of the code generation step. */ @@ -19,7 +19,7 @@ const componentFixtures = env.COMPONENT_FIXTURES ? env.COMPONENT_FIXTURES.split(',') : (await readdir('test/fixtures/components')).filter(name => name !== 'dummy_reactor.component.wasm'); -import { browserTest } from './browser.js'; +import { browserTest } from './browser/test.js'; import { codegenTest } from './codegen.js'; import { runtimeTest } from './runtime.js'; import { commandsTest } from './commands.js'; @@ -28,6 +28,7 @@ import { cliTest } from './cli.js'; import { preview2Test } from './preview2.js'; import { witTest } from './wit.js'; import { tsTest } from './typescript.js'; +import { asyncTest } from './async.js'; await codegenTest(componentFixtures); tsTest(); @@ -37,6 +38,8 @@ await commandsTest(); await apiTest(componentFixtures); await cliTest(componentFixtures); await witTest(); +await asyncTest(); -if (platform !== 'win32') +if (platform !== 'win32') { await browserTest(); +} diff --git a/xtask/src/build/jco.rs b/xtask/src/build/jco.rs index cdd53437..bae63ac3 100644 --- a/xtask/src/build/jco.rs +++ b/xtask/src/build/jco.rs @@ -85,6 +85,7 @@ fn transpile(component_path: &str, name: String, optimize: bool) -> Result<()> { multi_memory: true, import_bindings: Some(BindingsMode::Js), guest: false, + async_mode: None, }; let transpiled = js_component_bindgen::transpile(&adapted_component, opts)?; diff --git a/xtask/src/generate/wasi_types.rs b/xtask/src/generate/wasi_types.rs index 12b34fa2..ab170d64 100644 --- a/xtask/src/generate/wasi_types.rs +++ b/xtask/src/generate/wasi_types.rs @@ -39,6 +39,7 @@ pub(crate) fn run() -> Result<()> { multi_memory: false, import_bindings: Some(BindingsMode::Js), guest: false, + async_mode: None, }; let files = generate_types(name, resolve, world, opts)?;