From d1f29e807e175123770eb4d3a6cca821c814b6c7 Mon Sep 17 00:00:00 2001 From: MasonChow Date: Wed, 15 Jul 2026 22:17:01 +0800 Subject: [PATCH] feat: add native source-map-parser CLI --- .github/workflows/release.yml | 12 + Cargo.toml | 2 +- crates/cli/Cargo.toml | 19 + crates/cli/src/main.rs | 391 ++++++++++++++++++ .../source_map_parser/src/stack_transform.rs | 10 +- 5 files changed, 428 insertions(+), 6 deletions(-) create mode 100644 crates/cli/Cargo.toml create mode 100644 crates/cli/src/main.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bcbadd9..40d8b00 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,6 +68,8 @@ jobs: - name: Build Node/WASM package working-directory: crates/node_sdk run: pnpm run build + - name: Build native CLI + run: cargo build --release --bin source-map-parser - name: Run Node SDK tests working-directory: crates/node_sdk run: pnpm test @@ -88,6 +90,15 @@ jobs: printf '%s\n' "$TAG" > "$STAGE/VERSION" ARCHIVE="release/${PACKAGE_NAME}-${{ matrix.platform }}-${{ matrix.arch }}.tar.gz" tar -czf "$ARCHIVE" -C release "${PACKAGE_NAME}-${{ matrix.platform }}-${{ matrix.arch }}" + BIN_EXT="" + if [[ "${{ matrix.os }}" == "windows-latest" ]]; then BIN_EXT=".exe"; fi + CLI_NAME="source-map-parser-${{ matrix.platform }}-${{ matrix.arch }}${BIN_EXT}" + cp "target/release/source-map-parser${BIN_EXT}" "release/${CLI_NAME}" + if command -v sha256sum >/dev/null 2>&1; then + (cd release && sha256sum "$CLI_NAME" > "$CLI_NAME.sha256") + else + shasum -a 256 "release/${CLI_NAME}" | sed "s#release/##" > "release/${CLI_NAME}.sha256" + fi if command -v sha256sum >/dev/null 2>&1; then (cd release && sha256sum "$(basename "$ARCHIVE")" > "$(basename "$ARCHIVE").sha256") else @@ -100,6 +111,7 @@ jobs: path: | release/${{ env.PACKAGE_NAME }}-${{ matrix.platform }}-${{ matrix.arch }}.tar.gz release/${{ env.PACKAGE_NAME }}-${{ matrix.platform }}-${{ matrix.arch }}.tar.gz.sha256 + release/source-map-parser-${{ matrix.platform }}-${{ matrix.arch }}* publish-github-release: needs: package diff --git a/Cargo.toml b/Cargo.toml index 36eb5c9..02ec3e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/source_map_parser", "crates/node_sdk"] +members = ["crates/source_map_parser", "crates/node_sdk", "crates/cli"] resolver = "2" [workspace.package] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 0000000..9ce1513 --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "source-map-parser-cli" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +description = "Native CLI for source_map_parser" + +[[bin]] +name = "source-map-parser" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +source_map_parser = { path = "../source_map_parser" } +thiserror = "1.0" +ureq = { version = "2.12", default-features = true } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs new file mode 100644 index 0000000..339c006 --- /dev/null +++ b/crates/cli/src/main.rs @@ -0,0 +1,391 @@ +use clap::{Args, Parser, Subcommand, ValueEnum}; +use serde::Serialize; +use source_map_parser::{stack_transform, SourceMapParserClient}; +use std::{ + collections::HashMap, + fs, + io::{self, Read, Write}, + path::{Path, PathBuf}, +}; + +const ABOUT: &str = + "Parse and map minified JS stack traces back to original source via source maps."; + +#[derive(Parser, Debug)] +#[command(name = "source-map-parser", version, about = ABOUT, help_template = TOP_HELP)] +struct Cli { + #[command(subcommand)] + command: Command, + #[arg( + short, + long, + global = true, + value_name = "FILE", + help = "结果写入文件,默认 stdout" + )] + output: Option, + #[arg(long, global = true, help = "JSON 缩进输出(默认单行紧凑)")] + pretty: bool, + #[arg(short, long, global = true, help = "不输出进度/统计到 stderr")] + quiet: bool, +} + +const TOP_HELP: &str = "{name} {version}\n{about}\n\nUSAGE:\n {usage}\n\nCOMMANDS:\n{subcommands}\nGLOBAL OPTIONS:\n{options}\nEXIT CODES:\n 0 全部映射成功 2 部分失败(见 fail[])\n 1 参数/IO 错误 3 无可解析 stack\n"; + +#[derive(Subcommand, Debug)] +enum Command { + #[command(about = "映射整段 error stack -> 原始位置(批量)", long_about = MAP_ABOUT, after_long_help = MAP_AFTER_HELP)] + Map(MapArgs), + #[command( + about = "映射单个 line:column -> 原始 token", + long_about = "映射单个位置。", + after_long_help = LOOKUP_AFTER_HELP + )] + Lookup(LookupArgs), + #[command( + about = "从 .map 提取内嵌 sourcesContent", + long_about = "提取 .map 内嵌 sourcesContent。", + after_long_help = UNPACK_AFTER_HELP + )] + Unpack(UnpackArgs), +} +const MAP_ABOUT: &str = "映射整段 error stack。stack 从 --stack 文件或 stdin(-) 读入。"; +const MAP_AFTER_HELP: &str = r#"SOURCEMAP 来源(三选一,互斥): + --map 单一 .map 文件(stack 全部来自同一 bundle 时) + --map-dir .map 目录,按 stack 里 JS 文件 basename 匹配 + --map-url-template 由 JS URL 推 .map URL 的模板,含占位符 {url} + 例: "{url}.map" -> https://x/app.js 取 https://x/app.js.map + 仅做字符串替换,不 eval(不接受任意脚本 rule) + +OUTPUT (JSON): + { "success": [{ "raw", "source", "line", "column", "name", "context"? }], + "fail": [{ "raw", "reason" }] } + +EXAMPLES: + # 本地单 map + source-map-parser map --stack err.txt --map app.js.map --pretty + # stdin + 目录匹配 + 取 3 行上下文 + cat err.txt | source-map-parser map --stack - --map-dir ./maps --context 3 + # 按模板远程拉 map + source-map-parser map --stack - --map-url-template "{url}.map" < err.txt +"#; +const LOOKUP_AFTER_HELP: &str = r#"OUTPUT (JSON): + { "source", "line", "column", "name", "context"? } + +EXAMPLE: + source-map-parser lookup --map app.js.map --line 1 --column 24680 --context 5 +"#; +const UNPACK_AFTER_HELP: &str = r#"OPTIONS: + --list 只列出 sources 路径,不落盘 + --out-dir 按原始路径结构还原源码到目录 +"#; + +#[derive(Args, Debug)] +struct MapArgs { + #[arg( + long, + value_name = "FILE|-", + help = "error stack 输入,- 表示 stdin(必填)" + )] + stack: String, + #[arg(long, conflicts_with_all = ["map_dir", "map_url_template"], value_name = "FILE", help = "单一 .map 文件(stack 全部来自同一 bundle 时)")] + map: Option, + #[arg(long, conflicts_with_all = ["map", "map_url_template"], value_name = "DIR", help = ".map 目录,按 stack 里 JS 文件 basename 匹配")] + map_dir: Option, + #[arg(long, conflicts_with_all = ["map", "map_dir"], value_name = "TPL", help = "由 JS URL 推 .map URL 的模板,含占位符 {url};仅做字符串替换,不 eval")] + map_url_template: Option, + #[arg( + long, + default_value = "auto", + value_enum, + help = "堆栈格式,默认 auto(自动识别 V8/Firefox/Safari)" + )] + format: StackFormat, + #[arg( + long, + default_value_t = 0, + help = "每帧附带原始源码上下文行数,默认 0(不取)" + )] + context: u32, + #[arg( + long, + help = "禁止任何网络请求(--map-url-template 时改为报错而非下载)" + )] + no_fetch: bool, +} +#[derive(Copy, Clone, Debug, ValueEnum)] +enum StackFormat { + V8, + Auto, +} +#[derive(Args, Debug)] +struct LookupArgs { + #[arg(long)] + map: PathBuf, + #[arg(long)] + line: u32, + #[arg(long)] + column: u32, + #[arg(long)] + context: Option, +} +#[derive(Args, Debug)] +struct UnpackArgs { + #[arg(long)] + map: PathBuf, + #[arg(long)] + out_dir: Option, + #[arg(long)] + list: bool, +} + +#[derive(Serialize)] +struct MapOutput { + success: Vec, + fail: Vec, +} +#[derive(Serialize)] +struct MappedFrame { + raw: String, + source: String, + line: u32, + column: u32, + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + context: Option, +} +#[derive(Serialize)] +struct FailedFrame { + raw: String, + reason: String, +} +#[derive(Serialize)] +struct LookupOutput { + source: String, + line: u32, + column: u32, + name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + context: Option, +} + +#[derive(Debug, thiserror::Error)] +enum CliError { + #[error("{0}")] + Msg(String), + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Client(#[from] source_map_parser::ClientError), +} + +fn main() { + let cli = Cli::parse(); + match run(&cli) { + Ok(code) => std::process::exit(code), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1) + } + } +} +fn run(cli: &Cli) -> Result { + match &cli.command { + Command::Map(a) => run_map(cli, a), + Command::Lookup(a) => run_lookup(cli, a), + Command::Unpack(a) => run_unpack(cli, a), + } +} + +fn read_input(path: &str) -> Result { + if path == "-" { + let mut s = String::new(); + io::stdin().read_to_string(&mut s)?; + Ok(s) + } else { + Ok(fs::read_to_string(path)?) + } +} +fn client_from_path(p: &Path) -> Result { + Ok(SourceMapParserClient::new(&fs::read(p)?)?) +} +fn write_json(cli: &Cli, value: &T) -> Result<(), CliError> { + let data = if cli.pretty { + serde_json::to_vec_pretty(value)? + } else { + serde_json::to_vec(value)? + }; + if let Some(p) = &cli.output { + fs::write(p, data)? + } else { + io::stdout().write_all(&data)?; + println!(); + } + Ok(()) +} + +fn run_map(cli: &Cli, a: &MapArgs) -> Result { + let stack = read_input(&a.stack)?; + let frames = stack_transform::parse_stack_trace(&stack); + if frames.is_empty() { + return Ok(3); + } + let mut out = MapOutput { + success: vec![], + fail: vec![], + }; + let mut cache: HashMap = HashMap::new(); + for f in frames { + let key = map_key(a, f.source_file)?; + let client = match cache.get(&key) { + Some(c) => c, + None => { + let bytes = load_map(&key, a.no_fetch)?; + cache.insert(key.clone(), SourceMapParserClient::new(&bytes)?); + cache.get(&key).unwrap() + } + }; + if a.context > 0 { + match client.lookup_token_with_context(f.line, f.column, a.context) { + Some(t) => out.success.push(MappedFrame { + raw: f.original_raw.to_string(), + source: t.src, + line: t.line, + column: t.column, + name: f.name.to_string(), + context: Some(serde_json::to_value(t.source_code)?), + }), + None => out.fail.push(FailedFrame { + raw: f.original_raw.to_string(), + reason: "no matching source token".into(), + }), + } + } else { + match client.lookup_token(f.line, f.column) { + Some(t) => out.success.push(MappedFrame { + raw: f.original_raw.to_string(), + source: t.src.unwrap_or_default(), + line: t.line, + column: t.column, + name: f.name.to_string(), + context: None, + }), + None => out.fail.push(FailedFrame { + raw: f.original_raw.to_string(), + reason: "no matching source token".into(), + }), + } + } + } + if !cli.quiet { + eprintln!("mapped: {}, failed: {}", out.success.len(), out.fail.len()); + } + let code = if out.fail.is_empty() { 0 } else { 2 }; + write_json(cli, &out)?; + Ok(code) +} +fn map_key(a: &MapArgs, url: &str) -> Result { + if let Some(p) = &a.map { + Ok(p.display().to_string()) + } else if let Some(d) = &a.map_dir { + let base = Path::new(url) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(url); + let name = if base.ends_with(".map") { + base.to_string() + } else { + format!("{base}.map") + }; + find_map(d, &name) + .map(|p| p.display().to_string()) + .ok_or_else(|| CliError::Msg(format!("no sourcemap found for {url}"))) + } else if let Some(t) = &a.map_url_template { + Ok(t.replace("{url}", url)) + } else { + Err(CliError::Msg("one sourcemap source is required".into())) + } +} +fn find_map(dir: &Path, name: &str) -> Option { + for e in fs::read_dir(dir).ok()? { + let p = e.ok()?.path(); + if p.is_dir() { + if let Some(x) = find_map(&p, name) { + return Some(x); + } + } else if p.file_name().and_then(|s| s.to_str()) == Some(name) { + return Some(p); + } + } + None +} +fn load_map(key: &str, no_fetch: bool) -> Result, CliError> { + if key.starts_with("http://") || key.starts_with("https://") { + if no_fetch { + return Err(CliError::Msg("network fetch disabled by --no-fetch".into())); + } + let resp = ureq::get(key) + .call() + .map_err(|e| CliError::Msg(e.to_string()))?; + let mut r = resp.into_reader(); + let mut b = Vec::new(); + r.read_to_end(&mut b)?; + Ok(b) + } else { + Ok(fs::read(key)?) + } +} +fn run_lookup(cli: &Cli, a: &LookupArgs) -> Result { + let c = client_from_path(&a.map)?; + let out = if let Some(ctx) = a.context { + let t = c + .lookup_token_with_context(a.line, a.column, ctx) + .ok_or_else(|| CliError::Msg("no matching source token".into()))?; + LookupOutput { + source: t.src, + line: t.line, + column: t.column, + name: None, + context: Some(serde_json::to_value(t.source_code)?), + } + } else { + let t = c + .lookup_token(a.line, a.column) + .ok_or_else(|| CliError::Msg("no matching source token".into()))?; + LookupOutput { + source: t.src.unwrap_or_default(), + line: t.line, + column: t.column, + name: None, + context: None, + } + }; + write_json(cli, &out)?; + Ok(0) +} +fn run_unpack(cli: &Cli, a: &UnpackArgs) -> Result { + let c = client_from_path(&a.map)?; + let sources = c.unpack_all_sources(); + if a.list { + let mut keys: Vec<_> = sources.keys().cloned().collect(); + keys.sort(); + write_json(cli, &keys)?; + return Ok(0); + } + let out = a + .out_dir + .clone() + .ok_or_else(|| CliError::Msg("--out-dir is required unless --list is used".into()))?; + for (src, body) in &sources { + let rel = src.trim_start_matches('/'); + let path = out.join(rel); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, body)?; + } + write_json(cli, &sources)?; + Ok(0) +} diff --git a/crates/source_map_parser/src/stack_transform.rs b/crates/source_map_parser/src/stack_transform.rs index 84ed67b..d459534 100644 --- a/crates/source_map_parser/src/stack_transform.rs +++ b/crates/source_map_parser/src/stack_transform.rs @@ -28,7 +28,7 @@ pub struct Stack<'a> { pub original_raw: &'a str, } -pub fn parse_stack_line(original_raw: &str) -> Option { +pub fn parse_stack_line(original_raw: &str) -> Option> { let trimmed = original_raw.trim(); if trimmed.matches(':').count() < 2 { return None; @@ -82,7 +82,7 @@ pub fn parse_stack_line(original_raw: &str) -> Option { None } -pub fn parse_stack_trace(trace_string: &str) -> Vec { +pub fn parse_stack_trace(trace_string: &str) -> Vec> { trace_string .lines() .filter_map(|l| parse_stack_line(l.trim())) @@ -97,7 +97,7 @@ pub struct ErrorStack<'a> { } impl ErrorStack<'_> { - pub fn from_raw(error_raw: &str) -> ErrorStack { + pub fn from_raw(error_raw: &str) -> ErrorStack<'_> { let mut stacks: Vec = Vec::new(); let mut error_message = String::new(); for (index, line) in error_raw.lines().enumerate() { @@ -131,8 +131,8 @@ mod tests { assert_eq!(stacks.len(), lines.len()); assert_eq!(stacks[0].line, 10); assert_eq!(stacks[1].column, 15); - // The async prefix is ignored by the regex, only the method name is kept - assert_eq!(stacks[2].name, "bar"); + // The async prefix is ignored by the regex, only the method name is kept + assert_eq!(stacks[2].name, "bar"); assert_eq!(stacks[3].line, 40); }