From 3ad2dbcf766239c53bc89dbd44471b8fcfaa7aa9 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 3 Sep 2026 07:18:06 +0000 Subject: [PATCH 01/10] feat(bindgen): support external runtime provider --- .github/workflows/create-release-pr.yml | 7 + .github/workflows/main-windows.yml | 21 ++ .github/workflows/main.yml | 41 ++++ .github/workflows/release.yml | 28 +++ .github/workflows/tag-release.yml | 1 + Cargo.lock | 1 + .../js-component-bindgen-component/src/lib.rs | 1 + .../wit/js-component-bindgen.wit | 2 + crates/js-component-bindgen/Cargo.toml | 3 + crates/js-component-bindgen/README.md | 5 + .../src/intrinsics/mod.rs | 110 ++++++++- .../src/intrinsics/resource.rs | 21 +- crates/js-component-bindgen/src/lib.rs | 129 ++++++++++- crates/js-component-bindgen/src/names.rs | 34 +++ .../src/transpile_bindgen.rs | 27 ++- packages/jco-cm-runtime/CHANGELOG.md | 3 + packages/jco-cm-runtime/LICENSE | 219 ++++++++++++++++++ packages/jco-cm-runtime/README.md | 13 ++ packages/jco-cm-runtime/cliff.toml | 45 ++++ packages/jco-cm-runtime/package.json | 44 ++++ packages/jco-cm-runtime/src/index.ts | 17 ++ .../jco-cm-runtime/src/intrinsics/resource.ts | 18 ++ packages/jco-cm-runtime/src/runtime.ts | 44 ++++ packages/jco-cm-runtime/src/types.ts | 53 +++++ packages/jco-cm-runtime/test/resource.test.ts | 83 +++++++ packages/jco-cm-runtime/tsconfig.build.json | 10 + packages/jco-cm-runtime/tsconfig.json | 15 ++ packages/jco-transpile/package.json | 6 +- packages/jco-transpile/src/transpile.ts | 3 + packages/jco-transpile/test/browser/index.ts | 1 + packages/jco-transpile/test/codegen.ts | 63 +++++ .../test/fixtures/browser/transpile.js | 6 +- .../test/fixtures/custom-runtime-provider.js | 34 +++ packages/jco-transpile/test/helpers.ts | 10 + .../test/p3/ported/component-model/wast.ts | 36 ++- packages/jco/src/cmd/transpile.ts | 1 + packages/jco/src/jco.ts | 1 + pnpm-lock.yaml | 12 + pnpm-workspace.yaml | 1 + scripts/build-browser-bundle.mjs | 22 +- 40 files changed, 1147 insertions(+), 44 deletions(-) create mode 100644 packages/jco-cm-runtime/CHANGELOG.md create mode 100644 packages/jco-cm-runtime/LICENSE create mode 100644 packages/jco-cm-runtime/README.md create mode 100644 packages/jco-cm-runtime/cliff.toml create mode 100644 packages/jco-cm-runtime/package.json create mode 100644 packages/jco-cm-runtime/src/index.ts create mode 100644 packages/jco-cm-runtime/src/intrinsics/resource.ts create mode 100644 packages/jco-cm-runtime/src/runtime.ts create mode 100644 packages/jco-cm-runtime/src/types.ts create mode 100644 packages/jco-cm-runtime/test/resource.test.ts create mode 100644 packages/jco-cm-runtime/tsconfig.build.json create mode 100644 packages/jco-cm-runtime/tsconfig.json create mode 100644 packages/jco-transpile/test/fixtures/custom-runtime-provider.js 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 0d94deef8..f818d675b 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: | @@ -206,6 +217,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 @@ -292,6 +308,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 e3107a437..01f8e4ad3 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 @@ -270,6 +286,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 @@ -344,6 +365,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 @@ -421,6 +447,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 @@ -818,6 +849,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 @@ -988,6 +1024,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 59ac0fafe..b27933938 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 6a4d87217..8ae9b672f 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..461c0238b 100644 --- a/crates/js-component-bindgen/README.md +++ b/crates/js-component-bindgen/README.md @@ -30,6 +30,11 @@ $ cargo add js-component-bindgen ``` +Generated JavaScript that uses Component Model runtime intrinsics imports +`@bytecodealliance/jco-cm-runtime` by default. Applications using this crate +directly must make that package, or the module selected with +`TranspileOpts::runtime_module`, resolvable from the generated output. + # 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 44e762149..615cfeb89 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("const rscTableGet = _jcoIntrinsics.resource.tableGet;")); + assert!(!source.contains("function rscTableGet(table, handle)")); assert!( source.contains("throw new WebAssemblyRuntimeError(`unknown handle index ${handle}`);") ); @@ -1570,10 +1596,15 @@ mod tests { } assert!(!intrinsics.contains(&table_remove)); + let runtime_position = source.find("_jcoRuntimeProvider.create").unwrap(); + let get_position = source + .find("const rscTableGet = _jcoIntrinsics.resource.tableGet") + .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!(flag_position < get_position); + assert!(runtime_position < get_position); + assert!(get_position < flag_position); + assert!(flag_position < transfer_position); assert!(get_position < transfer_position); } @@ -2026,7 +2057,9 @@ mod tests { .build(), ); - assert!(source.contains("function rscTableGet(table, handle)")); + assert!(source.contains("const rscTableGet = _jcoIntrinsics.resource.tableGet;")); + assert!(!source.contains("function rscTableGet(table, handle)")); + 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]++;")); @@ -2434,7 +2467,7 @@ impl RenderIntrinsicsArgs<'_> { .lock() .expect("intrinsic dependency collector lock should not be poisoned") .insert(intrinsic); - intrinsic.name() + intrinsic.binding().local_name() } fn take_discovered_intrinsics(&self) -> BTreeSet { @@ -2465,7 +2498,9 @@ 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(), IntrinsicBinding::Inline { .. }) { + intrinsic.render(&mut source, args); + } let discovered = args.take_discovered_intrinsics(); for dependency in &discovered { if !rendered.contains_key(dependency) { @@ -2478,6 +2513,48 @@ fn render_intrinsics_discovered(args: &mut RenderIntrinsicsArgs<'_>) -> Source { } let mut output = Source::default(); + if uses_external_runtime(args.intrinsics) { + 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}', + }}); + 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() else { + continue; + }; + uwriteln!( + output, + "const {local_name} = {RUNTIME_INTRINSICS_LOCAL_NAME}.{path};" + ); + uwriteln!( + output, + "if (typeof {local_name} !== 'function') throw new TypeError('Jco Component Model runtime intrinsic {path} must be a function');" + ); + } + } if args .intrinsics .contains(&Intrinsic::Conversion(ConversionIntrinsic::F32ToI32)) @@ -2593,6 +2670,22 @@ const CONDITIONAL_SUSPENDING_3_I32_TO_VOID: &[u8] = &[ ]; impl Intrinsic { + pub(crate) fn binding(&self) -> IntrinsicBinding { + match self { + Self::Resource(ResourceIntrinsic::ResourceTableGet) => IntrinsicBinding::Runtime { + local_name: self.name(), + path: "resource.tableGet", + }, + _ => IntrinsicBinding::Inline { + local_name: self.name(), + }, + } + } + + pub(crate) fn is_runtime_provided(&self) -> bool { + matches!(self.binding(), 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 @@ -2629,6 +2722,9 @@ impl Intrinsic { "imports", "instantiateCore", "isLE", + RUNTIME_PROVIDER_LOCAL_NAME, + RUNTIME_LOCAL_NAME, + RUNTIME_INTRINSICS_LOCAL_NAME, "scopeId", "symbolCabiDispose", "symbolCabiLower", @@ -2809,3 +2905,7 @@ impl Intrinsic { ) } } + +pub(crate) fn uses_external_runtime(intrinsics: &BTreeSet) -> bool { + intrinsics.iter().any(Intrinsic::is_runtime_provided) +} diff --git a/crates/js-component-bindgen/src/intrinsics/resource.rs b/crates/js-component-bindgen/src/intrinsics/resource.rs index 4c1ee3c37..9919779e1 100644 --- a/crates/js-component-bindgen/src/intrinsics/resource.rs +++ b/crates/js-component-bindgen/src/intrinsics/resource.rs @@ -258,26 +258,7 @@ impl ResourceIntrinsic { } Self::ResourceTableGet => { - let table_flag = render_args.require_intrinsic(Self::ResourceTableFlag); - let runtime_error = - render_args.require_intrinsic(Intrinsic::WebAssemblyRuntimeError); - uwriteln!( - output, - r#" - function rscTableGet(table, handle) {{ - const scope = table[handle << 1]; - const val = table[(handle << 1) + 1]; - const own = (val & {table_flag}) !== 0; - const rep = val & ~{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) - if (val === undefined || rep === 0 || (scope & {table_flag}) !== 0) {{ - throw new {runtime_error}(`unknown handle index ${{handle}}`); - }} - return {{ rep, scope, own }}; - }} - "# - ) + unreachable!("resource.tableGet is provided by the external runtime") } Self::ResourceTableEnsureBorrowDrop => output.push_str( diff --git a/crates/js-component-bindgen/src/lib.rs b/crates/js-component-bindgen/src/lib.rs index 33cdf7a98..0fa3c9bd8 100644 --- a/crates/js-component-bindgen/src/lib.rs +++ b/crates/js-component-bindgen/src/lib.rs @@ -24,7 +24,7 @@ pub mod intrinsics; use intrinsics::Intrinsic; pub use transpile_bindgen::{ - AsyncMode, BindingsMode, ExportKind, InstantiationMode, TranspileOpts, + AsyncMode, BindingsMode, DEFAULT_RUNTIME_MODULE, ExportKind, InstantiationMode, TranspileOpts, }; use transpile_bindgen::{TranspileBindgenResult, transpile_bindgen}; @@ -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,114 @@ mod tests { &async_funcs, )); } + + #[test] + fn validates_runtime_module_specifiers() { + let mut default = TranspileOpts::default(); + normalize_namespace_object_options(&mut default).unwrap(); + assert_eq!(default.runtime_module(), DEFAULT_RUNTIME_MODULE); + + 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(), "./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); + let direct_import_position = direct_source + .find( + "import { runtime as _jcoRuntimeProvider } from \"@bytecodealliance/jco-cm-runtime\";", + ) + .unwrap(); + let direct_create_position = direct_source.find("_jcoRuntimeProvider.create(").unwrap(); + let direct_init_position = direct_source.find("const $init").unwrap(); + assert!(direct_import_position < direct_create_position); + assert!(direct_create_position < direct_init_position); + + 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 = _jcoIntrinsics.resource.tableGet;")); + 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(DEFAULT_RUNTIME_MODULE)); + } } 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..e093999f7 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, @@ -63,6 +66,8 @@ const MAX_FLAT_PARAMS: usize = 16; /// Maximum direct flat results for sync canonical lowering. const MAX_FLAT_RESULTS: usize = 1; +pub const DEFAULT_RUNTIME_MODULE: &str = "@bytecodealliance/jco-cm-runtime"; + #[derive(Debug, Default, Clone, bon::Builder)] pub struct TranspileOpts { pub name: String, @@ -134,6 +139,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, @@ -173,6 +181,14 @@ impl TranspileOpts { } } +impl TranspileOpts { + pub fn runtime_module(&self) -> &str { + self.runtime_module + .as_deref() + .unwrap_or(DEFAULT_RUNTIME_MODULE) + } +} + #[derive(Default, Clone, Debug)] #[non_exhaustive] pub enum AsyncMode { @@ -592,6 +608,13 @@ impl JsBindgen<'_> { .transpile_opts(opts) .build(); let js_intrinsics = render_intrinsics(render_args); + if uses_external_runtime(&self.all_intrinsics) { + uwriteln!( + output, + "import {{ runtime as {RUNTIME_PROVIDER_LOCAL_NAME} }} from {};", + js_string_literal(opts.runtime_module()), + ); + } // Write out instantiation if let Some(instantiation) = &self.opts.instantiation_mode { 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..dcdaa9397 --- /dev/null +++ b/packages/jco-cm-runtime/README.md @@ -0,0 +1,13 @@ +# `@bytecodealliance/jco-cm-runtime` + +The default 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. + +This package is currently experimental. Its runtime ABI is versioned +independently from 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..f94eb6fb8 --- /dev/null +++ b/packages/jco-cm-runtime/src/intrinsics/resource.ts @@ -0,0 +1,18 @@ +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; + if (rep === 0 || (scope & RESOURCE_TABLE_FLAG) !== 0) { + throw new RuntimeError(`unknown handle index ${(handle << 1) + 1}`); + } + 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..152352264 --- /dev/null +++ b/packages/jco-cm-runtime/src/runtime.ts @@ -0,0 +1,44 @@ +import { createResourceIntrinsics } from './intrinsics/resource.js'; +import { + RUNTIME_ABI_VERSION, + type ComponentModelRuntime, + type ComponentModelRuntimeProvider, + type RuntimeOptions, + type RuntimePlatform, +} from './types.js'; + +function defaultPlatform(): RuntimePlatform { + return { + WebAssembly: globalThis.WebAssembly, + }; +} + +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 defaults = defaultPlatform(); + const platform: RuntimePlatform = { + WebAssembly: options.platform?.WebAssembly ?? defaults.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..7d8a7f4b8 --- /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([ + { table: [TABLE_FLAG, 0], handle: 0, index: 1 }, + { table: [TABLE_FLAG, 0, TABLE_FLAG, 42], handle: 1, index: 3 }, + { table: [TABLE_FLAG, 0, 0, 0], handle: 1, index: 3 }, + { table: [TABLE_FLAG, 0], handle: 4, index: 9 }, + ])('traps for an invalid handle at index $index', ({ table, handle, index }) => { + const { tableGet } = createRuntime().intrinsics.resource; + + expect(() => tableGet(table, handle)).toThrowError(WebAssembly.RuntimeError); + expect(() => tableGet(table, handle)).toThrow(`unknown handle index ${index}`); + }); + + 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/package.json b/packages/jco-transpile/package.json index 1cee1c345..b9fd0d254 100644 --- a/packages/jco-transpile/package.json +++ b/packages/jco-transpile/package.json @@ -53,6 +53,7 @@ } }, "scripts": { + "setup:jco-cm-runtime:build": "pnpm run --filter '@bytecodealliance/jco-cm-runtime' build", "setup:jco:build": "pnpm run --filter '@bytecodealliance/jco' build", "setup:jco:build:release": "pnpm run --filter '@bytecodealliance/jco' build:release", "setup:jco:vendor": "cp ../jco/obj/*.core*.wasm vendor && cp ../jco/obj/*.js vendor && cp ../jco/obj/*.ts vendor && cp -r ../jco/obj/interfaces vendor", @@ -63,8 +64,8 @@ "lint": "oxlint", "lint:fix": "oxlint --fix", "build:ts": "tsc -p tsconfig.json", - "build": "pnpm run build:vendor && pnpm run build:ts", - "build:release": "pnpm run build:vendor:release && pnpm run build:ts", + "build": "pnpm run setup:jco-cm-runtime:build && pnpm run build:vendor && pnpm run build:ts", + "build:release": "pnpm run setup:jco-cm-runtime:build && pnpm run build:vendor:release && pnpm run build:ts", "test:build:components": "pnpm run -w build:test:components", "test": "vitest run -c test/vitest.ts", "test:extended": "vitest run -c test/vitest.extended.ts", @@ -72,6 +73,7 @@ "prepack": "pnpm run build:release" }, "dependencies": { + "@bytecodealliance/jco-cm-runtime": "workspace:^", "@bytecodealliance/preview2-shim": "^0.30.0", "@bytecodealliance/preview3-shim": "^0.10.0", "binaryen": "^132.0.0", diff --git a/packages/jco-transpile/src/transpile.ts b/packages/jco-transpile/src/transpile.ts index c438600b9..d2aa5bac1 100644 --- a/packages/jco-transpile/src/transpile.ts +++ b/packages/jco-transpile/src/transpile.ts @@ -160,6 +160,8 @@ export interface TranspilationOptions { * Strings are validated by default, as the canonical ABI requires. */ perfStringsSkipCopyUtf16Validation?: boolean; + /** ES module providing the Component Model runtime implementation */ + runtimeModule?: string; /** Represent WIT flags as bigint values instead of objects of booleans */ flagsAsBigInt?: boolean; @@ -344,6 +346,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/browser/index.ts b/packages/jco-transpile/test/browser/index.ts index af781bcb0..49e17d33f 100644 --- a/packages/jco-transpile/test/browser/index.ts +++ b/packages/jco-transpile/test/browser/index.ts @@ -130,6 +130,7 @@ suite('Browser', () => { jco: { transpile: { extraArgs: { + runtimeModule: `http://localhost:${serverPort}/jco-cm-runtime/dist/index.js`, asyncImports: ['something:test/test-interface#call-async'], asyncExports: ['run-async'], }, diff --git a/packages/jco-transpile/test/codegen.ts b/packages/jco-transpile/test/codegen.ts index 83d9b1bb9..842d69439 100644 --- a/packages/jco-transpile/test/codegen.ts +++ b/packages/jco-transpile/test/codegen.ts @@ -13,6 +13,7 @@ import { suite, test, assert, describe } from 'vitest'; import { readFixtureFlags, getTmpDir, getRandomPort } from './helpers.js'; import { getDefaultComponentFixtures, COMPONENT_FIXTURES_DIR } from './common.js'; +import { resetRuntimeCreateCallCount, runtimeCreateCallCount } 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 +154,68 @@ suite('Directive Prologue', () => { }); }); +suite('External Component Model runtime', () => { + const fixture = fileURLToPath(new URL('./fixtures/components/runtime/resources.2.component.wat', import.meta.url)); + + test('binds canon resource.rep through the default runtime', async () => { + const { files, imports } = await transpile(fixture, { name: 'external-runtime-resource' }); + const source = new TextDecoder().decode(files['external-runtime-resource.js']); + + assert.include(source, 'import { runtime as _jcoRuntimeProvider } from "@bytecodealliance/jco-cm-runtime";'); + assert.include(source, 'const rscTableGet = _jcoIntrinsics.resource.tableGet;'); + assert.notInclude(source, 'function rscTableGet(table, handle)'); + assert.include(source, 'rscTableGet('); + assert.lengthOf(source.match(/_jcoRuntimeProvider\.create\(/g) ?? [], 1); + 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('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..240fedda1 100644 --- a/packages/jco-transpile/test/fixtures/browser/transpile.js +++ b/packages/jco-transpile/test/fixtures/browser/transpile.js @@ -61,6 +61,7 @@ async function transpileOne(componentPath) { noNodejsCompat: true, instantiation: { tag: 'async' }, base64Cutoff: 1_000_000, + runtimeModule: new URL('/jco-cm-runtime/dist/index.js', location.href).href, map: WASI_MAP, }); const source = output.files.find(([name]) => name === 'test.js')?.[1]; @@ -73,8 +74,9 @@ 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. The only static import is + // the Component Model runtime, configured above with an absolute HTTP URL so it is + // resolvable from the blob module. const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); let module; try { 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..0a62b4ea9 --- /dev/null +++ b/packages/jco-transpile/test/fixtures/custom-runtime-provider.js @@ -0,0 +1,34 @@ +import { runtime as defaultRuntime } from '../../../jco-cm-runtime/dist/index.js'; + +export let tableGetCallCount = 0; +export let runtimeCreateCallCount = 0; + +export function resetTableGetCallCount() { + tableGetCallCount = 0; +} + +export function resetRuntimeCreateCallCount() { + runtimeCreateCallCount = 0; +} + +export const runtime = { + abiVersion: defaultRuntime.abiVersion, + create(options) { + runtimeCreateCallCount++; + const instance = defaultRuntime.create(options); + const resource = instance.intrinsics.resource; + return { + ...instance, + intrinsics: { + ...instance.intrinsics, + resource: { + ...resource, + tableGet(...args) { + tableGetCallCount++; + return resource.tableGet(...args); + }, + }, + }, + }; + }, +}; diff --git a/packages/jco-transpile/test/helpers.ts b/packages/jco-transpile/test/helpers.ts index 52b1e6e9b..7d4a0b47f 100644 --- a/packages/jco-transpile/test/helpers.ts +++ b/packages/jco-transpile/test/helpers.ts @@ -19,6 +19,8 @@ import { componentize } from '@bytecodealliance/componentize-js'; import { transpileBytes } from '../src/index.js'; import type { TranspilationOptions } from '../src/transpile.js'; +const DEFAULT_TEST_RUNTIME_MODULE = new URL('../../jco-cm-runtime/dist/index.js', import.meta.url).href; + /** Stable path to the jco's fixture directory containing WIT files */ export const JCO_WIT_FIXTURE_DIR = fileURLToPath(new URL('../../jco/test/fixtures/wit', import.meta.url)); @@ -259,6 +261,7 @@ export async function setupAsyncTest(args) { const transpileOpts: TranspilationOptions = { name: componentName, + runtimeModule: DEFAULT_TEST_RUNTIME_MODULE, minify: true, validLiftingOptimization: true, tlaCompat: true, @@ -640,6 +643,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..b99115545 100644 --- a/packages/jco/src/cmd/transpile.ts +++ b/packages/jco/src/cmd/transpile.ts @@ -170,6 +170,7 @@ export interface TranspileOpts { quiet?: boolean; noTypescript?: boolean; wasiShim?: boolean; + runtimeModule?: string; flagsAsBigInt?: boolean; variantsInlineCases?: boolean; useNamespaceObjects?: boolean; diff --git a/packages/jco/src/jco.ts b/packages/jco/src/jco.ts index 8404306f1..a990ab953 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 ", "Component Model runtime provider module") .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/pnpm-lock.yaml b/pnpm-lock.yaml index e1cb84c4d..d7371cca6 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': @@ -598,6 +607,9 @@ importers: packages/jco-transpile: dependencies: + '@bytecodealliance/jco-cm-runtime': + specifier: workspace:^ + version: link:../jco-cm-runtime '@bytecodealliance/preview2-shim': specifier: ^0.30.0 version: 0.30.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ba134941e..92b99d495 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..f7007b319 100644 --- a/scripts/build-browser-bundle.mjs +++ b/scripts/build-browser-bundle.mjs @@ -29,11 +29,16 @@ function findTarball(pattern) { async function main() { try { - // Pack the tarballs for current versions of jco-transpile and jco - for (const project of ['@bytecodealliance/jco-transpile', '@bytecodealliance/jco']) { + // Pack the tarballs for the runtime, jco-transpile, and jco. + for (const project of [ + '@bytecodealliance/jco-cm-runtime', + '@bytecodealliance/jco-transpile', + '@bytecodealliance/jco', + ]) { run('pnpm', ['--filter', project, 'pack', '--pack-destination', packDir]); } + const jcoRuntimeTarball = findTarball(/^bytecodealliance-jco-cm-runtime-.+\.tgz$/); const jcoTranspileTarball = findTarball(/^bytecodealliance-jco-transpile-.+\.tgz$/); const jcoTarball = findTarball(/^bytecodealliance-jco-\d.+\.tgz$/); @@ -49,7 +54,7 @@ async function main() { } // Unzip the packed tarballs, since we need to install overriden dependencies - for (const tarballPath of [jcoTarball, jcoTranspileTarball]) { + for (const tarballPath of [jcoTarball, jcoTranspileTarball, jcoRuntimeTarball]) { const pkgDir = basename(tarballPath).replace(/.tgz$/,''); mkdirSync(join(packDir, pkgDir)); execFileSync('tar', ['xzf', tarballPath, '--strip-components=1', '-C', pkgDir ], { @@ -60,17 +65,24 @@ async function main() { // Remove the tarballs rmSync(jcoTarball); rmSync(jcoTranspileTarball); + rmSync(jcoRuntimeTarball); - // Install the latest jco-transpile into Jco, so we're dealing with the freshest code + // Install the freshly packed dependency chain into each parent package. const jcoPkgDir = jcoTarball.replace(/.tgz$/,''); const jcoTranspilePkgDir = jcoTranspileTarball.replace(/.tgz$/,''); + const jcoRuntimePkgDir = jcoRuntimeTarball.replace(/.tgz$/,''); + try { + run('pnpm', ['add', jcoRuntimePkgDir], { + cwd: jcoTranspilePkgDir, + }); + } catch {} 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); From e14788d8bfcaee5a48f31566688ed032a078ecef Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 28 Sep 2026 18:06:25 +0000 Subject: [PATCH 02/10] fix(bindgen): make external runtime opt-in --- .../src/intrinsics/mod.rs | 63 ++++++++----- .../src/intrinsics/resource.rs | 21 ++++- crates/js-component-bindgen/src/lib.rs | 19 ++-- .../src/transpile_bindgen.rs | 14 +-- packages/jco-cm-runtime/README.md | 16 +++- packages/jco-transpile/package.json | 6 +- packages/jco-transpile/src/transpile.ts | 3 +- packages/jco-transpile/test/browser/index.ts | 1 - packages/jco-transpile/test/codegen.ts | 44 +++++++-- .../test/fixtures/browser/transpile.js | 5 +- .../test/fixtures/custom-runtime-provider.js | 25 +++-- packages/jco-transpile/test/helpers.ts | 3 - packages/jco/src/cmd/transpile.ts | 1 + packages/jco/src/jco.ts | 2 +- packages/jco/test/runtime-module.js | 91 +++++++++++++++++++ pnpm-lock.yaml | 3 - scripts/build-browser-bundle.mjs | 47 +++------- 17 files changed, 246 insertions(+), 118 deletions(-) create mode 100644 packages/jco/test/runtime-module.js diff --git a/crates/js-component-bindgen/src/intrinsics/mod.rs b/crates/js-component-bindgen/src/intrinsics/mod.rs index 615cfeb89..5ebc5b30d 100644 --- a/crates/js-component-bindgen/src/intrinsics/mod.rs +++ b/crates/js-component-bindgen/src/intrinsics/mod.rs @@ -1521,8 +1521,8 @@ mod tests { let remove = Intrinsic::Resource(ResourceIntrinsic::ResourceTableRemove); let (source, _) = render([get, remove]); - assert!(source.contains("const rscTableGet = _jcoIntrinsics.resource.tableGet;")); - assert!(!source.contains("function rscTableGet(table, handle)")); + assert!(source.contains("function rscTableGet(table, handle)")); + assert!(!source.contains("_jcoRuntimeProvider")); assert!( source.contains("throw new WebAssemblyRuntimeError(`unknown handle index ${handle}`);") ); @@ -1596,14 +1596,11 @@ mod tests { } assert!(!intrinsics.contains(&table_remove)); - let runtime_position = source.find("_jcoRuntimeProvider.create").unwrap(); - let get_position = source - .find("const rscTableGet = _jcoIntrinsics.resource.tableGet") - .unwrap(); + let get_position = source.find("function rscTableGet(table, handle)").unwrap(); let flag_position = source.find("const T_FLAG").unwrap(); let transfer_position = source.find("function resourceTransferBorrow").unwrap(); - assert!(runtime_position < get_position); - assert!(get_position < flag_position); + assert!(!source.contains("_jcoRuntimeProvider")); + assert!(flag_position < get_position); assert!(flag_position < transfer_position); assert!(get_position < transfer_position); } @@ -2057,8 +2054,8 @@ mod tests { .build(), ); - assert!(source.contains("const rscTableGet = _jcoIntrinsics.resource.tableGet;")); - assert!(!source.contains("function rscTableGet(table, handle)")); + 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);")); @@ -2467,7 +2464,9 @@ impl RenderIntrinsicsArgs<'_> { .lock() .expect("intrinsic dependency collector lock should not be poisoned") .insert(intrinsic); - intrinsic.binding().local_name() + intrinsic + .binding(self.transpile_opts.runtime_module.is_some()) + .local_name() } fn take_discovered_intrinsics(&self) -> BTreeSet { @@ -2498,7 +2497,10 @@ fn render_intrinsics_discovered(args: &mut RenderIntrinsicsArgs<'_>) -> Source { debug_assert!(args.take_discovered_intrinsics().is_empty()); let mut source = Source::default(); - if matches!(intrinsic.binding(), IntrinsicBinding::Inline { .. }) { + if matches!( + intrinsic.binding(args.transpile_opts.runtime_module.is_some()), + IntrinsicBinding::Inline { .. } + ) { intrinsic.render(&mut source, args); } let discovered = args.take_discovered_intrinsics(); @@ -2513,7 +2515,10 @@ fn render_intrinsics_discovered(args: &mut RenderIntrinsicsArgs<'_>) -> Source { } let mut output = Source::default(); - if uses_external_runtime(args.intrinsics) { + if uses_external_runtime( + args.intrinsics, + args.transpile_opts.runtime_module.is_some(), + ) { uwriteln!( output, r#" @@ -2542,7 +2547,9 @@ fn render_intrinsics_discovered(args: &mut RenderIntrinsicsArgs<'_>) -> Source { ); for intrinsic in args.intrinsics.iter() { - let IntrinsicBinding::Runtime { local_name, path } = intrinsic.binding() else { + let IntrinsicBinding::Runtime { local_name, path } = + intrinsic.binding(args.transpile_opts.runtime_module.is_some()) + else { continue; }; uwriteln!( @@ -2670,20 +2677,25 @@ const CONDITIONAL_SUSPENDING_3_I32_TO_VOID: &[u8] = &[ ]; impl Intrinsic { - pub(crate) fn binding(&self) -> IntrinsicBinding { + pub(crate) fn binding(&self, external_runtime: bool) -> IntrinsicBinding { match self { - Self::Resource(ResourceIntrinsic::ResourceTableGet) => IntrinsicBinding::Runtime { - local_name: self.name(), - path: "resource.tableGet", - }, + 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) -> bool { - matches!(self.binding(), IntrinsicBinding::Runtime { .. }) + 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 { @@ -2906,6 +2918,11 @@ impl Intrinsic { } } -pub(crate) fn uses_external_runtime(intrinsics: &BTreeSet) -> bool { - intrinsics.iter().any(Intrinsic::is_runtime_provided) +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/intrinsics/resource.rs b/crates/js-component-bindgen/src/intrinsics/resource.rs index 9919779e1..4c1ee3c37 100644 --- a/crates/js-component-bindgen/src/intrinsics/resource.rs +++ b/crates/js-component-bindgen/src/intrinsics/resource.rs @@ -258,7 +258,26 @@ impl ResourceIntrinsic { } Self::ResourceTableGet => { - unreachable!("resource.tableGet is provided by the external runtime") + let table_flag = render_args.require_intrinsic(Self::ResourceTableFlag); + let runtime_error = + render_args.require_intrinsic(Intrinsic::WebAssemblyRuntimeError); + uwriteln!( + output, + r#" + function rscTableGet(table, handle) {{ + const scope = table[handle << 1]; + const val = table[(handle << 1) + 1]; + const own = (val & {table_flag}) !== 0; + const rep = val & ~{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) + if (val === undefined || rep === 0 || (scope & {table_flag}) !== 0) {{ + throw new {runtime_error}(`unknown handle index ${{handle}}`); + }} + return {{ rep, scope, own }}; + }} + "# + ) } Self::ResourceTableEnsureBorrowDrop => output.push_str( diff --git a/crates/js-component-bindgen/src/lib.rs b/crates/js-component-bindgen/src/lib.rs index 0fa3c9bd8..15d550d62 100644 --- a/crates/js-component-bindgen/src/lib.rs +++ b/crates/js-component-bindgen/src/lib.rs @@ -24,7 +24,7 @@ pub mod intrinsics; use intrinsics::Intrinsic; pub use transpile_bindgen::{ - AsyncMode, BindingsMode, DEFAULT_RUNTIME_MODULE, ExportKind, InstantiationMode, TranspileOpts, + AsyncMode, BindingsMode, ExportKind, InstantiationMode, TranspileOpts, }; use transpile_bindgen::{TranspileBindgenResult, transpile_bindgen}; @@ -467,14 +467,14 @@ mod tests { fn validates_runtime_module_specifiers() { let mut default = TranspileOpts::default(); normalize_namespace_object_options(&mut default).unwrap(); - assert_eq!(default.runtime_module(), DEFAULT_RUNTIME_MODULE); + 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(), "./runtime.js"); + assert_eq!(custom.runtime_module.as_deref(), Some("./runtime.js")); for invalid in ["", "runtime\nmodule"] { let mut opts = TranspileOpts::builder() @@ -519,15 +519,8 @@ mod tests { ) .unwrap(); let direct_source = generated_javascript(&direct); - let direct_import_position = direct_source - .find( - "import { runtime as _jcoRuntimeProvider } from \"@bytecodealliance/jco-cm-runtime\";", - ) - .unwrap(); - let direct_create_position = direct_source.find("_jcoRuntimeProvider.create(").unwrap(); - let direct_init_position = direct_source.find("const $init").unwrap(); - assert!(direct_import_position < direct_create_position); - assert!(direct_create_position < direct_init_position); + assert!(direct_source.contains("function rscTableGet(table, handle)")); + assert!(!direct_source.contains("_jcoRuntimeProvider")); let transpiled = transpile( &component, @@ -570,6 +563,6 @@ mod tests { let source = generated_javascript(&transpiled); assert!(!source.contains("_jcoRuntimeProvider")); - assert!(!source.contains(DEFAULT_RUNTIME_MODULE)); + assert!(!source.contains("@bytecodealliance/jco-cm-runtime")); } } diff --git a/crates/js-component-bindgen/src/transpile_bindgen.rs b/crates/js-component-bindgen/src/transpile_bindgen.rs index e093999f7..69c7dc284 100644 --- a/crates/js-component-bindgen/src/transpile_bindgen.rs +++ b/crates/js-component-bindgen/src/transpile_bindgen.rs @@ -66,8 +66,6 @@ const MAX_FLAT_PARAMS: usize = 16; /// Maximum direct flat results for sync canonical lowering. const MAX_FLAT_RESULTS: usize = 1; -pub const DEFAULT_RUNTIME_MODULE: &str = "@bytecodealliance/jco-cm-runtime"; - #[derive(Debug, Default, Clone, bon::Builder)] pub struct TranspileOpts { pub name: String, @@ -181,14 +179,6 @@ impl TranspileOpts { } } -impl TranspileOpts { - pub fn runtime_module(&self) -> &str { - self.runtime_module - .as_deref() - .unwrap_or(DEFAULT_RUNTIME_MODULE) - } -} - #[derive(Default, Clone, Debug)] #[non_exhaustive] pub enum AsyncMode { @@ -608,11 +598,11 @@ impl JsBindgen<'_> { .transpile_opts(opts) .build(); let js_intrinsics = render_intrinsics(render_args); - if uses_external_runtime(&self.all_intrinsics) { + 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()), + js_string_literal(opts.runtime_module.as_deref().unwrap()), ); } diff --git a/packages/jco-cm-runtime/README.md b/packages/jco-cm-runtime/README.md index dcdaa9397..2e8c300a2 100644 --- a/packages/jco-cm-runtime/README.md +++ b/packages/jco-cm-runtime/README.md @@ -1,13 +1,25 @@ # `@bytecodealliance/jco-cm-runtime` -The default Component Model runtime for JavaScript generated by Jco's +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. +`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. diff --git a/packages/jco-transpile/package.json b/packages/jco-transpile/package.json index b9fd0d254..1cee1c345 100644 --- a/packages/jco-transpile/package.json +++ b/packages/jco-transpile/package.json @@ -53,7 +53,6 @@ } }, "scripts": { - "setup:jco-cm-runtime:build": "pnpm run --filter '@bytecodealliance/jco-cm-runtime' build", "setup:jco:build": "pnpm run --filter '@bytecodealliance/jco' build", "setup:jco:build:release": "pnpm run --filter '@bytecodealliance/jco' build:release", "setup:jco:vendor": "cp ../jco/obj/*.core*.wasm vendor && cp ../jco/obj/*.js vendor && cp ../jco/obj/*.ts vendor && cp -r ../jco/obj/interfaces vendor", @@ -64,8 +63,8 @@ "lint": "oxlint", "lint:fix": "oxlint --fix", "build:ts": "tsc -p tsconfig.json", - "build": "pnpm run setup:jco-cm-runtime:build && pnpm run build:vendor && pnpm run build:ts", - "build:release": "pnpm run setup:jco-cm-runtime:build && pnpm run build:vendor:release && pnpm run build:ts", + "build": "pnpm run build:vendor && pnpm run build:ts", + "build:release": "pnpm run build:vendor:release && pnpm run build:ts", "test:build:components": "pnpm run -w build:test:components", "test": "vitest run -c test/vitest.ts", "test:extended": "vitest run -c test/vitest.extended.ts", @@ -73,7 +72,6 @@ "prepack": "pnpm run build:release" }, "dependencies": { - "@bytecodealliance/jco-cm-runtime": "workspace:^", "@bytecodealliance/preview2-shim": "^0.30.0", "@bytecodealliance/preview3-shim": "^0.10.0", "binaryen": "^132.0.0", diff --git a/packages/jco-transpile/src/transpile.ts b/packages/jco-transpile/src/transpile.ts index d2aa5bac1..9b4d2cd24 100644 --- a/packages/jco-transpile/src/transpile.ts +++ b/packages/jco-transpile/src/transpile.ts @@ -160,7 +160,8 @@ export interface TranspilationOptions { * Strings are validated by default, as the canonical ABI requires. */ perfStringsSkipCopyUtf16Validation?: boolean; - /** ES module providing the Component Model runtime implementation */ + + /** 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 */ diff --git a/packages/jco-transpile/test/browser/index.ts b/packages/jco-transpile/test/browser/index.ts index 49e17d33f..af781bcb0 100644 --- a/packages/jco-transpile/test/browser/index.ts +++ b/packages/jco-transpile/test/browser/index.ts @@ -130,7 +130,6 @@ suite('Browser', () => { jco: { transpile: { extraArgs: { - runtimeModule: `http://localhost:${serverPort}/jco-cm-runtime/dist/index.js`, asyncImports: ['something:test/test-interface#call-async'], asyncExports: ['run-async'], }, diff --git a/packages/jco-transpile/test/codegen.ts b/packages/jco-transpile/test/codegen.ts index 842d69439..771927d68 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'; @@ -157,15 +157,18 @@ suite('Directive Prologue', () => { suite('External Component Model runtime', () => { const fixture = fileURLToPath(new URL('./fixtures/components/runtime/resources.2.component.wat', import.meta.url)); - test('binds canon resource.rep through the default runtime', async () => { + 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.include(source, 'import { runtime as _jcoRuntimeProvider } from "@bytecodealliance/jco-cm-runtime";'); - assert.include(source, 'const rscTableGet = _jcoIntrinsics.resource.tableGet;'); - assert.notInclude(source, 'function rscTableGet(table, handle)'); + assert.notInclude(source, '_jcoRuntimeProvider'); + assert.include(source, 'function rscTableGet(table, handle)'); assert.include(source, 'rscTableGet('); - assert.lengthOf(source.match(/_jcoRuntimeProvider\.create\(/g) ?? [], 1); assert.notInclude(imports, '@bytecodealliance/jco-cm-runtime'); }); @@ -190,6 +193,35 @@ suite('External Component Model runtime', () => { 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'; diff --git a/packages/jco-transpile/test/fixtures/browser/transpile.js b/packages/jco-transpile/test/fixtures/browser/transpile.js index 240fedda1..98eac1c6e 100644 --- a/packages/jco-transpile/test/fixtures/browser/transpile.js +++ b/packages/jco-transpile/test/fixtures/browser/transpile.js @@ -61,7 +61,6 @@ async function transpileOne(componentPath) { noNodejsCompat: true, instantiation: { tag: 'async' }, base64Cutoff: 1_000_000, - runtimeModule: new URL('/jco-cm-runtime/dist/index.js', location.href).href, map: WASI_MAP, }); const source = output.files.find(([name]) => name === 'test.js')?.[1]; @@ -74,9 +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 - // component imports are passed in rather than imported. The only static import is - // the Component Model runtime, configured above with an absolute HTTP URL so it is - // resolvable from the blob module. + // 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/custom-runtime-provider.js b/packages/jco-transpile/test/fixtures/custom-runtime-provider.js index 0a62b4ea9..fa7906a19 100644 --- a/packages/jco-transpile/test/fixtures/custom-runtime-provider.js +++ b/packages/jco-transpile/test/fixtures/custom-runtime-provider.js @@ -1,5 +1,3 @@ -import { runtime as defaultRuntime } from '../../../jco-cm-runtime/dist/index.js'; - export let tableGetCallCount = 0; export let runtimeCreateCallCount = 0; @@ -12,20 +10,27 @@ export function resetRuntimeCreateCallCount() { } export const runtime = { - abiVersion: defaultRuntime.abiVersion, + abiVersion: 1, create(options) { + if (options.requestedAbiVersion !== 1) { + throw new Error(`unsupported runtime ABI ${options.requestedAbiVersion}`); + } runtimeCreateCallCount++; - const instance = defaultRuntime.create(options); - const resource = instance.intrinsics.resource; return { - ...instance, + abiVersion: 1, intrinsics: { - ...instance.intrinsics, resource: { - ...resource, - tableGet(...args) { + tableGet(table, handle) { tableGetCallCount++; - return resource.tableGet(...args); + const flag = 1 << 30; + const scope = table[handle << 1]; + const value = table[(handle << 1) + 1]; + const own = (value & flag) !== 0; + const rep = value & ~flag; + if (rep === 0 || (scope & flag) !== 0) { + throw new WebAssembly.RuntimeError(`unknown handle index ${(handle << 1) + 1}`); + } + return { rep, scope, own }; }, }, }, diff --git a/packages/jco-transpile/test/helpers.ts b/packages/jco-transpile/test/helpers.ts index 7d4a0b47f..ddf3fea8c 100644 --- a/packages/jco-transpile/test/helpers.ts +++ b/packages/jco-transpile/test/helpers.ts @@ -19,8 +19,6 @@ import { componentize } from '@bytecodealliance/componentize-js'; import { transpileBytes } from '../src/index.js'; import type { TranspilationOptions } from '../src/transpile.js'; -const DEFAULT_TEST_RUNTIME_MODULE = new URL('../../jco-cm-runtime/dist/index.js', import.meta.url).href; - /** Stable path to the jco's fixture directory containing WIT files */ export const JCO_WIT_FIXTURE_DIR = fileURLToPath(new URL('../../jco/test/fixtures/wit', import.meta.url)); @@ -261,7 +259,6 @@ export async function setupAsyncTest(args) { const transpileOpts: TranspilationOptions = { name: componentName, - runtimeModule: DEFAULT_TEST_RUNTIME_MODULE, minify: true, validLiftingOptimization: true, tlaCompat: true, diff --git a/packages/jco/src/cmd/transpile.ts b/packages/jco/src/cmd/transpile.ts index b99115545..8d51bba6d 100644 --- a/packages/jco/src/cmd/transpile.ts +++ b/packages/jco/src/cmd/transpile.ts @@ -170,6 +170,7 @@ 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; diff --git a/packages/jco/src/jco.ts b/packages/jco/src/jco.ts index a990ab953..9f60deddc 100755 --- a/packages/jco/src/jco.ts +++ b/packages/jco/src/jco.ts @@ -190,7 +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 ", "Component Model runtime provider module") + .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..4c578bbc8 --- /dev/null +++ b/packages/jco/test/runtime-module.js @@ -0,0 +1,91 @@ +import { copyFile, mkdir, readFile, rm, 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 { 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); + +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))), {}); +} + +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 }); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7371cca6..3e44f4260 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -607,9 +607,6 @@ importers: packages/jco-transpile: dependencies: - '@bytecodealliance/jco-cm-runtime': - specifier: workspace:^ - version: link:../jco-cm-runtime '@bytecodealliance/preview2-shim': specifier: ^0.30.0 version: 0.30.0 diff --git a/scripts/build-browser-bundle.mjs b/scripts/build-browser-bundle.mjs index f7007b319..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,16 +29,11 @@ function findTarball(pattern) { async function main() { try { - // Pack the tarballs for the runtime, jco-transpile, and jco. - for (const project of [ - '@bytecodealliance/jco-cm-runtime', - '@bytecodealliance/jco-transpile', - '@bytecodealliance/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]); } - const jcoRuntimeTarball = findTarball(/^bytecodealliance-jco-cm-runtime-.+\.tgz$/); const jcoTranspileTarball = findTarball(/^bytecodealliance-jco-transpile-.+\.tgz$/); const jcoTarball = findTarball(/^bytecodealliance-jco-\d.+\.tgz$/); @@ -54,10 +49,10 @@ async function main() { } // Unzip the packed tarballs, since we need to install overriden dependencies - for (const tarballPath of [jcoTarball, jcoTranspileTarball, jcoRuntimeTarball]) { - const pkgDir = basename(tarballPath).replace(/.tgz$/,''); + for (const tarballPath of [jcoTarball, jcoTranspileTarball]) { + 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, }); @@ -65,17 +60,10 @@ async function main() { // Remove the tarballs rmSync(jcoTarball); rmSync(jcoTranspileTarball); - rmSync(jcoRuntimeTarball); // Install the freshly packed dependency chain into each parent package. - const jcoPkgDir = jcoTarball.replace(/.tgz$/,''); - const jcoTranspilePkgDir = jcoTranspileTarball.replace(/.tgz$/,''); - const jcoRuntimePkgDir = jcoRuntimeTarball.replace(/.tgz$/,''); - try { - run('pnpm', ['add', jcoRuntimePkgDir], { - cwd: jcoTranspilePkgDir, - }); - } catch {} + 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 @@ -118,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 }, ); @@ -138,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; +}); From c72cc8b725e5da71e24bdb07b0d554fdee6d381f Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Mon, 28 Sep 2026 18:08:37 +0000 Subject: [PATCH 03/10] test(jco): exercise runtime option through the CLI --- packages/jco/test/runtime-module.js | 57 ++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/jco/test/runtime-module.js b/packages/jco/test/runtime-module.js index 4c578bbc8..f1c564457 100644 --- a/packages/jco/test/runtime-module.js +++ b/packages/jco/test/runtime-module.js @@ -1,4 +1,4 @@ -import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +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'; @@ -11,13 +11,15 @@ vi.mock('@bytecodealliance/jco-transpile', async () => import('../../jco-transpi import { transpile } from '../src/api.js'; import { transpileCmd } from '../src/cmd/transpile.js'; -import { getTmpDir } from './helpers.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); @@ -35,6 +37,26 @@ async function instantiateFromDir(dir, name) { 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']) { @@ -88,4 +110,35 @@ describe('custom Component Model runtime', () => { 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 }); + } + }); }); From 62fb2c2afe5f14777e372be5583feb17d28c2e34 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 9 Oct 2026 05:28:47 +0000 Subject: [PATCH 04/10] fix(bindgen): preserve runtime WebAssembly overrides --- .../src/intrinsics/mod.rs | 1 + packages/jco-cm-runtime/src/runtime.ts | 9 +---- packages/jco-transpile/test/codegen.ts | 32 ++++++++++++++++- .../external-runtime-resource.component.wat | 31 ++++++++++++++++ .../external-runtime/override-platform.mjs | 35 +++++++++++++++++++ 5 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 packages/jco-transpile/test/fixtures/components/runtime/external-runtime-resource.component.wat create mode 100644 packages/jco-transpile/test/fixtures/external-runtime/override-platform.mjs diff --git a/crates/js-component-bindgen/src/intrinsics/mod.rs b/crates/js-component-bindgen/src/intrinsics/mod.rs index 5ebc5b30d..e98c2eda0 100644 --- a/crates/js-component-bindgen/src/intrinsics/mod.rs +++ b/crates/js-component-bindgen/src/intrinsics/mod.rs @@ -2534,6 +2534,7 @@ fn render_intrinsics_discovered(args: &mut RenderIntrinsicsArgs<'_>) -> Source { 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}}`); diff --git a/packages/jco-cm-runtime/src/runtime.ts b/packages/jco-cm-runtime/src/runtime.ts index 152352264..ee2ce4149 100644 --- a/packages/jco-cm-runtime/src/runtime.ts +++ b/packages/jco-cm-runtime/src/runtime.ts @@ -7,12 +7,6 @@ import { type RuntimePlatform, } from './types.js'; -function defaultPlatform(): RuntimePlatform { - return { - WebAssembly: globalThis.WebAssembly, - }; -} - function createRuntime(options: RuntimeOptions): ComponentModelRuntime { if (options.requestedAbiVersion !== RUNTIME_ABI_VERSION) { throw new Error( @@ -21,9 +15,8 @@ function createRuntime(options: RuntimeOptions): ComponentModelRuntime { ); } - const defaults = defaultPlatform(); const platform: RuntimePlatform = { - WebAssembly: options.platform?.WebAssembly ?? defaults.WebAssembly, + WebAssembly: options.platform?.WebAssembly ?? globalThis.WebAssembly, }; if (typeof platform.WebAssembly?.RuntimeError !== 'function') { diff --git a/packages/jco-transpile/test/codegen.ts b/packages/jco-transpile/test/codegen.ts index 771927d68..45bc1f5f7 100644 --- a/packages/jco-transpile/test/codegen.ts +++ b/packages/jco-transpile/test/codegen.ts @@ -10,7 +10,7 @@ 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 } from './fixtures/custom-runtime-provider.js'; @@ -156,6 +156,36 @@ 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 }); + } + }, + ); + } test('does not depend on the optional runtime package', async () => { const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); 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/external-runtime/override-platform.mjs b/packages/jco-transpile/test/fixtures/external-runtime/override-platform.mjs new file mode 100644 index 000000000..1f3fb33a4 --- /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 1', + }); + 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(); From da4bac5cb79245c7f8aa6b5548d0dbbbc76f7da5 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 9 Oct 2026 05:29:02 +0000 Subject: [PATCH 05/10] style(jco): format runtime CLI tests --- packages/jco/test/runtime-module.js | 104 ++++++++++++++-------------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/packages/jco/test/runtime-module.js b/packages/jco/test/runtime-module.js index f1c564457..38709ba5d 100644 --- a/packages/jco/test/runtime-module.js +++ b/packages/jco/test/runtime-module.js @@ -1,86 +1,86 @@ -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 { 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'; +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')); +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'; +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), + 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)); +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); + 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); + 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' })); + 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'); + 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')); + 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')); + 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' + name === "@bytecodealliance/jco-transpile" ? localTranspilerDir - : await realpath(join(jcoPackageDir, 'node_modules', name)); - const destination = join(installDir, 'node_modules', name); + : 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'); + await symlink(source, destination, process.platform === "win32" ? "junction" : "dir"); } - return join(installDir, 'dist', 'jco.js'); + 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'); +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 () => { + test("the API imports a caller-supplied runtime only in generated bindings", async () => { const dir = await getTmpDir(); - const name = 'runtime-api'; + const name = "runtime-api"; try { const provider = await installRuntimeInConsumer(dir); const { files } = await transpile(await readFile(fixture), { name, - instantiation: 'sync', + 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'); + const source = await readFile(join(dir, `${name}.js`), "utf8"); expect(source).toContain(`from "${runtimeModule}"`); await instantiateFromDir(dir, name); expect(provider.runtimeCreateCallCount).toBe(1); @@ -89,20 +89,20 @@ describe('custom Component Model runtime', () => { } }); - test('the command handler forwards runtimeModule to generated bindings', async () => { + test("the command handler forwards runtimeModule to generated bindings", async () => { const dir = await getTmpDir(); - const name = 'runtime-cli'; + const name = "runtime-cli"; try { const provider = await installRuntimeInConsumer(dir); await transpileCmd(fixture, { name, outDir: dir, - instantiation: 'sync', + instantiation: "sync", runtimeModule, quiet: true, }); - const source = await readFile(join(dir, `${name}.js`), 'utf8'); + const source = await readFile(join(dir, `${name}.js`), "utf8"); expect(source).toContain(`from "${runtimeModule}"`); await instantiateFromDir(dir, name); expect(provider.runtimeCreateCallCount).toBe(1); @@ -111,10 +111,10 @@ describe('custom Component Model runtime', () => { } }); - test('the CLI uses a local transpiler and a runtime installed only with its output', async () => { + 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'; + const dir = join(root, "output"); + const name = "runtime-cli-e2e"; try { await mkdir(dir); @@ -122,18 +122,18 @@ describe('custom Component Model runtime', () => { const provider = await installRuntimeInConsumer(dir); await exec( jcoCli, - 'transpile', + "transpile", fixture, - '--name', + "--name", name, - '--out-dir', + "--out-dir", dir, - '--instantiation', - 'sync', - '--runtime-module', + "--instantiation", + "sync", + "--runtime-module", runtimeModule, ); - const source = await readFile(join(dir, `${name}.js`), 'utf8'); + const source = await readFile(join(dir, `${name}.js`), "utf8"); expect(source).toContain(`from "${runtimeModule}"`); await instantiateFromDir(dir, name); expect(provider.runtimeCreateCallCount).toBe(1); From 9383808fca949f4d2201fca5c4d23adc774aac24 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 9 Oct 2026 05:35:59 +0000 Subject: [PATCH 06/10] fix(bindgen): bind runtime intrinsics to their receivers --- .../src/intrinsics/mod.rs | 23 +++-- crates/js-component-bindgen/src/lib.rs | 3 +- packages/jco-transpile/test/codegen.ts | 93 ++++++++++++++++++- .../test/fixtures/custom-runtime-provider.js | 11 ++- 4 files changed, 115 insertions(+), 15 deletions(-) diff --git a/crates/js-component-bindgen/src/intrinsics/mod.rs b/crates/js-component-bindgen/src/intrinsics/mod.rs index e98c2eda0..087df51b5 100644 --- a/crates/js-component-bindgen/src/intrinsics/mod.rs +++ b/crates/js-component-bindgen/src/intrinsics/mod.rs @@ -2553,14 +2553,21 @@ fn render_intrinsics_discovered(args: &mut RenderIntrinsicsArgs<'_>) -> Source { else { continue; }; - uwriteln!( - output, - "const {local_name} = {RUNTIME_INTRINSICS_LOCAL_NAME}.{path};" - ); - uwriteln!( - output, - "if (typeof {local_name} !== 'function') throw new TypeError('Jco Component Model runtime intrinsic {path} must be a function');" - ); + 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 diff --git a/crates/js-component-bindgen/src/lib.rs b/crates/js-component-bindgen/src/lib.rs index 15d550d62..6c2ef1250 100644 --- a/crates/js-component-bindgen/src/lib.rs +++ b/crates/js-component-bindgen/src/lib.rs @@ -540,7 +540,8 @@ mod tests { let create_position = source.find("_jcoRuntimeProvider.create(").unwrap(); assert!(import_position < instantiate_position); assert!(instantiate_position < create_position); - assert!(source.contains("const rscTableGet = _jcoIntrinsics.resource.tableGet;")); + 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); diff --git a/packages/jco-transpile/test/codegen.ts b/packages/jco-transpile/test/codegen.ts index 45bc1f5f7..2a8efb827 100644 --- a/packages/jco-transpile/test/codegen.ts +++ b/packages/jco-transpile/test/codegen.ts @@ -13,7 +13,12 @@ import { suite, test, assert, describe } from 'vitest'; import { readFixtureFlags, getTmpDir, getRandomPort, nodeExec } from './helpers.js'; import { getDefaultComponentFixtures, COMPONENT_FIXTURES_DIR } from './common.js'; -import { resetRuntimeCreateCallCount, runtimeCreateCallCount } from './fixtures/custom-runtime-provider.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 @@ -187,6 +192,92 @@ suite('External Component Model runtime', () => { ); } + for (const instantiation of ['sync', 'async', undefined] as const) { + test.each([false, true])( + `preserves the intrinsic receiver (${instantiation ?? 'direct'}, minify=%s)`, + 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 1'); + 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'); diff --git a/packages/jco-transpile/test/fixtures/custom-runtime-provider.js b/packages/jco-transpile/test/fixtures/custom-runtime-provider.js index fa7906a19..f292530f9 100644 --- a/packages/jco-transpile/test/fixtures/custom-runtime-provider.js +++ b/packages/jco-transpile/test/fixtures/custom-runtime-provider.js @@ -16,19 +16,20 @@ export const runtime = { 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 flag = 1 << 30; const scope = table[handle << 1]; const value = table[(handle << 1) + 1]; - const own = (value & flag) !== 0; - const rep = value & ~flag; - if (rep === 0 || (scope & flag) !== 0) { - throw new WebAssembly.RuntimeError(`unknown handle index ${(handle << 1) + 1}`); + const own = (value & this.tableFlag) !== 0; + const rep = value & ~this.tableFlag; + if (rep === 0 || (scope & this.tableFlag) !== 0) { + throw new RuntimeError(`unknown handle index ${(handle << 1) + 1}`); } return { rep, scope, own }; }, From aa838deabde1d096cab6c1cc2972a7c17f4ba0b3 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 9 Oct 2026 05:36:59 +0000 Subject: [PATCH 07/10] docs(bindgen): describe the opt-in runtime ABI --- crates/js-component-bindgen/README.md | 10 ++++--- docs/src/transpiling.md | 1 + packages/jco-cm-runtime/README.md | 38 +++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/crates/js-component-bindgen/README.md b/crates/js-component-bindgen/README.md index 461c0238b..51ce6099f 100644 --- a/crates/js-component-bindgen/README.md +++ b/crates/js-component-bindgen/README.md @@ -30,10 +30,12 @@ $ cargo add js-component-bindgen ``` -Generated JavaScript that uses Component Model runtime intrinsics imports -`@bytecodealliance/jco-cm-runtime` by default. Applications using this crate -directly must make that package, or the module selected with -`TranspileOpts::runtime_module`, resolvable from the generated output. +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 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/README.md b/packages/jco-cm-runtime/README.md index 2e8c300a2..1e569f961 100644 --- a/packages/jco-cm-runtime/README.md +++ b/packages/jco-cm-runtime/README.md @@ -23,3 +23,41 @@ 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. +- Slot `handle << 1` contains the scope. Bit 30 marks a vacant entry. +- Slot `(handle << 1) + 1` contains the representation, with bit 30 indicating + ownership. Clear that bit to obtain `rep`. +- A decoded representation of zero or a vacant entry must trap using the + selected `WebAssembly.RuntimeError` constructor. Missing slots decode to zero. + +Changes to this table encoding or these calling conventions require a new +runtime ABI version, independently of the npm package version. From d367d3b3cc4f18dbed1fcfea6b52cb41ff3a5bb3 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 9 Oct 2026 05:36:59 +0000 Subject: [PATCH 08/10] chore(bindgen): ignore runtime package build artifacts --- packages/jco-cm-runtime/.gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 packages/jco-cm-runtime/.gitignore 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 From bcf637307d4112e01b55da6c65a077cc8ca5758a Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Tue, 13 Oct 2026 16:45:20 +0000 Subject: [PATCH 09/10] fix(bindgen): trap on unknown handle indices in the external runtime --- packages/jco-cm-runtime/README.md | 13 ++++++++----- packages/jco-cm-runtime/src/intrinsics/resource.ts | 7 +++++-- packages/jco-cm-runtime/test/resource.test.ts | 12 ++++++------ packages/jco-transpile/test/codegen.ts | 2 +- .../test/fixtures/custom-runtime-provider.js | 4 ++-- .../fixtures/external-runtime/override-platform.mjs | 2 +- 6 files changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/jco-cm-runtime/README.md b/packages/jco-cm-runtime/README.md index 1e569f961..f3b51bd3a 100644 --- a/packages/jco-cm-runtime/README.md +++ b/packages/jco-cm-runtime/README.md @@ -52,12 +52,15 @@ providers must not rely on it for automatic cleanup. 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. -- Slot `handle << 1` contains the scope. Bit 30 marks a vacant entry. +- 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 decoded representation of zero or a vacant entry must trap using the - selected `WebAssembly.RuntimeError` constructor. Missing slots decode to zero. + 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/src/intrinsics/resource.ts b/packages/jco-cm-runtime/src/intrinsics/resource.ts index f94eb6fb8..bac17002c 100644 --- a/packages/jco-cm-runtime/src/intrinsics/resource.ts +++ b/packages/jco-cm-runtime/src/intrinsics/resource.ts @@ -9,8 +9,11 @@ export function createResourceIntrinsics(RuntimeError: RuntimeErrorConstructor): const value = table[(handle << 1) + 1]; const own = (value & RESOURCE_TABLE_FLAG) !== 0; const rep = value & ~RESOURCE_TABLE_FLAG; - if (rep === 0 || (scope & RESOURCE_TABLE_FLAG) !== 0) { - throw new RuntimeError(`unknown handle index ${(handle << 1) + 1}`); + // 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/test/resource.test.ts b/packages/jco-cm-runtime/test/resource.test.ts index 7d8a7f4b8..50eafb3cb 100644 --- a/packages/jco-cm-runtime/test/resource.test.ts +++ b/packages/jco-cm-runtime/test/resource.test.ts @@ -24,15 +24,15 @@ describe('resource.tableGet', () => { }); test.each([ - { table: [TABLE_FLAG, 0], handle: 0, index: 1 }, - { table: [TABLE_FLAG, 0, TABLE_FLAG, 42], handle: 1, index: 3 }, - { table: [TABLE_FLAG, 0, 0, 0], handle: 1, index: 3 }, - { table: [TABLE_FLAG, 0], handle: 4, index: 9 }, - ])('traps for an invalid handle at index $index', ({ table, handle, index }) => { + { 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 ${index}`); + expect(() => tableGet(table, handle)).toThrow(`unknown handle index ${handle}`); }); test('uses the injected RuntimeError realm', () => { diff --git a/packages/jco-transpile/test/codegen.ts b/packages/jco-transpile/test/codegen.ts index 2a8efb827..c10d46a34 100644 --- a/packages/jco-transpile/test/codegen.ts +++ b/packages/jco-transpile/test/codegen.ts @@ -215,7 +215,7 @@ suite('External Component Model runtime', () => { 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 1'); + 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 }); diff --git a/packages/jco-transpile/test/fixtures/custom-runtime-provider.js b/packages/jco-transpile/test/fixtures/custom-runtime-provider.js index f292530f9..ca7a4800e 100644 --- a/packages/jco-transpile/test/fixtures/custom-runtime-provider.js +++ b/packages/jco-transpile/test/fixtures/custom-runtime-provider.js @@ -28,8 +28,8 @@ export const runtime = { const value = table[(handle << 1) + 1]; const own = (value & this.tableFlag) !== 0; const rep = value & ~this.tableFlag; - if (rep === 0 || (scope & this.tableFlag) !== 0) { - throw new RuntimeError(`unknown handle index ${(handle << 1) + 1}`); + 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 index 1f3fb33a4..9adce3a77 100644 --- a/packages/jco-transpile/test/fixtures/external-runtime/override-platform.mjs +++ b/packages/jco-transpile/test/fixtures/external-runtime/override-platform.mjs @@ -18,7 +18,7 @@ async function checkRuntime() { assert.equal(instance.run(42), 42); assert.throws(() => instance.invalid(0), { constructor: CustomRuntimeError, - message: 'unknown handle index 1', + message: 'unknown handle index 0', }); assert.throws(() => instance.run(24), { constructor: CustomRuntimeError, From 1ac9c0392025e59ceb68e013b7543910cdaad030 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Sat, 17 Oct 2026 11:18:08 +0000 Subject: [PATCH 10/10] fix(ci): prepare local transpiler for runtime integration tests --- .github/workflows/main-windows.yml | 10 ++++++++++ .github/workflows/main.yml | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/.github/workflows/main-windows.yml b/.github/workflows/main-windows.yml index f818d675b..3e3cd3528 100644 --- a/.github/workflows/main-windows.yml +++ b/.github/workflows/main-windows.yml @@ -235,6 +235,16 @@ jobs: name: build-preview2-shim path: packages/preview2-shim/dist + # The runtime integration tests exercise the local transpiler through the API and CLI. + - name: Restore jco-transpile vendor + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: jco-transpile-vendor + path: packages/jco-transpile/vendor + + - name: Build workspace jco-transpile + run: pnpm --filter '@bytecodealliance/jco-transpile' run build:ts + - name: Build workspace jco-std run: pnpm --filter '@bytecodealliance/jco-std' run build:ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 01f8e4ad3..04cc4826d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -210,6 +210,16 @@ jobs: name: build-preview2-shim path: packages/preview2-shim/dist + # The runtime integration tests exercise the local transpiler through the API and CLI. + - name: Restore jco-transpile vendor + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: jco-transpile-vendor + path: packages/jco-transpile/vendor + + - name: Build workspace jco-transpile + run: pnpm --filter '@bytecodealliance/jco-transpile' run build:ts + # Temporarily exercise unpublished jco-std fixes through the workspace dependency. - name: Build workspace jco-std run: pnpm --filter '@bytecodealliance/jco-std' run build:ts