Skip to content
tuling-python-poppiesPublic

About

No description, website, or topics provided.

Resources

Stars

18 stars

Watchers

0 watching

Forks

Repository files navigation

NV8 — Node 原生的 Edge 浏览器运行时模拟与 Web 采集框架

图灵课堂 node Edge dependencies platform license Gitee GitHub

NV8 是一个零依赖的运行时:它在 Node 进程里重建一个面向协议逆向的高保真 Edge-like JavaScript 执行环境,用来运行目标站点的前端代码,从中恢复请求签名、令牌与协议行为; 同时提供一套完整的采集调度层,把恢复出来的协议变成可持续运行的数据采集。

许可证:木兰宽松许可证,第 2 版(MulanPSL-2.0)。


目录


定位:它解决什么问题

做 Web 协议逆向时,真正的困难通常不在算法,而在环境。

目标站点的签名逻辑跑在浏览器里,会读 navigator、screen、document、 CanvasRenderingContext2D、WebGLRenderingContext,会检查 Function.prototype.toString 是不是 [native code],会数 arguments.length 在参数不足时抛出的错误消息,会比对 Object.keys(window) 的枚举顺序。这些代码在 Node 里直接跑必然失败;在真实浏览器里跑则很难规模化、难以自动化、难以持续运行。

常见的三条路和它们的代价:

方案 代价
真实浏览器 + 自动化(Puppeteer/CDP) 资源重、易被检测、CDP 本身就是特征
抠出 JS 片段用 jsdom 跑 环境残缺,稍微认真的检测立刻发现
纯手工翻译成 Python/Go 一次性成本极高,目标一改就重做

NV8 走第四条:在 Node 里按声明的 Edge profile、Node 版本和 parity 范围补齐浏览器 环境,然后原样运行目标脚本。 不翻译、不改写、不注入痕迹。它不是 Chromium renderer, 也不承诺对任意站点或任意浏览器检测做到无条件等价。

补到什么程度是可以量化的,见三层对齐。

协议恢复正在变成人机协作:Agent 可以观察环境、提出指纹假设,在隔离 Sandbox 里 验证(compareEnvironment)后再落地。NV8 对此提供受控的 Agent 会话层:变更只能走声明式的 EnvironmentPatch,带版本、 可回滚、有审计记录,Agent 不能直接改 Realm。

协议恢复出来之后,采集本身还有一整套工程问题——分页、限流、熔断、代理轮换、 断点续采、去重落库。NV8 的采集层把这些做完了,所以从「跑通一次签名」 到「稳定跑一个月」之间不需要另起一个项目。


特性一览

  • Edge 环境模拟:DOM、事件、页面生命周期、CSSOM、Worker、存储、Canvas、WebGL 等表面在 Node 内重建,原生函数伪装为 [native code];不需要安装 Edge、Chrome 或 Chromium,对齐程度可量化,见三层对齐。
  • 两种装配方式:EdgeSandbox 开箱即用完整表面;createNv8 以 plugin 模式按需 裁剪,见三条使用路径。
  • 协议恢复工具链:networkRequests() 取出脚本发起的全部请求,trace() / watchApis() / openInspector() 把签名入口定位出来并下断点。
  • 逆向证据链:runtimeEvidence() 统一收集环境、Trace、网络、资源和因果记录; createTargetArtifactManifest() 绑定目标脚本 hash 与补丁,diff:evidence 对两次运行 做可定位差分,默认脱敏敏感请求材料。
  • Agent 会话层:面向 LLM Agent 的宿主侧控制面——声明式 EnvironmentPatch (仅 page / fingerprint / replay)、版本化 + 回滚 + 审计, compareEnvironment() 在隔离 Sandbox 里先验证假设再落地;附 stdio JSON-RPC 桥 (npm run agent:bridge),见 docs/agent-bridge.md。
  • 离线回放与 Evidence:运行目标脚本时默认不联网(显式启用的 networkRelay 外部中继是唯一例外,见 docs/user-guide.md 8.6),回放未命中就在本地失败,绝不回落 真实网络;采集产物可固化为可复现的 Evidence Bundle,见 Evidence 与离线回放。
  • 采集调度层:分页、限流、熔断、代理轮换、断点续采、去重落库,把恢复出的协议 变成可持续运行的数据采集,见采集层。
  • 进程后端与资源上限:worker-thread 与 child-process 两种后端可切换,含资源配额 与崩溃恢复,见进程后端与资源上限。

不做什么

划清边界比列举功能更重要:

  • 不做浏览器自动化。 不点击、不截图、不等元素出现。需要真实渲染请用别的工具。
  • 不做完整渲染与布局。 没有排版引擎,offsetWidth、getBoundingClientRect 这类几何值只是近似模型(显式尺寸 → 内联文本字体度量 → 后代最大值; documentElement/body 按视口,其余为 0),不是浏览器真值(见已知边界)。
  • 运行时不联网(默认)。 运行目标脚本时,所有网络访问走离线回放;回放未命中就在本地 失败,绝不回落真实网络。真实网络默认只属于采集层(显式启用的 networkRelay 外部中继是唯一例外,见 docs/user-guide.md 8.6)。
  • 不内置数据库驱动。 采集结果落地定义为接口,附内存与 NDJSON 两个实现, 接 Postgres/SQLite 由调用方提供。
  • 不猜业务语义。 分页游标怎么取、条目主键是哪个字段,都必须由调用方指定。 猜错的代价是静默少采或静默丢数据,比报错严重得多。

快速开始

环境要求

  • Node.js >= 18.18.0(在 18/20/22/24 上均有测试;推荐 24)
  • EdgeSandbox 的隔离子进程会自动带 --experimental-vm-modules,宿主 runner 不必显式添加; 直接用 createNv8(进程内)或跑仓库测试/构建时才需要自己加
  • 零依赖,npm install 不装任何包

运行 NV8 不需要安装 Edge、Chrome 或 Chromium。 Windows、macOS、Linux 均使用 Node 自带的 V8 与仓库内的冻结 Profile;EdgeSandbox 是模拟环境的名称。 openInspector() 也只依赖 Node inspector,图形调试可用 Chrome DevTools 或 VS Code。 只有主动运行 fingerprint:* 重新采集 Edge 基线时才需要真实 Edge;没有 Edge 的机器 直接使用仓库已有基线,不必执行这些采集命令。

指纹敏感场景请用 Node 22+。 Node 18/20 的 V8(10.x / 11.x)在 dictionary 模式的 global object 上把可枚举键排在不可枚举键之前,不按插入序——违反 [[OwnPropertyKeys]]。后果是 Object.getOwnPropertyNames(window) 的顺序无法与真实 Edge 一致(实测 238 个全局排到了 V8 内建之前,window 落在索引 0 而真实 Edge 是 678)。而 enumerable 本身是要复现的契约值,不能为了顺序去改,所以这不是能 绕过去的实现问题。V8 12.x(Node 22)已修正。

npm run capabilities 会把这一项报成 broken:

- vm.global-property-order  broken  global keys are not in insertion order …

Node 18–22 还有一处更窄的差异:V8 内建段自身的注册顺序与 Chromium 152 不同 (TypedArray 家族的组内次序、Iterator 的位置)。那一段不由 NV8 安装也不由它 重排,已在 tests/window-surface-order-test.js 登记;Node 24 与 Chromium 逐位一致。

跑一段脚本

import { EdgeSandbox } from 'nv8';

const sandbox = await EdgeSandbox.create({
  page: {
    url: 'https://example.com/list',
    html: '<div id="app">hello</div>',
  },
  limits: { timeoutMs: 5000 },
});

console.log((await sandbox.evaluate('navigator.userAgent')).value);
// Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
// (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 Edg/150.0.0.0

console.log((await sandbox.evaluate('document.getElementById("app").textContent')).value);
// hello

console.log((await sandbox.evaluate('location.href')).value);
// https://example.com/list

console.log((await sandbox.evaluate('getComputedStyle(document.body).display')).value);
// block

// 关键的一条:原生函数伪装
console.log((await sandbox.evaluate(
  'Function.prototype.toString.call(document.addEventListener)'
)).value);
// function addEventListener() { [native code] }

await sandbox.close();

运行:

node --experimental-vm-modules your-script.mjs

示例统一按包名导入:在仓库内可直接运行(Node 支持 package.json 的包自引用); 放到外部项目时先在 package.json 声明 "nv8": "file:<nv8-root>" 并 npm install。

采集一个分页接口

import {
  Collector,
  createPaginationScheduler,
  createNdjsonResultSink,
  createMemoryCheckpointStore,
} from 'nv8/collector';

const collector = new Collector({
  policy: { allowedOrigins: ['https://api.example.com'] },
  rateLimit: { requestsPerSecond: 5, burst: 10, maxConcurrent: 3 },
  circuitBreaker: { failureThreshold: 5, resetTimeoutMs: 30_000 },
});

const sink = createNdjsonResultSink({
  filePath: './out/items.ndjson',
  keyOf: (item) => item.id,          // 去重主键,必须由你指定
});

const scheduler = createPaginationScheduler({
  collector,
  // 「下一个请求是什么」只能你自己回答
  nextRequest: ({ index, previous, resumeContext }) => {
    const cursor = previous
      ? previous.response.body.next
      : (resumeContext?.cursor ?? null);
    if (index === 0 && cursor === null) {
      return { url: 'https://api.example.com/items', method: 'GET' };
    }
    return cursor ? { url: `https://api.example.com/items?cursor=${cursor}`, method: 'GET' } : null;
  },
  extractItems: (result) => result.response.body.items,
  checkpoint: {
    store: createMemoryCheckpointStore(),
    jobId: 'items',
    job: { target: 'items', query: {} },   // 指纹,查询变了就不会错续
    contextOf: (page) => ({ cursor: page.response.body.next }),
  },
  limits: { maxPages: 500, maxEmptyPages: 2 },
});

for await (const page of scheduler.pages()) {
  if (page.stop !== null) {
    console.log('结束:', page.stop.reason, page.stop.totalItems);
    break;
  }
  await sink.write(page.items);
}
await sink.close();

三条使用路径

NV8 有三个入口,对应三种心智模型:跑目标脚本(EdgeSandbox)、按需裁剪装配 (createNv8)、让 Agent 受控观察与试探(createAgentSession)。

EdgeSandbox(推荐用于协议恢复)

面向「我要跑一段目标站点的脚本」。开箱即用,默认装配完整的 Edge 表面。

const sandbox = await EdgeSandbox.create(options);

主要方法:

方法 用途
evaluate(source) 执行一段脚本,返回结构化结果
evaluateWithPayload(source, payload) 带命名二进制载荷执行:payload 为 { name: Uint8Array },Realm 内本次求值期间经 globalThis.__nv8Payload 读取;字节走协议原生类型,不经 base64
batchEvaluate(sources) 批量执行,共享同一个 Realm
evaluateModule(source, url) 以 ES 模块方式执行
setPage(page) 更换页面(URL / HTML / Cookie)
networkRequests() 取出脚本发起过的所有请求(协议恢复的主要产出)
enableTrace() / trace() 记录属性访问与函数调用,用于定位签名入口
resources() 当前 Realm / Worker / 定时器等资源占用
openInspector({ port?, host? }) 打开子进程 V8 调试端点,返回 { url, alreadyOpen };默认 127.0.0.1 随机端口,详见调试指南
watchApis(list) 设置 API 访问断点:被追踪的 API 命中时若已附 inspector 则在访问处暂停;需先 enableTrace(),传空数组清除
dispatchTrustedInput(type, init?) 派发宿主可信输入事件(isTrusted === true):click / dblclick / mousedown / mouseup / mousemove / keydown / keyup / input / change,其余类型抛 TypeError;事件从当前活动元素冒泡
close() 关闭,幂等

createNv8(面向可裁剪装配)

面向「我只要 DOM 不要 WebGL」这类需求,按插件与 Profile 组合运行时。

import { createNv8 } from 'nv8';

const nv8 = await createNv8({
  runtimeMode: 'plugin',
  profile: 'minimal-fetch',
  limits: {
    timeoutMs: 3000,
    maxStateContexts: 256,
    maxStateKeysPerStore: 4096,
    maxStateTotalKeys: 65536,
  },
});

两者可以共存。runtimeMode 默认是 legacy(完整装配,覆盖面最广); plugin 模式覆盖面较小,按需拉取——这是有意为之的决策,见 ADR-0001。

createAgentSession(面向 AI 辅助逆向侦察)

面向「让 Agent 观察并试探性调整模拟环境」。它是宿主侧控制面:Agent 不能直接改 Realm 的 globalThis,只能提交声明式、可审计的 EnvironmentPatch(仅 page / fingerprint / replay),并可用 compareEnvironment() 在隔离 Sandbox 里先验证 假设再落地。也有 stdio JSON-RPC 桥(npm run agent:bridge)。完整协议见 docs/agent-bridge.md。

import { createAgentSession } from 'nv8/agent';

const session = await createAgentSession({
  agent: { agentId: 'pi', agentVersion: '1.0' },
  sandbox: { page: { url: 'https://target.test/' }, proxyTrace: { enabled: true } },
});

createApp() 仍保留在内部 Core API 中,用于需要注册多个插件/Profile 并管理多个 Sandbox 的兼容编排场景;它不是 package root 的稳定导出。新代码优先使用 EdgeSandbox、createSandbox 或 createNv8,避免同时维护两套插件解析入口。


浏览器运行时

已实现的表面

大类如下(完整清单见 docs/edge-parity.md):

  • DOM:Node / Element / Document / 集合类 / 选择器引擎 / MutationObserver
  • HTML 元素:脚本、iframe、canvas、媒体、表单等
  • 事件:EventTarget 完整分发、捕获/冒泡、onxxx 处理器属性、Trusted 语义
  • 页面生命周期:流式解析、async/defer 顺序、DOMContentLoaded / load / beforeunload / pagehide / unload,document.write 与 document.open 语义
  • CSSOM:CSSStyleDeclaration(745 个属性以实例属性形式安装)、 UA 默认样式表、计算值
  • 网络类:fetch / XHR / WebSocket / EventSource(均走离线回放)
  • 存储:localStorage / sessionStorage / IndexedDB / Cookie
  • Worker 家族:Worker / SharedWorker / ServiceWorker(含完整生命周期与 客户端模型)/ Worklet
  • iframe:子 Realm、跨 Realm 消息、隔离
  • 图形与设备:Canvas 2D、WebGL(masked/unmasked 分离)、WebGPU 表面、 设备类 API
  • 加密:SubtleCrypto、哈希、随机
  • 其他:Performance、Intl、URL、Streams、AbortController、Trusted Types 等

隔离与执行

每个 sandbox 在独立子进程(或 worker 线程)里跑,页面脚本运行在 vm.createContext() 建立的 Realm 中。宿主的 process、require、 Buffer 等一律不可见:

await sandbox.evaluate('typeof process');   // "undefined"
await sandbox.evaluate('typeof require');   // "undefined"

iframe 与 Worker 各自获得独立的子 Realm,跨 Realm 只能通过消息通道通信。

Worker 资源可以单独治理:maxWorkerRealms 限制 Dedicated / Shared / Service Worker Realm 数,maxWorkerConnections 限制页面到 Worker 的活动连接数,maxWorkerDepth 限制 嵌套 Worker 深度。三个限制默认取高水位,不改变既有 maxRealms 行为;显式触发时返回 QuotaExceededError,并带有 LIMIT_WORKER_REALMS、LIMIT_WORKER_CONNECTIONS 或 LIMIT_WORKER_DEPTH 诊断码。resources() / diagnose() 会报告当前 Worker Realm、连接数 和创建中的数量。对于活动的 SharedWorker 和 Worklet,resources().graphFingerprints 还会 返回稳定、可序列化的 graph 指纹:包含 graph 类型、入口 URL、模块 URL 集合、浏览器主版本 和 per-Realm module cache 摘要。指纹采用版本号与 SHA-256 摘要;连接数、Realm 对象和时序 状态不参与摘要,因此等价运行不会因资源创建顺序变化而产生不同指纹。


三层对齐

「像浏览器」不是形容词,是可以逐层测量的。NV8 把它拆成三层, 每层职责不重叠、互相不能替代(ADR-0006)。

第一层:全局名存在性

真实 Edge 152 有 1239 个全局名。

指标 现状
覆盖率 100%(152 profile)
多余项 0(硬断言)
枚举顺序 与真实 Edge 152 逐字一致(Node 22+)
own-descriptor 形状 1178 项,0 处不符

多余项比缺失项严重。 缺失只是功能不全;多余是宿主特征泄漏——一个 AsyncIterator(Node 24 的 V8 特性)出现在 window 上,就足够证明这不是浏览器。 所以多余项断言为 0,缺失项允许登记原因并配单调递减的上限。

第二层:原型成员与描述符

不只看名字在不在,还看它挂在哪、writable/enumerable/configurable 对不对、 是访问器还是数据属性。

指标 现状
原型完全一致 969 / 969
缺失成员 0
多余成员 0
描述符比对 8957 个成员,0 处不符

这一层抓到过的典型问题:Event.prototype.isTrusted 实际是 [LegacyUnforgeable],应该在实例上且 configurable: false, 挂在原型上就错了。

第三层:运行时行为

前两层都对,行为仍可能不同。同步行为层用 211 个探针 / 28 类覆盖;另有独立的 5 个 Worker 异步探针(2 个 Dedicated Worker、3 个 ServiceWorker):

nativeToString、illegalInvocation、argumentCount、constructorGuard、 arityMetadata、errorShape、collections、worker、cssom、canvas、fontMetrics、 domRange、storage、fetch、crypto、xhr、websocket、indexedDB、 audio、intl、performance、eventTiming、crossRealm、urlParsing、typeTag、 svg、observers、animations。

同步基线现状:207 项一致,4 项登记——2 项动态 iframe(跨 Realm)差异(开 limits.prewarmChildRealms 后也一致),2 项 Intl 差异(ICU 数据 / V8 报错文案)。异步 Worker 基线另有 5 项,真实 Edge 双轮采集并由 tests/worker-async-parity-test.js 对等验证, 覆盖 Dedicated Worker 消息与终止、ServiceWorker 注册元数据、控制器接管和页面消息往返。

三层不可替代的证据:CSSStyleDeclaration 在形状层零差异(双方原型都是 10 个成员),行为层却查出 6 处不同。形状层永远看不到那个洞。

音频是第二个同样的例子:AudioContext / OfflineAudioContext / OscillatorNode / AnalyserNode / AudioBuffer 在形状层全部齐备、 descriptor 零差异,而 14 个新增行为探针里 10 个不一致——ctx.length 读出 undefined、destination 通道数是 2 而真实是 1、五处报错类型是 RangeError 而真实 是 NotSupportedError / IndexSizeError、frequency.minValue 是 float32 极值而真实 是 ±nyquist。形状完整、行为未验证,是最容易出「看起来对但算出来不一样」的地方。

顺序也是一维:Window 全局的枚举序

Object.getOwnPropertyNames(window) 的序列在真实 Edge 里是确定的。前三层谁都 没比过它,也没比过 window 自身那 1178 个受管理 own property 的 descriptor flag。 tests/window-surface-order-test.js(27 项)补上这一维,首轮就抄出三个真问题:

1. 版本门控的全局错位。 FontFaceSet 只在 browserMajorVersion >= 151 暴露, 而旧实现是一份 1.5 万行生成代码、表达不了版本门控,于是它落在索引 61 (紧随 V8 内建之后),真实 Edge 是 517——其后 1171 个全局的索引全部偏移一位。 只测默认 150 profile 永远是绿的,因为 150 下这一项本来就不应存在——与 locale 那两条是同一类陷阱,所以测试同时断言 150 与 151 两个 profile。

2. window.chrome 被写成了不可配置。 1171 项里唯一一个 configurable: false 的数据属性。实测真实 Edge 152 是 configurable: true,且 WebIDL 没有任何机制产生 不可配置的数据属性([LegacyUnforgeable] 产生的是访问器)。孤例 + 无规范依据 + 实测反证。后果不是形状好看不好看:delete window.chrome 返回 false、 Object.defineProperty(window, 'chrome', …) 抛 TypeError——而改写 window.chrome 正是反爬脚本常做的事。

3. Node 18/20 上顺序根本对不上。 见上面「环境要求」——V8 < 12 把可枚举键排在 前面。这是宿主限制,不是 NV8 能修的,已做成 vm.global-property-order 能力探针; 测试在这两档上用反向断言而不是跳过,宿主哪天修好了会红。

顺序现在是一张数据表(src/surface/install/window-surface-order.js,1178 项), 版本门控是一个字段:

["FontFaceSet", VALUE_HIDDEN, { since: 151 }],
["HTMLUserMediaElement", VALUE_HIDDEN, { since: 151 }],

{ pending } 是「已登记的缺口」的单一来源:两份 parity 测试都从这张表读, 不各自维护名单。目前 152 基准没有 pending 全局;未来若出现暂未实现的接口, 仍必须在这张表登记理由,不能让缺口静默消失。

Intl 的天花板:ICU 数据不是同一份

Intl 直接用宿主 Node 的实现,而 Node 与 Chromium 各自打包 ICU。实测差异 很窄——NumberFormat / ListFormat / RelativeTimeFormat / PluralRules / Collator / Segmenter 全部逐字一致,只有语言显示名不同:

new Intl.DisplayNames('en-US', { type: 'language' }).of('zh-Hant')
  真实 Edge : "Chinese (Traditional)"
  Node 18–24: "Traditional Chinese"

四档 Node 给的都是后者,所以这是 ICU 数据版本差异,不是 Node 版本差异 ——宿主升级修不掉。同理 new Intl.NumberFormat('!!') 的报错文案:V8 13.x 改成了 Invalid language tag: !!,四档 Node 都还是 Incorrect locale information provided。 两条都已登记。

时区相关的输出刻意不进探针:实测 Chromium 在 Windows 上不理 TZ 环境变量、 只跟随操作系统时区,所以采集器锁不住它,写进 fixture 就是烙一个采集机的时区。 (NV8 自己能锁——子进程 env 会设 TZ,Node 认这个变量。)

locale 必须由 profile 决定,不能由宿主机器决定

这一层行为探针验证不了:探针的期望值来自真实 Edge,而采集时真实 Edge 的默认 locale 就是采集机的系统 locale——拿它当基准等于把采集机的 locale 写进契约。所以它 是一条内部一致性检查(tests/intl-default-locale-test.js)。

实测修复前的状态(Windows 中文系统):profile 声明 en-US,而

Intl.DateTimeFormat().resolvedOptions().locale  → "zh-CN"   ← 宿主机器的
new Intl.ListFormat().format(['a','b','c'])     → "a、b和c"  ← 宿主机器的

Intl 的默认 locale 来自操作系统,profile 不起作用。zh-CN profile 在中文机器上 「看起来对」纯属巧合。后果有两层:navigator.language 与 Intl.DateTimeFormat().resolvedOptions() 对不上(这是最常一起被读的一对), 以及同一 profile 在不同机器上给出不同身份。

宿主侧改不了,逐个实测过:LANG / LC_ALL 在 Windows 无效、没有 --icu-default-locale、vm.createContext() 无 locale 选项。所以在 Realm 内接管: 包装 9 个 Intl 构造器与 8 个 toLocale* 方法,仅在调用方没传 locales 时填入 profile 的 navigator.language。timezone 本来就是对的(子进程 env 设 TZ)。

测试特意同时断言 zh-CN 与 en-US 两个 profile。只测一个的话,在与之同语言的机器上 永远是绿的——这正是这个 bug 藏住的原因。

UA 默认字体族也跟着 locale 切

同一条一致性要求的另一半:css-ua-defaults.js 里 fontFamily 的基线是采集时那个 locale 的值,而 profile 的 locale 可切,字体必须跟着切。逐 locale 实测 (Windows 11 + Edge 152):

--lang getComputedStyle(document.body).fontFamily
en-US / en-GB / de-DE / ru-RU / zh-TW "Times New Roman"
zh-CN "Noto Sans SC"
ja-JP "Yu Gothic"
ko-KR "Malgun Gothic"

zh-TW 走通用默认,所以不能按 zh 前缀一刀切,要最长前缀匹配。<pre> 在八个 locale 下一律 monospace——标签级 override 与 locale 无关,覆盖只作用于基线值。

zh-CN 那一项特意核查过是不是开发机产物:Noto Sans SC 不是上古 Windows 自带字体, 但实测它在本机 %WINDIR%\Fonts 里(Windows 11 的中文语言支持会装),而同目录下 simsun.ttc / msyh.ttc 都在却没被选中——说明这是 Chromium 对 zh-Hans 的偏好 顺序,不是「碰巧只有 Noto」。Windows 10 上大概率会落到 Microsoft YaHei,所以这个值 做成可覆盖字段而不是硬编码,与 ADR-0005 下的 gpu-profiles.js 同一个套路:值是挑选的,不是从开发机采下来就当真理。

这套机制抓出来的真实问题(举例)

  • legacy 模式下完全没有原生函数伪装: Function.prototype.toString.call(document.addEventListener) 返回的是 call(...args) { return invoke(this, args); }。这是一击致命的特征。
  • WebIDL 参数个数检查缺失:真实浏览器少传参数会抛带固定格式的 TypeError。 现在把检查沉到两个安装入口,用 callback.length 当权威来源 (已验证 3508 个方法的 length 与真实 Edge 完全一致), 而不是在 ~757 个调用点手写。
  • 构造器错误消息缺后缀:Please use the 'new' operator 少了 , this DOM object constructor cannot be called as a function.(38 处); Illegal constructor 少了 Failed to construct 'X': 前缀(245 处)。
  • readyState 初值错误:内联脚本执行时是 complete。正常页面永远不可能 在 complete 状态下首次执行内联脚本——单一信号即可判定。
  • CSS 属性挂错位置:745 个 CSS 属性在真实浏览器里是 style 对象的 自有属性,不在原型上。挂到原型会让第二层报 745 个多余成员。
  • URL 主机校验按两类字符实现:https://a b/ 被原样放过。真实浏览器把主机 字符分三类——safe 原样、escape 编码、forbidden 失败。空格属于 escape (编码成 %20),而规范条文和 Node 都判它失败。只分两类无论选哪一侧都错。

采集层

src/collection/collector/。分层原则是每一层只回答一个问题:

PaginationScheduler   下一个请求是什么
Checkpoint            中断后从哪继续
      ↓
RateLimiter           现在能发吗
CircuitBreaker        对方还活着吗
NetworkPolicy         这个 origin 允许吗
ProxyPool             走哪个出口
      ↓
Transport             唯一的真实网络出口
      ↓
ResultSink            采到的东西放哪

分页调度(pagination.js)

拉取式 async iterator,for await 天然获得背压,随时 break 即停。

三种上限,只做页数上限是不够的:

上限 防什么
maxPages 游标永不为空
游标环检测 目标在绕圈
maxEmptyPages 到底了或出错

环检测最关键:只靠 maxPages,环形游标会在上限内反复采同一页——日志显示 「成功采集 500 页」,实际全是重复数据,且没有任何报错。环检测在第二次请求就停, 并报出是哪个游标重复了。

游标提取必须由 nextRequest 回调提供。next_cursor/page/offset/ Link: rel=next/嵌套字段各站点都不一样,内置猜测猜错的代价是静默少采数据。

断点续采(checkpoint.js)

存储是注入的(load/save/clear 三个方法),附内存与文件两个实现。

  • 任务指纹防错续:查询条件变了却接着旧游标走,会产出混合两次查询的数据 且不报错——这是续采最危险的 bug。指纹用 canonical JSON,{a,b} 与 {b,a} 必须同指纹。
  • 保存在 yield 之后:反过来的话,调用方处理该页时崩溃、检查点已前进, 那一页数据永久丢失。代价是续采一定有重叠——宁可重复交付也不能跳过。
  • 已见游标一起存:不存的话续采后环检测从零开始。
  • 文件存储原子写(临时文件 + rename):直接覆盖时进程被杀会留下截断的 JSON, 等于丢掉全部进度。

限流(rate-limiter.js)

令牌桶(requestsPerSecond + burst)与 maxConcurrent 是两个独立维度: 只限速率会让慢响应堆成无界并发;只限并发会让快响应以无界速率打出去。

选令牌桶而不是固定间隔,因为真实浏览器是「突发十几个请求然后安静」, 完全均匀的请求流本身就是特征。

FIFO 公平不是可选项:「谁抢到算谁的」会让高频调用方饿死早到者, 表现为「第一页永远不返回」。

熔断(circuit-breaker.js)

按 origin 的三态熔断,半开态只放一个探针。

输入必须只有目标健康度。 计入:超时、连接错误、5xx、429。 排除:4xx、策略违规、abort、自身的 CIRCUIT_OPEN、以及所有代理故障。

混进调用方错误会毁掉信号——把策略违规算进去,一个配置失误就能跳闸一个 origin 并掩盖真实错误。

代理(proxy.js / proxy-transport.js)

零依赖实现 HTTP CONNECT 隧道与 SOCKS5 握手(node:net / node:tls)。

代理故障必须与目标故障分开——这是这个模块存在的首要理由。代理不通是 我们这一侧的出口坏了。混在一起时一个代理挂掉会让熔断器跳闸所有 origin 并归咎于目标,运维看到「所有站点都挂了」,真实原因被完全掩盖。所以代理用独立 错误码 PROXY_*,熔断器硬排除(即使调用方把它配进 tripErrors 也不生效)。

默认 sticky 轮换:很多站点把会话绑定 IP。中途换出口表现为莫名掉登录态, 看起来像「协议实现错了」,会把排查带向完全错误的方向。轮换必须显式选择。

配了代理就绝不直连:回落直连会泄露真实出口 IP,而且完全无声——请求成功、 采集正常,等到目标把真实 IP 拉黑才发现。要允许必须显式 allowDirect: true。

WebSocket 采集(websocket-transport.js)

WebSocket 采集是 Collector 的有界请求/响应传输,不是页面里的离线 WebSocket API,也不是长连接订阅管理器。先在 Protocol 层声明 ws: / wss: 计划和 metadata.websocket,再显式允许 ws: / wss: scheme:

import {
  createCollector,
  createWebSocketTransport,
} from 'nv8/collector';
import { createRequestPlan } from 'nv8/protocol';

const plan = createRequestPlan({
  method: 'GET',
  url: 'wss://api.example.com/stream',
  metadata: {
    websocket: {
      protocols: ['json'],
      send: ['{"op":"ping"}'],
      maxFrames: 1,
      maxMessageBytes: 1024 * 1024,
    },
  },
});
const collector = createCollector({
  transport: createWebSocketTransport(),
  policy: {
    enabled: true,
    allowedOrigins: ['wss://api.example.com'],
    allowedSchemes: ['wss:'],
  },
});

传输负责 RFC 6455 握手、客户端掩码、文本/二进制消息、分片、Ping/Pong、关闭 和 Abort/超时清理;响应中的 response.websocket.frames 是有限帧集合。发送过消息 后发生的传输失败不会标记为可重试,避免重试造成业务消息重复。长连接订阅、代理 隧道和业务重连策略由调用方注入或编排,不在这个有界传输里隐式完成。

凭据零泄露:password 不可枚举,toJSON 只报 authenticated: bool, 错误消息只带脱敏 label。JSON.stringify / util.inspect / 对象展开 / 模板串 四条泄露路径各有断言。

结果落地(result-sink.js)

  • 按 key 去重不按整体相等:条目常带易变字段(fetchedAt、排序分数、 A/B 分桶),按整体相等去重等于不去重。
  • keyOf 不给就不去重:猜不出主键,猜错会把两条不同记录当成同一条、 静默丢数据。宁可不去重也不猜。
  • NDJSON 而不是 JSON 数组:进程被杀最多留下一个残缺末行,前面全部有效; JSON 数组写一半就是整个文件不可解析。readNdjsonKeys 跳过残缺行并计数。
  • 批量写 + close() 必须冲干;persist 抛错时先清空缓冲,否则下次 flush 会把同一批再写一遍。

协议层(src/collection/request-protocol/)

把「一次请求」表达成可序列化、可校验、可比对的 RequestPlan, 适配器返回声明式变换列表而不是直接改计划——这样变换可审计、可重放、可测试。

配套 canonical-json.js 提供稳定序列化与摘要(键顺序无关), 是任务指纹与去重 key 的基础。Protocol registry 和 ProtocolResult 使用独立的 schemaVersion(当前 1.0):同主版本的旧 minor 可消费,未来 minor 和不同 major 拒绝;它与底层 Frame Protocol 的版本不是同一个概念。


Profile 与插件

Profile 描述「要一个什么样的浏览器」:版本、指纹字段、启用哪些插件、资源上限。

内置:minimal、minimal-fetch、dom-replay、legacy-full、 browser-profile-edge-v150、browser-profile-edge-v151、browser-profile-edge-v152、 browser-profile-edge-v154。Edge 150/151/152/154 的冻结指纹从对应的 nv8/fingerprint/edge-150、nv8/fingerprint/edge-151、 nv8/fingerprint/edge-152、nv8/fingerprint/edge-154 子路径读取。

Profile 字符串只接受已注册的内置 ID。未知 ID 会抛出 PROFILE_NOT_FOUND;需要自定义 Profile 时传入 manifest 对象,避免拼写错误静默启动空运行时。

import { createProfile } from 'nv8';

const profile = createProfile('legacy-full');

插件是运行时装配单元,声明自己需要的能力与提供的表面。约束:

  • 插件在 Sandbox 初始化期间不得改动宿主 globalThis
  • 依赖按能力匹配解析,缺能力时给结构化诊断而不修改全局—— 破坏 typeof 特性检测比缺诊断更糟(ADR-0002)
  • 可生成 Lock Plan 锁定装配结果,保证跨环境一致;每个插件声明独立于实现版本的 Plugin SDK apiVersion(当前支持 major 1),Core 在 lock-plan 阶段拒绝未知 major

Plugin SDK API 版本

plugin.version 表示插件实现版本,apiVersion 表示插件与 Core 之间的 SDK 契约 major,二者不能混用。插件可以省略 apiVersion,SDK 会填入当前值;也可以 显式声明数字 major 字符串:

const plugin = definePlugin({
  apiVersion: '1',
  id: 'example-plugin',
  version: '1.0.0',
  install() {},
});

apiVersion 只接受类似 '1' 的数字 major 字符串,不接受 '1.0' 或数字类型。 未来 major 可以被插件定义以便提前开发,但 Core 会在生成 Lock Plan 时以 PLUGIN_SDK_API_UNSUPPORTED 拒绝尚未支持的 major;这样失败发生在安装和 Realm 创建之前,而不是插件执行到一半才失败。

Plugin Lock 可以选择使用 Ed25519 签名。signPluginLockPlan() 只把去掉 signature 字段后的 canonical Lock Plan 作为签名输入;Bundle/Lock 本身不携带 信任根,验证方必须按 keyId 从调用方的密钥目录提供公钥。验证端明确拒绝私钥, 签名缺失、算法未知、keyId 不匹配或内容被篡改时均 fail closed。

Core SemVer 策略

Core 版本独立于 Plugin SDK、Evidence 和 Frame Protocol,当前版本为 0.1.0。 版本规则为:major 表示不兼容的公开 API 或行为契约变化,minor 表示向后兼容的 能力增加,patch 表示向后兼容的修复或对等性修正。Lock Plan 会记录 coreVersion 并纳入摘要,避免用插件的 version 误判 Core 兼容性。

内部 manifest 或宿主集成可以使用 *、精确版本、^、~ 以及比较运算符;不满足 要求时返回 CORE_VERSION_UNSUPPORTED,而不是在 Realm 已启动后才失败。

宿主能力降级

Profile 可以声明 requiredCapabilities 和 optionalCapabilities。required 能力在 Realm 创建前检查,缺失或 broken 时直接抛出 PROFILE_CAPABILITY_UNAVAILABLE; optional 能力默认采用 degrade 策略,并通过 nv8.capabilityResolution.degradations 返回状态、行为和原因。需要严格环境时传入 capabilityPolicy: 'strict',让 optional 能力也在启动阶段失败;ignore 仅适用于调用方明确不需要诊断的受控场景。

const nv8 = await createNv8({
  profile: {
    id: 'edge-target',
    plugins: [],
    requiredCapabilities: ['vm.context'],
    optionalCapabilities: ['array-buffer.transfer'],
    degradations: [{
      capability: 'array-buffer.transfer',
      behavior: 'use the host-compat copy fallback',
      reason: 'Node 20 does not expose native transfer',
    }],
  },
  capabilityPolicy: 'degrade',
});

降级不会伪造能力为可用;运行时只能使用 Profile 已声明的 fallback。缺少 required 能力或 strict 模式下的 optional 能力都会在目标脚本执行前失败。

受信任脚本与 CSP 边界

runtime.scriptPolicy 是页面脚本执行层的 CSP-like 白名单,不是对 vm.Context 的安全保证。它分别控制 inline、external、module 和 data: 脚本, 并可用 allowedOrigins 限制脚本来源。页面 module 的静态依赖和动态 import() 共用同一检查;未授权脚本收到 ERR_NV8_SCRIPT_POLICY_REJECTED 并派发脚本 error 事件,不会回退到真实网络。

runtime: {
  scriptPolicy: {
    allowInline: false,
    allowExternal: true,
    allowModules: true,
    allowDataUrls: false,
    allowedOrigins: ['https://target.test', 'https://cdn.target.test'],
  },
}

这条策略只约束页面目标脚本。Core 的内部 surface/module loader 不通过页面 allowlist;Evidence Bundle 中的脚本仍需另外通过 evidence.trustedScriptPolicy (entrypoints-only、allowlist 或 deny-all)授权。这样不会把“Bundle 来源可信” 错误地等同为“Bundle 中目标脚本可信”。

指纹字段遵循 ADR-0005 一条铁律: 浏览器身份照抄,机器特定值保持中性。

已经踩过三次的坑:WebGL renderer、hardwareConcurrency/deviceMemory、 CSS fontFamily。最后一个尤其典型——采集时不锁 locale,采集机的系统语言会 以 fontFamily: "Noto Sans SC" 混进默认样式表,而 navigator.language 声明 en-US,形成比缺值更糟的内部矛盾。

每次扩大采集范围都必须重跑这项审计,「上次查过了」不是有效假设。


Evidence 与离线回放

Evidence Bundle 把一次真实会话固化下来:页面 HTML、脚本、网络响应、Cookie。 之后运行时从 Bundle 回放,不碰网络。

const sandbox = await EdgeSandbox.create({
  evidence: { path: './evidence/site.bundle.json' },
});

规则:

  • 回放未命中 → 本地失败,不回落真实网络(显式启用 networkRelay 时按配置中继命中 origin 的请求)
  • Worker / ServiceWorker 脚本只允许来自 data: URL 或离线回放
  • 动态 import() 同样走回放,允许列表作用于解析后的 URL (ADR-0003)

这条规则让采集可复现:同一个 Bundle 在任何机器上跑出同样结果。


进程后端与资源上限

两种后端,行为必须一致:

后端 说明
child-process 默认,隔离性最好,崩溃不影响宿主
worker-thread 启动更快,适合高频短任务
await EdgeSandbox.create({
  execution: { backend: 'worker-thread' },
  limits: {
    timeoutMs: 1000,          // 墙钟超时(生产安全上限)
    maxHeapBytes: 512 * 1024 * 1024,
    maxRealms: 16,
    maxWorkerRealms: 64,
    maxWorkerConnections: 128,
    maxWorkerDepth: 8,
    maxValueDepth: 32,
  },
});

关于 timeoutMs

默认 1000ms 是针对不可信页面脚本的生产安全上限,不是「操作应该多快」 的断言。做重度内省(遍历全部原型成员之类)时必须显式放宽:

limits: { timeoutMs: 30_000 }

把测试绑在这个默认值上等于在赌执行时间,和写死 sleep 是同一类错误。

关于 maxHeapBytes

这个值有两个互不相干的用途:算 Realm 容量守卫,和设 V8 老生代上限。 后者有硬地板——引导一个完整 Realm(347 步装配)本身就要相当的老生代空间。

实测(Node 24,各 6 次并发):

老生代 成功率
32MB 0/6
48MB 0/6
64MB 5/6 ← 悬崖边
80MB 6/6
96MB 6/6
128MB 6/6

给太低时 V8 在引导过程中 OOM 并 abort()——收到 SIGABRT,没有任何结构化 错误,因为 abort 之后没有 JS 能再运行。所以 NV8 把 V8 上限钳制到 128MB 下限 (src/backend/controller/runtime-heap-floor.js),同时容量守卫仍按你配置的值计算, 两者走不同路径。


指纹采集脚本

所有采集都用无头 Edge + --dump-dom,不用 Puppeteer/CDP (CDP 本身就是特征,且引入依赖)。

命令 采集内容
npm run fingerprint:collect 身份字段(UA、brands、版本号等)
npm run fingerprint:globals 1239 个全局名
npm run fingerprint:members 8957 个原型成员与描述符
npm run fingerprint:lengths 3508 个方法的 length
npm run fingerprint:behavior 同步行为探针(211 项)
npm run fingerprint:async-behavior Worker / ServiceWorker 异步行为探针(5 项)
npm run fingerprint:css 746 个 CSS 属性名(保留真实枚举顺序)
npm run fingerprint:ua-defaults 96 个标签 × 736 个属性的 UA 默认值

探针准入标准

一个行为探针要进库,必须:

  1. 跨运行确定性 —— 采集脚本跑两遍并要求逐字节一致
  2. 机器无关
  3. 可序列化

因此刻意排除了 measureText 的字形宽度(依赖已装字体)和 width/height (依赖视口与布局)。

探针定义必须共享

采集脚本与测试用同一份探针定义(src/infra/baseline/behavior-probes.js)。 各写一份必然漂移,而一旦漂移,比对就失去意义。

采集方法论上的坑

  • 采集基准版本必须与 profile 一致。用 Edge 152 的 fixture 去比 150 的 profile,会把版本门控的成员误报成缺失(这个坑踩过两次)。
  • 布局相关属性的排除靠实测,不靠手写名单。需要两轴差分:视口 (800×600 vs 1400×900)与内容(空 div vs 填充 div)。只做视口那一轴会漏掉 height/blockSize——空 div 在两种视口下都是 0px。
  • UA 默认值基线取 <nv8unknown>,不取众数。众数会把 unicodeBidi 标错, 并把覆盖项从 82 个标签虚增到 93 个。
  • html 与 body 必须直接测页面节点。其余标签靠「创建元素塞进 body」测量, 但 <body> 不能嵌进 body。漏掉的后果很直观: getComputedStyle(document.body).display 退回基线值 inline, 而真实浏览器是 block。
  • Node 的 URL 不能当浏览器基准。https://a b/ 浏览器接受并编码, Node 直接抛。
  • 跨页面/iframe 测量需要临时本地 HTTP 服务(绑 127.0.0.1)。 file:// 让每个文件成为不透明源,iframe 拿不到 parent。
  • 采集脚本必须能在开发机上直接跑。7 个 collector 原来只列了 WSL(/mnt/c) 与 Linux 的 Edge 路径,在原生 Windows 上必须每次手动 --edge。而「基准跟随本机 Edge」要成为常规做法,就不能依赖手动传参——已补上 Windows 候选路径。
  • 临时目录不能硬编码 /mnt/c/temp。三个用临时 HTML 页的 collector 各自写了 这个路径——而它在 Windows 上不是标准目录,本机就没有,mkdtempSync 直接 ENOENT。WSL 下 Windows 版 Edge 看不见 /tmp,所以确实需要一个 /mnt/c 下的 目录,但得探测而不是假设(scripts/edge-temp-dir.mjs)。这类错误的特征是 在某个平台上从没跑过,不是跑坏了。
  • 采集页的顶层 var 会掺进结果。经典脚本里顶层 var 会变成 globalThis 的 own property:给 collect-edge-globals.mjs 加 descriptor 采集时,四个临时变量把 1239 抬到了 1243。整段包在 IIFE 里。
  • 形状也要采,不能猜。Edge 152 的 window own property descriptor 中,WebIDL 表面由 1178 项顺序表管理,另有 V8/不可重排项;猜错不会报错,只会变成一处可探测 偏差——chrome 被写成 configurable: false 就是这么来的。 edge-globals.json 现在带 descriptors 字段。

已测出的 151 → 152 差异

本机(采集当时)Edge 已是 152,7 份 fixture 已全部重采,152 现在是对等性基准:

维度 151 152 变化
全局名 1236 1239 +3:NodeRange OpaqueRange PermissionsPolicy
原型 / 成员 966 / 8941 969 / 8957 +6 成员,−2(AbstractRange.startContainer/endContainer 移到 NodeRange)
方法 length 3496 3508 已有方法零变化
行为探针 144 178(当次) +34:字体、DOM/Range/Selection、Storage、Fetch、Crypto、XHR、WebSocket、IndexedDB
CSS 属性 746 746 无
UA 默认值 96 标签 96 标签 无
UA / brands Edg/151 Edg/152 brands 顺序与 GREASE 串都变了:
Not=A?Brand/99 → Not?A_Brand/24,Chromium 排到第一

(这张表记录当次换基准的对比;152 基准的探针后续继续扩充,当前同步探针为 211 项。)

两条值得单独记:

  • 行为层在换基准时保持原有 144 项一致,本轮另增 10 项稳定探针覆盖字体解析、TextMetrics 形状和 DOM/Range/Selection;扩探针不必等特定版本。
  • brands 不只是版本号变了,GREASE 品牌串与数组顺序都变。这类字段照抄才安全, 按规律推导会错(ADR-0005 同一条铁律)。

换基准的阻塞项(finalize-window-surface-order.js 没有生成器、新增全局要在那份 1.5 万行文件里手改三处)已解除:顺序与 descriptor 形状变成了数据表, 版本门控是一个字段。现在刷新基准是三步:

npm run fingerprint:globals        # 采顺序 + descriptor 形状
npm run check:surface-order        # 先看差异(不一致则非零退出)
node scripts/build-window-surface-order.mjs --write

已用真实 Edge 152 完成重采:3 个新增全局自动带上形状,门控保留,1178 项表面 顺序与真实 152 逐字一致,原型成员和行为对等性测试也全部通过。

顺序不是「旧顺序 + 追加新增项」:实测 151 → 152 有 9 个已有全局挪了位置 (FeaturePolicy 523 → 69、PerformanceLongAnimationFrameTiming 333 → 1191, WebAssembly / XSLTProcessor / RTCDataChannel / PageRevealEvent / onpagereveal / PerformanceScriptTiming / PerformanceTimingConfidence 亦然)。 换基准必须整表重采,而这在 1.5 万行代码里等于重新生成整个文件。

HTMLUserMediaElement、NodeRange、OpaqueRange、PermissionsPolicy 和六个新增成员 已实现,并由 tests/edge-152-surface-test.js 锁定版本门控与运行时行为。


测试

npm test              # 全量(`node --test` 自动发现 tests/,新增测试不用注册)
npm run test:matrix   # Node 18 / 20 / 22 / 24
npm run benchmark     # 当前 Node / backend 的性能基准
npm run benchmark:matrix # Node 18/20/22/24 × 两种 backend 性能矩阵
npm run baseline      # 重新生成基线快照
npm run audit:state   # 模块级可变状态审计
npm run capabilities  # 宿主能力探测报告

五条硬规矩

1. 不许用固定时长 sleep。 由 tests/test-hygiene-test.js 强制(上限 2ms)。 需要等待就用 tests/helpers/async-wait.js 轮询条件。

固定 sleep 的问题不是慢,是它把「时间够了」冒充成「条件满足了」—— 在 CI 上一定会以随机的方式失败。

2. 让位用的定时器不许 unref。 async-wait.js 里的 sleep() 曾经写了 timer.unref(),理由是「避免拖住进程退出」——恰好把作用弄反了:让位期间它就是 唯一该维持事件循环存活的句柄。unref 之后,只要此刻没有别的 refed 句柄, 事件循环直接排空,promise 永远不 settle。

症状是 node:test 报 Promise resolution is still pending but the event loop has already resolved, 整个文件被 cancelledByParent。36 项测试就这样一直没有真正运行过, 而它们看起来只是「那几个文件红了」。用同一助手的其他文件却是绿的—— 差别只在「当时恰好有没有别的活动句柄」。

比失败更糟的是这种沉默:一个断言从不执行,和它不存在没有区别, 但它在计数里、在报告里、在你以为已经覆盖了的地方。

3. 偶发失败必须查到根因。 不接受「资源竞争」这类结论。

真实案例:某项测试在全量套件里偶尔失败,前三次被归因为资源竞争。第四次用 十路并发复现(4/10 红),抓到 SandboxChildExitError (signal=SIGABRT), 再打开子进程 stderr 看到 FATAL ERROR: Reached heap limit—— 根因是 maxHeapBytes 被同时用于两件互不相干的事。修复后十路并发 10/10 绿。

4. 断言「某件事没发生」不许靠等一段时间。 那是同一个赌注换了方向。

要证明「没有多余的 load」,正确做法是造一个因果哨兵:先让被测操作完成, 再触发一次导航到哨兵 URL 并等它的 load。任何多余的中间事件都排在哨兵之前, 于是「有没有多余项」变成「序列是否恰好等于预期」。见 tests/iframe-navigation-coalescing-test.js。

等 200ms 看第三个事件有没有来是双输:一次子 Realm 构建要几百毫秒,等太短抓不到, 等太长就成了 CI 抖动源。

5. 上界断言取多次采样的最小值,不取单次。

冷启动预算原来只采一次样,在并行跑整套测试时它测的是「此刻机器有多忙」—— 实测两次越过 3000ms 预算,而单独跑同一条只要几百毫秒。放大预算等于把噪声 正当化。取最小值是因为竞争只会让采样变大,所以最小值受污染最少。

还有个具体原因:第一次采样包含宿主 ESM 图的加载(约 1700 个模块,每进程 一次),那不是每次建沙箱都要付的成本。拿它去比 benchmark 报的 490ms, 比的是两件不同的事。

性能矩阵使用 npm run benchmark:matrix 采集 Node 18/20/22/24 与 child-process / worker-thread 的冷启动、热复用、Realm reset、Realm 创建销毁的 中位数、p90、最小值、最大值和 RSS 变化。 矩阵报告是描述性基线,不把机器相关的绝对毫秒数写成行为契约;跨版本比较时应 使用相同机器、相同迭代数和相同 backend。

测试入口不许手写路径

package.json 的 test 原来手写了 78 条测试路径。新增测试不注册就静默不跑 ——与「修掉沉默失效的 36 项测试」同一类隐患:一个从不执行的断言,和它不存在没有 区别,但它在计数里、在报告里、在你以为已经覆盖了的地方。

现在是 node --experimental-vm-modules --test(不带参数,自动发现)。

为什么是无参数而不是 --test tests/ 或 --test 'tests/**/*-test.js':两种形式 在四档之间不兼容。Node 18/20 的位置参数只认目录,Node 22+ 只认 glob (--test tests/ 在 Node 24 上会去 require('/path/tests') 然后 MODULE_NOT_FOUND)。无参数模式是唯一四档通用的写法,test-matrix.sh 用的也是它。

代价是发现范围变成整个仓库,所以补了一条断言:tests/ 之外不得有匹配 Node 测试文件名模式的文件。这条同时消掉了「测试住在产品树」—— plugin-sdk 那份测试原来在 src/engine/core/ 下,用 console.log 分段、顶层断言, 既不在 --test 的计数里,第一项失败后面也全部不执行。

文档失步要靠断言,不靠 review

docs/user-guide.md 曾有一版在整节里介绍 ExecutionCore / createExecutionCore / edgeCompatPlugins 与四个 nv8/ 子路径——全部不存在; 第 17 节还描述了一套九阶段审计,用到 5 个 npm 脚本,一个都没有。这类失步已由 tests/docs-contract-test.js 变成机械断言,不再依赖人工 review。

这类问题读一遍就能发现,但没人会为了 review 去逐条核对 1500 行手册。所以 tests/docs-contract-test.js 把三类可机械核对的引用变成断言:

  • npm run <script> 必须在 package.json 的 scripts 里;
  • nv8/<subpath> 必须在 exports 里;
  • src|tests|scripts|docs|fixtures/... 的路径必须存在。

工具脚本必须跨平台

审计与采集脚本自己也会坏,而且坏法通常是谎报通过:

  • audit:state 用 path.relative() 拼相对路径去比 src/surface/api/ 前缀。Windows 上 path.relative 给反斜杠,前缀判断全部落空,审计于是报「0 项待迁移」—— 比崩掉危险得多。
  • 同一个脚本还 execSync('ls src/plugins/*/index.js'),在 Windows 上直接 'ls' 不是内部或外部命令,连带 3 项断言变红。审计脚本不该依赖外部命令。
  • new URL('..', import.meta.url).pathname 在 Windows 上是 /C:/..., path.resolve 会拼成 C:\C:\...,spawnSync 的 cwd 则直接 ENOENT—— 而报错里显示的是 node.exe 的路径,看起来像「Node 装坏了」。 一律用 fileURLToPath()(顺带解决路径含空格时残留 %20)。

性能预算

预算刻意宽松(冷启动上限 3000ms ≈ 实测的 6 倍)。目标是抓数量级回归, 不是抓 1.5 倍波动——CI 负载轻易波动 2~3 倍,卡太紧只会制造被学会忽略的假警报。

泄漏判据是跨轮次增长,不是绝对值。句柄数稳定在 6(5 个 PipeWrap + 1 个 ProcessWrap,是进程池的常驻子进程),只有单调增长才算泄漏。

一次本机矩阵采集(每格 2 轮)的结果如下;这些数字用于同机回归参考,不是跨机器 的硬阈值。每个单元格为「中位数 / p90」,单位为毫秒:

Node backend 冷启动 热复用 run Realm reset Realm 创建+销毁
18.20.8 child-process 472.61 / 472.61 0.18 / 0.64 224.36 / 224.36 426.87 / 426.87
18.20.8 worker-thread 424.70 / 424.70 0.14 / 1.01 214.02 / 214.02 416.08 / 416.08
20.20.2 child-process 422.92 / 422.92 0.13 / 0.35 216.69 / 216.69 406.00 / 406.00
20.20.2 worker-thread 394.63 / 394.63 0.14 / 0.87 214.08 / 214.08 389.97 / 389.97
22.23.2 child-process 424.61 / 424.61 0.20 / 0.92 226.52 / 226.52 404.22 / 404.22
22.23.2 worker-thread 418.24 / 418.24 0.21 / 0.31 226.65 / 226.65 390.12 / 390.12
24.20.0 child-process 426.44 / 426.44 0.22 / 0.42 238.51 / 238.51 405.53 / 405.53
24.20.0 worker-thread 407.37 / 407.37 0.22 / 1.09 270.81 / 270.81 365.46 / 365.46

RSS 变化不作为性能门槛:短基准中的 GC 和线程池回收会产生负值或平台相关长尾。 需要重采集时可执行 npm run benchmark:matrix -- --iterations 4 --json,也可用 NV8_NODE_VERSIONS=18.20.8,24.20.0 限定版本。


命令一览

命令 说明
npm test 全量测试(自动发现 tests/;文件并发取 min(8, 核数-1, 内存/1GiB),理由见 scripts/run-tests.mjs)
npm run test:matrix 多 Node 版本矩阵
npm run test:node18 只跑 Node 18
npm run benchmark 当前 Node / backend 的冷启动、热执行、签名载荷、崩溃恢复、Realm 创建销毁
npm run benchmark:matrix Node 18/20/22/24 × child-process/worker-thread 性能矩阵
npm run baseline 重新生成引导顺序 + 表面 + 可观测性基线
npm run audit:state 模块级可变状态审计(当前 0 项待处理)
npm run capabilities 宿主能力三态报告
npm run build:bundle 生成 Realm 模块预打包缓存(本机产物)
npm run check:bundle 校验缓存是否属于本机
npm run check:surface-order 校验 Window 全局顺序表与采集 fixture 一致
npm run check:code-shape 代码形态护栏:空转循环与「内联数组 + 常量下标」两类摊平残留
npm run check:doc-drift 文档对账:文档里的仓库路径、点名的 npm 脚本、脚本与测试文件是否还存在(历史引用走白名单)
node scripts/probe-intrinsics.mjs --self-check 内在函数自省探测(目标是否枚举 Window 表面,新目标适配用)
npm run build:css-defaults 从 fixture 重新生成 UA 默认样式表
npm run agent:bridge 启动单会话 stdio JSON-RPC Agent 桥(见 docs/agent-bridge.md)
npm run fingerprint:* 见指纹采集脚本

基准数据、子进程 profile 与 bundle 对照结论见 docs/backend-benchmark.md; 编译缓存实验(结论:不启用)见 docs/compile-cache-experiment.md。

关于 build:bundle

RealmModuleLoader 支持把 3154 个模块预打包成一个 JSON 以减少文件读取。 这个缓存以绝对 file:// URL 为键,因此与生成它的机器路径绑定。

仓库里曾提交过一份这样的包(3992 个键,全部以 file:///D:/develop_software/Nv8/ 开头,15.8MB)。它在别的机器上命中率恒为 0,却仍要在每次进程启动时被 readFileSync + JSON.parse——实测冷启动因此慢约 90ms。这是个负优化。

现在它是 gitignore 的本机产物。在 Linux 上实测没有可测量收益 (435ms vs 424ms,噪声范围内),所以默认不生成;在文件 IO 更慢的平台上 (原作者的 Windows 环境)可能有效,自己测过再决定。


目录结构

src/ 顶层是 8 个职责目录 + 入口 index.js,每一项就是一个职责:

src/
├── index.js           createNv8 / nv8Eval 入口
├── public/            对外入口(EdgeSandbox、选项归一化)
│
├── engine/            运行时管道:不含任何浏览器 API 实现
│   ├── core/          Sandbox、插件注册表、状态作用域、诊断
│   ├── realm/         Realm 创建、模块加载、动态 import
│   ├── bootstrap/     Realm 引导(root / worker / worklet)
│   ├── webidl/        WebIDL 转换、原生函数伪装、跨 Realm 方法
│   ├── plugin-sdk/    插件定义与能力匹配
│   └── compat/        Node 版本兼容
│
├── surface/           浏览器表面:只做「有什么 API、装在哪」
│   ├── api/           API 实现,按规范域分目录(88 个域)
│   └── install/       安装器(把 api 装到 Realm 的 globalThis 上)
│
├── plugins/           装配策略:选哪些表面、依赖谁、声明什么能力
│
├── config/            身份与组合
│   ├── profiles/      Profile 工厂、注册表、内置 Profile
│   └── presets/       插件预设组合
│
├── backend/           进程/线程边界
│   ├── controller/    后端选择与资源上限
│   ├── child/         子进程入口与运行时池
│   ├── thread/        工作线程入口
│   └── protocol/      宿主↔子进程帧协议
│
├── collection/        采集链路:证据进、请求出
│   ├── collector/     采集层(16 个模块)
│   ├── request-protocol/  RequestPlan、适配器、声明式变换、canonical JSON
│   └── evidence/      Evidence Bundle 与离线回放
│
└── infra/             横向基础设施
    ├── baseline/      基线快照与行为探针
    ├── fingerprint/   GPU 等指纹身份
    ├── navigation/    URL 记录与导航
    ├── network/       请求捕获
    ├── scheduler/     任务调度
    ├── trace/         API 调用追踪
    └── utils/         logger

tests/                 163 个 .js 文件(其中 160 个 *-test.js)
scripts/               指纹采集与构建脚本
fixtures/              真实 Edge 采集结果与基线快照
docs/                  设计文档与 ADR

原来是 26 个顶层目录平铺,api/(3680 文件) 与 network/(1 文件) 并列。分层意图 只存在于阅读者脑子里,目录本身不说话。现在容器名就是职责名——决策依据、执行方式与 踩过的坑见 ADR-0008。

两个命名相近的 protocol 被拉开了:backend/protocol/ 是宿主↔子进程的帧协议, collection/request-protocol/ 是请求计划与适配器。它们从来不是一回事, 以前并列在顶层只能靠记。对外导出仍是 nv8/protocol → request-protocol。

surface/api/ 与 plugins/ 有 18 个同名域,它们不是一回事

surface/api/<域> 是实现,surface/install/install-<域>.js 把实现装到 Realm 的 globalThis 上,plugins/<域> 是装配策略——声明这个域提供哪些能力、依赖谁、 以及在 Realm 里调哪个安装器。两条路径共用同一份 api/ 与 install/:

legacy 模式   bootstrap-root.js  ──→ install-* ──→ api/*
plugin 模式   plugins/<域>.activate ──┘

install 与 activate 的分工是硬约束。 新插件用单参数 install(context) 只登记 元数据 / 预留表面;真正装表面必须在 activate(context) 里经 context.moduleLoader.importUrlAsync() 完成,不能直接改宿主 globalThis。 旧式三参数 install(sandbox, registry, config) 仍会被兼容调用(宿主作用域,不会装进 Realm);显式标记 legacy: true 的插件整段跳过,只保留元数据。

plugins/canvas 曾经把安装写在 install 里,于是整个插件是个空壳(加不加它 surface 一模一样),而它还声明了 canvas.base 能力——ADR-0002 那套缺失能力诊断 因此看不见这个洞。plugins/dom 与 plugins/html 则是有意的聚合门面: 它们的能力由 dom-core + dom-collections / html-elements 实际提供。

成员按工厂收成表,安装器循环装表。 surface/api/<域> 里凡是「内容能由名字描述」的 成员,不再一个成员一个文件,而是按工厂 + 接口 + 安装作用域收成表;安装器里对应的连续 语句变成一行循环:

for (const [name, entry] of stringReflectionTable) accessor(name, entry.get, entry.set);

表只装那段语句原本装的成员,所以循环与原语句严格等价(不会过装)。成员若在安装语句 之外还按名字被需要(当值用、被别的模块直接 import),表模块会为它补一个具名再导出。 带真实算法的成员仍然一个文件一个(dom 里大量如此)。迁移工具是 scripts/tablize-api-members.mjs,形态护栏是 scripts/check-code-shape.mjs。

跑验证前先删掉 gitignore 的 src/engine/realm/module-bundle.json: RealmModuleLoader 命中缓存就用缓存里的旧源码,会让测试与基线在改动没生效时显示全绿。


设计原则

这些不是口号,每一条背后都有一次踩坑。

状态必须显式声明作用域。 每份可变状态必须归属 realm、page、origin 或 sandbox 之一,由 npm run audit:state 强制(宿主图可变状态已从 85 降到 0)。 模块级可变状态在多 Realm 场景下会跨 Realm 泄漏,症状是「第二个 sandbox 行为不对」。

策略检查先于凭据注入,也先于熔断检查。 顺序错了会让配置失误被记成目标故障。

能力是三态的。 broken(存在但行为不对)必须与 unavailable 区分—— 把「有但坏」当成「没有」会让降级逻辑选错分支。

多余项比缺失项严重。 缺失是功能问题,多余是身份泄漏。

登记而不是隐藏。 三类登记表:KNOWN_MISSING_*(比对过,确实缺)、 KNOWN_BEHAVIOR_DIFFERENCES(探测过,不一致)、UNPROBED_KNOWN_GAPS (明知不同,刻意不探)。每类都有过期项检查——它在一次基准版本修正后 立刻抓出了 4 个假缺口。

不猜业务语义。 分页游标、去重主键都必须显式指定。

生成的文件必须有生成器。 「声称是生成的但没有生成器」等于手写文件, 只是看起来更可信。这个坑本仓库踩过两次(module-bundle.json、 css-ua-defaults.js),现在都有了脚本。

机器特定的产物不进版本库。 与 ADR-0005 同一条原则,用在构建产物上。


已知边界

诚实列出,不粉饰。

三层对齐的剩余缺口

  • Edge 152 基准下全局名缺失为 0:NodeRange、OpaqueRange、 PermissionsPolicy、HTMLUserMediaElement 均已实现。
  • Edge 152 原型成员的枚举顺序已对齐。采集器保留真实顺序,27 个存在实现差异 的原型由专用顺序表校正,并由 tests/prototype-order-parity-test.js 锁定。
  • Node 18/20 上全局枚举顺序做不到一致(宿主限制,见「环境要求」); Node 18–22 的 V8 内建段自身顺序也与 Chromium 不同。Node 24 逐位一致。
  • 4 个行为探针差异,已登记:2 个动态 iframe(跨 Realm)差异、2 个 Intl 差异 (ICU 数据 / V8 报错文案)。
  • illegalConstructor 文案已对齐:真实 Edge 只在 new X() 时带接口名,裸调用 只报 Illegal constructor;28 个 runtime 模块已统一传递 new.target。
  • 2 项刻意不探(UNPROBED_KNOWN_GAPS,断言恰好为 2):
    • CSS 属性描述符形状(访问器 vs 可写数据属性)。纯 JS 无法复制 V8 的 命名属性拦截器。选访问器是因为读写语义正确性(自动同步 cssText 与 style 属性)比描述符形状更重要——脚本天天读 el.style.display, 几乎从不检查它的描述符。
    • 10 个布局相关计算值。
  • 行为探针已扩展到字体、DOM Range、Storage、Fetch、Crypto、XHR、WebSocket、IndexedDB 与 Worker 入口契约: 当前同步行为基线为 211 个探针 / 28 类,207 项与真实 Edge 一致;另有 5 个 Worker / ServiceWorker 异步探针 由独立 fixture 锁定。绝对字形像素宽度仍不进入契约,因为它依赖机器字体安装。 Media、Web Animations、Observers、SVG 以及上述 API 的更深层语义另行登记。

布局相关

没有排版引擎,10 个布局相关属性与 getBoundingClientRect 返回近似值: 显式尺寸/属性 → 收缩到内容的内联文本盒按字体度量估算(字体探测读取的 span.offsetWidth 正是这一项)→ 后代最大尺寸;documentElement/body 按视口 (innerWidth 减滚动条);其余为 0。近似值不做排版保证—— 不要用它做依赖真实布局的判断,但它足以通过字体枚举这类探测。

iframe

  • 已插入文档的 iframe,contentWindow 立即存在,初始文档是 about:blank (真实 Edge 语义);src / srcdoc 导航完成后同一窗口换成目标文档。 实现上在首次读取时懒物化一个空白子 Realm:预热池命中则同步返回;未命中 异步就绪(首次读取仍是 null——尚未根治的时序近似)。 detached(未插入文档)的 iframe 与真实浏览器一致地返回 null。 反爬脚本「从干净 iframe 取原生函数」的写法依赖这条——Cloudflare Turnstile 实测会在 appendChild 后同步取窗口并 eval,旧实现直接 TypeError 打断流程。 开 limits.prewarmChildRealms(0–8,默认 0)可让首次读取也同步。 实测依据与选项对比见 ADR-0004。
  • src="about:blank" 是合法的空白导航,按空白 iframe 的同步窗口路径处理。
  • 空白 iframe 的 URL/origin 已解耦:location.href 和 document.URL 为 about:blank,location.origin 继承父页面;srcdoc 对应 about:srcdoc, 同样继承父页面 origin。该行为已由 tests/iframe-about-blank-test.js 覆盖。
  • event.source 的别名跨任务路径已补齐。同源子帧直写或先保存 const p = parent 再通过 setTimeout、setInterval、requestAnimationFrame、 queueMicrotask 调用时,均保留发送子窗口;定时器入口保存并恢复 Realm incumbent, 不改变页面可见的 parent 对象身份。
  • iframe 容量拒绝不再暴露为 DOM error 事件。容量拒绝是 NV8 内部宿主条件, 现在以导航结算的 load 结束,避免页面通过 error 事件探测实现配额;堆容量守卫 仍返回结构化 QuotaExceededError,不会再触发子进程 SIGABRT(128MB 安全上限 1 个子 Realm)。 现为 floor((maxHeapBytes - 90MB) / 36MB),默认 512MB 的能力不变。
  • 被顶掉的导航仍会建出子 Realm 再关掉:真实浏览器压根不会开始。是 CPU 浪费而非可观察偏差——导航合并本身是正确的(同一同步块内的多次属性变更只 提交最后一次,见 tests/iframe-navigation-coalescing-test.js)。

URL

主机解析已按真实 Edge 对齐(8 项探针全部一致,另有 tests/url-parsing-test.js 28 项)。关键是主机字符 分三类而不是两类:

类别 字符 处理
safe 字母数字 -._~ 原样(字母折小写)
escape 空格 !"$&'()*+,;= ` {} 百分号编码
forbidden C0 / DEL / %#/:<>?@[\]^| 解析失败

Node 不能当基准——https://a b/ 浏览器接受并编码为 https://a%20b/, Node 直接抛。规范条文也把空格列为 forbidden domain code point,同样与浏览器 不符;依据是 Chromium url_canon_host.cc 的 kHostCharLookup。

opaque path(有 scheme 但没有 //:about: / mailto: / data: / javascript: / tel: / urn:)此前完全不支持,后果分两种,第二种更糟:

输入 原行为 真实浏览器
new URL('mailto:a@b.com') THROWS mailto:a@b.com
new URL('mailto:a@b.com', base) https://t.test/dir/mailto:a@b.com mailto:a@b.com

抛错至少是显式失败;带 base 时它静默拼成一个 http URL,origin 还成了父页面 的。同时修了 origin:元组 origin 只属于特殊 scheme,其余(含 nv8-unknown://x、about://x)一律 "null"——拼出 protocol//host 会让两个 不同的不透明 origin 被判成同源,而同源判断错在放宽方向上比报错危险。

已补齐:非 ASCII 主机的 IDN / punycode(你好.test → xn--6qq79v.test)、 IPv4 点分十进制的数值归一化(127.1 → 127.0.0.1)、路径字符的 percent-encode (/a b → /a%20b)。IPv6 压缩形式的重新序列化仍未实现(无探针覆盖)。

legacy 导航

beforeunload / unload 事件和整文档替换已到位:导航获准后会销毁旧 Realm、重建根 Realm 并重新执行文档生命周期;取消导航则保留原文档。

Node 版本

四档(18 / 20 / 22 / 24)全绿,fail 0。当前 Node 22/24 的全量套件为 1348 项; Node 18/20 会按宿主版本门控少量测试。具体数量随测试新增和 Node major 变化,矩阵结果以 npm run test:matrix 为准。差异分两类处理:

  • 能补到与原生一致的就补:SuppressedError / DisposableStack / AsyncDisposableStack / Float16Array 形状 / DataView 半精度。 full-surface.json 的 node18/20/22 三档对这五个全局的记录与 node24 逐字节相同。
  • 补不像的就留空并登记:Iterator、Array.prototype.toSorted 等、 RegExp.prototype.unicodeSets、Set 集合运算、ArrayBuffer.prototype.transfer。 补一个 JS 版本会让 toString 与报错文案都对不上,等于把「缺一个方法」 换成「有一个假方法」——后者更容易被识别。

登记表是 NODE_VERSION_DEPENDENT_MEMBERS,与 baseline 共用同一份。

其他版本相关限制:

  • --experimental-vm-modules 在所有 Node 版本上都必需——针对进程内使用 (createNv8、仓库测试与构建脚本);EdgeSandbox 隔离子进程会自带该 flag。
  • Node 18 的 vm 模块链接是异步的,同步 importUrl() 不可用, 必须走 importUrlAsync()(见 docs/node-compatibility.md)。
  • Node 22 之前,Realm 里的 'X' in globalThis 会调用 X 的 getter。 vm 直到 Node 22 才给 contextified global 接上 PropertyQueryCallback, 之前 has 查询是用 getter 实现的。用户态修不了;标志位是 HAS_VM_PROPERTY_QUERY_CALLBACK。别用 Proxy 包 globalThis 抹平—— 代理对象自身的可检测面比这条差异危险得多。

其他

  • plugin 模式覆盖面小于 legacy,按需拉取(ADR-0001)。

文档

文档 内容
docs/edge-parity.md 三层对齐现状与方法
docs/baseline.md 基线快照机制
docs/protocol-collector.md 协议层与采集层
docs/frame-protocol.md 宿主↔后端二进制 Frame 表示格式
docs/evidence-contract.md Evidence 接口契约
docs/security-boundaries.md 组件信任边界与安全模型
docs/state-scope.md 状态作用域规则
docs/node-compatibility.md Node 18–24 兼容矩阵
docs/api-reference.md 包入口、Protocol / Collector API、错误与生命周期
docs/rust-migration-map.json Rust 原实现 → JS 实现对应关系
docs/target-playbooks/ 目标适配协议与逐目标 playbook
docs/adr/ 架构决策记录(8 篇)
docs/user-guide.md 使用手册

ADR 索引

ADR 决策
0001 插件模式不追赶 legacy 覆盖面
0002 缺能力诊断不修改全局
0003 动态 import 走离线回放
0004 动态 iframe 用 opt-in 预热池,默认关闭
0005 机器特定值不得进入浏览器身份
0006 三层对齐职责不重叠
0007 同源 parent / top 交出真实父 window
0008 顶层目录按职责容器归口

许可

本项目采用木兰宽松许可证,第 2 版(MulanPSL-2.0),全文见 LICENSE。

About

No description, website, or topics provided.

Resources

Stars

18 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages