fix(inspector): stabilize client bootstrap identity

This commit is contained in:
imccyu
2026-08-27 19:35:34 +08:00
parent 8b09a0be52
commit ac13b16c0c
15 changed files with 345 additions and 65 deletions
+145 -40
View File
@@ -23,13 +23,14 @@
*/
import { createHash, randomBytes } from 'node:crypto'
import { readFileSync, statSync } from 'node:fs'
import { existsSync, readFileSync, statSync } from 'node:fs'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { dirname, isAbsolute, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
import { optionalStringArray, stripClientSuffix } from './client/manifest.ts'
import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph } from './client/manifest.ts'
@@ -85,6 +86,11 @@ interface PkgMeta extends WebBootRowFields {
clientPath: string
}
interface ResolvedPkgMeta {
packageName: string
meta: PkgMeta
}
/** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
@@ -129,6 +135,10 @@ class ClientPackageCompositionError extends AggregateError {
/** One composed table row: the wire entry plus the resolved package metadata behind it. */
interface WebPluginRecord {
entry: WebBootEntry
/** Loader specifier whose active row contributes this browser module. */
loaderName: string
/** Loader resolution input that selected this package instance. */
sourceKey: string
meta: PkgMeta
/** Exact build artifact included in the startup batches. */
bundle: Buffer
@@ -167,6 +177,15 @@ const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$
/** Debugger source name appended to page bundles in the WebWorker image. */
const SOURCE_URL_TRAILER = /(?:\r?\n)?\/\/# sourceURL=([^\r\n]+)(?:\r?\n)?$/
/** Return a bare package-root specifier, excluding package subpaths and path-like entries. */
function exactPackageSpecifier(specifier: string): string | undefined {
if (specifier.startsWith('@')) {
const parts = specifier.split('/')
return parts.length === 2 && parts.every(Boolean) ? specifier : undefined
}
return specifier.length > 0 && !specifier.includes('/') ? specifier : undefined
}
/** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration | undefined {
if (value === undefined) return undefined
@@ -504,14 +523,12 @@ export class ClientModuleRegistry extends Service {
static inject = ['webServer', 'loader']
private readonly table = new Map<string, WebPluginRecord>()
// Negative verdicts (unresolvable specifier — builtins like cordis:include,
// subpath rows — or a package without a web `dsh.client` declaration) are
// cached as null and never expire: plugin-set changes take effect on restart.
private readonly pkgMeta = new Map<string, PkgMeta | null>()
// 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>()
private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
private readonly graphListeners = new Set<() => void>()
private readonly dirty = new Set<string>()
private readonly resolvePkgJson: (spec: string) => string
private readonly initialRevisionNonce = randomBytes(8).toString('hex')
private nextInitialRevision = 0
private responses = new Map<string, { body: Buffer; contentType: string }>()
@@ -527,16 +544,6 @@ export class ClientModuleRegistry extends Service {
*/
constructor(ctx: Context) {
super(ctx, 'clientModules')
// Resolution anchor: the config tree's baseUrl (the cordis.yml directory,
// whose package declares every composed plugin as a dependency). The
// modules package's own URL would miss sibling packages under pnpm's
// isolated node_modules.
if (ctx.baseUrl === undefined) {
throw new Error('client-modules: ctx.baseUrl is unset — the node half needs the config-tree anchor to resolve plugin packages')
}
const require = createRequire(ctx.baseUrl)
this.resolvePkgJson = spec => require.resolve(`${spec}/package.json`)
// Subscribe before seeding so a fiber arriving mid-activation lands in the
// same dirty set (Set idempotence makes the overlap harmless). An entry-less
// fiber is a child plugin or a manual mount — never a loader row; O(1) drop.
@@ -716,31 +723,31 @@ export class ClientModuleRegistry extends Service {
}
}
private resolveMeta(pkgName: string): PkgMeta | null {
const cached = this.pkgMeta.get(pkgName)
private resolveMeta(loaderName: string, baseUrl: string): ResolvedPkgMeta | null {
const sourceKey = this.sourceKey(loaderName, baseUrl)
const cached = this.pkgMeta.get(sourceKey)
if (cached !== undefined) return cached
let pkgPath: string
try {
pkgPath = this.resolvePkgJson(pkgName)
} catch {
const located = this.locatePkgJson(loaderName, baseUrl)
if (located === undefined) {
// Not a resolvable package root: loader builtins (cordis:include) and
// subpath entries (…/gateway) land here — permanently not a client row.
this.pkgMeta.set(pkgName, null)
this.pkgMeta.set(sourceKey, null)
return null
}
const { packageName, path: pkgPath } = located
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>
const dsh = pkg.dsh
const decl = parseDshClient(
pkgName,
packageName,
dsh !== null && typeof dsh === 'object' ? (dsh as Record<string, unknown>).client : undefined,
)
if (decl === undefined || decl.platform !== 'web') {
this.pkgMeta.set(pkgName, null)
this.pkgMeta.set(sourceKey, null)
return null
}
const clientRel = clientExportOf(pkgName, pkg.exports)
const clientRel = clientExportOf(packageName, pkg.exports)
if (clientRel === undefined) {
throw new Error(`client-modules: ${pkgName} declares dsh.client but exports no "./client" bundle`)
throw new Error(`client-modules: ${packageName} declares dsh.client but exports no "./client" bundle`)
}
const meta: PkgMeta = {
clientPath: join(dirname(pkgPath), clientRel),
@@ -748,8 +755,87 @@ export class ClientModuleRegistry extends Service {
external: decl.external ?? [],
immediately: decl.immediately === true,
}
this.pkgMeta.set(pkgName, meta)
return meta
const resolved = { packageName, meta }
this.pkgMeta.set(sourceKey, resolved)
return resolved
}
/**
* Locate the manifest of the package the Loader mounts for a row. The row's
* module location is authoritative: the specifier resolves through the same
* Loader resolution that imported the row's host half — including any
* active ESM hooks — and the nearest ancestor manifest declaring the name
* owns the module. Config-anchor `require` resolution remains only for
* runtimes without Node internals.
* @param loaderName - module specifier of the loader row.
* @param baseUrl - resolution base of the tree that owns the row.
* @returns the manifest path, or `undefined` when the name resolves to no package root.
*/
private locatePkgJson(loaderName: string, baseUrl: string): { path: string; packageName: string } | undefined {
if (loaderName.startsWith('cordis:')) return undefined
const pathLike = loaderName.startsWith('.') || loaderName.startsWith('file:') || isAbsolute(loaderName)
const expectedPackageName = pathLike ? undefined : exactPackageSpecifier(loaderName)
if (!pathLike && expectedPackageName === undefined) return undefined
const internal = this.ctx.loader.internal
if (internal === undefined || typeof Reflect.get(internal, 'resolveSync') !== 'function') {
if (expectedPackageName === undefined) {
const moduleUrl = loaderName.startsWith('file:')
? loaderName
: isAbsolute(loaderName) ? pathToFileURL(loaderName).href : new URL(loaderName, baseUrl).href
return this.nearestPackage(moduleUrl)
}
try {
return {
path: createRequire(baseUrl).resolve(`${expectedPackageName}/package.json`),
packageName: expectedPackageName,
}
} catch {
// Without Node internals the owning tree is the only resolver; an
// unresolvable name is classified exactly as below.
return undefined
}
}
let moduleUrl: string
try {
moduleUrl = internal.version === 'v2'
? internal.resolveSync(baseUrl, { specifier: loaderName, attributes: {} }).url
: internal.resolveSync(loaderName, baseUrl, {}).url
} catch {
// The Loader cannot resolve the name: its row cannot have imported, so
// the name is permanently not a client row.
return undefined
}
return this.nearestPackage(moduleUrl, expectedPackageName)
}
private nearestPackage(
moduleUrl: string,
expectedPackageName?: string,
): { path: string; packageName: string } | undefined {
if (!moduleUrl.startsWith('file:')) return undefined
let dir = dirname(fileURLToPath(moduleUrl))
while (true) {
const candidate = join(dir, 'package.json')
if (existsSync(candidate)) {
try {
const name = (JSON.parse(readFileSync(candidate, 'utf8')) as { name?: unknown }).name
if (typeof name === 'string' && (expectedPackageName === undefined || name === expectedPackageName)) {
return { path: candidate, packageName: name }
}
} catch {
// An unreadable or malformed intermediate manifest cannot own the
// module; keep walking toward the declaring package root.
}
}
const parent = dirname(dir)
if (parent === dir) break
dir = parent
}
return undefined
}
private sourceKey(loaderName: string, baseUrl: string): string {
return `${baseUrl}\0${loaderName}`
}
/** Capture the bundle stats before reading its bytes. */
@@ -802,23 +888,32 @@ 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 qualifies = false
let activeEntry: Entry | undefined
for (const entry of this.ctx.loader.entries()) {
if (entry.options.name === entryName && entry.fiber !== undefined && !entry.disabled) {
qualifies = true
activeEntry = entry
break
}
}
if (!qualifies) return this.table.delete(entryName)
if (this.table.has(entryName)) return false
const meta = this.resolveMeta(entryName)
if (meta === null) return false
if (activeEntry === undefined) return this.deleteLoaderEntry(entryName)
const baseUrl = activeEntry.parent.tree.ctx.baseUrl
if (baseUrl === undefined) {
throw new Error(`client-modules: loader entry ${entryName} 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)
// 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(entryName, meta.clientPath)
const snapshot = this.initialBundleSnapshot(packageName, meta.clientPath)
const rev = this.allocateInitialRevision()
this.table.set(entryName, {
entry: graphRow(entryName, rev, meta),
this.table.set(packageName, {
entry: graphRow(packageName, rev, meta),
loaderName: entryName,
sourceKey,
meta,
bundle: snapshot.bundle,
baseline: snapshot.baseline,
@@ -827,6 +922,16 @@ 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]) {
@@ -58,13 +58,26 @@ function writeBuiltPackage(packageName: string, client: Record<string, unknown>)
}
/** Construct the node-half service and capture its plugin-bundle route. */
function constructWithRoute(packageNames: string[]): { service: ClientModuleRegistry; route: WebRoute } {
function constructWithRoute(
packageNames: string[],
options: {
contextBaseUrl?: string
entryBaseUrl?: string
internal?: NonNullable<Context['loader']['internal']>
} = {},
): { service: ClientModuleRegistry; route: WebRoute } {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(root!).href + '/'
ctx.baseUrl = options.contextBaseUrl ?? pathToFileURL(root!).href + '/'
ctx.provide('loader', {
internal: options.internal,
*entries() {
for (const packageName of packageNames) {
yield { options: { name: packageName }, fiber: {}, disabled: false }
yield {
options: { name: packageName },
fiber: {},
disabled: false,
parent: { tree: { ctx: { baseUrl: options.entryBaseUrl ?? ctx.baseUrl } } },
}
}
},
})
@@ -227,6 +240,65 @@ describe('HTML bootstrap facade', () => {
})
describe('client bundle activation', () => {
it.each(['v1', 'v2'] as const)(
'resolves %s package metadata from the owning entry tree',
(version) => {
const packageName = `@fixture/entry-base-${version}`
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 contextBaseUrl = pathToFileURL(join(root!, 'profile')).href + '/'
const entryBaseUrl = pathToFileURL(join(root!, 'overlay')).href + '/'
const calls: unknown[][] = []
const resolveSync = (...args: unknown[]) => {
calls.push(args)
return { format: 'module' as const, url: pathToFileURL(hostPath).href }
}
const internal = { version, resolveSync }
const { service } = constructWithRoute([packageName], {
contextBaseUrl,
entryBaseUrl,
internal: internal as NonNullable<Context['loader']['internal']>,
})
expect(calls).toEqual(version === 'v2'
? [[entryBaseUrl, { specifier: packageName, attributes: {} }]]
: [[packageName, entryBaseUrl, {}]])
expect(service.clientPath(packageName)).toBe(clientPath)
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
},
)
it('derives the browser module id from a file entry owning manifest', () => {
const packageName = '@fixture/file-entry'
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 service = construct([pathToFileURL(hostPath).href])
expect(service.clientPath(packageName)).toBe(clientPath)
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
})
it('uses owning-tree package resolution for an import-only Worker module loader', () => {
const packageName = '@fixture/worker-loader'
writeBuiltPackage(packageName, {})
const internal = {
version: 'worker',
import: async () => ({}),
} as unknown as NonNullable<Context['loader']['internal']>
const { service } = constructWithRoute([packageName], { internal })
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
})
it('allows sibling dsh roles', () => {
const currentName = '@fixture/current-client-field'
const clientPath = writePackage(currentName, {
@@ -1,13 +1,8 @@
# Development overlay for the experimental inspector: mount it per launch with
# pnpm run demo:inspector (pnpm dsh web --patch ./packages/experimental/inspector/cordis.patch.yml)
# A source launch resolves this workspace package through the tsconfig paths
# facade and needs no installation. A built launch additionally needs the
# package importable from the profile:
# dsh plugin --profile web add link:<absolute path to this package directory>
# (`link:`, not `file:` — `file:` re-installs the workspace:^ dependencies
# inside the profile and fails). The package is private and ships with no
# published dsh installation; a missing package fails loud at entry import.
# Built-artifact overlay for the experimental inspector:
# node apps/cli/lib/bin.js web --patch ./packages/experimental/inspector/cordis.patch.yml
# The relative entry is anchored to this file, so the private package does not
# need to be installed into the selected profile. Run `pnpm run build` first.
- insert:
- id: experimental-inspector
name: '@deepseek-ai/dsh-experimental-inspector'
name: './lib/index.js'
@@ -0,0 +1,6 @@
# Source overlay for `pnpm run demo:inspector`. The relative entry is anchored
# to this file and runs through the CLI's tsx loader without a profile install.
- insert:
- id: experimental-inspector
name: './src/index.ts'
@@ -5,10 +5,12 @@ import { inspectorId } from '../../shared/identity.ts'
import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts'
import { bridgeCapabilities } from '../cdp/index.ts'
const CLIENT_SOURCE_STORAGE_KEY = 'dsh.experimental-inspector.client-source-id.v0'
/** Owns one browser realm's stable source id across transport reconnects. */
export class ClientRealmSource {
/** Logical source id retained across reconnecting transport generations. */
readonly sourceId = inspectorId<'InspectorSourceId'>(`client-${randomUUID()}`, 'sourceId')
readonly sourceId = sessionClientSourceId()
constructor(private readonly label: string) {}
@@ -29,6 +31,24 @@ export class ClientRealmSource {
}
}
function sessionClientSourceId(): InspectorSourceDescriptor['sourceId'] {
const generated = inspectorId<'InspectorSourceId'>(`client-${randomUUID()}`, 'sourceId')
try {
const stored = sessionStorage.getItem(CLIENT_SOURCE_STORAGE_KEY)
if (stored !== null) {
try {
return inspectorId<'InspectorSourceId'>(stored, 'sourceId')
} catch {
// Invalid page-owned storage is replaced with a fresh protocol identity below.
}
}
sessionStorage.setItem(CLIENT_SOURCE_STORAGE_KEY, generated)
} catch {
// Disabled or unavailable session storage limits identity to this page lifetime.
}
return generated
}
function clientOrigin(): string {
const location = Reflect.get(globalThis, 'location') as unknown
if (typeof location !== 'object' || location === null) return ''
@@ -66,9 +66,11 @@ describe('experimental Inspector Client plugin', () => {
const nativeFetch = globalThis.fetch
afterEach(() => {
vi.restoreAllMocks()
FakeWebSocket.sockets.length = 0
globalThis.WebSocket = nativeWebSocket
globalThis.fetch = nativeFetch
sessionStorage.clear()
delete globalThis.__DSH_INSPECTOR__
Reflect.deleteProperty(globalThis, '__DSH_BOOT__')
})
@@ -179,6 +181,50 @@ describe('experimental Inspector Client plugin', () => {
await fiber.dispose()
})
it('keeps the logical source id when the Client plugin is recreated after a page refresh', async () => {
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
globalThis.__DSH_INSPECTOR__ = bootstrap
const firstContext = new Context()
const firstFiber = firstContext.plugin({ apply })
await firstFiber.await()
const firstSocket = FakeWebSocket.sockets[0]!
firstSocket.open()
const firstOpen = JSON.parse(firstSocket.sent[0]!) as {
source: { sourceId: string; generation: string }
}
await firstFiber.dispose()
const secondContext = new Context()
const secondFiber = secondContext.plugin({ apply })
await secondFiber.await()
const secondSocket = FakeWebSocket.sockets[1]!
secondSocket.open()
const secondOpen = JSON.parse(secondSocket.sent[0]!) as {
source: { sourceId: string; generation: string }
}
expect(secondOpen.source.sourceId).toBe(firstOpen.source.sourceId)
expect(secondOpen.source.generation).not.toBe(firstOpen.source.generation)
await secondFiber.dispose()
})
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
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('storage disabled', 'SecurityError')
})
const ctx = new Context()
const fiber = ctx.plugin({ apply })
await fiber.await()
const socket = FakeWebSocket.sockets[0]!
socket.open()
const open = JSON.parse(socket.sent[0]!) as { source: { sourceId: string } }
expect(open.source.sourceId).toMatch(/^client-/u)
await fiber.dispose()
})
it('cancels an outstanding Client Runtime operation without sending a late response', async () => {
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
globalThis.__DSH_INSPECTOR__ = bootstrap
@@ -45,6 +45,12 @@ export type Resolution =
| { readonly kind: 'static'; readonly specifier: string; readonly factory: StaticModuleFactory }
| { readonly kind: 'file'; readonly path: string }
/** Node-loader-compatible resolution returned through the Cordis internal seam. */
export interface WorkerInternalResolution {
readonly format: 'builtin' | 'commonjs' | 'json'
readonly url: string
}
interface ModuleRecord {
readonly module: { exports: unknown }
}
@@ -121,6 +127,8 @@ export class WorkerModuleLoader {
readonly internal: {
readonly version: 'worker'
import(specifier: string, parentURL?: string, attributes?: unknown): Promise<unknown>
resolve(specifier: string, parentURL?: string, attributes?: unknown): Promise<WorkerInternalResolution>
resolveSync(specifier: string, parentURL?: string, attributes?: unknown): WorkerInternalResolution
}
constructor(options: WorkerModuleLoaderOptions) {
@@ -133,12 +141,23 @@ export class WorkerModuleLoader {
.sort(([left], [right]) => right.length - left.length)
this.conditions = new Set(options.conditions ?? DEFAULT_CONDITIONS)
this.als = createAlsRuntime(options.alsCausality)
const resolveInternal = (specifier: string, parentURL?: string): WorkerInternalResolution => {
const from = parentURL === undefined ? this.root : this.baseDirectoryOf(parentURL)
const resolution = this.resolve(specifier, from)
if (resolution.kind === 'static') return { format: 'builtin', url: resolution.specifier }
return {
format: resolution.path.endsWith('.json') ? 'json' : 'commonjs',
url: pathToFileUrl(resolution.path),
}
}
this.internal = {
version: 'worker',
import: async (specifier: string, parentURL?: string): Promise<unknown> => {
const from = parentURL === undefined ? this.root : this.baseDirectoryOf(parentURL)
return this.load(this.resolve(specifier, from))
},
resolve: async (specifier: string, parentURL?: string) => resolveInternal(specifier, parentURL),
resolveSync: resolveInternal,
}
}
@@ -60,6 +60,23 @@ describe('the replacement table', () => {
})
describe('module identity through the loader', () => {
it('exposes async and synchronous resolution through the Cordis internal seam', async () => {
const vfs = new MemoryVfs()
vfs.seedDirectory('/dsh/node_modules/example')
vfs.writeFileSync('/dsh/node_modules/example/package.json', JSON.stringify({ main: 'index.js' }))
vfs.writeFileSync('/dsh/node_modules/example/index.js', 'module.exports = {}\n')
const loader = new WorkerModuleLoader({ vfs, root: '/dsh', staticModules: createNodeBuiltins() })
expect(loader.internal.resolveSync('example', 'file:///dsh/app.js')).toEqual({
format: 'commonjs',
url: 'file:///dsh/node_modules/example/index.js',
})
await expect(loader.internal.resolve('node:fs', 'file:///dsh/app.js')).resolves.toEqual({
format: 'builtin',
url: 'node:fs',
})
})
it('hands the same instance to two requires of one specifier', () => {
const require = loaderRequire()
expect(require('node:events')).toBe(require('node:events'))