lazy_js_bundle/
lib.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
use std::collections::hash_map::DefaultHasher;
use std::path::{Path, PathBuf};
use std::{hash::Hasher, process::Command};

struct Binding {
    input_path: PathBuf,
    output_path: PathBuf,
}

/// A builder for generating TypeScript bindings lazily
#[derive(Default)]
pub struct LazyTypeScriptBindings {
    binding: Vec<Binding>,
    minify_level: MinifyLevel,
    watching: Vec<PathBuf>,
}

impl LazyTypeScriptBindings {
    /// Create a new builder for generating TypeScript bindings that inputs from the given path and outputs javascript to the given path
    pub fn new() -> Self {
        Self::default()
    }

    /// Add a binding to generate
    pub fn with_binding(
        mut self,
        input_path: impl AsRef<Path>,
        output_path: impl AsRef<Path>,
    ) -> Self {
        let input_path = input_path.as_ref();
        let output_path = output_path.as_ref();

        self.binding.push(Binding {
            input_path: input_path.to_path_buf(),
            output_path: output_path.to_path_buf(),
        });

        self
    }

    /// Set the minify level for the bindings
    pub fn with_minify_level(mut self, minify_level: MinifyLevel) -> Self {
        self.minify_level = minify_level;
        self
    }

    /// Watch any .js or .ts files in a directory and re-generate the bindings when they change
    // TODO: we should watch any files that get bundled by bun by reading the source map
    pub fn with_watching(mut self, path: impl AsRef<Path>) -> Self {
        let path = path.as_ref();
        self.watching.push(path.to_path_buf());
        self
    }

    /// Run the bindings
    pub fn run(&self) {
        // If any TS changes, re-run the build script
        let mut watching_paths = Vec::new();
        for path in &self.watching {
            if let Ok(dir) = std::fs::read_dir(path) {
                for entry in dir.flatten() {
                    let path = entry.path();
                    if path
                        .extension()
                        .map(|ext| ext == "ts" || ext == "js")
                        .unwrap_or(false)
                    {
                        watching_paths.push(path);
                    }
                }
            } else {
                watching_paths.push(path.to_path_buf());
            }
        }
        for path in &watching_paths {
            println!("cargo:rerun-if-changed={}", path.display());
        }

        // Compute the hash of the input files
        let hashes = hash_files(watching_paths);

        let hash_location = PathBuf::from("./src/js/");
        std::fs::create_dir_all(&hash_location).unwrap_or_else(|err| {
            panic!(
                "Failed to create directory for hash file: {} in {}",
                err,
                hash_location.display()
            )
        });
        let hash_location = hash_location.join("hash.txt");

        // If the hash matches the one on disk, we're good and don't need to update bindings
        let fs_hash_string = std::fs::read_to_string(&hash_location);
        let expected = fs_hash_string
            .as_ref()
            .map(|s| s.trim())
            .unwrap_or_default();
        let hashes_string = format!("{hashes:?}");
        if expected == hashes_string {
            return;
        }

        // Otherwise, generate the bindings and write the new hash to disk
        for path in &self.binding {
            gen_bindings(&path.input_path, &path.output_path, self.minify_level);
        }

        std::fs::write(hash_location, hashes_string).unwrap();
    }
}

/// The level of minification to apply to the bindings
#[derive(Copy, Clone, Debug, Default)]
pub enum MinifyLevel {
    /// Don't minify the bindings
    None,
    /// Minify whitespace
    Whitespace,
    /// Minify whitespace and syntax
    #[default]
    Syntax,
    /// Minify whitespace, syntax, and identifiers
    Identifiers,
}

impl MinifyLevel {
    fn as_args(&self) -> &'static [&'static str] {
        match self {
            MinifyLevel::None => &[],
            MinifyLevel::Whitespace => &["--minify-whitespace"],
            MinifyLevel::Syntax => &["--minify-whitespace", "--minify-syntax"],
            MinifyLevel::Identifiers => &[
                "--minify-whitespace",
                "--minify-syntax",
                "--minify-identifiers",
            ],
        }
    }
}

/// Hashes the contents of a directory
fn hash_files(mut files: Vec<PathBuf>) -> Vec<u64> {
    // Different systems will read the files in different orders, so we sort them to make sure the hash is consistent
    files.sort();
    let mut hashes = Vec::new();
    for file in files {
        let mut hash = DefaultHasher::new();
        let Ok(contents) = std::fs::read_to_string(file) else {
            continue;
        };
        // windows + git does a weird thing with line endings, so we need to normalize them
        for line in contents.lines() {
            hash.write(line.as_bytes());
        }
        hashes.push(hash.finish());
    }
    hashes
}

// okay...... so bun might fail if the user doesn't have it installed
// we don't really want to fail if that's the case
// but if you started *editing* the .ts files, you're gonna have a bad time
// so.....
// we need to hash each of the .ts files and add that hash to the JS files
// if the hashes don't match, we need to fail the build
// that way we also don't need
fn gen_bindings(input_path: &Path, output_path: &Path, minify_level: MinifyLevel) {
    // If the file is generated, and the hash is different, we need to generate it
    let status = Command::new("bun")
        .arg("build")
        .arg(input_path)
        .arg("--outfile")
        .arg(output_path)
        .args(minify_level.as_args())
        .status()
        .unwrap();

    if !status.success() {
        panic!(
            "Failed to generate bindings for {:?}. Make sure you have bun installed",
            input_path
        );
    }
}