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 set_chunk_id(&mut self, chunk_id: String) -> Result<()> {
68        if self.chunk_id.is_some() {
69            return Err(anyhow!("Chunk ID already set"));
70        }
71        let (new_source_content, source_adjustment) = {
72            // Update source content with chunk ID
73            let source_content = &self.source.content;
74            let mut magic_source = MagicString::new(source_content);
75            let code_snippet = CODE_SNIPPET_TEMPLATE.replace(CHUNKID_PLACEHOLDER, &chunk_id);
76            magic_source
77                .prepend(&code_snippet)
78                .map_err(|err| anyhow!("Failed to prepend code snippet: {}", err))?;
79            let chunk_comment = CHUNKID_COMMENT_PREFIX.replace(CHUNKID_PLACEHOLDER, &chunk_id);
80            magic_source
81                .append(&chunk_comment)
82                .map_err(|err| anyhow!("Failed to append chunk comment: {}", err))?;
83            let adjustment = magic_source
84                .generate_map(GenerateDecodedMapOptions {
85                    include_content: true,
86                    ..Default::default()
87                })
88                .map_err(|err| anyhow!("Failed to generate source map: {}", err))?;
89            let adjustment_sourcemap = SourceMap::from_slice(
90                adjustment
91                    .to_string()
92                    .map_err(|err| anyhow!("Failed to serialize source map: {}", err))?
93                    .as_bytes(),
94            )
95            .map_err(|err| anyhow!("Failed to parse adjustment sourcemap: {}", err))?;
96            (magic_source.to_string(), adjustment_sourcemap)
97        };
98
99        let new_sourcemap = {
100            // Update the sourcemap with the new mappings
101            let mut original_sourcemap =
102                SourceMap::from_slice(self.sourcemap.content.as_bytes())
103                    .map_err(|err| anyhow!("Failed to parse sourcemap: {}", err))?;
104            original_sourcemap.adjust_mappings(&source_adjustment);
105
106            let mut new_sourcemap_bytes = Vec::new();
107            original_sourcemap.to_writer(&mut new_sourcemap_bytes)?;
108
109            let mut sourcemap_chunk: SourceMapChunkId =
110                serde_json::from_slice(&new_sourcemap_bytes)?;
111            sourcemap_chunk.chunk_id = Some(chunk_id.clone());
112            sourcemap_chunk
113        };
114
115        self.chunk_id = Some(chunk_id.clone());
116        self.source.content = new_source_content;
117        self.sourcemap.content = serde_json::to_string(&new_sourcemap)?;
118        Ok(())
119    }
120
121    pub fn save(&self) -> Result<()> {
122        self.source.save(None)?;
123        self.sourcemap.save(None)?;
124        Ok(())
125    }
126
127    pub fn into_chunk_upload(self) -> Result<ChunkUpload> {
128        let chunk_id = self.chunk_id.ok_or_else(|| anyhow!("Chunk ID not found"))?;
129        let source_content = self.source.content;
130        let sourcemap_content = self.sourcemap.content;
131        let data = SourceAndMap {
132            minified_source: source_content,
133            sourcemap: sourcemap_content,
134        };
135        let data = write_symbol_data(data)?;
136        Ok(ChunkUpload { chunk_id, data })
137    }
138}
139
140pub fn read_pairs(directory: &PathBuf) -> Result<Vec<SourcePair>> {
141    // Make sure the directory exists
142    if !directory.exists() {
143        bail!("Directory does not exist");
144    }
145
146    let mut pairs = Vec::new();
147    for entry in WalkDir::new(directory).into_iter().filter_map(|e| e.ok()) {
148        let entry_path = entry.path().canonicalize()?;
149        info!("Processing file: {}", entry_path.display());
150        if is_javascript_file(&entry_path) {
151            let source = SourceFile::load(&entry_path)?;
152            let sourcemap_path = guess_sourcemap_path(&source.path);
153            if sourcemap_path.exists() {
154                let sourcemap = SourceFile::load(&sourcemap_path)?;
155                let chunk_id = get_chunk_id(&sourcemap);
156                pairs.push(SourcePair {
157                    chunk_id,
158                    source,
159                    sourcemap,
160                });
161            } else {
162                warn!("No sourcemap file found for file {}", entry_path.display());
163            }
164        }
165    }
166    Ok(pairs)
167}
168
169pub fn get_chunk_id(sourcemap: &SourceFile) -> Option<String> {
170    #[derive(Deserialize)]
171    struct SourceChunkId {
172        chunk_id: String,
173    }
174    serde_json::from_str(&sourcemap.content)
175        .map(|chunk_id: SourceChunkId| chunk_id.chunk_id)
176        .ok()
177}
178
179pub fn guess_sourcemap_path(path: &Path) -> PathBuf {
180    // Try to resolve the sourcemap by adding .map to the path
181    let mut sourcemap_path = path.to_path_buf();
182    match path.extension() {
183        Some(ext) => sourcemap_path.set_extension(format!("{}.map", ext.to_string_lossy())),
184        None => sourcemap_path.set_extension("map"),
185    };
186    sourcemap_path
187}
188
189fn is_javascript_file(path: &Path) -> bool {
190    path.extension()
191        .map_or(false, |ext| ext == "js" || ext == "mjs" || ext == "cjs")
192}