From 96023dbdc7a78d738dc5af8103bc8c5bec4fa2b5 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Wed, 21 Oct 2026 07:38:40 +0000 Subject: [PATCH 1/7] feat(runtime): use named exports with explicit trap contexts Publish the API-v2 runtime declaration contract and resource subpath. Generate static named imports and stable trap contexts for tableGet, including calls from remaining inline resource helpers. Resolve package and directory bases centrally, with migration diagnostics for legacy provider files. Cover packed NodeNext/bundler consumers, independent and re-exported runtimes, selected realms, isolated stores, native linking errors, and browser URL loads. Update CLI/API/WIT docs, migration notes, CI type checks, and release smoke. Validation: 84 Rust tests, clippy/fmt, runtime type/lint/package checks, 27 focused codegen tests, 20 browser tests, and the P3 passing-resources case. LTS failures from local-server restrictions and concurrent workload noise passed isolated reruns without threshold changes. A local paired pilot against main's one-intrinsic factory runtime reduced the resource bundle by 1,165 raw, 335 gzip, and 289 Brotli bytes with Rolldown. Named output is only 60 raw bytes above inline; the adder control is byte-identical. Startup timing improvements are modest and warm-call throughput is unchanged. The complete shared benchmark harness and remaining migrations follow later. BREAKING CHANGE: Experimental CM runtime API v2 replaces runtime.create with named runtime and intrinsics/resource subpaths. cmRuntimeEsModule now names a package or directory base. Providers must expose CM_RUNTIME_API_VERSION = 2 and tableGet(context, table, handle); migrate file specifiers to directory bases and regenerate bindings. Inline generation remains the default. --- .github/workflows/main-windows.yml | 1 + .github/workflows/main.yml | 1 + .github/workflows/release.yml | 3 +- .../wit/js-component-bindgen.wit | 3 +- crates/js-component-bindgen/README.md | 13 +- .../src/intrinsics/mod.rs | 108 ++--- .../src/intrinsics/resource.rs | 5 +- .../src/intrinsics/runtime.rs | 262 +++++++++++ crates/js-component-bindgen/src/lib.rs | 50 ++- .../src/transpile_bindgen.rs | 36 +- docs/src/transpiling.md | 2 +- packages/jco-cm-runtime/CHANGELOG.md | 4 + packages/jco-cm-runtime/README.md | 175 +++++--- packages/jco-cm-runtime/package.json | 10 +- packages/jco-cm-runtime/src/index.ts | 18 +- .../jco-cm-runtime/src/intrinsics/resource.ts | 33 +- packages/jco-cm-runtime/src/runtime.ts | 65 +-- packages/jco-cm-runtime/src/types.ts | 53 --- packages/jco-cm-runtime/test/contracts.ts | 30 ++ packages/jco-cm-runtime/test/package.test.ts | 144 ++++++ packages/jco-cm-runtime/test/resource.test.ts | 140 ++---- packages/jco-cm-runtime/tsconfig.build.json | 3 +- packages/jco-cm-runtime/tsconfig.json | 3 +- packages/jco-transpile/src/transpile.ts | 2 +- .../test/external-runtime/browser/cases.js | 3 +- .../test/external-runtime/browser/index.ts | 138 +++--- .../test/external-runtime/codegen.ts | 413 +++++++++--------- .../test/fixtures/browser/harness.html | 3 +- .../test/fixtures/custom-runtime-provider.js | 41 -- .../custom-runtime/intrinsics/resource.js | 34 ++ .../test/fixtures/custom-runtime/package.json | 8 + .../test/fixtures/custom-runtime/runtime.js | 1 + .../test/p3/ported/component-model/wast.ts | 4 +- packages/jco-transpile/test/resources.ts | 4 +- packages/jco/src/jco.ts | 4 +- scripts/test-jco-cm-runtime-release.mjs | 12 + 36 files changed, 1105 insertions(+), 724 deletions(-) create mode 100644 crates/js-component-bindgen/src/intrinsics/runtime.rs delete mode 100644 packages/jco-cm-runtime/src/types.ts create mode 100644 packages/jco-cm-runtime/test/contracts.ts create mode 100644 packages/jco-cm-runtime/test/package.test.ts delete mode 100644 packages/jco-transpile/test/fixtures/custom-runtime-provider.js create mode 100644 packages/jco-transpile/test/fixtures/custom-runtime/intrinsics/resource.js create mode 100644 packages/jco-transpile/test/fixtures/custom-runtime/package.json create mode 100644 packages/jco-transpile/test/fixtures/custom-runtime/runtime.js create mode 100644 scripts/test-jco-cm-runtime-release.mjs 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..799bf597e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -586,7 +586,8 @@ 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 + 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..3868cae74 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. + /// API-v2 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..b64036e9d 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. API-v2 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/README.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..36145ab25 --- /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 = 2; +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 an API-v2 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..b6b4788ef 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 API-v2 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..bf9324b5d 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-v2 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 and migration guide](../../packages/jco-cm-runtime/README.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/packages/jco-cm-runtime/CHANGELOG.md b/packages/jco-cm-runtime/CHANGELOG.md index 5823d9bb8..3780f31c1 100644 --- a/packages/jco-cm-runtime/CHANGELOG.md +++ b/packages/jco-cm-runtime/CHANGELOG.md @@ -1,3 +1,7 @@ # Changelog ## [unreleased] + +### Breaking changes + +- Experimental CM runtime API v2 replaces the root `runtime.create` provider with named `runtime` and `intrinsics/resource` subpaths. `tableGet` takes an explicit trap context. `cmRuntimeEsModule` now identifies a package or directory base; migrate provider files to the documented directory layout and regenerate bindings. diff --git a/packages/jco-cm-runtime/README.md b/packages/jco-cm-runtime/README.md index 7c1077dc2..eed22bb81 100644 --- a/packages/jco-cm-runtime/README.md +++ b/packages/jco-cm-runtime/README.md @@ -1,83 +1,116 @@ # `@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. ```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. +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. + +## API version 2: 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 2 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. + +```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. + +## 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 = 2` 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. + +### Resource table contract + +`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 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. +- A missing slot or freed entry traps with + `unknown handle index ` using `context.RuntimeError`. + +Changes to the encoding or operation contracts require a new runtime API version. -Incompatible changes to this table encoding or these method contracts require -a new JavaScript CM runtime API version, independently of the npm package version. +## Migrating from API version 1 -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. +Replace the root `runtime.create(options).intrinsics.resource.tableGet` factory +with a named `tableGet(context, table, handle)` export at `intrinsics/resource`. +Replace provider file specifiers with package/directory bases, publish version 2 +at `runtime`, and use the supplied trap capability instead of `this` or ambient +WebAssembly. The v1 provider/factory types and bootstrap are removed. Existing +resource data types remain exported at root and `runtime`. Generated v1 bindings +need to be regenerated for API-v2 runtimes; there is no dynamic legacy fallback. diff --git a/packages/jco-cm-runtime/package.json b/packages/jco-cm-runtime/package.json index 392c9b0d2..b76544d5d 100644 --- a/packages/jco-cm-runtime/package.json +++ b/packages/jco-cm-runtime/package.json @@ -25,10 +25,18 @@ ".": { "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", 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..f25bbdfc3 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 = 2 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/contracts.ts b/packages/jco-cm-runtime/test/contracts.ts new file mode 100644 index 000000000..dad01623e --- /dev/null +++ b/packages/jco-cm-runtime/test/contracts.ts @@ -0,0 +1,30 @@ +// Compile-only fixtures: function properties must reject narrower capabilities and operands. +import * as resource from '../src/intrinsics/resource.js'; +import type { ResourceModule, ResourceTableGetContext, RuntimeModuleContracts } from '../src/runtime.js'; + +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/package.test.ts b/packages/jco-cm-runtime/test/package.test.ts new file mode 100644 index 000000000..766bd899f --- /dev/null +++ b/packages/jco-cm-runtime/test/package.test.ts @@ -0,0 +1,144 @@ +import { execFile } from 'node:child_process'; +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 { promisify } from 'node:util'; + +import { expect, test } from 'vitest'; + +const execAsync = promisify(execFile); +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 }); + } +} +// pnpm exposes its executable/CLI path to scripts, avoiding Windows .cmd shell quoting. +const pnpm = process.env.npm_execpath?.includes('pnpm') ? process.env.npm_execpath : 'pnpm'; +const runPnpm = (args: string[], cwd: string) => + /\.[cm]?js$/.test(pnpm) ? exec(process.execPath, [pnpm, ...args], { cwd }) : exec(pnpm, args, { cwd }); +const packageRoot = fileURLToPath(new URL('../', import.meta.url)); +const tsc = join(dirname(createRequire(import.meta.url).resolve('typescript/package.json')), 'bin/tsc'); + +// One pack/build followed by consumers, so no build replaces artifacts under a running consumer. +test('packed exports, standalone declarations, and independent provider conformance', async () => { + const consumer = await mkdtemp(join(tmpdir(), 'jco-runtime-consumer-')); + try { + await runPnpm(['--config.verify-deps-before-run=false', 'pack', '--pack-destination', consumer], packageRoot); + const tarball = (await readdir(consumer)).find((name) => name.endsWith('.tgz'))!; + await writeFile(join(consumer, 'package.json'), JSON.stringify({ private: true, type: 'module' })); + 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 exec(process.execPath, ['smoke.mjs'], { cwd: consumer }); + + const installed = join(consumer, 'node_modules/@bytecodealliance/jco-cm-runtime'); + 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 = 2;'); + + // Copy the literal declaration into an independent provider; it must need no private files. + await writeFile(join(consumer, 'contract.d.ts'), contract); + await writeFile( + join(consumer, 'provider.ts'), + ` + 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 }; + }; + `, + ); + await writeFile( + join(consumer, 'consumer.ts'), + ` + 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: 2 = 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'); + `, + ); + const negatives = (await readFile(new URL('./contracts.ts', import.meta.url), 'utf8')) + .replaceAll('../src/intrinsics/resource.js', '@bytecodealliance/jco-cm-runtime/intrinsics/resource') + .replaceAll('../src/runtime.js', '@bytecodealliance/jco-cm-runtime/runtime'); + await writeFile(join(consumer, 'negatives.ts'), negatives); + await writeFile( + join(consumer, 'types-only.ts'), + ` + import type { ResourceModule, ResourceTableGetContext, RuntimeModuleContracts } from '@bytecodealliance/jco-cm-runtime/runtime'; + export type { ResourceModule, ResourceTableGetContext, RuntimeModuleContracts }; + `, + ); + for (const mode of ['NodeNext', 'Bundler']) { + await writeFile( + join(consumer, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + strict: true, + skipLibCheck: false, + target: 'ES2022', + lib: ['ES2022', 'DOM'], + types: [], + module: mode === 'NodeNext' ? 'NodeNext' : 'ESNext', + moduleResolution: mode, + verbatimModuleSyntax: true, + outDir: `out-${mode}`, + }, + include: ['*.ts'], + }), + ); + await exec(process.execPath, [tsc, '-p', 'tsconfig.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 }); + } + // Importing the resource subpath/metadata needs no platform globals or other families. + await writeFile( + join(consumer, 'minimal.mjs'), + ` + 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'); + `, + ); + 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 index d5af21ab0..95ffdfde0 100644 --- a/packages/jco-cm-runtime/test/resource.test.ts +++ b/packages/jco-cm-runtime/test/resource.test.ts @@ -1,105 +1,55 @@ -import { describe, expect, test } from 'vitest'; +import { expect, test } from 'vitest'; -import { CM_RUNTIME_API_VERSION, runtime, type ComponentModelRuntimeProvider } from '../src/index.js'; +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; + +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(2); +}); -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.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.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, +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); }, - { 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); }); + expect(() => tableGet.call(null, outer, table, 1)).toThrowError(OuterTrap); }); -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'); - }); -}); +// 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/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..531610ce1 100644 --- a/packages/jco-cm-runtime/tsconfig.json +++ b/packages/jco-cm-runtime/tsconfig.json @@ -10,6 +10,7 @@ "declaration": true, "declarationMap": true, "sourceMap": true, - "skipLibCheck": true + "skipLibCheck": true, + "types": ["node"] } } diff --git a/packages/jco-transpile/src/transpile.ts b/packages/jco-transpile/src/transpile.ts index 7c07f6ab8..7a4c0ad6c 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 API-v2 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/index.ts b/packages/jco-transpile/test/external-runtime/browser/index.ts index d84425591..2743fff97 100644 --- a/packages/jco-transpile/test/external-runtime/browser/index.ts +++ b/packages/jco-transpile/test/external-runtime/browser/index.ts @@ -1,76 +1,66 @@ -import { copyFile, readFile, rm, writeFile } from 'node:fs/promises'; +import { cp, readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { env } from 'node:process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import puppeteer, { type Browser } from 'puppeteer'; -import { afterAll, assert, beforeAll, expect, suite, test } from 'vitest'; +import { assert, test } from 'vitest'; import { transpile, writeFiles } from '../../../src/index.js'; import { parse } from '../../../src/wasm-tools.js'; import { getTmpDir, runBrowserCase, startTestWebServer } 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; +async function withBrowser(run: (browser: Browser, serverPort: number, outDir: string) => Promise) { + const outDir = await getTmpDir(); + let browser: Browser | undefined; let closeServer: (() => Promise) | undefined; - let tmpDir: string; - - beforeAll(async () => { - tmpDir = await getTmpDir(); + try { const server = await startTestWebServer({ routes: [ - { urlPrefix: '/tmpdir/', basePathURL: pathToFileURL(`${tmpDir}/`) }, + { urlPrefix: '/tmpdir/', basePathURL: pathToFileURL(`${outDir}/`) }, { 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 () => { + browser = await puppeteer.launch({ executablePath: env.PUPPETEER_PATH }); + await run(browser, server.serverPort, outDir); + } finally { await browser?.close(); await closeServer?.(); - if (tmpDir) { - await rm(tmpDir, { recursive: true, force: true }); - } - }); + await rm(outDir, { recursive: true, force: true }); + } +} - for (const provider of ['default', 'custom'] as const) { - for (const instantiation of ['sync', 'async', undefined] as const) { - test.each([false, true])( - `runs with the ${provider} provider (${instantiation ?? 'direct'}, minify=%s)`, - async (minify) => { - const name = `${provider}-${instantiation ?? 'direct'}-${minify}`; - const outDir = join(tmpDir, name); +for (const provider of ['default', 'custom', 'url'] as const) { + for (const instantiation of ['sync', 'async', undefined] as const) { + test.concurrent.each([false, true])( + `browser loads ${provider} named exports (${instantiation ?? 'direct'}, minify=%s)`, + async (minify) => { + 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: - provider === 'default' ? '@bytecodealliance/jco-cm-runtime' : './provider.js', + cmRuntimeEsModule, }); await writeFiles(files, { baseDir: outDir }); if (provider === 'custom') { - await copyFile(CUSTOM_PROVIDER, join(outDir, 'provider.js')); + await cp(CUSTOM_RUNTIME, join(outDir, 'runtime'), { recursive: true }); } const result = await runBrowserCase({ browser, @@ -79,21 +69,25 @@ suite('External Component Model runtime in the browser', () => { exportName: 'runComponent', args: [ { - componentPath: `/tmpdir/${name}/component.js`, + componentPath: '/tmpdir/component.js', corePaths: Object.keys(files).filter((path) => path.endsWith('.wasm')), instantiation, - providerPath: provider === 'custom' ? `/tmpdir/${name}/provider.js` : undefined, + providerPath: + provider === 'custom' ? '/tmpdir/runtime/intrinsics/resource.js' : undefined, }, ], }); - assert.deepEqual(result, expectedResult(provider, !!instantiation)); - }, - ); - } + assert.deepEqual(result, expectedResult(provider === 'custom', !!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'))); +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, @@ -101,62 +95,30 @@ suite('External Component Model runtime in the browser', () => { exportName: 'transpileInBrowser', args: [ { - componentPath, + componentPath: '/tmpdir/component.wasm', cmRuntimeEsModule: provider === 'default' ? '@bytecodealliance/jco-cm-runtime' - : '/jco-transpile/test/fixtures/custom-runtime-provider.js', + : '/jco-transpile/test/fixtures/custom-runtime/', providerPath: provider === 'custom' - ? '/jco-transpile/test/fixtures/custom-runtime-provider.js' + ? '/jco-transpile/test/fixtures/custom-runtime/intrinsics/resource.js' : undefined, }, ], }); - 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', + assert.deepEqual(result, expectedResult(provider === 'custom', true)); }); - 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(provider: 'default' | 'custom', explicitInstantiation: boolean) { +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: explicitInstantiation ? 7 : null, - runtimeCreates: provider === 'custom' ? (explicitInstantiation ? 2 : 1) : null, - tableGets: provider === 'custom' ? 2 : null, + siblingValue: explicit ? 7 : null, + contexts: custom ? (explicit ? 2 : 1) : null, + tableGets: 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..3164c7b6c 100644 --- a/packages/jco-transpile/test/external-runtime/codegen.ts +++ b/packages/jco-transpile/test/external-runtime/codegen.ts @@ -1,234 +1,251 @@ -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, 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 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); + +for (const instantiation of ['sync', 'async'] as const) { + test.concurrent.each([false, true])( + `uses the selected realm without a platform global (${instantiation}, minify=%s)`, + async (minify) => { + const outDir = await getTmpDir(); + try { + const { files } = await transpile(resourceFixture, { + name: 'component', + instantiation, + minify, + 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, 'component.js'), + ); + } finally { + await rm(outDir, { recursive: true, force: true }); + } + }, ); - const defaultCmRuntimeEsModule = new URL('../../../jco-cm-runtime/dist/index.js', import.meta.url).href; - - for (const instantiation of ['sync', 'async'] as const) { - test.each([false, true])( - `uses the selected WebAssembly 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, - instantiation, - minify, - cmRuntimeEsModule: defaultCmRuntimeEsModule, - }); - 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`), - ); - } finally { - await rm(outDir, { recursive: true, force: true }); - } - }, - ); - } - - for (const instantiation of ['sync', 'async', undefined] as const) { - test.each([false, true])( - `preserves the intrinsic receiver (${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 { - const { files } = await transpile(resourceFixture, { - name, - instantiation, - minify, - cmRuntimeEsModule, - }); - 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'); - } finally { - await rm(outDir, { recursive: true, force: true }); +} + +for (const instantiation of ['sync', 'async', undefined] as const) { + test.concurrent.each([false, true])( + `uses narrow stable contexts with independent stores (${instantiation ?? 'direct'}, minify=%s)`, + async (minify) => { + const outDir = await getTmpDir(); + 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: 'component', + instantiation, + minify, + cmRuntimeEsModule: './runtime/', + }); + await writeFiles(files, { baseDir: outDir }); + 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'); } - }, - ); - } - - 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', - }, - { - name: 'missing intrinsics', - provider: '{ apiVersion: 2, create() { return { apiVersion: 2 }; } }', - error: 'runtime intrinsic resource.tableGet must be a function', + assert.throws(() => first.run(42), WebAssembly.RuntimeError, 'cannot enter component instance'); + } finally { + await rm(outDir, { recursive: true, force: true }); + } }, - ])('rejects $name during instantiation', async ({ provider, error }) => { + ); +} + +test.concurrent.each(['independent', 'reexport'] as const)( + 'loads a %s package installed only beside generated bindings', + async (implementation) => { 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 (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, + 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, - ); + 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 }); } - }); - - test('does not depend on the optional runtime package', 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'); - 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', - instantiation: 'sync', - cmRuntimeEsModule, + }, +); + +test.concurrent.each(['sync', 'async', undefined] as const)('rejects API-v1 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 = 1;'); + await writeFile(join(outDir, 'package.json'), JSON.stringify({ type: 'module' })); + const { files } = await transpile(resourceFixture, { + name: 'component', + instantiation, + cmRuntimeEsModule: './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 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); - }); - - test('loads a bare runtime module installed only with the generated component', async () => { - const outDir = await getTmpDir(); - const name = 'external-runtime-local-package'; - const cmRuntimeEsModule = 'test-runtime-provider'; - const packageDir = join(outDir, 'node_modules', cmRuntimeEsModule); + 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 2, supported 1'); + } finally { + await rm(outDir, { recursive: true, force: true }); + } +}); - try { - await mkdir(packageDir, { recursive: true }); +test.concurrent.each(['export', 'subpath'] as const)('native ESM rejects a missing %s', async (missing) => { + const outDir = await getTmpDir(); + 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({ name: cmRuntimeEsModule, type: 'module' }), - ); - await copyFile( - new URL('../fixtures/custom-runtime-provider.js', import.meta.url), - join(packageDir, 'index.js'), + JSON.stringify({ type: 'module', exports: { './runtime': './runtime.js' } }), ); - const provider = await import(pathToFileURL(join(packageDir, 'index.js')).href); - provider.resetRuntimeCreateCallCount(); - - const { files } = await transpile(fixture, { name, instantiation: 'sync', cmRuntimeEsModule }); - 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); - } finally { - await rm(outDir, { recursive: true, force: true }); } + 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 }); + 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.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'); + const { files } = await transpile(resourceFixture, { name: 'component' }); + const source = new TextDecoder().decode(files['component.js']); + assert.include(source, 'function rscTableGet(table, handle)'); + assert.notInclude(source, '_jcoRuntime'); + const external = await transpile(resourceFixture, { + name: 'component', + instantiation: 'sync', + cmRuntimeEsModule: 'not-installed-runtime', }); + 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?')); + assert.notInclude(external.imports, 'not-installed-runtime'); +}); - 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(); +test.concurrent('reserves the named intrinsic alias against component exports', async () => { + const outDir = await getTmpDir(); + try { + const wat = (await readFile(resourceFixture, 'utf8')).replace( + '(func (export "run") (param "value"', + '(func (export "rsc-table-get") (param "value"', + ); + 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 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.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 { - const { files } = await transpile(fixture, { - name, + 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, `${name}.js`)).href); - const getCoreModule = (moduleName: string) => new WebAssembly.Module(files[moduleName]); - - bindings.instantiate(getCoreModule, {}); - bindings.instantiate(getCoreModule, {}); - - assert.strictEqual(runtimeCreateCallCount, 2); + 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 }); } - }); + }, +); + +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 @@