From bf1a2bf69c76af82eb583aad596f7e3eceb81d31 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Wed, 14 Oct 2026 23:19:38 +0800
Subject: [PATCH] fix(inspector): serve the Client bootstrap while the
Inspector Worker starts
A Client plugin loaded after the page starts fetches its bootstrap once. The Host now registers that route before starting the Worker and answers once the Worker is ready, so enabling Developer Tools on a running page opens the bottom DevTools without a reload. A failed start answers 503.
When the platform cannot run the Worker (ERR_FEATURE_UNAVAILABLE_ON_PLATFORM), the Host warns once and stays active with only the 503 bootstrap route; serve-static and open load after the Worker starts. Every other startup failure still fails the fiber.
---
apps/web/tests/inspector-late-enable.e2e.ts | 56 ++++
apps/web/tsconfig.json | 1 +
.../experimental/inspector/README.i18n.yaml | 4 +-
packages/experimental/inspector/README.md | 2 +-
packages/experimental/inspector/README.zh.md | 2 +-
.../experimental/inspector/src/host/plugin.ts | 74 ++++-
.../tests/bootstrap-route.host.spec.ts | 179 +++++++++++
.../tests/plugin-routes.host.spec.ts | 286 ++++++++++++++++++
tsconfig.host.json | 1 +
vitest.config.ts | 1 -
10 files changed, 589 insertions(+), 17 deletions(-)
create mode 100644 apps/web/tests/inspector-late-enable.e2e.ts
create mode 100644 packages/experimental/inspector/tests/bootstrap-route.host.spec.ts
create mode 100644 packages/experimental/inspector/tests/plugin-routes.host.spec.ts
diff --git a/apps/web/tests/inspector-late-enable.e2e.ts b/apps/web/tests/inspector-late-enable.e2e.ts
new file mode 100644
index 0000000000..9a7cda3eee
--- /dev/null
+++ b/apps/web/tests/inspector-late-enable.e2e.ts
@@ -0,0 +1,56 @@
+/** Enabling the optional Inspector bundle on a running page mounts the bottom DevTools without a reload. */
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser } from 'playwright'
+import { expect, it, onTestFailed, onTestFinished } from 'vitest'
+import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
+import { PLUGIN_TOGGLE_SETTLE_MS, saveFailureShot } from './support.ts'
+
+const bundle = fileURLToPath(new URL('../../../packages/experimental/inspector-profile', import.meta.url))
+const BOOTSTRAP_PATH = '/api/experimental-inspector/bootstrap'
+
+it('opens bottom DevTools after the Developer Tools bundle is enabled on an already loaded page', async () => {
+ const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
+ onTestFinished(async () => {
+ try { await resources.browser?.close() } finally { await resources.scaffold?.close() }
+ })
+ const scaffold = await launchWebScaffold({
+ profile: { packages: [{ dir: bundle, enabled: false }], hmr: true },
+ extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
+ })
+ resources.scaffold = scaffold
+ // Only a running HMR service applies the bundle switch to this page without a restart.
+ expect(scaffold.ctx.get('hmr')).toBeDefined()
+ // Full Chromium matches the other Inspector browser scenarios.
+ const browser = await chromium.launch({ executablePath: chromium.executablePath() })
+ resources.browser = browser
+ const page = await browser.newPage({ locale: 'en-US', viewport: { width: 1440, height: 900 } })
+ const tripwire = watchConsole(page)
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-inspector-late-enable'))
+ const bootstraps: number[] = []
+ page.on('response', (response) => {
+ if (new URL(response.url()).pathname === BOOTSTRAP_PATH) bootstraps.push(response.status())
+ })
+ await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+ await page.evaluate(() => { Reflect.set(window, '__inspectorLateEnableMarker', 'same-document') })
+
+ await page.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click()
+ const plugins = page.locator('[data-plugin-panel]')
+ const toggle = plugins.getByRole('switch', { name: 'Enable Developer Tools', exact: true })
+ await toggle.waitFor()
+ expect(await toggle.getAttribute('aria-checked')).toBe('false')
+ const panel = page.locator('[data-inspector-panel]')
+ expect(await panel.count()).toBe(0)
+
+ await toggle.click()
+ await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true')
+ await panel.waitFor({ state: 'attached', timeout: 20_000 }).catch((error: unknown) => {
+ throw new Error(`Bottom panel did not mount; ${BOOTSTRAP_PATH} responses: ${JSON.stringify(bootstraps)}`, { cause: error })
+ })
+ expect(await panel.isVisible()).toBe(false)
+ await page.keyboard.press('ControlOrMeta+Shift+Period')
+ await panel.waitFor({ state: 'visible' })
+ await page.frameLocator('iframe[data-inspector-devtools]').getByRole('tab', { name: 'Console', exact: true }).waitFor()
+ expect(await page.evaluate((): unknown => Reflect.get(window, '__inspectorLateEnableMarker'))).toBe('same-document')
+ expect(new Set(bootstraps)).toEqual(new Set([200]))
+ expect(tripwire.pageErrors).toEqual([])
+})
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 174956d1b9..ff56fc9485 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -85,6 +85,7 @@
"tests/worktree-opt-in.e2e.ts",
"tests/session-inspector.e2e.ts",
"tests/shared-inspector.e2e.ts",
+ "tests/inspector-late-enable.e2e.ts",
"tests/devtools-frontend.e2e.ts",
"tests/inspector-panels.e2e.ts",
"tests/inspector-source-build.e2e.ts",
diff --git a/packages/experimental/inspector/README.i18n.yaml b/packages/experimental/inspector/README.i18n.yaml
index 6c6b7c70d9..e5f1706ff4 100644
--- a/packages/experimental/inspector/README.i18n.yaml
+++ b/packages/experimental/inspector/README.i18n.yaml
@@ -18,8 +18,8 @@
en: 6d290aabc543f878
zh: 7ad283b159ae2c29
/deepseek-ai-dsh-experimental-inspector/runtime-layout:
- en: 30dad7b7fe3b3465
- zh: 2bda395981802f59
+ en: 13f495af2fec307d
+ zh: ec4bebf24a87ec54
/deepseek-ai-dsh-experimental-inspector/configuration:
en: d094842893700446
zh: 7a7f880ba795948f
diff --git a/packages/experimental/inspector/README.md b/packages/experimental/inspector/README.md
index a110302dd3..9856c718e5 100644
--- a/packages/experimental/inspector/README.md
+++ b/packages/experimental/inspector/README.md
@@ -48,7 +48,7 @@ The frontend uses browser runtime adapters and excludes Node-only transports at
## Runtime layout
-The Worker never accesses live Cordis objects: the shared Host/Client collector projects them into validated snapshots before transport. Cordis owns plugin composition, `ctx.inspector` registration, bootstrap injection, and disposal. A late-loading Client fetches its bootstrap through an authenticated Host route. Bootstrap transport and source-setup failures warn and retain connection-reset recovery for both injected and fetched startup; invalid bootstrap data or service-registration failures reject startup. Source replacement joins old service-consumer cleanup before claiming another source, and unloading an active plugin cancels its pending bootstrap refresh.
+The Worker never accesses live Cordis objects: the shared Host/Client collector projects them into validated snapshots before transport. Cordis owns plugin composition, `ctx.inspector` registration, bootstrap injection, and disposal. A late-loading Client fetches its bootstrap through an authenticated Host route, which the Host registers before it starts the Worker: a request waits for Worker readiness and then receives the bootstrap, or `503` when startup fails; an aborted request stops waiting. When the platform cannot run the Worker and reports Node's `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`, such as the browser preview's Worker host, the Host logs one warning and the plugin still starts with only that route, which answers `503`; it provides no `ctx.inspector`, DevTools routes, bootstrap injection, or `devtools://` URL. Any other startup error still rejects the plugin. Bootstrap transport and source-setup failures warn and retain connection-reset recovery for both injected and fetched startup; invalid bootstrap data or service-registration failures reject startup. Source replacement joins old service-consumer cleanup before claiming another source, and unloading an active plugin cancels its pending bootstrap refresh.
The Host plugin starts the Worker and connects a dedicated `MessagePort`. The Client uses injected or fetched browser-source parameters to open an authenticated WebSocket directly to the Worker; it receives no DevTools URL. Chrome DevTools connects to the Worker's CDP WebSocket. A private `node:inspector.Session` per DevTools connection attaches from the Worker to the Host main thread, so Host debugging remains available while Host JavaScript is paused.
diff --git a/packages/experimental/inspector/README.zh.md b/packages/experimental/inspector/README.zh.md
index 619a0be665..66b25ec2cc 100644
--- a/packages/experimental/inspector/README.zh.md
+++ b/packages/experimental/inspector/README.zh.md
@@ -48,7 +48,7 @@ Host 在相对于应用根目录的 `inspector/devtools/devtools_app.html` 路
## 运行时布局
-Worker 不访问实时 Cordis 对象;共享 Host/Client collector 会在传输前把它们投影成已验证快照。Cordis 负责插件组合、注册 `ctx.inspector`、注入 bootstrap 和 dispose(资源释放)。后加载的 Client 通过已鉴权的 Host 路由获取 bootstrap。初始注入与主动获取两条路径中,bootstrap 传输或 source 建立失败均记录警告并保留连接重置后的重试;bootstrap 数据非法或服务注册失败则使启动失败。替换 source 会等待旧服务消费者清理完成,再申请新 source;卸载已激活插件会取消其未完成的 bootstrap 刷新。
+Worker 不访问实时 Cordis 对象;共享 Host/Client collector 会在传输前把它们投影成已验证快照。Cordis 负责插件组合、注册 `ctx.inspector`、注入 bootstrap 和 dispose(资源释放)。后加载的 Client 通过已鉴权的 Host 路由获取 bootstrap。Host 在启动 Worker 之前注册该路由,请求会等到 Worker 就绪后返回 bootstrap,启动失败时返回 `503`;请求中止时立即停止等待。当前平台无法运行 Worker 并报告 Node 的 `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` 时(例如浏览器预览的 Worker host),Host 记录一条警告,插件仍照常启动,只保留该路由并返回 `503`,不提供 `ctx.inspector`、DevTools 路由、bootstrap 注入或 `devtools://` URL;其他任何启动错误仍使插件启动失败。初始注入与主动获取两条路径中,bootstrap 传输或 source 建立失败均记录警告并保留连接重置后的重试;bootstrap 数据非法或服务注册失败则使启动失败。替换 source 会等待旧服务消费者清理完成,再申请新 source;卸载已激活插件会取消其未完成的 bootstrap 刷新。
Host 插件启动 Worker 并连接专用 `MessagePort`。Client 使用注入或主动获取的浏览器 source 参数,直接向 Worker 打开带鉴权的 WebSocket,不接收 DevTools URL。Chrome DevTools 连接 Worker 的 CDP WebSocket。每条 DevTools 连接在 Worker 中独占一个连接 Host 主线程的 `node:inspector.Session`,因此 Host JavaScript 暂停时仍可调试 Host。
diff --git a/packages/experimental/inspector/src/host/plugin.ts b/packages/experimental/inspector/src/host/plugin.ts
index 23306c4cf4..6294edc2ed 100644
--- a/packages/experimental/inspector/src/host/plugin.ts
+++ b/packages/experimental/inspector/src/host/plugin.ts
@@ -4,12 +4,11 @@ import type { Context } from '@deepseek-ai/cordis'
import { fileURLToPath } from 'node:url'
import { connect } from 'node:net'
import type { Duplex } from 'node:stream'
-import serveStatic from 'serve-static'
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-client-connection'
import type {} from '@deepseek-ai/dsh-cmdline'
-import open, { apps } from 'open'
-import { resolveInspectorOptions, startInspector, type InspectorOptions } from './bridge/controller.ts'
+import { resolveInspectorOptions, startInspector, type InspectorHandle, type InspectorOptions } from './bridge/controller.ts'
+import type { InspectorClientBootstrap } from '../shared/bridge/messages/control.ts'
import { createInspectorService } from '../shared/service.ts'
import { publishCordisTree } from './inspection/cordis.ts'
import { INSPECTOR_BOOTSTRAP_PATH, INSPECTOR_CLIENT_QUERY, readInspectorClientSelection } from '../shared/web.ts'
@@ -17,6 +16,9 @@ import { disposeInspectorResources } from '../shared/dispose.ts'
const DEVTOOLS_PATH = '/inspector/devtools'
+/** Node's standard code for a feature the running platform cannot provide, such as `worker_threads` in a browser Worker host. */
+const FEATURE_UNAVAILABLE_ON_PLATFORM = 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM'
+
export { resolveInspectorOptions, startInspector } from './bridge/controller.ts'
export type { InspectorEndpoint, InspectorHandle, InspectorOptions, InspectorSpec } from './bridge/controller.ts'
export type { CordisRuntimeTreeReader } from '../shared/cordis/reader.ts'
@@ -45,14 +47,36 @@ export interface HostPluginConfig extends Omit {
await ctx.effect(async () => {
const spec = resolveInspectorOptions(config)
- const handle = await startInspector(spec)
+ const ready = Promise.withResolvers()
const disposers: Array<() => unknown> = []
- const dispose = () => disposeInspectorResources(disposers, () => handle.close(), 'experimental-inspector: disposal failed')
+ let worker: InspectorHandle | undefined
+ const dispose = () => disposeInspectorResources(disposers, () => worker?.close(), 'experimental-inspector: disposal failed')
try {
+ disposers.push(ctx.connection.fetch.register({
+ path: INSPECTOR_BOOTSTRAP_PATH, methods: ['GET'], requestBody: 'buffered',
+ fetch: request => bootstrapResponse(ready.promise, request.signal),
+ }))
+ const handle = await startInspector(spec).catch((error: unknown) => {
+ if (!isUnavailableOnPlatform(error)) throw error
+ ctx.logger.warn('experimental-inspector: NodeJS Inspector is unavailable on this platform; Host inspection stays disabled', error)
+ return undefined
+ })
+ if (handle === undefined) {
+ ready.resolve(undefined)
+ return dispose
+ }
+ worker = handle
+ // Node-only frontend and launcher packages load after the Worker starts, so a platform without it never loads them.
+ const { default: serveStatic } = await import('serve-static')
disposers.push(publishCordisTree(ctx, handle.source, {
maxNodes: spec.maxCordisNodes,
maxBytes: spec.maxSourceFrameBytes - 4_096,
@@ -61,12 +85,6 @@ export async function apply(ctx: Context, config: HostPluginConfig): Promise {
table.push({ kind: 'global', name: '__DSH_INSPECTOR__', value: handle.endpoint.client })
}))
- disposers.push(ctx.connection.fetch.register({
- path: INSPECTOR_BOOTSTRAP_PATH, methods: ['GET'], requestBody: 'buffered',
- fetch: () => Promise.resolve(Response.json(handle.endpoint.client, {
- headers: { 'cache-control': 'no-store' },
- })),
- }))
const assets = serveStatic(fileURLToPath(new URL('./lib/devtools/',
import.meta.resolve('@deepseek-ai/dsh-experimental-inspector/package.json'))), {
index: false, redirect: false, fallthrough: true,
@@ -148,12 +166,15 @@ export async function apply(ctx: Context, config: HostPluginConfig): Promise {
ctx.logger.error('experimental-inspector: Chrome could not open DevTools', error)
})
}
} catch (error) {
+ ready.resolve(undefined)
await dispose().catch((cleanupError: unknown) => {
ctx.logger.error('experimental-inspector: initialization rollback failed', cleanupError)
})
@@ -162,3 +183,32 @@ export async function apply(ctx: Context, config: HostPluginConfig): Promise, signal: AbortSignal): Promise {
+ const headers = { 'cache-control': 'no-store' }
+ const abandoned = Promise.withResolvers()
+ const abandon = () => { abandoned.resolve(undefined) }
+ if (signal.aborted) abandon()
+ else signal.addEventListener('abort', abandon, { once: true })
+ try {
+ const client = await Promise.race([ready, abandoned.promise])
+ return client === undefined ? new Response(null, { status: 503, headers }) : Response.json(client, { headers })
+ } finally {
+ signal.removeEventListener('abort', abandon)
+ }
+}
diff --git a/packages/experimental/inspector/tests/bootstrap-route.host.spec.ts b/packages/experimental/inspector/tests/bootstrap-route.host.spec.ts
new file mode 100644
index 0000000000..1a70b1917b
--- /dev/null
+++ b/packages/experimental/inspector/tests/bootstrap-route.host.spec.ts
@@ -0,0 +1,179 @@
+/** Host bootstrap route availability while the Inspector Worker is still starting. */
+
+import { Context, FiberState } from '@deepseek-ai/cordis'
+import type { Message } from '@deepseek-ai/cordis'
+import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
+import { HostConnectionService } from '@deepseek-ai/dsh-client-connection'
+import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts'
+import open from 'open'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { apply, Config, inject, name } from '../src/index.ts'
+import { startInspector, type InspectorHandle, type InspectorOptions } from '../src/host/bridge/controller.ts'
+import { INSPECTOR_BOOTSTRAP_PATH } from '../src/shared/web.ts'
+
+vi.mock('open', () => ({ default: vi.fn(async () => undefined), apps: { chrome: 'chrome' } }))
+
+vi.mock('../src/host/bridge/controller.ts', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, startInspector: vi.fn(actual.startInspector) }
+})
+
+/** Startup failures the fixture can inject; only the platform code selects the unavailable state. */
+const FAILURES = {
+ fail: () => new Error('fixture Worker startup failed'),
+ unavailable: () => Object.assign(new Error('web-preview: node:worker_threads.MessageChannel is not available in the worker host'), {
+ code: 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM',
+ }),
+ 'other-code': () => Object.assign(new Error('fixture Worker port is occupied'), { code: 'EADDRINUSE' }),
+ 'message-only': () => new Error('fixture ERR_FEATURE_UNAVAILABLE_ON_PLATFORM: worker host is not available'),
+} satisfies Record Error>
+
+const UNAVAILABLE_WARNING = 'experimental-inspector: NodeJS Inspector is unavailable on this platform; Host inspection stays disabled'
+
+describe('experimental Inspector Host bootstrap route', () => {
+ let context: Context | undefined
+ const gates: Array> = []
+
+ afterEach(async () => {
+ // A failed assertion can leave startup gated; disposal waits for the pending effect.
+ for (const gate of gates.splice(0)) gate.resolve(undefined)
+ await context?.fiber.dispose()
+ context = undefined
+ vi.restoreAllMocks()
+ vi.clearAllMocks()
+ })
+
+ /** Hold Worker startup until the returned gate opens. */
+ function gateStartup(outcome: 'start' | keyof typeof FAILURES): PromiseWithResolvers {
+ const gate = Promise.withResolvers()
+ gates.push(gate)
+ vi.mocked(startInspector).mockImplementationOnce(async (options?: InspectorOptions): Promise => {
+ await gate.promise
+ if (outcome !== 'start') throw FAILURES[outcome]()
+ const actual = await vi.importActual('../src/host/bridge/controller.ts')
+ return actual.startInspector(options)
+ })
+ return gate
+ }
+
+ function mount(args: readonly string[] = []) {
+ context = new Context()
+ const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
+ const register = vi.fn(() => () => {})
+ const registerUpgrade = vi.fn(() => () => {})
+ context.provide('webServer', { register, registerUpgrade } as Pick as WebServer)
+ context.provide('cmdlineArgs', { get: () => args })
+ const connection = new HostConnectionService(context, [], {} as BrowserAuth)
+ const api = connection.createSharedFetchHandler('/api')
+ const messages: Message[] = []
+ context.logger.exporter({ levels: { default: 3 }, export: (message) => { messages.push(message) } })
+ const fiber = context.plugin({ name, inject: [...inject], Config, apply }, { port: 0, captureFetch: false })
+ const bootstrap = (signal?: AbortSignal) => api.fetch(
+ new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`, signal === undefined ? {} : { signal }),
+ connection.browserPeer,
+ )
+ const injected = () => {
+ const rows: IndexInjection[] = []
+ context!.emit('webserver/index-inject', rows)
+ return rows.find(row => row.kind === 'global' && row.name === '__DSH_INSPECTOR__')
+ }
+ return { fiber, bootstrap, injected, register, registerUpgrade, messages, log }
+ }
+
+ it('accepts bootstrap requests before the Worker is ready and answers once it is', async () => {
+ const gate = gateStartup('start')
+ const { fiber, bootstrap, injected, register, registerUpgrade } = mount()
+ await vi.waitFor(() => { expect(startInspector).toHaveBeenCalledOnce() })
+
+ const early = bootstrap()
+ expect(await settledWithin(early, 50)).toBe(false)
+ expect(injected()).toBeUndefined()
+ expect(register).not.toHaveBeenCalled()
+ expect(registerUpgrade).not.toHaveBeenCalled()
+
+ gate.resolve(undefined)
+ await fiber.await()
+ const response = await early
+ expect(response.status).toBe(200)
+ expect(response.headers.get('cache-control')).toBe('no-store')
+ const value = injected()
+ expect(value).toMatchObject({ kind: 'global', name: '__DSH_INSPECTOR__' })
+ expect(await response.json()).toEqual(value?.kind === 'global' ? value.value : undefined)
+ expect(register).toHaveBeenCalledWith(expect.objectContaining({ path: '/inspector/devtools' }))
+ expect(registerUpgrade).toHaveBeenCalledWith(expect.objectContaining({ path: '/inspector/devtools/cdp' }))
+ })
+
+ for (const outcome of ['fail', 'other-code', 'message-only'] as const) {
+ it(`answers waiting bootstrap requests with 503 and removes the route when Worker startup fails (${outcome})`, async () => {
+ const gate = gateStartup(outcome)
+ const { fiber, bootstrap, injected, messages } = mount()
+ await vi.waitFor(() => { expect(startInspector).toHaveBeenCalledOnce() })
+
+ const early = bootstrap()
+ expect(await settledWithin(early, 50)).toBe(false)
+ gate.resolve(undefined)
+ await expect(fiber.await()).rejects.toThrow(FAILURES[outcome]().message)
+ expect(fiber.state).toBe(FiberState.FAILED)
+ const response = await early
+ expect(response.status).toBe(503)
+ expect(response.headers.get('cache-control')).toBe('no-store')
+ expect(injected()).toBeUndefined()
+ expect((await bootstrap()).status).toBe(404)
+ expect(messages.filter(message => message.args[0] === UNAVAILABLE_WARNING)).toEqual([])
+ })
+ }
+
+ it('starts without the NodeJS Inspector when the platform cannot run its Worker', async () => {
+ const gate = gateStartup('unavailable')
+ const { fiber, bootstrap, injected, register, registerUpgrade, messages, log } = mount(['--inspect'])
+ await vi.waitFor(() => { expect(startInspector).toHaveBeenCalledOnce() })
+
+ const early = bootstrap()
+ expect(await settledWithin(early, 50)).toBe(false)
+ gate.resolve(undefined)
+ await fiber.await()
+ expect(fiber.state).toBe(FiberState.ACTIVE)
+ for (const response of [await early, await bootstrap()]) {
+ expect(response.status).toBe(503)
+ expect(response.headers.get('cache-control')).toBe('no-store')
+ }
+ const warnings = messages.filter(message => message.type === 'warn')
+ expect(warnings).toHaveLength(1)
+ expect(warnings[0]!.args[0]).toBe(UNAVAILABLE_WARNING)
+ expect(warnings[0]!.args[1]).toMatchObject({ code: 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM' })
+ expect(messages.filter(message => message.type === 'error')).toEqual([])
+ expect(register).not.toHaveBeenCalled()
+ expect(registerUpgrade).not.toHaveBeenCalled()
+ expect(injected()).toBeUndefined()
+ expect(context!.get('inspector')).toBeUndefined()
+ expect(log).not.toHaveBeenCalledWith(expect.stringMatching(/^dsh inspector:/u))
+ expect(open).not.toHaveBeenCalled()
+
+ await fiber.dispose()
+ expect((await bootstrap()).status).toBe(404)
+ expect(messages.filter(message => message.type === 'error')).toEqual([])
+ })
+
+ it('stops waiting when a bootstrap request is aborted before the Worker is ready', async () => {
+ const gate = gateStartup('start')
+ const { fiber, bootstrap } = mount()
+ await vi.waitFor(() => { expect(startInspector).toHaveBeenCalledOnce() })
+
+ const abort = new AbortController()
+ const abandoned = bootstrap(abort.signal)
+ expect(await settledWithin(abandoned, 50)).toBe(false)
+ abort.abort()
+ expect((await abandoned).status).toBe(503)
+ expect((await bootstrap(AbortSignal.abort())).status).toBe(503)
+
+ gate.resolve(undefined)
+ await fiber.await()
+ expect((await bootstrap()).status).toBe(200)
+ })
+})
+
+/** Report whether a promise settles before a real-time deadline. */
+async function settledWithin(promise: Promise, ms: number): Promise {
+ const timeout = new Promise((resolve) => { setTimeout(() => { resolve(false) }, ms) })
+ return Promise.race([promise.then(() => true, () => true), timeout])
+}
diff --git a/packages/experimental/inspector/tests/plugin-routes.host.spec.ts b/packages/experimental/inspector/tests/plugin-routes.host.spec.ts
new file mode 100644
index 0000000000..d37d1cbfc2
--- /dev/null
+++ b/packages/experimental/inspector/tests/plugin-routes.host.spec.ts
@@ -0,0 +1,286 @@
+/** Host DevTools frontend and CDP upgrade routes registered by the Inspector plugin. */
+
+import { randomBytes } from 'node:crypto'
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { createConnection, createServer, type AddressInfo, type Socket } from 'node:net'
+import { Context } from '@deepseek-ai/cordis'
+import type { Message } from '@deepseek-ai/cordis'
+import type { WebRoute, WebServer, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
+import { HostConnectionService } from '@deepseek-ai/dsh-client-connection'
+import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { apply, Config, inject, name } from '../src/index.ts'
+import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts'
+
+type AssetMiddleware = (req: IncomingMessage, res: ServerResponse, next: (error?: unknown) => void) => void
+
+const assets = vi.hoisted(() => ({
+ options: undefined as { setHeaders?: (res: ServerResponse) => void } | undefined,
+ middleware: vi.fn(),
+}))
+
+vi.mock('serve-static', () => ({
+ default: (_root: string, options: { setHeaders?: (res: ServerResponse) => void }) => {
+ assets.options = options
+ return assets.middleware
+ },
+}))
+
+vi.mock('../src/host/bridge/controller.ts', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, startInspector: vi.fn(actual.startInspector) }
+})
+
+interface RecordedResponse {
+ status?: number
+ headers: Record
+ ended: boolean
+ destroyed: boolean
+}
+
+describe('experimental Inspector Host DevTools routes', () => {
+ let context: Context | undefined
+
+ afterEach(async () => {
+ await context?.fiber.dispose()
+ context = undefined
+ assets.middleware.mockReset()
+ vi.restoreAllMocks()
+ vi.clearAllMocks()
+ })
+
+ async function mount(options: { indexAllowed?: () => boolean; before?: (ctx: Context) => void } = {}) {
+ context = new Context()
+ vi.spyOn(console, 'log').mockImplementation(() => undefined)
+ const routes: WebRoute[] = []
+ const upgrades: WebUpgradeRoute[] = []
+ const webServer: Pick = {
+ register: (route) => { routes.push(route); return () => {} },
+ registerUpgrade: (route) => { upgrades.push(route); return () => {} },
+ }
+ context.provide('webServer', webServer as WebServer)
+ const auth: Pick = {
+ isAuthenticated: () => true,
+ authenticateApi: () => undefined,
+ authorizeIndex: (_request, response) => {
+ if (options.indexAllowed?.() !== false) return true
+ response.writeHead(401)
+ response.end()
+ return false
+ },
+ }
+ new HostConnectionService(context, [], auth as BrowserAuth)
+ const messages: Message[] = []
+ context.logger.exporter({ levels: { default: 3 }, export: (message) => { messages.push(message) } })
+ options.before?.(context)
+ const fiber = context.plugin({ name, inject: [...inject], Config, apply }, { port: 0, captureFetch: false })
+ return { fiber, routes, upgrades, messages }
+ }
+
+ it('answers frontend requests by method, index authorization, redirect, and asset outcome', async () => {
+ let indexAllowed = true
+ const { fiber, routes, messages } = await mount({ indexAllowed: () => indexAllowed })
+ await fiber.await()
+ const route = routes.find(row => row.path === '/inspector/devtools')
+ if (route === undefined) throw new Error('DevTools route was not registered')
+ const call = async (method: string | undefined, url: string | undefined, headersSent = false) => {
+ const recorded: RecordedResponse = { headers: {}, ended: false, destroyed: false }
+ const res = {
+ get headersSent() { return headersSent },
+ setHeader(key: string, value: string) { recorded.headers[key.toLowerCase()] = value },
+ writeHead(status: number, headers: Record = {}) {
+ recorded.status = status
+ for (const [key, value] of Object.entries(headers)) recorded.headers[key.toLowerCase()] = value
+ return this
+ },
+ end() { recorded.ended = true; return this },
+ destroy() { recorded.destroyed = true; return this },
+ }
+ const req = { method, url, headers: {} }
+ await route.handler(req as IncomingMessage, res as ServerResponse)
+ return { recorded, req }
+ }
+
+ expect((await call('POST', '/inspector/devtools/devtools_app.html')).recorded).toMatchObject({ status: 405, headers: { allow: 'GET, HEAD' }, ended: true })
+ indexAllowed = false
+ expect((await call('GET', '/inspector/devtools/devtools_app.html')).recorded.status).toBe(401)
+ indexAllowed = true
+ expect(assets.middleware).not.toHaveBeenCalled()
+ expect((await call('GET', '/inspector/devtools?panel=console')).recorded).toMatchObject({
+ status: 302, headers: { location: 'devtools/devtools_app.html?panel=console' },
+ })
+ expect((await call('HEAD', '/inspector/devtools/')).recorded).toMatchObject({ status: 302, headers: { location: 'devtools_app.html' } })
+
+ assets.middleware.mockImplementationOnce((_req, res) => {
+ assets.options?.setHeaders?.(res)
+ res.writeHead(200).end()
+ })
+ const served = await call('GET', '/inspector/devtools/devtools_app.html?ws=1')
+ expect(served.req.url).toBe('/devtools_app.html?ws=1')
+ expect(served.recorded).toMatchObject({ status: 200, headers: { 'x-content-type-options': 'nosniff' } })
+
+ assets.middleware.mockImplementationOnce((_req, _res, next) => { next() })
+ const missing = await call('GET', undefined)
+ expect(missing.req.url).toBe('')
+ expect(missing.recorded).toMatchObject({ status: 404, ended: true })
+
+ const failure = new Error('fixture asset read failed')
+ assets.middleware.mockImplementation((_req, _res, next) => { next(failure) })
+ expect((await call(undefined, '/inspector/devtools/main.js')).recorded).toMatchObject({ status: 405 })
+ expect((await call('GET', '/inspector/devtools/main.js')).recorded).toMatchObject({ status: 500, ended: true, destroyed: false })
+ const streaming = (await call('GET', '/inspector/devtools/main.js', true)).recorded
+ expect(streaming.status).toBeUndefined()
+ expect(streaming.destroyed).toBe(true)
+ expect(messages.filter(message => message.args[0] === 'experimental-inspector: frontend asset request failed')
+ .map((message): unknown => message.args[1])).toEqual([failure, failure])
+ })
+
+ it('proxies authenticated CDP upgrades with their WebSocket headers and early bytes to the Worker', async () => {
+ const { fiber, upgrades } = await mount()
+ await fiber.await()
+ const upgrade = upgrades.find(row => row.path === '/inspector/devtools/cdp')
+ if (upgrade === undefined) throw new Error('CDP upgrade route was not registered')
+
+ const selected = await socketPair()
+ const reply = readUpgrade(selected.client)
+ await upgrade.handler(upgradeRequest('/inspector/devtools/cdp?clientSourceId=client-fixture', {
+ 'sec-websocket-protocol': ['dsh-fixture', 'dsh-other'],
+ 'sec-websocket-extensions': undefined,
+ 'user-agent': 'fixture',
+ }), selected.server, clientTextFrame(JSON.stringify({ id: 1, method: 'DSHInspector.getSources' })))
+ const answered = await reply
+ expect(answered.status).toMatch(/^HTTP\/1\.1 101 /u)
+ expect(answered.headers.toLowerCase()).toContain('sec-websocket-protocol: dsh-fixture')
+ expect(answered.message.id).toBe(1)
+ expect(answered.message.result).toHaveProperty('sources')
+
+ const unselected = await socketPair()
+ const later = readUpgrade(unselected.client)
+ await upgrade.handler(upgradeRequest(undefined, {}), unselected.server, Buffer.alloc(0))
+ await vi.waitFor(() => { expect(unselected.received().toString('latin1')).toMatch(/^HTTP\/1\.1 101 /u) })
+ unselected.client.write(clientTextFrame(JSON.stringify({ id: 1, method: 'DSHInspector.getSources' })))
+ expect((await later).message).toMatchObject({ id: 1 })
+
+ const reset = await socketPair()
+ await upgrade.handler(upgradeRequest(undefined, {}), reset.server, Buffer.alloc(0))
+ reset.server.destroy(new Error('fixture browser reset'))
+ await reset.closed
+
+ // Disposal releases every still-proxied socket before the Worker closes.
+ await fiber.dispose()
+ await Promise.all([selected.closed, unselected.closed])
+ })
+
+ it('closes the browser socket when the Worker endpoint refuses the proxied connection', async () => {
+ const refused = await closedPort()
+ vi.mocked(startInspector).mockImplementationOnce(async (options) => {
+ const actual = await vi.importActual('../src/host/bridge/controller.ts')
+ const handle = await actual.startInspector(options)
+ return redirectedHandle(handle, `ws://127.0.0.1:${String(refused)}/devtools/page/refused`)
+ })
+ const { fiber, upgrades } = await mount()
+ await fiber.await()
+ const upgrade = upgrades.find(row => row.path === '/inspector/devtools/cdp')
+ if (upgrade === undefined) throw new Error('CDP upgrade route was not registered')
+ const pair = await socketPair()
+ await upgrade.handler(upgradeRequest(undefined, {}), pair.server, Buffer.alloc(0))
+ await pair.closed
+ expect(pair.received().length).toBe(0)
+ })
+
+ it('logs a rollback failure and still rejects initialization with the registration error', async () => {
+ const closeFailure = new Error('fixture Worker close failed')
+ vi.mocked(startInspector).mockImplementationOnce(async (options) => {
+ const actual = await vi.importActual('../src/host/bridge/controller.ts')
+ const handle = await actual.startInspector(options)
+ return { endpoint: handle.endpoint, source: handle.source, close: async () => { await handle.close(); throw closeFailure } }
+ })
+ const { fiber, messages } = await mount({
+ before: (ctx) => {
+ ctx.provide('inspector', {
+ publish: () => undefined,
+ cordis: { getTree: () => Promise.reject(new Error('unused test service')) },
+ })
+ },
+ })
+ await expect(fiber.await()).rejects.toThrow('service "inspector" has been registered')
+ const rollback = messages.find(message => message.args[0] === 'experimental-inspector: initialization rollback failed')
+ expect(rollback?.type).toBe('error')
+ expect(rollback?.args[1]).toMatchObject({ errors: [closeFailure] })
+ })
+})
+
+function upgradeRequest(url: string | undefined, headers: Record): IncomingMessage {
+ return {
+ url,
+ headers: {
+ host: '127.0.0.1', origin: 'http://127.0.0.1', connection: 'Upgrade', upgrade: 'websocket',
+ 'sec-websocket-key': randomBytes(16).toString('base64'), 'sec-websocket-version': '13', ...headers,
+ },
+ } as IncomingMessage
+}
+
+/** Encode one masked client-to-server WebSocket text frame. */
+function clientTextFrame(text: string): Buffer {
+ const payload = Buffer.from(text)
+ if (payload.length >= 126) throw new Error('fixture frame must stay below 126 bytes')
+ const mask = randomBytes(4)
+ return Buffer.concat([Buffer.from([0x81, 0x80 | payload.length]), mask, payload.map((byte, index) => byte ^ mask[index % 4]!)])
+}
+
+/** Read the upgrade response and the first server text frame that answers request id 1. */
+async function readUpgrade(socket: Socket): Promise<{ status: string; headers: string; message: Record }> {
+ let buffer = Buffer.alloc(0)
+ return new Promise((resolve, reject) => {
+ socket.on('data', (chunk: Buffer) => {
+ buffer = Buffer.concat([buffer, chunk])
+ const end = buffer.indexOf('\r\n\r\n')
+ if (end < 0) return
+ const head = buffer.subarray(0, end).toString('latin1')
+ let offset = end + 4
+ while (offset + 2 <= buffer.length) {
+ const short = buffer[offset + 1]! & 0x7f
+ const lengthBytes = short === 126 ? 2 : short === 127 ? 8 : 0
+ if (offset + 2 + lengthBytes > buffer.length) return
+ const length = short === 126 ? buffer.readUInt16BE(offset + 2) : short === 127 ? Number(buffer.readBigUInt64BE(offset + 2)) : short
+ const start = offset + 2 + lengthBytes
+ if (start + length > buffer.length) return
+ const message = JSON.parse(buffer.subarray(start, start + length).toString('utf8')) as Record
+ if (message.id === 1) {
+ const [status = '', ...headers] = head.split('\r\n')
+ resolve({ status, headers: headers.join('\n'), message })
+ return
+ }
+ offset = start + length
+ }
+ })
+ socket.once('close', () => { reject(new Error(`socket closed before a CDP reply: ${buffer.toString('latin1')}`)) })
+ })
+}
+
+/** Open one connected TCP pair: `server` stands for the browser socket the Web server hands to the upgrade route. */
+async function socketPair(): Promise<{ client: Socket; server: Socket; closed: Promise; received: () => Buffer }> {
+ const listener = createServer()
+ await new Promise((resolve) => { listener.listen(0, '127.0.0.1', resolve) })
+ const accepted = new Promise((resolve) => { listener.once('connection', resolve) })
+ const client = createConnection((listener.address() as AddressInfo).port, '127.0.0.1')
+ const server = await accepted
+ listener.close()
+ let received = Buffer.alloc(0)
+ client.on('data', (chunk: Buffer) => { received = Buffer.concat([received, chunk]) })
+ client.on('error', (error: unknown) => { void error /* The proxied peer may reset the fixture connection; `closed` observes the outcome. */ })
+ const closed = new Promise((resolve) => { client.once('close', () => { resolve() }) })
+ return { client, server, closed, received: () => received }
+}
+
+async function closedPort(): Promise {
+ const listener = createServer()
+ await new Promise((resolve) => { listener.listen(0, '127.0.0.1', resolve) })
+ const { port } = listener.address() as AddressInfo
+ await new Promise((resolve) => { listener.close(() => { resolve() }) })
+ return port
+}
+
+function redirectedHandle(handle: InspectorHandle, webSocketDebuggerUrl: string): InspectorHandle {
+ return { endpoint: { ...handle.endpoint, webSocketDebuggerUrl }, source: handle.source, close: () => handle.close() }
+}
diff --git a/tsconfig.host.json b/tsconfig.host.json
index 692b5c6d04..1815f14578 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -77,6 +77,7 @@
"apps/web/tests/worktree-opt-in.e2e.ts",
"apps/web/tests/session-inspector.e2e.ts",
"apps/web/tests/shared-inspector.e2e.ts",
+ "apps/web/tests/inspector-late-enable.e2e.ts",
"apps/web/tests/layout-bottom.e2e.ts",
"apps/web/tests/devtools-frontend.e2e.ts",
"apps/web/tests/inspector-panels.e2e.ts",
diff --git a/vitest.config.ts b/vitest.config.ts
index 711733c10e..9c717c0ced 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -306,7 +306,6 @@ export default defineConfig({
// Keep already-complete Inspector modules under the per-file gate and
// enumerate the remaining direct-test debt instead of exempting src/**.
// TODO(inspector): close these branch gaps and remove the entries.
- 'packages/experimental/inspector/src/host/plugin.ts',
'packages/experimental/inspector/src/shared/bridge/{control-codec,rpc}.ts',
'packages/experimental/inspector/src/shared/bridge/messages/observation.ts',
'packages/experimental/inspector/src/shared/bridge/messages/query/codec.ts',