forked from googleapis/cloud-debug-nodejs
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathsourcemapper.ts
348 lines (312 loc) · 12.1 KB
/
sourcemapper.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
// Copyright 2016 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as fs from 'fs';
import pLimit = require('p-limit');
import * as path from 'path';
import {promisify} from 'util';
import * as sourceMap from 'source-map';
import {Logger} from '../config';
import {findScriptsFuzzy} from '../util/utils';
const CONCURRENCY = 10;
const WEBPACK_PREFIX = 'webpack://';
const readFilep = promisify(fs.readFile);
/** @define {string} */ const MAP_EXT = '.map';
/** Represents one source map file. */
export interface MapInfoInput {
// The path of generated output file in the source map. For example, if
// "src/index1.ts" and "src/index2.ts" are generated into "dist/index.js" and
// "dist/index.js.map", then this field is "dist/index.js (relative to the
// process's working directory).
outputFile: string;
// The path of the original source file (relative to the process's working
// directory). In the example above, this will be either "src/index1.ts" or
// "src/index2.ts".
inputFile: string;
// The source map's path (relative to the process's working directory). For
// the same example above, this field is "dist/index.js.map".
mapFile: string;
// The SourceMapConsumer object after parsing the content in the mapFile.
mapConsumer: sourceMap.SourceMapConsumer;
// The original sources in the source map. Each value is relative to the
// source map file. For the same example above, this field is
// ['../src/index1.ts', '../src/index2.ts']. the sources are in ascending
// order from shortest to longest.
sources: string[];
}
export interface MapInfoOutput {
file: string;
line: number;
column?: number;
}
export class MultiFileMatchError implements Error {
readonly name = 'MultiFileMatchError';
readonly message = 'Error: matching multiple files';
constructor(readonly files: string[]) {}
}
/**
* @param {!Map} infoMap The map that maps input source files to
* SourceMapConsumer objects that are used to calculate mapping information
* @param {string} mapPath The path to the sourcemap file to process. The
* path should be relative to the process's current working directory
* @private
*/
async function processSourcemap(
infoMap: Map<string, MapInfoInput>,
mapPath: string
) {
// this handles the case when the path is undefined, null, or
// the empty string
if (!mapPath || !mapPath.endsWith(MAP_EXT)) {
throw new Error(`The path ${mapPath} does not specify a sourcemap file`);
}
mapPath = path.normalize(mapPath);
let rawSourceMapString;
try {
rawSourceMapString = await readFilep(mapPath, 'utf8');
} catch (e) {
throw new Error('Could not read sourcemap file ' + mapPath + ': ' + e);
}
let rawSourceMap;
try {
rawSourceMap = JSON.parse(rawSourceMapString);
} catch (e) {
throw new Error('Could not parse the raw sourcemap ' + mapPath + ': ' + e);
}
let consumer: sourceMap.SourceMapConsumer;
try {
consumer = await new sourceMap.SourceMapConsumer(rawSourceMapString);
} catch (e) {
throw new Error(
'An error occurred while reading the ' +
'sourcemap file ' +
mapPath +
': ' +
e
);
}
/*
* If the sourcemap file defines a "file" attribute, use it as
* the output file where the path is relative to the directory
* containing the map file. Otherwise, use the name of the output
* file (with the .map extension removed) as the output file.
*/
const outputBase = rawSourceMap.file
? rawSourceMap.file
: path.basename(mapPath, '.map');
const parentDir = path.dirname(mapPath);
const outputPath = path.normalize(path.join(parentDir, outputBase));
// The paths of the sources that are relative to the source map file. Sort
// them in ascending order from shortest to longest.
// For webpack file path, normalize the path after the webpack prefix so that
// the source map library can recognize it.
const sourcesRelToSrcmap = rawSourceMap.sources
.filter((val: string) => !!val)
.map((val: string) => {
if (val.toLowerCase().startsWith(WEBPACK_PREFIX)) {
return (
WEBPACK_PREFIX +
path.normalize(val.substr(WEBPACK_PREFIX.length)).replace(/\\/g, '/')
);
}
return val;
})
.sort((src1: string, src2: string) => src1.length - src2.length);
// The paths of the sources that are relative to the current process's working
// directory. These are the ones that are used for the fuzzy search (thus are
// platform specific, e.g. using '\\' on Windows and using '/' in Unix, etc.).
// For webpack file path, the prefix is filtered out for better fuzzy search
// result.
const normalizedSourcesRelToProc = sourcesRelToSrcmap
.map((src: string) => {
if (src.toLowerCase().startsWith(WEBPACK_PREFIX)) {
return src.substring(WEBPACK_PREFIX.length);
}
return src;
})
.map((relPath: string) => {
// resolve the paths relative to the map file so that they are relative to
// the process's current working directory
return path.normalize(path.join(parentDir, relPath));
});
for (const src of normalizedSourcesRelToProc) {
const inputFile = path.normalize(src);
infoMap.set(inputFile, {
outputFile: outputPath,
inputFile,
mapFile: mapPath,
mapConsumer: consumer,
sources: sourcesRelToSrcmap,
});
}
}
export class SourceMapper {
/** Maps each original source path to the corresponding source map info. */
infoMap: Map<string, MapInfoInput>;
constructor(readonly logger: Logger) {
this.infoMap = new Map();
}
/**
* Used to get the information about the transpiled file from a given input
* source file provided there isn't any ambiguity with associating the input
* path to exactly one output transpiled file.
*
* If there are more than one matches, throw the error to include all the
* matched candidates.
*
* If there is no such mapping, it could be because the input file is not
* the input to a transpilation process or it is the input to a transpilation
* process but its corresponding .map file was not given to the constructor of
* this mapper.
*
* @param inputPath The path to an input file that could possibly be the input
* to a transpilation process.
* The path can be relative to the process's current working directory.
* @return The `MapInfoInput` object that describes the transpiled file
* associated with the specified input path. `null` is returned if there is
* no files that are associated with the input path.
*/
getMapInfoInput(inputPath: string): MapInfoInput | null {
if (this.infoMap.has(path.normalize(inputPath))) {
return this.infoMap.get(inputPath) as MapInfoInput;
}
const matches = findScriptsFuzzy(
inputPath,
Array.from(this.infoMap.keys())
);
this.logger.debug(`sourcemapper fuzzy matches: ${matches}`);
if (matches.length === 1) {
return this.infoMap.get(matches[0]) as MapInfoInput;
}
if (matches.length > 1) {
throw new MultiFileMatchError(matches);
}
return null;
}
/**
* @param {number} The line number in the input file where the line number is
* zero-based.
* @param {number} (Optional) The column number in the line of the file
* specified where the column number is zero-based.
* @param {string} The entry of the source map info in the sourceMapper. Such
* an entry is supposed to be got by the getMapInfoInput method.
*
* @return {Object} The object returned has a "file" attribute for the
* path of the output file associated with the given input file (where the
* path is relative to the process's current working directory),
* a "line" attribute of the line number in the output file associated with
* the given line number for the input file, and an optional "column" number
* of the column number of the output file associated with the given file
* and line information.
*
* If the given input file does not have mapping information associated
* with it then null is returned.
*/
getMapInfoOutput(
lineNumber: number,
colNumber: number,
entry: MapInfoInput
): MapInfoOutput | null {
this.logger.debug(`sourcemapper entry.inputFile: ${entry.inputFile}`);
const relPath = path
.relative(path.dirname(entry.mapFile), entry.inputFile)
.replace(/\\/g, '/');
/**
* Note: Since `entry.sources` is in ascending order from shortest
* to longest, the first source path that ends with the
* relative path is necessarily the shortest source path
* that ends with the relative path.
*/
let source: string | undefined;
for (const src of entry.sources) {
if (src.endsWith(relPath)) {
source = src;
break;
}
}
const sourcePos = {
source: source || relPath,
line: lineNumber + 1, // the SourceMapConsumer expects the line number
// to be one-based but expects the column number
column: colNumber, // to be zero-based
};
this.logger.debug(`sourcemapper sourcePos: ${JSON.stringify(sourcePos)}`);
const allPos = entry.mapConsumer.allGeneratedPositionsFor(sourcePos);
/*
* Based on testing, it appears that the following code is needed to
* properly get the correct mapping information.
*
* In particular, the generatedPositionFor() alone doesn't appear to
* give the correct mapping information.
*/
const mappedPos: sourceMap.NullablePosition =
allPos && allPos.length > 0
? allPos.reduce((accumulator, value) => {
return (value.line ?? 0) < (accumulator.line ?? 0)
? value
: accumulator;
})
: entry.mapConsumer.generatedPositionFor(sourcePos);
this.logger.debug(`sourcemapper mappedPos: ${JSON.stringify(mappedPos)}`);
return {
file: entry.outputFile,
line: (mappedPos.line ?? 0) - 1, // convert the one-based line numbers returned
// by the SourceMapConsumer to the expected
// zero-based output.
// TODO: The `sourceMap.Position` type definition has a `column`
// attribute and not a `col` attribute. Determine if the type
// definition or this code is correct.
column: (mappedPos as {} as {col: number}).col, // SourceMapConsumer uses
// zero-based column
// numbers which is the
// same as the expected
// output
};
}
/** Prints the debugging information of the source mapper to the logger. */
debug() {
this.logger.debug('Printing source mapper debugging information ...');
for (const [key, value] of this.infoMap) {
this.logger.debug(` source ${key}:`);
this.logger.debug(` outputFile: ${value.outputFile}`);
this.logger.debug(` mapFile: ${value.mapFile}`);
this.logger.debug(` sources: ${value.sources}`);
}
}
}
/**
* @param {Array.<string>} sourcemapPaths An array of paths to .map sourcemap
* files that should be processed. The paths should be relative to the
* current process's current working directory
* @param {Logger} logger A logger that reports errors that occurred while
* processing the given sourcemap files
*/
export async function create(
sourcemapPaths: string[],
logger: Logger
): Promise<SourceMapper> {
const limit = pLimit(CONCURRENCY);
const mapper = new SourceMapper(logger);
const promises = sourcemapPaths.map(path =>
limit(() => processSourcemap(mapper.infoMap, path))
);
try {
await Promise.all(promises);
} catch (err) {
throw new Error(
'An error occurred while processing the sourcemap files' + err
);
}
mapper.debug();
return mapper;
}