lazy_js_bundle/
lib.rs

1use std::collections::hash_map::DefaultHasher;
2use std::path::{Path, PathBuf};
3use std::{hash::Hasher, process::Command};
4
5struct Binding {
6    input_path: PathBuf,
7    output_path: PathBuf,
8}
9
10/// A builder for generating TypeScript bindings lazily
11#[derive(Default)]
12pub struct LazyTypeScriptBindings {
13    binding: Vec<Binding>,
14    minify_level: MinifyLevel,
15    watching: Vec<PathBuf>,
16}
17
18impl LazyTypeScriptBindings {
19    /// Create a new builder for generating TypeScript bindings that inputs from the given path and outputs javascript to the given path
20    pub fn new() -> Self {
21        Self::default()
22    }
23
24    /// Add a binding to generate
25    pub fn with_binding(
26        mut self,
27        input_path: impl AsRef<Path>,
28        output_path: impl AsRef<Path>,
29    ) -> Self {
30        let input_path = input_path.as_ref();
31        let output_path = output_path.as_ref();
32
33        self.binding.push(Binding {
34            input_path: input_path.to_path_buf(),
35            output_path: output_path.to_path_buf(),
36        });
37
38        self
39    }
40
41    /// Set the minify level for the bindings
42    pub fn with_minify_level(mut self, minify_level: MinifyLevel) -> Self {
43        self.minify_level = minify_level;
44        self
45    }
46
47    /// Watch any .js or .ts files in a directory and re-generate the bindings when they change
48    // TODO: we should watch any files that get bundled by bun by reading the source map
49    pub fn with_watching(mut self, path: impl AsRef<Path>) -> Self {
50        let path = path.as_ref();
51        self.watching.push(path.to_path_buf());
52        self
53    }
54
55    /// Run the bindings
56    pub fn run(&self) {
57        // If any TS changes, re-run the build script
58        let mut watching_paths = Vec::new();
59        for path in &self.watching {
60            if let Ok(dir) = std::fs::read_dir(path) {
61                for entry in dir.flatten() {
62                    let path = entry.path();
63                    if path
64                        .extension()
65                        .map(|ext| ext == "ts" || ext == "js")
66                        .unwrap_or(false)
67                    {
68                        watching_paths.push(path);
69                    }
70                }
71            } else {
72                watching_paths.push(path.to_path_buf());
73            }
74        }
75        for path in &watching_paths {
76            println!("cargo:rerun-if-changed={}", path.display());
77        }
78
79        // Compute the hash of the input files
80        let hashes = hash_files(watching_paths);
81
82        let hash_location = PathBuf::from("./src/js/");
83        std::fs::create_dir_all(&hash_location).unwrap_or_else(|err| {
84            panic!(
85                "Failed to create directory for hash file: {} in {}",
86                err,
87                hash_location.display()
88            )
89        });
90        let hash_location = hash_location.join("hash.txt");
91
92        // If the hash matches the one on disk, we're good and don't need to update bindings
93        let fs_hash_string = std::fs::read_to_string(&hash_location);
94        let expected = fs_hash_string
95            .as_ref()
96            .map(|s| s.trim())
97            .unwrap_or_default();
98        let hashes_string = format!("{hashes:?}");
99        if expected == hashes_string {
100            return;
101        }
102
103        // Otherwise, generate the bindings and write the new hash to disk
104        for path in &self.binding {
105            gen_bindings(&path.input_path, &path.output_path, self.minify_level);
106        }
107
108        std::fs::write(hash_location, hashes_string).unwrap();
109    }
110}
111
112/// The level of minification to apply to the bindings
113#[derive(Copy, Clone, Debug, Default)]
114pub enum MinifyLevel {
115    /// Don't minify the bindings
116    None,
117    /// Minify whitespace
118    Whitespace,
119    /// Minify whitespace and syntax
120    #[default]
121    Syntax,
122    /// Minify whitespace, syntax, and identifiers
123    Identifiers,
124}
125
126impl MinifyLevel {
127    fn as_args(&self) -> &'static [&'static str] {
128        match self {
129            MinifyLevel::None => &[],
130            MinifyLevel::Whitespace => &["--minify-whitespace"],
131            MinifyLevel::Syntax => &["--minify-whitespace", "--minify-syntax"],
132            MinifyLevel::Identifiers => &[
133                "--minify-whitespace",
134                "--minify-syntax",
135                "--minify-identifiers",
136            ],
137        }
138    }
139}
140
141/// Hashes the contents of a directory
142fn hash_files(mut files: Vec<PathBuf>) -> Vec<u64> {
143    // Different systems will read the files in different orders, so we sort them to make sure the hash is consistent
144    files.sort();
145    let mut hashes = Vec::new();
146    for file in files {
147        let mut hash = DefaultHasher::new();
148        let Ok(contents) = std::fs::read_to_string(file) else {
149            continue;
150        };
151        // windows + git does a weird thing with line endings, so we need to normalize them
152        for line in contents.lines() {
153            hash.write(line.as_bytes());
154        }
155        hashes.push(hash.finish());
156    }
157    hashes
158}
159
160// okay...... so bun might fail if the user doesn't have it installed
161// we don't really want to fail if that's the case
162// but if you started *editing* the .ts files, you're gonna have a bad time
163// so.....
164// we need to hash each of the .ts files and add that hash to the JS files
165// if the hashes don't match, we need to fail the build
166// that way we also don't need
167fn gen_bindings(input_path: &Path, output_path: &Path, minify_level: MinifyLevel) {
168    // If the file is generated, and the hash is different, we need to generate it
169    let status = Command::new("bun")
170        .arg("build")
171        .arg(input_path)
172        .arg("--outfile")
173        .arg(output_path)
174        .args(minify_level.as_args())
175        .status()
176        .unwrap();
177
178    if !status.success() {
179        panic!(
180            "Failed to generate bindings for {:?}. Make sure you have bun installed",
181            input_path
182        );
183    }
184}