posthog_cli/utils/
sourcemaps.rs

1use anyhow::{anyhow, bail, Context, Ok, Result};
2use core::str;
3use posthog_symbol_data::{write_symbol_data, SourceAndMap};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::{
7    collections::HashMap,
8    path::{Path, PathBuf},
9};
10use tracing::{info, warn};
11use walkdir::WalkDir;
12
13pub struct Source {
14    path: PathBuf,
15    pub content: String,
16}
17
18impl Source {
19    pub fn get_sourcemap_path(&self) -> PathBuf {
20        // Try to resolve the sourcemap by adding .map to the path
21        let mut path = self.path.clone();
22        match path.extension() {
23            Some(ext) => path.set_extension(format!("{}.map", ext.to_string_lossy())),
24            None => path.set_extension("map"),
25        };
26        path
27    }
28
29    pub fn add_chunk_id(&mut self, chunk_id: String) {
30        self.prepend(&format!(r#"!function(){{try{{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{{}},n=(new e.Error).stack;n&&(e._posthogChunkIds=e._posthogChunkIds||{{}},e._posthogChunkIds[n]="{}")}}catch(e){{}}}}();"#, chunk_id));
31        self.append(&format!(r#"//# chunkId={}"#, chunk_id));
32    }
33
34    pub fn read(path: &PathBuf) -> Result<Source> {
35        let content = std::fs::read_to_string(path)
36            .map_err(|_| anyhow!("Failed to read source file: {}", path.display()))?;
37        Ok(Source {
38            path: path.clone(),
39            content,
40        })
41    }
42
43    pub fn write(&self) -> Result<()> {
44        std::fs::write(&self.path, &self.content)?;
45        Ok(())
46    }
47
48    pub fn prepend(&mut self, prefix: &str) {
49        self.content.insert_str(0, prefix);
50    }
51
52    pub fn append(&mut self, suffix: &str) {
53        self.content.push_str(suffix);
54    }
55}
56
57#[derive(Debug, Serialize, Deserialize)]
58pub struct SourceMapContent {
59    chunk_id: Option<String>,
60    #[serde(flatten)]
61    fields: HashMap<String, Value>,
62}
63
64#[derive(Debug)]
65pub struct SourceMap {
66    pub path: PathBuf,
67    content: SourceMapContent,
68}
69
70impl SourceMap {
71    pub fn add_chunk_id(&mut self, chunk_id: String) -> Result<()> {
72        if self.content.chunk_id.is_some() {
73            bail!("Sourcemap has already been processed");
74        }
75        self.content.chunk_id = Some(chunk_id);
76        Ok(())
77    }
78
79    pub fn read(path: &PathBuf) -> Result<SourceMap> {
80        let content = serde_json::from_slice(&std::fs::read(path)?)?;
81        Ok(SourceMap {
82            path: path.clone(),
83            content,
84        })
85    }
86
87    pub fn write(&self) -> Result<()> {
88        std::fs::write(&self.path, self.to_string()?)?;
89        Ok(())
90    }
91
92    pub fn chunk_id(&self) -> Option<String> {
93        self.content.chunk_id.clone()
94    }
95
96    pub fn to_string(&self) -> Result<String> {
97        serde_json::to_string(&self.content)
98            .map_err(|e| anyhow!("Failed to serialize sourcemap content: {}", e))
99    }
100}
101
102pub struct SourcePair {
103    pub source: Source,
104    pub sourcemap: SourceMap,
105}
106
107pub struct ChunkUpload {
108    pub chunk_id: String,
109    pub data: Vec<u8>,
110}
111
112impl SourcePair {
113    pub fn add_chunk_id(&mut self, chunk_id: String) -> Result<()> {
114        self.source.add_chunk_id(chunk_id.clone());
115        self.sourcemap.add_chunk_id(chunk_id)?;
116        Ok(())
117    }
118
119    pub fn write(&self) -> Result<()> {
120        self.source.write()?;
121        self.sourcemap.write()?;
122        Ok(())
123    }
124
125    pub fn chunk_id(&self) -> Option<String> {
126        self.sourcemap.chunk_id()
127    }
128
129    pub fn into_chunk_upload(self) -> Result<ChunkUpload> {
130        let chunk_id = self
131            .chunk_id()
132            .ok_or_else(|| anyhow!("Chunk ID not found"))?;
133        let sourcemap_content = self
134            .sourcemap
135            .to_string()
136            .context("Failed to serialize sourcemap")?;
137        let data = SourceAndMap {
138            minified_source: self.source.content,
139            sourcemap: sourcemap_content,
140        };
141        let data = write_symbol_data(data)?;
142        Ok(ChunkUpload { chunk_id, data })
143    }
144}
145
146pub fn read_pairs(directory: &PathBuf) -> Result<Vec<SourcePair>> {
147    // Make sure the directory exists
148    if !directory.exists() {
149        bail!("Directory does not exist");
150    }
151
152    let mut pairs = Vec::new();
153    for entry in WalkDir::new(directory).into_iter().filter_map(|e| e.ok()) {
154        let entry_path = entry.path().canonicalize()?;
155        info!("Processing file: {}", entry_path.display());
156        if is_javascript_file(&entry_path) {
157            let source = Source::read(&entry_path)?;
158            let sourcemap_path = source.get_sourcemap_path();
159            if sourcemap_path.exists() {
160                let sourcemap = SourceMap::read(&sourcemap_path)?;
161                pairs.push(SourcePair { source, sourcemap });
162            } else {
163                warn!("No sourcemap file found for file {}", entry_path.display());
164            }
165        }
166    }
167    Ok(pairs)
168}
169
170fn is_javascript_file(path: &Path) -> bool {
171    path.extension()
172        .map_or(false, |ext| ext == "js" || ext == "mjs" || ext == "cjs")
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use anyhow::{Context, Result};
179    use std::fs::File;
180    use std::io::Write;
181    use tempfile::{tempdir, TempDir};
182    use test_log::test;
183    use tracing::info;
184
185    fn create_pair(dir: &TempDir, path: &str, pair_name: &str, extension: &str) -> Result<()> {
186        let sub_path = dir.path().join(path);
187        if !sub_path.exists() {
188            std::fs::create_dir_all(&sub_path)?;
189        }
190        let js_path = sub_path.join(format!("{}.{}", pair_name, extension));
191        info!("Creating file: {:?}", js_path);
192        let mut file = File::create(&js_path).context("Failed to create file")?;
193        let map_path = sub_path.join(format!("{}.{}.{}", pair_name, extension, "map"));
194        let mut map_file = File::create(&map_path).context("Failed to create map")?;
195        writeln!(file, "console.log('hello');").context("Failed to write to file")?;
196        writeln!(map_file, "{{}}").context("Failed to write to file")?;
197        Ok(())
198    }
199
200    fn setup_test_directory() -> Result<TempDir> {
201        let dir = tempdir()?;
202        create_pair(&dir, "", "regular", "js")?;
203        create_pair(&dir, "assets", "module", "mjs")?;
204        create_pair(&dir, "assets/sub", "common", "cjs")?;
205        Ok(dir)
206    }
207
208    #[test]
209    fn test_tempdir_creation() {
210        let dist_dir = setup_test_directory().unwrap();
211        assert!(dist_dir.path().exists());
212        let dist_dir_path = dist_dir.path().to_path_buf();
213        let pairs = read_pairs(&dist_dir_path).expect("Failed to read pairs");
214        assert_eq!(pairs.len(), 3);
215    }
216}