mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(inspector): stabilize client bootstrap identity
This commit is contained in:
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user