From be7fb09435524bb47a07b635e240621296065c1c Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 23 Oct 2026 19:35:25 +0900 Subject: [PATCH 1/6] feat(bindgen): generate script engines and script components --- Cargo.lock | 1 + .../js-component-bindgen-component/src/lib.rs | 43 + .../wit/js-component-bindgen.wit | 59 ++ crates/js-component-bindgen/Cargo.toml | 1 + crates/js-component-bindgen/src/lib.rs | 1 + crates/js-component-bindgen/src/script/mod.rs | 849 ++++++++++++++++++ .../src/script/runtime.js | 234 +++++ .../src/script/script-engine.wit | 29 + .../src/script/script.wit | 24 + 9 files changed, 1241 insertions(+) create mode 100644 crates/js-component-bindgen/src/script/mod.rs create mode 100644 crates/js-component-bindgen/src/script/runtime.js create mode 100644 crates/js-component-bindgen/src/script/script-engine.wit create mode 100644 crates/js-component-bindgen/src/script/script.wit diff --git a/Cargo.lock b/Cargo.lock index b27933938..73fce1596 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -665,6 +665,7 @@ dependencies = [ "log", "semver", "wasm-encoder 0.259.0", + "wasm-metadata", "wasmparser 0.259.0", "wasmtime-environ", "wat", diff --git a/crates/js-component-bindgen-component/src/lib.rs b/crates/js-component-bindgen-component/src/lib.rs index 75974f373..76e83d5e5 100644 --- a/crates/js-component-bindgen-component/src/lib.rs +++ b/crates/js-component-bindgen-component/src/lib.rs @@ -43,6 +43,9 @@ mod bindings { }); export!(JsComponentBindgenComponent); } +use bindings::exports::local::js_component_bindgen::scripts::{ + self, ScriptComponentOpts, ScriptEngineBackend, ScriptEngineWitInput, ScriptEngineWorld, +}; use bindings::{ AsyncImportsExports, AsyncMode, BindingsMode, EnabledFeatureSet, ExportType, GenerateOptions, InstantiationMode, PackageMismatch, Transpiled, TypeGenerationOptions, UnpackError, Wit, @@ -264,6 +267,46 @@ impl bindings::Guest for JsComponentBindgenComponent { } } +/// Implementation of the `local:js-component-bindgen/scripts` interface +impl scripts::Guest for JsComponentBindgenComponent { + fn script_engine_world_new( + inputs: Vec, + backend: ScriptEngineBackend, + ) -> Result { + use js_component_bindgen::script; + let inputs: Vec = inputs + .into_iter() + .map(|input| script::WitInput { + path: PathBuf::from(input.path), + world: input.world, + }) + .collect(); + let backend = match backend { + ScriptEngineBackend::Starlingmonkey => script::Backend::StarlingMonkey, + ScriptEngineBackend::Quickjs => script::Backend::QuickJs, + }; + let engine = script::engine_world(&inputs, backend).map_err(|e| format!("{e:#}"))?; + Ok(ScriptEngineWorld { + wit: engine.wit, + world_name: engine.world_name, + bootstrap: engine.bootstrap, + imports: engine.imports, + exports: engine.exports, + }) + } + + fn script_component_new(opts: ScriptComponentOpts) -> Result, String> { + use js_component_bindgen::script; + script::script_component(&script::ScriptComponent { + name: opts.name, + source: opts.source, + engine_world: opts.engine_world, + exports: opts.exports, + }) + .map_err(|e| format!("{e:#}")) + } +} + fn parse_package_spec(spec: &str) -> Option { let source = format!("package {spec}; world validate {{}}"); UnresolvedPackageGroup::parse("package-spec.wit", &source) diff --git a/crates/js-component-bindgen-component/wit/js-component-bindgen.wit b/crates/js-component-bindgen-component/wit/js-component-bindgen.wit index 4d927bbbd..08008ba85 100644 --- a/crates/js-component-bindgen-component/wit/js-component-bindgen.wit +++ b/crates/js-component-bindgen-component/wit/js-component-bindgen.wit @@ -1,5 +1,62 @@ package local:js-component-bindgen; +/// Script engines and the script-only components that run on them +/// +/// A script engine is a JavaScript engine componentized against the union of several WIT +/// worlds, with an extra `jco:script-engine/script-loader` export through which a host feeds it +/// scripts at runtime. A script-only component packages one script's source as a component +/// exporting `jco:script/script`. +interface scripts { + /// A WIT world a script engine must support + record script-engine-wit-input { + /// Path to a WIT file or directory + path: string, + /// World to select within the package (the package's only world if omitted) + %world: option, + } + + /// JavaScript engine a script engine bootstrap will be componentized with + enum script-engine-backend { + starlingmonkey, + quickjs, + } + + /// A merged script engine world and the bootstrap module that implements it + record script-engine-world { + /// Single WIT document (with nested packages) defining the engine world + wit: string, + /// Name of the engine world within `wit` + world-name: string, + /// JavaScript module to componentize as the engine + bootstrap: string, + /// Specifiers scripts may import + imports: list, + /// JavaScript export names of the engine world, excluding the script loader + exports: list, + } + + /// A script to package as a script-only component + record script-component-opts { + /// Name the script is packaged under + name: string, + /// Script source, a bundled ES module + source: string, + /// Fully-qualified engine world the script was bundled against + engine-world: string, + /// JavaScript export names of the script + exports: list, + } + + /// Merge WIT worlds into a script engine world and generate the engine's bootstrap module + script-engine-world-new: func( + inputs: list, + backend: script-engine-backend, + ) -> result; + + /// Package a bundled ES module as a component exporting `jco:script/script` + script-component-new: func(opts: script-component-opts) -> result, string>; +} + world js-component-bindgen { type files = list>>; type maps = list>; @@ -221,4 +278,6 @@ world js-component-bindgen { /// Expand an encoded WIT package into a conventional WIT directory file map. export unpack-wit: func(expected: option, binary: list) -> result; + + export scripts; } diff --git a/crates/js-component-bindgen/Cargo.toml b/crates/js-component-bindgen/Cargo.toml index 8ae9b672f..05b038874 100644 --- a/crates/js-component-bindgen/Cargo.toml +++ b/crates/js-component-bindgen/Cargo.toml @@ -32,6 +32,7 @@ heck = { workspace = true } log = { workspace = true } semver = { workspace = true } wasm-encoder = { workspace = true, features = [ "wasmparser" ] } +wasm-metadata = { workspace = true } wasmparser = { workspace = true, features = [ "features", "component-model" ] } wasmtime-environ = { workspace = true, features = ['component-model'] } wit-bindgen-core = { workspace = true } diff --git a/crates/js-component-bindgen/src/lib.rs b/crates/js-component-bindgen/src/lib.rs index 2e88635e2..2efb473a8 100644 --- a/crates/js-component-bindgen/src/lib.rs +++ b/crates/js-component-bindgen/src/lib.rs @@ -18,6 +18,7 @@ mod ts_bindgen; pub mod esm_bindgen; pub mod function_bindgen; pub mod names; +pub mod script; pub mod source; pub mod intrinsics; diff --git a/crates/js-component-bindgen/src/script/mod.rs b/crates/js-component-bindgen/src/script/mod.rs new file mode 100644 index 000000000..1594b3494 --- /dev/null +++ b/crates/js-component-bindgen/src/script/mod.rs @@ -0,0 +1,849 @@ +//! Script engines and script-only components. +//! +//! `jco componentize` normally bakes one script into one JavaScript engine. This module supports +//! the alternative split: an *engine* component, built once from the union of the worlds its +//! scripts target, and *script-only* components that merely package a script's source. At +//! runtime a host instantiates the engine, reads the script out of a script-only component and +//! hands it to the engine through `jco:script-engine/script-loader`. +//! +//! The module does not run a JavaScript engine itself. [`engine_world`] produces the merged WIT +//! and a generated bootstrap module; the caller componentizes that module with ComponentizeJS or +//! componentize-qjs exactly as it would any other script. [`script_component`] packages a +//! bundled ES module as a component without any engine at all. + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::path::PathBuf; + +use anyhow::{Context, Result, bail}; +use heck::{ToLowerCamelCase, ToUpperCamelCase}; +use wasm_encoder::{ + CodeSection, ConstExpr, DataSection, ExportKind, ExportSection, Function, FunctionSection, + Instruction, MemorySection, MemoryType, Module, TypeSection, ValType, +}; +use wasm_metadata::Producers; +use wit_component::{ComponentEncoder, StringEncoding, WitPrinter}; +use wit_parser::{ + CloneMaps, FunctionKind, Interface, InterfaceId, PackageId, Resolve, Span, TypeDefKind, + WorldId, WorldItem, WorldKey, +}; + +/// WIT for `jco:script-engine`, the loader interface every engine exports. +pub const SCRIPT_ENGINE_WIT: &str = include_str!("script-engine.wit"); + +/// WIT for `jco:script`, the world script-only components export. +pub const SCRIPT_WIT: &str = include_str!("script.wit"); + +/// Name of the world [`engine_world`] produces. +pub const ENGINE_WORLD_NAME: &str = "engine"; + +/// Fully-qualified name of the world [`engine_world`] produces. +pub const ENGINE_WORLD_ID: &str = "jco:script-engine/engine@0.1.0"; + +/// Name of the world script-only components export. +pub const SCRIPT_ONLY_WORLD_NAME: &str = "script-only"; + +/// World key of the script loader interface every engine exports. +const SCRIPT_LOADER_KEY: &str = "jco:script-engine/script-loader@0.1.0"; + +/// JavaScript export name of the script loader interface on an engine. +const SCRIPT_LOADER_EXPORT: &str = "scriptLoader"; + +/// A world that a script engine must support. +#[derive(Debug, Clone)] +pub struct WitInput { + /// Path to a WIT file or directory. + pub path: PathBuf, + /// World to select within the package; the package's only world when `None`. + pub world: Option, +} + +/// The JavaScript engine the bootstrap module will be componentized with. +/// +/// The engines agree on how exports are looked up but differ in how a script imports a +/// function declared directly on the world. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Backend { + StarlingMonkey, + QuickJs, +} + +/// A merged engine world and the bootstrap module that implements it. +#[derive(Debug, Clone)] +pub struct EngineWorld { + /// Single WIT document (with nested packages) that defines the engine world. + pub wit: String, + /// Name of the engine world within `wit`. + pub world_name: String, + /// JavaScript module to componentize as the engine. + pub bootstrap: String, + /// Specifiers a script may `require`, in world order. + pub imports: Vec, + /// JavaScript export names of the engine world, excluding the script loader. + pub exports: Vec, +} + +/// Build the engine world for `inputs`. +/// +/// Every input world is merged into one world which additionally exports +/// `jco:script-engine/script-loader`. Shared packages, such as `wasi:cli`, are unified when the +/// inputs agree on their contents. Conflicting items with the same name, or two exported +/// interfaces that would share a JavaScript export name, are errors: the engine has to present +/// a single namespace to its scripts. +pub fn engine_world(inputs: &[WitInput], backend: Backend) -> Result { + if inputs.is_empty() { + bail!("at least one WIT world is required to build a script engine"); + } + + let mut resolve = Resolve::default(); + let engine_pkg = resolve + .push_str("script-engine.wit", SCRIPT_ENGINE_WIT) + .context("parsing the jco:script-engine WIT")?; + let engine = resolve + .select_world(&[engine_pkg], Some(ENGINE_WORLD_NAME)) + .context("selecting the jco:script-engine world")?; + + let mut clone_maps = CloneMaps::default(); + for input in inputs { + let mut input_resolve = Resolve::default(); + let (pkg, _) = input_resolve + .push_path(&input.path) + .with_context(|| format!("parsing WIT at [{}]", input.path.display()))?; + let world = input_resolve + .select_world(&[pkg], input.world.as_deref()) + .with_context(|| format!("selecting a world in [{}]", input.path.display()))?; + let world_name = input_resolve.worlds[world].name.clone(); + let remap = resolve + .merge(input_resolve) + .with_context(|| format!("merging packages from [{}]", input.path.display()))?; + let world = remap + .map_world(world, Span::default()) + .with_context(|| format!("locating world [{world_name}] after merge"))?; + resolve + .merge_worlds(world, engine, &mut clone_maps) + .with_context(|| { + format!( + "merging world [{world_name}] from [{}] into the engine world", + input.path.display() + ) + })?; + } + + let bootstrap = Bootstrap::new(&resolve, engine, backend)?; + let wit = print_wit(&resolve, engine_pkg)?; + + Ok(EngineWorld { + wit, + world_name: ENGINE_WORLD_NAME.into(), + bootstrap: bootstrap.render(), + imports: bootstrap.import_specifiers(), + exports: bootstrap.export_names(), + }) +} + +/// Print `resolve` as one WIT document with `main` first and every other package nested. +fn print_wit(resolve: &Resolve, main: PackageId) -> Result { + let nested: Vec = resolve + .topological_packages() + .into_iter() + .filter(|id| *id != main) + .collect(); + let mut printer = WitPrinter::default(); + printer + .print(resolve, main, &nested) + .context("printing the engine world WIT")?; + Ok(printer.output.to_string()) +} + +/// An import of the engine world, as the bootstrap module sees it. +#[derive(Debug)] +struct Import { + /// Specifier scripts `require` and the bootstrap imports. + specifier: String, + /// Whether the import is a function declared directly on the world. + world_function: bool, +} + +/// An export of the engine world, as the bootstrap module presents it. +#[derive(Debug)] +enum Export { + /// A function declared directly on the world. + Function { js_name: String }, + /// An interface, exported as an object. + Interface { + js_name: String, + key: String, + functions: Vec, + resources: Vec, + }, +} + +impl Export { + fn js_name(&self) -> &str { + match self { + Export::Function { js_name } | Export::Interface { js_name, .. } => js_name, + } + } +} + +/// A resource exported by an interface, exported as a class. +#[derive(Debug)] +struct ResourceExport { + js_name: String, + has_constructor: bool, + methods: Vec, + statics: Vec, +} + +/// The generated engine bootstrap module. +struct Bootstrap { + imports: Vec, + exports: Vec, +} + +impl Bootstrap { + fn new(resolve: &Resolve, world: WorldId, backend: Backend) -> Result { + let world = &resolve.worlds[world]; + + let mut imports = Vec::new(); + for (key, item) in world.imports.iter() { + match item { + WorldItem::Interface { .. } => imports.push(Import { + specifier: resolve.name_world_key(key), + world_function: false, + }), + WorldItem::Function(function) => { + if backend == Backend::QuickJs { + bail!( + "the engine world imports the function [{}] directly; the quickjs \ + backend has no module specifier for world-level function imports, \ + so move it into an interface or use the starlingmonkey backend", + function.name + ); + } + imports.push(Import { + specifier: function.name.clone(), + world_function: true, + }); + } + WorldItem::Type { .. } => {} + } + } + + let mut exports = Vec::new(); + let mut seen: BTreeMap = BTreeMap::new(); + for (key, item) in world.exports.iter() { + let key_name = resolve.name_world_key(key); + if key_name == SCRIPT_LOADER_KEY { + continue; + } + let export = match item { + WorldItem::Interface { id, .. } => interface_export(resolve, key, *id, &key_name)?, + WorldItem::Function(function) => Export::Function { + js_name: function.name.to_lower_camel_case(), + }, + WorldItem::Type { .. } => continue, + }; + let js_name = export.js_name().to_string(); + if js_name == SCRIPT_LOADER_EXPORT { + bail!( + "the export [{key_name}] would be exported as [{js_name}], which the engine \ + reserves for jco:script-engine/script-loader" + ); + } + if let Some(previous) = seen.insert(js_name.clone(), key_name.clone()) { + bail!( + "the exports [{previous}] and [{key_name}] would both be exported as \ + [{js_name}]; a script engine needs distinct JavaScript export names" + ); + } + exports.push(export); + } + + Ok(Self { imports, exports }) + } + + fn import_specifiers(&self) -> Vec { + self.imports.iter().map(|i| i.specifier.clone()).collect() + } + + fn export_names(&self) -> Vec { + self.exports + .iter() + .map(|e| e.js_name().to_string()) + .collect() + } + + fn render(&self) -> String { + let mut out = String::new(); + out.push_str( + "// Generated by `jco componentize --script-only`. This module is the engine: its\n\ + // exports forward to whichever script `scriptLoader.load` evaluated last.\n\n", + ); + + for (idx, import) in self.imports.iter().enumerate() { + if import.world_function { + let _ = writeln!( + out, + "import $import{idx} from {};", + js_str(&import.specifier) + ); + } else { + let _ = writeln!( + out, + "import * as $import{idx} from {};", + js_str(&import.specifier) + ); + } + } + + out.push_str("\nconst $modules = new Map([\n"); + for (idx, import) in self.imports.iter().enumerate() { + if import.world_function { + let _ = writeln!( + out, + " [{}, $namespace({{ default: $import{idx} }})],", + js_str(&import.specifier) + ); + } else { + let _ = writeln!( + out, + " [{}, $namespace($import{idx})],", + js_str(&import.specifier) + ); + } + } + out.push_str("]);\n\n"); + + // The runtime refers to `$modules` and `$linkResources`, defined around it. + out.push_str(RUNTIME_JS); + out.push('\n'); + + let mut links = String::new(); + for (idx, export) in self.exports.iter().enumerate() { + match export { + Export::Function { js_name } => { + let _ = writeln!( + out, + "function $export{idx}(...args) {{\n return $lookup([{}])(...args);\n}}\n\ + export {{ $export{idx} as {js_name} }};\n", + js_str(js_name) + ); + } + Export::Interface { + js_name, + key, + functions, + resources, + } => { + let _ = writeln!(out, "// {key}"); + for resource in resources { + let class = format!("$export{idx}${}", resource.js_name); + let path = format!("{}, {}", js_str(js_name), js_str(&resource.js_name)); + let _ = writeln!(out, "class {class} {{"); + if resource.has_constructor { + let _ = writeln!( + out, + " constructor(...args) {{\n return new ($lookup([{path}]))(...args);\n }}" + ); + } else { + let _ = writeln!( + out, + " constructor() {{\n throw new TypeError({});\n }}", + js_str(&format!( + "{}.{} has no constructor in the engine world", + js_name, resource.js_name + )) + ); + } + for method in &resource.methods { + let _ = writeln!( + out, + " {}(...args) {{\n return $method([{path}], {class}, {}).call(this, ...args);\n }}", + js_str(method), + js_str(method) + ); + } + for stat in &resource.statics { + let _ = writeln!( + out, + " static {}(...args) {{\n return $lookup([{path}, {}])(...args);\n }}", + js_str(stat), + js_str(stat) + ); + } + out.push_str("}\n"); + let _ = writeln!(links, " $link([{path}], {class});"); + } + let _ = writeln!(out, "const $export{idx} = {{"); + for resource in resources { + let _ = writeln!( + out, + " {}: $export{idx}${},", + js_str(&resource.js_name), + resource.js_name + ); + } + for function in functions { + let _ = writeln!( + out, + " {}(...args) {{\n return $lookup([{}, {}])(...args);\n }},", + js_str(function), + js_str(js_name), + js_str(function) + ); + } + let _ = writeln!(out, "}};\nexport {{ $export{idx} as {js_name} }};\n"); + } + } + } + + let _ = writeln!(out, "function $linkResources() {{\n{links}}}\n"); + let _ = writeln!(out, "export {{ $scriptLoader as {SCRIPT_LOADER_EXPORT} }};"); + out + } +} + +/// Describe an exported interface for the bootstrap module. +fn interface_export( + resolve: &Resolve, + key: &WorldKey, + id: InterfaceId, + key_name: &str, +) -> Result { + let iface: &Interface = &resolve.interfaces[id]; + let js_name = match key { + WorldKey::Name(name) => name.to_lower_camel_case(), + WorldKey::Interface(_) => iface + .name + .as_deref() + .with_context(|| format!("exported interface [{key_name}] has no name"))? + .to_lower_camel_case(), + }; + + let mut resources: BTreeMap = BTreeMap::new(); + for (name, type_id) in iface.types.iter() { + if resolve.types[*type_id].kind == TypeDefKind::Resource { + resources.insert( + name.clone(), + ResourceExport { + js_name: name.to_upper_camel_case(), + has_constructor: false, + methods: Vec::new(), + statics: Vec::new(), + }, + ); + } + } + + let mut functions = Vec::new(); + for function in iface.functions.values() { + let resource = match function.kind.resource() { + None => { + functions.push(function.item_name().to_lower_camel_case()); + continue; + } + Some(resource) => resource, + }; + let resource_name = resolve.types[resource] + .name + .as_deref() + .with_context(|| format!("resource of [{}] has no name", function.name))?; + let export = resources.get_mut(resource_name).with_context(|| { + format!("resource [{resource_name}] is not defined in [{key_name}]") + })?; + match function.kind { + FunctionKind::Constructor(_) => export.has_constructor = true, + FunctionKind::Method(_) | FunctionKind::AsyncMethod(_) => export + .methods + .push(function.item_name().to_lower_camel_case()), + FunctionKind::Static(_) | FunctionKind::AsyncStatic(_) => export + .statics + .push(function.item_name().to_lower_camel_case()), + FunctionKind::Freestanding | FunctionKind::AsyncFreestanding => unreachable!(), + } + } + + Ok(Export::Interface { + js_name, + key: key_name.to_string(), + functions, + resources: resources.into_values().collect(), + }) +} + +/// Runtime shared by every generated bootstrap module; see the file for how scripts are loaded. +const RUNTIME_JS: &str = include_str!("runtime.js"); + +/// Quote `s` as a JavaScript string literal. +fn js_str(s: &str) -> String { + // Rust's debug escaping is a subset of JavaScript string literal syntax: + // `\"`, `\\`, `\n`, `\t`, `\r` and `\u{..}` are all accepted by JavaScript. + format!("{s:?}") +} + +/// A script to package as a script-only component. +#[derive(Debug, Clone)] +pub struct ScriptComponent { + /// Name the script is packaged under. + pub name: String, + /// Script source: an ES module with its imports hoisted to the top and a single trailing + /// export list, as Rolldown emits. + pub source: String, + /// Fully-qualified engine world the script was bundled against. + pub engine_world: String, + /// JavaScript export names of the script. + pub exports: Vec, +} + +/// Package a script as a component exporting `jco:script/script`. +/// +/// The component is a data-only core module: every export returns a pointer into a +/// pre-laid-out return area, so it needs no allocator and no engine. +pub fn script_component(script: &ScriptComponent) -> Result> { + let mut resolve = Resolve::default(); + let pkg = resolve + .push_str("script.wit", SCRIPT_WIT) + .context("parsing the jco:script WIT")?; + let world = resolve + .select_world(&[pkg], Some(SCRIPT_ONLY_WORLD_NAME)) + .context("selecting the jco:script world")?; + + let mut data = DataLayout::default(); + let exports: Vec<[u32; 2]> = script + .exports + .iter() + .map(|export| data.string(export)) + .collect(); + let mut export_list = Vec::with_capacity(exports.len() * 8); + for [ptr, len] in &exports { + export_list.extend_from_slice(&ptr.to_le_bytes()); + export_list.extend_from_slice(&len.to_le_bytes()); + } + let export_list = data.bytes(&export_list, 4); + let functions = [ + ("name", data.string(&script.name)), + ("source", data.string(&script.source)), + ("engine-world", data.string(&script.engine_world)), + ("exports", [export_list, exports.len() as u32]), + ]; + let return_areas: Vec<(&str, u32)> = functions + .iter() + .map(|(name, [ptr, len])| { + let mut area = Vec::with_capacity(8); + area.extend_from_slice(&ptr.to_le_bytes()); + area.extend_from_slice(&len.to_le_bytes()); + (*name, data.bytes(&area, 4)) + }) + .collect(); + + let mut types = TypeSection::new(); + types.ty().function([], [ValType::I32]); + let mut funcs = FunctionSection::new(); + let mut code = CodeSection::new(); + let mut exports_section = ExportSection::new(); + exports_section.export("memory", ExportKind::Memory, 0); + for (idx, (name, area)) in return_areas.iter().enumerate() { + funcs.function(0); + let mut function = Function::new([]); + function.instruction(&Instruction::I32Const(*area as i32)); + function.instruction(&Instruction::End); + code.function(&function); + exports_section.export( + &format!("jco:script/script@0.1.0#{name}"), + ExportKind::Func, + idx as u32, + ); + } + let mut memory = MemorySection::new(); + memory.memory(MemoryType { + minimum: data.pages(), + maximum: None, + memory64: false, + shared: false, + page_size_log2: None, + }); + let mut data_section = DataSection::new(); + data_section.active(0, &ConstExpr::i32_const(0), data.bytes.clone()); + + let mut module = Module::new(); + module + .section(&types) + .section(&funcs) + .section(&memory) + .section(&exports_section) + .section(&code) + .section(&data_section); + let mut bytes = module.finish(); + + wit_component::embed_component_metadata(&mut bytes, &resolve, world, StringEncoding::UTF8) + .context("embedding the jco:script world")?; + let component = ComponentEncoder::default() + .validate(true) + .module(&bytes) + .context("encoding the script-only module")? + .encode() + .context("encoding the script-only component")?; + + let mut producers = Producers::default(); + producers.add("language", "JavaScript", ""); + producers.add("processed-by", "jco", env!("CARGO_PKG_VERSION")); + producers + .add_to_wasm(&component) + .context("adding producers metadata to the script-only component") +} + +/// Bump allocator for the data segment of a script-only component. +#[derive(Default)] +struct DataLayout { + bytes: Vec, +} + +impl DataLayout { + /// Append `bytes` aligned to `align`, returning its offset. + fn bytes(&mut self, bytes: &[u8], align: usize) -> u32 { + // Offset zero is left unused so every pointer, including that of empty data, is non-zero. + if self.bytes.is_empty() { + self.bytes.resize(align.max(8), 0); + } + let padding = (align - self.bytes.len() % align) % align; + self.bytes.resize(self.bytes.len() + padding, 0); + let offset = self.bytes.len() as u32; + self.bytes.extend_from_slice(bytes); + offset + } + + /// Append a string, returning its `[ptr, len]` pair. + fn string(&mut self, s: &str) -> [u32; 2] { + [self.bytes(s.as_bytes(), 1), s.len() as u32] + } + + /// Number of 64KiB pages needed to hold the data. + fn pages(&self) -> u64 { + (self.bytes.len() as u64).div_ceil(65536).max(1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use wit_component::DecodedWasm; + + fn write_wit(dir: &std::path::Path, name: &str, contents: &str) -> PathBuf { + let path = dir.join(name); + fs::write(&path, contents).unwrap(); + path + } + + const WORLD_A: &str = r#" +package local:a; + +interface greeter { + resource counter { + constructor(start: u32); + next: static func() -> counter; + get: func() -> u32; + } + greet: func(name: string) -> string; +} + +world a { + import log-it: func(message: string); + import wasi:cli/environment@0.2.3; + export greeter; + export run: func(); +} + +package wasi:cli@0.2.3 { + interface environment { + get-environment: func() -> list>; + } +} +"#; + + const WORLD_B: &str = r#" +package local:b; + +interface calc { + add: func(a: u32, b: u32) -> u32; +} + +world b { + import wasi:cli/environment@0.2.3; + export calc; + export run: func(); +} + +package wasi:cli@0.2.3 { + interface environment { + get-environment: func() -> list>; + } +} +"#; + + #[test] + fn merges_worlds_and_generates_bootstrap() { + let dir = tempdir(); + let a = write_wit(&dir, "a.wit", WORLD_A); + let b = write_wit(&dir, "b.wit", WORLD_B); + let engine = engine_world( + &[ + WitInput { + path: a, + world: None, + }, + WitInput { + path: b, + world: Some("b".into()), + }, + ], + Backend::StarlingMonkey, + ) + .unwrap(); + + // The merged world is parseable and holds the union of imports and exports. + let mut resolve = Resolve::default(); + let pkg = resolve.push_str("engine.wit", &engine.wit).unwrap(); + let world = resolve.select_world(&[pkg], Some("engine")).unwrap(); + let world = &resolve.worlds[world]; + let mut exports: Vec = world + .exports + .keys() + .map(|key| resolve.name_world_key(key)) + .collect(); + exports.sort(); + assert_eq!( + exports, + [ + "jco:script-engine/script-loader@0.1.0", + "local:a/greeter", + "local:b/calc", + "run", + ] + ); + let mut imports: Vec = world + .imports + .keys() + .map(|key| resolve.name_world_key(key)) + .collect(); + imports.sort(); + assert_eq!(imports, ["log-it", "wasi:cli/environment@0.2.3"]); + + let mut engine_imports = engine.imports.clone(); + engine_imports.sort(); + assert_eq!(engine_imports, ["log-it", "wasi:cli/environment@0.2.3"]); + let mut engine_exports = engine.exports.clone(); + engine_exports.sort(); + assert_eq!(engine_exports, ["calc", "greeter", "run"]); + + let bootstrap = &engine.bootstrap; + assert!(bootstrap.contains(" from \"log-it\";")); + assert!(bootstrap.contains(" from \"wasi:cli/environment@0.2.3\";")); + assert!(bootstrap.contains("$namespace({ default: $import")); + assert!(bootstrap.contains("function $rewrite(source)")); + assert!(bootstrap.contains(" as greeter };")); + assert!(bootstrap.contains(" as run };")); + assert!(bootstrap.contains(" as calc };")); + assert!(bootstrap.contains("export { $scriptLoader as scriptLoader };")); + assert!(bootstrap.contains("$Counter {")); + assert!(bootstrap.contains("static \"next\"(...args)")); + assert!(bootstrap.contains("$method([\"greeter\", \"Counter\"], $export")); + assert!(bootstrap.contains("$link([\"greeter\", \"Counter\"], $export")); + } + + #[test] + fn rejects_quickjs_world_function_imports() { + let dir = tempdir(); + let a = write_wit(&dir, "a.wit", WORLD_A); + let err = engine_world( + &[WitInput { + path: a, + world: None, + }], + Backend::QuickJs, + ) + .unwrap_err(); + assert!(err.to_string().contains("log-it"), "{err}"); + } + + #[test] + fn rejects_conflicting_exports() { + let dir = tempdir(); + let a = write_wit(&dir, "a.wit", WORLD_A); + let conflicting = write_wit( + &dir, + "c.wit", + "package local:c;\ninterface greeter { shout: func(); }\nworld c { export greeter; }\n", + ); + let err = engine_world( + &[ + WitInput { + path: a, + world: None, + }, + WitInput { + path: conflicting, + world: None, + }, + ], + Backend::StarlingMonkey, + ) + .unwrap_err(); + assert!(err.to_string().contains("greeter"), "{err}"); + } + + #[test] + fn rejects_reserved_export_name() { + let dir = tempdir(); + let reserved = write_wit( + &dir, + "r.wit", + "package local:r;\ninterface script-loader { reload: func(); }\nworld r { export script-loader; }\n", + ); + let err = engine_world( + &[WitInput { + path: reserved, + world: None, + }], + Backend::StarlingMonkey, + ) + .unwrap_err(); + assert!(err.to_string().contains("scriptLoader"), "{err}"); + } + + #[test] + fn packages_a_script_component() { + let component = script_component(&ScriptComponent { + name: "hello.js".into(), + source: "export const run = () => \"héllo\";\n".repeat(5000), + engine_world: ENGINE_WORLD_ID.into(), + exports: vec!["run".into(), "calc".into()], + }) + .unwrap(); + + let decoded = wit_component::decode(&component).unwrap(); + let DecodedWasm::Component(resolve, world) = decoded else { + panic!("expected a component"); + }; + let exports: Vec = resolve.worlds[world] + .exports + .keys() + .map(|key| resolve.name_world_key(key)) + .collect(); + assert_eq!(exports, ["jco:script/script@0.1.0"]); + } + + fn tempdir() -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "jco-script-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + dir + } +} diff --git a/crates/js-component-bindgen/src/script/runtime.js b/crates/js-component-bindgen/src/script/runtime.js new file mode 100644 index 000000000..6815cf09c --- /dev/null +++ b/crates/js-component-bindgen/src/script/runtime.js @@ -0,0 +1,234 @@ +// Runtime shared by every generated script engine bootstrap module. +// +// Scripts are ES modules. Neither StarlingMonkey nor QuickJS can import a module from a string +// once the engine has been pre-initialized, so `load` rewrites the module's top-level `import` +// and `export` declarations into plain code and evaluates the result with `new Function`. +// Rolldown, which `jco componentize --script-only` bundles with, hoists every import (and +// `export * from`) to the top of the module and collects every export into one trailing +// `export { ... };` statement, so the rewrite only has to recognise those two blocks. +// +// Exported resources are classes the engine captured at initialization, so a script's own +// class is spliced into the prototype chain beneath the engine's class: instances then pass +// the engine's `instanceof` checks while the engine's method stubs forward to the script. + +let $script = null; + +const $IDENT = "[A-Za-z_$][\\w$]*"; +const $STRING = "\"(?:[^\"\\\\]|\\\\.)*\"|'(?:[^'\\\\]|\\\\.)*'"; +const $IMPORT = new RegExp( + `^import\\s*(?:(${$STRING})|(?:(${$IDENT})\\s*,?\\s*)?(?:\\*\\s*as\\s+(${$IDENT})|\\{([^}]*)\\})?\\s*from\\s*(${$STRING}))\\s*;?`, +); +const $EXPORT_ALL = new RegExp(`^export\\s*\\*\\s*from\\s*(${$STRING})\\s*;?`); +const $EXPORT_LIST = /(^|\n)export\s*\{([^}]*)\}\s*;?\s*$/; + +function $import(specifier) { + const module = $modules.get(specifier); + if (module === undefined) { + throw new Error( + `"${specifier}" is not imported by the engine world; available imports: ${ + [...$modules.keys()].map((key) => `"${key}"`).join(", ") || "(none)" + }`, + ); + } + return module; +} + +/** Present an engine import as a module namespace with a `default` export, as both engines do. */ +function $namespace(module) { + const namespace = Object.create(null); + for (const key of Object.keys(module)) { + Object.defineProperty(namespace, key, { get: () => module[key], enumerable: true }); + } + if (!("default" in namespace)) { + Object.defineProperty(namespace, "default", { value: namespace, enumerable: true }); + } + return namespace; +} + +/** Skip whitespace and comments starting at `pos`. */ +function $skipTrivia(source, pos) { + for (;;) { + while (pos < source.length && /\s/.test(source[pos])) { + pos++; + } + if (source.startsWith("//", pos)) { + const end = source.indexOf("\n", pos); + pos = end === -1 ? source.length : end + 1; + } else if (source.startsWith("/*", pos)) { + const end = source.indexOf("*/", pos + 2); + pos = end === -1 ? source.length : end + 2; + } else { + return pos; + } + } +} + +/** Parse `name`, `name as alias` or `"string" as alias` entries of an import or export list. */ +function $bindings(list) { + return list + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => { + const match = /^("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[A-Za-z_$][\w$]*)(?:\s+as\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[A-Za-z_$][\w$]*))?$/.exec(entry); + if (match === null) { + throw new SyntaxError(`unsupported import or export entry "${entry}"`); + } + return { name: $unquote(match[1]), alias: $unquote(match[2] ?? match[1]) }; + }); +} + +function $unquote(literal) { + if (literal.startsWith('"')) { + return JSON.parse(literal); + } + if (literal.startsWith("'")) { + return JSON.parse(`"${literal.slice(1, -1).replace(/\\'/g, "'").replace(/"/g, '\\"')}"`); + } + return literal; +} + +/** Rewrite an ES module into a function body taking `$import` and `$exports`. */ +function $rewrite(source) { + let prelude = ""; + let pos = 0; + for (;;) { + pos = $skipTrivia(source, pos); + const rest = source.slice(pos); + let match = $IMPORT.exec(rest); + if (match !== null) { + const [, bare, defaultName, namespaceName, named, from] = match; + const specifier = JSON.stringify($unquote(bare ?? from)); + prelude += `$import(${specifier});\n`; + if (defaultName !== undefined) { + prelude += `const ${defaultName} = $import(${specifier}).default;\n`; + } + if (namespaceName !== undefined) { + prelude += `const ${namespaceName} = $import(${specifier});\n`; + } + if (named !== undefined) { + for (const { name, alias } of $bindings(named)) { + prelude += `const ${alias} = $import(${specifier})[${JSON.stringify(name)}];\n`; + } + } + pos += match[0].length; + continue; + } + match = $EXPORT_ALL.exec(rest); + if (match !== null) { + prelude += `$exportAll($exports, $import(${JSON.stringify($unquote(match[1]))}));\n`; + pos += match[0].length; + continue; + } + break; + } + + let body = source.slice(pos); + let epilogue = ""; + const exports = $EXPORT_LIST.exec(body); + if (exports !== null) { + body = body.slice(0, exports.index); + for (const { name, alias } of $bindings(exports[2])) { + epilogue += `Object.defineProperty($exports, ${JSON.stringify(alias)}, { get: () => ${name}, enumerable: true });\n`; + } + } + return `${prelude}${body}\n${epilogue}`; +} + +function $exportAll(exports, module) { + for (const key of Object.keys(module)) { + if (key !== "default" && !Object.hasOwn(exports, key)) { + Object.defineProperty(exports, key, { get: () => module[key], enumerable: true }); + } + } +} + +function $exports() { + if ($script === null) { + throw new Error( + "no script is loaded; call jco:script-engine/script-loader.load before using the engine's exports", + ); + } + return $script.exports; +} + +function $resolve(path) { + let value = $exports(); + for (const segment of path) { + if (value === null || value === undefined) { + return undefined; + } + value = value[segment]; + } + return value; +} + +function $lookup(path) { + const value = $resolve(path); + if (typeof value !== "function") { + throw new Error(`script "${$script.name}" does not export "${path.join(".")}"`); + } + return value; +} + +function $method(path, engineClass, name) { + let proto = $lookup(path).prototype; + while (proto !== null && proto !== undefined && proto !== engineClass.prototype) { + if (Object.hasOwn(proto, name)) { + return proto[name]; + } + proto = Object.getPrototypeOf(proto); + } + throw new Error(`script "${$script.name}" does not implement "${path.join(".")}.${name}"`); +} + +function $link(path, engineClass) { + const scriptClass = $resolve(path); + if (typeof scriptClass !== "function" || scriptClass === engineClass) { + return; + } + let proto = scriptClass.prototype; + if (proto === null || typeof proto !== "object") { + return; + } + for (;;) { + const parent = Object.getPrototypeOf(proto); + if (parent === null || parent === Object.prototype || parent === engineClass.prototype) { + break; + } + proto = parent; + } + if (Object.getPrototypeOf(proto) !== engineClass.prototype) { + Object.setPrototypeOf(proto, engineClass.prototype); + } +} + +const $scriptLoader = { + load(name, source) { + let evaluate; + try { + evaluate = new Function("$import", "$exports", "$exportAll", $rewrite(source)); + } catch (error) { + throw `failed to parse script "${name}": ${error}. Scripts must be ES modules as bundled by \`jco componentize --script-only\`: imports at the top, one export list at the end, no top-level await.`; + } + const exports = Object.create(null); + try { + evaluate($import, exports, $exportAll); + } catch (error) { + throw `failed to evaluate script "${name}": ${error}`; + } + $script = { name, exports }; + try { + $linkResources(); + } catch (error) { + $script = null; + throw `failed to link resources of script "${name}": ${error}`; + } + }, + unload() { + $script = null; + }, + loaded() { + return $script === null ? undefined : $script.name; + }, +}; diff --git a/crates/js-component-bindgen/src/script/script-engine.wit b/crates/js-component-bindgen/src/script/script-engine.wit new file mode 100644 index 000000000..7f73904c5 --- /dev/null +++ b/crates/js-component-bindgen/src/script/script-engine.wit @@ -0,0 +1,29 @@ +package jco:script-engine@0.1.0; + +/// Loads JavaScript into a Jco script engine component at runtime. +/// +/// A script engine is a JavaScript engine (StarlingMonkey or QuickJS) componentized +/// against the union of the worlds its scripts target. Its world exports forward to +/// whichever script is currently loaded, so one engine instance can run scripts one +/// after the other. Scripts loaded into the same engine instance share a JavaScript +/// realm, so this is not a multi-tenancy boundary. +interface script-loader { + /// Evaluate `source` and make its exports the implementation behind the engine's + /// world exports, replacing any previously loaded script. + /// + /// `source` is an ES module as packaged by `jco componentize --script-only`: imports + /// hoisted to the top, one export list at the end, and no top-level await. It may import + /// any import of the engine world by its WIT specifier. + load: func(name: string, source: string) -> result<_, string>; + + /// Drop the loaded script. World exports fail until another script is loaded. + unload: func(); + + /// Name of the loaded script, if any. + loaded: func() -> option; +} + +/// The world every script engine exports, in addition to the merged script worlds. +world engine { + export script-loader; +} diff --git a/crates/js-component-bindgen/src/script/script.wit b/crates/js-component-bindgen/src/script/script.wit new file mode 100644 index 000000000..b724a9c70 --- /dev/null +++ b/crates/js-component-bindgen/src/script/script.wit @@ -0,0 +1,24 @@ +package jco:script@0.1.0; + +/// A JavaScript script packaged as a component. +/// +/// The component does not execute the script. A host instantiates it to read the +/// script out and feeds it to a Jco script engine component through +/// `jco:script-engine/script-loader`. +interface script { + /// Name the script was packaged under, usually its entry file name. + name: func() -> string; + + /// The script as a bundled ES module whose imports name WIT imports of the engine world. + source: func() -> string; + + /// Fully-qualified engine world the script was bundled against. + engine-world: func() -> string; + + /// JavaScript export names found in the script. + exports: func() -> list; +} + +world script-only { + export script; +} From 99f7b88b4a84cbf2d2ad3aa4c6f25ad8b76686c4 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 23 Oct 2026 19:35:25 +0900 Subject: [PATCH 2/6] feat(transpile): expose script engine and script component builders --- packages/jco-transpile/src/index.ts | 1 + packages/jco-transpile/src/script.ts | 38 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 packages/jco-transpile/src/script.ts diff --git a/packages/jco-transpile/src/index.ts b/packages/jco-transpile/src/index.ts index 619151015..c447f3a87 100644 --- a/packages/jco-transpile/src/index.ts +++ b/packages/jco-transpile/src/index.ts @@ -1,4 +1,5 @@ export { generateHostTypes, generateGuestTypes, unpackWit } from './typegen.js'; export { transpile, transpileBytes } from './transpile.js'; export { componentWitMetadataForWorld } from './wasm-tools.js'; +export { createScriptComponent, createScriptEngineWorld } from './script.js'; export { writeFiles } from './common.js'; diff --git a/packages/jco-transpile/src/script.ts b/packages/jco-transpile/src/script.ts new file mode 100644 index 000000000..267228433 --- /dev/null +++ b/packages/jco-transpile/src/script.ts @@ -0,0 +1,38 @@ +import { $init as $initBindgenComponent, scripts } from '../vendor/js-component-bindgen-component.js'; +import type { scripts as ScriptsNamespace } from '../vendor/js-component-bindgen-component.js'; +import { ensureWitFilesystemPreopen } from './preopens.js'; + +export type ScriptComponentOpts = ScriptsNamespace.ScriptComponentOpts; +export type ScriptEngineBackend = ScriptsNamespace.ScriptEngineBackend; +export type ScriptEngineWitInput = ScriptsNamespace.ScriptEngineWitInput; +export type ScriptEngineWorld = ScriptsNamespace.ScriptEngineWorld; + +/** + * Merge WIT worlds into a script engine world and generate the engine's bootstrap module + * + * The bootstrap is componentized like any other script to produce an engine component whose + * exports forward to whichever script `jco:script-engine/script-loader` loaded last. + * + * @param inputs - WIT paths (and optional world names) the engine must support + * @param backend - JavaScript engine the bootstrap will be componentized with + * @returns the engine world WIT, bootstrap module, and its import/export names + */ +export async function createScriptEngineWorld( + inputs: ScriptEngineWitInput[], + backend: ScriptEngineBackend, +): Promise { + ensureWitFilesystemPreopen(); + await $initBindgenComponent; + return scripts.scriptEngineWorldNew(inputs, backend); +} + +/** + * Package a bundled ES module as a component exporting `jco:script/script` + * + * @param opts - the script, its name, and the engine world it targets + * @returns WebAssembly component bytes + */ +export async function createScriptComponent(opts: ScriptComponentOpts): Promise { + await $initBindgenComponent; + return scripts.scriptComponentNew(opts); +} From 78e5b8e437a4446eee0d9cbeba833fd838bd56b0 Mon Sep 17 00:00:00 2001 From: Victor Adossi Date: Fri, 23 Oct 2026 19:35:25 +0900 Subject: [PATCH 3/6] feat(jco): build script engines and script-only components with --script-only --- packages/jco/src/bundle.ts | 28 +- packages/jco/src/cmd/componentize.ts | 256 ++++++++++++++++-- packages/jco/src/jco.ts | 23 +- packages/jco/test/componentize.js | 14 +- .../componentize/script-only/bad-import.js | 5 + .../fixtures/componentize/script-only/calc.ts | 9 + .../componentize/script-only/greet.js | 33 +++ .../script-only/wit-calc-conflict/calc.wit | 9 + .../script-only/wit-calc/calc.wit | 10 + .../script-only/wit-greeter/greeter.wit | 23 ++ packages/jco/test/script-only.js | 172 ++++++++++++ 11 files changed, 546 insertions(+), 36 deletions(-) create mode 100644 packages/jco/test/fixtures/componentize/script-only/bad-import.js create mode 100644 packages/jco/test/fixtures/componentize/script-only/calc.ts create mode 100644 packages/jco/test/fixtures/componentize/script-only/greet.js create mode 100644 packages/jco/test/fixtures/componentize/script-only/wit-calc-conflict/calc.wit create mode 100644 packages/jco/test/fixtures/componentize/script-only/wit-calc/calc.wit create mode 100644 packages/jco/test/fixtures/componentize/script-only/wit-greeter/greeter.wit create mode 100644 packages/jco/test/script-only.js diff --git a/packages/jco/src/bundle.ts b/packages/jco/src/bundle.ts index 6dc7eca75..3ef236da6 100644 --- a/packages/jco/src/bundle.ts +++ b/packages/jco/src/bundle.ts @@ -10,6 +10,15 @@ export interface BundleComponentSourceOptions { typescript?: boolean; } +/** A bundled entry chunk and what it imports and exports. */ +export interface BundledComponentChunk { + code: string; + /** Names the chunk exports */ + exports: string[]; + /** External specifiers the chunk imports */ + imports: string[]; +} + /** External imports that represent WebAssembly Component capabilities. */ const WASI_EXTERNAL = /^wasi:/; const NODE_CAPABILITY_EXTERNAL = /^jco:node\//; @@ -76,6 +85,22 @@ export async function bundleComponentSource( entryPath: string, options: BundleComponentSourceOptions = {}, ): Promise { + const { code } = await bundleComponentChunk(entryPath, options); + return code; +} + +/** + * Bundle a component entry point, returning the chunk together with its external imports and + * export names. + * + * @param {string} entryPath + * @param {BundleComponentSourceOptions} [options] + * @returns {Promise} + */ +export async function bundleComponentChunk( + entryPath: string, + options: BundleComponentSourceOptions = {}, +): Promise { const { rolldown } = await import("rolldown"); const absoluteEntryPath = resolve(entryPath); const config = options.config ?? {}; @@ -143,7 +168,8 @@ export async function bundleComponentSource( ); } - return chunks[0].code; + const [chunk] = chunks; + return { code: chunk.code, exports: [...chunk.exports], imports: [...chunk.imports] }; } finally { await build.close(); } diff --git a/packages/jco/src/cmd/componentize.ts b/packages/jco/src/cmd/componentize.ts index 3cf6c8f32..764cf0ef2 100644 --- a/packages/jco/src/cmd/componentize.ts +++ b/packages/jco/src/cmd/componentize.ts @@ -1,11 +1,15 @@ import { mergeNodeWitRequirement } from "../node-wit.js"; -import { cp, mkdtemp, rm, stat, readFile, writeFile } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, rm, stat, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve, basename, dirname, extname, join } from "node:path"; -import { componentWitMetadataForWorld } from "@bytecodealliance/jco-transpile"; +import { + componentWitMetadataForWorld, + createScriptComponent, + createScriptEngineWorld, +} from "@bytecodealliance/jco-transpile"; -import { bundleComponentSource, classifyComponentSource, loadBundleConfig } from "../bundle.js"; +import { bundleComponentChunk, bundleComponentSource, classifyComponentSource, loadBundleConfig } from "../bundle.js"; import { engineCompatPlugin } from "../engine-compat.js"; import { styleText, isWindows } from "../common.js"; import { componentizePerry } from "./componentize/perry.js"; @@ -32,10 +36,21 @@ const DEBUG_FEATURES = ["stdio"]; type ComponentizeJSBackend = "qjs" | "quickjs" | "starlingmonkey" | "sm" | "perry"; +/** Fully-qualified world every script engine implements, which script-only components record. */ +export const SCRIPT_ENGINE_WORLD = "jco:script-engine/engine@0.1.0"; + +/** File name of the engine component written by {@link componentizeScriptOnly}. */ +const SCRIPT_ENGINE_FILE = "engine.wasm"; + export interface ComponentizeOptions { /** Required by the JavaScript engine backends; Perry supplies its own WASI P2 world. */ wit?: string; out: string; + /** + * Engine build to componentize with: a StarlingMonkey build for the starlingmonkey backend, + * or a QuickJS runtime module for the quickjs backend. + */ + engine?: string; /** * Directory componentization runs from. * @@ -76,7 +91,6 @@ export interface ComponentizeOptions { debug?: boolean; preview2Adapter?: string; debugStarlingmonkeyBuild?: boolean; - engine?: string; debugBindings?: boolean; debugBindingsDir?: string; debugBinary?: boolean; @@ -84,6 +98,32 @@ export interface ComponentizeOptions { debugEnableWizerLogging?: boolean; } +/** + * Options for {@link componentizeScriptOnly}. + * + * The engine is built against the union of every `wit` world, so `wit` accepts several paths. + * `worldName` selects the world in each: omit it to use each package's only world, or give one + * name per `wit` path. + */ +export interface ComponentizeScriptOnlyOptions extends Omit { + wit: string | string[]; + worldName?: string | string[]; + /** Directory that receives `engine.wasm`, `engine.wit` and one `