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 / Sourcesservice-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 / DOMDebuggeractive render guest转发 render debugger + 推回前端 Emulation 🚫service-host(红线)safe-area 主进程直发,前端 Emulation 绝不路由 render Page / Target / Inputservice-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, - )) { - form.append(key, String(value)); - } - return form.toString(); - } - return JSON.stringify(data); -} 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 c2c9fda4..0ba3b18f 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 @@ -12,7 +12,7 @@ import { normalizeRequestHeaders, resolveTimeoutBudgetMs, } from "./normalize.js"; -import type { RequestTracer } from "./trace.js"; +import type { NativeRequestRedirectResponse, RequestTracer } from "./trace.js"; import { decodeContent, decodeResponseData } from "./response.js"; import { captureRequestHeaders } from "./request-headers.js"; @@ -111,6 +111,39 @@ export function createNativeRequestTransport(): NativeRequestTransport { let generation = 0; let redirects = 0; + function followRedirect( + res: http.IncomingMessage, + location: string, + origin: string, + redirectResponse: NativeRequestRedirectResponse, + ): void { + try { + if (redirects++ >= 20) throw new Error("too many redirects"); + const nextUrl = new URL(location, url); + if (!["http:", "https:"].includes(nextUrl.protocol) || nextUrl.username || nextUrl.password) { + throw new Error("unsupported redirect URL"); + } + if (nextUrl.origin !== origin) { + for (const key of ["authorization", "proxy-authorization", "cookie", "host"]) delete nodeHeaders[key]; + } + const { status } = redirectResponse; + 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"); + } + } + function startHop(): void { if (settled) return; const hop = ++generation; @@ -131,31 +164,9 @@ export function createNativeRequestTransport(): NativeRequestTransport { 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"); - 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, ...requestHeaders }; - 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"); - } + followRedirect(res, res.headers.location, parsed.origin, { + url, status, statusText: res.statusMessage ?? "", headers: responseHeaders, ...requestHeaders, + }); return; } tracer?.response(status, res.statusMessage ?? "", responseHeaders, requestHeaders); diff --git a/packages/dimina-electron-runtime/src/shared/request-core.ts b/packages/dimina-electron-runtime/src/shared/request-core.ts index ea19e1a5..6b3f945a 100644 --- a/packages/dimina-electron-runtime/src/shared/request-core.ts +++ b/packages/dimina-electron-runtime/src/shared/request-core.ts @@ -35,6 +35,7 @@ * Not provided: `cookies` on the success result — fetch() cannot read * Set-Cookie response headers, so surfacing a fabricated list would lie. */ +import { buildHeaders, encodeBody } from "./request-encoding.js"; export interface RequestSuccessResult { data: unknown; @@ -97,23 +98,6 @@ export function resolveTimeoutBudgetMs(timeout: unknown): number { : DEFAULT_REQUEST_TIMEOUT_MS; } -// `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 (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. @@ -125,20 +109,6 @@ function appendQueryParams(url: string, data: Record): string { 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)); - } - return form.toString(); - } - return JSON.stringify(data); -} - async function decodeResponseData( response: Response, dataType: string, diff --git a/packages/dimina-electron-runtime/src/shared/request-encoding.ts b/packages/dimina-electron-runtime/src/shared/request-encoding.ts new file mode 100644 index 00000000..c0dcdfae --- /dev/null +++ b/packages/dimina-electron-runtime/src/shared/request-encoding.ts @@ -0,0 +1,28 @@ +/** Shared wire encoding for the native transport and the published fetch helper. */ +export 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)); + } + // A bodyless request needs no default content type; explicit caller values win. + if (willSendBody && !headers.has("content-type")) + headers.set("content-type", "application/json"); + return headers; +} + +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); +}