From 92555644257a2707ede2a7ea96d8f84e595afc8c Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Wed, 2 Sep 2026 11:07:45 +0000 Subject: [PATCH 1/7] fix(bindgen): detect store-wide async deadlocks --- .../src/intrinsics/component.rs | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/crates/js-component-bindgen/src/intrinsics/component.rs b/crates/js-component-bindgen/src/intrinsics/component.rs index aae1be4da..ff6111658 100644 --- a/crates/js-component-bindgen/src/intrinsics/component.rs +++ b/crates/js-component-bindgen/src/intrinsics/component.rs @@ -16,6 +16,15 @@ pub enum ComponentIntrinsic { /// Shared trap state for all component instances in this generated store. GlobalStoreTrap, + /// Shared scheduling state for all component instances in this generated store. + GlobalStoreAsyncState, + + /// Schedule a store-wide deadlock check after queued guest work has drained. + CheckForDeadlock, + + /// Track a possibly asynchronous host operation as an external wake source. + TrackHostOperation, + /// Trap if the specified component instance may not currently leave. CheckMayLeave, @@ -78,6 +87,9 @@ impl ComponentIntrinsic { match self { Self::GlobalInstanceFlagsMap => "INSTANCE_FLAGS", Self::GlobalStoreTrap => "STORE_TRAP", + Self::GlobalStoreAsyncState => "STORE_ASYNC_STATE", + Self::CheckForDeadlock => "_checkForDeadlock", + Self::TrackHostOperation => "_trackHostOperation", Self::CheckMayLeave => "_checkMayLeave", Self::GuardMayLeave => "_guardMayLeave", Self::GlobalAsyncStateMap => "ASYNC_STATE", @@ -102,6 +114,96 @@ impl ComponentIntrinsic { uwriteln!(output, r#"const {var_name} = {{ error: null }};"#); } + Self::GlobalStoreAsyncState => { + let var_name = render_args.require_intrinsic(Self::GlobalStoreAsyncState); + uwriteln!( + output, + r#"const {var_name} = {{ deadlockCheck: null, pendingHostOperations: 0 }};"# + ); + } + + Self::CheckForDeadlock => { + let check_for_deadlock_fn = render_args.require_intrinsic(Self::CheckForDeadlock); + let async_state_map = render_args.require_intrinsic(Self::GlobalAsyncStateMap); + let store_async_state = render_args.require_intrinsic(Self::GlobalStoreAsyncState); + let store_trap = render_args.require_intrinsic(Self::GlobalStoreTrap); + let runtime_error_class = + render_args.require_intrinsic(Intrinsic::WebAssemblyRuntimeError); + output.push_str(&format!( + r#" + function {check_for_deadlock_fn}() {{ + if ({store_async_state}.deadlockCheck !== null || {store_trap}.error !== null) {{ return; }} + {store_async_state}.deadlockCheck = setTimeout(() => {{ + {store_async_state}.deadlockCheck = null; + if ({store_trap}.error !== null || {store_async_state}.pendingHostOperations > 0) {{ return; }} + + const suspendedTasks = new Set(); + for (const state of {async_state_map}.values()) {{ + if (state.hasPendingSchedulerWork()) {{ + state.runTickLoop(); + return; + }} + for (const meta of state.suspendedTaskMetas()) {{ + suspendedTasks.add(meta.task); + }} + }} + + const unresolvedRoots = new Set(); + for (const task of suspendedTasks) {{ + const root = task.getRootTask(); + if (!root.isResolvedState()) {{ unresolvedRoots.add(root); }} + }} + if (unresolvedRoots.size === 0) {{ return; }} + + const err = new {runtime_error_class}('wasm trap: deadlock detected: event loop cannot make further progress'); + {store_trap}.error = err; + for (const root of unresolvedRoots) {{ + root.setErrored(err); + root.reject(err); + }} + for (const task of suspendedTasks) {{ + if (!task.isResolvedState() && unresolvedRoots.has(task.getRootTask())) {{ + task.setErrored(err); + task.reject(err); + }} + }} + for (const state of {async_state_map}.values()) {{ state.runTickLoop(); }} + }}, 0); + }} + "#, + )); + } + + Self::TrackHostOperation => { + let track_host_operation_fn = + render_args.require_intrinsic(Self::TrackHostOperation); + let check_for_deadlock_fn = render_args.require_intrinsic(Self::CheckForDeadlock); + let async_state_map = render_args.require_intrinsic(Self::GlobalAsyncStateMap); + let store_async_state = render_args.require_intrinsic(Self::GlobalStoreAsyncState); + output.push_str(&format!( + r#" + function {track_host_operation_fn}(operation) {{ + const result = operation(); + if (result === null || + (typeof result !== 'object' && typeof result !== 'function') || + typeof result.then !== 'function') {{ + return result; + }} + + {store_async_state}.pendingHostOperations++; + return Promise.resolve(result).finally(() => {{ + {store_async_state}.pendingHostOperations--; + if ({store_async_state}.pendingHostOperations < 0) {{ + throw new Error('negative pending host operation count'); + }} + for (const state of {async_state_map}.values()) {{ state.runTickLoop(); }} + {check_for_deadlock_fn}(); + }}); + }} + "#, + )); + } + Self::CheckMayLeave => { let check_may_leave_fn = render_args.require_intrinsic(Self::CheckMayLeave); let instance_flags = render_args.require_intrinsic(Self::GlobalInstanceFlagsMap); @@ -185,6 +287,7 @@ impl ComponentIntrinsic { render_args.require_intrinsic(Intrinsic::WebAssemblyRuntimeError); let instance_flags = render_args.require_intrinsic(Self::GlobalInstanceFlagsMap); let store_trap = render_args.require_intrinsic(Self::GlobalStoreTrap); + let check_for_deadlock_fn = render_args.require_intrinsic(Self::CheckForDeadlock); output.push_str(&format!( r#" @@ -579,6 +682,7 @@ impl ComponentIntrinsic { task.notifyProgress(); this.runTickLoop(); + {check_for_deadlock_fn}(); return promise; }} @@ -600,12 +704,27 @@ impl ComponentIntrinsic { return meta.task.isRejected() || meta.readyFn(); }} + suspendedTaskMetas() {{ + return this.#suspendedTasksByTaskID.values(); + }} + + hasPendingSchedulerWork() {{ + if (this.#lockHandoffScheduled) {{ return true; }} + for (const meta of this.#suspendedTasksByTaskID.values()) {{ + if (meta.task.isRejected() || meta.readyFn()) {{ return true; }} + }} + return false; + }} + async runTickLoop() {{ if (this.#tickLoop !== null) {{ return; }} this.#tickLoop = 1; setTimeout(async () => {{ let result = this.tick(); while (result !== {component_async_state_class}.TickResult.DONE) {{ + if (result === {component_async_state_class}.TickResult.IDLE) {{ + {check_for_deadlock_fn}(); + }} // After resuming a task, re-tick as soon as the resumed // slice's microtask continuations have drained (timeout 0) // so queued sibling resumptions aren't charged the idle From 9b936c16fb30faebee7d8328ba011cf9361d8fd1 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Wed, 2 Sep 2026 11:07:55 +0000 Subject: [PATCH 2/7] fix(bindgen): track external async wake sources --- crates/js-component-bindgen/src/function_bindgen.rs | 4 +++- crates/js-component-bindgen/src/intrinsics/p3/async_future.rs | 4 +++- crates/js-component-bindgen/src/intrinsics/p3/async_stream.rs | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/js-component-bindgen/src/function_bindgen.rs b/crates/js-component-bindgen/src/function_bindgen.rs index ed636a61e..777ff653a 100644 --- a/crates/js-component-bindgen/src/function_bindgen.rs +++ b/crates/js-component-bindgen/src/function_bindgen.rs @@ -1928,6 +1928,8 @@ impl Bindgen for FunctionBindgen<'_> { let get_component_state = self.intrinsic(Intrinsic::Component( ComponentIntrinsic::GetOrCreateAsyncState, )); + let track_host_operation = + self.intrinsic(Intrinsic::Component(ComponentIntrinsic::TrackHostOperation)); let start_current_task_fn = self.intrinsic(Intrinsic::AsyncTask( AsyncTaskIntrinsic::CreateNewCurrentTask, )); @@ -2105,7 +2107,7 @@ impl Bindgen for FunctionBindgen<'_> { r#"{call_prefix} {call_wrapper}({{ componentIdx: task.componentIdx(), taskID: task.id(), - fn: () => {callee_fn_js}({callee_args_js}), + fn: () => {track_host_operation}(() => {callee_fn_js}({callee_args_js})), }}) "#, ); diff --git a/crates/js-component-bindgen/src/intrinsics/p3/async_future.rs b/crates/js-component-bindgen/src/intrinsics/p3/async_future.rs index d05b687dc..d740975fa 100644 --- a/crates/js-component-bindgen/src/intrinsics/p3/async_future.rs +++ b/crates/js-component-bindgen/src/intrinsics/p3/async_future.rs @@ -1771,6 +1771,8 @@ impl AsyncFutureIntrinsic { let gen_host_inject_fn = self.name(); let nested_future_symbol = render_args.require_intrinsic(Self::NestedFutureSymbol); let get_error_payload = render_args.require_intrinsic(Intrinsic::GetErrorPayload); + let track_host_operation = + render_args.require_intrinsic(ComponentIntrinsic::TrackHostOperation); uwriteln!( output, @@ -1798,7 +1800,7 @@ impl AsyncFutureIntrinsic { let value; try {{ - value = await promise; + value = await {track_host_operation}(() => promise); }} catch (err) {{ const elemMeta = hostWriteEnd.getElemMeta(); if (!elemMeta.payloadTypeName?.startsWith('Result(')) {{ diff --git a/crates/js-component-bindgen/src/intrinsics/p3/async_stream.rs b/crates/js-component-bindgen/src/intrinsics/p3/async_stream.rs index 0990a802e..2bd0cd11b 100644 --- a/crates/js-component-bindgen/src/intrinsics/p3/async_stream.rs +++ b/crates/js-component-bindgen/src/intrinsics/p3/async_stream.rs @@ -1838,6 +1838,8 @@ impl AsyncStreamIntrinsic { Self::PendingValueQueueClass => { let pending_value_queue_class = self.name(); + let track_host_operation = + render_args.require_intrinsic(ComponentIntrinsic::TrackHostOperation); output.push_str(&format!( r#" @@ -1882,7 +1884,7 @@ impl AsyncStreamIntrinsic { async readSource() {{ if (!this.#sourceReadPromise) {{ this.#sourceReadPromise = (async () => {{ - const res = await this.#readFn(); + const res = await {track_host_operation}(() => this.#readFn()); const appended = this.appendReadValue(res.value); this.#done = res.done; return appended; From fece2d7d68fcb8b356a4ace0ea49384e63adc57e Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Wed, 2 Sep 2026 11:08:03 +0000 Subject: [PATCH 3/7] test(bindgen): cover async deadlock scheduling --- .../src/intrinsics/mod.rs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/js-component-bindgen/src/intrinsics/mod.rs b/crates/js-component-bindgen/src/intrinsics/mod.rs index 15987dbe4..b43e9d725 100644 --- a/crates/js-component-bindgen/src/intrinsics/mod.rs +++ b/crates/js-component-bindgen/src/intrinsics/mod.rs @@ -1510,6 +1510,48 @@ mod tests { assert!(yield_.contains("return keepGoing ? 0 : 1;")); } + #[test] + fn async_scheduler_detects_store_wide_deadlocks() { + let state = render_intrinsic_body(Intrinsic::Component( + ComponentIntrinsic::ComponentAsyncStateClass, + )); + assert!(state.contains("_checkForDeadlock();")); + assert!(state.contains("suspendedTaskMetas()")); + assert!(state.contains("hasPendingSchedulerWork()")); + + let check = + render_intrinsic_body(Intrinsic::Component(ComponentIntrinsic::CheckForDeadlock)); + assert!(check.contains("for (const state of ASYNC_STATE.values())")); + assert!(check.contains("STORE_ASYNC_STATE.pendingHostOperations > 0")); + assert!(check.contains("const root = task.getRootTask();")); + assert!(check.contains( + "new WebAssemblyRuntimeError('wasm trap: deadlock detected: event loop cannot make further progress')" + )); + assert!(check.contains("for (const root of unresolvedRoots)")); + assert!(check.contains("root.reject(err);")); + assert!(check.contains("task.reject(err);")); + } + + #[test] + fn host_async_operations_suppress_deadlock_detection() { + let tracker = + render_intrinsic_body(Intrinsic::Component(ComponentIntrinsic::TrackHostOperation)); + assert!(tracker.contains("STORE_ASYNC_STATE.pendingHostOperations++;")); + assert!(tracker.contains("Promise.resolve(result).finally(() =>")); + assert!(tracker.contains("STORE_ASYNC_STATE.pendingHostOperations--;")); + assert!(tracker.contains("_checkForDeadlock();")); + + let future = render_intrinsic_body(Intrinsic::AsyncFuture( + AsyncFutureIntrinsic::GenFutureHostInjectFn, + )); + assert!(future.contains("await _trackHostOperation(() => promise);")); + + let stream = render_intrinsic_body(Intrinsic::AsyncStream( + AsyncStreamIntrinsic::PendingValueQueueClass, + )); + assert!(stream.contains("await _trackHostOperation(() => this.#readFn());")); + } + #[test] fn sync_start_fused_adapter_runs_in_the_caller_task() { let source = render_intrinsic_body(Intrinsic::Host(HostIntrinsic::SyncStartCall)); From 5ebde25499eb072f3fb88662b02eb1281e4b33bb Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Wed, 2 Sep 2026 11:08:13 +0000 Subject: [PATCH 4/7] test(transpile): enable deadlock WAST --- packages/jco-transpile/test/p3/ported/component-model/wast.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jco-transpile/test/p3/ported/component-model/wast.ts b/packages/jco-transpile/test/p3/ported/component-model/wast.ts index 56bb4b69a..b2f6fdfff 100644 --- a/packages/jco-transpile/test/p3/ported/component-model/wast.ts +++ b/packages/jco-transpile/test/p3/ported/component-model/wast.ts @@ -31,10 +31,10 @@ const WAST_TESTS: readonly WastTest[] = [ { relPath: 'async/drop-subtask.wast' }, { relPath: 'async/async-calls-sync.wast' }, { relPath: 'async/cancellable.wast' }, + { relPath: 'async/deadlock.wast' }, // Skipped tests { relPath: 'async/sync-streams.wast', skip: true }, - { relPath: 'async/deadlock.wast', skip: true }, { relPath: 'async/trap-if-block-and-sync.wast', skip: true }, { relPath: 'async/trap-on-reenter.wast', skip: true }, { relPath: 'async/sync-barges-in.wast', skip: true }, From 039f3a311174d725a3a13751e3032bb6bdae5430 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Wed, 2 Sep 2026 11:27:20 +0000 Subject: [PATCH 5/7] fix(bindgen): split sync return from callee exit --- .../src/intrinsics/mod.rs | 8 +- .../src/intrinsics/p3/host.rs | 122 +++++++++++------- 2 files changed, 84 insertions(+), 46 deletions(-) diff --git a/crates/js-component-bindgen/src/intrinsics/mod.rs b/crates/js-component-bindgen/src/intrinsics/mod.rs index b43e9d725..f28958526 100644 --- a/crates/js-component-bindgen/src/intrinsics/mod.rs +++ b/crates/js-component-bindgen/src/intrinsics/mod.rs @@ -1563,8 +1563,12 @@ mod tests { "fn: () => subtaskCallMeta.returnFn.apply(null, [subtaskCallMeta.resultPtr])," )); assert!(source.contains("preparedTask.registerOnResolveHandler(() => {")); - assert!(source.contains("await taskReturnPromise;")); - assert!(!source.contains("await _driverLoop({")); + assert!(source.contains("const calleeLifecyclePromise = (async () => {")); + assert!(source.contains("await _driverLoop({")); + assert!(source.contains("await Promise.race([")); + assert!(source.contains("calleeLifecyclePromise.then(() => taskReturnPromise),")); + assert!(source.contains("calleeComponentState.markTrapped(err);")); + assert!(source.contains("preparedTask.exit({ skipExclusiveLockCheck: true });")); } #[test] diff --git a/crates/js-component-bindgen/src/intrinsics/p3/host.rs b/crates/js-component-bindgen/src/intrinsics/p3/host.rs index e27950ef2..118f8ccfd 100644 --- a/crates/js-component-bindgen/src/intrinsics/p3/host.rs +++ b/crates/js-component-bindgen/src/intrinsics/p3/host.rs @@ -879,57 +879,91 @@ impl HostIntrinsic { jspiCallee = callee._cachedPromising; }} - let callbackResult; - try {{ - callbackResult = await {with_global_current_task_meta_async_fn}({{ - taskID: preparedTask.id(), - componentIdx: preparedTask.componentIdx(), - fn: () => {{ - return jspiCallee.apply(null, startRes); - }} - }}); - }} catch (err) {{ - // A trapped callee propagates to the (sync) caller; release - // the per-slice hold the driver loop will never pair. + const failCalleeLifecycle = (err) => {{ + {debug_log_fn}('[{sync_start_call_fn}()] callee lifecycle failed', {{ err }}); + + // The initial slice owns component entry until it either hands + // control to the callback driver or fails. A failure has no driver + // exit with which to pair this hold. if (preparedTask.needsExclusiveLock()) {{ calleeComponentState.exclusiveRelease(preparedTask.id()); }} + + preparedTask.setErrored(err); + if (!preparedTask.isResolvedState()) {{ + preparedTask.reject(err); + }} else {{ + // task.return may already have released the synchronous caller. + // Preserve a later trap on the store and reject the still-running + // root task when there is one, rather than losing the detached + // lifecycle failure. + calleeComponentState.markTrapped(err); + const rootTask = preparedTask.getRootTask(); + if (!rootTask.isResolvedState()) {{ + rootTask.setErrored(err); + rootTask.reject(err); + }} + }} throw err; - }} + }}; - if (!callbackFn) {{ - // Async-lifted without a callback (stackful lift): the callee ran - // to completion above; task.return already ran within it. - {debug_log_fn}('[{sync_start_call_fn}()] no callback, resolving w/ callee result', {{ - taskID: preparedTask.id(), - componentIdx: preparedTask.componentIdx(), - }}); - if (!preparedTask.isResolved()) {{ preparedTask.resolve([callbackResult]); }} - }} else {{ - const fnName = callbackFn.fnName ?? (''); - {debug_log_fn}('[{sync_start_call_fn}()] starting driver loop', {{ - fnName, - componentIdx: preparedTask.componentIdx(), - subtaskID: subtask.id(), - }}); - {async_driver_loop_fn}({{ - componentState: calleeComponentState, - task: preparedTask, - fnName, - isAsync: true, - callbackResult, - }}).catch(err => {{ - {debug_log_fn}('[{sync_start_call_fn}()] driver loop failed', {{ err }}); - if (!preparedTask.isResolvedState()) {{ - preparedTask.setErrored(err); - preparedTask.reject(err); + // Invocation begins immediately and runs through its first JSPI + // suspension while the callee owns component entry. Keep driving that + // invocation and its callback/exit protocol in the background after + // task.return releases the synchronous caller. + const calleeLifecyclePromise = (async () => {{ + let callbackResult; + try {{ + callbackResult = await {with_global_current_task_meta_async_fn}({{ + taskID: preparedTask.id(), + componentIdx: preparedTask.componentIdx(), + fn: () => {{ + return jspiCallee.apply(null, startRes); + }} + }}); + + if (!callbackFn) {{ + // Async-lifted without a callback (stackful lift): the callee + // ran to completion above; task.return already ran within it. + {debug_log_fn}('[{sync_start_call_fn}()] no callback, resolving w/ callee result', {{ + taskID: preparedTask.id(), + componentIdx: preparedTask.componentIdx(), + }}); + if (!preparedTask.isResolved()) {{ preparedTask.resolve([callbackResult]); }} + preparedTask.exit({{ skipExclusiveLockCheck: true }}); + return; }} - }}); - }} - // A sync lower blocks only until task.return supplies its result. - // The callback protocol may continue running after that return. - await taskReturnPromise; + const fnName = callbackFn.fnName ?? (''); + {debug_log_fn}('[{sync_start_call_fn}()] starting driver loop', {{ + fnName, + componentIdx: preparedTask.componentIdx(), + subtaskID: subtask.id(), + }}); + await {async_driver_loop_fn}({{ + componentState: calleeComponentState, + task: preparedTask, + fnName, + isAsync: true, + callbackResult, + }}); + const driverError = preparedTask.isErrored(); + if (driverError) {{ throw driverError; }} + }} catch (err) {{ + failCalleeLifecycle(err); + }} + }})(); + // If task.return wins the race below, retain a rejection handler on + // the detached lifecycle while failCalleeLifecycle propagates it to + // the task tree/store. + calleeLifecyclePromise.catch(() => {{}}); + + // A sync lower blocks only until task.return supplies its result, but + // an earlier initial-invocation failure must still trap the caller. + await Promise.race([ + taskReturnPromise, + calleeLifecyclePromise.then(() => taskReturnPromise), + ]); const callMeta = subtask.getCallMetadata(); const flatResult = callMeta?.returnFnResult; From d935ffa57bd74c466198beb1a6fbc8ee7595a15c Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Wed, 2 Sep 2026 11:27:54 +0000 Subject: [PATCH 6/7] test(transpile): enable sync-streams WAST --- packages/jco-transpile/test/p3/ported/component-model/wast.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jco-transpile/test/p3/ported/component-model/wast.ts b/packages/jco-transpile/test/p3/ported/component-model/wast.ts index b2f6fdfff..3993b9b70 100644 --- a/packages/jco-transpile/test/p3/ported/component-model/wast.ts +++ b/packages/jco-transpile/test/p3/ported/component-model/wast.ts @@ -32,9 +32,9 @@ const WAST_TESTS: readonly WastTest[] = [ { relPath: 'async/async-calls-sync.wast' }, { relPath: 'async/cancellable.wast' }, { relPath: 'async/deadlock.wast' }, + { relPath: 'async/sync-streams.wast' }, // Skipped tests - { relPath: 'async/sync-streams.wast', skip: true }, { relPath: 'async/trap-if-block-and-sync.wast', skip: true }, { relPath: 'async/trap-on-reenter.wast', skip: true }, { relPath: 'async/sync-barges-in.wast', skip: true }, From 821b02ba1c57d038d39d9308196b772d5b029796 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Thu, 3 Sep 2026 07:18:06 +0000 Subject: [PATCH 7/7] 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 | 3 + crates/js-component-bindgen/Cargo.toml | 3 + crates/js-component-bindgen/README.md | 5 + .../src/intrinsics/mod.rs | 108 ++++++++- .../src/intrinsics/resource.rs | 20 +- 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 | 4 + 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 | 31 ++- 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, 1142 insertions(+), 43 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 c5eeed56f..4b37ac0b1 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 @@ -115,6 +116,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 1ac74f335..0d269b226 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: | @@ -218,6 +229,11 @@ jobs: with: name: jco-build-ts path: packages/jco/dist + - name: Restore Component Model runtime build output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: jco-cm-runtime-dist + path: packages/jco-cm-runtime/dist - name: Restore artifacts for JS tests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -301,6 +317,11 @@ jobs: with: name: jco-build-ts path: packages/jco/dist + - name: Restore Component Model runtime build output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: jco-cm-runtime-dist + path: packages/jco-cm-runtime/dist - name: Restore artifacts for JS tests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 25a82fffc..533da0e92 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 @@ -275,6 +291,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 @@ -349,6 +370,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 @@ -424,6 +450,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 @@ -800,6 +831,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 @@ -939,6 +975,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 16100edc9..f5e262fe2 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 5eaf766d0..3170f1f5f 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 36db94772..c7873c884 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -667,6 +667,7 @@ dependencies = [ "wasm-encoder 0.254.0", "wasmparser 0.254.0", "wasmtime-environ", + "wat", "wit-bindgen-core", "wit-component", "wit-parser 0.254.0", diff --git a/crates/js-component-bindgen-component/src/lib.rs b/crates/js-component-bindgen-component/src/lib.rs index 729c6fed3..d5a242aa0 100644 --- a/crates/js-component-bindgen-component/src/lib.rs +++ b/crates/js-component-bindgen-component/src/lib.rs @@ -76,6 +76,7 @@ impl bindings::Guest for JsComponentBindgenComponent { .guest(options.guest.unwrap_or(false)) .maybe_async_mode(options.async_mode.map(Into::into)) .strict(options.strict.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 16da6dce7..bc9b2350c 100644 --- a/crates/js-component-bindgen-component/wit/js-component-bindgen.wit +++ b/crates/js-component-bindgen-component/wit/js-component-bindgen.wit @@ -98,6 +98,9 @@ world js-component-bindgen { /// Configure whether to generate code that includes strict type checks strict: 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 c3985bc90..f82d40325 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 f28958526..fdac1c962 100644 --- a/crates/js-component-bindgen/src/intrinsics/mod.rs +++ b/crates/js-component-bindgen/src/intrinsics/mod.rs @@ -7,6 +7,11 @@ use std::sync::Mutex; 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; @@ -167,6 +172,25 @@ pub enum Intrinsic { SuspendingImportWrapperFn, } +#[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),+ $(,)?) => { $( @@ -1268,6 +1292,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 << 1) + 1}`);" )); @@ -1340,11 +1366,15 @@ mod tests { assert!(intrinsics.contains(&dependency)); } + 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 remove_position = source.find("function rscTableRemove").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 < remove_position); assert!(get_position < transfer_position); assert!(remove_position < transfer_position); @@ -1602,7 +1632,8 @@ 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);")); @@ -1795,7 +1826,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 { @@ -1826,7 +1857,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) { @@ -1839,6 +1872,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)) @@ -1920,6 +1995,22 @@ fn emit_intrinsic( } 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 { JsHelperIntrinsic::get_global_names() .into_iter() @@ -1937,6 +2028,9 @@ impl Intrinsic { "imports", "instantiateCore", "isLE", + RUNTIME_PROVIDER_LOCAL_NAME, + RUNTIME_LOCAL_NAME, + RUNTIME_INTRINSICS_LOCAL_NAME, "scopeId", "symbolCabiDispose", "symbolCabiLower", @@ -2061,3 +2155,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 0146ed605..fa7430a9b 100644 --- a/crates/js-component-bindgen/src/intrinsics/resource.rs +++ b/crates/js-component-bindgen/src/intrinsics/resource.rs @@ -220,25 +220,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}; - if (rep === 0 || (scope & {table_flag}) !== 0) {{ - // Resource entries occupy scope/rep pairs after the table sentinel. - throw new {runtime_error}(`unknown handle index ${{(handle << 1) + 1}}`); - }} - 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 b8649a12a..ccc67d349 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, @@ -118,6 +123,9 @@ pub struct TranspileOpts { /// Configure whether to generate code that includes strict type checks #[builder(default)] pub strict: 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, @@ -146,6 +154,14 @@ pub struct TranspileOpts { pub supports_wasm_exnref: bool, } +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 { @@ -510,6 +526,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 09836e251..194fe01ee 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.22.0", "@bytecodealliance/preview3-shim": "^0.5.0", "binaryen": "^130.0.0", diff --git a/packages/jco-transpile/src/transpile.ts b/packages/jco-transpile/src/transpile.ts index 8c5a9840c..2c4b5d6a0 100644 --- a/packages/jco-transpile/src/transpile.ts +++ b/packages/jco-transpile/src/transpile.ts @@ -146,6 +146,9 @@ export interface TranspilationOptions { /** Whether to run bindgen in strict mode */ strict?: boolean; + /** ES module providing the Component Model runtime implementation */ + runtimeModule?: string; + /** Represent WIT flags as bigint values instead of objects of booleans */ flagsAsBigInt?: boolean; @@ -326,6 +329,7 @@ export async function transpileBytes( multiMemory: opts.multiMemory === true, bindgenEnableWasmExnref: opts.bindgenEnableWasmExnref === true, strict: opts.strict === 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 b0addbff3..42320fc7f 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 b57fde8c0..b6b011ae5 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 @@ -104,6 +105,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('locks 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 { let cleanup; try { + const instrumentRuntime = relPath === 'async/passing-resources.wast'; + if (instrumentRuntime) { + resetTableGetCallCount(); + } const setup = await setupAsyncTest({ asyncMode: 'jspi', component: { name: basename(relPath).replace('.wast', ''), path: wasmPath, }, - // jco: { - // transpile: { - // extraArgs: { - // minify: false, - // }, - // }, - // }, + jco: instrumentRuntime + ? { + transpile: { + extraArgs: { + runtimeModule: new URL( + '../../../fixtures/custom-runtime-provider.js', + import.meta.url, + ).href, + }, + }, + } + : undefined, }); cleanup = setup.cleanup; const instance = setup.instance; @@ -116,6 +126,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 { await cleanup?.(); } diff --git a/packages/jco/src/cmd/transpile.ts b/packages/jco/src/cmd/transpile.ts index f2e392326..1b5e38723 100644 --- a/packages/jco/src/cmd/transpile.ts +++ b/packages/jco/src/cmd/transpile.ts @@ -91,6 +91,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 d8d964a03..39371f460 100755 --- a/packages/jco/src/jco.ts +++ b/packages/jco/src/jco.ts @@ -156,6 +156,7 @@ program .option("--multi-memory", "optimized output for Wasm multi-memory") .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 3e1dcd68c..28b3d1fdc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -416,6 +416,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.10(@types/node@24.13.3)(vite@7.3.6(@types/node@24.13.3)(jiti@2.6.1)(lightningcss@1.33.0)) + packages/jco-node-fs: devDependencies: '@napi-rs/cli': @@ -467,6 +476,9 @@ importers: packages/jco-transpile: dependencies: + '@bytecodealliance/jco-cm-runtime': + specifier: workspace:^ + version: link:../jco-cm-runtime '@bytecodealliance/preview2-shim': specifier: ^0.22.0 version: 0.22.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1f133eea9..0165f31d0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ allowBuilds: 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);