posthog_cli/utils/
sourcemaps.rs1use anyhow::{anyhow, bail, Ok, Result};
2use core::str;
3use magic_string::{GenerateDecodedMapOptions, MagicString};
4use posthog_symbol_data::{write_symbol_data, SourceAndMap};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use sourcemap::SourceMap;
8use std::collections::BTreeMap;
9use std::{
10 collections::HashMap,
11 path::{Path, PathBuf},
12};
13use tracing::{info, warn};
14use walkdir::WalkDir;
15
16use super::constant::{CHUNKID_COMMENT_PREFIX, CHUNKID_PLACEHOLDER, CODE_SNIPPET_TEMPLATE};
17
18pub struct SourceFile {
19 pub path: PathBuf,
20 pub content: String,
21}
22
23#[derive(Debug, Serialize, Deserialize)]
24pub struct SourceMapChunkId {
25 chunk_id: Option<String>,
26 #[serde(flatten)]
27 fields: BTreeMap<String, Value>,
28}
29
30impl SourceFile {
31 pub fn new(path: PathBuf, content: String) -> Self {
32 SourceFile { path, content }
33 }
34
35 pub fn load(path: &PathBuf) -> Result<Self> {
36 let content = std::fs::read_to_string(path)?;
37 Ok(SourceFile::new(path.clone(), content))
38 }
39
40 pub fn save(&self, dest: Option<PathBuf>) -> Result<()> {
41 let final_path = dest.unwrap_or(self.path.clone());
42 std::fs::write(&final_path, &self.content)?;
43 Ok(())
44 }
45}
46
47#[derive(Debug, Serialize, Deserialize)]
48pub struct SourceMapContent {
49 chunk_id: Option<String>,
50 #[serde(flatten)]
51 fields: HashMap<String, Value>,
52}
53
54pub struct SourcePair {
55 pub chunk_id: Option<String>,
56
57 pub source: SourceFile,
58 pub sourcemap: SourceFile,
59}
60
61pub struct ChunkUpload {
62 pub chunk_id: String,
63 pub data: Vec<u8>,
64}
65
66impl SourcePair {
67 pub fn has_chunk_id(&self) -> bool {
68 self.chunk_id.is_some()
69 }
70
71 pub fn set_chunk_id(&mut self, chunk_id: String) -> Result<()> {
72 if self.has_chunk_id() {
73 return Err(anyhow!("Chunk ID already set"));
74 }
75 let (new_source_content, source_adjustment) = {
76 let source_content = &self.source.content;
78 let mut magic_source = MagicString::new(source_content);
79 let code_snippet = CODE_SNIPPET_TEMPLATE.replace(CHUNKID_PLACEHOLDER, &chunk_id);
80 magic_source
81 .prepend(&code_snippet)
82 .map_err(|err| anyhow!("Failed to prepend code snippet: {}", err))?;
83 let chunk_comment = CHUNKID_COMMENT_PREFIX.replace(CHUNKID_PLACEHOLDER, &chunk_id);
84 magic_source
85 .append(&chunk_comment)
86 .map_err(|err| anyhow!("Failed to append chunk comment: {}", err))?;
87 let adjustment = magic_source
88 .generate_map(GenerateDecodedMapOptions {
89 include_content: true,
90 ..Default::default()
91 })
92 .map_err(|err| anyhow!("Failed to generate source map: {}", err))?;
93 let adjustment_sourcemap = SourceMap::from_slice(
94 adjustment
95 .to_string()
96 .map_err(|err| anyhow!("Failed to serialize source map: {}", err))?
97 .as_bytes(),
98 )
99 .map_err(|err| anyhow!("Failed to parse adjustment sourcemap: {}", err))?;
100 (magic_source.to_string(), adjustment_sourcemap)
101 };
102
103 let new_sourcemap = {
104 let mut original_sourcemap =
106 match sourcemap::decode_slice(self.sourcemap.content.as_bytes())
107 .map_err(|err| anyhow!("Failed to parse sourcemap: {}", err))?
108 {
109 sourcemap::DecodedMap::Regular(map) => map,
110 sourcemap::DecodedMap::Index(index_map) => index_map
111 .flatten()
112 .map_err(|err| anyhow!("Failed to parse sourcemap: {}", err))?,
113 sourcemap::DecodedMap::Hermes(_) => {
114 anyhow::bail!("Hermes source maps are not supported")
115 }
116 };
117
118 original_sourcemap.adjust_mappings(&source_adjustment);
119
120 let mut new_sourcemap_bytes = Vec::new();
121 original_sourcemap.to_writer(&mut new_sourcemap_bytes)?;
122
123 let mut sourcemap_chunk: SourceMapChunkId =
124 serde_json::from_slice(&new_sourcemap_bytes)?;
125 sourcemap_chunk.chunk_id = Some(chunk_id.clone());
126 sourcemap_chunk
127 };
128
129 self.chunk_id = Some(chunk_id.clone());
130 self.source.content = new_source_content;
131 self.sourcemap.content = serde_json::to_string(&new_sourcemap)?;
132 Ok(())
133 }
134
135 pub fn save(&self) -> Result<()> {
136 self.source.save(None)?;
137 self.sourcemap.save(None)?;
138 Ok(())
139 }
140
141 pub fn into_chunk_upload(self) -> Result<ChunkUpload> {
142 let chunk_id = self.chunk_id.ok_or_else(|| anyhow!("Chunk ID not found"))?;
143 let source_content = self.source.content;
144 let sourcemap_content = self.sourcemap.content;
145 let data = SourceAndMap {
146 minified_source: source_content,
147 sourcemap: sourcemap_content,
148 };
149 let data = write_symbol_data(data)?;
150 Ok(ChunkUpload { chunk_id, data })
151 }
152}
153
154pub fn read_pairs(directory: &PathBuf) -> Result<Vec<SourcePair>> {
155 if !directory.exists() {
157 bail!("Directory does not exist");
158 }
159
160 let mut pairs = Vec::new();
161 for entry in WalkDir::new(directory).into_iter().filter_map(|e| e.ok()) {
162 let entry_path = entry.path().canonicalize()?;
163 if is_javascript_file(&entry_path) {
164 info!("Processing file: {}", entry_path.display());
165 let source = SourceFile::load(&entry_path)?;
166 let sourcemap_path = guess_sourcemap_path(&source.path);
167 if sourcemap_path.exists() {
168 let sourcemap = SourceFile::load(&sourcemap_path)?;
169 let chunk_id = get_chunk_id(&sourcemap);
170 pairs.push(SourcePair {
171 chunk_id,
172 source,
173 sourcemap,
174 });
175 } else {
176 warn!("No sourcemap file found for file {}", entry_path.display());
177 }
178 }
179 }
180 Ok(pairs)
181}
182
183pub fn get_chunk_id(sourcemap: &SourceFile) -> Option<String> {
184 #[derive(Deserialize)]
185 struct SourceChunkId {
186 chunk_id: String,
187 }
188 serde_json::from_str(&sourcemap.content)
189 .map(|chunk_id: SourceChunkId| chunk_id.chunk_id)
190 .ok()
191}
192
193pub fn guess_sourcemap_path(path: &Path) -> PathBuf {
194 let mut sourcemap_path = path.to_path_buf();
196 match path.extension() {
197 Some(ext) => sourcemap_path.set_extension(format!("{}.map", ext.to_string_lossy())),
198 None => sourcemap_path.set_extension("map"),
199 };
200 sourcemap_path
201}
202
203fn is_javascript_file(path: &Path) -> bool {
204 path.extension()
205 .map_or(false, |ext| ext == "js" || ext == "mjs" || ext == "cjs")
206}