feat: 初始化符号化服务

支持平台:
- RN/Electron/Vue3 (JS Sourcemap)
- Android (ProGuard mapping)
- iOS (dSYM)
- Flutter (Debug Symbols)

Co-Authored-By: Claude <noreply@anthropic.com>
这个提交包含在:
徐勤民 2026-06-19 02:49:26 +08:00
当前提交 8fc4c95138
共有 10 个文件被更改,包括 909 次插入0 次删除

33
Dockerfile 普通文件
查看文件

@ -0,0 +1,33 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json* ./
RUN npm ci
# Copy source
COPY tsconfig.json ./
COPY src/ ./src/
# Build
RUN npm run build
# Production image
FROM node:20-alpine
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json* ./
RUN npm ci --production
# Copy built files
COPY --from=builder /app/dist ./dist
# Install atos for iOS symbolication (if needed)
RUN apk add --no-cache binutils
EXPOSE 3000
CMD ["node", "dist/index.js"]

25
package.json 普通文件
查看文件

@ -0,0 +1,25 @@
{
"name": "@xuqm/symbolicator",
"version": "1.0.0",
"description": "BugCollect 符号化服务 - 支持 RN/Android/iOS/Flutter",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"express": "^4.18.2",
"source-map": "^0.7.4",
"multer": "^1.4.5-lts.1",
"cors": "^2.8.5"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/multer": "^1.4.11",
"@types/cors": "^2.8.17",
"@types/node": "^20.10.0",
"typescript": "^5.3.2",
"tsx": "^4.6.2"
}
}

31
src/index.ts 普通文件
查看文件

@ -0,0 +1,31 @@
import express from 'express'
import cors from 'cors'
import { symbolicateRouter } from './routes/symbolicate'
const app = express()
const PORT = process.env.PORT || 3000
// Middleware
app.use(cors())
app.use(express.json({ limit: '200mb' }))
app.use(express.text({ limit: '200mb' }))
// Routes
app.use('/api', symbolicateRouter)
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'xuqm-symbolicator', version: '1.0.0' })
})
// Error handler
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error('Error:', err.message)
res.status(500).json({ success: false, error: err.message })
})
app.listen(PORT, () => {
console.log(`Symbolicator service running on port ${PORT}`)
})
export default app

85
src/parsers/dsym.ts 普通文件
查看文件

@ -0,0 +1,85 @@
/**
* iOS dSYM
*
* 使 atos
* atos -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp -arch arm64 -l 0x102345000 0x102345678
*/
import { execSync } from 'child_process'
import { StackFrame } from './stack-parser'
export interface DsymConfig {
/** dSYM 文件路径 */
dsymPath: string
/** 应用加载地址 */
loadAddress: string
/** 架构 (arm64, x86_64) */
arch?: string
}
/**
* 使 atos iOS
*/
export function symbolicateWithDsym(
frames: StackFrame[],
config: DsymConfig
): string[] {
const { dsymPath, loadAddress, arch = 'arm64' } = config
// 提取需要符号化的地址
const addresses = frames
.filter(f => f.file === '<binary>')
.map(f => `0x${f.column.toString(16)}`)
if (addresses.length === 0) {
return frames.map(f => `${f.function} (${f.file}:${f.line})`)
}
try {
// 调用 atos 命令
const cmd = `atos -o "${dsymPath}" -arch ${arch} -l ${loadAddress} ${addresses.join(' ')}`
const output = execSync(cmd, { encoding: 'utf-8', timeout: 30000 })
const symbolicated = output.trim().split('\n')
let addrIndex = 0
return frames.map(f => {
if (f.file === '<binary>' && addrIndex < symbolicated.length) {
return symbolicated[addrIndex++]
}
return `${f.function} (${f.file}:${f.line})`
})
} catch (err) {
console.error('atos symbolication failed:', err)
return frames.map(f => `${f.function} (${f.file}:${f.line})`)
}
}
/**
* iOS 线
*
*
* 0 MyApp 0x0000000102345678 myFunction + 123
* 1 MyApp 0x0000000102345678 myFunction + 123
*/
export function parseIOSCrashFrames(crashLog: string): StackFrame[] {
const frames: StackFrame[] = []
const lines = crashLog.split('\n')
for (const line of lines) {
const match = line.trim().match(
/(\d+)\s+(\S+)\s+(0x[\da-f]+)\s+(\S+)\s+\+\s+(\d+)/
)
if (match) {
frames.push({
function: match[4],
file: match[2],
line: 0,
column: parseInt(match[5], 10),
})
}
}
return frames
}

90
src/parsers/flutter.ts 普通文件
查看文件

@ -0,0 +1,90 @@
/**
* Flutter/Dart
*
* Flutter 使 --split-debug-info
* 使 flutter symbolize
*/
import { execSync } from 'child_process'
import { StackFrame } from './stack-parser'
export interface FlutterDebugConfig {
/** debug symbols 文件路径 (通常是 .debug 文件) */
debugSymbolsPath: string
/** Flutter SDK 路径 (可选,默认使用系统 flutter) */
flutterPath?: string
}
/**
* 使 flutter symbolize Flutter
*/
export function symbolicateWithFlutter(
stacktrace: string,
config: FlutterDebugConfig
): string {
const { debugSymbolsPath, flutterPath = 'flutter' } = config
try {
// 将堆栈写入临时文件
const tmpInput = `/tmp/flutter_stack_${Date.now()}.txt`
const tmpOutput = `/tmp/flutter_symbolicated_${Date.now()}.txt`
require('fs').writeFileSync(tmpInput, stacktrace)
// 调用 flutter symbolize
const cmd = `${flutterPath} symbolize -i "${tmpInput}" -o "${tmpOutput}" --debug-info "${debugSymbolsPath}"`
execSync(cmd, { timeout: 60000 })
// 读取符号化结果
const result = require('fs').readFileSync(tmpOutput, 'utf-8')
// 清理临时文件
try {
require('fs').unlinkSync(tmpInput)
require('fs').unlinkSync(tmpOutput)
} catch {}
return result
} catch (err) {
console.error('Flutter symbolization failed:', err)
return stacktrace
}
}
/**
* Flutter/Dart
*
*
* #0 MyApp.myMethod (package:my_app/main.dart:42:10)
* #1 MyApp.anotherMethod (package:my_app/main.dart:50:5)
* #2 _rootRun (dart:async/zone.dart:1390:13)
*/
export function parseFlutterStack(stacktrace: string): StackFrame[] {
const frames: StackFrame[] = []
const lines = stacktrace.split('\n')
for (const line of lines) {
// 匹配 "#0 ClassName.method (package:.../file.dart:line:col)" 格式
const match = line.trim().match(
/#\d+\s+(\S+)\s+\((.+?):(\d+):(\d+)\)/
)
if (match) {
frames.push({
function: match[1],
file: match[2],
line: parseInt(match[3], 10),
column: parseInt(match[4], 10),
})
}
}
return frames
}
/**
* Dart
*/
export function isDartNativeFrame(file: string): boolean {
return file.startsWith('dart:') || file.startsWith('package:flutter/')
}

174
src/parsers/proguard.ts 普通文件
查看文件

@ -0,0 +1,174 @@
/**
* Android ProGuard mapping.txt
*
* mapping.txt
* com.example.MyClass -> a.b.c:
* int myField -> a
* void myMethod(int) -> a
* 42:42:void otherMethod():100:100 -> b
*/
export interface ProguardMapping {
obfuscatedClass: string
originalClass: string
methods: MethodMapping[]
lines: LineMapping[]
}
export interface MethodMapping {
obfuscatedMethod: string
originalMethod: string
obfuscatedLine: number
originalLine: number
}
export interface LineMapping {
originalLine: number
obfuscatedLine: number
originalFile: string
}
/**
* ProGuard mapping.txt
*/
export function parseProguardMapping(content: string): Map<string, ProguardMapping> {
const mappings = new Map<string, ProguardMapping>()
let currentClass: ProguardMapping | null = null
let currentOriginalClass = ''
let currentObfuscatedClass = ''
let lineNumber = 0
const lines = content.split('\n')
for (const line of lines) {
lineNumber++
// 跳过空行和注释
if (!line.trim() || line.startsWith('#')) continue
// 类映射行com.example.MyClass -> a.b.c:
const classMatch = line.match(/^(\S+)\s+->\s+(\S+):$/)
if (classMatch) {
currentOriginalClass = classMatch[1]
currentObfuscatedClass = classMatch[2].replace(/:$/, '')
currentClass = {
obfuscatedClass: currentObfuscatedClass,
originalClass: currentOriginalClass,
methods: [],
lines: [],
}
mappings.set(currentObfuscatedClass, currentClass)
continue
}
if (!currentClass) continue
// 行号映射行42:42:void otherMethod():100:100 -> b
const lineMatch = line.match(
/^\s+(\d+):(\d+):([^:]+):(\d+):(\d+)\s+->\s+(\S+)$/
)
if (lineMatch) {
currentClass.lines.push({
originalLine: parseInt(lineMatch[4], 10),
obfuscatedLine: parseInt(lineMatch[1], 10),
originalFile: currentOriginalClass,
})
continue
}
// 方法映射行void myMethod(int) -> a
const methodMatch = line.match(
/^\s+(?:(\d+):(\d+):)?(\S+)\s+(\S+)\(([^)]*)\)\s+->\s+(\S+)$/
)
if (methodMatch) {
currentClass.methods.push({
obfuscatedMethod: methodMatch[6],
originalMethod: methodMatch[4],
obfuscatedLine: methodMatch[1] ? parseInt(methodMatch[1], 10) : 0,
originalLine: methodMatch[2] ? parseInt(methodMatch[2], 10) : 0,
})
continue
}
}
return mappings
}
/**
* 使 mapping
*/
export function deobfuscateFrame(
mappings: Map<string, ProguardMapping>,
obfuscatedClass: string,
obfuscatedMethod: string,
obfuscatedLine: number
): { class: string; method: string; line: number; file: string } | null {
// 查找类映射
const mapping = mappings.get(obfuscatedClass)
if (!mapping) return null
// 查找方法映射
const methodMapping = mapping.methods.find(m => m.obfuscatedMethod === obfuscatedMethod)
// 查找行号映射
let originalLine = obfuscatedLine
const lineMapping = mapping.lines.find(l => l.obfuscatedLine === obfuscatedLine)
if (lineMapping) {
originalLine = lineMapping.originalLine
} else if (methodMapping) {
// 使用方法的行号范围估算
originalLine = methodMapping.originalLine
}
return {
class: mapping.originalClass,
method: methodMapping?.originalMethod || obfuscatedMethod,
line: originalLine,
file: `${mapping.originalClass.split('.').pop()}.java`,
}
}
/**
*
*/
export function deobfuscateStack(
mappings: Map<string, ProguardMapping>,
stacktrace: string
): string {
const lines = stacktrace.split('\n')
const result: string[] = []
for (const line of lines) {
// 匹配 "at com.example.a.b(MyFile.java:42)" 格式
const match = line.match(
/at\s+([\w.$]+)\.([\w$]+)\(([\w.]+):(\d+)\)/
)
if (match) {
const obfuscatedClass = match[1]
const obfuscatedMethod = match[2]
const obfuscatedLine = parseInt(match[4], 10)
const deobfuscated = deobfuscateFrame(
mappings,
obfuscatedClass,
obfuscatedMethod,
obfuscatedLine
)
if (deobfuscated) {
result.push(
`at ${deobfuscated.class}.${deobfuscated.method}(${deobfuscated.file}:${deobfuscated.line})`
)
continue
}
}
// 无法反混淆,保留原始行
result.push(line)
}
return result.join('\n')
}

101
src/parsers/sourcemap.ts 普通文件
查看文件

@ -0,0 +1,101 @@
/**
* JS Sourcemap - 使 Mozilla source-map
*/
import { SourceMapConsumer } from 'source-map'
import { StackFrame } from './stack-parser'
export interface SymbolicatedFrame {
originalFile: string
originalLine: number
originalColumn: number
originalFunction: string
minifiedFile: string
minifiedLine: number
minifiedColumn: number
}
/**
* 使 sourcemap
*/
export async function symbolicateWithSourcemap(
frames: StackFrame[],
sourcemapContent: string
): Promise<SymbolicatedFrame[]> {
const rawSourceMap = JSON.parse(sourcemapContent)
const consumer = await new SourceMapConsumer(rawSourceMap)
const results: SymbolicatedFrame[] = []
for (const frame of frames) {
try {
const original = consumer.originalPositionFor({
line: frame.line,
column: frame.column,
})
if (original.source) {
results.push({
originalFile: original.source,
originalLine: original.line || 0,
originalColumn: original.column || 0,
originalFunction: original.name || frame.function,
minifiedFile: frame.file,
minifiedLine: frame.line,
minifiedColumn: frame.column,
})
} else {
// 无法映射,保留原始帧
results.push({
originalFile: frame.file,
originalLine: frame.line,
originalColumn: frame.column,
originalFunction: frame.function,
minifiedFile: frame.file,
minifiedLine: frame.line,
minifiedColumn: frame.column,
})
}
} catch (err) {
// 解析失败,保留原始帧
results.push({
originalFile: frame.file,
originalLine: frame.line,
originalColumn: frame.column,
originalFunction: frame.function,
minifiedFile: frame.file,
minifiedLine: frame.line,
minifiedColumn: frame.column,
})
}
}
consumer.destroy()
return results
}
/**
*
*/
export function formatSymbolicatedStack(
type: string,
message: string,
frames: SymbolicatedFrame[]
): string {
const lines: string[] = [`${type}: ${message}`]
for (const frame of frames) {
const funcPart = frame.originalFunction !== '<anonymous>'
? `${frame.originalFunction}`
: ''
const locationPart = `${frame.originalFile}:${frame.originalLine}:${frame.originalColumn}`
if (funcPart) {
lines.push(` at ${funcPart} (${locationPart})`)
} else {
lines.push(` at ${locationPart}`)
}
}
return lines.join('\n')
}

153
src/parsers/stack-parser.ts 普通文件
查看文件

@ -0,0 +1,153 @@
/**
* - RN/Hermes/V8/Java/iOS/Flutter
*/
export interface StackFrame {
function: string
file: string
line: number
column: number
}
export type Platform = 'rn' | 'android' | 'ios' | 'flutter' | 'electron' | 'vue3'
/**
*
*/
export function parseStackFrames(platform: Platform, stacktrace: string): StackFrame[] {
if (!stacktrace) return []
switch (platform) {
case 'rn':
case 'electron':
case 'vue3':
return parseJavaScriptStack(stacktrace)
case 'android':
return parseJavaStack(stacktrace)
case 'ios':
return parseIOSStack(stacktrace)
case 'flutter':
return parseFlutterStack(stacktrace)
default:
return parseJavaScriptStack(stacktrace)
}
}
/**
* JavaScript RN Hermes/V8/Chrome
*
*
* at someFunction (http://localhost:8081/index.bundle:12345:67)
* at Object.someFunction (http://localhost:8081/index.bundle:12345:67)
* at someFunction (index.bundle:12345:67)
* at someFunction (native)
*/
function parseJavaScriptStack(stacktrace: string): StackFrame[] {
const frames: StackFrame[] = []
const lines = stacktrace.split('\n')
for (const line of lines) {
// 匹配 "at functionName (file:line:column)" 格式
const match = line.trim().match(
/at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?/
)
if (match) {
frames.push({
function: match[1] || '<anonymous>',
file: match[2],
line: parseInt(match[3], 10),
column: parseInt(match[4], 10),
})
}
}
return frames
}
/**
* Java/Kotlin
*
*
* at com.example.MyClass.myMethod(MyFile.java:42)
* at com.example.MyClass.myMethod(MyFile.kt:42)
*/
function parseJavaStack(stacktrace: string): StackFrame[] {
const frames: StackFrame[] = []
const lines = stacktrace.split('\n')
for (const line of lines) {
const match = line.trim().match(
/at\s+([\w.$]+)\.([\w$]+)\(([\w.]+):(\d+)\)/
)
if (match) {
frames.push({
function: `${match[1]}.${match[2]}`,
file: match[3],
line: parseInt(match[4], 10),
column: 0,
})
}
}
return frames
}
/**
* iOS
*
*
* 0 MyApp 0x0000000102345678 myFunction + 123
* 1 MyApp 0x0000000102345678 myFunction + 123
*/
function parseIOSStack(stacktrace: string): StackFrame[] {
const frames: StackFrame[] = []
const lines = stacktrace.split('\n')
for (const line of lines) {
const match = line.trim().match(
/\d+\s+\S+\s+0x[\da-f]+\s+(\S+)\s+\+\s+(\d+)/
)
if (match) {
frames.push({
function: match[1],
file: '<binary>',
line: 0,
column: parseInt(match[2], 10),
})
}
}
return frames
}
/**
* Flutter/Dart
*
*
* #0 MyApp.myMethod (package:my_app/main.dart:42:10)
* #1 MyApp.anotherMethod (package:my_app/main.dart:50:5)
*/
function parseFlutterStack(stacktrace: string): StackFrame[] {
const frames: StackFrame[] = []
const lines = stacktrace.split('\n')
for (const line of lines) {
const match = line.trim().match(
/#\d+\s+(\S+)\s+\((.+?):(\d+):(\d+)\)/
)
if (match) {
frames.push({
function: match[1],
file: match[2],
line: parseInt(match[3], 10),
column: parseInt(match[4], 10),
})
}
}
return frames
}

198
src/routes/symbolicate.ts 普通文件
查看文件

@ -0,0 +1,198 @@
/**
* API
*/
import { Router, Request, Response } from 'express'
import { parseStackFrames, Platform } from '../parsers/stack-parser'
import { symbolicateWithSourcemap, formatSymbolicatedStack, SymbolicatedFrame } from '../parsers/sourcemap'
import { parseProguardMapping, deobfuscateStack } from '../parsers/proguard'
import { symbolicateWithDsym } from '../parsers/dsym'
import { symbolicateWithFlutter } from '../parsers/flutter'
export const symbolicateRouter = Router()
interface SymbolicateRequest {
/** 平台类型 */
platform: Platform
/** 原始堆栈字符串 */
stacktrace: string
/** 异常类型 */
type?: string
/** 异常消息 */
message?: string
// JS Sourcemap (RN/Electron/Vue3)
/** Sourcemap JSON 字符串 */
sourcemap?: string
// Android ProGuard
/** ProGuard mapping.txt 内容 */
mappingFile?: string
// iOS dSYM
/** dSYM 文件路径 */
dsymPath?: string
/** 应用加载地址 */
loadAddress?: string
/** 架构 */
arch?: string
// Flutter
/** Flutter debug symbols 路径 */
debugSymbolsPath?: string
}
/**
* POST /api/symbolicate
*
*
*/
symbolicateRouter.post('/symbolicate', async (req: Request, res: Response) => {
try {
const {
platform,
stacktrace,
type = 'Error',
message = '',
sourcemap,
mappingFile,
dsymPath,
loadAddress,
arch,
debugSymbolsPath,
} = req.body as SymbolicateRequest
if (!platform || !stacktrace) {
return res.status(400).json({
success: false,
error: 'platform and stacktrace are required',
})
}
let result: { symbolicated: string; frames?: SymbolicatedFrame[] }
switch (platform) {
case 'rn':
case 'electron':
case 'vue3':
// JS Sourcemap 符号化
if (!sourcemap) {
return res.status(400).json({
success: false,
error: 'sourcemap is required for JS platforms',
})
}
result = await symbolicateJS(stacktrace, sourcemap, type, message)
break
case 'android':
// Android ProGuard 反混淆
if (!mappingFile) {
return res.status(400).json({
success: false,
error: 'mappingFile is required for Android',
})
}
result = symbolicateAndroid(stacktrace, mappingFile)
break
case 'ios':
// iOS dSYM 符号化
if (!dsymPath || !loadAddress) {
return res.status(400).json({
success: false,
error: 'dsymPath and loadAddress are required for iOS',
})
}
result = symbolicateIOS(stacktrace, dsymPath, loadAddress, arch)
break
case 'flutter':
// Flutter 符号化
if (!debugSymbolsPath) {
return res.status(400).json({
success: false,
error: 'debugSymbolsPath is required for Flutter',
})
}
result = symbolicateFlutter(stacktrace, debugSymbolsPath)
break
default:
return res.status(400).json({
success: false,
error: `Unsupported platform: ${platform}`,
})
}
return res.json({
success: true,
...result,
})
} catch (err: any) {
console.error('Symbolication error:', err)
return res.status(500).json({
success: false,
error: err.message,
})
}
})
/**
* JS Sourcemap
*/
async function symbolicateJS(
stacktrace: string,
sourcemap: string,
type: string,
message: string
) {
const frames = parseStackFrames('rn', stacktrace)
const symbolicatedFrames = await symbolicateWithSourcemap(frames, sourcemap)
const symbolicated = formatSymbolicatedStack(type, message, symbolicatedFrames)
return {
symbolicated,
frames: symbolicatedFrames,
}
}
/**
* Android ProGuard
*/
function symbolicateAndroid(stacktrace: string, mappingFile: string) {
const mappings = parseProguardMapping(mappingFile)
const symbolicated = deobfuscateStack(mappings, stacktrace)
return { symbolicated }
}
/**
* iOS dSYM
*/
function symbolicateIOS(
stacktrace: string,
dsymPath: string,
loadAddress: string,
arch?: string
) {
const frames = parseStackFrames('ios', stacktrace)
const symbolicatedLines = symbolicateWithDsym(frames, {
dsymPath,
loadAddress,
arch: arch || 'arm64',
})
return { symbolicated: symbolicatedLines.join('\n') }
}
/**
* Flutter
*/
function symbolicateFlutter(stacktrace: string, debugSymbolsPath: string) {
const symbolicated = symbolicateWithFlutter(stacktrace, {
debugSymbolsPath,
})
return { symbolicated }
}

19
tsconfig.json 普通文件
查看文件

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}