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 #[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
36pub struct SourcePair {
38 pub source: MinifiedSourceFile,
39 pub sourcemap: SourceMapFile,
40}
41
42impl SourcePair {
43 pub fn has_chunk_id(&self) -> bool {
44 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 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 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 anyhow::bail!("Hermes source maps are not supported")
188 }
189 };
190
191 original_sourcemap.adjust_mappings(&adjustment);
192
193 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 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 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 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 .iter()
311 .filter(|p| line.starts_with(*p))
313 .filter_map(|_| line.split_once('=').map(|s| s.1.to_string())) .next()
317 {
318 return Some(val);
319 }
320 }
321 None
322 }
323}