posthog_cli/utils/
sourcemaps.rs

1use 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            // Update source content with chunk ID
77            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            // Update the sourcemap with the new mappings
105            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    // Make sure the directory exists
156    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    // Try to resolve the sourcemap by adding .map to the path
195    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}