posthog_cli/sourcemaps/
source_pair.rs1use 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
34pub 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 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 anyhow::bail!("Hermes source maps are not supported")
186 }
187 };
188
189 original_sourcemap.adjust_mappings(&adjustment);
190
191 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 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 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 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 .iter()
309 .filter(|p| line.starts_with(*p))
311 .filter_map(|_| line.split_once('=').map(|s| s.1.to_string())) .next()
315 {
316 return Some(val);
317 }
318 }
319 None
320 }
321}