fix(inspector): address bootstrap review findings

This commit is contained in:
imccyu
2026-08-27 20:03:17 +08:00
parent ac13b16c0c
commit dc1be1334f
23 changed files with 348 additions and 101 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
README.md: 4c81da74e5efbbbcdb35147431fc03252d51c94a
README.zh.md: 3e476c57606e64a4837ca97f4298faf124849e4c
README.md: 414d34af202ee4ab24e1b7570697ad63bd8cdbc8
README.zh.md: e6adc7b7d0265e52d5108e241a8f8a551976536d
+1 -1
View File
@@ -63,7 +63,7 @@ Executing a plugin bundle only registers its factory; every module-body side eff
### Incremental composition
The node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Package metadata is cached per name and never expires; bundle content changes reach the graph only through `rebuilt()` (the HMR hook).
The node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Package metadata is cached per Loader specifier and owning-tree base URL until restart, while the resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. Bundle content changes reach the graph only through `rebuilt()` (the HMR hook).
The node half snapshots each client bundle and available source map before publication. It groups resources into `/plugins/??...&rev=...` combo URLs, with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. Every combo map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial per-plugin revisions use process nonces, so startup does not hash every plugin; HMR hashes only an artifact reported as changed. Advertised responses are immutable, and an unknown combination or revision returns 404.
+1 -1
View File
@@ -63,7 +63,7 @@ application combo 脚本在启动时注册插件 factory;模块主体仍保持
### 增量组合
node 半侧逐包增量扫描——没有全量重扫路径。每次 `internal/plugin` 发出都会把该 fiber 的 entry 名标脏;一个微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 播种同一脏集合并同步 flush,因此首次扫描与稳态共用同一实现。包元数据按名缓存且永不过期;bundle 内容变更只能通过 `rebuilt()`HMR 钩子)进入图。
node 半侧逐包增量扫描——没有全量重扫路径。每次 `internal/plugin` 发出都会把该 fiber 的 entry 名标脏;一个微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 播种同一脏集合并同步 flush,因此首次扫描与稳态共用同一实现。包元数据按 Loader specifier 与所属 tree base URL 缓存至重启,解析出的 manifest 包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。bundle 内容变更只能通过 `rebuilt()`HMR 钩子)进入图。
node 半侧会在发布前快照每个客户端 bundle 及其现有 source map。它把资源分组到 `/plugins/??...&rev=...` combo URLmodules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。每个 combo map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。初始逐插件 revision 使用进程 nonce,所以启动时不哈希每个插件;HMR 只哈希被报告为已变化的产物。已公告响应不可变;未知组合或 revision 返回 404。
+75 -36
View File
@@ -15,9 +15,10 @@
* against the live loader entries. The activation pass seeds the same dirty
* set with all current entries and flushes synchronously, so first scan and
* steady state share one implementation. Package metadata (including the
* negative "not a client package" verdict) is cached per name and never
* expires — plugin-set changes take effect on restart; bundle content
* changes reach the graph only through
* negative "not a client package" verdict) is cached per Loader specifier and
* owning-tree base URL until restart. The manifest package name identifies
* the browser module; distinct active Loader sources for that package are a
* composition error. Bundle content changes reach the graph only through
* {@link ClientModuleRegistry.rebuilt}.
* @module @deepseek-ai/dsh-client-modules
*/
@@ -81,7 +82,7 @@ export interface ClientArtifactBaseline {
readonly size: number
}
/** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
/** Resolved metadata cached for one Loader specifier and owning-tree base URL until restart. */
interface PkgMeta extends WebBootRowFields {
clientPath: string
}
@@ -91,6 +92,16 @@ interface ResolvedPkgMeta {
meta: PkgMeta
}
/** One active Loader source and the browser package manifest it resolves to. */
interface ClientPackageSource extends ResolvedPkgMeta {
/** Loader specifier from the active row. */
loaderName: string
/** Resolution base of the config tree that owns the row. */
baseUrl: string
/** Stable cache and contribution key for this source. */
sourceKey: string
}
/** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
@@ -523,6 +534,7 @@ export class ClientModuleRegistry extends Service {
static inject = ['webServer', 'loader']
private readonly table = new Map<string, WebPluginRecord>()
private readonly sources = new Map<string, ClientPackageSource>()
// Resolution is entry-local: the same specifier can resolve differently in
// separate config trees. Negative verdicts remain stable until restart.
private readonly pkgMeta = new Map<string, ResolvedPkgMeta | null>()
@@ -886,35 +898,72 @@ export class ClientModuleRegistry extends Service {
}
}
/** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
private processOne(entryName: string): boolean {
let activeEntry: Entry | undefined
/** Reconcile one entry name against the live Loader sources. @returns whether the table changed. */
private processOne(entryName: string, onError: (err: Error) => void): boolean {
const nextSources = new Map<string, ClientPackageSource>()
for (const entry of this.ctx.loader.entries()) {
if (entry.options.name === entryName && entry.fiber !== undefined && !entry.disabled) {
activeEntry = entry
break
if (entry.options.name !== entryName || entry.fiber === undefined || entry.disabled) continue
const source = this.resolveSource(entry)
if (source !== undefined) nextSources.set(source.sourceKey, source)
}
const affectedPackages = new Set<string>()
for (const [sourceKey, source] of this.sources) {
if (source.loaderName !== entryName) continue
affectedPackages.add(source.packageName)
if (!nextSources.has(sourceKey)) this.sources.delete(sourceKey)
}
for (const [sourceKey, source] of nextSources) {
affectedPackages.add(source.packageName)
this.sources.set(sourceKey, source)
}
let changed = false
for (const packageName of affectedPackages) {
try {
if (this.reconcilePackage(packageName)) changed = true
} catch (error) {
onError(error instanceof Error ? error : new Error(String(error)))
}
}
if (activeEntry === undefined) return this.deleteLoaderEntry(entryName)
const baseUrl = activeEntry.parent.tree.ctx.baseUrl
return changed
}
private resolveSource(entry: Entry): ClientPackageSource | undefined {
const loaderName = entry.options.name
const baseUrl = entry.parent.tree.ctx.baseUrl
if (baseUrl === undefined) {
throw new Error(`client-modules: loader entry ${entryName} has no resolution base URL`)
throw new Error(`client-modules: loader entry ${loaderName} has no resolution base URL`)
}
const sourceKey = this.sourceKey(entryName, baseUrl)
const resolved = this.resolveMeta(entryName, baseUrl)
if (resolved === null) return this.deleteLoaderEntry(entryName)
const { packageName, meta } = resolved
if (this.table.get(packageName)?.sourceKey === sourceKey) return false
this.deleteLoaderEntry(entryName)
const resolved = this.resolveMeta(loaderName, baseUrl)
if (resolved === null) return undefined
return { ...resolved, loaderName, baseUrl, sourceKey: this.sourceKey(loaderName, baseUrl) }
}
private reconcilePackage(packageName: string): boolean {
const sources: ClientPackageSource[] = []
for (const source of this.sources.values()) {
if (source.packageName === packageName) sources.push(source)
}
if (sources.length > 1) {
const locations = sources
.map(source => `${JSON.stringify(source.loaderName)} from ${source.baseUrl}`)
.join(', ')
throw new Error(
`client-modules: package ${packageName} resolves from multiple active Loader sources: ${locations}; remove one entry`,
)
}
const source = sources[0]
if (source === undefined) return this.table.delete(packageName)
if (this.table.get(packageName)?.sourceKey === source.sourceKey) return false
// The opaque initial rev rides the row until HMR observes a file change;
// a fiber restart reuses the existing row without inspecting bytes.
const snapshot = this.initialBundleSnapshot(packageName, meta.clientPath)
// a fiber restart from the same source reuses the existing row.
const snapshot = this.initialBundleSnapshot(packageName, source.meta.clientPath)
const rev = this.allocateInitialRevision()
this.table.set(packageName, {
entry: graphRow(packageName, rev, meta),
loaderName: entryName,
sourceKey,
meta,
entry: graphRow(packageName, rev, source.meta),
loaderName: source.loaderName,
sourceKey: source.sourceKey,
meta: source.meta,
bundle: snapshot.bundle,
baseline: snapshot.baseline,
...(snapshot.sourceMap === undefined ? {} : { sourceMap: snapshot.sourceMap }),
@@ -922,22 +971,12 @@ export class ClientModuleRegistry extends Service {
return true
}
private deleteLoaderEntry(loaderName: string): boolean {
let changed = false
for (const [packageName, record] of this.table) {
if (record.loaderName !== loaderName) continue
this.table.delete(packageName)
changed = true
}
return changed
}
private flush(onError: (err: Error) => void): void {
let changed = false
for (const entryName of [...this.dirty]) {
this.dirty.delete(entryName)
try {
if (this.processOne(entryName)) changed = true
if (this.processOne(entryName, onError)) changed = true
} catch (error) {
// Steady state: one broken package must not poison the others; the
// activation pass aggregates these into a loud throw instead.
@@ -7,8 +7,8 @@ import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { runInNewContext } from 'node:vm'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { Context, type Fiber } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver'
import * as modulesClient from '../src/client/index.ts'
import { ClientModuleRegistry, bootInjections, orderByModuleGraph } from '../src/index.ts'
@@ -65,7 +65,7 @@ function constructWithRoute(
entryBaseUrl?: string
internal?: NonNullable<Context['loader']['internal']>
} = {},
): { service: ClientModuleRegistry; route: WebRoute } {
): { context: Context; service: ClientModuleRegistry; route: WebRoute } {
const ctx = new Context()
ctx.baseUrl = options.contextBaseUrl ?? pathToFileURL(root!).href + '/'
ctx.provide('loader', {
@@ -93,7 +93,7 @@ function constructWithRoute(
ctx.provide('webServer', webServer as WebServer)
const service = new ClientModuleRegistry(ctx)
if (route === undefined) throw new Error('client bundle route was not registered')
return { service, route }
return { context: ctx, service, route }
}
/** Construct the node-half service over the enabled fixture entries. */
@@ -286,6 +286,61 @@ describe('client bundle activation', () => {
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
})
it('rejects distinct active Loader sources for one browser package', () => {
const packageName = '@fixture/duplicate-source'
const clientPath = writePackage(packageName)
const hostPath = join(dirname(clientPath), 'index.js')
mkdirSync(dirname(hostPath), { recursive: true })
writeFileSync(hostPath, 'export default {}\n')
writeFileSync(clientPath, 'module.exports = {}\n')
const alias = './duplicate-source.js'
const internal = {
version: 'v2' as const,
resolveSync: () => ({ format: 'module' as const, url: pathToFileURL(hostPath).href }),
}
expect(() => constructWithRoute([packageName, alias], {
internal: internal as NonNullable<Context['loader']['internal']>,
})).toThrow(
`client-modules: package ${packageName} resolves from multiple active Loader sources:`,
)
})
it('promotes the remaining Loader source after the selected alias unloads', async () => {
const packageName = '@fixture/duplicate-source-recovery'
const clientPath = writePackage(packageName)
const hostPath = join(dirname(clientPath), 'index.js')
mkdirSync(dirname(hostPath), { recursive: true })
writeFileSync(hostPath, 'export default {}\n')
writeFileSync(clientPath, 'module.exports = {}\n')
const alias = './duplicate-source-recovery.js'
const entries = [packageName]
const internal = {
version: 'v2' as const,
resolveSync: () => ({ format: 'module' as const, url: pathToFileURL(hostPath).href }),
}
const { context, service } = constructWithRoute(entries, {
internal: internal as NonNullable<Context['loader']['internal']>,
})
const firstRevision = service.graph().entries[0]!.rev
const warning = vi.spyOn(context.logger, 'warn').mockImplementation(() => undefined)
entries.push(alias)
emitLoaderEntryChange(context, alias)
await Promise.resolve()
expect(warning).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining(`package ${packageName} resolves from multiple active Loader sources`) as string,
}))
expect(service.graph().entries[0]!.rev).toBe(firstRevision)
entries.splice(entries.indexOf(packageName), 1)
emitLoaderEntryChange(context, packageName)
await Promise.resolve()
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
expect(service.graph().entries[0]!.rev).not.toBe(firstRevision)
expect(service.clientPath(packageName)).toBe(clientPath)
})
it('uses owning-tree package resolution for an import-only Worker module loader', () => {
const packageName = '@fixture/worker-loader'
writeBuiltPackage(packageName, {})
@@ -636,6 +691,12 @@ describe('client bundle activation', () => {
})
})
function emitLoaderEntryChange(context: Context, name: string): void {
context.emit('internal/plugin', {
entry: { options: { name } },
} as unknown as Fiber)
}
describe('shared module declarations', () => {
it('accepts external requests and carries them onto the graph row', () => {
const packageName = '@fixture/shared-declared'
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/experimental/inspector/README.md
README.md: e10a68eed10c0bc71d26b5eacf9ee3eac4c6818d
README.zh.md: f6aceb5374730ff9879151980c60e54dad39fd89
README.md: d6c519723a8b9eab31048fb71f13b17004a6af52
README.zh.md: 0c2f58032a7c1f1a485f974da60c1d60e335eb51
+2 -1
View File
@@ -106,7 +106,7 @@ Node delivery is depth-limited per DevTools connection: `DOM.getDocument` serves
Sources publish complete snapshots, while the Worker compares stable backend node identities before notifying DevTools. Unchanged snapshots emit no DOM event; additions, removals, and attribute changes use node-level CDP events, inserted-node payloads withhold their subtree, and sibling reordering replaces only that parent's children. Existing `NodeId` values and unaffected Elements expansion remain stable.
When a Client disconnects, its Console execution context and live object ids are destroyed immediately. With disconnected-tree retention enabled, Elements keeps the last tree unchanged while connection state remains in the inspection model rather than becoming an unreviewed DOM attribute. Reconnection keeps the logical source id, creates a new synthetic CDP context id for the new transport generation, and replaces the stale tree after its complete snapshot arrives. The Worker retains at most `maxDisconnectedCordisTrees` such snapshots; zero removes them immediately.
When a Client disconnects, its Console execution context and live object ids are destroyed immediately. With disconnected-tree retention enabled, Elements keeps the last tree unchanged while connection state remains in the inspection model rather than becoming an unreviewed DOM attribute. Reconnection keeps the logical source id, creates a new synthetic CDP context id for the new transport generation, and replaces the stale tree after its complete snapshot arrives. The Client retains its logical id in `sessionStorage` and claims it through Web Locks for the page lifetime, so refresh reuses the id while a duplicated live tab receives a new one. The Worker retains at most `maxDisconnectedCordisTrees` such snapshots; zero removes them immediately.
<a id="host-fetch-capture"></a>
## Host fetch capture
@@ -138,6 +138,7 @@ None; this package neither assembles nor sends a provider request.
- **Client active debugging is unsupported** — Console events, Runtime evaluation, RemoteObject access, and read-only `lib/client.js` Sources work. Client-script debugger requests return explicit unsupported errors; target-wide pause and resume control the Host only.
- **Client Sources expose the Inspector bundle only** — other page scripts are not cataloged by this package.
- **Client evaluation uses page JavaScript** — page Content Security Policy can block dynamic evaluation, and the synthetic context does not provide DevTools command-line helpers or native REPL declaration semantics.
- **Client identity arbitration requires Web Locks** — browsers without that API retain reconnect and refresh identity through `sessionStorage`, but cannot distinguish two simultaneously live tabs copied from the same storage state.
- **Fetch interception covers `globalThis.fetch`** — direct Undici APIs and fetch references retained before activation are not observed.
- **Body cloning has cost** — full capture tees request and response streams up to the configured limits and can increase memory and I/O pressure. The retained-body limit does not include buffering inside the stream tee, including an oversized source chunk or data queued for a slower application reader.
- **No automatic Worker restart** — an unexpected Worker exit fails the current Inspector instance; lifecycle recovery belongs to a later change.
+2 -1
View File
@@ -106,7 +106,7 @@ Host 与 Client 发布同一种嵌套 `CordisTreeSnapshot` 类型。Context 与
source 仍发布完整 snapshotWorker 在通知 DevTools 前按稳定的 backend node identity 比较差异。无变化的 snapshot 不发送 DOM event;新增、移除和 attribute 变化使用节点级 CDP event,插入节点的载荷扣留其子树,兄弟节点重排只替换对应 parent 的 children。现有 `NodeId` 与未受影响的 Elements 展开状态保持稳定。
Client 断联时,其 Console execution context 与 live object id 会立即销毁。启用断联树保留后,Elements 会原样保留最后一棵树;连接状态留在 inspection model 中,不会未经设计就成为 DOM attribute。重连会沿用逻辑 source id,为新的 transport generation 创建新的 synthetic CDP context id,并在完整 snapshot 到达后替换旧树。Worker 最多保留 `maxDisconnectedCordisTrees` 棵此类 snapshot;设为零会立即移除。
Client 断联时,其 Console execution context 与 live object id 会立即销毁。启用断联树保留后,Elements 会原样保留最后一棵树;连接状态留在 inspection model 中,不会未经设计就成为 DOM attribute。重连会沿用逻辑 source id,为新的 transport generation 创建新的 synthetic CDP context id,并在完整 snapshot 到达后替换旧树。Client 把逻辑 id 保存在 `sessionStorage` 中,并通过 Web Locks 在页面存活期间独占该 id,因此刷新会复用 id,而复制出的另一个 live tab 会取得新 id。Worker 最多保留 `maxDisconnectedCordisTrees` 棵此类 snapshot;设为零会立即移除。
<a id="host-fetch-capture"></a>
## Host fetch 采集
@@ -138,6 +138,7 @@ CDP target 通过 `Runtime.evaluate` 提供 Host 和已连接 Client realm 中
- **Client active debugging 不受支持**——Console event、Runtime 求值、RemoteObject 访问和只读 `lib/client.js` Sources 可用。Client script debugger request 返回明确的 unsupported errortarget-wide pause 与 resume 只控制 Host。
- **Client Sources 只暴露 Inspector bundle**——本包不收录页面中的其他 script。
- **Client 求值使用页面 JavaScript**——页面 Content Security Policy 可能阻止动态求值;synthetic context 不提供 DevTools command-line helper 或原生 REPL 声明语义。
- **Client 身份仲裁依赖 Web Locks**——缺少该 API 的浏览器仍会通过 `sessionStorage` 保持重连与刷新身份,但无法区分从同一存储状态复制出的两个同时存活 tab。
- **fetch 拦截范围是 `globalThis.fetch`**——直接调用 Undici API,以及激活前保存的 fetch 引用不会被观察。
- **body clone 有运行成本**——完整采集会 tee 请求与响应 stream,直至达到配置上限,可能增加内存与 I/O 压力。保留 body 的上限不包含 stream tee 内部的缓冲,包括来源提供的超大 chunk,或为读取较慢的应用分支排队的数据。
- **不自动重启 Worker**——Worker 意外退出会使当前 Inspector 实例失败;生命周期恢复留待后续改动。
@@ -2,12 +2,20 @@
import type { InspectorClientBootstrap } from '../../shared/bridge/messages/control.ts'
import { ClientInspectorSource } from './transport.ts'
import { ClientRealmSource } from '../inspection/realm.ts'
/**
* Start the browser source transport for one validated Host bootstrap.
* @param bootstrap - Host-injected endpoint and resource limits.
* @returns The active reconnecting Client source.
* @returns The active reconnecting Client source after its tab identity is claimed.
*/
export function startInspectorClient(bootstrap: InspectorClientBootstrap): ClientInspectorSource {
return new ClientInspectorSource(bootstrap)
export async function startInspectorClient(bootstrap: InspectorClientBootstrap): Promise<ClientInspectorSource> {
const label = document.title || 'Client'
const realmSource = await ClientRealmSource.claim(label)
try {
return new ClientInspectorSource(bootstrap, label, undefined, realmSource)
} catch (error) {
realmSource.close()
throw error
}
}
@@ -50,9 +50,10 @@ export class ClientInspectorSource extends InspectorSourceConnection {
private readonly bootstrap: InspectorClientBootstrap,
label = document.title || 'Client',
private readonly sourceCatalog: ClientSourceCatalog | undefined = discoverInspectorClientSourceCatalog(),
realmSource = new ClientRealmSource(label),
) {
super()
this.realmSource = new ClientRealmSource(label)
this.realmSource = realmSource
this.lifecycle = new ClientBridgeLifecycle(bootstrap.reconnectBaseMs, bootstrap.reconnectMaxMs)
this.publisher = new ClientBridgePublisher({
topics: ['*'],
@@ -107,19 +108,23 @@ export class ClientInspectorSource extends InspectorSourceConnection {
this.publisher.close()
const socket = this.socket
const generation = this.generation
if (socket?.readyState === WebSocket.OPEN && generation !== undefined) {
const frame: SourceCloseFrame = {
v: INSPECTOR_PROTOCOL_VERSION,
t: 'source/close',
sourceId: this.realmSource.sourceId,
generation,
try {
if (socket?.readyState === WebSocket.OPEN && generation !== undefined) {
const frame: SourceCloseFrame = {
v: INSPECTOR_PROTOCOL_VERSION,
t: 'source/close',
sourceId: this.realmSource.sourceId,
generation,
}
socket.send(JSON.stringify(frame))
socket.close(1000, 'Client source closed')
} else {
socket?.close()
}
socket.send(JSON.stringify(frame))
socket.close(1000, 'Client source closed')
} else {
socket?.close()
} finally {
this.socket = undefined
this.realmSource.close()
}
this.socket = undefined
}
private connect(): void {
@@ -6,13 +6,41 @@ import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/obs
import { bridgeCapabilities } from '../cdp/index.ts'
const CLIENT_SOURCE_STORAGE_KEY = 'dsh.experimental-inspector.client-source-id.v0'
const CLIENT_SOURCE_LOCK_PREFIX = 'dsh.experimental-inspector.client-source:'
/** Owns one browser realm's stable source id across transport reconnects. */
export class ClientRealmSource {
/** Logical source id retained across reconnecting transport generations. */
readonly sourceId = sessionClientSourceId()
readonly sourceId: InspectorSourceDescriptor['sourceId']
constructor(private readonly label: string) {}
constructor(
private readonly label: string,
sourceId = sessionClientSourceId(),
private releaseClaim?: () => void,
) {
this.sourceId = sourceId
}
/**
* Claim the tab identity before opening its source transport. Browsers with
* Web Locks reject a copied `sessionStorage` identity while its original tab
* remains live; a fresh id is persisted and claimed instead.
* @param label - Human-readable Client label reported to the Worker.
* @returns The claimed realm source.
*/
static async claim(label: string): Promise<ClientRealmSource> {
let sourceId = sessionClientSourceId()
const locks = browserLockManager()
if (locks === undefined) return new ClientRealmSource(label, sourceId)
while (true) {
const release = await tryClaimSourceId(locks, sourceId)
if (release !== undefined) {
persistClientSourceId(sourceId)
return new ClientRealmSource(label, sourceId, release)
}
sourceId = generatedClientSourceId()
}
}
/**
* Create the descriptor for one newly admitted transport generation.
@@ -29,10 +57,16 @@ export class ClientRealmSource {
capabilities: bridgeCapabilities(clientOrigin(), hasSources),
}
}
/** Release this page's identity claim. */
close(): void {
this.releaseClaim?.()
this.releaseClaim = undefined
}
}
function sessionClientSourceId(): InspectorSourceDescriptor['sourceId'] {
const generated = inspectorId<'InspectorSourceId'>(`client-${randomUUID()}`, 'sourceId')
const generated = generatedClientSourceId()
try {
const stored = sessionStorage.getItem(CLIENT_SOURCE_STORAGE_KEY)
if (stored !== null) {
@@ -49,6 +83,41 @@ function sessionClientSourceId(): InspectorSourceDescriptor['sourceId'] {
return generated
}
function generatedClientSourceId(): InspectorSourceDescriptor['sourceId'] {
return inspectorId<'InspectorSourceId'>(`client-${randomUUID()}`, 'sourceId')
}
function persistClientSourceId(sourceId: InspectorSourceDescriptor['sourceId']): void {
try {
sessionStorage.setItem(CLIENT_SOURCE_STORAGE_KEY, sourceId)
} catch {
// Disabled or unavailable session storage limits identity to this page lifetime.
}
}
function browserLockManager(): LockManager | undefined {
if (typeof navigator === 'undefined') return undefined
return navigator.locks
}
function tryClaimSourceId(
locks: LockManager,
sourceId: InspectorSourceDescriptor['sourceId'],
): Promise<(() => void) | undefined> {
return new Promise((resolve, reject) => {
let release!: () => void
const held = new Promise<void>((released) => { release = released })
void locks.request(`${CLIENT_SOURCE_LOCK_PREFIX}${sourceId}`, { ifAvailable: true }, async (lock) => {
if (lock === null) {
resolve(undefined)
return
}
resolve(release)
await held
}).catch(reject)
})
}
function clientOrigin(): string {
const location = Reflect.get(globalThis, 'location') as unknown
if (typeof location !== 'object' || location === null) return ''
@@ -38,15 +38,18 @@ export const name = 'experimental-inspector'
/** This transport root has no Client service dependencies. */
export const inject: string[] = []
/** Mount the Client source and shared `ctx.inspector` publishing API. */
export function apply(ctx: Context): void {
/**
* Mount the Client source and shared `ctx.inspector` publishing API.
* @param ctx - Client Cordis context whose page identity and lifecycle own the source.
*/
export async function apply(ctx: Context): Promise<void> {
const injected = globalThis.__DSH_INSPECTOR__
if (injected === undefined) {
throw new Error('experimental inspector: Host bootstrap is missing')
}
const bootstrap = parseInspectorClientBootstrap(injected)
ctx.effect(() => {
const source = startInspectorClient(bootstrap)
await ctx.effect(async () => {
const source = await startInspectorClient(bootstrap)
const disposers: Array<() => unknown> = []
try {
disposers.push(publishCordisTree(ctx, source, {
@@ -66,7 +69,10 @@ export function apply(ctx: Context): void {
}, 'experimental-inspector: Client source')
}
function disposeInspectorClient(source: ReturnType<typeof startInspectorClient>, disposers: readonly (() => unknown)[]): void {
function disposeInspectorClient(
source: Awaited<ReturnType<typeof startInspectorClient>>,
disposers: readonly (() => unknown)[],
): void {
const failures: unknown[] = []
for (const dispose of [...disposers].reverse()) {
try {
@@ -150,8 +150,10 @@ describe.skipIf(!built)('Inspector built Client in Chromium', () => {
const context = contextEvent.params?.context as Record<string, unknown>
const contextId = context.id
const uniqueContextId = context.uniqueId
const sourceId = asRecord(context.auxData).sourceId
expect(contextId).toBeTypeOf('number')
expect(uniqueContextId).toBeTypeOf('string')
expect(sourceId).toBeTypeOf('string')
const evaluated = await cdp.call('Runtime.evaluate', {
expression: 'globalThis.__inspectorConsoleEvaluation = { answer: 6 * 7 }',
@@ -211,6 +213,26 @@ describe.skipIf(!built)('Inspector built Client in Chromium', () => {
url: script.params?.url,
lineNumber: 0,
})).error?.message).toContain('Client native debugging is unavailable')
const duplicateContext = cdp.waitForEvent('Runtime.executionContextCreated', (event) => {
const candidate = event.params?.context as Record<string, unknown> | undefined
return String(candidate?.name).startsWith('Client —') && candidate?.id !== contextId
})
const popup = await Promise.all([
page.waitForEvent('popup'),
page.evaluate(() => {
if (window.open(location.href, '_blank') === null) throw new Error('duplicate tab was blocked')
}),
]).then(([opened]) => opened)
await popup.waitForFunction(() => Reflect.get(globalThis, '__INSPECTOR_BROWSER_TEST__') !== undefined)
const duplicate = (await duplicateContext).params?.context as Record<string, unknown>
expect(asRecord(duplicate.auxData).sourceId).not.toBe(sourceId)
await cdp.call('Debugger.disable')
await popup.evaluate(async () => {
const state = Reflect.get(globalThis, '__INSPECTOR_BROWSER_TEST__') as { dispose?: () => Promise<void> } | undefined
await state?.dispose?.()
})
await popup.close()
}, 20_000)
})
@@ -231,14 +253,14 @@ globalThis.__DSH_BOOT__ = ${JSON.stringify(boot)};
globalThis.__ModuleLoader__ = { load(registration) { globalThis.__INSPECTOR_REGISTRATION__ = registration; } };
</script>
<script src="/client.js?rev=browser-test"></script>
<script>
<script type="module">
const registration = globalThis.__INSPECTOR_REGISTRATION__;
const disposers = [];
const root = {
__inspectorContext: true,
registry: new Map(),
events: { _hooks: {} },
effect(callback) { const dispose = callback(); disposers.push(dispose); return dispose; },
async effect(callback) { const dispose = await callback(); disposers.push(dispose); return dispose; },
on() { return () => {}; },
provide(name, value) { this[name] = value; return () => { delete this[name]; }; },
};
@@ -248,9 +270,9 @@ const plugin = registration.factory(specifier => {
if (specifier === '@deepseek-ai/cordis') return cordis;
throw new Error('Unexpected Client bundle dependency ' + specifier);
});
plugin.apply(root);
await plugin.apply(root);
globalThis.__INSPECTOR_BROWSER_TEST__ = {
dispose() { for (const dispose of disposers.reverse()) dispose(); },
async dispose() { for (const dispose of disposers.reverse()) await dispose(); },
};
</script>`
}
@@ -3,6 +3,7 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { apply } from '../src/client/index.ts'
import { ClientRealmSource } from '../src/client/inspection/realm.ts'
import type { InspectorClientBootstrap } from '../src/shared/bridge/messages/control.ts'
class FakeWebSocket extends EventTarget {
@@ -208,6 +209,48 @@ describe('experimental Inspector Client plugin', () => {
await secondFiber.dispose()
})
it('rotates a copied session identity while its original page remains live', async () => {
const descriptor = Object.getOwnPropertyDescriptor(navigator, 'locks')
const held = new Set<string>()
const request = async (
name: string,
_options: LockOptions,
callback: (lock: Lock | null) => unknown,
): Promise<unknown> => {
const acquired = !held.has(name)
if (acquired) held.add(name)
try {
return await callback(acquired ? { name, mode: 'exclusive' } : null)
} finally {
if (acquired) held.delete(name)
}
}
Object.defineProperty(navigator, 'locks', {
configurable: true,
value: { request },
})
let first: ClientRealmSource | undefined
let duplicate: ClientRealmSource | undefined
let refreshed: ClientRealmSource | undefined
try {
first = await ClientRealmSource.claim('first')
duplicate = await ClientRealmSource.claim('duplicate')
expect(duplicate.sourceId).not.toBe(first.sourceId)
first.close()
await vi.waitFor(() => { expect(held.size).toBe(1) })
sessionStorage.setItem('dsh.experimental-inspector.client-source-id.v0', first.sourceId)
refreshed = await ClientRealmSource.claim('refreshed')
expect(refreshed.sourceId).toBe(first.sourceId)
} finally {
first?.close()
duplicate?.close()
refreshed?.close()
if (descriptor === undefined) Reflect.deleteProperty(navigator, 'locks')
else Object.defineProperty(navigator, 'locks', descriptor)
}
})
it('falls back to a page-lifetime source id when session storage is unavailable', async () => {
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
globalThis.__DSH_INSPECTOR__ = bootstrap