posthog_cli/sourcemaps/
content.rs

1use anyhow::{anyhow, bail, Result};
2use magic_string::{GenerateDecodedMapOptions, MagicString};
3use posthog_symbol_data::{write_symbol_data, HermesMap};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use sourcemap::SourceMap;
7use std::{collections::BTreeMap, path::PathBuf};
8use tracing::info;
9
10use crate::{
11    api::symbol_sets::SymbolSetUpload,
12    sourcemaps::constant::{CHUNKID_COMMENT_PREFIX, CHUNKID_PLACEHOLDER, CODE_SNIPPET_TEMPLATE},
13    utils::files::SourceFile,
14};
15
16#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
17pub struct SourceMapContent {
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub release_id: Option<String>,
20    #[serde(skip_serializing_if = "Option::is_none")]
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
34impl SourceMapFile {
35    pub fn load(path: &PathBuf) -> Result<Self> {
36        let inner = SourceFile::load(path)?;
37
38        Ok(Self { inner })
39    }
40
41    pub fn save(&self) -> Result<()> {
42        self.inner.save(None)
43    }
44
45    pub fn get_chunk_id(&self) -> Option<String> {
46        self.inner.content.chunk_id.clone()
47    }
48
49    pub fn get_release_id(&self) -> Option<String> {
50        self.inner.content.release_id.clone()
51    }
52
53    pub fn apply_adjustment(&mut self, adjustment: SourceMap) -> Result<()> {
54        let new_content = {
55            let content = serde_json::to_string(&self.inner.content)?.into_bytes();
56            let mut map = sourcemap::decode_slice(content.as_slice())
57                .map_err(|err| anyhow!("Failed to parse sourcemap: {err}"))?;
58
59            // This looks weird. The reason we do it, is that we want `original` below
60            // to be a &mut SourceMap. This is easy to do if it's a Regular, or Hermes
61            // map, but if it's an Index map (Regular is already a SourceMap, so just
62            // taking the &mut works, and Hermes maps impl DerefMut<Target = SourceMap>),
63            // but for index maps, we have to flatten first, and that necessitates a Clone.
64            // Doing that Clone in the match below and then trying to borrow a &mut to the
65            // result of the Clone causes us to try and borrow something we immediately drop,
66            // (the clone is done in the match arm scope, and then a ref to a local in that
67            // scope is returned to the outer scope), so instead, we do the clone here if
68            // we need to, and declare the index branch unreachable below.
69            if let sourcemap::DecodedMap::Index(indexed) = &mut map {
70                let replacement = indexed
71                    .flatten()
72                    .map_err(|err| anyhow!("Failed to flatten sourcemap: {err}"))?;
73
74                map = sourcemap::DecodedMap::Regular(replacement);
75            };
76
77            let original = match &mut map {
78                sourcemap::DecodedMap::Regular(m) => m,
79                sourcemap::DecodedMap::Hermes(m) => m,
80                sourcemap::DecodedMap::Index(_) => unreachable!(),
81            };
82
83            original.adjust_mappings(&adjustment);
84
85            let mut content = content;
86            content.clear();
87            original.to_writer(&mut content)?;
88            serde_json::from_slice(&content)?
89        };
90
91        let mut old_content = std::mem::replace(&mut self.inner.content, new_content);
92        self.inner.content.chunk_id = old_content.chunk_id.take();
93        self.inner.content.release_id = old_content.release_id.take();
94
95        Ok(())
96    }
97
98    pub fn set_chunk_id(&mut self, chunk_id: Option<String>) {
99        self.inner.content.chunk_id = chunk_id;
100    }
101
102    pub fn set_release_id(&mut self, release_id: Option<String>) {
103        self.inner.content.release_id = release_id;
104    }
105}
106
107impl MinifiedSourceFile {
108    pub fn load(path: &PathBuf) -> Result<Self> {
109        let inner = SourceFile::load(path)?;
110
111        Ok(Self { inner })
112    }
113
114    pub fn save(&self) -> Result<()> {
115        self.inner.save(None)
116    }
117
118    pub fn get_chunk_id(&self) -> Option<String> {
119        let patterns = ["//# chunkId="];
120        self.get_comment_value(&patterns)
121    }
122
123    pub fn set_chunk_id(&mut self, chunk_id: &str) -> Result<SourceMap> {
124        let (new_source_content, source_adjustment) = {
125            // Update source content with chunk ID
126            let source_content = &self.inner.content;
127            let mut magic_source = MagicString::new(source_content);
128            let code_snippet = CODE_SNIPPET_TEMPLATE.replace(CHUNKID_PLACEHOLDER, chunk_id);
129            magic_source
130                .prepend(&code_snippet)
131                .map_err(|err| anyhow!("Failed to prepend code snippet: {err}"))?;
132            let chunk_comment = CHUNKID_COMMENT_PREFIX.replace(CHUNKID_PLACEHOLDER, chunk_id);
133            magic_source
134                .append(&chunk_comment)
135                .map_err(|err| anyhow!("Failed to append chunk comment: {err}"))?;
136            let adjustment = magic_source
137                .generate_map(GenerateDecodedMapOptions {
138                    include_content: true,
139                    ..Default::default()
140                })
141                .map_err(|err| anyhow!("Failed to generate source map: {err}"))?;
142            let adjustment_sourcemap = SourceMap::from_slice(
143                adjustment
144                    .to_string()
145                    .map_err(|err| anyhow!("Failed to serialize source map: {err}"))?
146                    .as_bytes(),
147            )
148            .map_err(|err| anyhow!("Failed to parse adjustment sourcemap: {err}"))?;
149            (magic_source.to_string(), adjustment_sourcemap)
150        };
151
152        self.inner.content = new_source_content;
153        Ok(source_adjustment)
154    }
155
156    pub fn get_sourcemap_path(&self, prefix: &Option<String>) -> Result<Option<PathBuf>> {
157        let mut possible_paths = Vec::new();
158        if let Some(filename) = self.get_sourcemap_reference()? {
159            possible_paths.push(
160                self.inner
161                    .path
162                    .parent()
163                    .map(|p| p.join(&filename))
164                    .unwrap_or_else(|| PathBuf::from(&filename)),
165            );
166
167            if let Some(prefix) = prefix {
168                if let Some(filename) = filename.strip_prefix(prefix) {
169                    possible_paths.push(
170                        self.inner
171                            .path
172                            .parent()
173                            .map(|p| p.join(filename))
174                            .unwrap_or_else(|| PathBuf::from(&filename)),
175                    );
176                }
177
178                if let Some(filename) = filename.strip_prefix(&format!("{prefix}/")) {
179                    possible_paths.push(
180                        self.inner
181                            .path
182                            .parent()
183                            .map(|p| p.join(filename))
184                            .unwrap_or_else(|| PathBuf::from(&filename)),
185                    );
186                }
187            }
188        };
189
190        let mut guessed_path = self.inner.path.to_path_buf();
191        match guessed_path.extension() {
192            Some(ext) => guessed_path.set_extension(format!("{}.map", ext.to_string_lossy())),
193            None => guessed_path.set_extension("map"),
194        };
195        possible_paths.push(guessed_path);
196
197        for path in possible_paths.into_iter() {
198            if path.exists() {
199                info!("Found sourcemap at path: {}", path.display());
200                return Ok(Some(path));
201            }
202        }
203
204        Ok(None)
205    }
206
207    pub fn get_sourcemap_reference(&self) -> Result<Option<String>> {
208        let patterns = ["//# sourceMappingURL=", "//@ sourceMappingURL="];
209        let Some(found) = self.get_comment_value(&patterns) else {
210            return Ok(None);
211        };
212        Ok(Some(urlencoding::decode(&found)?.into_owned()))
213    }
214
215    fn get_comment_value(&self, patterns: &[&str]) -> Option<String> {
216        for line in self.inner.content.lines().rev() {
217            if let Some(val) = patterns
218                // For each pattern passed
219                .iter()
220                // If the pattern matches
221                .filter(|p| line.starts_with(*p))
222                // And the line actually contains a key:value pair split by an equals
223                .filter_map(|_| line.split_once('=').map(|s| s.1.to_string())) // And the split_once returns a Some
224                // Return this value
225                .next()
226            {
227                return Some(val);
228            }
229        }
230        None
231    }
232
233    pub fn remove_chunk_id(&mut self, chunk_id: String) -> 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
239            let chunk_comment = CHUNKID_COMMENT_PREFIX.replace(CHUNKID_PLACEHOLDER, &chunk_id);
240            if let Some(chunk_comment_start) = source_content.find(&chunk_comment) {
241                let chunk_comment_end = chunk_comment_start as i64 + chunk_comment.len() as i64;
242                magic_source
243                    .remove(chunk_comment_start as i64, chunk_comment_end)
244                    .map_err(|err| anyhow!("Failed to remove chunk comment: {err}"))?;
245            }
246
247            let code_snippet = CODE_SNIPPET_TEMPLATE.replace(CHUNKID_PLACEHOLDER, &chunk_id);
248            if let Some(code_snippet_start) = source_content.find(&code_snippet) {
249                let code_snippet_end = code_snippet_start as i64 + code_snippet.len() as i64;
250                magic_source
251                    .remove(code_snippet_start as i64, code_snippet_end)
252                    .map_err(|err| anyhow!("Failed to remove code snippet {err}"))?;
253            }
254
255            let adjustment = magic_source
256                .generate_map(GenerateDecodedMapOptions {
257                    include_content: true,
258                    ..Default::default()
259                })
260                .map_err(|err| anyhow!("Failed to generate source map: {err}"))?;
261
262            let adjustment_sourcemap = SourceMap::from_slice(
263                adjustment
264                    .to_string()
265                    .map_err(|err| anyhow!("Failed to serialize source map: {err}"))?
266                    .as_bytes(),
267            )
268            .map_err(|err| anyhow!("Failed to parse adjustment sourcemap: {err}"))?;
269
270            (magic_source.to_string(), adjustment_sourcemap)
271        };
272
273        self.inner.content = new_source_content;
274        Ok(source_adjustment)
275    }
276}
277
278impl TryInto<SymbolSetUpload> for SourceMapFile {
279    type Error = anyhow::Error;
280
281    fn try_into(self) -> Result<SymbolSetUpload> {
282        let chunk_id = self
283            .get_chunk_id()
284            .ok_or_else(|| anyhow!("Chunk ID not found"))?;
285
286        let release_id = self.get_release_id();
287        let sourcemap = self.inner.content;
288        let content = serde_json::to_string(&sourcemap)?;
289        if !sourcemap.fields.contains_key("x_hermes_function_offsets") {
290            bail!("Map is not a hermes sourcemap - missing key x_hermes_function_offsets");
291        }
292
293        let data = HermesMap { sourcemap: content };
294
295        let data = write_symbol_data(data)?;
296
297        Ok(SymbolSetUpload {
298            chunk_id,
299            release_id,
300            data,
301        })
302    }
303}