Skip to main content

componentize_qjs/
lib.rs

1pub mod codegen;
2pub mod stubwasi;
3
4use std::path::{Component as PathComponent, Path, PathBuf};
5
6use anyhow::{Context, Result, anyhow};
7use bytes::Bytes;
8use stubwasi::stub_wasi_imports;
9use wasi_preview1_component_adapter_provider::WASI_SNAPSHOT_PREVIEW1_REACTOR_ADAPTER;
10use wasmtime::component::{Component as WasmtimeComponent, Linker, ResourceTable};
11use wasmtime::{Config, Engine, Store};
12use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe};
13use wasmtime_wasi::{DirPerms, FilePerms, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
14use wasmtime_wizer::{WasmtimeWizerComponent, Wizer};
15use wit_parser::Resolve;
16
17include!(concat!(env!("OUT_DIR"), "/output.rs"));
18
19wasmtime::component::bindgen!({
20    path: "wit/init.wit",
21    world: "init",
22    exports: { default: async },
23});
24
25struct Ctx {
26    wasi: WasiCtx,
27    table: ResourceTable,
28}
29
30impl WasiView for Ctx {
31    fn ctx(&mut self) -> WasiCtxView<'_> {
32        WasiCtxView {
33            ctx: &mut self.wasi,
34            table: &mut self.table,
35        }
36    }
37}
38
39/// Options for componentizing a JavaScript source file.
40pub struct ComponentizeOpts<'a> {
41    /// Path to the WIT file or directory
42    pub wit_path: &'a Path,
43    /// JavaScript source code
44    pub js_source: &'a str,
45    /// Path to the JavaScript entry file, used as the base for resolving imports
46    pub js_path: Option<&'a Path>,
47    /// Host directory exposed read-only during Wizer for resolving imported modules
48    pub module_root: Option<&'a Path>,
49    /// World name to use from the WIT (None = default world)
50    pub world_name: Option<&'a str>,
51    /// Stub all WASI imports with traps
52    pub stub_wasi: bool,
53    /// Disable automatic garbage collection in the QuickJS runtime
54    pub disable_gc: bool,
55    /// Runtime to embed before Wizer initialization
56    pub runtime: Runtime<'a>,
57}
58
59/// QuickJS runtime variant to embed in the generated component.
60#[derive(Clone, Copy, Debug)]
61pub enum Runtime<'a> {
62    /// Standard runtime optimized for speed.
63    ///
64    /// Built with component-model async support when the `component-model-async`
65    /// feature is enabled (the default); otherwise this is the non-async runtime.
66    Default,
67    /// Runtime optimized for smaller generated components.
68    ///
69    /// Built with component-model async support when the `component-model-async`
70    /// feature is enabled (the default); otherwise this is the non-async runtime.
71    OptSize,
72    /// Non-async runtime optimized for speed.
73    ///
74    /// Produces components that do not use the component-model async ABI, so they
75    /// run on hosts without async support. Always available regardless of Cargo
76    /// features.
77    DefaultSync,
78    /// Non-async runtime optimized for smaller generated components.
79    ///
80    /// Produces components that do not use the component-model async ABI, so they
81    /// run on hosts without async support. Always available regardless of Cargo
82    /// features.
83    OptSizeSync,
84    /// Caller-provided runtime Wasm bytes.
85    Custom(&'a [u8]),
86}
87
88impl Default for Runtime<'_> {
89    fn default() -> Self {
90        default_builtin_runtime()
91    }
92}
93
94/// Return the built-in runtime selected by Cargo features.
95pub fn default_builtin_runtime() -> Runtime<'static> {
96    if cfg!(feature = "opt-size") {
97        Runtime::OptSize
98    } else {
99        Runtime::Default
100    }
101}
102
103/// Convert JavaScript source code into a WebAssembly component.
104pub async fn componentize(opts: &ComponentizeOpts<'_>) -> Result<Vec<u8>> {
105    let mut resolve = Resolve::default();
106    let (pkg_id, _) = resolve.push_path(opts.wit_path)?;
107    let world_id = resolve.select_world(&[pkg_id], opts.world_name)?;
108
109    let shim = codegen::generate_shim(&resolve, world_id);
110    let module_resolution = module_resolution(opts)?;
111    let mut wit_dylib = wit_dylib::create(&resolve, world_id, None);
112
113    wit_component::embed_component_metadata(
114        &mut wit_dylib,
115        &resolve,
116        world_id,
117        wit_component::StringEncoding::UTF8,
118    )?;
119
120    let pre_wizer_component = wit_component::Linker::default()
121        .validate(true)
122        .library(
123            "componentize_qjs_runtime.wasm",
124            runtime_wasm(opts.runtime),
125            false,
126        )?
127        .library("wit-dylib.wasm", &wit_dylib, false)?
128        .adapter(
129            "wasi_snapshot_preview1",
130            WASI_SNAPSHOT_PREVIEW1_REACTOR_ADAPTER,
131        )?
132        .encode()
133        .context("failed to link and encode component")?;
134
135    let mut component = wizer_init(
136        &pre_wizer_component,
137        &shim,
138        opts.js_source,
139        module_resolution.as_ref(),
140        opts.disable_gc,
141    )
142    .await?;
143
144    if opts.stub_wasi {
145        component = stub_wasi_imports(&component).context("failed to stub WASI imports")?;
146    }
147
148    Ok(component)
149}
150
151/// Return the built-in default runtime Wasm bytes.
152pub fn default_runtime_wasm() -> &'static [u8] {
153    DEFAULT_RUNTIME_WASM
154}
155
156/// Return the built-in opt-size runtime Wasm bytes.
157pub fn opt_size_runtime_wasm() -> &'static [u8] {
158    OPT_SIZE_RUNTIME_WASM
159}
160
161/// Return the built-in non-async runtime Wasm bytes.
162pub fn default_sync_runtime_wasm() -> &'static [u8] {
163    DEFAULT_SYNC_RUNTIME_WASM
164}
165
166/// Return the built-in non-async opt-size runtime Wasm bytes.
167pub fn opt_size_sync_runtime_wasm() -> &'static [u8] {
168    OPT_SIZE_SYNC_RUNTIME_WASM
169}
170
171fn runtime_wasm(runtime: Runtime<'_>) -> &[u8] {
172    match runtime {
173        Runtime::Default => DEFAULT_RUNTIME_WASM,
174        Runtime::OptSize => OPT_SIZE_RUNTIME_WASM,
175        Runtime::DefaultSync => DEFAULT_SYNC_RUNTIME_WASM,
176        Runtime::OptSizeSync => OPT_SIZE_SYNC_RUNTIME_WASM,
177        Runtime::Custom(wasm) => wasm,
178    }
179}
180
181struct ModuleResolution {
182    host_root: PathBuf,
183    guest_entry_path: String,
184}
185
186fn module_resolution(opts: &ComponentizeOpts<'_>) -> Result<Option<ModuleResolution>> {
187    let Some(js_path) = opts.js_path else {
188        if opts.module_root.is_some() {
189            return Err(anyhow!("module_root requires js_path"));
190        }
191        return Ok(None);
192    };
193
194    let host_entry = js_path
195        .canonicalize()
196        .with_context(|| format!("failed to resolve JS entry path {}", js_path.display()))?;
197    if !host_entry.is_file() {
198        return Err(anyhow!(
199            "JS entry path is not a file: {}",
200            host_entry.display()
201        ));
202    }
203
204    let host_root = match opts.module_root {
205        Some(root) => root
206            .canonicalize()
207            .with_context(|| format!("failed to resolve module root {}", root.display()))?,
208        None => default_module_root(&host_entry)?,
209    };
210    if !host_root.is_dir() {
211        return Err(anyhow!(
212            "module root is not a directory: {}",
213            host_root.display()
214        ));
215    }
216
217    let relative_entry = host_entry.strip_prefix(&host_root).with_context(|| {
218        format!(
219            "JS entry path {} is not under module root {}",
220            host_entry.display(),
221            host_root.display()
222        )
223    })?;
224    let guest_entry_path = guest_absolute_path(relative_entry)?;
225
226    Ok(Some(ModuleResolution {
227        host_root,
228        guest_entry_path,
229    }))
230}
231
232fn default_module_root(host_entry: &Path) -> Result<PathBuf> {
233    let cwd = std::env::current_dir()
234        .context("failed to read current directory")?
235        .canonicalize()
236        .context("failed to resolve current directory")?;
237
238    if host_entry.starts_with(&cwd) {
239        return Ok(cwd);
240    }
241
242    host_entry
243        .parent()
244        .map(Path::to_path_buf)
245        .ok_or_else(|| anyhow!("JS entry path has no parent: {}", host_entry.display()))
246}
247
248fn guest_absolute_path(relative: &Path) -> Result<String> {
249    let mut guest = String::from("/");
250    let mut first = true;
251
252    for component in relative.components() {
253        let PathComponent::Normal(part) = component else {
254            return Err(anyhow!(
255                "JS entry path contains unsupported component: {}",
256                relative.display()
257            ));
258        };
259        let part = part.to_str().ok_or_else(|| {
260            anyhow!(
261                "JS entry path contains non-UTF-8 component: {}",
262                relative.display()
263            )
264        })?;
265
266        if !first {
267            guest.push('/');
268        }
269        guest.push_str(part);
270        first = false;
271    }
272
273    if first {
274        return Err(anyhow!("JS entry path cannot be the module root"));
275    }
276
277    Ok(guest)
278}
279
280async fn wizer_init(
281    component: &[u8],
282    shim: &str,
283    js: &str,
284    module_resolution: Option<&ModuleResolution>,
285    disable_gc: bool,
286) -> Result<Vec<u8>> {
287    let stdout = MemoryOutputPipe::new(10000);
288    let stderr = MemoryOutputPipe::new(10000);
289
290    let mut wasi = WasiCtxBuilder::new();
291    wasi.stdin(MemoryInputPipe::new(Bytes::new()))
292        .stdout(stdout.clone())
293        .stderr(stderr.clone());
294    if let Some(resolution) = module_resolution {
295        wasi.preopened_dir(&resolution.host_root, "/", DirPerms::READ, FilePerms::READ)
296            .map_err(|err| {
297                anyhow!(
298                    "failed to preopen module root {}: {err}",
299                    resolution.host_root.display()
300                )
301            })?;
302    }
303    let wasi = wasi.build();
304
305    let table = ResourceTable::new();
306    let mut config = Config::new();
307    config.wasm_component_model(true);
308    config.wasm_component_model_async(true);
309
310    let engine = Engine::new(&config)?;
311    let mut store = Store::new(&engine, Ctx { wasi, table });
312
313    let wizer = Wizer::new();
314    let (cx, instrumented) = wizer.instrument_component(component)?;
315    let comp = WasmtimeComponent::new(&engine, &instrumented)?;
316
317    let mut linker = Linker::new(&engine);
318    linker.allow_shadowing(true);
319    linker.define_unknown_imports_as_traps(&comp)?;
320    wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
321    wasmtime_wasi::p3::add_to_linker(&mut linker)?;
322    let instance = linker.instantiate_async(&mut store, &comp).await?;
323
324    let init = Init::new(&mut store, &instance)?;
325    init.call_init(
326        &mut store,
327        shim,
328        js,
329        module_resolution.map(|resolution| resolution.guest_entry_path.as_str()),
330        disable_gc,
331    )
332    .await?
333    .map_err(|e| anyhow!("{e}"))
334    .with_context(move || {
335        format!(
336            "{}{}",
337            String::from_utf8_lossy(&stdout.contents()),
338            String::from_utf8_lossy(&stderr.contents())
339        )
340    })?;
341
342    let component = wizer
343        .snapshot_component(
344            cx,
345            &mut WasmtimeWizerComponent {
346                store: &mut store,
347                instance,
348            },
349        )
350        .await?;
351
352    Ok(component)
353}