posthog_cli/utils/
sourcemaps.rs

1use anyhow::{anyhow, bail, Context, Ok, Result};
2use core::str;
3use globset::{Glob, GlobSetBuilder};
4use magic_string::{GenerateDecodedMapOptions, MagicString};
5use posthog_symbol_data::{write_symbol_data, SourceAndMap};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use sourcemap::SourceMap;
9use std::collections::BTreeMap;
10use std::str::Lines;
11use std::{
12    collections::HashMap,
13    path::{Path, PathBuf},
14};
15use tracing::{debug, info, warn};
16use walkdir::WalkDir;
17
18use super::constant::{CHUNKID_COMMENT_PREFIX, CHUNKID_PLACEHOLDER, CODE_SNIPPET_TEMPLATE};
19
20pub struct SourceFile {
21    pub path: PathBuf,
22    pub content: String,
23}
24
25#[derive(Debug, Serialize, Deserialize)]
26pub struct SourceMapChunkId {
27    chunk_id: Option<String>,
28    #[serde(flatten)]
29    fields: BTreeMap<String, Value>,
30}
31
32impl SourceFile {
33    pub fn new(path: PathBuf, content: String) -> Self {
34        SourceFile { path, content }
35    }
36
37    pub fn load(path: &PathBuf) -> Result<Self> {
38        let content = std::fs::read_to_string(path)?;
39        Ok(SourceFile::new(path.clone(), content))
40    }
41
42    pub fn save(&self, dest: Option<PathBuf>) -> Result<()> {
43        let final_path = dest.unwrap_or(self.path.clone());
44        std::fs::write(&final_path, &self.content)?;
45        Ok(())
46    }
47}
48
49#[derive(Debug, Serialize, Deserialize)]
50pub struct SourceMapContent {
51    chunk_id: Option<String>,
52    #[serde(flatten)]
53    fields: HashMap<String, Value>,
54}
55
56pub struct SourcePair {
57    pub chunk_id: Option<String>,
58
59    pub source: SourceFile,
60    pub sourcemap: SourceFile,
61}
62
63pub struct ChunkUpload {
64    pub chunk_id: String,
65    pub data: Vec<u8>,
66}
67
68impl SourcePair {
69    pub fn has_chunk_id(&self) -> bool {
70        self.chunk_id.is_some()
71    }
72
73    pub fn set_chunk_id(&mut self, chunk_id: String) -> Result<()> {
74        if self.has_chunk_id() {
75            return Err(anyhow!("Chunk ID already set"));
76        }
77        let (new_source_content, source_adjustment) = {
78            // Update source content with chunk ID
79            let source_content = &self.source.content;
80            let mut magic_source = MagicString::new(source_content);
81            let code_snippet = CODE_SNIPPET_TEMPLATE.replace(CHUNKID_PLACEHOLDER, &chunk_id);
82            magic_source
83                .prepend(&code_snippet)
84                .map_err(|err| anyhow!("Failed to prepend code snippet: {}", err))?;
85            let chunk_comment = CHUNKID_COMMENT_PREFIX.replace(CHUNKID_PLACEHOLDER, &chunk_id);
86            magic_source
87                .append(&chunk_comment)
88                .map_err(|err| anyhow!("Failed to append chunk comment: {}", err))?;
89            let adjustment = magic_source
90                .generate_map(GenerateDecodedMapOptions {
91                    include_content: true,
92                    ..Default::default()
93                })
94                .map_err(|err| anyhow!("Failed to generate source map: {}", err))?;
95            let adjustment_sourcemap = SourceMap::from_slice(
96                adjustment
97                    .to_string()
98                    .map_err(|err| anyhow!("Failed to serialize source map: {}", err))?
99                    .as_bytes(),
100            )
101            .map_err(|err| anyhow!("Failed to parse adjustment sourcemap: {}", err))?;
102            (magic_source.to_string(), adjustment_sourcemap)
103        };
104
105        let new_sourcemap = {
106            // Update the sourcemap with the new mappings
107            let mut original_sourcemap =
108                match sourcemap::decode_slice(self.sourcemap.content.as_bytes())
109                    .map_err(|err| anyhow!("Failed to parse sourcemap: {}", err))?
110                {
111                    sourcemap::DecodedMap::Regular(map) => map,
112                    sourcemap::DecodedMap::Index(index_map) => index_map
113                        .flatten()
114                        .map_err(|err| anyhow!("Failed to parse sourcemap: {}", err))?,
115                    sourcemap::DecodedMap::Hermes(_) => {
116                        anyhow::bail!("Hermes source maps are not supported")
117                    }
118                };
119
120            original_sourcemap.adjust_mappings(&source_adjustment);
121
122            let mut new_sourcemap_bytes = Vec::new();
123            original_sourcemap.to_writer(&mut new_sourcemap_bytes)?;
124
125            let mut sourcemap_chunk: SourceMapChunkId =
126                serde_json::from_slice(&new_sourcemap_bytes)?;
127            sourcemap_chunk.chunk_id = Some(chunk_id.clone());
128            sourcemap_chunk
129        };
130
131        self.chunk_id = Some(chunk_id.clone());
132        self.source.content = new_source_content;
133        self.sourcemap.content = serde_json::to_string(&new_sourcemap)?;
134        Ok(())
135    }
136
137    pub fn save(&self) -> Result<()> {
138        self.source.save(None)?;
139        self.sourcemap.save(None)?;
140        Ok(())
141    }
142
143    pub fn into_chunk_upload(self) -> Result<ChunkUpload> {
144        let chunk_id = self.chunk_id.ok_or_else(|| anyhow!("Chunk ID not found"))?;
145        let source_content = self.source.content;
146        let sourcemap_content = self.sourcemap.content;
147        let data = SourceAndMap {
148            minified_source: source_content,
149            sourcemap: sourcemap_content,
150        };
151        let data = write_symbol_data(data)?;
152        Ok(ChunkUpload { chunk_id, data })
153    }
154}
155
156pub fn read_pairs(directory: &PathBuf, ignore_globs: &[String]) -> Result<Vec<SourcePair>> {
157    // Make sure the directory exists
158    if !directory.exists() {
159        bail!("Directory does not exist");
160    }
161
162    let mut builder = GlobSetBuilder::new();
163    for glob in ignore_globs {
164        builder.add(Glob::new(glob)?);
165    }
166    let set: globset::GlobSet = builder.build()?;
167
168    let mut pairs = Vec::new();
169    for entry in WalkDir::new(directory).into_iter().filter_map(|e| e.ok()) {
170        let entry_path = entry.path().canonicalize()?;
171
172        if set.is_match(&entry_path) {
173            info!(
174                "Skipping because it matches an ignored glob: {}",
175                entry_path.display()
176            );
177        } else if is_javascript_file(&entry_path) {
178            info!("Processing file: {}", entry_path.display());
179            let source = SourceFile::load(&entry_path)?;
180            let sourcemap_path = get_sourcemap_path(&source)?;
181            if let Some(path) = sourcemap_path {
182                let sourcemap = SourceFile::load(&path).context(format!("reading {path:?}"))?;
183                let chunk_id = get_chunk_id(&sourcemap);
184                pairs.push(SourcePair {
185                    chunk_id,
186                    source,
187                    sourcemap,
188                });
189            } else {
190                warn!("No sourcemap file found for file {}", entry_path.display());
191            }
192        }
193    }
194    Ok(pairs)
195}
196
197pub fn get_chunk_id(sourcemap: &SourceFile) -> Option<String> {
198    #[derive(Deserialize)]
199    struct SourceChunkId {
200        chunk_id: String,
201    }
202    serde_json::from_str(&sourcemap.content)
203        .map(|chunk_id: SourceChunkId| chunk_id.chunk_id)
204        .ok()
205}
206
207pub fn get_sourcemap_reference(lines: Lines) -> Result<Option<String>> {
208    for line in lines.rev() {
209        if line.starts_with("//# sourceMappingURL=") || line.starts_with("//@ sourceMappingURL=") {
210            let url = str::from_utf8(&line.as_bytes()[21..])?.trim().to_owned();
211            let decoded_url = urlencoding::decode(&url)?;
212            return Ok(Some(decoded_url.into_owned()));
213        }
214    }
215    Ok(None)
216}
217
218pub fn get_sourcemap_path(source: &SourceFile) -> Result<Option<PathBuf>> {
219    match get_sourcemap_reference(source.content.lines())? {
220        Some(url) => {
221            let sourcemap_path = source
222                .path
223                .parent()
224                .map(|p| p.join(&url))
225                .unwrap_or_else(|| PathBuf::from(&url));
226            debug!("Found sourcemap path: {}", sourcemap_path.display());
227            Ok(Some(sourcemap_path))
228        }
229        None => {
230            let sourcemap_path = guess_sourcemap_path(&source.path);
231            debug!("Guessed sourcemap path: {}", sourcemap_path.display());
232            if sourcemap_path.exists() {
233                Ok(Some(sourcemap_path))
234            } else {
235                Ok(None)
236            }
237        }
238    }
239}
240
241pub fn guess_sourcemap_path(path: &Path) -> PathBuf {
242    // Try to resolve the sourcemap by adding .map to the path
243    let mut sourcemap_path = path.to_path_buf();
244    match path.extension() {
245        Some(ext) => sourcemap_path.set_extension(format!("{}.map", ext.to_string_lossy())),
246        None => sourcemap_path.set_extension("map"),
247    };
248    sourcemap_path
249}
250
251fn is_javascript_file(path: &Path) -> bool {
252    path.extension()
253        .is_some_and(|ext| ext == "js" || ext == "mjs" || ext == "cjs")
254}