diff --git a/.github/workflows/main-windows.yml b/.github/workflows/main-windows.yml index fcae3de05..08c6fae2d 100644 --- a/.github/workflows/main-windows.yml +++ b/.github/workflows/main-windows.yml @@ -70,6 +70,7 @@ jobs: - name: Build and test Component Model runtime run: | pnpm --filter '@bytecodealliance/jco-cm-runtime' run build + pnpm --filter '@bytecodealliance/jco-cm-runtime' run types:check pnpm --filter '@bytecodealliance/jco-cm-runtime' run test - name: Upload Component Model runtime build output diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 033dcd3b5..78040ee44 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -63,6 +63,7 @@ jobs: - name: Build and test Component Model runtime run: | pnpm --filter '@bytecodealliance/jco-cm-runtime' run build + pnpm --filter '@bytecodealliance/jco-cm-runtime' run types:check pnpm --filter '@bytecodealliance/jco-cm-runtime' run test - name: Upload Component Model runtime build output diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3cfb5381..a29eba21a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -586,7 +586,9 @@ jobs: cd /tmp/test-jco-cm-runtime echo '{"private":true,"type":"module"}' > package.json pnpm install "$PACKAGE_PATH" - node --input-type=module -e "import { runtime, RUNTIME_ABI_VERSION } from '@bytecodealliance/jco-cm-runtime'; const instance = runtime.create({ requestedAbiVersion: RUNTIME_ABI_VERSION, strict: false, flagsAsBigInt: false, nodejsCompat: true, asyncDeterminism: 'random' }); const entry = instance.intrinsics.resource.tableGet([1 << 30, 0, 7, 42 | (1 << 30)], 1); if (entry.rep !== 42 || !entry.own) throw new Error('runtime smoke test failed');" + cp "$GH_WORKSPACE/scripts/test-jco-cm-runtime-release.mjs" ./smoke.mjs + cp -r "$GH_WORKSPACE/scripts/cm-runtime-conformance" ./cm-runtime-conformance + node smoke.mjs - name: Test built jco-node-fs NPM package if: ${{ needs.meta.outputs.project == 'jco-node-fs' }} 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 4d927bbbd..190a14cff 100644 --- a/crates/js-component-bindgen-component/wit/js-component-bindgen.wit +++ b/crates/js-component-bindgen-component/wit/js-component-bindgen.wit @@ -106,7 +106,8 @@ world js-component-bindgen { /// both use utf16 (they are validated by default) perf-strings-skip-copy-utf16-validation: option, - /// ES module providing the Component Model runtime implementation. + /// Experimental Component Model runtime package or directory base. + /// Imports named runtime and intrinsics/* subpaths; directory/URL bases use .js files. cm-runtime-es-module: option, /// Represent WIT flags as bigint values instead of objects of booleans. diff --git a/crates/js-component-bindgen/README.md b/crates/js-component-bindgen/README.md index 2f1206765..b6ce443b4 100644 --- a/crates/js-component-bindgen/README.md +++ b/crates/js-component-bindgen/README.md @@ -31,11 +31,14 @@ $ cargo add js-component-bindgen ``` Generated JavaScript includes its runtime intrinsics inline by default. To -select an external runtime provider, set `TranspileOpts::cm_runtime_es_module` to -`@bytecodealliance/jco-cm-runtime` or another module specifier. Make the selected -module resolvable from the generated output; it must export a `runtime` provider. -Currently, external providers implement `resource.tableGet`, while the remaining -intrinsics are generated inline. +select an external runtime, set `TranspileOpts::cm_runtime_es_module` to +`@bytecodealliance/jco-cm-runtime` or another package/directory base. Generated bindings +import `CM_RUNTIME_API_VERSION` from `/runtime` and `tableGet` from +`/intrinsics/resource`; directory and file/HTTP(S) URL bases append `.js`. +Relative bases resolve from the generated output. `tableGet` receives an explicit +trap context, table and handle; other intrinsics remain inline. Legacy provider +files and `runtime.create` must migrate to the +[named-module contract](../../packages/jco-cm-runtime/docs/runtime.md). # License diff --git a/crates/js-component-bindgen/src/intrinsics/mod.rs b/crates/js-component-bindgen/src/intrinsics/mod.rs index 118ff52df..590897df7 100644 --- a/crates/js-component-bindgen/src/intrinsics/mod.rs +++ b/crates/js-component-bindgen/src/intrinsics/mod.rs @@ -9,10 +9,10 @@ use wasmtime_environ::Trap; use crate::source::Source; use crate::{TranspileOpts, uwrite, uwriteln}; -pub(crate) const CM_RUNTIME_API_VERSION: u32 = 2; -pub(crate) const CM_RUNTIME_PROVIDER_LOCAL_NAME: &str = "_jcoRuntimeProvider"; -const CM_RUNTIME_LOCAL_NAME: &str = "_jcoRuntime"; -const CM_RUNTIME_INTRINSICS_LOCAL_NAME: &str = "_jcoIntrinsics"; +pub(crate) mod runtime; +use runtime::{ + CM_RUNTIME_API_VERSION, RuntimeContext, RuntimeImport, TABLE_GET_CONTEXT, VERSION_LOCAL, +}; pub(crate) mod conversion; use conversion::ConversionIntrinsic; @@ -207,16 +207,36 @@ pub(crate) enum IntrinsicBinding { }, Runtime { local_name: &'static str, - path: &'static str, + import: RuntimeImport, }, } impl IntrinsicBinding { - fn local_name(self) -> &'static str { + pub(crate) fn local_name(self) -> &'static str { match self { Self::Inline { local_name } | Self::Runtime { local_name, .. } => local_name, } } + + /// Render a call, inserting only the context required by this operation. + pub(crate) fn call(self, operands: &str) -> String { + let name = self.local_name(); + match self { + Self::Runtime { + import: + RuntimeImport { + context: Some(context), + .. + }, + .. + } => { + let context = context.local_name(); + let separator = if operands.is_empty() { "" } else { ", " }; + format!("{name}({context}{separator}{operands})") + } + _ => format!("{name}({operands})"), + } + } } macro_rules! impl_from_intrinsic { @@ -2540,6 +2560,14 @@ pub struct RenderIntrinsicsArgs<'a> { } impl RenderIntrinsicsArgs<'_> { + pub(crate) fn intrinsic_call(&self, intrinsic: impl Into, operands: &str) -> String { + let intrinsic = intrinsic.into(); + self.require_intrinsic(intrinsic); + intrinsic + .binding(self.transpile_opts.cm_runtime_es_module.is_some()) + .call(operands) + } + /// Retrieve an intrinsic's name and register it as a dependency of the /// intrinsic currently being rendered. pub fn require_intrinsic(&self, intrinsic: impl Into) -> &'static str { @@ -2606,52 +2634,25 @@ fn render_intrinsics_discovered(args: &mut RenderIntrinsicsArgs<'_>) -> Source { uwriteln!( output, r#" - if (typeof {CM_RUNTIME_PROVIDER_LOCAL_NAME}?.create !== 'function') {{ - throw new TypeError('Jco Component Model runtime provider must define create(options)'); - }} - if ({CM_RUNTIME_PROVIDER_LOCAL_NAME}.apiVersion !== {CM_RUNTIME_API_VERSION}) {{ - throw new Error(`incompatible Jco JavaScript Component Model runtime API: requested {CM_RUNTIME_API_VERSION}, supported ${{{CM_RUNTIME_PROVIDER_LOCAL_NAME}.apiVersion}}`); - }} - const {CM_RUNTIME_LOCAL_NAME} = {CM_RUNTIME_PROVIDER_LOCAL_NAME}.create({{ - requestedApiVersion: {CM_RUNTIME_API_VERSION}, - strict: {strict}, - flagsAsBigInt: {flags_as_bigint}, - nodejsCompat: {nodejs_compat}, - asyncDeterminism: '{determinism}', - platform: {{ WebAssembly }}, - }}); - if ({CM_RUNTIME_LOCAL_NAME}?.apiVersion !== {CM_RUNTIME_API_VERSION}) {{ - throw new Error(`incompatible Jco JavaScript Component Model runtime API instance: requested {CM_RUNTIME_API_VERSION}, received ${{{CM_RUNTIME_LOCAL_NAME}?.apiVersion}}`); + if ({VERSION_LOCAL} !== {CM_RUNTIME_API_VERSION}) {{ + throw new Error(`incompatible Jco JavaScript Component Model runtime API: requested {CM_RUNTIME_API_VERSION}, supported ${{{VERSION_LOCAL}}}`); }} - const {CM_RUNTIME_INTRINSICS_LOCAL_NAME} = {CM_RUNTIME_LOCAL_NAME}.intrinsics; "#, - strict = args.transpile_opts.strict, - flags_as_bigint = args.transpile_opts.flags_as_bigint, - nodejs_compat = !args.transpile_opts.nodejs_compat_disabled, - determinism = args.determinism_profile, ); - + let mut contexts = BTreeSet::new(); for intrinsic in args.intrinsics.iter() { - let IntrinsicBinding::Runtime { local_name, path } = - intrinsic.binding(args.transpile_opts.cm_runtime_es_module.is_some()) - else { - continue; - }; - let (receiver_path, method) = path - .rsplit_once('.') - .expect("runtime intrinsic paths must name a method on a receiver"); - output.push_str(&format!( - r#" - const {local_name} = (() => {{ - const receiver = {CM_RUNTIME_INTRINSICS_LOCAL_NAME}?.{receiver_path}; - const intrinsic = receiver?.{method}; - if (typeof intrinsic !== 'function') {{ - throw new TypeError('Jco Component Model runtime intrinsic {path} must be a function'); - }} - return intrinsic.bind(receiver); - }})(); - "# - )); + if let IntrinsicBinding::Runtime { + import: + RuntimeImport { + context: Some(context), + .. + }, + .. + } = intrinsic.binding(true) + && contexts.insert(context.local_name()) + { + context.render(&mut output); + } } } if args @@ -2774,7 +2775,11 @@ impl Intrinsic { Self::Resource(ResourceIntrinsic::ResourceTableGet) if external_runtime => { IntrinsicBinding::Runtime { local_name: self.name(), - path: "resource.tableGet", + import: RuntimeImport { + suffix: "intrinsics/resource", + export: "tableGet", + context: Some(RuntimeContext::ResourceTableGet), + }, } } _ => IntrinsicBinding::Inline { @@ -2826,9 +2831,8 @@ impl Intrinsic { "imports", "instantiateCore", "isLE", - CM_RUNTIME_PROVIDER_LOCAL_NAME, - CM_RUNTIME_LOCAL_NAME, - CM_RUNTIME_INTRINSICS_LOCAL_NAME, + VERSION_LOCAL, + TABLE_GET_CONTEXT, "scopeId", "symbolCabiDispose", "symbolCabiLower", diff --git a/crates/js-component-bindgen/src/intrinsics/resource.rs b/crates/js-component-bindgen/src/intrinsics/resource.rs index 057af2e62..3aff99857 100644 --- a/crates/js-component-bindgen/src/intrinsics/resource.rs +++ b/crates/js-component-bindgen/src/intrinsics/resource.rs @@ -345,7 +345,8 @@ impl ResourceIntrinsic { let resource_transfer_borrow_fn = self.name(); let handle_tables = render_args.require_intrinsic(Intrinsic::HandleTables); let resource_borrows = render_args.require_intrinsic(Self::ResourceCallBorrows); - let rsc_table_get = render_args.require_intrinsic(Self::ResourceTableGet); + let table_get_call = + render_args.intrinsic_call(Self::ResourceTableGet, "fromTable, handle"); let rsc_table_create_borrow = render_args.require_intrinsic(Self::ResourceTableCreateBorrow); let scope_id = render_args.require_intrinsic(Intrinsic::ScopeId); @@ -360,7 +361,7 @@ impl ResourceIntrinsic { r#" function {resource_transfer_borrow_fn}(handle, fromTid, toTid) {{ const fromTable = {handle_tables}[fromTid]; - const {{ rep, own }} = {rsc_table_get}(fromTable, handle); + const {{ rep, own }} = {table_get_call}; let toTable = {handle_tables}[toTid]; if (!toTable) {{ diff --git a/crates/js-component-bindgen/src/intrinsics/runtime.rs b/crates/js-component-bindgen/src/intrinsics/runtime.rs new file mode 100644 index 000000000..017b9fc00 --- /dev/null +++ b/crates/js-component-bindgen/src/intrinsics/runtime.rs @@ -0,0 +1,262 @@ +//! Named external runtime imports. Keep public suffix selection and base resolution here. + +use anyhow::{Result, bail, ensure}; + +use super::{Intrinsic, IntrinsicBinding}; +use crate::{names::js_string_literal, source::Source, uwriteln}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write; + +pub(crate) const CM_RUNTIME_API_VERSION: u32 = 1; +pub(crate) const VERSION_LOCAL: &str = "_jcoRuntimeApiVersion"; +pub(crate) const TABLE_GET_CONTEXT: &str = "_jcoResourceTableGetContext"; + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub(crate) enum RuntimeContext { + ResourceTableGet, +} + +impl RuntimeContext { + pub(crate) fn local_name(self) -> &'static str { + match self { + Self::ResourceTableGet => TABLE_GET_CONTEXT, + } + } + + pub(crate) fn render(self, output: &mut Source) { + match self { + Self::ResourceTableGet => uwriteln!( + output, + "const {TABLE_GET_CONTEXT} = {{ RuntimeError: WebAssembly.RuntimeError }};" + ), + } + } +} + +/// A named call export. Context requirements are separate from the public name. +/// Add value/constructor/state descriptors when such exports are implemented. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub(crate) struct RuntimeImport { + pub(crate) suffix: &'static str, + pub(crate) export: &'static str, + pub(crate) context: Option, +} + +/// Resolve an informal runtime package/directory contract without consulting the filesystem. +pub(crate) fn module_specifier(base: &str, suffix: &str) -> Result { + ensure!( + !base.trim().is_empty() && !base.chars().any(char::is_control), + "cmRuntimeEsModule must be a non-empty base specifier without control characters" + ); + let directory_path = base == "." + || base == ".." + || base.starts_with("./") + || base.starts_with("../") + || base.starts_with('/'); + let url = if !directory_path + && let Some((scheme, _)) = base + .split_once(':') + .filter(|(prefix, _)| !prefix.contains('/')) + { + ensure!( + ["file", "http", "https"] + .iter() + .any(|supported| scheme.eq_ignore_ascii_case(supported)) + && base.starts_with(&format!("{scheme}://")), + "cmRuntimeEsModule has an unsupported URL scheme; use a package, directory, or file/HTTP(S) URL (including file URLs on Windows)" + ); + true + } else { + false + }; + let directory = url || directory_path; + let base = base.strip_suffix('/').unwrap_or(base); + if directory { + ensure!( + !base.contains(['?', '#']), + "cmRuntimeEsModule directory bases must not contain a query or fragment" + ); + if [".js", ".mjs", ".cjs"] + .iter() + .any(|ext| base.ends_with(ext)) + { + bail!( + "cmRuntimeEsModule now identifies a runtime directory base, not a provider file; replace the file specifier with a directory containing runtime.js and intrinsics/*.js" + ); + } + } + Ok(format!( + "{base}/{suffix}{}", + if directory { ".js" } else { "" } + )) +} + +/// Called after dependency discovery, so transitive inline calls also get their imports. +pub(crate) fn render_imports(output: &mut Source, intrinsics: &BTreeSet, base: &str) { + let mut modules = BTreeMap::<&str, BTreeSet<(&str, &str)>>::new(); + for intrinsic in intrinsics { + if let IntrinsicBinding::Runtime { local_name, import } = intrinsic.binding(true) { + modules + .entry(import.suffix) + .or_default() + .insert((import.export, local_name)); + } + } + if modules.is_empty() { + return; + } + modules + .entry("runtime") + .or_default() + .insert(("CM_RUNTIME_API_VERSION", VERSION_LOCAL)); + for (suffix, names) in modules { + let names = names + .into_iter() + .map(|(export, local)| format!("{export} as {local}")) + .collect::>() + .join(", "); + let specifier = + module_specifier(base, suffix).expect("runtime base was validated before codegen"); + uwriteln!( + output, + "import {{ {names} }} from {};", + js_string_literal(&specifier) + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::TranspileOpts; + use crate::intrinsics::{RenderIntrinsicsArgs, render_intrinsics, resource::ResourceIntrinsic}; + + #[test] + fn transitive_named_calls_keep_inline_dependencies_and_deduplicate_imports() { + let mut intrinsics = BTreeSet::from([Intrinsic::Resource( + ResourceIntrinsic::ResourceTransferBorrow, + )]); + let opts = TranspileOpts::builder() + .name("mixed".into()) + .cm_runtime_es_module("custom-runtime".into()) + .build(); + let source = render_intrinsics( + RenderIntrinsicsArgs::builder() + .intrinsics(&mut intrinsics) + .transpile_opts(&opts) + .build(), + ); + assert!(source.contains("rscTableGet(_jcoResourceTableGetContext, fromTable, handle)")); + assert!(source.contains("function rscTableCreateBorrow(")); + assert!(source.contains("const HANDLE_TABLES")); + assert!(!source.contains("function rscTableGet(")); + assert!(!source.contains("_jcoRuntimeProvider")); + let mut imports = Source::default(); + render_imports(&mut imports, &intrinsics, "custom-runtime"); + assert_eq!(imports.matches("tableGet as rscTableGet").count(), 1); + assert_eq!(imports.matches("CM_RUNTIME_API_VERSION as").count(), 1); + } + + #[test] + fn resource_context_needs_no_unrelated_initializers() { + let mut intrinsics = + BTreeSet::from([Intrinsic::Resource(ResourceIntrinsic::ResourceTableGet)]); + let opts = TranspileOpts::builder() + .name("resource".into()) + .cm_runtime_es_module("custom-runtime".into()) + .build(); + let source = render_intrinsics( + RenderIntrinsicsArgs::builder() + .intrinsics(&mut intrinsics) + .transpile_opts(&opts) + .build(), + ); + assert_eq!(intrinsics.len(), 1); + assert!(source.contains("RuntimeError: WebAssembly.RuntimeError")); + for unrelated in [ + "T_FLAG", + "HANDLE_TABLES", + "TextEncoder", + "TextDecoder", + "WebAssembly.Global", + "Suspending", + "new Map", + "new Int32Array", + ] { + assert!(!source.contains(unrelated), "{unrelated}"); + } + } + + #[test] + fn resolves_runtime_bases() { + for (base, expected) in [ + ("@scope/runtime", "@scope/runtime/intrinsics/resource"), + ("custom-runtime/", "custom-runtime/intrinsics/resource"), + ("something.js", "something.js/intrinsics/resource"), + ("custom/nested", "custom/nested/intrinsics/resource"), + ("./runtime", "./runtime/intrinsics/resource.js"), + ("../runtime/", "../runtime/intrinsics/resource.js"), + ("/runtime/", "/runtime/intrinsics/resource.js"), + ("/", "/intrinsics/resource.js"), + ( + "./runtime:version", + "./runtime:version/intrinsics/resource.js", + ), + (".", "./intrinsics/resource.js"), + ( + "file:///C:/runtime/", + "file:///C:/runtime/intrinsics/resource.js", + ), + ("file:///a%20b/", "file:///a%20b/intrinsics/resource.js"), + ( + "https://example.test/runtime/", + "https://example.test/runtime/intrinsics/resource.js", + ), + ( + "HTTPS://example.test/runtime/", + "HTTPS://example.test/runtime/intrinsics/resource.js", + ), + ( + "http://example.test/a//b", + "http://example.test/a//b/intrinsics/resource.js", + ), + ("./a b'\"", "./a b'\"/intrinsics/resource.js"), + ] { + assert_eq!( + module_specifier(base, "intrinsics/resource").unwrap(), + expected + ); + } + } + + #[test] + fn rejects_unsupported_bases() { + for base in [ + "", + " ", + "a\nb", + "a\0b", + "data:text/javascript,", + "ftp://example.test/a", + "C:\\runtime", + "./a?b", + "file:///a#b", + "https://example.test/a?q", + ] { + assert!(module_specifier(base, "runtime").is_err(), "{base}"); + } + for base in [ + "./provider.js", + "../provider.mjs/", + "file:///provider.cjs", + "https://example.test/provider.js", + ] { + assert!( + module_specifier(base, "runtime") + .unwrap_err() + .to_string() + .contains("directory base") + ); + } + } +} diff --git a/crates/js-component-bindgen/src/lib.rs b/crates/js-component-bindgen/src/lib.rs index 42f5ff9a8..21f48120d 100644 --- a/crates/js-component-bindgen/src/lib.rs +++ b/crates/js-component-bindgen/src/lib.rs @@ -225,10 +225,7 @@ fn normalize_namespace_object_options(opts: &mut TranspileOpts) -> Result<()> { opts.flags_as_bigint = true; } if let Some(cm_runtime_es_module) = &opts.cm_runtime_es_module { - ensure!( - !cm_runtime_es_module.is_empty() && !cm_runtime_es_module.chars().any(char::is_control), - "cmRuntimeEsModule must be a non-empty module specifier without control characters" - ); + intrinsics::runtime::module_specifier(cm_runtime_es_module, "runtime")?; } Ok(()) } @@ -472,10 +469,10 @@ mod tests { let mut custom = TranspileOpts::builder() .name("component".into()) - .cm_runtime_es_module("./runtime.js".into()) + .cm_runtime_es_module("./runtime".into()) .build(); normalize_namespace_object_options(&mut custom).unwrap(); - assert_eq!(custom.cm_runtime_es_module.as_deref(), Some("./runtime.js")); + assert_eq!(custom.cm_runtime_es_module.as_deref(), Some("./runtime")); for invalid in ["", "runtime\nmodule"] { let mut opts = TranspileOpts::builder() @@ -488,7 +485,7 @@ mod tests { #[cfg(feature = "transpile-bindgen")] #[test] - fn resource_rep_uses_external_runtime_provider() { + fn resource_rep_uses_named_runtime_exports() { let component = wat::parse_str( r#" (component @@ -527,7 +524,7 @@ mod tests { &component, TranspileOpts::builder() .name("resource-runtime".into()) - .cm_runtime_es_module("./custom-runtime.js".into()) + .cm_runtime_es_module("./custom-runtime".into()) .instantiation_mode(InstantiationMode::Sync) .build(), ) @@ -535,22 +532,29 @@ mod tests { let source = generated_javascript(&transpiled); let import_position = source - .find("import { runtime as _jcoRuntimeProvider } from \"./custom-runtime.js\";") + .find("import { tableGet as rscTableGet } from \"./custom-runtime/intrinsics/resource.js\";") .unwrap(); let instantiate_position = source.find("export function instantiate(").unwrap(); - let create_position = source.find("_jcoRuntimeProvider.create(").unwrap(); + let realm_position = source.find("const WebAssembly = options?").unwrap(); + let context_position = source + .find("const _jcoResourceTableGetContext = { RuntimeError: WebAssembly.RuntimeError };") + .unwrap(); assert!(import_position < instantiate_position); - assert!(instantiate_position < create_position); - assert!(source.contains("const rscTableGet = (() => {")); - assert!(source.contains("return intrinsic.bind(receiver);")); - assert!(source.contains("rscTableGet(")); - assert!(!source.contains("function rscTableGet(table, handle)")); - assert_eq!(source.matches("_jcoRuntimeProvider.create(").count(), 1); + assert!(instantiate_position < realm_position); + assert!(realm_position < context_position); assert!( - !transpiled - .imports - .contains(&"./custom-runtime.js".to_string()) + source.contains("rscTableGet(_jcoResourceTableGetContext, handleTable0, handle).rep") + ); + assert!(!source.contains("function rscTableGet(table, handle)")); + assert!(!source.contains("_jcoRuntimeProvider")); + assert!(!source.contains(".bind(receiver)")); + assert_eq!(source.matches("tableGet as rscTableGet").count(), 1); + assert_eq!( + source.matches("const _jcoResourceTableGetContext").count(), + 1 ); + assert!(source.contains("from \"./custom-runtime/runtime.js\"")); + assert!(!transpiled.imports.contains(&"./custom-runtime".to_string())); } #[cfg(feature = "transpile-bindgen")] @@ -559,12 +563,16 @@ mod tests { let component = wat::parse_str("(component)").unwrap(); let transpiled = transpile( &component, - TranspileOpts::builder().name("adder".into()).build(), + TranspileOpts::builder() + .name("adder".into()) + .cm_runtime_es_module("unused-runtime".into()) + .build(), ) .unwrap(); let source = generated_javascript(&transpiled); assert!(!source.contains("_jcoRuntimeProvider")); - assert!(!source.contains("@bytecodealliance/jco-cm-runtime")); + assert!(!source.contains("unused-runtime")); + assert!(!source.contains("_jcoResourceTableGetContext")); } } diff --git a/crates/js-component-bindgen/src/transpile_bindgen.rs b/crates/js-component-bindgen/src/transpile_bindgen.rs index 8ad41b8ce..b924897aa 100644 --- a/crates/js-component-bindgen/src/transpile_bindgen.rs +++ b/crates/js-component-bindgen/src/transpile_bindgen.rs @@ -49,8 +49,7 @@ use crate::intrinsics::resource::ResourceIntrinsic; use crate::intrinsics::string::StringIntrinsic; use crate::intrinsics::webidl::WebIdlIntrinsic; use crate::intrinsics::{ - AsyncDeterminismProfile, CM_RUNTIME_PROVIDER_LOCAL_NAME, Intrinsic, RenderIntrinsicsArgs, - render_intrinsics, uses_external_runtime, + AsyncDeterminismProfile, Intrinsic, RenderIntrinsicsArgs, render_intrinsics, runtime, }; use crate::names::{ LocalNames, is_js_reserved_word, js_string_literal, maybe_quote_id, maybe_quote_member, @@ -137,8 +136,8 @@ pub struct TranspileOpts { /// passing unpaired surrogates on to the receiving component. #[builder(default)] pub perf_strings_skip_copy_utf16_validation: bool, - /// ES module providing the Component Model runtime implementation used by - /// generated bindings. The module must export a `runtime` provider. + /// Package or directory base for the experimental Component Model runtime. + /// Generated bindings import named exports from runtime and intrinsics/* subpaths. pub cm_runtime_es_module: Option, /// Represent WIT flags as bigint values instead of objects of booleans. #[builder(default)] @@ -619,12 +618,8 @@ impl JsBindgen<'_> { .transpile_opts(opts) .build(); let js_intrinsics = render_intrinsics(render_args); - if uses_external_runtime(&self.all_intrinsics, opts.cm_runtime_es_module.is_some()) { - uwriteln!( - output, - "import {{ runtime as {CM_RUNTIME_PROVIDER_LOCAL_NAME} }} from {};", - js_string_literal(opts.cm_runtime_es_module.as_deref().unwrap()), - ); + if let Some(base) = opts.cm_runtime_es_module.as_deref() { + runtime::render_imports(&mut output, &self.all_intrinsics, base); } // Write out instantiation @@ -836,7 +831,17 @@ impl JsBindgen<'_> { fn intrinsic(&mut self, intrinsic: Intrinsic) -> String { self.all_intrinsics.insert(intrinsic); - intrinsic.name().to_string() + intrinsic + .binding(self.opts.cm_runtime_es_module.is_some()) + .local_name() + .to_string() + } + + fn intrinsic_call(&mut self, intrinsic: Intrinsic, operands: &str) -> String { + self.all_intrinsics.insert(intrinsic); + intrinsic + .binding(self.opts.cm_runtime_es_module.is_some()) + .call(operands) } /// Write out utility helpers/objects (available via esModule._util) @@ -3429,13 +3434,14 @@ impl<'a> Instantiator<'a, '_> { } => { self.ensure_resource_table(*resource_ty_idx); let rid = resource_ty_idx.as_u32(); - let rsc_table_get = self - .bindgen - .intrinsic(Intrinsic::Resource(ResourceIntrinsic::ResourceTableGet)); + let table_get_call = self.bindgen.intrinsic_call( + Intrinsic::Resource(ResourceIntrinsic::ResourceTableGet), + &format!("handleTable{rid}, handle"), + ); uwriteln!( self.src.js, "function trampoline{i} (handle) {{ - return {rsc_table_get}(handleTable{rid}, handle).rep; + return {table_get_call}.rep; }}" ); } diff --git a/docs/src/transpiling.md b/docs/src/transpiling.md index 85d3d71dc..43df5ab3b 100644 --- a/docs/src/transpiling.md +++ b/docs/src/transpiling.md @@ -49,7 +49,7 @@ Options include: * `--map`: Provide custom mappings for world imports. Supports both wildcard mappings (`*` similarly as in the package.json "exports" field) as well as `#` mappings for targetting exported interfaces. For example, the WASI mappings are internally defined with mappings like `--map wasi:filesystem/*=@bytecodealliance/preview2-shim/filesystem#*` to map `import as * filesystem from 'wasi:filesystem/types'` to `import { types } from '@bytecodealliance/preview2-shim/filesystem`. * `--no-nodejs-compat`: Disables Node.js compat in the output to load core Wasm with FS methods. * `--instantiation [mode]`: Instead of a direct ES module, export an `instantiate` function which can take the imports as an argument instead of implicit imports. The `instantiate` function can be async (with `--instantiation` or `--instantiation async`), or sync (with `--instantiation sync`). -* `--cm-runtime-es-module `: Select an external Component Model runtime provider exported as `runtime` by the given ES module. The module is resolved from the generated bindings when they are loaded. Install the provider alongside the generated component; omitting this option keeps runtime intrinsics inline. The experimental JavaScript Component Model runtime API currently delegates `resource.tableGet`. +* `--cm-runtime-es-module `: Select an experimental API-v1 Component Model runtime package or directory base. Bindings import named exports from `/runtime` and `/intrinsics/resource`; directory and file/HTTP(S) URL bases append `.js`. Relative bases resolve from the generated bindings. Install the runtime alongside the generated component; omitting this option keeps intrinsics inline. The current operation is `tableGet(context, table, handle)`. Legacy provider files such as `./provider.js` must migrate to a directory base. See the [runtime contract](../../packages/jco-cm-runtime/docs/runtime.md). * `--valid-lifting-optimization`: Internal validations are removed assuming that core Wasm binaries are valid components, providing a minor output size saving. * `--flags-as-bigint`: Represents WIT flags as `bigint` values and exports named flag constants. By default, flags remain objects of booleans for backwards compatibility. * `--variants-inline-cases`: Inlines WIT variant cases in their discriminated unions instead of exporting a named interface for every case. The named interfaces remain the default for backwards compatibility. diff --git a/examples/components/node-grpc-server/README.md b/examples/components/node-grpc-server/README.md index 2163987f9..46e05e2b2 100644 --- a/examples/components/node-grpc-server/README.md +++ b/examples/components/node-grpc-server/README.md @@ -23,13 +23,13 @@ defines `GreeterService.Greet`, a unary RPC that returns a greeting or rejects a empty name with `INVALID_ARGUMENT`. The service also sets a response header and a trailer containing the UTF-8 byte count of the supplied name. -| API or tool | Purpose | -| --- | --- | -| `node:http2` | Create the shared HTTP/2 server and send response trailers. | -| `node:buffer` | Count UTF-8 bytes and construct empty response bodies. | -| `node:stream/consumers` | Collect the SDK's response stream as a buffer. | -| Buf CLI | Lint the schema, run the local code generator, and call the server over gRPC. | -| Connect router | Handle gRPC envelopes, protobuf messages, status codes, and metadata. | +| API or tool | Purpose | +| ----------------------- | ----------------------------------------------------------------------------- | +| `node:http2` | Create the shared HTTP/2 server and send response trailers. | +| `node:buffer` | Count UTF-8 bytes and construct empty response bodies. | +| `node:stream/consumers` | Collect the SDK's response stream as a buffer. | +| Buf CLI | Lint the schema, run the local code generator, and call the server over gRPC. | +| Connect router | Handle gRPC envelopes, protobuf messages, status codes, and metadata. | The server uses Connect's router with only the gRPC protocol enabled. A small bridge connects its request/response interface to Node's HTTP/2 API. The @@ -49,16 +49,17 @@ I/O. Import mappings preserve WIT interface names. Node's `--experimental-wasm-jspi` flag enables the asynchronous component callbacks. [`serve-node.js`](./serve-node.js) imports the same source directly on Node. +The released CLI also imports `jco:node/tls@0.1.0` for the HTTP/2 module. +The runner supplies the default denial providers for Node and WASI TLS because +this example uses plaintext HTTP/2. + ## Quickstart -Use Node.js 24 or newer and pnpm. This example uses workspace packages to exercise -the HTTP/2 trailer support from this checkout. From the repository root: +Use Node.js 24 or newer and pnpm. This example uses the released Jco CLI. +From the repository root, install the dependencies: ```console pnpm install -pnpm --filter @bytecodealliance/jco run build -pnpm --filter @bytecodealliance/jco-std run build:ts -pnpm --filter @bytecodealliance/preview2-shim run build ``` Then run the complete example: @@ -96,7 +97,7 @@ Expected output: ```json { - "message": "Hello, World!" + "message": "Hello, World!" } ``` diff --git a/examples/components/node-grpc-server/package.json b/examples/components/node-grpc-server/package.json index b386037db..5401096ab 100644 --- a/examples/components/node-grpc-server/package.json +++ b/examples/components/node-grpc-server/package.json @@ -7,7 +7,7 @@ "scripts": { "generate": "buf lint && buf generate", "build": "pnpm run generate && jco componentize src/server.js --bundle --with-nodejs-http2-via direct --wit wit --out component.wasm", - "transpile": "jco transpile component.wasm --instantiation async --async-mode jspi --async-exports '*' --no-wasi-shim --map jco:node/http2@0.1.0=jco:node/http2@0.1.0 --out-dir dist/transpiled", + "transpile": "jco transpile component.wasm --instantiation async --async-mode jspi --async-exports '*' --no-wasi-shim --map jco:node/http2@0.1.0=jco:node/http2@0.1.0 --map jco:node/tls@0.1.0=jco:node/tls@0.1.0 --out-dir dist/transpiled", "serve": "node --experimental-wasm-jspi run-transpiled.js", "serve:node": "node serve-node.js", "run-transpiled": "node --experimental-wasm-jspi run-transpiled.js", @@ -24,7 +24,7 @@ "devDependencies": { "@bufbuild/buf": "1.72.0", "@bufbuild/protoc-gen-es": "2.14.1", - "@bytecodealliance/jco": "workspace:*" + "@bytecodealliance/jco": "1.39.0" }, "engines": { "node": ">=24" diff --git a/examples/components/node-grpc-server/run-transpiled.js b/examples/components/node-grpc-server/run-transpiled.js index 267b38171..cef752e75 100644 --- a/examples/components/node-grpc-server/run-transpiled.js +++ b/examples/components/node-grpc-server/run-transpiled.js @@ -1,5 +1,7 @@ import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation'; import { createHttp2Host } from '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/http2/host/node'; +import * as wasiTlsHost from '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/host'; +import tlsHost from '@bytecodealliance/jco-std/wasi/0.2.x/node/24.x.x/tls/node-host'; import { instantiate } from './dist/transpiled/component.js'; let component; @@ -9,6 +11,8 @@ const http2Host = createHttp2Host(() => component.http2Callbacks); component = await instantiate(undefined, { ...new WASIShim().getImportObject(), 'jco:node/http2@0.1.0': http2Host, + 'jco:node/tls@0.1.0': tlsHost, + 'wasi:tls/types': wasiTlsHost, }); const port = await component.start(Number(process.env.PORT ?? 3000)); diff --git a/examples/components/node-grpc-server/wit/component.wit b/examples/components/node-grpc-server/wit/component.wit index 76d5e46c5..b43baf297 100644 --- a/examples/components/node-grpc-server/wit/component.wit +++ b/examples/components/node-grpc-server/wit/component.wit @@ -1,6 +1,7 @@ package example:node-grpc-server; world component { + import jco:node/tls@0.1.0; import jco:node/http2@0.1.0; export jco:node/http2-callbacks@0.1.0; diff --git a/examples/components/node-grpc-server/wit/deps/jco-node-0.1.0/tls.wit b/examples/components/node-grpc-server/wit/deps/jco-node-0.1.0/tls.wit new file mode 100644 index 000000000..acd071779 --- /dev/null +++ b/examples/components/node-grpc-server/wit/deps/jco-node-0.1.0/tls.wit @@ -0,0 +1,108 @@ +package jco:node@0.1.0; + +/// Events are delivered after the initiating import returns, avoiding component reentrance. +interface tls-callbacks { + record event { + target: u32, + name: string, + value: string, + data: list, + } + + dispatch: func(event: event); +} + +/// Primary TLS capability for Node APIs. Providers may delegate to wasi:tls. +interface tls { + use wasi:io/streams@0.2.12.{input-stream, output-stream}; + use wasi:tls/types@0.2.0-draft.{future-client-streams}; + + /// Optional TLS upgrade over existing WASI streams. Ownership passes to the provider. + is-available: func() -> bool; + + start-tls: func(server-name: string, input: input-stream, output: output-stream) -> future-client-streams; + + record error { + name: string, + message: string, + code: option, + } + + enum query-operation { + ciphers, + compression-algorithms, + ca-certificates, + check-identity, + } + + enum socket-operation-kind { + state, + address, + certificate, + peer-certificate, + cipher, + ephemeral-key-info, + finished, + peer-finished, + protocol, + session, + shared-sigalgs, + tls-ticket, + session-reused, + export-keying-material, + disable-renegotiation, + enable-trace, + max-send-fragment, + key-cert, + timeout, + no-delay, + keep-alive, + ref, + unref, + resume, + pause, + destroy, + renegotiate, + set-session, + set-servername, + } + + enum server-operation-kind { + state, + address, + listen, + close, + connections, + ref, + unref, + ticket-keys, + set-ticket-keys, + secure-context, + add-context, + max-connections, + drop-max-connection, + } + + /// Options and inspection values use the documented TLS graph-data encoding. + query: func(operation: query-operation, args: string) -> result; + + set-default-ca: func(certs: string) -> result<_, error>; + + create-context: func(options: string) -> result; + + release-context: func(id: u32); + + connect: func(id: u32, options: string) -> result<_, error>; + + create-server: func(id: u32, options: string) -> result<_, error>; + + socket-operation: func(id: u32, operation: socket-operation-kind, args: string) -> result; + + server-operation: func(id: u32, operation: server-operation-kind, args: string) -> result; + + write: func(id: u32, token: u32, data: list) -> result<_, error>; + + end: func(id: u32, token: u32) -> result<_, error>; + + release: func(id: u32); +} diff --git a/examples/components/node-grpc-server/wit/deps/wasi-io-0.2.12/package.wit b/examples/components/node-grpc-server/wit/deps/wasi-io-0.2.12/package.wit new file mode 100644 index 000000000..8006d6d2e --- /dev/null +++ b/examples/components/node-grpc-server/wit/deps/wasi-io-0.2.12/package.wit @@ -0,0 +1,66 @@ +package wasi:io@0.2.12; +interface error { + resource error { + to-debug-string: func() -> string; + } +} +interface poll { + resource pollable { + ready: func() -> bool; + block: func(); + } + poll: func(in: list>) -> list; +} +interface streams { + use error.{error}; + use poll.{pollable}; + variant stream-error { + last-operation-failed(error), + closed + } + resource input-stream { + read: func( + len: u64 + ) -> result, stream-error>; + blocking-read: func( + len: u64 + ) -> result, stream-error>; + skip: func( + len: u64, + ) -> result; + blocking-skip: func( + len: u64, + ) -> result; + subscribe: func() -> pollable; + } + resource output-stream { + check-write: func() -> result; + write: func( + contents: list + ) -> result<_, stream-error>; + blocking-write-and-flush: func( + contents: list + ) -> result<_, stream-error>; + flush: func() -> result<_, stream-error>; + blocking-flush: func() -> result<_, stream-error>; + subscribe: func() -> pollable; + write-zeroes: func( + len: u64 + ) -> result<_, stream-error>; + blocking-write-zeroes-and-flush: func( + len: u64 + ) -> result<_, stream-error>; + splice: func( + src: borrow, + len: u64, + ) -> result; + blocking-splice: func( + src: borrow, + len: u64, + ) -> result; + } +} +world imports { + import streams; + import poll; +} diff --git a/examples/components/node-grpc-server/wit/deps/wasi-tls-0.2.0-draft/types.wit b/examples/components/node-grpc-server/wit/deps/wasi-tls-0.2.0-draft/types.wit new file mode 100644 index 000000000..a2a664948 --- /dev/null +++ b/examples/components/node-grpc-server/wit/deps/wasi-tls-0.2.0-draft/types.wit @@ -0,0 +1,27 @@ +// Adapted from WebAssembly/wasi-tls wit/types.wit, +// 6781ae26084100c0628ef72cc44e4517c6c48ae5 (W3C Community CLA). +// Local changes: wasi:io@0.2.12 and an availability query. See README.md. +interface types { + /// Whether the host grants TLS connections. This query performs no IO. + is-available: func() -> bool; + + use wasi:io/streams@0.2.12.{input-stream, output-stream}; + use wasi:io/poll@0.2.12.{pollable}; + use wasi:io/error@0.2.12.{error as io-error}; + + resource client-handshake { + constructor(server-name: string, input: input-stream, output: output-stream); + + finish: static func(this: client-handshake) -> future-client-streams; + } + + resource client-connection { + close-output: func(); + } + + resource future-client-streams { + subscribe: func() -> pollable; + + get: func() -> option, io-error>>>; + } +} diff --git a/examples/components/node-grpc-server/wit/deps/wasi-tls-0.2.0-draft/world.wit b/examples/components/node-grpc-server/wit/deps/wasi-tls-0.2.0-draft/world.wit new file mode 100644 index 000000000..2c7093d93 --- /dev/null +++ b/examples/components/node-grpc-server/wit/deps/wasi-tls-0.2.0-draft/world.wit @@ -0,0 +1,8 @@ +// Adapted from WebAssembly/wasi-tls wit/world.wit at +// 6781ae26084100c0628ef72cc44e4517c6c48ae5 (W3C Community CLA). +// Local change: omit unstable-feature gates; see README.md. +package wasi:tls@0.2.0-draft; + +world imports { + import types; +} diff --git a/examples/components/node-mcp-server/README.md b/examples/components/node-mcp-server/README.md index ea0dcbb18..2f9328704 100644 --- a/examples/components/node-mcp-server/README.md +++ b/examples/components/node-mcp-server/README.md @@ -23,13 +23,13 @@ and base64-encoded contents. It never opens the named file. The server uses ordinary Node.js imports: -| API | Use inside the component | -| --- | --- | -| `node:http` | Create and listen on the HTTP server in both runtimes. | -| `node:stream` | Turn the chunks into a classic `Readable` stream. | -| `node:stream/consumers` | Collect that stream as text. | -| `node:buffer` | Count UTF-8 bytes and encode them as base64. | -| `node:path` | Normalize the filename with portable POSIX semantics. | +| API | Use inside the component | +| ----------------------- | ------------------------------------------------------ | +| `node:http` | Create and listen on the HTTP server in both runtimes. | +| `node:stream` | Turn the chunks into a classic `Readable` stream. | +| `node:stream/consumers` | Collect that stream as text. | +| `node:buffer` | Count UTF-8 bytes and encode them as base64. | +| `node:path` | Normalize the filename with portable POSIX semantics. | Jco's built-in bundler replaces those imports with jco-std implementations. The SDK, Zod validation, and tool execution all run inside WebAssembly. There @@ -62,15 +62,11 @@ this HTTP example does not need host process operations or native stdin/stdout. ## Quickstart -Use Node.js 24 or newer and pnpm. This example uses workspace packages because -it exercises Node compatibility support from this checkout. From the repository -root, install and build the tooling once: +Use Node.js 24 or newer and pnpm. This example uses the released Jco CLI. +From the repository root, install the dependencies: ```console pnpm install -pnpm --filter @bytecodealliance/jco run build -pnpm --filter @bytecodealliance/jco-std run build:ts -pnpm --filter @bytecodealliance/preview2-shim run build ``` Then run the complete example: diff --git a/examples/components/node-mcp-server/package.json b/examples/components/node-mcp-server/package.json index 5ee6e4d0a..d20ac95d2 100644 --- a/examples/components/node-mcp-server/package.json +++ b/examples/components/node-mcp-server/package.json @@ -21,7 +21,7 @@ "zod": "4.6.7" }, "devDependencies": { - "@bytecodealliance/jco": "workspace:*", + "@bytecodealliance/jco": "1.39.0", "@modelcontextprotocol/client": "2.3.0" }, "engines": { diff --git a/examples/components/node-sveltekit-server/README.md b/examples/components/node-sveltekit-server/README.md index 9fe623db2..473ebb892 100644 --- a/examples/components/node-sveltekit-server/README.md +++ b/examples/components/node-sveltekit-server/README.md @@ -59,15 +59,11 @@ Svelte templates, route actions, server lifecycle, launcher, and e2e tests. ## Quickstart -Use Node.js 24 or newer and pnpm. This example uses workspace packages because -it exercises Node compatibility support from this checkout. From this example -directory, install and build the tooling once: +Use Node.js 24 or newer and pnpm. This example uses the released Jco CLI. +From this example directory, install the dependencies: ```console pnpm install -pnpm --filter @bytecodealliance/jco run build -pnpm --filter @bytecodealliance/jco-std run build:ts -pnpm --filter @bytecodealliance/preview2-shim run build pnpm run setup:browser ``` diff --git a/examples/components/node-sveltekit-server/generated/types/component.d.ts b/examples/components/node-sveltekit-server/generated/types/component.d.ts index c53f21b4d..c9796528a 100644 --- a/examples/components/node-sveltekit-server/generated/types/component.d.ts +++ b/examples/components/node-sveltekit-server/generated/types/component.d.ts @@ -25,20 +25,21 @@ export interface Component { * Options accepted by `instantiate`. */ export interface InstantiateOptions { -/** Runtime overrides for this instance. */ -shim?: { -/** Platform globals used instead of those on `globalThis`. */ -globals?: { -/** -* The WebAssembly implementation used by this instance, -* including by the default `instantiateCore`. Defaults to -* `globalThis.WebAssembly`. -*/ -WebAssembly?: typeof WebAssembly; -}; -}; + /** Runtime overrides for this instance. */ + shim?: { + /** Platform globals used instead of those on `globalThis`. */ + globals?: { + /** + * The WebAssembly implementation used by this instance, + * including by the default `instantiateCore`. Defaults to + * `globalThis.WebAssembly`. + */ + WebAssembly?: typeof WebAssembly; + }; + }; } + /** * Instantiates this component with the provided imports and * returns a map of all the exports of the component. diff --git a/examples/components/node-sveltekit-server/package.json b/examples/components/node-sveltekit-server/package.json index 3c6c9c20a..7a0fec49b 100644 --- a/examples/components/node-sveltekit-server/package.json +++ b/examples/components/node-sveltekit-server/package.json @@ -27,7 +27,7 @@ "@bytecodealliance/preview2-shim": "0.27.1" }, "devDependencies": { - "@bytecodealliance/jco": "workspace:*", + "@bytecodealliance/jco": "1.39.0", "@sveltejs/adapter-node": "6.0.0", "@sveltejs/kit": "3.1.0", "@types/node": "^24.3.0", diff --git a/examples/components/p3-stream-echo-qjs/package.json b/examples/components/p3-stream-echo-qjs/package.json index 70be39ffc..6ac7b87a0 100644 --- a/examples/components/p3-stream-echo-qjs/package.json +++ b/examples/components/p3-stream-echo-qjs/package.json @@ -12,6 +12,6 @@ "all": "pnpm run componentize && pnpm run transpile && pnpm run demo && pnpm run test" }, "dependencies": { - "@bytecodealliance/jco": "workspace:*" + "@bytecodealliance/jco": "1.39.0" } } diff --git a/examples/components/perry-filesystem/README.md b/examples/components/perry-filesystem/README.md index 7251bc9a4..c192a1c87 100644 --- a/examples/components/perry-filesystem/README.md +++ b/examples/components/perry-filesystem/README.md @@ -91,6 +91,9 @@ Perry read: hello from the filesystem ### Install dependencies and select the compiler +This example uses the workspace Jco CLI because Jco 1.39.0 +does not include `jco componentize --backend perry`. + From this example directory, install the package dependencies and Perry: ```console diff --git a/packages/jco-cm-runtime/README.md b/packages/jco-cm-runtime/README.md index 7c1077dc2..ad7ed5e9e 100644 --- a/packages/jco-cm-runtime/README.md +++ b/packages/jco-cm-runtime/README.md @@ -1,83 +1,20 @@ # `@bytecodealliance/jco-cm-runtime` -An optional Component Model runtime for JavaScript generated by Jco's -`js-component-bindgen`. - -The JavaScript Component Model runtime API maps closely to the Component Model's -[Canonical ABI](https://github.com/WebAssembly/component-model/blob/main/design/mvp/CanonicalABI.md), -exposing runtime operations through JavaScript objects and methods. Its -resource operations follow the Canonical ABI's handle, ownership, and trapping -semantics. - -Generated modules import a runtime provider, create one runtime instance per -Component Model store, and call reusable operations through its typed -`intrinsics` object. Alternative packages can implement the exported -`ComponentModelRuntimeProvider` interface and be selected with Jco's -`cmRuntimeEsModule` transpilation option. Without that option, Jco emits its -built-in runtime code. Neither `jco` nor `jco-transpile` needs this package -installed to build or transpile components. To use this package, install it in -the generated component's environment and pass its module name as -`cmRuntimeEsModule` (or `--cm-runtime-es-module` with the Jco CLI). +An optional Component Model runtime for JavaScript generated by Jco. Its +operations implement the [Canonical ABI](https://github.com/WebAssembly/component-model/blob/main/design/mvp/CanonicalABI.md). +The experimental JavaScript runtime API is versioned independently of the npm package. +It may change without an API version bump while it stabilizes. ```sh npm install @bytecodealliance/jco-cm-runtime jco transpile component.wasm --cm-runtime-es-module @bytecodealliance/jco-cm-runtime ``` -The module name is written into the generated bindings and resolved when those -bindings are loaded. The runtime package can be released independently of Jco. - -This package is currently experimental. Its JavaScript Component Model runtime -API is versioned independently from the npm package version. - -## JavaScript Component Model runtime API version 2 - -The selected module exports a `runtime` object with `apiVersion: 2` and a -synchronous `create(options)` method. The method returns a runtime instance -with `apiVersion: 2` and `intrinsics.resource.tableGet`. Generated bindings -pass `requestedApiVersion: 2` to `create`, check both API versions, and verify -that the required intrinsic is callable. Other runtime intrinsics continue to -be generated inline. - -Generated bindings pass the selected WebAssembly implementation in -`options.platform.WebAssembly`, including any per-instantiation override. -Providers must use its `RuntimeError` constructor for resource traps so that -the bindings can identify the trap and reject subsequent calls to the trapped -component. When this package's `runtime.create` is called directly without a -platform override, it uses the global WebAssembly implementation. - -Intrinsic methods are bound to their namespace object. A -`resource.tableGet` implementation may use `this` to access per-store state. -Each call to the generated `instantiate` function creates a fresh runtime; -direct ES module output creates its runtime when the module is evaluated. -Generated bindings currently do not invoke the optional `dispose()` hook, so -providers must not rely on it for automatic cleanup. - -### Resource tables - -`resource.tableGet(table, handle)` reads an array-like table without modifying -it and returns `{ rep, scope, own }`. The table layout is Jco's JavaScript -representation of a Canonical ABI resource table and is part of the JavaScript -CM runtime API: - -- Slots 0 and 1 form a sentinel, and resource handles start at 1. Handle indices - are allocated from a space shared by every handle kind of a component instance, - so a table may have holes for indices that belong to other kinds. -- Slot `handle << 1` contains the scope. Bit 30 marks a freed entry. -- Slot `(handle << 1) + 1` contains the representation, an unsigned 32-bit - value. An owned entry stores it plus `2 ** 32`: the entry is owned if it is at - least `2 ** 32`, and `rep` is the entry modulo `2 ** 32`. A freed entry stores - zero. -- A representation of zero is valid for both owned and borrowed entries. Only - handle index zero is reserved. -- A slot past the end of the table, a hole or a freed entry must trap with - `unknown handle index ` using the selected `WebAssembly.RuntimeError` - constructor. +Without the option, intrinsics remain inline. Neither `jco` nor `jco-transpile` +needs a concrete runtime installed to transpile. Resolution happens when the +bindings are loaded or bundled. -Incompatible changes to this table encoding or these method contracts require -a new JavaScript CM runtime API version, independently of the npm package version. +## Documentation -Version 1 marked ownership with bit 30 of the representation, so a -representation with that bit set could not be stored -([#2293](https://github.com/bytecodealliance/jco/issues/2293)). Version 2 -stores ownership above the 32 bits of the representation. +- [Runtime API and provider contracts](docs/runtime.md): module resolution, API versioning and implementing an alternative runtime. +- [Intrinsic contracts](docs/intrinsics/README.md): operations, contexts and state ownership for each public module. diff --git a/packages/jco-cm-runtime/docs/intrinsics/README.md b/packages/jco-cm-runtime/docs/intrinsics/README.md new file mode 100644 index 000000000..ee7a92ae0 --- /dev/null +++ b/packages/jco-cm-runtime/docs/intrinsics/README.md @@ -0,0 +1,6 @@ +# Intrinsic contracts + +Each page describes a public intrinsic module and the capabilities and state its operations use. +See the [runtime API](../runtime.md) for module resolution, versioning and provider conformance. + +- [`intrinsics/resource`](resource.md): resource table. diff --git a/packages/jco-cm-runtime/docs/intrinsics/resource.md b/packages/jco-cm-runtime/docs/intrinsics/resource.md new file mode 100644 index 000000000..2a589ff0e --- /dev/null +++ b/packages/jco-cm-runtime/docs/intrinsics/resource.md @@ -0,0 +1,36 @@ +# Resource table contract + +```js +import { tableGet } from '@bytecodealliance/jco-cm-runtime/intrinsics/resource'; + +const context = { RuntimeError: WebAssembly.RuntimeError }; +const entry = tableGet(context, [1 << 30, 0, 7, 42 + 2 ** 32], 1); +// { rep: 42, scope: 7, own: true } +``` + +Generated bindings create one stable `ResourceTableGetContext` after selecting +their WebAssembly implementation: once per explicit `instantiate` call, or once +per direct module evaluation. It contains only `RuntimeError`. Tables and handles +are operands; tables remain shared with inline resource operations in that store. +The function has no receiver dependency. It must use the supplied constructor for +traps, including when the global WebAssembly implementation is absent or differs. +No codecs, scratch arrays, scheduler or optional JSPI facilities are initialized +by this operation or by importing runtime metadata. + +`tableGet(context, table, handle)` reads an array-like table without changing it +and returns `{ rep, scope, own }`. + +- Slots 0 and 1 form a sentinel; resource handles start at 1. Indices are shared + by all handle kinds of a component instance, so a table may contain holes. +- Slot `handle << 1` holds the scope; bit 30 marks a freed entry. +- Slot `(handle << 1) + 1` holds an unsigned 32-bit representation. Owned entries + store the representation plus `2 ** 32`; subtract that value to obtain `rep`. + A freed entry stores zero. +- A representation of zero is valid for both owned and borrowed entries. Only + handle index zero is reserved. +- A missing slot or freed entry traps with + `unknown handle index ` using `context.RuntimeError`. + +The API version is currently 1. See the [runtime API](../runtime.md) for module contracts. + +[All intrinsic contracts](README.md) ยท [Runtime API](../runtime.md) diff --git a/packages/jco-cm-runtime/docs/runtime.md b/packages/jco-cm-runtime/docs/runtime.md new file mode 100644 index 000000000..12d315cf6 --- /dev/null +++ b/packages/jco-cm-runtime/docs/runtime.md @@ -0,0 +1,60 @@ +# Runtime API and provider contracts + +## API version 1: named modules and specific contexts + +`cmRuntimeEsModule` identifies a **package or directory base**. Generated bindings +statically import `CM_RUNTIME_API_VERSION` from the `runtime` subpath and +`tableGet` from `intrinsics/resource`. Only implemented subpaths are published. +Root exports the same metadata and types as `runtime`; there is no root factory. + +| Base | Resource import | Version import | +| ---------------------------------- | ------------------------------------------------------ | ------------------------------------------ | +| `@bytecodealliance/jco-cm-runtime` | `@bytecodealliance/jco-cm-runtime/intrinsics/resource` | `@bytecodealliance/jco-cm-runtime/runtime` | +| `custom-runtime` | `custom-runtime/intrinsics/resource` | `custom-runtime/runtime` | +| `./runtime/` | `./runtime/intrinsics/resource.js` | `./runtime/runtime.js` | +| `file:///path/dist/` | `file:///path/dist/intrinsics/resource.js` | `file:///path/dist/runtime.js` | +| `https://example.test/runtime/` | `https://example.test/runtime/intrinsics/resource.js` | `https://example.test/runtime/runtime.js` | + +One optional trailing slash is normalized. Relative bases resolve from the +**generated bindings**, not the transpilation process's working directory. +Directory/URL bases use real `.js` files. Use file URLs for Windows paths. +Control characters, empty bases, unsupported URL schemes and query/fragment +suffixes on directory bases are rejected. Bare packages named `something.js` +are packages; relative/URL file bases such as `./provider.js` must migrate to a +directory. Browser package imports need normal import-map or bundler setup; +map each public subpath to its `.js` file. HTTP servers must serve the whole +runtime directory tree with JavaScript MIME types. + +Bindings check version 1 before use. Missing subpaths or named exports fail +through the native ES module loader. The current externalized operation is +`tableGet`; other intrinsics remain inline during migration. + +## Implementing an alternative runtime + +`dist/runtime.d.ts`, published at `@bytecodealliance/jco-cm-runtime/runtime`, is +the explicit, self-contained structural contract. It has no imports from default +implementation modules. A TypeScript provider can check its module namespace: + +```ts +import type { ResourceModule } from '@bytecodealliance/jco-cm-runtime/runtime'; +import * as resource from './intrinsics/resource.js'; + +const conformance: ResourceModule = resource; +``` + +Export `CM_RUNTIME_API_VERSION = 1` from your `runtime.js`. Expose `./runtime` +and `./intrinsics/resource` in your package's `exports` map, with their matching +JavaScript and declaration targets. `RuntimeModuleContracts` is a type-only map +of public suffixes to their interfaces; no assembled runtime object is required. +Function-property signatures support strict parameter checking. Type-only +consumption produces no runtime imports. + +Structural conformance does not prove package resolution, exact JavaScript arity, +or Canonical ABI behavior. TypeScript permits implementations to ignore arguments. +Test the installed package and its behavior, especially traps and shared state. +Providers may re-export selected default functions. Those functions retain their +own statically imported dependencies: overriding a sibling export does not +intercept its calls. There is no mutable namespace receiver or implicit override +registry. Context-free operations, when added, will take no dummy context argument. + +See the [intrinsic contracts](intrinsics/README.md) for individual operations and capabilities. diff --git a/packages/jco-cm-runtime/package.json b/packages/jco-cm-runtime/package.json index 392c9b0d2..d4e48744a 100644 --- a/packages/jco-cm-runtime/package.json +++ b/packages/jco-cm-runtime/package.json @@ -17,7 +17,8 @@ "url": "git+https://github.com/bytecodealliance/jco.git" }, "files": [ - "dist" + "dist", + "docs" ], "type": "module", "sideEffects": false, @@ -25,19 +26,28 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./runtime": { + "types": "./dist/runtime.d.ts", + "default": "./dist/runtime.js" + }, + "./intrinsics/resource": { + "types": "./dist/intrinsics/resource.d.ts", + "default": "./dist/intrinsics/resource.js" } }, "scripts": { - "build": "tsc -p tsconfig.build.json", + "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json", "types:check": "tsc -p tsconfig.json", "fmt": "oxfmt", "fmt:check": "oxfmt --check", "lint": "oxlint", "lint:fix": "oxlint --fix", - "test": "vitest run", + "test": "vitest run -c test/vitest.ts", "prepack": "pnpm run build" }, "devDependencies": { + "@types/node": "^24.13.3", "typescript": "catalog:", "vitest": "catalog:" } diff --git a/packages/jco-cm-runtime/src/index.ts b/packages/jco-cm-runtime/src/index.ts index 753df3025..037e03f92 100644 --- a/packages/jco-cm-runtime/src/index.ts +++ b/packages/jco-cm-runtime/src/index.ts @@ -1,17 +1 @@ -export { runtime } from './runtime.js'; -export { - CM_RUNTIME_API_VERSION, - type AsyncDeterminism, - type ComponentModelRuntime, - type ComponentModelRuntimeProvider, - type ResourceHandle, - type ResourceIntrinsics, - type ResourceRep, - type ResourceScope, - type ResourceTableEntry, - type RuntimeApiVersion, - type RuntimeErrorConstructor, - type RuntimeIntrinsics, - type RuntimeOptions, - type RuntimePlatform, -} from './types.js'; +export * from './runtime.js'; diff --git a/packages/jco-cm-runtime/src/intrinsics/resource.ts b/packages/jco-cm-runtime/src/intrinsics/resource.ts index 518e72faa..83bbfdf93 100644 --- a/packages/jco-cm-runtime/src/intrinsics/resource.ts +++ b/packages/jco-cm-runtime/src/intrinsics/resource.ts @@ -1,24 +1,19 @@ -import type { ResourceIntrinsics, RuntimeErrorConstructor } from '../types.js'; +import type { ResourceModule } from '../runtime.js'; // Bit 30 of a scope marks a freed entry. An owned entry stores its u32 rep plus 2 ** 32. const RESOURCE_TABLE_FLAG = 1 << 30; const RESOURCE_TABLE_OWN = 2 ** 32; -export function createResourceIntrinsics(RuntimeError: RuntimeErrorConstructor): ResourceIntrinsics { - return { - tableGet(table, handle) { - const scope = table[handle << 1]; - const value = table[(handle << 1) + 1]; - const own = value >= RESOURCE_TABLE_OWN; - const rep = own ? value - RESOURCE_TABLE_OWN : value; - // A freed or never allocated index has no entry in this table: its index may - // belong to another handle kind of the instance, and `value` is undefined - // past the end of the table. - // Zero reps are valid; freed entries are identified by the scope flag. - if (value === undefined || (scope & RESOURCE_TABLE_FLAG) !== 0) { - throw new RuntimeError(`unknown handle index ${handle}`); - } - return { rep, scope, own }; - }, - }; -} +export const tableGet: ResourceModule['tableGet'] = (context, table, handle) => { + const scope = table[handle << 1]; + const value = table[(handle << 1) + 1]; + const own = value >= RESOURCE_TABLE_OWN; + const rep = own ? value - RESOURCE_TABLE_OWN : value; + // A freed or never allocated index has no entry in this table: its index may + // belong to another handle kind of the instance, and `value` is undefined + // past the end of the table. + if (value === undefined || (scope & RESOURCE_TABLE_FLAG) !== 0) { + throw new context.RuntimeError(`unknown handle index ${handle}`); + } + return { rep, scope, own }; +}; diff --git a/packages/jco-cm-runtime/src/runtime.ts b/packages/jco-cm-runtime/src/runtime.ts index 908d355db..82e642e74 100644 --- a/packages/jco-cm-runtime/src/runtime.ts +++ b/packages/jco-cm-runtime/src/runtime.ts @@ -1,37 +1,40 @@ -import { createResourceIntrinsics } from './intrinsics/resource.js'; -import { - CM_RUNTIME_API_VERSION, - type ComponentModelRuntime, - type ComponentModelRuntimeProvider, - type RuntimeOptions, - type RuntimePlatform, -} from './types.js'; +/** Experimental JavaScript Component Model runtime contract, independent of package version. */ +export const CM_RUNTIME_API_VERSION = 1 as const; -function createRuntime(options: RuntimeOptions): ComponentModelRuntime { - if (options.requestedApiVersion !== CM_RUNTIME_API_VERSION) { - throw new Error( - `incompatible Jco JavaScript Component Model runtime API: requested ${options.requestedApiVersion}, ` + - `supported ${CM_RUNTIME_API_VERSION}`, - ); - } +export type RuntimeApiVersion = number; +export type ResourceHandle = number; +export type ResourceRep = number; +export type ResourceScope = number; - const platform: RuntimePlatform = { - WebAssembly: options.platform?.WebAssembly ?? globalThis.WebAssembly, - }; +export interface RuntimeErrorConstructor { + new (message?: string): Error; + readonly prototype: Error; +} + +/** Trap capability from the WebAssembly implementation selected by the bindings. */ +export interface TrapContext { + readonly RuntimeError: RuntimeErrorConstructor; +} - if (typeof platform.WebAssembly?.RuntimeError !== 'function') { - throw new TypeError('Jco Component Model runtime requires WebAssembly.RuntimeError'); - } +/** Stable per-instantiation capability; the table and handle remain call operands. */ +export type ResourceTableGetContext = TrapContext; - return { - apiVersion: CM_RUNTIME_API_VERSION, - intrinsics: { - resource: createResourceIntrinsics(platform.WebAssembly.RuntimeError), - }, - }; +export interface ResourceTableEntry { + readonly rep: ResourceRep; + readonly scope: ResourceScope; + readonly own: boolean; } -export const runtime = { - apiVersion: CM_RUNTIME_API_VERSION, - create: createRuntime, -} satisfies ComponentModelRuntimeProvider; +/** Exports required at the public intrinsics/resource subpath. */ +export interface ResourceModule { + readonly tableGet: ( + context: ResourceTableGetContext, + table: ArrayLike, + handle: ResourceHandle, + ) => ResourceTableEntry; +} + +/** Type-only conformance map. No runtime registry or factory is required. */ +export interface RuntimeModuleContracts { + readonly 'intrinsics/resource': ResourceModule; +} diff --git a/packages/jco-cm-runtime/src/types.ts b/packages/jco-cm-runtime/src/types.ts deleted file mode 100644 index 3ad05ea4f..000000000 --- a/packages/jco-cm-runtime/src/types.ts +++ /dev/null @@ -1,53 +0,0 @@ -export const CM_RUNTIME_API_VERSION = 2 as const; - -export type RuntimeApiVersion = number; -export type AsyncDeterminism = 'random' | 'deterministic'; - -export type ResourceHandle = number; -export type ResourceRep = number; -export type ResourceScope = number; - -export interface RuntimeErrorConstructor { - new (message?: string): Error; - readonly prototype: Error; -} - -export interface RuntimePlatform { - readonly WebAssembly: { - readonly RuntimeError: RuntimeErrorConstructor; - }; -} - -export interface RuntimeOptions { - readonly requestedApiVersion: RuntimeApiVersion; - readonly strict: boolean; - readonly flagsAsBigInt: boolean; - readonly nodejsCompat: boolean; - readonly asyncDeterminism: AsyncDeterminism; - readonly platform?: Partial; -} - -export interface ResourceTableEntry { - readonly rep: ResourceRep; - readonly scope: ResourceScope; - readonly own: boolean; -} - -export interface ResourceIntrinsics { - tableGet(table: ArrayLike, handle: ResourceHandle): ResourceTableEntry; -} - -export interface RuntimeIntrinsics { - readonly resource: ResourceIntrinsics; -} - -export interface ComponentModelRuntime { - readonly apiVersion: RuntimeApiVersion; - readonly intrinsics: RuntimeIntrinsics; - dispose?(): void; -} - -export interface ComponentModelRuntimeProvider { - readonly apiVersion: RuntimeApiVersion; - create(options: RuntimeOptions): ComponentModelRuntime; -} diff --git a/packages/jco-cm-runtime/test/fixtures/package-consumer/conformance.mjs b/packages/jco-cm-runtime/test/fixtures/package-consumer/conformance.mjs new file mode 100644 index 000000000..0dc8b409b --- /dev/null +++ b/packages/jco-cm-runtime/test/fixtures/package-consumer/conformance.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; + +import { runRuntimeConformance } from './smoke.mjs'; +import { DEFAULT_GROUPS } from './cm-runtime-conformance/index.mjs'; + +for (const base of [ + 'test-runtime-alias', + './node_modules/test-runtime-alias/dist', + new URL('./node_modules/test-runtime-alias/dist/', import.meta.url), +]) { + const result = await runRuntimeConformance({ base }); + assert.deepEqual(result.groups, DEFAULT_GROUPS); +} + +const resource = await runRuntimeConformance({ base: 'test-runtime-alias', groups: ['resource', 'resource'] }); +assert.deepEqual(resource.groups, ['resource']); +await assert.rejects( + runRuntimeConformance({ base: 'not-installed', groups: ['unknown'] }), + /unknown runtime conformance group: unknown/, +); +await assert.rejects(runRuntimeConformance({ groups: [] }), /at least one conformance group/); +await assert.rejects( + runRuntimeConformance({ base: new URL('./invalid-version/', import.meta.url) }), + /runtime API version/, +); +await assert.rejects( + runRuntimeConformance({ base: new URL('./invalid-resource/', import.meta.url), groups: ['resource'] }), + /conformance failed.*\(resource\).*tableGet owned entry/, +); diff --git a/packages/jco-cm-runtime/test/fixtures/package-consumer/consumer.ts b/packages/jco-cm-runtime/test/fixtures/package-consumer/consumer.ts new file mode 100644 index 000000000..78b5b6790 --- /dev/null +++ b/packages/jco-cm-runtime/test/fixtures/package-consumer/consumer.ts @@ -0,0 +1,16 @@ +import { CM_RUNTIME_API_VERSION as rootVersion } from '@bytecodealliance/jco-cm-runtime'; +import { CM_RUNTIME_API_VERSION } from '@bytecodealliance/jco-cm-runtime/runtime'; +import * as resource from '@bytecodealliance/jco-cm-runtime/intrinsics/resource'; +import * as custom from './provider.js'; +import type { ResourceModule, RuntimeModuleContracts } from '@bytecodealliance/jco-cm-runtime/runtime'; +const defaultModule: ResourceModule = resource; +const customModule: RuntimeModuleContracts['intrinsics/resource'] = custom; +const version: 1 = rootVersion; +const entry = defaultModule.tableGet({ RuntimeError: WebAssembly.RuntimeError }, [1 << 30, 0, 7, 42], 1); +if ( + version !== CM_RUNTIME_API_VERSION || + entry.rep !== 42 || + customModule.tableGet({ RuntimeError: Error }, [1 << 30, 0, 7, 24], 1).rep !== 24 +) { + throw new Error('consumer failed'); +} diff --git a/packages/jco-cm-runtime/test/fixtures/package-consumer/contracts.ts b/packages/jco-cm-runtime/test/fixtures/package-consumer/contracts.ts new file mode 100644 index 000000000..6f40524a9 --- /dev/null +++ b/packages/jco-cm-runtime/test/fixtures/package-consumer/contracts.ts @@ -0,0 +1,34 @@ +// Compile-only fixtures: function properties must reject narrower capabilities and operands. +import * as resource from '@bytecodealliance/jco-cm-runtime/intrinsics/resource'; +import type { + ResourceModule, + ResourceTableGetContext, + RuntimeModuleContracts, +} from '@bytecodealliance/jco-cm-runtime/runtime'; + +export const defaultModule: ResourceModule = resource; +export const contractMapEntry: RuntimeModuleContracts['intrinsics/resource'] = resource; +// @ts-expect-error required export is missing +export const missing: ResourceModule = {}; +export const wrongContext: ResourceModule = { + // @ts-expect-error incompatible context capability + tableGet: (_context: { RuntimeError: string }) => ({ rep: 1, scope: 0, own: true }), +}; +export const privateState: ResourceModule = { + // @ts-expect-error narrower context requires private state unavailable in the public contract + tableGet: (_context: ResourceTableGetContext & { privateState: number }) => ({ rep: 1, scope: 0, own: true }), +}; +export const wrongTable: ResourceModule = { + // @ts-expect-error incompatible table operand + tableGet: (_context: ResourceTableGetContext, _table: string) => ({ rep: 1, scope: 0, own: true }), +}; +export const wrongHandle: ResourceModule = { + // @ts-expect-error incompatible handle operand + tableGet: (_context: ResourceTableGetContext, _table: ArrayLike, _handle: string) => ({ + rep: 1, + scope: 0, + own: true, + }), +}; +// @ts-expect-error incompatible result +export const wrongResult: ResourceModule = { tableGet: () => ({ rep: '1', scope: 0, own: true }) }; diff --git a/packages/jco-cm-runtime/test/fixtures/package-consumer/invalid-resource/intrinsics/resource.js b/packages/jco-cm-runtime/test/fixtures/package-consumer/invalid-resource/intrinsics/resource.js new file mode 100644 index 000000000..ada9632b2 --- /dev/null +++ b/packages/jco-cm-runtime/test/fixtures/package-consumer/invalid-resource/intrinsics/resource.js @@ -0,0 +1,3 @@ +export function tableGet() { + return { rep: 0, scope: 0, own: false }; +} diff --git a/packages/jco-cm-runtime/test/fixtures/package-consumer/invalid-resource/runtime.js b/packages/jco-cm-runtime/test/fixtures/package-consumer/invalid-resource/runtime.js new file mode 100644 index 000000000..3f95f07cb --- /dev/null +++ b/packages/jco-cm-runtime/test/fixtures/package-consumer/invalid-resource/runtime.js @@ -0,0 +1 @@ +export const CM_RUNTIME_API_VERSION = 1; diff --git a/packages/jco-cm-runtime/test/fixtures/package-consumer/invalid-version/runtime.js b/packages/jco-cm-runtime/test/fixtures/package-consumer/invalid-version/runtime.js new file mode 100644 index 000000000..bed7e092a --- /dev/null +++ b/packages/jco-cm-runtime/test/fixtures/package-consumer/invalid-version/runtime.js @@ -0,0 +1 @@ +export const CM_RUNTIME_API_VERSION = 2; diff --git a/packages/jco-cm-runtime/test/fixtures/package-consumer/minimal.mjs b/packages/jco-cm-runtime/test/fixtures/package-consumer/minimal.mjs new file mode 100644 index 000000000..e9973a5e8 --- /dev/null +++ b/packages/jco-cm-runtime/test/fixtures/package-consumer/minimal.mjs @@ -0,0 +1,22 @@ +for (const key of [ + 'WebAssembly', + 'TextEncoder', + 'TextDecoder', + 'DataView', + 'Int32Array', + 'Float32Array', + 'BigInt64Array', + 'Float64Array', +]) { + Object.defineProperty(globalThis, key, { + get() { + throw new Error('unexpected capability: ' + key); + }, + }); +} +await import('@bytecodealliance/jco-cm-runtime/runtime'); +const { tableGet } = await import('@bytecodealliance/jco-cm-runtime/intrinsics/resource'); +const entry = tableGet({ RuntimeError: Error }, [1 << 30, 0, 7, 42], 1); +if (entry.rep !== 42) { + throw new Error('bad entry'); +} diff --git a/packages/jco-cm-runtime/test/fixtures/package-consumer/package.json b/packages/jco-cm-runtime/test/fixtures/package-consumer/package.json new file mode 100644 index 000000000..44e4a84a6 --- /dev/null +++ b/packages/jco-cm-runtime/test/fixtures/package-consumer/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/packages/jco-cm-runtime/test/fixtures/package-consumer/provider.ts b/packages/jco-cm-runtime/test/fixtures/package-consumer/provider.ts new file mode 100644 index 000000000..72236d7aa --- /dev/null +++ b/packages/jco-cm-runtime/test/fixtures/package-consumer/provider.ts @@ -0,0 +1,11 @@ +import type { ResourceModule } from './contract.js'; +export const tableGet: ResourceModule['tableGet'] = (context, table, handle) => { + const scope = table[2 * handle]; + const value = table[2 * handle + 1]; + const own = value >= 2 ** 32; + const rep = own ? value - 2 ** 32 : value; + if (value === undefined || scope & (1 << 30)) { + throw new context.RuntimeError('invalid handle'); + } + return { rep, scope, own }; +}; diff --git a/packages/jco-cm-runtime/test/fixtures/package-consumer/tsconfig.base.json b/packages/jco-cm-runtime/test/fixtures/package-consumer/tsconfig.base.json new file mode 100644 index 000000000..025e28462 --- /dev/null +++ b/packages/jco-cm-runtime/test/fixtures/package-consumer/tsconfig.base.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "strict": true, + "skipLibCheck": false, + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "types": [], + "verbatimModuleSyntax": true + }, + "include": ["*.ts"] +} diff --git a/packages/jco-cm-runtime/test/fixtures/package-consumer/tsconfig.bundler.json b/packages/jco-cm-runtime/test/fixtures/package-consumer/tsconfig.bundler.json new file mode 100644 index 000000000..d12e81465 --- /dev/null +++ b/packages/jco-cm-runtime/test/fixtures/package-consumer/tsconfig.bundler.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "out-Bundler" + } +} diff --git a/packages/jco-cm-runtime/test/fixtures/package-consumer/tsconfig.nodenext.json b/packages/jco-cm-runtime/test/fixtures/package-consumer/tsconfig.nodenext.json new file mode 100644 index 000000000..bbf5775a7 --- /dev/null +++ b/packages/jco-cm-runtime/test/fixtures/package-consumer/tsconfig.nodenext.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "out-NodeNext" + } +} diff --git a/packages/jco-cm-runtime/test/fixtures/package-consumer/types-only.ts b/packages/jco-cm-runtime/test/fixtures/package-consumer/types-only.ts new file mode 100644 index 000000000..34d065588 --- /dev/null +++ b/packages/jco-cm-runtime/test/fixtures/package-consumer/types-only.ts @@ -0,0 +1,6 @@ +import type { + ResourceModule, + ResourceTableGetContext, + RuntimeModuleContracts, +} from '@bytecodealliance/jco-cm-runtime/runtime'; +export type { ResourceModule, ResourceTableGetContext, RuntimeModuleContracts }; diff --git a/packages/jco-cm-runtime/test/helpers.ts b/packages/jco-cm-runtime/test/helpers.ts new file mode 100644 index 000000000..691fbe755 --- /dev/null +++ b/packages/jco-cm-runtime/test/helpers.ts @@ -0,0 +1,19 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execAsync = promisify(execFile); + +export async function exec(command: string, args: string[], options: { cwd: string }) { + try { + return await execAsync(command, args, options); + } catch (error) { + const failure = error as Error & { stdout?: string; stderr?: string }; + throw new Error(failure.message + '\n' + (failure.stdout ?? '') + (failure.stderr ?? ''), { cause: error }); + } +} + +// Use pnpm's executable/CLI path rather than Windows .cmd shell quoting. +const pnpm = process.env.npm_execpath?.includes('pnpm') ? process.env.npm_execpath : 'pnpm'; + +export const runPnpm = (args: string[], cwd: string) => + /\.[cm]?js$/.test(pnpm) ? exec(process.execPath, [pnpm, ...args], { cwd }) : exec(pnpm, args, { cwd }); diff --git a/packages/jco-cm-runtime/test/package.ts b/packages/jco-cm-runtime/test/package.ts new file mode 100644 index 000000000..66d656db9 --- /dev/null +++ b/packages/jco-cm-runtime/test/package.ts @@ -0,0 +1,96 @@ +import { cp, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { expect, suite, test } from 'vitest'; + +import { exec, runPnpm } from './helpers.js'; + +const packageRoot = fileURLToPath(new URL('../', import.meta.url)); +const consumerFixture = new URL('./fixtures/package-consumer/', import.meta.url); +const tsc = join(dirname(createRequire(import.meta.url).resolve('typescript/package.json')), 'bin/tsc'); + +suite('runtime package', () => { + // Build once before exercising consumers so artifact replacement cannot race imports. + test.concurrent('packed exports, standalone declarations, and independent provider conformance', async () => { + const consumer = await mkdtemp(join(tmpdir(), 'jco-runtime-consumer-')); + try { + await cp(consumerFixture, consumer, { recursive: true }); + await runPnpm( + ['--config.verify-deps-before-run=false', 'pack', '--pack-destination', consumer], + packageRoot, + ); + const tarball = (await readdir(consumer)).find((name) => name.endsWith('.tgz'))!; + await runPnpm( + [ + '--ignore-workspace', + 'install', + '--offline', + '--ignore-scripts', + '--lockfile=false', + join(consumer, tarball), + ], + consumer, + ); + await cp( + new URL('../../../scripts/test-jco-cm-runtime-release.mjs', import.meta.url), + join(consumer, 'smoke.mjs'), + ); + await cp( + new URL('../../../scripts/cm-runtime-conformance/', import.meta.url), + join(consumer, 'cm-runtime-conformance'), + { recursive: true }, + ); + await exec(process.execPath, ['smoke.mjs'], { cwd: consumer }); + + const installed = join(consumer, 'node_modules/@bytecodealliance/jco-cm-runtime'); + await cp(installed, join(consumer, 'node_modules/test-runtime-alias'), { + recursive: true, + dereference: true, + }); + await exec(process.execPath, ['conformance.mjs'], { cwd: consumer }); + await exec(process.execPath, ['smoke.mjs', '--runtime-base', 'test-runtime-alias', '--group', 'resource'], { + cwd: consumer, + }); + await exec( + process.execPath, + [ + 'smoke.mjs', + '--runtime-base', + './node_modules/test-runtime-alias/dist', + '--group', + 'runtime', + '--group', + 'resource', + ], + { cwd: consumer }, + ); + expect((await exec(process.execPath, ['smoke.mjs', '--help'], { cwd: consumer })).stdout).toContain( + 'Available groups:', + ); + const contract = await readFile(join(installed, 'dist/runtime.d.ts'), 'utf8'); + expect(contract).not.toMatch(/\b(?:import|from)\s*['"({]/); + const manifest = JSON.parse(await readFile(join(installed, 'package.json'), 'utf8')); + expect(Object.keys(manifest.exports).sort()).toEqual(['.', './intrinsics/resource', './runtime']); + expect(manifest.sideEffects).toBe(false); + const rust = await readFile( + new URL('../../../crates/js-component-bindgen/src/intrinsics/runtime.rs', import.meta.url), + 'utf8', + ); + expect(rust).toContain('CM_RUNTIME_API_VERSION: u32 = 1;'); + + // The independent provider needs only the published declaration, without private files. + await writeFile(join(consumer, 'contract.d.ts'), contract); + for (const mode of ['NodeNext', 'Bundler']) { + await exec(process.execPath, [tsc, '-p', `tsconfig.${mode.toLowerCase()}.json`], { cwd: consumer }); + expect((await readFile(join(consumer, `out-${mode}/types-only.js`), 'utf8')).trim()).toBe('export {};'); + await exec(process.execPath, [`out-${mode}/consumer.js`], { cwd: consumer }); + } + await exec(process.execPath, ['minimal.mjs'], { cwd: consumer }); + } finally { + await rm(consumer, { recursive: true, force: true }); + } + }, 60_000); +}); diff --git a/packages/jco-cm-runtime/test/resource.test.ts b/packages/jco-cm-runtime/test/resource.test.ts deleted file mode 100644 index d5af21ab0..000000000 --- a/packages/jco-cm-runtime/test/resource.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { CM_RUNTIME_API_VERSION, runtime, type ComponentModelRuntimeProvider } from '../src/index.js'; - -const TABLE_FLAG = 1 << 30; -const TABLE_OWN = 2 ** 32; - -function createRuntime() { - return runtime.create({ - requestedApiVersion: CM_RUNTIME_API_VERSION, - strict: false, - flagsAsBigInt: false, - nodejsCompat: true, - asyncDeterminism: 'random', - }); -} - -describe('resource.tableGet', () => { - test('reads owned and borrowed resource entries', () => { - const { tableGet } = createRuntime().intrinsics.resource; - const table = [TABLE_FLAG, 0, 7, 42 + TABLE_OWN, 11, 24]; - - expect(tableGet(table, 1)).toEqual({ rep: 42, scope: 7, own: true }); - expect(tableGet(table, 2)).toEqual({ rep: 24, scope: 11, own: false }); - }); - - // see: https://github.com/bytecodealliance/jco/issues/2293 - test.each([0x40000000, 0x7ffffff0, 0x80000010, 0xc0000010, 0xffffffff])('keeps every bit of rep %i', (rep) => { - const { tableGet } = createRuntime().intrinsics.resource; - const table = [TABLE_FLAG, 0, 0, rep + TABLE_OWN, 3, rep]; - - expect(tableGet(table, 1)).toEqual({ rep, scope: 0, own: true }); - expect(tableGet(table, 2)).toEqual({ rep, scope: 3, own: false }); - }); - - test('accepts zero representations for owned and borrowed resources', () => { - const { tableGet } = createRuntime().intrinsics.resource; - const table = [TABLE_FLAG, 0, 0, TABLE_OWN, 3, 0]; - - expect(tableGet(table, 1)).toEqual({ rep: 0, scope: 0, own: true }); - expect(tableGet(table, 2)).toEqual({ rep: 0, scope: 3, own: false }); - }); - - test.each([ - { name: 'the sentinel at index 0', table: [TABLE_FLAG, 0], handle: 0 }, - { name: 'a freed entry', table: [TABLE_FLAG, 0, TABLE_FLAG, 0], handle: 1 }, - { - name: 'a hole belonging to another handle kind', - table: Object.assign([TABLE_FLAG, 0], { 4: 0, 5: TABLE_OWN }), - handle: 1, - }, - { name: 'an index past the end of the table', table: [TABLE_FLAG, 0], handle: 4 }, - ])('traps for $name', ({ table, handle }) => { - const { tableGet } = createRuntime().intrinsics.resource; - - expect(() => tableGet(table, handle)).toThrowError(WebAssembly.RuntimeError); - expect(() => tableGet(table, handle)).toThrow(`unknown handle index ${handle}`); - }); - - test('uses the injected RuntimeError realm', () => { - class CustomRuntimeError extends Error {} - const instance = runtime.create({ - requestedApiVersion: CM_RUNTIME_API_VERSION, - strict: false, - flagsAsBigInt: false, - nodejsCompat: true, - asyncDeterminism: 'random', - platform: { WebAssembly: { RuntimeError: CustomRuntimeError } }, - }); - - expect(() => instance.intrinsics.resource.tableGet([TABLE_FLAG, 0], 0)).toThrowError(CustomRuntimeError); - }); -}); - -describe('runtime provider', () => { - test('rejects an incompatible requested API', () => { - expect(() => - runtime.create({ - requestedApiVersion: 1, - strict: false, - flagsAsBigInt: false, - nodejsCompat: true, - asyncDeterminism: 'random', - }), - ).toThrow('requested 1, supported 2'); - }); - - test('creates isolated runtime objects', () => { - const first = createRuntime(); - const second = createRuntime(); - - expect(first).not.toBe(second); - expect(first.intrinsics).not.toBe(second.intrinsics); - expect(first.intrinsics.resource).not.toBe(second.intrinsics.resource); - }); - - test('supports structurally typed alternative providers', () => { - const alternative = { - apiVersion: CM_RUNTIME_API_VERSION, - create: (options) => runtime.create(options), - } satisfies ComponentModelRuntimeProvider; - - expect(alternative.create).toBeTypeOf('function'); - }); -}); diff --git a/packages/jco-cm-runtime/test/resource.ts b/packages/jco-cm-runtime/test/resource.ts new file mode 100644 index 000000000..fa9312e91 --- /dev/null +++ b/packages/jco-cm-runtime/test/resource.ts @@ -0,0 +1,58 @@ +import { expect, suite, test } from 'vitest'; + +import * as resource from '../src/intrinsics/resource.js'; +import { CM_RUNTIME_API_VERSION, type ResourceModule } from '../src/runtime.js'; + +const TABLE_FLAG = 1 << 30; +const TABLE_OWN = 2 ** 32; +const context = { RuntimeError: WebAssembly.RuntimeError }; +const { tableGet } = resource satisfies ResourceModule; + +suite('resource intrinsics', () => { + test.concurrent('reads owned and borrowed entries without changing the table', () => { + const table = Object.freeze([TABLE_FLAG, 0, 7, 42 + TABLE_OWN, 11, 24]); + expect(tableGet(context, table, 1)).toEqual({ rep: 42, scope: 7, own: true }); + expect(tableGet(context, table, 2)).toEqual({ rep: 24, scope: 11, own: false }); + expect(CM_RUNTIME_API_VERSION).toBe(1); + }); + + test.concurrent.each([ + { name: 'sentinel', table: [TABLE_FLAG, 0], handle: 0 }, + { name: 'freed entry', table: [TABLE_FLAG, 0, TABLE_FLAG, 42], handle: 1 }, + { name: 'missing index', table: [TABLE_FLAG, 0], handle: 4 }, + { name: 'hole for another handle kind', table: [TABLE_FLAG, 0, , , 7, 42], handle: 1 }, + ])('traps for $name in the selected realm', ({ table, handle }) => { + class CustomRuntimeError extends Error {} + // Sparse arrays have undefined holes at runtime, just like out-of-bounds reads. + const sparseTable = table as number[]; + expect(() => tableGet({ RuntimeError: CustomRuntimeError }, sparseTable, handle)).toThrowError( + CustomRuntimeError, + ); + expect(() => tableGet(context, sparseTable, handle)).toThrow(`unknown handle index ${handle}`); + }); + + test.concurrent('uses only the explicit context, including reentrant table reads', () => { + class OuterTrap extends Error {} + class InnerTrap extends Error {} + const outer = { RuntimeError: OuterTrap }; + const inner = { RuntimeError: InnerTrap }; + const table = new Proxy([TABLE_FLAG, 0, TABLE_FLAG, 0], { + get(target, key, receiver) { + if (key === '2') { + expect(() => tableGet(inner, [TABLE_FLAG, 0], 0)).toThrowError(InnerTrap); + } + return Reflect.get(target, key, receiver); + }, + }); + expect(() => tableGet.call(null, outer, table, 1)).toThrowError(OuterTrap); + }); + // Preserve the full-u32 and zero-representation regressions from the inline runtime. + test.concurrent.each([0, 0x40000000, 0x7ffffff0, 0x80000010, 0xc0000010, 0xffffffff])( + 'keeps every bit of owned and borrowed rep %i', + (rep) => { + const table = Object.freeze([TABLE_FLAG, 0, 0, rep + TABLE_OWN, 3, rep]); + expect(tableGet(context, table, 1)).toEqual({ rep, scope: 0, own: true }); + expect(tableGet(context, table, 2)).toEqual({ rep, scope: 3, own: false }); + }, + ); +}); diff --git a/packages/jco-cm-runtime/test/vitest.ts b/packages/jco-cm-runtime/test/vitest.ts new file mode 100644 index 000000000..69d1682c6 --- /dev/null +++ b/packages/jco-cm-runtime/test/vitest.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.ts'], + exclude: ['test/fixtures/**', 'test/**/*helpers.ts', 'test/vitest.ts'], + }, +}); diff --git a/packages/jco-cm-runtime/tsconfig.build.json b/packages/jco-cm-runtime/tsconfig.build.json index 303aaa51e..3f4b9de0b 100644 --- a/packages/jco-cm-runtime/tsconfig.build.json +++ b/packages/jco-cm-runtime/tsconfig.build.json @@ -5,6 +5,7 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "noEmit": false + "noEmit": false, + "types": [] } } diff --git a/packages/jco-cm-runtime/tsconfig.json b/packages/jco-cm-runtime/tsconfig.json index 13b0833d7..44eba027d 100644 --- a/packages/jco-cm-runtime/tsconfig.json +++ b/packages/jco-cm-runtime/tsconfig.json @@ -10,6 +10,8 @@ "declaration": true, "declarationMap": true, "sourceMap": true, - "skipLibCheck": true - } + "skipLibCheck": true, + "types": ["node"] + }, + "exclude": ["test/fixtures/**"] } diff --git a/packages/jco-transpile/src/transpile.ts b/packages/jco-transpile/src/transpile.ts index 7c07f6ab8..1597a3fc2 100644 --- a/packages/jco-transpile/src/transpile.ts +++ b/packages/jco-transpile/src/transpile.ts @@ -161,7 +161,7 @@ export interface TranspilationOptions { */ perfStringsSkipCopyUtf16Validation?: boolean; - /** Optional ES module specifier imported by generated bindings for the Component Model runtime */ + /** Optional experimental runtime package or directory base. Imports runtime and intrinsics/* subpaths; directory/URL bases use .js files. */ cmRuntimeEsModule?: string; /** Represent WIT flags as bigint values instead of objects of booleans */ diff --git a/packages/jco-transpile/test/external-runtime/browser/cases.js b/packages/jco-transpile/test/external-runtime/browser/cases.js index 5c0f1eccc..f1309d4d8 100644 --- a/packages/jco-transpile/test/external-runtime/browser/cases.js +++ b/packages/jco-transpile/test/external-runtime/browser/cases.js @@ -3,7 +3,6 @@ async function loadProvider(path) { return undefined; } const provider = await import(path); - provider.resetRuntimeCreateCallCount(); provider.resetTableGetCallCount(); return provider; } @@ -25,7 +24,7 @@ function exerciseInstances(instance, sibling, provider) { invalidHandle: captureTrap(() => instance.invalid(0)), afterTrap: captureTrap(() => instance.run(42)), siblingValue: sibling ? sibling.run(7) : null, - runtimeCreates: provider?.runtimeCreateCallCount ?? null, + contexts: provider?.contexts.size ?? null, tableGets, }; } diff --git a/packages/jco-transpile/test/external-runtime/browser/helpers.ts b/packages/jco-transpile/test/external-runtime/browser/helpers.ts new file mode 100644 index 000000000..1fe9f1ad1 --- /dev/null +++ b/packages/jco-transpile/test/external-runtime/browser/helpers.ts @@ -0,0 +1,28 @@ +import { rm } from 'node:fs/promises'; +import { env } from 'node:process'; +import { pathToFileURL } from 'node:url'; + +import puppeteer, { type Browser } from 'puppeteer'; + +import { getTmpDir, startTestWebServer } from '../../helpers.js'; + +export async function withBrowser(run: (browser: Browser, serverPort: number, outDir: string) => Promise) { + const outDir = await getTmpDir(); + let browser: Browser | undefined; + let closeServer: (() => Promise) | undefined; + try { + const server = await startTestWebServer({ + routes: [ + { urlPrefix: '/tmpdir/', basePathURL: pathToFileURL(`${outDir}/`) }, + { basePathURL: new URL('../../../../', import.meta.url) }, + ], + }); + closeServer = server.cleanup; + browser = await puppeteer.launch({ executablePath: env.PUPPETEER_PATH }); + await run(browser, server.serverPort, outDir); + } finally { + await browser?.close(); + await closeServer?.(); + await rm(outDir, { recursive: true, force: true }); + } +} diff --git a/packages/jco-transpile/test/external-runtime/browser/index.ts b/packages/jco-transpile/test/external-runtime/browser/index.ts index d84425591..353163f73 100644 --- a/packages/jco-transpile/test/external-runtime/browser/index.ts +++ b/packages/jco-transpile/test/external-runtime/browser/index.ts @@ -1,162 +1,107 @@ -import { copyFile, readFile, rm, writeFile } from 'node:fs/promises'; +import { cp, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { env } from 'node:process'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; -import puppeteer, { type Browser } from 'puppeteer'; -import { afterAll, assert, beforeAll, expect, suite, test } from 'vitest'; +import { assert, suite, test } from 'vitest'; import { transpile, writeFiles } from '../../../src/index.js'; import { parse } from '../../../src/wasm-tools.js'; -import { getTmpDir, runBrowserCase, startTestWebServer } from '../../helpers.js'; +import { runBrowserCase } from '../../helpers.js'; +import { withBrowser } from './helpers.js'; const CASE_MODULE = '/jco-transpile/test/external-runtime/browser/cases.js'; -const CUSTOM_PROVIDER = new URL('../../fixtures/custom-runtime-provider.js', import.meta.url); +const CUSTOM_RUNTIME = new URL('../../fixtures/custom-runtime/', import.meta.url); const RESOURCE_COMPONENT = new URL( '../../fixtures/components/runtime/external-runtime-resource.component.wat', import.meta.url, ); -suite('External Component Model runtime in the browser', () => { - let browser: Browser; - let serverPort: number; - let closeServer: (() => Promise) | undefined; - let tmpDir: string; - - beforeAll(async () => { - tmpDir = await getTmpDir(); - const server = await startTestWebServer({ - routes: [ - { urlPrefix: '/tmpdir/', basePathURL: pathToFileURL(`${tmpDir}/`) }, - { basePathURL: new URL('../../../../', import.meta.url) }, - ], - }); - serverPort = server.serverPort; - closeServer = server.cleanup; - browser = await puppeteer.launch({ - executablePath: env.PUPPETEER_PATH, - args: [ - '--enable-experimental-webassembly-jspi', - '--flag-switches-begin', - '--enable-features=WebAssemblyExperimentalJSPI', - '--flag-switches-end', - ], - }); - }); - - afterAll(async () => { - await browser?.close(); - await closeServer?.(); - if (tmpDir) { - await rm(tmpDir, { recursive: true, force: true }); - } - }); - - for (const provider of ['default', 'custom'] as const) { +suite('external runtime browser', () => { + for (const provider of ['default', 'custom', 'url'] as const) { for (const instantiation of ['sync', 'async', undefined] as const) { - test.each([false, true])( - `runs with the ${provider} provider (${instantiation ?? 'direct'}, minify=%s)`, + test.concurrent.each([false, true])( + `browser loads ${provider} named exports (${instantiation ?? 'direct'}, minify=%s)`, async (minify) => { - const name = `${provider}-${instantiation ?? 'direct'}-${minify}`; - const outDir = join(tmpDir, name); - const { files } = await transpile(fileURLToPath(RESOURCE_COMPONENT), { - name: 'component', - instantiation, - minify, - nodejsCompat: false, - base64Cutoff: 0, - cmRuntimeEsModule: - provider === 'default' ? '@bytecodealliance/jco-cm-runtime' : './provider.js', - }); - await writeFiles(files, { baseDir: outDir }); - if (provider === 'custom') { - await copyFile(CUSTOM_PROVIDER, join(outDir, 'provider.js')); - } - const result = await runBrowserCase({ - browser, - serverPort, - module: CASE_MODULE, - exportName: 'runComponent', - args: [ - { - componentPath: `/tmpdir/${name}/component.js`, - corePaths: Object.keys(files).filter((path) => path.endsWith('.wasm')), - instantiation, - providerPath: provider === 'custom' ? `/tmpdir/${name}/provider.js` : undefined, - }, - ], + await withBrowser(async (browser, serverPort, outDir) => { + const cmRuntimeEsModule = + provider === 'default' + ? '@bytecodealliance/jco-cm-runtime' + : provider === 'custom' + ? './runtime/' + : `http://localhost:${serverPort}/jco-cm-runtime/dist/`; + const { files } = await transpile(fileURLToPath(RESOURCE_COMPONENT), { + name: 'component', + instantiation, + minify, + nodejsCompat: false, + base64Cutoff: 0, + cmRuntimeEsModule, + }); + await writeFiles(files, { baseDir: outDir }); + if (provider === 'custom') { + await cp(CUSTOM_RUNTIME, join(outDir, 'runtime'), { recursive: true }); + } + const result = await runBrowserCase({ + browser, + serverPort, + module: CASE_MODULE, + exportName: 'runComponent', + args: [ + { + componentPath: '/tmpdir/component.js', + corePaths: Object.keys(files).filter((path) => path.endsWith('.wasm')), + instantiation, + providerPath: + provider === 'custom' ? '/tmpdir/runtime/intrinsics/resource.js' : undefined, + }, + ], + }); + assert.deepEqual(result, expectedResult(provider === 'custom', !!instantiation)); }); - assert.deepEqual(result, expectedResult(provider, !!instantiation)); }, ); } + } - test(`transpiles and runs with the ${provider} provider entirely in the browser`, async () => { - const componentPath = `/tmpdir/${provider}.wasm`; - await writeFile(join(tmpDir, `${provider}.wasm`), await parse(await readFile(RESOURCE_COMPONENT, 'utf8'))); - const result = await runBrowserCase({ - browser, - serverPort, - module: CASE_MODULE, - exportName: 'transpileInBrowser', - args: [ - { - componentPath, - cmRuntimeEsModule: - provider === 'default' - ? '@bytecodealliance/jco-cm-runtime' - : '/jco-transpile/test/fixtures/custom-runtime-provider.js', - providerPath: - provider === 'custom' - ? '/jco-transpile/test/fixtures/custom-runtime-provider.js' - : undefined, - }, - ], + for (const provider of ['default', 'custom'] as const) { + test.concurrent(`transpiles and loads ${provider} named exports entirely in the browser`, async () => { + await withBrowser(async (browser, serverPort, outDir) => { + await writeFile( + join(outDir, 'component.wasm'), + await parse(await readFile(RESOURCE_COMPONENT, 'utf8')), + ); + const result = await runBrowserCase({ + browser, + serverPort, + module: CASE_MODULE, + exportName: 'transpileInBrowser', + args: [ + { + componentPath: '/tmpdir/component.wasm', + cmRuntimeEsModule: + provider === 'default' + ? '@bytecodealliance/jco-cm-runtime' + : '/jco-transpile/test/fixtures/custom-runtime/', + providerPath: + provider === 'custom' + ? '/jco-transpile/test/fixtures/custom-runtime/intrinsics/resource.js' + : undefined, + }, + ], + }); + assert.deepEqual(result, expectedResult(provider === 'custom', true)); }); - assert.deepEqual(result, expectedResult(provider, true)); }); } - test.each([ - { - kind: 'provider', - provider: '{ apiVersion: 1, create() { throw new Error("factory must not run"); } }', - error: 'runtime API: requested 2, supported 1', - }, - { - kind: 'instance', - provider: '{ apiVersion: 2, create() { return { apiVersion: 1 }; } }', - error: 'runtime API instance: requested 2, received 1', - }, - ])('rejects an incompatible $kind API version', async ({ kind, provider, error }) => { - const name = `invalid-${kind}`; - const outDir = join(tmpDir, name); - const { files } = await transpile(fileURLToPath(RESOURCE_COMPONENT), { - name: 'component', - nodejsCompat: false, - cmRuntimeEsModule: './provider.js', - }); - await writeFiles(files, { baseDir: outDir }); - await writeFile(join(outDir, 'provider.js'), `export const runtime = ${provider};`); - await expect( - runBrowserCase({ - browser, - serverPort, - module: CASE_MODULE, - exportName: 'runComponent', - args: [{ componentPath: `/tmpdir/${name}/component.js` }], - }), - ).rejects.toThrow(error); - }); + function expectedResult(custom: boolean, explicit: boolean) { + return { + values: [42, 24], + invalidHandle: { runtimeError: true, message: 'unknown handle index 0' }, + afterTrap: { runtimeError: true, message: 'wasm trap: cannot enter component instance' }, + siblingValue: explicit ? 7 : null, + contexts: custom ? (explicit ? 2 : 1) : null, + tableGets: custom ? 2 : null, + }; + } }); - -function expectedResult(provider: 'default' | 'custom', explicitInstantiation: boolean) { - return { - values: [42, 24], - invalidHandle: { runtimeError: true, message: 'unknown handle index 0' }, - afterTrap: { runtimeError: true, message: 'wasm trap: cannot enter component instance' }, - siblingValue: explicitInstantiation ? 7 : null, - runtimeCreates: provider === 'custom' ? (explicitInstantiation ? 2 : 1) : null, - tableGets: provider === 'custom' ? 2 : null, - }; -} diff --git a/packages/jco-transpile/test/external-runtime/codegen.ts b/packages/jco-transpile/test/external-runtime/codegen.ts index 5809ae812..8a0951510 100644 --- a/packages/jco-transpile/test/external-runtime/codegen.ts +++ b/packages/jco-transpile/test/external-runtime/codegen.ts @@ -1,44 +1,36 @@ -import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { cp, readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { URL, fileURLToPath, pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; -import { assert, suite, test } from 'vitest'; +import { assert, expect, suite, test } from 'vitest'; import { transpile, writeFiles } from '../../src/index.js'; - -import { - resetRuntimeCreateCallCount, - resetTableGetCallCount, - runtimeCreateCallCount, - tableGetCallCount, -} from '../fixtures/custom-runtime-provider.js'; import { getTmpDir, nodeExec } from '../helpers.js'; -suite('External Component Model runtime', () => { - const fixture = fileURLToPath(new URL('../fixtures/components/runtime/resources.2.component.wat', import.meta.url)); - const resourceFixture = fileURLToPath( - new URL('../fixtures/components/runtime/external-runtime-resource.component.wat', import.meta.url), - ); - const defaultCmRuntimeEsModule = new URL('../../../jco-cm-runtime/dist/index.js', import.meta.url).href; +const resourceFixture = fileURLToPath( + new URL('../fixtures/components/runtime/external-runtime-resource.component.wat', import.meta.url), +); +const defaultBase = new URL('../../../jco-cm-runtime/dist/', import.meta.url).href; +const customBase = new URL('../fixtures/custom-runtime/', import.meta.url); +suite('external runtime codegen', () => { for (const instantiation of ['sync', 'async'] as const) { - test.each([false, true])( - `uses the selected WebAssembly realm without a platform global (${instantiation}, minify=%s)`, + test.concurrent.each([false, true])( + `uses the selected realm without a platform global (${instantiation}, minify=%s)`, async (minify) => { const outDir = await getTmpDir(); - const name = 'external-runtime-platform'; try { const { files } = await transpile(resourceFixture, { - name, + name: 'component', instantiation, minify, - cmRuntimeEsModule: defaultCmRuntimeEsModule, + cmRuntimeEsModule: defaultBase, }); await writeFiles(files, { baseDir: outDir }); await writeFile(join(outDir, 'package.json'), JSON.stringify({ type: 'module' })); await nodeExec( fileURLToPath(new URL('../fixtures/external-runtime/override-platform.mjs', import.meta.url)), - join(outDir, `${name}.js`), + join(outDir, 'component.js'), ); } finally { await rm(outDir, { recursive: true, force: true }); @@ -48,36 +40,48 @@ suite('External Component Model runtime', () => { } for (const instantiation of ['sync', 'async', undefined] as const) { - test.each([false, true])( - `preserves the intrinsic receiver (${instantiation ?? 'direct'}, minify=%s)`, + test.concurrent.each([false, true])( + `uses narrow stable contexts with independent stores (${instantiation ?? 'direct'}, minify=%s)`, async (minify) => { const outDir = await getTmpDir(); - const name = 'external-runtime-receiver'; - const cmRuntimeEsModule = new URL('../fixtures/custom-runtime-provider.js', import.meta.url).href; - resetTableGetCallCount(); try { + await cp(customBase, join(outDir, 'runtime'), { recursive: true }); + await writeFile(join(outDir, 'package.json'), JSON.stringify({ type: 'module' })); const { files } = await transpile(resourceFixture, { - name, + name: 'component', instantiation, minify, - cmRuntimeEsModule, + cmRuntimeEsModule: './runtime/', }); await writeFiles(files, { baseDir: outDir }); - await writeFile(join(outDir, 'package.json'), JSON.stringify({ type: 'module' })); - const bindings = await import(pathToFileURL(join(outDir, `${name}.js`)).href); - const instance = instantiation - ? await bindings.instantiate( - (moduleName: string) => new WebAssembly.Module(files[moduleName]), - {}, - ) - : bindings; - - assert.strictEqual(instance.run(42), 42); - assert.strictEqual(instance.run(24), 24); - assert.strictEqual(instance.run(0), 0); - assert.strictEqual(tableGetCallCount, 3); - assert.throws(() => instance.invalid(0), WebAssembly.RuntimeError, 'unknown handle index 0'); - assert.throws(() => instance.run(42), WebAssembly.RuntimeError, 'cannot enter component instance'); + const runtime = await import(pathToFileURL(join(outDir, 'runtime/intrinsics/resource.js')).href); + const bindings = await import(pathToFileURL(join(outDir, 'component.js')).href); + const load = (name: string) => new WebAssembly.Module(files[name]); + const first = instantiation ? await bindings.instantiate(load, {}) : bindings; + assert.strictEqual(first.run(42), 42); + assert.strictEqual(first.run(24), 24); + assert.strictEqual(first.run(0), 0); + assert.strictEqual(runtime.tableGetCallCount, 3); + assert.strictEqual(runtime.contexts.size, 1); + const [context] = runtime.contexts; + assert.strictEqual(context.RuntimeError, WebAssembly.RuntimeError); + if (instantiation) { + const [second, third] = await Promise.all([ + bindings.instantiate(load, {}), + bindings.instantiate(load, {}), + ]); + assert.strictEqual(second.run(7), 7); + assert.strictEqual(third.run(8), 8); + assert.strictEqual(first.run(9), 9); + assert.strictEqual(runtime.contexts.size, 3); + const tables = [...runtime.tables.values()].map((value: Set) => [...value][0]); + assert.strictEqual(new Set(tables).size, 3); + assert.throws(() => first.invalid(0), WebAssembly.RuntimeError, 'unknown handle index 0'); + assert.strictEqual(second.run(10), 10); + } else { + assert.throws(() => first.invalid(0), WebAssembly.RuntimeError, 'unknown handle index 0'); + } + assert.throws(() => first.run(42), WebAssembly.RuntimeError, 'cannot enter component instance'); } finally { await rm(outDir, { recursive: true, force: true }); } @@ -85,150 +89,173 @@ suite('External Component Model runtime', () => { ); } - test.each([ - { - name: 'a missing factory', - provider: '{ apiVersion: 2 }', - error: 'runtime provider must define create(options)', - }, - { - name: 'an incompatible provider API before calling its factory', - provider: '{ apiVersion: 1, create() { throw new Error("factory must not run"); } }', - error: 'runtime API: requested 2, supported 1', - }, - { - name: 'an incompatible instance API', - provider: '{ apiVersion: 2, create() { return { apiVersion: 1 }; } }', - error: 'runtime API instance: requested 2, received 1', - }, - { - name: 'a non-function intrinsic', - provider: - '{ apiVersion: 2, create() { return { apiVersion: 2, intrinsics: { resource: { tableGet: 0 } } }; } }', - error: 'runtime intrinsic resource.tableGet must be a function', - }, - { - name: 'a missing resource namespace', - provider: '{ apiVersion: 2, create() { return { apiVersion: 2, intrinsics: {} }; } }', - error: 'runtime intrinsic resource.tableGet must be a function', + test.concurrent.each(['independent', 'reexport'] as const)( + 'loads a %s package installed only beside generated bindings', + async (implementation) => { + const outDir = await getTmpDir(); + const packageDir = join(outDir, 'node_modules/test-runtime-provider'); + try { + await cp(customBase, packageDir, { recursive: true }); + if (implementation === 'reexport') { + await writeFile( + join(packageDir, 'intrinsics/resource.js'), + `export { tableGet } from ${JSON.stringify(defaultBase + 'intrinsics/resource.js')};`, + ); + } + await writeFile(join(outDir, 'package.json'), JSON.stringify({ type: 'module' })); + const { files } = await transpile(resourceFixture, { + name: 'component', + instantiation: 'sync', + cmRuntimeEsModule: 'test-runtime-provider', + }); + await writeFiles(files, { baseDir: outDir }); + const bindings = await import(pathToFileURL(join(outDir, 'component.js')).href); + const instance = bindings.instantiate((name: string) => new WebAssembly.Module(files[name]), {}); + assert.strictEqual(instance.run(42), 42); + assert.throws(() => instance.invalid(0), WebAssembly.RuntimeError, 'unknown handle index 0'); + } finally { + await rm(outDir, { recursive: true, force: true }); + } }, - { - name: 'missing intrinsics', - provider: '{ apiVersion: 2, create() { return { apiVersion: 2 }; } }', - error: 'runtime intrinsic resource.tableGet must be a function', + ); + + test.concurrent.each(['sync', 'async', undefined] as const)( + 'rejects unsupported API metadata (%s)', + async (instantiation) => { + const outDir = await getTmpDir(); + try { + await cp(customBase, join(outDir, 'runtime'), { recursive: true }); + await writeFile(join(outDir, 'runtime/runtime.js'), 'export const CM_RUNTIME_API_VERSION = 2;'); + await writeFile(join(outDir, 'package.json'), JSON.stringify({ type: 'module' })); + const { files } = await transpile(resourceFixture, { + name: 'component', + instantiation, + cmRuntimeEsModule: './runtime', + }); + await writeFiles(files, { baseDir: outDir }); + await expect( + (async () => { + const bindings = await import(pathToFileURL(join(outDir, 'component.js')).href); + if (instantiation) { + await bindings.instantiate((name: string) => new WebAssembly.Module(files[name]), {}); + } + })(), + ).rejects.toThrow('runtime API: requested 1, supported 2'); + } finally { + await rm(outDir, { recursive: true, force: true }); + } }, - ])('rejects $name during instantiation', async ({ provider, error }) => { + ); + + test.concurrent.each(['export', 'subpath'] as const)('native ESM rejects a missing %s', async (missing) => { const outDir = await getTmpDir(); - const name = 'external-runtime-invalid-provider'; + const packageDir = join(outDir, 'node_modules/test-runtime-provider'); try { + await cp(customBase, packageDir, { recursive: true }); + if (missing === 'export') { + await writeFile(join(packageDir, 'intrinsics/resource.js'), 'export {};'); + } else { + await writeFile( + join(packageDir, 'package.json'), + JSON.stringify({ type: 'module', exports: { './runtime': './runtime.js' } }), + ); + } + await writeFile(join(outDir, 'package.json'), JSON.stringify({ type: 'module' })); const { files } = await transpile(resourceFixture, { - name, + name: 'component', instantiation: 'sync', - cmRuntimeEsModule: './provider.js', + cmRuntimeEsModule: 'test-runtime-provider', }); await writeFiles(files, { baseDir: outDir }); - await writeFile(join(outDir, 'package.json'), JSON.stringify({ type: 'module' })); - await writeFile(join(outDir, 'provider.js'), `export const runtime = ${provider};`); - const bindings = await import(pathToFileURL(join(outDir, `${name}.js`)).href); - assert.throws( - () => bindings.instantiate((moduleName: string) => new WebAssembly.Module(files[moduleName]), {}), - error, + await writeFile(join(outDir, 'load.mjs'), "import './component.js';"); + await expect(nodeExec(join(outDir, 'load.mjs'))).rejects.toThrow( + missing === 'export' ? 'tableGet' : 'exports', ); } finally { await rm(outDir, { recursive: true, force: true }); } }); - test('does not depend on the optional runtime package', async () => { + test.concurrent('keeps inline output self-contained and transpilation independent of runtime installation', async () => { const manifest = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8')); assert.notProperty(manifest.dependencies, '@bytecodealliance/jco-cm-runtime'); - }); - - test('keeps canon resource.rep self-contained by default', async () => { - const { files, imports } = await transpile(fixture, { name: 'external-runtime-resource' }); - const source = new TextDecoder().decode(files['external-runtime-resource.js']); - - assert.notInclude(source, '_jcoRuntimeProvider'); + const { files } = await transpile(resourceFixture, { name: 'component' }); + const source = new TextDecoder().decode(files['component.js']); assert.include(source, 'function rscTableGet(table, handle)'); - assert.include(source, 'rscTableGet('); - assert.notInclude(imports, '@bytecodealliance/jco-cm-runtime'); - }); - - test('places custom runtime creation inside each instantiation', async () => { - const cmRuntimeEsModule = './test-runtime-provider.js'; - const { files, imports } = await transpile(fixture, { - name: 'external-runtime-instantiation', + assert.notInclude(source, '_jcoRuntime'); + const external = await transpile(resourceFixture, { + name: 'component', instantiation: 'sync', - cmRuntimeEsModule, + cmRuntimeEsModule: 'not-installed-runtime', }); - const source = new TextDecoder().decode(files['external-runtime-instantiation.js']); - const importPosition = source.indexOf( - 'import { runtime as _jcoRuntimeProvider } from "./test-runtime-provider.js";', + const named = new TextDecoder().decode(external.files['component.js']); + assert.include(named, 'from "not-installed-runtime/intrinsics/resource"'); + assert.include(named, 'from "not-installed-runtime/runtime"'); + assert.notInclude(named, '_jcoRuntimeProvider'); + assert.notInclude(named, '.bind(receiver)'); + assert.lengthOf(named.match(/tableGet as rscTableGet/g) ?? [], 1); + assert.isAbove( + named.indexOf('const _jcoResourceTableGetContext'), + named.indexOf('const WebAssembly = options?'), ); - const instantiatePosition = source.indexOf('export function instantiate('); - const createPosition = source.indexOf('_jcoRuntimeProvider.create('); - - assert.isAtLeast(importPosition, 0); - assert.isAbove(instantiatePosition, importPosition); - assert.isAbove(createPosition, instantiatePosition); - assert.lengthOf(source.match(/_jcoRuntimeProvider\.create\(/g) ?? [], 1); - assert.notInclude(imports, cmRuntimeEsModule); + assert.notInclude(external.imports, 'not-installed-runtime'); }); - test('loads a bare runtime module installed only with the generated component', async () => { + test.concurrent('reserves the named intrinsic alias against component exports', async () => { const outDir = await getTmpDir(); - const name = 'external-runtime-local-package'; - const cmRuntimeEsModule = 'test-runtime-provider'; - const packageDir = join(outDir, 'node_modules', cmRuntimeEsModule); - try { - await mkdir(packageDir, { recursive: true }); - await writeFile( - join(packageDir, 'package.json'), - JSON.stringify({ name: cmRuntimeEsModule, type: 'module' }), + const wat = (await readFile(resourceFixture, 'utf8')).replace( + '(func (export "run") (param "value"', + '(func (export "rsc-table-get") (param "value"', ); - await copyFile( - new URL('../fixtures/custom-runtime-provider.js', import.meta.url), - join(packageDir, 'index.js'), - ); - const provider = await import(pathToFileURL(join(packageDir, 'index.js')).href); - provider.resetRuntimeCreateCallCount(); - - const { files } = await transpile(fixture, { name, instantiation: 'sync', cmRuntimeEsModule }); + const component = join(outDir, 'collision.wat'); + await writeFile(component, wat); + const { files } = await transpile(component, { + name: 'component', + instantiation: 'sync', + cmRuntimeEsModule: defaultBase, + }); await writeFiles(files, { baseDir: outDir }); - const source = new TextDecoder().decode(files[`${name}.js`]); - assert.include(source, `from "${cmRuntimeEsModule}"`); - - const bindings = await import(pathToFileURL(join(outDir, `${name}.js`)).href); - bindings.instantiate((moduleName: string) => new WebAssembly.Module(files[moduleName]), {}); - assert.strictEqual(provider.runtimeCreateCallCount, 1); + const bindings = await import(pathToFileURL(join(outDir, 'component.js')).href); + const instance = bindings.instantiate((name: string) => new WebAssembly.Module(files[name]), {}); + assert.strictEqual(instance.rscTableGet(42), 42); } finally { await rm(outDir, { recursive: true, force: true }); } }); - test('creates a fresh runtime instance for each generated store', async () => { - const outDir = await getTmpDir(); - const name = 'external-runtime-two-stores'; - const cmRuntimeEsModule = new URL('../fixtures/custom-runtime-provider.js', import.meta.url).href; - resetRuntimeCreateCallCount(); - - try { - const { files } = await transpile(fixture, { - name, - instantiation: 'sync', - cmRuntimeEsModule, - }); - await writeFiles(files, { baseDir: outDir }); - const bindings = await import(pathToFileURL(join(outDir, `${name}.js`)).href); - const getCoreModule = (moduleName: string) => new WebAssembly.Module(files[moduleName]); - - bindings.instantiate(getCoreModule, {}); - bindings.instantiate(getCoreModule, {}); + test.concurrent.each(['relative', 'file-url'] as const)( + 'loads a %s directory base with spaces and quotes', + async (kind) => { + const outDir = await getTmpDir(); + const directory = "runtime with 'quotes'"; + try { + await cp(customBase, join(outDir, directory), { recursive: true }); + const cmRuntimeEsModule = + kind === 'relative' ? `./${directory}/` : pathToFileURL(join(outDir, directory)).href; + const { files } = await transpile(resourceFixture, { + name: 'component', + instantiation: 'sync', + cmRuntimeEsModule, + }); + await writeFiles(files, { baseDir: outDir }); + const bindings = await import(pathToFileURL(join(outDir, 'component.js')).href); + const instance = bindings.instantiate((name: string) => new WebAssembly.Module(files[name]), {}); + assert.strictEqual(instance.run(42), 42); + } finally { + await rm(outDir, { recursive: true, force: true }); + } + }, + ); - assert.strictEqual(runtimeCreateCallCount, 2); - } finally { - await rm(outDir, { recursive: true, force: true }); - } + test.concurrent.each([ + ['', 'non-empty base'], + ['runtime\nmodule', 'control characters'], + ['data:text/javascript,', 'unsupported URL scheme'], + ['./runtime?version=2', 'query or fragment'], + ['file:///runtime#version', 'query or fragment'], + ['./provider.js', 'directory base'], + ])('rejects invalid base %j through the transpiler API', async (cmRuntimeEsModule, message) => { + await expect(transpile(resourceFixture, { name: 'component', cmRuntimeEsModule })).rejects.toThrow(message); }); }); diff --git a/packages/jco-transpile/test/fixtures/browser/harness.html b/packages/jco-transpile/test/fixtures/browser/harness.html index 7c03213d2..4a34bf7dd 100644 --- a/packages/jco-transpile/test/fixtures/browser/harness.html +++ b/packages/jco-transpile/test/fixtures/browser/harness.html @@ -4,7 +4,8 @@