CDP篇:自动跟踪网络请求堆栈

161 阅读4分钟

1 前言

大家好,我是心锁。

在第一篇,我们完成了用 chrome devtools 来监听 nodejs 程序发起的 http/https 请求的壮举。但是现在遗留了一些问题需要我们处理,比如本期,我们需要完善我们的库,使得其支持通过 network 详情中 Initiator 这个 tab 显示调用请求的源地址

那么,阅读本期,你会学习到:

  1. 更完善的 Network 相关 CDP 协议
  2. 和 Network 息息相关的 Debugger 模块的 CDP 协议
  3. Sourcemap 的知识

2 要做什么?

initiator 标签下的信息目前存在缺失,大概率是我们没有往 Devtools 推送它需要的上下文数据,所以我们要找到根因,并主动处理。

当然——看过第一篇的朋友应该易得 requestWillBeSentinitiator 有问题

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 页面,并不能主动读取系统文件,这件事情需要我们自己来做。说难不难,说简单不简单,我们接下来只需要能得到每一个 filePathid 将其作为 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.scriptParsedDebugger.getScriptSource ,前者用于在 Source 标签页展示文件目录,后者用于返回某个 scriptId 的文件内容。
  • Sourcemap 对 devtools 来说并非一个自动的过程,文件尾部注释的 sourcemap url 需要我们手动为 devtools 加载