Переглянути джерело

fix(webworker): support package inventory resolution

imccyu 1 місяць тому
батько
коміт
91b545daf5

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml

@@ -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 .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md
-2026-08-20-webworker-node-face.md: 6e69af83354f1139a03d047a700e84e7e918a013
-2026-08-20-webworker-node-face.zh.md: 96a60e459372828273b3a4d1330a7b7eb8f2994f
+2026-08-20-webworker-node-face.md: 41a30dedc7df9a882fbc1d8d3e3583c0a3602d81
+2026-08-20-webworker-node-face.zh.md: b57481335808f3e1a764da123a11ea74ba6cf371

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md

@@ -10,7 +10,7 @@ The worker runs the web profile's Cordis configuration byte for byte — no work
 
 ## Decision
 
-**Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/<module>.ts` carries real semantics over a worker data source; `./mock/<module>.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. The worker installs the `process` global itself and fills it into the table at assembly.
+**Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/<module>.ts` carries real semantics over a worker data source; `./mock/<module>.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. Its `createRequire` face supplies both `resolve()` and `resolve.paths()` against the image's package root, allowing unchanged packages to discover manifests without loading targets. The worker installs the `process` global itself and fills it into the table at assembly.
 
 **VFS.** Memory is the truth. `statSync(path, { bigint: true })` returns Node's BigInt shape, and two fields carry real information because `dsh-fs-local`'s stale-write guard depends on them: `ino` is per-path identity from a monotonic counter (a recreated path reports a new identity), and `mtimeMs` is strictly increasing per entry (`max(now, previous + 1)`), because in-memory writes routinely land in one millisecond and an equal timestamp would let a stale overwrite pass. Committed mutations also drive the [Node-compatible watcher and confinement implementation](2026-08-23-webworker-vfs-watch-and-landlock.md). Boot diagnostics remain visible because cordis logger verbosity counts UP: `startWorkerHost` installs a console exporter with `levels: { default: 2 }` before any entry mounts, while an exporter with no declared level drops every warning.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md

@@ -10,7 +10,7 @@ worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属
 
 ## 决定
 
-**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/<module>.ts` 在 worker 数据源之上承载真语义;`./mock/<module>.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。`process` 全局由 worker 自装,装配期填入表中。
+**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/<module>.ts` 在 worker 数据源之上承载真语义;`./mock/<module>.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。它的 `createRequire` 面在镜像 package 根之上同时提供 `resolve()` 与 `resolve.paths()`,使未修改的包无需加载目标即可发现 manifest。`process` 全局由 worker 自装,装配期填入表中。
 
 **VFS。** 内存为真相。`statSync(path, { bigint: true })` 返回 Node 的 BigInt 形状,其中两个字段承载真实信息,因为 `dsh-fs-local` 的 stale-write guard 依赖它们:`ino` 是按路径的身份(单调计数器分配,路径重建即新身份),`mtimeMs` 按条目严格递增(`max(now, previous + 1)`)——内存写例行落在同一毫秒内,相等的时间戳会放过陈旧覆写。已提交的 mutation 还会驱动 [Node 兼容 watcher 与 confinement 实现](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)。Cordis 日志器的详细度数值向上计数,因此 `startWorkerHost` 会在任何 entry 挂载前安装 `levels: { default: 2 }` 的 console exporter,避免未声明等级的 exporter 丢掉所有 warning。
 

+ 66 - 0
packages/experimental/webworker-packer/tests/image-loadable.spec.ts

@@ -20,12 +20,14 @@ import { existsSync } from 'node:fs'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { describe, expect, it } from 'vitest'
+import { FiberState } from '@deepseek-ai/cordis'
 import { createNodeBuiltins, REPLACED_PREFIXES } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtins.ts'
 import {
   setActiveModuleLoader, WorkerModuleLoader,
 } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/module-system/module-loader.ts'
 import { inflateImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/image-gzip.ts'
 import { loadVfsImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts'
+import { setActiveVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active.ts'
 import { indexWorkspacePackages, previewFixtures } from '../src/repository.ts'
 import { DEFAULT_ROOT, MANIFEST_PATH, packVfsImage, packVfsOverlay } from '../src/pack.ts'
 
@@ -34,6 +36,7 @@ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
 /** A leaf workspace package: real build output, no dependencies to drag in. */
 const SUBJECT = '@deepseek-ai/dsh-timeout'
 const LANDLOCK = '@deepseek-ai/node-addon-landlock-run'
+const PLUGIN_INVENTORY = '@deepseek-ai/dsh-plugin-package-inventory-deepseek'
 
 const workspaces = indexWorkspacePackages(repoRoot)
 
@@ -91,6 +94,15 @@ const packedLandlock = (): ReturnType<typeof packVfsImage> => landlockMemo ??= p
   entries: [],
 })
 
+let pluginInventoryMemo: ReturnType<typeof packVfsImage> | undefined
+const packedPluginInventory = (): ReturnType<typeof packVfsImage> => pluginInventoryMemo ??= packVfsImage({
+  config: `- id: subject\n  name: '${PLUGIN_INVENTORY}'\n`,
+  profile: 'plugin-inventory-check',
+  workspaces,
+  resolveFrom: repoRoot,
+  entries: [],
+})
+
 /** The image's archive, inflated once: mounting reads the tar, not the gzip member. */
 let archiveMemo: Uint8Array | undefined
 const archive = async (): Promise<Uint8Array> =>
@@ -191,6 +203,7 @@ const archive = async (): Promise<Uint8Array> =>
       staticModules: createNodeBuiltins(),
       staticModulePrefixes: REPLACED_PREFIXES,
     })
+    setActiveVfs(vfs)
     setActiveModuleLoader(loader)
     const landlock = loader.requireFrom(`${DEFAULT_ROOT}/workspace`)(LANDLOCK) as {
       LAUNCHER_BIN: string
@@ -211,6 +224,59 @@ const archive = async (): Promise<Uint8Array> =>
     expect(landlock.probe()).toBe('full')
   })
 
+  it('prepares the unchanged plugin-package inventory through Worker createRequire paths', async () => {
+    const result = packedPluginInventory()
+    expect(result.missing).toEqual([])
+
+    const vfs = loadVfsImage(await inflateImage(result.image, 'the packed plugin inventory'), DEFAULT_ROOT)
+    const loader = new WorkerModuleLoader({
+      vfs,
+      root: DEFAULT_ROOT,
+      staticModules: createNodeBuiltins(),
+      staticModulePrefixes: REPLACED_PREFIXES,
+    })
+    setActiveVfs(vfs)
+    setActiveModuleLoader(loader)
+    const inventory = loader.requireFrom(`${DEFAULT_ROOT}/workspace`)(PLUGIN_INVENTORY) as {
+      apply(ctx: unknown, config: unknown): void
+    }
+
+    type Prepared = { readonly value: { readonly version: number; readonly packages: readonly unknown[] } }
+    type Prepare = (request: { readonly body: object; readonly signal: AbortSignal }) => Promise<Prepared>
+    let prepare: Prepare | undefined
+    const baseUrl = `file://${DEFAULT_ROOT}/config/cordis.yml`
+    const tree: { readonly ctx: { readonly baseUrl: string }; entries(): readonly unknown[] } = {
+      ctx: { baseUrl },
+      entries: () => [entry],
+    }
+    const entry = {
+      options: { name: PLUGIN_INVENTORY },
+      disabled: false,
+      fiber: { state: FiberState.ACTIVE },
+      parent: { tree },
+    }
+    inventory.apply({
+      baseUrl,
+      loader: tree,
+      deepseekLlmApiExtensions: {
+        register: (field: string, contribution: { readonly prepare: Prepare }): void => {
+          expect(field).toBe('dsh_plugin_packages')
+          prepare = contribution.prepare
+        },
+      },
+    }, {})
+
+    if (prepare === undefined) throw new Error('packed plugin inventory did not register its request contribution')
+    const prepared = await prepare({ body: {}, signal: new AbortController().signal })
+    const manifest = JSON.parse(vfs.readFileSync(
+      `${DEFAULT_ROOT}/node_modules/${PLUGIN_INVENTORY}/package.json`, 'utf8',
+    ) as string) as { version: string }
+    expect(prepared.value).toEqual({
+      version: 1,
+      packages: [{ name: PLUGIN_INVENTORY, version: manifest.version }],
+    })
+  })
+
   it('refuses a body the packer did not lower, naming the image', async () => {
     // The case above only proves the packed bytes are wrappable. This is the
     // other half: the loader has no transform to fall back on, so an entry the

+ 2 - 2
packages/experimental/webworker-runtime/README.i18n.yaml

@@ -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/webworker-runtime/README.md
-README.md: df6dacad35273f8c636a86c7c260b4f5d1958a6a
-README.zh.md: 721770149c04169d813d2b5d3d4faafdccf1dec5
+README.md: 3e9b4fffe0b97a97adf218aa12fd1f4342d3bc6c
+README.zh.md: 2552c659d1b735b0cf28b9b0d0808276d31d0a2a

+ 1 - 1
packages/experimental/webworker-runtime/README.md

@@ -7,7 +7,7 @@ The browser worker host: the whole harness plugin tree runs inside one dedicated
 Three artifacts from one tsdown pipeline:
 
 - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`.
-- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)).
+- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)).
 - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process.
 - **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. The tunnel exposes fetch-shaped transport, the API client, and `loadBundle` for the shell's boot seam.
 

+ 1 - 1
packages/experimental/webworker-runtime/README.zh.md

@@ -7,7 +7,7 @@
 一条 tsdown 管线出三个产物:
 
 - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。
-- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。
+- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。
 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。
 - **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。隧道暴露 fetch 形传输、API 客户端与壳启动缝隙用的 `loadBundle`。
 

+ 39 - 14
packages/experimental/webworker-runtime/src/module-system/module-loader.ts

@@ -1,8 +1,8 @@
 /**
  * CommonJS module loader over the worker VFS. It fills the `loader.internal`
  * seam Cordis uses for every entry import, and backs the `node:module`
- * `createRequire` proxy that `typert-loader` and `client-modules` resolve
- * package metadata through.
+ * `createRequire` proxy that `typert-loader`, `client-modules`, and the plugin
+ * package inventory resolve package metadata through.
  *
  * Resolution is a narrowed Node `require` algorithm: `exports` walk with a
  * fixed condition order, extension probing, and one cache keyed by resolved
@@ -49,10 +49,26 @@ interface ModuleRecord {
   readonly module: { exports: unknown }
 }
 
+/** Resolution helpers carried by a Worker-backed CommonJS require. */
+export interface WorkerRequireResolve {
+  /**
+   * Resolve one specifier without evaluating its module.
+   * @param specifier - Module request relative to the require base.
+   * @returns Static or VFS-backed module identity.
+   */
+  (specifier: string): string
+  /**
+   * Return the directories this loader's Node-style package discovery searches.
+   * @param specifier - Module request whose lookup roots are requested.
+   * @returns Search roots, or null for a Worker-provided module.
+   */
+  paths(specifier: string): string[] | null
+}
+
 /** The `require` function shape the roster consumes through `createRequire`. */
 export interface WorkerRequire {
   (specifier: string): unknown
-  resolve(specifier: string): string
+  readonly resolve: WorkerRequireResolve
 }
 
 /** Construction inputs for {@link WorkerModuleLoader}. */
@@ -226,6 +242,16 @@ export class WorkerModuleLoader {
     return this.fail(`cannot resolve "${specifier}": no file at ${candidates.join(', ')}`)
   }
 
+  /** @returns The Worker-provided implementation of a static specifier. */
+  private staticModule(specifier: string): StaticModuleFactory | undefined {
+    const exact = this.staticModules.get(specifier)
+    if (exact !== undefined) return exact
+    for (const [prefix, factory] of this.staticPrefixes) {
+      if (specifier.startsWith(prefix)) return factory
+    }
+    return this.staticModules.get(`node:${specifier}`)
+  }
+
   /**
    * Resolve a specifier the way the module that requested it would.
    * @param specifier - Bare name, relative path, absolute path, or file URL.
@@ -233,11 +259,8 @@ export class WorkerModuleLoader {
    * @returns Static module or the resolved VFS path.
    */
   resolve(specifier: string, fromDirectory: string): Resolution {
-    const exact = this.staticModules.get(specifier)
-    if (exact !== undefined) return { kind: 'static', specifier, factory: exact }
-    for (const [prefix, factory] of this.staticPrefixes) {
-      if (specifier.startsWith(prefix)) return { kind: 'static', specifier, factory }
-    }
+    const staticModule = this.staticModule(specifier)
+    if (staticModule !== undefined) return { kind: 'static', specifier, factory: staticModule }
     if (specifier.startsWith('cordis:') || specifier.startsWith('node:')) {
       return this.fail(`no static module is registered for "${specifier}"`)
     }
@@ -250,9 +273,6 @@ export class WorkerModuleLoader {
     if (isAbsolute(specifier)) {
       return { kind: 'file', path: this.probe(specifier, specifier) }
     }
-    // Node resolves `fs` and `node:fs` to the same builtin; the proxy table may register either.
-    const prefixed = this.staticModules.get(`node:${specifier}`)
-    if (prefixed !== undefined) return { kind: 'static', specifier, factory: prefixed }
     const segments = specifier.split('/')
     const packageName = specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0] ?? specifier
     const rest = specifier.slice(packageName.length).replace(/^\//, '')
@@ -356,15 +376,20 @@ export class WorkerModuleLoader {
    * @returns Callable require with `resolve`.
    */
   requireFrom(fromDirectory: string): WorkerRequire {
-    const require = ((specifier: string): unknown => this.load(this.resolve(specifier, fromDirectory))) as WorkerRequire
-    require.resolve = (specifier: string): string => {
+    const require = (specifier: string): unknown => this.load(this.resolve(specifier, fromDirectory))
+    const resolve = ((specifier: string): string => {
       const resolution = this.resolve(specifier, fromDirectory)
       if (resolution.kind === 'static') {
         return this.fail(`"${specifier}" is a worker-provided module and has no VFS path`)
       }
       return resolution.path
+    }) as WorkerRequireResolve
+    resolve.paths = (specifier: string): string[] | null => {
+      if (this.staticModule(specifier) !== undefined || specifier.startsWith('node:')) return null
+      if (specifier.startsWith('.')) return [resolvePath(fromDirectory, '.')]
+      return [join(this.root, 'node_modules')]
     }
-    return require
+    return Object.assign(require, { resolve })
   }
 
   /**

+ 4 - 3
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/module.ts

@@ -1,7 +1,8 @@
 /**
  * `node:module` for the worker: `createRequire` hands out the worker module
- * loader's synchronous require, so typert's `require.resolve('<pkg>/package.json')
- * + readFileSync + import()` bypass runs unmodified over the VFS.
+ * loader's synchronous require. Typert can resolve package exports, and package
+ * inventory can discover manifests through `require.resolve.paths()` without
+ * either consumer changing for the Worker.
  */
 import { requireActiveModuleLoader, type WorkerRequire } from '../../../module-system/module-loader.ts'
 
@@ -11,7 +12,7 @@ export type NodeRequire = WorkerRequire
 /**
  * Build a `require` bound to a base path or file URL.
  * @param base - directory, file path, or file URL the resolution starts from.
- * @returns the synchronous require face.
+ * @returns the synchronous require face, including `resolve()` and `resolve.paths()`.
  */
 export function createRequire(base: string | URL): NodeRequire {
   return requireActiveModuleLoader().createRequire(base)

+ 10 - 2
packages/experimental/webworker-runtime/tests/node/builtins-table.spec.ts

@@ -15,11 +15,11 @@
  */
 import { describe, expect, it } from 'vitest'
 import { createNodeBuiltins, REPLACED_PREFIXES } from '../../src/node/builtins.ts'
-import { WorkerModuleLoader } from '../../src/module-system/module-loader.ts'
+import { WorkerModuleLoader, type WorkerRequire } from '../../src/module-system/module-loader.ts'
 import { MemoryVfs } from '../../src/storage/memory.ts'
 
 /** A loader over an empty image: every specifier below resolves from the table. */
-function loaderRequire(): (specifier: string) => unknown {
+function loaderRequire(): WorkerRequire {
   const vfs = new MemoryVfs()
   vfs.seedDirectory('/dsh')
   const loader = new WorkerModuleLoader({ vfs, root: '/dsh', staticModules: createNodeBuiltins() })
@@ -84,4 +84,12 @@ describe('module identity through the loader', () => {
     const require = loaderRequire()
     expect(() => require('node:dns')).toThrow()
   })
+
+  it('exposes the package search paths used by the VFS resolver', () => {
+    const require = loaderRequire()
+    expect(require.resolve.paths('node:fs')).toBeNull()
+    expect(require.resolve.paths('node:dns')).toBeNull()
+    expect(require.resolve.paths('workspace-package')).toEqual(['/dsh/node_modules'])
+    expect(require.resolve.paths('./local.js')).toEqual(['/dsh'])
+  })
 })