diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml index 5cb1b8e77..2c52b417e 100644 --- a/.github/workflows/create-release-pr.yml +++ b/.github/workflows/create-release-pr.yml @@ -13,6 +13,7 @@ on: - bare-jco - jco-node-fs - jco-transpile + - jco-cm-runtime - jco-std - js-component-bindgen - preview2-shim @@ -116,6 +117,12 @@ jobs: export CURRENT_VERSION; export IS_JS_PROJECT=true; ;; + jco-cm-runtime) + export PROJECT_DIR="$PWD/packages/$PROJECT"; + CURRENT_VERSION="$(node -e "process.stdout.write(require(process.env.PROJECT_DIR + '/package.json').version)")"; + export CURRENT_VERSION; + export IS_JS_PROJECT=true; + ;; jco-std) export PROJECT_DIR="$PWD/packages/$PROJECT"; CURRENT_VERSION="$(node -e "process.stdout.write(require(process.env.PROJECT_DIR + '/package.json').version)")"; diff --git a/.github/workflows/main-windows.yml b/.github/workflows/main-windows.yml index 7a5abc74f..0216fc141 100644 --- a/.github/workflows/main-windows.yml +++ b/.github/workflows/main-windows.yml @@ -67,6 +67,17 @@ jobs: - name: Install node modules run: pnpm install + - name: Build and test Component Model runtime + run: | + pnpm --filter '@bytecodealliance/jco-cm-runtime' run build + pnpm --filter '@bytecodealliance/jco-cm-runtime' run test + + - name: Upload Component Model runtime build output + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: jco-cm-runtime-dist + path: packages/jco-cm-runtime/dist + - name: Build jco for test working-directory: packages/jco run: | @@ -215,6 +226,11 @@ jobs: with: name: jco-build-ts path: packages/jco/dist + - name: Restore Component Model runtime build output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: jco-cm-runtime-dist + path: packages/jco-cm-runtime/dist - name: Restore artifacts for JS tests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -301,6 +317,11 @@ jobs: with: name: jco-build-ts path: packages/jco/dist + - name: Restore Component Model runtime build output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: jco-cm-runtime-dist + path: packages/jco-cm-runtime/dist - name: Restore artifacts for JS tests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e6937bc96..1fa1fd715 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -60,6 +60,17 @@ jobs: - name: Install node modules run: pnpm install + - name: Build and test Component Model runtime + run: | + pnpm --filter '@bytecodealliance/jco-cm-runtime' run build + pnpm --filter '@bytecodealliance/jco-cm-runtime' run test + + - name: Upload Component Model runtime build output + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: jco-cm-runtime-dist + path: packages/jco-cm-runtime/dist + - name: Build jco for test working-directory: packages/jco run: | @@ -187,6 +198,11 @@ jobs: with: name: jco-build-ts path: packages/jco/dist + - name: Restore Component Model runtime build output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: jco-cm-runtime-dist + path: packages/jco-cm-runtime/dist - name: Restore preview2-shim build output uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -279,6 +295,11 @@ jobs: with: name: jco-transpile-vendor path: packages/jco-transpile/vendor + - name: Restore Component Model runtime build output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: jco-cm-runtime-dist + path: packages/jco-cm-runtime/dist - name: Restore artifacts for JS tests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -353,6 +374,11 @@ jobs: with: name: jco-build-ts path: packages/jco/dist + - name: Restore Component Model runtime build output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: jco-cm-runtime-dist + path: packages/jco-cm-runtime/dist - name: Restore artifacts for JS tests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -430,6 +456,11 @@ jobs: with: name: jco-build-ts path: packages/jco/dist + - name: Restore Component Model runtime build output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: jco-cm-runtime-dist + path: packages/jco-cm-runtime/dist - name: Restore artifacts for JS tests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -820,6 +851,11 @@ jobs: with: name: jco-build-ts path: packages/jco/dist + - name: Restore Component Model runtime build output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: jco-cm-runtime-dist + path: packages/jco-cm-runtime/dist - name: Restore artifacts for JS tests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -977,6 +1013,11 @@ jobs: with: name: jco-build-ts path: packages/jco/dist + - name: Restore Component Model runtime build output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: jco-cm-runtime-dist + path: packages/jco-cm-runtime/dist - name: Restore jco-transpile vendor uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fff8b0503..c048fa57e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,8 @@ on: - "jco-node-fs-v[0-9]+.[0-9]+.[0-9]+-*" - "jco-transpile-v[0-9]+.[0-9]+.[0-9]+*" - "jco-transpile-v[0-9]+.[0-9]+.[0-9]+-*" + - "jco-cm-runtime-v[0-9]+.[0-9]+.[0-9]+*" + - "jco-cm-runtime-v[0-9]+.[0-9]+.[0-9]+-*" - "jco-std-v[0-9]+.[0-9]+.[0-9]+*" - "jco-std-v[0-9]+.[0-9]+.[0-9]+-*" - "preview2-shim-v[0-9]+.[0-9]+.[0-9]+*" @@ -32,6 +34,8 @@ on: - "prep-release-jco-node-fs-v[0-9]+.[0-9]+.[0-9]+-*" - "prep-release-jco-transpile-v[0-9]+.[0-9]+.[0-9]+*" - "prep-release-jco-transpile-v[0-9]+.[0-9]+.[0-9]+-*" + - "prep-release-jco-cm-runtime-v[0-9]+.[0-9]+.[0-9]+*" + - "prep-release-jco-cm-runtime-v[0-9]+.[0-9]+.[0-9]+-*" - "prep-release-jco-std-v[0-9]+.[0-9]+.[0-9]+*" - "prep-release-jco-std-v[0-9]+.[0-9]+.[0-9]+-*" - "prep-release-preview2-shim-v[0-9]+.[0-9]+.[0-9]+*" @@ -54,6 +58,7 @@ on: - bare-jco - jco-node-fs - jco-transpile + - jco-cm-runtime - jco-std - js-component-bindgen - preview2-shim @@ -182,6 +187,15 @@ jobs: export ARTIFACTS_GLOB="packages/jco-transpile/bytecodealliance-jco-transpile-*.tgz"; export ARTIFACT_NAME="bytecodealliance-jco-transpile-$NEXT_VERSION.tgz"; ;; + jco-cm-runtime) + export PROJECT_DIR="$PWD/packages/$PROJECT"; + export PROJECT_PACKAGE_NAME="@bytecodealliance/$PROJECT"; + CURRENT_VERSION=$(node -e "process.stdout.write(require(process.env.PROJECT_DIR + '/package.json').version)"); + export CURRENT_VERSION; + export IS_JS_PROJECT=true; + export ARTIFACTS_GLOB="packages/jco-cm-runtime/bytecodealliance-jco-cm-runtime-*.tgz"; + export ARTIFACT_NAME="bytecodealliance-jco-cm-runtime-$NEXT_VERSION.tgz"; + ;; jco-std) export PROJECT_DIR="$PWD/packages/$PROJECT"; export PROJECT_PACKAGE_NAME="@bytecodealliance/$PROJECT"; @@ -560,6 +574,20 @@ jobs: pnpm install --save "$PACKAGE_DIR" pnpm run all + - name: Test built Component Model runtime NPM package + if: ${{ needs.meta.outputs.project == 'jco-cm-runtime' }} + shell: bash + env: + ARTIFACT_NAME: ${{ needs.meta.outputs.artifact-name }} + GH_WORKSPACE: ${{ github.workspace }} + run: | + export PACKAGE_PATH="$GH_WORKSPACE/gh-artifacts/$ARTIFACT_NAME" + mkdir -p /tmp/test-jco-cm-runtime + 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');" + - name: Test built jco-node-fs NPM package if: ${{ needs.meta.outputs.project == 'jco-node-fs' }} env: diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml index fdbead89e..2ce04c732 100644 --- a/.github/workflows/tag-release.yml +++ b/.github/workflows/tag-release.yml @@ -18,6 +18,7 @@ on: - bare-jco - jco-node-fs - jco-transpile + - jco-cm-runtime - jco-std - js-component-bindgen - preview2-shim diff --git a/Cargo.lock b/Cargo.lock index a8ff1a41c..6d03c4757 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -667,6 +667,7 @@ dependencies = [ "wasm-encoder 0.259.0", "wasmparser 0.259.0", "wasmtime-environ", + "wat", "wit-bindgen-core", "wit-component", "wit-parser 0.259.0", diff --git a/crates/js-component-bindgen-component/src/lib.rs b/crates/js-component-bindgen-component/src/lib.rs index 3470f632c..5177cdf66 100644 --- a/crates/js-component-bindgen-component/src/lib.rs +++ b/crates/js-component-bindgen-component/src/lib.rs @@ -86,6 +86,7 @@ impl bindings::Guest for JsComponentBindgenComponent { .perf_strings_skip_copy_utf16_validation .unwrap_or(false), ) + .maybe_runtime_module(options.runtime_module) .flags_as_bigint(options.flags_as_bigint.unwrap_or(false)) .variants_inline_cases(options.variants_inline_cases.unwrap_or(false)) .use_namespace_objects(options.use_namespace_objects.unwrap_or(false)) 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 43c5f5884..a2f4921b3 100644 --- a/crates/js-component-bindgen-component/wit/js-component-bindgen.wit +++ b/crates/js-component-bindgen-component/wit/js-component-bindgen.wit @@ -105,6 +105,8 @@ world js-component-bindgen { /// Skip validating utf16 strings that are copied between components which /// both use utf16 (they are validated by default) perf-strings-skip-copy-utf16-validation: option, + /// ES module providing the Component Model runtime implementation. + runtime-module: option, /// Represent WIT flags as bigint values instead of objects of booleans. flags-as-bigint: option, diff --git a/crates/js-component-bindgen/Cargo.toml b/crates/js-component-bindgen/Cargo.toml index e92bfaa85..ca7634d7b 100644 --- a/crates/js-component-bindgen/Cargo.toml +++ b/crates/js-component-bindgen/Cargo.toml @@ -37,3 +37,6 @@ wasmtime-environ = { workspace = true, features = ['component-model'] } wit-bindgen-core = { workspace = true } wit-component = { workspace = true } wit-parser = { workspace = true } + +[dev-dependencies] +wat = { workspace = true, features = [ "component-model" ] } diff --git a/crates/js-component-bindgen/README.md b/crates/js-component-bindgen/README.md index d93250beb..51ce6099f 100644 --- a/crates/js-component-bindgen/README.md +++ b/crates/js-component-bindgen/README.md @@ -30,6 +30,13 @@ $ cargo add js-component-bindgen ``` +Generated JavaScript includes its runtime intrinsics inline by default. To +select an external runtime provider, set `TranspileOpts::runtime_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. + # License This project is licensed under the Apache 2.0 license with the LLVM exception. diff --git a/crates/js-component-bindgen/src/intrinsics/mod.rs b/crates/js-component-bindgen/src/intrinsics/mod.rs index e7ba4301b..aebd9499c 100644 --- a/crates/js-component-bindgen/src/intrinsics/mod.rs +++ b/crates/js-component-bindgen/src/intrinsics/mod.rs @@ -9,6 +9,11 @@ use wasmtime_environ::Trap; use crate::source::Source; use crate::{TranspileOpts, uwrite, uwriteln}; +pub(crate) const RUNTIME_ABI_VERSION: u32 = 1; +pub(crate) const RUNTIME_PROVIDER_LOCAL_NAME: &str = "_jcoRuntimeProvider"; +const RUNTIME_LOCAL_NAME: &str = "_jcoRuntime"; +const RUNTIME_INTRINSICS_LOCAL_NAME: &str = "_jcoIntrinsics"; + pub(crate) mod conversion; use conversion::ConversionIntrinsic; @@ -195,6 +200,25 @@ pub enum Intrinsic { ConditionalSuspendingWasm, } +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub(crate) enum IntrinsicBinding { + Inline { + local_name: &'static str, + }, + Runtime { + local_name: &'static str, + path: &'static str, + }, +} + +impl IntrinsicBinding { + fn local_name(self) -> &'static str { + match self { + Self::Inline { local_name } | Self::Runtime { local_name, .. } => local_name, + } + } +} + macro_rules! impl_from_intrinsic { ($($ty:ty => $variant:ident),+ $(,)?) => { $( @@ -1497,6 +1521,8 @@ mod tests { let remove = Intrinsic::Resource(ResourceIntrinsic::ResourceTableRemove); let (source, _) = render([get, remove]); + assert!(source.contains("function rscTableGet(table, handle)")); + assert!(!source.contains("_jcoRuntimeProvider")); assert!( source.contains("throw new WebAssemblyRuntimeError(`unknown handle index ${handle}`);") ); @@ -1570,10 +1596,12 @@ mod tests { } assert!(!intrinsics.contains(&table_remove)); + let get_position = source.find("function rscTableGet(table, handle)").unwrap(); let flag_position = source.find("const T_FLAG").unwrap(); - let get_position = source.find("function rscTableGet").unwrap(); let transfer_position = source.find("function resourceTransferBorrow").unwrap(); + assert!(!source.contains("_jcoRuntimeProvider")); assert!(flag_position < get_position); + assert!(flag_position < transfer_position); assert!(get_position < transfer_position); } @@ -2021,6 +2049,8 @@ mod tests { ); assert!(source.contains("function rscTableGet(table, handle)")); + assert!(!source.contains("_jcoRuntimeProvider")); + assert!(!source.contains("function rscTableRemove(table, handle)")); assert!(source.contains("const { rep, own } = rscTableGet(fromTable, handle);")); assert!(!source.contains("if (!own) rscTableRemove(fromTable, handle);")); assert!(source.contains("fromTable[handle << 1]++;")); @@ -2428,7 +2458,9 @@ impl RenderIntrinsicsArgs<'_> { .lock() .expect("intrinsic dependency collector lock should not be poisoned") .insert(intrinsic); - intrinsic.name() + intrinsic + .binding(self.transpile_opts.runtime_module.is_some()) + .local_name() } fn take_discovered_intrinsics(&self) -> BTreeSet { @@ -2459,7 +2491,12 @@ fn render_intrinsics_discovered(args: &mut RenderIntrinsicsArgs<'_>) -> Source { debug_assert!(args.take_discovered_intrinsics().is_empty()); let mut source = Source::default(); - intrinsic.render(&mut source, args); + if matches!( + intrinsic.binding(args.transpile_opts.runtime_module.is_some()), + IntrinsicBinding::Inline { .. } + ) { + intrinsic.render(&mut source, args); + } let discovered = args.take_discovered_intrinsics(); for dependency in &discovered { if !rendered.contains_key(dependency) { @@ -2472,6 +2509,61 @@ fn render_intrinsics_discovered(args: &mut RenderIntrinsicsArgs<'_>) -> Source { } let mut output = Source::default(); + if uses_external_runtime( + args.intrinsics, + args.transpile_opts.runtime_module.is_some(), + ) { + uwriteln!( + output, + r#" + if (typeof {RUNTIME_PROVIDER_LOCAL_NAME}?.create !== 'function') {{ + throw new TypeError('Jco Component Model runtime provider must define create(options)'); + }} + if ({RUNTIME_PROVIDER_LOCAL_NAME}.abiVersion !== {RUNTIME_ABI_VERSION}) {{ + throw new Error(`incompatible Jco Component Model runtime ABI: requested {RUNTIME_ABI_VERSION}, supported ${{{RUNTIME_PROVIDER_LOCAL_NAME}.abiVersion}}`); + }} + const {RUNTIME_LOCAL_NAME} = {RUNTIME_PROVIDER_LOCAL_NAME}.create({{ + requestedAbiVersion: {RUNTIME_ABI_VERSION}, + strict: {strict}, + flagsAsBigInt: {flags_as_bigint}, + nodejsCompat: {nodejs_compat}, + asyncDeterminism: '{determinism}', + platform: {{ WebAssembly }}, + }}); + if ({RUNTIME_LOCAL_NAME}?.abiVersion !== {RUNTIME_ABI_VERSION}) {{ + throw new Error(`incompatible Jco Component Model runtime instance ABI: requested {RUNTIME_ABI_VERSION}, received ${{{RUNTIME_LOCAL_NAME}?.abiVersion}}`); + }} + const {RUNTIME_INTRINSICS_LOCAL_NAME} = {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, + ); + + for intrinsic in args.intrinsics.iter() { + let IntrinsicBinding::Runtime { local_name, path } = + intrinsic.binding(args.transpile_opts.runtime_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 = {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 args .intrinsics .contains(&Intrinsic::Conversion(ConversionIntrinsic::F32ToI32)) @@ -2587,6 +2679,27 @@ const CONDITIONAL_SUSPENDING_3_I32_TO_VOID: &[u8] = &[ ]; impl Intrinsic { + pub(crate) fn binding(&self, external_runtime: bool) -> IntrinsicBinding { + match self { + Self::Resource(ResourceIntrinsic::ResourceTableGet) if external_runtime => { + IntrinsicBinding::Runtime { + local_name: self.name(), + path: "resource.tableGet", + } + } + _ => IntrinsicBinding::Inline { + local_name: self.name(), + }, + } + } + + pub(crate) fn is_runtime_provided(&self, external_runtime: bool) -> bool { + matches!( + self.binding(external_runtime), + IntrinsicBinding::Runtime { .. } + ) + } + pub fn get_global_names() -> impl IntoIterator { // Every intrinsic is bound in the generated module's scope, so each // name is reserved against the local names of exports and imports (an @@ -2623,6 +2736,9 @@ impl Intrinsic { "imports", "instantiateCore", "isLE", + RUNTIME_PROVIDER_LOCAL_NAME, + RUNTIME_LOCAL_NAME, + RUNTIME_INTRINSICS_LOCAL_NAME, "scopeId", "symbolCabiDispose", "symbolCabiLower", @@ -2803,3 +2919,12 @@ impl Intrinsic { ) } } + +pub(crate) fn uses_external_runtime( + intrinsics: &BTreeSet, + external_runtime: bool, +) -> bool { + intrinsics + .iter() + .any(|intrinsic| intrinsic.is_runtime_provided(external_runtime)) +} diff --git a/crates/js-component-bindgen/src/lib.rs b/crates/js-component-bindgen/src/lib.rs index 33cdf7a98..6c2ef1250 100644 --- a/crates/js-component-bindgen/src/lib.rs +++ b/crates/js-component-bindgen/src/lib.rs @@ -223,6 +223,12 @@ fn normalize_namespace_object_options(opts: &mut TranspileOpts) -> Result<()> { if opts.use_namespace_objects { opts.flags_as_bigint = true; } + if let Some(runtime_module) = &opts.runtime_module { + ensure!( + !runtime_module.is_empty() && !runtime_module.chars().any(char::is_control), + "runtimeModule must be a non-empty module specifier without control characters" + ); + } Ok(()) } @@ -392,6 +398,17 @@ mod tests { } "#; + fn generated_javascript(transpiled: &Transpiled) -> &str { + transpiled + .files + .iter() + .find_map(|(name, contents)| { + name.ends_with(".js") + .then(|| std::str::from_utf8(contents).unwrap()) + }) + .unwrap() + } + fn flags_interface(flags_as_bigint: bool) -> String { let mut resolve = Resolve::default(); let package = resolve.push_str("flags.wit", FLAGS_WIT).unwrap(); @@ -445,4 +462,108 @@ mod tests { &async_funcs, )); } + + #[test] + fn validates_runtime_module_specifiers() { + let mut default = TranspileOpts::default(); + normalize_namespace_object_options(&mut default).unwrap(); + assert!(default.runtime_module.is_none()); + + let mut custom = TranspileOpts::builder() + .name("component".into()) + .runtime_module("./runtime.js".into()) + .build(); + normalize_namespace_object_options(&mut custom).unwrap(); + assert_eq!(custom.runtime_module.as_deref(), Some("./runtime.js")); + + for invalid in ["", "runtime\nmodule"] { + let mut opts = TranspileOpts::builder() + .name("component".into()) + .runtime_module(invalid.into()) + .build(); + assert!(normalize_namespace_object_options(&mut opts).is_err()); + } + } + + #[cfg(feature = "transpile-bindgen")] + #[test] + fn resource_rep_uses_external_runtime_provider() { + let component = wat::parse_str( + r#" + (component + (core module $module + (type $rep-type (func (param i32) (result i32))) + (import "intrinsics" "rep" (func $rep (type $rep-type))) + (func (export "run") (param i32) (result i32) + local.get 0 + call $rep + ) + ) + (type $resource (resource (rep i32))) + (core func $rep (canon resource.rep $resource)) + (core instance $intrinsics + (export "rep" (func $rep)) + ) + (core instance (instantiate $module + (with "intrinsics" (instance $intrinsics)) + )) + ) + "#, + ) + .unwrap(); + let direct = transpile( + &component, + TranspileOpts::builder() + .name("resource-runtime-direct".into()) + .build(), + ) + .unwrap(); + let direct_source = generated_javascript(&direct); + assert!(direct_source.contains("function rscTableGet(table, handle)")); + assert!(!direct_source.contains("_jcoRuntimeProvider")); + + let transpiled = transpile( + &component, + TranspileOpts::builder() + .name("resource-runtime".into()) + .runtime_module("./custom-runtime.js".into()) + .instantiation_mode(InstantiationMode::Sync) + .build(), + ) + .unwrap(); + let source = generated_javascript(&transpiled); + + let import_position = source + .find("import { runtime as _jcoRuntimeProvider } from \"./custom-runtime.js\";") + .unwrap(); + let instantiate_position = source.find("export function instantiate(").unwrap(); + let create_position = source.find("_jcoRuntimeProvider.create(").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!( + !transpiled + .imports + .contains(&"./custom-runtime.js".to_string()) + ); + } + + #[cfg(feature = "transpile-bindgen")] + #[test] + fn unrelated_component_does_not_import_external_runtime() { + let component = wat::parse_str("(component)").unwrap(); + let transpiled = transpile( + &component, + TranspileOpts::builder().name("adder".into()).build(), + ) + .unwrap(); + let source = generated_javascript(&transpiled); + + assert!(!source.contains("_jcoRuntimeProvider")); + assert!(!source.contains("@bytecodealliance/jco-cm-runtime")); + } } diff --git a/crates/js-component-bindgen/src/names.rs b/crates/js-component-bindgen/src/names.rs index 179c8de7a..c01764f49 100644 --- a/crates/js-component-bindgen/src/names.rs +++ b/crates/js-component-bindgen/src/names.rs @@ -166,6 +166,32 @@ pub fn maybe_quote_member(name: &str) -> String { } } +/// Quote arbitrary text as a JavaScript string literal. +pub fn js_string_literal(value: &str) -> String { + let mut literal = String::with_capacity(value.len() + 2); + literal.push('"'); + for ch in value.chars() { + match ch { + '"' => literal.push_str("\\\""), + '\\' => literal.push_str("\\\\"), + '\n' => literal.push_str("\\n"), + '\r' => literal.push_str("\\r"), + '\t' => literal.push_str("\\t"), + '\u{08}' => literal.push_str("\\b"), + '\u{0c}' => literal.push_str("\\f"), + '\u{2028}' => literal.push_str("\\u2028"), + '\u{2029}' => literal.push_str("\\u2029"), + ch if ch.is_control() => { + use std::fmt::Write as _; + write!(literal, "\\u{:04x}", ch as u32).unwrap(); + } + ch => literal.push(ch), + } + } + literal.push('"'); + literal +} + pub(crate) const RESERVED_KEYWORDS: &[&str] = &[ "await", "break", @@ -249,4 +275,12 @@ mod tests { assert_ne!(to_js_identifier("eval"), "eval"); assert!(is_valid_js_identifier(&to_js_identifier("eval"))); } + + #[test] + fn quotes_javascript_string_literals() { + assert_eq!( + js_string_literal("pkg/\"quoted\"\\path\u{2028}"), + "\"pkg/\\\"quoted\\\"\\\\path\\u2028\"" + ); + } } diff --git a/crates/js-component-bindgen/src/transpile_bindgen.rs b/crates/js-component-bindgen/src/transpile_bindgen.rs index d61809b9e..69c7dc284 100644 --- a/crates/js-component-bindgen/src/transpile_bindgen.rs +++ b/crates/js-component-bindgen/src/transpile_bindgen.rs @@ -49,9 +49,12 @@ use crate::intrinsics::resource::ResourceIntrinsic; use crate::intrinsics::string::StringIntrinsic; use crate::intrinsics::webidl::WebIdlIntrinsic; use crate::intrinsics::{ - AsyncDeterminismProfile, Intrinsic, RenderIntrinsicsArgs, render_intrinsics, + AsyncDeterminismProfile, Intrinsic, RUNTIME_PROVIDER_LOCAL_NAME, RenderIntrinsicsArgs, + render_intrinsics, uses_external_runtime, +}; +use crate::names::{ + LocalNames, is_js_reserved_word, js_string_literal, maybe_quote_id, maybe_quote_member, }; -use crate::names::{LocalNames, is_js_reserved_word, maybe_quote_id, maybe_quote_member}; use crate::{ FunctionIdentifier, ManagesIntrinsics, core, get_thrown_type, is_async_fn, requires_async_porcelain, source, uwrite, uwriteln, @@ -134,6 +137,9 @@ 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. + pub runtime_module: Option, /// Represent WIT flags as bigint values instead of objects of booleans. #[builder(default)] pub flags_as_bigint: bool, @@ -592,6 +598,13 @@ impl JsBindgen<'_> { .transpile_opts(opts) .build(); let js_intrinsics = render_intrinsics(render_args); + if uses_external_runtime(&self.all_intrinsics, opts.runtime_module.is_some()) { + uwriteln!( + output, + "import {{ runtime as {RUNTIME_PROVIDER_LOCAL_NAME} }} from {};", + js_string_literal(opts.runtime_module.as_deref().unwrap()), + ); + } // Write out instantiation if let Some(instantiation) = &self.opts.instantiation_mode { diff --git a/docs/src/transpiling.md b/docs/src/transpiling.md index fce255e4d..e8f8074a8 100644 --- a/docs/src/transpiling.md +++ b/docs/src/transpiling.md @@ -49,6 +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`). +* `--runtime-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 runtime ABI currently delegates `resource.tableGet`. * `--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/.gitignore b/packages/jco-cm-runtime/.gitignore new file mode 100644 index 000000000..6a34499b8 --- /dev/null +++ b/packages/jco-cm-runtime/.gitignore @@ -0,0 +1,2 @@ +/dist/ +/*.tgz diff --git a/packages/jco-cm-runtime/CHANGELOG.md b/packages/jco-cm-runtime/CHANGELOG.md new file mode 100644 index 000000000..5823d9bb8 --- /dev/null +++ b/packages/jco-cm-runtime/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +## [unreleased] diff --git a/packages/jco-cm-runtime/LICENSE b/packages/jco-cm-runtime/LICENSE new file mode 100644 index 000000000..be1d7c438 --- /dev/null +++ b/packages/jco-cm-runtime/LICENSE @@ -0,0 +1,219 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +--- LLVM Exceptions to the Apache 2.0 License ---- + +As an exception, if, as a result of your compiling your source code, portions +of this Software are embedded into an Object form of such source code, you +may redistribute such embedded portions in such Object form without complying +with the conditions of Sections 4(a), 4(b) and 4(d) of the License. + +In addition, if you combine or link compiled forms of this Software with +software that is licensed under the GPLv2 ("Combined Software") and if a +court of competent jurisdiction determines that the patent provision (Section +3), the indemnity provision (Section 9) or other Section of the License +conflicts with the conditions of the GPLv2, you may retroactively and +prospectively choose to deem waived or otherwise exclude such Section(s) of +the License, but only in their entirety and only with respect to the Combined +Software. diff --git a/packages/jco-cm-runtime/README.md b/packages/jco-cm-runtime/README.md new file mode 100644 index 000000000..f3b51bd3a --- /dev/null +++ b/packages/jco-cm-runtime/README.md @@ -0,0 +1,66 @@ +# `@bytecodealliance/jco-cm-runtime` + +An optional Component Model runtime for JavaScript generated by Jco's +`js-component-bindgen`. + +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 +`runtimeModule` 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 +`runtimeModule` (or `--runtime-module` with the Jco CLI). + +```sh +npm install @bytecodealliance/jco-cm-runtime +jco transpile component.wasm --runtime-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 runtime ABI is versioned +independently from the npm package version. + +## Runtime ABI version 1 + +The selected module exports a `runtime` object with `abiVersion: 1` and a +synchronous `create(options)` method. The method returns a runtime instance +with `abiVersion: 1` and `intrinsics.resource.tableGet`. Generated bindings +check both ABI 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 part of the runtime +ABI: + +- 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, with bit 30 indicating + ownership. Clear that bit to obtain `rep`. A freed entry stores zero. +- A slot past the end of the table, a decoded representation of zero or a freed + entry must trap with `unknown handle index ` using the selected + `WebAssembly.RuntimeError` constructor. + +Changes to this table encoding or these calling conventions require a new +runtime ABI version, independently of the npm package version. diff --git a/packages/jco-cm-runtime/cliff.toml b/packages/jco-cm-runtime/cliff.toml new file mode 100644 index 000000000..ecd9f5b52 --- /dev/null +++ b/packages/jco-cm-runtime/cliff.toml @@ -0,0 +1,45 @@ +# https://git-cliff.org/docs/configuration + +[changelog] +header = """ +# Changelog\n +""" +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="jco-cm-runtime-v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}_({{ commit.scope }})_ {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | split(pat="\n") | first | trim }}\ + {% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif %}\ + {% if commit.remote.pr_number %} in #{{ commit.remote.pr_number }}{%- endif %} + {% endfor %} +{% endfor %}\n +""" +trim = true + +[git] +commit_parsers = [ + { message = '^feat\((bindgen|cm-runtime)\)', group = '๐Ÿš€ Features' }, + { message = '^fix\((bindgen|cm-runtime)\)', group = '๐Ÿ› Bug Fixes' }, + { message = '^refactor\((bindgen|cm-runtime)\)', group = '๐Ÿšœ Refactor' }, + { message = '^doc\((bindgen|cm-runtime)\)', group = '๐Ÿ“š Documentation' }, + { message = '^perf\((bindgen|cm-runtime)\)', group = 'โšก Performance' }, + { message = '^style\((bindgen|cm-runtime)\)', group = '๐ŸŽจ Styling' }, + { message = '^test\((bindgen|cm-runtime)\)', group = '๐Ÿงช Testing' }, + { message = '^chore\((bindgen|cm-runtime)\)', group = 'โš™๏ธ Miscellaneous Tasks' }, + { message = '^sec\((bindgen|cm-runtime)\)', group = '๐Ÿ”’๏ธ Security' }, + { message = '^release', skip = true }, +] +filter_commits = true +topo_order = false +sort_commits = "newest" +tag_pattern = "^jco-cm-runtime-v[0-9]+.[0-9]+.[0-9]+(-beta|-rc|-alpha)?" +link_parsers = [{ pattern = "\\(#(\\d+)\\)$", href = "https://github.com/bytecodealliance/jco/pull/$1" }] + +include_paths = ["packages/jco-cm-runtime/**/*", "crates/js-component-bindgen/**/*"] diff --git a/packages/jco-cm-runtime/package.json b/packages/jco-cm-runtime/package.json new file mode 100644 index 000000000..392c9b0d2 --- /dev/null +++ b/packages/jco-cm-runtime/package.json @@ -0,0 +1,44 @@ +{ + "name": "@bytecodealliance/jco-cm-runtime", + "version": "0.1.0", + "description": "Default Component Model runtime for Jco-generated JavaScript", + "keywords": [ + "Component", + "Wasm", + "WebAssembly" + ], + "homepage": "https://github.com/bytecodealliance/jco#readme", + "bugs": { + "url": "https://github.com/bytecodealliance/jco/issues" + }, + "license": "(Apache-2.0 WITH LLVM-exception)", + "repository": { + "type": "git", + "url": "git+https://github.com/bytecodealliance/jco.git" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "types:check": "tsc -p tsconfig.json", + "fmt": "oxfmt", + "fmt:check": "oxfmt --check", + "lint": "oxlint", + "lint:fix": "oxlint --fix", + "test": "vitest run", + "prepack": "pnpm run build" + }, + "devDependencies": { + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/jco-cm-runtime/src/index.ts b/packages/jco-cm-runtime/src/index.ts new file mode 100644 index 000000000..fa68059fc --- /dev/null +++ b/packages/jco-cm-runtime/src/index.ts @@ -0,0 +1,17 @@ +export { runtime } from './runtime.js'; +export { + RUNTIME_ABI_VERSION, + type AsyncDeterminism, + type ComponentModelRuntime, + type ComponentModelRuntimeProvider, + type ResourceHandle, + type ResourceIntrinsics, + type ResourceRep, + type ResourceScope, + type ResourceTableEntry, + type RuntimeAbiVersion, + type RuntimeErrorConstructor, + type RuntimeIntrinsics, + type RuntimeOptions, + type RuntimePlatform, +} from './types.js'; diff --git a/packages/jco-cm-runtime/src/intrinsics/resource.ts b/packages/jco-cm-runtime/src/intrinsics/resource.ts new file mode 100644 index 000000000..bac17002c --- /dev/null +++ b/packages/jco-cm-runtime/src/intrinsics/resource.ts @@ -0,0 +1,21 @@ +import type { ResourceIntrinsics, RuntimeErrorConstructor } from '../types.js'; + +const RESOURCE_TABLE_FLAG = 1 << 30; + +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_FLAG) !== 0; + const rep = value & ~RESOURCE_TABLE_FLAG; + // 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 || rep === 0 || (scope & RESOURCE_TABLE_FLAG) !== 0) { + throw new 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 new file mode 100644 index 000000000..ee2ce4149 --- /dev/null +++ b/packages/jco-cm-runtime/src/runtime.ts @@ -0,0 +1,37 @@ +import { createResourceIntrinsics } from './intrinsics/resource.js'; +import { + RUNTIME_ABI_VERSION, + type ComponentModelRuntime, + type ComponentModelRuntimeProvider, + type RuntimeOptions, + type RuntimePlatform, +} from './types.js'; + +function createRuntime(options: RuntimeOptions): ComponentModelRuntime { + if (options.requestedAbiVersion !== RUNTIME_ABI_VERSION) { + throw new Error( + `incompatible Jco Component Model runtime ABI: requested ${options.requestedAbiVersion}, ` + + `supported ${RUNTIME_ABI_VERSION}`, + ); + } + + const platform: RuntimePlatform = { + WebAssembly: options.platform?.WebAssembly ?? globalThis.WebAssembly, + }; + + if (typeof platform.WebAssembly?.RuntimeError !== 'function') { + throw new TypeError('Jco Component Model runtime requires WebAssembly.RuntimeError'); + } + + return { + abiVersion: RUNTIME_ABI_VERSION, + intrinsics: { + resource: createResourceIntrinsics(platform.WebAssembly.RuntimeError), + }, + }; +} + +export const runtime = { + abiVersion: RUNTIME_ABI_VERSION, + create: createRuntime, +} satisfies ComponentModelRuntimeProvider; diff --git a/packages/jco-cm-runtime/src/types.ts b/packages/jco-cm-runtime/src/types.ts new file mode 100644 index 000000000..1da542262 --- /dev/null +++ b/packages/jco-cm-runtime/src/types.ts @@ -0,0 +1,53 @@ +export const RUNTIME_ABI_VERSION = 1 as const; + +export type RuntimeAbiVersion = 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 requestedAbiVersion: RuntimeAbiVersion; + 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 abiVersion: RuntimeAbiVersion; + readonly intrinsics: RuntimeIntrinsics; + dispose?(): void; +} + +export interface ComponentModelRuntimeProvider { + readonly abiVersion: RuntimeAbiVersion; + create(options: RuntimeOptions): ComponentModelRuntime; +} diff --git a/packages/jco-cm-runtime/test/resource.test.ts b/packages/jco-cm-runtime/test/resource.test.ts new file mode 100644 index 000000000..50eafb3cb --- /dev/null +++ b/packages/jco-cm-runtime/test/resource.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'vitest'; + +import { RUNTIME_ABI_VERSION, runtime, type ComponentModelRuntimeProvider } from '../src/index.js'; + +const TABLE_FLAG = 1 << 30; + +function createRuntime() { + return runtime.create({ + requestedAbiVersion: RUNTIME_ABI_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_FLAG, 11, 24]; + + expect(tableGet(table, 1)).toEqual({ rep: 42, scope: 7, own: true }); + expect(tableGet(table, 2)).toEqual({ rep: 24, scope: 11, own: false }); + }); + + test.each([ + { name: 'the sentinel at index 0', table: [TABLE_FLAG, 0], handle: 0 }, + { name: 'a freed entry', table: [TABLE_FLAG, 0, TABLE_FLAG, 0], handle: 1 }, + { name: 'a zero rep', table: [TABLE_FLAG, 0, 0, 0], handle: 1 }, + { name: 'an index past the end of the table', table: [TABLE_FLAG, 0], handle: 4 }, + ])('traps for $name', ({ table, handle }) => { + const { tableGet } = createRuntime().intrinsics.resource; + + expect(() => tableGet(table, handle)).toThrowError(WebAssembly.RuntimeError); + expect(() => tableGet(table, handle)).toThrow(`unknown handle index ${handle}`); + }); + + test('uses the injected RuntimeError realm', () => { + class CustomRuntimeError extends Error {} + const instance = runtime.create({ + requestedAbiVersion: RUNTIME_ABI_VERSION, + strict: false, + flagsAsBigInt: false, + nodejsCompat: true, + asyncDeterminism: 'random', + platform: { WebAssembly: { RuntimeError: CustomRuntimeError } }, + }); + + expect(() => instance.intrinsics.resource.tableGet([TABLE_FLAG, 0], 0)).toThrowError(CustomRuntimeError); + }); +}); + +describe('runtime provider', () => { + test('rejects an incompatible requested ABI', () => { + expect(() => + runtime.create({ + requestedAbiVersion: 2, + strict: false, + flagsAsBigInt: false, + nodejsCompat: true, + asyncDeterminism: 'random', + }), + ).toThrow('requested 2, supported 1'); + }); + + 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 = { + abiVersion: RUNTIME_ABI_VERSION, + create: (options) => runtime.create(options), + } satisfies ComponentModelRuntimeProvider; + + expect(alternative.create).toBeTypeOf('function'); + }); +}); diff --git a/packages/jco-cm-runtime/tsconfig.build.json b/packages/jco-cm-runtime/tsconfig.build.json new file mode 100644 index 000000000..303aaa51e --- /dev/null +++ b/packages/jco-cm-runtime/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "exclude": ["test/**/*"], + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "noEmit": false + } +} diff --git a/packages/jco-cm-runtime/tsconfig.json b/packages/jco-cm-runtime/tsconfig.json new file mode 100644 index 000000000..13b0833d7 --- /dev/null +++ b/packages/jco-cm-runtime/tsconfig.json @@ -0,0 +1,15 @@ +{ + "include": ["src/**/*", "test/**/*"], + "compilerOptions": { + "target": "ES2022", + "lib": ["ESNext", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "skipLibCheck": true + } +} diff --git a/packages/jco-transpile/src/transpile.ts b/packages/jco-transpile/src/transpile.ts index c438600b9..9b4d2cd24 100644 --- a/packages/jco-transpile/src/transpile.ts +++ b/packages/jco-transpile/src/transpile.ts @@ -161,6 +161,9 @@ export interface TranspilationOptions { */ perfStringsSkipCopyUtf16Validation?: boolean; + /** Optional ES module specifier imported by generated bindings for the Component Model runtime */ + runtimeModule?: string; + /** Represent WIT flags as bigint values instead of objects of booleans */ flagsAsBigInt?: boolean; @@ -344,6 +347,7 @@ export async function transpileBytes( strict: opts.strict === true, perfStringsSkipCopyUtf8Validation: opts.perfStringsSkipCopyUtf8Validation === true, perfStringsSkipCopyUtf16Validation: opts.perfStringsSkipCopyUtf16Validation === true, + runtimeModule: opts.runtimeModule, flagsAsBigint: opts.flagsAsBigInt === true, variantsInlineCases: opts.variantsInlineCases === true, useNamespaceObjects: opts.useNamespaceObjects === true, diff --git a/packages/jco-transpile/test/codegen.ts b/packages/jco-transpile/test/codegen.ts index 83d9b1bb9..c10d46a34 100644 --- a/packages/jco-transpile/test/codegen.ts +++ b/packages/jco-transpile/test/codegen.ts @@ -1,4 +1,4 @@ -import { readFile, rm, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { URL, fileURLToPath, pathToFileURL } from 'node:url'; @@ -10,9 +10,15 @@ import { componentNew, componentEmbed } from '../src/wasm-tools.js'; import { suite, test, assert, describe } from 'vitest'; -import { readFixtureFlags, getTmpDir, getRandomPort } from './helpers.js'; +import { readFixtureFlags, getTmpDir, getRandomPort, nodeExec } from './helpers.js'; import { getDefaultComponentFixtures, COMPONENT_FIXTURES_DIR } from './common.js'; +import { + resetRuntimeCreateCallCount, + runtimeCreateCallCount, + resetTableGetCallCount, + tableGetCallCount, +} from './fixtures/custom-runtime-provider.js'; suite('codegen', async () => { // NOTE: the codegen tests *must* run first and generate outputs for other tests to use @@ -153,6 +159,216 @@ suite('Directive Prologue', () => { }); }); +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 defaultRuntimeModule = 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, + runtimeModule: defaultRuntimeModule, + }); + 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 runtimeModule = new URL('./fixtures/custom-runtime-provider.js', import.meta.url).href; + resetTableGetCallCount(); + try { + const { files } = await transpile(resourceFixture, { name, instantiation, minify, runtimeModule }); + 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(tableGetCallCount, 2); + 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 }); + } + }, + ); + } + + test.each([ + { + name: 'a missing factory', + provider: '{ abiVersion: 1 }', + error: 'runtime provider must define create(options)', + }, + { + name: 'an incompatible provider ABI before calling its factory', + provider: '{ abiVersion: 2, create() { throw new Error("factory must not run"); } }', + error: 'runtime ABI: requested 1, supported 2', + }, + { + name: 'an incompatible instance ABI', + provider: '{ abiVersion: 1, create() { return { abiVersion: 2 }; } }', + error: 'runtime instance ABI: requested 1, received 2', + }, + { + name: 'a non-function intrinsic', + provider: + '{ abiVersion: 1, create() { return { abiVersion: 1, intrinsics: { resource: { tableGet: 0 } } }; } }', + error: 'runtime intrinsic resource.tableGet must be a function', + }, + { + name: 'a missing resource namespace', + provider: '{ abiVersion: 1, create() { return { abiVersion: 1, intrinsics: {} }; } }', + error: 'runtime intrinsic resource.tableGet must be a function', + }, + { + name: 'missing intrinsics', + provider: '{ abiVersion: 1, create() { return { abiVersion: 1 }; } }', + error: 'runtime intrinsic resource.tableGet must be a function', + }, + ])('rejects $name during instantiation', async ({ provider, error }) => { + const outDir = await getTmpDir(); + const name = 'external-runtime-invalid-provider'; + try { + const { files } = await transpile(resourceFixture, { + name, + instantiation: 'sync', + runtimeModule: './provider.js', + }); + 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, + ); + } 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 runtimeModule = './test-runtime-provider.js'; + const { files, imports } = await transpile(fixture, { + name: 'external-runtime-instantiation', + instantiation: 'sync', + runtimeModule, + }); + 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, runtimeModule); + }); + + test('loads a bare runtime module installed only with the generated component', async () => { + const outDir = await getTmpDir(); + const name = 'external-runtime-local-package'; + const runtimeModule = 'test-runtime-provider'; + const packageDir = join(outDir, 'node_modules', runtimeModule); + + try { + await mkdir(packageDir, { recursive: true }); + await writeFile(join(packageDir, 'package.json'), JSON.stringify({ name: runtimeModule, type: 'module' })); + await copyFile( + new URL('./fixtures/custom-runtime-provider.js', import.meta.url), + join(packageDir, 'index.js'), + ); + const provider = await import(pathToFileURL(join(packageDir, 'index.js')).href); + provider.resetRuntimeCreateCallCount(); + + const { files } = await transpile(fixture, { name, instantiation: 'sync', runtimeModule }); + await writeFiles(files, { baseDir: outDir }); + const source = new TextDecoder().decode(files[`${name}.js`]); + assert.include(source, `from "${runtimeModule}"`); + + 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 }); + } + }); + + test('creates a fresh runtime instance for each generated store', async () => { + const outDir = await getTmpDir(); + const name = 'external-runtime-two-stores'; + const runtimeModule = new URL('./fixtures/custom-runtime-provider.js', import.meta.url).href; + resetRuntimeCreateCallCount(); + + try { + const { files } = await transpile(fixture, { + name, + instantiation: 'sync', + runtimeModule, + }); + 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); + } finally { + await rm(outDir, { recursive: true, force: true }); + } + }); +}); + suite('Trap detection', () => { test.concurrent('does not lock down sibling instances after a trap', async () => { const outDir = await getTmpDir(); diff --git a/packages/jco-transpile/test/fixtures/browser/transpile.js b/packages/jco-transpile/test/fixtures/browser/transpile.js index 625d2be45..98eac1c6e 100644 --- a/packages/jco-transpile/test/fixtures/browser/transpile.js +++ b/packages/jco-transpile/test/fixtures/browser/transpile.js @@ -73,8 +73,7 @@ async function transpileOne(componentPath) { // for resolving relative URLs. In instantiation mode that is only safe because nothing is // fetched relative to the module: the core modules are handed over via getCoreModule // below (bindgen otherwise falls back to fetching them next to `import.meta.url`) and the - // imports are passed in rather than imported, so the generated source has no static - // imports of its own. + // component imports are passed in rather than imported. const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); let module; try { diff --git a/packages/jco-transpile/test/fixtures/components/runtime/external-runtime-resource.component.wat b/packages/jco-transpile/test/fixtures/components/runtime/external-runtime-resource.component.wat new file mode 100644 index 000000000..bee5f3b26 --- /dev/null +++ b/packages/jco-transpile/test/fixtures/components/runtime/external-runtime-resource.component.wat @@ -0,0 +1,31 @@ +(component + (type $resource (resource (rep i32))) + (core func $new (canon resource.new $resource)) + (core func $rep (canon resource.rep $resource)) + (core instance $intrinsics + (export "new" (func $new)) + (export "rep" (func $rep)) + ) + (core module $module + (import "intrinsics" "new" (func $new (param i32) (result i32))) + (import "intrinsics" "rep" (func $rep (param i32) (result i32))) + (func (export "run") (param i32) (result i32) + local.get 0 + call $new + call $rep + ) + (func (export "invalid") (param i32) (result i32) + local.get 0 + call $rep + ) + ) + (core instance $instance (instantiate $module + (with "intrinsics" (instance $intrinsics)) + )) + (func (export "run") (param "value" u32) (result u32) + (canon lift (core func $instance "run")) + ) + (func (export "invalid") (param "handle" u32) (result u32) + (canon lift (core func $instance "invalid")) + ) +) diff --git a/packages/jco-transpile/test/fixtures/custom-runtime-provider.js b/packages/jco-transpile/test/fixtures/custom-runtime-provider.js new file mode 100644 index 000000000..ca7a4800e --- /dev/null +++ b/packages/jco-transpile/test/fixtures/custom-runtime-provider.js @@ -0,0 +1,40 @@ +export let tableGetCallCount = 0; +export let runtimeCreateCallCount = 0; + +export function resetTableGetCallCount() { + tableGetCallCount = 0; +} + +export function resetRuntimeCreateCallCount() { + runtimeCreateCallCount = 0; +} + +export const runtime = { + abiVersion: 1, + create(options) { + if (options.requestedAbiVersion !== 1) { + throw new Error(`unsupported runtime ABI ${options.requestedAbiVersion}`); + } + runtimeCreateCallCount++; + const RuntimeError = options.platform?.WebAssembly?.RuntimeError ?? WebAssembly.RuntimeError; + return { + abiVersion: 1, + intrinsics: { + resource: { + tableFlag: 1 << 30, + tableGet(table, handle) { + tableGetCallCount++; + const scope = table[handle << 1]; + const value = table[(handle << 1) + 1]; + const own = (value & this.tableFlag) !== 0; + const rep = value & ~this.tableFlag; + if (value === undefined || rep === 0 || (scope & this.tableFlag) !== 0) { + throw new RuntimeError(`unknown handle index ${handle}`); + } + return { rep, scope, own }; + }, + }, + }, + }; + }, +}; diff --git a/packages/jco-transpile/test/fixtures/external-runtime/override-platform.mjs b/packages/jco-transpile/test/fixtures/external-runtime/override-platform.mjs new file mode 100644 index 000000000..9adce3a77 --- /dev/null +++ b/packages/jco-transpile/test/fixtures/external-runtime/override-platform.mjs @@ -0,0 +1,35 @@ +// Run separately so removing the global does not affect the transpiler or other tests. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +const moduleUrl = pathToFileURL(process.argv[2]); +const native = globalThis.WebAssembly; +class CustomRuntimeError extends Error {} +const wasm = Object.assign(Object.create(native), { RuntimeError: CustomRuntimeError }); +const options = { shim: { globals: { WebAssembly: wasm } } }; +const load = (name) => new native.Module(readFileSync(new URL(name, moduleUrl))); + +delete globalThis.WebAssembly; +const { instantiate } = await import(moduleUrl); + +async function checkRuntime() { + const instance = await instantiate(load, {}, undefined, options); + assert.equal(instance.run(42), 42); + assert.throws(() => instance.invalid(0), { + constructor: CustomRuntimeError, + message: 'unknown handle index 0', + }); + assert.throws(() => instance.run(24), { + constructor: CustomRuntimeError, + message: 'wasm trap: cannot enter component instance', + }); +} + +await checkRuntime(); +Object.defineProperty(globalThis, 'WebAssembly', { + get() { + throw new Error('accessed the platform WebAssembly'); + }, +}); +await checkRuntime(); diff --git a/packages/jco-transpile/test/helpers.ts b/packages/jco-transpile/test/helpers.ts index 52b1e6e9b..ddf3fea8c 100644 --- a/packages/jco-transpile/test/helpers.ts +++ b/packages/jco-transpile/test/helpers.ts @@ -640,6 +640,13 @@ export async function readFixtureFlags(fixturePath: string): Promise { return; } + const instrumentRuntime = relPath === 'async/passing-resources.wast'; + if (instrumentRuntime) { + resetTableGetCallCount(); + } + const runtimeModule = instrumentRuntime + ? new URL('../../../fixtures/custom-runtime-provider.js', import.meta.url).href + : undefined; const mod = (await import(pathToFileURL(scriptPath).href)) as WastTestModule; const artifacts = mod.wastTestArtifacts ?? []; const requiresInstance = mod.wastTestRequiresInstance ?? true; @@ -230,13 +238,13 @@ suite('component-model WAST', () => { name: componentName, path: wasmPath, }, - // jco: { - // transpile: { - // extraArgs: { - // minify: false, - // }, - // }, - // }, + jco: runtimeModule + ? { + transpile: { + extraArgs: { runtimeModule }, + }, + } + : undefined, }); cleanups.push(setup.cleanup); instance = setup.instance; @@ -277,6 +285,13 @@ suite('component-model WAST', () => { outputDir, skipInstantiation: true, }, + jco: runtimeModule + ? { + transpile: { + extraArgs: { runtimeModule }, + }, + } + : undefined, }); cleanups.push(setup.cleanup); return async () => setup.esModule.instantiate(undefined, {}); @@ -301,6 +316,13 @@ suite('component-model WAST', () => { assert, expect, }); + if (instrumentRuntime) { + assert.isAbove( + tableGetCallCount, + 0, + 'resource.rep should call resource.tableGet on the selected runtime provider', + ); + } } finally { for (const cleanup of cleanups.reverse()) { await cleanup(); diff --git a/packages/jco/src/cmd/transpile.ts b/packages/jco/src/cmd/transpile.ts index c1ef5a947..8d51bba6d 100644 --- a/packages/jco/src/cmd/transpile.ts +++ b/packages/jco/src/cmd/transpile.ts @@ -170,6 +170,8 @@ export interface TranspileOpts { quiet?: boolean; noTypescript?: boolean; wasiShim?: boolean; + /** Optional module specifier imported by generated bindings, never loaded during transpilation. */ + runtimeModule?: string; flagsAsBigInt?: boolean; variantsInlineCases?: boolean; useNamespaceObjects?: boolean; diff --git a/packages/jco/src/jco.ts b/packages/jco/src/jco.ts index c888d3e62..e91fbdb48 100755 --- a/packages/jco/src/jco.ts +++ b/packages/jco/src/jco.ts @@ -190,6 +190,7 @@ program ) .option("--bindgen-enable-wasm-exnref", "enable bindgen output that uses Wasm exception references (exnref)") .option("--strict", "generate bindings with strict type checking") + .option("--runtime-module ", "module imported by generated bindings for the Component Model runtime") .option("--flags-as-bigint", "represent WIT flags as bigint values") .option("--variants-inline-cases", "inline WIT variant cases in discriminated unions") .addOption( diff --git a/packages/jco/test/runtime-module.js b/packages/jco/test/runtime-module.js new file mode 100644 index 000000000..38709ba5d --- /dev/null +++ b/packages/jco/test/runtime-module.js @@ -0,0 +1,144 @@ +import { copyFile, cp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { describe, expect, test, vi } from "vitest"; + +// Jco depends on the last published jco-transpile release. Exercise the local +// implementation while testing this branch's Jco integration. +vi.mock("@bytecodealliance/jco-transpile", async () => import("../../jco-transpile/src/index.js")); + +import { transpile } from "../src/api.js"; +import { transpileCmd } from "../src/cmd/transpile.js"; +import { exec, getTmpDir } from "./helpers.js"; + +const fixture = fileURLToPath( + new URL("../../jco-transpile/test/fixtures/components/runtime/resources.2.component.wat", import.meta.url), +); +const runtimeModule = "test-runtime-provider"; +const runtimeFixture = new URL("../../jco-transpile/test/fixtures/custom-runtime-provider.js", import.meta.url); +const jcoPackageDir = fileURLToPath(new URL("../", import.meta.url)); +const localTranspilerDir = fileURLToPath(new URL("../../jco-transpile/", import.meta.url)); + +async function installRuntimeInConsumer(dir) { + const packageDir = join(dir, "node_modules", runtimeModule); + await mkdir(packageDir, { recursive: true }); + await writeFile(join(packageDir, "package.json"), JSON.stringify({ name: runtimeModule, type: "module" })); + await copyFile(runtimeFixture, join(packageDir, "index.js")); + const provider = await import(pathToFileURL(join(packageDir, "index.js")).href); + provider.resetRuntimeCreateCallCount(); + return provider; +} + +async function instantiateFromDir(dir, name) { + await writeFile(join(dir, "package.json"), JSON.stringify({ type: "module" })); + const bindings = await import(pathToFileURL(join(dir, `${name}.js`)).href); + bindings.instantiate((moduleName) => new WebAssembly.Module(readFileSync(join(dir, moduleName))), {}); +} + +async function installCliWithLocalTranspiler(dir) { + const installDir = join(dir, "jco-install"); + await mkdir(installDir); + await cp(join(jcoPackageDir, "dist"), join(installDir, "dist"), { recursive: true }); + await cp(join(jcoPackageDir, "lib"), join(installDir, "lib"), { recursive: true }); + await copyFile(join(jcoPackageDir, "package.json"), join(installDir, "package.json")); + + const manifest = JSON.parse(await readFile(join(jcoPackageDir, "package.json"), "utf8")); + for (const name of Object.keys(manifest.dependencies)) { + const source = + name === "@bytecodealliance/jco-transpile" + ? localTranspilerDir + : await realpath(join(jcoPackageDir, "node_modules", name)); + const destination = join(installDir, "node_modules", name); + await mkdir(dirname(destination), { recursive: true }); + await symlink(source, destination, process.platform === "win32" ? "junction" : "dir"); + } + return join(installDir, "dist", "jco.js"); +} + +describe("custom Component Model runtime", () => { + test("neither Jco package depends on the optional runtime", async () => { + for (const path of ["../package.json", "../../jco-transpile/package.json"]) { + const manifest = JSON.parse(await readFile(new URL(path, import.meta.url), "utf8")); + expect(manifest.dependencies).not.toHaveProperty("@bytecodealliance/jco-cm-runtime"); + } + }); + + test("the API imports a caller-supplied runtime only in generated bindings", async () => { + const dir = await getTmpDir(); + const name = "runtime-api"; + + try { + const provider = await installRuntimeInConsumer(dir); + const { files } = await transpile(await readFile(fixture), { + name, + instantiation: "sync", + runtimeModule, + }); + for (const [filename, bytes] of Object.entries(files)) { + await mkdir(dirname(join(dir, filename)), { recursive: true }); + await writeFile(join(dir, filename), bytes); + } + const source = await readFile(join(dir, `${name}.js`), "utf8"); + expect(source).toContain(`from "${runtimeModule}"`); + await instantiateFromDir(dir, name); + expect(provider.runtimeCreateCallCount).toBe(1); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("the command handler forwards runtimeModule to generated bindings", async () => { + const dir = await getTmpDir(); + const name = "runtime-cli"; + + try { + const provider = await installRuntimeInConsumer(dir); + await transpileCmd(fixture, { + name, + outDir: dir, + instantiation: "sync", + runtimeModule, + quiet: true, + }); + const source = await readFile(join(dir, `${name}.js`), "utf8"); + expect(source).toContain(`from "${runtimeModule}"`); + await instantiateFromDir(dir, name); + expect(provider.runtimeCreateCallCount).toBe(1); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("the CLI uses a local transpiler and a runtime installed only with its output", async () => { + const root = await getTmpDir(); + const dir = join(root, "output"); + const name = "runtime-cli-e2e"; + + try { + await mkdir(dir); + const jcoCli = await installCliWithLocalTranspiler(root); + const provider = await installRuntimeInConsumer(dir); + await exec( + jcoCli, + "transpile", + fixture, + "--name", + name, + "--out-dir", + dir, + "--instantiation", + "sync", + "--runtime-module", + runtimeModule, + ); + const source = await readFile(join(dir, `${name}.js`), "utf8"); + expect(source).toContain(`from "${runtimeModule}"`); + await instantiateFromDir(dir, name); + expect(provider.runtimeCreateCallCount).toBe(1); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1b384218..50486dd3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -523,6 +523,15 @@ importers: specifier: ^2.0.2 version: 2.0.2 + packages/jco-cm-runtime: + devDependencies: + typescript: + specifier: 'catalog:' + version: 7.0.2 + vitest: + specifier: 'catalog:' + version: 4.1.11(@types/node@24.13.3)(vite@8.3.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.6.1)) + packages/jco-node-fs: devDependencies: '@napi-rs/cli': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bbf157196..734b456ed 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,6 +19,7 @@ overrides: packages: - crates/jco-tests - packages/jco + - packages/jco-cm-runtime - packages/bare-jco - packages/jco-transpile - packages/jco-std diff --git a/scripts/build-browser-bundle.mjs b/scripts/build-browser-bundle.mjs index 7b5ab7a25..6bd597493 100644 --- a/scripts/build-browser-bundle.mjs +++ b/scripts/build-browser-bundle.mjs @@ -2,7 +2,7 @@ import { dirname, join, basename } from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { mkdtempSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; -import process from "node:process"; +import process from 'node:process'; import { tmpdir } from 'node:os'; const root = dirname(dirname(fileURLToPath(import.meta.url))); @@ -29,7 +29,7 @@ function findTarball(pattern) { async function main() { try { - // Pack the tarballs for current versions of jco-transpile and jco + // Pack jco-transpile and jco independently of the optional runtime. for (const project of ['@bytecodealliance/jco-transpile', '@bytecodealliance/jco']) { run('pnpm', ['--filter', project, 'pack', '--pack-destination', packDir]); } @@ -50,9 +50,9 @@ async function main() { // Unzip the packed tarballs, since we need to install overriden dependencies for (const tarballPath of [jcoTarball, jcoTranspileTarball]) { - const pkgDir = basename(tarballPath).replace(/.tgz$/,''); + const pkgDir = basename(tarballPath).replace(/.tgz$/, ''); mkdirSync(join(packDir, pkgDir)); - execFileSync('tar', ['xzf', tarballPath, '--strip-components=1', '-C', pkgDir ], { + execFileSync('tar', ['xzf', tarballPath, '--strip-components=1', '-C', pkgDir], { encoding: 'utf8', cwd: packDir, }); @@ -61,16 +61,16 @@ async function main() { rmSync(jcoTarball); rmSync(jcoTranspileTarball); - // Install the latest jco-transpile into Jco, so we're dealing with the freshest code - const jcoPkgDir = jcoTarball.replace(/.tgz$/,''); - const jcoTranspilePkgDir = jcoTranspileTarball.replace(/.tgz$/,''); + // Install the freshly packed dependency chain into each parent package. + const jcoPkgDir = jcoTarball.replace(/.tgz$/, ''); + const jcoTranspilePkgDir = jcoTranspileTarball.replace(/.tgz$/, ''); try { // NOTE: pnpm add will *seem* to fail due to ignored build scripts, // but we can generally ignore this failure run('pnpm', ['add', jcoTranspilePkgDir], { cwd: jcoPkgDir, }); - } catch (err) {} + } catch {} // Create a project directory that we will use to test out the browser build mkdirSync(projectDir); @@ -106,15 +106,7 @@ if (typeof transpile !== "function") throw new Error("transpile not exported"); }); run( 'pnpm', - [ - 'exec', - 'rolldown', - 'main.js', - '--format=esm', - '--platform=browser', - '--file=out.js', - '--external=node:*', - ], + ['exec', 'rolldown', 'main.js', '--format=esm', '--platform=browser', '--file=out.js', '--external=node:*'], { cwd: projectDir }, ); @@ -126,8 +118,7 @@ if (typeof transpile !== "function") throw new Error("transpile not exported"); } } -main() - .catch(err => { - console.error(`ERROR: ${err}`); - process.exitCode = 1; - }); +main().catch((err) => { + console.error(`ERROR: ${err}`); + process.exitCode = 1; +});