From 6dfc626896e01c7a71d26512f3e51e8552aa1e35 Mon Sep 17 00:00:00 2001
From: yangyu <991017358@qq.com>
Date: Mon, 7 Sep 2026 13:51:13 +0800
Subject: [PATCH 1/4] =?UTF-8?q?fix(devtools):=20=E5=B0=86=20wx.request=20?=
=?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=88=B0=E4=B8=BB=E8=BF=9B=E7=A8=8B=E5=B9=B6?=
=?UTF-8?q?=E6=81=A2=E5=A4=8D=20Network=20=E8=B0=83=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
将 service 和 preload 的请求统一交给 Node http/https,避免 Chromium CORS 与 OPTIONS 预检。保留请求编码、相对 URL、项目 Referer、解压和重定向语义,统一超时、取消及 owner 清理。
通过 NativeRequestTrace 合成 CDP Network 事件,接通 Response/Payload 缓存和两个前端查询入口;限制调试正文预算,修复重定向缓存、并发请求 ID 与销毁重入问题。同步协议及架构文档,保留公开 request-core 兼容出口。
验证:194 项聚焦测试和 7 项真实 Electron E2E 通过。本次提交前 gate 的 lint、typecheck、test 通过;Pawl 未通过:file-length 55→57、cognitive-complexity 0→2、code-duplication 275→303。未修改门禁基线。
---
.../devtools/docs/devtools-cdp-routing.mdx | 23 +-
.../devtools/docs/native-bridge-protocol.md | 153 +-
.../native-host-network-response-body.spec.ts | 138 +-
.../src/main/app/window-runtime-services.ts | 222 +--
...bridge-router-api-fail-passthrough.test.ts | 189 ++-
.../bridge-router-request-watchdog.test.ts | 266 +++-
.../main/services/elements-forward/index.ts | 13 +-
.../services/network-forward/body-cache.ts | 5 +
.../network-forward/global-body-gate.ts | 10 +-
.../network-forward/http-redirect.test.ts | 44 +
.../services/network-forward/http.test.ts | 319 ++++
.../src/main/services/network-forward/http.ts | 195 +++
.../main/services/network-forward/index.ts | 1361 ++++++++++-------
.../native-request-routing.test.ts | 43 +
.../report-native-request-trace.test.ts | 140 ++
.../services/network-forward/request-ids.ts | 4 +
.../preload/shared/api-compat-request.test.ts | 244 ++-
.../devtools/src/preload/shared/api-compat.ts | 222 +--
.../src/shared/request-core-body.test.ts | 367 +++--
...lator-api-metadata-watchdog-bounds.test.ts | 40 +-
.../simulator-api-metadata-watchdog.test.ts | 100 +-
.../simulator/direct-request-headers.test.ts | 111 --
.../direct-request-status-code.test.ts | 104 --
.../devtools/src/simulator/direct-request.ts | 54 -
.../run-api-async-request-routing.test.ts | 238 ---
.../devtools/src/simulator/simulator-app.tsx | 38 +-
.../src/main/ipc/bridge-router.ts | 694 ++++++---
.../native-request/http-compat.test.ts | 112 ++
.../services/native-request/index.test.ts | 320 ++++
.../src/main/services/native-request/index.ts | 104 ++
.../services/native-request/lifecycle.test.ts | 116 ++
.../main/services/native-request/normalize.ts | 57 +
.../native-request/preload-owners.test.ts | 33 +
.../services/native-request/preload-owners.ts | 31 +
.../native-request/request-context.test.ts | 36 +
.../native-request/request-context.ts | 23 +
.../main/services/native-request/response.ts | 28 +
.../native-request/trace-budget.test.ts | 31 +
.../native-request/trace.contract.test.ts | 141 ++
.../src/main/services/native-request/trace.ts | 158 ++
.../main/services/native-request/transport.ts | 192 +++
.../src/main/services/native-request/types.ts | 42 +
.../src/main/services/simulator/referer.ts | 10 +
.../src/shared/bridge-channels.ts | 16 +-
.../src/shared/request-core.ts | 203 +--
.../src/shared/simulator-api-metadata.ts | 23 +-
46 files changed, 4979 insertions(+), 2034 deletions(-)
create mode 100644 packages/devtools/src/main/services/network-forward/http-redirect.test.ts
create mode 100644 packages/devtools/src/main/services/network-forward/http.test.ts
create mode 100644 packages/devtools/src/main/services/network-forward/http.ts
create mode 100644 packages/devtools/src/main/services/network-forward/native-request-routing.test.ts
create mode 100644 packages/devtools/src/main/services/network-forward/report-native-request-trace.test.ts
create mode 100644 packages/devtools/src/main/services/network-forward/request-ids.ts
delete mode 100644 packages/devtools/src/simulator/direct-request-headers.test.ts
delete mode 100644 packages/devtools/src/simulator/direct-request-status-code.test.ts
delete mode 100644 packages/devtools/src/simulator/direct-request.ts
delete mode 100644 packages/devtools/src/simulator/run-api-async-request-routing.test.ts
create mode 100644 packages/dimina-electron-runtime/src/main/services/native-request/http-compat.test.ts
create mode 100644 packages/dimina-electron-runtime/src/main/services/native-request/index.test.ts
create mode 100644 packages/dimina-electron-runtime/src/main/services/native-request/index.ts
create mode 100644 packages/dimina-electron-runtime/src/main/services/native-request/lifecycle.test.ts
create mode 100644 packages/dimina-electron-runtime/src/main/services/native-request/normalize.ts
create mode 100644 packages/dimina-electron-runtime/src/main/services/native-request/preload-owners.test.ts
create mode 100644 packages/dimina-electron-runtime/src/main/services/native-request/preload-owners.ts
create mode 100644 packages/dimina-electron-runtime/src/main/services/native-request/request-context.test.ts
create mode 100644 packages/dimina-electron-runtime/src/main/services/native-request/request-context.ts
create mode 100644 packages/dimina-electron-runtime/src/main/services/native-request/response.ts
create mode 100644 packages/dimina-electron-runtime/src/main/services/native-request/trace-budget.test.ts
create mode 100644 packages/dimina-electron-runtime/src/main/services/native-request/trace.contract.test.ts
create mode 100644 packages/dimina-electron-runtime/src/main/services/native-request/trace.ts
create mode 100644 packages/dimina-electron-runtime/src/main/services/native-request/transport.ts
create mode 100644 packages/dimina-electron-runtime/src/main/services/native-request/types.ts
diff --git a/packages/devtools/docs/devtools-cdp-routing.mdx b/packages/devtools/docs/devtools-cdp-routing.mdx
index d2ec80e5..9cd485e2 100644
--- a/packages/devtools/docs/devtools-cdp-routing.mdx
+++ b/packages/devtools/docs/devtools-cdp-routing.mdx
@@ -1,11 +1,11 @@
---
title: 嵌入式 DevTools 的 CDP 路由架构
-description: 一个 Chrome DevTools 前端,如何同时服务小程序运行时的三个进程后端
+description: 一个 Chrome DevTools 前端,如何汇集小程序运行时的跨进程调试数据
---
# 嵌入式 DevTools 的 CDP 路由架构
-> **一句话**:devtools 右侧那个「开发者工具」是一整套**嵌入式 Chrome DevTools 前端**,但小程序运行时是**三个进程**。本文讲清楚——怎么让**一个**前端,把 Console / Network / Elements 三个面板分别接到**三个不同的后端**上。
+> **一句话**:devtools 右侧那个「开发者工具」是一整套**嵌入式 Chrome DevTools 前端**,但小程序运行时有**三个 WebContents 后端和主进程原生网络出口**。本文讲清楚——怎么让**一个**前端,把 Console / Network / Elements 三个面板接到各自的数据来源上。
>
> Console / Network / Elements 三条路径分别由 `console-forward`、`network-forward`、`elements-forward` 三个独立服务承载(见 §7)。
@@ -20,7 +20,7 @@ description: 一个 Chrome DevTools 前端,如何同时服务小程序运行
Network
- ← simulator WCV
+ ← 主进程 + simulator / render
wx.request / fetch / XHR
@@ -32,20 +32,22 @@ description: 一个 Chrome DevTools 前端,如何同时服务小程序运行
---
-## 1. 根本张力:一个前端 ↔ 三个后端
+## 1. 一个前端与多个数据来源
-native-host 把一个小程序拆成三个进程(详见 [native-host-abstractions](./native-host-abstractions.md)),三个面板想看的数据各在其中一个进程里:
+native-host 的三个 WebContents 后端与主进程共同提供调试数据(详见 [native-host-abstractions](./native-host-abstractions.md)):
- **service-host**(隐藏 `BrowserWindow`,`service.html`)跑**逻辑层**——`console.log`、断点、`wx.*` 业务 JS。**Console** 看的是它。
-- **simulator WCV**(顶层 `WebContentsView`,`simulator.html`)真正**发起网络请求**——`wx.request`/`downloadFile`/`uploadFile` 都走它的 `fetch`/XHR。**Network** 看的是它。
+- **主进程**通过 Node http/https 执行 `wx.request`,通过 trace 合成 Network 事件与正文缓存。
+- **simulator WCV**(顶层 `WebContentsView`,`simulator.html`)执行 `downloadFile`/`uploadFile` 等 fetch/XHR;其原生 CDP Network 事件也进入同一面板。
- **render guest**(每页一个嵌套 ``,`pageFrame.html`)承载**页面 DOM**。**Elements** 看的是它。
-而一个 Chrome DevTools 前端**原生只能 inspect 一个 WebContents**(被 Electron `setDevToolsWebContents` 绑定的那个;本架构里固定绑 service-host)。一个前端、三个后端——这就是 **1 ↔ N 的阻抗失配**。下面这张图是全局骨架,后续每节都在补全它的一条边。
+而一个 Chrome DevTools 前端**原生只能 inspect 一个 WebContents**(被 Electron `setDevToolsWebContents` 绑定的那个;本架构里固定绑 service-host)。其余 WebContents 的 CDP 数据和主进程 trace 经转发器注入同一个前端。下面这张图是全局骨架,后续每节都在补全它的一条边。
```d2
direction: right
-后端: 后端 · 三进程 {
+后端: 数据来源 {
+ MAIN: "主进程\n原生 HTTP / WebSocket"
RG: "render guest\n页面 DOM"
SH: "service-host\n逻辑层"
SW: "simulator WCV\n网络栈"
@@ -72,6 +74,7 @@ direction: right
后端.SH -> 分流.R2: console·响应
分流.R2 -> 前端.CO: 推回前端
+后端.MAIN -> 分流.R3: trace 合成
后端.SW -> 分流.R3: Network.* 事件
分流.R3 -> 前端.NW: 推送到面板
```
@@ -144,7 +147,7 @@ function routeByDomain(method): 'render' | 'service' {
}`}
-- **出站**:前端的 `InspectorFrontendHost.sendMessageToBackend` 被包了一层——**唯一**的出站 CDP 闸口(`routeOutboundCommand` 是唯一判据函数)。命中 render 前缀的命令被拦下,交给主进程转发到 active render guest;`Network.getResponseBody` / `Network.getRequestPostData` 且 requestId 带 `dimina:sim:` 虚拟前缀的,由主进程从 network-forward 的预取缓存回答(service-host 根本不认识这些 id);**其余一切(含 Emulation / Page / Target)原样透传 = 原生 service-host**。Network 面板的**事件**侧由 network-forward 反向注入——它监听 simulator 的 Network.\* 事件并 dispatch 进前端。
+- **出站**:前端的 `InspectorFrontendHost.sendMessageToBackend` 被包了一层——**唯一**的出站 CDP 闸口(`routeOutboundCommand` 是唯一判据函数)。命中 render 前缀的命令被拦下,交给主进程转发到 active render guest;`Network.getResponseBody` / `Network.getRequestPostData` 且 requestId 带 `dimina:sim:` 或 `dimina:http:` 虚拟前缀的,由主进程从 network-forward 的预取缓存回答(service-host 根本不认识这些 id);**其余一切(含 Emulation / Page / Target)原样透传 = 原生 service-host**。Network 面板的**事件**侧由 network-forward 反向注入——它监听 simulator 的 Network.\* 事件,并将主进程原生 HTTP trace 合成为 Network 事件,一起 dispatch 进前端。
- **入站**:render 的响应/事件、simulator 的 Network 事件,都经 `window.DevToolsAPI.dispatchMessage` 推送回前端(大 payload 走 `dispatchMessageChunk` 分片)。
- **三个后端**:`service-host` 是**隐式默认**(不拦、透传即得);`render`(elements-forward)认领 DOM / CSS / Overlay / DOMSnapshot / DOMDebugger;`simulator`(network-forward)认领 Network。
@@ -160,7 +163,7 @@ function routeByDomain(method): 'render' | 'service' {
| Runtime / Console / Debugger / Profiler / Log / Sources | service-host | 原生透传(零开销) |
- | Network(事件 + body/postData 回查) | simulator WCV + 每个 active render guest | 抓 simulator(`wx.request` 等)与 render guest(页面本体图片/字体等资源加载)各自的 Network.* 事件 → requestId 命名空间化 → 推送到面板前端;loadingFinished 时预取 body/postData 存有界缓存(forwarder 级并发上限 + 按 `encodedDataLength` 预筛跳过明显超限的),前端对 `dimina:sim:` id 的 `getResponseBody`/`getRequestPostData` 经出站闸口从缓存回答 |
+ | Network(事件 + body/postData 回查) | 主进程 + simulator WCV + render guests | 合成主进程 `wx.request` trace,抓取 simulator 与 render guest(图片/字体等资源)的 Network.* 事件 → requestId 命名空间化 → 推送到面板前端;loadingFinished 时预取 body/postData 存有界缓存(forwarder 级并发上限 + 按 `encodedDataLength` 预筛跳过明显超限的),主进程 HTTP 的 Response/Payload 直接写入同一有界缓存,前端对 `dimina:sim:` / `dimina:http:` id 的 `getResponseBody`/`getRequestPostData` 经出站闸口从缓存回答 |
| DOM / CSS / Overlay / DOMSnapshot / DOMDebugger | active render guest | 转发 render debugger + 推回前端 |
| Emulation 🚫 | service-host(红线) | safe-area 主进程直发,前端 Emulation 绝不路由 render |
| Page / Target / Input | service-host | 透传(结构性默认,同 Emulation) |
diff --git a/packages/devtools/docs/native-bridge-protocol.md b/packages/devtools/docs/native-bridge-protocol.md
index d9d349e9..6d570212 100644
--- a/packages/devtools/docs/native-bridge-protocol.md
+++ b/packages/devtools/docs/native-bridge-protocol.md
@@ -12,12 +12,12 @@ native-host 是 devtools 唯一的 simulator 运行时:Electron 充当 iOS/And
dimina 在每个平台上都把 service 跑在一个**与 render 物理隔离、不带 DOM 的 JS 上下文**里,render ↔ service 通过 native 注入的 bridge 中转。native-host 走的是同一份代码路径。
-| 维度 | iOS | Android | HarmonyOS | native-host(Electron) |
-|---|---|---|---|---|
-| Render 容器 | WKWebView | Android WebView | ArkWeb | ``(`pageFrame.html`) |
-| Service 引擎 | JavaScriptCore(`JSContext`) | QuickJS | ArkTS `ThreadWorker` | 隐藏的 ServiceHost BrowserWindow |
-| 通信通道 | `WKScriptMessageHandler` + `evaluateScript` | `evaluateJavaScript` + 回调 | `ThreadWorker.postMessage` | 主进程 `BridgeRouter`(IPC + `webContents.send`) |
-| Bridge 注入 | Native 注入 `global.DiminaServiceBridge` | Native 注入 `global.DiminaServiceBridge` | Worker 内置 + main 注入 | preload 注入 `globalThis.DiminaServiceBridge` / `window.DiminaRenderBridge` |
+| 维度 | iOS | Android | HarmonyOS | native-host(Electron) |
+| ------------ | ------------------------------------------- | ---------------------------------------- | -------------------------- | --------------------------------------------------------------------------- |
+| Render 容器 | WKWebView | Android WebView | ArkWeb | ``(`pageFrame.html`) |
+| Service 引擎 | JavaScriptCore(`JSContext`) | QuickJS | ArkTS `ThreadWorker` | 隐藏的 ServiceHost BrowserWindow |
+| 通信通道 | `WKScriptMessageHandler` + `evaluateScript` | `evaluateJavaScript` + 回调 | `ThreadWorker.postMessage` | 主进程 `BridgeRouter`(IPC + `webContents.send`) |
+| Bridge 注入 | Native 注入 `global.DiminaServiceBridge` | Native 注入 `global.DiminaServiceBridge` | Worker 内置 + main 注入 | preload 注入 `globalThis.DiminaServiceBridge` / `window.DiminaRenderBridge` |
dimina-fe 侧的关键锚点(`dimina/` submodule,只读):
@@ -33,7 +33,7 @@ native-host 在 Electron 里实现的等价物必须满足这套契约,`@dimin
```typescript
interface DiminaServiceBridge {
// Service → Native:发起 invoke(target='container' 时调 native API;target='render' 时由 native 转发)
- invoke(msg: MessageEnvelope): unknown // iOS/QuickJS 可同步返回
+ invoke(msg: MessageEnvelope): unknown // iOS/QuickJS 可同步返回
// Service → Render:透过 native 中转,第一参 bridgeId 用于多 mini-app 实例路由
publish(bridgeId: string, msg: MessageEnvelope): void
@@ -44,7 +44,7 @@ interface DiminaServiceBridge {
}
interface MessageEnvelope {
- type: string // 见 §2.3 type 一览
+ type: string // 见 §2.3 type 一览
target: 'service' | 'render' | 'container'
body: Record
}
@@ -79,33 +79,33 @@ interface DiminaRenderBridge {
`BridgeMessageType`(`packages/dimina-electron-runtime/src/shared/bridge-channels.ts`)以具名字面量列出主要 type,并以 `| string` 收尾——是开放联合,留给路由型扩展消息(`consoleLog` 即属此类:未具名于 `BridgeMessageType`,但由 `handleContainerMsg` 路由、由 preload 发出)。下表按发送方/接收方分类(含未具名的扩展消息):
-| Type | 方向 | 含义 | container 角色 |
-|---|---|---|---|
-| `loadResource` | Container → Service / Render | 通知加载小程序资源(service js / render css+js) | 发起 |
-| `serviceResourceLoaded` | Service → Container | service 加载完上报 | 接收+聚合 |
-| `renderResourceLoaded` | Render → Container | render 加载完上报 | 接收+聚合 |
-| `resourceLoaded` | Container → Service | 两端都加载完,通知 service 创建实例 | 发起 |
-| `firstRender` | Service → Render | 首屏数据 + 组件初始 props | 透明转发 |
-| `appShow` / `appHide` | Container → Service | App 前后台生命周期 | 发起(主进程 `installAppLifecycleDriver` 按主窗口 `minimize/hide`→`appHide`、`show/restore`→`appShow` 触发,驱动 `App.onShow/onHide` 与 `wx.onAppShow/onAppHide` 监听) |
-| `stackShow` / `stackHide` | Container → Service | 页面栈进出生命周期 | 声明保留(同上,不触发) |
-| `pageShow` / `pageHide` / `pageUnload` | 模拟器壳 → 主进程 → Service | 页面成为/离开栈顶、被销毁;壳的 reducer 是唯一的生产者,主进程经 `PAGE_LIFECYCLE` 原样转发 | 转发(`handlePageLifecycle`,同时维护 `visibleBridgeId`) |
-| `pageReady` / `pageScroll` / `pageRouteDone` | Render → Service | 页面就绪与交互 | 透明转发 |
-| `pageResize` | 主进程 → Service | `bridge.setDevice()` 几何变化(尺寸或朝向)时,向当前可见页推送新 `size`/`deviceOrientation`;无可见页或几何未变化不发 | 发起(`setDevice`) |
-| `hostEnvUpdate` | 主进程 → Service | `bridge.setDevice()` 时推送完整新 `HostEnvSnapshot`(`{ systemInfo }`)。service-host preload 在转发前先把它合并进 `__diminaSpawnContext.hostEnvSnapshot`(同步 `wx.getSystemInfoSync()` 每次调用都读这里),再交给 service 更新异步 `hostEnv`;先于同一次 `setDevice` 的 `pageResize` 发出,所以 `Page.onResize` 里读到的已是新机型 | 发起(`setDevice`) |
-| `mC` / `mR` / `mU` | Render → Service | Component create / ready / unmount | 透明转发 |
-| `t` | Render → Service | 用户事件触发自定义 method | 透明转发 |
-| `u` / `ub` | Service → Render | 单条 / 批量 setData 更新 | 透明转发 |
-| `triggerCallback` | 双向 | 异步回调结果 | 透明转发 |
-| `invokeAPI` | Service → Container | 能力调用(wx.* / navigation / route / tabBar / host API) | 处理(见 §6) |
-| `h5SdkAction` | Render → Service | 内嵌 web-view 的 SDK 行为 | 透明转发 |
-| `componentError` | Render → Service | 组件错误上报 | 透明转发 |
-| `domReady` | Render → Container | DOM 初始化完成,container 可隐藏 loading | 接收 |
-| `print` | Container → Render | 调试日志注入(dev only) | 发起 |
-| `renderHostReady` | Render → Container | render-host webview preload 就绪,container 回发 `loadResource` | 接收 |
-| `serviceHostError` | Service → Container | service-host boot / `deliver` 派发阶段错误上报 | 接收 + 触发 `wx.onError` 监听(dimina service runtime 不派发 `App.onError`) |
-| `consoleLog`(扩展消息,未具名于 `BridgeMessageType`) | Service / Render → Container | guest console 捕获转发(见 §3) | 接收 |
-| `storageChanged`(扩展消息) | Service → Container | 同步 `wx.setStorageSync`/`removeStorageSync`/`clearStorageSync` 写入通知(body 为 `SyncStorageChange`,key 带 `${appId}_` 前缀) | 接收 → `ctx.onServiceStorageChanged(ap.appId, body)` → simulator-storage 推 `StorageEvent`,保持 Storage 面板实时 |
-| `wxmlChanged`(扩展消息) | Render → Container | 活动页 DOM 就地变化(render-guest MutationObserver,去抖后发) | 接收 → `emitRenderEvent({ kind:'domMutated' })` → simulator-wxml 重新 pull + push,保持 WXML 面板实时 |
+| Type | 方向 | 含义 | container 角色 |
+| ------------------------------------------------------ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `loadResource` | Container → Service / Render | 通知加载小程序资源(service js / render css+js) | 发起 |
+| `serviceResourceLoaded` | Service → Container | service 加载完上报 | 接收+聚合 |
+| `renderResourceLoaded` | Render → Container | render 加载完上报 | 接收+聚合 |
+| `resourceLoaded` | Container → Service | 两端都加载完,通知 service 创建实例 | 发起 |
+| `firstRender` | Service → Render | 首屏数据 + 组件初始 props | 透明转发 |
+| `appShow` / `appHide` | Container → Service | App 前后台生命周期 | 发起(主进程 `installAppLifecycleDriver` 按主窗口 `minimize/hide`→`appHide`、`show/restore`→`appShow` 触发,驱动 `App.onShow/onHide` 与 `wx.onAppShow/onAppHide` 监听) |
+| `stackShow` / `stackHide` | Container → Service | 页面栈进出生命周期 | 声明保留(同上,不触发) |
+| `pageShow` / `pageHide` / `pageUnload` | 模拟器壳 → 主进程 → Service | 页面成为/离开栈顶、被销毁;壳的 reducer 是唯一的生产者,主进程经 `PAGE_LIFECYCLE` 原样转发 | 转发(`handlePageLifecycle`,同时维护 `visibleBridgeId`) |
+| `pageReady` / `pageScroll` / `pageRouteDone` | Render → Service | 页面就绪与交互 | 透明转发 |
+| `pageResize` | 主进程 → Service | `bridge.setDevice()` 几何变化(尺寸或朝向)时,向当前可见页推送新 `size`/`deviceOrientation`;无可见页或几何未变化不发 | 发起(`setDevice`) |
+| `hostEnvUpdate` | 主进程 → Service | `bridge.setDevice()` 时推送完整新 `HostEnvSnapshot`(`{ systemInfo }`)。service-host preload 在转发前先把它合并进 `__diminaSpawnContext.hostEnvSnapshot`(同步 `wx.getSystemInfoSync()` 每次调用都读这里),再交给 service 更新异步 `hostEnv`;先于同一次 `setDevice` 的 `pageResize` 发出,所以 `Page.onResize` 里读到的已是新机型 | 发起(`setDevice`) |
+| `mC` / `mR` / `mU` | Render → Service | Component create / ready / unmount | 透明转发 |
+| `t` | Render → Service | 用户事件触发自定义 method | 透明转发 |
+| `u` / `ub` | Service → Render | 单条 / 批量 setData 更新 | 透明转发 |
+| `triggerCallback` | 双向 | 异步回调结果 | 透明转发 |
+| `invokeAPI` | Service → Container | 能力调用(wx.\* / navigation / route / tabBar / host API) | 处理(见 §6) |
+| `h5SdkAction` | Render → Service | 内嵌 web-view 的 SDK 行为 | 透明转发 |
+| `componentError` | Render → Service | 组件错误上报 | 透明转发 |
+| `domReady` | Render → Container | DOM 初始化完成,container 可隐藏 loading | 接收 |
+| `print` | Container → Render | 调试日志注入(dev only) | 发起 |
+| `renderHostReady` | Render → Container | render-host webview preload 就绪,container 回发 `loadResource` | 接收 |
+| `serviceHostError` | Service → Container | service-host boot / `deliver` 派发阶段错误上报 | 接收 + 触发 `wx.onError` 监听(dimina service runtime 不派发 `App.onError`) |
+| `consoleLog`(扩展消息,未具名于 `BridgeMessageType`) | Service / Render → Container | guest console 捕获转发(见 §3) | 接收 |
+| `storageChanged`(扩展消息) | Service → Container | 同步 `wx.setStorageSync`/`removeStorageSync`/`clearStorageSync` 写入通知(body 为 `SyncStorageChange`,key 带 `${appId}_` 前缀) | 接收 → `ctx.onServiceStorageChanged(ap.appId, body)` → simulator-storage 推 `StorageEvent`,保持 Storage 面板实时 |
+| `wxmlChanged`(扩展消息) | Render → Container | 活动页 DOM 就地变化(render-guest MutationObserver,去抖后发) | 接收 → `emitRenderEvent({ kind:'domMutated' })` → simulator-wxml 重新 pull + push,保持 WXML 面板实时 |
容器(bridge-router)只需要参与以下角色:
@@ -146,14 +146,14 @@ dimina-fe **没有 `invokeSync` 方法**——真机端同步 API 依赖宿主 J
### 2.6 Native 注入对照表(iOS / Android → Electron)
-| 操作 | iOS(Swift) | Android(Kotlin) | native-host(Electron) |
-|---|---|---|---|
-| `DiminaServiceBridge.invoke` 注入 | `JSContext.setObject(..., "invoke")` | `QuickJSEngine.setInvokeCallback` | `preload.cjs`: `invoke(msg) => ipcRenderer.send('dmb:service:invoke', { msg })`(不带来源页 id——一个 service host 服务整个页面栈,消息若涉及具体页面自己在 `msg.body` 里带) |
-| `DiminaServiceBridge.publish` 注入 | `JSContext.setObject(..., "publish")` | `QuickJSEngine.setPublishCallback` | `preload.cjs`: `publish(targetBridgeId, msg) => ipcRenderer.send('dmb:service:publish', { targetBridgeId, msg })` |
-| Native → Service onMessage | `evaluateScript("DiminaServiceBridge.onMessage(...)")` | `evaluateJavaScript("...")` | `preload.cjs`: `ipcRenderer.on('dmb:to-service', (_e, { msg }) => onMessageFn?.(msg))` |
-| `DiminaRenderBridge.invoke` 注入 | `WKScriptMessageHandler` | `JavascriptInterface` | `render-host/preload.cjs`: `invoke(s) => ipcRenderer.send('dmb:render:invoke', …)` |
-| `DiminaRenderBridge.publish` 注入 | — | — | `render-host/preload.cjs`: `publish(s) => ipcRenderer.send('dmb:render:publish', …)` |
-| Service → Render publish | `DMPChannelProxy.serviceToRender` → `webview.evaluateJavaScript(...)` | `Bridge.messagePublish` | bridge-router 按 bridgeId 查到 renderWc,`webContents.send('dmb:to-render', { msg })`,render preload 调 `DiminaRenderBridge.onMessage(msg)` |
+| 操作 | iOS(Swift) | Android(Kotlin) | native-host(Electron) |
+| ---------------------------------- | --------------------------------------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `DiminaServiceBridge.invoke` 注入 | `JSContext.setObject(..., "invoke")` | `QuickJSEngine.setInvokeCallback` | `preload.cjs`: `invoke(msg) => ipcRenderer.send('dmb:service:invoke', { msg })`(不带来源页 id——一个 service host 服务整个页面栈,消息若涉及具体页面自己在 `msg.body` 里带) |
+| `DiminaServiceBridge.publish` 注入 | `JSContext.setObject(..., "publish")` | `QuickJSEngine.setPublishCallback` | `preload.cjs`: `publish(targetBridgeId, msg) => ipcRenderer.send('dmb:service:publish', { targetBridgeId, msg })` |
+| Native → Service onMessage | `evaluateScript("DiminaServiceBridge.onMessage(...)")` | `evaluateJavaScript("...")` | `preload.cjs`: `ipcRenderer.on('dmb:to-service', (_e, { msg }) => onMessageFn?.(msg))` |
+| `DiminaRenderBridge.invoke` 注入 | `WKScriptMessageHandler` | `JavascriptInterface` | `render-host/preload.cjs`: `invoke(s) => ipcRenderer.send('dmb:render:invoke', …)` |
+| `DiminaRenderBridge.publish` 注入 | — | — | `render-host/preload.cjs`: `publish(s) => ipcRenderer.send('dmb:render:publish', …)` |
+| Service → Render publish | `DMPChannelProxy.serviceToRender` → `webview.evaluateJavaScript(...)` | `Bridge.messagePublish` | bridge-router 按 bridgeId 查到 renderWc,`webContents.send('dmb:to-render', { msg })`,render preload 调 `DiminaRenderBridge.onMessage(msg)` |
## 3. Guest console 捕获
@@ -202,7 +202,7 @@ bridge-router 的 session 数据结构(字段全集):
`logic.js` 走两条互补路径:
-- **service loader 自身的 importScripts**:`@dimina/service` 的 Worker-style 运行时检测到自己处于 worker 形态时,loader(dimina-fe `service/src/core/loader.js`)调 `globalThis.importScripts(\`${baseUrl}${appId}/${root}/logic.js\`)`。BrowserWindow 没有 `importScripts`,所以 `service-host/preload.cjs` 给 `globalThis.importScripts` 装了一个 shim:同步 XHR 取脚本 + 间接 `eval`(`(0, eval)(...)`)在 global scope 执行,使脚本里的 `modDefine(...)` 注册进 service loader `modRequire` 读取的同一份 AMD 注册表。
+- **service loader 自身的 importScripts**:`@dimina/service` 的 Worker-style 运行时检测到自己处于 worker 形态时,loader(dimina-fe `service/src/core/loader.js`)调 `globalThis.importScripts(\`${baseUrl}${appId}/${root}/logic.js\`)`。BrowserWindow 没有 `importScripts`,所以 `service-host/preload.cjs`给`globalThis.importScripts`装了一个 shim:同步 XHR 取脚本 + 间接`eval`(`(0, eval)(...)`)在 global scope 执行,使脚本里的 `modDefine(...)`注册进 service loader`modRequire` 读取的同一份 AMD 注册表。
- **bridge-router 主进程预注入**:`bootServiceHost` → `injectLogicBundle` 在 service window `did-finish-load` 后,HTTP fetch logic.js 并 `serviceWc.executeJavaScript(content, true)`,**注入成功后才发** `loadResource`。fetch URL 视模式而定:本地 fallback `DiminaResourceServer`(`ap.resourceServer` 非空)取 `new URL('logic.js', resourceBaseUrl)` = `logic.js`(其 root 已是 `pkgRoot/root`);dev-server 模式取 `new URL('//logic.js', resourceBaseUrl)` = `//logic.js`。
- **注入失败(fetch 非 2xx / executeJavaScript 抛错)= fail-loud**:`injectLogicBundle` 返回 `false`,`ap.logicInjected` 记为 `false`,`bootServiceHost` **不再发 service `loadResource`**(否则 `modRequire('app')` 必抛 `module app not found`,掩盖真因),改由 `reportLogicLoadFailure` 经 `ctx.diagnostics` 报 `logic-bundle-unreachable` 诊断(总线镜像主进程 console,并由 `console-forward` 注入 service-host console → 内嵌 DevTools Console 面板;host 未就绪时按 session 排队、`bootServiceHost` 经 `notifyServiceHostReady` 冲洗),`ctx.guestConsole.emit` 保留给 automation 订阅者。
- **render 侧确定性门**:render guest 在自身 `DOMContentLoaded` 就发 `renderHostReady`,通常**早于**异步 fetch+inject 落定。`routeFromRender` 据 `ap.logicInjected` 三态处理:`false` → 跳过 render `loadResource`(避免第二条 `module not found`);`null`(注入仍在途)→ 把该页 `loadResource` 挂起(`page.renderLoadPending`),由 `bootServiceHost` 落定后统一冲洗(成功才发、失败丢弃)——保证失败的 bundle 永不到达 render 侧;`true` → 立即发。
@@ -218,35 +218,42 @@ service → container 的能力调用 envelope:
{ type: 'invokeAPI', target: 'container', body: { name, params: { ...userParams, success, fail, complete } } }
```
-`bridge-router.ts` 的 `handleSimulatorApi` 按 `name` 分流到五类目标:
+`bridge-router.ts` 的 `handleSimulatorApi` 按 `name` 分流:
-| 类别 | 名字示例 | 路由 |
-|---|---|---|
-| Navigation Bar API(`NAV_BAR_API_NAMES`,5 个) | setNavigationBarTitle / setNavigationBarColor / show\|hideNavigationBarLoading / hideHomeButton | `simulatorWc.send(E.NAV_BAR)` → fire-and-forget `:ok` 回调(UI 异步更新) |
-| Route Action API(`NAV_ACTION_NAMES`,5 个) | navigateTo / navigateBack / redirectTo / reLaunch / switchTab | `simulatorWc.send(E.NAV_ACTION)` → DeviceShell 调 reducer + ack via `NAV_CALLBACK` |
-| TabBar Action API(`TAB_ACTION_NAMES`,8 个) | setTabBarStyle / setTabBarItem / show\|hideTabBar / set\|removeTabBarBadge / show\|hideTabBarRedDot | `simulatorWc.send(E.TAB_ACTION)` → applyTabAction + ack via `NAV_CALLBACK` |
-| Storage 异步 API(`STORAGE_API_NAMES`,5 个) | setStorage / getStorage / removeStorage / clearStorage / getStorageInfo | `ctx.storageApi.invoke(appId, name, params)` → service-host 窗口 `file://` store(与 `*Sync` 同一 store),ack success/complete |
-| Host registry / Simulator window forward(其余) | getSystemInfo / chooseImage / login / fs.* / chooseMedia / … | 优先 `ctx.simulatorApis.invoke`;落空走 `forwardApiCallToSimulator`(`E.API_CALL` request/response,`API_CALL_TIMEOUT_MS` 超时) |
+| 类别 | 名字示例 | 路由 |
+| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Navigation Bar API(`NAV_BAR_API_NAMES`,5 个) | setNavigationBarTitle / setNavigationBarColor / show\|hideNavigationBarLoading / hideHomeButton | `simulatorWc.send(E.NAV_BAR)` → fire-and-forget `:ok` 回调(UI 异步更新) |
+| Route Action API(`NAV_ACTION_NAMES`,5 个) | navigateTo / navigateBack / redirectTo / reLaunch / switchTab | `simulatorWc.send(E.NAV_ACTION)` → DeviceShell 调 reducer + ack via `NAV_CALLBACK` |
+| TabBar Action API(`TAB_ACTION_NAMES`,8 个) | setTabBarStyle / setTabBarItem / show\|hideTabBar / set\|removeTabBarBadge / show\|hideTabBarRedDot | `simulatorWc.send(E.TAB_ACTION)` → applyTabAction + ack via `NAV_CALLBACK` |
+| Native HTTP API(`NATIVE_HTTP_API_NAMES`,2 个) | request / requestTaskAbort | 主进程 `main/services/native-request`(Node http/https)直接执行——不经渲染进程 `fetch()`,因此不触发 Chromium Fetch/CORS 的 OPTIONS 预检;结果经 `invokeSimulatorApiAndCallback` ack success/fail/complete,**不**落到下面的 simulator-window forward 兜底,也不占用 `NETWORK_BUDGET_SIMULATOR_APIS` 的转发看门狗 |
+| Storage 异步 API(`STORAGE_API_NAMES`,5 个) | setStorage / getStorage / removeStorage / clearStorage / getStorageInfo | `ctx.storageApi.invoke(appId, name, params)` → service-host 窗口 `file://` store(与 `*Sync` 同一 store),ack success/complete |
+| Host registry / Simulator window forward(其余,含 downloadFile / uploadFile) | getSystemInfo / chooseImage / login / fs.\* / chooseMedia / … | 优先 `ctx.simulatorApis.invoke`;落空走 `forwardApiCallToSimulator`(`E.API_CALL` request/response,`apiCallWatchdogMs` 超时——`downloadFile`/`uploadFile` 仍按 wx 超时预算 + 5s grace,其余 API 固定 5s) |
+
+`wx.request` 的两条入口共用 `nativeRequestOptions`:service 调用沿用 simulator 文档的相对 URL 基准和 Electron Session,preload 调用使用发送 frame 的文档 URL 与调用方 Session。主进程从既有 partition Referer 配置读取策略,不从当前全局项目猜测。默认超时覆盖整个跳转链与正文解码;支持 gzip/deflate/br 解压以及最多 20 次重定向,跨 origin 跳转移除凭据头。
+
+`NativeRequestTrace` 顺序为 `sent → redirect* → response? → finished/failed`。DevTools 合成 `dimina:http:` 请求 ID;重定向复用 ID 并携带 `redirectResponse`,清除前一跳 Payload 缓存。Response 与 Payload 观察数据各受 16 MiB 字符预算约束;超限省略正文,不影响业务返回,也不返回伪造的截断内容。
+
+preload 的 `RequestTask.abort()` 经 `dmb:native-request-abort` 取消;router 销毁时移除其挂在存活 WebContents 上的监听器。当前 service 上游 `request()` 仍未返回 RequestTask,`requestTaskAbort` 是主进程支持的取消入口,不能据此宣称 service JS 已具备 task 返回值。
container → service 的 lifecycle 消息(`PAGE_LIFECYCLE` channel → `handlePageLifecycle` → `forwardToService`,service `onMessage` 收)。`handlePageLifecycle` 只是把它收到的 `payload.event` 原样透传给 service,事件本身由 DeviceShell reducer 产出;reducer 的 `SideEffect` lifecycle 只覆盖 `pageShow | pageHide | pageUnload`:
-| event | 触发点 |
-|---|---|
-| pageShow | navigateBack 完成 / switchTab cache 命中 |
-| pageHide | navigateTo 完成 / switchTab 离开当前 tab |
-| pageUnload | navigateBack 弹栈 / redirectTo / reLaunch / switchTab 丢弃不属于任何 tab 子栈的页面 |
-| stackShow / stackHide | 声明保留(`PageLifecycleEvent` / `BridgeMessageType` 含此二项;reducer 与 main 不触发) |
-| appShow / appHide | DeviceShell reducer 不产出;改由主进程 `installAppLifecycleDriver` 按主窗口可见性直接 `forwardToService`(不走 `PAGE_LIFECYCLE`) |
+| event | 触发点 |
+| --------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
+| pageShow | navigateBack 完成 / switchTab cache 命中 |
+| pageHide | navigateTo 完成 / switchTab 离开当前 tab |
+| pageUnload | navigateBack 弹栈 / redirectTo / reLaunch / switchTab 丢弃不属于任何 tab 子栈的页面 |
+| stackShow / stackHide | 声明保留(`PageLifecycleEvent` / `BridgeMessageType` 含此二项;reducer 与 main 不触发) |
+| appShow / appHide | DeviceShell reducer 不产出;改由主进程 `installAppLifecycleDriver` 按主窗口可见性直接 `forwardToService`(不走 `PAGE_LIFECYCLE`) |
main ↔ simulator 的 `SIMULATOR_EVENTS`(`packages/dimina-electron-runtime/src/shared/bridge-channels.ts`):
```ts
SIMULATOR_EVENTS = {
- DOM_READY, // main → sim : renderHost domReady 转发,用于 mount 顺序协调
- NAV_BAR, // main → sim : 5 个 nav-bar 动态 API
- NAV_ACTION, // main → sim : 5 个路由 API
- TAB_ACTION, // main → sim : 8 个 tabBar 动态 API
- API_CALL, // main → sim : 兜底的 simulator-window-resident API call(带 requestId / timeout)
+ DOM_READY, // main → sim : renderHost domReady 转发,用于 mount 顺序协调
+ NAV_BAR, // main → sim : 5 个 nav-bar 动态 API
+ NAV_ACTION, // main → sim : 5 个路由 API
+ TAB_ACTION, // main → sim : 8 个 tabBar 动态 API
+ API_CALL, // main → sim : 兜底的 simulator-window-resident API call(带 requestId / timeout)
}
```
@@ -291,14 +298,14 @@ TabBar 同样由 DeviceShell 渲染(`tab-bar.tsx` + `tab-bar-state.ts`),
## 9. 文件清单
-| 文件 | 角色 |
-|---|---|
-| `src/service-host/preload.cjs` | service 窗口 preload:注入 `globalThis.DiminaServiceBridge`、`importScripts` shim、guest console 捕获、从 spawn URL 解 `apiNamespaces` 写 `globalThis.__diminaApiNamespaces` |
-| `src/render-host/preload.cjs` | render-host webview preload:注入 `window.DiminaRenderBridge`、`renderHostReady` 上报 |
-| `src/service-host/sync-api-patch.ts` | service.js 之后把 `*Sync` API patch 成 `sync-impls/` 本地实现 |
-| `packages/dimina-electron-runtime/src/shared/bridge-channels.ts` | `BridgeMessageType` / `BRIDGE_CHANNELS` / `SIMULATOR_EVENTS` / `TabBarConfig` 等协议常量;devtools 同名文件仅重导出 |
-| `packages/dimina-electron-runtime/src/main/ipc/bridge-router.ts` | 主进程 BridgeRouter:两级 session、`resourceLoaded` 聚合、`invokeAPI` 路由、生命周期转发、logic.js 注入;devtools 同名文件负责适配装配 |
-| `packages/dimina-electron-runtime/src/simulator-ui/navigation-bar.tsx` | NavigationBar 视觉实现(标题对齐 / 返回按钮 / loading / 颜色动画 / custom 隐藏) |
-| `packages/dimina-electron-runtime/src/simulator-ui/page-stack-controller.ts` | 页面栈纯 reducer(`navigateTo`/`Back`/`redirectTo`/`reLaunch`/`switchTab` → lifecycle/closePage effect) |
+| 文件 | 角色 |
+| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `src/service-host/preload.cjs` | service 窗口 preload:注入 `globalThis.DiminaServiceBridge`、`importScripts` shim、guest console 捕获、从 spawn URL 解 `apiNamespaces` 写 `globalThis.__diminaApiNamespaces` |
+| `src/render-host/preload.cjs` | render-host webview preload:注入 `window.DiminaRenderBridge`、`renderHostReady` 上报 |
+| `src/service-host/sync-api-patch.ts` | service.js 之后把 `*Sync` API patch 成 `sync-impls/` 本地实现 |
+| `packages/dimina-electron-runtime/src/shared/bridge-channels.ts` | `BridgeMessageType` / `BRIDGE_CHANNELS` / `SIMULATOR_EVENTS` / `TabBarConfig` 等协议常量;devtools 同名文件仅重导出 |
+| `packages/dimina-electron-runtime/src/main/ipc/bridge-router.ts` | 主进程 BridgeRouter:两级 session、`resourceLoaded` 聚合、`invokeAPI` 路由、生命周期转发、logic.js 注入;devtools 同名文件负责适配装配 |
+| `packages/dimina-electron-runtime/src/simulator-ui/navigation-bar.tsx` | NavigationBar 视觉实现(标题对齐 / 返回按钮 / loading / 颜色动画 / custom 隐藏) |
+| `packages/dimina-electron-runtime/src/simulator-ui/page-stack-controller.ts` | 页面栈纯 reducer(`navigateTo`/`Back`/`redirectTo`/`reLaunch`/`switchTab` → lifecycle/closePage effect) |
> 容器拓扑与 Session 细节见 [`./electron-container.md`](./electron-container.md);页面栈与 TabBar 见 [`./page-stack.md`](./page-stack.md) 与 [`./tab-bar.md`](./tab-bar.md);panel / toolbar 抽象见 [`./workbench-model.md`](./workbench-model.md)。
diff --git a/packages/devtools/e2e/native-host-network-response-body.spec.ts b/packages/devtools/e2e/native-host-network-response-body.spec.ts
index bb18e428..3dfe8ff1 100644
--- a/packages/devtools/e2e/native-host-network-response-body.spec.ts
+++ b/packages/devtools/e2e/native-host-network-response-body.spec.ts
@@ -1,43 +1,15 @@
/**
- * E2E (native-host): the right-panel Chrome DevTools "Network" panel can load a
- * response body for a mini-app `wx.request` call.
- *
- * Topology: `wx.request` issued by the service host is forwarded (via the
- * shared request-core / bridge-router — see request-statuscode.spec.ts) to a
- * real fetch executed in the SIMULATOR WebContents (the top-level DeviceShell
- * WebContentsView). The main process attaches a CDP debugger session to that
- * simulator wc, observes its `Network.*` events, rewrites each `requestId` to
- * a `dimina:sim:`-prefixed virtual id (so it can never collide with an id the
- * front-end's own natively-attached target — the service host — produces),
- * and re-injects the rewritten event into the right-panel DevTools front-end
- * via `window.DevToolsAPI.dispatchMessage`.
- *
- * That first leg (events arriving with a `dimina:sim:` id) already works and
- * is pinned by the first test below. The CONTRACT this spec exists to guard
- * is the second leg: when the user opens the Response tab for such a request,
- * the front-end sends `Network.getResponseBody({requestId: "dimina:sim:…"})`
- * back to whatever target `InspectorFrontendHost.sendMessageToBackend`
- * natively talks to (the service host's own CDP session). That target has
- * never heard of a `dimina:sim:` id — it belongs to a DIFFERENT wc's CDP
- * session — so the naive round-trip resolves with an error ("No resource
- * with given identifier found"), which is the regression this spec fails
- * red on. The fix intercepts `getResponseBody` calls for `dimina:sim:` ids in
- * the wrapped `sendMessageToBackend`, answers from a main-process prefetch
- * cache keyed by the virtual id, and replies through the same
- * `DevToolsAPI.dispatchMessage` channel the real backend would use.
- *
- * We can't read the closed-shadow Network panel UI, so — mirroring
- * native-host-devtools-elements.spec.ts / native-host-devtools-console.spec.ts
- * — we drive and observe the front-end's own CDP wire protocol directly:
- * wrap `DevToolsAPI.dispatchMessage` to capture every `Network.*` event and
- * every id-bearing reply, then issue the same `getResponseBody` command a
- * real Response-tab click would send.
+ * Real Electron Network round-trip: native wx.request emits dimina:http: events;
+ * renderer image loads retain dimina:sim: ids. The actual DevTools frontend
+ * sends body commands through its installed outbound hook and receives cached
+ * bytes through DevToolsAPI, exactly as its Response and Payload tabs do.
*/
import { test, expect, _electron, type ElectronApplication, type Page as PwPage } from '@playwright/test'
import http from 'http'
import type { AddressInfo } from 'net'
import path from 'path'
import fs from 'fs'
+import { gzipSync } from 'node:zlib'
import { fileURLToPath } from 'url'
import {
openProjectInUI,
@@ -63,12 +35,13 @@ interface CapturedCdpMessage {
id?: number
method?: string
params?: { requestId?: string; request?: { url?: string } }
- result?: { body?: string; base64Encoded?: boolean }
+ result?: { body?: string; base64Encoded?: boolean; postData?: string }
error?: { message?: string }
}
let server: http.Server
let baseUrl: string
+let preflightCount = 0
// A minimal valid 1x1 transparent PNG, hardcoded so the /img route needs no
// on-disk fixture. Its first bytes carry the PNG magic number (0x89 'P' 'N' 'G')
@@ -84,15 +57,28 @@ test.beforeAll(async () => {
'Access-Control-Allow-Headers': '*',
}
if (req.method === 'OPTIONS') {
- res.writeHead(204, cors)
+ preflightCount++
+ res.writeHead(405)
res.end()
return
}
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
+ if (url.pathname === '/redirect') {
+ res.writeHead(302, { location: `/compressed${url.search}` }); res.end(); return
+ }
+ if (url.pathname === '/compressed') {
+ res.writeHead(200, { 'content-type': 'application/json', 'content-encoding': 'gzip' })
+ res.end(gzipSync(JSON.stringify({ marker: url.searchParams.get('marker') }))); return
+ }
+ if (url.pathname === '/wait') return
if (url.pathname === '/echo') {
const marker = url.searchParams.get('marker') ?? ''
- res.writeHead(200, { ...cors, 'Content-Type': 'application/json' })
- res.end(JSON.stringify({ marker }))
+ const chunks: Buffer[] = []
+ req.on('data', (chunk: Buffer) => chunks.push(chunk))
+ req.on('end', () => {
+ res.writeHead(200, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify({ marker, body: Buffer.concat(chunks).toString(), method: req.method, origin: req.headers.origin ?? null, referer: req.headers.referer ?? null }))
+ })
return
}
if (url.pathname === '/img') {
@@ -233,11 +219,11 @@ async function readCaptured(app: ElectronApplication): Promise
m.method === 'Network.requestWillBeSent'
&& typeof m.params?.requestId === 'string'
- && m.params.requestId.startsWith('dimina:sim:')
+ && m.params.requestId.startsWith(prefix)
&& typeof m.params?.request?.url === 'string'
&& m.params.request.url.includes(urlSubstring),
)
@@ -296,7 +282,7 @@ test.describe('native-host DevTools Network panel loads a wx.request response bo
await shutdownApp(handle)
})
- test('a wx.request fired in the service host is forwarded to the front-end with a dimina:sim: request id', async () => {
+ test('a wx.request fired in the service host is forwarded to the front-end with a dimina:http: request id', async () => {
const { app } = handle!
requestToken = `net-body-${Date.now()}`
const url = `${baseUrl}/echo?marker=${requestToken}`
@@ -306,7 +292,7 @@ test.describe('native-host DevTools Network panel loads a wx.request response bo
const requestEvent = await pollUntil(
async () => {
const events = await readCaptured(app)
- return findRequestWillBeSent(events, requestToken)
+ return findRequestWillBeSent(events, requestToken, 'dimina:http:')
},
(evt) => !!evt,
20000,
@@ -314,7 +300,7 @@ test.describe('native-host DevTools Network panel loads a wx.request response bo
)
expect(
requestEvent,
- 'Network.requestWillBeSent for the request should reach the front-end with a dimina:sim: requestId',
+ 'Network.requestWillBeSent for the request should reach the front-end with a dimina:http: requestId',
).toBeTruthy()
capturedRequestId = requestEvent!.params!.requestId!
@@ -335,12 +321,13 @@ test.describe('native-host DevTools Network panel loads a wx.request response bo
const outcome = await outcomePromise
expect(outcome.path, `wx.request should resolve via success: ${JSON.stringify(outcome)}`).toBe('success')
expect(outcome.statusCode).toBe(200)
- expect(outcome.data).toEqual({ marker: requestToken })
+ const appId = JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, 'project.config.json'), 'utf8')).appid
+ expect(outcome.data).toEqual({ marker: requestToken, body: '', method: 'GET', origin: null, referer: `https://servicewechat.com/${appId}/develop/page-frame.html` })
})
- test('Network.getResponseBody for that dimina:sim: id resolves with the real response body, not an error', async () => {
+ test('Network.getResponseBody for that dimina:http: id resolves with the real response body, not an error', async () => {
const { app } = handle!
- expect(capturedRequestId, 'the previous test must have captured a dimina:sim: requestId').toBeTruthy()
+ expect(capturedRequestId, 'the previous test must have captured a dimina:http: requestId').toBeTruthy()
await evalInDevtools(
app,
@@ -367,7 +354,7 @@ test.describe('native-host DevTools Network panel loads a wx.request response bo
).toBeTruthy()
expect(
reply?.error,
- `Network.getResponseBody for a dimina:sim: id must not error; got: ${JSON.stringify(reply?.error)}`,
+ `Network.getResponseBody for a dimina:http: id must not error; got: ${JSON.stringify(reply?.error)}`,
).toBeUndefined()
expect(
reply?.result,
@@ -381,6 +368,65 @@ test.describe('native-host DevTools Network panel loads a wx.request response bo
).toContain(requestToken)
})
+ test('a custom-header POST has no preflight and its Payload is readable after completion', async () => {
+ const { app } = handle!
+ const marker = `post-body-${Date.now()}`
+ const url = `${baseUrl}/echo?marker=${marker}`
+ const outcome = await evalInWebContentsByUrl(app, 'service.html', `new Promise((resolve) => {
+ wx.request({ url: ${JSON.stringify(url)}, method: 'POST', header: { 'x-native-check': 'yes' }, data: { value: 42 },
+ success: (r) => resolve({ path: 'success', statusCode: r.statusCode, data: r.data }),
+ fail: (e) => resolve({ path: 'fail', errMsg: e.errMsg }) })
+ })`)
+ expect(outcome).toMatchObject({ path: 'success', statusCode: 200, data: { body: '{"value":42}', origin: null, method: 'POST' } })
+ expect(preflightCount).toBe(0)
+ const request = await pollUntil(async () => findRequestWillBeSent(await readCaptured(app), marker, 'dimina:http:'), (value) => !!value, 20000, 300)
+ expect(request).toBeTruthy()
+ const id = 424244
+ await evalInDevtools(app, `globalThis.InspectorFrontendHost.sendMessageToBackend(${JSON.stringify(JSON.stringify({ id, method: 'Network.getRequestPostData', params: { requestId: request!.params!.requestId } }))})`)
+ const reply = await pollUntil(async () => findReply(await readCaptured(app), id), (value) => !!value, 20000, 300)
+ expect(reply?.error).toBeUndefined()
+ expect(reply?.result).toEqual({ postData: '{"value":42}' })
+ })
+
+ test('redirected compressed data matches the response returned through the actual Network body hook', async () => {
+ const { app } = handle!
+ const marker = `redirect-${Date.now()}`
+ const outcome = await evalInWebContentsByUrl(app, 'service.html', `new Promise((resolve) => {
+ wx.request({ url: ${JSON.stringify(`${baseUrl}/redirect?marker=${marker}`)},
+ success: (r) => resolve({ path: 'success', statusCode: r.statusCode, data: r.data }),
+ fail: (e) => resolve({ path: 'fail', errMsg: e.errMsg }) })
+ })`)
+ expect(outcome).toMatchObject({ path: 'success', statusCode: 200, data: { marker } })
+ const request = await pollUntil(async () => findRequestWillBeSent(await readCaptured(app), marker, 'dimina:http:'), (value) => !!value, 20000, 300)
+ expect(request).toBeTruthy()
+ const id = 424245
+ await evalInDevtools(app, `globalThis.InspectorFrontendHost.sendMessageToBackend(${JSON.stringify(JSON.stringify({ id, method: 'Network.getResponseBody', params: { requestId: request!.params!.requestId } }))})`)
+ const reply = await pollUntil(async () => findReply(await readCaptured(app), id), (value) => !!value, 20000, 300)
+ expect(reply?.error).toBeUndefined()
+ expect(JSON.parse(decodeBody(reply!.result!))).toEqual({ marker })
+ })
+
+ test('the preload request task aborts through IPC and invokes fail then complete once', async () => {
+ const outcome = await evalInSimulator(handle!.app, `new Promise((resolve, reject) => {
+ const events = [];
+ const task = wx.request({ url: ${JSON.stringify(`${baseUrl}/wait`)}, timeout: 1000,
+ success: () => events.push('success'), fail: (e) => events.push(e.errMsg),
+ complete: () => { events.push('complete'); resolve(events); } });
+ if (!task || typeof task.abort !== 'function') { reject(new Error('missing preload RequestTask')); return; }
+ task.abort();
+ })`)
+ expect(outcome).toEqual(['request:fail abort', 'complete'])
+ })
+
+ test('the preload resolves a document-relative request using its own document base', async () => {
+ const outcome = await evalInSimulator(handle!.app, `new Promise((resolve) => {
+ wx.request({ url: './index.html', dataType: 'text',
+ success: (r) => resolve({ status: r.statusCode, html: typeof r.data === 'string' && / resolve({ errMsg: e.errMsg }) });
+ })`)
+ expect(outcome).toEqual({ status: 200, html: true })
+ })
+
test('a render-guest image load is forwarded with a dimina:sim: id and its body is retrievable', async () => {
const { app } = handle!
const imgToken = `img-body-${Date.now()}`
diff --git a/packages/devtools/src/main/app/window-runtime-services.ts b/packages/devtools/src/main/app/window-runtime-services.ts
index f7e390f4..28e64e7e 100644
--- a/packages/devtools/src/main/app/window-runtime-services.ts
+++ b/packages/devtools/src/main/app/window-runtime-services.ts
@@ -1,40 +1,49 @@
-import type { BrowserWindow } from 'electron'
-import type { ConnectionRegistry, DisposableRegistry } from '@dimina-kit/electron-deck/main'
-import type { SyncStorageChange } from '../../shared/ipc-channels.js'
-import type { SenderPolicy } from '../utils/ipc-registry.js'
-import type { BridgeRouterHandle } from '../ipc/bridge-router.js'
-import type { CdpSessionBroker } from '../services/cdp-session/index.js'
-import type { InternalDevtoolsWindow } from '../windows/internal-devtools-window/index.js'
-import type { NetworkForwarder } from '../services/network-forward/index.js'
-import type { AppDataTap } from '../services/simulator-appdata/index.js'
-import type { StorageApi } from '../services/simulator-storage/index.js'
-import type { WorkspaceService } from '../services/workspace/workspace-service.js'
-import { resolveNativeAppDataKeys, resolveNativeStorageOverview } from './native-overview.js'
-import { registerMcpWindow, noteActiveBridgeId } from '../services/mcp/index.js'
-import { createRenderInspector } from '../services/render-inspect/index.js'
-import { setupSimulatorStorage } from '../services/simulator-storage/index.js'
-import { createNetworkForwarder } from '../services/network-forward/index.js'
-import { setupSimulatorWxml } from '../services/simulator-wxml/index.js'
-import { setupSimulatorAppData } from '../services/simulator-appdata/index.js'
-import { setupSimulatorCurrentPage } from '../services/simulator-current-page/index.js'
-import { toDisposable } from '@dimina-kit/electron-deck/main'
+import type { BrowserWindow } from "electron";
+import type {
+ ConnectionRegistry,
+ DisposableRegistry,
+} from "@dimina-kit/electron-deck/main";
+import type { SyncStorageChange } from "../../shared/ipc-channels.js";
+import type { SenderPolicy } from "../utils/ipc-registry.js";
+import type { BridgeRouterHandle } from "../ipc/bridge-router.js";
+import type { CdpSessionBroker } from "../services/cdp-session/index.js";
+import type { InternalDevtoolsWindow } from "../windows/internal-devtools-window/index.js";
+import type { NetworkForwarder } from "../services/network-forward/index.js";
+import type { AppDataTap } from "../services/simulator-appdata/index.js";
+import type { StorageApi } from "../services/simulator-storage/index.js";
+import type { WorkspaceService } from "../services/workspace/workspace-service.js";
+import {
+ resolveNativeAppDataKeys,
+ resolveNativeStorageOverview,
+} from "./native-overview.js";
+import {
+ registerMcpWindow,
+ noteActiveBridgeId,
+} from "../services/mcp/index.js";
+import { createRenderInspector } from "../services/render-inspect/index.js";
+import { setupSimulatorStorage } from "../services/simulator-storage/index.js";
+import { createNetworkForwarder } from "../services/network-forward/index.js";
+import { setupSimulatorWxml } from "../services/simulator-wxml/index.js";
+import { setupSimulatorAppData } from "../services/simulator-appdata/index.js";
+import { setupSimulatorCurrentPage } from "../services/simulator-current-page/index.js";
+import { toDisposable } from "@dimina-kit/electron-deck/main";
/**
* Narrow view of the context fields these services read, plus the four fields
* they publish back onto it for bridge-router to consume.
*/
export interface WindowRuntimeContext {
- registry: DisposableRegistry
- connections: ConnectionRegistry
- cdpSessionBroker: CdpSessionBroker
- senderPolicy: SenderPolicy
- workspace: WorkspaceService
- bridge?: BridgeRouterHandle
- internalDevtoolsWindow?: InternalDevtoolsWindow
- storageApi?: StorageApi
- onServiceStorageChanged?: (appId: string, change: SyncStorageChange) => void
- networkForward?: NetworkForwarder
- appData?: AppDataTap
+ registry: DisposableRegistry;
+ connections: ConnectionRegistry;
+ cdpSessionBroker: CdpSessionBroker;
+ senderPolicy: SenderPolicy;
+ workspace: WorkspaceService;
+ bridge?: BridgeRouterHandle;
+ internalDevtoolsWindow?: InternalDevtoolsWindow;
+ storageApi?: StorageApi;
+ onServiceStorageChanged?: (appId: string, change: SyncStorageChange) => void;
+ networkForward?: NetworkForwarder;
+ appData?: AppDataTap;
}
/**
@@ -42,12 +51,14 @@ export interface WindowRuntimeContext {
* native-host WXML/element-inspect services (which scope the active render
* guest by appId) and the editor's per-project workspace identity.
*/
-export function createActiveAppIdResolver(context: Pick): () => string | null {
+export function createActiveAppIdResolver(
+ context: Pick,
+): () => string | null {
return () => {
- const session = context.workspace.getSession()
- const appInfo = session?.appInfo as { appId?: string } | undefined
- return appInfo?.appId ?? null
- }
+ const session = context.workspace.getSession();
+ const appInfo = session?.appInfo as { appId?: string } | undefined;
+ return appInfo?.appId ?? null;
+ };
}
/**
@@ -72,8 +83,8 @@ export function setupWindowRuntimeServices(
nativeOverviewProvider: null,
projectPath,
getAppId: getActiveAppId,
- })
- context.registry.add(mcpWindow.dispose)
+ });
+ context.registry.add(mcpWindow.dispose);
// Native-host: the real mini-app page runs in a nested render-host
// guest, not the localhost:7788 shell. Point the MCP
@@ -81,36 +92,41 @@ export function setupWindowRuntimeServices(
// the visible page across navigation/tab switches. Only wired under
// native-host so the default path stays byte-identical.
if (context.bridge?.isNativeHost()) {
- mcpWindow.facts.nativeHost = true
+ mcpWindow.facts.nativeHost = true;
mcpWindow.facts.nativeOverviewProvider = async () => {
- const appId = getActiveAppId()
- const stack = context.bridge?.getPageStack?.(appId ?? undefined) ?? []
- const top = stack[stack.length - 1]
+ const appId = getActiveAppId();
+ const stack = context.bridge?.getPageStack?.(appId ?? undefined) ?? [];
+ const top = stack[stack.length - 1];
const overview = {
currentRoute: top?.pagePath ?? null,
pageStackDepth: stack.length,
storageKeys: [] as string[],
storageCount: 0,
appDataKeys: [] as string[],
- }
+ };
if (appId) {
- const storage = await resolveNativeStorageOverview(context, appId)
- overview.storageKeys = storage.storageKeys
- overview.storageCount = storage.storageCount
- overview.appDataKeys = resolveNativeAppDataKeys(context, appId)
+ const storage = await resolveNativeStorageOverview(context, appId);
+ overview.storageKeys = storage.storageKeys;
+ overview.storageCount = storage.storageCount;
+ overview.appDataKeys = resolveNativeAppDataKeys(context, appId);
}
- return overview
- }
- const off = context.bridge.onRenderEvent((ev) => noteActiveBridgeId(context, ev.bridgeId))
- context.registry.add(off)
+ return overview;
+ };
+ const off = context.bridge.onRenderEvent((ev) =>
+ noteActiveBridgeId(context, ev.bridgeId),
+ );
+ context.registry.add(off);
}
// Native-host inspector: injects the render-guest IIFE and drives WXML /
// element-highlight against the active render-host . Reused by
// the storage panel (element inspect) and the WXML panel service.
- const renderInspector = createRenderInspector({ connections: context.connections, broker: context.cdpSessionBroker })
+ const renderInspector = createRenderInspector({
+ connections: context.connections,
+ broker: context.cdpSessionBroker,
+ });
const storage = setupSimulatorStorage(mainWindow.webContents, {
senderPolicy: context.senderPolicy,
@@ -131,18 +147,22 @@ export function setupWindowRuntimeServices(
// read/write storage from the service-host window's file:// store.
bridge: context.bridge,
renderInspector,
- })
- context.registry.add(storage)
+ });
+ context.registry.add(storage);
// Native-host: expose the async-storage runtime hook so bridge-router
// routes async wx.setStorage/etc. to the unified service-host store.
if (storage.storageApi) {
- context.storageApi = storage.storageApi
- context.registry.add(() => { context.storageApi = undefined })
+ context.storageApi = storage.storageApi;
+ context.registry.add(() => {
+ context.storageApi = undefined;
+ });
// SYNC wx storage writes bypass main (they hit the service-host localStorage
// directly); the service-host posts `storageChanged` and bridge-router routes
// it here so the Storage panel stays live without a manual reload.
- context.onServiceStorageChanged = storage.onSyncStorageChange
- context.registry.add(() => { context.onServiceStorageChanged = undefined })
+ context.onServiceStorageChanged = storage.onSyncStorageChange;
+ context.registry.add(() => {
+ context.onServiceStorageChanged = undefined;
+ });
}
// Native-host WXML + AppData panels: main sources the data (WXML pulled
@@ -160,7 +180,8 @@ export function setupWindowRuntimeServices(
// DevTools host exist; getServiceWc here is the fallback sink target.
const networkForward = createNetworkForwarder({
getServiceWc: (appId) => context.bridge?.getServiceWc(appId) ?? null,
- getResourceServerBaseUrl: () => context.bridge?.getResourceBaseUrl?.() ?? null,
+ getResourceServerBaseUrl: () =>
+ context.bridge?.getResourceBaseUrl?.() ?? null,
// The simulator shell's own static-asset server (serves simulator.html
// + its JS/CSS, independent from the resource server above — see
// NetworkForwarderBridge.getSimulatorServerBaseUrl's doc). Host is
@@ -168,58 +189,83 @@ export function setupWindowRuntimeServices(
// buildSimulatorUrlFromSpec default. Absent (null port) when no
// project is open.
getSimulatorServerBaseUrl: () => {
- const port = context.workspace?.getSession()?.port
- return typeof port === 'number' ? `http://localhost:${port}/` : null
+ const port = context.workspace?.getSession()?.port;
+ return typeof port === "number" ? `http://localhost:${port}/` : null;
},
connections: context.connections,
broker: context.cdpSessionBroker,
- })
- context.networkForward = networkForward
- context.registry.add(networkForward)
- context.registry.add(() => { context.networkForward = undefined })
+ });
+ context.networkForward = networkForward;
+ context.registry.add(networkForward);
+ context.registry.add(() => {
+ context.networkForward = undefined;
+ });
// Global mirror: once the standalone internal
// DevTools window builds its own front-end host, mirror the full
// unfiltered Network stream into it. Attached AFTER context.networkForward
// is assigned above — the callback re-reads the mutable field on every
// fire, so ordering only matters for readability here, not correctness.
- context.registry.add(toDisposable(
- context.internalDevtoolsWindow?.onHostChanged((hostWc) => {
- context.networkForward?.setGlobalDevtoolsHost(hostWc)
- }) ?? (() => {}),
- ))
+ context.registry.add(
+ toDisposable(
+ context.internalDevtoolsWindow?.onHostChanged((hostWc) => {
+ context.networkForward?.setGlobalDevtoolsHost(hostWc);
+ }) ?? (() => {}),
+ ),
+ );
// Main-process WebSocket traffic (wx.connectSocket runs on the Node `ws`
// transport, invisible to any webContents debugger): bridge-router fans
// the trace stream out here, and the forwarder synthesizes it into
// Network.webSocket* CDP events for the same Network panel sinks.
- context.registry.add(toDisposable(
- context.bridge?.onNativeWebSocketTrace?.((ownerId, event) => {
- context.networkForward?.reportWebSocketTrace(ownerId, event)
- }) ?? (() => {}),
- ))
+ context.registry.add(
+ toDisposable(
+ context.bridge?.onNativeWebSocketTrace?.((ownerId, event) => {
+ context.networkForward?.reportWebSocketTrace(ownerId, event);
+ }) ?? (() => {}),
+ ),
+ );
- context.registry.add(setupSimulatorWxml(mainWindow.webContents, {
- senderPolicy: context.senderPolicy,
- bridge: context.bridge,
- inspector: renderInspector,
- getActiveAppId,
- }))
+ // Main-process HTTP traffic (wx.request runs on Node http/https,
+ // invisible to any webContents debugger — that's the whole point of the
+ // migration off renderer `fetch()`, since Chromium's Fetch/CORS algorithm
+ // was attaching a spurious OPTIONS preflight to it): same trace-stream
+ // fan-out as the WebSocket case above.
+ context.registry.add(
+ toDisposable(
+ context.bridge?.onNativeRequestTrace?.((ownerId, event) => {
+ context.networkForward?.reportNativeRequestTrace(ownerId, event);
+ }) ?? (() => {}),
+ ),
+ );
+
+ context.registry.add(
+ setupSimulatorWxml(mainWindow.webContents, {
+ senderPolicy: context.senderPolicy,
+ bridge: context.bridge,
+ inspector: renderInspector,
+ getActiveAppId,
+ }),
+ );
const appDataService = setupSimulatorAppData(mainWindow.webContents, {
senderPolicy: context.senderPolicy,
getActiveAppId,
// AppData-panel edit write-back target: the service-host window owning
// the edited page bridge.
bridge: context.bridge,
- })
+ });
// bridge-router feeds this via ctx.appData (service→render tap + evict).
- context.appData = appDataService
- context.registry.add(appDataService)
- context.registry.add(() => { context.appData = undefined })
+ context.appData = appDataService;
+ context.registry.add(appDataService);
+ context.registry.add(() => {
+ context.appData = undefined;
+ });
// Push the visible page route to the toolbar on every navigation (the
// page stack lives in the DeviceShell WCV, invisible to renderer
// nav events).
- context.registry.add(setupSimulatorCurrentPage(mainWindow.webContents, {
- bridge: context.bridge,
- }))
+ context.registry.add(
+ setupSimulatorCurrentPage(mainWindow.webContents, {
+ bridge: context.bridge,
+ }),
+ );
}
}
diff --git a/packages/devtools/src/main/ipc/bridge-router-api-fail-passthrough.test.ts b/packages/devtools/src/main/ipc/bridge-router-api-fail-passthrough.test.ts
index e456087c..1eeda0b5 100644
--- a/packages/devtools/src/main/ipc/bridge-router-api-fail-passthrough.test.ts
+++ b/packages/devtools/src/main/ipc/bridge-router-api-fail-passthrough.test.ts
@@ -38,14 +38,29 @@ const stubs = vi.hoisted(() => {
const listeners: EventBag = {}
const api = {
listeners,
- on(event: string, fn: AnyFn) { (listeners[event] ??= new Set()).add(fn); return api },
+ on(event: string, fn: AnyFn) {
+ ;(listeners[event] ??= new Set()).add(fn)
+ return api
+ },
once(event: string, fn: AnyFn) {
- const wrap: AnyFn = (...a: unknown[]) => { listeners[event]?.delete(wrap); return fn(...a) }
- ;(listeners[event] ??= new Set()).add(wrap); return api
+ const wrap: AnyFn = (...a: unknown[]) => {
+ listeners[event]?.delete(wrap)
+ return fn(...a)
+ }
+ ;(listeners[event] ??= new Set()).add(wrap)
+ return api
+ },
+ off(event: string, fn: AnyFn) {
+ listeners[event]?.delete(fn)
+ return api
+ },
+ removeListener(event: string, fn: AnyFn) {
+ listeners[event]?.delete(fn)
+ return api
+ },
+ emit(event: string, ...a: unknown[]) {
+ for (const fn of [...(listeners[event] ?? [])]) fn(...a)
},
- off(event: string, fn: AnyFn) { listeners[event]?.delete(fn); return api },
- removeListener(event: string, fn: AnyFn) { listeners[event]?.delete(fn); return api },
- emit(event: string, ...a: unknown[]) { for (const fn of [...(listeners[event] ?? [])]) fn(...a) },
}
return api
}
@@ -59,10 +74,14 @@ const stubs = vi.hoisted(() => {
...em,
id: nextWcId++,
destroyed: false,
- isDestroyed() { return this.destroyed },
+ isDestroyed() {
+ return this.destroyed
+ },
getURL: () => 'about:blank',
getType: () => 'window',
- send: vi.fn((channel: string, payload: unknown) => { sent.push({ channel, payload }) }),
+ send: vi.fn((channel: string, payload: unknown) => {
+ sent.push({ channel, payload })
+ }),
executeJavaScript: vi.fn(() => Promise.resolve(undefined)),
openDevTools: vi.fn(),
sentMessages: sent,
@@ -77,8 +96,12 @@ const stubs = vi.hoisted(() => {
...em,
webContents: makeWebContents(),
destroyed: false,
- isDestroyed() { return this.destroyed },
- close: vi.fn(function (this: { destroyed: boolean }) { this.destroyed = true }),
+ isDestroyed() {
+ return this.destroyed
+ },
+ close: vi.fn(function (this: { destroyed: boolean }) {
+ this.destroyed = true
+ }),
loadURL: vi.fn(() => Promise.resolve()),
loadFile: vi.fn(() => Promise.resolve()),
}
@@ -91,7 +114,15 @@ const stubs = vi.hoisted(() => {
nextWcId = 8000
}
- return { onListeners, invokeHandlers, wcById, makeEmitter, makeWebContents, makeBrowserWindow, reset }
+ return {
+ onListeners,
+ invokeHandlers,
+ wcById,
+ makeEmitter,
+ makeWebContents,
+ makeBrowserWindow,
+ reset,
+ }
})
vi.mock('electron', () => {
@@ -99,16 +130,26 @@ vi.mock('electron', () => {
const ipcMain = {
on: vi.fn((channel: string, fn: AnyFn) => {
- ;(stubs.onListeners.get(channel) ?? stubs.onListeners.set(channel, new Set()).get(channel)!).add(fn)
+ ;(
+ stubs.onListeners.get(channel) ?? stubs.onListeners.set(channel, new Set()).get(channel)!
+ ).add(fn)
}),
removeListener: vi.fn((channel: string, fn: AnyFn) => {
stubs.onListeners.get(channel)?.delete(fn)
}),
- handle: vi.fn((channel: string, fn: AnyFn) => { stubs.invokeHandlers.set(channel, fn) }),
- removeHandler: vi.fn((channel: string) => { stubs.invokeHandlers.delete(channel) }),
+ handle: vi.fn((channel: string, fn: AnyFn) => {
+ stubs.invokeHandlers.set(channel, fn)
+ }),
+ removeHandler: vi.fn((channel: string) => {
+ stubs.invokeHandlers.delete(channel)
+ }),
}
- const protocolStub = { handle: vi.fn(), unhandle: vi.fn(), registerSchemesAsPrivileged: vi.fn() }
+ const protocolStub = {
+ handle: vi.fn(),
+ unhandle: vi.fn(),
+ registerSchemesAsPrivileged: vi.fn(),
+ }
const sessionStub = {
fromPartition: vi.fn(() => ({
webRequest: { onBeforeSendHeaders: vi.fn(), onHeadersReceived: vi.fn() },
@@ -120,12 +161,23 @@ vi.mock('electron', () => {
return {
ipcMain,
- app: { isPackaged: true, getLocale: () => 'en-US', getPath: vi.fn(() => '/tmp/dimina-test-userdata') },
+ app: {
+ isPackaged: true,
+ getLocale: () => 'en-US',
+ getPath: vi.fn(() => '/tmp/dimina-test-userdata'),
+ },
BrowserWindow: class {},
- WebContentsView: class { webContents = {}; setBounds = vi.fn(); setBackgroundColor = vi.fn() },
+ WebContentsView: class {
+ webContents = {}
+ setBounds = vi.fn()
+ setBackgroundColor = vi.fn()
+ },
protocol: protocolStub,
session: sessionStub,
- webContents: { fromId: vi.fn(() => null), getAllWebContents: vi.fn(() => []) },
+ webContents: {
+ fromId: vi.fn(() => null),
+ getAllWebContents: vi.fn(() => []),
+ },
nativeTheme: { themeSource: 'system', on: vi.fn() },
default: {},
}
@@ -142,7 +194,13 @@ vi.mock('@dimina-kit/electron-runtime/main/service-host-window', () => ({
}))
import { BRIDGE_CHANNELS as C } from '../../shared/bridge-channels.js'
-import type { ApiResponsePayload, MessageEnvelope, ServiceInvokePayload, SpawnRequest, SpawnResult } from '../../shared/bridge-channels.js'
+import type {
+ ApiResponsePayload,
+ MessageEnvelope,
+ ServiceInvokePayload,
+ SpawnRequest,
+ SpawnResult,
+} from '../../shared/bridge-channels.js'
import type { WorkbenchContext } from '../services/workbench-context.js'
import { createConnectionRegistry } from '@dimina-kit/electron-deck/main'
@@ -172,14 +230,22 @@ function makeCtx(): { ctx: WorkbenchContext; simulatorWc: MockWc } {
const ctx = {
registry: { add: (_fn: AnyFn) => {} },
connections: createConnectionRegistry(),
- simulatorApis: { has: (_name: string) => false, invoke: async () => ({}), list: () => [] },
- windows: { mainWindow: { webContents: simulatorWc, isDestroyed: () => false } },
+ simulatorApis: {
+ has: (_name: string) => false,
+ invoke: async () => ({}),
+ list: () => [],
+ },
+ windows: {
+ mainWindow: { webContents: simulatorWc, isDestroyed: () => false },
+ },
workspace: { getSession: () => undefined },
} as unknown as WorkbenchContext
return { ctx, simulatorWc }
}
-async function spawnSession(simulatorWc: MockWc): Promise<{ result: SpawnResult; serviceWc: MockWc }> {
+async function spawnSession(
+ simulatorWc: MockWc,
+): Promise<{ result: SpawnResult; serviceWc: MockWc }> {
const handle = stubs.invokeHandlers.get(C.SPAWN)
if (!handle) throw new Error('SPAWN handler not registered')
const req: SpawnRequest = {
@@ -197,16 +263,21 @@ async function spawnSession(simulatorWc: MockWc): Promise<{ result: SpawnResult;
return { result, serviceWc: serviceWc as unknown as MockWc }
}
-/** Forward an ordinary (non-persistent) `request` invokeAPI from the service. */
-function forwardRequestCall(serviceWc: MockWc, callbacks: {
- success?: unknown; complete?: unknown; fail?: unknown
-}): void {
+/** Forward an ordinary (non-persistent) `downloadFile` invokeAPI from the service. */
+function forwardDownloadFileCall(
+ serviceWc: MockWc,
+ callbacks: {
+ success?: unknown
+ complete?: unknown
+ fail?: unknown
+ },
+): void {
const msg: MessageEnvelope = {
type: 'invokeAPI',
target: 'container',
body: {
- name: 'request',
- params: { url: 'https://example.com/api', ...callbacks },
+ name: 'downloadFile',
+ params: { url: 'https://example.com/file.bin', ...callbacks },
},
}
const payload: ServiceInvokePayload = { msg }
@@ -228,11 +299,20 @@ function triggerCallbacks(serviceWc: MockWc): Array<{ id: unknown; args: unknown
.map(m => m.body as { id: unknown; args: unknown })
}
-async function setup(): Promise<{ ctx: WorkbenchContext; simulatorWc: MockWc; serviceWc: MockWc; requestId: string }> {
+async function setup(): Promise<{
+ ctx: WorkbenchContext
+ simulatorWc: MockWc
+ serviceWc: MockWc
+ requestId: string
+}> {
const { ctx, simulatorWc } = makeCtx()
installBridgeRouter(ctx)
const { serviceWc } = await spawnSession(simulatorWc)
- forwardRequestCall(serviceWc, { success: 'svc-success', complete: 'svc-complete', fail: 'svc-fail' })
+ forwardDownloadFileCall(serviceWc, {
+ success: 'svc-success',
+ complete: 'svc-complete',
+ fail: 'svc-fail',
+ })
const requestId = forwardedRequestId(simulatorWc)
return { ctx, simulatorWc, serviceWc, requestId }
}
@@ -241,7 +321,12 @@ describe('bridge-router — handleApiResponse fail path transparently forwards `
it('an ok:false response carries every `result` field (e.g. errno) through to the fail callback args', async () => {
const { simulatorWc, serviceWc, requestId } = await setup()
- const resp: ApiResponsePayload = { appSessionId: 'demo-app', requestId, ok: false, result: { errMsg: '', errno: 5 } }
+ const resp: ApiResponsePayload = {
+ appSessionId: 'demo-app',
+ requestId,
+ ok: false,
+ result: { errMsg: '', errno: 5 },
+ }
emitOn(C.API_RESPONSE, simulatorWc, resp)
const cbs = triggerCallbacks(serviceWc)
@@ -256,7 +341,13 @@ describe('bridge-router — handleApiResponse fail path transparently forwards `
// (an h2 response with no statusText stringifies to errMsg: '').
const { simulatorWc, serviceWc, requestId } = await setup()
- const resp: ApiResponsePayload = { appSessionId: 'demo-app', requestId, ok: false, errMsg: '', result: { errMsg: '', errno: 5 } }
+ const resp: ApiResponsePayload = {
+ appSessionId: 'demo-app',
+ requestId,
+ ok: false,
+ errMsg: '',
+ result: { errMsg: '', errno: 5 },
+ }
emitOn(C.API_RESPONSE, simulatorWc, resp)
const cbs = triggerCallbacks(serviceWc)
@@ -264,7 +355,7 @@ describe('bridge-router — handleApiResponse fail path transparently forwards `
const args = failFire!.args as { errMsg?: string; errno?: number }
expect(typeof args.errMsg).toBe('string')
expect(args.errMsg).not.toBe('')
- expect(args.errMsg).toBe('request:fail')
+ expect(args.errMsg).toBe('downloadFile:fail')
expect(args.errno).toBe(5)
})
})
@@ -274,7 +365,10 @@ describe('bridge-router — handleApiResponse fail path errMsg resolution priori
const { simulatorWc, serviceWc, requestId } = await setup()
const resp: ApiResponsePayload = {
- appSessionId: 'demo-app', requestId, ok: false, errMsg: 'top-level failure',
+ appSessionId: 'demo-app',
+ requestId,
+ ok: false,
+ errMsg: 'top-level failure',
result: { errMsg: 'ignored-nested-message', errno: 1 },
}
emitOn(C.API_RESPONSE, simulatorWc, resp)
@@ -291,7 +385,9 @@ describe('bridge-router — handleApiResponse fail path errMsg resolution priori
const { simulatorWc, serviceWc, requestId } = await setup()
const resp: ApiResponsePayload = {
- appSessionId: 'demo-app', requestId, ok: false,
+ appSessionId: 'demo-app',
+ requestId,
+ ok: false,
result: { errMsg: 'nested failure reason' },
}
emitOn(C.API_RESPONSE, simulatorWc, resp)
@@ -304,13 +400,18 @@ describe('bridge-router — handleApiResponse fail path errMsg resolution priori
it('no usable errMsg anywhere falls back to `${name}:fail`, and any surviving result fields still pass through', async () => {
const { simulatorWc, serviceWc, requestId } = await setup()
- const resp: ApiResponsePayload = { appSessionId: 'demo-app', requestId, ok: false, result: { errno: 9 } }
+ const resp: ApiResponsePayload = {
+ appSessionId: 'demo-app',
+ requestId,
+ ok: false,
+ result: { errno: 9 },
+ }
emitOn(C.API_RESPONSE, simulatorWc, resp)
const cbs = triggerCallbacks(serviceWc)
const failFire = cbs.find(c => c.id === 'svc-fail')
const args = failFire!.args as { errMsg?: string; errno?: number }
- expect(args.errMsg).toBe('request:fail')
+ expect(args.errMsg).toBe('downloadFile:fail')
expect(args.errno).toBe(9)
})
})
@@ -319,7 +420,12 @@ describe('bridge-router — handleApiResponse fail path complete parity', () =>
it('complete fires with the identical object the fail callback received', async () => {
const { simulatorWc, serviceWc, requestId } = await setup()
- const resp: ApiResponsePayload = { appSessionId: 'demo-app', requestId, ok: false, result: { errno: 5 } }
+ const resp: ApiResponsePayload = {
+ appSessionId: 'demo-app',
+ requestId,
+ ok: false,
+ result: { errno: 5 },
+ }
emitOn(C.API_RESPONSE, simulatorWc, resp)
const cbs = triggerCallbacks(serviceWc)
@@ -334,7 +440,12 @@ describe('bridge-router — handleApiResponse ok:true path is unaffected (regres
it('an ok:true response still delivers `result` unchanged to the success callback', async () => {
const { simulatorWc, serviceWc, requestId } = await setup()
- const resp: ApiResponsePayload = { appSessionId: 'demo-app', requestId, ok: true, result: { data: { a: 1 }, statusCode: 200 } }
+ const resp: ApiResponsePayload = {
+ appSessionId: 'demo-app',
+ requestId,
+ ok: true,
+ result: { data: { a: 1 }, statusCode: 200 },
+ }
emitOn(C.API_RESPONSE, simulatorWc, resp)
const cbs = triggerCallbacks(serviceWc)
diff --git a/packages/devtools/src/main/ipc/bridge-router-request-watchdog.test.ts b/packages/devtools/src/main/ipc/bridge-router-request-watchdog.test.ts
index 0837e33b..2ebc8722 100644
--- a/packages/devtools/src/main/ipc/bridge-router-request-watchdog.test.ts
+++ b/packages/devtools/src/main/ipc/bridge-router-request-watchdog.test.ts
@@ -32,14 +32,29 @@ const stubs = vi.hoisted(() => {
const listeners: EventBag = {}
const api = {
listeners,
- on(event: string, fn: AnyFn) { (listeners[event] ??= new Set()).add(fn); return api },
+ on(event: string, fn: AnyFn) {
+ ;(listeners[event] ??= new Set()).add(fn)
+ return api
+ },
once(event: string, fn: AnyFn) {
- const wrap: AnyFn = (...a: unknown[]) => { listeners[event]?.delete(wrap); return fn(...a) }
- ;(listeners[event] ??= new Set()).add(wrap); return api
+ const wrap: AnyFn = (...a: unknown[]) => {
+ listeners[event]?.delete(wrap)
+ return fn(...a)
+ }
+ ;(listeners[event] ??= new Set()).add(wrap)
+ return api
+ },
+ off(event: string, fn: AnyFn) {
+ listeners[event]?.delete(fn)
+ return api
+ },
+ removeListener(event: string, fn: AnyFn) {
+ listeners[event]?.delete(fn)
+ return api
+ },
+ emit(event: string, ...a: unknown[]) {
+ for (const fn of [...(listeners[event] ?? [])]) fn(...a)
},
- off(event: string, fn: AnyFn) { listeners[event]?.delete(fn); return api },
- removeListener(event: string, fn: AnyFn) { listeners[event]?.delete(fn); return api },
- emit(event: string, ...a: unknown[]) { for (const fn of [...(listeners[event] ?? [])]) fn(...a) },
}
return api
}
@@ -53,10 +68,14 @@ const stubs = vi.hoisted(() => {
...em,
id: nextWcId++,
destroyed: false,
- isDestroyed() { return this.destroyed },
+ isDestroyed() {
+ return this.destroyed
+ },
getURL: () => 'about:blank',
getType: () => 'window',
- send: vi.fn((channel: string, payload: unknown) => { sent.push({ channel, payload }) }),
+ send: vi.fn((channel: string, payload: unknown) => {
+ sent.push({ channel, payload })
+ }),
executeJavaScript: vi.fn(() => Promise.resolve(undefined)),
openDevTools: vi.fn(),
sentMessages: sent,
@@ -71,8 +90,12 @@ const stubs = vi.hoisted(() => {
...em,
webContents: makeWebContents(),
destroyed: false,
- isDestroyed() { return this.destroyed },
- close: vi.fn(function (this: { destroyed: boolean }) { this.destroyed = true }),
+ isDestroyed() {
+ return this.destroyed
+ },
+ close: vi.fn(function (this: { destroyed: boolean }) {
+ this.destroyed = true
+ }),
loadURL: vi.fn(() => Promise.resolve()),
loadFile: vi.fn(() => Promise.resolve()),
}
@@ -85,7 +108,15 @@ const stubs = vi.hoisted(() => {
nextWcId = 8000
}
- return { onListeners, invokeHandlers, wcById, makeEmitter, makeWebContents, makeBrowserWindow, reset }
+ return {
+ onListeners,
+ invokeHandlers,
+ wcById,
+ makeEmitter,
+ makeWebContents,
+ makeBrowserWindow,
+ reset,
+ }
})
vi.mock('electron', () => {
@@ -93,16 +124,26 @@ vi.mock('electron', () => {
const ipcMain = {
on: vi.fn((channel: string, fn: AnyFn) => {
- ;(stubs.onListeners.get(channel) ?? stubs.onListeners.set(channel, new Set()).get(channel)!).add(fn)
+ ;(
+ stubs.onListeners.get(channel) ?? stubs.onListeners.set(channel, new Set()).get(channel)!
+ ).add(fn)
}),
removeListener: vi.fn((channel: string, fn: AnyFn) => {
stubs.onListeners.get(channel)?.delete(fn)
}),
- handle: vi.fn((channel: string, fn: AnyFn) => { stubs.invokeHandlers.set(channel, fn) }),
- removeHandler: vi.fn((channel: string) => { stubs.invokeHandlers.delete(channel) }),
+ handle: vi.fn((channel: string, fn: AnyFn) => {
+ stubs.invokeHandlers.set(channel, fn)
+ }),
+ removeHandler: vi.fn((channel: string) => {
+ stubs.invokeHandlers.delete(channel)
+ }),
}
- const protocolStub = { handle: vi.fn(), unhandle: vi.fn(), registerSchemesAsPrivileged: vi.fn() }
+ const protocolStub = {
+ handle: vi.fn(),
+ unhandle: vi.fn(),
+ registerSchemesAsPrivileged: vi.fn(),
+ }
const sessionStub = {
fromPartition: vi.fn(() => ({
webRequest: { onBeforeSendHeaders: vi.fn(), onHeadersReceived: vi.fn() },
@@ -114,12 +155,23 @@ vi.mock('electron', () => {
return {
ipcMain,
- app: { isPackaged: true, getLocale: () => 'en-US', getPath: vi.fn(() => '/tmp/dimina-test-userdata') },
+ app: {
+ isPackaged: true,
+ getLocale: () => 'en-US',
+ getPath: vi.fn(() => '/tmp/dimina-test-userdata'),
+ },
BrowserWindow: class {},
- WebContentsView: class { webContents = {}; setBounds = vi.fn(); setBackgroundColor = vi.fn() },
+ WebContentsView: class {
+ webContents = {}
+ setBounds = vi.fn()
+ setBackgroundColor = vi.fn()
+ },
protocol: protocolStub,
session: sessionStub,
- webContents: { fromId: vi.fn(() => null), getAllWebContents: vi.fn(() => []) },
+ webContents: {
+ fromId: vi.fn(() => null),
+ getAllWebContents: vi.fn(() => []),
+ },
nativeTheme: { themeSource: 'system', on: vi.fn() },
default: {},
}
@@ -136,7 +188,13 @@ vi.mock('@dimina-kit/electron-runtime/main/service-host-window', () => ({
}))
import { BRIDGE_CHANNELS as C } from '../../shared/bridge-channels.js'
-import type { ApiResponsePayload, MessageEnvelope, ServiceInvokePayload, SpawnRequest, SpawnResult } from '../../shared/bridge-channels.js'
+import type {
+ ApiResponsePayload,
+ MessageEnvelope,
+ ServiceInvokePayload,
+ SpawnRequest,
+ SpawnResult,
+} from '../../shared/bridge-channels.js'
import type { WorkbenchContext } from '../services/workbench-context.js'
import { createConnectionRegistry } from '@dimina-kit/electron-deck/main'
@@ -167,14 +225,22 @@ function makeCtx(): { ctx: WorkbenchContext; simulatorWc: MockWc } {
const ctx = {
registry: { add: (_fn: AnyFn) => {} },
connections: createConnectionRegistry(),
- simulatorApis: { has: (_name: string) => false, invoke: async () => ({}), list: () => [] },
- windows: { mainWindow: { webContents: simulatorWc, isDestroyed: () => false } },
+ simulatorApis: {
+ has: (_name: string) => false,
+ invoke: async () => ({}),
+ list: () => [],
+ },
+ windows: {
+ mainWindow: { webContents: simulatorWc, isDestroyed: () => false },
+ },
workspace: { getSession: () => undefined },
} as unknown as WorkbenchContext
return { ctx, simulatorWc }
}
-async function spawnSession(simulatorWc: MockWc): Promise<{ result: SpawnResult; serviceWc: MockWc }> {
+async function spawnSession(
+ simulatorWc: MockWc,
+): Promise<{ result: SpawnResult; serviceWc: MockWc }> {
const handle = stubs.invokeHandlers.get(C.SPAWN)
if (!handle) throw new Error('SPAWN handler not registered')
const req: SpawnRequest = {
@@ -226,60 +292,100 @@ function triggerCallbacks(serviceWc: MockWc): Array<{ id: unknown; args: unknown
async function setup(
name: string,
params: Record,
-): Promise<{ ctx: WorkbenchContext; simulatorWc: MockWc; serviceWc: MockWc; requestId: string }> {
+): Promise<{
+ ctx: WorkbenchContext
+ simulatorWc: MockWc
+ serviceWc: MockWc
+ requestId: string
+}> {
const { ctx, simulatorWc } = makeCtx()
installBridgeRouter(ctx)
const { serviceWc } = await spawnSession(simulatorWc)
- forwardApiCall(serviceWc, name, params, { success: 'svc-success', complete: 'svc-complete', fail: 'svc-fail' })
+ forwardApiCall(serviceWc, name, params, {
+ success: 'svc-success',
+ complete: 'svc-complete',
+ fail: 'svc-fail',
+ })
const requestId = forwardedRequestId(simulatorWc)
return { ctx, simulatorWc, serviceWc, requestId }
}
-describe('bridge-router — forwarded `request` call watchdog scales with the wx timeout budget', () => {
+describe('bridge-router — forwarded `downloadFile` call watchdog scales with the wx timeout budget', () => {
it('does not fail at the legacy 5s mark, and still delivers a within-budget late success (e.g. at 30s)', async () => {
- const { simulatorWc, serviceWc, requestId } = await setup('request', { url: 'https://example.com/api' })
+ const { simulatorWc, serviceWc, requestId } = await setup('downloadFile', {
+ url: 'https://example.com/file.bin',
+ })
await vi.advanceTimersByTimeAsync(5_000)
let cbs = triggerCallbacks(serviceWc)
- expect(cbs.find(c => c.id === 'svc-fail'), 'must not fail at the legacy 5s mark').toBeUndefined()
+ expect(
+ cbs.find(c => c.id === 'svc-fail'),
+ 'must not fail at the legacy 5s mark',
+ ).toBeUndefined()
await vi.advanceTimersByTimeAsync(25_000) // now 30s since the call was forwarded
- const resp: ApiResponsePayload = { appSessionId: 'demo-app', requestId, ok: true, result: { data: { a: 1 }, statusCode: 200 } }
+ const resp: ApiResponsePayload = {
+ appSessionId: 'demo-app',
+ requestId,
+ ok: true,
+ result: { filePath: '/tmp/file.bin', statusCode: 200 },
+ }
emitOn(C.API_RESPONSE, simulatorWc, resp)
cbs = triggerCallbacks(serviceWc)
const successFire = cbs.find(c => c.id === 'svc-success')
- expect(successFire, 'a within-budget late response must still be delivered (pending not torn down)').toBeDefined()
- expect(successFire!.args).toEqual({ data: { a: 1 }, statusCode: 200 })
+ expect(
+ successFire,
+ 'a within-budget late response must still be delivered (pending not torn down)',
+ ).toBeDefined()
+ expect(successFire!.args).toEqual({
+ filePath: '/tmp/file.bin',
+ statusCode: 200,
+ })
})
it('fires "no handler (timeout)" only once the full 60000ms default budget + 5000ms grace elapses (65000ms), not before', async () => {
- const { serviceWc } = await setup('request', { url: 'https://example.com/api' })
+ const { serviceWc } = await setup('downloadFile', {
+ url: 'https://example.com/file.bin',
+ })
await vi.advanceTimersByTimeAsync(64_999)
let cbs = triggerCallbacks(serviceWc)
- expect(cbs.find(c => c.id === 'svc-fail'), 'must not fire before 65000ms').toBeUndefined()
+ expect(
+ cbs.find(c => c.id === 'svc-fail'),
+ 'must not fire before 65000ms',
+ ).toBeUndefined()
await vi.advanceTimersByTimeAsync(1) // now exactly 65000ms
cbs = triggerCallbacks(serviceWc)
const failFire = cbs.find(c => c.id === 'svc-fail')
expect(failFire, 'must fire exactly at 65000ms').toBeDefined()
- expect((failFire!.args as { errMsg?: string }).errMsg).toBe('request:fail no handler (timeout)')
+ expect((failFire!.args as { errMsg?: string }).errMsg).toBe(
+ 'downloadFile:fail no handler (timeout)',
+ )
})
it('honors an explicit params.timeout: 1000 as a 6000ms watchdog window (timeout + 5000ms grace)', async () => {
- const { serviceWc } = await setup('request', { url: 'https://example.com/api', timeout: 1000 })
+ const { serviceWc } = await setup('downloadFile', {
+ url: 'https://example.com/file.bin',
+ timeout: 1000,
+ })
await vi.advanceTimersByTimeAsync(5_999)
let cbs = triggerCallbacks(serviceWc)
- expect(cbs.find(c => c.id === 'svc-fail'), 'must not fire before 6000ms').toBeUndefined()
+ expect(
+ cbs.find(c => c.id === 'svc-fail'),
+ 'must not fire before 6000ms',
+ ).toBeUndefined()
await vi.advanceTimersByTimeAsync(1) // now exactly 6000ms
cbs = triggerCallbacks(serviceWc)
const failFire = cbs.find(c => c.id === 'svc-fail')
expect(failFire, 'must fire exactly at 6000ms').toBeDefined()
- expect((failFire!.args as { errMsg?: string }).errMsg).toBe('request:fail no handler (timeout)')
+ expect((failFire!.args as { errMsg?: string }).errMsg).toBe(
+ 'downloadFile:fail no handler (timeout)',
+ )
})
})
@@ -289,13 +395,71 @@ describe('bridge-router — non-network API calls keep the flat 5000ms watchdog
await vi.advanceTimersByTimeAsync(4_999)
let cbs = triggerCallbacks(serviceWc)
- expect(cbs.find(c => c.id === 'svc-fail'), 'must not fire before 5000ms').toBeUndefined()
+ expect(
+ cbs.find(c => c.id === 'svc-fail'),
+ 'must not fire before 5000ms',
+ ).toBeUndefined()
await vi.advanceTimersByTimeAsync(1) // now exactly 5000ms
cbs = triggerCallbacks(serviceWc)
const failFire = cbs.find(c => c.id === 'svc-fail')
expect(failFire, 'must fire exactly at 5000ms').toBeDefined()
- expect((failFire!.args as { errMsg?: string }).errMsg).toBe('showToast:fail no handler (timeout)')
+ expect((failFire!.args as { errMsg?: string }).errMsg).toBe(
+ 'showToast:fail no handler (timeout)',
+ )
+ })
+})
+
+describe('bridge-router — `request` never uses the simulator-forwarding watchdog (main-process native handler owns it)', () => {
+ it('assigns distinct native ids to concurrent requests in the same millisecond', async () => {
+ const { ctx, simulatorWc } = makeCtx()
+ installBridgeRouter(ctx)
+ const { serviceWc } = await spawnSession(simulatorWc)
+ const ids: string[] = []
+ ctx.bridge!.onNativeRequestTrace!((_owner, event) => {
+ if (event.type === 'sent') ids.push(event.requestId)
+ })
+ for (let i = 0; i < 2; i++) {
+ forwardApiCall(serviceWc, 'request', { url: 'http://127.0.0.1:1/' }, {})
+ }
+ await vi.advanceTimersByTimeAsync(0)
+ expect(ids).toHaveLength(2)
+ expect(new Set(ids).size).toBe(2)
+ })
+
+ it('a forwarded request call never sends simulator:api-call, and settles via the native HTTP handler instead', async () => {
+ const { ctx, simulatorWc } = makeCtx()
+ installBridgeRouter(ctx)
+ const { serviceWc } = await spawnSession(simulatorWc)
+
+ // Connection-refused address: settles fast, no real network I/O.
+ forwardApiCall(
+ serviceWc,
+ 'request',
+ { url: 'http://127.0.0.1:1/' },
+ {
+ success: 'svc-success',
+ complete: 'svc-complete',
+ fail: 'svc-fail',
+ },
+ )
+
+ // Give the native transport's error event a turn to fire.
+ await vi.advanceTimersByTimeAsync(0)
+ await Promise.resolve()
+ await Promise.resolve()
+
+ const apiCall = simulatorWc.sentMessages.find(m => m.channel === 'simulator:api-call')
+ expect(
+ apiCall,
+ 'request must never be forwarded to the simulator window — it is handled entirely in the main process',
+ ).toBeUndefined()
+
+ const cbs = triggerCallbacks(serviceWc)
+ expect(
+ cbs.find(c => c.id === 'svc-fail'),
+ 'the native handler must still deliver a fail callback for the refused connection',
+ ).toBeDefined()
})
})
@@ -314,14 +478,22 @@ describe('bridge-router — non-network API calls keep the flat 5000ms watchdog
// not prevent the real, later verdict from being delivered.
describe('bridge-router — a simulator-side ack must not resolve the call, and must not block the later real verdict', () => {
it('an ack-shaped API_RESPONSE does not fire fail/complete, does not time out, and the later real success is still delivered', async () => {
- const { simulatorWc, serviceWc, requestId } = await setup('showToast', { title: 'hi' })
+ const { simulatorWc, serviceWc, requestId } = await setup('showToast', {
+ title: 'hi',
+ })
const ackPayload = { appSessionId: 'demo-app', requestId, ack: true }
emitOn(C.API_RESPONSE, simulatorWc, ackPayload)
let cbs = triggerCallbacks(serviceWc)
- expect(cbs.find(c => c.id === 'svc-fail'), 'an ack must not resolve the call as a failure').toBeUndefined()
- expect(cbs.find(c => c.id === 'svc-complete'), 'an ack must not fire complete').toBeUndefined()
+ expect(
+ cbs.find(c => c.id === 'svc-fail'),
+ 'an ack must not resolve the call as a failure',
+ ).toBeUndefined()
+ expect(
+ cbs.find(c => c.id === 'svc-complete'),
+ 'an ack must not fire complete',
+ ).toBeUndefined()
// Advance past the flat 5000ms no-handler watchdog window that would
// otherwise apply to a plain (non-network) API like showToast.
@@ -334,10 +506,18 @@ describe('bridge-router — a simulator-side ack must not resolve the call, and
// The real, later verdict must still be deliverable — an ack must not
// have torn the pending call down.
- const resp: ApiResponsePayload = { appSessionId: 'demo-app', requestId, ok: true, result: { errMsg: 'showToast:ok' } }
+ const resp: ApiResponsePayload = {
+ appSessionId: 'demo-app',
+ requestId,
+ ok: true,
+ result: { errMsg: 'showToast:ok' },
+ }
emitOn(C.API_RESPONSE, simulatorWc, resp)
cbs = triggerCallbacks(serviceWc)
const successFire = cbs.find(c => c.id === 'svc-success')
- expect(successFire, 'the real verdict following an ack must still be delivered to the service').toBeDefined()
+ expect(
+ successFire,
+ 'the real verdict following an ack must still be delivered to the service',
+ ).toBeDefined()
})
})
diff --git a/packages/devtools/src/main/services/elements-forward/index.ts b/packages/devtools/src/main/services/elements-forward/index.ts
index 3f98253c..84cfd129 100644
--- a/packages/devtools/src/main/services/elements-forward/index.ts
+++ b/packages/devtools/src/main/services/elements-forward/index.ts
@@ -61,7 +61,8 @@ import type { WebContents } from 'electron'
import type { ConnectionRegistry } from '@dimina-kit/electron-deck/main'
import type { BridgeRouterHandle, RenderEvent } from '../../ipc/bridge-router.js'
import { isFrontendSettled } from '../views/inject-when-ready.js'
-import { VIRTUAL_REQUEST_ID_PREFIX, type NetworkBodyProvider } from '../network-forward/index.js'
+import type { NetworkBodyProvider } from '../network-forward/index.js'
+import { BODY_REQUEST_ID_PREFIXES } from '../network-forward/request-ids.js'
import { buildSingleDispatchScript, createFrontendReplyChannel, answerNetworkBodyCommand, drainOutboundBatch } from '../network-forward/frontend-dispatch.js'
import { createCdpSessionBroker, type CdpSessionBroker, type CdpSessionLease } from '../cdp-session/index.js'
@@ -134,14 +135,14 @@ export const NETWORK_BODY_METHODS: readonly string[] = [
* equivalent test driven by the same literals, so the two cannot drift.
*
* A non-string / missing requestId routes to 'service': only the
- * `dimina:sim:` namespace is ours to answer, and the real backend is the
+ * simulator and native HTTP namespaces is ours to answer, and the real backend is the
* correct authority for its own ids.
*/
export function routeOutboundCommand(method: string, params: unknown): CdpRoute {
if (routeByDomain(method) === 'render') return 'render'
if (NETWORK_BODY_METHODS.includes(method)) {
const requestId = (params as { requestId?: unknown } | null | undefined)?.requestId
- if (typeof requestId === 'string' && requestId.startsWith(VIRTUAL_REQUEST_ID_PREFIX)) {
+ if (typeof requestId === 'string' && BODY_REQUEST_ID_PREFIXES.some((prefix) => requestId.startsWith(prefix))) {
return 'network'
}
}
@@ -166,13 +167,13 @@ export function routeOutboundCommand(method: string, params: unknown): CdpRoute
export function buildElementsHookScript(): string {
const prefixes = JSON.stringify(RENDER_DOMAIN_PREFIXES)
const netMethods = JSON.stringify(NETWORK_BODY_METHODS)
- const vprefix = JSON.stringify(VIRTUAL_REQUEST_ID_PREFIX)
+ const vprefix = JSON.stringify(BODY_REQUEST_ID_PREFIXES)
return `(function(){try{
if (globalThis.__diminaElementsHookInstalled) return 'already';
var OUT = (globalThis.__diminaElementsOutbound = globalThis.__diminaElementsOutbound || []);
var PREFIXES = ${prefixes};
var NET_METHODS = ${netMethods};
- var VPREFIX = ${vprefix};
+ var VPREFIXES = ${vprefix};
function isRender(method){
if (!method) return false;
for (var i=0;i= 0 && m.params
&& typeof m.params.requestId === 'string'
- && m.params.requestId.indexOf(VPREFIX) === 0) return 'network';
+ && VPREFIXES.some(function(prefix){ return m.params.requestId.indexOf(prefix) === 0; })) return 'network';
return 'service';
}
var IFH = globalThis.InspectorFrontendHost;
diff --git a/packages/devtools/src/main/services/network-forward/body-cache.ts b/packages/devtools/src/main/services/network-forward/body-cache.ts
index 5c2b2af0..8a795ef2 100644
--- a/packages/devtools/src/main/services/network-forward/body-cache.ts
+++ b/packages/devtools/src/main/services/network-forward/body-cache.ts
@@ -164,6 +164,11 @@ export class PrefetchCache {
return Promise.resolve(v)
}
+ /** Retire a previous redirect hop without disturbing unrelated cached requests. */
+ delete(id: string): void {
+ this.map.delete(id)
+ }
+
clear(): void {
this.map.clear()
}
diff --git a/packages/devtools/src/main/services/network-forward/global-body-gate.ts b/packages/devtools/src/main/services/network-forward/global-body-gate.ts
index eff7e543..a486da53 100644
--- a/packages/devtools/src/main/services/network-forward/global-body-gate.ts
+++ b/packages/devtools/src/main/services/network-forward/global-body-gate.ts
@@ -12,7 +12,7 @@
* Installing the full elements-forward gate here would silently reroute the
* global window's Elements panel to the wrong target. This module intercepts
* ONLY `Network.getResponseBody` / `Network.getRequestPostData` for
- * `dimina:sim:` virtual requestIds; every other command (including all
+ * `dimina:sim:` and `dimina:http:` virtual requestIds; every other command (including all
* DOM/CSS/Overlay/Runtime/Debugger/…) passes straight through untouched.
*
* ── Mechanism (same two-way poll bridge as elements-forward) ───────────────
@@ -31,7 +31,7 @@
import type { WebContents } from 'electron'
import { isFrontendSettled } from '../views/inject-when-ready.js'
import type { NetworkBodyProvider } from './index.js'
-import { VIRTUAL_REQUEST_ID_PREFIX } from './index.js'
+import { BODY_REQUEST_ID_PREFIXES } from './request-ids.js'
import { createFrontendReplyChannel, answerNetworkBodyCommand, drainOutboundBatch } from './frontend-dispatch.js'
/** The only two Network.* commands this gate ever intercepts. */
@@ -55,16 +55,16 @@ const DRAIN_INTERVAL_MS = 150
*/
export function buildNetworkOnlyHookScript(): string {
const netMethods = JSON.stringify(NETWORK_BODY_METHODS)
- const vprefix = JSON.stringify(VIRTUAL_REQUEST_ID_PREFIX)
+ const vprefix = JSON.stringify(BODY_REQUEST_ID_PREFIXES)
return `(function(){try{
if (globalThis.__diminaGlobalNetworkHookInstalled) return 'already';
var OUT = (globalThis.__diminaGlobalNetworkOutbound = globalThis.__diminaGlobalNetworkOutbound || []);
var NET_METHODS = ${netMethods};
- var VPREFIX = ${vprefix};
+ var VPREFIXES = ${vprefix};
function isNetworkBody(m){
if (!m || !m.method) return false;
if (NET_METHODS.indexOf(m.method) < 0) return false;
- return !!(m.params && typeof m.params.requestId === 'string' && m.params.requestId.indexOf(VPREFIX) === 0);
+ return !!(m.params && typeof m.params.requestId === 'string' && VPREFIXES.some(function(prefix){ return m.params.requestId.indexOf(prefix) === 0; }));
}
var IFH = globalThis.InspectorFrontendHost;
if (IFH && typeof IFH.sendMessageToBackend === 'function' && !IFH.__diminaGlobalNetworkWrapped){
diff --git a/packages/devtools/src/main/services/network-forward/http-redirect.test.ts b/packages/devtools/src/main/services/network-forward/http-redirect.test.ts
new file mode 100644
index 00000000..782c2bc0
--- /dev/null
+++ b/packages/devtools/src/main/services/network-forward/http-redirect.test.ts
@@ -0,0 +1,44 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { RequestTraceSynthesizer } from './http.js'
+import { createNetworkForwarder } from './index.js'
+
+afterEach(() => vi.useRealTimers())
+
+const sent = { type: 'sent' as const, requestId: 'r', url: 'https://example.com/start', method: 'POST', headers: {}, postData: 'payload', time: 0 }
+const redirected = { type: 'redirect' as const, requestId: 'r', url: 'https://example.com/end', method: 'GET', headers: {},
+ redirectResponse: { url: sent.url, status: 302, statusText: 'Found', headers: { location: '/end' } }, time: 1 }
+
+describe('native HTTP redirect observation', () => {
+ it('reports an omitted POST body without embedding an oversized payload', () => {
+ const synth = new RequestTraceSynthesizer({ epoch: 'test' })
+ const message = synth.synthesize('owner', { ...sent, postData: undefined, hasPostData: true })!
+ expect(message).toMatchObject({ params: { request: { hasPostData: true } } })
+ expect((message.params as { request: { postData?: string } }).request.postData).toBeUndefined()
+ expect(message.postData).toBeUndefined()
+ })
+
+ it('links redirect hops with one CDP id and updates the final response URL', () => {
+ const synth = new RequestTraceSynthesizer({ epoch: 'test' })
+ const first = synth.synthesize('owner', sent)!
+ const next = synth.synthesize('owner', redirected)
+ expect(next).toMatchObject({ method: 'Network.requestWillBeSent', params: {
+ requestId: (first.params as { requestId: string }).requestId,
+ request: { url: redirected.url, method: 'GET', hasPostData: false },
+ redirectResponse: { url: sent.url, status: 302 },
+ } })
+ expect(synth.synthesize('owner', { type: 'response', requestId: 'r', status: 200, statusText: 'OK', headers: {}, time: 2 })).toMatchObject({
+ params: { response: { url: redirected.url } },
+ })
+ })
+
+ it('does not serve the previous POST payload after a redirect changes the request to GET', async () => {
+ vi.useFakeTimers(); vi.setSystemTime(0)
+ const forwarder = createNetworkForwarder({ getServiceWc: () => null })
+ try {
+ forwarder.reportNativeRequestTrace('owner', sent)
+ expect(await forwarder.bodies.getRequestPostData('dimina:http:0:0')).toEqual({ postData: 'payload' })
+ forwarder.reportNativeRequestTrace('owner', redirected)
+ await expect(forwarder.bodies.getRequestPostData('dimina:http:0:0')).rejects.toThrow()
+ } finally { forwarder.dispose() }
+ })
+})
diff --git a/packages/devtools/src/main/services/network-forward/http.test.ts b/packages/devtools/src/main/services/network-forward/http.test.ts
new file mode 100644
index 00000000..1b34cb41
--- /dev/null
+++ b/packages/devtools/src/main/services/network-forward/http.test.ts
@@ -0,0 +1,319 @@
+/**
+ * Unit tests for RequestTraceSynthesizer — the pure mapper that re-shapes one
+ * main-process HTTP request trace event into the exact `Network.request*`/
+ * `Network.loading*` CDP message the embedded DevTools front-end renders
+ * natively.
+ *
+ * Contracts guarded here:
+ * - `sent` mints a `dimina:http::` virtual requestId and carries
+ * the business url/method/headers verbatim (this row is what makes a
+ * request visible in the Network panel);
+ * - `response` passes status/statusText/headers through, deriving mimeType
+ * from the Content-Type header (case-insensitively);
+ * - `finished` carries the body ready to prime the forwarder's body-cache
+ * and releases the id mapping so a re-sent requestId gets a fresh one;
+ * - `failed` carries the errorText and also releases the id mapping;
+ * - any non-`sent` event for a requestId this synthesizer never saw is
+ * dropped (the front-end would drop it too);
+ * - the user-facing verdict is decided once at `sent` (the only event
+ * carrying a url) and reused by every later event of the same request;
+ * - timestamps convert from wall-clock ms to CDP seconds.
+ */
+import { describe, expect, it } from 'vitest'
+import { RequestTraceSynthesizer } from './http.js'
+import type { NativeRequestTrace } from '../../ipc/bridge-router.js'
+
+const SESSION = 'owner-session-1'
+const EPOCH = 'epoch-test'
+const BASE_TIME = 1_700_000_000_000
+
+interface RequestWillBeSentParams {
+ requestId: string
+ loaderId: string
+ documentURL: string
+ request: {
+ url: string
+ method: string
+ headers: Record
+ hasPostData: boolean
+ postData?: string
+ }
+ timestamp: number
+ wallTime: number
+ type: string
+}
+
+interface ResponseReceivedParams {
+ requestId: string
+ timestamp: number
+ response: {
+ status: number
+ statusText: string
+ headers: Record
+ mimeType: string
+ }
+}
+
+interface LoadingFinishedParams {
+ requestId: string
+ timestamp: number
+ encodedDataLength: number
+}
+
+interface LoadingFailedParams {
+ requestId: string
+ timestamp: number
+ errorText: string
+}
+
+function makeSynthesizer(internalOrigins?: () => ReadonlyArray): RequestTraceSynthesizer {
+ return new RequestTraceSynthesizer({ epoch: EPOCH, internalOrigins })
+}
+
+function sentEvent(requestId: string, url: string, method = 'GET', postData?: string): NativeRequestTrace {
+ return postData === undefined
+ ? { type: 'sent', requestId, url, method, headers: { 'x-test': '1' }, time: BASE_TIME }
+ : { type: 'sent', requestId, url, method, headers: { 'x-test': '1' }, postData, time: BASE_TIME }
+}
+
+function createRequest(
+ synthesizer: RequestTraceSynthesizer,
+ requestId: string,
+ url = 'https://business.example.com/api',
+ sessionId = SESSION,
+): RequestWillBeSentParams {
+ const message = synthesizer.synthesize(sessionId, sentEvent(requestId, url))
+ expect(message).not.toBeNull()
+ expect(message!.method).toBe('Network.requestWillBeSent')
+ return message!.params as RequestWillBeSentParams
+}
+
+describe('RequestTraceSynthesizer', () => {
+ describe('sent', () => {
+ it('maps sent to Network.requestWillBeSent with a namespaced requestId and the request verbatim', () => {
+ const synthesizer = makeSynthesizer()
+ const message = synthesizer.synthesize(SESSION, sentEvent('r1', 'https://business.example.com/api?x=1', 'POST'))
+ expect(message).not.toBeNull()
+ expect(message!.method).toBe('Network.requestWillBeSent')
+ const params = message!.params as RequestWillBeSentParams
+ expect(params.requestId).toBe(`dimina:http:${EPOCH}:0`)
+ expect(params.request.url).toBe('https://business.example.com/api?x=1')
+ expect(params.request.method).toBe('POST')
+ expect(params.request.headers).toEqual({ 'x-test': '1' })
+ expect(params.request.hasPostData).toBe(false)
+ expect(params.type).toBe('XHR')
+ })
+
+ it('mints a monotonically increasing requestId per sent request', () => {
+ const synthesizer = makeSynthesizer()
+ const first = createRequest(synthesizer, 'r1')
+ const second = createRequest(synthesizer, 'r2')
+ const third = createRequest(synthesizer, 'r3', 'https://other.example.com/', 'owner-session-2')
+ expect(first.requestId).toBe(`dimina:http:${EPOCH}:0`)
+ expect(second.requestId).toBe(`dimina:http:${EPOCH}:1`)
+ expect(third.requestId).toBe(`dimina:http:${EPOCH}:2`)
+ expect(new Set([first.requestId, second.requestId, third.requestId]).size).toBe(3)
+ })
+
+ it('carries postData and hasPostData:true when the trace event has a body, exposed for cache priming', () => {
+ const synthesizer = makeSynthesizer()
+ const message = synthesizer.synthesize(SESSION, sentEvent('r1', 'https://business.example.com/api', 'POST', '{"a":1}'))
+ expect(message).not.toBeNull()
+ const params = message!.params as RequestWillBeSentParams
+ expect(params.request.hasPostData).toBe(true)
+ expect(params.request.postData).toBe('{"a":1}')
+ expect(message!.postData).toBe('{"a":1}')
+ })
+
+ it('omits postData entirely for a bodyless GET', () => {
+ const synthesizer = makeSynthesizer()
+ const message = synthesizer.synthesize(SESSION, sentEvent('r1', 'https://business.example.com/api'))
+ const params = message!.params as RequestWillBeSentParams
+ expect(params.request.hasPostData).toBe(false)
+ expect('postData' in params.request).toBe(false)
+ expect(message!.postData).toBeUndefined()
+ })
+ })
+
+ describe('response', () => {
+ it('maps response to Network.responseReceived with status/statusText/headers passed through and mimeType derived', () => {
+ const synthesizer = makeSynthesizer()
+ const { requestId } = createRequest(synthesizer, 'r1')
+ const headers = { 'content-type': 'application/json; charset=utf-8', 'x-custom': 'v' }
+ const message = synthesizer.synthesize(SESSION, {
+ type: 'response',
+ requestId: 'r1',
+ status: 200,
+ statusText: 'OK',
+ headers,
+ time: BASE_TIME + 500,
+ })
+ expect(message).not.toBeNull()
+ expect(message!.method).toBe('Network.responseReceived')
+ const params = message!.params as ResponseReceivedParams
+ expect(params.requestId).toBe(requestId)
+ expect(params.response.status).toBe(200)
+ expect(params.response.statusText).toBe('OK')
+ expect(params.response.headers).toEqual(headers)
+ expect(params.response.mimeType).toBe('application/json')
+ expect(params.timestamp).toBe((BASE_TIME + 500) / 1000)
+ })
+
+ it('derives mimeType case-insensitively and defaults to empty string when Content-Type is absent', () => {
+ const synthesizer = makeSynthesizer()
+ createRequest(synthesizer, 'r1')
+ const message = synthesizer.synthesize(SESSION, {
+ type: 'response',
+ requestId: 'r1',
+ status: 200,
+ statusText: 'OK',
+ headers: { 'Content-Type': 'text/plain' },
+ time: BASE_TIME,
+ })
+ const params = message!.params as ResponseReceivedParams
+ expect(params.response.mimeType).toBe('text/plain')
+
+ const noContentType = synthesizer.synthesize(SESSION, {
+ type: 'response',
+ requestId: 'r1',
+ status: 204,
+ statusText: 'No Content',
+ headers: {},
+ time: BASE_TIME,
+ })
+ expect((noContentType!.params as ResponseReceivedParams).response.mimeType).toBe('')
+ })
+
+ it('an HTTP error status (401/500) still maps to responseReceived, never loadingFailed', () => {
+ const synthesizer = makeSynthesizer()
+ createRequest(synthesizer, 'r1')
+ const message = synthesizer.synthesize(SESSION, {
+ type: 'response',
+ requestId: 'r1',
+ status: 401,
+ statusText: 'Unauthorized',
+ headers: {},
+ time: BASE_TIME,
+ })
+ expect(message!.method).toBe('Network.responseReceived')
+ expect((message!.params as ResponseReceivedParams).response.status).toBe(401)
+ })
+ })
+
+ describe('finished and failed', () => {
+ it('maps finished to Network.loadingFinished, carrying the body for cache priming', () => {
+ const synthesizer = makeSynthesizer()
+ const { requestId } = createRequest(synthesizer, 'r1')
+ const message = synthesizer.synthesize(SESSION, {
+ type: 'finished',
+ requestId: 'r1',
+ body: Buffer.from('{"a":1}').toString('base64'),
+ bodyBase64Encoded: true,
+ encodedDataLength: 7,
+ time: BASE_TIME + 1000,
+ })
+ expect(message).not.toBeNull()
+ expect(message!.method).toBe('Network.loadingFinished')
+ const params = message!.params as LoadingFinishedParams
+ expect(params.requestId).toBe(requestId)
+ expect(params.encodedDataLength).toBe(7)
+ expect(params.timestamp).toBe((BASE_TIME + 1000) / 1000)
+ expect(message!.body).toEqual({ base64Encoded: true, body: Buffer.from('{"a":1}').toString('base64') })
+ })
+
+ it('maps failed to Network.loadingFailed carrying the errorText', () => {
+ const synthesizer = makeSynthesizer()
+ const { requestId } = createRequest(synthesizer, 'r1')
+ const message = synthesizer.synthesize(SESSION, {
+ type: 'failed',
+ requestId: 'r1',
+ errorText: 'request:fail timeout',
+ time: BASE_TIME + 2000,
+ })
+ expect(message).not.toBeNull()
+ expect(message!.method).toBe('Network.loadingFailed')
+ const params = message!.params as LoadingFailedParams
+ expect(params.requestId).toBe(requestId)
+ expect(params.errorText).toBe('request:fail timeout')
+ })
+
+ it('releases the id mapping at finished so a re-sent requestId mints a fresh virtual id', () => {
+ const synthesizer = makeSynthesizer()
+ const first = createRequest(synthesizer, 'r1')
+ synthesizer.synthesize(SESSION, { type: 'finished', requestId: 'r1', body: '', bodyBase64Encoded: true, encodedDataLength: 0, time: BASE_TIME + 1 })
+
+ const straggler = synthesizer.synthesize(SESSION, { type: 'response', requestId: 'r1', status: 200, statusText: 'OK', headers: {}, time: BASE_TIME + 2 })
+ expect(straggler).toBeNull()
+
+ const second = createRequest(synthesizer, 'r1')
+ expect(second.requestId).not.toBe(first.requestId)
+ expect(second.requestId).toBe(`dimina:http:${EPOCH}:1`)
+ })
+
+ it('releases the id mapping at failed too', () => {
+ const synthesizer = makeSynthesizer()
+ createRequest(synthesizer, 'r1')
+ synthesizer.synthesize(SESSION, { type: 'failed', requestId: 'r1', errorText: 'boom', time: BASE_TIME + 1 })
+ const straggler = synthesizer.synthesize(SESSION, { type: 'response', requestId: 'r1', status: 200, statusText: 'OK', headers: {}, time: BASE_TIME + 2 })
+ expect(straggler).toBeNull()
+ })
+ })
+
+ describe('unknown-requestId discipline', () => {
+ it('drops response, finished and failed events for a requestId that was never sent', () => {
+ const synthesizer = makeSynthesizer()
+ const response = synthesizer.synthesize(SESSION, { type: 'response', requestId: 'ghost', status: 200, statusText: 'OK', headers: {}, time: BASE_TIME })
+ const finished = synthesizer.synthesize(SESSION, { type: 'finished', requestId: 'ghost', body: '', bodyBase64Encoded: true, encodedDataLength: 0, time: BASE_TIME })
+ const failed = synthesizer.synthesize(SESSION, { type: 'failed', requestId: 'ghost', errorText: 'boom', time: BASE_TIME })
+ expect(response).toBeNull()
+ expect(finished).toBeNull()
+ expect(failed).toBeNull()
+ })
+
+ it('drops events arriving on a session whose requestId was never sent there', () => {
+ const synthesizer = makeSynthesizer()
+ createRequest(synthesizer, 'r1', 'https://business.example.com/', 'owner-session-1')
+ const leaked = synthesizer.synthesize('owner-session-2', {
+ type: 'response',
+ requestId: 'r1',
+ status: 200,
+ statusText: 'OK',
+ headers: {},
+ time: BASE_TIME,
+ })
+ expect(leaked).toBeNull()
+ })
+ })
+
+ describe('user-facing verdict', () => {
+ it('flags a business https url as user-facing on every event of the request', () => {
+ const synthesizer = makeSynthesizer(() => ['http://127.0.0.1:54321/'])
+ const sent = synthesizer.synthesize(SESSION, sentEvent('r1', 'https://business.example.com/api'))
+ expect(sent!.userFacing).toBe(true)
+ const finished = synthesizer.synthesize(SESSION, { type: 'finished', requestId: 'r1', body: '', bodyBase64Encoded: true, encodedDataLength: 0, time: BASE_TIME + 1 })
+ expect(finished!.userFacing).toBe(true)
+ })
+
+ it('flags a request whose url origin matches internalOrigins as not user-facing, decided once at sent', () => {
+ const synthesizer = makeSynthesizer(() => ['http://127.0.0.1:54321/', null, undefined])
+ const sent = synthesizer.synthesize(SESSION, sentEvent('r1', 'http://127.0.0.1:54321/internal'))
+ expect(sent!.userFacing).toBe(false)
+ // Later events carry no url of their own; they must reuse the
+ // sent-time verdict rather than re-deriving (or failing open).
+ const response = synthesizer.synthesize(SESSION, { type: 'response', requestId: 'r1', status: 200, statusText: 'OK', headers: {}, time: BASE_TIME + 1 })
+ expect(response!.userFacing).toBe(false)
+ const finished = synthesizer.synthesize(SESSION, { type: 'finished', requestId: 'r1', body: '', bodyBase64Encoded: true, encodedDataLength: 0, time: BASE_TIME + 2 })
+ expect(finished!.userFacing).toBe(false)
+ })
+
+ it('re-reads internalOrigins at each sent instead of caching across requests', () => {
+ let internal: ReadonlyArray = []
+ const synthesizer = makeSynthesizer(() => internal)
+ const before = synthesizer.synthesize(SESSION, sentEvent('r1', 'http://127.0.0.1:54321/api'))
+ expect(before!.userFacing).toBe(true)
+ internal = ['http://127.0.0.1:54321/']
+ const after = synthesizer.synthesize(SESSION, sentEvent('r2', 'http://127.0.0.1:54321/api'))
+ expect(after!.userFacing).toBe(false)
+ })
+ })
+})
diff --git a/packages/devtools/src/main/services/network-forward/http.ts b/packages/devtools/src/main/services/network-forward/http.ts
new file mode 100644
index 00000000..30de9ea5
--- /dev/null
+++ b/packages/devtools/src/main/services/network-forward/http.ts
@@ -0,0 +1,195 @@
+/**
+ * Synthesize Chrome DevTools Protocol `Network.request*`/`Network.loading*`
+ * messages from the main-process HTTP request trace stream (see
+ * electron-runtime `native-request/trace.ts`).
+ *
+ * Why this exists: `wx.request` runs on Node http/https in the MAIN process
+ * (see native-request's design notes — this replaced a renderer `fetch()`
+ * specifically to stop Chromium's Fetch/CORS algorithm from attaching a
+ * spurious OPTIONS preflight to it) — no `webContents.debugger` can observe
+ * it, so the embedded DevTools Network tab would otherwise show nothing for
+ * every wx.request call. The trace stream gives one ordered fact per
+ * lifecycle moment; this module re-shapes each fact into the CDP events the
+ * front-end already renders natively for an XHR-classified resource
+ * (`requestWillBeSent` creates the row, `responseReceived` fills in
+ * status/headers, `loadingFinished`/`loadingFailed` settles it).
+ *
+ * Ordering contract this module relies on (guaranteed by the trace layer):
+ * `sent` strictly precedes every other event for its requestId, and exactly
+ * one of `loadingFinished`/`loadingFailed` terminates each sent request. The
+ * front-end silently drops loading events for an unknown requestId, so this
+ * module mirrors that discipline defensively: any non-`sent` event for an
+ * unknown requestId is dropped rather than synthesized.
+ *
+ * Pure and self-contained (no Electron imports) so it is unit-testable.
+ */
+import { NATIVE_HTTP_REQUEST_ID_PREFIX } from "./request-ids.js";
+import { isUserFacingRequest } from "./user-facing.js";
+import type { NativeRequestTrace } from "../../ipc/bridge-router.js";
+
+/** One CDP message ready for `DevToolsAPI.dispatchMessage` injection. */
+export interface SynthesizedRequestMessage {
+ method: string;
+ params: unknown;
+ /**
+ * The verdict `isUserFacingRequest` produced for this request's url at
+ * `sent` time, cached for the request's whole lifetime — every later event
+ * reuses it (they carry no url of their own). The user-facing sink gates
+ * on this; the global mirror ignores it.
+ */
+ userFacing: boolean;
+ /** Response body ready to prime the forwarder's body-cache, present only
+ * on the message synthesized from a `finished` trace event. */
+ body?: { base64Encoded: boolean; body: string };
+ /** Request post data ready to prime the forwarder's post-data cache,
+ * present only on the message synthesized from the `sent` trace event when
+ * the request carried a body. */
+ postData?: string;
+}
+
+export interface RequestTraceSynthesizerOptions {
+ /** Per-forwarder instance tag keeping virtual ids collision-free. */
+ epoch: string;
+ /**
+ * Origins the app itself serves (resource server / simulator shell), the
+ * same inputs `resolveUserFacing` feeds `isUserFacingRequest` for
+ * simulator-captured HTTP traffic. Re-read at each initial `sent`; redirects retain that verdict.
+ */
+ internalOrigins?: () => ReadonlyArray;
+}
+
+interface RequestState {
+ requestId: string;
+ url: string;
+ userFacing: boolean;
+}
+
+/** CDP `Network.ResourceType` this forwarder classifies every wx.request
+ * call as — the same bucket the real front-end uses for `XMLHttpRequest`/
+ * `fetch()` business calls, so it renders with the matching icon/filter. */
+const RESOURCE_TYPE = "XHR";
+
+export class RequestTraceSynthesizer {
+ private readonly requests = new Map();
+ private seq = 0;
+
+ constructor(private readonly options: RequestTraceSynthesizerOptions) {}
+
+ /**
+ * Map one trace event to its CDP message, or null when the event must be
+ * dropped (a non-`sent` event for a requestId this synthesizer never saw
+ * sent — defensive against out-of-order delivery).
+ */
+ synthesize(
+ sessionId: string,
+ event: NativeRequestTrace,
+ ): SynthesizedRequestMessage | null {
+ const key = `${sessionId} ${event.requestId}`;
+ if (event.type === "sent" || event.type === "redirect") {
+ const previous = this.requests.get(key);
+ if (event.type === "redirect" && !previous) return null;
+ const requestId = event.type === "redirect" ? previous!.requestId
+ : `${NATIVE_HTTP_REQUEST_ID_PREFIX}${this.options.epoch}:${this.seq++}`;
+ const userFacing = event.type === "redirect" ? previous!.userFacing : isUserFacingRequest(
+ event.url,
+ this.options.internalOrigins?.(),
+ );
+ this.requests.set(key, { requestId, url: event.url, userFacing });
+ const timestamp = event.time / 1000;
+ const hasPostData = event.hasPostData ?? event.postData !== undefined;
+ const message: SynthesizedRequestMessage = {
+ method: "Network.requestWillBeSent",
+ params: {
+ requestId,
+ loaderId: requestId,
+ documentURL: event.url,
+ request: {
+ url: event.url,
+ method: event.method,
+ headers: event.headers,
+ hasPostData,
+ ...(hasPostData ? { postData: event.postData } : {}),
+ },
+ timestamp,
+ wallTime: timestamp,
+ initiator: { type: "script" },
+ type: RESOURCE_TYPE,
+ ...(event.type === "redirect" ? { redirectResponse: {
+ ...event.redirectResponse,
+ mimeType: mimeTypeOf(event.redirectResponse.headers),
+ connectionReused: false, connectionId: 0, encodedDataLength: 0,
+ } } : {}),
+ },
+ userFacing,
+ };
+ if (hasPostData) message.postData = event.postData;
+ return message;
+ }
+
+ const state = this.requests.get(key);
+ if (!state) return null;
+ const { requestId, userFacing } = state;
+ const timestamp = event.time / 1000;
+
+ switch (event.type) {
+ case "response":
+ return {
+ method: "Network.responseReceived",
+ params: {
+ requestId,
+ loaderId: requestId,
+ timestamp,
+ type: RESOURCE_TYPE,
+ response: {
+ url: state.url,
+ status: event.status,
+ statusText: event.statusText,
+ headers: event.headers,
+ mimeType: mimeTypeOf(event.headers),
+ connectionReused: false,
+ connectionId: 0,
+ encodedDataLength: 0,
+ fromDiskCache: false,
+ fromServiceWorker: false,
+ },
+ },
+ userFacing,
+ };
+ case "finished":
+ // Terminal: release the id mapping so a long-lived owner can't grow it.
+ this.requests.delete(key);
+ return {
+ method: "Network.loadingFinished",
+ params: {
+ requestId,
+ timestamp,
+ encodedDataLength: event.encodedDataLength,
+ },
+ userFacing,
+ ...(event.body !== undefined ? { body: { base64Encoded: event.bodyBase64Encoded, body: event.body } } : {}),
+ };
+ case "failed":
+ this.requests.delete(key);
+ return {
+ method: "Network.loadingFailed",
+ params: {
+ requestId,
+ timestamp,
+ type: RESOURCE_TYPE,
+ errorText: event.errorText,
+ },
+ userFacing,
+ };
+ }
+ }
+}
+
+/** `Content-Type: application/json; charset=utf-8` → `application/json`. Case
+ * -insensitive header lookup, matching HTTP semantics. */
+function mimeTypeOf(headers: Record): string {
+ for (const [name, value] of Object.entries(headers)) {
+ if (name.toLowerCase() !== "content-type") continue;
+ return value.split(";")[0]?.trim() ?? "";
+ }
+ return "";
+}
diff --git a/packages/devtools/src/main/services/network-forward/index.ts b/packages/devtools/src/main/services/network-forward/index.ts
index 62311f26..86ec9d49 100644
--- a/packages/devtools/src/main/services/network-forward/index.ts
+++ b/packages/devtools/src/main/services/network-forward/index.ts
@@ -74,31 +74,43 @@
* transport) arrive as trace events via `reportWebSocketTrace` and are
* synthesized into `Network.webSocket*` CDP messages (see websocket.ts).
*/
-import type { WebContents } from 'electron'
-import { SyncDisposableRegistry, toDisposable, type ConnectionRegistry, type Disposable } from '@dimina-kit/electron-deck/main'
-import { isFrontendSettled } from '../views/inject-when-ready.js'
-import { packDispatchBatch } from './dispatch-batch.js'
-import { PrefetchCache, DEFAULT_PER_ENTRY_MAX_CHARS } from './body-cache.js'
-import { PROBE_DEVTOOLS_API, MAX_SINGLE_DISPATCH_CHARS, CHUNK_CHARS, buildChunkedDispatchScript } from './frontend-dispatch.js'
-import { createCdpSessionBroker, type CdpSessionBroker, type CdpSessionLease } from '../cdp-session/index.js'
-import { isUserFacingRequest } from './user-facing.js'
-import { installGlobalNetworkBodyGate } from './global-body-gate.js'
-import { WebSocketTraceSynthesizer } from './websocket.js'
-import type { NativeWebSocketTrace } from '../../ipc/bridge-router.js'
-
-/**
- * Namespace prefix of every virtual requestId this forwarder injects into the
- * DevTools front-end. SINGLE SOURCE for the literal: the front-end outbound
- * gate (elements-forward) keys its `Network.getResponseBody` /
- * `Network.getRequestPostData` interception on this exact prefix, so the two
- * modules can never drift apart on what counts as "one of ours".
- */
-export const VIRTUAL_REQUEST_ID_PREFIX = 'dimina:sim:'
+import type { WebContents } from "electron";
+import {
+ SyncDisposableRegistry,
+ toDisposable,
+ type ConnectionRegistry,
+ type Disposable,
+} from "@dimina-kit/electron-deck/main";
+import { isFrontendSettled } from "../views/inject-when-ready.js";
+import { packDispatchBatch } from "./dispatch-batch.js";
+import { PrefetchCache, DEFAULT_PER_ENTRY_MAX_CHARS } from "./body-cache.js";
+import {
+ PROBE_DEVTOOLS_API,
+ MAX_SINGLE_DISPATCH_CHARS,
+ CHUNK_CHARS,
+ buildChunkedDispatchScript,
+} from "./frontend-dispatch.js";
+import {
+ createCdpSessionBroker,
+ type CdpSessionBroker,
+ type CdpSessionLease,
+} from "../cdp-session/index.js";
+import { isUserFacingRequest } from "./user-facing.js";
+import { installGlobalNetworkBodyGate } from "./global-body-gate.js";
+import { WebSocketTraceSynthesizer } from "./websocket.js";
+import { RequestTraceSynthesizer } from "./http.js";
+import type {
+ NativeRequestTrace,
+ NativeWebSocketTrace,
+} from "../../ipc/bridge-router.js";
+
+import { VIRTUAL_REQUEST_ID_PREFIX } from "./request-ids.js";
+export { VIRTUAL_REQUEST_ID_PREFIX } from "./request-ids.js";
/** CDP `Network.getResponseBody` result shape (served from the prefetch cache). */
export interface CdpResponseBody {
- body: string
- base64Encoded: boolean
+ body: string;
+ base64Encoded: boolean;
}
/**
@@ -110,22 +122,22 @@ export interface CdpResponseBody {
* real CDP backend produces for an unknown requestId.
*/
export interface NetworkBodyProvider {
- getResponseBody(requestId: string): Promise
- getRequestPostData(requestId: string): Promise<{ postData: string }>
+ getResponseBody(requestId: string): Promise;
+ getRequestPostData(requestId: string): Promise<{ postData: string }>;
}
/** Which layer a captured request came from (tags the fallback log line). */
-export type NetworkSource = 'service' | 'render'
+export type NetworkSource = "service" | "render";
/** A normalized completed network request, used only by the console fallback. */
export interface NetworkRequestRecord {
- source: NetworkSource
- url: string
- method: string
+ source: NetworkSource;
+ url: string;
+ method: string;
/** HTTP status, or 0 when the request failed before a response. */
- status: number
+ status: number;
/** Failure text for `loadingFailed`, else undefined. */
- errorText?: string
+ errorText?: string;
}
/**
@@ -135,13 +147,13 @@ export interface NetworkRequestRecord {
*/
export interface NetworkForwarderBridge {
/** The SERVICE HOST wc — fallback console sink target. */
- getServiceWc(appId?: string): WebContents | null
+ getServiceWc(appId?: string): WebContents | null;
/**
* The wc hosting the right-panel Chrome DevTools FRONT-END (the one we inject
* `window.DevToolsAPI.dispatchMessage` into). Set by the ViewManager via
* `setDevtoolsHost`. Null until the DevTools host view exists.
*/
- getDevtoolsWc?(): WebContents | null
+ getDevtoolsWc?(): WebContents | null;
/**
* The dimina resource server's baseUrl (e.g. `'http://127.0.0.1:54321/'`),
* when running — consulted by `isUserFacingRequest` so the framework
@@ -149,7 +161,7 @@ export interface NetworkForwarderBridge {
* Absent → that origin check is simply skipped (the scheme rule alone
* still filters out file:// / difile:// / devtools:// framework loads).
*/
- getResourceServerBaseUrl?(): string | null
+ getResourceServerBaseUrl?(): string | null;
/**
* The simulator's own static-asset server baseUrl (serves `simulator.html`
* + its JS/CSS — a devkit-owned server, independent from the resource
@@ -161,7 +173,7 @@ export interface NetworkForwarderBridge {
* misclassified as the developer's business traffic. Absent → that origin
* check is simply skipped.
*/
- getSimulatorServerBaseUrl?(): string | null
+ getSimulatorServerBaseUrl?(): string | null;
/**
* Optional connection-layer registry (`@dimina-kit/electron-deck/main`). When
* present, per-webContents teardowns route through `acquire(wc).own(d)` so the
@@ -169,7 +181,7 @@ export interface NetworkForwarderBridge {
* the bespoke `wc.once('destroyed', cleanup)`). Omitted → the legacy
* `once('destroyed')` fallback is used, so existing callers compile unchanged.
*/
- connections?: ConnectionRegistry
+ connections?: ConnectionRegistry;
/**
* Shared CDP session broker (see cdp-session/index.ts) that owns every
* render-guest AND simulator debugger session's attach/detach lifecycle —
@@ -181,7 +193,7 @@ export interface NetworkForwarderBridge {
* `attachSimulator` go through it — see detachSimulator's docstring for why
* the simulator path never forces a physical detach either.
*/
- broker?: CdpSessionBroker
+ broker?: CdpSessionBroker;
}
export interface NetworkForwarder extends Disposable {
@@ -191,9 +203,9 @@ export interface NetworkForwarder extends Disposable {
* relaunch / pool swap) detaches the previous one first. No-op if the wc is
* destroyed or its debugger is already claimed (DevTools / another client).
*/
- attachSimulator(wc: WebContents): void
+ attachSimulator(wc: WebContents): void;
/** Detach from the current simulator WCV (without disposing the forwarder). */
- detachSimulator(): void
+ detachSimulator(): void;
/**
* Wire a render-host guest wc (pageFrame) for Network capture — page-level
* resource loads (images/fonts/page fetch) that never touch the simulator's
@@ -207,13 +219,13 @@ export interface NetworkForwarder extends Disposable {
* virtual-id namespace prefix (distinct epochs keep them collision-free) and
* the same body prefetch cache.
*/
- attachRenderGuest(wc: WebContents): void
+ attachRenderGuest(wc: WebContents): void;
/**
* Point the forwarder at the WebContents hosting the DevTools FRONT-END (the
* primary, native-Network-tab sink). Pass null when that view is torn down so
* we fall back to the console line. Re-callable across DevTools re-creates.
*/
- setDevtoolsHost(wc: WebContents | null): void
+ setDevtoolsHost(wc: WebContents | null): void;
/**
* Point the forwarder at the WebContents hosting the standalone internal
* (app-wide) DevTools window's front-end — the global mirror sink.
@@ -224,12 +236,12 @@ export interface NetworkForwarder extends Disposable {
* failed dispatch is silently dropped — nobody depends on this path having
* a fallback). Pass null when the window closes.
*/
- setGlobalDevtoolsHost(wc: WebContents | null): void
+ setGlobalDevtoolsHost(wc: WebContents | null): void;
/**
* Manually surface a request that no `webContents.debugger` can observe
* (e.g. a main-process direct send). Uses the console fallback sink.
*/
- report(record: NetworkRequestRecord): void
+ report(record: NetworkRequestRecord): void;
/**
* Surface one main-process WebSocket trace event (wx.connectSocket traffic
* lives on the Node `ws` transport, invisible to any debugger) as a
@@ -238,14 +250,27 @@ export interface NetworkForwarder extends Disposable {
* plus the user-facing native sink when the socket's url classified
* user-facing. Best-effort like every injection point — never throws.
*/
- reportWebSocketTrace(sessionId: string, event: NativeWebSocketTrace): void
+ reportWebSocketTrace(sessionId: string, event: NativeWebSocketTrace): void;
+ /**
+ * Surface one main-process HTTP request trace event (wx.request traffic
+ * runs on Node http/https, invisible to any debugger, precisely so it
+ * skips Chromium's Fetch/CORS algorithm and its OPTIONS preflight) as a
+ * synthesized `Network.request*`/`Network.loading*` CDP event, through the
+ * SAME channels as forwarded simulator traffic: the global mirror
+ * unfiltered, plus the user-facing native sink when the request's url
+ * classified user-facing. `finished` primes the body cache (the response is
+ * already fully buffered in-process, no CDP round-trip needed); `sent`
+ * primes the post-data cache when the request carried a body. Best-effort
+ * like every injection point — never throws.
+ */
+ reportNativeRequestTrace(sessionId: string, event: NativeRequestTrace): void;
/**
* Body/post-data lookups for the virtual requestIds this forwarder injected,
* backed by the loadingFinished-time prefetch cache. Keyed by virtual id, so
* entries stay valid across detach/re-attach (each attach epoch mints
* non-colliding ids); dispose() drops them all.
*/
- readonly bodies: NetworkBodyProvider
+ readonly bodies: NetworkBodyProvider;
}
// ── requestId namespacing (pure, testable) ──────────────────────────────────
@@ -258,35 +283,35 @@ export interface NetworkForwarder extends Disposable {
* included for that reason (rewrite-only today, not forwarded).
*/
export const REWRITE_REQUEST_ID_METHODS: ReadonlySet = new Set([
- 'Network.requestWillBeSent',
- 'Network.requestWillBeSentExtraInfo',
- 'Network.responseReceived',
- 'Network.responseReceivedExtraInfo',
- 'Network.dataReceived',
- 'Network.loadingFinished',
- 'Network.loadingFailed',
- 'Network.requestServedFromCache',
- 'Network.resourceChangedPriority',
+ "Network.requestWillBeSent",
+ "Network.requestWillBeSentExtraInfo",
+ "Network.responseReceived",
+ "Network.responseReceivedExtraInfo",
+ "Network.dataReceived",
+ "Network.loadingFinished",
+ "Network.loadingFailed",
+ "Network.requestServedFromCache",
+ "Network.resourceChangedPriority",
// 二期 (when WebSocket/EventSource forwarding lands): Network.webSocket*,
// Network.eventSourceMessageReceived — keep ids namespaced once added here.
-])
+]);
/** The Network.* methods this one-shot pass forwards to the front-end. */
export const FORWARDED_METHODS: ReadonlySet = new Set([
- 'Network.requestWillBeSent',
- 'Network.requestWillBeSentExtraInfo',
- 'Network.responseReceived',
- 'Network.responseReceivedExtraInfo',
- 'Network.loadingFinished',
- 'Network.loadingFailed',
+ "Network.requestWillBeSent",
+ "Network.requestWillBeSentExtraInfo",
+ "Network.responseReceived",
+ "Network.responseReceivedExtraInfo",
+ "Network.loadingFinished",
+ "Network.loadingFailed",
// `dataReceived` deliberately omitted (二期) — see header.
-])
+]);
/** Methods that mark a request finished, so its id mapping can age out. */
const TERMINAL_METHODS: ReadonlySet = new Set([
- 'Network.loadingFinished',
- 'Network.loadingFailed',
-])
+ "Network.loadingFinished",
+ "Network.loadingFailed",
+]);
/**
* Bounded, TTL'd raw→virtual requestId map with an active/retired split.
@@ -303,8 +328,11 @@ const TERMINAL_METHODS: ReadonlySet = new Set([
* touches the retired pool.
*/
export class RequestIdNamespace {
- private readonly map = new Map()
- private seq = 0
+ private readonly map = new Map<
+ string,
+ { virtual: string; expires: number; active: boolean }
+ >();
+ private seq = 0;
constructor(
private readonly epoch: string,
@@ -324,20 +352,20 @@ export class RequestIdNamespace {
* active entry never "expires" no matter how long it stays in flight.
*/
resolve(rawId: string): string {
- const t = this.now()
- const existing = this.map.get(rawId)
+ const t = this.now();
+ const existing = this.map.get(rawId);
if (existing && (existing.active || existing.expires > t)) {
// Refresh recency (LRU) on touch; refresh TTL only for retired entries
// (active entries don't use TTL at all).
- if (!existing.active) existing.expires = t + this.ttlMs
- this.map.delete(rawId)
- this.map.set(rawId, existing)
- return existing.virtual
+ if (!existing.active) existing.expires = t + this.ttlMs;
+ this.map.delete(rawId);
+ this.map.set(rawId, existing);
+ return existing.virtual;
}
- const virtual = `${VIRTUAL_REQUEST_ID_PREFIX}${this.epoch}:${this.seq++}:${rawId}`
- this.map.set(rawId, { virtual, expires: t + this.ttlMs, active: true })
- this.evict(t)
- return virtual
+ const virtual = `${VIRTUAL_REQUEST_ID_PREFIX}${this.epoch}:${this.seq++}:${rawId}`;
+ this.map.set(rawId, { virtual, expires: t + this.ttlMs, active: true });
+ this.evict(t);
+ return virtual;
}
/**
@@ -345,38 +373,38 @@ export class RequestIdNamespace {
* pool with a fresh TTL, where it becomes eligible for TTL/LRU eviction.
*/
retire(rawId: string): void {
- const e = this.map.get(rawId)
+ const e = this.map.get(rawId);
if (e) {
- e.active = false
- e.expires = this.now() + this.ttlMs
+ e.active = false;
+ e.expires = this.now() + this.ttlMs;
}
}
private evict(t: number): void {
// Drop expired retired entries first (active entries never expire).
for (const [k, v] of this.map) {
- if (!v.active && v.expires <= t) this.map.delete(k)
+ if (!v.active && v.expires <= t) this.map.delete(k);
}
// Then LRU-trim the RETIRED pool to the cap. Active entries are exempt:
// we walk in insertion/refresh order and skip any still-active entry, so an
// in-flight request is never evicted even past the cap.
- if (this.map.size <= this.max) return
+ if (this.map.size <= this.max) return;
for (const [k, v] of this.map) {
- if (this.map.size <= this.max) break
- if (!v.active) this.map.delete(k)
+ if (this.map.size <= this.max) break;
+ if (!v.active) this.map.delete(k);
}
}
/** Total entries (active + retired). */
get size(): number {
- return this.map.size
+ return this.map.size;
}
/** Entries still in flight (resolved, not yet retired). */
get activeSize(): number {
- let n = 0
- for (const v of this.map.values()) if (v.active) n++
- return n
+ let n = 0;
+ for (const v of this.map.values()) if (v.active) n++;
+ return n;
}
}
@@ -391,12 +419,12 @@ export function rewriteRequestId(
params: unknown,
ns: RequestIdNamespace,
): { method: string; params: unknown } {
- if (!REWRITE_REQUEST_ID_METHODS.has(method)) return { method, params }
- const p = params as { requestId?: unknown } | null | undefined
- if (!p || typeof p.requestId !== 'string') return { method, params }
- const virtual = ns.resolve(p.requestId)
- if (TERMINAL_METHODS.has(method)) ns.retire(p.requestId)
- return { method, params: { ...(p as object), requestId: virtual } }
+ if (!REWRITE_REQUEST_ID_METHODS.has(method)) return { method, params };
+ const p = params as { requestId?: unknown } | null | undefined;
+ if (!p || typeof p.requestId !== "string") return { method, params };
+ const virtual = ns.resolve(p.requestId);
+ if (TERMINAL_METHODS.has(method)) ns.retire(p.requestId);
+ return { method, params: { ...(p as object), requestId: virtual } };
}
// ── DevTools front-end injection (the primary sink) ─────────────────────────
@@ -409,13 +437,15 @@ export function rewriteRequestId(
* when the API isn't there yet, so the main side knows to retry / fall back.
*/
function buildDispatchScript(messages: string[]): string {
- const arr = JSON.stringify(messages)
- return `(()=>{try{`
- + `if(!${PROBE_DEVTOOLS_API})return false;`
- + `const ms=JSON.parse(${JSON.stringify(arr)});`
- + `for(const m of ms){try{window.DevToolsAPI.dispatchMessage(m)}catch(_){}}`
- + `return true;`
- + `}catch(_){return false}})()`
+ const arr = JSON.stringify(messages);
+ return (
+ `(()=>{try{` +
+ `if(!${PROBE_DEVTOOLS_API})return false;` +
+ `const ms=JSON.parse(${JSON.stringify(arr)});` +
+ `for(const m of ms){try{window.DevToolsAPI.dispatchMessage(m)}catch(_){}}` +
+ `return true;` +
+ `}catch(_){return false}})()`
+ );
}
/**
@@ -425,7 +455,7 @@ function buildDispatchScript(messages: string[]): string {
* whole batch. We pack greedily up to this many chars, then flush and start a
* new batch, so each `executeJavaScript` stays well-sized.
*/
-const MAX_BATCH_CHARS = 512 * 1024
+const MAX_BATCH_CHARS = 512 * 1024;
// ── console fallback sink ───────────────────────────────────────────────────
@@ -435,54 +465,61 @@ const MAX_BATCH_CHARS = 512 * 1024
* captured value is ever interpolated into executable JS (data-not-code).
*/
function buildForwardScript(record: NetworkRequestRecord): string {
- const json = JSON.stringify(record)
- return `(()=>{try{const r=JSON.parse(${JSON.stringify(json)});`
- + `const tag='[网络]['+r.source+']';`
- + `const head=r.method+' '+(r.status||'-')+' '+r.url;`
- + `if(r.errorText){console.warn(tag,head,r.errorText)}`
- + `else if(r.status>=400){console.warn(tag,head)}`
- + `else{console.log(tag,head)}`
- + `}catch(_){}})()`
+ const json = JSON.stringify(record);
+ return (
+ `(()=>{try{const r=JSON.parse(${JSON.stringify(json)});` +
+ `const tag='[网络]['+r.source+']';` +
+ `const head=r.method+' '+(r.status||'-')+' '+r.url;` +
+ `if(r.errorText){console.warn(tag,head,r.errorText)}` +
+ `else if(r.status>=400){console.warn(tag,head)}` +
+ `else{console.log(tag,head)}` +
+ `}catch(_){}})()`
+ );
}
/** CDP `Network.requestWillBeSent` params slice the fallback + prefetch read. */
interface RequestWillBeSent {
- requestId: string
- request: { url: string; method: string; hasPostData?: boolean; postData?: string }
+ requestId: string;
+ request: {
+ url: string;
+ method: string;
+ hasPostData?: boolean;
+ postData?: string;
+ };
}
/** CDP `Network.responseReceived` params slice the fallback reads. */
interface ResponseReceived {
- requestId: string
- response: { status: number }
+ requestId: string;
+ response: { status: number };
}
/** CDP `Network.loadingFailed` params slice the fallback reads. */
interface LoadingFailed {
- requestId: string
- errorText?: string
- canceled?: boolean
+ requestId: string;
+ errorText?: string;
+ canceled?: boolean;
}
/** Pending-request bookkeeping for the console fallback between events. */
interface Pending {
- url: string
- method: string
- status: number
+ url: string;
+ method: string;
+ status: number;
}
/** Cap so a long session can't grow the fallback pending map unboundedly. */
-const MAX_PENDING = 1000
+const MAX_PENDING = 1000;
/** Cap on the native dispatch queue (chars-agnostic count) while no/ready host. */
-const MAX_DISPATCH_QUEUE = 2000
+const MAX_DISPATCH_QUEUE = 2000;
/**
* How long we wait for `window.DevToolsAPI.dispatchMessage` to answer ready once
* a host wc IS set, before giving up on the native path for that host and
* degrading to the console sink. Prevents the infinite-requeue-never-fallback
* loop when a host exists but its front-end never finishes booting.
*/
-const DEVTOOLS_READY_TIMEOUT_MS = 5_000
+const DEVTOOLS_READY_TIMEOUT_MS = 5_000;
/** Poll interval while probing for the front-end API to become ready. */
-const READY_RETRY_MS = 100
+const READY_RETRY_MS = 100;
/**
* Per-host native-sink state. The forwarder routes EACH request to exactly one
@@ -499,19 +536,23 @@ const READY_RETRY_MS = 100
* - 'degraded' : ready timed out for this host. Native path abandoned (queue
* dropped, marked so the hot path stops retrying); console used.
*/
-type SinkState = 'idle' | 'probing' | 'ready' | 'degraded'
+type SinkState = "idle" | "probing" | "ready" | "degraded";
-export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkForwarder {
- const registry = new SyncDisposableRegistry()
- let disposed = false
+export function createNetworkForwarder(
+ bridge: NetworkForwarderBridge,
+): NetworkForwarder {
+ const registry = new SyncDisposableRegistry();
+ let disposed = false;
// ── Response body / post-data prefetch (serves the front-end's clicks) ─────
// Keyed by VIRTUAL requestId and owned by the forwarder (not the attach):
// epochs make ids non-colliding, so entries stay servable across a simulator
// detach/re-attach while the panel still shows the old rows. Bounded + TTL'd
// in the cache itself.
- const bodyCache = new PrefetchCache((v) => v.body.length)
- const postDataCache = new PrefetchCache<{ postData: string }>((v) => v.postData.length)
+ const bodyCache = new PrefetchCache((v) => v.body.length);
+ const postDataCache = new PrefetchCache<{ postData: string }>(
+ (v) => v.postData.length,
+ );
// ── Prefetch admission control ──────────────────────────────────────────────
// Every completed request (simulator + all render guests combined — this
@@ -529,8 +570,8 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
// spike. `primeWithAdmission`'s bookkeeping only counts a slot when the cache
// actually started a new fetch (PrefetchCache.prime()'s idempotent no-op
// return doesn't consume one).
- const MAX_CONCURRENT_PREFETCHES = 32
- let pendingPrefetchCount = 0
+ const MAX_CONCURRENT_PREFETCHES = 32;
+ let pendingPrefetchCount = 0;
/**
* `fetch` MUST be an `async` function (both current call sites in
* `prefetchBodies` are). An `async` function can never throw synchronously —
@@ -541,31 +582,48 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
* slot forever (`PrefetchCache.prime` catches that synchronous throw and
* still reports `started: true`), so never pass one here.
*/
- function primeWithAdmission(cache: PrefetchCache, id: string, fetch: () => Promise): void {
- if (pendingPrefetchCount >= MAX_CONCURRENT_PREFETCHES) return
- pendingPrefetchCount++
- let released = false
- const release = (): void => { if (!released) { released = true; pendingPrefetchCount-- } }
- const started = cache.prime(id, () => fetch().then(
- (v) => { release(); return v },
- (err: unknown) => { release(); throw err },
- ))
- if (!started) release()
+ function primeWithAdmission(
+ cache: PrefetchCache,
+ id: string,
+ fetch: () => Promise,
+ ): void {
+ if (pendingPrefetchCount >= MAX_CONCURRENT_PREFETCHES) return;
+ pendingPrefetchCount++;
+ let released = false;
+ const release = (): void => {
+ if (!released) {
+ released = true;
+ pendingPrefetchCount--;
+ }
+ };
+ const started = cache.prime(id, () =>
+ fetch().then(
+ (v) => {
+ release();
+ return v;
+ },
+ (err: unknown) => {
+ release();
+ throw err;
+ },
+ ),
+ );
+ if (!started) release();
}
// The simulator WCV we currently have a debugger session on, our broker
// lease for it, and the per-attach teardown (message listener). Null when
// not attached.
- let simWc: WebContents | null = null
- let simLease: CdpSessionLease | null = null
- let attachDisposables: SyncDisposableRegistry | null = null
+ let simWc: WebContents | null = null;
+ let simLease: CdpSessionLease | null = null;
+ let attachDisposables: SyncDisposableRegistry | null = null;
// Render-host guests wired for capture: wc.id → SYNCHRONOUS per-guest
// teardown (message listener + broker lease). Attach/detach ownership for
// these sessions lives entirely in the shared broker now (see cdp-session/
// index.ts) — this map only tracks OUR OWN wiring (the 'message' listener
// wireNetworkCapture installs), not who may detach the underlying session.
- const guestWired = new Map()
+ const guestWired = new Map();
// wc.id → the generation token of the most recently scheduled render-guest
// retry (acquire refused, or an established session got detached).
// `guestWired` alone cannot de-duplicate repeat `attachRenderGuest` calls
@@ -580,71 +638,73 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
// chains. A monotonic generation token lets a fired timer recognize its own
// staleness (its captured token no longer matches the current one) and
// no-op instead of touching state it no longer owns.
- const guestRetryGeneration = new Map()
- let nextRenderGuestRetryGeneration = 0
+ const guestRetryGeneration = new Map();
+ let nextRenderGuestRetryGeneration = 0;
// Own (and dispose on this forwarder's own dispose()) a private broker only
// when the caller didn't supply a shared one.
- const ownsBroker = !bridge.broker
- const broker = bridge.broker ?? createCdpSessionBroker({ connections: bridge.connections })
+ const ownsBroker = !bridge.broker;
+ const broker =
+ bridge.broker ??
+ createCdpSessionBroker({ connections: bridge.connections });
// The DevTools front-end host wc (primary sink), set by the ViewManager.
- let devtoolsWc: WebContents | null = null
+ let devtoolsWc: WebContents | null = null;
// Teardown for the wc 'destroyed' watcher on the current host (clears the host).
- let devtoolsHostDisposable: Disposable | null = null
+ let devtoolsHostDisposable: Disposable | null = null;
// ── Global mirror sink ──────────────────────────────────────────────────────
// The standalone internal (app-wide) DevTools window's front-end host wc.
// Deliberately NO probing/ready/degraded state machine and NO console
// fallback — see setGlobalDevtoolsHost's doc comment.
- let globalDevtoolsWc: WebContents | null = null
- let globalDevtoolsHostDisposable: Disposable | null = null
+ let globalDevtoolsWc: WebContents | null = null;
+ let globalDevtoolsHostDisposable: Disposable | null = null;
// Dedicated network-only outbound gate (body/post-data lookups) for the
// CURRENT global host — never elements-forward's gate, see
// global-body-gate.ts's header for why. Stopped whenever the host changes
// or clears.
- let globalBodyGateStop: (() => void) | null = null
+ let globalBodyGateStop: (() => void) | null = null;
// Bounded in-order hold for global-mirror messages arriving while the
// global host's front-end has not settled yet (its boot window) — without
// it those events are silently unrecoverable, breaking the window's
// full-stream promise. Scoped strictly to the CURRENT host: cleared when
// the host changes/clears, never replayed into a different host.
- const MAX_GLOBAL_PENDING_QUEUE = 2000
- let globalPendingQueue: string[] = []
- let globalPendingOverflowWarned = false
- let globalFlushTimer: ReturnType | null = null
+ const MAX_GLOBAL_PENDING_QUEUE = 2000;
+ let globalPendingQueue: string[] = [];
+ let globalPendingOverflowWarned = false;
+ let globalFlushTimer: ReturnType | null = null;
// Wall-clock bound on the settle-poll (not on the queue itself): a
// front-end that never settles stops being polled after this, and the
// still-held queue flushes via the next incoming event instead.
- const GLOBAL_FLUSH_POLL_MAX_MS = 60_000
+ const GLOBAL_FLUSH_POLL_MAX_MS = 60_000;
function clearGlobalPending(): void {
- globalPendingQueue = []
- globalPendingOverflowWarned = false
+ globalPendingQueue = [];
+ globalPendingOverflowWarned = false;
if (globalFlushTimer) {
- clearTimeout(globalFlushTimer)
- globalFlushTimer = null
+ clearTimeout(globalFlushTimer);
+ globalFlushTimer = null;
}
}
function scheduleGlobalFlush(startedAt = Date.now()): void {
- if (globalFlushTimer) return
- if (Date.now() - startedAt >= GLOBAL_FLUSH_POLL_MAX_MS) return
+ if (globalFlushTimer) return;
+ if (Date.now() - startedAt >= GLOBAL_FLUSH_POLL_MAX_MS) return;
// unref'd like scheduleRenderGuestRetry's timer: a settle-poll must
// never be what keeps the process (or a test file) alive.
globalFlushTimer = setTimeout(() => {
- globalFlushTimer = null
- if (globalPendingQueue.length === 0) return
+ globalFlushTimer = null;
+ if (globalPendingQueue.length === 0) return;
if (!globalDevtoolsWc || globalDevtoolsWc.isDestroyed()) {
- clearGlobalPending()
- return
+ clearGlobalPending();
+ return;
}
if (!isFrontendSettled(globalDevtoolsWc)) {
- scheduleGlobalFlush(startedAt)
- return
+ scheduleGlobalFlush(startedAt);
+ return;
}
- flushGlobalPending()
- }, READY_RETRY_MS)
- globalFlushTimer.unref?.()
+ flushGlobalPending();
+ }, READY_RETRY_MS);
+ globalFlushTimer.unref?.();
}
/** Drain the pending queue into the (settled) global host, oldest first,
@@ -654,78 +714,91 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
* giant executeJavaScript overflows the IPC/script limit and the whole
* call rejects into a silent drop. */
function flushGlobalPending(): void {
- if (globalPendingQueue.length === 0) return
- const target = globalDevtoolsWc
+ if (globalPendingQueue.length === 0) return;
+ const target = globalDevtoolsWc;
if (!target || target.isDestroyed()) {
- clearGlobalPending()
- return
+ clearGlobalPending();
+ return;
}
while (globalPendingQueue.length > 0) {
- const { batch, chunked, remaining } = packDispatchBatch(globalPendingQueue, MAX_SINGLE_DISPATCH_CHARS, MAX_BATCH_CHARS)
- globalPendingQueue = remaining
- for (const msg of chunked) dispatchChunked(target, msg)
+ const { batch, chunked, remaining } = packDispatchBatch(
+ globalPendingQueue,
+ MAX_SINGLE_DISPATCH_CHARS,
+ MAX_BATCH_CHARS,
+ );
+ globalPendingQueue = remaining;
+ for (const msg of chunked) dispatchChunked(target, msg);
if (batch.length > 0) {
- target.executeJavaScript(buildDispatchScript(batch), true).catch(() => { /* best-effort */ })
+ target.executeJavaScript(buildDispatchScript(batch), true).catch(() => {
+ /* best-effort */
+ });
}
}
- globalPendingOverflowWarned = false
+ globalPendingOverflowWarned = false;
}
function enqueueGlobalPending(json: string): void {
if (globalPendingQueue.length >= MAX_GLOBAL_PENDING_QUEUE) {
- globalPendingQueue.shift()
+ globalPendingQueue.shift();
// Not silent: dropping past the cap means the window's full-stream
// promise is degraded — say so once per overflow episode, not per event.
if (!globalPendingOverflowWarned) {
- globalPendingOverflowWarned = true
- console.warn(`[network-forward] global mirror queue overflow (cap ${MAX_GLOBAL_PENDING_QUEUE}): dropping oldest events while the debug window front-end is still loading`)
+ globalPendingOverflowWarned = true;
+ console.warn(
+ `[network-forward] global mirror queue overflow (cap ${MAX_GLOBAL_PENDING_QUEUE}): dropping oldest events while the debug window front-end is still loading`,
+ );
}
}
- globalPendingQueue.push(json)
- scheduleGlobalFlush()
+ globalPendingQueue.push(json);
+ scheduleGlobalFlush();
}
function applyGlobalDevtoolsHost(host: WebContents | null): void {
- globalDevtoolsHostDisposable?.dispose()
- globalDevtoolsHostDisposable = null
- globalBodyGateStop?.()
- globalBodyGateStop = null
+ globalDevtoolsHostDisposable?.dispose();
+ globalDevtoolsHostDisposable = null;
+ globalBodyGateStop?.();
+ globalBodyGateStop = null;
// Queued events belong to the PREVIOUS host's boot window — never carry
// them across a host change (or into "no host").
- clearGlobalPending()
- globalDevtoolsWc = host && !host.isDestroyed() ? host : null
- if (!globalDevtoolsWc) return
- const target = globalDevtoolsWc
+ clearGlobalPending();
+ globalDevtoolsWc = host && !host.isDestroyed() ? host : null;
+ if (!globalDevtoolsWc) return;
+ const target = globalDevtoolsWc;
const onHostDestroyed = (): void => {
- if (globalDevtoolsWc === target) globalDevtoolsWc = null
- globalBodyGateStop?.()
- globalBodyGateStop = null
- clearGlobalPending()
- }
- const reg = bridge.connections
- if (reg && typeof target.once === 'function') {
- const owned = reg.acquire(target).own(onHostDestroyed)
- globalDevtoolsHostDisposable = toDisposable(() => owned.dispose())
+ if (globalDevtoolsWc === target) globalDevtoolsWc = null;
+ globalBodyGateStop?.();
+ globalBodyGateStop = null;
+ clearGlobalPending();
+ };
+ const reg = bridge.connections;
+ if (reg && typeof target.once === "function") {
+ const owned = reg.acquire(target).own(onHostDestroyed);
+ globalDevtoolsHostDisposable = toDisposable(() => owned.dispose());
} else {
- if (typeof target.once === 'function') target.once('destroyed', onHostDestroyed)
+ if (typeof target.once === "function")
+ target.once("destroyed", onHostDestroyed);
globalDevtoolsHostDisposable = toDisposable(() => {
- try { target.removeListener?.('destroyed', onHostDestroyed) } catch { /* gone */ }
- })
+ try {
+ target.removeListener?.("destroyed", onHostDestroyed);
+ } catch {
+ /* gone */
+ }
+ });
}
globalBodyGateStop = installGlobalNetworkBodyGate(target, {
getResponseBody: (requestId) => bodyCache.lookup(requestId),
getRequestPostData: (requestId) => postDataCache.lookup(requestId),
- })
+ });
}
/** Best-effort mirror of one raw CDP message into the global host. */
function dispatchToGlobal(method: string, params: unknown): void {
- if (!globalDevtoolsWc || globalDevtoolsWc.isDestroyed()) return
- let json: string
+ if (!globalDevtoolsWc || globalDevtoolsWc.isDestroyed()) return;
+ let json: string;
try {
- json = JSON.stringify({ method, params })
+ json = JSON.stringify({ method, params });
} catch {
- return
+ return;
}
// Same settled gate every other injection point in this file uses: an
// unsettled front-end wipes its state on load anyway, and executeJavaScript
@@ -734,26 +807,30 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
// those injection points, the events here are NOT re-derivable later, so
// they queue for a post-settle flush instead of dropping.
if (!isFrontendSettled(globalDevtoolsWc)) {
- enqueueGlobalPending(json)
- return
+ enqueueGlobalPending(json);
+ return;
}
// Anything still queued flushes first so delivery order matches arrival.
- flushGlobalPending()
+ flushGlobalPending();
// Oversized single messages take the chunked transport, mirroring the
// native sink's flushDispatch — see flushGlobalPending's doc.
if (json.length > MAX_SINGLE_DISPATCH_CHARS) {
- dispatchChunked(globalDevtoolsWc, json)
- return
+ dispatchChunked(globalDevtoolsWc, json);
+ return;
}
- globalDevtoolsWc.executeJavaScript(buildDispatchScript([json]), true).catch(() => { /* best-effort */ })
+ globalDevtoolsWc
+ .executeJavaScript(buildDispatchScript([json]), true)
+ .catch(() => {
+ /* best-effort */
+ });
}
// ── Native-sink state machine ─────────────────────────────────────────────
- let sink: SinkState = 'idle'
+ let sink: SinkState = "idle";
// Buffered completed-request records while 'probing' — flushed to console if we
// degrade, dropped if we go ready (so a request shows in exactly one sink).
- let probeConsoleBuffer: NetworkRequestRecord[] = []
- let readyTimeoutTimer: ReturnType | null = null
+ let probeConsoleBuffer: NetworkRequestRecord[] = [];
+ let readyTimeoutTimer: ReturnType | null = null;
// Wall-clock deadline for the CURRENT probe, set once when 'probing' begins.
// scheduleReadyRetry() re-checks this on every retry so the retry chain is
// itself authoritative on giving up — it does not depend on winning a race
@@ -763,42 +840,48 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
// "Aborting after running 10000 timers" abort in `network-forward`'s
// ready-timeout test). readyTimeoutTimer stays as a backstop for hosts that
// never retry at all (e.g. the queue drains before the deadline).
- let probeDeadline: number | null = null
+ let probeDeadline: number | null = null;
// ── Batched native dispatch into the DevTools front-end ───────────────────
// Queued raw CDP messages (already namespaced + JSON-stringified) awaiting a
// microtask flush — coalescing many events into one executeJavaScript avoids
// high-frequency IPC.
- let dispatchQueue: string[] = []
- let flushScheduled = false
- let readyRetryTimer: ReturnType | null = null
+ let dispatchQueue: string[] = [];
+ let flushScheduled = false;
+ let readyRetryTimer: ReturnType | null = null;
function resolveDevtoolsWc(): WebContents | null {
- const wc = devtoolsWc ?? bridge.getDevtoolsWc?.() ?? null
- return wc && !wc.isDestroyed() ? wc : null
+ const wc = devtoolsWc ?? bridge.getDevtoolsWc?.() ?? null;
+ return wc && !wc.isDestroyed() ? wc : null;
}
/** Enter the 'probing' state and arm the ready-timeout (idempotent). */
function beginProbing(): void {
- if (sink === 'probing') return
- sink = 'probing'
- probeDeadline = Date.now() + DEVTOOLS_READY_TIMEOUT_MS
- if (readyTimeoutTimer) clearTimeout(readyTimeoutTimer)
+ if (sink === "probing") return;
+ sink = "probing";
+ probeDeadline = Date.now() + DEVTOOLS_READY_TIMEOUT_MS;
+ if (readyTimeoutTimer) clearTimeout(readyTimeoutTimer);
readyTimeoutTimer = setTimeout(() => {
- readyTimeoutTimer = null
+ readyTimeoutTimer = null;
// Still not ready after the grace period → abandon native for this host.
- if (sink === 'probing') degradeToConsole()
- }, DEVTOOLS_READY_TIMEOUT_MS)
+ if (sink === "probing") degradeToConsole();
+ }, DEVTOOLS_READY_TIMEOUT_MS);
}
/** Native path confirmed live: console buffer is moot, drop it. */
function markReady(): void {
- sink = 'ready'
- probeDeadline = null
- if (readyTimeoutTimer) { clearTimeout(readyTimeoutTimer); readyTimeoutTimer = null }
- if (readyRetryTimer) { clearTimeout(readyRetryTimer); readyRetryTimer = null }
+ sink = "ready";
+ probeDeadline = null;
+ if (readyTimeoutTimer) {
+ clearTimeout(readyTimeoutTimer);
+ readyTimeoutTimer = null;
+ }
+ if (readyRetryTimer) {
+ clearTimeout(readyRetryTimer);
+ readyRetryTimer = null;
+ }
// Native rendered these requests; their buffered console copies would dup.
- probeConsoleBuffer = []
+ probeConsoleBuffer = [];
}
/**
@@ -807,20 +890,26 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
* and flush the buffered completed-request records to the console sink.
*/
function degradeToConsole(): void {
- sink = 'degraded'
- probeDeadline = null
- dispatchQueue = []
- if (readyTimeoutTimer) { clearTimeout(readyTimeoutTimer); readyTimeoutTimer = null }
- if (readyRetryTimer) { clearTimeout(readyRetryTimer); readyRetryTimer = null }
- const buffered = probeConsoleBuffer
- probeConsoleBuffer = []
- for (const r of buffered) forwardToConsole(r)
+ sink = "degraded";
+ probeDeadline = null;
+ dispatchQueue = [];
+ if (readyTimeoutTimer) {
+ clearTimeout(readyTimeoutTimer);
+ readyTimeoutTimer = null;
+ }
+ if (readyRetryTimer) {
+ clearTimeout(readyRetryTimer);
+ readyRetryTimer = null;
+ }
+ const buffered = probeConsoleBuffer;
+ probeConsoleBuffer = [];
+ for (const r of buffered) forwardToConsole(r);
}
function scheduleFlush(): void {
- if (flushScheduled) return
- flushScheduled = true
- queueMicrotask(flushDispatch)
+ if (flushScheduled) return;
+ flushScheduled = true;
+ queueMicrotask(flushDispatch);
}
/** Trim the native queue to its cap, preferring to keep request-opening events.
@@ -830,36 +919,42 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
* silently drops events for an unknown requestId); we drop the oldest
* NON-opening (low-value / completion) events first. */
function trimQueue(): void {
- if (dispatchQueue.length <= MAX_DISPATCH_QUEUE) return
+ if (dispatchQueue.length <= MAX_DISPATCH_QUEUE) return;
const isOpener = (json: string): boolean =>
- json.includes('"Network.requestWillBeSent"')
- || json.includes('"Network.requestWillBeSentExtraInfo"')
- || json.includes('"Network.webSocketCreated"')
+ json.includes('"Network.requestWillBeSent"') ||
+ json.includes('"Network.requestWillBeSentExtraInfo"') ||
+ json.includes('"Network.webSocketCreated"');
// First pass: drop oldest non-opener events.
- const kept: string[] = []
- let over = dispatchQueue.length - MAX_DISPATCH_QUEUE
+ const kept: string[] = [];
+ let over = dispatchQueue.length - MAX_DISPATCH_QUEUE;
for (const json of dispatchQueue) {
- if (over > 0 && !isOpener(json)) { over--; continue }
- kept.push(json)
+ if (over > 0 && !isOpener(json)) {
+ over--;
+ continue;
+ }
+ kept.push(json);
}
// If openers alone still exceed the cap, fall back to dropping oldest openers.
if (kept.length > MAX_DISPATCH_QUEUE) {
- dispatchQueue = kept.slice(kept.length - MAX_DISPATCH_QUEUE)
+ dispatchQueue = kept.slice(kept.length - MAX_DISPATCH_QUEUE);
} else {
- dispatchQueue = kept
+ dispatchQueue = kept;
}
}
function flushDispatch(): void {
- flushScheduled = false
- if (dispatchQueue.length === 0) return
- if (sink === 'degraded') { dispatchQueue = []; return }
- const wc = resolveDevtoolsWc()
+ flushScheduled = false;
+ if (dispatchQueue.length === 0) return;
+ if (sink === "degraded") {
+ dispatchQueue = [];
+ return;
+ }
+ const wc = resolveDevtoolsWc();
if (!wc) {
// Host went away mid-flight. Keep the queue bounded (the cap applies on
// EVERY path, not just no-host) and wait — setDevtoolsHost re-arms probing.
- trimQueue()
- return
+ trimQueue();
+ return;
}
if (!isFrontendSettled(wc)) {
// An unsettled front-end can't run the dispatch script anyway — and every
@@ -869,91 +964,99 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
// isLoading() probe diverges from the internal isLoadingMainFrame gate).
// Hold the (bounded) queue; the next event's flush or the ready-retry
// delivers after the load.
- trimQueue()
- scheduleReadyRetry()
- return
+ trimQueue();
+ scheduleReadyRetry();
+ return;
}
- if (sink === 'idle') beginProbing()
+ if (sink === "idle") beginProbing();
// Pack greedily up to MAX_BATCH_CHARS so one executeJavaScript stays sized;
// oversized single messages go via the chunked transport (see packDispatchBatch).
- const { batch, chunked, remaining } = packDispatchBatch(dispatchQueue, MAX_SINGLE_DISPATCH_CHARS, MAX_BATCH_CHARS)
- for (const msg of chunked) dispatchChunked(wc, msg)
+ const { batch, chunked, remaining } = packDispatchBatch(
+ dispatchQueue,
+ MAX_SINGLE_DISPATCH_CHARS,
+ MAX_BATCH_CHARS,
+ );
+ for (const msg of chunked) dispatchChunked(wc, msg);
if (batch.length === 0) {
// Only chunked messages were processed this turn; continue with the rest.
- dispatchQueue = remaining
- if (dispatchQueue.length > 0) scheduleFlush()
- return
+ dispatchQueue = remaining;
+ if (dispatchQueue.length > 0) scheduleFlush();
+ return;
}
- let script: string
+ let script: string;
try {
- script = buildDispatchScript(batch)
+ script = buildDispatchScript(batch);
} catch {
- dispatchQueue = remaining
- if (dispatchQueue.length > 0) scheduleFlush()
- return
+ dispatchQueue = remaining;
+ if (dispatchQueue.length > 0) scheduleFlush();
+ return;
}
// Hold the rest of the queue (un-flushed) until this batch resolves so a
// not-ready answer can re-queue the in-flight batch ahead of it in order.
- dispatchQueue = remaining
- if (remaining.length > 0) scheduleFlush()
- wc.executeJavaScript(script, true).then((ok: unknown) => {
- if (ok === true) {
- if (sink !== 'ready') markReady()
- return
- }
- // API not present yet — front-end still booting. Re-queue this batch ahead
- // of any newer events and poll until ready (or the timeout degrades us).
- if (sink === 'degraded') return
- dispatchQueue = batch.concat(dispatchQueue)
- trimQueue()
- scheduleReadyRetry()
- }).catch(() => {
- // wc navigated / torn down mid-call, OR the script overflowed IPC. Re-queue
- // (bounded) and let the next flush re-resolve the host. Best-effort; the
- // ready-timeout still governs giving up. Backoff is via the retry timer.
- if (sink === 'degraded') return
- dispatchQueue = batch.concat(dispatchQueue)
- trimQueue()
- scheduleReadyRetry()
- })
+ dispatchQueue = remaining;
+ if (remaining.length > 0) scheduleFlush();
+ wc.executeJavaScript(script, true)
+ .then((ok: unknown) => {
+ if (ok === true) {
+ if (sink !== "ready") markReady();
+ return;
+ }
+ // API not present yet — front-end still booting. Re-queue this batch ahead
+ // of any newer events and poll until ready (or the timeout degrades us).
+ if (sink === "degraded") return;
+ dispatchQueue = batch.concat(dispatchQueue);
+ trimQueue();
+ scheduleReadyRetry();
+ })
+ .catch(() => {
+ // wc navigated / torn down mid-call, OR the script overflowed IPC. Re-queue
+ // (bounded) and let the next flush re-resolve the host. Best-effort; the
+ // ready-timeout still governs giving up. Backoff is via the retry timer.
+ if (sink === "degraded") return;
+ dispatchQueue = batch.concat(dispatchQueue);
+ trimQueue();
+ scheduleReadyRetry();
+ });
}
function dispatchChunked(wc: WebContents, msg: string): void {
- const totalSize = msg.length
- const chunks: string[] = []
+ const totalSize = msg.length;
+ const chunks: string[] = [];
for (let i = 0; i < msg.length; i += CHUNK_CHARS) {
- chunks.push(msg.slice(i, i + CHUNK_CHARS))
+ chunks.push(msg.slice(i, i + CHUNK_CHARS));
}
- let script: string
+ let script: string;
try {
- script = buildChunkedDispatchScript(chunks, totalSize)
+ script = buildChunkedDispatchScript(chunks, totalSize);
} catch {
- return
+ return;
}
- wc.executeJavaScript(script, true).catch(() => { /* best-effort */ })
+ wc.executeJavaScript(script, true).catch(() => {
+ /* best-effort */
+ });
}
function scheduleReadyRetry(): void {
- if (readyRetryTimer || sink === 'ready' || sink === 'degraded') return
+ if (readyRetryTimer || sink === "ready" || sink === "degraded") return;
// Self-terminate on the same deadline readyTimeoutTimer enforces, instead of
// trusting that timer to win a same-instant race against this one (see the
// comment on `probeDeadline`'s declaration).
if (probeDeadline !== null && Date.now() >= probeDeadline) {
- degradeToConsole()
- return
+ degradeToConsole();
+ return;
}
readyRetryTimer = setTimeout(() => {
- readyRetryTimer = null
- if (sink === 'degraded') return
+ readyRetryTimer = null;
+ if (sink === "degraded") return;
if (probeDeadline !== null && Date.now() >= probeDeadline) {
- degradeToConsole()
- return
+ degradeToConsole();
+ return;
}
- if (dispatchQueue.length > 0) scheduleFlush()
- }, READY_RETRY_MS)
+ if (dispatchQueue.length > 0) scheduleFlush();
+ }, READY_RETRY_MS);
}
/** Queue one raw (already-namespaced) CDP message for native dispatch. */
@@ -963,33 +1066,33 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
// requests — queueing them would let them double-render if a host later
// arrives / swaps in. resolveDevtoolsWc() guards the idle case where a host
// is set on the bridge but applyDevtoolsHost hasn't run yet.
- if (sink === 'degraded') return
- if (sink === 'idle' && !resolveDevtoolsWc()) return
- let json: string
+ if (sink === "degraded") return;
+ if (sink === "idle" && !resolveDevtoolsWc()) return;
+ let json: string;
try {
- json = JSON.stringify({ method, params })
+ json = JSON.stringify({ method, params });
} catch {
// A value CDP serialized but we can't re-serialize — drop, never throw.
- return
+ return;
}
- dispatchQueue.push(json)
- trimQueue()
- scheduleFlush()
+ dispatchQueue.push(json);
+ trimQueue();
+ scheduleFlush();
}
// ── console fallback ──────────────────────────────────────────────────────
/** Re-emit a completed request into the service host's console (fallback). */
function forwardToConsole(record: NetworkRequestRecord): void {
- const wc = bridge.getServiceWc()
- if (!wc || wc.isDestroyed()) return
- let script: string
+ const wc = bridge.getServiceWc();
+ if (!wc || wc.isDestroyed()) return;
+ let script: string;
try {
- script = buildForwardScript(record)
+ script = buildForwardScript(record);
} catch {
- return
+ return;
}
- wc.executeJavaScript(script, true).catch(() => {})
+ wc.executeJavaScript(script, true).catch(() => {});
}
/**
@@ -1004,23 +1107,35 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
* self-attached session) — never a single consumer switching away.
*/
function detachSimulator(): void {
- simWc = null
- const lease = simLease
- simLease = null
- const ad = attachDisposables
- attachDisposables = null
- if (ad) { try { ad.disposeAll() } catch { /* best-effort teardown */ } }
- lease?.dispose()
+ simWc = null;
+ const lease = simLease;
+ simLease = null;
+ const ad = attachDisposables;
+ attachDisposables = null;
+ if (ad) {
+ try {
+ ad.disposeAll();
+ } catch {
+ /* best-effort teardown */
+ }
+ }
+ lease?.dispose();
// Drop any queued-but-unflushed messages so a new attach starts clean, and
// reset the native-sink state machine (the next attach re-probes the host).
- dispatchQueue = []
- probeConsoleBuffer = []
+ dispatchQueue = [];
+ probeConsoleBuffer = [];
// A new simulator attach restarts the native sink: 'idle' until the next
// event re-probes the (possibly already-set) host.
- sink = 'idle'
- probeDeadline = null
- if (readyRetryTimer) { clearTimeout(readyRetryTimer); readyRetryTimer = null }
- if (readyTimeoutTimer) { clearTimeout(readyTimeoutTimer); readyTimeoutTimer = null }
+ sink = "idle";
+ probeDeadline = null;
+ if (readyRetryTimer) {
+ clearTimeout(readyRetryTimer);
+ readyRetryTimer = null;
+ }
+ if (readyTimeoutTimer) {
+ clearTimeout(readyTimeoutTimer);
+ readyTimeoutTimer = null;
+ }
}
/**
@@ -1033,24 +1148,29 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
* semantics) — the capture pipeline is identical, only session ownership
* differs. The 'message' listener teardown is registered on `attach`.
*/
- function wireNetworkCapture(wc: WebContents, source: NetworkSource, attach: SyncDisposableRegistry, epoch: string): void {
- const ns = new RequestIdNamespace(epoch)
+ function wireNetworkCapture(
+ wc: WebContents,
+ source: NetworkSource,
+ attach: SyncDisposableRegistry,
+ epoch: string,
+ ): void {
+ const ns = new RequestIdNamespace(epoch);
// Fallback bookkeeping: requestId → in-flight request, for the console line
// when the native dispatch path is unusable.
- const pending = new Map()
+ const pending = new Map();
// User-facing classification, decided ONCE at
// requestWillBeSent (the only event carrying a url) and consulted by every
// later event on the same rawId — later events (responseReceived/
// loadingFinished/loadingFailed) carry no url, so they cannot re-derive
// the verdict; they must remember it.
- const userFacingByRawId = new Map()
+ const userFacingByRawId = new Map();
// Raw ids whose requestWillBeSent announced a post body that was NOT
// inlined (`hasPostData` without `postData`) — the only case the front-end
// round-trips `Network.getRequestPostData`. Consumed at loadingFinished.
- const postDataWanted = new Set()
+ const postDataWanted = new Set();
/**
* Prefetch the response body (and, when flagged, the post data) from this
@@ -1060,17 +1180,22 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
* (idle-without-host / degraded) there is no panel to click, so buffering
* bodies would only burn memory.
*/
- const prefetchBodies = (rawId: string, encodedDataLength?: number): void => {
+ const prefetchBodies = (
+ rawId: string,
+ encodedDataLength?: number,
+ ): void => {
// Skip only when NEITHER consumer can use the body: the user-facing
// sink is unusable (degraded, or idle with no host) AND the global
// mirror has no host either. Either alone is enough reason to prefetch
// — global-body-gate answers Network.getResponseBody/getRequestPostData
// from this same cache, and it must not 404 just because the
// user-facing sink happens to be closed/degraded.
- const userSinkUsable = sink !== 'degraded' && !(sink === 'idle' && !resolveDevtoolsWc())
- const globalUsable = globalDevtoolsWc !== null && !globalDevtoolsWc.isDestroyed()
- if (!userSinkUsable && !globalUsable) return
- const virtualId = ns.resolve(rawId)
+ const userSinkUsable =
+ sink !== "degraded" && !(sink === "idle" && !resolveDevtoolsWc());
+ const globalUsable =
+ globalDevtoolsWc !== null && !globalDevtoolsWc.isDestroyed();
+ if (!userSinkUsable && !globalUsable) return;
+ const virtualId = ns.resolve(rawId);
// Skip the CDP round-trip entirely for a response already known (from
// the wire size CDP reports at completion) to exceed the cache's own
// per-entry ceiling — no point materializing a full body into main-
@@ -1078,85 +1203,121 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
// is a best-effort heuristic (encodedDataLength is the ON-THE-WIRE size;
// a decompressed body can be larger), not a hard guarantee — the
// concurrency cap below is what actually bounds worst-case memory.
- const knownOversized = typeof encodedDataLength === 'number' && encodedDataLength > DEFAULT_PER_ENTRY_MAX_CHARS
+ const knownOversized =
+ typeof encodedDataLength === "number" &&
+ encodedDataLength > DEFAULT_PER_ENTRY_MAX_CHARS;
if (!knownOversized) {
- primeWithAdmission(bodyCache, virtualId, async (): Promise => {
- const raw: unknown = await wc.debugger.sendCommand('Network.getResponseBody', { requestId: rawId })
- const r = raw as { body?: unknown, base64Encoded?: unknown } | null | undefined
- if (!r || typeof r.body !== 'string') throw new Error('response body unavailable')
- return { body: r.body, base64Encoded: r.base64Encoded === true }
- })
+ primeWithAdmission(
+ bodyCache,
+ virtualId,
+ async (): Promise => {
+ const raw: unknown = await wc.debugger.sendCommand(
+ "Network.getResponseBody",
+ { requestId: rawId },
+ );
+ const r = raw as
+ | { body?: unknown; base64Encoded?: unknown }
+ | null
+ | undefined;
+ if (!r || typeof r.body !== "string")
+ throw new Error("response body unavailable");
+ return { body: r.body, base64Encoded: r.base64Encoded === true };
+ },
+ );
}
- if (!postDataWanted.delete(rawId)) return
- primeWithAdmission(postDataCache, virtualId, async (): Promise<{ postData: string }> => {
- const raw: unknown = await wc.debugger.sendCommand('Network.getRequestPostData', { requestId: rawId })
- const r = raw as { postData?: unknown } | null | undefined
- if (!r || typeof r.postData !== 'string') throw new Error('post data unavailable')
- return { postData: r.postData }
- })
- }
+ if (!postDataWanted.delete(rawId)) return;
+ primeWithAdmission(
+ postDataCache,
+ virtualId,
+ async (): Promise<{ postData: string }> => {
+ const raw: unknown = await wc.debugger.sendCommand(
+ "Network.getRequestPostData",
+ { requestId: rawId },
+ );
+ const r = raw as { postData?: unknown } | null | undefined;
+ if (!r || typeof r.postData !== "string")
+ throw new Error("post data unavailable");
+ return { postData: r.postData };
+ },
+ );
+ };
// ── Fallback bookkeeping handlers — one per Network.* method, each kept
// simple so `onMessage` itself stays a flat dispatch (not a branchy switch). ──
function onRequestWillBeSent(params: unknown): void {
- const p = params as RequestWillBeSent
- if (!p?.request) return
- if (pending.size >= MAX_PENDING) pending.clear()
- pending.set(p.requestId, { url: p.request.url, method: p.request.method, status: 0 })
+ const p = params as RequestWillBeSent;
+ if (!p?.request) return;
+ if (pending.size >= MAX_PENDING) pending.clear();
+ pending.set(p.requestId, {
+ url: p.request.url,
+ method: p.request.method,
+ status: 0,
+ });
// A body announced but not inlined is the one case the panel will
// round-trip `Network.getRequestPostData` — flag it for prefetch.
- if (p.request.hasPostData === true && typeof p.request.postData !== 'string') {
- if (postDataWanted.size >= MAX_PENDING) postDataWanted.clear()
- postDataWanted.add(p.requestId)
+ if (
+ p.request.hasPostData === true &&
+ typeof p.request.postData !== "string"
+ ) {
+ if (postDataWanted.size >= MAX_PENDING) postDataWanted.clear();
+ postDataWanted.add(p.requestId);
}
}
function onResponseReceived(params: unknown): void {
- const p = params as ResponseReceived
- const req = pending.get(p.requestId)
- if (req) req.status = p.response?.status ?? 0
+ const p = params as ResponseReceived;
+ const req = pending.get(p.requestId);
+ if (req) req.status = p.response?.status ?? 0;
}
/** Shared "request terminated" bookkeeping for both loadingFinished and
* loadingFailed: drop the classification + pending record, returning the
* pending entry (or undefined if it was already gone/never seen). */
function retirePending(rawId: string): Pending | undefined {
- userFacingByRawId.delete(rawId)
- const req = pending.get(rawId)
- if (!req) return undefined
- pending.delete(rawId)
- return req
+ userFacingByRawId.delete(rawId);
+ const req = pending.get(rawId);
+ if (!req) return undefined;
+ pending.delete(rawId);
+ return req;
}
function onLoadingFinished(params: unknown): void {
- const p = params as { requestId: string, encodedDataLength?: number }
- if (typeof p?.requestId === 'string') prefetchBodies(p.requestId, p.encodedDataLength)
- const req = retirePending(p?.requestId)
- if (!req) return
- maybeFallback({ source, url: req.url, method: req.method, status: req.status })
+ const p = params as { requestId: string; encodedDataLength?: number };
+ if (typeof p?.requestId === "string")
+ prefetchBodies(p.requestId, p.encodedDataLength);
+ const req = retirePending(p?.requestId);
+ if (!req) return;
+ maybeFallback({
+ source,
+ url: req.url,
+ method: req.method,
+ status: req.status,
+ });
}
function onLoadingFailed(params: unknown): void {
- const p = params as LoadingFailed
- postDataWanted.delete(p?.requestId)
- const req = retirePending(p?.requestId)
- if (!req) return
+ const p = params as LoadingFailed;
+ postDataWanted.delete(p?.requestId);
+ const req = retirePending(p?.requestId);
+ if (!req) return;
maybeFallback({
source,
url: req.url,
method: req.method,
status: req.status,
- errorText: p.canceled ? 'canceled' : (p.errorText || 'failed'),
- })
+ errorText: p.canceled ? "canceled" : p.errorText || "failed",
+ });
}
- const FALLBACK_HANDLERS: Readonly void>> = {
- 'Network.requestWillBeSent': onRequestWillBeSent,
- 'Network.responseReceived': onResponseReceived,
- 'Network.loadingFinished': onLoadingFinished,
- 'Network.loadingFailed': onLoadingFailed,
- }
+ const FALLBACK_HANDLERS: Readonly<
+ Record void>
+ > = {
+ "Network.requestWillBeSent": onRequestWillBeSent,
+ "Network.responseReceived": onResponseReceived,
+ "Network.loadingFinished": onLoadingFinished,
+ "Network.loadingFailed": onLoadingFailed,
+ };
/**
* Resolve (and, on `requestWillBeSent`, record) whether `rawId` is
@@ -1168,19 +1329,29 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
* recorded verdict; an unknown rawId fails open (user-facing) rather
* than silently hiding it.
*/
- function resolveUserFacing(method: string, params: unknown, rawId: string | undefined): boolean {
- if (method === 'Network.requestWillBeSent') {
- const url = (params as RequestWillBeSent | null | undefined)?.request?.url
- const verdict = typeof url === 'string'
- ? isUserFacingRequest(url, [bridge.getResourceServerBaseUrl?.(), bridge.getSimulatorServerBaseUrl?.()])
- : true
+ function resolveUserFacing(
+ method: string,
+ params: unknown,
+ rawId: string | undefined,
+ ): boolean {
+ if (method === "Network.requestWillBeSent") {
+ const url = (params as RequestWillBeSent | null | undefined)?.request
+ ?.url;
+ const verdict =
+ typeof url === "string"
+ ? isUserFacingRequest(url, [
+ bridge.getResourceServerBaseUrl?.(),
+ bridge.getSimulatorServerBaseUrl?.(),
+ ])
+ : true;
if (rawId) {
- if (userFacingByRawId.size >= MAX_PENDING) userFacingByRawId.clear()
- userFacingByRawId.set(rawId, verdict)
+ if (userFacingByRawId.size >= MAX_PENDING) userFacingByRawId.clear();
+ userFacingByRawId.set(rawId, verdict);
}
- return verdict
+ return verdict;
}
- if (rawId && userFacingByRawId.has(rawId)) return userFacingByRawId.get(rawId)!
+ if (rawId && userFacingByRawId.has(rawId))
+ return userFacingByRawId.get(rawId)!;
// `Network.requestWillBeSentExtraInfo` can arrive BEFORE its own
// `requestWillBeSent` (real CDP ordering) — it carries no url, so an
// unrecorded rawId here means "classification not yet known", not
@@ -1192,47 +1363,60 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
// classification is even known. Every OTHER "rawId never seen" case
// (e.g. capture attached mid-flight, no requestWillBeSent ever coming)
// keeps failing open — there is no better signal available there.
- if (method === 'Network.requestWillBeSentExtraInfo') return false
- return true
+ if (method === "Network.requestWillBeSentExtraInfo") return false;
+ return true;
}
- const onMessage = (_event: Electron.Event, method: string, params: unknown): void => {
+ const onMessage = (
+ _event: Electron.Event,
+ method: string,
+ params: unknown,
+ ): void => {
// ── Primary sink: forward the raw CDP event into the DevTools front-end ──
if (FORWARDED_METHODS.has(method)) {
- const rewritten = rewriteRequestId(method, params, ns)
+ const rewritten = rewriteRequestId(method, params, ns);
// Global mirror: full, unfiltered — independent of the
// user-facing sink's classification and state machine.
- dispatchToGlobal(rewritten.method, rewritten.params)
+ dispatchToGlobal(rewritten.method, rewritten.params);
// User-facing sink: only requests isUserFacingRequest() judged as the
// developer's own business traffic — internal/framework resource
// loads are mirrored to the global window ONLY.
- const rawId = (params as { requestId?: unknown } | null | undefined)?.requestId
- const userFacing = resolveUserFacing(method, params, typeof rawId === 'string' ? rawId : undefined)
+ const rawId = (params as { requestId?: unknown } | null | undefined)
+ ?.requestId;
+ const userFacing = resolveUserFacing(
+ method,
+ params,
+ typeof rawId === "string" ? rawId : undefined,
+ );
if (userFacing) {
- enqueueNative(rewritten.method, rewritten.params)
+ enqueueNative(rewritten.method, rewritten.params);
}
} else if (REWRITE_REQUEST_ID_METHODS.has(method)) {
// Methods we namespace but don't forward (dataReceived): still resolve
// so the id mapping stays coherent if forwarding is added later.
- rewriteRequestId(method, params, ns)
+ rewriteRequestId(method, params, ns);
}
// ── Fallback bookkeeping (used only when native dispatch is unusable) ──
- FALLBACK_HANDLERS[method]?.(params)
- }
+ FALLBACK_HANDLERS[method]?.(params);
+ };
- wc.debugger.on('message', onMessage)
+ wc.debugger.on("message", onMessage);
attach.add(() => {
- try { wc.debugger.removeListener('message', onMessage) } catch { /* wc gone */ }
- })
+ try {
+ wc.debugger.removeListener("message", onMessage);
+ } catch {
+ /* wc gone */
+ }
+ });
}
/** Drop one guest's wiring (message listener + broker lease). Attach/detach
* ownership of the underlying session is entirely the broker's concern. */
function cleanupGuest(wcId: number): void {
- const teardown = guestWired.get(wcId)
- guestWired.delete(wcId)
- teardown?.disposeAll()
+ const teardown = guestWired.get(wcId);
+ guestWired.delete(wcId);
+ teardown?.disposeAll();
}
/**
@@ -1257,7 +1441,7 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
* loop's real termination check (`wc.isDestroyed()` / `disposed`) has a
* chance to observe the close having finished before the next attempt.
*/
- const RENDER_GUEST_REATTACH_DELAY_MS = 300
+ const RENDER_GUEST_REATTACH_DELAY_MS = 300;
/**
* Schedule one more `wireRenderGuest(wc)` attempt after
@@ -1280,18 +1464,18 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
* (and forking) a newer retry's bookkeeping.
*/
function scheduleRenderGuestRetry(wc: WebContents): void {
- if (wc.isDestroyed()) return
- if (guestRetryGeneration.has(wc.id)) return
- const generation = ++nextRenderGuestRetryGeneration
- guestRetryGeneration.set(wc.id, generation)
+ if (wc.isDestroyed()) return;
+ if (guestRetryGeneration.has(wc.id)) return;
+ const generation = ++nextRenderGuestRetryGeneration;
+ guestRetryGeneration.set(wc.id, generation);
const timer = setTimeout(() => {
- if (guestRetryGeneration.get(wc.id) !== generation) return
- guestRetryGeneration.delete(wc.id)
- if (!disposed && !wc.isDestroyed()) wireRenderGuest(wc)
- }, RENDER_GUEST_REATTACH_DELAY_MS)
+ if (guestRetryGeneration.get(wc.id) !== generation) return;
+ guestRetryGeneration.delete(wc.id);
+ if (!disposed && !wc.isDestroyed()) wireRenderGuest(wc);
+ }, RENDER_GUEST_REATTACH_DELAY_MS);
// Best-effort: if the whole forwarder tears down before this fires, there
// is nothing to clear it from — the disposed check above is the guard.
- timer.unref?.()
+ timer.unref?.();
}
/**
@@ -1315,43 +1499,46 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
* a stale pending window.
*/
function wireRenderGuest(wc: WebContents): void {
- if (guestWired.has(wc.id)) return
- const lease = broker.acquire(wc)
+ if (guestWired.has(wc.id)) return;
+ const lease = broker.acquire(wc);
if (!lease) {
// Not terminal — the exclusive holder may release the session later.
// Retry on the same cadence as the onDetach self-heal below, or this
// guest's capture would die for its whole remaining lifetime the very
// first time acquire() lost the race.
- scheduleRenderGuestRetry(wc)
- return
+ scheduleRenderGuestRetry(wc);
+ return;
}
// This wc is now genuinely wired — drop any stale pending-retry
// generation (e.g. from an earlier detach cycle superseded by THIS
// successful acquire) so a LATER detach can freely mint its own fresh
// generation instead of being silently swallowed by a leftover entry.
- guestRetryGeneration.delete(wc.id)
- const teardown = new SyncDisposableRegistry()
- guestWired.set(wc.id, teardown)
- wireNetworkCapture(wc, 'render', teardown, `g${wc.id}-${Date.now()}`)
+ guestRetryGeneration.delete(wc.id);
+ const teardown = new SyncDisposableRegistry();
+ guestWired.set(wc.id, teardown);
+ wireNetworkCapture(wc, "render", teardown, `g${wc.id}-${Date.now()}`);
const detachSub = lease.onDetach(() => {
- cleanupGuest(wc.id)
- scheduleRenderGuestRetry(wc)
- })
+ cleanupGuest(wc.id);
+ scheduleRenderGuestRetry(wc);
+ });
teardown.add(() => {
- detachSub.dispose()
- lease.dispose()
- })
-
- void lease.send('Network.enable').catch((err: unknown) => {
- console.warn('[network-forward] guest Network.enable failed:', err instanceof Error ? err.message : err)
- })
+ detachSub.dispose();
+ lease.dispose();
+ });
+
+ void lease.send("Network.enable").catch((err: unknown) => {
+ console.warn(
+ "[network-forward] guest Network.enable failed:",
+ err instanceof Error ? err.message : err,
+ );
+ });
}
function attachRenderGuest(wc: WebContents): void {
- if (!wc || wc.isDestroyed()) return
- wireRenderGuest(wc)
+ if (!wc || wc.isDestroyed()) return;
+ wireRenderGuest(wc);
}
/**
@@ -1363,39 +1550,50 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
* simulator-storage's capture too.
*/
function attachSimulator(wc: WebContents): void {
- if (!wc || wc.isDestroyed()) return
- if (simWc === wc && !wc.isDestroyed()) return
- detachSimulator()
+ if (!wc || wc.isDestroyed()) return;
+ if (simWc === wc && !wc.isDestroyed()) return;
+ detachSimulator();
- const lease = broker.acquire(wc)
+ const lease = broker.acquire(wc);
if (!lease) {
- console.warn('[network-forward] debugger session unavailable; simulator network not captured')
- return
+ console.warn(
+ "[network-forward] debugger session unavailable; simulator network not captured",
+ );
+ return;
}
- simWc = wc
- simLease = lease
- const attach = new SyncDisposableRegistry()
- attachDisposables = attach
+ simWc = wc;
+ simLease = lease;
+ const attach = new SyncDisposableRegistry();
+ attachDisposables = attach;
- wireNetworkCapture(wc, 'service', attach, String(Date.now()))
+ wireNetworkCapture(wc, "service", attach, String(Date.now()));
const detachSub = lease.onDetach(() => {
- if (simWc !== wc) return
- simWc = null
- simLease = null
+ if (simWc !== wc) return;
+ simWc = null;
+ simLease = null;
// Tear down OUR OWN wiring (wireNetworkCapture's 'message' listener) —
// the broker already removed its own; without this, our listener would
// keep receiving events from a session we no longer track as "current".
- const ad = attachDisposables
- attachDisposables = null
- if (ad) { try { ad.disposeAll() } catch { /* best-effort teardown */ } }
- })
- attach.add(() => detachSub.dispose())
-
- void lease.send('Network.enable').catch((err: unknown) => {
- console.warn('[network-forward] Network.enable failed:', err instanceof Error ? err.message : err)
- })
+ const ad = attachDisposables;
+ attachDisposables = null;
+ if (ad) {
+ try {
+ ad.disposeAll();
+ } catch {
+ /* best-effort teardown */
+ }
+ }
+ });
+ attach.add(() => detachSub.dispose());
+
+ void lease.send("Network.enable").catch((err: unknown) => {
+ console.warn(
+ "[network-forward] Network.enable failed:",
+ err instanceof Error ? err.message : err,
+ );
+ });
}
/**
@@ -1411,67 +1609,81 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
// 'idle' but a host is resolvable (set via the bridge, applyDevtoolsHost not
// run): the native path is in play, so promote to 'probing' instead of
// console — otherwise this completion would later double-render natively.
- if (sink === 'idle' && resolveDevtoolsWc()) beginProbing()
+ if (sink === "idle" && resolveDevtoolsWc()) beginProbing();
switch (sink) {
- case 'ready':
- return
- case 'probing':
- if (probeConsoleBuffer.length >= MAX_PENDING) probeConsoleBuffer.shift()
- probeConsoleBuffer.push(record)
- return
- case 'degraded':
- case 'idle':
+ case "ready":
+ return;
+ case "probing":
+ if (probeConsoleBuffer.length >= MAX_PENDING)
+ probeConsoleBuffer.shift();
+ probeConsoleBuffer.push(record);
+ return;
+ case "degraded":
+ case "idle":
default:
- forwardToConsole(record)
+ forwardToConsole(record);
}
}
/** Apply a new (or cleared) DevTools host: reset sink state and (re)probe. */
function applyDevtoolsHost(wc: WebContents | null): void {
- devtoolsHostDisposable?.dispose()
- devtoolsHostDisposable = null
- devtoolsWc = wc && !wc.isDestroyed() ? wc : null
+ devtoolsHostDisposable?.dispose();
+ devtoolsHostDisposable = null;
+ devtoolsWc = wc && !wc.isDestroyed() ? wc : null;
// Reset the native-sink state machine for the new host.
- if (readyTimeoutTimer) { clearTimeout(readyTimeoutTimer); readyTimeoutTimer = null }
- if (readyRetryTimer) { clearTimeout(readyRetryTimer); readyRetryTimer = null }
- probeDeadline = null
+ if (readyTimeoutTimer) {
+ clearTimeout(readyTimeoutTimer);
+ readyTimeoutTimer = null;
+ }
+ if (readyRetryTimer) {
+ clearTimeout(readyRetryTimer);
+ readyRetryTimer = null;
+ }
+ probeDeadline = null;
// Records buffered while probing the OLD host are stale — drop, don't flush
// (their native copies were already queued; on a host swap we restart clean).
- probeConsoleBuffer = []
+ probeConsoleBuffer = [];
if (!devtoolsWc) {
// No host → 'idle': completions go straight to console; native queue is
// moot, drop it so it can't double-render if a host later appears.
- sink = 'idle'
- dispatchQueue = []
- return
+ sink = "idle";
+ dispatchQueue = [];
+ return;
}
// Host present: watch it so its destruction equals setDevtoolsHost(null)
// WITHOUT touching view-manager (host-destroyed cleanup lives here). Begin
// probing and flush anything already queued.
- const host = devtoolsWc
- const onHostDestroyed = (): void => { applyDevtoolsHost(null) }
+ const host = devtoolsWc;
+ const onHostDestroyed = (): void => {
+ applyDevtoolsHost(null);
+ };
// Route host-destroyed teardown through the connection registry when present
// (the Connection fires onHostDestroyed on wc destroy / reset, and the
// returned Disposable releases the ownership early on host swap/clear);
// otherwise keep the bespoke `once('destroyed')` watcher. The
// `typeof host.once === 'function'` guard stays on the fallback so minimal
// test fakes / odd hosts don't throw.
- const reg = bridge.connections
+ const reg = bridge.connections;
// `acquire(host)` internally arms `host.once('destroyed')`, so it must be
// gated by the SAME `typeof host.once === 'function'` guard the fallback
// uses — otherwise a minimal/fake DevTools host (no emitter) throws on the
// connection path where the fallback would safely no-op.
- if (reg && typeof host.once === 'function') {
- const owned = reg.acquire(host).own(onHostDestroyed)
- devtoolsHostDisposable = toDisposable(() => owned.dispose())
+ if (reg && typeof host.once === "function") {
+ const owned = reg.acquire(host).own(onHostDestroyed);
+ devtoolsHostDisposable = toDisposable(() => owned.dispose());
} else {
- if (typeof host.once === 'function') host.once('destroyed', onHostDestroyed)
+ if (typeof host.once === "function")
+ host.once("destroyed", onHostDestroyed);
devtoolsHostDisposable = toDisposable(() => {
- try { host.removeListener?.('destroyed', onHostDestroyed) } catch { /* gone */ }
- })
+ try {
+ host.removeListener?.("destroyed", onHostDestroyed);
+ } catch {
+ /* gone */
+ }
+ });
}
// A host swap while still 'probing' the OLD host must not inherit its
@@ -1479,33 +1691,41 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
// (re-)arming readyTimeoutTimer/probeDeadline for the NEW host, leaving it
// probing forever with no timeout. Force through 'idle' so beginProbing()
// always arms a fresh window for whichever host is now current.
- sink = 'idle'
- beginProbing()
- if (dispatchQueue.length > 0) scheduleFlush()
+ sink = "idle";
+ beginProbing();
+ if (dispatchQueue.length > 0) scheduleFlush();
}
- registry.add(() => { disposed = true })
registry.add(() => {
- devtoolsHostDisposable?.dispose()
- devtoolsHostDisposable = null
- if (readyTimeoutTimer) { clearTimeout(readyTimeoutTimer); readyTimeoutTimer = null }
- if (readyRetryTimer) { clearTimeout(readyRetryTimer); readyRetryTimer = null }
- })
+ disposed = true;
+ });
registry.add(() => {
- globalDevtoolsHostDisposable?.dispose()
- globalDevtoolsHostDisposable = null
- globalBodyGateStop?.()
- globalBodyGateStop = null
- globalDevtoolsWc = null
+ devtoolsHostDisposable?.dispose();
+ devtoolsHostDisposable = null;
+ if (readyTimeoutTimer) {
+ clearTimeout(readyTimeoutTimer);
+ readyTimeoutTimer = null;
+ }
+ if (readyRetryTimer) {
+ clearTimeout(readyRetryTimer);
+ readyRetryTimer = null;
+ }
+ });
+ registry.add(() => {
+ globalDevtoolsHostDisposable?.dispose();
+ globalDevtoolsHostDisposable = null;
+ globalBodyGateStop?.();
+ globalBodyGateStop = null;
+ globalDevtoolsWc = null;
// A live settle-poll timer at dispose time is a real-timer leak (this
// suite's flaky-test history: undisposed timers adopted by later tests'
// fake clocks).
- clearGlobalPending()
- })
+ clearGlobalPending();
+ });
registry.add(() => {
- bodyCache.clear()
- postDataCache.clear()
- })
+ bodyCache.clear();
+ postDataCache.clear();
+ });
// Every debugger session (simulator + all render guests) must already be
// detached and every 'message' listener already removed before `dispose()`
// returns control to an un-awaited caller (every real call site is
@@ -1513,15 +1733,15 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
// tick as dispose() must never be forwarded. `registry` is a
// SyncDisposableRegistry, so every entry below runs to completion before
// disposeAll() returns — no ordering dependency between them.
- registry.add(() => detachSimulator())
+ registry.add(() => detachSimulator());
registry.add(() => {
- for (const wcId of [...guestWired.keys()]) cleanupGuest(wcId)
- })
+ for (const wcId of [...guestWired.keys()]) cleanupGuest(wcId);
+ });
// Only detach sessions we self-attached if we own the broker's lifecycle —
// a shared/injected broker keeps serving other consumers past our dispose().
registry.add(() => {
- if (ownsBroker) broker.dispose()
- })
+ if (ownsBroker) broker.dispose();
+ });
// ── Main-process WebSocket trace → synthesized Network.webSocket* CDP ─────
// One synthesizer for the forwarder's lifetime: its virtual-id epoch keeps
@@ -1529,18 +1749,66 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
// per-socket verdict cache mirrors resolveUserFacing's decide-once rule.
const wsSynthesizer = new WebSocketTraceSynthesizer({
epoch: String(Date.now()),
- internalOrigins: () => [bridge.getResourceServerBaseUrl?.(), bridge.getSimulatorServerBaseUrl?.()],
- })
-
- function reportWebSocketTrace(sessionId: string, event: NativeWebSocketTrace): void {
- if (disposed) return
- const message = wsSynthesizer.synthesize(sessionId, event)
- if (!message) return
+ internalOrigins: () => [
+ bridge.getResourceServerBaseUrl?.(),
+ bridge.getSimulatorServerBaseUrl?.(),
+ ],
+ });
+
+ function reportWebSocketTrace(
+ sessionId: string,
+ event: NativeWebSocketTrace,
+ ): void {
+ if (disposed) return;
+ const message = wsSynthesizer.synthesize(sessionId, event);
+ if (!message) return;
// Global mirror first (full, unfiltered), then the user-facing native
// sink gated on the created-time verdict — the same routing
// wireNetworkCapture's onMessage uses for simulator traffic.
- dispatchToGlobal(message.method, message.params)
- if (message.userFacing) enqueueNative(message.method, message.params)
+ dispatchToGlobal(message.method, message.params);
+ if (message.userFacing) enqueueNative(message.method, message.params);
+ }
+
+ // ── Main-process HTTP request trace → synthesized Network.request*/
+ // Network.loading* CDP ──────────────────────────────────────────────────
+ // Same shape as the WebSocket trace bridge above, for the same reason:
+ // wx.request now runs on Node http/https in this process (that's what
+ // stops Chromium's Fetch/CORS algorithm from attaching a spurious OPTIONS
+ // preflight to it), so no webContents.debugger can observe it either.
+ const httpSynthesizer = new RequestTraceSynthesizer({
+ epoch: String(Date.now()),
+ internalOrigins: () => [
+ bridge.getResourceServerBaseUrl?.(),
+ bridge.getSimulatorServerBaseUrl?.(),
+ ],
+ });
+
+ function reportNativeRequestTrace(
+ sessionId: string,
+ event: NativeRequestTrace,
+ ): void {
+ if (disposed) return;
+ const message = httpSynthesizer.synthesize(sessionId, event);
+ if (!message) return;
+ const params = message.params as { requestId: string };
+ if (event.type === "redirect") {
+ bodyCache.delete(params.requestId);
+ postDataCache.delete(params.requestId);
+ }
+ // The response is already fully buffered in-process at `finished` — prime
+ // the SAME cache a simulator-CDP prefetch would populate, so the
+ // front-end's Get Response Body click resolves without a debugger
+ // round-trip that (for this requestId) has nowhere to go.
+ if (message.body) {
+ bodyCache.prime(params.requestId, () => Promise.resolve(message.body!));
+ }
+ if (message.postData !== undefined) {
+ postDataCache.prime(params.requestId, () =>
+ Promise.resolve({ postData: message.postData! }),
+ );
+ }
+ dispatchToGlobal(message.method, message.params);
+ if (message.userFacing) enqueueNative(message.method, message.params);
}
return {
@@ -1553,10 +1821,11 @@ export function createNetworkForwarder(bridge: NetworkForwarderBridge): NetworkF
// natively — surface it via the console fallback line.
report: (record) => forwardToConsole(record),
reportWebSocketTrace,
+ reportNativeRequestTrace,
bodies: {
getResponseBody: (requestId) => bodyCache.lookup(requestId),
getRequestPostData: (requestId) => postDataCache.lookup(requestId),
},
dispose: () => registry.disposeAll(),
- }
+ };
}
diff --git a/packages/devtools/src/main/services/network-forward/native-request-routing.test.ts b/packages/devtools/src/main/services/network-forward/native-request-routing.test.ts
new file mode 100644
index 00000000..540eba5e
--- /dev/null
+++ b/packages/devtools/src/main/services/network-forward/native-request-routing.test.ts
@@ -0,0 +1,43 @@
+// @vitest-environment node
+import { runInNewContext } from 'node:vm'
+import { describe, expect, it } from 'vitest'
+import { buildElementsHookScript, routeOutboundCommand } from '../elements-forward/index.js'
+import { buildNetworkOnlyHookScript } from './global-body-gate.js'
+
+describe('native request body command routing', () => {
+ it.each(['Network.getResponseBody', 'Network.getRequestPostData'])('routes %s for native HTTP without intercepting backend-owned requests', (method) => {
+ expect(routeOutboundCommand(method, { requestId: 'dimina:http:epoch:0' })).toBe('network')
+ expect(routeOutboundCommand(method, { requestId: 'dimina:sim:epoch:0:raw' })).toBe('network')
+ expect(routeOutboundCommand(method, { requestId: 'raw' })).toBe('service')
+ expect(routeOutboundCommand(method, { requestId: 'dimina:ws:epoch:0' })).toBe('service')
+ })
+
+ it.each([
+ ['embedded', buildElementsHookScript, '__diminaElementsOutbound'],
+ ['global', buildNetworkOnlyHookScript, '__diminaGlobalNetworkOutbound'],
+ ] as const)('%s frontend intercepts native HTTP body commands for the cache', async (_name, script, queueKey) => {
+ const forwarded: string[] = []
+ const realm = {
+ InspectorFrontendHost: { sendMessageToBackend: (message: string) => forwarded.push(message) },
+ }
+ expect(runInNewContext(script(), realm)).toBe('installed')
+ const { RequestTraceSynthesizer } = await import('./http.js')
+ const synth = new RequestTraceSynthesizer({ epoch: 'test' })
+ const msg = synth.synthesize('owner', {
+ type: 'sent', requestId: 'r', url: 'https://example.com/api', method: 'POST', headers: {}, time: 0,
+ })!
+ const requestId = (msg.params as { requestId: string }).requestId
+ for (const [id, method] of [[1, 'Network.getResponseBody'], [2, 'Network.getRequestPostData']] as const) {
+ realm.InspectorFrontendHost.sendMessageToBackend(JSON.stringify({ id, method, params: { requestId } }))
+ }
+ const queue = (realm as unknown as Record>)[queueKey]
+ expect(queue).toMatchObject([
+ { id: 1, method: 'Network.getResponseBody', params: { requestId } },
+ { id: 2, method: 'Network.getRequestPostData', params: { requestId } },
+ ])
+ expect(forwarded).toEqual([])
+ const raw = JSON.stringify({ id: 3, method: 'Network.getResponseBody', params: { requestId: 'chromium-id' } })
+ realm.InspectorFrontendHost.sendMessageToBackend(raw)
+ expect(forwarded).toEqual([raw])
+ })
+})
diff --git a/packages/devtools/src/main/services/network-forward/report-native-request-trace.test.ts b/packages/devtools/src/main/services/network-forward/report-native-request-trace.test.ts
new file mode 100644
index 00000000..e2c88fbb
--- /dev/null
+++ b/packages/devtools/src/main/services/network-forward/report-native-request-trace.test.ts
@@ -0,0 +1,140 @@
+/**
+ * Behavior tests for `createNetworkForwarder(...).reportNativeRequestTrace`.
+ *
+ * `RequestTraceSynthesizer` (http.test.ts) already covers the pure trace →
+ * CDP message mapping; these tests cover the forwarder-level glue that
+ * `websocket`'s equivalent path doesn't need: priming the SAME body/post-data
+ * caches a simulator-CDP prefetch would populate, so the front-end's Get
+ * Response Body / Get Request Post Data clicks resolve for a virtual
+ * `dimina:http:` requestId without a debugger round-trip (there is none for
+ * main-process traffic).
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { createNetworkForwarder } from "./index.js";
+import type { NativeRequestTrace } from "../../ipc/bridge-router.js";
+
+// The synthesizer mints its virtual-id epoch from `Date.now()` at forwarder
+// construction. Pinning the clock makes the first virtual id for any fresh
+// forwarder in these tests deterministic — `dimina:http::0` — without
+// reaching into the module's internal id scheme.
+const EPOCH_TIME = 1_700_000_000_000;
+const FIRST_VIRTUAL_ID = `dimina:http:${EPOCH_TIME}:0`;
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(EPOCH_TIME);
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+function sent(
+ requestId: string,
+ url: string,
+ method = "GET",
+ postData?: string,
+): NativeRequestTrace {
+ return postData === undefined
+ ? { type: "sent", requestId, url, method, headers: {}, time: Date.now() }
+ : {
+ type: "sent",
+ requestId,
+ url,
+ method,
+ headers: {},
+ postData,
+ time: Date.now(),
+ };
+}
+
+describe("createNetworkForwarder — reportNativeRequestTrace", () => {
+ it("primes the response-body cache at finished so getResponseBody resolves without a debugger round-trip", async () => {
+ const fwd = createNetworkForwarder({ getServiceWc: () => null });
+ fwd.reportNativeRequestTrace(
+ "owner-1",
+ sent("r1", "https://business.example.com/api"),
+ );
+ fwd.reportNativeRequestTrace("owner-1", {
+ type: "finished",
+ requestId: "r1",
+ body: Buffer.from('{"a":1}').toString("base64"),
+ bodyBase64Encoded: true,
+ encodedDataLength: 7,
+ time: Date.now(),
+ });
+
+ const body = await fwd.bodies.getResponseBody(FIRST_VIRTUAL_ID);
+ expect(body.base64Encoded).toBe(true);
+ expect(Buffer.from(body.body, "base64").toString("utf-8")).toBe('{"a":1}');
+ });
+
+ it("primes the post-data cache at sent when the request carried a body", async () => {
+ const fwd = createNetworkForwarder({ getServiceWc: () => null });
+ fwd.reportNativeRequestTrace(
+ "owner-1",
+ sent("r1", "https://business.example.com/api", "POST", '{"a":1}'),
+ );
+
+ const postData = await fwd.bodies.getRequestPostData(FIRST_VIRTUAL_ID);
+ expect(postData.postData).toBe('{"a":1}');
+ });
+
+ it("does not prime the post-data cache for a bodyless GET", async () => {
+ const fwd = createNetworkForwarder({ getServiceWc: () => null });
+ fwd.reportNativeRequestTrace(
+ "owner-1",
+ sent("r1", "https://business.example.com/api"),
+ );
+
+ await expect(
+ fwd.bodies.getRequestPostData(FIRST_VIRTUAL_ID),
+ ).rejects.toThrow();
+ });
+
+ it("a failed request never primes the body cache", async () => {
+ const fwd = createNetworkForwarder({ getServiceWc: () => null });
+ fwd.reportNativeRequestTrace(
+ "owner-1",
+ sent("r1", "https://business.example.com/api"),
+ );
+ fwd.reportNativeRequestTrace("owner-1", {
+ type: "failed",
+ requestId: "r1",
+ errorText: "request:fail timeout",
+ time: Date.now(),
+ });
+
+ await expect(
+ fwd.bodies.getResponseBody(FIRST_VIRTUAL_ID),
+ ).rejects.toThrow();
+ });
+
+ it("never throws for a trace event with no matching sent (defensive against out-of-order delivery)", () => {
+ const fwd = createNetworkForwarder({ getServiceWc: () => null });
+ expect(() =>
+ fwd.reportNativeRequestTrace("owner-1", {
+ type: "response",
+ requestId: "ghost",
+ status: 200,
+ statusText: "OK",
+ headers: {},
+ time: Date.now(),
+ }),
+ ).not.toThrow();
+ });
+
+ it("is a no-op after dispose (no throw, no cache write)", async () => {
+ const fwd = createNetworkForwarder({ getServiceWc: () => null });
+ fwd.dispose();
+ expect(() =>
+ fwd.reportNativeRequestTrace(
+ "owner-1",
+ sent("r1", "https://business.example.com/api"),
+ ),
+ ).not.toThrow();
+ await expect(
+ fwd.bodies.getResponseBody(FIRST_VIRTUAL_ID),
+ ).rejects.toThrow();
+ });
+});
diff --git a/packages/devtools/src/main/services/network-forward/request-ids.ts b/packages/devtools/src/main/services/network-forward/request-ids.ts
new file mode 100644
index 00000000..590dea93
--- /dev/null
+++ b/packages/devtools/src/main/services/network-forward/request-ids.ts
@@ -0,0 +1,4 @@
+/** Body caches own simulator-CDP and main-process HTTP ids, never native backend ids. */
+export const VIRTUAL_REQUEST_ID_PREFIX = 'dimina:sim:'
+export const NATIVE_HTTP_REQUEST_ID_PREFIX = 'dimina:http:'
+export const BODY_REQUEST_ID_PREFIXES = [VIRTUAL_REQUEST_ID_PREFIX, NATIVE_HTTP_REQUEST_ID_PREFIX] as const
diff --git a/packages/devtools/src/preload/shared/api-compat-request.test.ts b/packages/devtools/src/preload/shared/api-compat-request.test.ts
index 9931f6a1..c6852ac1 100644
--- a/packages/devtools/src/preload/shared/api-compat-request.test.ts
+++ b/packages/devtools/src/preload/shared/api-compat-request.test.ts
@@ -4,95 +4,183 @@
* including 401 — resolves via `success` with a full `{ statusCode, … }`
* object, never `fail`.
*
- * It must also carry the same case-insensitive content-type dedup contract
- * `directRequest` has (direct-request-headers.test.ts): the current
- * implementation merges headers with a plain-object spread
- * (`{ 'Content-Type': …, ...header }`), which produces two distinct keys
- * when the caller supplies a differently-cased `content-type` — `new
- * Headers()` then joins them into `application/json, application/json`.
+ * After the migration to the main-process native HTTP transport, the shim
+ * forwards calls through `ipcRenderer.invoke(BRIDGE_CHANNELS.NATIVE_REQUEST, …)`
+ * and receives a result object that already carries the wx.request shape. No
+ * `fetch()` runs in the renderer, so the CORS/preflight issue cannot appear here.
*
- * Environment: jsdom (this package's default vitest environment), so
- * `window`/`Response`/`Headers` are the real browser-ish globals the shim
- * runs against.
+ * Environment: jsdom (this package's default vitest environment).
*/
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { setupApiCompatHook } from './api-compat'
-import type { RequestFailResult, RequestSuccessResult } from '../../shared/request-core.js'
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { ipcRenderer } from "electron";
+import { setupApiCompatHook } from "./api-compat";
+import { BRIDGE_CHANNELS } from "../../shared/bridge-channels.js";
+import type {
+ RequestFailResult,
+ RequestSuccessResult,
+} from "../../shared/request-core.js";
-type WxWindow = Window & { wx?: Record }
+vi.mock("electron", () => ({
+ ipcRenderer: {
+ invoke: vi.fn().mockResolvedValue({}),
+ send: vi.fn(),
+ },
+}));
-let fetchMock: ReturnType
+type WxWindow = Window & { wx?: Record };
beforeEach(() => {
- ;(window as WxWindow).wx = {}
- fetchMock = vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(new Response('{}', { status: 200 })))
- vi.stubGlobal('fetch', fetchMock)
-})
+ (window as WxWindow).wx = {};
+ vi.mocked(ipcRenderer.invoke).mockReset().mockResolvedValue({});
+ vi.mocked(ipcRenderer.send).mockReset();
+});
afterEach(() => {
- delete (window as WxWindow).wx
- vi.unstubAllGlobals()
-})
-
-/** Call the installed wx.request shim and drain its internal fetch chain. */
-async function callWxRequest(opts: Record): Promise {
- setupApiCompatHook()
- const wx = (window as WxWindow).wx as { request: (o: Record) => unknown }
- wx.request(opts)
- await new Promise((resolve) => setTimeout(resolve, 0))
- await new Promise((resolve) => setTimeout(resolve, 0))
+ delete (window as WxWindow).wx;
+ vi.restoreAllMocks();
+});
+
+async function flushAsyncTurns(times = 3): Promise {
+ for (let i = 0; i < times; i++) {
+ await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+}
+
+/** Call the installed wx.request shim and drain the IPC response chain. */
+async function callWxRequest(opts: Record): Promise<{
+ requestId: string;
+ forwarded: Record;
+}> {
+ setupApiCompatHook();
+ const wx = (window as WxWindow).wx as {
+ request: (o: Record) => unknown;
+ };
+ wx.request(opts);
+ await flushAsyncTurns(1);
+
+ const [channel, requestId, forwarded] = vi.mocked(ipcRenderer.invoke).mock
+ .calls[0] as [string, string, Record];
+ expect(channel).toBe(BRIDGE_CHANNELS.NATIVE_REQUEST);
+ return { requestId, forwarded };
}
-describe('wx.request shim (api-compat) — HTTP status never decides success vs fail', () => {
- it('a 401 response invokes success (not fail) with statusCode 401', async () => {
- fetchMock.mockImplementation(() => Promise.resolve(new Response('{}', { status: 401 })))
- const success = vi.fn<(res: RequestSuccessResult) => void>()
- const fail = vi.fn<(err: RequestFailResult) => void>()
+describe("wx.request shim (api-compat) — HTTP status never decides success vs fail", () => {
+ it("a 401 response invokes success (not fail) with statusCode 401", async () => {
+ vi.mocked(ipcRenderer.invoke).mockResolvedValue({
+ statusCode: 401,
+ data: {},
+ header: {},
+ errMsg: "request:ok",
+ });
+ const success = vi.fn<(res: RequestSuccessResult) => void>();
+ const fail = vi.fn<(err: RequestFailResult) => void>();
- await callWxRequest({ url: 'https://example.com/api', success, fail })
+ await callWxRequest({ url: "https://example.com/api", success, fail });
+ await flushAsyncTurns();
- expect(fail).not.toHaveBeenCalled()
- expect(success).toHaveBeenCalledTimes(1)
- expect(success.mock.calls[0][0].statusCode).toBe(401)
- })
-})
+ expect(fail).not.toHaveBeenCalled();
+ expect(success).toHaveBeenCalledTimes(1);
+ expect(success.mock.calls[0][0].statusCode).toBe(401);
+ });
+
+ it("a network failure result invokes fail and complete", async () => {
+ vi.mocked(ipcRenderer.invoke).mockResolvedValue({
+ errMsg: "request:fail network error",
+ });
+ const success = vi.fn<(res: RequestSuccessResult) => void>();
+ const fail = vi.fn<(err: RequestFailResult) => void>();
+ const complete =
+ vi.fn<(res: RequestSuccessResult | RequestFailResult) => void>();
-describe('wx.request shim (api-compat) — content-type header dedup', () => {
- it('a caller-supplied lowercase content-type is sent exactly once, not comma-joined with the runtime default', async () => {
- await callWxRequest({
- url: 'https://example.com/api',
- method: 'POST',
- header: { 'content-type': 'application/json' },
- })
-
- const init = fetchMock.mock.calls[0][1]
- const headers = new Headers(init?.headers as HeadersInit)
- const ct = headers.get('content-type')
- expect(ct).not.toContain(',')
- expect(ct).toBe('application/json')
- })
-
- it('omitting content-type applies the runtime default exactly once (no duplicate key)', async () => {
await callWxRequest({
- url: 'https://example.com/api',
- method: 'POST',
- header: {},
- })
-
- const init = fetchMock.mock.calls[0][1]
- const headers = new Headers(init?.headers as HeadersInit)
- const ct = headers.get('content-type')
- expect(ct).not.toContain(',')
- expect(ct).toBe('application/json')
- })
-})
-
-describe('wx.request shim (api-compat) — return value', () => {
- it('returns a request task exposing abort() as a function', async () => {
- setupApiCompatHook()
- const wx = (window as WxWindow).wx as { request: (o: Record) => { abort?: unknown } }
- const task = wx.request({ url: 'https://example.com/api' })
-
- expect(typeof task.abort).toBe('function')
- })
-})
+ url: "https://example.com/api",
+ success,
+ fail,
+ complete,
+ });
+ await flushAsyncTurns();
+
+ expect(success).not.toHaveBeenCalled();
+ expect(fail).toHaveBeenCalledTimes(1);
+ expect(complete).toHaveBeenCalledTimes(1);
+ });
+
+ it("a rejected IPC invoke invokes fail and complete", async () => {
+ vi.mocked(ipcRenderer.invoke).mockRejectedValue(new Error("ipc broken"));
+ const fail = vi.fn<(err: RequestFailResult) => void>();
+ const complete =
+ vi.fn<(res: RequestSuccessResult | RequestFailResult) => void>();
+
+ await callWxRequest({ url: "https://example.com/api", fail, complete });
+ await flushAsyncTurns();
+
+ expect(fail).toHaveBeenCalledTimes(1);
+ expect(complete).toHaveBeenCalledTimes(1);
+ expect(String(fail.mock.calls[0][0].errMsg)).toContain("ipc broken");
+ });
+});
+
+describe("wx.request shim (api-compat) — forwarded options", () => {
+ it("forwards the option bag to the main-process handler unchanged", async () => {
+ const { forwarded } = await callWxRequest({
+ url: "https://example.com/api",
+ method: "POST",
+ header: { "x-token": "abc" },
+ data: { a: 1 },
+ timeout: 3000,
+ dataType: "json",
+ responseType: "text",
+ });
+
+ expect(forwarded).toMatchObject({
+ url: "https://example.com/api",
+ method: "POST",
+ header: { "x-token": "abc" },
+ data: { a: 1 },
+ timeout: 3000,
+ dataType: "json",
+ responseType: "text",
+ });
+ });
+
+ it("forwards only the provided option fields", async () => {
+ const { forwarded } = await callWxRequest({
+ url: "https://example.com/api",
+ method: "GET",
+ });
+
+ expect(forwarded).toMatchObject({
+ url: "https://example.com/api",
+ method: "GET",
+ });
+ });
+});
+
+describe("wx.request shim (api-compat) — return value", () => {
+ it("returns a request task exposing abort() as a function", async () => {
+ setupApiCompatHook();
+ const wx = (window as WxWindow).wx as {
+ request: (o: Record) => { abort?: unknown };
+ };
+
+ const task = wx.request({ url: "https://example.com/api" });
+ expect(typeof task.abort).toBe("function");
+ });
+
+ it("task.abort() sends the matching requestId on the native-request-abort channel", async () => {
+ setupApiCompatHook();
+ const wx = (window as WxWindow).wx as {
+ request: (o: Record) => { abort: () => void };
+ };
+
+ const task = wx.request({ url: "https://example.com/api" });
+ const requestId = vi.mocked(ipcRenderer.invoke).mock.calls[0][1];
+ task.abort();
+
+ expect(ipcRenderer.send).toHaveBeenCalledWith(
+ BRIDGE_CHANNELS.NATIVE_REQUEST_ABORT,
+ requestId,
+ );
+ });
+});
diff --git a/packages/devtools/src/preload/shared/api-compat.ts b/packages/devtools/src/preload/shared/api-compat.ts
index 22f31a3b..85ed6ce9 100644
--- a/packages/devtools/src/preload/shared/api-compat.ts
+++ b/packages/devtools/src/preload/shared/api-compat.ts
@@ -1,20 +1,23 @@
-import { performRequest } from '../../shared/request-core.js'
+import { ipcRenderer } from "electron";
+import { BRIDGE_CHANNELS } from "../../shared/bridge-channels.js";
-type Callback = ((payload: T) => void) | undefined
+type Callback = ((payload: T) => void) | undefined;
function call(fn: Callback, payload: T): void {
try {
- fn?.(payload)
+ fn?.(payload);
} catch {
// Ignore callback errors in compat shims.
}
}
function buildWindowInfo() {
- const width = window.innerWidth || document.documentElement.clientWidth || 375
- const height = window.innerHeight || document.documentElement.clientHeight || 812
- const pixelRatio = window.devicePixelRatio || 2
- const statusBarHeight = 0
+ const width =
+ window.innerWidth || document.documentElement.clientWidth || 375;
+ const height =
+ window.innerHeight || document.documentElement.clientHeight || 812;
+ const pixelRatio = window.devicePixelRatio || 2;
+ const statusBarHeight = 0;
return {
pixelRatio,
screenWidth: width,
@@ -30,124 +33,133 @@ function buildWindowInfo() {
left: 0,
right: width,
},
- }
+ };
}
function makeStorageKey(key: string): string {
- return `dimina:${key}`
+ return `dimina:${key}`;
}
function ensureWxApi(wx: Record): void {
- if (typeof wx.canIUse !== 'function') {
- wx.canIUse = (_schema: unknown) => true
+ if (typeof wx.canIUse !== "function") {
+ wx.canIUse = (_schema: unknown) => true;
}
- if (typeof wx.getWindowInfo !== 'function') {
- wx.getWindowInfo = (opts: { success?: Callback; complete?: Callback } = {}) => {
- const info = buildWindowInfo()
- call(opts.success, info)
- call(opts.complete, undefined)
- return info
- }
+ if (typeof wx.getWindowInfo !== "function") {
+ wx.getWindowInfo = (
+ opts: { success?: Callback; complete?: Callback } = {},
+ ) => {
+ const info = buildWindowInfo();
+ call(opts.success, info);
+ call(opts.complete, undefined);
+ return info;
+ };
}
- if (typeof wx.getSystemSetting !== 'function') {
- wx.getSystemSetting = (opts: { success?: Callback; complete?: Callback } = {}) => {
+ if (typeof wx.getSystemSetting !== "function") {
+ wx.getSystemSetting = (
+ opts: { success?: Callback; complete?: Callback } = {},
+ ) => {
const info = {
bluetoothEnabled: false,
locationEnabled: true,
wifiEnabled: true,
- deviceOrientation: 'portrait',
- }
- call(opts.success, info)
- call(opts.complete, undefined)
- return info
- }
+ deviceOrientation: "portrait",
+ };
+ call(opts.success, info);
+ call(opts.complete, undefined);
+ return info;
+ };
}
- if (typeof wx.getSystemInfoSync !== 'function') {
+ if (typeof wx.getSystemInfoSync !== "function") {
wx.getSystemInfoSync = () => ({
- brand: 'simulator',
- model: 'web',
- platform: 'simulator',
- system: 'web',
- language: 'zh_CN',
- SDKVersion: '3.0.0',
+ brand: "simulator",
+ model: "web",
+ platform: "simulator",
+ system: "web",
+ language: "zh_CN",
+ SDKVersion: "3.0.0",
...buildWindowInfo(),
- })
+ });
}
- if (typeof wx.setStorageSync !== 'function') {
+ if (typeof wx.setStorageSync !== "function") {
wx.setStorageSync = (key: string, data: unknown) => {
- const value = typeof data === 'string' ? data : JSON.stringify(data)
- localStorage.setItem(makeStorageKey(String(key)), value)
- }
+ const value = typeof data === "string" ? data : JSON.stringify(data);
+ localStorage.setItem(makeStorageKey(String(key)), value);
+ };
}
- if (typeof wx.getStorageSync !== 'function') {
+ if (typeof wx.getStorageSync !== "function") {
wx.getStorageSync = (key: string) => {
- const raw = localStorage.getItem(makeStorageKey(String(key)))
- if (raw == null) return ''
+ const raw = localStorage.getItem(makeStorageKey(String(key)));
+ if (raw == null) return "";
try {
- return JSON.parse(raw)
+ return JSON.parse(raw);
} catch {
- return raw
+ return raw;
}
- }
+ };
}
- if (typeof wx.removeStorageSync !== 'function') {
+ if (typeof wx.removeStorageSync !== "function") {
wx.removeStorageSync = (key: string) => {
- localStorage.removeItem(makeStorageKey(String(key)))
- }
+ localStorage.removeItem(makeStorageKey(String(key)));
+ };
}
- if (typeof wx.clearStorageSync !== 'function') {
+ if (typeof wx.clearStorageSync !== "function") {
wx.clearStorageSync = () => {
- const prefix = 'dimina:'
- const keys: string[] = []
+ const prefix = "dimina:";
+ const keys: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
- const key = localStorage.key(i)
- if (key?.startsWith(prefix)) keys.push(key)
+ const key = localStorage.key(i);
+ if (key?.startsWith(prefix)) keys.push(key);
}
- keys.forEach((key) => localStorage.removeItem(key))
- }
+ keys.forEach((key) => localStorage.removeItem(key));
+ };
}
- if (typeof wx.getStorageInfoSync !== 'function') {
+ if (typeof wx.getStorageInfoSync !== "function") {
wx.getStorageInfoSync = () => {
- const prefix = 'dimina:'
- const keys: string[] = []
- let currentSize = 0
+ const prefix = "dimina:";
+ const keys: string[] = [];
+ let currentSize = 0;
for (let i = 0; i < localStorage.length; i++) {
- const fullKey = localStorage.key(i)
- if (!fullKey?.startsWith(prefix)) continue
- keys.push(fullKey.slice(prefix.length))
- currentSize += (localStorage.getItem(fullKey) || '').length * 2
+ const fullKey = localStorage.key(i);
+ if (!fullKey?.startsWith(prefix)) continue;
+ keys.push(fullKey.slice(prefix.length));
+ currentSize += (localStorage.getItem(fullKey) || "").length * 2;
}
- return { keys, currentSize, limitSize: 10 * 1024 * 1024 }
- }
+ return { keys, currentSize, limitSize: 10 * 1024 * 1024 };
+ };
}
- if (typeof wx.request !== 'function') {
- // Delegates to the shared wx.request core (shared/request-core.ts) — the
- // single owner of success/fail semantics, header dedup, timeout default,
- // and body encoding. This shim only adapts the wx.request option bag onto
- // the core's callbacks.
+ if (typeof wx.request !== "function") {
+ // Route wx.request through the main-process native HTTP transport so it
+ // never executes in the renderer (no Chromium Fetch/CORS algorithm, no
+ // preflight). The main process owns the network I/O and returns a result
+ // object that already carries the wx.request success/fail shape.
wx.request = (opts: {
- url: string
- data?: unknown
- header?: Record
- timeout?: number
- method?: string
- dataType?: string
- responseType?: string
- success?: Callback
- fail?: Callback
- complete?: Callback
- }) =>
- performRequest(
- {
+ url: string;
+ data?: unknown;
+ header?: Record;
+ timeout?: number;
+ method?: string;
+ dataType?: string;
+ responseType?: string;
+ success?: Callback;
+ fail?: Callback;
+ complete?: Callback;
+ }) => {
+ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ const task = {
+ abort: () =>
+ ipcRenderer.send(BRIDGE_CHANNELS.NATIVE_REQUEST_ABORT, requestId),
+ };
+ ipcRenderer
+ .invoke(BRIDGE_CHANNELS.NATIVE_REQUEST, requestId, {
url: opts.url,
data: opts.data,
header: opts.header,
@@ -155,30 +167,42 @@ function ensureWxApi(wx: Record): void {
method: opts.method,
dataType: opts.dataType,
responseType: opts.responseType,
- },
- {
- success: (res) => call(opts.success, res),
- fail: (err) => call(opts.fail, err),
- complete: (res) => call(opts.complete, res),
- },
- )
+ })
+ .then((result: unknown) => {
+ if (result && typeof result === "object" && "statusCode" in result) {
+ call(opts.success, result);
+ call(opts.complete, result);
+ } else {
+ call(opts.fail, result);
+ call(opts.complete, result);
+ }
+ })
+ .catch((error: unknown) => {
+ const err = {
+ errMsg: `request:fail ${error instanceof Error ? error.message : String(error)}`,
+ };
+ call(opts.fail, err);
+ call(opts.complete, err);
+ });
+ return task;
+ };
}
}
export function setupApiCompatHook(): void {
const apply = () => {
- const target = window as unknown as { wx?: Record }
- if (!target.wx || typeof target.wx !== 'object') {
- target.wx = {}
+ const target = window as unknown as { wx?: Record };
+ if (!target.wx || typeof target.wx !== "object") {
+ target.wx = {};
}
- const wx = target.wx
- ensureWxApi(wx)
- return true
- }
+ const wx = target.wx;
+ ensureWxApi(wx);
+ return true;
+ };
- if (apply()) return
+ if (apply()) return;
const timer = window.setInterval(() => {
- if (apply()) window.clearInterval(timer)
- }, 200)
+ if (apply()) window.clearInterval(timer);
+ }, 200);
}
diff --git a/packages/devtools/src/shared/request-core-body.test.ts b/packages/devtools/src/shared/request-core-body.test.ts
index ec8092d3..3f7eb167 100644
--- a/packages/devtools/src/shared/request-core-body.test.ts
+++ b/packages/devtools/src/shared/request-core-body.test.ts
@@ -14,158 +14,249 @@
* - responseType 'arraybuffer' (and the legacy dataType 'arraybuffer' spelling)
* yields an ArrayBuffer instead of decoded text/JSON.
*/
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { performRequest, type RequestFailResult, type RequestSuccessResult } from './request-core'
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ performRequest,
+ type RequestFailResult,
+ type RequestSuccessResult,
+} from "./request-core";
async function flushAsyncTurns(times = 5): Promise {
for (let i = 0; i < times; i++) {
- await Promise.resolve()
- await new Promise((resolve) => setTimeout(resolve, 0))
+ await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
}
}
-function okResponse(body: BodyInit = '{}'): Response {
- return new Response(body, { status: 200 })
+function okResponse(body: BodyInit = "{}"): Response {
+ return new Response(body, { status: 200 });
}
afterEach(() => {
- vi.unstubAllGlobals()
-})
-
-describe('performRequest — method default and GET/HEAD query encoding', () => {
- it('omitting `method` defaults to GET: an object `data` is appended as URL query params, not a body', async () => {
- const fetchMock = vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(okResponse()))
- vi.stubGlobal('fetch', fetchMock)
-
- performRequest({ url: 'https://example.com/api', data: { a: 1, b: 'x' } }, {})
- await flushAsyncTurns()
-
- const [urlArg, init] = fetchMock.mock.calls[0]
- const url = new URL(String(urlArg))
- expect(url.searchParams.get('a')).toBe('1')
- expect(url.searchParams.get('b')).toBe('x')
- expect(init?.body).toBeUndefined()
- })
-
- it('HEAD with an object `data` also appends query params and sends no body', async () => {
- const fetchMock = vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(okResponse()))
- vi.stubGlobal('fetch', fetchMock)
-
- performRequest({ url: 'https://example.com/api', method: 'HEAD', data: { q: 'search term' } }, {})
- await flushAsyncTurns()
-
- const [urlArg, init] = fetchMock.mock.calls[0]
- const url = new URL(String(urlArg))
- expect(url.searchParams.get('q')).toBe('search term')
- expect(init?.body).toBeUndefined()
- })
-})
-
-describe('performRequest — non-GET/HEAD body encoding', () => {
- it('a string `data` is sent as the body verbatim, untouched', async () => {
- const fetchMock = vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(okResponse()))
- vi.stubGlobal('fetch', fetchMock)
-
- performRequest({ url: 'https://example.com/api', method: 'POST', data: 'raw=body&x=1' }, {})
- await flushAsyncTurns()
-
- const init = fetchMock.mock.calls[0][1]
- expect(init?.body).toBe('raw=body&x=1')
- })
-
- it('an object `data` under the default (application/json) content-type is JSON-encoded', async () => {
- const fetchMock = vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(okResponse()))
- vi.stubGlobal('fetch', fetchMock)
-
- performRequest({ url: 'https://example.com/api', method: 'POST', data: { a: 1, b: 'x' } }, {})
- await flushAsyncTurns()
-
- const init = fetchMock.mock.calls[0][1]
- expect(init?.body).toBe(JSON.stringify({ a: 1, b: 'x' }))
- })
-
- it('an object `data` under application/x-www-form-urlencoded is form-encoded as key=value pairs, not JSON', async () => {
- const fetchMock = vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(okResponse()))
- vi.stubGlobal('fetch', fetchMock)
-
- performRequest({
- url: 'https://example.com/api',
- method: 'POST',
- header: { 'content-type': 'application/x-www-form-urlencoded' },
- data: { a: 1, b: 'x' },
- }, {})
- await flushAsyncTurns()
-
- const init = fetchMock.mock.calls[0][1]
- expect(typeof init?.body).toBe('string')
- expect(init?.body).not.toContain('{')
- const parsed = new URLSearchParams(init?.body as string)
- expect(parsed.get('a')).toBe('1')
- expect(parsed.get('b')).toBe('x')
- })
-})
+ vi.unstubAllGlobals();
+});
+
+describe("performRequest — method default and GET/HEAD query encoding", () => {
+ it("omitting `method` defaults to GET: an object `data` is appended as URL query params, not a body", async () => {
+ const fetchMock = vi.fn((_url: string, _init?: RequestInit) =>
+ Promise.resolve(okResponse()),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ performRequest(
+ { url: "https://example.com/api", data: { a: 1, b: "x" } },
+ {},
+ );
+ await flushAsyncTurns();
+
+ const [urlArg, init] = fetchMock.mock.calls[0];
+ const url = new URL(String(urlArg));
+ expect(url.searchParams.get("a")).toBe("1");
+ expect(url.searchParams.get("b")).toBe("x");
+ expect(init?.body).toBeUndefined();
+ });
+
+ it("HEAD with an object `data` also appends query params and sends no body", async () => {
+ const fetchMock = vi.fn((_url: string, _init?: RequestInit) =>
+ Promise.resolve(okResponse()),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ performRequest(
+ {
+ url: "https://example.com/api",
+ method: "HEAD",
+ data: { q: "search term" },
+ },
+ {},
+ );
+ await flushAsyncTurns();
+
+ const [urlArg, init] = fetchMock.mock.calls[0];
+ const url = new URL(String(urlArg));
+ expect(url.searchParams.get("q")).toBe("search term");
+ expect(init?.body).toBeUndefined();
+ });
+
+ it("a bodyless GET (no `data`, no `header`) sends no content-type at all — matching a real device, which never adds one to a request with no body, and keeping the request CORS-simple in the simulator's Chromium renderer", async () => {
+ const fetchMock = vi.fn((_url: string, _init?: RequestInit) =>
+ Promise.resolve(okResponse()),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ performRequest({ url: "https://example.com/api" }, {});
+ await flushAsyncTurns();
+
+ const init = fetchMock.mock.calls[0][1];
+ const headers = new Headers(init?.headers as HeadersInit);
+ expect(headers.has("content-type")).toBe(false);
+ });
+
+ it("a GET whose object `data` becomes query params still sends no content-type, even though `data` was supplied", async () => {
+ const fetchMock = vi.fn((_url: string, _init?: RequestInit) =>
+ Promise.resolve(okResponse()),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ performRequest({ url: "https://example.com/api", data: { q: 1 } }, {});
+ await flushAsyncTurns();
+
+ const init = fetchMock.mock.calls[0][1];
+ const headers = new Headers(init?.headers as HeadersInit);
+ expect(headers.has("content-type")).toBe(false);
+ });
+});
+
+describe("performRequest — non-GET/HEAD body encoding", () => {
+ it("a string `data` is sent as the body verbatim, untouched", async () => {
+ const fetchMock = vi.fn((_url: string, _init?: RequestInit) =>
+ Promise.resolve(okResponse()),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ performRequest(
+ { url: "https://example.com/api", method: "POST", data: "raw=body&x=1" },
+ {},
+ );
+ await flushAsyncTurns();
+
+ const init = fetchMock.mock.calls[0][1];
+ expect(init?.body).toBe("raw=body&x=1");
+ });
+
+ it("an object `data` under the default (application/json) content-type is JSON-encoded", async () => {
+ const fetchMock = vi.fn((_url: string, _init?: RequestInit) =>
+ Promise.resolve(okResponse()),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ performRequest(
+ {
+ url: "https://example.com/api",
+ method: "POST",
+ data: { a: 1, b: "x" },
+ },
+ {},
+ );
+ await flushAsyncTurns();
+
+ const init = fetchMock.mock.calls[0][1];
+ expect(init?.body).toBe(JSON.stringify({ a: 1, b: "x" }));
+ });
+
+ it("an object `data` under application/x-www-form-urlencoded is form-encoded as key=value pairs, not JSON", async () => {
+ const fetchMock = vi.fn((_url: string, _init?: RequestInit) =>
+ Promise.resolve(okResponse()),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ performRequest(
+ {
+ url: "https://example.com/api",
+ method: "POST",
+ header: { "content-type": "application/x-www-form-urlencoded" },
+ data: { a: 1, b: "x" },
+ },
+ {},
+ );
+ await flushAsyncTurns();
+
+ const init = fetchMock.mock.calls[0][1];
+ expect(typeof init?.body).toBe("string");
+ expect(init?.body).not.toContain("{");
+ const parsed = new URLSearchParams(init?.body as string);
+ expect(parsed.get("a")).toBe("1");
+ expect(parsed.get("b")).toBe("x");
+ });
+});
describe('performRequest — dataType (default "json")', () => {
- it('parses a JSON-shaped response body into `data`', async () => {
- vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(okResponse(JSON.stringify({ ok: true, n: 3 })))))
- const success = vi.fn<(res: RequestSuccessResult) => void>()
-
- performRequest({ url: 'https://example.com/api' }, { success })
- await flushAsyncTurns()
-
- expect(success).toHaveBeenCalledTimes(1)
- expect(success.mock.calls[0][0].data).toEqual({ ok: true, n: 3 })
- })
-
- it('falls back to the raw text when the response body is not valid JSON, without throwing', async () => {
- vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(okResponse('not-json{{{'))))
- const success = vi.fn<(res: RequestSuccessResult) => void>()
- const fail = vi.fn<(err: RequestFailResult) => void>()
-
- performRequest({ url: 'https://example.com/api' }, { success, fail })
- await flushAsyncTurns()
-
- expect(fail).not.toHaveBeenCalled()
- expect(success).toHaveBeenCalledTimes(1)
- expect(success.mock.calls[0][0].data).toBe('not-json{{{')
- })
+ it("parses a JSON-shaped response body into `data`", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() =>
+ Promise.resolve(okResponse(JSON.stringify({ ok: true, n: 3 }))),
+ ),
+ );
+ const success = vi.fn<(res: RequestSuccessResult) => void>();
+
+ performRequest({ url: "https://example.com/api" }, { success });
+ await flushAsyncTurns();
+
+ expect(success).toHaveBeenCalledTimes(1);
+ expect(success.mock.calls[0][0].data).toEqual({ ok: true, n: 3 });
+ });
+
+ it("falls back to the raw text when the response body is not valid JSON, without throwing", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.resolve(okResponse("not-json{{{"))),
+ );
+ const success = vi.fn<(res: RequestSuccessResult) => void>();
+ const fail = vi.fn<(err: RequestFailResult) => void>();
+
+ performRequest({ url: "https://example.com/api" }, { success, fail });
+ await flushAsyncTurns();
+
+ expect(fail).not.toHaveBeenCalled();
+ expect(success).toHaveBeenCalledTimes(1);
+ expect(success.mock.calls[0][0].data).toBe("not-json{{{");
+ });
it('a non-"json" dataType leaves the response body as raw text, even when it happens to be valid JSON', async () => {
- const jsonText = JSON.stringify({ a: 1 })
- vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(okResponse(jsonText))))
- const success = vi.fn<(res: RequestSuccessResult) => void>()
-
- performRequest({ url: 'https://example.com/api', dataType: 'text' }, { success })
- await flushAsyncTurns()
-
- expect(success.mock.calls[0][0].data).toBe(jsonText)
- })
-})
+ const jsonText = JSON.stringify({ a: 1 });
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.resolve(okResponse(jsonText))),
+ );
+ const success = vi.fn<(res: RequestSuccessResult) => void>();
+
+ performRequest(
+ { url: "https://example.com/api", dataType: "text" },
+ { success },
+ );
+ await flushAsyncTurns();
+
+ expect(success.mock.calls[0][0].data).toBe(jsonText);
+ });
+});
describe('performRequest — responseType "arraybuffer"', () => {
- it('yields an ArrayBuffer in `data`, bypassing text/JSON decoding', async () => {
- const bytes = new Uint8Array([1, 2, 3, 4])
- vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(okResponse(bytes))))
- const success = vi.fn<(res: RequestSuccessResult) => void>()
-
- performRequest({ url: 'https://example.com/api', responseType: 'arraybuffer' }, { success })
- await flushAsyncTurns()
-
- const data = success.mock.calls[0][0].data
- expect(data).toBeInstanceOf(ArrayBuffer)
- expect(new Uint8Array(data as ArrayBuffer)).toEqual(bytes)
- })
+ it("yields an ArrayBuffer in `data`, bypassing text/JSON decoding", async () => {
+ const bytes = new Uint8Array([1, 2, 3, 4]);
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.resolve(okResponse(bytes))),
+ );
+ const success = vi.fn<(res: RequestSuccessResult) => void>();
+
+ performRequest(
+ { url: "https://example.com/api", responseType: "arraybuffer" },
+ { success },
+ );
+ await flushAsyncTurns();
+
+ const data = success.mock.calls[0][0].data;
+ expect(data).toBeInstanceOf(ArrayBuffer);
+ expect(new Uint8Array(data as ArrayBuffer)).toEqual(bytes);
+ });
it('the legacy dataType "arraybuffer" spelling is honoured the same as responseType "arraybuffer"', async () => {
- const bytes = new Uint8Array([9, 8, 7])
- vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(okResponse(bytes))))
- const success = vi.fn<(res: RequestSuccessResult) => void>()
-
- performRequest({ url: 'https://example.com/api', dataType: 'arraybuffer' }, { success })
- await flushAsyncTurns()
-
- const data = success.mock.calls[0][0].data
- expect(data).toBeInstanceOf(ArrayBuffer)
- expect(new Uint8Array(data as ArrayBuffer)).toEqual(bytes)
- })
-})
+ const bytes = new Uint8Array([9, 8, 7]);
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.resolve(okResponse(bytes))),
+ );
+ const success = vi.fn<(res: RequestSuccessResult) => void>();
+
+ performRequest(
+ { url: "https://example.com/api", dataType: "arraybuffer" },
+ { success },
+ );
+ await flushAsyncTurns();
+
+ const data = success.mock.calls[0][0].data;
+ expect(data).toBeInstanceOf(ArrayBuffer);
+ expect(new Uint8Array(data as ArrayBuffer)).toEqual(bytes);
+ });
+});
diff --git a/packages/devtools/src/shared/simulator-api-metadata-watchdog-bounds.test.ts b/packages/devtools/src/shared/simulator-api-metadata-watchdog-bounds.test.ts
index 03b8e4b0..b8c3a775 100644
--- a/packages/devtools/src/shared/simulator-api-metadata-watchdog-bounds.test.ts
+++ b/packages/devtools/src/shared/simulator-api-metadata-watchdog-bounds.test.ts
@@ -16,25 +16,29 @@
* a legal 2147483647ms budget plus the 5000ms forwarding grace would
* otherwise overflow the same limit one layer up.
*/
-import { describe, it, expect } from 'vitest'
-import { apiCallWatchdogMs } from './simulator-api-metadata'
+import { describe, it, expect } from "vitest";
+import { apiCallWatchdogMs } from "./simulator-api-metadata";
-const MAX_SAFE_DELAY_MS = 2_147_483_647
+const MAX_SAFE_DELAY_MS = 2_147_483_647;
-describe('apiCallWatchdogMs — non-finite params.timeout falls back to the default budget', () => {
- it('Infinity is rejected, not passed through to the watchdog delay', () => {
- expect(apiCallWatchdogMs('request', { timeout: Infinity })).toBe(65_000)
- })
+describe("apiCallWatchdogMs — non-finite params.timeout falls back to the default budget", () => {
+ it("Infinity is rejected, not passed through to the watchdog delay", () => {
+ expect(apiCallWatchdogMs("downloadFile", { timeout: Infinity })).toBe(
+ 65_000,
+ );
+ });
- it('a value larger than the setTimeout max safe delay is rejected', () => {
- expect(apiCallWatchdogMs('request', { timeout: 1e12 })).toBe(65_000)
- })
-})
+ it("a value larger than the setTimeout max safe delay is rejected", () => {
+ expect(apiCallWatchdogMs("downloadFile", { timeout: 1e12 })).toBe(65_000);
+ });
+});
-describe('apiCallWatchdogMs — the legal upper bound is honoured without overflowing the return value', () => {
- it('params.timeout at exactly the setTimeout max safe delay is used, and the +5000ms grace is clamped rather than overflowing', () => {
- const result = apiCallWatchdogMs('request', { timeout: MAX_SAFE_DELAY_MS })
- expect(result).toBe(MAX_SAFE_DELAY_MS)
- expect(result).toBeLessThanOrEqual(MAX_SAFE_DELAY_MS)
- })
-})
+describe("apiCallWatchdogMs — the legal upper bound is honoured without overflowing the return value", () => {
+ it("params.timeout at exactly the setTimeout max safe delay is used, and the +5000ms grace is clamped rather than overflowing", () => {
+ const result = apiCallWatchdogMs("downloadFile", {
+ timeout: MAX_SAFE_DELAY_MS,
+ });
+ expect(result).toBe(MAX_SAFE_DELAY_MS);
+ expect(result).toBeLessThanOrEqual(MAX_SAFE_DELAY_MS);
+ });
+});
diff --git a/packages/devtools/src/shared/simulator-api-metadata-watchdog.test.ts b/packages/devtools/src/shared/simulator-api-metadata-watchdog.test.ts
index 9a965092..2af2c5d0 100644
--- a/packages/devtools/src/shared/simulator-api-metadata-watchdog.test.ts
+++ b/packages/devtools/src/shared/simulator-api-metadata-watchdog.test.ts
@@ -1,56 +1,64 @@
/**
* `apiCallWatchdogMs` decides how long bridge-router's one-shot "no handler"
* watchdog waits before it tears a forwarded simulator-API call down. Network
- * -budget APIs (request/downloadFile/uploadFile) must get the caller's wx
+ * -budget APIs (`downloadFile`/`uploadFile`) must get the caller's wx
* timeout budget (`params.timeout` when positive, else the wx default
* `DEFAULT_REQUEST_TIMEOUT_MS`) plus a 5000ms forwarding grace window;
- * anything else keeps the flat 5000ms window bridge-router used before this
- * API existed.
+ * `request` is no longer forwarded to the simulator window and therefore keeps
+ * the flat 5000ms window; anything else also keeps the flat 5000ms window.
*
* `DEFAULT_REQUEST_TIMEOUT_MS` (shared/request-core.ts) is the single source
* of truth for the wx default (60000ms) — pinned here too so the watchdog
* budget cannot silently drift from the actual request timeout default.
*/
-import { describe, it, expect } from 'vitest'
-import { apiCallWatchdogMs } from './simulator-api-metadata'
-import { DEFAULT_REQUEST_TIMEOUT_MS } from './request-core'
-
-describe('DEFAULT_REQUEST_TIMEOUT_MS', () => {
- it('is the wx.request default timeout budget of 60000ms', () => {
- expect(DEFAULT_REQUEST_TIMEOUT_MS).toBe(60_000)
- })
-})
-
-describe('apiCallWatchdogMs — network-budget APIs (request/downloadFile/uploadFile)', () => {
- it('request with no params.timeout uses the default budget + 5000ms grace', () => {
- expect(apiCallWatchdogMs('request', {})).toBe(65_000)
- expect(apiCallWatchdogMs('request', {})).toBe(DEFAULT_REQUEST_TIMEOUT_MS + 5_000)
- })
-
- it('request with a positive params.timeout uses timeout + 5000ms grace', () => {
- expect(apiCallWatchdogMs('request', { timeout: 1000 })).toBe(6_000)
- })
-
- it('request with a non-positive params.timeout falls back to the default budget', () => {
- expect(apiCallWatchdogMs('request', { timeout: 0 })).toBe(65_000)
- })
-
- it('request with an absent params object falls back to the default budget', () => {
- expect(apiCallWatchdogMs('request', undefined)).toBe(65_000)
- })
-
- it('downloadFile and uploadFile share the same network-budget treatment as request', () => {
- expect(apiCallWatchdogMs('downloadFile', {})).toBe(65_000)
- expect(apiCallWatchdogMs('uploadFile', {})).toBe(65_000)
- })
-})
-
-describe('apiCallWatchdogMs — every other API keeps the flat 5000ms window', () => {
- it('showToast (a representative non-network API) is unaffected', () => {
- expect(apiCallWatchdogMs('showToast', {})).toBe(5_000)
- })
-
- it('an absent params object does not change the flat window for a non-network API', () => {
- expect(apiCallWatchdogMs('showToast', undefined)).toBe(5_000)
- })
-})
+import { describe, it, expect } from "vitest";
+import { apiCallWatchdogMs } from "./simulator-api-metadata";
+import { DEFAULT_REQUEST_TIMEOUT_MS } from "./request-core";
+
+describe("DEFAULT_REQUEST_TIMEOUT_MS", () => {
+ it("is the wx.request default timeout budget of 60000ms", () => {
+ expect(DEFAULT_REQUEST_TIMEOUT_MS).toBe(60_000);
+ });
+});
+
+describe("apiCallWatchdogMs — network-budget APIs (downloadFile/uploadFile)", () => {
+ it("downloadFile with no params.timeout uses the default budget + 5000ms grace", () => {
+ expect(apiCallWatchdogMs("downloadFile", {})).toBe(65_000);
+ expect(apiCallWatchdogMs("downloadFile", {})).toBe(
+ DEFAULT_REQUEST_TIMEOUT_MS + 5_000,
+ );
+ });
+
+ it("downloadFile with a positive params.timeout uses timeout + 5000ms grace", () => {
+ expect(apiCallWatchdogMs("downloadFile", { timeout: 1000 })).toBe(6_000);
+ });
+
+ it("downloadFile with a non-positive params.timeout falls back to the default budget", () => {
+ expect(apiCallWatchdogMs("downloadFile", { timeout: 0 })).toBe(65_000);
+ });
+
+ it("downloadFile with an absent params object falls back to the default budget", () => {
+ expect(apiCallWatchdogMs("downloadFile", undefined)).toBe(65_000);
+ });
+
+ it("uploadFile shares the same network-budget treatment as downloadFile", () => {
+ expect(apiCallWatchdogMs("uploadFile", {})).toBe(65_000);
+ expect(apiCallWatchdogMs("uploadFile", { timeout: 1000 })).toBe(6_000);
+ });
+});
+
+describe("apiCallWatchdogMs — request and every other non-network API keeps the flat 5000ms window", () => {
+ it("request is no longer a network-budget simulator API", () => {
+ expect(apiCallWatchdogMs("request", {})).toBe(5_000);
+ expect(apiCallWatchdogMs("request", { timeout: 1000 })).toBe(5_000);
+ expect(apiCallWatchdogMs("request", undefined)).toBe(5_000);
+ });
+
+ it("showToast (a representative non-network API) is unaffected", () => {
+ expect(apiCallWatchdogMs("showToast", {})).toBe(5_000);
+ });
+
+ it("an absent params object does not change the flat window for a non-network API", () => {
+ expect(apiCallWatchdogMs("showToast", undefined)).toBe(5_000);
+ });
+});
diff --git a/packages/devtools/src/simulator/direct-request-headers.test.ts b/packages/devtools/src/simulator/direct-request-headers.test.ts
deleted file mode 100644
index c09c252c..00000000
--- a/packages/devtools/src/simulator/direct-request-headers.test.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * Header deduplication contract for `directRequest`.
- *
- * The runtime merges a default `Content-Type: application/json` with the
- * caller-supplied `header` map. Because the default key uses title-case
- * (`Content-Type`) while callers often supply lowercase (`content-type`),
- * the plain-object spread `{ 'Content-Type': '…', ...header }` produces two
- * distinct keys that `new Headers()` normalises and joins with a comma,
- * yielding `application/json, application/json` — the duplication bug these
- * tests pin.
- *
- * Contract: the outgoing `content-type` must always be a single, comma-free
- * value. Caller's explicit value wins; runtime default fills the gap only when
- * the caller omits it entirely.
- */
-
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { directRequest } from './direct-request'
-
-// Minimal MiniAppContext seam — mirrors the pattern used in other
-// simulator-api tests (e.g. run-api-async.test.ts).
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-const ctx = { appId: 'test-app', createCallbackFunction: (fn: unknown) => (typeof fn === 'function' ? fn as (...a: any[]) => void : undefined) }
-
-let fetchMock: ReturnType
-
-beforeEach(() => {
- fetchMock = vi.fn(() => Promise.resolve(new Response('{}', { status: 200 })))
- vi.stubGlobal('fetch', fetchMock)
-})
-
-afterEach(() => {
- vi.unstubAllGlobals()
-})
-
-/** Extract and normalise the outgoing headers from the captured fetch call. */
-function capturedHeaders(): Headers {
- const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined
- return new Headers(init?.headers as HeadersInit | undefined)
-}
-
-/** Fire `directRequest` and wait for the internal fetch promise to settle. */
-async function request(args: Parameters[0]): Promise {
- directRequest.call(ctx, args)
- // Let the microtask queue drain so `fetch(…).then(…)` has resolved.
- await new Promise((resolve) => setTimeout(resolve, 0))
-}
-
-describe('directRequest — Content-Type header deduplication', () => {
- it('caller sets lowercase content-type: application/json → exactly one value, no comma', async () => {
- await request({
- url: 'https://example.com/api',
- method: 'POST',
- header: { 'content-type': 'application/json' },
- })
-
- const headers = capturedHeaders()
- const ct = headers.get('content-type')
-
- // Must be exactly the single value — NOT 'application/json, application/json'
- expect(ct).not.toContain(',')
- expect(ct).toBe('application/json')
- })
-
- it('caller sets NO content-type → runtime default applied exactly once', async () => {
- await request({
- url: 'https://example.com/api',
- method: 'POST',
- header: {},
- })
-
- const headers = capturedHeaders()
- const ct = headers.get('content-type')
-
- expect(ct).not.toContain(',')
- expect(ct).toBe('application/json')
- })
-
- it('caller sets Content-Type with different casing (title-case) and custom value → caller wins, single value', async () => {
- await request({
- url: 'https://example.com/api',
- method: 'POST',
- header: { 'Content-Type': 'application/x-www-form-urlencoded' },
- })
-
- const headers = capturedHeaders()
- const ct = headers.get('content-type')
-
- // Caller's value must win and appear exactly once — default must NOT be appended
- expect(ct).not.toContain(',')
- expect(ct).toBe('application/x-www-form-urlencoded')
- })
-
- it('caller sets an unrelated header alongside absent content-type → both header and default survive correctly', async () => {
- await request({
- url: 'https://example.com/api',
- method: 'POST',
- header: { 'x-token': 'abc' },
- })
-
- const headers = capturedHeaders()
-
- // Unrelated caller header must pass through
- expect(headers.get('x-token')).toBe('abc')
-
- // Runtime default still applied, exactly once
- const ct = headers.get('content-type')
- expect(ct).not.toContain(',')
- expect(ct).toBe('application/json')
- })
-})
diff --git a/packages/devtools/src/simulator/direct-request-status-code.test.ts b/packages/devtools/src/simulator/direct-request-status-code.test.ts
deleted file mode 100644
index 3be5fdf8..00000000
--- a/packages/devtools/src/simulator/direct-request-status-code.test.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * `directRequest` must align with wx.request's HTTP-status-agnostic success
- * contract: any received HTTP response — including 401/500 — resolves via the
- * wired `success` callback with a full `{ data, statusCode, header, errMsg }`
- * object, never the wired `fail` callback. `fail` is reserved for
- * network-layer failures (fetch rejection), and its `errMsg` must never be
- * empty.
- *
- * Today `directRequest` treats any non-2xx response as a failure and calls
- * `fail` with `{ errMsg: '' }` — statusCode and body are dropped entirely,
- * making auth-status branching (401 handling) impossible from userland.
- *
- * Header-merge/dedup for this same handler is pinned separately in
- * direct-request-headers.test.ts (left untouched).
- */
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { directRequest } from './direct-request'
-import type { RequestFailResult, RequestSuccessResult } from '../shared/request-core.js'
-
-// Mirrors the seam used by direct-request-headers.test.ts and
-// run-api-async-request-routing.test.ts: `this.createCallbackFunction` hands
-// back the already-concrete function it was given.
-const ctx = { appId: 'test-app', createCallbackFunction: (fn: unknown) => (typeof fn === 'function' ? fn as (...a: unknown[]) => void : undefined) }
-
-let fetchMock: ReturnType
-
-beforeEach(() => {
- fetchMock = vi.fn(() => Promise.resolve(new Response('{}', { status: 200 })))
- vi.stubGlobal('fetch', fetchMock)
-})
-
-afterEach(() => {
- vi.unstubAllGlobals()
-})
-
-/** Fire `directRequest` and drain the fetch/.then chain before assertions run. */
-async function request(args: Parameters[0]): Promise {
- directRequest.call(ctx, args)
- await new Promise((resolve) => setTimeout(resolve, 0))
- await new Promise((resolve) => setTimeout(resolve, 0))
-}
-
-describe('directRequest — HTTP status never decides success vs fail', () => {
- it('a 401 response invokes success (not fail) with statusCode 401', async () => {
- fetchMock.mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 })))
- const success = vi.fn<(res: RequestSuccessResult) => void>()
- const fail = vi.fn<(err: RequestFailResult) => void>()
-
- await request({ url: 'https://example.com/api', success, fail })
-
- expect(fail).not.toHaveBeenCalled()
- expect(success).toHaveBeenCalledTimes(1)
- expect(success.mock.calls[0][0].statusCode).toBe(401)
- })
-
- it('a 500 response invokes success (not fail) with statusCode 500', async () => {
- fetchMock.mockImplementation(() => Promise.resolve(new Response('Internal Server Error', { status: 500 })))
- const success = vi.fn<(res: RequestSuccessResult) => void>()
- const fail = vi.fn<(err: RequestFailResult) => void>()
-
- await request({ url: 'https://example.com/api', success, fail })
-
- expect(fail).not.toHaveBeenCalled()
- expect(success).toHaveBeenCalledTimes(1)
- expect(success.mock.calls[0][0].statusCode).toBe(500)
- })
-
- it('a network-layer rejection invokes fail (not success) with a non-empty request:fail-prefixed errMsg', async () => {
- fetchMock.mockImplementation(() => Promise.reject(new TypeError('Failed to fetch')))
- const success = vi.fn<(res: RequestSuccessResult) => void>()
- const fail = vi.fn<(err: RequestFailResult) => void>()
-
- await request({ url: 'https://example.com/api', success, fail })
-
- expect(success).not.toHaveBeenCalled()
- expect(fail).toHaveBeenCalledTimes(1)
- const err = fail.mock.calls[0][0]
- expect(typeof err.errMsg).toBe('string')
- expect(err.errMsg.length).toBeGreaterThan(0)
- expect(err.errMsg.startsWith('request:fail')).toBe(true)
- })
-
- it('complete fires once after a 401, receiving the identical object success received', async () => {
- fetchMock.mockImplementation(() => Promise.resolve(new Response('{}', { status: 401 })))
- const success = vi.fn<(res: RequestSuccessResult) => void>()
- const complete = vi.fn<(res: RequestSuccessResult | RequestFailResult) => void>()
-
- await request({ url: 'https://example.com/api', success, complete })
-
- expect(complete).toHaveBeenCalledTimes(1)
- expect(complete).toHaveBeenCalledWith(success.mock.calls[0][0])
- })
-
- it('complete fires once after a network-layer rejection, receiving the identical object fail received', async () => {
- fetchMock.mockImplementation(() => Promise.reject(new TypeError('Failed to fetch')))
- const fail = vi.fn<(err: RequestFailResult) => void>()
- const complete = vi.fn<(res: RequestSuccessResult | RequestFailResult) => void>()
-
- await request({ url: 'https://example.com/api', fail, complete })
-
- expect(complete).toHaveBeenCalledTimes(1)
- expect(complete).toHaveBeenCalledWith(fail.mock.calls[0][0])
- })
-})
diff --git a/packages/devtools/src/simulator/direct-request.ts b/packages/devtools/src/simulator/direct-request.ts
deleted file mode 100644
index ec193d6c..00000000
--- a/packages/devtools/src/simulator/direct-request.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * Simulator-side `request` handler for the Electron environment: adapts the
- * MiniApp callback-id seam (`this.createCallbackFunction`) onto the shared
- * wx.request core. All network semantics — HTTP status never decides
- * success vs fail, timeout/abort mapping, header merging, body encoding —
- * live in shared/request-core.ts; this file must stay a thin adapter so the
- * simulator surface cannot drift from the render-window shim.
- *
- * This function is called with `this` bound to a MiniApp instance
- * (via MiniApp.invokeApi), so `this.createCallbackFunction` is available.
- */
-
-import { performRequest } from '../shared/request-core.js'
-import type { MiniAppContext } from './types'
-
-export function directRequest(
- this: MiniAppContext,
- {
- url,
- data,
- header,
- timeout,
- method,
- dataType,
- responseType,
- success,
- fail,
- complete,
- }: {
- url: string
- data?: unknown
- header?: Record
- timeout?: number
- method?: string
- dataType?: string
- responseType?: string
- success?: unknown
- fail?: unknown
- complete?: unknown
- },
-) {
- const onSuccess = this.createCallbackFunction(success)
- const onFail = this.createCallbackFunction(fail)
- const onComplete = this.createCallbackFunction(complete)
-
- performRequest(
- { url, data, header, timeout, method, dataType, responseType },
- {
- success: (res) => onSuccess?.(res),
- fail: (err) => onFail?.(err),
- complete: (res) => onComplete?.(res),
- },
- )
-}
diff --git a/packages/devtools/src/simulator/run-api-async-request-routing.test.ts b/packages/devtools/src/simulator/run-api-async-request-routing.test.ts
deleted file mode 100644
index ed67a4a5..00000000
--- a/packages/devtools/src/simulator/run-api-async-request-routing.test.ts
+++ /dev/null
@@ -1,238 +0,0 @@
-/**
- * Routing tests for `runApiAsync` + `directRequest` (`wx.request` / `qd.request`).
- *
- * Under native-host, the main process strips `success`/`fail`/`complete` from
- * params BEFORE forwarding the API call into `runApiAsync`. So the handler
- * (`directRequest`) receives only the network-level params (url, method, …).
- * `runApiAsync` re-injects sentinel callbacks and resolves the verdict from
- * whichever sentinel fires first.
- *
- * The bug being pinned: `directRequest` does not return its internal fetch
- * promise — it returns `undefined`. `runApiAsync` therefore sees a sync return
- * of `undefined` and (because the params had no success/fail) immediately emits
- * `{ ok: true, result: undefined }` BEFORE the async fetch settles. On actual
- * network failure the real `onFail` sentinel fires later but `settled` is
- * already true, so it is ignored. The premature `ok: true` verdict is the sole
- * emission — wrong on every axis.
- *
- * Contract the tests encode (wx.request semantics: HTTP status never decides
- * success vs fail — only a network-layer failure does):
- * 1. fetch reject → verdict must be ok:false, NOT ok:true
- * 2. fetch 500 → verdict ok:true carrying statusCode 500 in result
- * 3. fetch 200 → verdict ok:true AND result contains the parsed body
- * 4. (regression guard) sync handler unrelated to request → still settles ok:true
- */
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { runApiAsync, type ApiRunVerdict } from './run-api-async'
-import { directRequest } from './direct-request'
-import { previewImage } from './simulator-api-media'
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-type LooseHandler = (this: any, params?: unknown) => unknown
-
-/** Minimal `this` seen by a handler: just the callback factory the seam patches in. */
-type SentinelCtx = { createCallbackFunction(fn: unknown): ((...args: unknown[]) => void) | undefined }
-
-function makeMiniApp() {
- return {
- appId: 'test-app',
- apiRegistry: {
- request: directRequest as unknown as LooseHandler,
- getThingSync: (() => ({ ok: 1 })) as unknown as LooseHandler,
- },
- }
-}
-
-afterEach(() => {
- vi.unstubAllGlobals()
-})
-
-/**
- * Drain the microtask queue and one macrotask tick so the internal fetch chain
- * (.then(parseResponse).then(onSuccess).catch(onFail).finally(onComplete)) has
- * time to settle before we assert.
- */
-async function drainAsync(): Promise {
- await new Promise((r) => setTimeout(r, 0))
- await new Promise((r) => setTimeout(r, 0))
-}
-
-describe('runApiAsync + directRequest — wx.request routing (stripped params)', () => {
- it('1. fetch rejection routes to fail (ok:false), NOT a premature ok:true', async () => {
- vi.stubGlobal(
- 'fetch',
- vi.fn(() => Promise.reject(new TypeError('Failed to fetch'))),
- )
-
- const miniApp = makeMiniApp()
- const emits: ApiRunVerdict[] = []
-
- // Stripped params — no success / fail / complete keys, mirroring native-host reality.
- await runApiAsync(miniApp, 'request', { url: 'https://api.example.com/x', method: 'GET' }, (v) => {
- emits.push(v)
- })
- await drainAsync()
-
- // There must be NO premature ok:true emission.
- expect(emits.some((v) => v.ok === true)).toBe(false)
- // There MUST be at least one ok:false verdict carrying the network error.
- expect(emits.some((v) => v.ok === false)).toBe(true)
- })
-
- it('2. non-2xx response (HTTP 500) routes to success (ok:true) carrying statusCode 500, never fail', async () => {
- vi.stubGlobal(
- 'fetch',
- vi.fn(() => Promise.resolve(new Response('Internal Server Error', { status: 500 }))),
- )
-
- const miniApp = makeMiniApp()
- const emits: ApiRunVerdict[] = []
-
- await runApiAsync(miniApp, 'request', { url: 'https://api.example.com/x', method: 'GET' }, (v) => {
- emits.push(v)
- })
- await drainAsync()
-
- // wx.request contract: an HTTP response — any status — is a SUCCESS; the
- // service branches on result.statusCode (401 → re-login, 500 → retry).
- expect(emits.some((v) => v.ok === false)).toBe(false)
- const successVerdict = emits.find((v) => v.ok === true)
- expect(successVerdict).toBeDefined()
- expect(successVerdict!.result).toMatchObject({ statusCode: 500 })
- })
-
- it('3. successful 200 JSON response emits ok:true with parsed body in result (not undefined)', async () => {
- vi.stubGlobal(
- 'fetch',
- vi.fn(() =>
- Promise.resolve(
- new Response(JSON.stringify({ a: 1 }), {
- status: 200,
- headers: { 'Content-Type': 'application/json' },
- }),
- ),
- ),
- )
-
- const miniApp = makeMiniApp()
- const emits: ApiRunVerdict[] = []
-
- await runApiAsync(miniApp, 'request', { url: 'https://api.example.com/x', method: 'GET' }, (v) => {
- emits.push(v)
- })
- await drainAsync()
-
- const successVerdict = emits.find((v) => v.ok === true)
-
- // Must emit an ok:true verdict.
- expect(successVerdict).toBeDefined()
- // result must NOT be undefined — it carries the parsed response.
- expect(successVerdict!.result).toBeDefined()
- // Parsed body arrives as result.data; status is result.statusCode.
- expect(successVerdict!.result).toMatchObject({ data: { a: 1 }, statusCode: 200 })
- })
-
- it('4. (regression guard) a truly synchronous callback-less handler still settles ok:true from its return value', async () => {
- const miniApp = makeMiniApp()
- const emits: ApiRunVerdict[] = []
-
- await runApiAsync(miniApp, 'getThingSync', {}, (v) => {
- emits.push(v)
- })
-
- // Sync handler — no fetch involved, so no drainAsync needed.
- expect(emits).toHaveLength(1)
- expect(emits[0]).toMatchObject({ ok: true, result: { ok: 1 } })
- // One-shot sync APIs must NOT be flagged keep.
- expect(emits[0].keep).not.toBe(true)
- })
-
- it('5. a fail-wired handler that throws synchronously after wiring still settles ok:false via catch', async () => {
- // Wires the injected FAIL sentinel (so the seam knows the call is async),
- // then throws synchronously. The seam's try/catch must convert the throw
- // into a single ok:false verdict — the wired fail callback never fires, so
- // this pins that the catch path wins and no premature ok:true leaks.
- const miniApp = {
- appId: 'test-app',
- apiRegistry: {
- boom: (function (this: { createCallbackFunction: (id: unknown) => unknown }, params?: unknown) {
- this.createCallbackFunction((params as { fail?: unknown }).fail)
- throw new Error('boom')
- }) as unknown as LooseHandler,
- },
- }
- const emits: ApiRunVerdict[] = []
-
- await runApiAsync(miniApp, 'boom', {}, (v) => {
- emits.push(v)
- })
-
- expect(emits).toHaveLength(1)
- expect(emits[0].ok).toBe(false)
- expect(emits.some((v) => v.ok === true)).toBe(false)
- expect(String(emits[0].errMsg)).toContain('boom')
- expect(String(emits[0].errMsg)).toContain('boom:fail')
- })
-
- it('6. a success-only async handler waits for its real success verdict (showModal class), not a premature undefined', async () => {
- // Regression pin for the showModal premature-settle bug.
- // showModal wires only the success sentinel (it never fails) and resolves
- // when the user taps the modal — i.e. on a future tick, not synchronously.
- // The corrected discriminator is "wired SUCCESS OR FAIL" ⇒ treat as async
- // and wait for the sentinel; the void return must NOT premature-settle.
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- let deferredSuccess: ((...args: any[]) => void) | undefined
-
- const miniApp = {
- appId: 'test-app',
- apiRegistry: {
- modalLike: (function (this: SentinelCtx, params?: unknown) {
- // Wire success sentinel and store it; do NOT call it synchronously.
- // Simulates waiting for user interaction (e.g. tapping the modal OK button).
- deferredSuccess = this.createCallbackFunction((params as { success?: unknown }).success)
- // Returns void — the real verdict arrives later.
- }) as unknown as LooseHandler,
- },
- }
- const emits: ApiRunVerdict[] = []
-
- await runApiAsync(miniApp, 'modalLike', {}, (v) => {
- emits.push(v)
- })
-
- // Must NOT have premature-settled while waiting for the deferred user action.
- expect(emits).toHaveLength(0)
-
- // Simulate user tapping OK on the modal.
- deferredSuccess!({ confirm: true, errMsg: 'showModal:ok' })
-
- expect(emits).toHaveLength(1)
- expect(emits[0]).toMatchObject({ ok: true, result: { confirm: true, errMsg: 'showModal:ok' } })
- })
-
- it('7. previewImage([]) (complete-only guard path) settles ok:true under stripped params, not a 5s timeout', async () => {
- // Pins that complete-only handlers must settle in production where `complete`
- // is stripped, because the seam always injects the COMPLETE sentinel. With an
- // empty urls list, previewImage([]) fires only onComplete and returns early —
- // no success/fail. The always-injected COMPLETE sentinel routes to
- // finish({ ok: true }), resolving the verdict instead of hanging until the
- // main-side no-handler timeout.
- const miniApp = {
- appId: 'test-app',
- apiRegistry: {
- previewImage: previewImage as unknown as LooseHandler,
- },
- }
- const emits: ApiRunVerdict[] = []
-
- // Pass ONLY { urls: [] } — no success/fail/complete keys, mirroring
- // production (main strips the callbacks before forwarding to runApiAsync).
- await runApiAsync(miniApp, 'previewImage', { urls: [] }, (v) => emits.push(v))
- await new Promise((r) => setTimeout(r, 0))
-
- // The COMPLETE sentinel must have settled the call: exactly one ok:true
- // verdict, not a 'no handler (timeout)' fail and not zero emissions/hang.
- expect(emits).toHaveLength(1)
- expect(emits[0].ok).toBe(true)
- })
-})
diff --git a/packages/devtools/src/simulator/simulator-app.tsx b/packages/devtools/src/simulator/simulator-app.tsx
index b66a9009..c5c7923f 100644
--- a/packages/devtools/src/simulator/simulator-app.tsx
+++ b/packages/devtools/src/simulator/simulator-app.tsx
@@ -3,7 +3,6 @@ import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
// loading. Host bundles are injected on did-finish-load, while DeviceShell is
// lazy and may not have requested its chunk yet.
import './ui-extension-runtime'
-import { directRequest } from './direct-request'
import { simulatorApis } from './simulator-api'
import { registerCustomApis } from './custom-api-boot'
import { resolveCustomApisBridge } from './resolve-custom-apis-bridge'
@@ -17,7 +16,9 @@ import type { SimulatorMiniApp } from './simulator-mini-app'
// the simulator entry bundle stays small; the chunk is fetched lazily on boot.
// (SimulatorMiniApp is dynamically imported inside bootShellSession below.)
const DeviceShell = lazy(() =>
- import('./device-shell/device-shell').then((m) => ({ default: m.DeviceShell })),
+ import('./device-shell/device-shell').then(m => ({
+ default: m.DeviceShell,
+ })),
)
declare global {
@@ -50,12 +51,11 @@ interface ShellSlots {
pending: ShellSession | null
}
-// Register the built-in devtools APIs (request + simulatorApis) on the mini-app
+// Register the built-in devtools APIs (simulatorApis) on the mini-app
// instance. `SimulatorMiniApp` exposes `registerApi(name, handler)`.
function registerBuiltinApis(app: {
registerApi: (name: string, handler: (...args: unknown[]) => unknown) => void
}): void {
- app.registerApi('request', directRequest as (...args: unknown[]) => unknown)
for (const [name, handler] of Object.entries(
simulatorApis as Record unknown>,
)) {
@@ -64,7 +64,10 @@ function registerBuiltinApis(app: {
}
/** Boot options for one session, derived from a simulator-route entry spec. */
-function bootSpecFromEntry(appId: string, entry: PageSpec): {
+function bootSpecFromEntry(
+ appId: string,
+ entry: PageSpec,
+): {
appId: string
scene: number
pagePath: string
@@ -102,7 +105,10 @@ async function bootShellSession(spec: {
* unmounts, so a recompile no longer blanks the device.
*/
export function SimulatorApp() {
- const [slots, setSlots] = useState({ current: null, pending: null })
+ const [slots, setSlots] = useState({
+ current: null,
+ pending: null,
+ })
// Set only by the initial-boot effect below; a failure here means `current`
// never gets committed, so the render must show this instead of the (empty)
// shell list — otherwise the simulator area stays permanently blank.
@@ -185,7 +191,9 @@ export function SimulatorApp() {
pendingTimerRef.current = window.setTimeout(() => {
pendingTimerRef.current = null
if (slotsRef.current.pending !== shell) return
- console.error('[simulator] soft reload timed out waiting for the new page; keeping the previous content')
+ console.error(
+ '[simulator] soft reload timed out waiting for the new page; keeping the previous content',
+ )
shell.miniApp.dispose()
commitSlots({ ...slotsRef.current, pending: null })
}, SOFT_RELOAD_TIMEOUT_MS)
@@ -210,11 +218,15 @@ export function SimulatorApp() {
const offRelaunch = host.onSimulatorEvent(
SIMULATOR_EVENTS.RELAUNCH,
- (payload) => { void beginSoftReload(payload?.url) },
+ payload => {
+ void beginSoftReload(payload?.url)
+ },
)
const offDomReady = host.onSimulatorEvent<{ bridgeId?: string }>(
SIMULATOR_EVENTS.DOM_READY,
- (payload) => { promoteIfReady(payload?.bridgeId) },
+ payload => {
+ promoteIfReady(payload?.bridgeId)
+ },
)
return () => {
offRelaunch()
@@ -270,9 +282,11 @@ export function SimulatorApp() {
// but laid out — visibility (not display): a display:none
// never attaches its guest. Promotion restyles this SAME node in
// place (display:contents hands layout back to the shell root).
- style={role === 'pending'
- ? { position: 'fixed', inset: 0, visibility: 'hidden' }
- : { display: 'contents' }}
+ style={
+ role === 'pending'
+ ? { position: 'fixed', inset: 0, visibility: 'hidden' }
+ : { display: 'contents' }
+ }
>
void): () => void
+ onNativeWebSocketTrace?(
+ listener: (ownerId: string, event: NativeWebSocketTrace) => void,
+ ): () => void
+ /** Subscribe to the native HTTP request transport's trace stream (sent /
+ * response / finished / failed per request, keyed by owner appSessionId).
+ * Pure observation — subscribing never alters request forwarding. Each
+ * `sent` requestId is guaranteed exactly one terminal event (`finished`
+ * XOR `failed`). Optional so partial test mocks of the handle need not
+ * stub it. */
+ onNativeRequestTrace?(listener: (ownerId: string, event: NativeRequestTrace) => void): () => void
/** The currently-selected device (renderer toolbar), or null pre-selection. */
getDevice(): NativeDeviceInfo | null
/** Cache the selected device, push DEVICE_CHANGE to the live simulator
@@ -703,13 +745,18 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
nativeWebSocket: createNativeWebSocketService({
idleTimeoutMs: socketIdleTimeoutMsFromEnv(),
}),
- evictAppDataBridges: (ap) => {
+ nativeRequest: createNativeRequestService(),
+ evictAppDataBridges: ap => {
for (const page of ap.pages.values()) {
- ctx.events.emit('app-data-evict', { appId: ap.appId, bridgeId: page.bridgeId })
+ ctx.events.emit('app-data-evict', {
+ appId: ap.appId,
+ bridgeId: page.bridgeId,
+ })
}
},
}
ctx.registry.add(() => state.nativeWebSocket.dispose())
+ ctx.registry.add(() => state.nativeRequest.dispose())
// Opt-in (default OFF) pre-warm pool for service-host windows. When enabled,
// handleSpawn acquires a warm window instead of constructing one per spawn.
@@ -729,7 +776,7 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
defaultSpec: serviceHostSpec(undefined, undefined, runtimeAssets),
maxPoolSize: PREWARM_MAX_POOL_SIZE,
})
- .catch((error) => {
+ .catch(error => {
console.warn('[bridge-router] webview pool warm-up failed:', error)
})
}, 500)
@@ -777,17 +824,21 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
}
event.returnValue = reply
}
- ctx.registry.add(addMuxedSyncListener(C.NATIVE_HOST_ENABLED, {
- claims: ownsSender,
- handle: onNativeHostQuery,
- }))
+ ctx.registry.add(
+ addMuxedSyncListener(C.NATIVE_HOST_ENABLED, {
+ claims: ownsSender,
+ handle: onNativeHostQuery,
+ }),
+ )
// Subscribers to render-side activity (domReady / active-page). Panels that
// pull from the active render guest (WXML) re-read on these.
const renderEventListeners = new Set<(event: RenderEvent) => void>()
const emitRenderEvent = (event: RenderEvent): void => {
for (const listener of renderEventListeners) {
- try { listener(event) } catch (error) {
+ try {
+ listener(event)
+ } catch (error) {
console.warn('[bridge-router] render-event listener threw:', error)
}
}
@@ -801,7 +852,9 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
const emitServiceHostReady = (event: ServiceHostReadyEvent): void => {
state.lastServiceHostReady = event
for (const listener of serviceHostReadyListeners) {
- try { listener(event) } catch (error) {
+ try {
+ listener(event)
+ } catch (error) {
console.warn('[bridge-router] service-host-ready listener threw:', error)
}
}
@@ -812,60 +865,82 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
// The service exposes a SINGLE tracer; the router fans out from here, so the
// service itself never knows how many observers exist. Pure subscription:
// no forwarding path awaits or reorders on this channel.
- const nativeWebSocketTraceListeners = new Set<(ownerId: string, event: NativeWebSocketTrace) => void>()
+ const nativeWebSocketTraceListeners = new Set<
+ (ownerId: string, event: NativeWebSocketTrace) => void
+ >()
state.nativeWebSocket.setTracer((ownerId, event) => {
for (const listener of nativeWebSocketTraceListeners) {
- try { listener(ownerId, event) } catch (error) {
+ try {
+ listener(ownerId, event)
+ } catch (error) {
console.warn('[bridge-router] websocket-trace listener threw:', error)
}
}
})
ctx.registry.add(() => nativeWebSocketTraceListeners.clear())
+ // Subscribers to the native HTTP request trace stream (devtools Network
+ // panel) — same fan-out shape as the WebSocket trace stream above, for the
+ // same reason: wx.request now runs on Node http/https in this process, so
+ // no webContents.debugger can observe it either.
+ const nativeRequestTraceListeners = new Set<
+ (ownerId: string, event: NativeRequestTrace) => void
+ >()
+ state.nativeRequest.setTracer((ownerId, event) => {
+ for (const listener of nativeRequestTraceListeners) {
+ try {
+ listener(ownerId, event)
+ } catch (error) {
+ console.warn('[bridge-router] request-trace listener threw:', error)
+ }
+ }
+ })
+ ctx.registry.add(() => nativeRequestTraceListeners.clear())
+
// Expose a thin accessor over RouterState so other main services (storage,
// automation, appdata) can resolve live render/service WebContents without
// owning router state. Getters resolve fresh — the pre-warm pool can swap
// windows on respawn, so cached handles go stale.
const bridgeHandle: BridgeRouterHandle = {
isNativeHost: () => true,
- resolveRenderWc: (bridgeId) => {
+ resolveRenderWc: bridgeId => {
const page = state.pageSessions.get(bridgeId)
return page?.renderWc && !page.renderWc.isDestroyed() ? page.renderWc : null
},
- getServiceWc: (appId) => {
+ getServiceWc: appId => {
const ap = resolveCurrentApp(state, ctx, appId)
return ap && !ap.serviceWc.isDestroyed() ? ap.serviceWc : null
},
- getServiceWcForBridge: (bridgeId) => {
+ getServiceWcForBridge: bridgeId => {
const page = state.pageSessions.get(bridgeId)
if (!page) return null
const ap = state.appSessions.get(page.appSessionId)
return ap && !ap.serviceWc.isDestroyed() ? ap.serviceWc : null
},
- getActiveBridgeId: (appId) => {
+ getActiveBridgeId: appId => {
const ap = resolveCurrentApp(state, ctx, appId)
return ap ? resolveActiveBridgeId(ap) : null
},
- getPageStack: (appId) => {
+ getPageStack: appId => {
const ap = resolveCurrentApp(state, ctx, appId)
return ap?.pageStack ?? null
},
- getResourceBaseUrl: (appId) => {
+ getResourceBaseUrl: appId => {
const ap = resolveCurrentApp(state, ctx, appId)
return ap?.resourceBaseUrl ?? null
},
- getActiveRenderWc: (appId) => {
+ getActiveRenderWc: appId => {
const ap = resolveCurrentApp(state, ctx, appId)
if (!ap) return null
const bridgeId = resolveActiveBridgeId(ap)
const page = bridgeId ? state.pageSessions.get(bridgeId) : undefined
return page?.renderWc && !page.renderWc.isDestroyed() ? page.renderWc : null
},
- onRenderEvent: (listener) => {
+ onRenderEvent: listener => {
renderEventListeners.add(listener)
return () => renderEventListeners.delete(listener)
},
- onServiceHostReady: (listener) => {
+ onServiceHostReady: listener => {
serviceHostReadyListeners.add(listener)
// Missed-signal catch-up (mirrors host-toolbar-port-channel.ts's
// `onReady`): a subscriber registering AFTER the session it cares
@@ -882,19 +957,25 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
if (!serviceHostReadyListeners.has(listener)) return
const ap = state.appSessions.get(candidate.appSessionId)
if (!ap || ap.serviceWc.isDestroyed() || ap.serviceWc.id !== candidate.serviceWcId) return
- try { listener(candidate) } catch (error) {
+ try {
+ listener(candidate)
+ } catch (error) {
console.warn('[bridge-router] service-host-ready catch-up listener threw:', error)
}
})
}
return () => serviceHostReadyListeners.delete(listener)
},
- onNativeWebSocketTrace: (listener) => {
+ onNativeWebSocketTrace: listener => {
nativeWebSocketTraceListeners.add(listener)
return () => nativeWebSocketTraceListeners.delete(listener)
},
+ onNativeRequestTrace: listener => {
+ nativeRequestTraceListeners.add(listener)
+ return () => nativeRequestTraceListeners.delete(listener)
+ },
getDevice: () => currentDevice,
- setDevice: (device) => {
+ setDevice: device => {
currentDevice = device
// Push to the live simulator WC(s) so a mounted DeviceShell re-renders the
// bezel/status-bar/notch. Pre-spawn there is no session yet — the initial
@@ -918,7 +999,7 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
}
}
},
- disposeSessionsForSimulator: (simulatorWcId) => {
+ disposeSessionsForSimulator: simulatorWcId => {
// Snapshot ids first: disposeAppSession mutates state.appSessions.
const ids: string[] = []
for (const [id, ap] of state.appSessions) {
@@ -931,7 +1012,7 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
// after full teardown. disposeAppSession logs those tail failures
// internally, so this resolves rather than rejecting on them — it is a
// completion signal, not an error channel.
- return Promise.all(ids.map((id) => disposeAppSession(state, id))).then(() => {})
+ return Promise.all(ids.map(id => disposeAppSession(state, id))).then(() => {})
},
debugTap: state.debugTap,
census: (): BridgeResourceCensus => {
@@ -1032,7 +1113,9 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
}
}
ipcMain.on(C.ACTIVE_PAGE, onActivePage)
- ctx.registry.add(() => { ipcMain.removeListener(C.ACTIVE_PAGE, onActivePage) })
+ ctx.registry.add(() => {
+ ipcMain.removeListener(C.ACTIVE_PAGE, onActivePage)
+ })
// DeviceShell → main: the full ordered page stack (bottom→top). Stored so
// automation's App.getPageStack can report a multi-page stack (main has no
@@ -1044,23 +1127,33 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
ap.pageStack = payload.stack
}
ipcMain.on(C.PAGE_STACK, onPageStack)
- ctx.registry.add(() => { ipcMain.removeListener(C.PAGE_STACK, onPageStack) })
+ ctx.registry.add(() => {
+ ipcMain.removeListener(C.PAGE_STACK, onPageStack)
+ })
- ctx.registry.add(addMuxedInvokeHandler(C.SPAWN, {
- claims: ownsSender,
- handle: (event, opts): Promise => handleSpawn(state, ctx, event, opts as SpawnRequest),
- }))
+ ctx.registry.add(
+ addMuxedInvokeHandler(C.SPAWN, {
+ claims: ownsSender,
+ handle: (event, opts): Promise =>
+ handleSpawn(state, ctx, event, opts as SpawnRequest),
+ }),
+ )
- ctx.registry.add(addMuxedInvokeHandler(C.PAGE_OPEN, {
- claims: ownsSender,
- handle: (event, opts): Promise => handlePageOpen(state, event, opts as PageOpenRequest),
- }))
+ ctx.registry.add(
+ addMuxedInvokeHandler(C.PAGE_OPEN, {
+ claims: ownsSender,
+ handle: (event, opts): Promise =>
+ handlePageOpen(state, event, opts as PageOpenRequest),
+ }),
+ )
const onPageClose = (event: IpcMainEvent, payload: PageClosePayload): void => {
handlePageClose(state, event.sender, payload)
}
ipcMain.on(C.PAGE_CLOSE, onPageClose)
- ctx.registry.add(() => { ipcMain.removeListener(C.PAGE_CLOSE, onPageClose) })
+ ctx.registry.add(() => {
+ ipcMain.removeListener(C.PAGE_CLOSE, onPageClose)
+ })
const onPageLifecycle = (event: IpcMainEvent, payload: PageLifecyclePayload): void => {
handlePageLifecycle(state, event.sender, payload)
@@ -1069,18 +1162,25 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
if (payload.event === 'pageUnload') {
const ap = state.appSessions.get(payload.appSessionId)
if (ap && senderBoundToSession(state, event.sender, ap)) {
- ctx.events.emit('app-data-evict', { appId: ap.appId, bridgeId: payload.bridgeId })
+ ctx.events.emit('app-data-evict', {
+ appId: ap.appId,
+ bridgeId: payload.bridgeId,
+ })
}
}
}
ipcMain.on(C.PAGE_LIFECYCLE, onPageLifecycle)
- ctx.registry.add(() => { ipcMain.removeListener(C.PAGE_LIFECYCLE, onPageLifecycle) })
+ ctx.registry.add(() => {
+ ipcMain.removeListener(C.PAGE_LIFECYCLE, onPageLifecycle)
+ })
const onNavCallback = (event: IpcMainEvent, payload: NavCallbackPayload): void => {
handleNavCallback(state, event.sender, payload)
}
ipcMain.on(C.NAV_CALLBACK, onNavCallback)
- ctx.registry.add(() => { ipcMain.removeListener(C.NAV_CALLBACK, onNavCallback) })
+ ctx.registry.add(() => {
+ ipcMain.removeListener(C.NAV_CALLBACK, onNavCallback)
+ })
const onDispose = (event: IpcMainEvent, payload: DisposePayload): void => {
const target = resolveAppByBridgeId(state, payload.bridgeId)
@@ -1090,7 +1190,9 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
// for every session it hosts, so an older session's own dispose stays
// valid while a newer spawn shares the wc.)
if (!senderBoundToSession(state, event.sender, target) && appByWc(state, event.sender)) {
- console.warn(`[bridge-router] DISPOSE rejected: sender not bound to target ${target.appSessionId}`)
+ console.warn(
+ `[bridge-router] DISPOSE rejected: sender not bound to target ${target.appSessionId}`,
+ )
return
}
// AppData bridge eviction happens inside disposeAppSession (single
@@ -1098,7 +1200,9 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
void disposeAppSession(state, target.appSessionId)
}
ipcMain.on(C.DISPOSE, onDispose)
- ctx.registry.add(() => { ipcMain.removeListener(C.DISPOSE, onDispose) })
+ ctx.registry.add(() => {
+ ipcMain.removeListener(C.DISPOSE, onDispose)
+ })
// debugTap (see foundation.md) ingress recorder — near-free no-op unless DIMINA_DEBUG_TAP=1.
// Hung on the bridge dispatch chokepoint so the cross-wc message flow is
@@ -1131,7 +1235,9 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
routeFromService(state, ap, page, payload.msg, ctx)
}
ipcMain.on(C.SERVICE_INVOKE, onServiceInvoke)
- ctx.registry.add(() => { ipcMain.removeListener(C.SERVICE_INVOKE, onServiceInvoke) })
+ ctx.registry.add(() => {
+ ipcMain.removeListener(C.SERVICE_INVOKE, onServiceInvoke)
+ })
const onServicePublish = (event: IpcMainEvent, payload: ServicePublishPayload): void => {
tapIn(C.SERVICE_PUBLISH, event.sender, payload)
@@ -1141,10 +1247,15 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
// Native-host AppData panel: tap the service→render setData stream centrally
// (the simulator guest has no Worker to sniff under native-host). Cheap —
// the tap ignores non-ub/non-page_* messages.
- ctx.events.emit('app-data-message', { appId: ap.appId, message: payload.msg })
+ ctx.events.emit('app-data-message', {
+ appId: ap.appId,
+ message: payload.msg,
+ })
}
ipcMain.on(C.SERVICE_PUBLISH, onServicePublish)
- ctx.registry.add(() => { ipcMain.removeListener(C.SERVICE_PUBLISH, onServicePublish) })
+ ctx.registry.add(() => {
+ ipcMain.removeListener(C.SERVICE_PUBLISH, onServicePublish)
+ })
const onRenderInvoke = (event: IpcMainEvent, payload: RenderInvokePayload): void => {
tapIn(C.RENDER_INVOKE, event.sender, payload)
@@ -1155,7 +1266,9 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
routeFromRender(state, ap, page, payload.msg, ctx)
}
ipcMain.on(C.RENDER_INVOKE, onRenderInvoke)
- ctx.registry.add(() => { ipcMain.removeListener(C.RENDER_INVOKE, onRenderInvoke) })
+ ctx.registry.add(() => {
+ ipcMain.removeListener(C.RENDER_INVOKE, onRenderInvoke)
+ })
const onRenderPublish = (event: IpcMainEvent, payload: RenderPublishPayload): void => {
tapIn(C.RENDER_PUBLISH, event.sender, payload)
@@ -1166,22 +1279,28 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
forwardToService(ap, payload.msg)
}
ipcMain.on(C.RENDER_PUBLISH, onRenderPublish)
- ctx.registry.add(() => { ipcMain.removeListener(C.RENDER_PUBLISH, onRenderPublish) })
+ ctx.registry.add(() => {
+ ipcMain.removeListener(C.RENDER_PUBLISH, onRenderPublish)
+ })
- ctx.registry.add(addMuxedInvokeHandler(C.SIMULATOR_API, {
- claims: ownsSender,
- handle: (_event, payload) => {
- const call = payload as { name: string; params: unknown }
- return ctx.simulatorApis.invoke(call.name, call.params)
- },
- }))
+ ctx.registry.add(
+ addMuxedInvokeHandler(C.SIMULATOR_API, {
+ claims: ownsSender,
+ handle: (_event, payload) => {
+ const call = payload as { name: string; params: unknown }
+ return ctx.simulatorApis.invoke(call.name, call.params)
+ },
+ }),
+ )
const onApiResponse = (event: IpcMainEvent, payload: ApiResponsePayload): void => {
tapIn(C.API_RESPONSE, event.sender, payload)
handleApiResponse(state, event.sender, payload)
}
ipcMain.on(C.API_RESPONSE, onApiResponse)
- ctx.registry.add(() => { ipcMain.removeListener(C.API_RESPONSE, onApiResponse) })
+ ctx.registry.add(() => {
+ ipcMain.removeListener(C.API_RESPONSE, onApiResponse)
+ })
ctx.registry.add(async () => {
// Clear any in-flight pending API timers before tearing down sessions so a
@@ -1191,9 +1310,46 @@ export function installBridgeRouter(ctx: RuntimeContext): void {
})
ctx.registry.add(async () => {
- await Promise.all(
- Array.from(state.appSessions.keys()).map(id => disposeAppSession(state, id)),
- )
+ await Promise.all(Array.from(state.appSessions.keys()).map(id => disposeAppSession(state, id)))
+ })
+
+ // Preload-rendered windows (e.g. Web Workspace preview) run wx.request
+ // directly in the renderer context, not through a service-host. Route them
+ // through the same native-request service so they also avoid Chromium
+ // Fetch/CORS. Every workbench window installs its own router, so — like
+ // SPAWN/PAGE_OPEN/SIMULATOR_API above — the invoke channel is muxed
+ // (bridge-router-ipc-mux.ts) and dispatched to the router that owns the
+ // calling webContents.
+ //
+ // Cleanup: the owner key is this router's own `preload:` — disjoint
+ // from every other router's, since webContents ids are process-wide unique
+ // — so aborting on destroy only ever touches requests THIS router started.
+ // Registered lazily on first use (once per wc) instead of a separate
+ // "attach" channel, since a `once('destroyed', …)` costs nothing to arm
+ // speculatively but a dedicated handshake message would need its own
+ // ordering guarantee against the first NATIVE_REQUEST call.
+ const preloadOwners = createPreloadRequestOwners(state.nativeRequest)
+ ctx.registry.add(() => preloadOwners.dispose())
+ ctx.registry.add(
+ addMuxedInvokeHandler(C.NATIVE_REQUEST, {
+ claims: ownsSender,
+ handle: (event, ...args): Promise => {
+ const requestId = String(args[0] ?? '')
+ const params = (args[1] ?? {}) as Record
+ preloadOwners.ensure(event.sender)
+ const options = nativeRequestOptions(params, event.sender, event.senderFrame?.url)
+ return state.nativeRequest.request(`preload:${event.sender.id}`, requestId, options)
+ },
+ }),
+ )
+
+ const onNativeRequestAbort = (event: IpcMainEvent, requestId: string): void => {
+ if (!ownsSender(event)) return
+ state.nativeRequest.abort(`preload:${event.sender.id}`, requestId)
+ }
+ ipcMain.on(C.NATIVE_REQUEST_ABORT, onNativeRequestAbort)
+ ctx.registry.add(() => {
+ ipcMain.removeListener(C.NATIVE_REQUEST_ABORT, onNativeRequestAbort)
})
}
@@ -1225,7 +1381,11 @@ function startLaunchTimer(state: RouterState, ctx: RuntimeContext, ap: AppSessio
message: reason,
appSessionId: ap.appSessionId,
})
- pushRuntimeStatus(ctx, ap, { phase: 'launch-failed', code: 'timeout', reason })
+ pushRuntimeStatus(ctx, ap, {
+ phase: 'launch-failed',
+ code: 'timeout',
+ reason,
+ })
}, LAUNCH_TIMEOUT_MS)
}
@@ -1252,7 +1412,11 @@ function markSessionRunning(ctx: RuntimeContext, ap: AppSession, page: PageSessi
function pushRuntimeStatus(
ctx: RuntimeContext,
session: Pick,
- status: { phase: 'launching' | 'running' | 'launch-failed' | 'crashed'; code?: string; reason?: string },
+ status: {
+ phase: 'launching' | 'running' | 'launch-failed' | 'crashed'
+ code?: string
+ reason?: string
+ },
): void {
ctx.events.emit('session-status', {
appId: session.appId,
@@ -1275,9 +1439,8 @@ async function handleSpawn(
const simulatorWc = resolveSimulatorWebContents(ctx, opts.simulatorWcId, event.sender)
const pagePath = normalizePagePath(opts.pagePath || 'pages/index/index')
- const workspaceProjectPath = typeof ctx.workspace.getProjectPath === 'function'
- ? ctx.workspace.getProjectPath()
- : ''
+ const workspaceProjectPath =
+ typeof ctx.workspace.getProjectPath === 'function' ? ctx.workspace.getProjectPath() : ''
const pkgRoot = path.resolve(opts.pkgRoot || workspaceProjectPath || process.cwd())
const root = opts.root || 'main'
// Host-config custom API namespaces (RuntimeContext is the single owner).
@@ -1299,7 +1462,9 @@ async function handleSpawn(
let resourceServer: DiminaResourceServer | null = null
let resourceBaseUrl: string
if (opts.resourceBaseUrl) {
- resourceBaseUrl = opts.resourceBaseUrl.endsWith('/') ? opts.resourceBaseUrl : `${opts.resourceBaseUrl}/`
+ resourceBaseUrl = opts.resourceBaseUrl.endsWith('/')
+ ? opts.resourceBaseUrl
+ : `${opts.resourceBaseUrl}/`
} else {
resourceServer = await startDiminaResourceServer(path.resolve(pkgRoot, root))
resourceBaseUrl = resourceServer.baseUrl
@@ -1359,14 +1524,21 @@ async function handleSpawn(
if (ap) clearLaunchTimer(ap)
pushRuntimeStatus(
ctx,
- ap ?? { appId, pageFallback: pageFallbackApplied ? { requested: pagePath, resolved: resolvedPagePath } : null },
- { phase: 'launch-failed', code: 'service-host-navigation-failed', reason: message },
+ ap ?? {
+ appId,
+ pageFallback: pageFallbackApplied
+ ? { requested: pagePath, resolved: resolvedPagePath }
+ : null,
+ },
+ {
+ phase: 'launch-failed',
+ code: 'service-host-navigation-failed',
+ reason: message,
+ },
)
}
if (state.pool) {
- const acquired = await state.pool.acquire(
- serviceHostSpec(undefined, undefined, runtimeAssets),
- )
+ const acquired = await state.pool.acquire(serviceHostSpec(undefined, undefined, runtimeAssets))
serviceWindow = acquired.win
poolEntryId = acquired.entryId
hostEnv = resolveHostEnv()
@@ -1392,7 +1564,8 @@ async function handleSpawn(
}
serviceWindow = createServiceHostWindow({
...freshWindowOptions,
- onLoadFailed: err => reportServiceHostNavigationFailed(buildServiceHostSpawnUrl(freshWindowOptions), err),
+ onLoadFailed: err =>
+ reportServiceHostNavigationFailed(buildServiceHostSpawnUrl(freshWindowOptions), err),
})
}
@@ -1560,7 +1733,10 @@ async function handleSpawn(
message: `Service host renderer process gone for appSessionId=${appSessionId}`,
appSessionId,
})
- pushRuntimeStatus(ctx, appSession, { phase: 'crashed', code: 'service-host-crashed' })
+ pushRuntimeStatus(ctx, appSession, {
+ phase: 'crashed',
+ code: 'service-host-crashed',
+ })
}
appSession.listenerBag.on(serviceWindow.webContents, 'render-process-gone', onServiceCrashed)
@@ -1601,7 +1777,9 @@ async function handlePageOpen(
// Only enforced against a real compiled manifest ('app-config') — a
// 'fallback' manifest has no compiled truth to validate membership against.
if (ap.manifest.source === 'app-config' && !ap.manifest.pages.includes(pagePath)) {
- throw new Error(`[bridge-router] PAGE_OPEN rejected: page-not-found "${pagePath}" is not in the compiled manifest`)
+ throw new Error(
+ `[bridge-router] PAGE_OPEN rejected: page-not-found "${pagePath}" is not in the compiled manifest`,
+ )
}
const bridgeId = opts.bridgeId || newBridgeId()
const windowConfig = resolvePageWindowConfig(ap.appConfig, pagePath)
@@ -1639,7 +1817,7 @@ function handlePageClose(state: RouterState, sender: WebContents, payload: PageC
// deep-linked launch. It stays uncloseable only while it is the session's
// sole page: emptying a session of pages is DISPOSE's job, not PAGE_CLOSE's.
if (page.isRoot && ap.pages.size <= 1) {
- console.warn('[bridge-router] PAGE_CLOSE refused on the session\'s only page; use DISPOSE')
+ console.warn("[bridge-router] PAGE_CLOSE refused on the session's only page; use DISPOSE")
return
}
if (!senderBoundToSession(state, sender, ap)) {
@@ -1649,7 +1827,11 @@ function handlePageClose(state: RouterState, sender: WebContents, payload: PageC
disposePageSession(state, ap, page)
}
-function handlePageLifecycle(state: RouterState, sender: WebContents, payload: PageLifecyclePayload): void {
+function handlePageLifecycle(
+ state: RouterState,
+ sender: WebContents,
+ payload: PageLifecyclePayload,
+): void {
const ap = state.appSessions.get(payload.appSessionId)
if (!ap) return
if (!senderBoundToSession(state, sender, ap)) return
@@ -1660,8 +1842,8 @@ function handlePageLifecycle(state: RouterState, sender: WebContents, payload: P
if (payload.event === 'pageShow') {
ap.visibleBridgeId = payload.bridgeId
} else if (
- (payload.event === 'pageHide' || payload.event === 'pageUnload')
- && ap.visibleBridgeId === payload.bridgeId
+ (payload.event === 'pageHide' || payload.event === 'pageUnload') &&
+ ap.visibleBridgeId === payload.bridgeId
) {
ap.visibleBridgeId = null
}
@@ -1673,7 +1855,11 @@ function handlePageLifecycle(state: RouterState, sender: WebContents, payload: P
})
}
-function handleNavCallback(state: RouterState, sender: WebContents, payload: NavCallbackPayload): void {
+function handleNavCallback(
+ state: RouterState,
+ sender: WebContents,
+ payload: NavCallbackPayload,
+): void {
const ap = state.appSessions.get(payload.appSessionId)
if (!ap) return
if (!senderBoundToSession(state, sender, ap)) return
@@ -1689,7 +1875,11 @@ function handleNavCallback(state: RouterState, sender: WebContents, payload: Nav
// ── Service-host boot & per-page resource handshake ──────────────────────────
-async function bootServiceHost(state: RouterState, ap: AppSession, ctx: RuntimeContext): Promise {
+async function bootServiceHost(
+ state: RouterState,
+ ap: AppSession,
+ ctx: RuntimeContext,
+): Promise {
// Liveness guard: never boot a session that was already disposed. With pooling,
// the service window is recycled, so a stale did-finish-load listener from an
// early-disposed prior owner could otherwise fire here and inject the wrong
@@ -1715,7 +1905,11 @@ async function bootServiceHost(state: RouterState, ap: AppSession, ctx: RuntimeC
// `ServiceHostReadyEvent`'s doc comment) — other main-process consumers
// (the right-panel DevTools attach) need this exact signal too and must
// not poll `getServiceWc` on a fixed retry budget for it.
- state.emitServiceHostReady({ appId: ap.appId, appSessionId: ap.appSessionId, serviceWcId: ap.serviceWc.id })
+ state.emitServiceHostReady({
+ appId: ap.appId,
+ appSessionId: ap.appSessionId,
+ serviceWcId: ap.serviceWc.id,
+ })
ap.logicInjected = await injectLogicBundle(ap)
if (!ap.logicInjected) {
// The compiled logic.js never executed, so `modDefine` registered nothing.
@@ -1726,7 +1920,11 @@ async function bootServiceHost(state: RouterState, ap: AppSession, ctx: RuntimeC
// gates on the same flag in `routeFromRender`.
const reason = reportLogicLoadFailure(ap, ctx)
clearLaunchTimer(ap)
- pushRuntimeStatus(ctx, ap, { phase: 'launch-failed', code: 'logic-bundle-unreachable', reason })
+ pushRuntimeStatus(ctx, ap, {
+ phase: 'launch-failed',
+ code: 'logic-bundle-unreachable',
+ reason,
+ })
return
}
// A root page absent from the compiled manifest — most commonly a page the
@@ -1798,7 +1996,9 @@ function sendRenderLoadResource(ap: AppSession, page: PageSession): void {
// found`, blanking the simulator (a page the developer deleted, then
// hot-reloaded to). bootServiceHost surfaces the one-shot diagnostic.
if (!pageInManifest(ap, page.pagePath)) return
- page.renderWc.send(C.TO_RENDER, { msg: makeLoadResource(ap, page, 'render') })
+ page.renderWc.send(C.TO_RENDER, {
+ msg: makeLoadResource(ap, page, 'render'),
+ })
page.renderLoadSent = true
}
@@ -1845,22 +2045,27 @@ async function injectLogicBundle(ap: AppSession): Promise {
* duplicating the wording.
*/
function reportLogicLoadFailure(ap: AppSession, ctx: RuntimeContext): string {
- const hint = ap.appId === 'unknown'
- ? ' appId could not be resolved (it fell back to "unknown") — the mini-program likely failed to compile or its project manifest/app config is missing.'
- : ''
+ const hint =
+ ap.appId === 'unknown'
+ ? ' appId could not be resolved (it fell back to "unknown") — the mini-program likely failed to compile or its project manifest/app config is missing.'
+ : ''
const shortReason = `[dimina-kit] Failed to load the mini-program logic bundle from ${logicBundleUrl(ap)}.`
- const message
- = `${shortReason} `
- + 'The service runtime has no registered modules, so no page can mount.'
- + hint
- + ` Verify the project compiled successfully and that the resource server serves "${ap.appId}/${ap.root}/".`
+ const message =
+ `${shortReason} ` +
+ 'The service runtime has no registered modules, so no page can mount.' +
+ hint +
+ ` Verify the project compiled successfully and that the resource server serves "${ap.appId}/${ap.root}/".`
ctx.diagnostics?.report({
severity: 'error',
code: 'logic-bundle-unreachable',
message,
appSessionId: ap.appSessionId,
})
- ctx.guestConsole?.emit({ source: 'service', level: 'error', args: [message] })
+ ctx.guestConsole?.emit({
+ source: 'service',
+ level: 'error',
+ args: [message],
+ })
return shortReason
}
@@ -1889,9 +2094,9 @@ function reportPageNotFound(
pagePath: string,
fallbackTo?: string,
): void {
- const base
- = `Page[${pagePath}] not found. May be caused by: 1. Forgetting to add page route in app.json. `
- + '2. Invoking Page() in async task.'
+ const base =
+ `Page[${pagePath}] not found. May be caused by: 1. Forgetting to add page route in app.json. ` +
+ '2. Invoking Page() in async task.'
const message = fallbackTo ? `${base} Falling back to "${fallbackTo}".` : base
ctx.diagnostics?.report({
severity: 'error',
@@ -1899,7 +2104,11 @@ function reportPageNotFound(
message,
appSessionId,
})
- ctx.guestConsole?.emit({ source: 'service', level: 'error', args: [message] })
+ ctx.guestConsole?.emit({
+ source: 'service',
+ level: 'error',
+ args: [message],
+ })
}
function maybeSendResourceLoaded(ctx: RuntimeContext, ap: AppSession, page: PageSession): void {
@@ -2013,10 +2222,21 @@ function routeFromRender(
* (they never call `console.*`), so this diagnostics report is the only way
* one reaches the Console panel / main log.
*/
-function reportServiceUncaughtError(ctx: RuntimeContext, ap: AppSession, body: GuestConsoleEntry): void {
+function reportServiceUncaughtError(
+ ctx: RuntimeContext,
+ ap: AppSession,
+ body: GuestConsoleEntry,
+): void {
const severity = body.level === 'error' ? 'error' : body.level === 'warn' ? 'warn' : 'info'
- const message = Array.isArray(body.args) ? body.args.map(a => String(a)).join(' ') : String(body.args ?? '')
- ctx.diagnostics?.report({ severity, code: 'service-uncaught-error', message, appSessionId: ap.appSessionId })
+ const message = Array.isArray(body.args)
+ ? body.args.map(a => String(a)).join(' ')
+ : String(body.args ?? '')
+ ctx.diagnostics?.report({
+ severity,
+ code: 'service-uncaught-error',
+ message,
+ appSessionId: ap.appSessionId,
+ })
}
function handleContainerMsg(
@@ -2042,7 +2262,11 @@ function handleContainerMsg(
if (!ap.simulatorWc.isDestroyed()) {
ap.simulatorWc.send(E.DOM_READY, { bridgeId: page.bridgeId })
}
- state.emitRenderEvent({ kind: 'domReady', appId: ap.appId, bridgeId: page.bridgeId })
+ state.emitRenderEvent({
+ kind: 'domReady',
+ appId: ap.appId,
+ bridgeId: page.bridgeId,
+ })
markSessionRunning(ctx, ap, page)
break
case 'invokeAPI':
@@ -2096,7 +2320,11 @@ function handleContainerMsg(
// the active page's DOM mutated in place (setData). Surface it as a render
// event so the WXML panel service re-pulls + pushes — same pipeline as
// domReady/activePage. Trust `page.bridgeId` (sender-resolved), not the body.
- state.emitRenderEvent({ kind: 'domMutated', appId: ap.appId, bridgeId: page.bridgeId })
+ state.emitRenderEvent({
+ kind: 'domMutated',
+ appId: ap.appId,
+ bridgeId: page.bridgeId,
+ })
break
default:
break
@@ -2189,7 +2417,12 @@ const APP_LIFECYCLE_UNREGISTER: Record = {
offError: 'onError',
}
-function handleNavBarApi(ap: AppSession, page: PageSession, name: string, params: Record): void {
+function handleNavBarApi(
+ ap: AppSession,
+ page: PageSession,
+ name: string,
+ params: Record,
+): void {
if (!ap.simulatorWc.isDestroyed()) {
ap.simulatorWc.send(E.NAV_BAR, {
bridgeId: page.bridgeId,
@@ -2251,11 +2484,21 @@ interface NavTargetVerdict {
function checkNavTarget(ap: AppSession, name: string, targetPagePath: string): NavTargetVerdict {
if (ap.manifest.source !== 'app-config') return { ok: true }
if (!ap.manifest.pages.includes(targetPagePath)) {
- return { ok: false, errMsg: `${name}:fail page "${targetPagePath}" is not found`, reportNotFound: true }
+ return {
+ ok: false,
+ errMsg: `${name}:fail page "${targetPagePath}" is not found`,
+ reportNotFound: true,
+ }
}
if (name === 'switchTab') {
- const inTabBar = ap.manifest.tabBar?.list.some(item => normalizePagePath(item.pagePath) === targetPagePath) ?? false
- if (!inTabBar) return { ok: false, errMsg: 'switchTab:fail can not switch to no-tabBar page' }
+ const inTabBar =
+ ap.manifest.tabBar?.list.some(item => normalizePagePath(item.pagePath) === targetPagePath) ??
+ false
+ if (!inTabBar)
+ return {
+ ok: false,
+ errMsg: 'switchTab:fail can not switch to no-tabBar page',
+ }
}
return { ok: true }
}
@@ -2286,7 +2529,12 @@ function handleNavActionApi(
sendActionOrFail(ap, E.NAV_ACTION, payload, name, params)
}
-function handleTabActionApi(ap: AppSession, page: PageSession, name: string, params: Record): void {
+function handleTabActionApi(
+ ap: AppSession,
+ page: PageSession,
+ name: string,
+ params: Record,
+): void {
const payload: TabActionPayload = {
appSessionId: ap.appSessionId,
bridgeId: page.bridgeId,
@@ -2327,7 +2575,11 @@ function handleAppLifecycleToggle(
// pageScrollTo acts on the page's render guest (scroll its document), which
// only the main process can reach — run the scroll script in the invoking
// page's render webContents rather than forwarding to the simulator.
-function handlePageScrollApi(ap: AppSession, page: PageSession, params: Record): void {
+function handlePageScrollApi(
+ ap: AppSession,
+ page: PageSession,
+ params: Record,
+): void {
const renderWc = page.renderWc
if (renderWc && !renderWc.isDestroyed()) {
void renderWc.executeJavaScript(buildPageScrollScript(params)).catch(() => {})
@@ -2349,9 +2601,10 @@ async function invokeSimulatorApiAndCallback(
): Promise {
try {
const result = await invoke()
- const errMsg = result && typeof result === 'object' && 'errMsg' in result
- ? String((result as { errMsg?: unknown }).errMsg ?? '')
- : ''
+ const errMsg =
+ result && typeof result === 'object' && 'errMsg' in result
+ ? String((result as { errMsg?: unknown }).errMsg ?? '')
+ : ''
if (errMsg.startsWith(`${name}:fail`)) {
sendCallback(ap, params.fail, result)
sendCallback(ap, params.complete, result)
@@ -2391,13 +2644,15 @@ const NATIVE_WEBSOCKET_API_NAMES = new Set([
...NATIVE_WEBSOCKET_OFF_EVENTS.keys(),
])
+const NATIVE_HTTP_API_NAMES = new Set(['request', 'requestTaskAbort'])
+
function socketConnectTimeout(ap: AppSession, rawTimeout: unknown): number | undefined {
if (typeof rawTimeout === 'number' && rawTimeout >= 1) return rawTimeout
const configured = ap.appConfig.app?.networkTimeout?.connectSocket
- return typeof configured === 'number'
- && Number.isFinite(configured)
- && configured >= 1
- && configured <= 0x7fff_ffff
+ return typeof configured === 'number' &&
+ Number.isFinite(configured) &&
+ configured >= 1 &&
+ configured <= 0x7fff_ffff
? configured
: undefined
}
@@ -2445,11 +2700,8 @@ async function handleNativeWebSocketApi(
// for the same development-build fallback.
containerReferer: `https://servicedimina.com/${ap.appId}/${DEVTOOLS_APP_VERSION}/page-frame.html`,
}
- await invokeSimulatorApiAndCallback(
- ap,
- name,
- params,
- async () => state.nativeWebSocket.connect(ap.appSessionId, options),
+ await invokeSimulatorApiAndCallback(ap, name, params, async () =>
+ state.nativeWebSocket.connect(ap.appSessionId, options),
)
return
}
@@ -2460,11 +2712,8 @@ async function handleNativeWebSocketApi(
data: params.data,
isBuffer: params.isBuffer === true,
}
- await invokeSimulatorApiAndCallback(
- ap,
- name,
- params,
- () => state.nativeWebSocket.send(ap.appSessionId, options),
+ await invokeSimulatorApiAndCallback(ap, name, params, () =>
+ state.nativeWebSocket.send(ap.appSessionId, options),
)
return
}
@@ -2474,11 +2723,30 @@ async function handleNativeWebSocketApi(
code: params.code as number | undefined,
reason: params.reason as string | undefined,
}
- await invokeSimulatorApiAndCallback(
- ap,
- name,
- params,
- async () => state.nativeWebSocket.close(ap.appSessionId, options),
+ await invokeSimulatorApiAndCallback(ap, name, params, async () =>
+ state.nativeWebSocket.close(ap.appSessionId, options),
+ )
+}
+
+async function handleNativeHttpApi(
+ state: RouterState,
+ ap: AppSession,
+ name: string,
+ params: Record,
+): Promise {
+ const requestId = String(
+ params.requestId ?? params.taskId ?? `${ap.appSessionId}:${name}:${randomUUID()}`,
+ )
+
+ if (name === 'requestTaskAbort') {
+ state.nativeRequest.abort(ap.appSessionId, requestId)
+ return
+ }
+
+ // This call ran in the simulator document before migration; preserve its URL base and session policy.
+ const options = nativeRequestOptions(params, ap.simulatorWc)
+ await invokeSimulatorApiAndCallback(ap, name, params, () =>
+ state.nativeRequest.request(ap.appSessionId, requestId, options),
)
}
@@ -2522,6 +2790,11 @@ async function handleSimulatorApi(
return
}
+ if (NATIVE_HTTP_API_NAMES.has(name)) {
+ await handleNativeHttpApi(state, ap, name, params)
+ return
+ }
+
// Native-host storage unification: route async wx.setStorage/getStorage/etc.
// to the service-host window's file:// store (the same store the *Sync APIs +
// the Storage panel use), instead of forwarding to the simulator guest's
@@ -2529,7 +2802,9 @@ async function handleSimulatorApi(
// two origins even for the running mini-app.
if (ctx.storageApi && STORAGE_API_NAMES.has(name)) {
const storageApi = ctx.storageApi
- await invokeSimulatorApiAndCallback(ap, name, params, () => storageApi.invoke(ap.appId, name, params))
+ await invokeSimulatorApiAndCallback(ap, name, params, () =>
+ storageApi.invoke(ap.appId, name, params),
+ )
return
}
@@ -2539,7 +2814,9 @@ async function handleSimulatorApi(
// MiniApp owns the DOM-touching defaults (wx.getSystemInfo, chooseImage,
// chooseMedia, fs.*, …) and the bridge-router can't run those itself.
if (ctx.simulatorApis.has(name)) {
- await invokeSimulatorApiAndCallback(ap, name, params, () => ctx.simulatorApis.invoke(name, params))
+ await invokeSimulatorApiAndCallback(ap, name, params, () =>
+ ctx.simulatorApis.invoke(name, params),
+ )
return
}
@@ -2578,16 +2855,19 @@ function forwardApiCallToSimulator(
const keep = params.keep === true || isPersistentSimulatorApi(name)
const timer = keep
? undefined
- : setTimeout(() => {
- const pending = state.pendingApiCalls.get(requestId)
- if (!pending) return
- state.pendingApiCalls.delete(requestId)
- const target = state.appSessions.get(pending.appSessionId)
- if (!target) return
- const fail = { errMsg: `${pending.name}:fail no handler (timeout)` }
- sendCallback(target, pending.callbacks.fail, fail)
- sendCallback(target, pending.callbacks.complete, fail)
- }, apiCallWatchdogMs(name, params))
+ : setTimeout(
+ () => {
+ const pending = state.pendingApiCalls.get(requestId)
+ if (!pending) return
+ state.pendingApiCalls.delete(requestId)
+ const target = state.appSessions.get(pending.appSessionId)
+ if (!target) return
+ const fail = { errMsg: `${pending.name}:fail no handler (timeout)` }
+ sendCallback(target, pending.callbacks.fail, fail)
+ sendCallback(target, pending.callbacks.complete, fail)
+ },
+ apiCallWatchdogMs(name, params),
+ )
state.pendingApiCalls.set(requestId, {
appSessionId: ap.appSessionId,
@@ -2706,12 +2986,16 @@ function newRequestId(): string {
}
function extractCallbacks(params: Record): NavActionPayload['callbacks'] {
- return { success: params.success, fail: params.fail, complete: params.complete }
+ return {
+ success: params.success,
+ fail: params.fail,
+ complete: params.complete,
+ }
}
function normalizeParams(params: unknown): Record {
return params && typeof params === 'object' && !Array.isArray(params)
- ? params as Record
+ ? (params as Record)
: { value: params }
}
@@ -2726,7 +3010,11 @@ function sendCallback(ap: AppSession, id: unknown, args: unknown): void {
// ── Resource helpers ────────────────────────────────────────────────────────
-function makeLoadResource(ap: AppSession, page: PageSession, target: 'service' | 'render'): MessageEnvelope {
+function makeLoadResource(
+ ap: AppSession,
+ page: PageSession,
+ target: 'service' | 'render',
+): MessageEnvelope {
return {
type: 'loadResource',
target,
@@ -2776,7 +3064,9 @@ function forwardToService(ap: AppSession, msg: MessageEnvelope): void {
function forwardToRender(ap: AppSession, msg: MessageEnvelope, targetBridgeId?: string): void {
const renderBridgeId = targetBridgeId || readBridgeId(msg)
if (!renderBridgeId) {
- throw new Error('[bridge-router] cannot route to render: missing bridgeId in body and no explicit target')
+ throw new Error(
+ '[bridge-router] cannot route to render: missing bridgeId in body and no explicit target',
+ )
}
const page = ap.pages.get(renderBridgeId)
if (!page) return
@@ -2786,7 +3076,11 @@ function forwardToRender(ap: AppSession, msg: MessageEnvelope, targetBridgeId?:
}
}
-function ensureRenderBound(state: RouterState, sender: WebContents, bridgeId: string): PageSession | undefined {
+function ensureRenderBound(
+ state: RouterState,
+ sender: WebContents,
+ bridgeId: string,
+): PageSession | undefined {
const page = state.pageSessions.get(bridgeId)
if (!page) return undefined
// A destroyed sender is never a valid owner, even when it's still the
@@ -2802,12 +3096,15 @@ function ensureRenderBound(state: RouterState, sender: WebContents, bridgeId: st
// behalf. Cleared once that webContents is actually destroyed (below).
if (page.supersededRenderWcIds.has(sender.id)) return undefined
if (page.renderWc && page.renderWc !== sender && !page.renderWc.isDestroyed()) {
- console.warn(`[bridge-router] page ${bridgeId} render webview swap (wc ${page.renderWc.id} → ${sender.id})`)
+ console.warn(
+ `[bridge-router] page ${bridgeId} render webview swap (wc ${page.renderWc.id} → ${sender.id})`,
+ )
page.supersededRenderWcIds.add(page.renderWc.id)
// The replaced guest loses both ownership and the reverse lookup in the
// same step, so census-style bindings counts reflect the swap
// immediately instead of waiting for the old guest's own destroy to fire.
- if (state.wcIdToBridgeId.get(page.renderWc.id) === bridgeId) state.wcIdToBridgeId.delete(page.renderWc.id)
+ if (state.wcIdToBridgeId.get(page.renderWc.id) === bridgeId)
+ state.wcIdToBridgeId.delete(page.renderWc.id)
}
page.renderWc = sender
state.wcIdToBridgeId.set(sender.id, bridgeId)
@@ -2834,7 +3131,11 @@ function ensureRenderBound(state: RouterState, sender: WebContents, bridgeId: st
// page binding its guest is likewise not an activity a panel needs to
// react to.
const ap = state.appSessions.get(page.appSessionId)
- if (ap && resolveActiveBridgeId(ap) === bridgeId && findAppSessionByAppId(state, ap.appId) === ap) {
+ if (
+ ap &&
+ resolveActiveBridgeId(ap) === bridgeId &&
+ findAppSessionByAppId(state, ap.appId) === ap
+ ) {
state.emitRenderEvent({
kind: 'activePage',
appId: ap.appId,
@@ -2847,7 +3148,11 @@ function ensureRenderBound(state: RouterState, sender: WebContents, bridgeId: st
return page
}
-function pageFromMsg(state: RouterState, ap: AppSession, msg: MessageEnvelope): PageSession | undefined {
+function pageFromMsg(
+ state: RouterState,
+ ap: AppSession,
+ msg: MessageEnvelope,
+): PageSession | undefined {
const target = readBridgeId(msg)
if (!target) return undefined
const page = ap.pages.get(target)
@@ -2977,7 +3282,11 @@ function closeSessionPages(state: RouterState, ap: AppSession): void {
// rather than leaving the outgoing project's guest alive to be
// screenshotted or re-resolved by the next project. Idempotent with the
// cascade (guarded on isDestroyed).
- try { page.renderWc.close() } catch { /* guest already gone */ }
+ try {
+ page.renderWc.close()
+ } catch {
+ /* guest already gone */
+ }
}
state.pageSessions.delete(page.bridgeId)
}
@@ -3007,7 +3316,7 @@ async function releaseServiceWindow(
if (ap.onServiceBoot && !ap.serviceWindow.isDestroyed()) {
ap.serviceWindow.webContents.removeListener('did-finish-load', ap.onServiceBoot)
}
- await state.pool.release(ap.poolEntryId, ap.serviceWindow).catch((error) => {
+ await state.pool.release(ap.poolEntryId, ap.serviceWindow).catch(error => {
console.warn('[bridge-router] pool release failed:', error)
})
} else if (ap.poolEntryId !== null && state.pool && opts.serviceAlreadyClosed) {
@@ -3023,7 +3332,11 @@ async function releaseServiceWindow(
}
}
-function unbindSessionFromSharedMaps(state: RouterState, ap: AppSession, appSessionId: string): void {
+function unbindSessionFromSharedMaps(
+ state: RouterState,
+ ap: AppSession,
+ appSessionId: string,
+): void {
// Value-checked unbind for the service wc: this delete runs after the
// pool-release await above, so on the pool path the next spawn may have
// already re-acquired the SAME window and rebound its wc id — only remove
@@ -3064,6 +3377,7 @@ async function disposeAppSession(
void registryHandle?.dispose()
state.appLifecycle.dispose(appSessionId)
state.nativeWebSocket.disposeOwner(appSessionId)
+ state.nativeRequest.disposeOwner(appSessionId)
// Evict AppData bridges FIRST — eviction enumerates `ap.pages`, which the
// page teardown below progressively empties (and finally clears).
@@ -3090,7 +3404,7 @@ async function disposeAppSession(
// Only the local fallback server needs closing; the dev-server base is owned
// by the workspace session, not this app session.
if (ap.resourceServer) {
- await ap.resourceServer.close().catch((error) => {
+ await ap.resourceServer.close().catch(error => {
console.warn('[bridge-router] resource server close failed:', error)
})
}
@@ -3112,7 +3426,10 @@ async function loadAppConfig(
// `resourceBase` is the dir/URL that directly contains `app-config.json`:
// the dev server's `//` (http) or the local fallback
// server root (also http). Both are HTTP, so a single fetch path covers them.
- const cfgUrl = new URL('app-config.json', resourceBase.endsWith('/') ? resourceBase : `${resourceBase}/`).toString()
+ const cfgUrl = new URL(
+ 'app-config.json',
+ resourceBase.endsWith('/') ? resourceBase : `${resourceBase}/`,
+ ).toString()
try {
const res = await fetch(cfgUrl)
if (!res.ok) {
@@ -3121,7 +3438,7 @@ async function loadAppConfig(
onUnreachable?.({ url: cfgUrl, error })
return {}
}
- return await res.json() as RawAppConfig
+ return (await res.json()) as RawAppConfig
} catch (error) {
console.warn('[bridge-router] failed to fetch/parse app-config.json:', error)
onUnreachable?.({ url: cfgUrl, error })
@@ -3141,13 +3458,17 @@ function buildAppManifest(appConfig: RawAppConfig, fallbackEntry: string): AppMa
// without updating app.json), so it loses to `pages[0]` — the same rule
// `resolveRootPagePath` applies to a launch request.
const declaredEntry = appConfig.app?.entryPagePath
- const entryIsMember = !!declaredEntry
- && compiledPages.some(page => normalizePagePath(page) === normalizePagePath(declaredEntry))
- const entry = entryIsMember ? declaredEntry! : (compiledPages[0] || fallbackEntry)
+ const entryIsMember =
+ !!declaredEntry &&
+ compiledPages.some(page => normalizePagePath(page) === normalizePagePath(declaredEntry))
+ const entry = entryIsMember ? declaredEntry! : compiledPages[0] || fallbackEntry
const pages = hasCompiledPages ? compiledPages : [entry]
- const tabBar = appConfig.app?.tabBar && Array.isArray(appConfig.app.tabBar.list) && appConfig.app.tabBar.list.length > 0
- ? appConfig.app.tabBar
- : undefined
+ const tabBar =
+ appConfig.app?.tabBar &&
+ Array.isArray(appConfig.app.tabBar.list) &&
+ appConfig.app.tabBar.list.length > 0
+ ? appConfig.app.tabBar
+ : undefined
return {
entryPagePath: normalizePagePath(entry),
pages: pages.map(normalizePagePath),
@@ -3191,18 +3512,16 @@ function resolvePageWindowConfig(appConfig: RawAppConfig, pagePath: string): Pag
navigationBarTitleText:
pageWindow.navigationBarTitleText ?? appWindow.navigationBarTitleText ?? '',
navigationBarBackgroundColor:
- pageWindow.navigationBarBackgroundColor ?? appWindow.navigationBarBackgroundColor ?? '#ffffff',
+ pageWindow.navigationBarBackgroundColor ??
+ appWindow.navigationBarBackgroundColor ??
+ '#ffffff',
navigationBarTextStyle:
pageWindow.navigationBarTextStyle ?? appWindow.navigationBarTextStyle ?? 'black',
- navigationStyle:
- pageWindow.navigationStyle ?? appWindow.navigationStyle ?? 'default',
+ navigationStyle: pageWindow.navigationStyle ?? appWindow.navigationStyle ?? 'default',
homeButton: pageWindow.homeButton ?? appWindow.homeButton,
- backgroundColor:
- pageWindow.backgroundColor ?? appWindow.backgroundColor,
- backgroundTextStyle:
- pageWindow.backgroundTextStyle ?? appWindow.backgroundTextStyle,
- enablePullDownRefresh:
- pageWindow.enablePullDownRefresh ?? appWindow.enablePullDownRefresh,
+ backgroundColor: pageWindow.backgroundColor ?? appWindow.backgroundColor,
+ backgroundTextStyle: pageWindow.backgroundTextStyle ?? appWindow.backgroundTextStyle,
+ enablePullDownRefresh: pageWindow.enablePullDownRefresh ?? appWindow.enablePullDownRefresh,
disableScroll: pageWindow.disableScroll ?? appWindow.disableScroll,
}
}
@@ -3238,14 +3557,25 @@ function installResourceProtocolHandlers(
// The registrars this scheme lives on are process-wide, so the handler is
// muxed (see bridge-router-protocol-mux.ts). The request's bridgeId names the
// session, and only the router that owns that session can resolve it.
- ctx.registry.add(addMuxedDmbResourceHandler({
- claims: (requestUrl) => {
- let bridgeId: string
- try { bridgeId = new URL(requestUrl).hostname } catch { return false }
- return resolveSession(bridgeId) !== null
- },
- handle: (request) => handleDmbResourceRequest({ requestUrl: request.url, sdkRoot, resolveSession }),
- }))
+ ctx.registry.add(
+ addMuxedDmbResourceHandler({
+ claims: requestUrl => {
+ let bridgeId: string
+ try {
+ bridgeId = new URL(requestUrl).hostname
+ } catch {
+ return false
+ }
+ return resolveSession(bridgeId) !== null
+ },
+ handle: request =>
+ handleDmbResourceRequest({
+ requestUrl: request.url,
+ sdkRoot,
+ resolveSession,
+ }),
+ }),
+ )
}
function makeHostEnv(snapshot: Partial | undefined): HostEnvSnapshot {
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/http-compat.test.ts b/packages/dimina-electron-runtime/src/main/services/native-request/http-compat.test.ts
new file mode 100644
index 00000000..9fb52b16
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/http-compat.test.ts
@@ -0,0 +1,112 @@
+import http from 'node:http'
+import { brotliCompressSync, deflateSync, gzipSync } from 'node:zlib'
+import { afterEach, describe, expect, it } from 'vitest'
+import { createNativeRequestService } from './index.js'
+import type { NativeRequestTrace } from './trace.js'
+
+const cleanup: Array<() => void | Promise> = []
+afterEach(async () => { for (const dispose of cleanup.splice(0).reverse()) await dispose() })
+
+async function serve(handler: http.RequestListener): Promise {
+ const server = http.createServer(handler)
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ cleanup.push(() => new Promise((resolve) => {
+ server.closeAllConnections()
+ server.close(() => resolve())
+ }))
+ return `http://127.0.0.1:${(server.address() as { port: number }).port}`
+}
+
+function tracedService() {
+ const service = createNativeRequestService()
+ cleanup.push(() => service.dispose())
+ const events: NativeRequestTrace[] = []
+ service.setTracer((_owner, event) => events.push(event))
+ return { service, events }
+}
+
+describe('native HTTP migration compatibility', () => {
+ it.each(['./echo', '/echo'])('resolves %s using the explicit caller document base', async (relativeUrl) => {
+ const url = await serve((req, res) => res.end(JSON.stringify({ path: req.url })))
+ const { service } = tracedService()
+ expect(await service.request('owner', 'r', { url: relativeUrl, baseUrl: `${url}/page/index.html`, data: { x: 1 } })).toMatchObject({
+ statusCode: 200, data: { path: relativeUrl.startsWith('./') ? '/page/echo?x=1' : '/echo?x=1' },
+ })
+ })
+
+ it('fails a relative URL when no document base is available', async () => {
+ const { service } = tracedService()
+ expect(await service.request('owner', 'r', { url: '/echo' })).toMatchObject({ errMsg: expect.stringContaining('request:fail') })
+ })
+
+ it.each([['gzip', gzipSync], ['deflate', deflateSync], ['br', brotliCompressSync]] as const)(
+ 'decodes %s before JSON parsing and Network body capture', async (encoding, compress) => {
+ const encoded = compress('{"ok":true}')
+ const url = await serve((_req, res) => {
+ res.writeHead(200, { 'content-type': 'application/json', 'content-encoding': encoding })
+ res.end(encoded)
+ })
+ const { service, events } = tracedService()
+ expect(await service.request('owner', 'r', { url })).toMatchObject({ data: { ok: true }, statusCode: 200 })
+ const terminal = events.at(-1)
+ expect(terminal).toMatchObject({ type: 'finished', encodedDataLength: encoded.length })
+ if (terminal?.type !== 'finished') throw new Error('missing completion')
+ expect(Buffer.from(terminal.body!, 'base64').toString()).toBe('{"ok":true}')
+ },
+ )
+
+ it('fails exactly once when a compressed body is corrupt', async () => {
+ const url = await serve((_req, res) => { res.writeHead(200, { 'content-encoding': 'gzip' }); res.end('invalid') })
+ const { service, events } = tracedService()
+ expect(await service.request('owner', 'r', { url })).toMatchObject({ errMsg: expect.stringContaining('request:fail') })
+ expect(events.map((event) => event.type)).toEqual(['sent', 'response', 'failed'])
+ })
+
+ it.each([301, 302, 303, 307, 308])('follows HTTP %s with the appropriate method and body', async (status) => {
+ const url = await serve((req, res) => {
+ if (req.url === '/start') { res.writeHead(status, { location: './end' }); res.end(); return }
+ const chunks: Buffer[] = []
+ req.on('data', (chunk) => chunks.push(chunk))
+ req.on('end', () => res.end(JSON.stringify({ method: req.method, body: Buffer.concat(chunks).toString(), contentType: req.headers['content-type'] ?? null })))
+ })
+ const { service, events } = tracedService()
+ const preserve = status === 307 || status === 308
+ expect(await service.request('owner', 'r', { url: `${url}/start`, method: 'POST', data: { ok: true } })).toMatchObject({
+ statusCode: 200, data: { method: preserve ? 'POST' : 'GET', body: preserve ? '{"ok":true}' : '', contentType: preserve ? 'application/json' : null },
+ })
+ expect(events.map((event) => event.type)).toEqual(['sent', 'redirect', 'response', 'finished'])
+ })
+
+ it('drops credentials and a caller Host header when a redirect changes origin', async () => {
+ const destination = await serve((req, res) => res.end(JSON.stringify({ auth: req.headers.authorization ?? null, cookie: req.headers.cookie ?? null, host: req.headers.host })))
+ const url = await serve((_req, res) => { res.writeHead(302, { location: destination }); res.end() })
+ const { service } = tracedService()
+ expect(await service.request('owner', 'r', { url, header: { authorization: 'secret', cookie: 'session=secret', host: 'original.invalid' } })).toMatchObject({
+ statusCode: 200, data: { auth: null, cookie: null, host: new URL(destination).host },
+ })
+ })
+
+ it('bounds redirect loops and emits one failure', async () => {
+ let calls = 0
+ const url = await serve((_req, res) => { calls++; res.writeHead(302, { location: '/loop' }); res.end() })
+ const { service, events } = tracedService()
+ expect(await service.request('owner', 'r', { url })).toMatchObject({ errMsg: expect.stringContaining('redirect') })
+ expect(calls).toBe(21)
+ expect(events.filter((event) => event.type === 'failed')).toHaveLength(1)
+ })
+
+ it('keeps abort ownership after following a redirect', async () => {
+ let secondHop!: () => void
+ const reached = new Promise((resolve) => { secondHop = resolve })
+ const url = await serve((req, res) => {
+ if (req.url === '/start') { res.writeHead(302, { location: '/wait' }); res.end() }
+ else secondHop()
+ })
+ const { service, events } = tracedService()
+ const pending = service.request('owner', 'r', { url: `${url}/start`, timeout: 1000 })
+ await reached
+ service.abort('owner', 'r')
+ expect(await pending).toEqual({ errMsg: 'request:fail abort' })
+ expect(events.filter((event) => event.type === 'failed')).toHaveLength(1)
+ })
+})
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/index.test.ts b/packages/dimina-electron-runtime/src/main/services/native-request/index.test.ts
new file mode 100644
index 00000000..056c64cc
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/index.test.ts
@@ -0,0 +1,320 @@
+import http from "node:http";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import {
+ createNativeRequestService,
+ createNativeRequestTransport,
+} from "./index.js";
+import { normalizeRequestHeaders } from "./normalize.js";
+
+describe("native-request", () => {
+ describe("normalizeRequestHeaders", () => {
+ it("adds content-type application/json only when the method can carry a body and data is provided", () => {
+ const postHeaders = normalizeRequestHeaders(undefined, "POST", { a: 1 });
+ expect(postHeaders.get("content-type")).toBe("application/json");
+ });
+
+ it("does not add content-type for bodyless GET requests", () => {
+ const getHeaders = normalizeRequestHeaders(undefined, "GET", undefined);
+ expect(getHeaders.has("content-type")).toBe(false);
+ });
+
+ it("does not add content-type for bodyless HEAD requests", () => {
+ const headHeaders = normalizeRequestHeaders(undefined, "HEAD", undefined);
+ expect(headHeaders.has("content-type")).toBe(false);
+ });
+
+ it("does not duplicate content-type when the caller supplies a differently-cased header", () => {
+ const headers = normalizeRequestHeaders(
+ { "content-type": "application/x-www-form-urlencoded" },
+ "POST",
+ { a: 1 },
+ );
+ expect(headers.get("content-type")).toBe(
+ "application/x-www-form-urlencoded",
+ );
+ });
+
+ it("preserves unrelated headers", () => {
+ const headers = normalizeRequestHeaders(
+ { "x-token": "abc" },
+ "GET",
+ undefined,
+ );
+ expect(headers.get("x-token")).toBe("abc");
+ });
+ });
+
+ describe("transport.request", () => {
+ let server: http.Server;
+ let serverUrl: string;
+
+ beforeAll(async () => {
+ server = http.createServer((req, res) => {
+ const chunks: Buffer[] = [];
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
+ req.on("error", () => {
+ if (!res.writableEnded) res.destroy();
+ });
+ req.on("end", () => {
+ const body = Buffer.concat(chunks).toString("utf-8");
+ if (req.method === "POST" && req.url === "/echo") {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(
+ JSON.stringify({
+ method: req.method,
+ headers: req.headers,
+ body,
+ }),
+ );
+ return;
+ }
+ if (req.url === "/unauthorized") {
+ res.writeHead(401, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "unauthorized" }));
+ return;
+ }
+ if (req.url === "/text") {
+ res.writeHead(200, { "Content-Type": "text/plain" });
+ res.end("hello");
+ return;
+ }
+ if (req.url === "/binary") {
+ res.writeHead(200, {
+ "Content-Type": "application/octet-stream",
+ });
+ res.end(Buffer.from([0x00, 0x01, 0x02, 0x03]));
+ return;
+ }
+ if (req.url?.startsWith("/query")) {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ query: req.url }));
+ return;
+ }
+ res.writeHead(404);
+ res.end("not found");
+ });
+ });
+ await new Promise((resolve) => {
+ server.listen(0, "127.0.0.1", () => {
+ const addr = server.address();
+ if (addr && typeof addr === "object") {
+ serverUrl = `http://127.0.0.1:${addr.port}`;
+ }
+ resolve();
+ });
+ });
+ });
+
+ afterAll(async () => {
+ await new Promise((resolve, reject) => {
+ server.close((err) => (err ? reject(err) : resolve()));
+ });
+ });
+
+ const transport = createNativeRequestTransport();
+
+ it("resolves a 200 JSON response via success with parsed data", async () => {
+ const result = await transport.request(
+ "r1",
+ { url: `${serverUrl}/query?x=1` },
+ new AbortController().signal,
+ );
+ expect("statusCode" in result).toBe(true);
+ if (!("statusCode" in result)) return;
+ expect(result.statusCode).toBe(200);
+ expect(result.errMsg).toBe("request:ok");
+ expect((result.data as { query: string }).query).toContain("/query?x=1");
+ });
+
+ it("resolves a 401 response via success, never fail", async () => {
+ const result = await transport.request(
+ "r2",
+ { url: `${serverUrl}/unauthorized` },
+ new AbortController().signal,
+ );
+ expect("statusCode" in result).toBe(true);
+ if (!("statusCode" in result)) return;
+ expect(result.statusCode).toBe(401);
+ expect(result.errMsg).toBe("request:ok");
+ expect((result.data as { error: string }).error).toBe("unauthorized");
+ });
+
+ it("encodes object data into the query string for GET", async () => {
+ const result = await transport.request(
+ "r3",
+ { url: `${serverUrl}/query`, method: "GET", data: { a: 1, b: 2 } },
+ new AbortController().signal,
+ );
+ expect("statusCode" in result).toBe(true);
+ if (!("statusCode" in result)) return;
+ const query = (result.data as { query: string }).query;
+ expect(query).toContain("a=1");
+ expect(query).toContain("b=2");
+ expect(query).toContain("/query?");
+ });
+
+ it("sends a JSON body for POST and defaults content-type to application/json", async () => {
+ const result = await transport.request(
+ "r4",
+ { url: `${serverUrl}/echo`, method: "POST", data: { a: 1 } },
+ new AbortController().signal,
+ );
+ expect("statusCode" in result).toBe(true);
+ if (!("statusCode" in result)) return;
+ expect(result.statusCode).toBe(200);
+ const parsed = result.data as {
+ body: string;
+ headers: Record;
+ };
+ expect(parsed.headers["content-type"]).toBe("application/json");
+ expect(parsed.body).toBe('{"a":1}');
+ });
+
+ it("sends a form-encoded body when content-type is application/x-www-form-urlencoded", async () => {
+ const result = await transport.request(
+ "r5",
+ {
+ url: `${serverUrl}/echo`,
+ method: "POST",
+ data: { a: 1, b: 2 },
+ header: { "content-type": "application/x-www-form-urlencoded" },
+ },
+ new AbortController().signal,
+ );
+ expect("statusCode" in result).toBe(true);
+ if (!("statusCode" in result)) return;
+ const parsed = result.data as {
+ body: string;
+ headers: Record;
+ };
+ expect(parsed.headers["content-type"]).toBe(
+ "application/x-www-form-urlencoded",
+ );
+ expect(parsed.body).toContain("a=1");
+ expect(parsed.body).toContain("b=2");
+ });
+
+ it("returns text as a string when dataType is not json", async () => {
+ const result = await transport.request(
+ "r6",
+ { url: `${serverUrl}/text`, dataType: "text" },
+ new AbortController().signal,
+ );
+ expect("statusCode" in result).toBe(true);
+ if (!("statusCode" in result)) return;
+ expect(result.data).toBe("hello");
+ });
+
+ it("returns an ArrayBuffer when responseType is arraybuffer", async () => {
+ const result = await transport.request(
+ "r7",
+ { url: `${serverUrl}/binary`, responseType: "arraybuffer" },
+ new AbortController().signal,
+ );
+ expect("statusCode" in result).toBe(true);
+ if (!("statusCode" in result)) return;
+ expect(result.data).toBeInstanceOf(ArrayBuffer);
+ expect((result.data as ArrayBuffer).byteLength).toBe(4);
+ });
+
+ it("fails with a timeout when the response does not arrive in time", async () => {
+ const slowServer = http.createServer((_req, res) => {
+ setTimeout(() => res.end("late"), 100);
+ });
+ await new Promise((resolve) =>
+ slowServer.listen(0, "127.0.0.1", resolve),
+ );
+ const addr = slowServer.address();
+ const url = `http://127.0.0.1:${(addr as { port: number }).port}`;
+ const result = await transport.request(
+ "r8",
+ { url, timeout: 10 },
+ new AbortController().signal,
+ );
+ slowServer.close();
+ expect("statusCode" in result).toBe(false);
+ if ("statusCode" in result) return;
+ expect(result.errMsg).toBe("request:fail timeout");
+ });
+
+ it("fails when the network connection is refused", async () => {
+ const result = await transport.request(
+ "r9",
+ { url: "http://127.0.0.1:1/" },
+ new AbortController().signal,
+ );
+ expect("statusCode" in result).toBe(false);
+ });
+
+ it("fails with abort when the caller aborts the request", async () => {
+ const controller = new AbortController();
+ const promise = transport.request(
+ "r10",
+ { url: `${serverUrl}/query` },
+ controller.signal,
+ );
+ controller.abort();
+ const result = await promise;
+ expect("statusCode" in result).toBe(false);
+ if ("statusCode" in result) return;
+ expect(result.errMsg).toBe("request:fail abort");
+ });
+ });
+
+ describe("createNativeRequestService", () => {
+ let server: http.Server;
+ let serverUrl: string;
+
+ beforeAll(async () => {
+ server = http.createServer((_req, res) => {
+ setTimeout(() => {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end("{}");
+ }, 200);
+ });
+ await new Promise((resolve) => {
+ server.listen(0, "127.0.0.1", () => {
+ const addr = server.address();
+ if (addr && typeof addr === "object") {
+ serverUrl = `http://127.0.0.1:${addr.port}`;
+ }
+ resolve();
+ });
+ });
+ });
+
+ afterAll(async () => {
+ await new Promise((resolve, reject) => {
+ server.close((err) => (err ? reject(err) : resolve()));
+ });
+ });
+
+ it("aborts an in-flight request when the owner is disposed", async () => {
+ const service = createNativeRequestService();
+ const promise = service.request("owner-1", "r11", {
+ url: serverUrl,
+ timeout: 5_000,
+ });
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ service.disposeOwner("owner-1");
+ const result = await promise;
+ expect("statusCode" in result).toBe(false);
+ if ("statusCode" in result) return;
+ expect(result.errMsg).toBe("request:fail abort");
+ });
+
+ it("does not abort a request that belongs to another owner", async () => {
+ const service = createNativeRequestService();
+ const promise = service.request("owner-2", "r12", {
+ url: serverUrl,
+ timeout: 5_000,
+ });
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ service.disposeOwner("other-owner");
+ const result = await promise;
+ expect("statusCode" in result).toBe(true);
+ if (!("statusCode" in result)) return;
+ expect(result.statusCode).toBe(200);
+ });
+ });
+});
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/index.ts b/packages/dimina-electron-runtime/src/main/services/native-request/index.ts
new file mode 100644
index 00000000..c28ef632
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/index.ts
@@ -0,0 +1,104 @@
+import type { NativeRequestResult, NativeRequestService } from "./types.js";
+import { createNativeRequestTransport } from "./transport.js";
+import { createRequestTracer, type NativeRequestTracer } from "./trace.js";
+
+interface RequestEntry {
+ controller: AbortController;
+ settled: boolean;
+}
+
+export function createNativeRequestService(): NativeRequestService {
+ const transport = createNativeRequestTransport();
+ const owners = new Map>();
+ let tracer: NativeRequestTracer | undefined;
+ let disposed = false;
+
+ const owner = (ownerId: string): Map => {
+ let map = owners.get(ownerId);
+ if (!map) {
+ map = new Map();
+ owners.set(ownerId, map);
+ }
+ return map;
+ };
+
+ const settle = (ownerId: string, requestId: string, entry: RequestEntry): void => {
+ const map = owners.get(ownerId);
+ // An old completion must not erase a new request after owner reuse.
+ if (map?.get(requestId) === entry) {
+ entry.settled = true;
+ map.delete(requestId);
+ if (map.size === 0) owners.delete(ownerId);
+ }
+ };
+
+ return {
+ async request(ownerId, requestId, options): Promise {
+ if (disposed) return { errMsg: "request:fail service disposed" };
+ if (owners.get(ownerId)?.has(requestId)) return { errMsg: "request:fail duplicate active requestId" };
+ const controller = new AbortController();
+ const entry: RequestEntry = { controller, settled: false };
+ owner(ownerId).set(requestId, entry);
+
+ try {
+ const requestTracer = createRequestTracer(
+ () => tracer,
+ ownerId,
+ requestId,
+ );
+ const result = await transport.request(
+ requestId,
+ options,
+ controller.signal,
+ requestTracer,
+ );
+ return result;
+ } finally {
+ settle(ownerId, requestId, entry);
+ }
+ },
+
+ abort(ownerId, requestId): void {
+ const entry = owners.get(ownerId)?.get(requestId);
+ if (!entry || entry.settled) return;
+ entry.controller.abort();
+ },
+
+ disposeOwner(ownerId): void {
+ const map = owners.get(ownerId);
+ if (!map) return;
+ // Retire the old generation before observers can re-enter with a new one.
+ owners.delete(ownerId);
+ for (const entry of map.values()) {
+ if (!entry.settled) entry.controller.abort();
+ }
+ },
+
+ dispose(): void {
+ disposed = true;
+ for (const [ownerId, map] of Array.from(owners.entries())) {
+ for (const entry of map.values()) {
+ if (!entry.settled) entry.controller.abort();
+ }
+ owners.delete(ownerId);
+ }
+ },
+
+ setTracer(next): void {
+ tracer = next;
+ },
+ };
+}
+
+export type {
+ NativeRequestOptions,
+ NativeRequestResult,
+ NativeRequestService,
+} from "./types.js";
+export { createNativeRequestTransport } from "./transport.js";
+export {
+ appendQueryParams,
+ encodeBody,
+ normalizeRequestHeaders,
+} from "./normalize.js";
+export type { NativeRequestTrace, NativeRequestTracer } from "./trace.js";
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/lifecycle.test.ts b/packages/dimina-electron-runtime/src/main/services/native-request/lifecycle.test.ts
new file mode 100644
index 00000000..6d50e94b
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/lifecycle.test.ts
@@ -0,0 +1,116 @@
+import http from 'node:http'
+import { afterEach, describe, expect, it } from 'vitest'
+import { createNativeRequestService, createNativeRequestTransport } from './index.js'
+import type { NativeRequestTrace } from './trace.js'
+
+const cleanup: Array<() => void | Promise> = []
+afterEach(async () => { for (const dispose of cleanup.splice(0).reverse()) await dispose() })
+
+async function serve(handler: http.RequestListener): Promise {
+ const server = http.createServer(handler)
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ cleanup.push(() => new Promise((resolve) => {
+ server.closeAllConnections()
+ server.close(() => resolve())
+ }))
+ return `http://127.0.0.1:${(server.address() as { port: number }).port}`
+}
+
+function service() {
+ const instance = createNativeRequestService()
+ cleanup.push(() => instance.dispose())
+ const events: NativeRequestTrace[] = []
+ instance.setTracer((_owner, event) => events.push(event))
+ return { instance, events }
+}
+
+describe('native HTTP terminal and ownership boundaries', () => {
+ it('does not cancel a new owner generation created by an old abort observer', async () => {
+ const url = await serve((_req, res) => res.end('ok'))
+ const { instance } = service()
+ let replacement: ReturnType | undefined
+ instance.setTracer((_owner, event) => {
+ if (event.type === 'failed' && event.requestId === 'old') replacement = instance.request('owner', 'new', { url })
+ })
+ const old = instance.request('owner', 'old', { url })
+ instance.disposeOwner('owner')
+ expect(await old).toEqual({ errMsg: 'request:fail abort' })
+ expect(await replacement).toMatchObject({ errMsg: 'request:ok', data: 'ok' })
+ })
+
+ it('fails once when the response closes before its declared length', async () => {
+ const url = await serve((_req, res) => {
+ res.writeHead(200, { 'Content-Length': '100' })
+ res.write('short')
+ setImmediate(() => res.destroy())
+ })
+ const { instance, events } = service()
+ const result = await Promise.race([
+ instance.request('owner', 'r', { url, timeout: 100 }),
+ new Promise((resolve) => { const timer = setTimeout(() => resolve('unsettled'), 300); cleanup.push(() => clearTimeout(timer)) }),
+ ])
+ expect(result).toMatchObject({ errMsg: expect.stringContaining('request:fail') })
+ expect(events.map((e) => e.type)).toEqual(['sent', 'response', 'failed'])
+ })
+
+ it('enforces the complete request budget while response chunks keep arriving', async () => {
+ const url = await serve((_req, res) => {
+ res.writeHead(200)
+ const interval = setInterval(() => res.write('x'), 5)
+ const end = setTimeout(() => res.end('late'), 150)
+ res.on('close', () => { clearInterval(interval); clearTimeout(end) })
+ })
+ const { instance, events } = service()
+ expect(await instance.request('owner', 'r', { url, timeout: 35 })).toEqual({ errMsg: 'request:fail timeout' })
+ expect(events.filter((e) => e.type === 'failed')).toHaveLength(1)
+ })
+
+ it('settles invalid transport options as failures and closes any emitted trace', async () => {
+ const { instance, events } = service()
+ await expect(instance.request('owner', 'r', { url: 'ftp://example.com' })).resolves.toMatchObject({ errMsg: expect.stringContaining('request:fail') })
+ expect(events.filter((e) => e.type === 'sent').length).toBe(events.filter((e) => e.type === 'failed').length)
+ })
+
+ it('does not start network I/O for an already aborted signal', async () => {
+ let calls = 0
+ const url = await serve((_req, res) => { calls++; res.end('ok') })
+ const controller = new AbortController()
+ controller.abort()
+ expect(await createNativeRequestTransport().request('r', { url }, controller.signal)).toEqual({ errMsg: 'request:fail abort' })
+ expect(calls).toBe(0)
+ })
+
+ it('rejects a duplicate active id without orphaning the original request', async () => {
+ const url = await serve(() => {})
+ const { instance } = service()
+ const first = instance.request('owner', 'same', { url, timeout: 100 })
+ const second = instance.request('owner', 'same', { url, timeout: 100 })
+ instance.abort('owner', 'same')
+ expect(await first).toEqual({ errMsg: 'request:fail abort' })
+ expect(await second).toMatchObject({ errMsg: expect.stringContaining('duplicate') })
+ })
+
+ it('rejects requests after service disposal without emitting new traffic', async () => {
+ const url = await serve((_req, res) => res.end('ok'))
+ const { instance, events } = service()
+ instance.dispose()
+ expect(await instance.request('owner', 'r', { url })).toMatchObject({ errMsg: expect.stringContaining('disposed') })
+ expect(events).toEqual([])
+ })
+
+ it('keeps trace header edits from changing the request or its business result', async () => {
+ const url = await serve((req, res) => { res.setHeader('x-observed', req.headers['x-input'] ?? 'missing'); res.end('ok') })
+ const { instance } = service()
+ instance.setTracer((_owner, event) => {
+ if (event.type === 'sent') event.headers['x-input'] = 'mutated'
+ if (event.type === 'response') event.headers['x-observed'] = 'mutated'
+ })
+ expect(await instance.request('owner', 'r', { url, header: { 'x-input': 'original' } })).toMatchObject({ header: { 'x-observed': 'original' } })
+ })
+
+ it('returns a failure for cyclic request data instead of rejecting the invocation', async () => {
+ const { instance } = service()
+ const data: Record = {}; data.self = data
+ await expect(instance.request('owner', 'r', { url: 'http://127.0.0.1/', method: 'POST', data })).resolves.toMatchObject({ errMsg: expect.stringContaining('request:fail') })
+ })
+})
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/normalize.ts b/packages/dimina-electron-runtime/src/main/services/native-request/normalize.ts
new file mode 100644
index 00000000..65ce32ff
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/normalize.ts
@@ -0,0 +1,57 @@
+import {
+ DEFAULT_REQUEST_TIMEOUT_MS,
+ MAX_TIMEOUT_MS,
+ resolveTimeoutBudgetMs,
+} from "../../../shared/request-core.js";
+
+export { DEFAULT_REQUEST_TIMEOUT_MS, MAX_TIMEOUT_MS, resolveTimeoutBudgetMs };
+
+function buildHeaders(
+ header: Record | undefined,
+ willSendBody: boolean,
+): Headers {
+ const headers = new Headers();
+ for (const [key, value] of Object.entries(header ?? {})) {
+ if (value != null) headers.set(key, String(value));
+ }
+ if (willSendBody && !headers.has("content-type"))
+ headers.set("content-type", "application/json");
+ return headers;
+}
+
+export function normalizeRequestHeaders(
+ header: Record | undefined,
+ method: string,
+ data: unknown,
+): Headers {
+ const upper = method.toUpperCase();
+ const canHaveBody = upper !== "GET" && upper !== "HEAD";
+ const willSendBody = canHaveBody && data != null;
+ return buildHeaders(header, willSendBody);
+}
+
+export function appendQueryParams(
+ url: string,
+ data: Record,
+ baseUrl?: string,
+): string {
+ const resolved = new URL(url, baseUrl);
+ for (const [key, value] of Object.entries(data)) {
+ resolved.searchParams.append(key, String(value));
+ }
+ return resolved.toString();
+}
+
+export function encodeBody(data: unknown, contentType: string): string {
+ if (typeof data === "string") return data;
+ if (contentType.includes("application/x-www-form-urlencoded")) {
+ const form = new URLSearchParams();
+ for (const [key, value] of Object.entries(
+ data as Record,
+ )) {
+ form.append(key, String(value));
+ }
+ return form.toString();
+ }
+ return JSON.stringify(data);
+}
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/preload-owners.test.ts b/packages/dimina-electron-runtime/src/main/services/native-request/preload-owners.test.ts
new file mode 100644
index 00000000..5f9c67b7
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/preload-owners.test.ts
@@ -0,0 +1,33 @@
+import { EventEmitter } from 'node:events'
+import { describe, expect, it, vi } from 'vitest'
+import { createPreloadRequestOwners } from './preload-owners.js'
+import type { NativeRequestService } from './types.js'
+
+describe('native preload owner listeners', () => {
+ it('returns a live caller to its listener baseline across router recreation', () => {
+ const wc = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false })
+ const disposeOwner = vi.fn()
+ const service = { disposeOwner } as unknown as NativeRequestService
+ for (let i = 0; i < 3; i++) {
+ const owners = createPreloadRequestOwners(service)
+ owners.ensure(wc); owners.ensure(wc)
+ expect(wc.listenerCount('destroyed')).toBe(1)
+ owners.dispose()
+ expect(wc.listenerCount('destroyed')).toBe(0)
+ owners.ensure(wc)
+ expect(wc.listenerCount('destroyed')).toBe(0)
+ }
+ expect(disposeOwner.mock.calls).toEqual([['preload:7'], ['preload:7'], ['preload:7']])
+ })
+
+ it('releases only the destroyed caller and forgets its listener before teardown', () => {
+ const wc = Object.assign(new EventEmitter(), { id: 9, isDestroyed: () => false })
+ const disposeOwner = vi.fn()
+ const owners = createPreloadRequestOwners({ disposeOwner } as unknown as NativeRequestService)
+ owners.ensure(wc)
+ wc.emit('destroyed')
+ owners.dispose()
+ expect(disposeOwner.mock.calls).toEqual([['preload:9']])
+ expect(wc.listenerCount('destroyed')).toBe(0)
+ })
+})
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/preload-owners.ts b/packages/dimina-electron-runtime/src/main/services/native-request/preload-owners.ts
new file mode 100644
index 00000000..c98cbb49
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/preload-owners.ts
@@ -0,0 +1,31 @@
+import type { WebContents } from 'electron'
+import type { NativeRequestService } from './types.js'
+
+type RequestOwner = Pick
+
+/** The router owns these listeners; its callers' WebContents can outlive it. */
+export function createPreloadRequestOwners(service: NativeRequestService) {
+ const listeners = new Map void>()
+ let disposed = false
+ return {
+ ensure(wc: RequestOwner): void {
+ if (disposed || listeners.has(wc)) return
+ const onDestroyed = () => {
+ listeners.delete(wc)
+ service.disposeOwner(`preload:${wc.id}`)
+ }
+ listeners.set(wc, onDestroyed)
+ wc.once('destroyed', onDestroyed)
+ },
+ dispose(): void {
+ disposed = true
+ // Snapshot and detach before any abort observer can re-enter.
+ const retired = Array.from(listeners)
+ listeners.clear()
+ for (const [wc, listener] of retired) {
+ if (!wc.isDestroyed()) wc.removeListener('destroyed', listener)
+ service.disposeOwner(`preload:${wc.id}`)
+ }
+ },
+ }
+}
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/request-context.test.ts b/packages/dimina-electron-runtime/src/main/services/native-request/request-context.test.ts
new file mode 100644
index 00000000..45f83a3a
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/request-context.test.ts
@@ -0,0 +1,36 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { WebContents } from 'electron'
+const sessions = vi.hoisted(() => new Map())
+vi.mock('electron', () => ({ session: { fromPartition: (partition: string) => {
+ if (!sessions.has(partition)) sessions.set(partition, {})
+ return sessions.get(partition)
+} } }))
+import { session } from 'electron'
+import { miniappPartition } from '../views/miniapp-partition.js'
+import { clearSimulatorServicewechatReferer, setSimulatorServicewechatReferer } from '../simulator/referer.js'
+import { nativeRequestOptions } from './request-context.js'
+
+afterEach(() => {
+ for (const project of ['/project/a', '/project/b']) clearSimulatorServicewechatReferer('same-app', project)
+ sessions.clear()
+})
+
+describe('native request execution context', () => {
+ it('uses the original execution document and supports an invoking subframe override', () => {
+ const source = { getURL: () => 'https://example.com/page/index.html', session: {} } as WebContents
+ expect(nativeRequestOptions({ url: './api', baseUrl: 'https://untrusted.invalid/' }, source).baseUrl).toBe(source.getURL())
+ expect(nativeRequestOptions({ url: '/api' }, source, 'https://example.com/frame/index.html').baseUrl).toBe('https://example.com/frame/index.html')
+ })
+
+ it('applies the authoritative Referer for each project partition without mutating caller headers', () => {
+ const header = { ReFeReR: 'caller', 'x-test': 'yes' }
+ for (const [project, version] of [['/project/a', 'develop'], ['/project/b', 'release']]) {
+ setSimulatorServicewechatReferer('same-app', version, project)
+ }
+ for (const [project, version] of [['/project/a', 'develop'], ['/project/b', 'release']]) {
+ const source = { getURL: () => 'https://example.com/', session: session.fromPartition(miniappPartition('same-app', project)) } as WebContents
+ expect(nativeRequestOptions({ url: '/api', header }, source).header).toEqual({ referer: `https://servicewechat.com/same-app/${version}/page-frame.html`, 'x-test': 'yes' })
+ }
+ expect(header.ReFeReR).toBe('caller')
+ })
+})
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/request-context.ts b/packages/dimina-electron-runtime/src/main/services/native-request/request-context.ts
new file mode 100644
index 00000000..f49abb3a
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/request-context.ts
@@ -0,0 +1,23 @@
+import type { WebContents } from 'electron'
+import type { NativeRequestOptions } from './types.js'
+import { getSimulatorServicewechatRefererForSession } from '../simulator/referer.js'
+
+/** Both native entrypoints use the old execution document and session policy. */
+export function nativeRequestOptions(params: Record, source: Pick, documentUrl?: string): NativeRequestOptions {
+ const forcedReferer = getSimulatorServicewechatRefererForSession(source.session)
+ let header = params.header as Record | undefined
+ if (forcedReferer) {
+ header = Object.fromEntries(Object.entries(header ?? {}).filter(([key]) => key.toLowerCase() !== 'referer'))
+ header.referer = forcedReferer
+ }
+ return {
+ url: typeof params.url === 'string' ? params.url : '',
+ baseUrl: documentUrl || source.getURL(),
+ data: params.data,
+ header,
+ timeout: typeof params.timeout === 'number' ? params.timeout : undefined,
+ method: typeof params.method === 'string' ? params.method : undefined,
+ dataType: typeof params.dataType === 'string' ? params.dataType : undefined,
+ responseType: typeof params.responseType === 'string' ? params.responseType : undefined,
+ }
+}
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/response.ts b/packages/dimina-electron-runtime/src/main/services/native-request/response.ts
new file mode 100644
index 00000000..4021d8be
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/response.ts
@@ -0,0 +1,28 @@
+import { promisify } from 'node:util'
+import { brotliDecompress, gunzip, inflate } from 'node:zlib'
+
+const decompressors = {
+ gzip: promisify(gunzip),
+ 'x-gzip': promisify(gunzip),
+ deflate: promisify(inflate),
+ br: promisify(brotliDecompress),
+}
+
+/** Node http exposes wire bytes; callers and the Network body cache need decoded bytes. */
+export async function decodeContent(buffer: Buffer, encoding: string): Promise {
+ let decoded = buffer
+ for (const name of encoding.toLowerCase().split(',').map((value) => value.trim()).reverse()) {
+ const decompress = decompressors[name as keyof typeof decompressors]
+ if (decompress && decoded.length > 0) decoded = await decompress(decoded)
+ }
+ return decoded
+}
+
+export function decodeResponseData(buffer: Buffer, dataType = 'json', responseType = 'text'): unknown {
+ if (responseType === 'arraybuffer' || dataType === 'arraybuffer') {
+ return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
+ }
+ const text = buffer.toString('utf-8')
+ if (dataType !== 'json') return text
+ try { return JSON.parse(text) } catch { return text }
+}
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/trace-budget.test.ts b/packages/dimina-electron-runtime/src/main/services/native-request/trace-budget.test.ts
new file mode 100644
index 00000000..72feb43f
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/trace-budget.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest'
+import { createRequestTracer, type NativeRequestTrace } from './trace.js'
+
+describe('native HTTP observation bounds', () => {
+ it('does not allocate base64 when a small compressed response expands beyond the cache budget', () => {
+ const events: NativeRequestTrace[] = []
+ const tracer = createRequestTracer(() => (_owner, event) => events.push(event), 'owner', 'r')
+ let allocations = 0
+ tracer.finished(() => { allocations++; return 'body' }, true, 1024, 17 * 1024 * 1024)
+ expect(allocations).toBe(0)
+ expect(events[0]).toMatchObject({ type: 'finished', encodedDataLength: 1024 })
+ expect((events[0] as { body?: string }).body).toBeUndefined()
+ })
+
+ it('keeps oversized POST data out of the trace while reporting that a body exists', () => {
+ const events: NativeRequestTrace[] = []
+ const tracer = createRequestTracer(() => (_owner, event) => events.push(event), 'owner', 'r')
+ tracer.sent('https://example.com', 'POST', {}, 'a'.repeat(17 * 1024 * 1024))
+ expect((events[0] as { postData?: string }).postData?.length).toBeUndefined()
+ expect(events[0]).toMatchObject({ hasPostData: true })
+ })
+
+ it('omits an oversized body while preserving completion and actual byte count', () => {
+ const events: NativeRequestTrace[] = []
+ const tracer = createRequestTracer(() => (_owner, event) => events.push(event), 'owner', 'r')
+ tracer.sent('https://example.com', 'GET', {})
+ tracer.finished('a'.repeat(17 * 1024 * 1024), true, 13 * 1024 * 1024)
+ expect(events[1]).toMatchObject({ type: 'finished', encodedDataLength: 13 * 1024 * 1024 })
+ expect((events[1] as { body?: string }).body).toBeUndefined()
+ })
+})
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/trace.contract.test.ts b/packages/dimina-electron-runtime/src/main/services/native-request/trace.contract.test.ts
new file mode 100644
index 00000000..2685a078
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/trace.contract.test.ts
@@ -0,0 +1,141 @@
+import { afterAll, afterEach, describe, expect, it } from 'vitest'
+import http from 'node:http'
+import { createNativeRequestService } from './index.js'
+import type { NativeRequestTrace } from './trace.js'
+
+/**
+ * Contract for the NativeRequestTrace observation stream (trace.ts): one
+ * ordered fact per lifecycle moment — `sent` strictly first, and exactly one
+ * terminal event (`finished` XOR `failed`) per request no matter which path
+ * settles it (response received, network error, timeout, caller abort).
+ * Pure observation: registering a tracer never changes the result the
+ * business caller (`service.request`) sees.
+ */
+describe('Native HTTP request trace stream contract', () => {
+ let server: http.Server
+ let serverUrl: string
+
+ afterAll(async () => {
+ await new Promise((resolve, reject) => {
+ server.close((err) => (err ? reject(err) : resolve()))
+ })
+ })
+
+ const started = new Promise((resolve) => {
+ server = http.createServer((req, res) => {
+ if (req.url === '/ok') {
+ res.writeHead(200, { 'Content-Type': 'application/json' })
+ res.end('{"a":1}')
+ return
+ }
+ if (req.url === '/slow') {
+ setTimeout(() => res.end('late'), 200)
+ return
+ }
+ res.writeHead(404)
+ res.end('not found')
+ })
+ server.listen(0, '127.0.0.1', () => {
+ const addr = server.address()
+ if (addr && typeof addr === 'object') serverUrl = `http://127.0.0.1:${addr.port}`
+ resolve()
+ })
+ })
+
+ function newTracedService(): {
+ service: ReturnType
+ traces: Array<{ ownerId: string; event: NativeRequestTrace }>
+ } {
+ const service = createNativeRequestService()
+ const traces: Array<{ ownerId: string; event: NativeRequestTrace }> = []
+ service.setTracer((ownerId, event) => {
+ traces.push({ ownerId, event })
+ })
+ return { service, traces }
+ }
+
+ const disposables: ReturnType[] = []
+ afterEach(() => {
+ for (const service of disposables.splice(0)) service.dispose()
+ })
+
+ it('emits sent → response → finished for a completed 200 request, with the body/headers the caller saw', async () => {
+ await started
+ const { service, traces } = newTracedService()
+ disposables.push(service)
+
+ const result = await service.request('owner-1', 'r1', { url: `${serverUrl}/ok` })
+ expect('statusCode' in result).toBe(true)
+
+ const types = traces.map((t) => t.event.type)
+ expect(types).toEqual(['sent', 'response', 'finished'])
+ expect(traces.every((t) => t.ownerId === 'owner-1')).toBe(true)
+
+ const sent = traces[0]!.event as NativeRequestTrace & { type: 'sent' }
+ expect(sent.url).toContain('/ok')
+ expect(sent.method).toBe('GET')
+
+ const response = traces[1]!.event as NativeRequestTrace & { type: 'response' }
+ expect(response.status).toBe(200)
+
+ const finished = traces[2]!.event as NativeRequestTrace & { type: 'finished' }
+ expect(finished.bodyBase64Encoded).toBe(true)
+ expect(Buffer.from(finished.body!, 'base64').toString('utf-8')).toBe('{"a":1}')
+ expect(finished.encodedDataLength).toBe(Buffer.byteLength('{"a":1}'))
+ })
+
+ it('emits sent → failed (no response) for a network error, without a finished event', async () => {
+ const { service, traces } = newTracedService()
+ disposables.push(service)
+
+ const result = await service.request('owner-2', 'r2', { url: 'http://127.0.0.1:1/' })
+ expect('statusCode' in result).toBe(false)
+
+ const types = traces.map((t) => t.event.type)
+ expect(types).toEqual(['sent', 'failed'])
+ const failed = traces[1]!.event as NativeRequestTrace & { type: 'failed' }
+ expect(failed.errorText).toContain('request:fail')
+ })
+
+ it('emits sent → failed(abort) for a caller-cancelled request, still exactly one terminal event', async () => {
+ await started
+ const { service, traces } = newTracedService()
+ disposables.push(service)
+
+ const promise = service.request('owner-3', 'r3', { url: `${serverUrl}/slow`, timeout: 5_000 })
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ service.abort('owner-3', 'r3')
+ const result = await promise
+
+ expect('statusCode' in result).toBe(false)
+ const types = traces.map((t) => t.event.type)
+ expect(types).toEqual(['sent', 'failed'])
+ const failed = traces[1]!.event as NativeRequestTrace & { type: 'failed' }
+ expect(failed.errorText).toBe('request:fail abort')
+ })
+
+ it('carries postData on sent for a request with a JSON body', async () => {
+ await started
+ const { service, traces } = newTracedService()
+ disposables.push(service)
+
+ await service.request('owner-4', 'r4', {
+ url: `${serverUrl}/ok`,
+ method: 'POST',
+ data: { a: 1 },
+ })
+
+ const sent = traces[0]!.event as NativeRequestTrace & { type: 'sent' }
+ expect(sent.method).toBe('POST')
+ expect(sent.postData).toBe('{"a":1}')
+ })
+
+ it('never emits a trace event when no tracer is registered', async () => {
+ await started
+ const service = createNativeRequestService()
+ disposables.push(service)
+ // No setTracer call — the transport must run exactly as before.
+ const result = await service.request('owner-5', 'r5', { url: `${serverUrl}/ok` })
+ expect('statusCode' in result).toBe(true)
+ })
+})
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/trace.ts b/packages/dimina-electron-runtime/src/main/services/native-request/trace.ts
new file mode 100644
index 00000000..e4106218
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/trace.ts
@@ -0,0 +1,158 @@
+/**
+ * Pure observation stream of the native (main-process) HTTP transport,
+ * parallel to native-websocket's `NativeWebSocketTrace` (trace.ts there) and
+ * built for the same reason: `wx.request` now runs on Node http/https in the
+ * main process, so no `webContents.debugger` attached to the simulator can
+ * see it — the embedded DevTools Network tab would otherwise show nothing for
+ * every request. One event per lifecycle fact, in the order the facts
+ * happen; every `sent` requestId is followed by exactly one terminal event
+ * (`finished` or `failed`), so a downstream CDP synthesizer can rely on
+ * sent→redirect*→(response)?→terminal without tracking transport state itself.
+ *
+ * `time` is wall-clock milliseconds (`Date.now()`); CDP consumers divide by
+ * 1000 for `timestamp`/`wallTime`.
+ */
+/** Matches the Network body cache's per-entry character budget. */
+export const NATIVE_REQUEST_TRACE_MAX_CHARS = 16 * 1024 * 1024
+
+export interface NativeRequestRedirectResponse {
+ url: string
+ status: number
+ statusText: string
+ headers: Record
+}
+
+export type NativeRequestTrace =
+ | {
+ type: 'redirect'
+ requestId: string
+ url: string
+ method: string
+ headers: Record
+ postData?: string
+ hasPostData?: boolean
+ redirectResponse: NativeRequestRedirectResponse
+ time: number
+ }
+ | {
+ type: 'sent'
+ requestId: string
+ url: string
+ method: string
+ headers: Record
+ postData?: string
+ hasPostData?: boolean
+ time: number
+ }
+ | {
+ type: 'response'
+ requestId: string
+ status: number
+ statusText: string
+ headers: Record
+ time: number
+ }
+ | {
+ type: 'finished'
+ requestId: string
+ /** Missing when the body exceeds the observation budget; never partial data. */
+ body?: string
+ bodyBase64Encoded: boolean
+ encodedDataLength: number
+ time: number
+ }
+ | { type: 'failed'; requestId: string; errorText: string; time: number }
+
+/**
+ * Single observer of the trace stream (set via `setTracer`). Independent from
+ * the per-owner business result the caller awaits: registering or clearing
+ * the tracer never alters request behaviour, and the tracer's own exceptions
+ * are swallowed at the emission site so observation can never break a live
+ * request.
+ */
+export type NativeRequestTracer = (ownerId: string, event: NativeRequestTrace) => void
+
+/**
+ * Per-request trace emitter. Every method no-ops (before building any
+ * payload) when no tracer is registered; a throwing tracer is swallowed with a warning. `finished`/
+ * `failed` are mutually exclusive terminals — the transport calls at most one
+ * of them per request.
+ */
+export interface RequestTracer {
+ sent(url: string, method: string, headers: Record, postData?: string): void
+ redirect(url: string, method: string, headers: Record, postData: string | undefined, response: NativeRequestRedirectResponse): void
+ response(status: number, statusText: string, headers: Record): void
+ finished(body: string | (() => string), bodyBase64Encoded: boolean, encodedDataLength: number, decodedDataLength?: number): void
+ failed(errorText: string): void
+}
+
+export function createRequestTracer(
+ getTracer: () => NativeRequestTracer | undefined,
+ ownerId: string,
+ requestId: string,
+): RequestTracer {
+ return new RequestTracerImpl(getTracer, ownerId, requestId)
+}
+
+class RequestTracerImpl implements RequestTracer {
+ constructor(
+ private readonly getTracer: () => NativeRequestTracer | undefined,
+ private readonly ownerId: string,
+ private readonly requestId: string,
+ ) {}
+
+ private emit(event: NativeRequestTrace): void {
+ const tracer = this.getTracer()
+ if (!tracer) return
+ try {
+ tracer(this.ownerId, event)
+ } catch (error) {
+ console.warn('[native-request] tracer threw:', error)
+ }
+ }
+
+ sent(url: string, method: string, headers: Record, postData?: string): void {
+ if (!this.getTracer()) return
+ const event: NativeRequestTrace = { type: 'sent', requestId: this.requestId, url, method, headers: { ...headers },
+ ...boundedPostData(postData), time: Date.now() }
+ this.emit(event)
+ }
+
+ response(status: number, statusText: string, headers: Record): void {
+ if (!this.getTracer()) return
+ this.emit({ type: 'response', requestId: this.requestId, status, statusText, headers: { ...headers }, time: Date.now() })
+ }
+
+ redirect(url: string, method: string, headers: Record, postData: string | undefined, response: NativeRequestRedirectResponse): void {
+ if (!this.getTracer()) return
+ this.emit({ type: 'redirect', requestId: this.requestId, url, method, headers: { ...headers },
+ ...boundedPostData(postData),
+ redirectResponse: { ...response, headers: { ...response.headers } }, time: Date.now() })
+ }
+
+ finished(body: string | (() => string), bodyBase64Encoded: boolean, encodedDataLength: number, decodedDataLength = encodedDataLength): void {
+ if (!this.getTracer()) return
+ const encodedChars = bodyBase64Encoded ? Math.ceil(decodedDataLength / 3) * 4 : decodedDataLength
+ const value = encodedChars <= NATIVE_REQUEST_TRACE_MAX_CHARS
+ ? typeof body === 'function' ? body() : body
+ : undefined
+ this.emit({
+ type: 'finished',
+ requestId: this.requestId,
+ ...(value !== undefined && value.length <= NATIVE_REQUEST_TRACE_MAX_CHARS ? { body: value } : {}),
+ bodyBase64Encoded,
+ encodedDataLength,
+ time: Date.now(),
+ })
+ }
+
+ failed(errorText: string): void {
+ if (!this.getTracer()) return
+ this.emit({ type: 'failed', requestId: this.requestId, errorText, time: Date.now() })
+ }
+}
+
+function boundedPostData(postData?: string): { postData?: string; hasPostData?: boolean } {
+ if (postData === undefined) return {}
+ return postData.length <= NATIVE_REQUEST_TRACE_MAX_CHARS ? { postData } : { hasPostData: true }
+}
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/transport.ts b/packages/dimina-electron-runtime/src/main/services/native-request/transport.ts
new file mode 100644
index 00000000..be836048
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/transport.ts
@@ -0,0 +1,192 @@
+import http from "node:http";
+import https from "node:https";
+import type {
+ NativeRequestFailResult,
+ NativeRequestOptions,
+ NativeRequestResult,
+ NativeRequestSuccessResult,
+} from "./types.js";
+import {
+ appendQueryParams,
+ encodeBody,
+ normalizeRequestHeaders,
+ resolveTimeoutBudgetMs,
+} from "./normalize.js";
+import type { RequestTracer } from "./trace.js";
+import { decodeContent, decodeResponseData } from "./response.js";
+
+export interface NativeRequestTransport {
+ request(
+ requestId: string,
+ options: NativeRequestOptions,
+ signal: AbortSignal,
+ tracer?: RequestTracer,
+ ): Promise;
+}
+
+export function createNativeRequestTransport(): NativeRequestTransport {
+ return {
+ request(_requestId, options, signal, tracer) {
+ return new Promise((resolve) => {
+ if (signal.aborted) {
+ resolve({ errMsg: "request:fail abort" });
+ return;
+ }
+ let method = (options.method || "GET").toUpperCase();
+ const canHaveBody = method !== "GET" && method !== "HEAD";
+
+ let resolvedUrl: URL;
+ try {
+ resolvedUrl = new URL(options.url, options.baseUrl);
+ } catch (error) {
+ resolve({
+ errMsg: `request:fail ${error instanceof Error ? error.message : "invalid url"}`,
+ });
+ return;
+ }
+
+ const headers = normalizeRequestHeaders(
+ options.header,
+ method,
+ options.data,
+ );
+ let url = resolvedUrl.toString();
+
+ if (!canHaveBody) {
+ if (options.data && typeof options.data === "object") {
+ url = appendQueryParams(
+ url,
+ options.data as Record,
+ );
+ }
+ }
+
+ const nodeHeaders: Record = {};
+ headers.forEach((value, key) => {
+ nodeHeaders[key] = value;
+ });
+
+ nodeHeaders["accept-encoding"] ??= "gzip, deflate, br";
+ let postDataForTrace: string | undefined;
+ if (canHaveBody && options.data != null) {
+ const contentType = headers.get("content-type") ?? "";
+ postDataForTrace = encodeBody(options.data, contentType);
+ }
+ tracer?.sent(url, method, nodeHeaders, postDataForTrace);
+
+ let settled = false;
+ let req: http.ClientRequest | undefined;
+ let response: http.IncomingMessage | undefined;
+ const deadline = setTimeout(() => {
+ fail("timeout");
+ }, resolveTimeoutBudgetMs(options.timeout));
+ function fail(reason: string): void {
+ if (settled) return;
+ finish({ errMsg: `request:fail ${reason}` });
+ response?.destroy();
+ req?.destroy();
+ }
+ function finish(result: NativeRequestResult): void {
+ if (settled) return;
+ settled = true;
+ clearTimeout(deadline);
+ signal.removeEventListener("abort", abortRequest);
+ if ("statusCode" in result) {
+ const buffer = lastResponseBuffer ?? Buffer.alloc(0);
+ tracer?.finished(
+ () => buffer.toString("base64"),
+ true,
+ encodedDataLength,
+ buffer.byteLength,
+ );
+ } else {
+ tracer?.failed(result.errMsg);
+ }
+ resolve(result);
+ }
+
+ let lastResponseBuffer: Buffer | undefined;
+ let encodedDataLength = 0;
+ let generation = 0;
+ let redirects = 0;
+
+ function startHop(): void {
+ if (settled) return;
+ const hop = ++generation;
+ try {
+ const parsed = new URL(url);
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ throw new Error("unsupported request protocol");
+ }
+ const nodeModule = parsed.protocol === "https:" ? https : http;
+ req = nodeModule.request(url, { method, headers: nodeHeaders }, (res) => {
+ if (settled || hop !== generation) { res.destroy(); return; }
+ response = res;
+ res.on("error", (error) => { if (hop === generation) fail(error.message || "response interrupted"); });
+ res.on("aborted", () => { if (hop === generation) fail("response aborted"); });
+ const responseHeaders = Object.fromEntries(Object.entries(res.headers).map(([key, value]) => [
+ key, Array.isArray(value) ? value.join(", ") : value ?? "",
+ ]));
+ const status = res.statusCode ?? 0;
+ if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) {
+ try {
+ if (redirects++ >= 20) throw new Error("too many redirects");
+ const nextUrl = new URL(res.headers.location, url);
+ if (!["http:", "https:"].includes(nextUrl.protocol) || nextUrl.username || nextUrl.password) {
+ throw new Error("unsupported redirect URL");
+ }
+ const redirectResponse = { url, status, statusText: res.statusMessage ?? "", headers: responseHeaders };
+ if (nextUrl.origin !== parsed.origin) {
+ for (const key of ["authorization", "proxy-authorization", "cookie", "host"]) delete nodeHeaders[key];
+ }
+ if (([301, 302].includes(status) && method === "POST") || (status === 303 && method !== "GET" && method !== "HEAD")) {
+ method = "GET";
+ postDataForTrace = undefined;
+ for (const key of ["content-type", "content-length", "content-encoding", "content-language", "content-location", "transfer-encoding"]) delete nodeHeaders[key];
+ }
+ url = nextUrl.toString();
+ tracer?.redirect(url, method, nodeHeaders, postDataForTrace, redirectResponse);
+ // Retire this hop before destroying it; late socket errors cannot fail its successor.
+ generation++;
+ res.destroy();
+ response = undefined;
+ startHop();
+ } catch (error) {
+ fail(error instanceof Error ? error.message : "invalid redirect");
+ }
+ return;
+ }
+ tracer?.response(status, res.statusMessage ?? "", responseHeaders);
+ if (settled) return;
+ const chunks: Buffer[] = [];
+ res.on("data", (chunk: Buffer) => chunks.push(chunk));
+ res.on("end", () => {
+ if (settled || hop !== generation) return;
+ const wireBody = Buffer.concat(chunks);
+ encodedDataLength = wireBody.byteLength;
+ void decodeContent(wireBody, responseHeaders["content-encoding"] ?? "").then((buffer) => {
+ if (settled || hop !== generation) return;
+ lastResponseBuffer = buffer;
+ finish({ data: decodeResponseData(buffer, options.dataType, options.responseType),
+ statusCode: status, header: responseHeaders, errMsg: "request:ok" });
+ }).catch((error: unknown) => fail(error instanceof Error ? error.message : "invalid response body"));
+ });
+ });
+ req.on("error", (error) => { if (hop === generation) fail(signal.aborted ? "abort" : error.message || "network error"); });
+ if (postDataForTrace !== undefined) req.write(postDataForTrace);
+ req.end();
+ } catch (error) {
+ fail(error instanceof Error ? error.message : "invalid request");
+ }
+ }
+ signal.addEventListener("abort", abortRequest, { once: true });
+ if (signal.aborted) abortRequest();
+ else startHop();
+
+ function abortRequest(): void { fail("abort"); }
+ }).catch((error: unknown) => ({
+ errMsg: `request:fail ${error instanceof Error ? error.message : "invalid request"}`,
+ }));
+ },
+ };
+}
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/types.ts b/packages/dimina-electron-runtime/src/main/services/native-request/types.ts
new file mode 100644
index 00000000..377b2dbc
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/types.ts
@@ -0,0 +1,42 @@
+import type { NativeRequestTracer } from "./trace.js";
+
+export interface NativeRequestOptions {
+ url: string;
+ /** Execution document supplied by the native bridge, never a renderer-global fallback. */
+ baseUrl?: string;
+ data?: unknown;
+ header?: Record;
+ timeout?: number;
+ method?: string;
+ dataType?: string;
+ responseType?: string;
+}
+
+export interface NativeRequestSuccessResult {
+ data: unknown;
+ statusCode: number;
+ header: Record;
+ errMsg: "request:ok";
+}
+
+export interface NativeRequestFailResult {
+ errMsg: string;
+}
+
+export type NativeRequestResult =
+ | NativeRequestSuccessResult
+ | NativeRequestFailResult;
+
+export interface NativeRequestService {
+ request(
+ ownerId: string,
+ requestId: string,
+ options: NativeRequestOptions,
+ ): Promise;
+ abort(ownerId: string, requestId: string): void;
+ disposeOwner(ownerId: string): void;
+ dispose(): void;
+ /** Register the single observer of the trace stream (devtools Network
+ * panel). An absent tracer skips observation payloads and base64 encoding. */
+ setTracer(tracer: NativeRequestTracer | undefined): void;
+}
diff --git a/packages/dimina-electron-runtime/src/main/services/simulator/referer.ts b/packages/dimina-electron-runtime/src/main/services/simulator/referer.ts
index b4c37ee9..e663f960 100644
--- a/packages/dimina-electron-runtime/src/main/services/simulator/referer.ts
+++ b/packages/dimina-electron-runtime/src/main/services/simulator/referer.ts
@@ -10,6 +10,7 @@
*/
import { miniappPartition } from '../views/miniapp-partition.js'
+import { session, type Session } from 'electron'
const DEFAULT_VERSION = 'develop'
@@ -64,3 +65,12 @@ export function clearSimulatorServicewechatReferer(
export function getSimulatorServicewechatReferer(partition: string): string | null {
return refererByPartition.get(partition) ?? null
}
+
+/** Node requests use the same policy as the caller's actual Electron session. */
+export function getSimulatorServicewechatRefererForSession(source: Session | undefined): string | null {
+ if (!source) return null
+ for (const [partition, referer] of refererByPartition) {
+ if (session.fromPartition(partition) === source) return referer
+ }
+ return null
+}
diff --git a/packages/dimina-electron-runtime/src/shared/bridge-channels.ts b/packages/dimina-electron-runtime/src/shared/bridge-channels.ts
index 0c00b45b..0dec19e3 100644
--- a/packages/dimina-electron-runtime/src/shared/bridge-channels.ts
+++ b/packages/dimina-electron-runtime/src/shared/bridge-channels.ts
@@ -39,6 +39,20 @@ export const BRIDGE_CHANNELS = {
* this to report multi-page stacks. Fire-and-forget.
*/
PAGE_STACK: 'dmb:page-stack',
+ /**
+ * preload (window.wx.request, no service-host in the picture) → main
+ * (invoke): run an HTTP request through the main-process native transport
+ * (`main/services/native-request`) instead of the renderer's `fetch()`, so
+ * it never hits Chromium's Fetch/CORS algorithm (no spurious OPTIONS
+ * preflight). Reply is a `NativeRequestResult` (success or fail shape).
+ */
+ NATIVE_REQUEST: 'dmb:native-request',
+ /**
+ * preload → main: cancel the in-flight native request named by the
+ * requestId a prior NATIVE_REQUEST call was invoked with. Fire-and-forget;
+ * a requestId that already settled or belongs to another sender is a no-op.
+ */
+ NATIVE_REQUEST_ABORT: 'dmb:native-request-abort',
} as const
export const SIMULATOR_EVENTS = {
@@ -104,7 +118,7 @@ export interface NativeHostConfig {
device?: NativeDeviceInfo
}
-export type BridgeChannel = typeof BRIDGE_CHANNELS[keyof typeof BRIDGE_CHANNELS]
+export type BridgeChannel = (typeof BRIDGE_CHANNELS)[keyof typeof BRIDGE_CHANNELS]
export type BridgeTarget = 'service' | 'render' | 'container'
diff --git a/packages/dimina-electron-runtime/src/shared/request-core.ts b/packages/dimina-electron-runtime/src/shared/request-core.ts
index 721e0f5b..ea19e1a5 100644
--- a/packages/dimina-electron-runtime/src/shared/request-core.ts
+++ b/packages/dimina-electron-runtime/src/shared/request-core.ts
@@ -1,8 +1,7 @@
/**
- * Runtime-owned authoritative implementation of wx.request network semantics, shared
- * by every request surface (simulator `directRequest`, preload api-compat
- * `wx.request` shim). Keeping the semantics in one module is what prevents the
- * surfaces from drifting apart on the core contract.
+ * Published fetch-based compatibility helper for external embedders. DevTools'
+ * request entrypoints use main/services/native-request; timeout constants remain
+ * shared with that transport and the simulator forwarding watchdog.
*
* The contract (official wx.request semantics):
* - success vs fail is decided ONLY by whether a server response was
@@ -21,7 +20,11 @@
* spelling) yields an ArrayBuffer instead.
* - Outgoing headers merge case-insensitively via `Headers` so a caller's
* `content-type` in any casing wins exactly once; the `application/json`
- * default applies only when the caller supplied none. Plain-object merges
+ * default applies only when the caller supplied none AND the request will
+ * actually carry a body (non-GET/HEAD with `data`). A bodyless GET/HEAD
+ * gets no default content-type — matching what a real device sends — so
+ * it stays a CORS-simple request instead of forcing a preflight the
+ * simulator's Chromium renderer would otherwise send. Plain-object merges
* would keep both casings as distinct keys and comma-join them on the wire.
* - GET/HEAD serialize object `data` into URL query params (no body);
* other methods send string data verbatim, form-encode objects under
@@ -34,35 +37,35 @@
*/
export interface RequestSuccessResult {
- data: unknown
- statusCode: number
- header: Record
- errMsg: 'request:ok'
+ data: unknown;
+ statusCode: number;
+ header: Record;
+ errMsg: "request:ok";
}
export interface RequestFailResult {
- errMsg: string
- errno?: number
+ errMsg: string;
+ errno?: number;
}
export interface RequestHandle {
- abort(): void
+ abort(): void;
}
export interface RequestCoreOptions {
- url: string
- data?: unknown
- header?: Record
- timeout?: number
- method?: string
- dataType?: string
- responseType?: string
+ url: string;
+ data?: unknown;
+ header?: Record;
+ timeout?: number;
+ method?: string;
+ dataType?: string;
+ responseType?: string;
}
export interface RequestCoreCallbacks {
- success?: (res: RequestSuccessResult) => void
- fail?: (err: RequestFailResult) => void
- complete?: (res: RequestSuccessResult | RequestFailResult) => void
+ success?: (res: RequestSuccessResult) => void;
+ fail?: (err: RequestFailResult) => void;
+ complete?: (res: RequestSuccessResult | RequestFailResult) => void;
}
/**
@@ -71,14 +74,14 @@ export interface RequestCoreCallbacks {
* bridge-router watchdog (apiCallWatchdogMs in simulator-api-metadata.ts)
* derives its window from this so the two cannot drift apart.
*/
-export const DEFAULT_REQUEST_TIMEOUT_MS = 60_000
+export const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
/**
* Largest delay setTimeout honours (2^31-1 ms). Anything above overflows the
* signed-32-bit timer register and fires ~immediately (~1ms) instead of late —
* so an oversized caller timeout must be rejected, never passed through.
*/
-export const MAX_TIMEOUT_MS = 2_147_483_647
+export const MAX_TIMEOUT_MS = 2_147_483_647;
/**
* Resolve a caller-supplied wx timeout into a usable budget: a finite positive
@@ -88,40 +91,52 @@ export const MAX_TIMEOUT_MS = 2_147_483_647
* two layers can never disagree on what a valid timeout is.
*/
export function resolveTimeoutBudgetMs(timeout: unknown): number {
- const t = Number(timeout)
- return Number.isFinite(t) && t > 0 && t <= MAX_TIMEOUT_MS ? t : DEFAULT_REQUEST_TIMEOUT_MS
+ const t = Number(timeout);
+ return Number.isFinite(t) && t > 0 && t <= MAX_TIMEOUT_MS
+ ? t
+ : DEFAULT_REQUEST_TIMEOUT_MS;
}
-function buildHeaders(header: Record | undefined): Headers {
- const headers = new Headers()
+// `willSendBody` gates the `application/json` default: a bodyless GET/HEAD
+// must not gain a content-type it never asked for, or it stops being a
+// CORS-simple request in the simulator's Chromium renderer (see the
+// module-level contract note above).
+function buildHeaders(
+ header: Record | undefined,
+ willSendBody: boolean,
+): Headers {
+ const headers = new Headers();
for (const [key, value] of Object.entries(header ?? {})) {
- if (value != null) headers.set(key, String(value))
+ if (value != null) headers.set(key, String(value));
}
- if (!headers.has('content-type')) headers.set('content-type', 'application/json')
- return headers
+ if (willSendBody && !headers.has("content-type"))
+ headers.set("content-type", "application/json");
+ return headers;
}
function appendQueryParams(url: string, data: Record): string {
// Resolve against the current document when available so page-relative URLs
// keep working in the render-window shim.
- const base = typeof location !== 'undefined' ? location.href : undefined
- const resolved = new URL(url, base)
+ const base = typeof location !== "undefined" ? location.href : undefined;
+ const resolved = new URL(url, base);
for (const [key, value] of Object.entries(data)) {
- resolved.searchParams.append(key, String(value))
+ resolved.searchParams.append(key, String(value));
}
- return resolved.toString()
+ return resolved.toString();
}
function encodeBody(data: unknown, contentType: string): BodyInit {
- if (typeof data === 'string') return data
- if (contentType.includes('application/x-www-form-urlencoded')) {
- const form = new URLSearchParams()
- for (const [key, value] of Object.entries(data as Record)) {
- form.append(key, String(value))
+ if (typeof data === "string") return data;
+ if (contentType.includes("application/x-www-form-urlencoded")) {
+ const form = new URLSearchParams();
+ for (const [key, value] of Object.entries(
+ data as Record,
+ )) {
+ form.append(key, String(value));
}
- return form.toString()
+ return form.toString();
}
- return JSON.stringify(data)
+ return JSON.stringify(data);
}
async function decodeResponseData(
@@ -129,88 +144,104 @@ async function decodeResponseData(
dataType: string,
responseType: string,
): Promise {
- if (responseType === 'arraybuffer' || dataType === 'arraybuffer') {
- return response.arrayBuffer()
+ if (responseType === "arraybuffer" || dataType === "arraybuffer") {
+ return response.arrayBuffer();
}
- const text = await response.text()
- if (dataType !== 'json') return text
+ const text = await response.text();
+ if (dataType !== "json") return text;
try {
- return JSON.parse(text)
+ return JSON.parse(text);
} catch {
- return text
+ return text;
}
}
+/**
+ * @deprecated No longer called by devtools' own wx.request path — that now
+ * runs through `main/services/native-request` (Node http/https in the main
+ * process), so it never participates in Chromium's Fetch/CORS algorithm.
+ * Kept here, unchanged, only because `./shared/request-core` is a published
+ * npm subpath export of `@dimina-kit/electron-runtime` (an embeddable
+ * package) — an external embedder could import this directly. Do not wire
+ * this back into any devtools call site; fix wx.request behaviour in
+ * native-request instead.
+ */
export function performRequest(
opts: RequestCoreOptions,
callbacks: RequestCoreCallbacks,
): RequestHandle {
- const method = (opts.method || 'GET').toUpperCase()
- const canHaveBody = method !== 'GET' && method !== 'HEAD'
- const headers = buildHeaders(opts.header)
+ const method = (opts.method || "GET").toUpperCase();
+ const canHaveBody = method !== "GET" && method !== "HEAD";
+ const willSendBody = canHaveBody && opts.data != null;
+ const headers = buildHeaders(opts.header, willSendBody);
- let url = opts.url
- const init: RequestInit = { method, headers }
+ let url = opts.url;
+ const init: RequestInit = { method, headers };
if (!canHaveBody) {
- if (opts.data && typeof opts.data === 'object') {
- url = appendQueryParams(url, opts.data as Record)
+ if (opts.data && typeof opts.data === "object") {
+ url = appendQueryParams(url, opts.data as Record);
}
} else if (opts.data != null) {
- init.body = encodeBody(opts.data, headers.get('content-type') ?? '')
+ init.body = encodeBody(opts.data, headers.get("content-type") ?? "");
}
- const controller = new AbortController()
- init.signal = controller.signal
+ const controller = new AbortController();
+ init.signal = controller.signal;
// First verdict wins: a timeout/abort settles the call even though the fetch
// promise is still pending, and the fetch's own late resolution/AbortError
// rejection must not fire a second callback round.
- let settled = false
+ let settled = false;
function settleSuccess(res: RequestSuccessResult): void {
- if (settled) return
- settled = true
- clearTimeout(timer)
- callbacks.success?.(res)
- callbacks.complete?.(res)
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ callbacks.success?.(res);
+ callbacks.complete?.(res);
}
function settleFail(err: RequestFailResult): void {
- if (settled) return
- settled = true
- clearTimeout(timer)
- callbacks.fail?.(err)
- callbacks.complete?.(err)
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ callbacks.fail?.(err);
+ callbacks.complete?.(err);
}
- const timeoutMs = resolveTimeoutBudgetMs(opts.timeout)
+ const timeoutMs = resolveTimeoutBudgetMs(opts.timeout);
const timer = setTimeout(() => {
- settleFail({ errMsg: 'request:fail timeout' })
- controller.abort()
- }, timeoutMs)
+ settleFail({ errMsg: "request:fail timeout" });
+ controller.abort();
+ }, timeoutMs);
- const dataType = opts.dataType ?? 'json'
- const responseType = opts.responseType ?? 'text'
+ const dataType = opts.dataType ?? "json";
+ const responseType = opts.responseType ?? "text";
fetch(url, init)
.then(async (response) => {
- const header: Record = {}
+ const header: Record = {};
response.headers.forEach((value, key) => {
- header[key] = value
- })
- const data = await decodeResponseData(response, dataType, responseType)
- settleSuccess({ data, statusCode: response.status, header, errMsg: 'request:ok' })
+ header[key] = value;
+ });
+ const data = await decodeResponseData(response, dataType, responseType);
+ settleSuccess({
+ data,
+ statusCode: response.status,
+ header,
+ errMsg: "request:ok",
+ });
})
.catch((error: unknown) => {
- const reason = error instanceof Error ? error.message : String(error)
- settleFail({ errMsg: `request:fail ${reason || 'network error'}` })
- })
+ const reason = error instanceof Error ? error.message : String(error);
+ settleFail({ errMsg: `request:fail ${reason || "network error"}` });
+ });
return {
abort() {
- settleFail({ errMsg: 'request:fail abort' })
- controller.abort()
+ settleFail({ errMsg: "request:fail abort" });
+ controller.abort();
},
- }
+ };
}
diff --git a/packages/dimina-electron-runtime/src/shared/simulator-api-metadata.ts b/packages/dimina-electron-runtime/src/shared/simulator-api-metadata.ts
index ce1c08aa..fdfba79e 100644
--- a/packages/dimina-electron-runtime/src/shared/simulator-api-metadata.ts
+++ b/packages/dimina-electron-runtime/src/shared/simulator-api-metadata.ts
@@ -16,12 +16,14 @@
* truth, consumed by `bridge-router` (skip the one-shot timeout, keep-alive
* responses) and `run-api-async` (no premature settle, re-fire on every event).
*/
-import { MAX_TIMEOUT_MS, resolveTimeoutBudgetMs } from './request-core.js'
+import { MAX_TIMEOUT_MS, resolveTimeoutBudgetMs } from "./request-core.js";
-export const PERSISTENT_SIMULATOR_APIS: ReadonlySet = new Set(['audioListen'])
+export const PERSISTENT_SIMULATOR_APIS: ReadonlySet = new Set([
+ "audioListen",
+]);
export function isPersistentSimulatorApi(name: string): boolean {
- return PERSISTENT_SIMULATOR_APIS.has(name)
+ return PERSISTENT_SIMULATOR_APIS.has(name);
}
/**
@@ -30,16 +32,15 @@ export function isPersistentSimulatorApi(name: string): boolean {
* handler answers when the network answers, not within a fixed router window.
*/
export const NETWORK_BUDGET_SIMULATOR_APIS: ReadonlySet = new Set([
- 'request',
- 'downloadFile',
- 'uploadFile',
-])
+ "downloadFile",
+ "uploadFile",
+]);
/**
* Flat watchdog window for forwarded one-shot calls whose handler is expected
* to answer promptly; it guards against a missing handler / dead seam.
*/
-export const API_CALL_WATCHDOG_MS = 5_000
+export const API_CALL_WATCHDOG_MS = 5_000;
/**
* How long bridge-router's one-shot "no handler" watchdog waits before
@@ -56,10 +57,10 @@ export function apiCallWatchdogMs(
name: string,
params: Record | undefined,
): number {
- if (!NETWORK_BUDGET_SIMULATOR_APIS.has(name)) return API_CALL_WATCHDOG_MS
+ if (!NETWORK_BUDGET_SIMULATOR_APIS.has(name)) return API_CALL_WATCHDOG_MS;
// resolveTimeoutBudgetMs rejects non-finite/oversized caller timeouts, and
// the final clamp keeps budget+grace inside setTimeout's range — an
// overflowing delay would wrap to ~1ms and fire the watchdog immediately.
- const budget = resolveTimeoutBudgetMs(params?.timeout)
- return Math.min(budget + API_CALL_WATCHDOG_MS, MAX_TIMEOUT_MS)
+ const budget = resolveTimeoutBudgetMs(params?.timeout);
+ return Math.min(budget + API_CALL_WATCHDOG_MS, MAX_TIMEOUT_MS);
}
From 8732d6a9b4e71b89bf71eb8b18729fe04c77f0c6 Mon Sep 17 00:00:00 2001
From: yangyu <991017358@qq.com>
Date: Mon, 7 Sep 2026 14:53:32 +0800
Subject: [PATCH 2/4] =?UTF-8?q?fix(devtools):=20=E4=B8=BA=E8=87=AA?=
=?UTF-8?q?=E5=AE=9A=E4=B9=89=20API=20=E4=BC=A0=E9=80=92=E8=B0=83=E7=94=A8?=
=?UTF-8?q?=E9=A1=B9=E7=9B=AE=E4=B8=8A=E4=B8=8B=E6=96=87?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
保持自定义 API 注册及清理归应用所有,由各窗口的调用入口附带所属项目路径,供宿主区分并发项目。上下文与小程序参数分离,不随窗口焦点切换;保留原有单参数回调兼容。
补充多窗口调用来源和元信息透传回归,保留关闭窗口、注册撤销及动态菜单测试。验证:52 项相关测试通过;electron-runtime 代码构建、devtools 主进程构建和改动文件 ESLint 通过。按本次验证约束沿用聚焦验证,未重复运行全仓 gate,未运行 E2E。
---
.../main/app/instance-simulator-api.test.ts | 2 +-
.../main/app/simulator-api-app-level.test.ts | 23 ++++++++++++++++---
.../src/main/services/workbench-context.ts | 14 +++++++----
packages/devtools/src/shared/types.ts | 2 ++
.../services/simulator/custom-apis.test.ts | 7 ++++++
.../main/services/simulator/custom-apis.ts | 16 +++++++++----
6 files changed, 52 insertions(+), 12 deletions(-)
diff --git a/packages/devtools/src/main/app/instance-simulator-api.test.ts b/packages/devtools/src/main/app/instance-simulator-api.test.ts
index 5ba79d60..7512a346 100644
--- a/packages/devtools/src/main/app/instance-simulator-api.test.ts
+++ b/packages/devtools/src/main/app/instance-simulator-api.test.ts
@@ -284,7 +284,7 @@ describe('Requirement B: instance.registerSimulatorApi', () => {
expect(reg.list()).toContain('host.api')
const result = await reg.invoke('host.api', { v: 1 })
- expect(handler).toHaveBeenCalledWith({ v: 1 })
+ expect(handler).toHaveBeenCalledWith({ v: 1 }, { projectPath: null })
expect(result).toEqual({ echoed: { v: 1 } })
await instance.dispose()
diff --git a/packages/devtools/src/main/app/simulator-api-app-level.test.ts b/packages/devtools/src/main/app/simulator-api-app-level.test.ts
index ea95646c..9fc9122f 100644
--- a/packages/devtools/src/main/app/simulator-api-app-level.test.ts
+++ b/packages/devtools/src/main/app/simulator-api-app-level.test.ts
@@ -1,9 +1,8 @@
/**
* SIMULATOR CUSTOM APIs ARE APP-LEVEL, NOT PER-WINDOW.
*
- * `ctx.simulatorApis` is the ONE registry owned by `AppServices` — every
- * window's context points at the same object (workbench-context.ts takes
- * `appServices.simulatorApis`). So a name registered once is visible to every
+ * `AppServices` owns the shared registrations. Each window's invocation
+ * facade adds its own project context. A name registered once is visible to every
* window, including windows opened later: the service host reads the
* registered names off the registry when it spawns.
*
@@ -23,6 +22,24 @@ import {
const state = registerRuntimeTestLifecycle()
describe('simulator custom API lifetime across project windows', () => {
+ it('dispatches each window with its own project even when another window is focused', async () => {
+ const instance = await state.createDevtoolsRuntime({})
+ instance.registerSimulatorApi('projectIdentity', async (_params, context) => context?.projectPath)
+ const first = await openProjectWindow(instance, '/tmp/simApiIdentityA')
+ const second = await openProjectWindow(instance, '/tmp/simApiIdentityB')
+ await first.context.workspace.openProject('/tmp/simApiIdentityA')
+ await second.context.workspace.openProject('/tmp/simApiIdentityB')
+
+ expect(instance.context.workspace.hasActiveSession()).toBe(false)
+ second.window.emit('focus')
+ await expect(Promise.all([
+ first.context.simulatorApis.invoke('projectIdentity', { projectPath: '/forged' }),
+ second.context.simulatorApis.invoke('projectIdentity', {}),
+ ])).resolves.toEqual(['/tmp/simApiIdentityA', '/tmp/simApiIdentityB'])
+
+ await instance.dispose()
+ })
+
it('keeps host APIs working in the windows that stay open when one window closes', async () => {
const instance = await state.createDevtoolsRuntime({})
const first = await openProjectWindow(instance, '/tmp/simApiA1')
diff --git a/packages/devtools/src/main/services/workbench-context.ts b/packages/devtools/src/main/services/workbench-context.ts
index dc3c0906..056767f4 100644
--- a/packages/devtools/src/main/services/workbench-context.ts
+++ b/packages/devtools/src/main/services/workbench-context.ts
@@ -208,9 +208,9 @@ export interface WorkbenchContext extends RuntimeContext {
* `instance.registerSimulatorApi`; read by the simulator IPC handlers.
*
* Supplied by {@link AppServices} when the caller has one, and therefore
- * shared across contexts: the host registers each handler once, so a window
- * opened afterwards must still answer those `wx.*` calls. A context built
- * without app services (focused unit tests) gets its own registry.
+ * registrations are shared across contexts. The invocation facade supplies
+ * THIS window's project path, independently of focus, without changing the
+ * app-owned handlers or their lifetime.
*/
simulatorApis: SimulatorApiRegistry
@@ -407,7 +407,13 @@ export function createWorkbenchContext(opts: CreateContextOptions): WorkbenchCon
ctx.registry.add(() => ctx.cdpSessionBroker.dispose())
ctx.trustedWindowSenderIds =
opts.appServices?.trustedWindowSenderIds ?? new Map()
- ctx.simulatorApis = opts.appServices?.simulatorApis ?? createSimulatorApiRegistry()
+ const simulatorApis = opts.appServices?.simulatorApis ?? createSimulatorApiRegistry()
+ ctx.simulatorApis = {
+ ...simulatorApis,
+ invoke: (name, params) => simulatorApis.invoke(name, params, {
+ projectPath: ctx.workspace.hasActiveSession() ? ctx.workspace.getProjectPath() : null,
+ }),
+ }
ctx.simulatorUiExtensions = createSimulatorUiExtensionRegistry()
ctx.registry.add(() => ctx.simulatorUiExtensions.clear())
ctx.windows = createWindowService(opts.mainWindow)
diff --git a/packages/devtools/src/shared/types.ts b/packages/devtools/src/shared/types.ts
index d1516a0a..d44f5c26 100644
--- a/packages/devtools/src/shared/types.ts
+++ b/packages/devtools/src/shared/types.ts
@@ -210,6 +210,8 @@ export interface WorkbenchHostInstance {
* every project window, including windows opened afterwards, and no window
* closing revokes it. It lives until the app is disposed or the returned
* Disposable revokes it — which removes only the registration it created.
+ * The handler's optional second argument identifies the calling window's
+ * project; it is supplied by the main process, separately from miniapp params.
*/
registerSimulatorApi(
name: string,
diff --git a/packages/dimina-electron-runtime/src/main/services/simulator/custom-apis.test.ts b/packages/dimina-electron-runtime/src/main/services/simulator/custom-apis.test.ts
index 26dabc9d..6aee203d 100644
--- a/packages/dimina-electron-runtime/src/main/services/simulator/custom-apis.test.ts
+++ b/packages/dimina-electron-runtime/src/main/services/simulator/custom-apis.test.ts
@@ -26,4 +26,11 @@ describe('SimulatorApiRegistry.register name validation', () => {
expect(registry.has('joinIsland')).toBe(true)
await expect(registry.invoke('joinIsland', { a: 1 })).resolves.toEqual({ echo: { a: 1 } })
})
+
+ it('passes invocation context separately from untrusted miniapp parameters', async () => {
+ const registry = createSimulatorApiRegistry()
+ registry.register('login', async (_params, context) => context?.projectPath)
+ await expect(registry.invoke('login', { projectPath: '/forged' }, { projectPath: '/actual' }))
+ .resolves.toBe('/actual')
+ })
})
diff --git a/packages/dimina-electron-runtime/src/main/services/simulator/custom-apis.ts b/packages/dimina-electron-runtime/src/main/services/simulator/custom-apis.ts
index a02ef280..b143d1b9 100644
--- a/packages/dimina-electron-runtime/src/main/services/simulator/custom-apis.ts
+++ b/packages/dimina-electron-runtime/src/main/services/simulator/custom-apis.ts
@@ -1,10 +1,18 @@
-export type SimulatorApiHandler = (params: unknown) => unknown | Promise
+/** Main-process invocation metadata, supplied by the owning host context. */
+export interface SimulatorApiCallContext {
+ readonly projectPath: string | null
+}
+
+export type SimulatorApiHandler = (
+ params: unknown,
+ context?: SimulatorApiCallContext,
+) => unknown | Promise
export interface SimulatorApiRegistry {
register(name: string, handler: SimulatorApiHandler): () => void
list(): string[]
has(name: string): boolean
- invoke(name: string, params: unknown): Promise
+ invoke(name: string, params: unknown, context?: SimulatorApiCallContext): Promise
clear(): void
}
@@ -67,10 +75,10 @@ export function createSimulatorApiRegistry(): SimulatorApiRegistry {
has(name) {
return handlers.has(name)
},
- async invoke(name, params) {
+ async invoke(name, params, context) {
const handler = handlers.get(name)
if (!handler) throw new Error(`Simulator API "${name}" is not registered`)
- return await handler(params)
+ return await (context === undefined ? handler(params) : handler(params, context))
},
clear() {
handlers.clear()
From 0a593dcbb84529503ff1e4207152909c52a9d092 Mon Sep 17 00:00:00 2001
From: yangyu <991017358@qq.com>
Date: Mon, 7 Sep 2026 15:31:27 +0800
Subject: [PATCH 3/4] =?UTF-8?q?fix(devtools):=20=E8=A1=A5=E5=85=A8?=
=?UTF-8?q?=E5=8E=9F=E5=A7=8B=E8=AF=B7=E6=B1=82=E5=A4=B4=E5=B1=95=E7=A4=BA?=
=?UTF-8?q?=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=93=8D=E5=BA=94=E8=A7=A3=E7=A0=81?=
=?UTF-8?q?=E5=85=BC=E5=AE=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
采集 Node HTTP/1.1 已发送请求的完整头部快照,按响应及各跳重定向传递最终字段和原始文本,使 Network 支持完整标头及 Raw 查看。采集失败或尚无响应时保留预配提示,观察数据与业务请求隔离。
恢复带 UTF-8 BOM 的 JSON/文本解码,兼容缺少 zlib 包装的 raw DEFLATE;保留二进制和调试正文原始字节,标准压缩流的校验和及截断错误仍失败。补充原始字节对照、跨源重定向、观察者隔离及解码回归。
验证:75 项 native-request 与 32 项 Network 聚焦测试通过,相关类型检查、构建、ESLint 和 diff 检查通过。独立 Electron 验证 HTTP/HTTPS、两个 Network 前端及 Raw 展示,Node 24.18 验证 BOM、raw DEFLATE 和二进制返回。按本次验证约束沿用聚焦结果,未重复全仓 gate 或完整 E2E 套件。
---
.../http-request-headers.test.ts | 42 ++++++
.../src/main/services/network-forward/http.ts | 7 +
.../native-request/http-compat.test.ts | 54 ++++++-
.../native-request/request-headers.test.ts | 134 ++++++++++++++++++
.../native-request/request-headers.ts | 31 ++++
.../main/services/native-request/response.ts | 24 +++-
.../src/main/services/native-request/trace.ts | 21 ++-
.../main/services/native-request/transport.ts | 6 +-
8 files changed, 308 insertions(+), 11 deletions(-)
create mode 100644 packages/devtools/src/main/services/network-forward/http-request-headers.test.ts
create mode 100644 packages/dimina-electron-runtime/src/main/services/native-request/request-headers.test.ts
create mode 100644 packages/dimina-electron-runtime/src/main/services/native-request/request-headers.ts
diff --git a/packages/devtools/src/main/services/network-forward/http-request-headers.test.ts b/packages/devtools/src/main/services/network-forward/http-request-headers.test.ts
new file mode 100644
index 00000000..d25e94ab
--- /dev/null
+++ b/packages/devtools/src/main/services/network-forward/http-request-headers.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest'
+import { RequestTraceSynthesizer } from './http.js'
+
+const sent = { type: 'sent' as const, requestId: 'r', url: 'https://example.com/start', method: 'GET', headers: {}, time: 0 }
+const actual = {
+ requestHeaders: { Host: 'example.com', Connection: 'keep-alive', 'X-Repeated': 'one\ntwo' },
+ requestHeadersText: 'GET /start HTTP/1.1\r\nHost: example.com\r\nConnection: keep-alive\r\nX-Repeated: one\r\nX-Repeated: two\r\n\r\n',
+}
+const response = { type: 'response' as const, requestId: 'r', status: 200, statusText: 'OK', headers: {}, time: 1 }
+
+describe('native HTTP request headers in CDP', () => {
+ it('supplies actual fields and verbatim source through the response understood by Chromium', () => {
+ const synth = new RequestTraceSynthesizer({ epoch: 'test' })
+ synth.synthesize('owner', sent)
+ expect(synth.synthesize('owner', { ...response, ...actual })).toMatchObject({
+ method: 'Network.responseReceived', params: { response: actual },
+ })
+ })
+
+ it('attaches the previous hop source to redirectResponse without contaminating the next request', () => {
+ const synth = new RequestTraceSynthesizer({ epoch: 'test' })
+ synth.synthesize('owner', sent)
+ expect(synth.synthesize('owner', {
+ ...sent, type: 'redirect', url: 'https://other.example/end', time: 1,
+ redirectResponse: { url: sent.url, status: 302, statusText: 'Found', headers: { location: 'https://other.example/end' }, ...actual },
+ })).toMatchObject({ params: { request: { headers: {} }, redirectResponse: actual } })
+ const message = synth.synthesize('owner', response)!
+ expect(message).toMatchObject({ params: { response: { url: 'https://other.example/end' } } })
+ expect(message.params).not.toHaveProperty('response.requestHeaders')
+ expect(message.params).not.toHaveProperty('response.requestHeadersText')
+ })
+
+ it('does not manufacture actual headers when unavailable or replace a failed request with a response', () => {
+ const synth = new RequestTraceSynthesizer({ epoch: 'test' })
+ synth.synthesize('owner', sent)
+ const message = synth.synthesize('owner', response)!
+ expect(message.params).not.toHaveProperty('response.requestHeaders')
+ expect(message.params).not.toHaveProperty('response.requestHeadersText')
+ expect(synth.synthesize('owner', { type: 'failed', requestId: 'r', errorText: 'request:fail abort', time: 2 }))
+ .toMatchObject({ method: 'Network.loadingFailed', params: { errorText: 'request:fail abort' } })
+ })
+})
diff --git a/packages/devtools/src/main/services/network-forward/http.ts b/packages/devtools/src/main/services/network-forward/http.ts
index 30de9ea5..598fd322 100644
--- a/packages/devtools/src/main/services/network-forward/http.ts
+++ b/packages/devtools/src/main/services/network-forward/http.ts
@@ -145,6 +145,13 @@ export class RequestTraceSynthesizer {
status: event.status,
statusText: event.statusText,
headers: event.headers,
+ // Chromium uses these together to replace provisional headers
+ // and expose the verbatim source. ExtraInfo has no raw request
+ // text and would override this response snapshot in the frontend.
+ ...(event.requestHeaders && event.requestHeadersText ? {
+ requestHeaders: event.requestHeaders,
+ requestHeadersText: event.requestHeadersText,
+ } : {}),
mimeType: mimeTypeOf(event.headers),
connectionReused: false,
connectionId: 0,
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/http-compat.test.ts b/packages/dimina-electron-runtime/src/main/services/native-request/http-compat.test.ts
index 9fb52b16..52c631af 100644
--- a/packages/dimina-electron-runtime/src/main/services/native-request/http-compat.test.ts
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/http-compat.test.ts
@@ -1,5 +1,5 @@
import http from 'node:http'
-import { brotliCompressSync, deflateSync, gzipSync } from 'node:zlib'
+import { brotliCompressSync, deflateRawSync, deflateSync, gzipSync } from 'node:zlib'
import { afterEach, describe, expect, it } from 'vitest'
import { createNativeRequestService } from './index.js'
import type { NativeRequestTrace } from './trace.js'
@@ -39,6 +39,31 @@ describe('native HTTP migration compatibility', () => {
expect(await service.request('owner', 'r', { url: '/echo' })).toMatchObject({ errMsg: expect.stringContaining('request:fail') })
})
+ it.each(['json', 'text', 'arraybuffer'])('decodes a UTF-8 BOM response as %s without changing captured bytes', async (responseKind) => {
+ const wireBody = Buffer.from('\uFEFF{"ok":true}')
+ const url = await serve((_req, res) => {
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
+ res.end(wireBody)
+ })
+ const { service, events } = tracedService()
+ const result = await service.request('owner', 'r', {
+ url,
+ ...(responseKind === 'arraybuffer' ? { responseType: 'arraybuffer' } : { dataType: responseKind }),
+ })
+ expect(result).toMatchObject({ statusCode: 200, errMsg: 'request:ok' })
+ if (!('data' in result)) throw new Error('missing response')
+ if (responseKind === 'arraybuffer') {
+ expect(result.data).toBeInstanceOf(ArrayBuffer)
+ expect(Buffer.from(result.data as ArrayBuffer)).toEqual(wireBody)
+ } else {
+ expect(result.data).toEqual(responseKind === 'json' ? { ok: true } : '{"ok":true}')
+ }
+ const terminal = events.at(-1)
+ expect(terminal).toMatchObject({ type: 'finished', encodedDataLength: wireBody.length })
+ if (terminal?.type !== 'finished') throw new Error('missing completion')
+ expect(Buffer.from(terminal.body!, 'base64')).toEqual(wireBody)
+ })
+
it.each([['gzip', gzipSync], ['deflate', deflateSync], ['br', brotliCompressSync]] as const)(
'decodes %s before JSON parsing and Network body capture', async (encoding, compress) => {
const encoded = compress('{"ok":true}')
@@ -62,6 +87,33 @@ describe('native HTTP migration compatibility', () => {
expect(events.map((event) => event.type)).toEqual(['sent', 'response', 'failed'])
})
+ it('accepts a raw DEFLATE response while preserving its wire size in Network', async () => {
+ const encoded = deflateRawSync('{"ok":true}')
+ const url = await serve((_req, res) => {
+ res.writeHead(200, { 'content-encoding': 'deflate' })
+ res.end(encoded)
+ })
+ const { service, events } = tracedService()
+ expect(await service.request('owner', 'r', { url })).toMatchObject({ statusCode: 200, data: { ok: true } })
+ const terminal = events.at(-1)
+ expect(terminal).toMatchObject({ type: 'finished', encodedDataLength: encoded.length })
+ if (terminal?.type !== 'finished') throw new Error('missing completion')
+ expect(Buffer.from(terminal.body!, 'base64').toString()).toBe('{"ok":true}')
+ })
+
+ it.each(['checksum', 'truncated'])('rejects a standard DEFLATE response with a %s error', async (corruption) => {
+ let encoded = deflateSync('{"ok":true}')
+ if (corruption === 'checksum') encoded[encoded.length - 1]! ^= 1
+ else encoded = encoded.subarray(0, encoded.length - 1)
+ const url = await serve((_req, res) => {
+ res.writeHead(200, { 'content-encoding': 'deflate' })
+ res.end(encoded)
+ })
+ const { service, events } = tracedService()
+ expect(await service.request('owner', 'r', { url })).toMatchObject({ errMsg: expect.stringContaining('request:fail') })
+ expect(events.map(event => event.type)).toEqual(['sent', 'response', 'failed'])
+ })
+
it.each([301, 302, 303, 307, 308])('follows HTTP %s with the appropriate method and body', async (status) => {
const url = await serve((req, res) => {
if (req.url === '/start') { res.writeHead(status, { location: './end' }); res.end(); return }
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/request-headers.test.ts b/packages/dimina-electron-runtime/src/main/services/native-request/request-headers.test.ts
new file mode 100644
index 00000000..6db88543
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/request-headers.test.ts
@@ -0,0 +1,134 @@
+import net from 'node:net'
+import { afterEach, describe, expect, it } from 'vitest'
+import { createNativeRequestService } from './index.js'
+import type { NativeRequestTrace } from './trace.js'
+import { createRequestTracer } from './trace.js'
+import { captureRequestHeaders } from './request-headers.js'
+
+const cleanups: Array<() => void | Promise> = []
+afterEach(async () => {
+ for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
+})
+
+async function rawServer(reply: (headers: string) => string) {
+ const received: string[] = []
+ const sockets = new Set()
+ const server = net.createServer(socket => {
+ sockets.add(socket)
+ socket.on('close', () => sockets.delete(socket))
+ let data = ''
+ let handled = false
+ socket.on('data', chunk => {
+ if (handled) return
+ data += chunk.toString('latin1')
+ const end = data.indexOf('\r\n\r\n')
+ if (end < 0) return
+ handled = true
+ const headers = data.slice(0, end + 4)
+ received.push(headers)
+ socket.end(reply(headers))
+ })
+ })
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
+ cleanups.push(async () => {
+ for (const socket of sockets) socket.destroy()
+ await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
+ })
+ const address = server.address() as net.AddressInfo
+ return { url: `http://127.0.0.1:${address.port}`, received }
+}
+
+function tracedService() {
+ const service = createNativeRequestService()
+ cleanups.push(() => service.dispose())
+ const events: NativeRequestTrace[] = []
+ service.setTracer((_owner, event) => events.push(event))
+ return { service, events }
+}
+
+const ok = 'HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok'
+
+describe('native request final wire headers', () => {
+ it.each(['GET', 'POST'])('captures the exact %s header block received by the server, including Node defaults', async method => {
+ const server = await rawServer(() => ok)
+ const { service, events } = tracedService()
+ const result = await service.request('owner', 'request', {
+ url: `${server.url}/path?query=1`, method, header: { 'X-Trace-Test': 'custom' },
+ ...(method === 'POST' ? { data: { text: '你好' } } : {}),
+ })
+ expect(result).toMatchObject({ errMsg: 'request:ok', statusCode: 200, data: 'ok' })
+ expect(events.map(event => event.type)).toEqual(['sent', 'response', 'finished'])
+ const response = events.find(event => event.type === 'response')!
+ expect(response).toMatchObject({
+ requestHeadersText: server.received[0],
+ requestHeaders: { Host: new URL(server.url).host, Connection: 'keep-alive', 'x-trace-test': 'custom' },
+ })
+ if (method === 'POST') expect(response).toMatchObject({ requestHeaders: { 'Transfer-Encoding': 'chunked' } })
+ })
+
+ it.each([302, 307])('keeps each %s redirect hop headers separate and preserves method/body rules', async status => {
+ const server = await rawServer(headers => headers.includes('/start ')
+ ? `HTTP/1.1 ${status} Redirect\r\nLocation: /end\r\nContent-Length: 0\r\nConnection: close\r\n\r\n`
+ : ok)
+ const { service, events } = tracedService()
+ expect(await service.request('owner', 'request', { url: `${server.url}/start`, method: 'POST', data: { a: 1 } }))
+ .toMatchObject({ errMsg: 'request:ok' })
+ expect(events.map(event => event.type)).toEqual(['sent', 'redirect', 'response', 'finished'])
+ expect(events.find(event => event.type === 'redirect')).toMatchObject({
+ redirectResponse: { requestHeadersText: server.received[0], requestHeaders: { 'Transfer-Encoding': 'chunked' } },
+ })
+ expect(events.find(event => event.type === 'response')).toMatchObject({ requestHeadersText: server.received[1] })
+ expect(server.received[1]).toMatch(status === 302 ? /^GET \/end / : /^POST \/end /)
+ expect(server.received[1]!.includes('Transfer-Encoding: chunked')).toBe(status === 307)
+ })
+
+ it('captures the destination Host and excludes credentials stripped on a cross-origin redirect', async () => {
+ const target = await rawServer(() => ok)
+ const source = await rawServer(() => `HTTP/1.1 302 Found\r\nLocation: ${target.url}/end\r\nContent-Length: 0\r\nConnection: close\r\n\r\n`)
+ const { service, events } = tracedService()
+ await service.request('owner', 'request', {
+ url: source.url, header: { Authorization: 'test-only', Cookie: 'test=value', Host: 'custom.example' },
+ })
+ expect(events.find(event => event.type === 'redirect')).toMatchObject({ redirectResponse: { requestHeadersText: source.received[0] } })
+ const response = events.find(event => event.type === 'response')!
+ expect(response).toMatchObject({ requestHeadersText: target.received[0], requestHeaders: { Host: new URL(target.url).host } })
+ expect(target.received[0]).not.toMatch(/authorization:|cookie:|custom\.example/i)
+ })
+
+ it('leaves failures without a response provisional and emits only one terminal', async () => {
+ const { service, events } = tracedService()
+ const result = await service.request('owner', 'request', { url: 'http://127.0.0.1:1' })
+ expect(result.errMsg).toContain('request:fail')
+ expect(events.map(event => event.type)).toEqual(['sent', 'failed'])
+ for (const event of events) {
+ expect(event).not.toHaveProperty('requestHeaders')
+ expect(event).not.toHaveProperty('requestHeadersText')
+ }
+ })
+
+ it('preserves casing, whitespace and duplicate header lines in source while exposing every field to CDP', () => {
+ const text = 'POST /path?q=1 HTTP/1.1\r\nX-Repeated: one\r\nx-repeated:\ttwo \r\nEmpty: \r\n__proto__: literal\r\nContent-Length: 0\r\n\r\n'
+ const captured = captureRequestHeaders({ _header: text })!
+ expect(captured.requestHeadersText).toBe(text)
+ expect(captured.requestHeaders).toEqual({ 'X-Repeated': 'one\ntwo', Empty: '', ['__proto__']: 'literal', 'Content-Length': '0' })
+ })
+
+ it.each([undefined, {}, { _header: null }, { _header: 42 }, { _header: 'GET / HTTP/1.1\r\nHost: partial' },
+ { _header: 'invalid\r\n\r\n' }, { _header: 'GET / HTTP/1.1\r\ninvalid\r\n\r\n' },
+ { get _header() { throw new Error('unavailable') } },
+ ])('ignores unavailable or invalid snapshots without throwing (%#)', request => {
+ expect(captureRequestHeaders(request)).toBeUndefined()
+ })
+
+ it('isolates observer mutation of request headers for both responses and redirects', () => {
+ const actual = { requestHeaders: { Host: 'original.example' }, requestHeadersText: 'GET / HTTP/1.1\r\nHost: original.example\r\n\r\n' }
+ const tracer = createRequestTracer(() => (_owner, event) => {
+ if (event.type === 'response') event.requestHeaders!.Host = 'changed'
+ if (event.type === 'redirect') event.redirectResponse.requestHeaders!.Host = 'changed'
+ }, 'owner', 'request')
+ tracer.response(200, 'OK', {}, actual)
+ expect(actual.requestHeaders.Host).toBe('original.example')
+ tracer.redirect('http://next.example', 'GET', {}, undefined, { url: 'http://original.example', status: 302, statusText: 'Found', headers: {}, ...actual })
+ expect(actual.requestHeaders.Host).toBe('original.example')
+ })
+})
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/request-headers.ts b/packages/dimina-electron-runtime/src/main/services/native-request/request-headers.ts
new file mode 100644
index 00000000..2813d0b0
--- /dev/null
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/request-headers.ts
@@ -0,0 +1,31 @@
+import type { NativeRequestHeaders } from './trace.js'
+
+/** Read only after a response confirms this hop was sent. Node's public
+ * getHeaders() omits automatically serialized fields such as Connection and
+ * Transfer-Encoding. Keep the private HTTP/1.1 snapshot access isolated: an
+ * unavailable snapshot leaves DevTools provisional without affecting I/O. */
+export function captureRequestHeaders(request: unknown): NativeRequestHeaders | undefined {
+ try {
+ const text = (request as { _header?: unknown } | undefined)?._header
+ if (typeof text !== 'string' || !text.endsWith('\r\n\r\n')) return undefined
+ const lines = text.slice(0, -4).split('\r\n')
+ if (!/^\S+ \S+ HTTP\/1\.1$/.test(lines.shift() ?? '')) return undefined
+ const requestHeaders: Record = Object.create(null)
+ const names = new Map()
+ for (const line of lines) {
+ const colon = line.indexOf(':')
+ const name = line.slice(0, colon)
+ if (colon < 1 || !/^[!#$%&'*+.^_`|~\w-]+$/.test(name)) return undefined
+ const value = line.slice(colon + 1).replace(/^[\t ]+|[\t ]+$/g, '')
+ const previous = names.get(name.toLowerCase())
+ if (previous !== undefined) requestHeaders[previous] += `\n${value}`
+ else {
+ names.set(name.toLowerCase(), name)
+ requestHeaders[name] = value
+ }
+ }
+ return { requestHeaders, requestHeadersText: text }
+ } catch {
+ return undefined
+ }
+}
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/response.ts b/packages/dimina-electron-runtime/src/main/services/native-request/response.ts
index 4021d8be..8d55bff1 100644
--- a/packages/dimina-electron-runtime/src/main/services/native-request/response.ts
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/response.ts
@@ -1,10 +1,27 @@
import { promisify } from 'node:util'
-import { brotliDecompress, gunzip, inflate } from 'node:zlib'
+import { brotliDecompress, gunzip, inflate, inflateRaw } from 'node:zlib'
+
+const inflateBody = promisify(inflate)
+const inflateRawBody = promisify(inflateRaw)
+
+async function decodeDeflate(buffer: Buffer): Promise {
+ try {
+ return await inflateBody(buffer)
+ } catch (error) {
+ const cmf = buffer[0] ?? 0
+ const flg = buffer[1] ?? 0
+ const zlibWrapped = buffer.length >= 2 && (cmf & 15) === 8 && (cmf >> 4) <= 7 && ((cmf << 8) | flg) % 31 === 0
+ // Browsers also accept raw DEFLATE under this encoding. A recognized
+ // zlib stream must retain checksum/truncation errors instead of retrying.
+ if (zlibWrapped || !(error instanceof Error) || !('code' in error) || error.code !== 'Z_DATA_ERROR') throw error
+ return inflateRawBody(buffer)
+ }
+}
const decompressors = {
gzip: promisify(gunzip),
'x-gzip': promisify(gunzip),
- deflate: promisify(inflate),
+ deflate: decodeDeflate,
br: promisify(brotliDecompress),
}
@@ -22,7 +39,8 @@ export function decodeResponseData(buffer: Buffer, dataType = 'json', responseTy
if (responseType === 'arraybuffer' || dataType === 'arraybuffer') {
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
}
- const text = buffer.toString('utf-8')
+ // Match Fetch's UTF-8 decoding, including removal of a leading BOM.
+ const text = new TextDecoder('utf-8').decode(buffer)
if (dataType !== 'json') return text
try { return JSON.parse(text) } catch { return text }
}
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/trace.ts b/packages/dimina-electron-runtime/src/main/services/native-request/trace.ts
index e4106218..b58e4cae 100644
--- a/packages/dimina-electron-runtime/src/main/services/native-request/trace.ts
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/trace.ts
@@ -15,7 +15,14 @@
/** Matches the Network body cache's per-entry character budget. */
export const NATIVE_REQUEST_TRACE_MAX_CHARS = 16 * 1024 * 1024
-export interface NativeRequestRedirectResponse {
+export interface NativeRequestHeaders {
+ /** Final serialized fields, including Node defaults; repeated values use CDP's newline separator. */
+ requestHeaders: Record
+ /** Verbatim HTTP/1.1 request line and header block, including its trailing CRLF. */
+ requestHeadersText: string
+}
+
+export interface NativeRequestRedirectResponse extends Partial {
url: string
status: number
statusText: string
@@ -50,6 +57,8 @@ export type NativeRequestTrace =
status: number
statusText: string
headers: Record
+ requestHeaders?: Record
+ requestHeadersText?: string
time: number
}
| {
@@ -81,7 +90,7 @@ export type NativeRequestTracer = (ownerId: string, event: NativeRequestTrace) =
export interface RequestTracer {
sent(url: string, method: string, headers: Record, postData?: string): void
redirect(url: string, method: string, headers: Record, postData: string | undefined, response: NativeRequestRedirectResponse): void
- response(status: number, statusText: string, headers: Record): void
+ response(status: number, statusText: string, headers: Record, requestHeaders?: NativeRequestHeaders): void
finished(body: string | (() => string), bodyBase64Encoded: boolean, encodedDataLength: number, decodedDataLength?: number): void
failed(errorText: string): void
}
@@ -118,16 +127,18 @@ class RequestTracerImpl implements RequestTracer {
this.emit(event)
}
- response(status: number, statusText: string, headers: Record): void {
+ response(status: number, statusText: string, headers: Record, requestHeaders?: NativeRequestHeaders): void {
if (!this.getTracer()) return
- this.emit({ type: 'response', requestId: this.requestId, status, statusText, headers: { ...headers }, time: Date.now() })
+ this.emit({ type: 'response', requestId: this.requestId, status, statusText, headers: { ...headers },
+ ...(requestHeaders ? { ...requestHeaders, requestHeaders: { ...requestHeaders.requestHeaders } } : {}), time: Date.now() })
}
redirect(url: string, method: string, headers: Record, postData: string | undefined, response: NativeRequestRedirectResponse): void {
if (!this.getTracer()) return
this.emit({ type: 'redirect', requestId: this.requestId, url, method, headers: { ...headers },
...boundedPostData(postData),
- redirectResponse: { ...response, headers: { ...response.headers } }, time: Date.now() })
+ redirectResponse: { ...response, headers: { ...response.headers },
+ ...(response.requestHeaders ? { requestHeaders: { ...response.requestHeaders } } : {}) }, time: Date.now() })
}
finished(body: string | (() => string), bodyBase64Encoded: boolean, encodedDataLength: number, decodedDataLength = encodedDataLength): void {
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/transport.ts b/packages/dimina-electron-runtime/src/main/services/native-request/transport.ts
index be836048..c2c9fda4 100644
--- a/packages/dimina-electron-runtime/src/main/services/native-request/transport.ts
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/transport.ts
@@ -14,6 +14,7 @@ import {
} from "./normalize.js";
import type { RequestTracer } from "./trace.js";
import { decodeContent, decodeResponseData } from "./response.js";
+import { captureRequestHeaders } from "./request-headers.js";
export interface NativeRequestTransport {
request(
@@ -128,6 +129,7 @@ export function createNativeRequestTransport(): NativeRequestTransport {
key, Array.isArray(value) ? value.join(", ") : value ?? "",
]));
const status = res.statusCode ?? 0;
+ const requestHeaders = tracer ? captureRequestHeaders(req) : undefined;
if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) {
try {
if (redirects++ >= 20) throw new Error("too many redirects");
@@ -135,7 +137,7 @@ export function createNativeRequestTransport(): NativeRequestTransport {
if (!["http:", "https:"].includes(nextUrl.protocol) || nextUrl.username || nextUrl.password) {
throw new Error("unsupported redirect URL");
}
- const redirectResponse = { url, status, statusText: res.statusMessage ?? "", headers: responseHeaders };
+ const redirectResponse = { url, status, statusText: res.statusMessage ?? "", headers: responseHeaders, ...requestHeaders };
if (nextUrl.origin !== parsed.origin) {
for (const key of ["authorization", "proxy-authorization", "cookie", "host"]) delete nodeHeaders[key];
}
@@ -156,7 +158,7 @@ export function createNativeRequestTransport(): NativeRequestTransport {
}
return;
}
- tracer?.response(status, res.statusMessage ?? "", responseHeaders);
+ tracer?.response(status, res.statusMessage ?? "", responseHeaders, requestHeaders);
if (settled) return;
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
From 66ba0c4f4a9413614cfacc4ddd29aca2fcd652da Mon Sep 17 00:00:00 2001
From: yangyu <991017358@qq.com>
Date: Mon, 7 Sep 2026 16:01:21 +0800
Subject: [PATCH 4/4] =?UTF-8?q?fix(devtools):=20=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=E5=8E=9F=E7=94=9F=E8=AF=B7=E6=B1=82=E9=93=BE=E8=B7=AF=E7=9A=84?=
=?UTF-8?q?=20Pawl=20=E9=97=A8=E7=A6=81=E5=9B=9E=E5=BD=92?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
复用请求头和正文编码,拆分 CDP 请求事件合成与 HTTP 重定向处理,保留现有请求及终态行为。更新 watchdog 测试中的过时注释并保留全部断言。
验证:148 个聚焦测试通过;electron-runtime build:code、devtools build:main 及修改文件 ESLint 通过;Pawl file-length、cognitive-complexity、code-duplication 三项检查通过。
---
.../bridge-router-request-watchdog.test.ts | 37 ++-------
.../src/main/services/network-forward/http.ts | 83 ++++++++++---------
.../main/services/native-request/normalize.ts | 29 +------
.../main/services/native-request/transport.ts | 63 ++++++++------
.../src/shared/request-core.ts | 32 +------
.../src/shared/request-encoding.ts | 28 +++++++
6 files changed, 118 insertions(+), 154 deletions(-)
create mode 100644 packages/dimina-electron-runtime/src/shared/request-encoding.ts
diff --git a/packages/devtools/src/main/ipc/bridge-router-request-watchdog.test.ts b/packages/devtools/src/main/ipc/bridge-router-request-watchdog.test.ts
index 2ebc8722..b8a233eb 100644
--- a/packages/devtools/src/main/ipc/bridge-router-request-watchdog.test.ts
+++ b/packages/devtools/src/main/ipc/bridge-router-request-watchdog.test.ts
@@ -1,23 +1,8 @@
/**
- * The one-shot "no handler" watchdog that guards a forwarded API call
- * (`forwardApiCallToSimulator` in bridge-router.ts) currently arms a flat
- * `API_CALL_TIMEOUT_MS` (5000ms) for every call regardless of name or params.
- *
- * For `request` (and any other network-budget API: downloadFile, uploadFile)
- * this races the wx.request contract, whose real timeout budget is the
- * caller's `params.timeout` (default 60000ms). A slow-but-legitimate HTTP
- * round trip past 5s gets its pending entry deleted by the watchdog, and the
- * later-arriving `API_RESPONSE` (200 or 401 alike) is silently dropped
- * because `handleApiResponse` no-ops on a requestId it no longer has pending.
- *
- * Contract pinned: the watchdog window must scale with `apiCallWatchdogMs`
- * (shared/simulator-api-metadata.ts) — network-budget APIs get
- * `timeout-or-60000 + 5000` grace, everything else keeps the flat 5000ms.
- *
- * Seam: identical harness to bridge-router-api-fail-passthrough.test.ts
- * (exhaustive electron mock, real `installBridgeRouter` driven through
- * SPAWN → SERVICE_INVOKE(invokeAPI) → API_RESPONSE), plus fake timers per
- * bridge-router-keep-api.test.ts to control the watchdog clock precisely.
+ * Forwarded network APIs get their timeout budget plus watchdog grace;
+ * other forwarded calls get 5s. An ack disarms only the watchdog, keeping
+ * the call pending. Native wx.request owns its deadline and bypasses
+ * simulator forwarding entirely.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -463,19 +448,7 @@ describe('bridge-router — `request` never uses the simulator-forwarding watchd
})
})
-// ─── ack: a simulator-side "still working on it" signal must extend, not resolve, the pending call ──
-//
-// `handleApiResponse` currently treats ANY `API_RESPONSE` with a matching
-// `requestId` as the terminal verdict: `payload.ok` truthy -> success,
-// falsy -> fail — there is no third "acknowledged, not done yet" outcome.
-// `ApiResponsePayload` (bridge-channels.ts) has no `ack` field either. This
-// is a genuinely new (not yet implemented) wire contract, not a bug in an
-// existing one; the wire shape below (`{ requestId, ack: true }`, no `ok`)
-// is a proposal, not a pinned interface — `emitOn`'s payload parameter is
-// `unknown`, so the literal object compiles without widening
-// `ApiResponsePayload` itself. The assertions pin the OBSERVABLE behavior an
-// implementation must produce: an ack must not resolve the call, and it must
-// not prevent the real, later verdict from being delivered.
+// An ack confirms the handler is running; only its later verdict settles the call.
describe('bridge-router — a simulator-side ack must not resolve the call, and must not block the later real verdict', () => {
it('an ack-shaped API_RESPONSE does not fire fail/complete, does not time out, and the later real success is still delivered', async () => {
const { simulatorWc, serviceWc, requestId } = await setup('showToast', {
diff --git a/packages/devtools/src/main/services/network-forward/http.ts b/packages/devtools/src/main/services/network-forward/http.ts
index 598fd322..faf2db09 100644
--- a/packages/devtools/src/main/services/network-forward/http.ts
+++ b/packages/devtools/src/main/services/network-forward/http.ts
@@ -86,44 +86,7 @@ export class RequestTraceSynthesizer {
): SynthesizedRequestMessage | null {
const key = `${sessionId} ${event.requestId}`;
if (event.type === "sent" || event.type === "redirect") {
- const previous = this.requests.get(key);
- if (event.type === "redirect" && !previous) return null;
- const requestId = event.type === "redirect" ? previous!.requestId
- : `${NATIVE_HTTP_REQUEST_ID_PREFIX}${this.options.epoch}:${this.seq++}`;
- const userFacing = event.type === "redirect" ? previous!.userFacing : isUserFacingRequest(
- event.url,
- this.options.internalOrigins?.(),
- );
- this.requests.set(key, { requestId, url: event.url, userFacing });
- const timestamp = event.time / 1000;
- const hasPostData = event.hasPostData ?? event.postData !== undefined;
- const message: SynthesizedRequestMessage = {
- method: "Network.requestWillBeSent",
- params: {
- requestId,
- loaderId: requestId,
- documentURL: event.url,
- request: {
- url: event.url,
- method: event.method,
- headers: event.headers,
- hasPostData,
- ...(hasPostData ? { postData: event.postData } : {}),
- },
- timestamp,
- wallTime: timestamp,
- initiator: { type: "script" },
- type: RESOURCE_TYPE,
- ...(event.type === "redirect" ? { redirectResponse: {
- ...event.redirectResponse,
- mimeType: mimeTypeOf(event.redirectResponse.headers),
- connectionReused: false, connectionId: 0, encodedDataLength: 0,
- } } : {}),
- },
- userFacing,
- };
- if (hasPostData) message.postData = event.postData;
- return message;
+ return this.synthesizeRequest(key, event);
}
const state = this.requests.get(key);
@@ -189,6 +152,50 @@ export class RequestTraceSynthesizer {
};
}
}
+
+ private synthesizeRequest(
+ key: string,
+ event: Extract,
+ ): SynthesizedRequestMessage | null {
+ const previous = this.requests.get(key);
+ if (event.type === "redirect" && !previous) return null;
+ const requestId = event.type === "redirect" ? previous!.requestId
+ : `${NATIVE_HTTP_REQUEST_ID_PREFIX}${this.options.epoch}:${this.seq++}`;
+ const userFacing = event.type === "redirect" ? previous!.userFacing : isUserFacingRequest(
+ event.url,
+ this.options.internalOrigins?.(),
+ );
+ this.requests.set(key, { requestId, url: event.url, userFacing });
+ const timestamp = event.time / 1000;
+ const hasPostData = event.hasPostData ?? event.postData !== undefined;
+ const message: SynthesizedRequestMessage = {
+ method: "Network.requestWillBeSent",
+ params: {
+ requestId,
+ loaderId: requestId,
+ documentURL: event.url,
+ request: {
+ url: event.url,
+ method: event.method,
+ headers: event.headers,
+ hasPostData,
+ ...(hasPostData ? { postData: event.postData } : {}),
+ },
+ timestamp,
+ wallTime: timestamp,
+ initiator: { type: "script" },
+ type: RESOURCE_TYPE,
+ ...(event.type === "redirect" ? { redirectResponse: {
+ ...event.redirectResponse,
+ mimeType: mimeTypeOf(event.redirectResponse.headers),
+ connectionReused: false, connectionId: 0, encodedDataLength: 0,
+ } } : {}),
+ },
+ userFacing,
+ };
+ if (hasPostData) message.postData = event.postData;
+ return message;
+ }
}
/** `Content-Type: application/json; charset=utf-8` → `application/json`. Case
diff --git a/packages/dimina-electron-runtime/src/main/services/native-request/normalize.ts b/packages/dimina-electron-runtime/src/main/services/native-request/normalize.ts
index 65ce32ff..3a1c6542 100644
--- a/packages/dimina-electron-runtime/src/main/services/native-request/normalize.ts
+++ b/packages/dimina-electron-runtime/src/main/services/native-request/normalize.ts
@@ -3,21 +3,10 @@ import {
MAX_TIMEOUT_MS,
resolveTimeoutBudgetMs,
} from "../../../shared/request-core.js";
+import { buildHeaders } from "../../../shared/request-encoding.js";
export { DEFAULT_REQUEST_TIMEOUT_MS, MAX_TIMEOUT_MS, resolveTimeoutBudgetMs };
-
-function buildHeaders(
- header: Record | undefined,
- willSendBody: boolean,
-): Headers {
- const headers = new Headers();
- for (const [key, value] of Object.entries(header ?? {})) {
- if (value != null) headers.set(key, String(value));
- }
- if (willSendBody && !headers.has("content-type"))
- headers.set("content-type", "application/json");
- return headers;
-}
+export { encodeBody } from "../../../shared/request-encoding.js";
export function normalizeRequestHeaders(
header: Record | undefined,
@@ -41,17 +30,3 @@ export function appendQueryParams(
}
return resolved.toString();
}
-
-export function encodeBody(data: unknown, contentType: string): string {
- if (typeof data === "string") return data;
- if (contentType.includes("application/x-www-form-urlencoded")) {
- const form = new URLSearchParams();
- for (const [key, value] of Object.entries(
- data as Record