posthog_cli/utils/
sourcemaps.rs1use anyhow::{anyhow, bail, Context, Ok, Result};
2use core::str;
3use globset::{Glob, GlobSetBuilder};
4use magic_string::{GenerateDecodedMapOptions, MagicString};
5use posthog_symbol_data::{write_symbol_data, SourceAndMap};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use sourcemap::SourceMap;
9use std::collections::BTreeMap;
10use std::str::Lines;
11use std::{
12 collections::HashMap,
13 path::{Path, PathBuf},
14};
15use tracing::{debug, info, warn};
16use walkdir::WalkDir;
17
18use super::constant::{CHUNKID_COMMENT_PREFIX, CHUNKID_PLACEHOLDER, CODE_SNIPPET_TEMPLATE};
19
20pub struct SourceFile {
21 pub path: PathBuf,
22 pub content: String,
23}
24
25#[derive(Debug, Serialize, Deserialize)]
26pub struct SourceMapChunkId {
27 chunk_id: Option<String>,
28 #[serde(flatten)]
29 fields: BTreeMap<String, Value>,
30}
31
32impl SourceFile {
33 pub fn new(path: PathBuf, content: String) -> Self {
34 SourceFile { path, content }
35 }
36
37 pub fn load(path: &PathBuf) -> Result<Self> {
38 let content = std::fs::read_to_string(path)?;
39 Ok(SourceFile::new(path.clone(), content))
40 }
41
42 pub fn save(&self, dest: Option<PathBuf>) -> Result<()> {
43 let final_path = dest.unwrap_or(self.path.clone());
44 std::fs::write(&final_path, &self.content)?;
45 Ok(())
46 }
47}
48
49#[derive(Debug, Serialize, Deserialize)]
50pub struct SourceMapContent {
51 chunk_id: Option<String>,
52 #[serde(flatten)]
53 fields: HashMap<String, Value>,
54}
55
56pub struct SourcePair {
57 pub chunk_id: Option<String>,
58
59 pub source: SourceFile,
60 pub sourcemap: SourceFile,
61}
62
63pub struct ChunkUpload {
64 pub chunk_id: String,
65 pub data: Vec<u8>,
66}
67
68impl SourcePair {
69 pub fn has_chunk_id(&self) -> bool {
70 self.chunk_id.is_some()
71 }
72
73 pub fn set_chunk_id(&mut self, chunk_id: String) -> Result<()> {
74 if self.has_chunk_id() {
75 return Err(anyhow!("Chunk ID already set"));
76 }
77 let (new_source_content, source_adjustment) = {
78 let source_content = &self.source.content;
80 let mut magic_source = MagicString::new(source_content);
81 let code_snippet = CODE_SNIPPET_TEMPLATE.replace(CHUNKID_PLACEHOLDER, &chunk_id);
82 magic_source
83 .prepend(&code_snippet)
84 .map_err(|err| anyhow!("Failed to prepend code snippet: {}", err))?;
85 let chunk_comment = CHUNKID_COMMENT_PREFIX.replace(CHUNKID_PLACEHOLDER, &chunk_id);
86 magic_source
87 .append(&chunk_comment)
88 .map_err(|err| anyhow!("Failed to append chunk comment: {}", err))?;
89 let adjustment = magic_source
90 .generate_map(GenerateDecodedMapOptions {
91 include_content: true,
92 ..Default::default()
93 })
94 .map_err(|err| anyhow!("Failed to generate source map: {}", err))?;
95 let adjustment_sourcemap = SourceMap::from_slice(
96 adjustment
97 .to_string()
98 .map_err(|err| anyhow!("Failed to serialize source map: {}", err))?
99 .as_bytes(),
100 )
101 .map_err(|err| anyhow!("Failed to parse adjustment sourcemap: {}", err))?;
102 (magic_source.to_string(), adjustment_sourcemap)
103 };
104
105 let new_sourcemap = {
106 let mut original_sourcemap =
108 match sourcemap::decode_slice(self.sourcemap.content.as_bytes())
109 .map_err(|err| anyhow!("Failed to parse sourcemap: {}", err))?
110 {
111 sourcemap::DecodedMap::Regular(map) => map,
112 sourcemap::DecodedMap::Index(index_map) => index_map
113 .flatten()
114 .map_err(|err| anyhow!("Failed to parse sourcemap: {}", err))?,
115 sourcemap::DecodedMap::Hermes(_) => {
116 anyhow::bail!("Hermes source maps are not supported")
117 }
118 };
119
120 original_sourcemap.adjust_mappings(&source_adjustment);
121
122 let mut new_sourcemap_bytes = Vec::new();
123 original_sourcemap.to_writer(&mut new_sourcemap_bytes)?;
124
125 let mut sourcemap_chunk: SourceMapChunkId =
126 serde_json::from_slice(&new_sourcemap_bytes)?;
127 sourcemap_chunk.chunk_id = Some(chunk_id.clone());
128 sourcemap_chunk
129 };
130
131 self.chunk_id = Some(chunk_id.clone());
132 self.source.content = new_source_content;
133 self.sourcemap.content = serde_json::to_string(&new_sourcemap)?;
134 Ok(())
135 }
136
137 pub fn save(&self) -> Result<()> {
138 self.source.save(None)?;
139 self.sourcemap.save(None)?;
140 Ok(())
141 }
142
143 pub fn into_chunk_upload(self) -> Result<ChunkUpload> {
144 let chunk_id = self.chunk_id.ok_or_else(|| anyhow!("Chunk ID not found"))?;
145 let source_content = self.source.content;
146 let sourcemap_content = self.sourcemap.content;
147 let data = SourceAndMap {
148 minified_source: source_content,
149 sourcemap: sourcemap_content,
150 };
151 let data = write_symbol_data(data)?;
152 Ok(ChunkUpload { chunk_id, data })
153 }
154}
155
156pub fn read_pairs(directory: &PathBuf, ignore_globs: &[String]) -> Result<Vec<SourcePair>> {
157 if !directory.exists() {
159 bail!("Directory does not exist");
160 }
161
162 let mut builder = GlobSetBuilder::new();
163 for glob in ignore_globs {
164 builder.add(Glob::new(glob)?);
165 }
166 let set: globset::GlobSet = builder.build()?;
167
168 let mut pairs = Vec::new();
169 for entry in WalkDir::new(directory).into_iter().filter_map(|e| e.ok()) {
170 let entry_path = entry.path().canonicalize()?;
171
172 if set.is_match(&entry_path) {
173 info!(
174 "Skipping because it matches an ignored glob: {}",
175 entry_path.display()
176 );
177 } else if is_javascript_file(&entry_path) {
178 info!("Processing file: {}", entry_path.display());
179 let source = SourceFile::load(&entry_path)?;
180 let sourcemap_path = get_sourcemap_path(&source)?;
181 if let Some(path) = sourcemap_path {
182 let sourcemap = SourceFile::load(&path).context(format!("reading {path:?}"))?;
183 let chunk_id = get_chunk_id(&sourcemap);
184 pairs.push(SourcePair {
185 chunk_id,
186 source,
187 sourcemap,
188 });
189 } else {
190 warn!("No sourcemap file found for file {}", entry_path.display());
191 }
192 }
193 }
194 Ok(pairs)
195}
196
197pub fn get_chunk_id(sourcemap: &SourceFile) -> Option<String> {
198 #[derive(Deserialize)]
199 struct SourceChunkId {
200 chunk_id: String,
201 }
202 serde_json::from_str(&sourcemap.content)
203 .map(|chunk_id: SourceChunkId| chunk_id.chunk_id)
204 .ok()
205}
206
207pub fn get_sourcemap_reference(lines: Lines) -> Result<Option<String>> {
208 for line in lines.rev() {
209 if line.starts_with("//# sourceMappingURL=") || line.starts_with("//@ sourceMappingURL=") {
210 let url = str::from_utf8(&line.as_bytes()[21..])?.trim().to_owned();
211 let decoded_url = urlencoding::decode(&url)?;
212 return Ok(Some(decoded_url.into_owned()));
213 }
214 }
215 Ok(None)
216}
217
218pub fn get_sourcemap_path(source: &SourceFile) -> Result<Option<PathBuf>> {
219 match get_sourcemap_reference(source.content.lines())? {
220 Some(url) => {
221 let sourcemap_path = source
222 .path
223 .parent()
224 .map(|p| p.join(&url))
225 .unwrap_or_else(|| PathBuf::from(&url));
226 debug!("Found sourcemap path: {}", sourcemap_path.display());
227 Ok(Some(sourcemap_path))
228 }
229 None => {
230 let sourcemap_path = guess_sourcemap_path(&source.path);
231 debug!("Guessed sourcemap path: {}", sourcemap_path.display());
232 if sourcemap_path.exists() {
233 Ok(Some(sourcemap_path))
234 } else {
235 Ok(None)
236 }
237 }
238 }
239}
240
241pub fn guess_sourcemap_path(path: &Path) -> PathBuf {
242 let mut sourcemap_path = path.to_path_buf();
244 match path.extension() {
245 Some(ext) => sourcemap_path.set_extension(format!("{}.map", ext.to_string_lossy())),
246 None => sourcemap_path.set_extension("map"),
247 };
248 sourcemap_path
249}
250
251fn is_javascript_file(path: &Path) -> bool {
252 path.extension()
253 .is_some_and(|ext| ext == "js" || ext == "mjs" || ext == "cjs")
254}