Skip to main content

wasm_rquickjs/
lib.rs

1use crate::conversions::generate_conversions;
2use crate::exports::generate_export_impls;
3use crate::imports::generate_import_modules;
4use crate::skeleton::{copy_skeleton_lock, copy_skeleton_sources, generate_cargo_toml};
5use crate::wit::{add_get_script_import, add_wizer_init_export};
6use anyhow::{Context, anyhow};
7use camino::{Utf8Path, Utf8PathBuf};
8use heck::{ToSnakeCase, ToUpperCamelCase};
9use proc_macro2::{Ident, Span};
10use std::cell::RefCell;
11use std::collections::{BTreeSet, VecDeque};
12use wit_parser::{
13    Function, Interface, InterfaceId, PackageId, PackageName, PackageSourceMap, Resolve, TypeDef,
14    TypeId, TypeOwner, WorldId, WorldItem,
15};
16
17/// WASI package namespaces whose interfaces are remapped to `wasip2::` in the generated code.
18/// These correspond to interfaces provided by the `wasip2` crate and are mapped via the
19/// `with:` block in `wit_bindgen::generate!`.
20const WASI_REMAP_NAMESPACES: &[(&str, &str)] = &[
21    ("cli", "cli"),
22    ("clocks", "clocks"),
23    ("filesystem", "filesystem"),
24    ("http", "http"),
25    ("io", "io"),
26    ("random", "random"),
27    ("sockets", "sockets"),
28];
29
30mod conversions;
31mod exports;
32mod imports;
33mod inject;
34mod javascript;
35#[cfg(feature = "optimize")]
36mod optimize;
37mod rust_bindgen;
38mod skeleton;
39mod types;
40mod typescript;
41mod wit;
42
43pub use inject::{SLOT_END_MAGIC, SLOT_MAGIC, create_marker_file, inject_js_into_component};
44#[cfg(feature = "optimize")]
45pub use optimize::optimize_component;
46
47/// Write `contents` to `path` only if the file doesn't exist or its current content differs.
48/// This preserves file timestamps when content hasn't changed, avoiding unnecessary recompilation.
49pub(crate) fn write_if_changed(
50    path: impl AsRef<std::path::Path>,
51    contents: impl AsRef<[u8]>,
52) -> std::io::Result<()> {
53    let path = path.as_ref();
54    let contents = contents.as_ref();
55    if let Ok(existing) = std::fs::read(path)
56        && existing == contents
57    {
58        return Ok(());
59    }
60    std::fs::write(path, contents)
61}
62
63/// Copy a file from `src` to `dst` only if the destination doesn't exist or its content differs.
64pub(crate) fn copy_if_changed(
65    src: impl AsRef<std::path::Path>,
66    dst: impl AsRef<std::path::Path>,
67) -> std::io::Result<()> {
68    let src = src.as_ref();
69    let dst = dst.as_ref();
70    let src_contents = std::fs::read(src)?;
71    if let Ok(existing) = std::fs::read(dst)
72        && existing == src_contents
73    {
74        return Ok(());
75    }
76    std::fs::write(dst, src_contents)
77}
78
79/// Specifies how a given user-defined JS module gets embedded into the generated Rust crate.
80#[derive(Debug, Clone)]
81pub enum EmbeddingMode {
82    /// Points to a JS module file that is going to be embedded into the generated Rust crate
83    EmbedFile(Utf8PathBuf),
84    /// The JS module is going to be fetched run-time through an imported WIT interface
85    Composition,
86    /// Embeds a small marker in the compiled WASM component.
87    /// After compilation, JS source can be injected into the marker via `inject_js_into_component`
88    /// without recompiling the Rust crate. The injected JS can be any size — the WASM component
89    /// is structurally rewritten to accommodate the new data.
90    BinarySlot,
91}
92
93impl EmbeddingMode {
94    pub fn is_binary_slot(&self) -> bool {
95        matches!(self, EmbeddingMode::BinarySlot)
96    }
97}
98
99/// Specifies a JS module to be evaluated in the generated component.
100#[derive(Debug, Clone)]
101pub struct JsModuleSpec {
102    pub name: String,
103    pub mode: EmbeddingMode,
104}
105
106impl JsModuleSpec {
107    pub fn file_name(&self) -> String {
108        self.name.replace('/', "_") + ".js"
109    }
110}
111
112/// Generates a Rust wrapper crate for a combination of a WIT package and a JavaScript module.
113///
114/// The `wit` parameter should point to a WIT root (holding the WIT package of the component, with
115/// optionally a `deps` subdirectory with an arbitrary number of dependencies).
116///
117/// The `js_modules` parameter must point to at least one JavaScript module that implements the WIT package,
118/// and optionally additional modules that get imported during the initialization of the component. It is
119/// always the first in the list that is considered the one containing the implementation of the WIT exports.
120///
121/// The `output` parameter is the root directory where the generated Rust crate's source code and
122/// Cargo manifest is placed.
123///
124/// If `world` is `None`, the default world is selected and used, otherwise the specified one.
125pub fn generate_wrapper_crate(
126    wit: &Utf8Path,
127    js_modules: &[JsModuleSpec],
128    output: &Utf8Path,
129    world: Option<&str>,
130) -> anyhow::Result<()> {
131    // Making sure the target directories exists
132    std::fs::create_dir_all(output).context("Failed to create output directory")?;
133    std::fs::create_dir_all(output.join("src")).context("Failed to create output/src directory")?;
134    std::fs::create_dir_all(output.join("src").join("modules"))
135        .context("Failed to create output/src/modules directory")?;
136
137    // Resolving the WIT package (initial parse for Cargo.toml generation)
138    let context = GeneratorContext::new(output, wit, world)?;
139
140    // Generating the Cargo.toml file
141    generate_cargo_toml(&context)?;
142
143    // Copying the skeleton's Cargo.lock for faster dependency resolution
144    copy_skeleton_lock(context.output).context("Failed to copy skeleton Cargo.lock")?;
145
146    // Copying the skeleton files
147    copy_skeleton_sources(context.output).context("Failed to copy skeleton sources")?;
148
149    // Copying the WIT package to the output directory
150    copy_wit_directory(wit, &context.output.join("wit"))
151        .context("Failed to copy WIT package to output directory")?;
152
153    if uses_composition(js_modules) {
154        add_get_script_import(&context.output.join("wit"), world)
155            .context("Failed to add get-script import to the WIT world")?;
156    }
157
158    // Add wizer-initialize export for pre-initialization support
159    add_wizer_init_export(&context.output.join("wit"), world)
160        .context("Failed to add wizer-initialize export to the WIT world")?;
161
162    // Re-resolve the WIT package after modifications (wizer-initialize export was added)
163    let modified_wit = output.join("wit");
164    let context = GeneratorContext::new(output, &modified_wit, world)?;
165
166    // Copying the JavaScript module to the output directory
167    copy_js_modules(js_modules, context.output)
168        .context("Failed to copy JavaScript module to output directory")?;
169
170    // Generating the lib.rs file implementing the component exports
171    generate_export_impls(&context, js_modules)
172        .context("Failed to generate the component export implementations")?;
173
174    // Generating the native modules implementing the component imports
175    generate_import_modules(&context).context("Failed to generate the component import modules")?;
176
177    // Generating the conversions.rs file implementing the IntoJs and FromJs typeclass instances
178    // This step must be done after `generate_export_impls` to ensure all visited types are registered.
179    generate_conversions(&context)
180        .context("Failed to generate the IntoJs and FromJs typeclass instances")?;
181
182    Ok(())
183}
184
185/// Generates TypeScript module definitions for a given (or default) world of a WIT package.
186///
187/// Returns the list of generated files.
188pub fn generate_dts(
189    wit: &Utf8Path,
190    output: &Utf8Path,
191    world: Option<&str>,
192) -> anyhow::Result<Vec<Utf8PathBuf>> {
193    // Making sure the target directories exist
194    std::fs::create_dir_all(output).context("Failed to create output directory")?;
195
196    // Resolving the WIT package
197    let context = GeneratorContext::new(output, wit, world)?;
198
199    let mut result = Vec::new();
200    result.extend(
201        typescript::generate_export_module(&context)
202            .context("Failed to generate the TypeScript module definition for the exports")?,
203    );
204
205    // Generating the native modules implementing the component imports
206    result.extend(typescript::generate_import_modules(&context).context(
207        "Failed to generate the TypeScript module definitions for the imported modules",
208    )?);
209
210    Ok(result)
211}
212
213struct GeneratorContext<'a> {
214    output: &'a Utf8Path,
215    #[allow(dead_code)]
216    wit_source_path: &'a Utf8Path,
217    resolve: Resolve,
218    root_package: PackageId,
219    world: WorldId,
220    #[allow(dead_code)]
221    source_map: PackageSourceMap,
222    visited_types: RefCell<BTreeSet<TypeId>>,
223    world_name: String,
224    types: wit_bindgen_core::Types,
225}
226
227impl<'a> GeneratorContext<'a> {
228    fn new(output: &'a Utf8Path, wit: &'a Utf8Path, world: Option<&str>) -> anyhow::Result<Self> {
229        let mut resolve = Resolve::default();
230        let (root_package, source_map) = resolve
231            .push_path(wit)
232            .context("Failed to resolve WIT package")?;
233        let world = resolve
234            .select_world(root_package, world)
235            .context("Failed to select WIT world")?;
236
237        let world_name = resolve.worlds[world].name.clone();
238
239        let mut types = wit_bindgen_core::Types::default();
240        types.analyze(&resolve);
241
242        Ok(Self {
243            output,
244            wit_source_path: wit,
245            resolve,
246            root_package,
247            world,
248            source_map,
249            visited_types: RefCell::new(BTreeSet::new()),
250            world_name,
251            types,
252        })
253    }
254
255    fn root_package_name(&self) -> String {
256        self.resolve.packages[self.root_package].name.to_string()
257    }
258
259    fn record_visited_type(&self, type_id: TypeId) {
260        self.visited_types.borrow_mut().insert(type_id);
261    }
262
263    fn is_exported_interface(&self, interface_id: InterfaceId) -> bool {
264        let world = &self.resolve.worlds[self.world];
265        world
266            .exports
267            .iter()
268            .any(|(_, item)| matches!(item, WorldItem::Interface { id, .. } if id == &interface_id))
269    }
270
271    fn is_exported_type(&self, type_id: TypeId) -> bool {
272        if let Some(typ) = self.resolve.types.get(type_id) {
273            match &typ.owner {
274                TypeOwner::World(world_id) => {
275                    if world_id == &self.world {
276                        let world = &self.resolve.worlds[self.world];
277                        world
278                            .exports
279                            .iter()
280                            .any(|(_, item)| matches!(item, WorldItem::Type(id) if id == &type_id))
281                    } else {
282                        false
283                    }
284                }
285                TypeOwner::Interface(interface_id) => self.is_exported_interface(*interface_id),
286                TypeOwner::None => false,
287            }
288        } else {
289            false
290        }
291    }
292
293    fn bindgen_type_info(&self, type_id: TypeId) -> wit_bindgen_core::TypeInfo {
294        self.types.get(type_id)
295    }
296
297    fn get_imported_interface(
298        &self,
299        interface_id: &InterfaceId,
300    ) -> anyhow::Result<ImportedInterface<'_>> {
301        let interface = &self.resolve.interfaces[*interface_id];
302        let name = interface
303            .name
304            .as_ref()
305            .ok_or_else(|| anyhow!("Interface import does not have a name"))?
306            .as_str();
307
308        let functions = interface
309            .functions
310            .iter()
311            .map(|(name, f)| (name.as_str(), f))
312            .collect();
313
314        let package_id = interface
315            .package
316            .ok_or_else(|| anyhow!("Anonymous interface imports are not supported yet"))?;
317        let package = self
318            .resolve
319            .packages
320            .get(package_id)
321            .ok_or_else(|| anyhow!("Could not find package of imported interface {name}"))?;
322        let package_name = &package.name;
323
324        Ok(ImportedInterface {
325            package_name: Some(package_name),
326            name: name.to_string(),
327            functions,
328            interface: Some(interface),
329            interface_id: Some(*interface_id),
330        })
331    }
332
333    fn typ(&self, type_id: TypeId) -> anyhow::Result<&TypeDef> {
334        self.resolve
335            .types
336            .get(type_id)
337            .ok_or_else(|| anyhow!("Unknown type id: {type_id:?}"))
338    }
339
340    /// Returns `true` if the given package is a WASI package whose interfaces are remapped
341    /// to `wasip2::` via the `with:` block in `wit_bindgen::generate!`.
342    fn is_wasi_remapped_package(&self, package_id: PackageId) -> bool {
343        let package = &self.resolve.packages[package_id];
344        if package.name.namespace != "wasi" {
345            return false;
346        }
347        WASI_REMAP_NAMESPACES
348            .iter()
349            .any(|(pkg_name, _)| *pkg_name == package.name.name.as_str())
350    }
351
352    /// For a WASI-remapped resource type, returns the import module path and resource class
353    /// name (e.g., `crate::modules::wasi_io_0_2_3_poll::Pollable`).
354    fn wasi_resource_module_path(
355        &self,
356        type_id: TypeId,
357    ) -> Option<(proc_macro2::TokenStream, Ident)> {
358        let typ = self.resolve.types.get(type_id)?;
359        let resource_name = typ.name.as_ref()?;
360        let resource_ident = Ident::new(&resource_name.to_upper_camel_case(), Span::call_site());
361
362        let interface_id = match &typ.owner {
363            TypeOwner::Interface(id) => *id,
364            _ => return None,
365        };
366        let interface = self.resolve.interfaces.get(interface_id)?;
367        let interface_name = interface.name.as_ref()?;
368        let package_id = interface.package?;
369        let package = self.resolve.packages.get(package_id)?;
370        let package_name = &package.name;
371
372        let module_name = format!(
373            "{}_{}",
374            package_name.to_string().to_snake_case(),
375            interface_name.to_snake_case()
376        );
377        let module_ident = Ident::new(&module_name, Span::call_site());
378
379        Some((
380            quote::quote! { crate::modules::#module_ident },
381            resource_ident,
382        ))
383    }
384
385    /// Returns `true` if the given type belongs to a WASI-remapped interface.
386    fn is_wasi_remapped_type(&self, type_id: TypeId) -> bool {
387        if let Some(typ) = self.resolve.types.get(type_id) {
388            match &typ.owner {
389                TypeOwner::Interface(interface_id) => {
390                    if let Some(interface) = self.resolve.interfaces.get(*interface_id)
391                        && let Some(package_id) = interface.package
392                    {
393                        return self.is_wasi_remapped_package(package_id);
394                    }
395                    false
396                }
397                _ => false,
398            }
399        } else {
400            false
401        }
402    }
403}
404
405pub struct ImportedInterface<'a> {
406    package_name: Option<&'a PackageName>,
407    name: String,
408    functions: Vec<(&'a str, &'a Function)>,
409    interface: Option<&'a Interface>,
410    interface_id: Option<InterfaceId>,
411}
412
413impl<'a> ImportedInterface<'a> {
414    pub fn module_name(&self) -> anyhow::Result<String> {
415        let package_name = self
416            .package_name
417            .ok_or_else(|| anyhow!("imported interface has no package name"))?;
418        let interface_name = &self.name;
419
420        Ok(format!(
421            "{}_{}",
422            package_name.to_string().to_snake_case(),
423            interface_name.to_snake_case()
424        ))
425    }
426
427    pub fn rust_interface_name(&self) -> Ident {
428        let interface_name = format!("Js{}Module", self.name.to_upper_camel_case());
429        Ident::new(&interface_name, Span::call_site())
430    }
431
432    pub fn name_and_interface(&self) -> Option<(&str, &Interface)> {
433        self.interface
434            .map(|interface| (self.name.as_str(), interface))
435    }
436
437    pub fn fully_qualified_interface_name(&self) -> String {
438        if let Some(package_name) = &self.package_name {
439            package_name.interface_id(&self.name)
440        } else {
441            self.name.clone()
442        }
443    }
444
445    pub fn interface_stack(&self) -> VecDeque<InterfaceId> {
446        self.interface_id.iter().cloned().collect()
447    }
448}
449
450/// Recursively copies a WIT directory to `<output>/wit`.
451fn copy_wit_directory(wit: &Utf8Path, output: &Utf8Path) -> anyhow::Result<()> {
452    std::fs::create_dir_all(output)?;
453    copy_dir_if_changed(wit.as_std_path(), output.as_std_path())
454        .context("Failed to copy WIT directory")?;
455    Ok(())
456}
457
458fn copy_dir_if_changed(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
459    std::fs::create_dir_all(dst)?;
460    for entry in std::fs::read_dir(src)? {
461        let entry = entry?;
462        let src_path = entry.path();
463        let dst_path = dst.join(entry.file_name());
464        if src_path.is_dir() {
465            copy_dir_if_changed(&src_path, &dst_path)?;
466        } else {
467            copy_if_changed(&src_path, &dst_path)?;
468        }
469    }
470    Ok(())
471}
472
473/// Copies the JS module files to `<output>/src/<name>.js` or generates slot files.
474fn copy_js_modules(js_modules: &[JsModuleSpec], output: &Utf8Path) -> anyhow::Result<()> {
475    let mut slot_index: u32 = 0;
476    for module in js_modules {
477        match &module.mode {
478            EmbeddingMode::EmbedFile(source) => {
479                let filename = module.file_name();
480                let js_dest = output.join("src").join(filename);
481                copy_if_changed(source, js_dest)
482                    .context(format!("Failed to copy JavaScript module {}", module.name))?;
483            }
484            EmbeddingMode::BinarySlot => {
485                let slot_filename = module.name.replace('/', "_") + ".slot";
486                let slot_dest = output.join("src").join(slot_filename);
487                let slot_data = inject::create_marker_file(slot_index);
488                write_if_changed(slot_dest, slot_data).context(format!(
489                    "Failed to create marker file for module {}",
490                    module.name
491                ))?;
492                slot_index += 1;
493            }
494            EmbeddingMode::Composition => {}
495        }
496    }
497    Ok(())
498}
499
500/// Checks if any of the provided JS modules uses composition mode.
501fn uses_composition(js_module_spec: &[JsModuleSpec]) -> bool {
502    js_module_spec
503        .iter()
504        .any(|m| matches!(m.mode, EmbeddingMode::Composition))
505}