use anyhow::{anyhow, bail, Result};
use magic_string::{GenerateDecodedMapOptions, MagicString};
use posthog_symbol_data::{write_symbol_data, HermesMap};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sourcemap::SourceMap;
use std::{collections::BTreeMap, path::PathBuf};
use crate::{
api::symbol_sets::SymbolSetUpload,
sourcemaps::constant::{CHUNKID_COMMENT_PREFIX, CHUNKID_PLACEHOLDER, CODE_SNIPPET_TEMPLATE},
utils::files::SourceFile,
};
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct SourceMapContent {
#[serde(skip_serializing_if = "Option::is_none")]
pub release_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", alias = "debugId")]
pub chunk_id: Option<String>,
#[serde(flatten)]
pub fields: BTreeMap<String, Value>,
}
pub struct SourceMapFile {
pub inner: SourceFile<SourceMapContent>,
}
pub struct MinifiedSourceFile {
pub inner: SourceFile<String>,
}
impl SourceMapFile {
pub fn load(path: &PathBuf) -> Result<Self> {
let inner = SourceFile::load(path)?;
Ok(Self { inner })
}
pub fn save(&self) -> Result<()> {
self.inner.save(None)
}
pub fn get_chunk_id(&self) -> Option<String> {
self.inner.content.chunk_id.clone()
}
pub fn get_release_id(&self) -> Option<String> {
self.inner.content.release_id.clone()
}
pub fn has_release_id(&self) -> bool {
self.get_release_id().is_some()
}
pub fn apply_adjustment(&mut self, adjustment: SourceMap) -> Result<()> {
let new_content = {
let content = serde_json::to_string(&self.inner.content)?.into_bytes();
let mut map = sourcemap::decode_slice(content.as_slice())
.map_err(|err| anyhow!("Failed to parse sourcemap: {err}"))?;
if let sourcemap::DecodedMap::Index(indexed) = &mut map {
let replacement = indexed
.flatten()
.map_err(|err| anyhow!("Failed to flatten sourcemap: {err}"))?;
map = sourcemap::DecodedMap::Regular(replacement);
};
let original = match &mut map {
sourcemap::DecodedMap::Regular(m) => m,
sourcemap::DecodedMap::Hermes(m) => m,
sourcemap::DecodedMap::Index(_) => unreachable!(),
};
original.adjust_mappings(&adjustment);
let mut content = content;
content.clear();
original.to_writer(&mut content)?;
serde_json::from_slice(&content)?
};
let mut old_content = std::mem::replace(&mut self.inner.content, new_content);
self.inner.content.chunk_id = old_content.chunk_id.take();
self.inner.content.release_id = old_content.release_id.take();
Ok(())
}
pub fn set_chunk_id(&mut self, chunk_id: Option<String>) {
self.inner.content.chunk_id = chunk_id;
}
pub fn set_release_id(&mut self, release_id: Option<String>) {
self.inner.content.release_id = release_id;
}
}
impl MinifiedSourceFile {
pub fn load(path: &PathBuf) -> Result<Self> {
let inner = SourceFile::load(path)?;
Ok(Self { inner })
}
pub fn save(&self) -> Result<()> {
self.inner.save(None)
}
pub fn get_chunk_id(&self) -> Option<String> {
let patterns = ["//# chunkId="];
self.get_comment_value(&patterns)
}
pub fn set_chunk_id(&mut self, chunk_id: &str) -> Result<SourceMap> {
let (new_source_content, source_adjustment) = {
let source_content = &self.inner.content;
let mut magic_source = MagicString::new(source_content);
let code_snippet = CODE_SNIPPET_TEMPLATE.replace(CHUNKID_PLACEHOLDER, chunk_id);
magic_source
.prepend(&code_snippet)
.map_err(|err| anyhow!("Failed to prepend code snippet: {err}"))?;
let chunk_comment = CHUNKID_COMMENT_PREFIX.replace(CHUNKID_PLACEHOLDER, chunk_id);
magic_source
.append(&chunk_comment)
.map_err(|err| anyhow!("Failed to append chunk comment: {err}"))?;
let adjustment = magic_source
.generate_map(GenerateDecodedMapOptions {
include_content: true,
..Default::default()
})
.map_err(|err| anyhow!("Failed to generate source map: {err}"))?;
let adjustment_sourcemap = SourceMap::from_slice(
adjustment
.to_string()
.map_err(|err| anyhow!("Failed to serialize source map: {err}"))?
.as_bytes(),
)
.map_err(|err| anyhow!("Failed to parse adjustment sourcemap: {err}"))?;
(magic_source.to_string(), adjustment_sourcemap)
};
self.inner.content = new_source_content;
Ok(source_adjustment)
}
pub fn get_sourcemap_path(&self, prefix: &Option<String>) -> Result<Option<PathBuf>> {
let mut possible_paths = Vec::new();
if let Some(filename) = self.get_sourcemap_reference()? {
possible_paths.push(
self.inner
.path
.parent()
.map(|p| p.join(&filename))
.unwrap_or_else(|| PathBuf::from(&filename)),
);
if let Some(prefix) = prefix {
if let Some(filename) = filename.strip_prefix(prefix) {
possible_paths.push(
self.inner
.path
.parent()
.map(|p| p.join(filename))
.unwrap_or_else(|| PathBuf::from(&filename)),
);
}
if let Some(filename) = filename.strip_prefix(&format!("{prefix}/")) {
possible_paths.push(
self.inner
.path
.parent()
.map(|p| p.join(filename))
.unwrap_or_else(|| PathBuf::from(&filename)),
);
}
}
};
let mut guessed_path = self.inner.path.to_path_buf();
match guessed_path.extension() {
Some(ext) => guessed_path.set_extension(format!("{}.map", ext.to_string_lossy())),
None => guessed_path.set_extension("map"),
};
possible_paths.push(guessed_path);
for path in possible_paths.into_iter() {
if path.exists() {
return Ok(Some(path));
}
}
Ok(None)
}
pub fn get_sourcemap_reference(&self) -> Result<Option<String>> {
let patterns = ["//# sourceMappingURL=", "//@ sourceMappingURL="];
let Some(found) = self.get_comment_value(&patterns) else {
return Ok(None);
};
Ok(Some(urlencoding::decode(&found)?.into_owned()))
}
fn get_comment_value(&self, patterns: &[&str]) -> Option<String> {
for line in self.inner.content.lines().rev() {
if let Some(val) = patterns
.iter()
.filter(|p| line.starts_with(*p))
.filter_map(|_| line.split_once('=').map(|s| s.1.to_string())) .next()
{
return Some(val);
}
}
None
}
pub fn remove_chunk_id(&mut self, chunk_id: String) -> Result<SourceMap> {
let (new_source_content, source_adjustment) = {
let source_content = &self.inner.content;
let mut magic_source = MagicString::new(source_content);
let chunk_comment = CHUNKID_COMMENT_PREFIX.replace(CHUNKID_PLACEHOLDER, &chunk_id);
if let Some(chunk_comment_start) = source_content.find(&chunk_comment) {
let chunk_comment_end = chunk_comment_start as i64 + chunk_comment.len() as i64;
magic_source
.remove(chunk_comment_start as i64, chunk_comment_end)
.map_err(|err| anyhow!("Failed to remove chunk comment: {err}"))?;
}
let code_snippet = CODE_SNIPPET_TEMPLATE.replace(CHUNKID_PLACEHOLDER, &chunk_id);
if let Some(code_snippet_start) = source_content.find(&code_snippet) {
let code_snippet_end = code_snippet_start as i64 + code_snippet.len() as i64;
magic_source
.remove(code_snippet_start as i64, code_snippet_end)
.map_err(|err| anyhow!("Failed to remove code snippet {err}"))?;
}
let adjustment = magic_source
.generate_map(GenerateDecodedMapOptions {
include_content: true,
..Default::default()
})
.map_err(|err| anyhow!("Failed to generate source map: {err}"))?;
let adjustment_sourcemap = SourceMap::from_slice(
adjustment
.to_string()
.map_err(|err| anyhow!("Failed to serialize source map: {err}"))?
.as_bytes(),
)
.map_err(|err| anyhow!("Failed to parse adjustment sourcemap: {err}"))?;
(magic_source.to_string(), adjustment_sourcemap)
};
self.inner.content = new_source_content;
Ok(source_adjustment)
}
}
impl TryInto<SymbolSetUpload> for SourceMapFile {
type Error = anyhow::Error;
fn try_into(self) -> Result<SymbolSetUpload> {
let chunk_id = self
.get_chunk_id()
.ok_or_else(|| anyhow!("Chunk ID not found"))?;
let release_id = self.get_release_id();
let sourcemap = self.inner.content;
let content = serde_json::to_string(&sourcemap)?;
if !sourcemap.fields.contains_key("x_hermes_function_offsets") {
bail!("Map is not a hermes sourcemap - missing key x_hermes_function_offsets");
}
let data = HermesMap { sourcemap: content };
let data = write_symbol_data(data)?;
Ok(SymbolSetUpload {
chunk_id,
release_id,
data,
})
}
}