1 前言
大家好,我是心锁。
在第一篇,我们完成了用 chrome devtools
来监听 nodejs
程序发起的 http/https
请求的壮举。但是现在遗留了一些问题需要我们处理,比如本期,我们需要完善我们的库,使得其支持通过 network 详情中 Initiator 这个 tab 显示调用请求的源地址。
那么,阅读本期,你会学习到:
- 更完善的 Network 相关 CDP 协议
- 和 Network 息息相关的 Debugger 模块的 CDP 协议
- Sourcemap 的知识
2 要做什么?
initiator 标签下的信息目前存在缺失,大概率是我们没有往 Devtools 推送它需要的上下文数据,所以我们要找到根因,并主动处理。
当然——看过第一篇的朋友应该易得 requestWillBeSent
的 initiator
有问题
2.1 解析堆栈和过滤堆栈
目前的代码是:
{
initiator: {
type: "other"
}
}
既然没有包含任何数据结构,自然也不会有足够的堆栈信息。
实际上 initiator
的数据结构如下:
{
type: string
stack: {
callFrames: {
columnNumber: number
functionName: string
lineNumber: number
url: string
scriptId?: string
}[]
}
}
其中 type
我们填写 script
就可以,代表这是一个 JS 文件;而后边的 stack 部分则是堆栈结构。
在 nodejs 中,我们可以通过 Error.captureStackTrace
来得到当前的堆栈信息:
export function getStackFrames(_stack?: string) {
const e = Object.create(null)
if (_stack) {
e.stack = _stack
} else {
Error.stackTraceLimit = Infinity
Error.captureStackTrace(e)
}
const stack = e.stack
const frames = stack
.split('\n')
.slice(1)
.map((frame: string) => new CallSite(frame))
return frames
}
由于现在 npm 上进行堆栈处理的模块已经很老了,而且结构和我们也不太一样,我们可以自己来完成这一部分,也就是对应上边 CallSite
类的实现:
export class CallSite {
fileName?: string;
lineNumber?: number;
functionName?: string;
typeName?: string;
methodName?: string;
columnNumber?: number;
native?: boolean;
constructor(site: string | CallSite) {
if (typeof site === "string") {
this.parse(site);
}
if (site instanceof CallSite) {
Object.assign(this, site);
}
}
parse(line: string) {
if (line.match(/^\s*[-]{4,}$/)) {
this.fileName = line;
return this;
}
const lineReg = /at (?:(.+?)\s+\()?(?:(.+?):(\d+)(?::(\d+))?|([^)]+))\)?/;
const lineMatch = line.match(lineReg);
if (!lineMatch) {
return this;
}
let object = null;
let method = null;
let functionName = null;
let typeName = null;
let methodName = null;
let isNative = lineMatch[5] === "native";
if (lineMatch[1]) {
functionName = lineMatch[1];
let methodStart = functionName.lastIndexOf(".");
if (functionName[methodStart - 1] == ".") methodStart--;
if (methodStart > 0) {
object = functionName.substr(0, methodStart);
method = functionName.substr(methodStart + 1);
const objectEnd = object.indexOf(".Module");
if (objectEnd > 0) {
functionName = functionName.substr(objectEnd + 1);
object = object.substr(0, objectEnd);
}
}
}
if (method) {
typeName = object;
methodName = method;
}
if (method === "<anonymous>") {
methodName = null;
functionName = null;
}
const properties = {
fileName: lineMatch[2] || null,
lineNumber: parseInt(lineMatch[3], 10) || null,
functionName: functionName,
typeName: typeName,
methodName: methodName,
columnNumber: parseInt(lineMatch[4], 10) || null,
native: isNative,
};
Object.assign(this, properties);
return this;
}
valueOf() {
return {
fileName: this.fileName,
lineNumber: this.lineNumber,
functionName: this.functionName,
typeName: this.typeName,
methodName: this.methodName,
columnNumber: this.columnNumber,
native: this.native,
};
}
toString() {
return JSON.stringify(this.valueOf());
}
}
Callsite
,其实对应的就是堆栈中的每一行,其中包含了 fileName、lineNumber、functionName、typeName、methodName、columnNumber、native 等信息。
一般的堆栈还好,格式基本一致,但是我们其实很容易看到一些不太符合预期的堆栈,比如这份:
这是在浏览器控制台生成的堆栈,所以看不到函数名(或者说,函数名是<anonymous>
,即匿名函数),也看不到文件位置。
对此,我们本来要考虑很多情况。但我们站在了前人的肩膀上。社区为我们留下了一份宝藏正则:
/at (?:(.+?)\s+\()?(?:(.+?):(\d+)(?::(\d+))?|([^)]+))\)?/
这份正则我们通过可视化工具可以看到,其中的每一个 Group ,都可以代表匹配出来的数据。
结合这个 demo 堆栈来看:
const lineReg = /at (?:(.+?)\s+\()?(?:(.+?):(\d+)(?::(\d+))?|([^)]+))\)?/;
`at HTMLDocument.dt (main.js:4502:6449)`.match(lineReg)
我们可以知道:
- group[1] 代表 functionName。即函数名称
- group[2] 代表 fileName。即文件名称,但可能为空。
- group[3] 代表 lineNumber。即调用的函数在对应文件第几行。
- group[4] 代表 columnNumber。即调用的函数在对应文件某一行的第几列。
那么,完成了这些步骤之后,我们得到了一份 stack frames
,但是这份数据还不能被实际使用,因为它包含了很多对调试没有意义的堆栈信息。
我的处理方式是手动建立了一个 ignoreList
,在实际使用时,我们会忽略堆栈关于 node_modules
、node 部分原生模块的部分。
const ignoreList = [
/* node:async_hooks */
/\((internal\/)?async_hooks\.js:/,
/* external */
/\(\//,
/* node_modules */
/node_modules/
]
export function initiatorStackPipe(sites: CallSite[]) {
const frames = sites.filter((site) => {
return !ignoreList.some((reg) => reg.test(site.fileName || ''))
})
return frames
}
完成了这些步骤,我们再看到 devtools 的话,已经可以看到对应的堆栈信息了。
但是其中的每一个堆栈地址都无法点击,这虽然已经有效了,但仍不符合我们的预期。而要解决这个问题,其实和我们前边忽略的 scriptId
有关系。
2.2 通过 Debugger 协议来建立跟踪能力
我们之所以没办法做到「点击跳转」,根本原因就是 devtools
无法基于 scriptId
拿到文件内容。
因为 devtools
本质是一个 Web 页面,并不能主动读取系统文件,这件事情需要我们自己来做。说难不难,说简单不简单,我们接下来只需要能得到每一个 filePath
的 id
将其作为 scriptId
使用,然后在用户点击某个 file
路径时,用其scriptId
能成功换取到对应的文件内容,那么这件事就算成了。
——简述,第一步,我们要建设「目录↔scriptId 」的双向映射表。
双向 hashmap
是 js 没有默认提供的能力,我们可以自己实现一份:
export class ScriptMap {
private urlToScriptId: Map<string, string>
private scriptIdToUrl: Map<string, string>
constructor() {
this.urlToScriptId = new Map<string, string>()
this.scriptIdToUrl = new Map<string, string>()
}
public addMapping(filePath: string, scriptId: string) {
this.urlToScriptId.set(filePath, scriptId)
this.scriptIdToUrl.set(scriptId, filePath)
}
public getUrlByScriptId(scriptId: string) {
return this.scriptIdToUrl.get(scriptId)
}
public getScriptIdByUrl(url: string) {
return this.urlToScriptId.get(url)
}
}
第二步,我们需要遍历文件夹,做两件事,一件是用上边的 ScriptMap
建立双向映射,另一件是向 devtools
推送命令,让 devtools
知道我们有哪些文件。
根据 CDP 协议,我们需要通过 Debugger.scriptParsed
指令来让 devtools 知道存在哪些 script
文件。
而此时需要的数据结构是:
{
url: string;
scriptLanguage: string;
embedderName: string;
scriptId: string;
sourceMapURL: string;
hasSourceURL: boolean;
}
关于 sourceMap 的部分,我们可以晚点处理,而前边四个是我们需要现在关注的。其中 script langauage,目前只支持 JavaScript 和 WebAssembly:
function getScriptLangByFileName(fileName: string) {
const extension = fileName.split('.').pop()?.toLowerCase()
switch (extension) {
case 'js':
case 'mjs':
case 'cjs':
return 'JavaScript'
case 'wasm':
return 'WebAssembly'
default:
return 'Unknown'
}
}
embedderName
代表 href, 通过 pathToFileURL
我们可以拿到一个 URL
对象,而 fileUrl.href
就是 embedderName
。
知道了这些之后,我们就可以得到这样的遍历代码:
function traverseDirToMap(directoryPath: string, ignoreList: string[] = ['node_modules']) {
const scriptList = []
const stack = [directoryPath]
let scriptId = this.scriptIdCounter
while (stack.length > 0) {
const currentPath = stack.pop()!
const items = fs.readdirSync(currentPath)
for (const item of items) {
if (ignoreList.includes(item)) {
continue
}
const fullPath = path.join(currentPath, item)
const stats = fs.statSync(fullPath)
if (stats.isDirectory()) {
stack.push(fullPath)
} else {
const resolvedPath = path.resolve(fullPath)
const fileUrl = pathToFileURL(resolvedPath)
const scriptIdStr = `${++scriptId}`
const url = fileUrl.href
const scriptLanguage = getScriptLangByFileName(url)
scriptList.push({
url,
scriptLanguage,
embedderName: fileUrl.href,
scriptId: scriptIdStr,
sourceMapURL: '',
hasSourceURL: false
})
this.scriptMap.addMapping(url, scriptIdStr)
}
}
}
this.scriptIdCounter += scriptList.length
return scriptList
}
这份代码会为我们产出 Debugger.scriptParsed
需要的数据结构,我们只需要将这份列表中的每一项发送给 devtools,就能看到原本空荡荡的 source 标签页下出现了一系列文件
但是当我们点击文件,此时其实拿不到文件内容,我们看到 monitor 会发现有 Debugger.getScriptSource
的请求我们没有响应。
我们尝试在代码中完成这一部分的编写,这个时候就可以看到返回了。
export const debuggerPlugin = createPlugin(({ devtool, core }) => {
useHandler<ScriptSourceData>('Debugger.getScriptSource', ({ id, data }) => {
const { scriptId } = data
const scriptSource = core.resourceService.getScriptSource(scriptId)
devtool.send({
id: id,
method: 'Debugger.getScriptSourceResponse',
result: {
scriptSource
}
})
})
const scriptList = core.resourceService.getLocalScriptList()
scriptList.forEach((script) => {
devtool.send({
method: 'Debugger.scriptParsed',
params: script
})
})
})
现在我们再看到我们的 network 界面,终于可以看到堆栈文件点亮了~
2.3 为 JS 文件添加 Sourcemap
虽说似乎一切都跑起来了,但是一个令人头疼的问题是我们的 JS 文件拿到之后成了乱码。
这样的话,我们首先要了解一下 devtools 加载 sourcemap 的规则,这里其实存在一个误区。
可以看到上图,在 JS 文件的底部存在一行注释。一般来说,我们知道的规律是如果我们在一个脚本的最后几行添加上 //# sourceMappingURL=
的注释之后 devtools 就能找到对应的源文件。
但实际上,这个因果是反过来的,这个过程并不是 devtools 帮忙处理的,而是需要我们自己处理的。这一行注释,与其说是给 devtools 看的,不如说是给我们开发者自己看的。
这一行约定注释,需要我们自己解析出来地址,然后提供给 devtools,当然由于涉及文件读取,我们要注意做好文件读取优化。
/**
* @description Read the last lines of the file
* @param filePath
* @param stat
* @param totalLines
* @returns string
*/
function readLastLine(filePath: string, stat: fs.Stats, totalLines = 1) {
const fileSize = stat.size
const chunkSize = Math.min(1024, fileSize)
let startPos = fileSize - chunkSize
let buffer = Buffer.alloc(chunkSize)
let lines: string[] = []
const fd = fs.openSync(filePath, 'r')
while (lines.length < totalLines && startPos >= 0) {
fs.readSync(fd, buffer, 0, chunkSize, startPos)
const chunk = buffer.toString('utf8')
lines = chunk.split('\n').concat(lines)
startPos -= chunkSize
if (startPos < 0) {
startPos = 0
buffer = Buffer.alloc(fileSize - startPos)
}
}
fs.closeSync(fd)
// Return the last `totalLines` lines
return lines.slice(-totalLines).join('\n')
}
在这份代码中,我们通过分块读取和倒序读取,确保在读取大文件时的性能不会爆炸。
let sourceMapURL = ''
if (/\.(js|ts)$/.test(resolvedPath)) {
const lastChunkCode = this.readLastLine(fullPath, stats, 2)
const sourceMapFilePathMatch = lastChunkCode.match(/sourceMappingURL=(.+)$/m)?.[1] ?? ''
sourceMapURL = sourceMapFilePathMatch
? sourceMapFilePathMatch.startsWith('data:')
? // inline sourcemap
sourceMapFilePathMatch
: // file path
pathToFileURL(path.join(currentPath, sourceMapFilePathMatch)).href
: ''
}
const url = fileUrl.href
...
{
...
sourceMapURL: sourceMapURL,
hasSourceURL: Boolean(sourceMapURL)
}
然后,我们将其加入到Debugger.scriptParsed
中,再来看看效果~会发现原本的乱码文件变成了我们的 ts 文件。
3 总结
☝️一个有趣的事情是,在我早期进行技术方案选型时,考虑了包括 dc 通道、网络代理以及目前的拦截。
拦截其实是最容易实现的方案之一,但是如果不是我们采用了拦截的方式,那么反而会因为丢失上下文,很难去完善 devtools
更多的信息。
比如,本篇涉及的 initiator
能力,在 node 官方目前提供的网络检测能力中即是无法实现的,因为 dc 通道想拿到堆栈信息略困难。
那么总之,阅读本篇,希望你能收获:
- 原来 Network Scope 域的 CDP 协议会通过 scriptId 和 Debugger Scope 域进行交互
Debugger.scriptParsed
和Debugger.getScriptSource
,前者用于在 Source 标签页展示文件目录,后者用于返回某个 scriptId 的文件内容。- Sourcemap 对 devtools 来说并非一个自动的过程,文件尾部注释的 sourcemap url 需要我们手动为 devtools 加载