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