feat: 初始化符号化服务
支持平台: - RN/Electron/Vue3 (JS Sourcemap) - Android (ProGuard mapping) - iOS (dSYM) - Flutter (Debug Symbols) Co-Authored-By: Claude <noreply@anthropic.com>
这个提交包含在:
当前提交
8fc4c95138
33
Dockerfile
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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"]
|
||||
}
|
||||
正在加载...
在新工单中引用
屏蔽一个用户