posthog_cli/sourcemaps/
content.rs1use 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 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 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 .iter()
220 .filter(|p| line.starts_with(*p))
222 .filter_map(|_| line.split_once('=').map(|s| s.1.to_string())) .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 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}