Merge pull request #4401 from deepseek-harness/perf/workspace-file-binary-transfer

feat(api): support binary fields in Remote results
This commit is contained in:
Yudong Han authored and GitHub committed 2026-09-21 05:18:16 +00:00
commit eaf0f68bb2
127 files changed
+1908 -506

No files matched your search

@@ -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: 2a12aaf78f638d8af786d2ca817a3fee07a2c614
2026-08-02-typert-remote-method-calls.zh.md: bf4a7b0179b4dc878a0a53c7076c02e6f1bf0273
@@ -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.<namespace>` | Mounts Remote contributions, materializes each namespace as a traced `remote.<namespace>` 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 |
@@ -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.
@@ -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.<namespace>` | mount Remote contribution,把每个 namespace 实体化为可追踪的 `remote.<namespace>` 子 Service,并把规范调用交给 `ctx.connection.rpc` |
| API Remotes | 无新增服务 | 负责 Host Agent/Session lookup 策略,并作为 Client 业务的唯一 facade,选择并挂载 `/remote` contribution,同时暴露所选 API 声明 |
| Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider |
@@ -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 不可用必须明确失败。
@@ -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
@@ -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<WorkspaceFileText>
@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
@Remote async readRelated(workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal): Promise<WorkspaceFileBytes>
@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
@@ -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://<type>/…` 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://<type>/…` 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.
@@ -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<WorkspaceFileText>
@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
@Remote async readRelated(workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal): Promise<WorkspaceFileBytes>
@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
@@ -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://<type>/…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../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://<type>/…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../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` 之外的作用域;语法留有余地,提供者只服务一个。
@@ -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
@@ -16,9 +16,9 @@ Document Preview separates resource observation from content reads. The [resourc
Readable files use `dsh-resource://file/session/<sessionId>/<path>`. 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<ArrayBuffer>` 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 `<img>` 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<ArrayBuffer>` 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 `<img>` 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.
@@ -16,9 +16,9 @@ Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-0
可读取的文件使用 `dsh-resource://file/session/<sessionId>/<path>`。路径可以相对工作区,也可以是绝对路径;编码后的绝对路径保留前导斜杠。`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<ArrayBuffer>` 数据;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,在 `<img>` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。
Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array<ArrayBuffer>` 数据;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,在 `<img>` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 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 授权,也让同地址的所有读者共享观察。
@@ -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
@@ -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.
@@ -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。
@@ -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
@@ -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.
@@ -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 分发和随应用打包的离线转换。
@@ -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: b419c8bd15f51246825ceddf2905ec055bbc70ae
2026-09-17-workspace-file-binary-transfer.zh.md: 9cf3d7074c3d536a5c8a39732231c6996d6f6ead
@@ -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 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
- 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.
@@ -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,保持 strict JSON 值不变,并为源码模式调用执行运行时字节识别;随后向 Connection 返回 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 使用相同合成文件比较完整与分段读取,并区分传输/解码和完整读取路径。
@@ -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
@@ -34,7 +34,7 @@ The [Document Preview decision](../architecture/2026-09-08-document-preview-oper
A tab uses `dsh-resource://file/session/<sessionId>/<path>`, 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.
@@ -34,7 +34,7 @@ Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、
tab 使用 `dsh-resource://file/session/<sessionId>/<path>`,其中路径可以是相对路径或绝对路径([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 回来时停在读者离开的地方,而不是重读或再跳一次。刷新页面后什么都不保留。
@@ -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
@@ -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 |
@@ -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 关闭完成 |
+14
View File
@@ -571,7 +571,21 @@ 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).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)
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)
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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', '<namespace>/<method>', { args }, signal)`; the HTTP carrier maps this to `POST /api/<namespace>/<method>`, 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; 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, 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.
+3 -3
View File
@@ -122,9 +122,9 @@ Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则
Remote 调用使用 Connection 的 `/api` 路由。Client Remote 调用 `connection.rpc.call('/api', '<namespace>/<method>', { args }, signal)`;HTTP carrier 对应 `POST /api/<namespace>/<method>`,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 字段投影为 JSON 兼容元数据及相对于结果的字节附件,再由 Connection 封装为 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 策略,而这种策略并不存在;不能通过业务方法内部猜测对象是否来自恢复。
+2 -2
View File
@@ -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: 1fac327a8a50a09cf694f6fc31a0af1f031f596f
config-catalog.zh.md: fbc043ab62d57d1b7f754e28d3469cc5ebd4fb49
+3 -3
View File
@@ -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)
<a id="deepseek-aidsh-api-job-controller"></a>
@@ -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)
<a id="deepseek-aidsh-attachment-local"></a>
@@ -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)
<a id="deepseek-aidsh-client-hmr"></a>
+3 -3
View File
@@ -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)
<a id="deepseek-aidsh-api-job-controller"></a>
@@ -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)
<a id="deepseek-aidsh-attachment-local"></a>
@@ -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)
<a id="deepseek-aidsh-client-hmr"></a>
+2 -2
View File
@@ -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.zh.md: a57e1b7e9447939e6a6586c4743dfd39345cc15c
event-producer-consumer.md: 2b373821a2d0c9acf2284c4195a928947700f1ce
event-producer-consumer.zh.md: 90ca2a4c76ac477265a60dad80c5f65d04509e90
+4 -4
View File
@@ -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` |
@@ -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`) | - |
+1 -1
View File
@@ -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` |
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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<void>>
watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise<void>>
/**
* Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a
+1 -1
View File
@@ -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<void>>
watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise<void>>
/**
* Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 或追加事件。
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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<ArrayBuffer>` 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<ArrayBuffer>` 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
+3 -3
View File
@@ -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<ArrayBuffer>`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 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<ArrayBuffer>`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 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 传输返回原生字节。
## 内置类型
+2 -2
View File
@@ -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: db8b48945e2301e14d866b8a2e3df59c4ae408c9
typert.zh.md: cdaee66201d0f27a5a18aa99cb1888438051e018
+14 -1
View File
@@ -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 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.
*/
readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown
}
| {
readonly mode: 'src-json'
+14 -1
View File
@@ -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 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.
*/
readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown
}
| {
readonly mode: 'src-json'
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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.<namespace>` and Session-scoped `agentCtx.remote.<namespace>`. 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.
+1 -1
View File
@@ -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.<namespace>` 与 Session scope 的 `agentCtx.remote.<namespace>`。功能包依赖生成的 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()` 或拒绝。
+2 -2
View File
@@ -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
+5 -25
View File
@@ -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<WorkspaceFileText>
/**
* 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<WorkspaceFileBytes>
/**
* 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<WorkspaceFileBytes>
/**
* 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<WorkspaceFileBytes>
@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal, ): Promise<WorkspaceFileBytes>
/**
* Report one regular file's identity, version, and size without its content.
+5 -25
View File
@@ -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<WorkspaceFileText>
/**
* 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<WorkspaceFileBytes>
/**
* 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<WorkspaceFileBytes>
/**
* 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<WorkspaceFileBytes>
@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal, ): Promise<WorkspaceFileBytes>
/**
* Report one regular file's identity, version, and size without its content.
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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.<namespace>` 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<Out, In>` 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<Out, In>` 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<T>` — `{ 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.
+2 -2
View File
@@ -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.<namespace>` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格 codec 的 Client 供值字段,都会在方法可调用前报错。
每次一元调用都会检查位置参数数量,构造与描述符完全匹配的具名 `args`,再把带类型的值原样交给 `ctx.connection.rpc.call('/api', endpoint, ...)`,而不执行 Client 侧 schema;Host 会在业务调用前校验收到的 wire 字段。生成的流方法返回 `dsh-typert-protocol` 的 `RemoteStreamHandle<Out, In>`,并在被调用时就打开一条逻辑流,进程内 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<Out, In>`,并在被调用时就打开一条逻辑流,进程内 Connection 载体可用时通过它打开,否则通过共享的 Gateway WebSocket 打开。句柄只迭代下行一次。`send(item)` 把一个上行项入队,在 `open` 帧之后发出;`end()` 半关闭上行;`dispose()` 在未收到终止帧时发送 `cancel`,并让迭代安静结束。提前跳出迭代等价于 dispose 句柄;下行终止后 `send` 抛错,`end` 被忽略。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。Client 将一元结果转换交给 codec 的可选 `decode()`。生成的解码器校验嵌套元数据与原生字节类型,不逐字节遍历、复制或冻结字节载荷。JSON 一元结果与流项不经 Client 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。
每次一元调用都解析为 `RemoteResult<T>`——`{ 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)` 构造这两种折叠结果,测试里的替代实现据此采用相同的折叠方式。
+5 -1
View File
@@ -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
+77 -5
View File
@@ -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<unknown> {
const prepared = await this.prepareInvocation(request, new AbortController())
return this.invokePrepared(await this.prepareInvocation(request, new AbortController()))
}
private async invokePrepared(prepared: PreparedInvocation): Promise<unknown> {
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<ConnectionRpcResult> {
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<object>()
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<string, unknown> = {}
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
@@ -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<RemoteResult<{ readonly ref: string }>>
'probe/maybe': (value: string | null | undefined) => Promise<RemoteResult<string | null | undefined>>
'probe/bytes': (signal?: AbortSignal) => Promise<RemoteResult<{ readonly data: Uint8Array<ArrayBuffer>; readonly offset: number }>>
'probe/watch': (topic: string, signal?: AbortSignal) => AsyncIterable<string>
'probe/attach': (topic: string, signal?: AbortSignal) => RemoteStreamHandle<string, string>
}
@@ -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<ConnectionHandle['rpc']['call']>().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<ConnectionHandle['rpc']['call']>().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<typeof decode>).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<Awaited<ReturnType<ConnectionHandle['rpc']['call']>>>()
const call = vi.fn<ConnectionHandle['rpc']['call']>().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<unknown>): Promise<unknown[]> {
const values: unknown[] = []
for await (const value of source) values.push(value)
+130 -11
View File
@@ -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<ReturnType<ConnectionRpcHandler>>
type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<FakeRpcResult>
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<string, unknown>
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)
@@ -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
+11 -11
View File
@@ -25,29 +25,29 @@ Use this package to preview files readable through a Session's filesystem from t
<a id="use-this-package"></a>
## 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<ArrayBuffer>` 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/<sessionId>/<path>` 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/<path>` 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 |
+11 -11
View File
@@ -25,29 +25,29 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
把本包与 `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<ArrayBuffer>`,无需 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/<sessionId>/<path>` 地址携带授权 Session,以及相对或绝对路径;前导斜杠保留,例如 `dsh-resource://file/session/s//etc/hosts`。Host 原样接收路径,负责解析与权限检查;Client 不需要 Session `cwd`。`absolute/<path>` 仍可解析,但没有授权 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) | 浏览器插件、文件元数据与按目标建立的变更流 |
@@ -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:^",
+27 -49
View File
@@ -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<WorkspaceFileBytes> {
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<WorkspaceFileBytes> {
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<WorkspaceFileBytes> {
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<string> {
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')
+11 -3
View File
@@ -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<Data extends Uint8Array = Uint8Array> 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
}
@@ -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<typeof createWebConnectionRpc>
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<undefined>()
const controller = new AbortController()
const abortable = async (signal: AbortSignal | undefined): Promise<Uint8Array> => {
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
}
})
@@ -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, '<script src="./app.js"></script>')
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')
})
})
@@ -17,18 +17,16 @@ afterEach(async () => {
await harness.dispose()
})
const endpoint = (caps?: { maxBytes?: number }): ReturnType<Harness['endpoint']> => harness.endpoint(caps)
const endpoint = (caps?: { maxBytes?: number; maxFileBytes?: number }): ReturnType<Harness['endpoint']> => 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 })
})
})
+2 -2
View File
@@ -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
+3 -1
View File
@@ -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.
<a id="connection-generation"></a>
## Connection generation
+3 -1
View File
@@ -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 负责。
<a id="connection-generation"></a>
## Connection generation
+63 -1
View File
@@ -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,64 @@ export function createWebConnectionRpc(doFetch?: RpcFetch, openStream?: RpcStrea
}
}
async function parseBinaryResponse(response: Response): Promise<ReturnType<typeof parseConnectionResponse>> {
const body = await response.formData()
const fields = new Map<string, string | Blob>()
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) {
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) {
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
}
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<unknown>
+19 -6
View File
@@ -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,9 +13,20 @@ 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<string, string>): unknown
write(chunk: Uint8Array): boolean
end(): unknown
}
/**
* 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.
@@ -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<void> {
@@ -90,11 +101,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<void>((resolve) => {
const done = (): void => {
res.off('drain', done)
+2
View File
@@ -21,8 +21,10 @@ export type {
ConnectionIndexRequest,
ConnectionIndexResponse,
ConnectionRpcEndpointMatcher,
ConnectionRpcAttachment,
ConnectionRpcFailure,
ConnectionRpcHandler,
ConnectionRpcHandlerResult,
ConnectionRequestRejection,
ConnectionRpcResult,
ConnectionRequestBodyMode,
+17 -3
View File
@@ -292,9 +292,23 @@ function errorResponse(rpcId: RpcIdType, error: ConnectionRpcFailure): Response
return fullResponse(rpcId, { ok: false, error })
}
function fullResponse(rpcId: RpcIdType, result: ConnectionRpcResult<unknown>): Response {
const body: ConnectionServerResponse = { type: 'server-response', rpcId, result }
return Response.json(body)
function fullResponse(rpcId: RpcIdType, result: Awaited<ReturnType<ConnectionRpcHandler>>): Response {
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 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(attachment.bytes)]))
return { path: [...attachment.path], codec: 'bytes' as const, part }
})
parts.set('metadata', JSON.stringify({ ...body, attachments: attachmentMetadata }))
return new Response(parts)
}
function assertChannel(channel: string): void {
+19 -1
View File
@@ -27,6 +27,24 @@ export type ConnectionRpcResult<T> =
| { 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<T> = ConnectionRpcResult<T>
@@ -111,7 +129,7 @@ export type ConnectionRpcHandler = (
payload: unknown,
signal: AbortSignal,
peer: PeerScope,
) => Promise<ConnectionRpcResult<unknown>>
) => Promise<ConnectionRpcHandlerResult>
/** Synchronous ownership test for one endpoint on a shared RPC channel. */
export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean
@@ -0,0 +1,285 @@
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 { ConnectionRpcHandlerResult, 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: 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
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 byteValues = [
new Uint8Array([9, 0, 128, 255, 9]).subarray(1, 4),
new Uint8Array(new SharedArrayBuffer(3)).fill(255),
new Uint8Array(),
]
const cases: readonly {
readonly sent: ConnectionRpcHandlerResult
readonly expected: ConnectionRpcResult<unknown>
}[] = [
...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)[] = []
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 { sent, expected } of cases) {
next = sent
const received = await rpc.call('/api', 'fixture/read', { args: {} })
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)
}
}
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('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)
connection.rpc.intercept('/api', () => true, async () => ({
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 } },
})
} 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 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 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 },
],
}
const connection = ctx.get('connection') as HostConnectionService
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)))
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 = { 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 = {
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()
}
})
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<string, unknown>])[])('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<string, unknown>
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('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')
})
})
@@ -75,6 +75,73 @@ 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 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(), {
destroyed: false,
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 },
})
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 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 },
})
const started = Promise.withResolvers<undefined>()
let controller!: ReadableStreamDefaultController<Uint8Array>
const body = new ReadableStream<Uint8Array>({
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, {
@@ -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",
@@ -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
@@ -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<ArrayBuffer>` 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<ArrayBuffer>` 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 `<base href>` 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 `<base href>` 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 `<img>` 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.
</details>
@@ -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<ArrayBuffer>`,供 `{ 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<ArrayBuffer>`,供 `{ 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 引导载荷嵌入脚本文本。`<base href>` 将依赖解析交给浏览器,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 引导载荷嵌入脚本文本。`<base href>` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。
PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态图片上下文中渲染,带 12px 内边距和圆角。宽图按固有纵横比缩小到面板宽度,小图按固有 CSS 像素尺寸居中,超高图纵向滚动。渲染器既不提供缩放,也不提供拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。
@@ -86,7 +86,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
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 前复制。
</details>
@@ -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.
*/
@@ -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,
@@ -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<RemoteResult<WorkspaceFileBytes>>
export type ReadHtmlRelated = (address: string, relativePath: string, signal: AbortSignal) => Promise<RemoteResult<DocumentFileBytes>>
/**
* 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
}
}
@@ -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 }
@@ -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)
@@ -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<WorkspaceFileBytes, 'data'> & { readonly data: Uint8Array<ArrayBuffer> }
/** Document bytes borrowed read-only by renderers; copy before transferring to a Worker. */
export type DocumentFileBytes = WorkspaceFileBytes<Uint8Array<ArrayBuffer>>
/**
* 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<RemoteResult<DocumentFileBytes>>
/**
* 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<WorkspaceFileBytes, 'data'> & { 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)
@@ -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<ClientRemote['workspaceFiles']['readBytes']>(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()
})
})
@@ -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<ClientRemote['workspaceFiles']['read']>().mockImplementation(async (_sessionId, _path, range) => ({
const read = vi.fn<WorkspaceFilesReadRemote['workspaceFiles']['read']>().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<ClientRemote['workspaceFiles']['readAll']>().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 }) {
@@ -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 () => {
@@ -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<number, RemoteResult<WorkspaceFileText>>
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<DocumentFileBytes> {
return { ok: true, value: { absolutePath: '/host/notes', version: 'v1', offset: 0, eof: true, bytes: data.byteLength, data } }
}
@@ -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<string, unknown>()
const bodies = new Map<string, unknown>()
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)
@@ -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<HtmlBodyProps['readRelated']>().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) })
@@ -161,7 +161,7 @@ describe('HtmlBody', () => {
const preference = new DeveloperToolsPreference(host.scope)
const readRelated = vi.fn<HtmlBodyProps['readRelated']>().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('<p>Preview</p><script src="./asset.js"></script>'),
@@ -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 = {
@@ -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<ReadHtmlRelated>().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<ReadHtmlRelated>().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
})
})
@@ -55,7 +55,7 @@ async function harness(config: Partial<Config['office']> = {}, missing?: 'remote
const render = vi.fn<ClientRemote['officeToPdf']['render']>().mockResolvedValue(converted)
const rendererGeneration = vi.fn<ClientRemote['officeToPdf']['generation']>().mockResolvedValue({ ok: true, value: generation })
const stat = vi.fn<ClientRemote['workspaceFiles']['stat']>().mockResolvedValue({ ok: true, value: source })
const readBytes = vi.fn<ClientRemote['workspaceFiles']['readBytes']>().mockResolvedValue({ ok: true, value: source })
const readBytes = vi.fn<ClientRemote['workspaceFiles']['readBytes']>().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<OfficeStore['create']>['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() }
@@ -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()
})
})
+1 -1
View File
@@ -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) {
+3 -1
View File
@@ -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<WorkspaceFileBytes, 'data'> {
/** Base64 PDF contents carried by the JSON Remote response. */
readonly data: string
readonly missingFonts: string[]
readonly generation: OfficeToPdfGeneration
}
@@ -31,7 +31,7 @@ beforeEach(async () => {
fileInfo = vi.fn<FileSystem['stat']>().mockResolvedValue(sourceInfo)
processPath = vi.fn<FileSystem['processPath']>().mockReturnValue(source.absolutePath)
ctx.provide('fs', { resolve: vi.fn().mockResolvedValue(target), stat: fileInfo, processPath, readBytes: read } as never)
authorize = vi.fn<WorkspaceFiles['readBytes']>().mockResolvedValue(wireSource)
authorize = vi.fn<WorkspaceFiles['readBytes']>().mockResolvedValue({ ...wireSource, eof: false, data: rawSource.data.subarray(0, 1) })
metadata = vi.fn<WorkspaceFiles['stat']>().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 })
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more