Bläddra i källkod

fix(preview): provide the native realpath callback alias

Tianyi Cui 2 veckor sedan
förälder
incheckning
f72c2e019d

+ 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: 35e34fa0893920976822591d7d8d756d8c1ea076
-README.zh.md: ec23210e5bfdf3b2d03f31dfd725fc3d1d5a170d
+README.md: 15b30b87d3e82ad78ad91dc24133113bcc6c4773
+README.zh.md: 6b89b729973b92278d1a488d8d0d4e4578997097

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

@@ -26,7 +26,7 @@ The browser worker host: the whole harness plugin tree runs inside one dedicated
 Three build artifacts and one source-owned process layer:
 
 - **`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. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink, `FileHandle.stat({ bigint: true })` reports the same device and inode identity as a path stat while the name still refers to that file, and `FileHandle.chmod()` updates the opened file identity; `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.
+- **`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. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink, `FileHandle.stat({ bigint: true })` reports the same device and inode identity as a path stat while the name still refers to that file, and `FileHandle.chmod()` updates the opened file identity; `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. The callback `fs.realpath.native` alias resolves within the mounted VFS and supports `util.promisify`.
 - **`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. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport, the independent file-upload carrier, and the API client. Request frames preserve Blob bodies through structured clone and transfer `ReadableStream<Uint8Array>` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload.
 

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

@@ -26,7 +26,7 @@ kind: "package-library"
 一条 tsdown 管线产出三个构建产物;另有一层由源码维护的进程实现:
 
 - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务 seam 交给隧道。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 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式,只要文件名仍指向该文件,`FileHandle.stat({ bigint: true })` 报告的 device 与 inode 身份就与路径 stat 相同,`FileHandle.chmod()` 则更新打开文件身份的权限;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒。
+- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式,只要文件名仍指向该文件,`FileHandle.stat({ bigint: true })` 报告的 device 与 inode 身份就与路径 stat 相同,`FileHandle.chmod()` 则更新打开文件身份的权限;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒。回调式 `fs.realpath.native` 别名在已挂载的 VFS 内解析路径,并支持 `util.promisify`。
 - **`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` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形式的传输、独立文件上传载体和 API 客户端。请求帧通过结构化克隆保留 Blob 请求体,并转移 `ReadableStream<Uint8Array>` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。
 

+ 22 - 3
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts

@@ -225,6 +225,25 @@ export function realpathSync(path: PathArg): string {
   return vfs().realpathSync(asPath(path))
 }
 
+/**
+ * Resolve a UTF-8 path through Node's callback form and its native alias.
+ * @param path - Path in the symlink-free VFS.
+ * @param callback - Asynchronous completion with the canonical path or filesystem error.
+ */
+export function realpath(path: PathArg, callback: (error: NodeJS.ErrnoException | null, path?: string) => void): void {
+  queueMicrotask(() => {
+    let result: string
+    try {
+      result = realpathSync(path)
+    } catch (error) {
+      callback(error as NodeJS.ErrnoException)
+      return
+    }
+    callback(null, result)
+  })
+}
+realpath.native = realpath
+
 /**
  * List a directory.
  * @param path - directory path.
@@ -828,13 +847,13 @@ export const __esModule = true
  * (`readFileSync` answering `Buffer` XOR `string`, `statSync` answering `Stats`
  * XOR `BigIntStats`, `mkdirSync` answering `string` XOR `void`). This module
  * answers the union its VFS actually produces from one signature, which no single
- * signature can present as all of Node's overloads; `realpathSync` additionally
+ * signature can present as all of Node's overloads; `realpath` additionally
  * carries Node's `.native` member, and `constants`, `promises`, and `Dirent` hold
  * the subsets the host tree reads.
  */
 type OwnSignature =
   | 'constants' | 'promises' | 'Dirent' | 'FSWatcher' | 'StatWatcher' | 'ReadStream' | 'WriteStream'
-  | 'readFileSync' | 'writeFileSync' | 'appendFileSync' | 'statSync' | 'lstatSync' | 'realpathSync'
+  | 'readFileSync' | 'writeFileSync' | 'appendFileSync' | 'statSync' | 'lstatSync' | 'realpathSync' | 'realpath'
   | 'readdirSync' | 'mkdirSync' | 'mkdtempSync' | 'rmSync' | 'opendirSync'
   | 'openSync' | 'readSync' | 'writeSync' | 'stat' | 'lstat' | 'watch' | 'watchFile' | 'unwatchFile'
   | 'createReadStream' | 'createWriteStream'
@@ -850,7 +869,7 @@ type NodeFace = Partial<Omit<typeof import('node:fs'), OwnSignature>>
 /** CommonJS default export: the members `require()` hands a caller of this module. */
 export default {
   constants, promises, Dirent, FSWatcher, StatWatcher, ReadStream, WriteStream,
-  readFileSync, writeFileSync, appendFileSync, existsSync, statSync, stat, lstatSync, lstat, realpathSync, chmodSync,
+  readFileSync, writeFileSync, appendFileSync, existsSync, statSync, stat, lstatSync, lstat, realpathSync, realpath, chmodSync,
   readdirSync, mkdirSync, mkdtempSync, rmSync, unlinkSync, renameSync, accessSync, opendirSync,
   openHandleSync, linkSync,
   openSync, readSync, writeSync, closeSync, watch, watchFile, unwatchFile,

+ 17 - 0
packages/experimental/webworker-runtime/tests/node/fs.spec.ts

@@ -12,6 +12,7 @@ import { MemoryVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/s
 import { setActiveVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active.ts'
 import * as fs from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/fs.ts'
 import * as fsp from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/fs/promises.ts'
+import { promisify } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/util.ts'
 import type { VfsBigIntStats, VfsMutationSink, VfsStats } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/types.ts'
 
 let flushes = 0
@@ -63,6 +64,22 @@ check('statSync size', fs.statSync('/dsh/config/cordis.yml').size, 12)
 check('statSync dir', fs.statSync('/dsh/config').isDirectory(), true)
 check('realpathSync', fs.realpathSync('/dsh/config/../config/cordis.yml'), '/dsh/config/cordis.yml')
 
+test('native realpath supports the filesystem provider promise wrapper', async () => {
+  expect(fs.default.realpath).toBe(fs.realpath)
+  const resolveNative = promisify(fs.realpath.native)
+  expect(await resolveNative('/dsh/config/../config/cordis.yml')).toBe('/dsh/config/cordis.yml')
+  await expect(resolveNative('/dsh/missing-realpath')).rejects.toMatchObject({ code: 'ENOENT' })
+})
+
+test('callback realpath settles after the current call returns', async () => {
+  let returned = false
+  const completion = new Promise<unknown>((resolve) => {
+    fs.realpath('/dsh/config/cordis.yml', (error, path) => { resolve({ error, path, returned }) })
+  })
+  returned = true
+  expect(await completion).toEqual({ error: null, path: '/dsh/config/cordis.yml', returned: true })
+})
+
 fs.appendFileSync('/dsh/config/cordis.yml', '- id: llm\n')
 check('appendFileSync', fs.readFileSync('/dsh/config/cordis.yml', 'utf8'), '- id: timer\n- id: llm\n')