wasmer_pack/js/
mod.rs

1use std::path::Path;
2
3use anyhow::Error;
4use heck::{ToPascalCase, ToSnakeCase};
5use minijinja::Environment;
6use once_cell::sync::Lazy;
7use wai_bindgen_gen_core::Generator;
8use wai_bindgen_gen_js::Js;
9use wai_parser::Interface;
10
11use crate::{types::Command, Files, Library, Metadata, Package, SourceFile};
12
13/// The version of `@wasmer/wasi` pulled in when using a WASI library.
14///
15/// Note: we need at least `1.2.2` so we get the fix for
16/// [wasmer-js#310](https://github.com/wasmerio/wasmer-js/pull/310).
17const WASMER_WASI_VERSION: &str = "^1.2.2";
18
19static TEMPLATES: Lazy<Environment> = Lazy::new(|| {
20    let mut env = Environment::new();
21    env.add_template("bindings.index.js", include_str!("bindings.index.js.j2"))
22        .unwrap();
23    env.add_template(
24        "bindings.index.d.ts",
25        include_str!("bindings.index.d.ts.j2"),
26    )
27    .unwrap();
28    env.add_template("command.d.ts", include_str!("command.d.ts.j2"))
29        .unwrap();
30    env.add_template("command.js", include_str!("command.js.j2"))
31        .unwrap();
32    env.add_template("top-level.index.js", include_str!("top-level.index.js.j2"))
33        .unwrap();
34    env.add_template(
35        "top-level.index.d.ts",
36        include_str!("top-level.index.d.ts.j2"),
37    )
38    .unwrap();
39
40    env
41});
42
43/// Generate JavaScript bindings for a package.
44pub fn generate_javascript(package: &Package) -> Result<Files, Error> {
45    let mut files = Files::new();
46
47    let ctx = Context::for_package(package);
48
49    files.insert_child_directory(Path::new("src").join("bindings"), library_bindings(&ctx)?);
50
51    for cmd in &ctx.commands {
52        files.insert_child_directory(Path::new("src").join("commands"), command_bindings(cmd)?);
53    }
54
55    files.insert_child_directory("src", top_level(&ctx)?);
56
57    let package_json = generate_package_json(package.requires_wasi(), package.metadata());
58    files.insert("package.json", package_json);
59
60    // Note: We need to wrap the generated files in an extra folder because
61    // that's how "npm pack" works
62    let mut f = Files::new();
63    f.insert_child_directory("package", files);
64
65    Ok(f)
66}
67
68#[derive(Debug, serde::Serialize)]
69struct Context {
70    libraries: Vec<LibraryContext>,
71    commands: Vec<CommandContext>,
72    generator: String,
73    wasi: bool,
74    has_wasi_libraries: bool,
75}
76
77impl Context {
78    fn for_package(pkg: &Package) -> Self {
79        let libraries: Vec<_> = pkg
80            .libraries()
81            .iter()
82            .map(LibraryContext::for_lib)
83            .collect();
84        let commands: Vec<_> = pkg.commands().iter().map(CommandContext::for_cmd).collect();
85
86        let has_wasi_libraries = libraries.iter().any(|lib| lib.wasi);
87
88        let wasi = !commands.is_empty() || has_wasi_libraries;
89
90        Context {
91            libraries,
92            commands,
93            generator: crate::GENERATOR.to_string(),
94            wasi,
95            has_wasi_libraries,
96        }
97    }
98}
99
100#[derive(Debug, serde::Serialize)]
101struct CommandContext {
102    name: String,
103    ident: String,
104    module_filename: String,
105    #[serde(skip)]
106    wasm: Vec<u8>,
107}
108
109impl CommandContext {
110    fn for_cmd(cmd: &Command) -> CommandContext {
111        let module_filename = Path::new(&cmd.name).with_extension("wasm");
112
113        CommandContext {
114            name: cmd.name.clone(),
115            ident: cmd.name.replace('-', "_"),
116            module_filename: module_filename.display().to_string(),
117            wasm: cmd.wasm.clone(),
118        }
119    }
120}
121
122#[derive(Debug, serde::Serialize)]
123struct LibraryContext {
124    /// The identifier that should be used when accessing this library.
125    ident: String,
126    /// The filename of the WebAssembly module (e.g. `wasmer-pack.wasm`).
127    module_filename: String,
128    /// Does this library require WASI?
129    wasi: bool,
130    exports: InterfaceContext,
131    imports: Vec<InterfaceContext>,
132    #[serde(skip)]
133    wasm: Vec<u8>,
134}
135
136impl LibraryContext {
137    fn for_lib(lib: &Library) -> Self {
138        let module_filename = Path::new(lib.module_filename()).with_extension("wasm");
139        let interface_name = lib.interface_name();
140        let ident = interface_name.to_snake_case();
141
142        let exports = InterfaceContext {
143            interface_name: lib.exports.name().to_string(),
144            class_name: lib.exports.name().to_pascal_case(),
145            interface: lib.exports.0.clone(),
146        };
147        let imports = lib
148            .imports
149            .iter()
150            .map(|interface| InterfaceContext {
151                interface_name: interface.name().to_string(),
152                class_name: interface.name().to_pascal_case(),
153                interface: interface.0.clone(),
154            })
155            .collect();
156
157        LibraryContext {
158            ident,
159            module_filename: module_filename.display().to_string(),
160            wasi: lib.requires_wasi(),
161            exports,
162            imports,
163            wasm: lib.module.wasm.clone(),
164        }
165    }
166}
167
168#[derive(Debug, serde::Serialize)]
169struct InterfaceContext {
170    /// The name of the interface (i.e. the `wasmer-pack` in
171    /// `wasmer-pack.exports.wit`).
172    interface_name: String,
173    /// The name of the class generated by `wai-bindgen` (i.e. `WasmerPack`).
174    class_name: String,
175    #[serde(skip)]
176    interface: Interface,
177}
178
179fn command_bindings(cmd: &CommandContext) -> Result<Files, Error> {
180    let mut files = Files::new();
181    let module_filename = Path::new(&cmd.name).with_extension("wasm");
182
183    files.insert(
184        Path::new(&cmd.name).with_extension("js"),
185        TEMPLATES
186            .get_template("command.js")
187            .unwrap()
188            .render(cmd)?
189            .into(),
190    );
191
192    files.insert(
193        Path::new(&cmd.name).with_extension("d.ts"),
194        TEMPLATES
195            .get_template("command.d.ts")
196            .unwrap()
197            .render(cmd)?
198            .into(),
199    );
200    files.insert(module_filename, SourceFile::from(&cmd.wasm));
201
202    Ok(files)
203}
204
205fn top_level(ctx: &Context) -> Result<Files, Error> {
206    let mut files = Files::new();
207
208    files.insert(
209        "index.js",
210        TEMPLATES
211            .get_template("top-level.index.js")
212            .unwrap()
213            .render(ctx)?
214            .into(),
215    );
216
217    files.insert(
218        "index.d.ts",
219        TEMPLATES
220            .get_template("top-level.index.d.ts")
221            .unwrap()
222            .render(ctx)?
223            .into(),
224    );
225
226    Ok(files)
227}
228
229fn library_bindings(ctx: &Context) -> Result<Files, Error> {
230    let mut files = Files::new();
231
232    for LibraryContext {
233        module_filename,
234        exports,
235        imports,
236        wasm,
237        ..
238    } in &ctx.libraries
239    {
240        let mut bindings = generate_bindings(exports, imports);
241        bindings.insert(module_filename, wasm.into());
242        files.insert_child_directory(&exports.interface_name, bindings);
243    }
244
245    let index_js = TEMPLATES
246        .get_template("bindings.index.js")
247        .unwrap()
248        .render(ctx)?;
249    files.insert("index.js", index_js.into());
250
251    let typings_file = TEMPLATES
252        .get_template("bindings.index.d.ts")
253        .unwrap()
254        .render(ctx)?;
255    files.insert("index.d.ts", typings_file.into());
256
257    Ok(files)
258}
259
260fn generate_package_json(needs_wasi: bool, metadata: &Metadata) -> SourceFile {
261    let dependencies = if needs_wasi {
262        serde_json::json!({
263            "@wasmer/wasi": WASMER_WASI_VERSION,
264        })
265    } else {
266        serde_json::json!({})
267    };
268
269    let package_json = serde_json::json!({
270        "name": metadata.package_name.javascript_package(),
271        "version": &metadata.version,
272        "main": format!("src/index.js"),
273        "types": format!("src/index.d.ts"),
274        "type": "commonjs",
275        "dependencies": dependencies,
276    });
277
278    format!("{package_json:#}").into()
279}
280
281fn generate_bindings(
282    guest_exports: &InterfaceContext,
283    guest_imports: &[InterfaceContext],
284) -> Files {
285    // Note: imports and exports were reported from the perspective of the
286    // guest, but we're generating bindings from the perspective of the host.
287    // Hence the "host_imports = guest_exports" thing.
288    let host_imports: &[wai_parser::Interface] = &[guest_exports.interface.clone()];
289    let host_exports: Vec<_> = guest_imports.iter().map(|i| i.interface.clone()).collect();
290
291    let mut generated = wai_bindgen_gen_core::Files::default();
292
293    Js::new().generate_all(host_imports, &host_exports, &mut generated);
294
295    generated.into()
296}
297
298#[cfg(test)]
299mod tests {
300    use std::collections::BTreeSet;
301
302    use insta::Settings;
303
304    use crate::{Metadata, Module};
305
306    use super::*;
307
308    #[test]
309    fn package_json() {
310        let metadata = Metadata::new("wasmerio/wasmer-pack".parse().unwrap(), "0.0.0");
311
312        let got = generate_package_json(false, &metadata);
313
314        insta::assert_display_snapshot!(got.utf8_contents().unwrap());
315    }
316
317    #[test]
318    fn package_json_wasi() {
319        let metadata = Metadata::new("wasmerio/wabt".parse().unwrap(), "0.0.0");
320
321        let got = generate_package_json(true, &metadata);
322
323        insta::assert_display_snapshot!(got.utf8_contents().unwrap());
324    }
325
326    const WASMER_PACK_EXPORTS: &str = include_str!(concat!(
327        env!("CARGO_MANIFEST_DIR"),
328        "/../wasm/wasmer-pack.exports.wai"
329    ));
330
331    #[test]
332    fn generated_files() {
333        let expected: BTreeSet<&Path> = [
334            "package/package.json",
335            "package/src/bindings/index.d.ts",
336            "package/src/bindings/index.js",
337            "package/src/bindings/wasmer-pack/browser.d.ts",
338            "package/src/bindings/wasmer-pack/browser.js",
339            "package/src/bindings/wasmer-pack/intrinsics.js",
340            "package/src/bindings/wasmer-pack/wasmer_pack_wasm.wasm",
341            "package/src/bindings/wasmer-pack/wasmer-pack.d.ts",
342            "package/src/bindings/wasmer-pack/wasmer-pack.js",
343            "package/src/commands/first.d.ts",
344            "package/src/commands/first.js",
345            "package/src/commands/first.wasm",
346            "package/src/commands/second-with-dashes.d.ts",
347            "package/src/commands/second-with-dashes.js",
348            "package/src/commands/second-with-dashes.wasm",
349            "package/src/index.d.ts",
350            "package/src/index.js",
351        ]
352        .iter()
353        .map(Path::new)
354        .collect();
355        let metadata = Metadata::new("wasmer/wasmer-pack".parse().unwrap(), "1.2.3");
356        let module = Module {
357            name: "wasmer_pack_wasm.wasm".to_string(),
358            abi: crate::Abi::None,
359            wasm: Vec::new(),
360        };
361        let exports =
362            crate::Interface::from_wit("wasmer-pack.exports.wit", WASMER_PACK_EXPORTS).unwrap();
363        let commands = vec![
364            Command::new("first", []),
365            Command::new("second-with-dashes", []),
366        ];
367        let browser =
368            crate::Interface::from_wit("browser.wit", "greet: func(who: string) -> string")
369                .unwrap();
370        let libraries = vec![Library {
371            module,
372            exports,
373            imports: vec![browser],
374        }];
375        let pkg = Package::new(metadata, libraries, commands);
376
377        let files = generate_javascript(&pkg).unwrap();
378
379        let actual_files: BTreeSet<_> = files.iter().map(|(p, _)| p).collect();
380        assert_eq!(actual_files, expected);
381
382        let mut settings = Settings::clone_current();
383        settings.add_filter(
384            r"Generated by wasmer-pack v\d+\.\d+\.\d+(-\w+(\.\d+)?)?",
385            "Generated by XXX",
386        );
387        settings.bind(|| {
388            insta::assert_display_snapshot!(files["package/package.json"].utf8_contents().unwrap());
389            insta::assert_display_snapshot!(files["package/src/commands/first.d.ts"]
390                .utf8_contents()
391                .unwrap());
392            insta::assert_display_snapshot!(files["package/src/commands/first.js"]
393                .utf8_contents()
394                .unwrap());
395            insta::assert_display_snapshot!(files["package/src/commands/second-with-dashes.d.ts"]
396                .utf8_contents()
397                .unwrap());
398            insta::assert_display_snapshot!(files["package/src/commands/second-with-dashes.js"]
399                .utf8_contents()
400                .unwrap());
401            insta::assert_display_snapshot!(files["package/src/index.d.ts"]
402                .utf8_contents()
403                .unwrap());
404            insta::assert_display_snapshot!(files["package/src/index.js"].utf8_contents().unwrap());
405            insta::assert_display_snapshot!(files["package/src/bindings/index.d.ts"]
406                .utf8_contents()
407                .unwrap());
408            insta::assert_display_snapshot!(files["package/src/bindings/index.js"]
409                .utf8_contents()
410                .unwrap());
411            insta::assert_display_snapshot!(files["package/src/bindings/wasmer-pack/browser.d.ts"]
412                .utf8_contents()
413                .unwrap());
414            insta::assert_display_snapshot!(files["package/src/bindings/wasmer-pack/browser.js"]
415                .utf8_contents()
416                .unwrap());
417        });
418    }
419}