@forge-kit/plugin-source-map-prase 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +3 -5
- package/dist/node.js +2556 -0
- package/package.json +5 -3
- package/src/App.less +43 -74
- package/src/App.tsx +98 -54
- package/src/components/map-input-panel/index.less +71 -79
- package/src/components/map-input-panel/index.tsx +65 -69
- package/src/components/panel-card/index.less +21 -0
- package/src/components/panel-card/index.tsx +6 -8
- package/src/components/trace-chain/index.less +126 -125
- package/src/components/trace-chain/index.tsx +25 -24
- package/src/main.tsx +1 -1
- package/src/node/index.ts +6 -0
- package/src/{utils/source-map → node/trace/core}/base/registry.ts +14 -11
- package/src/{utils/source-map → node/trace/core}/base/types.ts +1 -0
- package/src/node/trace/core/domain/chain-slots.ts +33 -0
- package/src/{utils/source-map → node/trace/core}/domain/source-content.ts +15 -2
- package/src/node/trace/core/domain/trace-resolver.ts +179 -0
- package/src/node/trace/core/domain/view-model.ts +57 -0
- package/src/node/trace/resolve/context.ts +59 -0
- package/src/node/trace/resolve/index.ts +97 -0
- package/src/node/trace/resolve/input.ts +35 -0
- package/src/node/trace/resolve/snippet-limit.ts +35 -0
- package/src/node/trace/resolve/types.ts +37 -0
- package/src/node/trace/runner.ts +149 -0
- package/src/shared/trace-common.ts +104 -0
- package/src/shared/trace-contract.ts +29 -0
- package/src/types.ts +19 -0
- package/src/utils/trace-ui/index.ts +12 -0
- package/src/utils/trace-ui/state.ts +81 -0
- package/src/utils/source-map/domain/chain-slots.ts +0 -59
- package/src/utils/source-map/domain/trace-resolver.ts +0 -165
- package/src/utils/source-map/domain/view-model.ts +0 -20
- package/src/utils/source-map/facade/source-map-utils.ts +0 -212
- package/src/utils/source-map/index.ts +0 -18
- /package/src/{utils/source-map → node/trace/core}/base/constants.ts +0 -0
- /package/src/{utils/source-map → node/trace/core}/base/path.ts +0 -0
|
@@ -9,6 +9,11 @@ interface SourceCodeMatch {
|
|
|
9
9
|
location: TraceLocation
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
interface ResolveSourceCodeOptions {
|
|
13
|
+
onlyLastMap?: boolean
|
|
14
|
+
lastMapFileName?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
const findSourceCodeMatch = (hop: TraceHop, sourceMapRecord: SourceMapRecord): SourceCodeMatch | null => {
|
|
13
18
|
if (!hop.output) return null
|
|
14
19
|
|
|
@@ -42,14 +47,22 @@ const findSourceCodeMatch = (hop: TraceHop, sourceMapRecord: SourceMapRecord): S
|
|
|
42
47
|
}
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
export const resolveSourceCodeFromTrace = (
|
|
50
|
+
export const resolveSourceCodeFromTrace = (
|
|
51
|
+
traceHops: TraceHop[],
|
|
52
|
+
sourceMaps: SourceMapRegistry,
|
|
53
|
+
options: ResolveSourceCodeOptions = {},
|
|
54
|
+
) => {
|
|
46
55
|
const sourceMapRecords = listSourceMapRecords(sourceMaps)
|
|
56
|
+
const sourceMapRecordByName = new Map(sourceMapRecords.map((record) => [record.mapFileName, record] as const))
|
|
57
|
+
const {onlyLastMap = false, lastMapFileName} = options
|
|
47
58
|
|
|
48
59
|
for (let index = traceHops.length - 1; index >= 0; index--) {
|
|
49
60
|
const hop = traceHops[index]
|
|
50
61
|
if (!hop.output || !hop.mapFileName) continue
|
|
62
|
+
// 仅允许链路最后一个 map 返回 sourcesContent,控制传输体积和性能开销。
|
|
63
|
+
if (onlyLastMap && lastMapFileName && hop.mapFileName !== lastMapFileName) continue
|
|
51
64
|
|
|
52
|
-
const sourceMapRecord =
|
|
65
|
+
const sourceMapRecord = sourceMapRecordByName.get(hop.mapFileName)
|
|
53
66
|
if (!sourceMapRecord) continue
|
|
54
67
|
|
|
55
68
|
const match = findSourceCodeMatch(hop, sourceMapRecord)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import {SourceMapConsumer, type RawSourceMap} from "source-map-js";
|
|
2
|
+
import {MAX_TRACE_DEPTH} from "../base/constants";
|
|
3
|
+
import {getSourceMapByFileName, getSourceMapByMapFileName} from "../base/registry";
|
|
4
|
+
import type {ResolveTraceOptions, SourceMapRecord, SourceMapRegistry, TraceHop, TraceLocation} from "../base/types";
|
|
5
|
+
|
|
6
|
+
interface ResolvedTraceHop extends TraceHop {
|
|
7
|
+
output: TraceLocation
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const toLocationCursor = (location: TraceLocation) => {
|
|
11
|
+
return `${location.fileName}:${location.lineNumber}:${location.columnNumber}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const isSameLocation = (left: TraceLocation, right: TraceLocation) => {
|
|
15
|
+
return (
|
|
16
|
+
left.fileName === right.fileName
|
|
17
|
+
&& left.lineNumber === right.lineNumber
|
|
18
|
+
&& left.columnNumber === right.columnNumber
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const resolveOriginalLocation = (
|
|
23
|
+
rawSourceMap: RawSourceMap,
|
|
24
|
+
position: { line: number; column: number },
|
|
25
|
+
): TraceLocation | null => {
|
|
26
|
+
const consumer = new SourceMapConsumer(rawSourceMap)
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
// 回退策略:
|
|
30
|
+
// 1) 优先按目标列号查询;
|
|
31
|
+
// 2) 再回退到列号 0 的行级查询;
|
|
32
|
+
// 同时组合 GLB/LUB 两种 bias,提高稀疏映射和压缩代码场景下的命中率。
|
|
33
|
+
const attempts = [
|
|
34
|
+
{line: position.line, column: position.column, bias: SourceMapConsumer.GREATEST_LOWER_BOUND},
|
|
35
|
+
{line: position.line, column: 0, bias: SourceMapConsumer.GREATEST_LOWER_BOUND},
|
|
36
|
+
{line: position.line, column: position.column, bias: SourceMapConsumer.LEAST_UPPER_BOUND},
|
|
37
|
+
{line: position.line, column: 0, bias: SourceMapConsumer.LEAST_UPPER_BOUND},
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
for (const attempt of attempts) {
|
|
41
|
+
const found = consumer.originalPositionFor(attempt)
|
|
42
|
+
if (found.source && found.line != null && found.column != null) {
|
|
43
|
+
return {
|
|
44
|
+
fileName: found.source,
|
|
45
|
+
lineNumber: found.line,
|
|
46
|
+
columnNumber: found.column,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} finally {
|
|
51
|
+
const maybeDestroy = (consumer as { destroy?: () => void }).destroy
|
|
52
|
+
maybeDestroy?.()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const resolveHopFromRecord = (sourceMapRecord: SourceMapRecord, input: TraceLocation): ResolvedTraceHop | null => {
|
|
59
|
+
try {
|
|
60
|
+
const {lineNumber: line, columnNumber: column} = input
|
|
61
|
+
const output = resolveOriginalLocation(sourceMapRecord.rawSourceMap, {line, column})
|
|
62
|
+
if (!output) return null
|
|
63
|
+
return {input, output, mapFileName: sourceMapRecord.mapFileName}
|
|
64
|
+
} catch {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface AppendUnresolvedHopInput {
|
|
70
|
+
traceHops: TraceHop[]
|
|
71
|
+
input: TraceLocation
|
|
72
|
+
mapFileName?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const appendUnresolvedHop = (input: AppendUnresolvedHopInput) => {
|
|
76
|
+
const {traceHops, input: location, mapFileName} = input
|
|
77
|
+
traceHops.push({input: location, mapFileName})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface ResolveTraceWithOrderedMapsInput {
|
|
81
|
+
entry: TraceLocation
|
|
82
|
+
sourceMaps: SourceMapRegistry
|
|
83
|
+
orderedMapFileNames: string[]
|
|
84
|
+
maxDepth: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const resolveTraceWithOrderedMaps = (input: ResolveTraceWithOrderedMapsInput) => {
|
|
88
|
+
const {entry, sourceMaps, orderedMapFileNames, maxDepth} = input
|
|
89
|
+
const traceHops: TraceHop[] = []
|
|
90
|
+
const visited = new Set<string>()
|
|
91
|
+
let current = entry
|
|
92
|
+
|
|
93
|
+
for (let depth = 0; depth < orderedMapFileNames.length && depth < maxDepth; depth++) {
|
|
94
|
+
const mapFileName = orderedMapFileNames[depth]
|
|
95
|
+
const cursor = toLocationCursor(current)
|
|
96
|
+
|
|
97
|
+
if (visited.has(cursor)) {
|
|
98
|
+
appendUnresolvedHop({traceHops, input: current, mapFileName})
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
visited.add(cursor)
|
|
102
|
+
|
|
103
|
+
const sourceMapRecord = getSourceMapByMapFileName(sourceMaps, mapFileName)
|
|
104
|
+
if (!sourceMapRecord) {
|
|
105
|
+
appendUnresolvedHop({traceHops, input: current, mapFileName})
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const hop = resolveHopFromRecord(sourceMapRecord, current)
|
|
110
|
+
if (!hop) {
|
|
111
|
+
appendUnresolvedHop({traceHops, input: current, mapFileName: sourceMapRecord.mapFileName})
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
traceHops.push(hop)
|
|
116
|
+
current = hop.output
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return traceHops
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface ResolveTraceByFileLookupInput {
|
|
123
|
+
entry: TraceLocation
|
|
124
|
+
sourceMaps: SourceMapRegistry
|
|
125
|
+
maxDepth: number
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const resolveTraceByFileLookup = (input: ResolveTraceByFileLookupInput) => {
|
|
129
|
+
const {entry, sourceMaps, maxDepth} = input
|
|
130
|
+
const traceHops: TraceHop[] = []
|
|
131
|
+
const visited = new Set<string>()
|
|
132
|
+
let current = entry
|
|
133
|
+
|
|
134
|
+
for (let depth = 0; depth < maxDepth; depth++) {
|
|
135
|
+
const cursor = toLocationCursor(current)
|
|
136
|
+
|
|
137
|
+
if (visited.has(cursor)) {
|
|
138
|
+
appendUnresolvedHop({traceHops, input: current})
|
|
139
|
+
break
|
|
140
|
+
}
|
|
141
|
+
visited.add(cursor)
|
|
142
|
+
|
|
143
|
+
const sourceMapRecord = getSourceMapByFileName(sourceMaps, current.fileName)
|
|
144
|
+
if (!sourceMapRecord) {
|
|
145
|
+
if (traceHops.length === 0) appendUnresolvedHop({traceHops, input: current})
|
|
146
|
+
break
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const hop = resolveHopFromRecord(sourceMapRecord, current)
|
|
150
|
+
if (!hop) {
|
|
151
|
+
appendUnresolvedHop({traceHops, input: current, mapFileName: sourceMapRecord.mapFileName})
|
|
152
|
+
break
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
traceHops.push(hop)
|
|
156
|
+
|
|
157
|
+
if (isSameLocation(hop.output, current)) break
|
|
158
|
+
|
|
159
|
+
current = hop.output
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return traceHops
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface ResolveTraceBySourceMapsInput {
|
|
166
|
+
entry: TraceLocation
|
|
167
|
+
sourceMaps: SourceMapRegistry
|
|
168
|
+
orderedMapFileNames?: string[]
|
|
169
|
+
options?: ResolveTraceOptions
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const resolveTraceBySourceMaps = async (input: ResolveTraceBySourceMapsInput): Promise<TraceHop[]> => {
|
|
173
|
+
const {entry, sourceMaps, orderedMapFileNames = [], options = {}} = input
|
|
174
|
+
const maxDepth = options.maxDepth ?? MAX_TRACE_DEPTH
|
|
175
|
+
|
|
176
|
+
if (orderedMapFileNames.length > 0) return resolveTraceWithOrderedMaps({entry, sourceMaps, orderedMapFileNames, maxDepth})
|
|
177
|
+
|
|
178
|
+
return resolveTraceByFileLookup({entry, sourceMaps, maxDepth})
|
|
179
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {DEFAULT_CONTEXT_LINE_RADIUS} from "../base/constants";
|
|
2
|
+
import type {TraceLocation} from "../base/types";
|
|
3
|
+
|
|
4
|
+
export const toDisplayLocation = (location: TraceLocation): TraceLocation => ({...location})
|
|
5
|
+
|
|
6
|
+
const MAX_EXCERPT_CHARS = 12000
|
|
7
|
+
const COLUMN_WINDOW_CHARS = 4000
|
|
8
|
+
|
|
9
|
+
const truncateLineByColumn = (line: string, targetColumn = 0) => {
|
|
10
|
+
if (line.length <= MAX_EXCERPT_CHARS) return line
|
|
11
|
+
const halfWindow = Math.floor(COLUMN_WINDOW_CHARS / 2)
|
|
12
|
+
const start = Math.max(0, targetColumn - halfWindow)
|
|
13
|
+
const end = Math.min(line.length, start + COLUMN_WINDOW_CHARS)
|
|
14
|
+
const head = start > 0 ? "…[truncated]…" : ""
|
|
15
|
+
const tail = end < line.length ? "…[truncated]…" : ""
|
|
16
|
+
return `${head}${line.slice(start, end)}${tail}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const truncateExcerpt = (code: string) => {
|
|
20
|
+
if (code.length <= MAX_EXCERPT_CHARS) return code
|
|
21
|
+
return `${code.slice(0, MAX_EXCERPT_CHARS)}\n...[truncated ${code.length - MAX_EXCERPT_CHARS} chars]...`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CreateCodeExcerptInput {
|
|
25
|
+
fullCode: string
|
|
26
|
+
targetLine: number
|
|
27
|
+
radius?: number
|
|
28
|
+
targetColumn?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const createCodeExcerpt = (input: CreateCodeExcerptInput) => {
|
|
32
|
+
const {
|
|
33
|
+
fullCode,
|
|
34
|
+
targetLine,
|
|
35
|
+
radius = DEFAULT_CONTEXT_LINE_RADIUS,
|
|
36
|
+
targetColumn = 0,
|
|
37
|
+
} = input
|
|
38
|
+
const lines = fullCode.split(/\r?\n/)
|
|
39
|
+
if (lines.length === 0) {
|
|
40
|
+
return {code: truncateExcerpt(fullCode), highlightLine: targetLine}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const safeTarget = Math.min(Math.max(targetLine, 1), lines.length)
|
|
44
|
+
|
|
45
|
+
// 压缩后的单行源码可能非常大,仅截取目标列附近窗口。
|
|
46
|
+
if (lines.length === 1) {
|
|
47
|
+
return {code: truncateLineByColumn(lines[0], targetColumn), highlightLine: 1}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const start = Math.max(1, safeTarget - radius)
|
|
51
|
+
const end = Math.min(lines.length, safeTarget + radius)
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
code: truncateExcerpt(lines.slice(start - 1, end).join("\n")),
|
|
55
|
+
highlightLine: safeTarget - start + 1,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {readFile} from "node:fs/promises";
|
|
2
|
+
import {basename} from "node:path";
|
|
3
|
+
import {homedir} from "node:os";
|
|
4
|
+
import type {ChainMapSlot} from "@/node/trace/core/base/types";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_CONTEXT_LINE_RADIUS,
|
|
7
|
+
buildChainStateFromSlots,
|
|
8
|
+
validateResolveInput,
|
|
9
|
+
} from "../runner";
|
|
10
|
+
import type {ResolveTaskContext, ResolveTraceInput} from "./types";
|
|
11
|
+
|
|
12
|
+
const createChainSlotFromPath = async (filePath: string): Promise<ChainMapSlot> => {
|
|
13
|
+
const resolvedPath = filePath.startsWith("~/") ? `${homedir()}/${filePath.slice(2)}` : filePath
|
|
14
|
+
const mapFileName = basename(resolvedPath)
|
|
15
|
+
const slotId = `${mapFileName}-${Math.random().toString(36).slice(2, 8)}`
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const fileText = await readFile(resolvedPath, "utf8")
|
|
19
|
+
const rawSourceMap = JSON.parse(fileText)
|
|
20
|
+
if (!rawSourceMap || typeof rawSourceMap !== "object" || typeof rawSourceMap.mappings !== "string") {
|
|
21
|
+
throw new Error("invalid source-map json")
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
id: slotId,
|
|
25
|
+
mapFileName,
|
|
26
|
+
mapFilePath: resolvedPath,
|
|
27
|
+
sourceMapRecord: {rawSourceMap, mapFileName},
|
|
28
|
+
}
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const message = error instanceof Error ? error.message : "unknown error"
|
|
31
|
+
return {
|
|
32
|
+
id: slotId,
|
|
33
|
+
mapFileName,
|
|
34
|
+
mapFilePath: resolvedPath,
|
|
35
|
+
error: `${mapFileName}: ${message}`,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const createResolveContext = async (parsed: ResolveTraceInput): Promise<ResolveTaskContext> => {
|
|
41
|
+
// 单次请求内统一读取并校验 map 文件,避免重复 IO。
|
|
42
|
+
const chainSlots = await Promise.all(parsed.mapFilePaths.map(createChainSlotFromPath))
|
|
43
|
+
const chainState = buildChainStateFromSlots(chainSlots)
|
|
44
|
+
const validated = validateResolveInput({
|
|
45
|
+
entryFileName: parsed.entryFileName,
|
|
46
|
+
entryLine: parsed.entryLine,
|
|
47
|
+
entryColumn: parsed.entryColumn,
|
|
48
|
+
contextLineRadius: parsed.contextLineRadius || String(DEFAULT_CONTEXT_LINE_RADIUS),
|
|
49
|
+
mapCount: chainState.sourceMapFileNames.length,
|
|
50
|
+
})
|
|
51
|
+
if (!validated.ok) throw new Error(validated.message)
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
entry: validated.entry,
|
|
55
|
+
contextLineRadius: validated.contextLineRadius,
|
|
56
|
+
sourceMaps: chainState.sourceMaps,
|
|
57
|
+
sourceMapFileNames: chainState.sourceMapFileNames,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {performance} from "node:perf_hooks";
|
|
2
|
+
import {
|
|
3
|
+
buildTraceMetaOutput,
|
|
4
|
+
buildTraceSnippetOutput,
|
|
5
|
+
resolveTraceContextTask,
|
|
6
|
+
} from "../runner";
|
|
7
|
+
import {createResolveContext} from "./context";
|
|
8
|
+
import {
|
|
9
|
+
createValidationMetaOutput,
|
|
10
|
+
isInputValidationMessage,
|
|
11
|
+
parseResolveInput,
|
|
12
|
+
toFallbackEntry,
|
|
13
|
+
} from "./input";
|
|
14
|
+
import {
|
|
15
|
+
assertSnippetPayloadWithinLimit,
|
|
16
|
+
estimateSnippetPayloadBytes,
|
|
17
|
+
parseMaxSnippetPayloadBytes,
|
|
18
|
+
} from "./snippet-limit";
|
|
19
|
+
import type {ProfileResolveTraceOutput, TraceProfilePhase} from "./types";
|
|
20
|
+
|
|
21
|
+
const pushProfilePhase = (phases: TraceProfilePhase[], name: string, startedAt: number) => {
|
|
22
|
+
phases.push({name, durationMs: Number((performance.now() - startedAt).toFixed(2))})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const resolveTrace = async (payloadText: string) => {
|
|
26
|
+
const parsed = parseResolveInput(payloadText)
|
|
27
|
+
try {
|
|
28
|
+
const resolveContext = await createResolveContext(parsed)
|
|
29
|
+
const resolved = await resolveTraceContextTask(resolveContext)
|
|
30
|
+
return buildTraceMetaOutput(resolved)
|
|
31
|
+
} catch (error) {
|
|
32
|
+
const message = error instanceof Error ? error.message : "unknown error"
|
|
33
|
+
if (isInputValidationMessage(message)) return createValidationMetaOutput(message)
|
|
34
|
+
return {
|
|
35
|
+
traceData: [{input: toFallbackEntry(parsed)}],
|
|
36
|
+
resolvedSourceMeta: null,
|
|
37
|
+
canFetchSourceSnippet: false,
|
|
38
|
+
message: `source-map trace failed: ${message}`,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const getSourceSnippet = async (payloadText: string) => {
|
|
44
|
+
// 两阶段接口在无进程缓存模式下,第二阶段使用同一入参重新计算。
|
|
45
|
+
const parsed = parseResolveInput(payloadText)
|
|
46
|
+
const resolveContext = await createResolveContext(parsed)
|
|
47
|
+
const resolved = await resolveTraceContextTask(resolveContext)
|
|
48
|
+
const snippetOutput = buildTraceSnippetOutput({
|
|
49
|
+
contextLineRadius: resolveContext.contextLineRadius,
|
|
50
|
+
resolved,
|
|
51
|
+
})
|
|
52
|
+
assertSnippetPayloadWithinLimit(snippetOutput, parsed)
|
|
53
|
+
return snippetOutput
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const profileResolveTracePhases = async (payloadText: string): Promise<ProfileResolveTraceOutput> => {
|
|
57
|
+
const phases: TraceProfilePhase[] = []
|
|
58
|
+
const totalStart = performance.now()
|
|
59
|
+
|
|
60
|
+
const parseStart = performance.now()
|
|
61
|
+
const parsed = parseResolveInput(payloadText)
|
|
62
|
+
pushProfilePhase(phases, "parseResolveInput", parseStart)
|
|
63
|
+
|
|
64
|
+
const createContextStart = performance.now()
|
|
65
|
+
const resolveContext = await createResolveContext(parsed)
|
|
66
|
+
pushProfilePhase(phases, "createResolveContext", createContextStart)
|
|
67
|
+
|
|
68
|
+
const resolveTraceStart = performance.now()
|
|
69
|
+
const resolved = await resolveTraceContextTask(resolveContext)
|
|
70
|
+
pushProfilePhase(phases, "resolveTraceContextTask", resolveTraceStart)
|
|
71
|
+
|
|
72
|
+
const buildMetaStart = performance.now()
|
|
73
|
+
const metaOutput = buildTraceMetaOutput(resolved)
|
|
74
|
+
pushProfilePhase(phases, "buildTraceMetaOutput", buildMetaStart)
|
|
75
|
+
|
|
76
|
+
const buildSnippetStart = performance.now()
|
|
77
|
+
const snippetOutput = buildTraceSnippetOutput({
|
|
78
|
+
contextLineRadius: resolveContext.contextLineRadius,
|
|
79
|
+
resolved,
|
|
80
|
+
})
|
|
81
|
+
pushProfilePhase(phases, "buildTraceSnippetOutput", buildSnippetStart)
|
|
82
|
+
|
|
83
|
+
const estimateStart = performance.now()
|
|
84
|
+
const maxSnippetPayloadBytes = parseMaxSnippetPayloadBytes(parsed)
|
|
85
|
+
const snippetPayloadBytes = estimateSnippetPayloadBytes(snippetOutput)
|
|
86
|
+
pushProfilePhase(phases, "estimateSnippetPayloadBytes", estimateStart)
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
phases,
|
|
90
|
+
totalMs: Number((performance.now() - totalStart).toFixed(2)),
|
|
91
|
+
traceHopCount: metaOutput.traceData.length,
|
|
92
|
+
resolvedSourceMetaExists: Boolean(metaOutput.resolvedSourceMeta),
|
|
93
|
+
snippetPayloadBytes,
|
|
94
|
+
maxSnippetPayloadBytes,
|
|
95
|
+
snippetPayloadWouldExceedLimit: snippetPayloadBytes > maxSnippetPayloadBytes,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {DEFAULT_CONTEXT_LINE_RADIUS, type TraceMetaOutputState} from "../runner";
|
|
2
|
+
import type {ResolveTraceInput} from "./types";
|
|
3
|
+
|
|
4
|
+
const INPUT_VALIDATION_PATTERNS = ["请输入", "必须是", "未加载"]
|
|
5
|
+
|
|
6
|
+
export const parseResolveInput = (payloadText: string): ResolveTraceInput => {
|
|
7
|
+
const parsed = JSON.parse(payloadText) as ResolveTraceInput
|
|
8
|
+
if (!parsed || typeof parsed !== "object") throw new Error("invalid resolveTrace payload")
|
|
9
|
+
if (!parsed.entryFileName) throw new Error("missing argument: entryFileName")
|
|
10
|
+
if (!parsed.entryLine) throw new Error("missing argument: entryLine")
|
|
11
|
+
if (!parsed.entryColumn) throw new Error("missing argument: entryColumn")
|
|
12
|
+
if (!Array.isArray(parsed.mapFilePaths) || parsed.mapFilePaths.length === 0) {
|
|
13
|
+
throw new Error("missing argument: mapFilePaths")
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
...parsed,
|
|
17
|
+
contextLineRadius: parsed.contextLineRadius || String(DEFAULT_CONTEXT_LINE_RADIUS),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const isInputValidationMessage = (message: string) =>
|
|
22
|
+
INPUT_VALIDATION_PATTERNS.some((pattern) => message.includes(pattern))
|
|
23
|
+
|
|
24
|
+
export const toFallbackEntry = (parsed: ResolveTraceInput) => ({
|
|
25
|
+
fileName: parsed.entryFileName,
|
|
26
|
+
lineNumber: Number(parsed.entryLine) || 1,
|
|
27
|
+
columnNumber: Number(parsed.entryColumn) || 0,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export const createValidationMetaOutput = (message: string): TraceMetaOutputState => ({
|
|
31
|
+
traceData: [],
|
|
32
|
+
resolvedSourceMeta: null,
|
|
33
|
+
canFetchSourceSnippet: false,
|
|
34
|
+
message,
|
|
35
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type {TraceSnippetState} from "@/shared/trace-common";
|
|
2
|
+
import type {ResolveTraceInput} from "./types";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_MAX_SNIPPET_PAYLOAD_BYTES = 32 * 1024
|
|
5
|
+
export const SNIPPET_PAYLOAD_EXCEEDED_ERROR = "SOURCE_SNIPPET_PAYLOAD_EXCEEDED: 返回体超出限制"
|
|
6
|
+
const SNIPPET_PAYLOAD_OVERHEAD_BYTES = 512
|
|
7
|
+
|
|
8
|
+
export const parseMaxSnippetPayloadBytes = (input: ResolveTraceInput) => {
|
|
9
|
+
if (!input.maxSnippetPayloadBytes?.trim()) return DEFAULT_MAX_SNIPPET_PAYLOAD_BYTES
|
|
10
|
+
const parsed = Number(input.maxSnippetPayloadBytes)
|
|
11
|
+
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error("maxSnippetPayloadBytes 必须是大于 0 的整数。")
|
|
12
|
+
return parsed
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const estimateSnippetPayloadBytes = (snippet: TraceSnippetState) => {
|
|
16
|
+
const codeBytes = Buffer.byteLength(snippet.traceCode, "utf8")
|
|
17
|
+
const sourceFileBytes = snippet.resolvedSourceMeta?.sourceFile
|
|
18
|
+
? Buffer.byteLength(snippet.resolvedSourceMeta.sourceFile, "utf8")
|
|
19
|
+
: 0
|
|
20
|
+
const highlightBytes = snippet.traceHighlightLines.reduce((total, line) => {
|
|
21
|
+
return total + Buffer.byteLength(String(line), "utf8") + 1
|
|
22
|
+
}, 0)
|
|
23
|
+
return codeBytes + sourceFileBytes + highlightBytes + SNIPPET_PAYLOAD_OVERHEAD_BYTES
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const assertSnippetPayloadWithinLimit = (
|
|
27
|
+
snippet: TraceSnippetState,
|
|
28
|
+
input: ResolveTraceInput,
|
|
29
|
+
) => {
|
|
30
|
+
const maxSnippetPayloadBytes = parseMaxSnippetPayloadBytes(input)
|
|
31
|
+
const payloadBytes = estimateSnippetPayloadBytes(snippet)
|
|
32
|
+
if (payloadBytes > maxSnippetPayloadBytes) throw new Error(
|
|
33
|
+
`${SNIPPET_PAYLOAD_EXCEEDED_ERROR} (${payloadBytes} bytes > ${maxSnippetPayloadBytes} bytes)`,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type {SourceMapRegistry, TraceHop} from "@/node/trace/core/base/types";
|
|
2
|
+
import type {TraceLocation} from "@/shared/trace-common";
|
|
3
|
+
import type {ResolveTraceInput} from "@/shared/trace-contract";
|
|
4
|
+
export type {ResolveTraceInput};
|
|
5
|
+
|
|
6
|
+
export interface ResolveTaskContext {
|
|
7
|
+
entry: TraceLocation
|
|
8
|
+
contextLineRadius: number
|
|
9
|
+
sourceMaps: SourceMapRegistry
|
|
10
|
+
sourceMapFileNames: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ResolvedTraceContext {
|
|
14
|
+
traceChain: TraceHop[]
|
|
15
|
+
sourceCode: {
|
|
16
|
+
code: string
|
|
17
|
+
sourceFile: string
|
|
18
|
+
lineNumber: number
|
|
19
|
+
columnNumber: number
|
|
20
|
+
} | null
|
|
21
|
+
lastResolved: TraceLocation | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TraceProfilePhase {
|
|
25
|
+
name: string
|
|
26
|
+
durationMs: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ProfileResolveTraceOutput {
|
|
30
|
+
phases: TraceProfilePhase[]
|
|
31
|
+
totalMs: number
|
|
32
|
+
traceHopCount: number
|
|
33
|
+
resolvedSourceMetaExists: boolean
|
|
34
|
+
snippetPayloadBytes: number
|
|
35
|
+
maxSnippetPayloadBytes: number
|
|
36
|
+
snippetPayloadWouldExceedLimit: boolean
|
|
37
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {MAX_TRACE_DEPTH} from "@/node/trace/core/base/constants";
|
|
2
|
+
import {buildRegistryFromChainSlots, getAutoEntryFileNameFromSlots} from "@/node/trace/core/domain/chain-slots";
|
|
3
|
+
import {resolveSourceCodeFromTrace, getLastResolvedOutput} from "@/node/trace/core/domain/source-content";
|
|
4
|
+
import {resolveTraceBySourceMaps} from "@/node/trace/core/domain/trace-resolver";
|
|
5
|
+
import {toDisplayLocation, createCodeExcerpt} from "@/node/trace/core/domain/view-model";
|
|
6
|
+
import type {
|
|
7
|
+
ChainMapSlot,
|
|
8
|
+
SourceMapRegistry,
|
|
9
|
+
TraceHop,
|
|
10
|
+
} from "@/node/trace/core/base/types";
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_CONTEXT_LINE_RADIUS,
|
|
13
|
+
createInitialOutputState,
|
|
14
|
+
patchOutputMessage,
|
|
15
|
+
validateResolveInput,
|
|
16
|
+
} from "@/shared/trace-common";
|
|
17
|
+
import type {
|
|
18
|
+
TraceMetaOutputState as SharedTraceMetaOutputState,
|
|
19
|
+
TraceOutputState as SharedTraceOutputState,
|
|
20
|
+
TraceSnippetState,
|
|
21
|
+
} from "@/shared/trace-common";
|
|
22
|
+
import type {ResolvedTraceContext, ResolveTaskContext} from "./resolve/types";
|
|
23
|
+
|
|
24
|
+
export type TraceOutputState = SharedTraceOutputState<TraceHop>
|
|
25
|
+
export type TraceMetaOutputState = SharedTraceMetaOutputState<TraceHop>
|
|
26
|
+
|
|
27
|
+
export interface SourceMapChainState {
|
|
28
|
+
sourceMaps: SourceMapRegistry
|
|
29
|
+
sourceMapFileNames: string[]
|
|
30
|
+
chainMapSlots: ChainMapSlot[]
|
|
31
|
+
entryFileName: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface BuildTraceSnippetOutputInput {
|
|
35
|
+
contextLineRadius: number
|
|
36
|
+
resolved: ResolvedTraceContext
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const buildChainStateFromSlots = (slots: ChainMapSlot[]): SourceMapChainState => {
|
|
40
|
+
const {registry, orderedMapFileNames} = buildRegistryFromChainSlots(slots)
|
|
41
|
+
return {
|
|
42
|
+
sourceMaps: registry,
|
|
43
|
+
sourceMapFileNames: orderedMapFileNames,
|
|
44
|
+
chainMapSlots: slots,
|
|
45
|
+
entryFileName: getAutoEntryFileNameFromSlots(slots),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const resolveTraceContextTask = async (context: ResolveTaskContext): Promise<ResolvedTraceContext> => {
|
|
50
|
+
const traceChain = await resolveTraceBySourceMaps({
|
|
51
|
+
entry: context.entry,
|
|
52
|
+
sourceMaps: context.sourceMaps,
|
|
53
|
+
orderedMapFileNames: context.sourceMapFileNames,
|
|
54
|
+
options: {maxDepth: MAX_TRACE_DEPTH},
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const sourceCode = resolveSourceCodeFromTrace(traceChain, context.sourceMaps, {
|
|
58
|
+
onlyLastMap: true,
|
|
59
|
+
lastMapFileName: context.sourceMapFileNames[context.sourceMapFileNames.length - 1],
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
traceChain,
|
|
64
|
+
sourceCode,
|
|
65
|
+
lastResolved: getLastResolvedOutput(traceChain),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const buildTraceMetaOutput = (resolved: ResolvedTraceContext): TraceMetaOutputState => {
|
|
70
|
+
if (resolved.sourceCode) {
|
|
71
|
+
// 第一阶段只返回元信息;大段源码由第二阶段接口按需获取。
|
|
72
|
+
return {
|
|
73
|
+
traceData: resolved.traceChain,
|
|
74
|
+
resolvedSourceMeta: {
|
|
75
|
+
sourceFile: resolved.sourceCode.sourceFile,
|
|
76
|
+
lineNumber: resolved.sourceCode.lineNumber,
|
|
77
|
+
columnNumber: resolved.sourceCode.columnNumber,
|
|
78
|
+
},
|
|
79
|
+
canFetchSourceSnippet: true,
|
|
80
|
+
message: "已定位到源码位置,开始加载源码片段。",
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (resolved.lastResolved) {
|
|
85
|
+
const display = toDisplayLocation(resolved.lastResolved)
|
|
86
|
+
return {
|
|
87
|
+
traceData: resolved.traceChain,
|
|
88
|
+
resolvedSourceMeta: {
|
|
89
|
+
sourceFile: display.fileName,
|
|
90
|
+
lineNumber: display.lineNumber,
|
|
91
|
+
columnNumber: display.columnNumber,
|
|
92
|
+
},
|
|
93
|
+
canFetchSourceSnippet: false,
|
|
94
|
+
message: "当前 map 不包含 sourcesContent,无法直接渲染源码内容。",
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
traceData: resolved.traceChain,
|
|
100
|
+
resolvedSourceMeta: null,
|
|
101
|
+
canFetchSourceSnippet: false,
|
|
102
|
+
message: "未命中映射,未定位到源码位置。",
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const buildTraceSnippetOutput = (input: BuildTraceSnippetOutputInput): TraceSnippetState => {
|
|
107
|
+
const {contextLineRadius, resolved} = input
|
|
108
|
+
if (resolved.sourceCode) {
|
|
109
|
+
const excerpt = createCodeExcerpt({
|
|
110
|
+
fullCode: resolved.sourceCode.code,
|
|
111
|
+
targetLine: resolved.sourceCode.lineNumber,
|
|
112
|
+
radius: contextLineRadius,
|
|
113
|
+
targetColumn: resolved.sourceCode.columnNumber,
|
|
114
|
+
})
|
|
115
|
+
return {
|
|
116
|
+
traceCode: excerpt.code,
|
|
117
|
+
resolvedSourceMeta: {
|
|
118
|
+
sourceFile: resolved.sourceCode.sourceFile,
|
|
119
|
+
lineNumber: resolved.sourceCode.lineNumber,
|
|
120
|
+
columnNumber: resolved.sourceCode.columnNumber,
|
|
121
|
+
},
|
|
122
|
+
traceHighlightLines: [excerpt.highlightLine],
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (resolved.lastResolved) {
|
|
127
|
+
const display = toDisplayLocation(resolved.lastResolved)
|
|
128
|
+
return {
|
|
129
|
+
traceCode: [
|
|
130
|
+
`已定位到源码位置:${display.fileName}:${display.lineNumber}:${display.columnNumber}`,
|
|
131
|
+
"当前 map 不包含 sourcesContent,无法直接渲染源码内容。",
|
|
132
|
+
].join("\n"),
|
|
133
|
+
resolvedSourceMeta: {
|
|
134
|
+
sourceFile: display.fileName,
|
|
135
|
+
lineNumber: display.lineNumber,
|
|
136
|
+
columnNumber: display.columnNumber,
|
|
137
|
+
},
|
|
138
|
+
traceHighlightLines: [],
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
traceCode: "未命中映射,未定位到源码位置。",
|
|
144
|
+
resolvedSourceMeta: null,
|
|
145
|
+
traceHighlightLines: [],
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export {DEFAULT_CONTEXT_LINE_RADIUS, createInitialOutputState, patchOutputMessage, validateResolveInput}
|