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