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