posthog_cli/sourcemaps/
source_pair.rs

1use std::{collections::BTreeMap, path::PathBuf};
2
3use crate::{
4    api::symbol_sets::SymbolSetUpload,
5    sourcemaps::constant::{CHUNKID_COMMENT_PREFIX, CHUNKID_PLACEHOLDER, CODE_SNIPPET_TEMPLATE},
6    utils::files::{is_javascript_file, SourceFile},
7};
8use anyhow::{anyhow, bail, Context, Result};
9use globset::{Glob, GlobSetBuilder};
10use magic_string::{GenerateDecodedMapOptions, MagicString};
11use posthog_symbol_data::{write_symbol_data, SourceAndMap};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use sourcemap::SourceMap;
15use tracing::{info, warn};
16use walkdir::WalkDir;
17
18#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
19pub struct SourceMapContent {
20    pub release_id: Option<String>,
21    pub chunk_id: Option<String>,
22    #[serde(flatten)]
23    pub fields: BTreeMap<String, Value>,
24}
25
26pub struct SourceMapFile {
27    pub inner: SourceFile<SourceMapContent>,
28}
29
30pub struct MinifiedSourceFile {
31    pub inner: SourceFile<String>,
32}
33
34// Source pairs are the fundamental unit of a frontend symbol set
35pub struct SourcePair {
36    pub source: MinifiedSourceFile,
37    pub sourcemap: SourceMapFile,
38}
39
40impl SourcePair {
41    pub fn new(source: MinifiedSourceFile, sourcemap: SourceMapFile) -> Result<Self> {
42        if sourcemap.get_chunk_id() != source.get_chunk_id() {
43            anyhow::bail!(
44                "Source chunk ID and sourcemap chunk ID disagree. Try re-running injection"
45            )
46        }
47
48        Ok(Self { source, sourcemap })
49    }
50
51    pub fn has_chunk_id(&self) -> bool {
52        self.sourcemap.get_chunk_id().is_some()
53    }
54
55    pub fn has_release_id(&self) -> bool {
56        self.sourcemap.get_release_id().is_some()
57    }
58
59    pub fn set_chunk_id(&mut self, chunk_id: String) -> Result<()> {
60        if self.has_chunk_id() {
61            return Err(anyhow!("Chunk ID already set"));
62        }
63
64        let adjustment = self.source.set_chunk_id(&chunk_id)?;
65        self.sourcemap.apply_adjustment(adjustment)?;
66        self.sourcemap.set_chunk_id(chunk_id);
67        Ok(())
68    }
69
70    pub fn set_release_id(&mut self, release_id: String) {
71        self.sourcemap.set_release_id(release_id);
72    }
73
74    pub fn save(&self) -> Result<()> {
75        self.source.save()?;
76        self.sourcemap.save()?;
77        Ok(())
78    }
79}
80
81pub fn read_pairs(directory: &PathBuf, ignore_globs: &[String]) -> Result<Vec<SourcePair>> {
82    // Make sure the directory exists
83    if !directory.exists() {
84        bail!("Directory does not exist");
85    }
86
87    let mut builder = GlobSetBuilder::new();
88    for glob in ignore_globs {
89        builder.add(Glob::new(glob)?);
90    }
91    let set: globset::GlobSet = builder.build()?;
92
93    let mut pairs = Vec::new();
94
95    for entry_path in WalkDir::new(directory)
96        .into_iter()
97        .filter_map(|e| e.ok())
98        .filter(is_javascript_file)
99        .map(|e| e.path().canonicalize())
100    {
101        let entry_path = entry_path?;
102
103        if set.is_match(&entry_path) {
104            info!(
105                "Skipping because it matches an ignored glob: {}",
106                entry_path.display()
107            );
108            continue;
109        }
110
111        info!("Processing file: {}", entry_path.display());
112        let source = MinifiedSourceFile::load(&entry_path)?;
113        let sourcemap_path = source.get_sourcemap_path()?;
114
115        let Some(path) = sourcemap_path else {
116            warn!(
117                "No sourcemap file found for file {}, skipping",
118                entry_path.display()
119            );
120            continue;
121        };
122
123        let sourcemap = SourceMapFile::load(&path).context(format!("reading {path:?}"))?;
124        pairs.push(SourcePair { source, sourcemap });
125    }
126    Ok(pairs)
127}
128
129impl TryInto<SymbolSetUpload> for SourcePair {
130    type Error = anyhow::Error;
131
132    fn try_into(self) -> Result<SymbolSetUpload> {
133        let chunk_id = self
134            .sourcemap
135            .get_chunk_id()
136            .ok_or_else(|| anyhow!("Chunk ID not found"))?;
137        let source_content = self.source.inner.content;
138        let sourcemap_content = serde_json::to_string(&self.sourcemap.inner.content)?;
139        let data = SourceAndMap {
140            minified_source: source_content,
141            sourcemap: sourcemap_content,
142        };
143
144        let data = write_symbol_data(data)?;
145
146        Ok(SymbolSetUpload {
147            chunk_id,
148            data,
149            release_id: self.sourcemap.get_release_id(),
150        })
151    }
152}
153
154impl SourceMapFile {
155    pub fn load(path: &PathBuf) -> Result<Self> {
156        let inner = SourceFile::load(path)?;
157
158        Ok(Self { inner })
159    }
160
161    pub fn save(&self) -> Result<()> {
162        self.inner.save(None)
163    }
164
165    pub fn get_chunk_id(&self) -> Option<String> {
166        self.inner.content.chunk_id.clone()
167    }
168
169    pub fn get_release_id(&self) -> Option<String> {
170        self.inner.content.release_id.clone()
171    }
172
173    pub fn apply_adjustment(&mut self, adjustment: SourceMap) -> Result<()> {
174        let new_content = {
175            let content = serde_json::to_string(&self.inner.content)?.into_bytes();
176            let mut original_sourcemap = match sourcemap::decode_slice(content.as_slice())
177                .map_err(|err| anyhow!("Failed to parse sourcemap: {}", err))?
178            {
179                sourcemap::DecodedMap::Regular(map) => map,
180                sourcemap::DecodedMap::Index(index_map) => index_map
181                    .flatten()
182                    .map_err(|err| anyhow!("Failed to parse sourcemap: {}", err))?,
183                sourcemap::DecodedMap::Hermes(_) => {
184                    // TODO(olly) - YES THEY ARE!!!!! WOOOOOOO!!!!! YIPEEEEEEEE!!!
185                    anyhow::bail!("Hermes source maps are not supported")
186                }
187            };
188
189            original_sourcemap.adjust_mappings(&adjustment);
190
191            // I mean if we've got the bytes allocated already, why not use 'em
192            let mut content = content;
193            content.clear();
194            original_sourcemap.to_writer(&mut content)?;
195
196            serde_json::from_slice(&content)?
197        };
198
199        let mut old_content = std::mem::replace(&mut self.inner.content, new_content);
200        self.inner.content.chunk_id = old_content.chunk_id.take();
201        self.inner.content.release_id = old_content.release_id.take();
202
203        Ok(())
204    }
205
206    pub fn set_chunk_id(&mut self, chunk_id: String) {
207        self.inner.content.chunk_id = Some(chunk_id);
208    }
209
210    pub fn set_release_id(&mut self, release_id: String) {
211        self.inner.content.release_id = Some(release_id);
212    }
213}
214
215impl MinifiedSourceFile {
216    pub fn load(path: &PathBuf) -> Result<Self> {
217        let inner = SourceFile::load(path)?;
218
219        Ok(Self { inner })
220    }
221
222    pub fn save(&self) -> Result<()> {
223        self.inner.save(None)
224    }
225
226    pub fn get_chunk_id(&self) -> Option<String> {
227        let patterns = ["//#chunk_id"];
228        self.get_comment_value(&patterns)
229    }
230
231    pub fn set_chunk_id(&mut self, chunk_id: &str) -> Result<SourceMap> {
232        let (new_source_content, source_adjustment) = {
233            // Update source content with chunk ID
234            let source_content = &self.inner.content;
235            let mut magic_source = MagicString::new(source_content);
236            let code_snippet = CODE_SNIPPET_TEMPLATE.replace(CHUNKID_PLACEHOLDER, &chunk_id);
237            magic_source
238                .prepend(&code_snippet)
239                .map_err(|err| anyhow!("Failed to prepend code snippet: {}", err))?;
240            let chunk_comment = CHUNKID_COMMENT_PREFIX.replace(CHUNKID_PLACEHOLDER, &chunk_id);
241            magic_source
242                .append(&chunk_comment)
243                .map_err(|err| anyhow!("Failed to append chunk comment: {}", err))?;
244            let adjustment = magic_source
245                .generate_map(GenerateDecodedMapOptions {
246                    include_content: true,
247                    ..Default::default()
248                })
249                .map_err(|err| anyhow!("Failed to generate source map: {}", err))?;
250            let adjustment_sourcemap = SourceMap::from_slice(
251                adjustment
252                    .to_string()
253                    .map_err(|err| anyhow!("Failed to serialize source map: {}", err))?
254                    .as_bytes(),
255            )
256            .map_err(|err| anyhow!("Failed to parse adjustment sourcemap: {}", err))?;
257            (magic_source.to_string(), adjustment_sourcemap)
258        };
259
260        self.inner.content = new_source_content;
261        Ok(source_adjustment)
262    }
263
264    pub fn get_sourcemap_path(&self) -> Result<Option<PathBuf>> {
265        match self.get_sourcemap_reference()? {
266            // If we've got a reference, use it
267            Some(filename) => {
268                let sourcemap_path = self
269                    .inner
270                    .path
271                    .parent()
272                    .map(|p| p.join(&filename))
273                    .unwrap_or_else(|| PathBuf::from(&filename));
274                Ok(Some(sourcemap_path))
275            }
276            // If we don't, try guessing
277            None => {
278                let mut sourcemap_path = self.inner.path.to_path_buf();
279                match sourcemap_path.extension() {
280                    Some(ext) => {
281                        sourcemap_path.set_extension(format!("{}.map", ext.to_string_lossy()))
282                    }
283                    None => sourcemap_path.set_extension("map"),
284                };
285                if sourcemap_path.exists() {
286                    info!("Guessed sourcemap path: {}", sourcemap_path.display());
287                    Ok(Some(sourcemap_path))
288                } else {
289                    warn!("Could not find sourcemap for {}", self.inner.path.display());
290                    Ok(None)
291                }
292            }
293        }
294    }
295
296    pub fn get_sourcemap_reference(&self) -> Result<Option<String>> {
297        let patterns = ["//# sourceMappingURL=", "//@ sourceMappingURL="];
298        let Some(found) = self.get_comment_value(&patterns) else {
299            return Ok(None);
300        };
301        Ok(Some(urlencoding::decode(&found)?.into_owned()))
302    }
303
304    fn get_comment_value(&self, patterns: &[&str]) -> Option<String> {
305        for line in self.inner.content.lines().rev() {
306            if let Some(val) = patterns
307                // For each pattern passed
308                .iter()
309                // If the pattern matches
310                .filter(|p| line.starts_with(*p))
311                // And the line actually contains a key:value pair split by an equals
312                .filter_map(|_| line.split_once('=').map(|s| s.1.to_string())) // And the split_once returns a Some
313                // Return this value
314                .next()
315            {
316                return Some(val);
317            }
318        }
319        None
320    }
321}