From ecf6acfb15d91b5a253d026cd7c4fac760891bc7 Mon Sep 17 00:00:00 2001 From: Yudong Date: Sat, 19 Sep 2026 03:06:04 +0800 Subject: [PATCH 1/5] feat(api): support binary fields in Remote results --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 4 +- ...026-08-02-typert-remote-method-calls.zh.md | 4 +- ...26-09-05-workspace-files-service.i18n.yaml | 4 +- .../2026-09-05-workspace-files-service.md | 28 +- .../2026-09-05-workspace-files-service.zh.md | 28 +- ...9-08-document-preview-operations.i18n.yaml | 4 +- .../2026-09-08-document-preview-operations.md | 6 +- ...26-09-08-document-preview-operations.zh.md | 6 +- ...09-workspace-file-read-authority.i18n.yaml | 4 +- ...026-09-09-workspace-file-read-authority.md | 4 +- ...-09-09-workspace-file-read-authority.zh.md | 4 +- .../2026-09-11-node-office-kit.i18n.yaml | 4 +- .../2026-09-11-node-office-kit.md | 2 +- .../2026-09-11-node-office-kit.zh.md | 2 +- ...7-workspace-file-binary-transfer.i18n.yaml | 6 + ...26-09-17-workspace-file-binary-transfer.md | 31 ++ ...09-17-workspace-file-binary-transfer.zh.md | 31 ++ ...debar-text-preview-and-file-tree.i18n.yaml | 4 +- ...9-05-sidebar-text-preview-and-file-tree.md | 2 +- ...5-sidebar-text-preview-and-file-tree.zh.md | 2 +- apps/web/tests/document-preview.e2e.ts | 12 + docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 6 +- docs/api-gateway.zh.md | 6 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/office-to-pdf.i18n.yaml | 4 +- docs/subsystems/office-to-pdf.md | 2 +- docs/subsystems/office-to-pdf.zh.md | 2 +- docs/subsystems/sidebar-right.i18n.yaml | 4 +- docs/subsystems/sidebar-right.md | 6 +- docs/subsystems/sidebar-right.zh.md | 6 +- docs/subsystems/typert.i18n.yaml | 4 +- docs/subsystems/typert.md | 15 +- docs/subsystems/typert.zh.md | 15 +- docs/subsystems/web-client.i18n.yaml | 4 +- docs/subsystems/web-client.md | 2 +- docs/subsystems/web-client.zh.md | 2 +- docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 30 +- docs/subsystems/workspace.zh.md | 30 +- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 4 +- packages/api/gateway/README.zh.md | 4 +- packages/api/gateway/src/client/index.ts | 6 +- packages/api/gateway/src/index.ts | 82 ++++- .../api/gateway/tests/gateway.client.spec.ts | 93 +++++- .../api/gateway/tests/gateway.host.spec.ts | 141 +++++++- packages/api/workspace-files/README.i18n.yaml | 4 +- packages/api/workspace-files/README.md | 22 +- packages/api/workspace-files/README.zh.md | 22 +- packages/api/workspace-files/package.json | 1 + packages/api/workspace-files/src/index.ts | 76 ++--- packages/api/workspace-files/src/types.ts | 14 +- .../workspace-files/tests/binary-rpc.spec.ts | 93 ++++++ .../workspace-files/tests/read-all.spec.ts | 91 ++++-- .../workspace-files/tests/read-bytes.spec.ts | 75 +++-- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 4 +- packages/client/connection/README.zh.md | 4 +- packages/client/connection/src/client/rpc.ts | 60 +++- packages/client/connection/src/http-bridge.ts | 10 +- packages/client/connection/src/rpc-host.ts | 60 +++- packages/client/connection/src/rpc.ts | 20 +- .../connection/tests/binary-rpc.host.spec.ts | 309 ++++++++++++++++++ .../connection/tests/http-bridge.host.spec.ts | 65 ++++ packages/client/connection/tsconfig.host.json | 2 + .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 8 +- .../ui-sidebar-documentpreview/README.zh.md | 8 +- .../src/client/face.ts | 2 +- .../src/client/html/index.ts | 2 +- .../src/client/html/read-relative.ts | 13 +- .../src/client/index.ts | 11 +- .../src/client/office/index.ts | 5 +- .../src/client/rpc.ts | 12 +- .../tests/apply.client.spec.ts | 38 +-- .../tests/document-seat.client.spec.tsx | 12 +- .../tests/face.client.spec.ts | 1 + .../tests/fixtures.client.ts | 7 +- .../tests/html-apply.client.spec.ts | 9 +- .../tests/html-body.client.spec.tsx | 8 +- .../tests/html-read-relative.client.spec.ts | 12 +- .../tests/office-registration.client.spec.ts | 10 +- .../tests/rpc.client.spec.ts | 16 +- packages/document/office-to-pdf/src/index.ts | 2 +- packages/document/office-to-pdf/src/types.ts | 4 +- .../office-to-pdf/tests/remote.spec.ts | 4 +- packages/host/webserver/README.i18n.yaml | 4 +- packages/host/webserver/README.md | 2 +- packages/host/webserver/README.zh.md | 2 +- packages/host/webserver/src/index.ts | 1 + packages/typert/generator/README.i18n.yaml | 4 +- packages/typert/generator/README.md | 2 + packages/typert/generator/README.zh.md | 2 + packages/typert/generator/src/analyzer.ts | 39 ++- packages/typert/generator/src/emitter.ts | 176 +++++++++- .../generator/tests/remote-model.spec.ts | 238 +++++++++++++- packages/typert/loader/src/index.ts | 5 + packages/typert/loader/tests/loader.spec.ts | 8 + packages/typert/protocol/README.i18n.yaml | 4 +- packages/typert/protocol/README.md | 2 + packages/typert/protocol/README.zh.md | 2 + packages/typert/protocol/src/types.ts | 13 + packages/typert/registry/tests/typert.spec.ts | 12 + pnpm-lock.yaml | 3 + scripts/gen-cordis-catalog.ts | 1 + 109 files changed, 1882 insertions(+), 446 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md create mode 100644 .agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md create mode 100644 packages/api/workspace-files/tests/binary-rpc.spec.ts create mode 100644 packages/client/connection/tests/binary-rpc.host.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 190fecf123..534a9fddcd 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.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-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: b599b3f9a353738bbb44a4da9b5a72be8309e2fb -2026-08-02-typert-remote-method-calls.zh.md: 64dc84f0ae07b10d50452ef7e36c30db102953cf +2026-08-02-typert-remote-method-calls.md: 9986e0dac5e85d0aadc745ca4084ed3d1cecae54 +2026-08-02-typert-remote-method-calls.zh.md: 282af768413498658722af3adee17fb59a8e93c2 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index b599b3f9a3..9986e0dac5 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -181,7 +181,7 @@ Consequently, `SessionId`, the Agent wire ID, the request, and the result all re Remote methods themselves use declaration-map navigation. Typert anchors `InvocationModel.location` to the decorated Host method-name token and emits a source-map segment on the corresponding property of the namespace interface. For an adapter-backed endpoint, after the TypeScript editor resolves `ctx.remote.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. -Typert generates a wire Zod codec for the same symbol key. The Host Gateway uses parameter and identity codecs to validate input. Client Remote trusts its generated TypeScript arguments and successful Host results instead of executing invocation codecs. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. +Typert generates a wire Zod codec for the same symbol key. The Host Gateway uses parameter and identity codecs to validate input. Client Remote trusts its generated TypeScript arguments and JSON success results. Binary success results use their generated codec after multipart decoding, as specified by [binary Remote transfer](2026-09-17-workspace-file-binary-transfer.md). If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. Named business types referenced by Remote methods must be exported from public, type-only subpaths. If the only reachable entry also imports Host Services, Cordis `Context` merges, or Host-only implementations, the build fails and requires the business package to provide a safe type entry. Primitives, literals, and simple compositions explicitly supported by Typert need no additional names. @@ -514,7 +514,7 @@ Canonical public types require business DTOs to have type-only entries, which ma Type imports and runtime contributions have different effects. `import type {}` extends only the static Remote surface. If a real calling environment omits the value contribution, the Client Remote Service must fail with an explicit "Remote not mounted" error. -Generated Host and Client artifacts carry matching Zod factories, but Client Remote does not materialize invocation schemas. Canonical symbol keys, the same generated model, and Host wire validation keep the two sides aligned without comparing schema object identities across realms. +Generated Host and Client artifacts carry matching Zod factories; Client Remote materializes only binary result schemas. Canonical symbol keys, the same generated model, and Host wire validation keep the two sides aligned without comparing schema object identities across realms. A consumer may import a Remote contract that is not currently mounted on the Host. The types mean "this protocol capability was selected by the consumer," not that a corresponding Service currently exists in the target process; an unavailable endpoint must fail explicitly at runtime. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 64dc84f0ae..282af76841 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -181,7 +181,7 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/ Remote 方法本身使用 declaration map 导航。Typert 把 `InvocationModel.location` 固定在 Host 被装饰方法的方法名 token,并在 namespace interface 的对应属性上写入 source-map segment。对于由适配器支撑的 endpoint,TypeScript editor 从 `ctx.remote.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 -Typert 为同一 symbol key 生成 wire Zod codec。Host Gateway 用参数与 identity codec 校验输入;Client Remote 信任生成的 TypeScript 参数与成功的 Host 结果,不执行调用 codec。复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。 +Typert 为同一 symbol key 生成 wire Zod codec。Host Gateway 用参数与身份 codec 校验输入。Client Remote 信任生成的 TypeScript 参数与 JSON 成功结果。二进制成功结果在 multipart 解码后执行生成的结果 codec,具体由[二进制 Remote 传输](2026-09-17-workspace-file-binary-transfer.zh.md)规定。复杂类型若无法生成严格 codec,LIB 构建失败,不降级为 `unknown` 或未校验 JSON。 Remote 方法引用的命名业务类型必须从纯类型公共 subpath 导出。如果唯一可达入口会带入 Host Service、Cordis `Context` merge 或 Host-only 实现,构建失败并要求业务包提供安全的类型出口。原始值、字面量和 Typert 明确支持的简单组合不需要额外命名。 @@ -514,7 +514,7 @@ SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化 类型 import 与运行时 contribution 是两种不同效果。`import type {}` 只扩展静态 Remote surface;真实调用环境遗漏 value contribution 时,Client Remote Service 必须以明确的「Remote 未挂载」错误失败。 -生成的 Host 与 Client 产物携带匹配的 Zod factory,但 Client Remote 不实例化调用 schema。规范 symbol key、同一生成模型和 Host wire 校验让两侧保持一致,而无需跨 realm 比较 schema 对象 identity。 +生成的 Host 与 Client 产物携带匹配的 Zod factory;Client Remote 仅实例化二进制结果 schema。规范 symbol key、同一生成模型和 Host wire 校验让两侧保持一致,而无需跨 realm 比较 schema 对象 identity。 消费端可以导入 Host 当前未挂载的 Remote contract。类型表示「该协议能力已被消费端选择」,不保证目标进程当前存在对应 Service;运行时 endpoint 不可用必须明确失败。 diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml index 050ce7d48f..f44cffc1fa 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.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-09-05-workspace-files-service.md -2026-09-05-workspace-files-service.md: 40157508a83abc61245845a1dbe4f1749e175563 -2026-09-05-workspace-files-service.zh.md: 51de0ad5b644f27e30557ea6b11ba62b8ef9a715 +2026-09-05-workspace-files-service.md: 65bebf18175d9a94452c92cd36ada3c5ef267ce5 +2026-09-05-workspace-files-service.zh.md: b4ca420042dc702f9bc1eaf5bb09f2667a19322d diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md index 40157508a8..65bebf1817 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md @@ -25,15 +25,13 @@ Two constraints frame the service. File reads through `ctx.fs` use the Session's `api/remotes` and both root aggregates reference the matching Host/Client leaf. The package exports `.`, `./client`, `./types`, `./typert`, and `./remote`, with one `workspace-files` web-app row supplying both faces. The Client plugin injects `['resources', 'remote', 'remote.workspaceFiles']`; the resource model takes result types directly from the protocol package, and the text preview owns the Sidebar parameter declaration, so the Client compilation graph has no reverse dependency on Remote assembly or Sidebar UI. -### The `workspaceFiles` Remote namespace +### The `workspaceFiles` file operations -Every Host method takes `WorkspaceFileScope` first. The Gateway resolves it from the wire Session identity by reading the live Session header or, for a cold Session, `SessionPersistence.stat`; it never activates an Agent, reads the event body, or falls back to a parent Session. The scope carries the selected Session id and its `cwd`, with the sandbox policy's deployment root used only when that header has no `cwd`. A Client passes its Session id and never names a root. The seven signatures, as `src/index.ts` declares them: +Every Host method takes `WorkspaceFileScope` first. The `workspaceFileScope` Gateway lookup resolves Session identity. It reads the live Session header or, for a cold Session, `SessionPersistence.stat`; it never activates an Agent, reads the event body, or falls back to a parent Session. The scope carries the selected Session id and its `cwd`, with the sandbox policy's deployment root used only when that header has no `cwd`. A Client passes its Session id and never names a root. The five signatures, as `src/index.ts` declares them: ```ts ignore-check @Remote async read(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise -@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise -@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise -@Remote async readRelated(workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal): Promise +@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal): Promise @Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise @Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise @Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable @@ -41,20 +39,20 @@ Every Host method takes `WorkspaceFileScope` first. The Gateway resolves it from - **`stat`** returns `WorkspaceFileStat { absolutePath, version, bytes? }`: the file's identity, its opaque freshness token, and its size when the backend reports one. It accepts a regular file only. - **`read`** returns one window of lines, `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`; `lines` counts the page's lines, so a page holding one empty line (`text: ''`, `lines: 1`) and a page past the end (`lines: 0`) read differently. `range.offset` is the 1-based first line and defaults to 1; `range.limit` is the largest number of lines and defaults to `maxLines`, which it may not exceed. Lines end at `\n` and a final `\n` terminates the last line rather than opening an empty one; `text` joins the page's lines with `\n` and carries no terminator; `eof` is true when the page includes the last line, and an offset past the end returns an empty page with `eof` true. The pager walks `streamText`, counts the lines before the window without keeping them, admits each in-window segment against `maxBytes` before buffering it, and returns at the first character past the window, so a file of any size costs one page of memory. The `version` and `bytes` on a page are the stat's, taken before the stream. -- **`readBytes`** returns one window of raw bytes, `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`. `range.offset` is the 0-based first byte and defaults to 0; `range.length` is the largest byte count and defaults to `maxBytes`, which it may not exceed. `data` is base64, shorter than `length` where the file ends and empty at or past it; `eof` is true when the window includes the last byte. Nothing is decoded and nothing is refused as binary. `read` pages by lines and never by bytes; a byte window is `readBytes`. -- **`readAll` and `readRelated`** return complete `WorkspaceFileBytes` under `maxFileBytes`. `readRelated` resolves a relative filesystem path from the base file's directory; the Host applies the same regular-file checks and backend read authority to both files. [Document Preview](2026-09-08-document-preview-operations.md) owns their loading and address semantics. +- **`readBytes`** returns native bytes with file metadata, offset, and EOF. The required options object selects a complete read under `maxFileBytes` when `range` is absent, or a bounded window under `maxBytes` when present. `range: {}` uses offset zero and the configured window cap; windows can read files above the complete-file cap. The [binary transfer decision](2026-09-17-workspace-file-binary-transfer.md) owns the Remote response encoding. +- **`baseFile`** selects target resolution independently of `range`: the target `path` must be relative and resolves from the base file's directory. The Host applies regular-file checks to both files and preserves backend read authority. [Document Preview](2026-09-08-document-preview-operations.md) owns content loading and address semantics. - **`list`** returns `WorkspaceDirectoryListing { path, entries, truncated }`: the listed directory as a workspace path relative to the root (empty for the root), its direct children in the backend's stable name order as `{ name, type, size? }`, and whether `maxEntries` cut the list. `type` is `file`, `directory`, or `other`; a symlink child reports the type of what it points to and a dangling one is `other`, while opening such a child still fails the link gate below. Dotfiles are listed; nothing is filtered. - **`changes`** yields `WorkspaceFileWatchFrame`: `{ kind: 'ready' }` after the observation queue is registered and the workspace root resolves, followed by `{ kind: 'change', change }`. The `WorkspaceFileChange` payload is `{ absolutePath, version }` for a present file or `{ absolutePath, absent: true }` for one observed gone. Its source is `fs/observed` inside the workspace root, never an OS watcher. Observations after the first pull are queued, including during root resolution; cancellation or plugin disposal ends the generation. ### Paths on the wire -Two path vocabularies leave the service, and each method uses exactly one. `read`, `readBytes`, `readAll`, `readRelated`, `stat`, and `changes` name a file by `absolutePath`: its absolute path in the filesystem's execution world, symlinks resolved (`ctx.fs.processPath(target)`), so the Client provider matches a change frame to an open address by absolute path: the Client sends the address's path unchanged to the Host and binds the follower only to a successful `stat.absolutePath`, without reading a Session summary's cwd. `list` speaks workspace paths — the same syntax its `path` argument accepts, absolute or relative to the root — because its consumer is a tree rooted there. The field is called `absolutePath` and not `url` because it is not a resource address; the address grammar belongs to `dsh-util-workspace-path` and is described with the resource model. Input paths to `read`, `readBytes`, `readAll`, `readRelated`, `stat`, and `list` are absolute or relative to the session's workspace root, never to the backend's own cwd. +Two path vocabularies leave the service, and each method uses exactly one. `read`, `readBytes`, `stat`, and `changes` name a file by `absolutePath`: its absolute path in the filesystem's execution world, symlinks resolved (`ctx.fs.processPath(target)`), so the Client provider matches a change frame to an open address by absolute path: the Client sends the address's path unchanged to the Host and binds the follower only to a successful `stat.absolutePath`, without reading a Session summary's cwd. `list` speaks workspace paths — the same syntax its `path` argument accepts, absolute or relative to the root — because its consumer is a tree rooted there. The field is called `absolutePath` and not `url` because it is not a resource address; the address grammar belongs to `dsh-util-workspace-path` and is described with the resource model. Input paths to `read`, `readBytes`, `stat`, and `list` are absolute or relative to the session's workspace root, never to the backend's own cwd; `readBytes` with `baseFile` resolves the target from that file's directory. `version` is an opaque string a consumer compares for equality and never parses: the local backend derives it from device, inode, size, and nanosecond mtime and ctime, so a rewrite that leaves the content identical still changes it. `offset` means a line on `read` and a byte on `readBytes`; the two units never mix, and `eof` on either means the window reached the file's end. ### File checks and workspace containment -`read`, `readBytes`, `readAll`, `readRelated`, and `stat` share regular-file checks and then rely on the filesystem backend's read authority. `list` shares path inspection but also checks workspace containment, while `changes` filters observations to the workspace root. The service applies the following checks: +`read`, `readBytes`, and `stat` share regular-file checks and then rely on the filesystem backend's read authority. `list` shares path inspection but also checks workspace containment, while `changes` filters observations to the workspace root. The service applies the following checks: 1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods and `not-directory` for `list`. An empty path is a `gateway/bad-request`. 2. **Workspace containment for `list`.** The directory resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is the `WorkspaceFileScope.workspaceRoot` resolved from the selected Session header. A `..` traversal or an absolute directory outside the root is `outside-workspace`. `changes` applies the same backend containment predicate to observed targets. @@ -73,7 +71,7 @@ Each failure is one `RemoteError` code with typed details, declared beside the t | `workspace-file/outside-workspace` | a `list` target is not inside the workspace root | `{ path }` | | `workspace-file/too-large` | a page's text or byte window exceeds `maxBytes`, or a complete read exceeds `maxFileBytes` | `{ path, limit }` | | `workspace-file/not-text` | invalid UTF-8 up to the page's end, or a NUL byte in the sample or the page (`read` only) | `{ path }` | -| `workspace-file/not-regular-file` | `read`, `readBytes`, `readAll`, `readRelated`, or `stat` on something that is not a regular file | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | +| `workspace-file/not-regular-file` | `read`, `readBytes`, or `stat` on something that is not a regular file | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | | `workspace-file/not-directory` | `list` on something that is not a directory | `{ path, kind: 'file' \| 'symlink' \| 'other' }` | | `workspace-file/unsupported-address` | Client-minted: a resource address this provider cannot serve | `{ address }` | | `workspace-file/unknown-workspace` | Client-minted: an `absolute` address, which carries no Session | `{ address }` | @@ -109,7 +107,7 @@ The Client export registers one `ResourceProvider<'file'>` into `ctx.resources` ### Related notes -The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, `useResource`, the `dsh-resource:///…` address grammar, and the reasoning for one resource per address; the [text preview and file tree](../feature/2026-09-05-sidebar-text-preview-and-file-tree.md) are the shipped consumers of `read`, `list`, and the `file` provider; the [right Sidebar docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) is the surface they open into; [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) is where serving files over HTTP was rejected. Anyone extending this system reaches the same seven methods through `remote.workspaceFiles` and the same `file` resource through `useResource<'file'>`; the wire types are published as `@deepseek-ai/dsh-api-workspace-files/types`. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) owns Host read access and the HTML security trade-off; [Document Preview](2026-09-08-document-preview-operations.md) owns content loading and per-tab freshness. +The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, `useResource`, the `dsh-resource:///…` address grammar, and the reasoning for one resource per address; the [text preview and file tree](../feature/2026-09-05-sidebar-text-preview-and-file-tree.md) are the shipped consumers of `read`, `list`, and the `file` provider; the [right Sidebar docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) is the surface they open into; [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) is where serving files over HTTP was rejected. Anyone extending this system reaches the same five methods through `remote.workspaceFiles` and the same `file` resource through `useResource<'file'>`; the wire types are published as `@deepseek-ai/dsh-api-workspace-files/types`. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) owns Host read access and the HTML security trade-off; [Document Preview](2026-09-08-document-preview-operations.md) owns content loading and per-tab freshness. ## Alternatives considered @@ -117,7 +115,7 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, **A dual-face package with reverse UI dependencies.** The split-package choice followed two project-reference cycles after `api/remotes` referenced the Client leaf: the resource model imported Remote assembly for result types, and the file provider imported Sidebar UI for its parameter map. TypeScript rejected these cycles with `TS6202`. [dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) supersedes that split: result types come directly from the protocol package, and Sidebar parameter registration belongs to the text preview; both root aggregates retain explicit compiler entries. -**Serving workspace files over HTTP.** Already rejected by [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) on origin grounds and not revisited: `read` and `readBytes` carry plain text and base64 over the authenticated Remote carrier, so no document is served, no URL is minted, and no origin question arises. +**Serving navigable workspace documents.** Rejected on origin grounds; the [binary transfer](2026-09-17-workspace-file-binary-transfer.md) returns multipart data while HTML remains isolated in a Blob iframe. **Log-reachable or workspace-contained authorization for file reads.** The one precedent that sends file content over the wire, command attachments, authorizes only files that appear in the session log. That excludes typed paths, while workspace containment excludes readable files elsewhere on the Session backend. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) instead makes the backend's read decision authoritative and keeps containment only for workspace-shaped operations. @@ -138,14 +136,14 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, - Change frames report the agent's own operations only. A file edited by the user's editor, a shell, or a subprocess raises no frame; an agent merely reading a file that something else changed does raise one, because the read observes a new version. - File-kind inspection precedes backend reads, and `list` reports kind before an outside position. A page's `version` may be one write behind its content, and a stalled `changes` consumer grows Host memory because a generation's queue is unbounded; each is a known trade-off recorded in the package README. - The `file` resource pushes change, not content, so a preview learns a file moved on without a payload and reads the pages it wants; a failed open keeps following the address, so the agent creating the file brings the tab live without user action. -- `readBytes` has no shipped consumer yet; Document Preview uses `readAll` for complete-file formats. +- Office authorization probes use `readBytes` with a one-byte `range`; Document Preview uses the same method without `range` for complete-file formats. ## Testing -Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, symlink children, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs` and `fs-local` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite. +Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through `Uint8Array`, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, symlink children, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs` and `fs-local` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite. ## Deferred - A bound on a `changes` generation's queue. -- The shipped consumer of `readBytes` (image and binary previews) and any write, search, or media route; the service is read-only. +- Write and search operations; the service is read-only. - Scopes other than `session` in the file address; the grammar leaves room, the provider serves one. diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md index 51de0ad5b6..b4ca420042 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md @@ -25,15 +25,13 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 `api/remotes` 和两个根聚合分别引用匹配的 Host/Client 叶子。包导出 `.`、`./client`、`./types`、`./typert` 和 `./remote`,web-app 中单个 `workspace-files` 条目供应两面。Client 插件注入 `['resources', 'remote', 'remote.workspaceFiles']`;资源模型直接从协议包取结果类型,Sidebar 参数声明归文本预览,因此 Client 编译图不再反向依赖 Remote 装配或右栏 UI。 -### `workspaceFiles` Remote 命名空间 +### `workspaceFiles` 文件操作 -每个 Host 方法首参都是 `WorkspaceFileScope`。Gateway 从线路上的 Session 身份解析它:优先读取 live Session header,cold Session 则只调用 `SessionPersistence.stat`;不会激活 Agent、读取事件正文或回退到父 Session。scope 携带所选 Session id 及其 `cwd`,仅当该 header 没有 `cwd` 时才使用沙箱策略的部署根。Client 传入 Session id,从不自行命名根。七个签名照 `src/index.ts` 的声明: +每个 Host 方法首参都是 `WorkspaceFileScope`。`workspaceFileScope` Gateway lookup 解析 Session 身份。它优先读取 live Session header,cold Session 则只调用 `SessionPersistence.stat`;不会激活 Agent、读取事件正文或回退到父 Session。scope 携带所选 Session id 及其 `cwd`,仅当该 header 没有 `cwd` 时才使用沙箱策略的部署根。Client 传入 Session id,从不自行命名根。五个签名照 `src/index.ts` 的声明: ```ts ignore-check @Remote async read(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise -@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise -@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise -@Remote async readRelated(workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal): Promise +@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal): Promise @Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise @Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise @Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable @@ -41,20 +39,20 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 - **`stat`** 返回 `WorkspaceFileStat { absolutePath, version, bytes? }`:文件身份、不透明的新鲜度令牌,以及后端报得出时的大小。它只接受普通文件。 - **`read`** 返回一个行窗口 `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`;`lines` 计页内行数,使只含一个空行的页(`text: ''`、`lines: 1`)与越过文件末尾的页(`lines: 0`)可区分。`range.offset` 是 1 起算的首行,缺省 1;`range.limit` 是最多行数,缺省 `maxLines` 且不得超过。行以 `\n` 结束,末尾的 `\n` 终止最后一行而不是开启一空行;`text` 以 `\n` 连接本页各行且不带终止符;页含最后一行时 `eof` 为 true,越过末尾的 offset 返回 `eof` 为 true 的空页。切页器沿 `streamText` 前进,数过窗口前的行而不保留,把每个窗内片段先按 `maxBytes` 核准再缓冲,并在越过窗口的第一个字符处返回,因此任意大小的文件只花一页内存。页上的 `version` 与 `bytes` 来自流之前的那次 stat。 -- **`readBytes`** 返回一个原始字节窗口 `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`。`range.offset` 是 0 起算的首字节,缺省 0;`range.length` 是最多字节数,缺省 `maxBytes` 且不得超过。`data` 为 base64,文件在窗内结束则短于 `length`,位于或越过末尾则为空;窗口含最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝。`read` 按行分页、绝不按字节;字节窗口走 `readBytes`。 -- **`readAll` 与 `readRelated`** 在 `maxFileBytes` 上限内返回完整的 `WorkspaceFileBytes`。`readRelated` 从基准文件所在目录解析相对文件系统路径;Host 对两个文件执行相同的普通文件检查和后端读取权限。[Document Preview](2026-09-08-document-preview-operations.zh.md) 负责其加载与地址语义。 +- **`readBytes`** 返回原生字节、文件元数据、偏移量和 EOF。必填选项对象未传 `range` 时,在 `maxFileBytes` 上限内完整读取;传入时,在 `maxBytes` 上限内读取有界窗口。`range: {}` 使用零偏移和配置的窗口上限;窗口可读取超过完整文件上限的文件。[二进制传输决议](2026-09-17-workspace-file-binary-transfer.zh.md)负责 Remote 响应编码。 +- **`baseFile`** 独立于 `range` 选择目标解析方式:目标 `path` 必须为相对路径,从基准文件所在目录解析。Host 对两个文件执行普通文件检查,并保留后端读取权限。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责内容加载与地址语义。 - **`list`** 返回 `WorkspaceDirectoryListing { path, entries, truncated }`:被列目录相对根的工作区路径(根为空串)、其直接子项按后端的稳定名序以 `{ name, type, size? }` 给出,以及 `maxEntries` 是否截断了列表。`type` 为 `file`、`directory` 或 `other`;符号链接子项报告其指向目标的类型,悬空者为 `other`,而打开这样的子项仍会在下文的链接关被拒。dotfile 照常列出,不做任何过滤。 - **`changes`** 产出 `WorkspaceFileWatchFrame`:在观察队列注册且工作区根解析完成后先发 `{ kind: 'ready' }`,随后为 `{ kind: 'change', change }`。载荷 `WorkspaceFileChange` 对存在的文件为 `{ absolutePath, version }`,对消失的文件为 `{ absolutePath, absent: true }`。来源是工作区根内的 `fs/observed`,不监视操作系统。首次拉取后的观察都会排队,包括根解析期间的观察;取消或插件释放会结束该代流。 ### 线路上的路径 -离开服务的路径词汇有两套,每个方法只用其中一套。`read`、`readBytes`、`readAll`、`readRelated`、`stat` 与 `changes` 以 `absolutePath` 命名文件:它在文件系统执行环境中、符号链接已解析的绝对路径(`ctx.fs.processPath(target)`),因此 Client 提供者按绝对路径把变更帧匹配到已打开的地址:Client 把地址路径原样交给 Host,并只按成功的 `stat.absolutePath` 绑定跟随者,不读取会话摘要的 cwd。`list` 说工作区路径——与其 `path` 参数相同的语法,绝对或相对根——因为其消费方是一棵以根为起点的树。该字段叫 `absolutePath` 而不叫 `url`,因为它不是资源地址;地址语法归 `dsh-util-workspace-path` 所有,与资源模型一并描述。`read`、`readBytes`、`readAll`、`readRelated`、`stat` 与 `list` 的输入路径是绝对路径或相对会话工作区根的路径,从不相对后端自己的 cwd。 +离开服务的路径词汇有两套,每个方法只用其中一套。`read`、`readBytes`、`stat` 与 `changes` 以 `absolutePath` 命名文件:它在文件系统执行环境中、符号链接已解析的绝对路径(`ctx.fs.processPath(target)`),因此 Client 提供者按绝对路径把变更帧匹配到已打开的地址:Client 把地址路径原样交给 Host,并只按成功的 `stat.absolutePath` 绑定跟随者,不读取会话摘要的 cwd。`list` 说工作区路径——与其 `path` 参数相同的语法,绝对或相对根——因为其消费方是一棵以根为起点的树。该字段叫 `absolutePath` 而不叫 `url`,因为它不是资源地址;地址语法归 `dsh-util-workspace-path` 所有,与资源模型一并描述。`read`、`readBytes`、`stat` 与 `list` 的输入路径是绝对路径或相对会话工作区根的路径,从不相对后端自己的 cwd;带 `baseFile` 的 `readBytes` 从基准文件所在目录解析目标。 `version` 是消费者只比较是否相等、从不解析的不透明字符串:本地后端由设备、inode、大小及纳秒级 mtime 与 ctime 导出,因此内容不变的重写也会改变它。`offset` 在 `read` 上指行、在 `readBytes` 上指字节;两套单位从不混用,二者的 `eof` 都表示窗口到达了文件末尾。 ### 文件检查与工作区包含 -`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 共享普通文件检查,之后依赖文件系统后端的读取权限。`list` 共享路径检查,但还会检查工作区包含关系;`changes` 则把观察过滤到工作区根内。服务执行以下检查: +`read`、`readBytes` 与 `stat` 共享普通文件检查,之后依赖文件系统后端的读取权限。`list` 共享路径检查,但还会检查工作区包含关系;`changes` 则把观察过滤到工作区根内。服务执行以下检查: 1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`),对 `list` 为 `not-directory`。空路径是 `gateway/bad-request`。 2. **`list` 的工作区包含。** 目录解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是从所选 Session header 解析出的 `WorkspaceFileScope.workspaceRoot`。`..` 爬出或根外绝对目录为 `outside-workspace`。`changes` 对观察到的目标使用相同的后端包含判定。 @@ -73,7 +71,7 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 | `workspace-file/outside-workspace` | `list` 的目标不在工作区根内 | `{ path }` | | `workspace-file/too-large` | 一页文本或字节窗口超过 `maxBytes`,或全文读取超过 `maxFileBytes` | `{ path, limit }` | | `workspace-file/not-text` | 到页末为止的非法 UTF-8,或样本或页内的 NUL 字节(仅 `read`) | `{ path }` | -| `workspace-file/not-regular-file` | 对非普通文件执行 `read`、`readBytes`、`readAll`、`readRelated` 或 `stat` | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | +| `workspace-file/not-regular-file` | 对非普通文件执行 `read`、`readBytes` 或 `stat` | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | | `workspace-file/not-directory` | 对非目录执行 `list` | `{ path, kind: 'file' \| 'symlink' \| 'other' }` | | `workspace-file/unsupported-address` | Client 铸出:本提供者无法服务的资源地址 | `{ address }` | | `workspace-file/unknown-workspace` | Client 铸出:不携带 Session 的 `absolute` 地址 | `{ address }` | @@ -109,7 +107,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存 ### 相关记录 -[资源模型](2026-09-05-client-resource-model.zh.md)拥有 `ctx.resources`、`useResource`、`dsh-resource:///…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md)是 `read`、`list` 与 `file` 提供者随包交付的消费方;[右侧 Sidebar 停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)是它们打开进去的界面;[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)是经 HTTP 供文件被否决之处。任何在这套体系上扩展的人都经 `remote.workspaceFiles` 触达同样的七个方法、经 `useResource<'file'>` 触达同样的 `file` 资源;线路类型以 `@deepseek-ai/dsh-api-workspace-files/types` 发布。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)负责 Host 读取权限与 HTML 安全取舍;[Document Preview](2026-09-08-document-preview-operations.zh.md)负责内容加载和逐 tab 新鲜度。 +[资源模型](2026-09-05-client-resource-model.zh.md)拥有 `ctx.resources`、`useResource`、`dsh-resource:///…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md)是 `read`、`list` 与 `file` 提供者随包交付的消费方;[右侧 Sidebar 停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)是它们打开进去的界面;[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)是经 HTTP 供文件被否决之处。任何在这套体系上扩展的人都经 `remote.workspaceFiles` 触达同样的五个方法、经 `useResource<'file'>` 触达同样的 `file` 资源;线路类型以 `@deepseek-ai/dsh-api-workspace-files/types` 发布。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)负责 Host 读取权限与 HTML 安全取舍;[Document Preview](2026-09-08-document-preview-operations.zh.md)负责内容加载和逐 tab 新鲜度。 ## Alternatives considered @@ -117,7 +115,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存 **带有反向 UI 依赖的双面包。** 拆包选择源于 `api/remotes` 引用 Client 叶子后形成的两条工程引用环:资源模型为了结果类型引用 Remote 装配,文件提供者为了 Sidebar 参数表引用右栏 UI。TypeScript 以 `TS6202` 拒绝这些环。[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)取代拆包选择:结果类型直接取自协议包,Sidebar 参数注册移至文本预览;保留两个根聚合中的显式编译入口。 -**经 HTTP 供工作区文件。** 已被[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)以 origin 理由否决且未重议:`read` 与 `readBytes` 经认证的 Remote 载体传送纯文本与 base64,因此不供文档、不铸 URL,也不产生 origin 问题。 +**提供可导航的工作区文档。** 因源隔离问题不采用;[二进制传输](2026-09-17-workspace-file-binary-transfer.zh.md)返回 multipart 数据,HTML 仍隔离在 Blob iframe 中。 **文件读取采用“日志可达”或工作区包含授权。** 唯一把文件内容送过线路的先例——命令附件——只授权出现在会话日志里的文件,这会排除手输路径;工作区包含则会排除 Session 后端其他位置的可读文件。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)改为以后端读取决策为准,只对工作区形态的操作保留包含限制。 @@ -138,14 +136,14 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存 - 变更帧只报告 agent 自己的操作。用户编辑器、shell 或子进程改动的文件不产生帧;agent 仅仅读取一个被别处改动的文件却会产生帧,因为读取观察到了新版本。 - 文件类型检查先于后端读取,`list` 也先报种类再报根外位置。页的 `version` 可能落后内容一次写入,停滞的 `changes` 消费者会让 Host 内存增长,因为一代流的队列无界;每一条都是包 README 记录在册的已知取舍。 - `file` 资源推送变更而非内容,因此预览不靠载荷就得知文件已更新并读取它想要的页;失败的打开继续跟随地址,因此 agent 创建该文件时 tab 无需用户动作即恢复正常。 -- `readBytes` 尚无随包交付的消费方;Document Preview 对整文件格式使用 `readAll`。 +- Office 授权探测使用带单字节 `range` 的 `readBytes`;Document Preview 对整文件格式使用同一方法并省略 `range`。 ## Testing -`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、base64 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、符号链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs` 与 `fs-local` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。 +`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、`Uint8Array` 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、符号链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs` 与 `fs-local` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。 ## Deferred - 给 `changes` 一代流的队列加上限。 -- `readBytes` 随包交付的消费方(图片与二进制预览)以及任何写入、搜索或媒体路由;本服务只读。 +- 写入和搜索操作;本服务只读。 - 文件地址中 `session` 之外的作用域;语法留有余地,提供者只服务一个。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml index 414fda0806..ecb554c14d 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.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-09-08-document-preview-operations.md -2026-09-08-document-preview-operations.md: be3a835a6296cb28d9a90c5c5e8b9bf7367598c2 -2026-09-08-document-preview-operations.zh.md: efb87e16cc126001c1a6eb2bf938124d7e00044a +2026-09-08-document-preview-operations.md: 0b90c17ba94d0c7cd30afb0a6e6a033f8f12d0df +2026-09-08-document-preview-operations.zh.md: ec9c208c97dfe75afdb0915c655d9c1c7b4d42ff diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md index be3a835a62..0b90c17ba9 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md @@ -16,9 +16,9 @@ Document Preview separates resource observation from content reads. The [resourc Readable files use `dsh-resource://file/session//`. The path may be workspace-relative or absolute; an encoded absolute path retains its leading slash. `fileAddressFor` always emits this Session-address form. The provider and Preview RPC take the Session only from that address, never from the current selection, first holder, or owning tab. A Session-less `absolute` URI cannot be read; the provider reports `workspace-file/unknown-workspace`. Session authorization is a file-protocol rule, not an additional Resource identity. -[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists supported alternatives and remembers a manual choice per tab. Plain text remains available for unknown extensions and text-compatible renderers. Registered binary suffixes determine text compatibility independently of loading mode; HTML and SVG remain outside those suffixes and retain source viewing. A single candidate renders no viewer control. Registered binary suffixes omit plain text; known binary suffixes without a renderer show an unsupported state without reading the file ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session. +[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists supported alternatives and remembers a manual choice per tab. Plain text remains available for unknown extensions and text-compatible renderers. Registered binary suffixes determine text compatibility independently of loading mode; HTML and SVG remain outside those suffixes and retain source viewing. A single candidate renders no viewer control. Registered binary suffixes omit plain text; known binary suffixes without a renderer show an unsupported state without reading the file ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls `ctx.remote.workspaceFiles.read` for text and `ctx.remote.workspaceFiles.readBytes` for native bytes, with `baseFile` for related resources. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session. -Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL. +Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array` data; Host transport uses the [binary workspace file transfer](2026-09-17-workspace-file-binary-transfer.md). Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL. PDF.js's official TextLayerBuilder owns selection boundaries and copy normalization over the width-fitted canvas, with shared page cleanup and a component-owned resize observer. Responsive sizing uses the CSS `scale` property independently of PDF.js's page rotation and translation transforms. Its end-of-content marker and stacking rules constrain selection in blank regions; line-break highlighting is suppressed. Per-page cancellation uses the builder's cleanup rather than aborting the first page's signal, because the official selection listeners are shared across pages. @@ -28,7 +28,7 @@ PDF.js's official TextLayerBuilder owns selection boundaries and copy normalizat **Methods attached to an Iterator or its values.** This conflates observation with commands and repeats capability identity in data frames. Frames carry data and failures; explicit Preview RPC callbacks perform reads. -**A core public-projection factory, or the same assembly inside `open`.** Separate stream values, operations bundles, and public interfaces add assembly without another current consumer that needs it. Preview's shared RPC adapter already keeps Session decoding and base64 out of renderers. Resource offers no provider-agnostic command interface or opening-bound command lifetime; adding either needs consumer evidence beyond file preview. +**A core public-projection factory, or the same assembly inside `open`.** Separate stream values, operations bundles, and public interfaces add assembly without another current consumer that needs it. Preview's shared RPC adapter already keeps Session addressing and byte transport out of renderers. Resource offers no provider-agnostic command interface or opening-bound command lifetime; adding either needs consumer evidence beyond file preview. **UI Session as extra Resource identity, or authorization from the first holder or current selection.** A retained tab can belong to a different Session from the selected one, and the UI location does not identify the addressed file. Encoding the required Session in the file address preserves Host authorization while letting all readers of one address share observation. diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md index efb87e16cc..ec9c208c97 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md @@ -16,9 +16,9 @@ Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-0 可读取的文件使用 `dsh-resource://file/session//`。路径可以相对工作区,也可以是绝对路径;编码后的绝对路径保留前导斜杠。`fileAddressFor` 始终生成这种 Session 地址。提供方与 Preview RPC 只从该地址取 Session,不取当前选择、首个持有者或 tab 所属 Session。不带 Session 的 `absolute` URI 无法读取;提供方报告 `workspace-file/unknown-workspace`。Session 授权是文件协议规则,不是额外的 Resource 身份。 -[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出受支持的候选,按 tab 记住手动选择。未知扩展名及文本兼容的渲染器仍可使用纯文本。文本兼容性由注册的二进制后缀决定,与加载方式无关;HTML 和 SVG 不属于二进制后缀,保留源码查看。只有一个候选时不显示查看器控件。注册声明为二进制的后缀不提供纯文本;已知二进制后缀没有注册渲染器时不发起读取,并显示不支持预览的空态([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。 +[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出受支持的候选,按 tab 记住手动选择。未知扩展名及文本兼容的渲染器仍可使用纯文本。文本兼容性由注册的二进制后缀决定,与加载方式无关;HTML 和 SVG 不属于二进制后缀,保留源码查看。只有一个候选时不显示查看器控件。注册声明为二进制的后缀不提供纯文本;已知二进制后缀没有注册渲染器时不发起读取,并显示不支持预览的空态([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 通过 `ctx.remote.workspaceFiles.read` 读取文本,通过 `ctx.remote.workspaceFiles.readBytes` 读取原生字节,关联资源使用 `baseFile`。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。 -Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。 +Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array` 数据;Host 传输使用[工作区文件二进制传输](2026-09-17-workspace-file-binary-transfer.zh.md)。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。 PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界和复制规范化,共享页面清理,并使用由组件拥有的 resize observer。响应式尺寸适配使用独立的 CSS `scale` 属性,与 PDF.js 的页面旋转和平移变换组合。其内容结束标记和堆叠规则限制空白区域中的选择;换行高亮被抑制。逐页取消使用 builder 的清理操作,而不 abort 第一页的信号,因为官方选择监听器跨页面共享。 @@ -28,7 +28,7 @@ PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界 **把方法挂到 Iterator 或其值上。** 这会混淆观察与命令,并在数据帧中重复能力身份。帧携带数据和失败;显式 Preview RPC 回调负责读取。 -**核心公开投影工厂,或在 `open` 内做同样的组装。** 分开的流值、operations 组合与公开接口增加了组装步骤,没有另一个当前消费方需要它。Preview 的共享 RPC 适配已让渲染器无需解码 Session 和 base64。Resource 不提供与提供方无关的命令接口,也不提供绑定于打开实例的命令生命周期;增加任一种都需要文件预览之外的消费方证据。 +**核心公开投影工厂,或在 `open` 内做同样的组装。** 分开的流值、operations 组合与公开接口增加了组装步骤,没有另一个当前消费方需要它。Preview 的共享 RPC 适配已让渲染器无需处理 Session 寻址和字节传输。Resource 不提供与提供方无关的命令接口,也不提供绑定于打开实例的命令生命周期;增加任一种都需要文件预览之外的消费方证据。 **把 UI Session 作为额外 Resource 身份,或由首个持有者、当前选择决定授权。** 保留的 tab 可以属于不同于当前选择的 Session,UI 所在位置也不能标识地址指向的文件。将所需 Session 编入文件地址,既保留 Host 授权,也让同地址的所有读者共享观察。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml index 97cdb07d3f..eed28e3666 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.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-09-09-workspace-file-read-authority.md -2026-09-09-workspace-file-read-authority.md: d1a5b9d9634381cb74b17651c67f2b701402ff1c -2026-09-09-workspace-file-read-authority.zh.md: f8b0fae4325a90548c6b9dc33ac46b17248b04e7 +2026-09-09-workspace-file-read-authority.md: 140816e8c48a6628133cf9208d5b48545e2e5eaf +2026-09-09-workspace-file-read-authority.zh.md: 75ffee32c40930c459018ab0fde42d5324371ba8 diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md index d1a5b9d963..140816e8c4 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md +++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md @@ -10,11 +10,11 @@ Workspace Files serves both file content and workspace navigation. Applying work ## Decision -`read`, `readBytes`, `readAll`, `readRelated`, and `stat` inherit the addressed Session filesystem backend's read authority. The workspace root is the base for relative input paths, not a read boundary; absolute paths and relative paths that leave the workspace are readable when the backend allows them. The service still requires regular files, refuses symlinks, and applies its text and byte caps. +`read`, `readBytes`, and `stat` inherit the addressed Session filesystem backend's read authority. The workspace root is the base for relative input paths, not a read boundary; absolute paths and relative paths that leave the workspace are readable when the backend allows them. The service still requires regular files, refuses symlinks, and applies its text and byte caps. `list` and `changes` remain workspace-scoped because they expose workspace navigation and observation rather than a named file read. `list` rejects a directory outside the root, and `changes` filters observations through the backend's workspace-containment predicate. -`readRelated` resolves a relative path from the base file's directory. A `..` path may therefore read JavaScript or CSS outside the workspace when the Session backend permits it. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, Document Preview packages bounded, statically declared local scripts and stylesheets into an HTML Blob iframe with `sandbox="allow-scripts"`; the opaque origin blocks parent access, but the browser retains normal network access. This exposure is an intentional security trade-off for rendering static generated HTML. +`readBytes` with `baseFile` resolves a relative path from the base file's directory. A `..` path may therefore read JavaScript or CSS outside the workspace when the Session backend permits it. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, Document Preview packages bounded, statically declared local scripts and stylesheets into an HTML Blob iframe with `sandbox="allow-scripts"`; the opaque origin blocks parent access, but the browser retains normal network access. This exposure is an intentional security trade-off for rendering static generated HTML. The [Workspace Files service](2026-09-05-workspace-files-service.md) owns paging, file checks, listing, and observation. [Document Preview](2026-09-08-document-preview-operations.md) owns which related files are packaged and the iframe sandbox. diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md index f8b0fae432..75ffee32c4 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md @@ -10,11 +10,11 @@ Workspace Files 同时提供文件内容与工作区导航。对所有操作应 ## Decision -`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 继承被寻址 Session 的文件系统后端读取权限。工作区根是输入相对路径的基准,而不是读取边界;只要后端允许,就可以读取绝对路径和离开工作区的相对路径。服务仍要求普通文件、拒绝符号链接,并应用文本和字节上限。 +`read`、`readBytes` 与 `stat` 继承被寻址 Session 的文件系统后端读取权限。工作区根是输入相对路径的基准,而不是读取边界;只要后端允许,就可以读取绝对路径和离开工作区的相对路径。服务仍要求普通文件、拒绝符号链接,并应用文本和字节上限。 `list` 与 `changes` 仍限于工作区,因为它们暴露工作区导航和观察,而不是读取一个具名文件。`list` 拒绝根外目录,`changes` 通过后端的工作区包含判定过滤观察。 -`readRelated` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,Document Preview 把有界、静态声明的本地脚本与样式表打包进带 `sandbox="allow-scripts"` 的 HTML Blob iframe;不透明源阻止访问父应用,但浏览器保留正常网络访问。这种暴露是为渲染静态生成 HTML 而有意接受的安全取舍。 +带 `baseFile` 的 `readBytes` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,Document Preview 把有界、静态声明的本地脚本与样式表打包进带 `sandbox="allow-scripts"` 的 HTML Blob iframe;不透明源阻止访问父应用,但浏览器保留正常网络访问。这种暴露是为渲染静态生成 HTML 而有意接受的安全取舍。 [Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)负责分页、文件检查、列举和观察。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责选择要打包的关联文件及 iframe sandbox。 diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml index 9947e91ff2..b40fa370ec 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.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-09-11-node-office-kit.md -2026-09-11-node-office-kit.md: b8e94bf6b392edf7931346400fda9ca5d3416188 -2026-09-11-node-office-kit.zh.md: 41b84f26c8150db90f09e4ae2b01abb3dddfdc6d +2026-09-11-node-office-kit.md: 132ac8b3dd709f9f16254a5569dec59f01d29fda +2026-09-11-node-office-kit.zh.md: c7bdab2322766c42cfee4092f158d7b6b530e875 diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md index b8e94bf6b3..132ac8b3dd 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md @@ -24,7 +24,7 @@ DSH exports raster images at configurable resolution, defaulting to 192 DPI for The [Office viewer](../../../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) lives under Document Preview’s `client/office/` directory, alongside the loading lifecycle, PDF body, and reader types it uses. Keeping these components in one package removes an independent UI boot entry without creating cross-plugin runtime imports. Its bounded cache validates authorized source metadata, shares pending conversions between readers, cancels only when the final reader leaves, excludes failures, and clears on connection reset. Conversion starts when a user opens a preview. Missing declared font families accompany the PDF and are available from the document toolbar’s warning icon; font-table inventories and unrelated engine defaults are not warnings. The shared preview entry’s `office` cache settings use the existing page-global injection channel because the module boot graph carries package identities, not Loader configuration. Reloading the page adopts updated YAML values. -Ordinary file reads and Office responses share `documentFileBytes()`, which decodes into one typed byte buffer rather than materializing a JavaScript element array from the binary string. Element-array expansion can exhaust the browser heap for a PDF that the configured Host limits permit. A child-process regression checks byte equality within a fixed heap, while the built browser scenario verifies the transport and PDF Worker together. Cache byte limits do not bound transient transport or viewer memory. +Office responses use `documentFileBytes()`, which decodes into one typed byte buffer rather than materializing a JavaScript element array from the binary string. Element-array expansion can exhaust the browser heap for a PDF that the configured Host limits permit. A child-process regression checks byte equality within a fixed heap, while the built browser scenario verifies the transport and PDF Worker together. Cache byte limits do not bound transient transport or viewer memory. The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) defines npm distribution and bundled offline conversion. diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md index 41b84f26c8..c7bdab2322 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md @@ -24,7 +24,7 @@ DSH 按可配置分辨率导出栅格图片,默认 192 DPI,对应共享 PDF [Office 查看器](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview)位于文档预览的 `client/office/` 目录,与其使用的加载生命周期、PDF 正文和读取器类型同属一个包。这些组件放在同一包中,既减少一个独立 UI 启动入口,也无需跨插件运行时导入。其有界缓存校验已授权的源元数据,在读取方之间共享待完成转换,仅在最后一个读取方离开时取消,不缓存失败,并在连接重置时清空。用户打开预览时才开始转换。缺失的已声明字体族随 PDF 返回,可通过文档工具栏的警告图标查看;字体表清单与无关的引擎默认字体不构成警告。共享预览入口的 `office` 缓存设置复用页面全局注入通道,因为模块启动图携带包标识而不传递 Loader 配置。重新加载页面后采用更新的 YAML 值。 -普通文件读取与 Office 响应共享 `documentFileBytes()`,解码使用一个类型化字节缓冲区,不将二进制字符串物化为 JavaScript 元素数组。即使 PDF 符合 Host 配置的大小限制,元素数组展开也可能耗尽浏览器堆内存。子进程回归测试在固定堆容量内检查字节一致性,构建后的浏览器场景则一起验证传输和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。 +Office 响应使用 `documentFileBytes()`,解码使用一个类型化字节缓冲区,不将二进制字符串物化为 JavaScript 元素数组。即使 PDF 符合 Host 配置的大小限制,元素数组展开也可能耗尽浏览器堆内存。子进程回归测试在固定堆容量内检查字节一致性,构建后的浏览器场景则一起验证传输和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。 [kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)定义 npm 分发和随应用打包的离线转换。 diff --git a/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.i18n.yaml new file mode 100644 index 0000000000..87ea138f6d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-09-17-workspace-file-binary-transfer.md +2026-09-17-workspace-file-binary-transfer.md: 1cfffb4113619fba17479065bac6bb420f214d7d +2026-09-17-workspace-file-binary-transfer.zh.md: 1cacc2c294b979ed7e840c84293eb3f6421c6bcb diff --git a/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md new file mode 100644 index 0000000000..1cfffb4113 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md @@ -0,0 +1,31 @@ +# Agent Note: Binary workspace file transfer + +Status: implemented + +English | [中文](2026-09-17-workspace-file-binary-transfer.zh.md) + +## Problem + +Document previews need native bytes. Encoding file contents as base64 adds about one third to the payload before compression and requires browser decoding. A separate file Fetch route also duplicates the Gateway's method dispatch, Session lookup, and error handling. + +## Decision + +The [Remote method architecture](2026-08-02-typert-remote-method-calls.md) owns method dispatch and lifetime; this note defines binary success encoding and Client validation. The [workspace file service](2026-09-05-workspace-files-service.md) exposes one binary `readBytes` Remote. Its required options object independently selects a byte `range` and a `baseFile` for relative target resolution. Without `range`, it reads a complete file. This avoids separate method names for orthogonal choices while preserving bounded `fs.readBytes` and `fs.readByteRange` reads. + +Typert recursively recognizes `Uint8Array` in unary result types, including root values, optional fields, and containers. Generated result codecs provide `encode()` for subtrees whose types can contain bytes and `decode()` to validate the reconstructed value without iterating, copying, or freezing byte payloads; Client declarations narrow each byte buffer to `ArrayBuffer`. Gateway supplies the encoder to Connection, using identity for strict JSON results so they avoid recursive detection. Source-mode calls retain runtime byte detection. Connection owns standard `FormData` parts and preserves the RPC envelope and correlation id in JSON metadata; it does not depend on Typert reflection. An attachment table associates byte parts with paths through the JSON result; field names remain business-owned. The Client restores `Uint8Array` views over `Blob.arrayBuffer()` and Gateway delegates validation to the generated decoder. Ordinary results and errors retain JSON responses. Gateway owns method dispatch, Session lookup, cancellation, and mount lifetime; feature code needs no multipart reader or dedicated route. + +## Alternatives considered + +- Separate `readAll`, range, and related-file methods mix independent path and extent choices. One options object permits their combination without adding another method. +- A workspace-only Fetch route keeps binary transport out of Remote but requires feature-specific dispatch, Session lookup, and error decoding. +- A whole-result binary marker or a reserved `data` field couples transport to one business result. Type-directed codecs and attachment paths support independently named and nested fields without adding result-wide categories. +- A custom metadata prefix needs owned framing and length validation. FormData delegates those mechanics to the platform, at the cost of Blob copies. +- Metadata HTTP headers constrain long Unicode paths. Multipart fields keep paths in the body; HTML stays in an isolated Blob iframe rather than a navigable Host response. + +## Consequences + +Byte ranges retain only the requested bytes under `maxBytes`, independently of `maxFileBytes`. Complete reads retain a bounded whole file; this is not a streaming preview or zero-copy optimization. On HTTP disconnect, the bridge aborts the request signal and drains materialized response chunks without writing to the socket: cancelling Node multipart bodies can race their producer and cause an unhandled `ERR_INVALID_STATE` rejection. Office authorization probes use the same Remote with a one-byte range; converted PDFs remain base64 in the separate Office Remote and use its bounded decoder. Plugin callers migrate to `readBytes` options and stop decoding its data as base64. Binary arguments, events, and stream items remain unsupported; recursive types require acyclic runtime values. No Session event or persistence format changes. + +## Validation + +Workspace tests cover complete and ranged reads, combined base-file resolution, limits, Session lookup, cancellation, and binary RPC round trips. Connection and Gateway tests cover nested and multiple attachments, framing, correlation, malformed multipart, and cancelled or withdrawn calls; Typert tests cover recursive result codecs, Client declarations, and rejected binary inputs or streams. The built browser scenario checks raw PNG bytes plus HTML, PDF, image, and text previews. Local benchmarks compare full and ranged reads using identical synthetic files, and distinguish transport/decoding from the complete read path. diff --git a/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md new file mode 100644 index 0000000000..1cacc2c294 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 工作区文件二进制传输 + +Status: implemented + +[English](2026-09-17-workspace-file-binary-transfer.md) | 中文 + +## 问题 + +文档预览需要原生字节。将文件内容编码为 base64 会在压缩前使载荷增加约三分之一,并要求浏览器解码。独立的文件 Fetch 路由还会重复 Gateway 的方法分发、Session 查找和错误处理。 + +## 决策 + +[Remote 方法架构](2026-08-02-typert-remote-method-calls.zh.md)负责方法分发与生命周期;本记录规定二进制成功响应的编码及 Client 校验。[工作区文件服务](2026-09-05-workspace-files-service.zh.md)提供单一的二进制 `readBytes` Remote。其必填选项对象独立选择字节 `range` 和用于相对目标解析的 `baseFile`。未传 `range` 时读取完整文件。这避免为正交的选择设置不同方法名,同时保留有界的 `fs.readBytes` 和 `fs.readByteRange` 读取。 + +Typert 递归识别一元结果类型中的 `Uint8Array`,包括根值、可选字段和容器。生成的结果 codec 提供 `encode()` 处理类型可能包含字节的子树,并通过 `decode()` 校验还原后的值,不逐字节遍历、复制或冻结字节载荷;Client 声明将每个字节缓冲区限定为 `ArrayBuffer`。Gateway 将 encoder 交给 Connection,对 strict JSON 结果原值传递,从而避免递归识别。源码模式调用保留运行时字节识别。Connection 负责标准 `FormData` 部分,并在 JSON 元数据中保留 RPC 信封和关联 id,不依赖 Typert 反射。附件表将字节部分与 JSON 结果中的路径关联,字段名仍由业务拥有。Client 恢复基于 `Blob.arrayBuffer()` 的 `Uint8Array` 视图,Gateway 将校验交给生成的解码器。普通结果与错误继续使用 JSON 响应。Gateway 负责方法分发、Session 查找、取消及挂载生命周期;功能代码无需 multipart 读取器或专用路由。 + +## 考虑过的替代方案 + +- 分立的 `readAll`、范围和关联文件方法混合了路径与读取范围两个独立选择。单一选项对象支持组合,无需再增加方法。 +- 工作区专用 Fetch 路由让二进制传输独立于 Remote,但需要功能专属的方法分发、Session 查找与错误解码。 +- 整体结果的二进制标记或保留的 `data` 字段会将传输与某一种业务结果耦合。按类型生成的 codec 和附件路径支持独立命名及嵌套字段,无需新增整体结果分类。 +- 自定义元数据前缀需要自有的帧格式与长度校验。FormData 将这些机制交给平台,代价是 Blob 复制。 +- 元数据 HTTP 头会限制较长的 Unicode 路径。Multipart 字段将路径保留在正文中;HTML 仍置于隔离的 Blob iframe,不作为可导航的 Host 响应。 + +## 影响 + +字节范围仅保留 `maxBytes` 内的请求字节,不受 `maxFileBytes` 约束。完整读取保留有上限的整个文件;这不是流式预览或零复制优化。HTTP 连接断开后,桥接器中止请求信号,并排空已准备的响应块,不再写入 socket:取消 Node multipart 响应体可能与其生产方竞争,导致未处理的 `ERR_INVALID_STATE` 拒绝。Office 授权探测使用同一 Remote 的单字节范围;转换后的 PDF 仍通过独立的 Office Remote 传输 base64,并使用其有界解码器。插件调用方迁移到 `readBytes` 选项,并停止将其 data 按 base64 解码。仍不支持二进制参数、事件和流条目;递归类型要求运行时值无环。不改变 Session 事件或持久化格式。 + +## 验证 + +Workspace 测试覆盖完整与分段读取、基准文件解析组合、上限、Session 查找、取消以及二进制 RPC 往返。Connection 和 Gateway 测试覆盖嵌套及多附件、帧格式、关联关系、畸形 multipart,以及取消或撤销的调用;Typert 测试覆盖递归结果 codec、Client 声明,以及对二进制输入和流的拒绝。构建后的浏览器场景验证原始 PNG 字节以及 HTML、PDF、图片和文本预览。本机 benchmark 使用相同合成文件比较完整与分段读取,并区分传输/解码和完整读取路径。 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml index 702e3bf1fc..0d328978e1 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.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/feature/2026-09-05-sidebar-text-preview-and-file-tree.md -2026-09-05-sidebar-text-preview-and-file-tree.md: 8ddb1a336307011d8f6ce9bbe927f00642a83e60 -2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 294a2b456008837882f7f82e55e8e9e435254554 +2026-09-05-sidebar-text-preview-and-file-tree.md: e978043545b7f172f2b67053fe44b5936ea9b529 +2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 088d4a58c973843ac5085cfaa1ca9980387bcc8f diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md index 8ddb1a3363..e978043545 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md @@ -34,7 +34,7 @@ The [Document Preview decision](../architecture/2026-09-08-document-preview-oper A tab uses `dsh-resource://file/session//`, where the path may be relative or absolute ([Workspace Files](../architecture/2026-09-05-workspace-files-service.md) owns the grammar and the `fileAddressFor` / `parseFileAddress` helpers). `hostFileOf` accepts only this Session scope and takes both Session and path from the address; a Session-less `absolute` address is not claimed. A malformed claimed address throws as a programming error. -Metadata and content come from different places. `useResource<'file'>(tab.contentId)`, the global standard hook from the [client resource model](../architecture/2026-09-05-client-resource-model.md), yields `WorkspaceFileStat`; the body compares that observed version with the version of its loaded content. The Preview face reads text through `remote.workspaceFiles.read` and complete bytes through `readAll`. A later text page from a newer version restarts from page one, and a retired request cannot write after reload or tab disposal. The [Document Preview decision](../architecture/2026-09-08-document-preview-operations.md) owns renderer-specific loading. +Metadata and content come from different places. `useResource<'file'>(tab.contentId)`, the global standard hook from the [client resource model](../architecture/2026-09-05-client-resource-model.md), yields `WorkspaceFileStat`; the body compares that observed version with the version of its loaded content. The Preview face reads text through `remote.workspaceFiles.read` and complete bytes through `readBytes` with `{}` options. A later text page from a newer version restarts from page one, and a retired request cannot write after reload or tab disposal. The [Document Preview decision](../architecture/2026-09-08-document-preview-operations.md) owns renderer-specific loading. The store is Slot-standard: one exclusive instance per session, bucketed by tab id, holding `{ version, pages, eof, loading, failure, scrollTop, wrap, revision }`. Bucketing by tab, not by file, is deliberate — two tabs of one file scroll independently. The face (`loadPage`, `reloadPages`) is the only asynchronous half: it marks a read in flight, awaits the Remote result, and writes a page or a failure through the store's actions, writing nothing if the owner's `signal` has fired. The `signal` also ends the bucket: the face arms one abort listener per tab at the tab's first read, and that listener forgets the bucket — not the body, which mounts and unmounts as tabs switch; a tab that never read has no bucket and no listener, and a record can end while its body is unmounted behind another tab. Scroll offset, wrap, and the navigation already answered therefore outlive the body: a tab comes back where the reader left it rather than re-reading or jumping again. Nothing persists across a page reload. diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md index 294a2b4560..088d4a58c9 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md @@ -34,7 +34,7 @@ Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、 tab 使用 `dsh-resource://file/session//`,其中路径可以是相对路径或绝对路径([Workspace Files](../architecture/2026-09-05-workspace-files-service.zh.md)负责该语法与 `fileAddressFor` / `parseFileAddress` 辅助函数)。`hostFileOf` 只接受这种 Session scope,并从地址取得 Session 与路径;不认领不带 Session 的 `absolute` 地址。被认领的地址若格式错误,则作为程序错误抛出。 -元数据与内容来自不同的地方。`useResource<'file'>(tab.contentId)`——[client 资源模型](../architecture/2026-09-05-client-resource-model.zh.md)提供的全局标准 hook——产生 `WorkspaceFileStat`;正文把其观察版本与已加载内容版本比较。Preview face 通过 `remote.workspaceFiles.read` 读取文本,通过 `readAll` 读取完整字节。后续文本页若来自更新版本,则从第一页重新开始;被重载或 tab 销毁淘汰的请求不能再写入。[Document Preview 决议](../architecture/2026-09-08-document-preview-operations.zh.md)负责各渲染器的加载方式。 +元数据与内容来自不同的地方。`useResource<'file'>(tab.contentId)`——[client 资源模型](../architecture/2026-09-05-client-resource-model.zh.md)提供的全局标准 hook——产生 `WorkspaceFileStat`;正文把其观察版本与已加载内容版本比较。Preview face 通过 `remote.workspaceFiles.read` 读取文本,通过带 `{}` 选项的 `readBytes` 读取完整字节。后续文本页若来自更新版本,则从第一页重新开始;被重载或 tab 销毁淘汰的请求不能再写入。[Document Preview 决议](../architecture/2026-09-08-document-preview-operations.zh.md)负责各渲染器的加载方式。 store 是 Slot 标准件:每会话一个独占实例,按 tab id 分桶,持有 `{ version, pages, eof, loading, failure, scrollTop, wrap, revision }`。按 tab 而非按文件分桶是有意的——同一文件的两个 tab 各自滚动。face(`loadPage`、`reloadPages`)是唯一的异步半边:它标记读取进行中,等待 Remote 结果,再经 store 的 action 写入一页或一次失败;若 owner 的 `signal` 已触发则什么也不写。`signal` 同时终结这个桶:face 在 tab 首次读取时挂一个 abort 监听器,由它忘掉桶——不是体,体随 tab 切换反复挂载卸载;从未读过的 tab 没有桶也没有监听器,而 tab 记录可能在其体被另一 tab 挡住而卸载时结束。因此滚动位置、换行与已答过的导航都活得比体久:tab 回来时停在读者离开的地方,而不是重读或再跳一次。刷新页面后什么都不保留。 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 603569a567..e2dc9c1713 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -571,7 +571,19 @@ else process.exit(1); await successShot(page, 'pdf-drag-selection') sections.push('## PDF drag selection\n\n- Table selection: forward and backward drags exclude later sections\n- Line-break highlight: transparent') + const pngResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/workspaceFiles/readBytes' + && (response.request().postDataJSON() as { payload: { args: { path: string } } }).payload.args.path === 'tiny.png') await openFile('tiny.png') + const transferred = await pngResponse + expect(transferred.headers()['content-type']).toMatch(/^multipart\/form-data;/) + const transferredBody = await new Response(new Uint8Array(await transferred.body()), { headers: transferred.headers() }).formData() + const metadata = transferredBody.get('metadata') + if (typeof metadata !== 'string') throw new Error('missing PNG metadata') + const { attachments } = JSON.parse(metadata) as { attachments: { path: string[]; codec: string; part: string }[] } + expect(attachments).toEqual([{ path: ['data'], codec: 'bytes', part: expect.any(String) as unknown }]) + const transferredFile = transferredBody.get(attachments[0]!.part) + if (transferredFile === null || typeof transferredFile === 'string') throw new Error('missing PNG payload') + expect(Buffer.from(await transferredFile.arrayBuffer())).toEqual(TINY_PNG) const tinyImage = preview.getByRole('img', { name: 'Image preview: tiny.png', exact: true }) await tinyImage.waitFor({ state: 'visible', timeout: 15_000 }) expect(await viewer.count()).toBe(0) diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 018bb1abc0..e11ee881f4 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.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 docs/api-gateway.md -api-gateway.md: 52e91c48104ac8a8a46ad27f2b329ca72d6abc06 -api-gateway.zh.md: 9f062eaa6e6618354afa9ed1101e9cf6ed67d465 +api-gateway.md: 957f40775857d8f57af436a44690fd5a453aeeae +api-gateway.zh.md: 1062adcc89909d5ce3701ac82b55c5d0ece21ab0 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 52e91c4810..232ee859c7 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -122,9 +122,9 @@ Strict analysis requires a Remote to be a public, non-static instance method wit Remote calls use the Connection's `/api` route. The Client Remote calls `connection.rpc.call('/api', '/', { args }, signal)`; the HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. -The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler. The Typert Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; feature-owned exact Fetch routes handle non-JSON responses, and other requests return 404. The Connection owns transport, RPC ids, response envelopes, and request cancellation, while the Gateway owns only the Remote data protocol and business dispatch. Replacing the Connection carrier does not require changes to Remote descriptors or the Client programming interface. +The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler. The Typert Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; binary Remote successes use multipart responses, and feature-owned exact Fetch routes handle responses outside the RPC envelope, and other requests return 404. The Connection owns transport, RPC ids, response envelopes, and request cancellation, while the Gateway owns only the Remote data protocol and business dispatch. Replacing the Connection carrier does not require changes to Remote descriptors or the Client programming interface. -For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. +For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, and invokes the service method targeted by the binding. The Client Gateway validates decoded binary results with the generated result codec; ordinary JSON results retain their existing handling. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `gateway/lookup-unavailable`, and unloading the configuration restores the provider's default policy. The Session Controller owns the standard resolver semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. A resume failure and an ownership fence raise a `RemoteError` carrying their own code, `session/not-found` or `session/agent-busy`, which the Gateway encodes onto the wire unchanged; only an unclassified throw folds into `gateway/internal`. @@ -160,6 +160,6 @@ The running Client watcher consumes these generated files when it rebundles. If Remote handles unary method calls with one request and one result, and stream methods whose items flow Host → Client while the Client's items reach the running Host method through `this.ctx.invocation.uplink()`. Session event streams, pagination, incremental reduce, projection, and entity substreams still require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors. -The API layers are organized as `remotes → gateway → connection → webserver`. The BFF and Typert RPC layers live under `packages/api`; Connection and WebServer live at `packages/client/connection` and `packages/host/webserver`. A feature that needs a streamed or browser-native response registers an exact Connection Fetch route instead of defining a Remote method. +The API layers are organized as `remotes → gateway → connection → webserver`. The BFF and Typert RPC layers live under `packages/api`; Connection and WebServer live at `packages/client/connection` and `packages/host/webserver`. Unary results containing `Uint8Array`, including nested fields and array entries, use [binary Remote transfer](../.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md). A feature that needs a response outside the RPC envelope registers an exact Connection Fetch route. Lookup policy is configured per key, so all `agent` or `session` parameters share the cold-resume behavior. Accepting live objects only would require an explicit per-parameter or per-endpoint policy, which does not exist; the business method must not guess whether the object came from restoration. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 9f062eaa6e..aa980adca0 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -122,9 +122,9 @@ Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则 Remote 调用使用 Connection 的 `/api` 路由。Client Remote 调用 `connection.rpc.call('/api', '/', { args }, signal)`;HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 -Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内分发。Typert Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;功能自有的精确 Fetch 路由处理非 JSON 响应,其他请求返回 404。Connection 拥有传输、RPC id、响应 envelope 和请求取消,Gateway 只拥有 Remote 数据协议和业务分发。替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程接口。 +Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内分发。Typert Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;二进制 Remote 成功响应使用 multipart,功能自有的精确 Fetch 路由处理 RPC 信封之外的响应,其他请求返回 404。Connection 拥有传输、RPC id、响应 envelope 和请求取消,Gateway 只拥有 Remote 数据协议和业务分发。替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程接口。 -Gateway 每次调用都从当前注册表解析描述符和实时服务,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context 提供方解析对象或接收者,最后调用 binding 指向的服务方法并校验返回值。缺少提供方、identity 未命中、binding 不一致、参数缺失或多余、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 +Gateway 每次调用都从当前注册表解析描述符和实时服务,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context 提供方解析对象或接收者,最后调用 binding 指向的服务方法。Client Gateway 使用生成的结果 codec 校验解码后的二进制结果;普通 JSON 结果保持原有处理方式。缺少提供方、identity 未命中、binding 不一致、参数缺失或多余、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 lookup 提供方的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于提供方挂载;没有提供方时调用仍以 `gateway/lookup-unavailable` 失败,配置卸载后则恢复提供方默认策略。Session Controller 负责 `agent` 与 `session` 的标准 resolver 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败与 ownership fence 抛出携带自有码的 `RemoteError`(`session/not-found` 或 `session/agent-busy`),Gateway 原样编码上 wire;只有未归类的 throw 才折成 `gateway/internal`。 @@ -160,6 +160,6 @@ pnpm run build:lib Remote 处理单请求单结果的一元方法调用,以及项沿 Host → Client 方向流动、而 Client 的项经 `this.ctx.invocation.uplink()` 到达正在运行的 Host 方法的流方法。会话事件流、分页、增量 reduce、projection 和实体子流仍需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 -API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 Typert RPC 层位于 `packages/api`;Connection 与 WebServer 位于 `packages/client/connection` 和 `packages/host/webserver`。需要流式或浏览器原生响应的功能注册精确的 Connection Fetch 路由,而不定义 Remote 方法。 +API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 Typert RPC 层位于 `packages/api`;Connection 与 WebServer 位于 `packages/client/connection` 和 `packages/host/webserver`。包含 `Uint8Array` 的一元结果(包括嵌套字段和数组条目)使用[二进制 Remote 传输](../.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md)。需要 RPC 信封之外响应的功能注册精确的 Connection Fetch 路由。 lookup 策略按 key 配置,因此所有 `agent` 或 `session` 参数共享冷恢复行为。只接受 live 对象需要显式的逐参数或逐 endpoint 策略,而这种策略并不存在;不能通过业务方法内部猜测对象是否来自恢复。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2aa6cd3874..16eda74241 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: bb95bd3794b0be4fd7688f77c2ac77efd4249ecf -config-catalog.zh.md: b1297bf254db369d063a39395d1678f91f807550 +config-catalog.md: 645c64b43a95d227a9373985f05bc50b4b4e93f6 +config-catalog.zh.md: e9f8db259539b83f771027062019039b930f8d37 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index bb95bd3794..645c64b43a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -338,7 +338,7 @@ export interface Config { } ``` -Source: [`packages/api/workspace-files/src/index.ts:69`](../packages/api/workspace-files/src/index.ts) +Source: [`packages/api/workspace-files/src/index.ts:70`](../packages/api/workspace-files/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b1297bf254..e9f8db2595 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -342,7 +342,7 @@ export interface Config { } ``` -来源:[`packages/api/workspace-files/src/index.ts:69`](../packages/api/workspace-files/src/index.ts) +来源:[`packages/api/workspace-files/src/index.ts:70`](../packages/api/workspace-files/src/index.ts) diff --git a/docs/subsystems/office-to-pdf.i18n.yaml b/docs/subsystems/office-to-pdf.i18n.yaml index 7f4afe0589..200c1c670b 100644 --- a/docs/subsystems/office-to-pdf.i18n.yaml +++ b/docs/subsystems/office-to-pdf.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 docs/subsystems/office-to-pdf.md -office-to-pdf.md: 65f46ded856e81f9bcd7890c11c82f0a22f769c4 -office-to-pdf.zh.md: df46af01d78241bbc88f6b85a83c47551faf4bfe +office-to-pdf.md: dd0f6392f264775f12c0f7612656f1c07366567d +office-to-pdf.zh.md: 5c99f87681dd8d98da3717a311a7f858d4b52b32 diff --git a/docs/subsystems/office-to-pdf.md b/docs/subsystems/office-to-pdf.md index 65f46ded85..dd0f6392f2 100644 --- a/docs/subsystems/office-to-pdf.md +++ b/docs/subsystems/office-to-pdf.md @@ -29,7 +29,7 @@ The provider admits the deferred read before allocating source bytes, shares con ## Preview reads -`RenderedDocumentBytes` extends the workspace byte response with `missingFonts` and `generation`; the original source identity accompanies the converted PDF. +`RenderedDocumentBytes` carries workspace file metadata, base64 PDF `data`, `missingFonts`, and `generation`; the original source identity accompanies the converted PDF. The `officeToPdf.render` Remote method checks source authorization and versions through the Session's [Workspace Files](../../packages/api/workspace-files/README.md) service. After conversion admission, `fs.readBytes` supplies raw input within the reserved byte capacity; Office input limits govern this read. The response carries base64 PDF bytes with the source absolute path and freshness version. Source access failures pass through; size and engine failures expose a classified reason without diagnostics. Conversion does not activate an Agent or append events. diff --git a/docs/subsystems/office-to-pdf.zh.md b/docs/subsystems/office-to-pdf.zh.md index df46af01d7..5c99f87681 100644 --- a/docs/subsystems/office-to-pdf.zh.md +++ b/docs/subsystems/office-to-pdf.zh.md @@ -29,7 +29,7 @@ ## 预览读取 -`RenderedDocumentBytes` 在工作区字节响应上增加 `missingFonts` 和 `generation`;转换后的 PDF 附带原始源文件身份。 +`RenderedDocumentBytes` 携带工作区文件元数据、base64 PDF `data`、`missingFonts` 和 `generation`;转换后的 PDF 附带原始源文件身份。 `officeToPdf.render` Remote 方法通过 Session 的[工作区文件](../../packages/api/workspace-files/README.zh.md)服务检查源文件授权与版本。取得转换容量后,`fs.readBytes` 在预留字节容量内提供原始输入;该读取受 Office 输入上限约束。响应携带 base64 PDF 字节、源文件绝对路径与新鲜度版本。源访问失败直接传递;大小和引擎失败只暴露分类原因,不含诊断信息。转换不激活 Agent 或追加事件。 diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 091c70c940..b4d97350d4 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.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 docs/subsystems/sidebar-right.md -sidebar-right.md: e3a4ffcf4e4b17975c10c0358f6f53dc5eea6ec2 -sidebar-right.zh.md: a588dc99b92314c54ea1309e4a44d305e23b970f +sidebar-right.md: bade9a958a62368b84ddfae8b5dbe869dfe11e4a +sidebar-right.zh.md: 93a6f1f25171b19fef5876e353e4b8be46aef78d diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index e3a4ffcf4e..bade9a958a 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -112,7 +112,7 @@ The `text` tab is the shared Document Preview owner. Its [root registration](../ The [registry](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts) records unique `id`, `extensions`, localized `title()`, `loading`, optional `priority`, and optional `wrap`. Case-insensitive suffix matching ranks `extension` (the default) before `builtin`, then longer suffixes before shorter ones, then registration order. Unlike tab-kind replacement, the registry keeps all implementations available; the toolbar lists matching alternatives and remembers the selection per tab. Unknown extensions use plain text. Suffixes declared in `binaryExtensions` suppress the plain-text alternative, as described in the [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers). `loading` is `text-pages`, `bytes-complete`, or `renderer`; `wrap` advertises support for the shared source-wrap control. -[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) derives from `PropsRuntime<'sidebar.right.tab.document'>`. The owner supplies the original `resourceAddress`, `content`, current `wrap`, and `addResource` / `setResources` dependency callbacks: text content is `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`, with cumulative `text`; complete bytes are `{ kind: 'bytes', data }`, with `Uint8Array` data. These transient buffers are borrowed read-only and must not enter durable layout or Session JSON. PDF copies the bytes before Worker transfer, preserving the owner's buffer. The child receives the same framework-bound `useTabInfo` and the global metadata-only `useResource`. The parent reads through ordinary inject callbacks to `remote.workspaceFiles.read`/`readAll` and owns page appends, per-tab refresh, and loading status. HTML's own inject callback uses `readRelated`; Host code resolves paths. Markdown and code retain one incremental renderer across appends and settle at EOF; HTML and PDF receive complete bytes. +[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) derives from `PropsRuntime<'sidebar.right.tab.document'>`. The owner supplies the original `resourceAddress`, `content`, current `wrap`, and `addResource` / `setResources` dependency callbacks: text content is `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`, with cumulative `text`; complete bytes are `{ kind: 'bytes', data }`, with `Uint8Array` data. These transient buffers are borrowed read-only and must not enter durable layout or Session JSON. PDF copies the bytes before Worker transfer, preserving the owner's buffer. The child receives the same framework-bound `useTabInfo` and the global metadata-only `useResource`. The parent reads text through `remote.workspaceFiles.read` and bytes through `remote.workspaceFiles.readBytes`, and owns page appends, per-tab refresh, and loading status. HTML passes `baseFile` to the same byte reader; Host code resolves paths. Markdown and code retain one incremental renderer across appends and settle at EOF; HTML and PDF receive complete bytes. Preview records its loaded version and the version observed when a read starts. Automatic refresh defaults to enabled; initial member metadata establishes a baseline, and later changes reload the tab. Refresh rereads only that tab, without changing shared metadata or another tab's content. Reads are non-transactional; versions are opaque equality tokens, not ordered timestamps ([resource observation and Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md)). @@ -128,9 +128,9 @@ A resource stays open while it has a holder — a subscribed `useResource` or a ## Workspace Files -The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote namespace read files allowed by the Session filesystem backend: `stat(path)` returns `{ absolutePath, version, bytes? }`; `read(path, { offset?, limit? })` returns one page of lines (`offset` 1-based, `limit` capped by the configured page size) as `{ …stat, offset, text, eof }`; `readBytes(path, { offset?, length? })` returns one raw byte window (`offset` 0-based, `length` capped by the configured byte limit) as base64 `{ …stat, offset, data, eof }` with no text decoding. `list(path)` remains inside the workspace root and returns a directory's direct children (`name`, `type: 'file' | 'directory' | 'other'`, `size?`) cut to the configured cap with `truncated` set. `changes(path)` watches one target; the Host determines its type and confines directories to the workspace. It yields `{ kind: 'ready' }` once subscribed, then `{ kind: 'change', change }` frames whose payload is `{ absolutePath, version }` or `{ absolutePath, absent: true }` ([README](../../packages/api/workspace-files/README.md#use-this-package)). File reads reject final symlinks and enforce transfer caps; `read` additionally requires UTF-8 text. Failures use `workspace-file/*` codes ([failures](../../packages/api/workspace-files/README.md)). +The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote namespace read files allowed by the Session filesystem backend: `stat(path)` returns `{ absolutePath, version, bytes? }`; `read(path, { offset?, limit? })` returns one page of lines (`offset` 1-based, `limit` capped by the configured page size) as `{ …stat, offset, text, eof }`; `readBytes(path, { range?, baseFile? })` returns a complete file under `maxFileBytes` or a byte window under `maxBytes` as `{ …stat, offset, data, eof }` with native `Uint8Array` data. Its optional `range` uses 0-based `offset` and a byte `length`; `baseFile` resolves the relative target from another file’s directory. `list(path)` remains inside the workspace root and returns a directory's direct children (`name`, `type: 'file' | 'directory' | 'other'`, `size?`) cut to the configured cap with `truncated` set. `changes(path)` watches one target; the Host determines its type and confines directories to the workspace. It yields `{ kind: 'ready' }` once subscribed, then `{ kind: 'change', change }` frames whose payload is `{ absolutePath, version }` or `{ absolutePath, absent: true }` ([README](../../packages/api/workspace-files/README.md#use-this-package)). File reads reject final symlinks and enforce transfer caps; `read` additionally requires UTF-8 text. Failures use `workspace-file/*` codes ([failures](../../packages/api/workspace-files/README.md)). -[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) registers the `file` provider, with `ResourceProtocolMap.file` directly naming `WorkspaceFileStat`. A Session address carries the authorizing Session and a relative or absolute path, passed unchanged to the Host for resolution. The provider waits for Host `ready` before stat and filters changes by `stat.absolutePath`. Bare `absolute` addresses have no authorizing Session and fail with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Any UI, including Global components, shares the observation for the same complete address. Preview's ordinary Remote callbacks use the Session in that address; Host `readAll` and `readRelated` remain, and Preview's `rpc.ts` decodes byte results. +[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) registers the `file` provider, with `ResourceProtocolMap.file` directly naming `WorkspaceFileStat`. A Session address carries the authorizing Session and a relative or absolute path, passed unchanged to the Host for resolution. The provider waits for Host `ready` before stat and filters changes by `stat.absolutePath`. Bare `absolute` addresses have no authorizing Session and fail with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Any UI, including Global components, shares the observation for the same complete address. Preview's Remote callbacks use the Session in that address; `remote.workspaceFiles.readBytes` returns native bytes through Connection's binary RPC transport. ## Shipped types diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index a588dc99b9..93a6f1f251 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -112,7 +112,7 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed [注册表](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts)记录唯一的 `id`、`extensions`、本地化 `title()`、`loading`,以及可选的 `priority` 和 `wrap`。后缀匹配不区分大小写,先排 `extension`(缺省值)、再排 `builtin`,随后比较后缀长度(长者优先)与注册顺序。与 tab kind 替换不同,注册表保留所有实现;工具栏列出匹配的候选,按 tab 记住选择。未知扩展名使用纯文本。`binaryExtensions` 声明的后缀不提供纯文本备选,见[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)。`loading` 为 `text-pages`、`bytes-complete` 或 `renderer`;`wrap` 声明是否支持共享的源码换行控件。 -[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 派生自 `PropsRuntime<'sidebar.right.tab.document'>`。owner 提供原始 `resourceAddress`、`content`、当前 `wrap`,以及声明依赖的 `addResource` / `setResources` 回调:文本内容为 `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`,其中 `text` 为累积文本;完整字节为 `{ kind: 'bytes', data }`,其中 `data` 为 `Uint8Array`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 Session JSON。PDF 在转移到 Worker 前复制字节,以保留 owner 的缓冲区。子组件收到同一个框架绑定的 `useTabInfo`,以及全局共享、仅提供元数据的 `useResource`。父组件通过普通 inject 回调调用 `remote.workspaceFiles.read`/`readAll`,拥有追加分页、逐 tab 刷新与加载状态。HTML 自己的 inject 回调使用 `readRelated`;路径由 Host 代码解析。Markdown 和代码在追加期间保留同一个增量渲染器,到 EOF 完成最终解析;HTML 和 PDF 接收完整字节。 +[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 派生自 `PropsRuntime<'sidebar.right.tab.document'>`。owner 提供原始 `resourceAddress`、`content`、当前 `wrap`,以及声明依赖的 `addResource` / `setResources` 回调:文本内容为 `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`,其中 `text` 为累积文本;完整字节为 `{ kind: 'bytes', data }`,其中 `data` 为 `Uint8Array`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 Session JSON。PDF 在转移到 Worker 前复制字节,以保留 owner 的缓冲区。子组件收到同一个框架绑定的 `useTabInfo`,以及全局共享、仅提供元数据的 `useResource`。父组件通过 `remote.workspaceFiles.read` 读取文本,通过 `remote.workspaceFiles.readBytes` 读取字节,拥有追加分页、逐 tab 刷新与加载状态。HTML 向同一个字节读取方法传入 `baseFile`;路径由 Host 代码解析。Markdown 和代码在追加期间保留同一个增量渲染器,到 EOF 完成最终解析;HTML 和 PDF 接收完整字节。 Preview 记录已载入版本和读取开始时的观察版本。自动刷新默认开启;成员首次元数据只建立基线,后续变化才重新加载 tab。刷新只重读当前 tab,不改变共享元数据或其他 tab 的内容。读取不具备事务性;版本是不透明的相等性令牌,不是可排序的时间戳([资源观察与 Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md))。 @@ -128,9 +128,9 @@ Preview 记录已载入版本和读取开始时的观察版本。自动刷新默 ## Workspace Files -Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名空间读取 Session 文件系统后端允许的文件:`stat(path)` 返回 `{ absolutePath, version, bytes? }`;`read(path, { offset?, limit? })` 返回一页行(`offset` 1 起,`limit` 受配置页长限制),形如 `{ …stat, offset, text, eof }`;`readBytes(path, { offset?, length? })` 返回一个原始字节窗口(`offset` 0 起,`length` 受配置字节上限限制),形如 base64 的 `{ …stat, offset, data, eof }`、不做文本解码。`list(path)` 仍限定在工作区根内,返回目录的直接子项(`name`、`type: 'file' | 'directory' | 'other'`、`size?`),按配置上限截断并置 `truncated`。`changes(path)` 监听一个目标,由 Host 判断目标类型并将目录限制在工作区内;订阅就绪后产出 `{ kind: 'ready' }`,随后产出 `{ kind: 'change', change }` 帧,其载荷为 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`([README](../../packages/api/workspace-files/README.zh.md#use-this-package))。文件读取拒绝末端符号链接并执行传输上限;`read` 还要求 UTF-8 文本。失败使用 `workspace-file/*` 错误码([失败](../../packages/api/workspace-files/README.zh.md))。 +Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名空间读取 Session 文件系统后端允许的文件:`stat(path)` 返回 `{ absolutePath, version, bytes? }`;`read(path, { offset?, limit? })` 返回一页行(`offset` 1 起,`limit` 受配置页长限制),形如 `{ …stat, offset, text, eof }`;`readBytes(path, { range?, baseFile? })` 在 `maxFileBytes` 上限内返回完整文件,或在 `maxBytes` 上限内返回字节窗口,形如 `{ …stat, offset, data, eof }`,其中 `data` 为原生 `Uint8Array`。可选 `range` 使用 0 起算的 `offset` 与字节数 `length`;`baseFile` 从另一文件所在目录解析相对目标。`list(path)` 仍限定在工作区根内,返回目录的直接子项(`name`、`type: 'file' | 'directory' | 'other'`、`size?`),按配置上限截断并置 `truncated`。`changes(path)` 监听一个目标,由 Host 判断目标类型并将目录限制在工作区。订阅就绪后产出 `{ kind: 'ready' }`,随后产出 `{ kind: 'change', change }` 帧,其载荷为 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`([README](../../packages/api/workspace-files/README.zh.md#use-this-package))。文件读取拒绝末端符号链接并执行传输上限;`read` 还要求 UTF-8 文本。失败使用 `workspace-file/*` 错误码([失败](../../packages/api/workspace-files/README.zh.md))。 -[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 注册 `file` 提供方,`ResourceProtocolMap.file` 直接是 `WorkspaceFileStat`。Session 地址携带授权 Session 与相对或绝对路径,Host 原样接收并解析。提供方在 stat 前等待 Host 的 `ready` 帧,并按 `stat.absolutePath` 过滤变更。裸 `absolute` 地址没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。任何 UI(包括 Global)访问同一完整地址都共享观察。Preview 的普通 Remote 回调使用地址中的 Session;Host `readAll` 和 `readRelated` 保留,字节结果由 Preview 的 `rpc.ts` 解码。 +[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 注册 `file` 提供方,`ResourceProtocolMap.file` 直接是 `WorkspaceFileStat`。Session 地址携带授权 Session 与相对或绝对路径,Host 原样接收并解析。提供方在 stat 前等待 Host 的 `ready` 帧,并按 `stat.absolutePath` 过滤变更。裸 `absolute` 地址没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。任何 UI(包括 Global)访问同一完整地址都共享观察。Preview 的 Remote 回调使用地址中的 Session;`remote.workspaceFiles.readBytes` 通过 Connection 的二进制 RPC 传输返回原生字节。 ## 内置类型 diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index 2f43dc6d54..374b43c01f 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.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 docs/subsystems/typert.md -typert.md: a8192b319215957b8d373ec01f6f29d73e165e45 -typert.zh.md: 08d1e8f466ef1e516c04426165b8180bd9881c83 +typert.md: a8ad968b7af77c90cfcdbeb910f435be461bf442 +typert.zh.md: d020b75ee7c52cc87aaea371978aeebe78d6fa45 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index a8192b3192..a8ad968b7a 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -38,7 +38,7 @@ interface TypertLookupDefinition { ## Invocation descriptors -An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schema factories, while SRC codecs enforce JSON-safe values without structural type recovery. Cancellation is an out-of-band carrier signal injected after business parameters and never enters `args`. +An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schema factories, while SRC codecs enforce JSON-safe inputs without structural type recovery. Unary result codecs can supply `encode()` for byte-containing subtrees and `decode()` for recursive native-byte validation; Client declarations narrow every `Uint8Array` to `ArrayBuffer` backing. Pure JSON results need neither byte detection nor Client decoding. Cancellation is an out-of-band carrier signal injected after business parameters and never enters `args`. ```ts type-equiv /** Codec attached to one invocation parameter or result. */ @@ -48,6 +48,19 @@ type TypertCodec = readonly typeSymbol: string /** Materialize and return the process-realm schema on first boundary use. */ readonly create: () => TypertSchema + /** + * Decode a unary result whose fields require type-specific handling. + * @param value - result reconstructed by the RPC carrier. + * @returns the validated result, retaining native byte views. + */ + readonly decode?: (value: unknown) => unknown + /** + * Project typed binary fields into carrier-owned multipart attachments. + * @param value - native unary result. + * @param writeBytes - records a byte view at its result-relative path and returns its JSON placeholder. + * @returns JSON metadata with untouched JSON subtrees retained. + */ + readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown } | { readonly mode: 'src-json' diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 08d1e8f466..d020b75ee7 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -38,7 +38,7 @@ interface TypertLookupDefinition { ## 调用 descriptor -`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema factory,SRC codec 则在不恢复结构类型的前提下强制要求 JSON 安全值。取消通过带外 carrier signal 表达:它在业务参数之后注入,绝不进入 `args`。 +`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema factory,SRC codec 则在不恢复结构类型的前提下强制要求输入为 JSON 安全值。一元结果 codec 可提供 `encode()` 处理含字节的子树,并通过 `decode()` 递归校验原生字节;Client 声明将每个 `Uint8Array` 限定为以 `ArrayBuffer` 为底层缓冲区。纯 JSON 结果无需字节识别或 Client 解码。取消通过带外 carrier signal 表达:它在业务参数之后注入,绝不进入 `args`。 ```ts type-equiv /** Codec attached to one invocation parameter or result. */ @@ -48,6 +48,19 @@ type TypertCodec = readonly typeSymbol: string /** Materialize and return the process-realm schema on first boundary use. */ readonly create: () => TypertSchema + /** + * Decode a unary result whose fields require type-specific handling. + * @param value - result reconstructed by the RPC carrier. + * @returns the validated result, retaining native byte views. + */ + readonly decode?: (value: unknown) => unknown + /** + * Project typed binary fields into carrier-owned multipart attachments. + * @param value - native unary result. + * @param writeBytes - records a byte view at its result-relative path and returns its JSON placeholder. + * @returns JSON metadata with untouched JSON subtrees retained. + */ + readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown } | { readonly mode: 'src-json' diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index 0f1163393e..f89b3c13dc 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.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 docs/subsystems/web-client.md -web-client.md: c43b7553d1a223b82fec73c7431e4655cf1cbee1 -web-client.zh.md: d98febb7494a2b2fe082f8544850dbf67ee75794 +web-client.md: 80d233b634f57bb150d8eb6604428557e1e16033 +web-client.zh.md: f14cdbe9b1d5605b90bd595a384ef7ac9c0d7286 diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index c43b7553d1..80d233b634 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -27,7 +27,7 @@ The Web boot kernel creates the module system, prefetches `immediately` entries, Host business services annotate callable methods with Typert Remote decorators. Host generation emits strict descriptors, runtime codecs, declaration merges, and source maps. The Client-side `api-remotes` assembly selects those generated contributions and mounts concrete methods under `ctx.remote.` and Session-scoped `agentCtx.remote.`. Feature packages depend on the generated service face, not the Gateway implementation or a Host package's runtime entry. -The Connection owns request correlation, the `/api` carrier, trust checks, exact Fetch routes, and connection generations. API Gateway owns Remote dispatch, cancellation, logical streams, and selected Host event forwarding. Controller operations belong on generated Remote methods or explicit Remote streams; feature-owned downloads register exact Fetch routes. The [API Gateway reference](../api-gateway.md) defines generation and invocation, while the [Connection README](../../packages/client/connection/README.md) defines the physical carrier and trust policy. +The Connection owns request URL resolution, correlation, the `/api` carrier, trust checks, exact Fetch routes, and connection generations. API Gateway owns Remote dispatch, cancellation, logical streams, and selected Host event forwarding. Controller operations belong on generated Remote methods or explicit Remote streams; feature-owned downloads register exact Fetch routes. The [API Gateway reference](../api-gateway.md) defines generation and invocation, while the [Connection README](../../packages/client/connection/README.md) defines the physical carrier and trust policy. The internal `$events` logical stream is the Connection generation source. Its opening `ready` frame carries the Host home used for path display and establishes the generation after Host listeners are attached, before any controller begins a baseline read. `ctx.remote.$on()` delivers allowlisted ordinary events to the root Client Context and scoped waterfall events to the resolved Session Context; a waterfall listener returns a result, calls `next()`, or rejects. diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index d98febb749..f14cdbe9b1 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -27,7 +27,7 @@ Web boot kernel 创建模块系统、预取 `immediately` entry、挂载 vendore Host 业务 service 使用 Typert Remote decorator 标记可调用 method。Host generation 产出严格 descriptor、runtime codec、declaration merge 与 source map。Client 侧 `api-remotes` assembly 选择这些生成贡献,并把具体 method 挂到 `ctx.remote.` 与 Session scope 的 `agentCtx.remote.`。功能包依赖生成的 service face,而不依赖 Gateway 实现或 Host 包的运行时 entry。 -Connection 拥有 request correlation、`/api` carrier、trust check、精确 Fetch 路由与 connection generation。API Gateway 拥有 Remote dispatch、取消、logical stream 与选定 Host event 的转发。Controller 操作应进入生成的 Remote method 或显式 Remote stream;功能自有的下载则注册精确 Fetch 路由。[API Gateway 参考](../api-gateway.zh.md)定义 generation 与调用,[Connection README](../../packages/client/connection/README.zh.md)定义物理 carrier 与信任策略。 +Connection 拥有请求 URL 解析、request correlation、`/api` carrier、trust check、精确 Fetch 路由与 connection generation。API Gateway 拥有 Remote dispatch、取消、logical stream 与选定 Host event 的转发。Controller 操作应进入生成的 Remote method 或显式 Remote stream;功能自有的下载则注册精确 Fetch 路由。[API Gateway 参考](../api-gateway.zh.md)定义 generation 与调用,[Connection README](../../packages/client/connection/README.zh.md)定义物理 carrier 与信任策略。 内部 `$events` logical stream 是 Connection generation source。它的 opening `ready` frame 携带用于路径显示的 Host home,并在 Host listener 已挂载、任何 controller 开始 baseline read 之前建立 generation。`ctx.remote.$on()` 把 allowlist 内的普通 event 交付给 root Client Context,并把 scoped waterfall event 交付给已解析的 Session Context;waterfall listener 可以返回结果、调用 `next()` 或拒绝。 diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index 452d200cf5..b03f962bef 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.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 docs/subsystems/workspace.md -workspace.md: 323323946ac92bbc03962b83c5e77a81a960d89a -workspace.zh.md: aedd1134305aaa7e97ccd449631ff72446bf28b4 +workspace.md: 8a3369778f0eac10d5fe077686d336eb47f6b41e +workspace.zh.md: f1e7e96f1fb5e983de30bd8e2f9fe08ed0635beb diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 323323946a..8a3369778f 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -397,34 +397,14 @@ Host Remote file reads and workspace directory observations over the composed fi @Remote async read( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal, ): Promise /** - * Read one byte window of a regular file readable by the filesystem backend: raw - * bytes, no text decoding and no binary rejection. + * Read a complete regular file or one byte range without text decoding. * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - absolute path or path relative to the workspace root; files outside it are allowed. - * @param range - the byte window; omitted fields take the window defaults. + * @param path - target path, absolute or workspace-relative; relative to the base file's directory when provided. + * @param options - optional base file and range; without a range the complete-file cap applies. * @param signal - caller cancellation. - * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte. + * @returns native bytes with the file's version and size at the preceding stat, byte offset, and EOF marker. */ -@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal, ): Promise - -/** - * Read a complete regular file as bytes, subject to the configured full-file cap. - * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - absolute or workspace-relative file path. - * @param signal - caller cancellation. - * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large. - */ -@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise - -/** - * Read a complete file relative to another file's directory, including outside the workspace. - * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - base file, absolute or workspace-relative. - * @param relativePath - relative filesystem path, not a URL or absolute path. - * @param signal - caller cancellation. - * @returns the complete related file using the ordinary file-size and access checks. - */ -@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise +@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal, ): Promise /** * Report one regular file's identity, version, and size without its content. diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index aedd113430..f1e7e96f1f 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -397,34 +397,14 @@ Host Remote file reads and workspace directory observations over the composed fi @Remote async read( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal, ): Promise /** - * Read one byte window of a regular file readable by the filesystem backend: raw - * bytes, no text decoding and no binary rejection. + * Read a complete regular file or one byte range without text decoding. * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - absolute path or path relative to the workspace root; files outside it are allowed. - * @param range - the byte window; omitted fields take the window defaults. + * @param path - target path, absolute or workspace-relative; relative to the base file's directory when provided. + * @param options - optional base file and range; without a range the complete-file cap applies. * @param signal - caller cancellation. - * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte. + * @returns native bytes with the file's version and size at the preceding stat, byte offset, and EOF marker. */ -@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal, ): Promise - -/** - * Read a complete regular file as bytes, subject to the configured full-file cap. - * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - absolute or workspace-relative file path. - * @param signal - caller cancellation. - * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large. - */ -@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise - -/** - * Read a complete file relative to another file's directory, including outside the workspace. - * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - base file, absolute or workspace-relative. - * @param relativePath - relative filesystem path, not a URL or absolute path. - * @param signal - caller cancellation. - * @returns the complete related file using the ordinary file-size and access checks. - */ -@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise +@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal, ): Promise /** * Report one regular file's identity, version, and size without its content. diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 5f05ce5e1e..832baf5fd0 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/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/api/gateway/README.md -README.md: 996e68fd2b99989523b01272c693d4039fdc9f44 -README.zh.md: 23b83fbf2c7559434dfd47d6b06994a8d390d1ac +README.md: 71171ef65c9113315cbd401a6b59eefb5a9a45a1 +README.zh.md: f9f2068eb593acc25f1fd384d08979d5c48c7743 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 996e68fd2b..71171ef65c 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -26,7 +26,7 @@ Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host `ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, and invokes the public business method. Business Services extend `TypertRemoteService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `bindTypertRemote()` remains available when another base class owns inheritance. -Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. +Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. For unary results, Gateway executes the generated codec's `encode()` when present and otherwise keeps strict JSON values unchanged; SRC recursively detects runtime byte values. Both paths return JSON-compatible metadata and result-relative byte attachments to Connection for transport framing. The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` is a `RemoteError` subclass whose `gateway/*` codes name the failures owned by dispatch, binding, providers, lookup, Context, arguments, codecs, and the uplink: `gateway/uplink-overflow` for an uplink whose buffered frames exceed `streamInboxBytes`, `gateway/protocol` for an uplink item after `end`, and the codec code `gateway/input-invalid` for a rejected uplink item. A resolver that refuses on policy grounds — a cold-resume failure or an ownership fence — throws its own `RemoteError`, and the code it chose reaches the caller unchanged. @@ -45,7 +45,7 @@ The browser carrier accepts the shell-owned stream origin described by [Connecti `ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and Client-supplied fields without strict generated codecs fail before methods become callable. -Each unary call checks positional arity, constructs the descriptor's exact named `args`, and sends the typed values through `ctx.connection.rpc.call('/api', endpoint, ...)` without executing Client-side schemas; the Host validates the received wire fields before business invocation. A generated stream method returns a `RemoteStreamHandle` from `dsh-typert-protocol` and opens one logical stream when it is called, through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. The handle iterates the downlink once. `send(item)` queues an uplink item, sent after the `open` frame; `end()` half-closes the uplink; `dispose()` sends `cancel` unless a terminal frame has arrived and ends the iteration quietly. Breaking out of the iteration early disposes the handle; after the downlink terminates `send` throws and `end` is ignored. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Successful unary results and stream items pass through without Client-side type parsing. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. +Each unary call checks positional arity, constructs the descriptor's exact named `args`, and sends the typed values through `ctx.connection.rpc.call('/api', endpoint, ...)` without executing Client-side schemas; the Host validates the received wire fields before business invocation. A generated stream method returns a `RemoteStreamHandle` from `dsh-typert-protocol` and opens one logical stream when it is called, through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. The handle iterates the downlink once. `send(item)` queues an uplink item, sent after the `open` frame; `end()` half-closes the uplink; `dispose()` sends `cancel` unless a terminal frame has arrived and ends the iteration quietly. Breaking out of the iteration early disposes the handle; after the downlink terminates `send` throws and `end` is ignored. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. The Client delegates unary result conversion to the codec's optional `decode()`. Generated decoders validate nested metadata and native byte types without iterating, copying, or freezing byte payloads. JSON unary results and stream items pass through without Client-side type parsing. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. Every unary call resolves to `RemoteResult` — `{ ok: true, value }` or `{ ok: false, error }` — and never rejects for a carrier problem: this face folds an offline carrier into the error branch and answers `gateway/cancelled` when the caller's signal aborts, so no consumer wraps a call to recover one. Only an assembly fault still rejects: wrong arity, an unmounted method, a withdrawn contribution, a missing Context adapter. `error` is a live `RemoteError` instance, so `throw result.error` keeps throw semantics, and `isRemoteFailure(value)` is the one predicate a consumer needs — a caught value it accepts carries a Host code, and anything it rejects is a local fault the caller should let crash. `carrierFailure(endpoint, error)` and `cancelledFailure(endpoint, cause)` build those two folded results, so a test stand-in for this face folds identically. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 23b83fbf2c..f9f2068eb5 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -26,7 +26,7 @@ kind: "package-reference" 每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,并调用公开的业务方法。业务服务继承 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `TypertRemoteService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypertRemote()`。 -严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的解析器:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 适配器解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 +严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的解析器:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 适配器解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。对于一元结果,Gateway 在生成 codec 提供 `encode()` 时执行它,否则保持 strict JSON 值不变;SRC 则递归识别运行时字节值。两条路径都向 Connection 返回 JSON 兼容元数据及相对于结果的字节附件,由 Connection 完成传输帧封装。 Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 是 `RemoteError` 的子类,其 `gateway/*` 码命名了分发、绑定、提供方、查找、Context、参数、编解码器以及上行各自负责的故障:缓冲帧超过 `streamInboxBytes` 的上行报 `gateway/uplink-overflow`,`end` 之后的上行项报 `gateway/protocol`,被 codec 拒绝的上行项报编解码码 `gateway/input-invalid`。因策略而拒绝的解析器——冷恢复失败或 ownership fence——抛出自己的 `RemoteError`,它选定的码原样到达调用方。 @@ -45,7 +45,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source `ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格 codec 的 Client 供值字段,都会在方法可调用前报错。 -每次一元调用都会检查位置参数数量,构造与描述符完全匹配的具名 `args`,再把带类型的值原样交给 `ctx.connection.rpc.call('/api', endpoint, ...)`,而不执行 Client 侧 schema;Host 会在业务调用前校验收到的 wire 字段。生成的流方法返回 `dsh-typert-protocol` 的 `RemoteStreamHandle`,并在被调用时就打开一条逻辑流,进程内 Connection 载体可用时通过它打开,否则通过共享的 Gateway WebSocket 打开。句柄只迭代下行一次。`send(item)` 把一个上行项入队,在 `open` 帧之后发出;`end()` 半关闭上行;`dispose()` 在未收到终止帧时发送 `cancel`,并让迭代安静结束。提前跳出迭代等价于 dispose 句柄;下行终止后 `send` 抛错,`end` 被忽略。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。成功的一元结果与流项不经 Client 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 +每次一元调用都会检查位置参数数量,构造与描述符完全匹配的具名 `args`,再把带类型的值原样交给 `ctx.connection.rpc.call('/api', endpoint, ...)`,而不执行 Client 侧 schema;Host 会在业务调用前校验收到的 wire 字段。生成的流方法返回 `dsh-typert-protocol` 的 `RemoteStreamHandle`,并在被调用时就打开一条逻辑流,进程内 Connection 载体可用时通过它打开,否则通过共享的 Gateway WebSocket 打开。句柄只迭代下行一次。`send(item)` 把一个上行项入队,在 `open` 帧之后发出;`end()` 半关闭上行;`dispose()` 在未收到终止帧时发送 `cancel`,并让迭代安静结束。提前跳出迭代等价于 dispose 句柄;下行终止后 `send` 抛错,`end` 被忽略。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。Client 将一元结果转换交给 codec 的可选 `decode()`。生成的解码器校验嵌套元数据与原生字节类型,不逐字节遍历、复制或冻结字节载荷。JSON 一元结果与流项不经 Client 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 每次一元调用都解析为 `RemoteResult`——`{ ok: true, value }` 或 `{ ok: false, error }`——且绝不因载体问题 reject:本面把断线载体折入错误分支,调用方 signal 中止时答以 `gateway/cancelled`,因此没有消费方需要包一层来兜载体失败。只有装配故障仍会 reject:参数个数不符、方法未挂载、贡献已撤下、缺少 Context 适配器。`error` 是活的 `RemoteError` 实例,所以 `throw result.error` 保持 throw 语义;而 `isRemoteFailure(value)` 是消费方唯一需要的谓词——它认下的捕获值带着 Host 码,它拒绝的一律是本地故障,调用方应当让其崩掉。`carrierFailure(endpoint, error)` 与 `cancelledFailure(endpoint, cause)` 构造这两种折叠结果,测试里的替代实现据此采用相同的折叠方式。 diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 43992cb60e..9a6cb07d16 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -444,8 +444,12 @@ class ClientRemoteService extends Service implements ClientRemote { try { const result = await connection.rpc.call('/api', endpoint, { args: prepared.args }, prepared.signal) if (!mountActive(token)) return withdrawn(endpoint) + prepared.signal.throwIfAborted() if (!result.ok) return { ok: false, error: rebuiltFailure(result.error) } - return { ok: true, value: result.value } + const value = descriptor.result.mode === 'strict' && descriptor.result.decode !== undefined + ? descriptor.result.decode(result.value) + : result.value + return { ok: true, value } } catch (error) { // Carrier throws (offline or abort) are outcomes of the call, not assembly // faults, so they join the same error branch. A caller-aborted call is a diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 02b4c7fbbb..d9bc91d52a 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -7,7 +7,11 @@ import { randomUUID } from 'node:crypto' import { Context, Service, symbols } from '@deepseek-ai/cordis' -import { OperatorPeer, type ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' +import { + OperatorPeer, + type ConnectionRpcAttachment, + type ConnectionRpcHandler, +} from '@deepseek-ai/dsh-client-connection' import { Deque } from '@deepseek-ai/dsh-deque' import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -325,7 +329,10 @@ export class TypertGatewayService extends Service implements TypertGateway { * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise { - const prepared = await this.prepareInvocation(request, new AbortController()) + return this.invokePrepared(await this.prepareInvocation(request, new AbortController())) + } + + private async invokePrepared(prepared: PreparedInvocation): Promise { if (prepared.descriptor.mode !== undefined) { throw new TypertGatewayError( 'gateway/signature-invalid', @@ -337,7 +344,7 @@ export class TypertGatewayService extends Service implements TypertGateway { try { return await Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown } catch (error) { - if (request.signal?.aborted === true) throw remoteCancelled(prepared.endpoint, error) + if (prepared.invocation.signal.aborted) throw remoteCancelled(prepared.endpoint, error) throw error } finally { // A unary call's uplink is readable only while the method runs. @@ -647,11 +654,15 @@ export class TypertGatewayService extends Service implements TypertGateway { peer: PeerScope, ): Promise { try { - const value = await this.invoke(remoteRequest(endpoint, payload, signal, peer)) + const prepared = await this.prepareInvocation( + remoteRequest(endpoint, payload, signal, peer), + new AbortController(), + ) + const value = await this.invokePrepared(prepared) // A void or explicitly absent business result carries no `value` field; // JSON has no `undefined`, and the envelope's optional slot is the one // representation of absence that both args and results already use. - return { ok: true, value } + return encodeRpcResult(value, prepared.descriptor.result) } catch (error) { return rpcFailure(error) } @@ -963,6 +974,67 @@ export class TypertGatewayService extends Service implements TypertGateway { } } +function encodeRpcResult(value: unknown, codec: TypertCodec): ConnectionRpcResult { + const attachments: ConnectionRpcAttachment[] = [] + const writeBytes = (bytes: Uint8Array, path: readonly (string | number)[]): null => { + attachments.push({ path: [...path], bytes }) + return null + } + const encoded = codec.mode === 'strict' + ? codec.encode?.(value, writeBytes) ?? value + : encodeRuntimeResult(value, writeBytes) + return { ok: true, value: encoded, ...(attachments.length === 0 ? {} : { attachments }) } +} + +function encodeRuntimeResult( + input: unknown, + writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null, +): unknown { + const path: (string | number)[] = [] + const ancestors = new Set() + const extract = (input: unknown, key: string): unknown => { + // Capture accessors and toJSON once, before materializing the JSON metadata. + let value = input + if (input !== null && typeof input === 'object' && !(input instanceof Uint8Array)) { + const toJSON: unknown = Reflect.get(input, 'toJSON') + if (typeof toJSON === 'function') value = Reflect.apply(toJSON, input, [key]) + } + if (value instanceof Uint8Array) return writeBytes(value, path) + if (typeof value !== 'object' || value === null) return value + if (value instanceof Number || value instanceof String || value instanceof Boolean) return value.valueOf() + if (ancestors.has(value)) throw new TypeError('gateway: circular RPC result') + ancestors.add(value) + let copy: object + if (Array.isArray(value)) { + const items: unknown[] = [] + // JSON arrays include every index, even holes and non-enumerable elements. + for (let index = 0, length = value.length; index < length; index++) items.push(child(value[index], index)) + copy = items + } else { + const fields: Record = {} + for (const key of Object.keys(value)) { + const item: unknown = Reflect.get(value, key) + // A toJSON method on the projected object must not run a second time. + if (key === 'toJSON' && typeof item === 'function') continue + const extracted = child(item, key) + if (key === '__proto__') Object.defineProperty(fields, key, { value: extracted, enumerable: true }) + else fields[key] = extracted + } + copy = fields + } + ancestors.delete(value) + return copy + } + const child = (value: unknown, key: string | number): unknown => { + if (typeof value !== 'object' || value === null) return value + path.push(key) + const extracted = extract(value, String(key)) + path.pop() + return extracted + } + return extract(input, 'value') +} + type RemoteEventWireFrame = | RemoteEventEmitFrame | RemoteEventInvocationFrame diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index ddd6a846b8..aeec14ecf1 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -24,7 +24,7 @@ import type { } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { ClientRemote } from '../src/client/index.ts' -import { apply, inject, RemoteStream } from '../src/client/index.ts' +import { apply, inject, isRemoteFailure, RemoteStream } from '../src/client/index.ts' import { ClientUplinkQueue, RemoteStreamCarrierError, @@ -95,6 +95,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { signal?: AbortSignal, ) => Promise> 'probe/maybe': (value: string | null | undefined) => Promise> + 'probe/bytes': (signal?: AbortSignal) => Promise; readonly offset: number }>> 'probe/watch': (topic: string, signal?: AbortSignal) => AsyncIterable 'probe/attach': (topic: string, signal?: AbortSignal) => RemoteStreamHandle } @@ -3156,6 +3157,96 @@ async function withFakeWebSocket( } } +describe('Remote result decoders', () => { + const schema = z.object({ data: z.instanceof(Uint8Array), offset: z.number() }) + const descriptor: InvocationDescriptor = { + id: '@fixture/probe#probe/bytes', + service: 'probe', namespace: 'probe', method: 'bytes', + invocation: { kind: 'direct' }, parameters: [], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', typeSymbol: '@fixture#Bytes', + create: () => schema, + decode: value => schema.parse(value), + }, + } + + it('validates metadata without copying or freezing native data on a logical RPC carrier', async () => { + const data = new Uint8Array([0, 128, 255]) + const call = vi.fn().mockResolvedValue({ ok: true, value: { data, offset: 2 } }) + const { ctx, client } = await benchFiber(call) + try { + await ctx.remote.$mount({ package: '@fixture/binary', descriptors: [descriptor] }) + const result = await ctx.remote.probe.bytes() + expect(result).toEqual({ ok: true, value: { data, offset: 2 } }) + if (result.ok) expect(result.value.data).toBe(data) + expect(Object.isFrozen(data)).toBe(false) + for (const value of [{ data: 'base64', offset: 2 }, { data: [0, 128], offset: 2 }, { data, offset: '2' }]) { + call.mockResolvedValueOnce({ ok: true, value }) + const failure = await ctx.remote.probe.bytes() + expect(failure).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) + if (!failure.ok) expect(isRemoteFailure(failure.error)).toBe(true) + } + } finally { + await client.dispose() + } + }) + + it('delegates nested fields to the result decoder and leaves JSON results untouched', async () => { + const data = new Uint8Array([0, 128, 255]) + const value = { files: [{ content: data }], thumbnail: data } + const call = vi.fn().mockResolvedValue({ ok: true, value }) + const decode = vi.fn((input: unknown) => z.object({ + files: z.array(z.object({ content: z.instanceof(Uint8Array) })), + thumbnail: z.instanceof(Uint8Array), + }).parse(input)) + const { ctx, client } = await benchFiber(call) + try { + const dispose = await ctx.remote.$mount({ + package: '@fixture/binary', descriptors: [{ + ...descriptor, result: { mode: 'strict', typeSymbol: '@fixture#Files', create: () => schema, decode }, + }], + }) + expect(await ctx.remote.probe.bytes()).toEqual({ ok: true, value }) + expect(decode).toHaveBeenCalledWith(value) + expect((decode.mock.results[0]?.value as ReturnType).files[0]?.content).toBe(data) + expect(Object.isFrozen(data)).toBe(false) + await dispose() + const json = { base64: 'AP8=', items: [1, 2] } + call.mockResolvedValue({ ok: true, value: json }) + const create = vi.fn(() => schema) + await ctx.remote.$mount({ package: '@fixture/json', descriptors: [{ + ...descriptor, result: { mode: 'strict', typeSymbol: '@fixture#Json', create }, + }] }) + const result = await ctx.remote.probe.bytes() + if (result.ok) expect(result.value).toBe(json) + expect(create).not.toHaveBeenCalled() + } finally { + await client.dispose() + } + }) + + it.each(['cancel', 'withdraw'] as const)('discards late binary results after %s', async (action) => { + const response = Promise.withResolvers>>() + const call = vi.fn().mockReturnValue(response.promise) + const { ctx, client } = await benchFiber(call) + try { + const dispose = await ctx.remote.$mount({ package: '@fixture/binary', descriptors: [descriptor] }) + const abort = new AbortController() + const pending = ctx.remote.probe.bytes(abort.signal) + expect(call).toHaveBeenCalledOnce() + if (action === 'cancel') abort.abort() + else await dispose() + response.resolve({ ok: true, value: { data: new Uint8Array([7]), offset: 0 } }) + expect(await pending).toMatchObject({ + ok: false, error: { code: action === 'cancel' ? 'gateway/cancelled' : 'gateway/internal' }, + }) + } finally { + await client.dispose() + } + }) +}) + async function drainStream(source: AsyncIterable): Promise { const values: unknown[] = [] for await (const value of source) values.push(value) diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index 94f75e9138..24522ce846 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -4,7 +4,12 @@ import { describe, expect, it } from 'vitest' import { Context, Service, symbols } from '@deepseek-ai/cordis' import { z } from 'zod' import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' -import type { HostConnectionHandle, PeerId, PeerScope } from '@deepseek-ai/dsh-client-connection' +import type { + ConnectionRpcHandler, + HostConnectionHandle, + PeerId, + PeerScope, +} from '@deepseek-ai/dsh-client-connection' import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver' import { bindTypertRemote, @@ -53,6 +58,7 @@ class GoalService extends Service { readonly typertRemote = bindTypertRemote(this, 'goals') readonly calls: string[] = [] lastSignal: AbortSignal | undefined + lastPeer: PeerScope | undefined nextResult: unknown = undefined businessError: Error | undefined @@ -64,6 +70,7 @@ class GoalService extends Service { create(agent: FixtureAgent, request: { readonly title: string }, signal: AbortSignal): unknown { this.calls.push('create') this.lastSignal = signal + this.lastPeer = this.ctx.invocation?.peer return { agentId: agent.id, title: request.title, @@ -102,10 +109,7 @@ class GoalService extends Service { } } -type FakeRpcResult = - | { readonly ok: true; readonly value: unknown } - | { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details: object } } - +type FakeRpcResult = Awaited> type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise class FakeConnectionService extends Service { @@ -126,12 +130,12 @@ class FakeConnectionService extends Service { intercept: ( channel: string, matches: (endpoint: string) => boolean, - handler: FakeRpcHandler, + handler: ConnectionRpcHandler, ) => owner.effect(() => { this.channel = channel this.matches = matches - this.handler = handler + this.handler = (endpoint, payload, signal) => handler(endpoint, payload, signal, this.operator) return () => { this.channel = undefined this.matches = undefined @@ -996,16 +1000,25 @@ describe('TypertGatewayService', () => { const signal = abort.signal const handler = connection.handler if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor') - await expect(handler('goals/create', { + const success = await handler('goals/create', { args: { agentId: 'agent-1', request: { title: 'ship' } }, - }, signal)).resolves.toEqual({ + }, signal) + expect(success).toMatchObject({ ok: true, value: { agentId: 'agent-1', title: 'ship', scope: 'rpc-caller' }, }) + if (!success.ok) throw new Error('fixture Remote failed') + expect(success.attachments).toBeUndefined() const service = rawGoalService(ctx) expect(service.lastSignal).toBe(signal) abort.abort(new Error('client disconnected')) expect(service.lastSignal?.aborted).toBe(true) + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'direct' } }, + })).resolves.toMatchObject({ title: 'direct' }) + expect(service.lastPeer).toBe(connection.operator) const invalid = await handler('goals/create', { invalid: true }, signal) expect(invalid).toMatchObject({ ok: false, @@ -1014,11 +1027,11 @@ describe('TypertGatewayService', () => { if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded') expect(invalid.error.message).toMatch(/exactly one plain-object args field/) - await expect(handler('goals/maybe', { args: {} }, signal)).resolves.toEqual({ + await expect(handler('goals/maybe', { args: {} }, signal)).resolves.toMatchObject({ ok: true, value: undefined, }) - await expect(handler('goals/maybe', { args: { value: null } }, signal)).resolves.toEqual({ + await expect(handler('goals/maybe', { args: { value: null } }, signal)).resolves.toMatchObject({ ok: true, value: null, }) @@ -1068,6 +1081,112 @@ describe('TypertGatewayService', () => { expect(connection.handler).toBeUndefined() }) + it('projects strict and SRC result bytes before returning to Connection', async () => { + const { ctx } = await setup() + const fiber = ctx.plugin(FakeConnectionService) + await fiber + try { + const value = { content: new Uint8Array([0, 128, 255]) } + rawGoalService(ctx).nextResult = value + const handler = rawConnection(ctx).handler! + const source = await handler('goals/passthrough', { args: { value: null } }, new AbortController().signal) + expect(source).toEqual({ + ok: true, + value: { content: null }, + attachments: [{ path: ['content'], bytes: value.content }], + }) + const descriptor = passthroughDescriptor() + const codec = { + mode: 'strict' as const, + typeSymbol: '@fixture/gateway#Bytes', + create: () => z.object({ content: z.instanceof(Uint8Array) }), + encode: (input: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => { + const result = input as { readonly content: Uint8Array } + return { content: writeBytes(result.content, ['content']) } + }, + } + const remove = registerStrict(ctx, [{ ...descriptor, result: codec }]) + const strict = await handler('goals/passthrough', { args: { value: null } }, new AbortController().signal) + expect(strict).toEqual({ + ok: true, + value: { content: null }, + attachments: [{ path: ['content'], bytes: value.content }], + }) + await remove() + } finally { + await fiber.dispose() + } + }) + + it('owns runtime result projection for SRC methods', async () => { + const { ctx, service } = await setup() + const fiber = ctx.plugin(FakeConnectionService) + await fiber + try { + const handler = rawConnection(ctx).handler! + const call = () => handler('goals/passthrough', { args: { value: null } }, new AbortController().signal) + let reads = 0 + const bytes = new Uint8Array([1, 2]) + service.nextResult = { get content() { reads++; return bytes }, get count() { return ++reads } } + await expect(call()).resolves.toEqual({ + ok: true, + value: { content: null, count: 2 }, + attachments: [{ path: ['content'], bytes }], + }) + expect(reads).toBe(2) + + const array: Uint8Array[] = [] + Object.defineProperty(array, 0, { value: bytes }) + service.nextResult = array + await expect(call()).resolves.toEqual({ + ok: true, + value: [null], + attachments: [{ path: [0], bytes }], + }) + + service.nextResult = Object.assign(new Date('2026-01-01T00:00:00Z'), { content: bytes }) + await expect(call()).resolves.toEqual({ ok: true, value: '2026-01-01T00:00:00.000Z' }) + const json = { self: {} as object, toJSON: () => ({ selected: true }) } + json.self = json + service.nextResult = json + await expect(call()).resolves.toEqual({ ok: true, value: { selected: true } }) + let conversions = 0 + service.nextResult = { toJSON(key: string) { conversions++; expect(key).toBe('value'); return this }, count: 5 } + await expect(call()).resolves.toEqual({ ok: true, value: { count: 5 } }) + expect(conversions).toBe(1) + for (const primitive of [1, 'text', true]) { + service.nextResult = Object(primitive) as object + await expect(call()).resolves.toEqual({ ok: true, value: primitive }) + } + service.nextResult = { toJSON: 'ordinary' } + await expect(call()).resolves.toEqual({ ok: true, value: service.nextResult }) + + const protectedName = JSON.parse('{"__proto__":null}') as Record + Object.defineProperty(protectedName, '__proto__', { value: bytes, enumerable: true }) + service.nextResult = protectedName + const protectedResult = await call() + expect(protectedResult).toMatchObject({ + ok: true, + attachments: [{ path: ['__proto__'], bytes }], + }) + if (!protectedResult.ok || typeof protectedResult.value !== 'object' || protectedResult.value === null) { + throw new Error('SRC protected-name result was not projected') + } + expect(Object.hasOwn(protectedResult.value, '__proto__')).toBe(true) + expect(Reflect.get(protectedResult.value, '__proto__')).toBeNull() + + const circular: { next?: object } = {} + circular.next = circular + service.nextResult = circular + await expect(call()).resolves.toMatchObject({ + ok: false, + error: { code: 'gateway/internal', message: 'gateway: circular RPC result' }, + }) + } finally { + await fiber.dispose() + } + }) + it('claims and validates in-process Remote event results for the active Client generation', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml index 7611258318..0f0e41d9c1 100644 --- a/packages/api/workspace-files/README.i18n.yaml +++ b/packages/api/workspace-files/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/api/workspace-files/README.md -README.md: 6e5025b8f5543d810fa951acf5baa32a3c060494 -README.zh.md: c913145966cc982e10c7a533274941c6c52b8b55 +README.md: 0345887e58975b9ef2764144ded20a4f25e8f04d +README.zh.md: 572b83ec37696ee32ba9c3e1e0babdf696afe903 diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md index 6e5025b8f5..0345887e58 100644 --- a/packages/api/workspace-files/README.md +++ b/packages/api/workspace-files/README.md @@ -25,29 +25,29 @@ Use this package to preview files readable through a Session's filesystem from t ## Use this package -Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and the Typert Gateway; the bundle does so right after the Session Controller. Every method takes the Session identity on the wire, so a Client calls `remote.workspaceFiles.read(sessionId, path, range, signal)`, `stat(sessionId, path, signal)`, `readBytes(sessionId, path, range, signal)`, `list(sessionId, path, signal)`, or `changes(sessionId, path, signal)` and never names a root itself. The Host reads a live Session header or uses persistence `stat` for a cold Session; it does not activate an Agent, read the event body, or borrow a parent Session's root. Session persistence is optional for live reads, but without it a cold Session cannot resolve and the Gateway returns `gateway/lookup-not-found`. +Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and the Typert Gateway; the bundle does so right after the Session Controller. Every method takes the Session identity on the wire, so a Client calls `remote.workspaceFiles.read(sessionId, path, range, signal)`, `stat(sessionId, path, signal)`, `readBytes(sessionId, path, options, signal)`, `list(sessionId, path, signal)`, or `changes(sessionId, path, signal)` and never names a root itself. The Host reads a live Session header or uses persistence `stat` for a cold Session; it does not activate an Agent, read the event body, or borrow a parent Session's root. Session persistence is optional for live reads, but without it a cold Session cannot resolve and the Gateway returns `gateway/lookup-not-found`. | Method | Returns | Purpose | |---|---|---| | `stat(path)` | `WorkspaceFileStat { absolutePath, version, bytes? }` | Identity, version, and size of one regular file, without content | | `read(path, { offset?, limit? })` | `WorkspaceFileText` = stat + `{ offset, text, lines, eof }` | One window of lines from a UTF-8 text file; `lines` counts them, so one empty line and a page past the end read differently | -| `readBytes(path, { offset?, length? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | One window of raw bytes from any regular file, base64-encoded | -| `readAll(path)` | `WorkspaceFileBytes` with `offset: 0`, `eof: true` | Complete raw bytes under `maxFileBytes`; oversized files fail instead of being truncated | -| `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Complete bytes of a file resolved from the base file's directory on the Host | +| `readBytes(path, { range?, baseFile? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | Complete file or bounded byte range as `Uint8Array`; optionally resolve from another file's directory | | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | Direct children of one directory | | `changes(path)` | stream of `WorkspaceFileWatchFrame` | Subscription readiness, then invalidations of one file or a directory's direct entries | ### Addressing and paths -`read`, `readBytes`, `readAll`, `readRelated`, and `stat` accept an absolute path or one relative to the selected Session's workspace root. The composed filesystem decides whether the path is readable; the service does not impose workspace containment on file reads. `readRelated` resolves a relative filesystem path from the base file's directory, including when either file is outside the workspace. These methods report the file's absolute path in the filesystem's execution world. `list` remains workspace-scoped and reports the listed directory relative to that root. `changes` uses the same path resolution: file watches follow file-read authority, while directory watches remain workspace-scoped. +`read`, `readBytes`, and `stat` accept an absolute path or one relative to the selected Session's workspace root. The composed filesystem decides whether the path is readable; the service does not impose workspace containment on file reads. `readBytes` with `options.baseFile` resolves the relative target `path` from that file's directory, including when either file is outside the workspace. The base file itself accepts an absolute or workspace-relative path. Both files receive the same regular-file checks; an empty target, absolute target, URL, or NUL-containing target is rejected. File results report the absolute path in the filesystem's execution world. `list` remains workspace-scoped and reports the listed directory relative to that root. `changes` uses the same path resolution: file watches follow file-read authority, while directory watches remain workspace-scoped. ### Pages `read` returns one line window, never the whole file. `range.offset` is the 1-based first line and defaults to 1; `range.limit` is the largest number of lines on the page and defaults to `maxLines`, which it may not exceed — a larger limit, or an offset or limit that is not a positive integer, is a `gateway/bad-request`. Lines end at `\n`, and a final `\n` terminates the last line rather than starting an empty one, so a two-line file has two lines. The page's `text` joins its lines with `\n` and carries no terminator after the last; `eof` is true when the page includes the file's last line, and an offset past the end returns an empty page with `eof` true. Every page also carries the file's `version` from the stat that preceded it, so a consumer can tell a fresh page from a stale one, and `bytes`, the complete file's size when the backend reports it. The service reads the file only up to the first character past the page, so a very large file costs one page of memory per request. -### Byte windows +### Binary reads -`read` pages by lines and never by bytes; a byte window is `readBytes`. `range.offset` is the 0-based first byte and defaults to 0; `range.length` is the largest number of bytes in the window and defaults to `maxBytes`, which it may not exceed — a longer window fails with `too-large` instead of arriving shortened, and an offset or length that is not an integer in range is a `gateway/bad-request`. The window comes back as base64 `data`, shorter than `length` at the end of the file and empty at or past it; `eof` is true when the window includes the file's last byte. Nothing is decoded and nothing is refused as binary, so an image or a NUL-laden file reads where `read` fails with `not-text`. The same `version` and `bytes` ride along as on a page. +`read` pages by lines and never by bytes; a byte window is `readBytes(path, { range: { offset?, length? } })`. `options.range.offset` is the 0-based first byte and defaults to 0; `options.range.length` is the largest number of bytes in the window and defaults to `maxBytes`, which it may not exceed — a longer window fails with `too-large` instead of arriving shortened, and an offset or length that is not an integer in range is a `gateway/bad-request`. The window comes back as `Uint8Array` data, shorter than `length` at the end of the file and empty at or past it; `eof` is true when the window includes the file's last byte. Nothing is decoded and nothing is refused as binary, so an image or a NUL-laden file reads where `read` fails with `not-text`. The same `version` and `bytes` ride along as on a page. + +Pass the required options object as `{}` for a complete file under `maxFileBytes`, or `{ range: {} }` for the default byte window under `maxBytes`. Oversized complete files fail rather than arriving truncated. `baseFile` and `range` can be combined, and windows remain independent of the complete-file cap. The binary Remote uses Connection's multipart response with JSON metadata and raw bytes; the generated Client returns `Uint8Array` without base64 decoding. Caller cancellation reaches the filesystem. The endpoint returns data, not a navigable document. ### File-read and directory checks @@ -62,7 +62,7 @@ File reads and directory listing first use `lstat` to reject a missing path, a f | Field | Default | Meaning | |---|---|---| | `maxBytes` | `2097152` (2 MiB) | Inclusive byte cap on one page's text and on one byte window; a larger page or window fails | -| `maxFileBytes` | `33554432` (32 MiB) | Inclusive complete-file cap for `readAll` and `readRelated`; larger files fail with `too-large` | +| `maxFileBytes` | `33554432` (32 MiB) | Inclusive complete-file cap for `readBytes` without `range`; larger files fail with `too-large` | | `maxLines` | `5000` | Default and largest page size in lines; a larger `limit` is refused | | `maxEntries` | `2000` | Cap on returned directory entries; the rest is dropped and reported cut | @@ -74,7 +74,7 @@ Each failure is one `RemoteError` code with typed details, declared in [`src/typ ### Client file resources -The browser export registers the `file` provider into `ctx.resources` and requires `resources`, `remote`, and `remote.workspaceFiles`. The bundle's single `workspace-files` row supplies both faces; the Client has no separate configuration. A component reads `WorkspaceFileStat { absolutePath, version, bytes? }` metadata through `useResource<'file'>(address)` and fetches content separately through Remote reads. Any UI, including Global components, shares the observation for the same complete address. +The browser export registers the `file` provider into `ctx.resources` and requires `resources`, `remote`, and `remote.workspaceFiles`. The bundle's single `workspace-files` row supplies both faces; the Client has no separate configuration. A component reads `WorkspaceFileStat { absolutePath, version, bytes? }` metadata through `useResource<'file'>(address)` and fetches content separately through the text or binary Remote. Any UI, including Global components, shares the observation for the same complete address. A `session//` address carries the authorizing Session and a relative or absolute path; leading slashes are preserved, as in `dsh-resource://file/session/s//etc/hosts`. The Host receives the path unchanged and owns resolution and access checks; the Client needs no Session `cwd`. `absolute/` remains parseable but has no authorizing Session and fails with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Unsupported addresses fail with `workspace-file/unsupported-address`. [Workspace-path](../../util/workspace-path/README.md) owns the grammar; the generic Resource layer knows only the address and `signal`. @@ -94,13 +94,13 @@ One supervised `changes` stream serves each Session and requested path; consumer Reads through `ctx.fs` use the backend's read authority; the sandboxing backend fences writes and edits, not reads. A Typert lookup derives `WorkspaceFileScope` from a live Session header or the persistence service's header-only `stat`, so cold subagent Sessions need neither Agent activation nor event-body reads. The service adds regular-file checks and bounded transfer, while workspace containment belongs only to directory listing and directory watching. A page is cut from `streamText`, which decodes and rejects non-UTF-8 chunk by chunk: the cutter counts lines before the window without keeping them, admits each in-window segment against the byte cap before buffering it, and returns at the first character past the window. One `stat` before the stream names the version and size the page reports. -Complete-file reads delegate size enforcement to `fs.readBytes` and encode the returned bytes as base64 for Remote responses. +Complete-file reads delegate size enforcement to `fs.readBytes` and return raw bytes through the binary Remote. ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`: the `workspaceFiles` service and Remote namespace, `Config`, the gates, the page cutter, `read`, `readBytes`, `readAll`, `readRelated`, `stat`, `list` | +| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`: the `workspaceFiles` service and Remote namespace, `Config`, the gates, the page cutter, `read`, `readBytes`, `stat`, `list` | | [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`: target watchers, matching `fs/observed` notifications, and one queue per generation | | [`src/types.ts`](src/types.ts) | Wire types and the `RemoteErrorDetailsMap` codes, published as `./types` for Client packages | | [`src/client/index.ts`](src/client/index.ts), [`provider.ts`](src/client/provider.ts), [`change-feed.ts`](src/client/change-feed.ts) | Browser plugin, file metadata, and per-target change feeds | diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md index c913145966..572b83ec37 100644 --- a/packages/api/workspace-files/README.zh.md +++ b/packages/api/workspace-files/README.zh.md @@ -25,29 +25,29 @@ kind: "package-reference" ## 使用本包 -把本包与 `dsh-fs`、`dsh-sandbox-policy`、Session store 和 Typert Gateway 一起挂载;bundle 把它紧随 Session Controller 之后挂载。每个方法都在线路上携带 Session 身份,Client 调用 `remote.workspaceFiles.read(sessionId, path, range, signal)`、`stat(sessionId, path, signal)`、`readBytes(sessionId, path, range, signal)`、`list(sessionId, path, signal)` 或 `changes(sessionId, path, signal)`,从不自己指定根。Host 读取 live Session header,cold Session 则使用持久层 `stat`;它不会激活 Agent、读取事件正文或借用父 Session 的根。live 读取不要求挂载 Session persistence;未挂载时 cold Session 无法解析,Gateway 返回 `gateway/lookup-not-found`。 +把本包与 `dsh-fs`、`dsh-sandbox-policy`、Session store 和 Typert Gateway 一起挂载;bundle 把它紧随 Session Controller 之后挂载。每个方法都在线路上携带 Session 身份,Client 调用 `remote.workspaceFiles.read(sessionId, path, range, signal)`、`stat(sessionId, path, signal)`、`readBytes(sessionId, path, options, signal)`、`list(sessionId, path, signal)` 或 `changes(sessionId, path, signal)`,从不自己指定根。Host 读取 live Session header,cold Session 则使用持久层 `stat`;它不会激活 Agent、读取事件正文或借用父 Session 的根。live 读取不要求挂载 Session persistence;未挂载时 cold Session 无法解析,Gateway 返回 `gateway/lookup-not-found`。 | 方法 | 返回 | 用途 | |---|---|---| | `stat(path)` | `WorkspaceFileStat { absolutePath, version, bytes? }` | 一个普通文件的身份、版本与大小,不含内容 | | `read(path, { offset?, limit? })` | `WorkspaceFileText` = stat + `{ offset, text, lines, eof }` | UTF-8 文本文件的一个行窗口;`lines` 计行数,使单个空行与越过文件末尾的页可区分 | -| `readBytes(path, { offset?, length? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | 任意普通文件的一个原始字节窗口,base64 编码 | -| `readAll(path)` | `WorkspaceFileBytes`,其中 `offset: 0`、`eof: true` | `maxFileBytes` 内的完整原始字节;超大文件失败,不截断 | -| `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Host 从基文件目录解析出的文件的完整字节 | +| `readBytes(path, { range?, baseFile? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | 以 `Uint8Array` 返回完整文件或有界字节范围;可从另一个文件所在目录解析目标 | | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | 一个目录的直接子项 | | `changes(path)` | `WorkspaceFileWatchFrame` 流 | 订阅就绪确认,随后为单个文件或目录直接子项的失效通知 | ### 寻址与路径 -`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 接受绝对路径或相对于所选 Session 工作区根的路径。组合文件系统决定路径是否可读;本服务不额外要求文件读取限定于工作区。`readRelated` 从基文件所在目录解析相对文件系统路径,基文件或目标文件位于工作区外时同样适用。这些方法以文件系统执行环境中的绝对路径报告文件。`list` 仍限定于工作区,并以相对于该根的路径报告被列举目录。`changes` 使用相同的路径解析:文件监听沿用文件读取权限,目录监听仍限定于工作区。 +`read`、`readBytes` 与 `stat` 接受绝对路径或相对于所选 Session 工作区根的路径。组合文件系统决定路径是否可读;本服务不额外要求文件读取限定于工作区。`readBytes` 携带 `options.baseFile` 时,从基准文件所在目录解析相对目标 `path`,基准文件或目标文件位于工作区外时同样适用。基准文件本身接受绝对路径或工作区相对路径。两个文件执行相同的普通文件检查;目标为空、绝对路径、URL 或含 NUL 时拒绝。文件结果报告文件系统执行环境中的绝对路径。`list` 仍限定于工作区,并以相对于该根的路径报告被列举目录。`changes` 使用相同的路径解析:文件监听沿用文件读取权限,目录监听仍限定于工作区。 ### 分页 `read` 返回一个行窗口,绝不返回整个文件。`range.offset` 是 1 起算的首行,缺省为 1;`range.limit` 是该页最多的行数,缺省为 `maxLines` 且不得超过它——更大的 limit,或不是正整数的 offset / limit,都是 `gateway/bad-request`。行以 `\n` 结束,末尾的 `\n` 是最后一行的终止符而不是再起一空行,所以两行文件就是两行。页的 `text` 以 `\n` 连接各行,最后一行之后不带终止符;`eof` 在该页含文件最后一行时为 true,offset 越过末尾则返回空页且 `eof` 为 true。每页还带上前置 stat 得到的文件 `version`,消费方据此分辨新页与旧页,以及 `bytes`——后端能报告时的整文件大小。服务只把文件读到该页之后的第一个字符为止,所以再大的文件每次请求也只占一页内存。 -### 字节窗口 +### 二进制读取 -`read` 按行分页,绝不按字节;字节窗口走 `readBytes`。`range.offset` 是 0 起算的首字节,缺省为 0;`range.length` 是窗口最多的字节数,缺省为 `maxBytes` 且不得超过它——更长的窗口以 `too-large` 失败而不是被截短,不是整数或越界的 offset / length 则是 `gateway/bad-request`。窗口以 base64 的 `data` 返回,到文件末尾时短于 `length`,位于或越过末尾时为空;窗口含文件最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝,因此图片或含 NUL 的文件在 `read` 以 `not-text` 失败之处仍可读出。与页一样附带同一 `version` 与 `bytes`。 +`read` 按行分页,绝不按字节;字节窗口走 `readBytes(path, { range: { offset?, length? } })`。`options.range.offset` 是 0 起算的首字节,缺省为 0;`options.range.length` 是窗口最多的字节数,缺省为 `maxBytes` 且不得超过它——更长的窗口以 `too-large` 失败而不是被截短,不是整数或越界的 offset / length 则是 `gateway/bad-request`。窗口以 `Uint8Array` 类型的 `data` 返回,到文件末尾时短于 `length`,位于或越过末尾时为空;窗口含文件最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝,因此图片或含 NUL 的文件在 `read` 以 `not-text` 失败之处仍可读出。与页一样附带同一 `version` 与 `bytes`。 + +必填的选项对象传 `{}` 时,在 `maxFileBytes` 上限内读取完整文件;传 `{ range: {} }` 时,在 `maxBytes` 上限内读取默认字节窗口。超大完整文件失败,不截断。`baseFile` 与 `range` 可以组合,窗口始终不受完整文件上限约束。二进制 Remote 使用 Connection 的 multipart 响应传递 JSON 元数据与原始字节;生成的 Client 直接返回 `Uint8Array`,无需 base64 解码。调用方取消会传到文件系统。端点返回数据,不提供可导航的文档。 ### 文件读取与目录检查 @@ -62,7 +62,7 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| | `maxBytes` | `2097152`(2 MiB) | 单页文本与单个字节窗口的字节上限(含);更大的页或窗口失败 | -| `maxFileBytes` | `33554432`(32 MiB) | `readAll` 和 `readRelated` 的完整文件字节上限(含);更大文件以 `too-large` 失败 | +| `maxFileBytes` | `33554432`(32 MiB) | `readBytes` 未传 `range` 时的完整文件字节上限(含);更大文件以 `too-large` 失败 | | `maxLines` | `5000` | 页大小的缺省值与上限(行);更大的 `limit` 被拒绝 | | `maxEntries` | `2000` | 返回目录条目数上限;其余丢弃并报告截断 | @@ -74,7 +74,7 @@ kind: "package-reference" ### Client 文件资源 -浏览器导出向 `ctx.resources` 注册 `file` 提供方,要求 `resources`、`remote` 和 `remote.workspaceFiles` 在场。bundle 中单个 `workspace-files` 条目供应两面;Client 没有单独配置。组件通过 `useResource<'file'>(address)` 读取 `WorkspaceFileStat { absolutePath, version, bytes? }` 元数据,内容另经 Remote 读取。任何 UI(包括 Global)访问同一完整地址都共享观察。 +浏览器导出向 `ctx.resources` 注册 `file` 提供方,要求 `resources`、`remote` 和 `remote.workspaceFiles` 在场。bundle 中单个 `workspace-files` 条目供应两面;Client 没有单独配置。组件通过 `useResource<'file'>(address)` 读取 `WorkspaceFileStat { absolutePath, version, bytes? }` 元数据,文本与字节内容另经对应 Remote 读取。任何 UI(包括 Global)访问同一完整地址都共享观察。 `session//` 地址携带授权 Session,以及相对或绝对路径;前导斜杠保留,例如 `dsh-resource://file/session/s//etc/hosts`。Host 原样接收路径,负责解析与权限检查;Client 不需要 Session `cwd`。`absolute/` 仍可解析,但没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。不支持的地址以 `workspace-file/unsupported-address` 失败。语法由 [workspace-path](../../util/workspace-path/README.zh.md) 定义;Resource 泛型层只认地址和 `signal`。 @@ -94,13 +94,13 @@ kind: "package-reference" 经 `ctx.fs` 的读取使用后端的读取权限;沙箱后端限制写与编辑,而不限制读取。Typert lookup 从 live Session header 或持久层的 header-only `stat` 导出 `WorkspaceFileScope`,所以 cold subagent Session 不需要激活 Agent 或读取事件正文。本服务增加普通文件检查与有界传输,工作区包含要求只属于目录列举与目录监听。页从 `streamText` 切出,后者逐块解码并拒绝非 UTF-8:切页器对窗口之前的行只计数不保留,对窗口内的每个片段先按字节上限验收再缓冲,并在窗口之后的第一个字符处返回。流之前的一次 `stat` 给出页所报告的版本与大小。 -完整文件读取将大小上限检查交给 `fs.readBytes`,并将返回的字节编码为 base64,供 Remote 响应使用。 +完整文件读取将大小上限检查交给 `fs.readBytes`,并通过二进制 Remote 返回原始字节。 ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`:`workspaceFiles` 服务与 Remote 命名空间、`Config`、四道关、切页器、`read`、`readBytes`、`readAll`、`readRelated`、`stat`、`list` | +| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`:`workspaceFiles` 服务与 Remote 命名空间、`Config`、四道关、切页器、`read`、`readBytes`、`stat`、`list` | | [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`:目标监听、匹配的 `fs/observed` 通知与每代流各自的队列 | | [`src/types.ts`](src/types.ts) | 线路类型与 `RemoteErrorDetailsMap` 错误码,以 `./types` 发布给 Client 包 | | [`src/client/index.ts`](src/client/index.ts)、[`provider.ts`](src/client/provider.ts)、[`change-feed.ts`](src/client/change-feed.ts) | 浏览器插件、文件元数据与按目标建立的变更流 | diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index 73df728df4..480f133ed8 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -63,6 +63,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-resources": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts index f9807ea534..c5d1542593 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -32,6 +32,7 @@ import { Remote, RemoteError, TypertRemoteService, type TypertLookup } from '@de import { WorkspaceChangeFeed } from './changes.ts' import type { WorkspaceByteRange, + WorkspaceByteReadOptions, WorkspaceDirectoryEntry, WorkspaceDirectoryListing, WorkspaceFileBytes, @@ -245,38 +246,29 @@ export class WorkspaceFiles extends TypertRemoteService { } /** - * Read one byte window of a regular file readable by the filesystem backend: raw - * bytes, no text decoding and no binary rejection. + * Read a complete regular file or one byte range without text decoding. * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - absolute path or path relative to the workspace root; files outside it are allowed. - * @param range - the byte window; omitted fields take the window defaults. + * @param path - target path, absolute or workspace-relative; relative to the base file's directory when provided. + * @param options - optional base file and range; without a range the complete-file cap applies. * @param signal - caller cancellation. - * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte. + * @returns native bytes with the file's version and size at the preceding stat, byte offset, and EOF marker. */ @Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, - range: WorkspaceByteRange, + options: WorkspaceByteReadOptions, signal: AbortSignal, ): Promise { - const { offset, length } = this.resolveWindow(range, path) - const { target, info } = await this.locateFile(workspaceFileScope, path, signal) - const data = await this.ctx.fs.readByteRange(target, { offset, length }, signal) - const eof = info.size === undefined ? data.length < length : offset + data.length >= info.size - return { ...this.statOf(target, info), offset, data: Buffer.from(data).toString('base64'), eof } - } - - /** - * Read a complete regular file as bytes, subject to the configured full-file cap. - * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - absolute or workspace-relative file path. - * @param signal - caller cancellation. - * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large. - */ - @Remote - async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise { - const { target, info } = await this.locateFile(workspaceFileScope, path, signal) + const window = options.range === undefined ? undefined : this.resolveWindow(options.range, path) + const resolved = options.baseFile === undefined ? path : await this.relativePath(workspaceFileScope, options.baseFile, path, signal) + const { target, info } = await this.locateFile(workspaceFileScope, resolved, signal) + if (window !== undefined) { + const { offset, length } = window + const data = await this.ctx.fs.readByteRange(target, window, signal) + const eof = info.size === undefined ? data.length < length : offset + data.length >= info.size + return { ...this.statOf(target, info), offset, data, eof } + } const limit = this.config.maxFileBytes const data = await this.ctx.fs.readBytes(target, signal, limit).catch((cause: unknown) => { if (typeof cause === 'object' && cause !== null && 'code' in cause && cause.code === 'FS_TOO_LARGE') { @@ -284,32 +276,7 @@ export class WorkspaceFiles extends TypertRemoteService { } throw cause }) - return { ...this.statOf(target, info), offset: 0, data: Buffer.from(data).toString('base64'), eof: true } - } - - /** - * Read a complete file relative to another file's directory, including outside the workspace. - * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - base file, absolute or workspace-relative. - * @param relativePath - relative filesystem path, not a URL or absolute path. - * @param signal - caller cancellation. - * @returns the complete related file using the ordinary file-size and access checks. - */ - @Remote - async readRelated( - workspaceFileScope: WorkspaceFileScope, - path: string, - relativePath: string, - signal: AbortSignal, - ): Promise { - const relative = relativePath.replace(/\\/g, '/') - if (relative.length === 0 || relative.startsWith('/') || /^[a-z][a-z\d+.-]*:/iu.test(relative) || relative.includes(NUL)) { - throw new RemoteError('gateway/bad-request', 'relativePath must be a relative filesystem path', {}) - } - const { target } = await this.locateFile(workspaceFileScope, path, signal) - const absolute = this.ctx.fs.processPath(target) - const paths = absolute.startsWith('/') ? posix : win32 - return this.readAll(workspaceFileScope, paths.resolve(paths.dirname(absolute), relative), signal) + return { ...this.statOf(target, info), offset: 0, data, eof: true } } /** @@ -365,6 +332,17 @@ export class WorkspaceFiles extends TypertRemoteService { return this.feed.follow(workspaceFileScope.workspaceRoot, path, signal) } + private async relativePath(scope: WorkspaceFileScope, baseFile: string, path: string, signal: AbortSignal): Promise { + const relative = path.replace(/\\/g, '/') + if (relative.length === 0 || relative.startsWith('/') || /^[a-z][a-z\d+.-]*:/iu.test(relative) || relative.includes(NUL)) { + throw new RemoteError('gateway/bad-request', 'path must be relative when baseFile is provided', {}) + } + const { target } = await this.locateFile(scope, baseFile, signal) + const absolute = this.ctx.fs.processPath(target) + const paths = absolute.startsWith('/') ? posix : win32 + return paths.resolve(paths.dirname(absolute), relative) + } + /** Apply the page defaults and caps here, so the request never carries them implicitly. */ private resolvePage(range: WorkspaceFileRange): { offset: number; limit: number } { const offset = range.offset === undefined ? 1 : integerAtLeast(range.offset, 1, 'offset') diff --git a/packages/api/workspace-files/src/types.ts b/packages/api/workspace-files/src/types.ts index 184767f10e..ea6bc2c086 100644 --- a/packages/api/workspace-files/src/types.ts +++ b/packages/api/workspace-files/src/types.ts @@ -69,15 +69,23 @@ export interface WorkspaceByteRange { readonly length?: number } +/** Target resolution and optional byte range for one binary file read. */ +export interface WorkspaceByteReadOptions { + /** Byte window; omit to read the complete file under the configured `maxFileBytes` cap. */ + readonly range?: WorkspaceByteRange + /** Base file, absolute or workspace-relative; resolve the relative target from its directory. */ + readonly baseFile?: string +} + /** * One byte window of a workspace file as a Client reads it: raw bytes, no text * decoding and no binary rejection. `bytes` is the complete file's size. */ -export interface WorkspaceFileBytes extends WorkspaceFileStat { +export interface WorkspaceFileBytes extends WorkspaceFileStat { /** First byte of the window, as requested. */ readonly offset: number - /** The window's bytes in base64; empty when `offset` lies at or past the file's end. */ - readonly data: string + /** Native file bytes; empty at or past EOF. */ + readonly data: Data /** Whether the window includes the file's last byte. */ readonly eof: boolean } diff --git a/packages/api/workspace-files/tests/binary-rpc.spec.ts b/packages/api/workspace-files/tests/binary-rpc.spec.ts new file mode 100644 index 0000000000..35058d58a1 --- /dev/null +++ b/packages/api/workspace-files/tests/binary-rpc.spec.ts @@ -0,0 +1,93 @@ +/** Workspace byte reads traverse Gateway lookup and Connection's binary RPC response. */ +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { beforeEach, afterEach, expect, it, vi } from 'vitest' +import SessionStore from '@deepseek-ai/dsh-session' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import TypertGateway from '@deepseek-ai/dsh-api-gateway' +import { HostConnectionService } from '@deepseek-ai/dsh-client-connection/src/rpc-host.ts' +import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' +import { createWebConnectionRpc } from '@deepseek-ai/dsh-client-connection/src/client/rpc.ts' +import WorkspaceFiles, { type WorkspaceByteReadOptions } from '../src/index.ts' +import { openWorkspace, type Harness } from './harness.ts' + +let harness: Harness +let rpc: ReturnType + +beforeEach(async () => { + harness = await openWorkspace('dsh-workspace-binary-rpc-') + const ctx = harness.ctx + await ctx.plugin(SessionStore) + await ctx.plugin(TypertRegistry) + await ctx.plugin(TypertGateway) + await ctx.plugin((scope) => { new HostConnectionService(scope, [], {} as BrowserAuth) }) + await ctx.plugin(WorkspaceFiles, { maxBytes: 8, maxFileBytes: 4, maxLines: 100, maxEntries: 100 }) + ctx.sessions.create(harness.scope.sessionId, { meta: { cwd: harness.workspace, origin: 'subagent' } }) + const handler = (ctx.get('connection') as HostConnectionService).createSharedFetchHandler('/api') + rpc = createWebConnectionRpc(async (path, init) => handler.fetch(new Request(new URL(path, 'http://host'), init))) +}) + +afterEach(async () => { + await harness.ctx.fiber.dispose() + await harness.dispose() +}) + +function read(path: string, options: WorkspaceByteReadOptions, signal = new AbortController().signal) { + return rpc.call('/api', 'workspaceFiles/readBytes', { args: { workspaceFileScopeId: harness.scope.sessionId, path, options } }, signal) +} + +it('returns complete binary contents through the ordinary Remote endpoint', async () => { + const data = new Uint8Array([0, 128, 255, 1]) + await writeFile(join(harness.workspace, '字节.bin'), data) + expect(await read('字节.bin', {})).toMatchObject({ ok: true, value: { offset: 0, eof: true, bytes: 4, data } }) + expect(harness.ctx.get('agents')).toBeUndefined() +}) + +it.each([ + [{}, [0, 1, 2, 3, 4, 5, 6, 7], 0, false], + [{ offset: 7, length: 8 }, [7, 8, 9], 7, true], + [{ offset: 10 }, [], 10, true], +])('preserves range %j without reading the whole oversized file', async (range, data, offset, eof) => { + await writeFile(join(harness.workspace, 'base.html'), 'base') + await writeFile(join(harness.workspace, 'large.bin'), new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])) + const complete = vi.spyOn(harness.ctx.fs, 'readBytes') + const window = vi.spyOn(harness.ctx.fs, 'readByteRange') + expect(await read('large.bin', { baseFile: 'base.html', range })).toMatchObject({ + ok: true, value: { offset, eof, bytes: 10, data: new Uint8Array(data) }, + }) + expect(window).toHaveBeenCalledExactlyOnceWith(expect.anything(), { offset, length: 8 }, expect.any(AbortSignal)) + expect(complete).not.toHaveBeenCalled() +}) + +it('preserves full-file size failures and Gateway Session lookup failures', async () => { + await writeFile(join(harness.workspace, 'large'), '12345') + expect(await read('large', {})).toMatchObject({ ok: false, error: { code: 'workspace-file/too-large', details: { path: 'large', limit: 4 } } }) + expect(await rpc.call('/api', 'workspaceFiles/readBytes', { args: { workspaceFileScopeId: 'missing', path: 'large', options: {} } })) + .toMatchObject({ ok: false, error: { code: 'gateway/lookup-not-found' } }) +}) + +it.each([{}, { range: {} }])('propagates cancellation into the filesystem for options %j', async (options) => { + await writeFile(join(harness.workspace, 'file'), 'data') + const entered = Promise.withResolvers() + const controller = new AbortController() + const abortable = async (signal: AbortSignal | undefined): Promise => { + if (signal === undefined) throw new Error('read did not receive cancellation') + entered.resolve(undefined) + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(signal.reason as Error) }, { once: true }) + }) + } + if ('range' in options) vi.spyOn(harness.ctx.fs, 'readByteRange').mockImplementation((_target, _range, signal) => abortable(signal)) + else vi.spyOn(harness.ctx.fs, 'readBytes').mockImplementation((_target, signal) => abortable(signal)) + const failure = new Error('tab closed') + const pending = read('file', options, controller.signal) + const rejected = expect(pending).rejects.toBe(failure) + try { + await entered.promise + controller.abort(failure) + await rejected + } finally { + controller.abort(failure) + await rejected + } +}) diff --git a/packages/api/workspace-files/tests/read-all.spec.ts b/packages/api/workspace-files/tests/read-all.spec.ts index 21d75bd849..cd5d3b9d73 100644 --- a/packages/api/workspace-files/tests/read-all.spec.ts +++ b/packages/api/workspace-files/tests/read-all.spec.ts @@ -1,6 +1,6 @@ /** Full-file reads retain the ordinary file gates and never return a silently truncated result. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdir, symlink, writeFile } from 'node:fs/promises' +import { mkdir, realpath, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { FsVersion } from '@deepseek-ai/dsh-fs' import { failureOf, openWorkspace, signal, type Harness } from './harness.ts' @@ -10,31 +10,33 @@ let harness: Harness beforeEach(async () => { harness = await openWorkspace('dsh-workspace-files-read-all-') }) afterEach(async () => { await harness.dispose() }) -describe('workspaceFiles.readAll', () => { +describe('workspaceFiles.readBytes — complete files', () => { it('reads the complete bytes independently of the window cap', async () => { const bytes = Buffer.from([0, 255, 1, 2]) await writeFile(join(harness.workspace, 'file.bin'), bytes) - const result = await harness.endpoint({ maxBytes: 1, maxFileBytes: 4 }).readAll(harness.scope, 'file.bin', signal()) - expect(Buffer.from(result.data, 'base64')).toEqual(bytes) + const result = await harness.endpoint({ maxBytes: 1, maxFileBytes: 4 }).readBytes(harness.scope, 'file.bin', {}, signal()) + expect(Buffer.from(result.data)).toEqual(bytes) expect(result).toMatchObject({ offset: 0, eof: true, bytes: 4 }) }) it('returns an empty complete file', async () => { await writeFile(join(harness.workspace, 'empty'), '') - expect(await harness.endpoint().readAll(harness.scope, 'empty', signal())).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 }) + const result = await harness.endpoint().readBytes(harness.scope, 'empty', {}, signal()) + expect(result.data.byteLength).toBe(0) + expect(result).toMatchObject({ offset: 0, eof: true, bytes: 0 }) }) it.each(['workspace', 'outside'] as const)('maps the filesystem size refusal for a %s file', async (location) => { const path = join(harness[location], 'large') await writeFile(path, 'abcde') - expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, path, signal()))) + expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readBytes(harness.scope, path, {}, signal()))) .toEqual({ code: 'workspace-file/too-large', details: { path, limit: 4 } }) }) it.each([undefined, 1])('checks the actual bytes when stat reports %s', async (size) => { await writeFile(join(harness.workspace, 'growing'), 'abcde') vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ type: 'file', version: FsVersion('v'), ...size === undefined ? {} : { size } }) - expect((await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, 'growing', signal()))).code) + expect((await failureOf(harness.endpoint({ maxFileBytes: 4 }).readBytes(harness.scope, 'growing', {}, signal()))).code) .toBe('workspace-file/too-large') }) @@ -42,17 +44,17 @@ describe('workspaceFiles.readAll', () => { await mkdir(join(harness.workspace, 'directory')) await writeFile(join(harness.outside, 'outside'), 'outside') const files = harness.endpoint() - expect((await failureOf(files.readAll(harness.scope, 'missing', signal()))).code).toBe('workspace-file/not-found') - expect((await failureOf(files.readAll(harness.scope, 'directory', signal()))).code).toBe('workspace-file/not-regular-file') - const outside = await files.readAll(harness.scope, join(harness.outside, 'outside'), signal()) - expect(Buffer.from(outside.data, 'base64').toString()).toBe('outside') + expect((await failureOf(files.readBytes(harness.scope, 'missing', {}, signal()))).code).toBe('workspace-file/not-found') + expect((await failureOf(files.readBytes(harness.scope, 'directory', {}, signal()))).code).toBe('workspace-file/not-regular-file') + const outside = await files.readBytes(harness.scope, join(harness.outside, 'outside'), {}, signal()) + expect(Buffer.from(outside.data).toString()).toBe('outside') }) it.each([new Error('Read denied'), new DOMException('Cancelled', 'AbortError'), null, 'backend failure', { code: 'FS_NOT_FOUND' }])( 'preserves non-size filesystem failures: %s', async (failure) => { await writeFile(join(harness.workspace, 'file'), '1234') vi.spyOn(harness.ctx.fs, 'readBytes').mockRejectedValueOnce(failure) - await expect(harness.endpoint().readAll(harness.scope, 'file', signal())).rejects.toBe(failure) + await expect(harness.endpoint().readBytes(harness.scope, 'file', {}, signal())).rejects.toBe(failure) }, ) @@ -60,39 +62,39 @@ describe('workspaceFiles.readAll', () => { await writeFile(join(harness.workspace, 'file'), '1234') const failure = { code: 'FS_TOO_LARGE' } vi.spyOn(harness.ctx.fs, 'readBytes').mockRejectedValueOnce(failure) - await expect(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, 'file', signal())) + await expect(harness.endpoint({ maxFileBytes: 4 }).readBytes(harness.scope, 'file', {}, signal())) .rejects.toMatchObject({ code: 'workspace-file/too-large', details: { path: 'file', limit: 4 }, cause: failure }) }) }) -describe('workspaceFiles.readRelated', () => { +describe('workspaceFiles.readBytes — base file', () => { it('resolves relative paths from the base file directory on the Host', async () => { await mkdir(join(harness.workspace, 'nested')) await writeFile(join(harness.workspace, 'nested/base.txt'), 'base') await writeFile(join(harness.workspace, 'nested/near.txt'), 'near') await writeFile(join(harness.workspace, 'root.txt'), 'root') const files = harness.endpoint() - const near = await files.readRelated(harness.scope, 'nested/base.txt', './near.txt', signal()) - const root = await files.readRelated(harness.scope, 'nested/base.txt', '../root.txt', signal()) - expect(Buffer.from(near.data, 'base64').toString()).toBe('near') - expect(Buffer.from(root.data, 'base64').toString()).toBe('root') - const fromRoot = await files.readRelated(harness.scope, 'root.txt', 'nested\\near.txt', signal()) - expect(Buffer.from(fromRoot.data, 'base64').toString()).toBe('near') + const near = await files.readBytes(harness.scope, './near.txt', { baseFile: 'nested/base.txt' }, signal()) + const root = await files.readBytes(harness.scope, '../root.txt', { baseFile: 'nested/base.txt' }, signal()) + expect(Buffer.from(near.data).toString()).toBe('near') + expect(Buffer.from(root.data).toString()).toBe('root') + const fromRoot = await files.readBytes(harness.scope, 'nested\\near.txt', { baseFile: 'root.txt' }, signal()) + expect(Buffer.from(fromRoot.data).toString()).toBe('near') }) it.each(['', '/outside', 'C:\\outside', '\\\\host\\share', 'https://example.test/a.js', 'bad\0path'])('rejects non-relative path %j', async (path) => { - expect((await failureOf(harness.endpoint().readRelated(harness.scope, 'base', path, signal()))).code).toBe('gateway/bad-request') + expect((await failureOf(harness.endpoint().readBytes(harness.scope, path, { baseFile: 'base' }, signal()))).code).toBe('gateway/bad-request') }) it('resolves related files on either side of the workspace root', async () => { await writeFile(join(harness.workspace, 'base'), 'base') await writeFile(join(harness.outside, 'outside'), 'outside') const files = harness.endpoint() - expect((await failureOf(files.readRelated(harness.scope, 'missing', 'file', signal()))).code).toBe('workspace-file/not-found') - const fromOutside = await files.readRelated(harness.scope, join(harness.outside, 'outside'), '../workspace/base', signal()) - const toOutside = await files.readRelated(harness.scope, 'base', '../outside/outside', signal()) - expect(Buffer.from(fromOutside.data, 'base64').toString()).toBe('base') - expect(Buffer.from(toOutside.data, 'base64').toString()).toBe('outside') + expect((await failureOf(files.readBytes(harness.scope, 'file', { baseFile: 'missing' }, signal()))).code).toBe('workspace-file/not-found') + const fromOutside = await files.readBytes(harness.scope, '../workspace/base', { baseFile: join(harness.outside, 'outside') }, signal()) + const toOutside = await files.readBytes(harness.scope, '../outside/outside', { baseFile: 'base' }, signal()) + expect(Buffer.from(fromOutside.data).toString()).toBe('base') + expect(Buffer.from(toOutside.data).toString()).toBe('outside') }) it('reads sibling assets beside an outside HTML file with escaped path characters', async () => { @@ -100,8 +102,8 @@ describe('workspaceFiles.readRelated', () => { const base = join(harness.outside, 'space # assets', 'page.html') await writeFile(base, '') await writeFile(join(harness.outside, 'space # assets', 'app.js'), 'EXTERNAL_ASSET') - const result = await harness.endpoint().readRelated(harness.scope, base, './app.js', signal()) - expect(Buffer.from(result.data, 'base64').toString()).toBe('EXTERNAL_ASSET') + const result = await harness.endpoint().readBytes(harness.scope, './app.js', { baseFile: base }, signal()) + expect(Buffer.from(result.data).toString()).toBe('EXTERNAL_ASSET') }) it.each([ @@ -112,16 +114,41 @@ describe('workspaceFiles.readRelated', () => { await writeFile(join(harness.workspace, 'base'), 'base') const files = harness.endpoint() vi.spyOn(harness.ctx.fs, 'processPath').mockReturnValue(base) - const read = vi.spyOn(files, 'readAll').mockResolvedValue({ absolutePath: expected, version: 'v', offset: 0, data: '', eof: true }) + const lstat = harness.ctx.fs.lstat.bind(harness.ctx.fs) + const inspect = vi.spyOn(harness.ctx.fs, 'lstat').mockImplementation((path, options, abort) => + path === 'base' ? lstat(path, options, abort) : Promise.resolve(undefined)) const caller = signal() - await files.readRelated(harness.scope, 'base', './app.js', caller) - expect(read).toHaveBeenCalledWith(harness.scope, expected, caller) + await expect(files.readBytes(harness.scope, './app.js', { baseFile: 'base' }, caller)).rejects.toMatchObject({ code: 'workspace-file/not-found' }) + expect(inspect).toHaveBeenLastCalledWith(expected, { cwd: harness.workspace }, caller) + }) + + it('combines a base file with a bounded range without applying the complete-file cap', async () => { + await mkdir(join(harness.workspace, 'nested')) + await writeFile(join(harness.workspace, 'nested/base.html'), 'base') + await writeFile(join(harness.workspace, 'nested/asset.bin'), new Uint8Array([0, 255, 128, 4, 5, 6])) + const completeRead = vi.spyOn(harness.ctx.fs, 'readBytes') + const rangeRead = vi.spyOn(harness.ctx.fs, 'readByteRange') + const caller = signal() + const result = await harness.endpoint({ maxBytes: 2, maxFileBytes: 1 }).readBytes( + harness.scope, './asset.bin', { baseFile: 'nested/base.html', range: { offset: 1, length: 2 } }, caller, + ) + expect([...result.data]).toEqual([255, 128]) + expect(result).toMatchObject({ absolutePath: await realpath(join(harness.workspace, 'nested/asset.bin')), offset: 1, eof: false, bytes: 6 }) + expect(rangeRead).toHaveBeenCalledExactlyOnceWith(expect.anything(), { offset: 1, length: 2 }, caller) + expect(completeRead).not.toHaveBeenCalled() + }) + + it('checks the base file kind before reading the target', async () => { + await mkdir(join(harness.workspace, 'directory')) + await writeFile(join(harness.workspace, 'target'), 'target') + expect((await failureOf(harness.endpoint().readBytes(harness.scope, 'target', { baseFile: 'directory' }, signal()))).code) + .toBe('workspace-file/not-regular-file') }) it('rejects a related symlink rather than following it', async () => { await writeFile(join(harness.workspace, 'base'), 'base') await writeFile(join(harness.workspace, 'target'), 'target') await symlink('target', join(harness.workspace, 'link')) - expect((await failureOf(harness.endpoint().readRelated(harness.scope, 'base', 'link', signal()))).code).toBe('workspace-file/not-regular-file') + expect((await failureOf(harness.endpoint().readBytes(harness.scope, 'link', { baseFile: 'base' }, signal()))).code).toBe('workspace-file/not-regular-file') }) }) diff --git a/packages/api/workspace-files/tests/read-bytes.spec.ts b/packages/api/workspace-files/tests/read-bytes.spec.ts index 2af2ca9927..62cb55a218 100644 --- a/packages/api/workspace-files/tests/read-bytes.spec.ts +++ b/packages/api/workspace-files/tests/read-bytes.spec.ts @@ -17,18 +17,16 @@ afterEach(async () => { await harness.dispose() }) -const endpoint = (caps?: { maxBytes?: number }): ReturnType => harness.endpoint(caps) +const endpoint = (caps?: { maxBytes?: number; maxFileBytes?: number }): ReturnType => harness.endpoint(caps) /** 256 bytes, each equal to its offset: the window's content names its position. */ const RAMP = Buffer.from(Array.from({ length: 256 }, (_, i) => i)) -const decode = (data: string): Buffer => Buffer.from(data, 'base64') - describe('workspaceFiles.readBytes — the window', () => { it('returns the whole file as one window by default, with its absolute path, version, and size', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const result = await endpoint().readBytes(harness.scope, 'ramp.bin', {}, signal()) - expect(decode(result.data).equals(RAMP)).toBe(true) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: {} }, signal()) + expect(Buffer.from(result.data).equals(RAMP)).toBe(true) expect(result).toMatchObject({ offset: 0, eof: true, bytes: 256 }) expect(result.absolutePath.endsWith('ramp.bin')).toBe(true) expect(result.version.length).toBeGreaterThan(0) @@ -36,61 +34,68 @@ describe('workspaceFiles.readBytes — the window', () => { it('cuts the requested window and reports that more follows', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 16, length: 8 }, signal()) - expect([...decode(result.data)]).toEqual([16, 17, 18, 19, 20, 21, 22, 23]) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 16, length: 8 } }, signal()) + expect([...result.data]).toEqual([16, 17, 18, 19, 20, 21, 22, 23]) expect(result).toMatchObject({ offset: 16, eof: false, bytes: 256 }) }) - it('reads a window of a file far above the byte cap', async () => { + it('reads only a window of a file above both byte caps', async () => { await writeFile(join(workspace, 'huge.bin'), Buffer.alloc(200_000, 7)) - const result = await endpoint({ maxBytes: 1024 }).readBytes(harness.scope, 'huge.bin', { offset: 199_000, length: 1024 }, signal()) - expect(decode(result.data)).toHaveLength(1000) + const rangeRead = vi.spyOn(harness.ctx.fs, 'readByteRange') + const completeRead = vi.spyOn(harness.ctx.fs, 'readBytes') + const caller = signal() + const result = await endpoint({ maxBytes: 1024, maxFileBytes: 4 }).readBytes(harness.scope, 'huge.bin', { range: { offset: 199_000, length: 1024 } }, caller) + expect(rangeRead).toHaveBeenCalledExactlyOnceWith(expect.anything(), { offset: 199_000, length: 1024 }, caller) + expect(completeRead).not.toHaveBeenCalled() + expect(result.data).toHaveLength(1000) expect(result).toMatchObject({ eof: true, bytes: 200_000 }) }) it('infers eof from a short window when the backend reports no size', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ version: FsVersion('v-sizeless'), type: 'file' }) - const full = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 0, length: 256 }, signal()) + const full = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 0, length: 256 } }, signal()) expect(full.eof).toBe(false) - const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 250, length: 10 }, signal()) + const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 250, length: 10 } }, signal()) expect(short).toMatchObject({ eof: true }) expect(short.bytes).toBeUndefined() }) it('reports eof on the window that holds the last byte, whether or not the length is reached', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const exact = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 248, length: 8 }, signal()) + const exact = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 248, length: 8 } }, signal()) expect(exact.eof).toBe(true) - expect(decode(exact.data)).toHaveLength(8) - const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 250, length: 100 }, signal()) + expect(exact.data).toHaveLength(8) + const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 250, length: 100 } }, signal()) expect(short.eof).toBe(true) - expect([...decode(short.data)]).toEqual([250, 251, 252, 253, 254, 255]) + expect([...short.data]).toEqual([250, 251, 252, 253, 254, 255]) }) it('returns an empty eof window for an offset at or past the end', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 300, length: 8 }, signal()) - expect(result).toMatchObject({ data: '', offset: 300, eof: true, bytes: 256 }) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 300, length: 8 } }, signal()) + expect(result.data).toHaveLength(0) + expect(result).toMatchObject({ offset: 300, eof: true, bytes: 256 }) }) it('returns an empty eof window for an empty file', async () => { await writeFile(join(workspace, 'empty.bin'), Buffer.alloc(0)) - const result = await endpoint().readBytes(harness.scope, 'empty.bin', {}, signal()) - expect(result).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 }) + const result = await endpoint().readBytes(harness.scope, 'empty.bin', { range: {} }, signal()) + expect(result.data).toHaveLength(0) + expect(result).toMatchObject({ offset: 0, eof: true, bytes: 0 }) }) - it('carries bytes a text read would refuse: NUL and invalid UTF-8 round-trip through base64', async () => { + it('carries bytes a text read would refuse: NUL and invalid UTF-8 round-trip as bytes', async () => { const raw = Buffer.from([0, 0xff, 0xfe, 0x80, 0x41, 0]) await writeFile(join(workspace, 'blob.bin'), raw) - const result = await endpoint().readBytes(harness.scope, 'blob.bin', {}, signal()) - expect(decode(result.data).equals(raw)).toBe(true) + const result = await endpoint().readBytes(harness.scope, 'blob.bin', { range: {} }, signal()) + expect(Buffer.from(result.data).equals(raw)).toBe(true) }) it('names the version a stat of the same file reports', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) const stat = await endpoint().stat(harness.scope, 'ramp.bin', signal()) - const result = await endpoint().readBytes(harness.scope, 'ramp.bin', {}, signal()) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: {} }, signal()) expect(result.version).toBe(stat.version) }) }) @@ -98,22 +103,22 @@ describe('workspaceFiles.readBytes — the window', () => { describe('workspaceFiles.readBytes — defaults and cap', () => { it('defaults the length to the configured byte cap', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64)) - const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', {}, signal()) - expect(decode(result.data)).toHaveLength(64) + const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { range: {} }, signal()) + expect(result.data).toHaveLength(64) expect(result.eof).toBe(true) }) it('refuses a window longer than the cap as too-large rather than shortening it', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const failure = await failureOf(endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { length: 65 }, signal())) + const failure = await failureOf(endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { range: { length: 65 } }, signal())) expect(failure.code).toBe('workspace-file/too-large') expect(failure.details).toMatchObject({ limit: 64 }) }) it('accepts a window exactly at the cap', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64)) - const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { length: 64 }, signal()) - expect(decode(result.data)).toHaveLength(64) + const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { range: { length: 64 } }, signal()) + expect(result.data).toHaveLength(64) }) it('refuses a negative or fractional offset and a non-positive length as bad requests', async () => { @@ -124,7 +129,7 @@ describe('workspaceFiles.readBytes — defaults and cap', () => { { offset: 2 ** 53 }, { offset: Number.MAX_SAFE_INTEGER, length: 2 }, ] for (const range of ranges) { - const failure = await failureOf(endpoint().readBytes(harness.scope, 'ramp.bin', range, signal())) + const failure = await failureOf(endpoint().readBytes(harness.scope, 'ramp.bin', { range }, signal())) expect(failure.code).toBe('gateway/bad-request') } }) @@ -133,15 +138,15 @@ describe('workspaceFiles.readBytes — defaults and cap', () => { describe('workspaceFiles.readBytes — the gates it shares with read', () => { it('rejects a directory, a missing path, and an empty path', async () => { await mkdir(join(workspace, 'dir')) - expect((await failureOf(endpoint().readBytes(harness.scope, 'dir', {}, signal()))).code).toBe('workspace-file/not-regular-file') - expect((await failureOf(endpoint().readBytes(harness.scope, 'missing.bin', {}, signal()))).code).toBe('workspace-file/not-found') - expect((await failureOf(endpoint().readBytes(harness.scope, '', {}, signal()))).code).toBe('gateway/bad-request') + expect((await failureOf(endpoint().readBytes(harness.scope, 'dir', { range: {} }, signal()))).code).toBe('workspace-file/not-regular-file') + expect((await failureOf(endpoint().readBytes(harness.scope, 'missing.bin', { range: {} }, signal()))).code).toBe('workspace-file/not-found') + expect((await failureOf(endpoint().readBytes(harness.scope, '', { range: {} }, signal()))).code).toBe('gateway/bad-request') }) it('reads a bounded byte window outside the workspace', async () => { await writeFile(join(harness.outside, 'sample.bin'), RAMP) - const result = await endpoint().readBytes(harness.scope, join(harness.outside, 'sample.bin'), { offset: 2, length: 4 }, signal()) - expect(decode(result.data)).toEqual(RAMP.subarray(2, 6)) + const result = await endpoint().readBytes(harness.scope, join(harness.outside, 'sample.bin'), { range: { offset: 2, length: 4 } }, signal()) + expect(result.data).toEqual(RAMP.subarray(2, 6)) expect(result).toMatchObject({ offset: 2, eof: false }) }) }) diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index ddf0603df0..fbc282a42b 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/client/connection/README.md -README.md: 6c49836b0e581666db0df503b175132ed1e6295b -README.zh.md: 5843efd2299526cf00b0c6331668c304d6042f56 +README.md: e50f4fb6838ae27b545bd2b3c4683224f7eb37c9 +README.zh.md: 1bf0733bd660049a5b99a7e7966d65121b0dea3f diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 6c49836b0e..e50f4fb683 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -27,6 +27,8 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con A static desktop page can provide `__DSH_TRANSPORT__.streamBaseUrl` for the HTTP origin of its owned Host. The Gateway uses that origin for its WebSocket while HTTP transport remains independently selected. The desktop carrier owns authentication; setting the origin alone grants no access. +Unary RPC requests use JSON. A Host handler may return byte attachments that it has already separated from the JSON-compatible result value, with each attachment naming its result-relative path. Connection writes these values as multipart parts. The JSON `metadata` part contains the RPC response envelope with `null` placeholders and an attachment table recording each path, codec, and part identifier. Paths use string keys and numeric array indices; no business field name is reserved. The Client validates the envelope, `rpcId`, attachment table, and parts, then restores each byte value as an `ArrayBuffer`-backed view. Results without attachments, including base64 strings, and failures remain JSON. Logical RPC carriers return decoded native values directly. Connection does not discover binary fields or depend on Typert; the handler that owns a result protocol performs any type-directed or runtime projection before returning. Binary parameters, events, and streamed binary results are unsupported. + The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams, including a stream's uplink, through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). ----- @@ -42,7 +44,7 @@ Before authentication, every request still passes `src/api-request-trust.ts`. It Every admitted request speaks for one Peer, the operator. `ctx.connection.operator` is that `PeerScope`: its `ctx` is a Cordis scope that owns connection-lifetime registrations and is disposed with the Connection. `ctx.connection.admit(request)` runs the trust and authentication checks and answers with the rejection status or the operator; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. `OperatorPeer` is exported so a composition without Connection, such as the Gateway's in-process carrier, owns an operator scope with the same contract. -Authenticated shared HTTP requests pass through the `connection/request` waterfall before body transfer. A listener may refuse new requests or await `next()` through response completion; removing its owning fiber removes admission behavior. Desktop uses this hook to lock new API work during an approved installation without canceling already-admitted work. WebSocket stream ownership remains with API Gateway. +Authenticated shared HTTP requests pass through the `connection/request` waterfall before body transfer. A listener may refuse new requests or await `next()` through response completion; removing its owning fiber removes admission behavior. Desktop uses this hook to lock new API work during an approved installation without canceling already-admitted work. Client disconnection aborts the handler signal; the bridge stops socket writes and drains any remaining response chunks. WebSocket stream ownership remains with API Gateway. ## Connection generation diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 5843efd229..1bf0733bd6 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 静态桌面页面可以通过 `__DSH_TRANSPORT__.streamBaseUrl` 提供其所拥有 Host 的 HTTP origin。Gateway 将该 origin 用于 WebSocket,HTTP 传输仍独立选择。桌面载体负责认证;仅设置 origin 不会授予访问权限。 +一元 RPC 请求使用 JSON。Host handler 可以返回已经从 JSON 兼容结果值中分离的字节附件,每个附件标明其相对于结果的路径。Connection 将这些值写为 multipart 部分。JSON `metadata` 部分包含带 `null` 占位值的 RPC 响应信封,以及记录各路径、codec 和部分标识符的附件表。路径使用字符串键与数字数组下标,不保留任何业务字段名。Client 校验信封、`rpcId`、附件表和各部分,再将每个字节值恢复为以 `ArrayBuffer` 为底层缓冲区的视图。没有附件的结果(包括 base64 字符串)与失败仍使用 JSON。逻辑 RPC 载体直接返回解码后的原生值。Connection 不识别二进制字段,也不依赖 Typert;拥有结果协议的 handler 在返回前执行按类型或按运行时值的投影。不支持二进制参数、事件和二进制流式结果。 + 浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流(包括流的上行),不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 ----- @@ -42,7 +44,7 @@ cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-sessi 每个被接纳的请求都代表同一个 Peer——操作者。`ctx.connection.operator` 就是这个 `PeerScope`:其 `ctx` 是拥有连接期注册的 Cordis scope,随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或操作者作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。`OperatorPeer` 对外导出,供没有 Connection 的组合(例如 Gateway 的进程内载体)以同一约定拥有一个操作者 scope。 -通过认证的共享 HTTP 请求在传输请求体之前经过 `connection/request` waterfall。监听器可以拒绝新请求,或等待 `next()` 直到响应完成;释放所属 fiber 会移除准入行为。Desktop 使用此扩展点,在已批准的安装期间锁住新的 API 工作,而不取消已接纳的工作。WebSocket 流仍由 API Gateway 负责。 +通过认证的共享 HTTP 请求在传输请求体之前经过 `connection/request` waterfall。监听器可以拒绝新请求,或等待 `next()` 直到响应完成;释放所属 fiber 会移除准入行为。Desktop 使用此扩展点,在已批准的安装期间锁住新的 API 工作,而不取消已接纳的工作。客户端断开会中止处理函数的信号;桥接器停止写入 socket,并排空剩余响应块。WebSocket 流仍由 API Gateway 负责。 ## Connection generation diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index bf4427b41c..66811432cc 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -58,7 +58,11 @@ export function createWebConnectionRpc(doFetch?: RpcFetch, openStream?: RpcStrea if (!response.ok) { throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`) } - const full = parseConnectionResponse(await response.json()) + const mediaType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + const full = mediaType === 'multipart/form-data' + ? await parseBinaryResponse(response) + : parseConnectionResponse(await response.json()) + signal?.throwIfAborted() if (full.rpcId !== rpcId) { throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`) } @@ -76,6 +80,60 @@ export function createWebConnectionRpc(doFetch?: RpcFetch, openStream?: RpcStrea } } +async function parseBinaryResponse(response: Response): Promise> { + const body = await response.formData() + const fields = new Map() + for (const [name, value] of body) { + if (fields.has(name)) throw new TypeError('connection: invalid binary response fields') + fields.set(name, value) + } + const metadata = fields.get('metadata') + fields.delete('metadata') + if (typeof metadata !== 'string') { + throw new TypeError('connection: invalid binary response fields') + } + const envelope: unknown = JSON.parse(metadata) + const full = parseConnectionResponse(envelope) + if (!full.result.ok || !isRecord(envelope) || !Array.isArray(envelope.attachments) || envelope.attachments.length === 0) { + throw new TypeError('connection: invalid binary response result') + } + const root = { value: full.result.value } + for (const attachment of envelope.attachments as unknown[]) { + if (!isRecord(attachment) || attachment.codec !== 'bytes' || typeof attachment.part !== 'string' + || !Array.isArray(attachment.path)) { + throw new TypeError('connection: invalid binary response attachment') + } + const data = fields.get(attachment.part) + fields.delete(attachment.part) + if (!(data instanceof Blob)) { + throw new TypeError('connection: invalid binary response fields') + } + let parent: object = root + let key: string | number = 'value' + for (const segment of attachment.path as unknown[]) { + const value = Reflect.get(parent, key) as unknown + if (typeof value !== 'object' || value === null + || (Array.isArray(value) + ? typeof segment !== 'number' || !Number.isSafeInteger(segment) || segment < 0 || segment >= value.length + : typeof segment !== 'string') + || !Object.hasOwn(value, segment as string | number)) { + throw new TypeError('connection: invalid binary response path') + } + parent = value + key = segment as string | number + } + if (Reflect.get(parent, key) !== null) throw new TypeError('connection: invalid binary response placeholder') + Object.defineProperty(parent, key, { + value: new Uint8Array(await data.arrayBuffer()), enumerable: true, writable: true, configurable: true, + }) + } + if (fields.size !== 0) throw new TypeError('connection: invalid binary response fields') + return { + rpcId: full.rpcId, + result: { ok: true, value: root.value }, + } +} + function parseConnectionResponse(value: unknown): { readonly rpcId: RpcIdType readonly result: ConnectionRpcResult diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index ff6042d117..03109936b4 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -15,7 +15,7 @@ export const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024 /** * Bridge one node:http request to the fetch-shaped handler (client close - * aborts; response bodies stream out chunk by chunk). + * aborts; response writes respect backpressure and stop on disconnect). * @param req - incoming node:http request. * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. @@ -90,11 +90,13 @@ export async function bridge( return } for await (const chunk of response.body) { + // Drain without writing after disconnect: cancelling Node multipart bodies + // can race their producer and reject with ERR_INVALID_STATE. + if (abort.signal.aborted) continue // Backpressure: a false return means the socket buffer is full — wait for drain // instead of buffering unboundedly (slow or suspended consumers). 'close' also - // resolves so a mid-wait disconnect can't park this loop forever; the close - // handler above aborts the handler stream, which then ends the iteration. - if (!res.write(chunk)) { + // resolves so a mid-wait disconnect cannot park this loop forever. + if (!res.write(chunk) && !res.destroyed) { await new Promise((resolve) => { const done = (): void => { res.off('drain', done) diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index ba6f2736d6..2007e3a32d 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -292,9 +292,65 @@ function errorResponse(rpcId: RpcIdType, error: ConnectionRpcFailure): Response return fullResponse(rpcId, { ok: false, error }) } -function fullResponse(rpcId: RpcIdType, result: ConnectionRpcResult): Response { +function fullResponse(rpcId: RpcIdType, result: Awaited>): Response { const body: ConnectionServerResponse = { type: 'server-response', rpcId, result } - return Response.json(body) + if (!result.ok) return Response.json(body) + const parts = new FormData() + const attachments: { path: (string | number)[]; codec: 'bytes'; part: string }[] = [] + const path: (string | number)[] = [] + const ancestors = new Set() + const writeBytes = (value: Uint8Array, path: readonly (string | number)[]): null => { + const part = `bytes-${attachments.length}` + attachments.push({ path: [...path], codec: 'bytes', part }) + // FileSystem bytes may have SharedArrayBuffer backing, which BlobPart excludes. + parts.set(part, new Blob([new Uint8Array(value)])) + return null + } + const extract = (input: unknown, key: string): unknown => { + // Capture accessors and toJSON once, before materializing the JSON metadata. + let value = input + if (input !== null && typeof input === 'object' && !(input instanceof Uint8Array)) { + const toJSON = Reflect.get(input, 'toJSON') as unknown + if (typeof toJSON === 'function') value = Reflect.apply(toJSON, input, [key]) as unknown + } + if (value instanceof Uint8Array) return writeBytes(value, path) + if (typeof value !== 'object' || value === null) return value + if (value instanceof Number || value instanceof String || value instanceof Boolean) return value + if (ancestors.has(value)) throw new TypeError('connection: circular RPC result') + ancestors.add(value) + let copy: object + if (Array.isArray(value)) { + const items: unknown[] = [] + // JSON arrays include every index, even holes and non-enumerable elements. + for (let index = 0, length = value.length; index < length; index++) items.push(child(value[index], index)) + copy = items + } else { + const fields: Record = {} + for (const key of Object.keys(value)) { + const item = Reflect.get(value, key) as unknown + // A toJSON method on the projected object must not run a second time. + if (key === 'toJSON' && typeof item === 'function') continue + const extracted = child(item, key) + if (key === '__proto__') Object.defineProperty(fields, key, { value: extracted, enumerable: true }) + else fields[key] = extracted + } + copy = fields + } + ancestors.delete(value) + return copy + } + const child = (value: unknown, key: string | number): unknown => { + if (typeof value !== 'object' || value === null) return value + path.push(key) + const extracted = extract(value, String(key)) + path.pop() + return extracted + } + const value = result.encode === undefined ? extract(result.value, 'value') : result.encode(result.value, writeBytes) + const metadata = { ...body, result: { ok: true, value } } + if (attachments.length === 0) return Response.json(metadata) + parts.set('metadata', JSON.stringify({ ...metadata, attachments })) + return new Response(parts) } function assertChannel(channel: string): void { diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index e77562acfd..0b8755bf71 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -27,6 +27,24 @@ export type ConnectionRpcResult = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: ConnectionRpcFailure } +/** One binary value separated from a successful RPC result before transport framing. */ +export interface ConnectionRpcAttachment { + /** Result-relative path occupied by the attachment's `null` placeholder. */ + readonly path: readonly (string | number)[] + /** Byte view carried outside the JSON response metadata. */ + readonly bytes: Uint8Array +} + +/** Successful or failed handler result ready for Connection transport framing. */ +export type ConnectionRpcHandlerResult = + | { + readonly ok: true + readonly value: unknown + /** Binary fields already projected by the handler that owns the result protocol. */ + readonly attachments?: readonly ConnectionRpcAttachment[] + } + | { readonly ok: false; readonly error: ConnectionRpcFailure } + /** Historical short name for a generic Connection result. */ export type RpcResult = ConnectionRpcResult @@ -111,7 +129,7 @@ export type ConnectionRpcHandler = ( payload: unknown, signal: AbortSignal, peer: PeerScope, -) => Promise> +) => Promise /** Synchronous ownership test for one endpoint on a shared RPC channel. */ export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean diff --git a/packages/client/connection/tests/binary-rpc.host.spec.ts b/packages/client/connection/tests/binary-rpc.host.spec.ts new file mode 100644 index 0000000000..b65fefed19 --- /dev/null +++ b/packages/client/connection/tests/binary-rpc.host.spec.ts @@ -0,0 +1,309 @@ +import { request } from 'node:http' +import { gunzipSync } from 'node:zlib' +import { Context } from '@deepseek-ai/cordis' +import WebServer from '@deepseek-ai/dsh-host-webserver' +import { describe, expect, it } from 'vitest' +import type { BrowserAuth } from '../src/browser-auth.ts' +import { createWebConnectionRpc } from '../src/client/rpc.ts' +import { bridge } from '../src/http-bridge.ts' +import type { ConnectionRpcResult } from '../src/rpc.ts' +import { HostConnectionService } from '../src/rpc-host.ts' + +function binaryResponse(rpcId: string, mutate: (parts: FormData) => void = () => {}): Response { + const parts = new FormData() + parts.set('metadata', JSON.stringify({ + type: 'server-response', rpcId, result: { ok: true, value: { offset: 7, data: null } }, + attachments: [{ path: ['data'], codec: 'bytes', part: 'bytes-0' }], + })) + parts.set('bytes-0', new Blob([new Uint8Array([0, 128, 255])])) + mutate(parts) + return new Response(parts) +} + +describe('Connection binary RPC', () => { + it.each(['gzip', 'none'] as const)('preserves %s configuration through the HTTP bridge', async (compression) => { + const ctx = new Context() + try { + await ctx.plugin(WebServer, { host: '127.0.0.1', port: 0, compression }) + await ctx.plugin((owner) => { new HostConnectionService(owner, [], {} as BrowserAuth) }) + const connection = ctx.get('connection') as HostConnectionService + const data = new Uint8Array(1024 * 1024).fill(65) + data.set([0, 128, 255]) + connection.rpc.intercept('/api', endpoint => endpoint === 'fixture/read', async () => ({ ok: true, value: { data, bytes: data.length } })) + const handler = connection.createSharedFetchHandler('/api') + ctx.webServer.register({ kind: 'prefix', path: '/api', handler: (req, res) => bridge(req, res, handler) }) + let transferred = 0 + let encoding: string | undefined + const rpc = createWebConnectionRpc((path, init) => new Promise((resolve, reject) => { + const req = request(new URL(path, `http://127.0.0.1:${ctx.webServer.port}`), { + method: init.method, + headers: { ...Object.fromEntries(new Headers(init.headers)), 'accept-encoding': 'gzip' }, + }, (response) => { + void (async () => { + const chunks: Buffer[] = [] + for await (const chunk of response) chunks.push(chunk as Buffer) + const body = Buffer.concat(chunks) + transferred = body.length + encoding = response.headers['content-encoding'] + return new Response(encoding === 'gzip' ? gunzipSync(body) : body, { + status: response.statusCode!, + headers: { 'content-type': response.headers['content-type']! }, + }) + })().then(resolve, reject) + }) + req.once('error', reject) + req.end(init.body as string) + })) + expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ ok: true, value: { data, bytes: data.length } }) + if (compression === 'gzip') { + expect(encoding).toBe('gzip') + expect(transferred).toBeLessThan(data.length / 10) + } else { + expect(encoding).toBeUndefined() + expect(transferred).toBeGreaterThan(data.length) + } + } finally { + await ctx.fiber.dispose() + } + }) + + it('roundtrips raw bytes and metadata on the existing channel while JSON results and errors stay JSON', async () => { + const ctx = new Context() + const fiber = ctx.plugin((owner) => { new HostConnectionService(owner, [], {} as BrowserAuth) }) + await fiber.await() + try { + const connection = ctx.get('connection') as HostConnectionService + const results: ConnectionRpcResult[] = [ + { ok: true, value: { data: new Uint8Array([9, 0, 128, 255, 9]).subarray(1, 4), offset: 7, eof: false, bytes: 42 } }, + { ok: true, value: { data: new Uint8Array(new SharedArrayBuffer(3)).fill(255), offset: 0, eof: true, bytes: 3 } }, + { ok: true, value: { data: new Uint8Array(), offset: 0, eof: true, bytes: 0 } }, + { ok: true, value: { data: 'AID/', encoding: 'base64' } }, + { ok: true, value: { count: 4 } }, + { ok: true, value: null }, + { ok: true, value: 'plain' }, + { ok: false, error: { code: 'fixture/denied', message: 'denied', details: { path: 'private' } } }, + ] + let next: ConnectionRpcResult = results[0]! + connection.rpc.intercept('/api', endpoint => endpoint === 'fixture/read', async () => next) + const shared = connection.createSharedFetchHandler('/api') + const mediaTypes: (string | null)[] = [] + const rpc = createWebConnectionRpc(async (url, init) => { + const response = await shared.fetch(new Request(new URL(url, 'http://host'), init)) + mediaTypes.push(response.headers.get('content-type')) + return response + }) + for (const result of results) { + next = result + const received = await rpc.call('/api', 'fixture/read', { args: {} }) + expect(received).toEqual(result) + if (received.ok && typeof received.value === 'object' && received.value !== null + && 'data' in received.value && received.value.data instanceof Uint8Array) { + expect(received.value.data.buffer).toBeInstanceOf(ArrayBuffer) + } + } + expect(mediaTypes.slice(0, 3).every(type => type?.startsWith('multipart/form-data;'))).toBe(true) + expect(mediaTypes.slice(3)).toEqual(Array(5).fill('application/json')) + } finally { + await fiber.dispose() + } + }) + + it('uses owner result codecs without putting their callbacks on the wire', async () => { + const ctx = new Context() + const fiber = ctx.plugin((owner) => { new HostConnectionService(owner, [], {} as BrowserAuth) }) + await fiber.await() + try { + const connection = ctx.get('connection') as HostConnectionService + const content = Buffer.from([9, 0, 128, 255, 9]).subarray(1, 4) + let reads = 0 + const value = { content, metadata: { get count() { return ++reads } } } + connection.rpc.intercept('/api', () => true, async () => ({ + ok: true, value, + encode: (input, writeBytes) => { + expect(input).toBe(value) + return { content: writeBytes(content, ['content']), metadata: value.metadata } + }, + })) + const handler = connection.createSharedFetchHandler('/api') + const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init))) + expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ + ok: true, value: { content: new Uint8Array([0, 128, 255]), metadata: { count: 1 } }, + }) + expect(reads).toBe(1) + } finally { + await fiber.dispose() + } + }) + + it.each([ + ['missing bytes', (p: FormData) => { p.delete('bytes-0') }], + ['text bytes', (p: FormData) => { p.set('bytes-0', 'AID/') }], + ['missing metadata', (p: FormData) => { p.delete('metadata') }], + ['file metadata', (p: FormData) => { p.set('metadata', new Blob(['{}'])) }], + ['duplicate metadata', (p: FormData) => { p.append('metadata', '{}') }], + ['duplicate bytes', (p: FormData) => { p.append('bytes-0', new Blob()) }], + ['extra part', (p: FormData) => { p.set('extra', 'unclaimed') }], + ['invalid JSON', (p: FormData) => { p.set('metadata', '{') }], + ['invalid envelope', (p: FormData) => { p.set('metadata', '{}') }], + ...[ + { ok: false, error: { code: 'fixture/error', message: 'failed', details: {} } }, + { ok: true, value: null }, + { ok: true, value: [] }, + { ok: true, value: { data: 'duplicate' } }, + ].map(result => ['invalid result', (p: FormData) => { p.set('metadata', JSON.stringify({ + type: 'server-response', rpcId: 'fixture', result, + })) }] as const), + ] satisfies readonly (readonly [string, (parts: FormData) => void])[])('rejects malformed multipart: %s', async (_name, mutate) => { + const rpc = createWebConnectionRpc(async (_url, init) => { + const message = JSON.parse(init.body as string) as { rpcId: string } + return binaryResponse(message.rpcId, mutate) + }) + await expect(rpc.call('/api', 'fixture/read', {})).rejects.toThrow(/invalid binary response|invalid server-response|JSON/) + }) + + it('rejects a binary response for another request', async () => { + const rpc = createWebConnectionRpc(async () => binaryResponse('unrelated')) + await expect(rpc.call('/api', 'fixture/read', {})).rejects.toThrow('rpcId mismatch') + }) + + it('roundtrips nested, optional and root bytes without changing caller objects or reserving field names', async () => { + const ctx = new Context() + const fiber = ctx.plugin((owner) => { new HostConnectionService(owner, [], {} as BrowserAuth) }) + await fiber.await() + try { + const data = new Uint8Array([0, 128, 255]) + const shared = { content: data } + const value = { + files: [{ name: 'a.png', ...shared }, { name: 'b.png', content: Buffer.from([9, 2, 3, 9]).subarray(1, 3) }, { name: 'empty' }], + 'dots./[]': [new Uint8Array(), null, [data]], + metadata: { attachments: shared }, + ...JSON.parse('{"__proto__":null,"constructor":null}') as object, + } + Object.defineProperty(value, '__proto__', { value: data, enumerable: true }) + Object.freeze(value.files) + Object.freeze(value) + let next: unknown = value + const connection = ctx.get('connection') as HostConnectionService + connection.rpc.intercept('/api', () => true, async () => ({ ok: true, value: next })) + const handler = connection.createSharedFetchHandler('/api') + const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init))) + expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ ok: true, value: { + ...value, files: [value.files[0], { name: 'b.png', content: new Uint8Array([2, 3]) }, value.files[2]], + } }) + expect(value.files[0]?.content).toBe(data) + expect(shared.content).toBe(data) + expect(Object.getPrototypeOf(value)).toBe(Object.prototype) + for (const bytes of [data, Buffer.from([0, 128, 255]), new Uint8Array(new SharedArrayBuffer(3))]) { + next = bytes + const response = await rpc.call('/api', 'fixture/read', {}) + expect(response).toEqual({ ok: true, value: new Uint8Array(bytes) }) + if (!response.ok || !(response.value instanceof Uint8Array)) throw new Error('expected root bytes') + expect(response.value.buffer).toBeInstanceOf(ArrayBuffer) + expect(Object.isFrozen(response.value)).toBe(false) + } + next = [data, [data]] + expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ ok: true, value: next }) + const circular: { next?: object } = {} + circular.next = circular + next = circular + await expect(rpc.call('/api', 'fixture/read', {})).rejects.toThrow('HTTP 500') + next = { ordinary: [1, 'base64', null], omitted: undefined, date: new Date('2026-01-01T00:00:00Z') } + expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ ok: true, value: { + ordinary: [1, 'base64', null], date: '2026-01-01T00:00:00.000Z', + } }) + const sparse = [data, , null] + Object.assign(sparse, { extra: data, '-1': data, '01': data, '1.5': data, Infinity: data }) + next = sparse + expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ ok: true, value: [data, null, null] }) + } finally { + await fiber.dispose() + } + }) + + it.each([ + ['absent table', { attachments: undefined }], + ['non-array table', { attachments: {} }], + ['empty table', { attachments: [] }], + ...[ + null, {}, { path: ['data'], codec: 'numpy', part: 'bytes-0' }, + { path: ['data'], codec: 'bytes', part: 0 }, + { path: ['data'], codec: 'bytes', part: 'metadata' }, + { path: 'data', codec: 'bytes', part: 'bytes-0' }, + ...[['missing'], ['offset', 'child'], ['constructor', 'prototype'], [0], ['files', '0'], ['files', -1], ['files', 0.5], ['files', 1], ['files', false], ['files', null]].map(path => ({ path, codec: 'bytes', part: 'bytes-0' })), + ].map(attachment => ['invalid attachment', { attachments: [attachment] }] as const), + ['duplicate part', { attachments: [0, 1].map(() => ({ path: ['data'], codec: 'bytes', part: 'bytes-0' })) }], + ['duplicate path', { attachments: [0, 1].map(i => ({ path: ['data'], codec: 'bytes', part: `bytes-${i}` })) }], + ['overlapping path', { attachments: [ + { path: ['data'], codec: 'bytes', part: 'bytes-0' }, + { path: ['data', '0'], codec: 'bytes', part: 'bytes-1' }, + ] }], + ['occupied placeholder', { result: { ok: true, value: { data: 'AID/' } } }], + ] satisfies readonly (readonly [string, Record])[])('rejects malformed attachment metadata: %s', async (_name, overrides) => { + const rpc = createWebConnectionRpc(async (_url, init) => { + const { rpcId } = JSON.parse(init.body as string) as { rpcId: string } + return binaryResponse(rpcId, (parts) => { + const metadata = JSON.parse(parts.get('metadata') as string) as Record + parts.set('metadata', JSON.stringify({ ...metadata, result: { ok: true, value: { data: null, offset: 7, files: [null] } }, ...overrides })) + if (_name === 'duplicate path' || _name === 'overlapping path') parts.set('bytes-1', new Blob()) + }) + }) + await expect(rpc.call('/api', 'fixture/read', {})).rejects.toThrow('invalid binary response') + }) + + it('preserves JSON accessor and toJSON evaluation while reading all array indices', async () => { + const ctx = new Context() + const fiber = ctx.plugin((owner) => { new HostConnectionService(owner, [], {} as BrowserAuth) }) + await fiber.await() + try { + let reads = 0 + let next: unknown = { get count() { return ++reads } } + const connection = ctx.get('connection') as HostConnectionService + connection.rpc.intercept('/api', () => true, async () => ({ ok: true, value: next })) + const handler = connection.createSharedFetchHandler('/api') + const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init))) + const read = () => rpc.call('/api', 'fixture/read', {}) + expect(await read()).toEqual({ ok: true, value: { count: 1 } }) + expect(reads).toBe(1) + const bytes = new Uint8Array([1, 2]) + next = { get content() { reads++; return bytes }, get count() { return ++reads } } + expect(await read()).toEqual({ ok: true, value: { content: bytes, count: 3 } }) + const array: Uint8Array[] = [] + Object.defineProperty(array, 0, { value: bytes }) + next = array + expect(await read()).toEqual({ ok: true, value: [bytes] }) + const date = Object.assign(new Date('2026-01-01T00:00:00Z'), { content: bytes }) + next = date + expect(await read()).toEqual({ ok: true, value: '2026-01-01T00:00:00.000Z' }) + const json = { self: {} as object, toJSON: () => ({ selected: true }) } + json.self = json + next = json + expect(await read()).toEqual({ ok: true, value: { selected: true } }) + let conversions = 0 + next = { toJSON(key: string) { conversions++; expect(key).toBe('value'); return this }, count: 5 } + expect(await read()).toEqual({ ok: true, value: { count: 5 } }) + expect(conversions).toBe(1) + for (const primitive of [1, 'text', true]) { + next = Object(primitive) as object + expect(await read()).toEqual({ ok: true, value: primitive }) + } + next = { toJSON: 'ordinary' } + expect(await read()).toEqual({ ok: true, value: next }) + } finally { + await fiber.dispose() + } + }) + + it('rejects truncated multipart and late bytes after caller cancellation', async () => { + const broken = createWebConnectionRpc(async () => new Response('truncated', { + headers: { 'content-type': 'multipart/form-data; boundary=fixture' }, + })) + await expect(broken.call('/api', 'fixture/read', {})).rejects.toThrow() + const abort = new AbortController() + const rpc = createWebConnectionRpc(async (_url, init) => { + const message = JSON.parse(init.body as string) as { rpcId: string } + abort.abort(new Error('caller left')) + return binaryResponse(message.rpcId) + }) + await expect(rpc.call('/api', 'fixture/read', {}, abort.signal)).rejects.toThrow('caller left') + }) +}) diff --git a/packages/client/connection/tests/http-bridge.host.spec.ts b/packages/client/connection/tests/http-bridge.host.spec.ts index 9dc6df8405..8d972824b9 100644 --- a/packages/client/connection/tests/http-bridge.host.spec.ts +++ b/packages/client/connection/tests/http-bridge.host.spec.ts @@ -75,6 +75,71 @@ describe('HTTP bridge abort', () => { expect(carrierSignal?.aborted).toBe(true) }) + it.each(['write', 'backpressure'])('finishes a multipart response without further writes when the client closes during %s', async (phase) => { + const request = Readable.from([]) as unknown as IncomingMessage + Object.assign(request, { url: '/api/workspaceFiles/readBytes', method: 'GET', headers: {} }) + const body = new FormData() + body.set('metadata', '{}') + body.set('data', new Blob([new Uint8Array(16 * 1024 * 1024)])) + let writes = 0 + let cleaningUp = false + const response = Object.assign(new EventEmitter(), { + writableEnded: false, + writeHead() { return this }, + write() { + writes++ + if (writes === 1) { + const close = (): void => { response.destroyed = true; response.emit('close') } + if (phase === 'write') close() + else queueMicrotask(close) + } + return cleaningUp + }, + end() { this.writableEnded = true; return this }, + }) as unknown as ServerResponse + let settled = false + const pending = bridge(request, response, { + requestBodyMode: () => 'buffered', + fetch: async () => new Response(body), + }).then(() => { settled = true }) + try { + await expect.poll(() => settled).toBe(true) + expect(writes).toBe(1) + expect(response.listenerCount('drain')).toBe(0) + } finally { + // Release a regressed bridge parked after the one close event. + cleaningUp = true + response.emit('drain') + await pending + } + }) + + it('discards bytes returned after the client disconnects', async () => { + const request = Readable.from([]) as unknown as IncomingMessage + Object.assign(request, { url: '/api/workspaceFiles/readBytes', method: 'GET', headers: {} }) + const response = Object.assign(new EventEmitter(), { + writableEnded: false, + writeHead() { return this }, + write() { throw new Error('a disconnected response must not receive bytes') }, + end() { this.writableEnded = true; return this }, + }) as unknown as ServerResponse + const started = Promise.withResolvers() + let controller!: ReadableStreamDefaultController + const body = new ReadableStream({ + start(value) { controller = value }, + pull() { started.resolve(undefined) }, + }) + const pending = bridge(request, response, { + requestBodyMode: () => 'buffered', + fetch: async () => new Response(body), + }) + await started.promise + response.emit('close') + controller.enqueue(Uint8Array.of(1)) + controller.close() + await pending + }) + it('streams a declared 2.19 GiB request before the body ends and bypasses the JSON buffer cap', async () => { const request = new Readable({ read() {} }) as IncomingMessage Object.assign(request, { diff --git a/packages/client/connection/tsconfig.host.json b/packages/client/connection/tsconfig.host.json index c291ff868d..66d9844f17 100644 --- a/packages/client/connection/tsconfig.host.json +++ b/packages/client/connection/tsconfig.host.json @@ -9,6 +9,8 @@ "src/api-path.ts", "src/api-request-trust.ts", "src/browser-auth.ts", + "src/client/random-uuid.ts", + "src/client/rpc.ts", "src/http-bridge.ts", "src/index.ts", "src/loopback-hostname.ts", diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 141c7e0474..faad4651ee 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/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/client/ui-sidebar-documentpreview/README.md -README.md: 2797d6fd6be8f429043c8de1413f90e64bda3876 -README.zh.md: a04571ed6d76ca812aa0a70ce6f3c403ad80947d +README.md: 79c7253887cd580bdcc725054c0bbf5b2877e90b +README.zh.md: 77847e8ce7a9203a85fee360ec8a481e32e7e871 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 2797d6fd6b..79c7253887 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -51,10 +51,10 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. ` - The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; loaded content remains while metadata is unavailable. - **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. A failure before any content fills the body with the file-type icon, the explanation, and the recourse the failure names (Retry, the unpreviewable child slot, or nothing); a later failure retains loaded content and adds the retry below it. -- **Complete bytes** — PDF, HTML, and common images use an inject callback to `remote.workspaceFiles.readAll(sessionId, path, signal)`. `rpc.ts` decodes the wire base64 into `data: Uint8Array` for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results. -- **Reload** — manual reload rereads only the current Preview tab through its Remote callbacks, preserving its scroll preference and retiring older requests. Automatic refresh uses the same callbacks after a ResourceGroup member changes. Each member's first metadata establishes a baseline without triggering reload or first-read version reconciliation; later changes received during a read remain pending for another refresh. Reads neither refresh shared metadata nor clear another tab's notice. +- **Complete bytes** — PDF, HTML, and common images call `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`. The binary Remote returns `data: Uint8Array` directly for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results. +- **Reload** — manual reload rereads only the current Preview tab through its Remote callbacks, preserving its scroll preference and retiring older requests. Automatic refresh uses the same callbacks after a `ResourceGroup` member changes. Each member's first metadata establishes a baseline without triggering reload or first-read version reconciliation; later changes received during a read remain pending for another refresh. Reads neither refresh shared metadata nor clear another tab's notice. -When Developer tools is enabled, HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its ordinary inject callback to `remote.workspaceFiles.readRelated`, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path; `rpc.ts` decodes the returned bytes. The dependency Resource is added after `readRelated` returns, using its `absolutePath` or a string `error.details.path` on failure; without a Host path, the Client does not guess one. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL. +When Developer tools is enabled, HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its injected Remote callback, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path, and `readBytes` with `baseFile` returns native bytes. The dependency Resource is added after the read returns, using its `absolutePath` or a string `error.details.path` on failure; without a Host path, the Client does not guess one. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `` static-image context, rounded inside a 12px inset. A wider image scales down to the pane's width at its intrinsic aspect ratio, a smaller image centres at its intrinsic CSS-pixel dimensions, and a taller image scrolls vertically. The renderer provides neither zoom nor drag-to-pan. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL. @@ -86,7 +86,7 @@ A yellow rounded-triangle warning appears before Reload in the document toolbar Office registration, loading, caching, and font notices live in `src/client/office/`. The Office body owns converted PDF bytes and font metadata, and declares a nested PDF slot that reuses the lazy PDF body and its tab viewing state. The keyed `sidebar.right.tab.document.action` slot places renderer controls before Reload. The Office action shares the body’s store and reads only the current revision’s font metadata. Registration remains available without the Host renderer; optional `remote.officeToPdf` and `remote.workspaceFiles` injections supply conversion and freshness callbacks, and their removal restores unavailable guidance. Registrations and tab retention follow effect lifetimes. The [conversion service](../../document/office-to-pdf/README.md) owns the Host Remote methods, mounted by `api/remotes`. -The shared `documentFileBytes()` helper decodes ordinary file and converted PDF responses into one owned byte buffer without expanding bytes into JavaScript array elements. Renderers borrow retained bytes read-only and copy them before Worker transfer. +The `documentFileBytes()` helper decodes converted Office PDF responses into one owned byte buffer without expanding bytes into JavaScript array elements. Renderers borrow retained bytes read-only and copy them before Worker transfer. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index a04571ed6d..77847e8ce7 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -51,10 +51,10 @@ Web 和桌面端均通过开发者工具选择 HTML 预览策略。渲染器从 - 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;元数据不可用时保留已加载内容。 - **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。尚无内容时,失败会以文件类型图标、说明和该失败对应的出路(重试、unpreviewable 子 slot,或什么都不给)填满正文;较晚的失败保留已有内容并在其下提供重试。 -- **完整字节** —— PDF、HTML 和常见图片通过 inject 回调调用 `remote.workspaceFiles.readAll(sessionId, path, signal)`。`rpc.ts` 将线路上的 base64 解码为 `data: Uint8Array`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。 -- **重新载入** —— 手动重新载入仅让当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。ResourceGroup 成员变化后,自动刷新使用相同回调。各成员首次元数据仅建立基线,不触发重新载入或首读版本对账;后续在读取期间收到的变化仍会留待下一次刷新。读取既不刷新共享元数据,也不清除其它 tab 的提示。 +- **完整字节** —— PDF、HTML 和常见图片调用 `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`。二进制 Remote 直接返回 `data: Uint8Array`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。 +- **重新载入** —— 手动重新载入仅让当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。`ResourceGroup` 成员变化后,自动刷新使用相同回调。各成员首次元数据仅建立基线,不触发重新载入或首读版本对账;后续在读取期间收到的变化仍会留待下一次刷新。读取既不刷新共享元数据,也不清除其它 tab 的提示。 -开启开发者工具时,HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过普通 inject 回调调用 `remote.workspaceFiles.readRelated`,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,`rpc.ts` 解码返回的字节。依赖 Resource 在 `readRelated` 返回后加入,使用返回的 `absolutePath`,失败时则使用字符串类型的 `error.details.path`;没有 Host 路径时,Client 不自行猜测。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。 +开启开发者工具时,HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过注入的 Remote 回调,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,带 `baseFile` 的 `readBytes` 返回原生字节。依赖 Resource 在读取返回后加入,使用返回的 `absolutePath`,失败时则使用字符串类型的 `error.details.path`;没有 Host 路径时,Client 不自行猜测。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。 PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态图片上下文中渲染,带 12px 内边距和圆角。宽图按固有纵横比缩小到面板宽度,小图按固有 CSS 像素尺寸居中,超高图纵向滚动。渲染器既不提供缩放,也不提供拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。 @@ -86,7 +86,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态 Office 注册、加载、缓存和字体提示位于 `src/client/office/`。Office 正文持有转换后的 PDF 字节和字体元数据,并声明嵌套 PDF slot,复用惰性 PDF 正文及其 tab 阅读状态。keyed slot `sidebar.right.tab.document.action` 将渲染器操作放在刷新按钮前。Office 操作与正文共享 store,仅读取当前 revision 的字体元数据。Host 渲染器缺失时,注册仍然可用;可选的 `remote.officeToPdf` 和 `remote.workspaceFiles` 注入提供转换与版本检查回调,移除后恢复不可用提示。注册和 tab 状态保留都遵循 effect 生命周期。[转换服务](../../document/office-to-pdf/README.zh.md)拥有 Host Remote 方法,由 `api/remotes` 挂载。 -共享 `documentFileBytes()` 辅助函数将普通文件与转换后 PDF 的响应解码到一个独立持有的字节缓冲区,不会将字节展开为 JavaScript 数组元素。渲染器以只读方式借用保留的字节,并在传给 Worker 前复制。 +`documentFileBytes()` 辅助函数将 Office 转换后的 PDF 响应解码到一个独立持有的字节缓冲区,不会将字节展开为 JavaScript 数组元素。渲染器以只读方式借用保留的字节,并在传给 Worker 前复制。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/face.ts b/packages/client/ui-sidebar-documentpreview/src/client/face.ts index 17dda43da3..914c754278 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/face.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/face.ts @@ -101,7 +101,7 @@ interface TabReads { /** * Bind the preview's face to one paged read and one complete-byte read. * @param read - the bound `workspaceFiles.read` call. - * @param readAll - ordinary complete-byte Remote read. + * @param readAll - complete-byte workspace Remote read. * @param resources - shared metadata sources for the document and its dependencies. * @returns the Slot `inject` factory: bound actions in, face out. The slot's session id is unused because the address carries its own. */ diff --git a/packages/client/ui-sidebar-documentpreview/src/client/html/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/html/index.ts index 50672fc97b..88ebcfda78 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/html/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/html/index.ts @@ -35,7 +35,7 @@ export function apply(ctx: Context): void { hooks: { interactivePreview: ctx.settingsScope.developerTools.enabled }, readRelated: (address, relativePath, signal) => { const file = hostFileOf(address) - return ctx.remote.workspaceFiles.readRelated(file.sessionId, file.path, relativePath, signal) + return ctx.remote.workspaceFiles.readBytes(file.sessionId, relativePath, { baseFile: file.path }, signal) }, }), }, HtmlBody, diff --git a/packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts b/packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts index 5bbf23c94e..8421a1ab29 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts @@ -1,7 +1,6 @@ -/** Adapt a Remote relative read without changing its Session or Host path authority. */ +/** Adapt a relative workspace read without changing its Session or Host path authority. */ import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' -import type { WorkspaceFileBytes } from '@deepseek-ai/dsh-api-workspace-files/types' -import { documentFileBytes } from '../rpc.ts' +import type { DocumentFileBytes } from '../rpc.ts' import type { ReadHtmlRelative } from './pack.ts' import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' import { hostFileOf } from '../rpc.ts' @@ -11,13 +10,13 @@ import { hostFileOf } from '../rpc.ts' * @param address - root HTML file address. * @param relativePath - decoded dependency path, resolved only by the Host. * @param signal - cancellation shared by the tab and packing operation. - * @returns the Remote complete-byte result, including declared failures. + * @returns the complete-byte result, including declared failures. */ -export type ReadHtmlRelated = (address: string, relativePath: string, signal: AbortSignal) => Promise> +export type ReadHtmlRelated = (address: string, relativePath: string, signal: AbortSignal) => Promise> /** * Bind a package reader to the original HTML file's address. - * @param readRelated - Remote reader using the Session in the root HTML address. + * @param readRelated - workspace reader using the Session in the root HTML address. * @param address - root HTML file address. * @param lifetime - tab lifetime. * @param addResource - subscribes to the dependency path reported by the Host. @@ -47,6 +46,6 @@ export function createReadHtmlRelative( throw new Error(result.error.message) } addResource(sessionFileAddress(file.sessionId, result.value.absolutePath)) - return documentFileBytes(result.value) + return result.value } } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/index.ts index c5edbc05a5..7c8b6907b2 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/index.ts @@ -25,7 +25,7 @@ import type { TextPreviewInjected } from './TextPreview.tsx' import { TextTitle } from './TextTitle.tsx' import { TEXTPREVIEW_ID, textDefinition } from './definition.ts' import { textFace } from './face.ts' -import { createReadPage, documentFileBytes } from './rpc.ts' +import { createReadPage } from './rpc.ts' import { createTextStore } from './store.ts' import { en, zh } from './locales.ts' import { DocumentPreviewRegistry } from './document/registry.ts' @@ -76,13 +76,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** * Required browser services: the tab registry, the slot registry, copy, and the - * Remote carrier with its `workspaceFiles` namespace. + * workspace Remote for bytes and paged text reads. */ export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles', 'settingsScope', 'resources'] /** * Client plugin body: register the type, its dictionaries, its body, and its chip title. - * @param ctx - client root context carrying the registry, the slots, copy, and the Remote face. + * @param ctx - client root context carrying the registry, slots, copy, and file readers. */ export function apply(ctx: ClientContext): void { const config = Config((globalThis as { __DSH_DOCUMENT_PREVIEW_CONFIG__?: unknown }).__DSH_DOCUMENT_PREVIEW_CONFIG__ ?? {}) @@ -95,10 +95,7 @@ export function apply(ctx: ClientContext): void { const store = createTextStore() const face = textFace( createReadPage(ctx.remote), - async (file, signal) => { - const result = await ctx.remote.workspaceFiles.readAll(file.sessionId, file.path, signal) - return result.ok ? { ok: true, value: documentFileBytes(result.value) } : result - }, + (file, signal) => ctx.remote.workspaceFiles.readBytes(file.sessionId, file.path, {}, signal), ctx.resources, ) const source = { getSnapshot: previews.getSnapshot, subscribe: previews.subscribe } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts index 0672b5f1ed..098e5dd06c 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts @@ -5,7 +5,6 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-office-to-pdf/remote' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-api-workspace-files/remote' -import type {} from '@deepseek-ai/dsh-client-connection/client' import { documentFileBytes } from '../rpc.ts' import { failureLine } from '../failure-line.ts' import { documentTabInfoFactory } from '../document/contract.ts' @@ -81,7 +80,9 @@ export function apply(ctx: Context, config: Config['office']): void { } const createCache = () => new OfficePreviewCache( async (file, signal) => { - const authorized = await scope.remote.workspaceFiles.readBytes(file.sessionId, file.path, { offset: 0, length: 1 }, signal) + const authorized = await scope.remote.workspaceFiles.readBytes( + file.sessionId, file.path, { range: { offset: 0, length: 1 } }, signal, + ) signal.throwIfAborted() if (!authorized.ok) return authorized const metadata = await scope.remote.workspaceFiles.stat(file.sessionId, file.path, signal) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/rpc.ts b/packages/client/ui-sidebar-documentpreview/src/client/rpc.ts index d609278c4f..8289374cdc 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/rpc.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/rpc.ts @@ -81,23 +81,23 @@ export function createReadPage(remote: WorkspaceFilesReadRemote): ReadWorkspaceF return (sessionId, path, offset, signal) => remote.workspaceFiles.read(sessionId, path, { offset }, signal) } -/** Complete document bytes borrowed read-only by renderers; copy before transferring to a Worker. */ -export type DocumentFileBytes = Omit & { readonly data: Uint8Array } +/** Document bytes borrowed read-only by renderers; copy before transferring to a Worker. */ +export type DocumentFileBytes = WorkspaceFileBytes> /** * Read a complete file through the Host endpoint. * @param file - Session and path decoded from the tab address. * @param signal - owning tab lifetime. - * @returns complete binary bytes, including declared failures. + * @returns complete native bytes, including declared failures. */ export type ReadDocumentBytes = (file: SessionFile, signal: AbortSignal) => Promise> /** - * Decode one successful Remote byte result for document renderers. - * @param file - Host byte result with base64 data. + * Decode the Office conversion Remote's base64 PDF response. + * @param file - converted PDF metadata and base64 contents. * @returns the same metadata with native bytes; malformed base64 throws. */ -export function documentFileBytes(file: WorkspaceFileBytes): DocumentFileBytes { +export function documentFileBytes(file: Omit & { readonly data: string }): DocumentFileBytes { const binary = atob(file.data) const data = new Uint8Array(binary.length) for (let index = 0; index < binary.length; index++) data[index] = binary.charCodeAt(index) diff --git a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts index 976da58bbc..5e82256d60 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts @@ -7,6 +7,8 @@ * and face — and that every registration is gone after dispose, which is what * makes a reload safe. */ +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -29,10 +31,9 @@ import { LazyPdfBody } from '../src/client/pdf/LazyPdfBody.tsx' import { PDF_BODY_ID } from '../src/client/pdf/index.ts' import { CodeBody } from '../src/client/code/CodeBody.tsx' import { en, zh } from '../src/client/locales.ts' -import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { textFace } from '../src/client/face.ts' import type { TextStore } from '../src/client/store.ts' -import { FILE, SESSION, TAB_ID, createResources, page } from './fixtures.client.ts' +import { FILE, SESSION, TAB_ID, byteResult, createResources, page } from './fixtures.client.ts' interface Recorded { name: string @@ -65,21 +66,17 @@ async function boot() { return () => { dictionaries.delete(ns) } }), } - const workspaceFiles = { - read: vi.fn().mockResolvedValue(page(1, ['first'], true)), - readAll: vi.fn().mockResolvedValue({ - ok: true, value: { absolutePath: '/workspace/notes.md', version: 'v1', offset: 0, data: 'AAH/', eof: true }, - }), - } + const read = vi.fn().mockResolvedValue(page(1, ['first'], true)) + const readBytes = vi.fn(async () => byteResult()) ctx.provide('sidebarRightTabs', tabs as never) ctx.provide('slots', slots as never) ctx.provide('locale', locale as never) - ctx.provide('remote', { workspaceFiles } as never) - ctx.provide('remote.workspaceFiles', workspaceFiles as never) + ctx.provide('remote', { workspaceFiles: { read, readBytes } } as never) + ctx.provide('remote.workspaceFiles', { read, readBytes } as never) const fiber = ctx.plugin({ inject: [...inject], apply }) onTestFinished(async () => { await fiber.dispose() }) await fiber.await() - return { tabs, registered, dictionaries, fiber, workspaceFiles } + return { tabs, registered, dictionaries, fiber, read, readBytes } } describe('ui-sidebar-documentpreview apply', () => { @@ -115,8 +112,8 @@ describe('ui-sidebar-documentpreview apply', () => { expect(dictionaries.size).toBe(0) }) - it('injects ordinary Remote reads independently of resource metadata', async () => { - const { registered, workspaceFiles } = await boot() + it('injects paged and byte Remote reads independently of resource metadata', async () => { + const { registered, read, readBytes } = await boot() const registration = registered.find(entry => entry.component === TextPreview) if (registration === undefined) throw new Error('missing preview registration') const instance = (registration.store as TextStore).create() @@ -124,17 +121,16 @@ describe('ui-sidebar-documentpreview apply', () => { const controller = new AbortController() onTestFinished(() => { controller.abort() }) face.loadPage(TAB_ID, FILE, 1, controller.signal, 'v1') - await workspaceFiles.read.mock.results[0]?.value - expect(workspaceFiles.read).toHaveBeenCalledExactlyOnceWith(FILE.sessionId, FILE.path, { offset: 1 }, controller.signal) + await read.mock.results[0]?.value + expect(read).toHaveBeenCalledExactlyOnceWith(FILE.sessionId, FILE.path, { offset: 1 }, controller.signal) expect(instance.getSnapshot().byTab[TAB_ID]?.pages[1]?.text).toBe('first') face.loadAll(TAB_ID, FILE, controller.signal, 'v1') - await workspaceFiles.readAll.mock.results[0]?.value - expect(workspaceFiles.readAll).toHaveBeenCalledExactlyOnceWith(FILE.sessionId, FILE.path, expect.any(AbortSignal)) - await expect.poll(() => instance.getSnapshot().byTab[TAB_ID]?.complete?.data).toEqual(new Uint8Array([0, 1, 255])) - const failure = new RemoteError('workspace-file/not-found', 'File missing', { path: FILE.path }) - workspaceFiles.readAll.mockResolvedValueOnce({ ok: false, error: failure }) + await vi.waitFor(() => { expect(instance.getSnapshot().byTab[TAB_ID]?.complete).toBeDefined() }) + expect(readBytes).toHaveBeenCalledExactlyOnceWith(FILE.sessionId, FILE.path, {}, expect.any(AbortSignal)) + expect(instance.getSnapshot().byTab[TAB_ID]?.complete?.data).toEqual(new Uint8Array([0, 1, 255])) + readBytes.mockResolvedValueOnce({ ok: false, error: new RemoteError('workspace-file/not-found', 'File missing', { path: FILE.path }) }) face.reloadAll(TAB_ID, FILE, controller.signal, 'v2') - await expect.poll(() => instance.getSnapshot().byTab[TAB_ID]?.failure).toBe(failure) + await expect.poll(() => instance.getSnapshot().byTab[TAB_ID]?.failure).toMatchObject({ code: 'workspace-file/not-found', message: 'File missing' }) expect(instance.getSnapshot().byTab[TAB_ID]?.complete).toBeUndefined() }) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx index f938754af5..f3eeace3c8 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx @@ -1,18 +1,19 @@ // @vitest-environment jsdom /** Document extension registration and dispatch through the production Sidebar and Slot renderer. */ +import { byteResult } from './fixtures.client.ts' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' import { apply as resourcesApply, inject as resourcesInject } from '@deepseek-ai/dsh-client-resources/src/client/index.ts' import { apply as sidebarApply, inject as sidebarInject } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/index.ts' import { apply, inject } from '../src/client/index.ts' import type { DocumentPreviewProps } from '../src/client/document/contract.ts' import type { DocumentLoadMode } from '../src/client/document/registry.ts' +import type { WorkspaceFilesReadRemote } from '../src/client/rpc.ts' import { PLAIN_BODY_ID } from '../src/client/text/index.ts' const SESSION = 'documents' as SessionId @@ -49,15 +50,12 @@ async function boot() { await rt.sessions.add({ id: SESSION }) await rt.sessions.retainFor(rt.ctx, SESSION, { source: 'mainView' }).ready await rt.mount({ inject: [...resourcesInject], apply: resourcesApply }) - const read = vi.fn().mockImplementation(async (_sessionId, _path, range) => ({ + const read = vi.fn().mockImplementation(async (_sessionId, _path, range) => ({ ok: true, value: { absolutePath: '/host/notes', version: 'v1', bytes: 18, offset: range.offset ?? 1, text: range.offset === 3 ? 'third' : 'first\nsecond', lines: range.offset === 3 ? 1 : 2, eof: range.offset === 3 }, })) - const bytes = vi.fn().mockResolvedValue({ - ok: true, value: { absolutePath: '/host/notes', version: 'v1', offset: 0, data: btoa('all'), bytes: 3, eof: true }, - }) - const workspaceFiles = { read, readAll: bytes } - rt.remote.provideNamespaces({ workspaceFiles }) + const bytes = vi.fn(async () => byteResult(new TextEncoder().encode('all'))) + rt.remote.provideNamespaces({ workspaceFiles: { read, readBytes: bytes } }) rt.ctx.effect(() => rt.ctx.resources.register({ protocol: 'file', open: async function* (_address, { signal }) { diff --git a/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts index 2a8a85e183..0eead1b061 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts @@ -237,6 +237,7 @@ describe('textFace', () => { expect(tab()?.complete).toBeUndefined() await settleAll(result) expect(tab()).toMatchObject({ mode: 'bytes-complete', loading: false, complete: result.value, version: 'v1', eof: true, pages: {} }) + expect(tab()?.complete?.data).toBe(result.value.data) }) it('records a complete-read failure and clears it when the read is retried', async () => { diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts b/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts index d34bd4bc5a..d71d1167b1 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts @@ -21,7 +21,7 @@ import type { WorkspaceFileStat, WorkspaceFileText } from '@deepseek-ai/dsh-api- import type { TextPreviewProps } from '../src/client/TextPreview.tsx' import { textFace } from '../src/client/face.ts' import type { TextInjected } from '../src/client/face.ts' -import type { ReadDocumentBytes, ReadWorkspaceFilePage, SessionFile } from '../src/client/rpc.ts' +import type { DocumentFileBytes, ReadDocumentBytes, ReadWorkspaceFilePage, SessionFile } from '../src/client/rpc.ts' import { createTextStore } from '../src/client/store.ts' import type { TextStore } from '../src/client/store.ts' import type { DocumentPreviewProps } from '../src/client/document/contract.ts' @@ -189,3 +189,8 @@ export function harness(script: Record> setFailure(failure) { current.failure = failure; refresh() }, } } + +/** Native byte result returned by the generated workspace Remote. */ +export function byteResult(data = new Uint8Array([0, 1, 255])): RemoteResult { + return { ok: true, value: { absolutePath: '/host/notes', version: 'v1', offset: 0, eof: true, bytes: data.byteLength, data } } +} diff --git a/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts index bcf14b5c6a..e2f7a2eff6 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts @@ -1,3 +1,4 @@ +import { byteResult } from './fixtures.client.ts' /** HTML metadata and keyed slot contributions share one identity and unwind with their fiber. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -36,8 +37,8 @@ describe('HTML registration', () => { const registry = new DocumentPreviewRegistry() const dictionaries = new Map() const bodies = new Map() - const readRelated = vi.fn().mockResolvedValue({ ok: true, value: { data: '' } }) - ctx.provide('remote', { workspaceFiles: { readRelated } } as never) + const readRelated = vi.fn(async () => byteResult()) + ctx.provide('remote', { workspaceFiles: { readBytes: readRelated } } as never) const register = vi.fn((options: Registration, body: unknown) => { bodies.set(options.key, body) return () => { bodies.delete(options.key) } @@ -62,9 +63,9 @@ describe('HTML registration', () => { expect(typeof injected?.readRelated).toBe('function') const signal = new AbortController().signal await injected?.readRelated('dsh-resource://file/session/explicit-session/sub/index.html', '../app.js', signal) - expect(readRelated).toHaveBeenLastCalledWith('explicit-session', 'sub/index.html', '../app.js', signal) + expect(readRelated).toHaveBeenLastCalledWith('explicit-session', '../app.js', { baseFile: 'sub/index.html' }, signal) await injected?.readRelated(sessionFileAddress('absolute-session', '/workspace/index.html'), './app.js', signal) - expect(readRelated).toHaveBeenLastCalledWith('absolute-session', '/workspace/index.html', './app.js', signal) + expect(readRelated).toHaveBeenLastCalledWith('absolute-session', './app.js', { baseFile: '/workspace/index.html' }, signal) expect(() => injected?.readRelated('dsh-resource://file/absolute/workspace/index.html', './app.js', signal)) .toThrow('not a session file address') expect(readRelated).toHaveBeenCalledTimes(2) diff --git a/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx index b136e6791b..fdbd5452cb 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx @@ -90,7 +90,7 @@ describe('HtmlBody', () => { h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: ABSOLUTE_PATH, version: 'root-v1', data, offset: 0, eof: true } }) const readRelated = vi.fn().mockResolvedValue({ ok: true, value: { absolutePath: `/workspace/asset.${extension}`, version: 'asset-v1', offset: 0, eof: true, - data: btoa(extension === 'css' ? 'body { color: red }' : 'window.loaded = true'), + data: utf8(extension === 'css' ? 'body { color: red }' : 'window.loaded = true'), } }) const definition = { ...htmlBodyDefinition(() => 'HTML'), extensions: ['md'] } let interactive = true @@ -111,7 +111,7 @@ describe('HtmlBody', () => { expect(create).toHaveBeenCalledOnce() readRelated.mockResolvedValueOnce({ ok: true, value: { absolutePath: `/workspace/asset.${extension}`, version: 'asset-v2', offset: 0, eof: true, - data: btoa(extension === 'css' ? 'body { color: blue }' : 'window.loaded = false'), + data: utf8(extension === 'css' ? 'body { color: blue }' : 'window.loaded = false'), } }) act(() => { dependency.set(metadata('asset-v2')) }) await waitFor(() => { expect(screen.getByTitle(en.frame)).not.toBe(previous) }) @@ -252,7 +252,7 @@ describe('HtmlBody', () => { const signal = new AbortController().signal const bytes = vi.fn().mockResolvedValue({ ok: true, - value: { absolutePath: '/workspace/app.js', data: btoa('window.ready=true'), version: 'v1', offset: 0, eof: true }, + value: { absolutePath: '/workspace/app.js', data: utf8('window.ready=true'), version: 'v1', offset: 0, eof: true }, }) const useResource = vi.fn().mockReturnValue({ value: undefined }) const initial = { @@ -275,7 +275,7 @@ describe('HtmlBody', () => { const signal = new AbortController().signal const bytes = vi.fn().mockResolvedValue({ ok: true, - value: { absolutePath: '/workspace/app.js', version: 'v1', bytes: 16, offset: 0, data: btoa('window.ready=1'), eof: true }, + value: { absolutePath: '/workspace/app.js', version: 'v1', bytes: 16, offset: 0, data: utf8('window.ready=1'), eof: true }, }) const useResource = vi.fn(() => ({ value: { version: 'v1' } })) const initial = { diff --git a/packages/client/ui-sidebar-documentpreview/tests/html-read-relative.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/html-read-relative.client.spec.ts index d043a1a579..3c6e65e2ec 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/html-read-relative.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/html-read-relative.client.spec.ts @@ -1,4 +1,4 @@ -/** HTML URL decoding stays local; ordinary Remote reads leave path resolution and authorization to the Host. */ +/** HTML URL decoding stays local; workspace reads leave path resolution and authorization to the Host. */ import { describe, expect, it, vi } from 'vitest' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' @@ -8,14 +8,14 @@ import type { ReadHtmlRelated } from '../src/client/html/read-relative.ts' const ADDRESS = 'dsh-resource://file/session/html/sub/index.html' describe('HTML relative file reader', () => { - it('decodes a relative URL once and converts the Remote result to native bytes', async () => { - const value = { absolutePath: '/workspace/a b.js', data: btoa('x'), version: 'v1', offset: 0, bytes: 1, eof: true } + it('decodes a relative URL once and preserves the returned bytes', async () => { + const value = { absolutePath: '/workspace/a b.js', data: new Uint8Array([120]), version: 'v1', offset: 0, bytes: 1, eof: true } const readRelated = vi.fn().mockResolvedValue({ ok: true, value }) const tab = new AbortController() const loading = new AbortController() const addResource = vi.fn() const read = createReadHtmlRelative(readRelated, ADDRESS, tab.signal, addResource) - await expect(read('../a%20b.js?v=1#fragment', loading.signal)).resolves.toEqual({ ...value, data: new Uint8Array([120]) }) + await expect(read('../a%20b.js?v=1#fragment', loading.signal)).resolves.toBe(value) const signal = readRelated.mock.calls[0]?.[2] expect(readRelated).toHaveBeenCalledExactlyOnceWith(ADDRESS, '../a b.js', signal) expect(addResource).toHaveBeenCalledExactlyOnceWith(sessionFileAddress('html', value.absolutePath)) @@ -25,7 +25,7 @@ describe('HTML relative file reader', () => { }) it('observes the Host-resolved dependency instead of a symlink and parent-segment spelling', async () => { - const value = { absolutePath: '/workspace/style.css', data: btoa('x'), version: 'v1', offset: 0, eof: true } + const value = { absolutePath: '/workspace/style.css', data: new TextEncoder().encode('x'), version: 'v1', offset: 0, bytes: 1, eof: true } const readRelated = vi.fn().mockResolvedValue({ ok: true, value }) const addResource = vi.fn() const signal = new AbortController().signal @@ -85,7 +85,7 @@ describe('HTML relative file reader', () => { const result = read('./late.js', loading.signal) const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }) loading.abort() - pending.resolve({ ok: true, value: { absolutePath: '/workspace/late.js', version: 'v1', bytes: 1, offset: 0, data: 'AQ==', eof: true } }) + pending.resolve({ ok: true, value: { absolutePath: '/workspace/late.js', version: 'v1', bytes: 1, offset: 0, data: new Uint8Array([1]), eof: true } }) await rejected }) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts index 98a980f49d..ffa492509f 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts @@ -55,7 +55,7 @@ async function harness(config: Partial = {}, missing?: 'remote const render = vi.fn().mockResolvedValue(converted) const rendererGeneration = vi.fn().mockResolvedValue({ ok: true, value: generation }) const stat = vi.fn().mockResolvedValue({ ok: true, value: source }) - const readBytes = vi.fn().mockResolvedValue({ ok: true, value: source }) + const readBytes = vi.fn().mockResolvedValue({ ok: true, value: { ...source, data: pdf.subarray(0, 1), eof: false } }) const removeNotice = vi.fn() const recorded: { options: { name: string; store: OfficeStore; inject: (id: SessionId, actions: ReturnType['actions']) => OfficeBodyInjected }; component: unknown }[] = [] const register = vi.fn((options: typeof recorded[number]['options'], component: unknown) => { recorded.push({ options, component }); return removeNotice }) @@ -112,7 +112,7 @@ it('requests a Host PDF with source identity and borrows the same binary cache r expect(await h.read()).toBe(result) expect(h.stat).toHaveBeenCalledTimes(2) expect(h.readBytes).toHaveBeenCalledTimes(2) - expect(h.readBytes).toHaveBeenCalledWith(file.sessionId, file.path, { offset: 0, length: 1 }, expect.any(AbortSignal)) + expect(h.readBytes).toHaveBeenCalledWith(file.sessionId, file.path, { range: { offset: 0, length: 1 } }, expect.any(AbortSignal)) expect(h.render).toHaveBeenCalledOnce() } finally { await h.close() } }) @@ -147,8 +147,8 @@ it('refuses Client cached bytes when metadata still succeeds but the read probe try { await h.read() const failure = new Error('read denied') - h.readBytes.mockRejectedValueOnce(failure) - await expect(h.read()).rejects.toBe(failure) + h.readBytes.mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', failure.message, {}) }) + expect(await h.read()).toMatchObject({ ok: false, error: { code: 'gateway/internal', message: failure.message } }) expect(h.render).toHaveBeenCalledOnce() expect(h.stat).toHaveBeenCalledOnce() } finally { await h.close() } @@ -160,7 +160,7 @@ it('returns a declared authorization failure without consulting metadata or cach await h.read() const denied = { ok: false as const, error: new RemoteError('workspace-file/not-found', 'File missing', { path: file.path }) } h.readBytes.mockResolvedValueOnce(denied) - expect(await h.read()).toBe(denied) + expect(await h.read()).toMatchObject(denied) expect(h.stat).toHaveBeenCalledOnce() expect(h.render).toHaveBeenCalledOnce() } finally { await h.close() } diff --git a/packages/client/ui-sidebar-documentpreview/tests/rpc.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/rpc.client.spec.ts index 0657e1aaf4..a7a6a74f5e 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/rpc.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/rpc.client.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it, vi } from 'vitest' import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' -import { createReadPage, documentFileBytes, hostFileOf } from '../src/client/rpc.ts' +import { createReadPage, hostFileOf } from '../src/client/rpc.ts' import type { ReadWorkspaceFilePage, WorkspaceFilesReadRemote } from '../src/client/index.ts' import { ADDRESS, FILE, PATH, SESSION, page } from './fixtures.client.ts' @@ -39,17 +39,3 @@ describe('createReadPage', () => { expect(read).toHaveBeenCalledWith(SESSION, PATH, { offset: 4 }, signal) }) }) - -describe('documentFileBytes', () => { - it.each(['', 'AAH/'])('decodes complete Remote bytes without changing metadata (%j)', (data) => { - const file = { absolutePath: '/workspace/a.bin', version: 'v1', bytes: 3, offset: 0, data, eof: true } - const result = documentFileBytes(file) - expect(result).toEqual({ ...file, data: data === '' ? new Uint8Array() : new Uint8Array([0, 1, 255]) }) - expect(result.data.buffer).toBeInstanceOf(ArrayBuffer) - expect(file.data).toBe(data) - }) - - it('rejects malformed wire base64', () => { - expect(() => documentFileBytes({ absolutePath: '/workspace/a.bin', version: 'v1', offset: 0, data: '!!!', eof: true })).toThrow() - }) -}) diff --git a/packages/document/office-to-pdf/src/index.ts b/packages/document/office-to-pdf/src/index.ts index fbcff20e1a..f8d3b993c9 100644 --- a/packages/document/office-to-pdf/src/index.ts +++ b/packages/document/office-to-pdf/src/index.ts @@ -187,7 +187,7 @@ export class OfficeToPdf extends TypertRemoteService { const files = this.ctx.get('workspaceFiles') const fs = this.ctx.get('fs') if (files === undefined || fs === undefined) throw new OfficeToPdfError('unavailable', 'Office file rendering requires workspaceFiles and fs.') - const authorized = await files.readBytes(scope, path, { offset: 0, length: 1 }, signal) + const authorized = await files.readBytes(scope, path, { range: { offset: 0, length: 1 } }, signal) const source = await files.stat(scope, path, signal) const assertUnchanged = (current: WorkspaceFileStat): void => { if (current.absolutePath !== source.absolutePath || current.version !== source.version) { diff --git a/packages/document/office-to-pdf/src/types.ts b/packages/document/office-to-pdf/src/types.ts index a1123a5ba1..5138dad52c 100644 --- a/packages/document/office-to-pdf/src/types.ts +++ b/packages/document/office-to-pdf/src/types.ts @@ -45,7 +45,9 @@ export type OfficeToPdfErrorCode = | 'invalid-output' | 'timeout' | 'unavailable' | 'failed' | 'busy' | 'source-changed' /** PDF content carries the original Office file's absolute path and version. */ -export interface RenderedDocumentBytes extends WorkspaceFileBytes { +export interface RenderedDocumentBytes extends Omit { + /** Base64 PDF contents carried by the JSON Remote response. */ + readonly data: string readonly missingFonts: string[] readonly generation: OfficeToPdfGeneration } diff --git a/packages/document/office-to-pdf/tests/remote.spec.ts b/packages/document/office-to-pdf/tests/remote.spec.ts index 542dc1a333..4029f3ea74 100644 --- a/packages/document/office-to-pdf/tests/remote.spec.ts +++ b/packages/document/office-to-pdf/tests/remote.spec.ts @@ -31,7 +31,7 @@ beforeEach(async () => { fileInfo = vi.fn().mockResolvedValue(sourceInfo) processPath = vi.fn().mockReturnValue(source.absolutePath) ctx.provide('fs', { resolve: vi.fn().mockResolvedValue(target), stat: fileInfo, processPath, readBytes: read } as never) - authorize = vi.fn().mockResolvedValue(wireSource) + authorize = vi.fn().mockResolvedValue({ ...wireSource, eof: false, data: rawSource.data.subarray(0, 1) }) metadata = vi.fn().mockResolvedValue(source) await ctx.plugin(OfficeToPdf) generation = ctx.officeToPdf.generation @@ -48,7 +48,7 @@ it.each(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'])('converts authorized %s b const path = `report.${extension.toUpperCase()}` const result = await ctx.officeToPdf.render(scope, path, 'foreground', new AbortController().signal) expect(read).toHaveBeenCalledExactlyOnceWith(target, expect.any(AbortSignal), 4) - expect(authorize).toHaveBeenCalledExactlyOnceWith(scope, path, { offset: 0, length: 1 }, expect.any(AbortSignal)) + expect(authorize).toHaveBeenCalledExactlyOnceWith(scope, path, { range: { offset: 0, length: 1 } }, expect.any(AbortSignal)) expect(render).toHaveBeenCalledOnce() expect(render.mock.calls[0]?.[0]).toMatchObject({ extension, priority: 'foreground', source: { version: source.version, bytes: 4 } }) expect(result).toEqual({ ...wireSource, data: Buffer.from(pdf).toString('base64'), bytes: pdf.length, missingFonts: ['Missing Serif'], generation }) diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 96a6f4432f..3f353ad6d9 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/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/host/webserver/README.md -README.md: df1881a3a16b69f61127956127c7806c70793f91 -README.zh.md: 1d07d43ac97c616c9b7450da71ad09cb55fa4267 +README.md: 9dd698404719eecdf9fb4c40720ccc74ab37be9d +README.zh.md: d4701b79e432dfa3e8ba8485f8644758ec3b34d7 diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index df1881a3a1..9dd6984047 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -38,7 +38,7 @@ Compose the webserver as the HTTP transport of a browser-facing host, then let t `host` accepts exactly two values: `127.0.0.1` (default posture, loopback only) and `0.0.0.0` (deliberate network exposure — the server carries no TLS, authentication, or origin policy of its own). `port` 0 requests an OS-assigned port; `ctx.webServer.port` reads the listening port afterwards. -Set `compression: 'gzip'` to wrap eligible socket-backed responses without changing route APIs. The client must accept gzip and the media type must be compressible; known response lengths below `compressionThresholdBytes` remain uncompressed, while unknown-length streams are eligible immediately. Existing encodings, `Cache-Control: no-transform`, range responses, SSE, ZIP, and the packaged `.gz` Worker image remain unchanged. The shipped Web bundle uses compression level 1 with a 1024-byte threshold; other compositions default to no compression. +Set `compression: 'gzip'` to wrap eligible socket-backed responses without changing route APIs. The client must accept gzip and the media type must be compressible or `multipart/form-data`; known response lengths below `compressionThresholdBytes` remain uncompressed, while unknown-length streams are eligible immediately. Existing encodings, `Cache-Control: no-transform`, range responses, SSE, ZIP, and the packaged `.gz` Worker image remain unchanged. The shipped Web bundle uses compression level 1 with a 1024-byte threshold; other compositions default to no compression. ### Registering routes diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 1d07d43ac9..d4701b79e4 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -38,7 +38,7 @@ kind: "package-reference" `host` 只接受两个值:`127.0.0.1`(默认姿态,仅回环)与 `0.0.0.0`(有意向网络开放——服务器自身不携带 TLS、认证或来源策略)。`port` 为 0 时请求 OS 分配端口;之后用 `ctx.webServer.port` 读取正在监听的端口。 -设置 `compression: 'gzip'` 可以包装符合条件的 socket-backed 响应,而不改变 route API。客户端必须接受 gzip,且媒体类型必须可压缩;已知长度小于 `compressionThresholdBytes` 的响应保持未压缩,未知长度的流则立即符合条件。已有编码、`Cache-Control: no-transform`、range 响应、SSE(Server-Sent Events)、ZIP 与已打包的 `.gz` Worker image 均保持不变。随附 Web bundle 使用 level 1 与 1024 字节阈值;其他组合默认不压缩。 +设置 `compression: 'gzip'` 可以包装符合条件的 socket-backed 响应,而不改变 route API。客户端必须接受 gzip,且媒体类型必须可压缩或为 `multipart/form-data`;已知长度小于 `compressionThresholdBytes` 的响应保持未压缩,未知长度的流则立即符合条件。已有编码、`Cache-Control: no-transform`、range 响应、SSE(Server-Sent Events)、ZIP 与已打包的 `.gz` Worker image 均保持不变。随附 Web bundle 使用 level 1 与 1024 字节阈值;其他组合默认不压缩。 ### 注册路由 diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 7443d04211..5b490fc770 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -95,6 +95,7 @@ function createGzipMiddleware(config: ResolvedConfig): NodeMiddleware { if (response.getHeader('content-range') !== undefined) return false const contentType = response.getHeader('content-type') if (typeof contentType === 'string' && contentType.toLowerCase().startsWith('text/event-stream')) return false + if (typeof contentType === 'string' && /^multipart\/form-data(?:;|$)/i.test(contentType)) return true return compressionMiddleware.filter(request, response) }, }) as NodeMiddleware diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index c5841ecac6..7b0c085cd5 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/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/typert/generator/README.md -README.md: aa7fd3d6e068ea6532d55de04970ab3e28b74285 -README.zh.md: e911feac821deda1d0876c26c575dcff5cd2a741 +README.md: 9c207574e9bb576a965ab1a91576a3b5a9db397e +README.zh.md: 9a6fa9e81d7950efb4d4b76df6921bc5a83b6adf diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md index aa7fd3d6e0..9c207574e9 100644 --- a/packages/typert/generator/README.md +++ b/packages/typert/generator/README.md @@ -85,6 +85,8 @@ Host and Client are independent TypeScript programs. Direct project references e `FaceModelEmitter` emits executable JavaScript containing success-cached Zod schema factories and the `TYPERT` contribution, plus a declaration file whose factories return `z.ZodType` through the package's public export; unsupported Zod projections fail. The Host face with Remote methods additionally emits `typert.remote-client.*` projections of Host Remote contracts for the Client. `WorkspaceTypertGenerator` validates each contributor's `package.json`: `./typert` and `./client/typert` (and `./remote` when Remote methods exist) must point at the exact generated files, and the `files` list must include them. +[Unary binary results](../protocol/README.md) are recognized after generic type resolution. Their codecs validate the byte-array type and JSON metadata without iterating, copying, or freezing the byte payload; generated Client declarations retain the authored metadata and narrow the byte backing to `ArrayBuffer`. + ### Catalog projection The runtime type closure indexes exported workspace declarations and referenced framework enums. Vendored declarations stay outside business API discovery; their enums remain available when a public result refers to them. diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index e911feac82..9a6fa9e81d 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -85,6 +85,8 @@ Host 与 Client 是两个独立的 TypeScript 程序。直接项目引用确定 `FaceModelEmitter` 输出包含只缓存成功结果的 Zod schema factory 与 `TYPERT` 贡献的可执行 JavaScript,以及把 factory 通过包的公开导出标注为返回 `z.ZodType` 的声明文件;不支持的 Zod 投影会失败。含 Remote 方法的 Host face 还会额外为 Client 生成 Host Remote 约定的 `typert.remote-client.*` 投影。`WorkspaceTypertGenerator` 校验每个贡献方的 `package.json`:`./typert` 与 `./client/typert`(存在 Remote 方法时还有 `./remote`)必须指向精确的生成文件,且 `files` 清单必须包含它们。 +[一元二进制结果](../protocol/README.zh.md)在泛型类型解析后被识别。其 codec 校验字节数组类型与 JSON 元数据,不遍历、复制或冻结字节载荷;生成的 Client 声明保留原有元数据类型,并将字节的底层缓冲区收窄为 `ArrayBuffer`。 + ### 目录投影 运行时类型闭包索引导出的 workspace 声明和被引用的框架 enum。Vendor 声明不参与业务 API 发现;公共结果引用其中 enum 时,目录仍提供其定义。 diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 7388d6c6b1..726828f0ed 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -1226,6 +1226,8 @@ class FaceAnalyzer { `${registration.name}#${binding.namespace}/${exportedMethod}:result`, false, 'undefined-or-void', + false, + mode === undefined, ), location: this.location(method.name), } @@ -1511,6 +1513,7 @@ class FaceAnalyzer { requireNamed: boolean, topLevelAbsence: 'reject' | 'undefined' | 'undefined-or-void' = 'reject', optional = false, + allowBytes = false, ): RemoteBoundaryModel { const type = this.convertType(authoredType) const declaredType = this.checker.getTypeFromTypeNode(authoredType) @@ -1519,7 +1522,7 @@ class FaceAnalyzer { const resolvedType = optional ? this.checker.getNullableType(declaredType, ts.TypeFlags.Undefined) : declaredType - const codecType = this.resolvedRemoteCodecType(authoredType, resolvedType, topLevelAbsence) + const codecType = this.resolvedRemoteCodecType(authoredType, resolvedType, topLevelAbsence, allowBytes) const acceptsUndefined = topLevelAbsence !== 'reject' && this.includesRemoteAbsence(resolvedType) const rootSymbol = this.namedWorkspaceType(authoredType) const imports = new Map() @@ -1575,6 +1578,7 @@ class FaceAnalyzer { authoredType: ts.TypeNode, resolvedType: ts.Type, topLevelAbsence: 'reject' | 'undefined' | 'undefined-or-void', + allowBytes: boolean, ): TypeNodeId { this.assertRemoteJsonType( resolvedType, @@ -1582,6 +1586,7 @@ class FaceAnalyzer { new Set(), topLevelAbsence !== 'reject', topLevelAbsence === 'undefined-or-void', + allowBytes, ) const completed = new Map() const active = new Map() @@ -1617,6 +1622,9 @@ class FaceAnalyzer { return id } const flags = type.flags + if (this.isRemoteByteArray(type)) { + return add({ kind: 'reference', name: 'Uint8Array', target: { kind: 'standard', name: 'Uint8Array' }, arguments: [] }) + } if ((flags & ts.TypeFlags.Any) !== 0) return add({ kind: 'keyword', name: 'any' }) if ((flags & ts.TypeFlags.Unknown) !== 0) return add({ kind: 'keyword', name: 'unknown' }) if ((flags & ts.TypeFlags.Never) !== 0) return add({ kind: 'keyword', name: 'never' }) @@ -1654,7 +1662,7 @@ class FaceAnalyzer { if ((flags & ts.TypeFlags.TypeParameter) !== 0) { this.fail(authoredType, 'Remote codec contains an unresolved type parameter') } - if ((flags & ts.TypeFlags.Object) === 0) { + if ((flags & (ts.TypeFlags.Object | ts.TypeFlags.Intersection)) === 0) { this.fail( authoredType, `Remote codec type ${this.checker.typeToString(type, authoredType, ts.TypeFormatFlags.NoTruncation)} has no concrete Zod projection`, @@ -1669,7 +1677,9 @@ class FaceAnalyzer { elements: arguments_.map((argument, index) => { const elementFlags = target.elementFlags[index] ?? ts.ElementFlags.Required return { - type: convert(argument), + type: (elementFlags & ts.ElementFlags.Rest) !== 0 + ? this.addNode(authoredType, { kind: 'array', element: convert(argument) }) + : convert(argument), optional: (elementFlags & ts.ElementFlags.Optional) !== 0, rest: (elementFlags & (ts.ElementFlags.Rest | ts.ElementFlags.Variadic)) !== 0, } @@ -1748,6 +1758,7 @@ class FaceAnalyzer { active: Set, allowUndefined: boolean, allowVoid: boolean, + allowBytes = false, ): void { const flags = type.flags if ((flags & ts.TypeFlags.Undefined) !== 0 && allowUndefined) return @@ -1763,22 +1774,26 @@ class FaceAnalyzer { | ts.TypeFlags.BooleanLike | ts.TypeFlags.Null | ts.TypeFlags.Never)) !== 0) return + if (this.isRemoteByteArray(type)) { + if (allowBytes) return + this.fail(site, 'Remote Uint8Array is only supported in unary results') + } if (type.isUnion()) { for (const member of type.types) { - this.assertRemoteJsonType(member, site, active, allowUndefined, allowVoid) + this.assertRemoteJsonType(member, site, active, allowUndefined, allowVoid, allowBytes) } return } if (type.isIntersection()) { const material = type.types.filter(member => !this.isRemotePhantomConstraint(member)) if (material.length === 0) this.fail(site, 'Remote boundary contains a symbol-only object') - for (const member of material) this.assertRemoteJsonType(member, site, active, false, false) + for (const member of material) this.assertRemoteJsonType(member, site, active, false, false, allowBytes) return } if ((flags & ts.TypeFlags.TypeParameter) !== 0) { this.fail(site, 'Remote boundary contains an unresolved type parameter') } - if ((flags & ts.TypeFlags.Object) === 0) { + if ((flags & (ts.TypeFlags.Object | ts.TypeFlags.Intersection)) === 0) { this.fail(site, `Remote boundary contains non-JSON type ${this.checker.typeToString(type)}`) } const symbol = type.getSymbol() @@ -1804,6 +1819,7 @@ class FaceAnalyzer { active, (elementFlags & ts.ElementFlags.Optional) !== 0, false, + allowBytes, ) }) return @@ -1811,7 +1827,7 @@ class FaceAnalyzer { if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) { const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number) if (element === undefined) this.fail(site, 'Remote boundary array has no element type') - this.assertRemoteJsonType(element, site, active, false, false) + this.assertRemoteJsonType(element, site, active, false, false, allowBytes) return } const properties = this.checker.getPropertiesOfType(type) @@ -1827,19 +1843,26 @@ class FaceAnalyzer { active, (property.flags & ts.SymbolFlags.Optional) !== 0, false, + allowBytes, ) } for (const info of this.checker.getIndexInfosOfType(type)) { if ((info.keyType.flags & ts.TypeFlags.ESSymbolLike) !== 0) { this.fail(site, 'Remote boundary contains a symbol index signature') } - this.assertRemoteJsonType(info.type, site, active, false, false) + this.assertRemoteJsonType(info.type, site, active, false, false, allowBytes) } } finally { active.delete(type) } } + private isRemoteByteArray(type: ts.Type): boolean { + const symbol = type.getSymbol() + return symbol?.name === 'Uint8Array' + && symbol.declarations?.some(declaration => isStandardLibraryFile(declaration.getSourceFile().fileName)) === true + } + private includesRemoteAbsence(type: ts.Type): boolean { if ((type.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0) return true return type.isUnion() && type.types.some(member => this.includesRemoteAbsence(member)) diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index f7c1beaf71..78907abd5f 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -22,6 +22,7 @@ import type { TypeNodeModel, } from './model.ts' import { TypeGraphRenderer } from './renderer.ts' +import { childTypeNodeIds } from './model.ts' /** Failure to project a modeled construct into an emitted artifact. */ export class TypertEmitError extends Error { @@ -331,6 +332,7 @@ export class FaceModelEmitter { lines.push(` result: ${indent(strictCodec( invocation.result, schemas.boundary(resultBoundaryKey(invocation)), + schemas.encoder(resultBoundaryKey(invocation)), ), 2).trimStart()},`) lines.push(` sourceLocation: ${JSON.stringify(invocation.location)},`) lines.push('}') @@ -366,6 +368,12 @@ export class FaceModelEmitter { lines.push(`import type { ${names.join(', ')} } from ${quote(specifier)}`) } lines.push('') + if (packageModel.invocations.some(invocation => containsBytes(this.renderer, invocation.result.codecType))) { + lines.push('type RemoteDecoded = Value extends Uint8Array ? Uint8Array') + lines.push(' : Value extends string | number | boolean | null | undefined ? Value') + lines.push(' : { [Key in keyof Value]: RemoteDecoded }') + lines.push('') + } lines.push('declare module \'@deepseek-ai/dsh-typert-protocol\' {') const direct = packageModel.invocations.filter(invocation => invocation.invocation.kind === 'direct') const scoped = packageModel.invocations.filter(invocation => @@ -478,7 +486,10 @@ export class FaceModelEmitter { !scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter => `${safeIdentifier(parameter.wire)}${parameter.optional === true ? '?' : ''}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal') - const result = this.renderer.renderType(invocation.result.type, referenceNames) + const authoredResult = this.renderer.renderType(invocation.result.type, referenceNames) + const result = containsBytes(this.renderer, invocation.result.codecType) + ? `RemoteDecoded<${authoredResult}>` + : authoredResult if (invocation.mode === 'stream') { const uplink = invocation.uplink === undefined ? 'never' @@ -519,6 +530,7 @@ interface SchemaArtifact { readonly definitions: readonly string[] readonly exports: readonly SchemaExport[] boundary(key: string): string + encoder(key: string): string | undefined } interface BoundarySchemaRoot { @@ -526,6 +538,118 @@ interface BoundarySchemaRoot { readonly type: TypeNodeId } +class ResultEncoderEmitter { + private readonly names = new Map() + private readonly bodies = new Map() + + constructor(private readonly renderer: TypeGraphRenderer) {} + + reference(id: TypeNodeId): string | undefined { + if (!containsBytes(this.renderer, id)) return undefined + const existing = this.names.get(id) + if (existing !== undefined) return existing + const name = `$encode${this.names.size}` + this.names.set(id, name) + const node = this.renderer.node(id) + const container = node.kind === 'object' || node.kind === 'array' || node.kind === 'tuple' + const body = container || node.kind === 'union' || node.kind === 'intersection' + ? `return $resultContainer(value, path, ancestors, value => {\n${indent(this.body(id), 2)}\n}, ${container})` + : this.body(id) + this.bodies.set(id, `const ${name} = (value, writeBytes, path, ancestors) => {\n${indent(body, 2)}\n}`) + return name + } + + definitions(): string[] { + return this.bodies.size === 0 ? [] : [RESULT_SNAPSHOT, ...this.bodies.values()] + } + + private call(id: TypeNodeId, value: string, path = 'path'): string { + const reference = this.reference(id) + return reference === undefined ? value : `${reference}(${value}, writeBytes, ${path}, ancestors)` + } + + private body(id: TypeNodeId): string { + const node = this.renderer.node(id) + switch (node.kind) { + case 'reference': { + if (node.target.kind === 'standard' && node.target.name === 'Uint8Array') { + return 'return value instanceof Uint8Array ? writeBytes(value, path) : value' + } + if (node.target.kind === 'declaration') { + const type = this.renderer.declaration(node.target.symbol).type + if (type !== undefined) return `return ${this.call(type, 'value')}` + } + break + } + case 'union': + case 'intersection': return [ + ...node.types.filter(type => containsBytes(this.renderer, type)).map(type => `value = ${this.call(type, 'value')}`), + 'return value', + ].join('\n') + case 'array': return [ + 'if (!Array.isArray(value)) return value', + `for (let index = 0; index < value.length; index++) value[index] = ${this.call(node.element, 'value[index]', '[...path, index]')}`, + 'return value', + ].join('\n') + case 'tuple': { + const lines = ['if (!Array.isArray(value)) return value'] + for (const [index, element] of node.elements.entries()) { + if (!containsBytes(this.renderer, element.type)) continue + if (element.rest) { + const rest = this.renderer.node(element.type) + if (rest.kind !== 'array') break + lines.push(`for (let index = ${index}; index < value.length; index++) value[index] = ${this.call(rest.element, 'value[index]', '[...path, index]')}`) + } else { + lines.push(`if (value.length > ${index}) value[${index}] = ${this.call(element.type, `value[${index}]`, `[...path, ${index}]`)}`) + } + } + return [...lines, 'return value'].join('\n') + } + case 'object': { + const lines = ["if (value === null || typeof value !== 'object' || Array.isArray(value) || value instanceof Uint8Array) return value"] + for (const member of node.members) { + if (member.kind === 'property' && containsBytes(this.renderer, member.type)) { + const key = quote(member.name) + lines.push(`if (Object.hasOwn(value, ${key})) value[${key}] = ${this.call(member.type, `value[${key}]`, `[...path, ${key}]`)}`) + } else if (member.kind === 'index' && containsBytes(this.renderer, member.signature.returns)) { + lines.push(`for (const key of Object.keys(value)) value[key] = ${this.call(member.signature.returns, 'value[key]', '[...path, key]')}`) + } + } + return [...lines, 'return value'].join('\n') + } + default: break + } + throw new TypertEmitError(`typert result encoder: ${node.kind} has no binary field projection`) + } +} + +const RESULT_SNAPSHOT = `const $resultSnapshot = (input, path) => { + if (input === null || typeof input !== 'object' || input instanceof Uint8Array) return input + const toJSON = input.toJSON + const value = typeof toJSON === 'function' ? toJSON.call(input, path.at(-1)?.toString() ?? 'value') : input + if (value === null || typeof value !== 'object' || value instanceof Uint8Array) return value + if (Array.isArray(value)) { + const items = [] + for (let index = 0, length = value.length; index < length; index++) items.push(value[index]) + return items + } + const fields = {} + for (const key of Object.keys(value)) { + const item = key === 'toJSON' && value === input ? toJSON : value[key] + if (key === 'toJSON' && typeof item === 'function') continue + Object.defineProperty(fields, key, { value: item, enumerable: true, writable: true, configurable: true }) + } + return fields +} +const $resultContainer = (input, path, ancestors, project, snapshot) => { + if (input === null || typeof input !== 'object') return project(input) + const owner = !ancestors.has(input) + if (!owner && ancestors.get(input) !== path.length) throw new TypeError('Remote result contains a circular object') + if (owner) ancestors.set(input, path.length) + try { return project(snapshot ? $resultSnapshot(input, path) : input) } + finally { if (owner) ancestors.delete(input) } +}` + class SchemaEmitter { private readonly names = new Map() private readonly boundaryNames = new Map() @@ -569,10 +693,15 @@ class SchemaEmitter { emit(): SchemaArtifact { const definitions = this.declarations.map(declaration => this.declarationDefinition(declaration)) + const encoder = new ResultEncoderEmitter(this.renderer) + const encoders = new Map() for (const boundary of this.boundaries) { + const encode = encoder.reference(boundary.type) + if (encode !== undefined) encoders.set(boundary.key, encode) const name = this.boundaryName(boundary.key) definitions.push(`let ${name}$value\nconst ${name} = () => (${name}$value ??= ${this.typeSchema(boundary.type)})`) } + definitions.push(...encoder.definitions()) const exports = this.schemas.map((model): SchemaExport => ({ model, exportName: safeIdentifier(model.export.name), @@ -582,6 +711,7 @@ class SchemaEmitter { definitions, exports, boundary: key => this.boundaryName(key), + encoder: key => encoders.get(key), } } @@ -699,6 +829,7 @@ class SchemaEmitter { return `z.record(${this.typeSchema(key, substitutions)}, ${this.typeSchema(value, substitutions)})` } case 'Date': return 'z.date()' + case 'Uint8Array': return 'z.instanceof(Uint8Array)' default: this.fail(node.name, `standard type ${node.target.name} has no Zod projection`) } } @@ -773,8 +904,11 @@ class SchemaEmitter { continue } if (member.kind !== 'property') this.fail(subject, `${member.kind} member ${member.name} is not data-schema projectable`) + // Freezing a union may freeze its populated Uint8Array member. + const schema = this.readonly(this.typeSchema(member.type, substitutions), + member.readonly && !containsBytes(this.renderer, member.type, false)) const property = this.describe( - this.optional(this.readonly(this.typeSchema(member.type, substitutions), member.readonly), member.optional), + this.optional(schema, member.optional), member, ) properties.push(`${quote(member.jsonName ?? member.name)}: ${property}`) @@ -890,12 +1024,40 @@ function resultBoundaryKey(invocation: InvocationModel): string { return `${invocation.id}:result` } -function strictCodec(boundary: RemoteBoundaryModel, schema: string): string { +function containsBytes( + renderer: TypeGraphRenderer, + id: TypeNodeId, + nested = true, + visited = new Set(), +): boolean { + if (visited.has(id)) return false + visited.add(id) + const node = renderer.node(id) + if (node.kind === 'reference') { + if (node.target.kind === 'standard' && node.target.name === 'Uint8Array') return true + if (node.target.kind === 'declaration') { + const declaration = renderer.declaration(node.target.symbol) + if (declaration.type !== undefined && containsBytes(renderer, declaration.type, nested, visited)) return true + } + if (!nested) return false + } + if (!nested && node.kind !== 'reference' && node.kind !== 'parenthesized' + && node.kind !== 'union' && node.kind !== 'intersection') return false + if (node.kind === 'object' && node.members.some(member => + containsBytes(renderer, member.kind === 'property' ? member.type : member.signature.returns, nested, visited))) return true + return childTypeNodeIds(node).some(child => containsBytes(renderer, child, nested, visited)) +} + +function strictCodec(boundary: RemoteBoundaryModel, schema: string, encoder?: string): string { return [ '{', ' mode: \'strict\',', ` typeSymbol: ${quote(boundary.typeSymbol)},`, ` create: ${schema},`, + ...(encoder === undefined ? [] : [ + ` decode: value => ${schema}().parse(value),`, + ` encode: (value, writeBytes) => ${encoder}(value, writeBytes, [], new Map()),`, + ]), '}', ].join('\n') } @@ -923,7 +1085,13 @@ function remoteImports(invocations: readonly InvocationModel[]): RemoteTypeImpor } function allocateRemoteImportNames(imports: readonly RemoteTypeImportModel[]): ReadonlyMap { - const used = new Set(['RemoteStreamHandle', 'TypertRemoteContribution', 'TYPERT_REMOTE']) + const used = new Set([ + 'RemoteDecoded', + 'RemoteResult', + 'RemoteStreamHandle', + 'TypertRemoteContribution', + 'TYPERT_REMOTE', + ]) const names = new Map() for (const imported of imports) { const base = safeIdentifier(imported.name) diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index e291df692c..e8e69be85f 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -15,7 +15,7 @@ function normalizedPath(path: string): string { } interface RuntimeSchema { - safeParse(value: unknown): { readonly success: boolean } + safeParse(value: unknown): { readonly success: boolean; readonly data?: unknown } } interface RuntimeDescriptor { @@ -30,7 +30,11 @@ interface RuntimeDescriptor { readonly uplink?: { readonly codec: { readonly create: () => RuntimeSchema } } - readonly result: { readonly create: () => RuntimeSchema } + readonly result: { + readonly create: () => RuntimeSchema + readonly decode?: (value: unknown) => unknown + readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown + } } interface RuntimeRemoteModule { @@ -51,6 +55,234 @@ afterEach(() => { }) describe('Remote model generation', { timeout: 60_000 }, () => { + it('projects a generic binary result without copying or freezing its bytes', async () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/types.ts', source => `${source}\nexport type BinaryFile = {\n readonly data: Data\n} & { readonly size: number }\n`) + editFile(root, 'packages/remote/src/index.ts', source => `import type { BinaryFile } from './types.ts'\n${source.replace( + '\n}\n\nexport type {', + '\n @Remote\n bytes(): BinaryFile { return { data: new Uint8Array([0, 255]), size: 2 } }\n}\n\nexport type {', + )}`) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + expect(artifact?.js).not.toContain('binaryResult') + expect(artifact?.remote?.dts).toContain('Promise>>') + const generated = await import(`data:text/javascript,${encodeURIComponent( + (artifact?.remote?.js ?? '').replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`), + )}`) as RuntimeRemoteModule + const descriptor = generated.TYPERT_REMOTE.descriptors.find(invocation => invocation.id.endsWith('/bytes')) + expect(descriptor?.result.decode).toBeTypeOf('function') + if (descriptor === undefined) throw new Error('binary descriptor missing') + const schema = descriptor.result.create() + for (const data of [new Uint8Array(), new Uint8Array([0, 128, 255]), new Uint8Array(new SharedArrayBuffer(3))]) { + const parsed = schema.safeParse({ data, size: data.length }) + expect(parsed.success).toBe(true) + expect((parsed.data as { readonly data: Uint8Array }).data).toBe(data) + expect(Object.isFrozen(data)).toBe(false) + } + expect(schema.safeParse({ data: 'AP8=', size: 2 }).success).toBe(false) + expect(schema.safeParse({ data: [0, 255], size: 2 }).success).toBe(false) + expect(schema.safeParse({ data: new Uint8Array(), size: '0' }).success).toBe(false) + expect(schema.safeParse({ size: 0 }).success).toBe(false) + assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root, ` +async function readBytes() { + const result = await ctx.remote.goals.bytes() + if (!result.ok) return + const data: Uint8Array = result.value.data + new Blob([data]) + // @ts-expect-error binary results do not carry base64 strings. + const base64: string = result.value.data + // @ts-expect-error the Client bytes have ordinary ArrayBuffer backing. + const shared: Uint8Array = result.value.data + void base64 + void shared +} +void readBytes +`) + }) + + it.each([ + ['parameter', '@Remote\n bytes(data: Uint8Array): number { return data.length }'], + ['nested parameter', '@Remote\n bytes(files: { content: Uint8Array }[]): number { return files.length }'], + ['binary stream', "@Remote({ mode: 'stream' })\n async *bytes(): AsyncIterable<{ data: Uint8Array }> { throw new Error() }"], + ])('rejects unsupported binary %s', (_name, method) => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace( + '\n}\n\nexport type {', `\n ${method}\n}\n\nexport type {`, + )) + expect(() => new WorkspaceTypertGenerator(root).generate()) + .toThrow('Remote Uint8Array is only supported in unary results') + }) + + it.each([ + ['undefined data', '{ data: Uint8Array | undefined }', 'Remote boundary contains non-JSON type undefined'], + ['unconstrained metadata', '{ data: Uint8Array; meta: unknown }', 'Remote boundary contains unconstrained unknown data'], + ])('rejects binary results with %s', (_name, type, message) => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source.replace( + '\n}\n\nexport type {', `\n @Remote\n bytes(): ${type} { throw new Error() }\n}\n\nexport type {`, + )) + expect(() => new WorkspaceTypertGenerator(root).generate()).toThrow(message) + }) + + it('decodes recursive objects, arrays, tuples, unions and root bytes by their field types', async () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/types.ts', source => `${source} +declare const fileNameBrand: unique symbol +export type FileName = string & { readonly [fileNameBrand]: true } +export type LinkedBytes = { readonly content: Uint8Array } & { readonly next?: LinkedBytes } +export interface BinaryTree { + readonly name: FileName + readonly content?: Uint8Array | null + readonly files: readonly BinaryTree[] + readonly pair: readonly [Uint8Array, { readonly raw: Uint8Array }?] + readonly tail: readonly [string, ...Uint8Array[]] + readonly choice: Uint8Array | { readonly preview: Uint8Array } + readonly variants: ({ readonly content: Uint8Array } & { readonly content: Uint8Array; readonly size: number })[] + readonly chunks: Readonly> +} +`) + editFile(root, 'packages/remote/src/index.ts', source => `import type { BinaryTree, LinkedBytes } from './types.ts'\n${source.replace( + '\n}\n\nexport type {', ` + @Remote + tree(): BinaryTree { throw new Error() } + @Remote + linked(): LinkedBytes { throw new Error() } + @Remote + raw(): Uint8Array { throw new Error() } + @Remote + nullable(): Uint8Array | null { throw new Error() } + @Remote + maybe(): Uint8Array | void { throw new Error() } + @Remote + array(): readonly Uint8Array[] { throw new Error() } + @Remote + envelope(): { readonly content?: Uint8Array; readonly metadata: { readonly title: string } } { throw new Error() } + @Remote + overlap(): { content: Uint8Array } | { content: Uint8Array; title: string } { throw new Error() } + @Remote + objectOrBytes(): { content: Uint8Array } | Uint8Array { throw new Error() } +}\n\nexport type {`, + )}`) + const [artifact] = new WorkspaceTypertGenerator(root).generate() + const generated = await import(`data:text/javascript,${encodeURIComponent( + (artifact?.remote?.js ?? '').replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`), + )}`) as RuntimeRemoteModule + const codec = (method: string) => { + const descriptor = generated.TYPERT_REMOTE.descriptors.find(item => item.id.endsWith(`/${method}`)) + if (descriptor === undefined) throw new Error(`descriptor missing: ${method}`) + return descriptor.result + } + const content = new Uint8Array([128, 255]) + const leaf = { name: 'leaf', files: [], pair: [content], tail: ['parts', content], choice: content, variants: [], chunks: {} } + const value = { + ...leaf, content, files: [{ ...leaf, content: null }], pair: [content, { raw: content }], + choice: { preview: content }, variants: [{ content, size: 2 }], chunks: { 'a.b/c': content }, + } + expect(codec('tree').decode?.(value)).toEqual(value) + const parsed = codec('tree').decode?.(value) as typeof value + expect(parsed.content).toBe(content) + expect(Object.isFrozen(parsed.files)).toBe(true) + expect(Object.isFrozen(parsed.pair)).toBe(true) + expect(parsed.pair[0]).toBe(content) + expect(parsed.variants[0]?.content).toBe(content) + expect(parsed.tail[1]).toBe(content) + expect(Object.isFrozen(content)).toBe(false) + expect(codec('tree').decode?.(leaf)).toEqual(leaf) + expect(() => codec('tree').decode?.({ ...value, files: [{ ...leaf, content: 'base64' }] })).toThrow() + expect(() => codec('tree').decode?.({ ...value, variants: [{ content, size: '2' }] })).toThrow() + expect(codec('raw').decode?.(content)).toBe(content) + expect(codec('nullable').decode?.(null)).toBe(null) + expect(codec('nullable').decode?.(content)).toBe(content) + expect(codec('maybe').decode?.(undefined)).toBeUndefined() + expect(codec('maybe').decode?.(content)).toBe(content) + expect(() => codec('raw').decode?.([128, 255])).toThrow() + expect(codec('array').decode?.([content])).toEqual([content]) + expect((codec('array').decode?.([content]) as Uint8Array[])[0]).toBe(content) + expect(codec('create').decode).toBeUndefined() + expect(codec('create').encode).toBeUndefined() + const attachments: { bytes: Uint8Array; path: readonly (string | number)[] }[] = [] + const writeBytes = (bytes: Uint8Array, path: readonly (string | number)[]): null => { + attachments.push({ bytes, path }) + return null + } + const metadata = codec('tree').encode?.(value, writeBytes) as typeof value + expect(attachments.map(item => item.path)).toEqual([ + ['content'], ['files', 0, 'pair', 0], ['files', 0, 'tail', 1], ['files', 0, 'choice'], + ['pair', 0], ['pair', 1, 'raw'], ['tail', 1], ['choice', 'preview'], + ['variants', 0, 'content'], ['chunks', 'a.b/c'], + ]) + expect(attachments.every(item => item.bytes === content)).toBe(true) + expect(metadata.content).toBeNull() + expect(metadata.files[0]?.content).toBeNull() + expect(metadata.variants[0]?.content).toBeNull() + expect(value.content).toBe(content) + attachments.length = 0 + expect(codec('overlap').encode?.({ content, title: 'image' }, writeBytes)).toEqual({ content: null, title: 'image' }) + expect(attachments).toEqual([{ bytes: content, path: ['content'] }]) + attachments.length = 0 + const augmented = Object.assign(new Uint8Array(new SharedArrayBuffer(1)).fill(127), { content }) + expect(codec('objectOrBytes').encode?.(augmented, writeBytes)).toBeNull() + expect(augmented.content).toBe(content) + expect(attachments).toEqual([{ bytes: augmented, path: [] }]) + attachments.length = 0 + expect(codec('raw').encode?.(content, writeBytes)).toBeNull() + expect(attachments).toEqual([{ bytes: content, path: [] }]) + expect(codec('maybe').encode?.(undefined, writeBytes)).toBeUndefined() + const circular: { files: unknown[] } = { ...leaf, files: [] } + circular.files.push(circular) + expect(() => codec('tree').encode?.(circular, writeBytes)).toThrow('circular object') + const linked: { content: Uint8Array; next?: unknown } = { content } + linked.next = linked + expect(() => codec('linked').encode?.(linked, writeBytes)).toThrow('circular object') + let metadataReads = 0 + let contentReads = 0 + const opaque = { get title() { metadataReads++; return 'image' } } + const optional = { get content() { contentReads++; return undefined }, metadata: opaque } + const absent = codec('envelope').encode?.(optional, writeBytes) as typeof optional + expect(absent.metadata).toBe(opaque) + expect(metadataReads).toBe(0) + expect(JSON.stringify(absent)).toBe('{"metadata":{"title":"image"}}') + expect(contentReads).toBe(1) + expect(metadataReads).toBe(1) + let jsonCalls = 0 + const projected = codec('envelope').encode?.({ toJSON(key: string) { + jsonCalls++ + expect(key).toBe('value') + return { content, metadata: { title: 'image' } } + } }, writeBytes) + expect(JSON.stringify(projected)).toBe('{"content":null,"metadata":{"title":"image"}}') + expect(jsonCalls).toBe(1) + const dictionary = codec('tree').encode?.({ + ...leaf, chunks: Object.fromEntries([['__proto__', content]]), + }, writeBytes) as typeof value + expect(Object.hasOwn(dictionary.chunks, '__proto__')).toBe(true) + expect((dictionary.chunks as Record)['__proto__']).toBeNull() + assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root, ` +import type { BinaryTree, FileName } from '@fixture/remote/types' +async function binaryTree() { + const result = await ctx.remote.goals.tree() + if (!result.ok) return + const tree: BinaryTree = result.value + const name: FileName = result.value.name + const leaf = result.value.files[0]! + if (leaf.content) new Blob([leaf.content]) + const pair: readonly [Uint8Array, { readonly raw: Uint8Array }?] = leaf.pair + const choice: Uint8Array | { readonly preview: Uint8Array } = leaf.choice + const chunk: Uint8Array = leaf.chunks['file']! + // @ts-expect-error array readonly modifier is preserved. + result.value.files.push(leaf) + // @ts-expect-error property readonly modifier is preserved. + result.value.name = name + const raw = await ctx.remote.goals.raw() + if (raw.ok) new Blob([raw.value]) + const maybe: Promise | void>> = ctx.remote.goals.maybe() + const absent = await maybe + if (absent.ok && absent.value !== undefined) new Blob([absent.value]) + void tree; void pair; void choice; void chunk +} +void binaryTree +`) + }) + it('discovers a Remote-only package and emits strict direct and Context descriptors', async () => { const generator = new WorkspaceTypertGenerator(fixtureRoot) @@ -740,6 +972,7 @@ function assertRemoteConsumerTypechecks( dts: string | undefined, dtsMap: string | undefined, sourceRoot = fixtureRoot, + extraConsumer = '', ): void { if (dts === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration') if (dtsMap === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration map') @@ -782,6 +1015,7 @@ void createdScoped void renamed void watched void navigated +${extraConsumer} ` writeFileSync(consumerPath, consumerSource) const configPath = join(consumerRoot, 'tsconfig.consumer.json') diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index 004a2fdc17..50ec19165d 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -273,6 +273,11 @@ function requireStrictCodec(pkgName: string, value: unknown, subject: string): v throw new Error(`typert-loader: ${pkgName} ${subject} must use a strict codec`) } requireString(pkgName, codec, 'typeSymbol', subject) + for (const method of ['decode', 'encode']) { + if (codec[method] !== undefined && typeof codec[method] !== 'function') { + throw new Error(`typert-loader: ${pkgName} ${subject} ${method} must be a function`) + } + } if (typeof codec.create !== 'function') { throw new Error(`typert-loader: ${pkgName} ${subject} has no create() factory`) } diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 748f005d4a..72db60cbae 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -535,6 +535,14 @@ describe('validateTypertManifest', () => { const descriptor = strictInvocation() const manifest = { ...base, invocations: [descriptor] } expect(validateTypertManifest('pkg', manifest)).toBe(manifest) + const decoded = { ...descriptor, result: { ...descriptor.result, decode: (value: unknown) => value } } + expect(validateTypertManifest('pkg', { ...base, invocations: [decoded] }).invocations).toEqual([decoded]) + expect(() => validateTypertManifest('pkg', { + ...base, invocations: [{ ...decoded, result: { ...decoded.result, decode: true } }], + })).toThrow('decode must be a function') + expect(() => validateTypertManifest('pkg', { + ...base, invocations: [{ ...decoded, result: { ...decoded.result, encode: true } }], + })).toThrow('encode must be a function') const cancellable = { ...descriptor, cancellation: { parameter: 'signal' } } expect(validateTypertManifest('pkg', { ...base, invocations: [cancellable] }).invocations) .toEqual([cancellable]) diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index 8a68e37489..d1073f7952 100644 --- a/packages/typert/protocol/README.i18n.yaml +++ b/packages/typert/protocol/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/typert/protocol/README.md -README.md: e96c53fb2c8ad671fce1bb25c235c06517fc3d6b -README.zh.md: e9cb0f8496683c12ea009530a6cf97bdd023ee24 +README.md: 8a97bb339d462c8dbea7666c7855a7e51ae9b113 +README.zh.md: 9bb2cae765ee5747bf11a92d44ec2bb201b0285d diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index e96c53fb2c..8a97bb339d 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -44,6 +44,8 @@ export class GoalService extends TypertRemoteService { Generation turns the method into a wire endpoint under the service's namespace; Clients call it as a typed method through `ctx.remote` (see the [API Gateway reference](../../../docs/api-gateway.md)). A method opts into cooperative cancellation by declaring `signal: AbortSignal` as its final parameter — the signal is injected, never a JSON parameter or lookup field. +A unary method can return `Uint8Array` directly or within nested objects, arrays, tuples, optional fields, unions, and recursive types. Generation supplies optional result codec `encode()` and `decode()` functions: encoding visits only subtrees whose types can contain bytes, while decoding validates reconstructed values; Client declarations use `Uint8Array` at every byte position while retaining other field types. Pure JSON results pass through without Host byte detection or Client parsing. Parameters, events, and stream items remain JSON-only; runtime object cycles are unsupported. + A stream method (`@Remote({ mode: 'stream' })`) returns `Iterable`, `AsyncIterable`, or `RemoteStream`. `In` declares the items the Client may send back on the same logical stream; the method reads them through `this.ctx.invocation.uplink()`, and the descriptor carries their codec. `RemoteInvocation` also names the receiving `service`, the calling `peer` (a `PeerScope` the connection layer admitted), and the carrier `signal`; `ctx.invocation` is `undefined` on a Context no Remote call derived: the first `bindTypertRemote()` binding in a tree, which every `TypertRemoteService` constructor makes, registers that accessor on the root. A generated Client stream method returns `RemoteStreamHandle`: the handle with `send`, `end`, and `dispose` beside the downlink iteration. Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through. ### Associating Host objects and Contexts with wire identities diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index e9cb0f8496..9bb2cae765 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -44,6 +44,8 @@ export class GoalService extends TypertRemoteService { 生成会把方法变为服务命名空间下的 wire 端点;Client 通过 `ctx.remote` 以类型化方法调用它(见 [API Gateway 参考](../../../docs/api-gateway.zh.md))。方法把 `signal: AbortSignal` 声明为最后一个参数即可选择协作式取消——该信号是注入的,绝不会成为 JSON 参数或查找字段。 +一元方法可以直接返回 `Uint8Array`,也可以将其放在嵌套对象、数组、元组、可选字段、联合类型和递归类型中。生成器提供可选的结果 codec `encode()` 和 `decode()`:编码仅访问类型可能包含字节的子树,解码则校验还原后的值;Client 声明在每个字节位置使用 `Uint8Array`,同时保留其他字段类型。纯 JSON 结果不经 Host 字节识别或 Client 解析直接传递。参数、事件与流条目仍仅支持 JSON;不支持运行时对象循环。 + 流方法(`@Remote({ mode: 'stream' })`)返回 `Iterable`、`AsyncIterable` 或 `RemoteStream`。`In` 声明 Client 可以在同一条逻辑流上回送的项;方法通过 `this.ctx.invocation.uplink()` 读取它们,描述符携带其 codec。`RemoteInvocation` 还给出接收服务 `service`、发起调用的 `peer`(连接层接纳的一个 `PeerScope`)与载体 `signal`;非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`:树中第一个 `bindTypertRemote()` 绑定(每个 `TypertRemoteService` 构造函数都会建立一个)在根上注册该 accessor。生成的 Client 流方法返回 `RemoteStreamHandle`:在下行迭代之外提供 `send`、`end` 与 `dispose` 的句柄。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,原样透传。 ### 把 Host 对象与 Context 关联到 wire identity diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index 14917682b7..b05ce9249b 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -273,6 +273,19 @@ export type TypertCodec = readonly typeSymbol: string /** Materialize and return the process-realm schema on first boundary use. */ readonly create: () => TypertSchema + /** + * Decode a unary result whose fields require type-specific handling. + * @param value - result reconstructed by the RPC carrier. + * @returns the validated result, retaining native byte views. + */ + readonly decode?: (value: unknown) => unknown + /** + * Project typed binary fields into carrier-owned multipart attachments. + * @param value - native unary result. + * @param writeBytes - records a byte view at its result-relative path and returns its JSON placeholder. + * @returns JSON metadata with untouched JSON subtrees retained. + */ + readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown } | { readonly mode: 'src-json' diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 9f9b9c54cb..9df0c730e8 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -99,6 +99,18 @@ function scopedInvocation(): InvocationDescriptor { } describe('TypertRegistry', () => { + it('retains generated result decoders until the contribution is disposed', async () => { + const ctx = await makeCtx() + const decode = (value: unknown) => value + const descriptor: InvocationDescriptor = { + ...invocation(), result: { mode: 'strict', typeSymbol: 'fixture#bytes', create: () => ({ parse: decode }), decode }, + } + const dispose = ctx.typert.register({ ...toolsContribution(), invocations: [descriptor] }) + expect(ctx.typert.local.get('goals/create')?.result).toBe(descriptor.result) + await dispose() + expect(ctx.typert.local.get('goals/create')).toBeUndefined() + }) + it('registers and queries generated schemas separately from package reflection', async () => { const ctx = await makeCtx() const contribution = toolsContribution() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2803aabdc..8a78e83473 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1427,6 +1427,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../gateway + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection '@deepseek-ai/dsh-client-resources': specifier: workspace:^ version: link:../../client/resources diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 8e6b094b10..b0d76c28ee 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -855,6 +855,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', WorkspaceFileScope: 'Host workspace file lookup contract is owned by packages/api/workspace-files/README.md', + WorkspaceByteReadOptions: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceByteRange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceDirectoryListing: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceFileBytes: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', From 983b89f9a50e3d13afaeaa2567ca5dcab9a2e8e0 Mon Sep 17 00:00:00 2001 From: Yudong Date: Sat, 19 Sep 2026 06:39:20 +0800 Subject: [PATCH 2/5] refactor(fs): default unsupported file watching --- ...-17-sidebar-file-and-directory-auto-refresh.i18n.yaml | 4 ++-- ...2026-09-17-sidebar-file-and-directory-auto-refresh.md | 4 ++-- ...6-09-17-sidebar-file-and-directory-auto-refresh.zh.md | 4 ++-- docs/event-producer-consumer.i18n.yaml | 2 +- docs/event-producer-consumer.md | 6 +++--- docs/subsystems/filesystem.i18n.yaml | 4 ++-- docs/subsystems/filesystem.md | 2 +- docs/subsystems/filesystem.zh.md | 2 +- packages/extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/fs/fs/src/index.ts | 8 +++++++- packages/fs/fs/tests/service.spec.ts | 9 ++++++++- packages/ssh/fs-ssh/README.i18n.yaml | 4 ++-- packages/ssh/fs-ssh/README.md | 2 +- packages/ssh/fs-ssh/README.zh.md | 2 +- packages/ssh/fs-ssh/src/index.ts | 2 -- packages/ssh/fs-ssh/tests/provider.spec.ts | 4 ++-- 16 files changed, 36 insertions(+), 25 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml index 70ee32d07a..bee0b26a39 100644 --- a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.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/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md -2026-09-17-sidebar-file-and-directory-auto-refresh.md: f33be6a8d45756d3bac83d0a6e21a4110543e3b2 -2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md: 88fe6e7abb4027ec8b5c4fab4f1ad07477aef6fa +2026-09-17-sidebar-file-and-directory-auto-refresh.md: f509a8cf2642d3ca816d40e9e9817d3c962c79a6 +2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md: ff7a950aa86c663d4f5d2fb8e20d64bb2b077f6b diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md index f33be6a8d4..f509a8cf26 100644 --- a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md @@ -38,11 +38,11 @@ Notifications invoke existing reload/list operations; a failed reload does not r | Class or type | File | Responsibility | |---|---|---| -| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | Declare abstract single-target `watch(target, changed, signal)`: a file observes itself; a directory observes its direct entries. Resolve with an async close function once ready. Unsupported providers explicitly throw, without a new FS error code | +| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | Declare single-target `watch(target, changed, signal)`: a file observes itself; a directory observes its direct entries. Resolve with an async close function once ready. The base implementation rejects `FS_IO_ERROR`, so a provider without watching states nothing and adds no FS error code | | `FsTarget` | [fs/src/types.ts](../../../../packages/fs/fs/src/types.ts) | Reuse the existing target type, without another FS watch-event system or upper-layer parsing of `targetKey` | | `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | Watches a file's parent with target-only filtering, or the directory itself for directory targets; awaits Chokidar readiness and closure; owns the Chokidar dependency | | `SandboxedFileSystem` | [fs-sandbox/src/index.ts](../../../../packages/fs/fs-sandbox/src/index.ts) | Inherit local read-only observation without duplicating the watcher; retain its write and edit policy checks | -| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | `watch()` explicitly throws an ordinary exception; the Host converts it to a `workspace-file/watch-unsupported` `RemoteError`, without importing or checking an `FsError` runtime class across packages or passing remote `processPath()` values to local Chokidar | +| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | Inherits the base `FS_IO_ERROR` rejection; the Host converts it to a `workspace-file/watch-unsupported` `RemoteError`, without importing or checking an `FsError` runtime class across packages or passing remote `processPath()` values to local Chokidar | | `WorkspaceFiles` | [workspace-files/src/index.ts](../../../../packages/api/workspace-files/src/index.ts) | Use `changes(scope, path, signal)` with only the target path. Host stat determines whether directory containment applies, including after type changes; ordinary files retain file-read authority | | `WorkspaceChangeFeed` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | Establish the target watch in the existing follow path and emit readiness and changes. Retain the queue and operation-observation input; filter by target before emission without rewriting dispatch | | `ChangeFollower` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | Receive operation observations and OS notifications; handle watch errors, close independently of generator pulls, and await watcher closure | diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md index 88fe6e7abb..ff7a950aa8 100644 --- a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md @@ -38,11 +38,11 @@ Sidebar 的 Document Preview 和 Files 面板都需要反映磁盘上的最新 | 类或类型 | 所在文件 | 职责 | |---|---|---| -| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | 声明抽象的单目标 `watch(target, changed, signal)` 方法;文件观察自身,目录观察直接子项;就绪后返回异步关闭函数,不支持的提供方显式抛异常,不新增 FS 错误码 | +| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | 声明单目标 `watch(target, changed, signal)` 方法;文件观察自身,目录观察直接子项;就绪后返回异步关闭函数,基类以 `FS_IO_ERROR` 拒绝,不支持监听的提供方无需覆写,也不新增 FS 错误码 | | `FsTarget` | [fs/src/types.ts](../../../../packages/fs/fs/src/types.ts) | 沿用现有目标类型,不单独增加 FS 监听事件体系,也不让上层解析 `targetKey` | | `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | 文件监听其父目录并过滤到目标,目录监听自身;等待 Chokidar 就绪和关闭;依赖声明在 `fs-local` | | `SandboxedFileSystem` | [fs-sandbox/src/index.ts](../../../../packages/fs/fs-sandbox/src/index.ts) | 继承本地只读监听能力,不复制 watcher 实现;保留自身对写入、编辑的策略检查 | -| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | `watch()` 显式抛出普通异常;Host 转成 `workspace-file/watch-unsupported` 的 `RemoteError`,不跨包导入或判断 `FsError` 实体,也不把远端 `processPath()` 传给本机 Chokidar | +| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | 继承基类的 `FS_IO_ERROR` 拒绝;Host 将其转换为 `workspace-file/watch-unsupported` 的 `RemoteError`,不跨包导入或判断 `FsError` 实体,也不把远端 `processPath()` 传给本机 Chokidar | | `WorkspaceFiles` | [workspace-files/src/index.ts](../../../../packages/api/workspace-files/src/index.ts) | 使用仅携带目标路径的 `changes(scope, path, signal)`;Host stat 决定是否执行目录包含检查,目标类型变化后也检查,普通文件沿用文件读取权限 | | `WorkspaceChangeFeed` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | 在现有 follow 中建立目标 watch,发送就绪和变化;保留原队列与操作观察入口,出流前过滤目标,不重写整套分发逻辑 | | `ChangeFollower` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | 接收操作观察与 OS 通知,处理监听错误;关闭独立于生成器拉取进度,并等待 watcher 关闭完成 | diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 33dfb05b8e..94906b4159 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: 8101bb9aa3d6e3da1cca96a5593de5c52d6454cd +event-producer-consumer.md: de767c7629adcf554b3c58e43bb91af62e7aac1c event-producer-consumer.zh.md: a57e1b7e9447939e6a6586c4743dfd39345cc15c diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 8101bb9aa3..de767c7629 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -41,9 +41,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | -| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:30`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index 06d2e4aa51..c04ea7446f 100644 --- a/docs/subsystems/filesystem.i18n.yaml +++ b/docs/subsystems/filesystem.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 docs/subsystems/filesystem.md -filesystem.md: a4ed5460ac333b57520b241f31f5c809e574f858 -filesystem.zh.md: 6f06c140c1a9a2c685aebd6339cd1ee97d4303bb +filesystem.md: d099e619b208f5984db6ff4561cd203d13910ff0 +filesystem.zh.md: 6e907d07b82c72453ef45d67e6bcb9809431e20a diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index a4ed5460ac..d099e619b2 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -300,7 +300,7 @@ Abstract filesystem provider. Targets must preserve identity across aliases; rea * @returns a promise resolving once observation is active, with an asynchronous close function. * @throws when the provider does not support watching or cannot initialize the watcher. */ -abstract watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> +watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> /** * Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index 6f06c140c1..6e907d07b8 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -300,7 +300,7 @@ Abstract filesystem provider. Targets must preserve identity across aliases; rea * @returns a promise resolving once observation is active, with an asynchronous close function. * @throws when the provider does not support watching or cannot initialize the watcher. */ -abstract watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> +watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> /** * Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 06602809cb..6791693ea8 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -987,7 +987,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract.', methods: [ { - signature: 'abstract watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise>', + signature: 'watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise>', description: 'Observe one file or a directory\'s direct entries in this provider\'s execution world.', parameters: [{ name: 'target', description: 'resolved file or directory, including an absent path to observe for creation.' }, { name: 'changed', description: 'invalidation callback; errors can be reported during or after initialization.' }, { name: 'signal', description: 'cancels watcher initialization; the caller closes an initialized watcher.' }], returns: 'a promise resolving once observation is active, with an asynchronous close function.', diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index 6f4ebe4e50..1158a6dede 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -10,6 +10,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { FsError } from './types.ts' import type { FsDirEntry, FsEditOutcome, @@ -96,7 +97,12 @@ export abstract class FileSystem extends Service { * @returns a promise resolving once observation is active, with an asynchronous close function. * @throws when the provider does not support watching or cannot initialize the watcher. */ - abstract watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> + watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> { + void target + void changed + signal.throwIfAborted() + return Promise.reject(new FsError('Filesystem watching is not supported by this provider.', 'FS_IO_ERROR')) + } /** * The sandbox mode this backend enforces on mutations BY DEFAULT, or diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index 41e698a70e..e3105925a5 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -21,7 +21,6 @@ import type { /** A minimal in-memory fake implementing the provider primitives. */ class FakeFileSystem extends FileSystem { - override watch(): never { throw new Error('Fixture does not support watching') } files = new Map() override async resolve(path: string): Promise { @@ -145,6 +144,14 @@ describe('FileSystem provider seam', () => { await expect(fs.readBytes(target, undefined, 1)).rejects.toMatchObject({ code: 'FS_TOO_LARGE' }) }) + it('rejects watching when a provider does not implement observation', async () => { + const ctx = new Context() + await ctx.plugin(FakeFileSystem) + const fs = ctx.fs as FakeFileSystem + await expect(fs.watch(await fs.resolve('a.txt'), () => {}, new AbortController().signal)) + .rejects.toMatchObject({ code: 'FS_IO_ERROR' }) + }) + it('listDir returns child entry targets without reading file content', async () => { const ctx = new Context() await ctx.plugin(FakeFileSystem) diff --git a/packages/ssh/fs-ssh/README.i18n.yaml b/packages/ssh/fs-ssh/README.i18n.yaml index 9c53eeb146..33b115144a 100644 --- a/packages/ssh/fs-ssh/README.i18n.yaml +++ b/packages/ssh/fs-ssh/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/ssh/fs-ssh/README.md -README.md: a950726df972952a8430ef0d00ba455f2e0de11e -README.zh.md: fa69aaee52c705b094de1b0f4860109023c2069f +README.md: fe777f672ab7cf474adf10b05f6d6f646864548f +README.zh.md: 9aa92d99584c48d9179e26ccd1f733d2bda36441 diff --git a/packages/ssh/fs-ssh/README.md b/packages/ssh/fs-ssh/README.md index a950726df9..fe777f672a 100644 --- a/packages/ssh/fs-ssh/README.md +++ b/packages/ssh/fs-ssh/README.md @@ -67,7 +67,7 @@ This provider contributes no request-prefix content. Its consumers own model-vis - Whole-text reads and individual byte windows are limited to 8 MiB by the helper. Use text streaming or multiple byte windows for larger reads; other JSON transfers also obey the connection’s frame cap. -- Filesystem watching is unsupported: `watch()` throws without polling or opening a local watcher for a remote path. Ordinary reads and consumer-owned manual refresh remain available. +- Filesystem watching is unsupported: the provider keeps no `watch()` override, so the base `FS_IO_ERROR` rejection applies without polling or opening a local watcher for a remote path. Ordinary reads and consumer-owned manual refresh remain available. - Remote file URLs are execution coordinates, not host filesystem handles or Web download links. diff --git a/packages/ssh/fs-ssh/README.zh.md b/packages/ssh/fs-ssh/README.zh.md index fa69aaee52..9aa92d9958 100644 --- a/packages/ssh/fs-ssh/README.zh.md +++ b/packages/ssh/fs-ssh/README.zh.md @@ -67,7 +67,7 @@ kind: "package-reference" - 辅助程序将整段文本读取及单次字节窗口限制为 8 MiB。更大的读取需使用文本流或多个字节窗口;其他 JSON 传输也受连接消息大小上限约束。 -- 不支持文件系统监听:`watch()` 直接抛错,不使用轮询,也不为远程路径建立本地监听。普通读取与消费方提供的手动刷新仍可用。 +- 不支持文件系统监听:提供方不覆写 `watch()`,由基类以 `FS_IO_ERROR` 拒绝;不使用轮询,也不为远程路径建立本地监听。普通读取与消费方提供的手动刷新仍可用。 - 远端文件 URL 是执行坐标,不是主机文件系统句柄或 Web 下载链接。 diff --git a/packages/ssh/fs-ssh/src/index.ts b/packages/ssh/fs-ssh/src/index.ts index 1bbcfbf097..9a20414a95 100644 --- a/packages/ssh/fs-ssh/src/index.ts +++ b/packages/ssh/fs-ssh/src/index.ts @@ -20,8 +20,6 @@ const errorCodes: Record = { export class SshFileSystem extends FileSystem { static inject = ['ssh', 'sandboxPolicy'] - override watch(): never { throw new Error('SSH filesystem watching is not supported') } - override get sandboxMode(): SandboxMode { return this.ctx.sandboxPolicy.defaultMode } override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise { diff --git a/packages/ssh/fs-ssh/tests/provider.spec.ts b/packages/ssh/fs-ssh/tests/provider.spec.ts index d242d4184e..b6e6626abf 100644 --- a/packages/ssh/fs-ssh/tests/provider.spec.ts +++ b/packages/ssh/fs-ssh/tests/provider.spec.ts @@ -33,8 +33,8 @@ async function setup() { describe('SSH filesystem provider', () => { it('declares watching unsupported without sending a remote request', async () => { const { fs, dispatch } = await setup() - expect(() => fs.watch(target, vi.fn(), new AbortController().signal)) - .toThrow('SSH filesystem watching is not supported') + await expect(fs.watch(target, vi.fn(), new AbortController().signal)) + .rejects.toMatchObject({ code: 'FS_IO_ERROR' }) expect(dispatch).not.toHaveBeenCalled() }) From 285f0a700550f37e20a2a1a72182771df1c43cd5 Mon Sep 17 00:00:00 2001 From: Yudong Date: Sun, 20 Sep 2026 17:46:37 +0800 Subject: [PATCH 3/5] fix(connection): narrow binary transport values --- apps/web/tests/document-preview.e2e.ts | 4 +++- packages/client/connection/src/client/rpc.ts | 20 +++++++++++-------- packages/client/connection/src/http-bridge.ts | 15 ++++++++++++-- packages/client/connection/src/rpc-host.ts | 6 +++--- .../connection/tests/http-bridge.host.spec.ts | 10 ++++++---- .../tests/html-body.client.spec.tsx | 2 +- 6 files changed, 38 insertions(+), 19 deletions(-) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index e2dc9c1713..adefac10ee 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -580,7 +580,9 @@ else process.exit(1); const metadata = transferredBody.get('metadata') if (typeof metadata !== 'string') throw new Error('missing PNG metadata') const { attachments } = JSON.parse(metadata) as { attachments: { path: string[]; codec: string; part: string }[] } - expect(attachments).toEqual([{ path: ['data'], codec: 'bytes', part: expect.any(String) as unknown }]) + expect(attachments).toHaveLength(1) + expect(attachments[0]).toMatchObject({ path: ['data'], codec: 'bytes' }) + expect(attachments[0]!.part).toEqual(expect.any(String)) const transferredFile = transferredBody.get(attachments[0]!.part) if (transferredFile === null || typeof transferredFile === 'string') throw new Error('missing PNG payload') expect(Buffer.from(await transferredFile.arrayBuffer())).toEqual(TINY_PNG) diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index 66811432cc..efbb6d5c71 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -98,7 +98,7 @@ async function parseBinaryResponse(response: Response): Promise= value.length - : typeof segment !== 'string') - || !Object.hasOwn(value, segment as string | number)) { + for (const segment of attachment.path) { + const value: unknown = Reflect.get(parent, key) + if (typeof value !== 'object' || value === null) { throw new TypeError('connection: invalid binary response path') } + if (Array.isArray(value)) { + if (typeof segment !== 'number' || !Number.isSafeInteger(segment) || segment < 0 || segment >= value.length) { + throw new TypeError('connection: invalid binary response path') + } + } else if (typeof segment !== 'string') { + throw new TypeError('connection: invalid binary response path') + } + if (!Object.hasOwn(value, segment)) throw new TypeError('connection: invalid binary response path') parent = value key = segment as string | number } diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index 03109936b4..713f53dcea 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -3,7 +3,7 @@ * web carrier; the fetch-shaped handler itself is transport-agnostic). */ -import type { IncomingMessage, ServerResponse } from 'node:http' +import type { IncomingMessage } from 'node:http' import { Readable } from 'node:stream' import type { ConnectionFetchHandler } from './rpc.ts' @@ -13,6 +13,17 @@ import type { ConnectionFetchHandler } from './rpc.ts' * each body in memory, so this cap is also the per-request resident bound. */ export const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024 +interface BridgeServerResponse { + readonly destroyed: boolean + readonly writableEnded: boolean + on(event: 'close', listener: () => void): this + off(event: 'close' | 'drain', listener: () => void): this + once(event: 'close' | 'drain', listener: () => void): this + writeHead(statusCode: number, headers?: Record): unknown + write(chunk: Uint8Array): boolean + end(): unknown +} + /** * Bridge one node:http request to the fetch-shaped handler (client close * aborts; response writes respect backpressure and stop on disconnect). @@ -23,7 +34,7 @@ export const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024 */ export async function bridge( req: IncomingMessage, - res: ServerResponse, + res: BridgeServerResponse, apiHandler: ConnectionFetchHandler, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES, ): Promise { diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 2007e3a32d..fe185b9316 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -310,8 +310,8 @@ function fullResponse(rpcId: RpcIdType, result: Awaited = {} for (const key of Object.keys(value)) { - const item = Reflect.get(value, key) as unknown + const item: unknown = Reflect.get(value, key) // A toJSON method on the projected object must not run a second time. if (key === 'toJSON' && typeof item === 'function') continue const extracted = child(item, key) diff --git a/packages/client/connection/tests/http-bridge.host.spec.ts b/packages/client/connection/tests/http-bridge.host.spec.ts index 8d972824b9..e9fa9df364 100644 --- a/packages/client/connection/tests/http-bridge.host.spec.ts +++ b/packages/client/connection/tests/http-bridge.host.spec.ts @@ -76,7 +76,7 @@ describe('HTTP bridge abort', () => { }) it.each(['write', 'backpressure'])('finishes a multipart response without further writes when the client closes during %s', async (phase) => { - const request = Readable.from([]) as unknown as IncomingMessage + const request = Readable.from([]) as IncomingMessage Object.assign(request, { url: '/api/workspaceFiles/readBytes', method: 'GET', headers: {} }) const body = new FormData() body.set('metadata', '{}') @@ -84,6 +84,7 @@ describe('HTTP bridge abort', () => { let writes = 0 let cleaningUp = false const response = Object.assign(new EventEmitter(), { + destroyed: false, writableEnded: false, writeHead() { return this }, write() { @@ -96,7 +97,7 @@ describe('HTTP bridge abort', () => { return cleaningUp }, end() { this.writableEnded = true; return this }, - }) as unknown as ServerResponse + }) let settled = false const pending = bridge(request, response, { requestBodyMode: () => 'buffered', @@ -115,14 +116,15 @@ describe('HTTP bridge abort', () => { }) it('discards bytes returned after the client disconnects', async () => { - const request = Readable.from([]) as unknown as IncomingMessage + const request = Readable.from([]) as IncomingMessage Object.assign(request, { url: '/api/workspaceFiles/readBytes', method: 'GET', headers: {} }) const response = Object.assign(new EventEmitter(), { + destroyed: false, writableEnded: false, writeHead() { return this }, write() { throw new Error('a disconnected response must not receive bytes') }, end() { this.writableEnded = true; return this }, - }) as unknown as ServerResponse + }) const started = Promise.withResolvers() let controller!: ReadableStreamDefaultController const body = new ReadableStream({ diff --git a/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx index fdbd5452cb..8ed6f439a2 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx @@ -161,7 +161,7 @@ describe('HtmlBody', () => { const preference = new DeveloperToolsPreference(host.scope) const readRelated = vi.fn().mockResolvedValue({ ok: true, value: { absolutePath: '/workspace/asset.js', version: 'asset-v1', offset: 0, eof: true, - data: btoa('window.loaded = true'), + data: utf8('window.loaded = true'), } }) const scripted: HtmlBodyProps = { ...props('

Preview

'), From 2aac7c8ef5259fb4e4d9bcd0c8864e6cf1561bf0 Mon Sep 17 00:00:00 2001 From: Yudong Date: Sun, 20 Sep 2026 18:11:10 +0800 Subject: [PATCH 4/5] fix(connection): keep narrowed attachment path type --- packages/client/connection/src/client/rpc.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index efbb6d5c71..c5d5c9b394 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -124,7 +124,7 @@ async function parseBinaryResponse(response: Response): Promise Date: Sun, 20 Sep 2026 18:22:56 +0800 Subject: [PATCH 5/5] refactor(api): move Remote result projection to gateway --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 4 +- ...026-08-02-typert-remote-method-calls.zh.md | 4 +- ...7-workspace-file-binary-transfer.i18n.yaml | 4 +- ...26-09-17-workspace-file-binary-transfer.md | 2 +- ...09-17-workspace-file-binary-transfer.zh.md | 2 +- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/typert.i18n.yaml | 4 +- docs/subsystems/typert.md | 2 +- docs/subsystems/typert.zh.md | 2 +- packages/client/connection/src/index.ts | 2 + packages/client/connection/src/rpc-host.ts | 68 ++----- .../connection/tests/binary-rpc.host.spec.ts | 176 ++++++++---------- .../extensions/tool-cordis/src/api-catalog.ts | 40 ++-- packages/typert/protocol/src/types.ts | 2 +- 22 files changed, 138 insertions(+), 202 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 534a9fddcd..279482f67a 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.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-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 9986e0dac5e85d0aadc745ca4084ed3d1cecae54 -2026-08-02-typert-remote-method-calls.zh.md: 282af768413498658722af3adee17fb59a8e93c2 +2026-08-02-typert-remote-method-calls.md: 2a12aaf78f638d8af786d2ca817a3fee07a2c614 +2026-08-02-typert-remote-method-calls.zh.md: bf4a7b0179b4dc878a0a53c7076c02e6f1bf0273 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 9986e0dac5..2a12aaf78f 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -31,8 +31,8 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | `@deepseek-ai/dsh-typert-protocol` | Declares only the minimal `ctx.typert` protocol | `TypertRemoteService`, decorators, binding fallback, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | | Typert registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | | Typert generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | -| API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, and invokes methods | -| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, Typert interception, and owner-registered exact Fetch routes on the same channel | +| API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and projects unary results into carrier-neutral fields and attachments | +| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, physical serialization, trust, error transport, Typert interception, and owner-registered exact Fetch routes on the same channel | | API Gateway's Client face | `ctx.remote`, `ctx.remote.` | Mounts Remote contributions, materializes each namespace as a traced `remote.` child Service, and delegates canonical calls to `ctx.connection.rpc` | | API Remotes | No new service | Owns Host Agent/Session lookup policy and serves as the only Client business facade, selecting and mounting `/remote` contributions while exposing the selected API declarations | | Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers | diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 282af76841..bf4a7b0179 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -31,8 +31,8 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | `@deepseek-ai/dsh-typert-protocol` | 只声明 `ctx.typert` 的最小协议 | `TypertRemoteService`、decorator、binding 回退、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | | Typert registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | | Typert generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | -| API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver 并调用方法 | -| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、Typert 拦截,以及各 owner 在同一 channel 上注册的精确 Fetch route | +| API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法,并将一元结果投影为载体无关的字段与附件 | +| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、物理序列化、trust、错误传输、Typert 拦截,以及各 owner 在同一 channel 上注册的精确 Fetch route | | API Gateway 的 Client face | `ctx.remote`、`ctx.remote.` | mount Remote contribution,把每个 namespace 实体化为可追踪的 `remote.` 子 Service,并把规范调用交给 `ctx.connection.rpc` | | API Remotes | 无新增服务 | 负责 Host Agent/Session lookup 策略,并作为 Client 业务的唯一 facade,选择并挂载 `/remote` contribution,同时暴露所选 API 声明 | | Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider | diff --git a/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.i18n.yaml index 87ea138f6d..7eef4b0fa9 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.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-09-17-workspace-file-binary-transfer.md -2026-09-17-workspace-file-binary-transfer.md: 1cfffb4113619fba17479065bac6bb420f214d7d -2026-09-17-workspace-file-binary-transfer.zh.md: 1cacc2c294b979ed7e840c84293eb3f6421c6bcb +2026-09-17-workspace-file-binary-transfer.md: b419c8bd15f51246825ceddf2905ec055bbc70ae +2026-09-17-workspace-file-binary-transfer.zh.md: 9cf3d7074c3d536a5c8a39732231c6996d6f6ead diff --git a/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md index 1cfffb4113..b419c8bd15 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md +++ b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md @@ -12,7 +12,7 @@ Document previews need native bytes. Encoding file contents as base64 adds about The [Remote method architecture](2026-08-02-typert-remote-method-calls.md) owns method dispatch and lifetime; this note defines binary success encoding and Client validation. The [workspace file service](2026-09-05-workspace-files-service.md) exposes one binary `readBytes` Remote. Its required options object independently selects a byte `range` and a `baseFile` for relative target resolution. Without `range`, it reads a complete file. This avoids separate method names for orthogonal choices while preserving bounded `fs.readBytes` and `fs.readByteRange` reads. -Typert recursively recognizes `Uint8Array` in unary result types, including root values, optional fields, and containers. Generated result codecs provide `encode()` for subtrees whose types can contain bytes and `decode()` to validate the reconstructed value without iterating, copying, or freezing byte payloads; Client declarations narrow each byte buffer to `ArrayBuffer`. Gateway supplies the encoder to Connection, using identity for strict JSON results so they avoid recursive detection. Source-mode calls retain runtime byte detection. Connection owns standard `FormData` parts and preserves the RPC envelope and correlation id in JSON metadata; it does not depend on Typert reflection. An attachment table associates byte parts with paths through the JSON result; field names remain business-owned. The Client restores `Uint8Array` views over `Blob.arrayBuffer()` and Gateway delegates validation to the generated decoder. Ordinary results and errors retain JSON responses. Gateway owns method dispatch, Session lookup, cancellation, and mount lifetime; feature code needs no multipart reader or dedicated route. +Typert recursively recognizes `Uint8Array` in unary result types, including root values, optional fields, and containers. Generated result codecs provide `encode()` for subtrees whose types can contain bytes and `decode()` to validate the reconstructed value without iterating, copying, or freezing byte payloads; Client declarations narrow each byte buffer to `ArrayBuffer`. Gateway executes the generated encoder, keeps strict JSON values unchanged, and performs runtime byte detection for source-mode calls. It returns JSON-compatible metadata and result-relative byte attachments to Connection. Connection owns standard `FormData` parts and preserves the RPC envelope and correlation id in JSON metadata; it does not inspect business values or depend on Typert reflection. An attachment table associates byte parts with paths through the JSON result; field names remain business-owned. The Client restores `Uint8Array` views over `Blob.arrayBuffer()` and Gateway delegates validation to the generated decoder. Ordinary results and errors retain JSON responses. Gateway owns method dispatch, Session lookup, cancellation, result projection, and mount lifetime; feature code needs no multipart reader or dedicated route. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md index 1cacc2c294..9cf3d7074c 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md @@ -12,7 +12,7 @@ Status: implemented [Remote 方法架构](2026-08-02-typert-remote-method-calls.zh.md)负责方法分发与生命周期;本记录规定二进制成功响应的编码及 Client 校验。[工作区文件服务](2026-09-05-workspace-files-service.zh.md)提供单一的二进制 `readBytes` Remote。其必填选项对象独立选择字节 `range` 和用于相对目标解析的 `baseFile`。未传 `range` 时读取完整文件。这避免为正交的选择设置不同方法名,同时保留有界的 `fs.readBytes` 和 `fs.readByteRange` 读取。 -Typert 递归识别一元结果类型中的 `Uint8Array`,包括根值、可选字段和容器。生成的结果 codec 提供 `encode()` 处理类型可能包含字节的子树,并通过 `decode()` 校验还原后的值,不逐字节遍历、复制或冻结字节载荷;Client 声明将每个字节缓冲区限定为 `ArrayBuffer`。Gateway 将 encoder 交给 Connection,对 strict JSON 结果原值传递,从而避免递归识别。源码模式调用保留运行时字节识别。Connection 负责标准 `FormData` 部分,并在 JSON 元数据中保留 RPC 信封和关联 id,不依赖 Typert 反射。附件表将字节部分与 JSON 结果中的路径关联,字段名仍由业务拥有。Client 恢复基于 `Blob.arrayBuffer()` 的 `Uint8Array` 视图,Gateway 将校验交给生成的解码器。普通结果与错误继续使用 JSON 响应。Gateway 负责方法分发、Session 查找、取消及挂载生命周期;功能代码无需 multipart 读取器或专用路由。 +Typert 递归识别一元结果类型中的 `Uint8Array`,包括根值、可选字段和容器。生成的结果 codec 提供 `encode()` 处理类型可能包含字节的子树,并通过 `decode()` 校验还原后的值,不逐字节遍历、复制或冻结字节载荷;Client 声明将每个字节缓冲区限定为 `ArrayBuffer`。Gateway 执行生成的 encoder,保持 strict JSON 值不变,并为源码模式调用执行运行时字节识别;随后向 Connection 返回 JSON 兼容元数据及相对于结果的字节附件。Connection 负责标准 `FormData` 部分,并在 JSON 元数据中保留 RPC 信封和关联 id;它不检查业务值,也不依赖 Typert 反射。附件表将字节部分与 JSON 结果中的路径关联,字段名仍由业务拥有。Client 恢复基于 `Blob.arrayBuffer()` 的 `Uint8Array` 视图,Gateway 将校验交给生成的解码器。普通结果与错误继续使用 JSON 响应。Gateway 负责方法分发、Session 查找、取消、结果投影及挂载生命周期;功能代码无需 multipart 读取器或专用路由。 ## 考虑过的替代方案 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 232ee859c7..957f407758 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -122,7 +122,7 @@ Strict analysis requires a Remote to be a public, non-static instance method wit Remote calls use the Connection's `/api` route. The Client Remote calls `connection.rpc.call('/api', '/', { args }, signal)`; the HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. -The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler. The Typert Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; binary Remote successes use multipart responses, and feature-owned exact Fetch routes handle responses outside the RPC envelope, and other requests return 404. The Connection owns transport, RPC ids, response envelopes, and request cancellation, while the Gateway owns only the Remote data protocol and business dispatch. Replacing the Connection carrier does not require changes to Remote descriptors or the Client programming interface. +The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler. The Typert Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; it projects binary Remote fields into JSON-compatible metadata and result-relative byte attachments, which Connection frames as multipart responses. Feature-owned exact Fetch routes handle responses outside the RPC envelope, and other requests return 404. Connection owns transport, RPC ids, response envelopes, and request cancellation, while Gateway owns the Remote data protocol and business dispatch. Replacing the Connection carrier does not require changes to Remote descriptors or the Client programming interface. For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, and invokes the service method targeted by the binding. The Client Gateway validates decoded binary results with the generated result codec; ordinary JSON results retain their existing handling. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index aa980adca0..1062adcc89 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -122,7 +122,7 @@ Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则 Remote 调用使用 Connection 的 `/api` 路由。Client Remote 调用 `connection.rpc.call('/api', '/', { args }, signal)`;HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 -Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内分发。Typert Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;二进制 Remote 成功响应使用 multipart,功能自有的精确 Fetch 路由处理 RPC 信封之外的响应,其他请求返回 404。Connection 拥有传输、RPC id、响应 envelope 和请求取消,Gateway 只拥有 Remote 数据协议和业务分发。替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程接口。 +Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内分发。Typert Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;它把二进制 Remote 字段投影为 JSON 兼容元数据及相对于结果的字节附件,再由 Connection 封装为 multipart 响应。功能自有的精确 Fetch 路由处理 RPC 信封之外的响应,其他请求返回 404。Connection 拥有传输、RPC id、响应 envelope 和请求取消,Gateway 拥有 Remote 数据协议和业务分发。替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程接口。 Gateway 每次调用都从当前注册表解析描述符和实时服务,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context 提供方解析对象或接收者,最后调用 binding 指向的服务方法。Client Gateway 使用生成的结果 codec 校验解码后的二进制结果;普通 JSON 结果保持原有处理方式。缺少提供方、identity 未命中、binding 不一致、参数缺失或多余、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 16eda74241..c1cf209b2c 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 645c64b43a95d227a9373985f05bc50b4b4e93f6 -config-catalog.zh.md: e9f8db259539b83f771027062019039b930f8d37 +config-catalog.md: 1fac327a8a50a09cf694f6fc31a0af1f031f596f +config-catalog.zh.md: fbc043ab62d57d1b7f754e28d3469cc5ebd4fb49 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 645c64b43a..1fac327a8a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -199,7 +199,7 @@ export interface Config { } ``` -Source: [`packages/api/gateway/src/index.ts:140`](../packages/api/gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts:144`](../packages/api/gateway/src/index.ts) @@ -466,7 +466,7 @@ export interface ConnectionRecoveryConfig { } ``` -Source: [`packages/client/connection/src/index.ts:90`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:92`](../packages/client/connection/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e9f8db2595..fbc043ab62 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -201,7 +201,7 @@ export interface Config { } ``` -来源:[`packages/api/gateway/src/index.ts:140`](../packages/api/gateway/src/index.ts) +来源:[`packages/api/gateway/src/index.ts:144`](../packages/api/gateway/src/index.ts) @@ -470,7 +470,7 @@ export interface ConnectionRecoveryConfig { } ``` -来源: [`packages/client/connection/src/index.ts:90`](../packages/client/connection/src/index.ts) +来源: [`packages/client/connection/src/index.ts:92`](../packages/client/connection/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 94906b4159..bb2e2b647b 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: de767c7629adcf554b3c58e43bb91af62e7aac1c -event-producer-consumer.zh.md: a57e1b7e9447939e6a6586c4743dfd39345cc15c +event-producer-consumer.md: 2b373821a2d0c9acf2284c4195a928947700f1ce +event-producer-consumer.zh.md: 90ca2a4c76ac477265a60dad80c5f65d04509e90 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index de767c7629..2b373821a2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,7 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | -| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:65`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:67`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index a57e1b7e94..90ca2a4c76 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -32,7 +32,7 @@ | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | -| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:65`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:67`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index 374b43c01f..e63a0fe9d5 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.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 docs/subsystems/typert.md -typert.md: a8ad968b7af77c90cfcdbeb910f435be461bf442 -typert.zh.md: d020b75ee7c52cc87aaea371978aeebe78d6fa45 +typert.md: db8b48945e2301e14d866b8a2e3df59c4ae408c9 +typert.zh.md: cdaee66201d0f27a5a18aa99cb1888438051e018 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index a8ad968b7a..db8b48945e 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -55,7 +55,7 @@ type TypertCodec = */ readonly decode?: (value: unknown) => unknown /** - * Project typed binary fields into carrier-owned multipart attachments. + * Project typed binary fields into RPC result attachments. * @param value - native unary result. * @param writeBytes - records a byte view at its result-relative path and returns its JSON placeholder. * @returns JSON metadata with untouched JSON subtrees retained. diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index d020b75ee7..cdaee66201 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -55,7 +55,7 @@ type TypertCodec = */ readonly decode?: (value: unknown) => unknown /** - * Project typed binary fields into carrier-owned multipart attachments. + * Project typed binary fields into RPC result attachments. * @param value - native unary result. * @param writeBytes - records a byte view at its result-relative path and returns its JSON placeholder. * @returns JSON metadata with untouched JSON subtrees retained. diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index c76ce937c8..647174f743 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -21,8 +21,10 @@ export type { ConnectionIndexRequest, ConnectionIndexResponse, ConnectionRpcEndpointMatcher, + ConnectionRpcAttachment, ConnectionRpcFailure, ConnectionRpcHandler, + ConnectionRpcHandlerResult, ConnectionRequestRejection, ConnectionRpcResult, ConnectionRequestBodyMode, diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index fe185b9316..0f98bc1f19 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -293,63 +293,21 @@ function errorResponse(rpcId: RpcIdType, error: ConnectionRpcFailure): Response } function fullResponse(rpcId: RpcIdType, result: Awaited>): Response { - const body: ConnectionServerResponse = { type: 'server-response', rpcId, result } - if (!result.ok) return Response.json(body) + if (!result.ok) { + const body: ConnectionServerResponse = { type: 'server-response', rpcId, result } + return Response.json(body) + } + const { attachments, ...success } = result + const body: ConnectionServerResponse = { type: 'server-response', rpcId, result: success } + if (attachments === undefined || attachments.length === 0) return Response.json(body) const parts = new FormData() - const attachments: { path: (string | number)[]; codec: 'bytes'; part: string }[] = [] - const path: (string | number)[] = [] - const ancestors = new Set() - const writeBytes = (value: Uint8Array, path: readonly (string | number)[]): null => { - const part = `bytes-${attachments.length}` - attachments.push({ path: [...path], codec: 'bytes', part }) + const attachmentMetadata = attachments.map((attachment, index) => { + const part = `bytes-${index}` // FileSystem bytes may have SharedArrayBuffer backing, which BlobPart excludes. - parts.set(part, new Blob([new Uint8Array(value)])) - return null - } - const extract = (input: unknown, key: string): unknown => { - // Capture accessors and toJSON once, before materializing the JSON metadata. - let value = input - if (input !== null && typeof input === 'object' && !(input instanceof Uint8Array)) { - const toJSON: unknown = Reflect.get(input, 'toJSON') - if (typeof toJSON === 'function') value = Reflect.apply(toJSON, input, [key]) - } - if (value instanceof Uint8Array) return writeBytes(value, path) - if (typeof value !== 'object' || value === null) return value - if (value instanceof Number || value instanceof String || value instanceof Boolean) return value - if (ancestors.has(value)) throw new TypeError('connection: circular RPC result') - ancestors.add(value) - let copy: object - if (Array.isArray(value)) { - const items: unknown[] = [] - // JSON arrays include every index, even holes and non-enumerable elements. - for (let index = 0, length = value.length; index < length; index++) items.push(child(value[index], index)) - copy = items - } else { - const fields: Record = {} - for (const key of Object.keys(value)) { - const item: unknown = Reflect.get(value, key) - // A toJSON method on the projected object must not run a second time. - if (key === 'toJSON' && typeof item === 'function') continue - const extracted = child(item, key) - if (key === '__proto__') Object.defineProperty(fields, key, { value: extracted, enumerable: true }) - else fields[key] = extracted - } - copy = fields - } - ancestors.delete(value) - return copy - } - const child = (value: unknown, key: string | number): unknown => { - if (typeof value !== 'object' || value === null) return value - path.push(key) - const extracted = extract(value, String(key)) - path.pop() - return extracted - } - const value = result.encode === undefined ? extract(result.value, 'value') : result.encode(result.value, writeBytes) - const metadata = { ...body, result: { ok: true, value } } - if (attachments.length === 0) return Response.json(metadata) - parts.set('metadata', JSON.stringify({ ...metadata, attachments })) + parts.set(part, new Blob([new Uint8Array(attachment.bytes)])) + return { path: [...attachment.path], codec: 'bytes' as const, part } + }) + parts.set('metadata', JSON.stringify({ ...body, attachments: attachmentMetadata })) return new Response(parts) } diff --git a/packages/client/connection/tests/binary-rpc.host.spec.ts b/packages/client/connection/tests/binary-rpc.host.spec.ts index b65fefed19..05ec270545 100644 --- a/packages/client/connection/tests/binary-rpc.host.spec.ts +++ b/packages/client/connection/tests/binary-rpc.host.spec.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest' import type { BrowserAuth } from '../src/browser-auth.ts' import { createWebConnectionRpc } from '../src/client/rpc.ts' import { bridge } from '../src/http-bridge.ts' -import type { ConnectionRpcResult } from '../src/rpc.ts' +import type { ConnectionRpcHandlerResult, ConnectionRpcResult } from '../src/rpc.ts' import { HostConnectionService } from '../src/rpc-host.ts' function binaryResponse(rpcId: string, mutate: (parts: FormData) => void = () => {}): Response { @@ -29,7 +29,11 @@ describe('Connection binary RPC', () => { const connection = ctx.get('connection') as HostConnectionService const data = new Uint8Array(1024 * 1024).fill(65) data.set([0, 128, 255]) - connection.rpc.intercept('/api', endpoint => endpoint === 'fixture/read', async () => ({ ok: true, value: { data, bytes: data.length } })) + connection.rpc.intercept('/api', endpoint => endpoint === 'fixture/read', async () => ({ + ok: true, + value: { data: null, bytes: data.length }, + attachments: [{ path: ['data'], bytes: data }], + })) const handler = connection.createSharedFetchHandler('/api') ctx.webServer.register({ kind: 'prefix', path: '/api', handler: (req, res) => bridge(req, res, handler) }) let transferred = 0 @@ -73,17 +77,36 @@ describe('Connection binary RPC', () => { await fiber.await() try { const connection = ctx.get('connection') as HostConnectionService - const results: ConnectionRpcResult[] = [ - { ok: true, value: { data: new Uint8Array([9, 0, 128, 255, 9]).subarray(1, 4), offset: 7, eof: false, bytes: 42 } }, - { ok: true, value: { data: new Uint8Array(new SharedArrayBuffer(3)).fill(255), offset: 0, eof: true, bytes: 3 } }, - { ok: true, value: { data: new Uint8Array(), offset: 0, eof: true, bytes: 0 } }, - { ok: true, value: { data: 'AID/', encoding: 'base64' } }, - { ok: true, value: { count: 4 } }, - { ok: true, value: null }, - { ok: true, value: 'plain' }, - { ok: false, error: { code: 'fixture/denied', message: 'denied', details: { path: 'private' } } }, + const byteValues = [ + new Uint8Array([9, 0, 128, 255, 9]).subarray(1, 4), + new Uint8Array(new SharedArrayBuffer(3)).fill(255), + new Uint8Array(), ] - let next: ConnectionRpcResult = results[0]! + const cases: readonly { + readonly sent: ConnectionRpcHandlerResult + readonly expected: ConnectionRpcResult + }[] = [ + ...byteValues.map((data, index) => ({ + sent: { + ok: true as const, + value: { data: null, offset: index === 0 ? 7 : 0, eof: index !== 0, bytes: index === 0 ? 42 : data.length }, + attachments: [{ path: ['data'], bytes: data }], + }, + expected: { + ok: true as const, + value: { data, offset: index === 0 ? 7 : 0, eof: index !== 0, bytes: index === 0 ? 42 : data.length }, + }, + })), + { sent: { ok: true, value: { data: 'AID/', encoding: 'base64' } }, expected: { ok: true, value: { data: 'AID/', encoding: 'base64' } } }, + { sent: { ok: true, value: { count: 4 } }, expected: { ok: true, value: { count: 4 } } }, + { sent: { ok: true, value: null }, expected: { ok: true, value: null } }, + { sent: { ok: true, value: 'plain' }, expected: { ok: true, value: 'plain' } }, + { + sent: { ok: false, error: { code: 'fixture/denied', message: 'denied', details: { path: 'private' } } }, + expected: { ok: false, error: { code: 'fixture/denied', message: 'denied', details: { path: 'private' } } }, + }, + ] + let next: ConnectionRpcHandlerResult = cases[0]!.sent connection.rpc.intercept('/api', endpoint => endpoint === 'fixture/read', async () => next) const shared = connection.createSharedFetchHandler('/api') const mediaTypes: (string | null)[] = [] @@ -92,10 +115,10 @@ describe('Connection binary RPC', () => { mediaTypes.push(response.headers.get('content-type')) return response }) - for (const result of results) { - next = result + for (const { sent, expected } of cases) { + next = sent const received = await rpc.call('/api', 'fixture/read', { args: {} }) - expect(received).toEqual(result) + expect(received).toEqual(expected) if (received.ok && typeof received.value === 'object' && received.value !== null && 'data' in received.value && received.value.data instanceof Uint8Array) { expect(received.value.data.buffer).toBeInstanceOf(ArrayBuffer) @@ -108,28 +131,23 @@ describe('Connection binary RPC', () => { } }) - it('uses owner result codecs without putting their callbacks on the wire', async () => { + it('frames attachments already projected by the result owner', async () => { const ctx = new Context() const fiber = ctx.plugin((owner) => { new HostConnectionService(owner, [], {} as BrowserAuth) }) await fiber.await() try { const connection = ctx.get('connection') as HostConnectionService const content = Buffer.from([9, 0, 128, 255, 9]).subarray(1, 4) - let reads = 0 - const value = { content, metadata: { get count() { return ++reads } } } connection.rpc.intercept('/api', () => true, async () => ({ - ok: true, value, - encode: (input, writeBytes) => { - expect(input).toBe(value) - return { content: writeBytes(content, ['content']), metadata: value.metadata } - }, + ok: true, + value: { content: null, metadata: { count: 1 } }, + attachments: [{ path: ['content'], bytes: content }], })) const handler = connection.createSharedFetchHandler('/api') const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init))) expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ ok: true, value: { content: new Uint8Array([0, 128, 255]), metadata: { count: 1 } }, }) - expect(reads).toBe(1) } finally { await fiber.dispose() } @@ -166,55 +184,56 @@ describe('Connection binary RPC', () => { await expect(rpc.call('/api', 'fixture/read', {})).rejects.toThrow('rpcId mismatch') }) - it('roundtrips nested, optional and root bytes without changing caller objects or reserving field names', async () => { + it('roundtrips nested, optional and root attachment paths without reserving field names', async () => { const ctx = new Context() const fiber = ctx.plugin((owner) => { new HostConnectionService(owner, [], {} as BrowserAuth) }) await fiber.await() try { const data = new Uint8Array([0, 128, 255]) - const shared = { content: data } - const value = { - files: [{ name: 'a.png', ...shared }, { name: 'b.png', content: Buffer.from([9, 2, 3, 9]).subarray(1, 3) }, { name: 'empty' }], - 'dots./[]': [new Uint8Array(), null, [data]], - metadata: { attachments: shared }, - ...JSON.parse('{"__proto__":null,"constructor":null}') as object, + const second = Buffer.from([9, 2, 3, 9]).subarray(1, 3) + let next: ConnectionRpcHandlerResult = { + ok: true, + value: { + files: [{ name: 'a.png', content: null }, { name: 'b.png', content: null }, { name: 'empty' }], + 'dots./[]': [null, null, [null]], + metadata: { attachments: { content: null } }, + ...JSON.parse('{"__proto__":null,"constructor":null}') as object, + }, + attachments: [ + { path: ['files', 0, 'content'], bytes: data }, + { path: ['files', 1, 'content'], bytes: second }, + { path: ['dots./[]', 0], bytes: new Uint8Array() }, + { path: ['dots./[]', 2, 0], bytes: data }, + { path: ['metadata', 'attachments', 'content'], bytes: data }, + { path: ['__proto__'], bytes: data }, + ], } - Object.defineProperty(value, '__proto__', { value: data, enumerable: true }) - Object.freeze(value.files) - Object.freeze(value) - let next: unknown = value const connection = ctx.get('connection') as HostConnectionService - connection.rpc.intercept('/api', () => true, async () => ({ ok: true, value: next })) + connection.rpc.intercept('/api', () => true, async () => next) const handler = connection.createSharedFetchHandler('/api') const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init))) - expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ ok: true, value: { - ...value, files: [value.files[0], { name: 'b.png', content: new Uint8Array([2, 3]) }, value.files[2]], - } }) - expect(value.files[0]?.content).toBe(data) - expect(shared.content).toBe(data) - expect(Object.getPrototypeOf(value)).toBe(Object.prototype) + const expected = { + files: [{ name: 'a.png', content: data }, { name: 'b.png', content: new Uint8Array([2, 3]) }, { name: 'empty' }], + 'dots./[]': [new Uint8Array(), null, [data]], + metadata: { attachments: { content: data } }, + ...JSON.parse('{"__proto__":null,"constructor":null}') as object, + } + Object.defineProperty(expected, '__proto__', { value: data, enumerable: true }) + expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ ok: true, value: expected }) for (const bytes of [data, Buffer.from([0, 128, 255]), new Uint8Array(new SharedArrayBuffer(3))]) { - next = bytes + next = { ok: true, value: null, attachments: [{ path: [], bytes }] } const response = await rpc.call('/api', 'fixture/read', {}) expect(response).toEqual({ ok: true, value: new Uint8Array(bytes) }) if (!response.ok || !(response.value instanceof Uint8Array)) throw new Error('expected root bytes') expect(response.value.buffer).toBeInstanceOf(ArrayBuffer) expect(Object.isFrozen(response.value)).toBe(false) } - next = [data, [data]] - expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ ok: true, value: next }) - const circular: { next?: object } = {} - circular.next = circular - next = circular - await expect(rpc.call('/api', 'fixture/read', {})).rejects.toThrow('HTTP 500') - next = { ordinary: [1, 'base64', null], omitted: undefined, date: new Date('2026-01-01T00:00:00Z') } - expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ ok: true, value: { - ordinary: [1, 'base64', null], date: '2026-01-01T00:00:00.000Z', - } }) - const sparse = [data, , null] - Object.assign(sparse, { extra: data, '-1': data, '01': data, '1.5': data, Infinity: data }) - next = sparse - expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ ok: true, value: [data, null, null] }) + next = { + ok: true, + value: [null, [null]], + attachments: [{ path: [0], bytes: data }, { path: [1, 0], bytes: data }], + } + expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ ok: true, value: [data, [data]] }) } finally { await fiber.dispose() } @@ -250,49 +269,6 @@ describe('Connection binary RPC', () => { await expect(rpc.call('/api', 'fixture/read', {})).rejects.toThrow('invalid binary response') }) - it('preserves JSON accessor and toJSON evaluation while reading all array indices', async () => { - const ctx = new Context() - const fiber = ctx.plugin((owner) => { new HostConnectionService(owner, [], {} as BrowserAuth) }) - await fiber.await() - try { - let reads = 0 - let next: unknown = { get count() { return ++reads } } - const connection = ctx.get('connection') as HostConnectionService - connection.rpc.intercept('/api', () => true, async () => ({ ok: true, value: next })) - const handler = connection.createSharedFetchHandler('/api') - const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init))) - const read = () => rpc.call('/api', 'fixture/read', {}) - expect(await read()).toEqual({ ok: true, value: { count: 1 } }) - expect(reads).toBe(1) - const bytes = new Uint8Array([1, 2]) - next = { get content() { reads++; return bytes }, get count() { return ++reads } } - expect(await read()).toEqual({ ok: true, value: { content: bytes, count: 3 } }) - const array: Uint8Array[] = [] - Object.defineProperty(array, 0, { value: bytes }) - next = array - expect(await read()).toEqual({ ok: true, value: [bytes] }) - const date = Object.assign(new Date('2026-01-01T00:00:00Z'), { content: bytes }) - next = date - expect(await read()).toEqual({ ok: true, value: '2026-01-01T00:00:00.000Z' }) - const json = { self: {} as object, toJSON: () => ({ selected: true }) } - json.self = json - next = json - expect(await read()).toEqual({ ok: true, value: { selected: true } }) - let conversions = 0 - next = { toJSON(key: string) { conversions++; expect(key).toBe('value'); return this }, count: 5 } - expect(await read()).toEqual({ ok: true, value: { count: 5 } }) - expect(conversions).toBe(1) - for (const primitive of [1, 'text', true]) { - next = Object(primitive) as object - expect(await read()).toEqual({ ok: true, value: primitive }) - } - next = { toJSON: 'ordinary' } - expect(await read()).toEqual({ ok: true, value: next }) - } finally { - await fiber.dispose() - } - }) - it('rejects truncated multipart and late bytes after caller cancellation', async () => { const broken = createWebConnectionRpc(async () => new Response('truncated', { headers: { 'content-type': 'multipart/form-data; boundary=fixture' }, diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 6791693ea8..ddf1e301c6 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3365,22 +3365,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the page, the file\'s version at the stat before it, and whether it reaches the last line.', }, { - signature: '@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal, ): Promise', - description: 'Read one byte window of a regular file readable by the filesystem backend: raw bytes, no text decoding and no binary rejection.', - parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'absolute path or path relative to the workspace root; files outside it are allowed.' }, { name: 'range', description: 'the byte window; omitted fields take the window defaults.' }, { name: 'signal', description: 'caller cancellation.' }], - returns: 'the window in base64, the file\'s version and size at the stat before it, and whether it reaches the last byte.', - }, - { - signature: '@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise', - description: 'Read a complete regular file as bytes, subject to the configured full-file cap.', - parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'absolute or workspace-relative file path.' }, { name: 'signal', description: 'caller cancellation.' }], - returns: 'one complete base64 window with offset zero and eof true; oversized files fail with too-large.', - }, - { - signature: '@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise', - description: 'Read a complete file relative to another file\'s directory, including outside the workspace.', - parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'base file, absolute or workspace-relative.' }, { name: 'relativePath', description: 'relative filesystem path, not a URL or absolute path.' }, { name: 'signal', description: 'caller cancellation.' }], - returns: 'the complete related file using the ordinary file-size and access checks.', + signature: '@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal, ): Promise', + description: 'Read a complete regular file or one byte range without text decoding.', + parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'target path, absolute or workspace-relative; relative to the base file\'s directory when provided.' }, { name: 'options', description: 'optional base file and range; without a range the complete-file cap applies.' }, { name: 'signal', description: 'caller cancellation.' }], + returns: 'native bytes with the file\'s version and size at the preceding stat, byte offset, and EOF marker.', }, { signature: '@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise', @@ -4452,6 +4440,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ConnectionRequestRejection', declaration: 'export type ConnectionRequestRejection = 401 | 403 | undefined;', }, + { + name: 'ConnectionRpcAttachment', + declaration: 'export interface ConnectionRpcAttachment {\n readonly path: readonly (string | number)[];\n readonly bytes: Uint8Array;\n}', + }, { name: 'ConnectionRpcEndpointMatcher', declaration: 'export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean;', @@ -4462,7 +4454,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConnectionRpcHandler', - declaration: 'export type ConnectionRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal, peer: PeerScope) => Promise>;', + declaration: 'export type ConnectionRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal, peer: PeerScope) => Promise;', + }, + { + name: 'ConnectionRpcHandlerResult', + declaration: 'export type ConnectionRpcHandlerResult = {\n readonly ok: true;\n readonly value: unknown;\n readonly attachments?: readonly ConnectionRpcAttachment[];\n} | {\n readonly ok: false;\n readonly error: ConnectionRpcFailure;\n};', }, { name: 'ConnectionRpcResult', @@ -5702,7 +5698,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RenderedDocumentBytes', - declaration: 'export interface RenderedDocumentBytes extends WorkspaceFileBytes {\n readonly missingFonts: string[];\n readonly generation: OfficeToPdfGeneration;\n}', + declaration: 'export interface RenderedDocumentBytes extends Omit {\n readonly data: string;\n readonly missingFonts: string[];\n readonly generation: OfficeToPdfGeneration;\n}', }, { name: 'ReplayEnvelope', @@ -7034,7 +7030,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TypertCodec', - declaration: 'export type TypertCodec = {\n readonly mode: \'strict\';\n readonly typeSymbol: string;\n readonly create: () => TypertSchema;\n} | {\n readonly mode: \'src-json\';\n};', + declaration: 'export type TypertCodec = {\n readonly mode: \'strict\';\n readonly typeSymbol: string;\n readonly create: () => TypertSchema;\n readonly decode?: (value: unknown) => unknown;\n readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown;\n} | {\n readonly mode: \'src-json\';\n};', }, { name: 'TypertContribution', @@ -7332,6 +7328,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'WorkspaceByteRange', declaration: 'export interface WorkspaceByteRange {\n readonly offset?: number;\n readonly length?: number;\n}', }, + { + name: 'WorkspaceByteReadOptions', + declaration: 'export interface WorkspaceByteReadOptions {\n readonly range?: WorkspaceByteRange;\n readonly baseFile?: string;\n}', + }, { name: 'WorkspaceChangedFile', declaration: 'export interface WorkspaceChangedFile {\n path: string;\n display: string;\n added: number;\n deleted: number;\n binary?: true;\n oversized?: true;\n}', @@ -7370,7 +7370,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'WorkspaceFileBytes', - declaration: 'export interface WorkspaceFileBytes extends WorkspaceFileStat {\n readonly offset: number;\n readonly data: string;\n readonly eof: boolean;\n}', + declaration: 'export interface WorkspaceFileBytes extends WorkspaceFileStat {\n readonly offset: number;\n readonly data: Data;\n readonly eof: boolean;\n}', }, { name: 'WorkspaceFileChange', diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index b05ce9249b..ec2a88c4f9 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -280,7 +280,7 @@ export type TypertCodec = */ readonly decode?: (value: unknown) => unknown /** - * Project typed binary fields into carrier-owned multipart attachments. + * Project typed binary fields into RPC result attachments. * @param value - native unary result. * @param writeBytes - records a byte view at its result-relative path and returns its JSON placeholder. * @returns JSON metadata with untouched JSON subtrees retained.