Bladeren bron

refactor(client): extract background file upload service

creatixchu 1 week geleden
bovenliggende
commit
bbb2ca7c9b
61 gewijzigde bestanden met toevoegingen van 1217 en 496 verwijderingen
  1. 2 2
      .agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml
  2. 0 0
      .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md
  3. 0 0
      .agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md
  4. 2 2
      packages/api/session-controller/README.i18n.yaml
  5. 1 1
      packages/api/session-controller/README.md
  6. 1 1
      packages/api/session-controller/README.zh.md
  7. 3 1
      packages/api/session-controller/package.json
  8. 3 3
      packages/api/session-controller/src/client/contract/session.ts
  9. 14 2
      packages/api/session-controller/src/client/contract/snapshot.ts
  10. 6 3
      packages/api/session-controller/src/client/index.ts
  11. 0 3
      packages/api/session-controller/src/client/sessions/manager.ts
  12. 0 3
      packages/api/session-controller/src/client/sessions/service.ts
  13. 11 10
      packages/api/session-controller/src/client/sessions/session.ts
  14. 11 7
      packages/api/session-controller/tests/session-pending-submissions.client.spec.ts
  15. 72 10
      packages/api/session-controller/tests/session.client.spec.ts
  16. 1 0
      packages/api/session-controller/tsconfig.client.json
  17. 5 0
      packages/bundle/web-app/cordis.patch.yml
  18. 1 0
      packages/bundle/web-app/package.json
  19. 2 2
      packages/client/README.i18n.yaml
  20. 1 0
      packages/client/README.md
  21. 1 0
      packages/client/README.zh.md
  22. 2 2
      packages/client/connection/README.i18n.yaml
  23. 2 2
      packages/client/connection/README.md
  24. 2 2
      packages/client/connection/README.zh.md
  25. 0 184
      packages/client/connection/src/client/background-upload.ts
  26. 1 17
      packages/client/connection/src/client/index.ts
  27. 0 193
      packages/client/connection/tests/background-upload.client.spec.ts
  28. 0 1
      packages/client/connection/tsconfig.client.json
  29. 6 0
      packages/client/file-upload/README.i18n.yaml
  30. 94 0
      packages/client/file-upload/README.md
  31. 94 0
      packages/client/file-upload/README.zh.md
  32. 51 0
      packages/client/file-upload/package.json
  33. 49 0
      packages/client/file-upload/src/client/contract.ts
  34. 32 0
      packages/client/file-upload/src/client/index.ts
  35. 248 0
      packages/client/file-upload/src/client/runtime.ts
  36. 4 0
      packages/client/file-upload/src/index.ts
  37. 322 0
      packages/client/file-upload/tests/file-upload.client.spec.ts
  38. 15 0
      packages/client/file-upload/tsconfig.json
  39. 3 0
      packages/client/file-upload/tsdown.config.ts
  40. 5 5
      packages/client/ui-chat/src/client/chat/MessageItem.tsx
  41. 10 6
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  42. 5 5
      packages/client/ui-conversation/src/client/queue/QueueDock.tsx
  43. 7 5
      packages/client/ui-conversation/src/client/service.ts
  44. 5 2
      packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
  45. 7 4
      packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
  46. 2 2
      packages/experimental/webworker-runtime/README.i18n.yaml
  47. 1 1
      packages/experimental/webworker-runtime/README.md
  48. 1 1
      packages/experimental/webworker-runtime/README.zh.md
  49. 1 0
      packages/experimental/webworker-runtime/package.json
  50. 9 6
      packages/experimental/webworker-runtime/src/client/client.ts
  51. 9 0
      packages/experimental/webworker-runtime/src/client/index.ts
  52. 7 4
      packages/experimental/webworker-runtime/src/transport/frames.ts
  53. 10 0
      packages/experimental/webworker-runtime/src/transport/synthetic-http.ts
  54. 7 3
      packages/experimental/webworker-runtime/tests/transport/frames.spec.ts
  55. 17 1
      packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts
  56. 30 0
      packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts
  57. 3 0
      packages/experimental/webworker-runtime/tsconfig.json
  58. 15 0
      pnpm-lock.yaml
  59. 1 0
      scripts/gen-cordis-catalog.ts
  60. 2 0
      tsconfig.base.json
  61. 1 0
      tsconfig.client.json

+ 2 - 2
.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md
-2026-08-26-generic-file-upload.md: 91f331cfc43cdfd771953b27baffb712eb4cbd19
-2026-08-26-generic-file-upload.zh.md: 46c62750be7331acc384cb8c5271ef49cedb73f4
+2026-08-26-generic-file-upload.md: bc8eb9783096e5e7763969630b57419a64c0cb9f
+2026-08-26-generic-file-upload.zh.md: dbc392d700a84a77d321e5637be34da8c113a331

File diff suppressed because it is too large
+ 0 - 0
.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md


File diff suppressed because it is too large
+ 0 - 0
.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md


+ 2 - 2
packages/api/session-controller/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: 4f5d41ae34690e74a880c57434f99f06a12b23b8
-README.zh.md: 2404da3973e36514fdf27a78055403baffe2a478
+README.md: ab4001052306be1b052d1ca6a0e8ff8ddeffd459
+README.zh.md: 5100959e826e1099007a85d34b345a7d7d8481e8

+ 1 - 1
packages/api/session-controller/README.md

@@ -27,7 +27,7 @@ History pages and follow opening snapshots carry a discriminated `SessionHistory
 
 Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, file-reference, and file-upload operations may resolve or resume an ordinary Session. A generated Remote accepts base64 file bytes for non-browser carriers, while an authenticated exact Fetch route streams raw chunks from the browser background-upload carrier without entering Connection's buffered JSON path. The streaming path hashes and writes each chunk with backpressure, publishes the completed object atomically, and removes its staging file on cancellation or failure. Both paths store the file verbatim and stage it in this process for the same exact Agent; completion after that Agent is disposed stores no receipt. A later prompt or attachment-accepting command cites the opaque per-upload receipt. Both admission paths validate every same-Session receipt before persisting images. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message; prompt receipts retire when that `rpcId` becomes a user event or its queued occurrence is removed, while command-only receipts remain until Session disposal. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent.
 
-The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
+The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. A Client Session resolves the independent [`fileUpload`](../../client/file-upload/README.md) service from its Agent scope for `Blob` and one-shot `ReadableStream<Uint8Array>` bodies. A fixture or bare Session keeps the generated Remote fallback for `Blob` and exact-byte inputs; a stream requires the background service because it cannot be replayed through the base64 Remote.
 
 The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
 

+ 1 - 1
packages/api/session-controller/README.zh.md

@@ -27,7 +27,7 @@ kind: "package-reference"
 
 每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt、文件引用和文件上传操作可以解析或恢复普通 Session。生成 Remote 为非浏览器载体接收 base64 文件字节,经过认证的精确 Fetch 路由则从浏览器后台上传载体流式接收原始分块,不进入 Connection 的缓冲 JSON 路径。流式路径以背压逐块计算摘要和写入,完整接收后以原子方式发布对象,并在取消或失败时移除暂存文件。两条路径都按字节原样存储文件,并在本进程内按同一个准确 Agent 暂存;如果保存完成时该 Agent 已销毁,就不发布凭证。之后的 prompt 或接受附件的命令引用每次上传独有的不透明凭证。两条准入路径都先验证所有凭证属于同一 Session,再持久化图片。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息;prompt 凭证在同一 `rpcId` 进入 user event 或对应 queue occurrence 被移除后退休,只被命令使用的凭证则保留到 Session 销毁。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。
 
-Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
+Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。Client Session 从自己的 Agent scope 取得独立的 [`fileUpload`](../../client/file-upload/README.zh.md) 服务,用于 `Blob` 与只能使用一次的 `ReadableStream<Uint8Array>` 请求体。fixture 或未绑定的 Session 会让 `Blob` 与精确字节输入继续使用生成的 Remote;stream 无法通过 base64 Remote 重放,因此必须有后台服务。
 
 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
 

+ 3 - 1
packages/api/session-controller/package.json

@@ -47,7 +47,8 @@
         "@deepseek-ai/dsh-api-gateway/client"
       ],
       "inject": [
-        "@deepseek-ai/dsh-api-gateway"
+        "@deepseek-ai/dsh-api-gateway",
+        "@deepseek-ai/dsh-client-file-upload"
       ],
       "platform": "web"
     }
@@ -120,6 +121,7 @@
     "@deepseek-ai/dsh-api-gateway": "workspace:^",
     "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-client-file-upload": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-file-reference": "workspace:^",

+ 3 - 3
packages/api/session-controller/src/client/contract/session.ts

@@ -92,14 +92,14 @@ export interface ISession {
   /**
    * Persist one browser file verbatim and stage it for a later prompt on this
    * session. The returned opaque receipt is what a prompt file part cites.
-   * @param data - browser Blob or exact file bytes.
+   * @param data - browser Blob, exact file bytes, or a one-shot byte stream.
    * @param name - optional display name; the host sanitizes the stored leaf name.
    * @param signal - optional cancellation for the active upload.
-   * @param onProgress - optional byte-progress observer for background Blob uploads.
+   * @param onProgress - optional byte-progress observer for background Blob or stream uploads.
    * @returns the staged-upload receipt and durable file reference, or the business error.
    */
   uploadFile(
-    data: Blob | Uint8Array,
+    data: Blob | Uint8Array | ReadableStream<Uint8Array>,
     name?: string,
     signal?: AbortSignal,
     onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void,

+ 14 - 2
packages/api/session-controller/src/client/contract/snapshot.ts

@@ -31,10 +31,22 @@ export interface PendingSubmissionImage {
   readonly height?: number
 }
 
+/** Image branch of a local submission echo attachment. */
+export interface PendingSubmissionImageAttachment {
+  readonly type: 'image'
+  readonly value: PendingSubmissionImage
+}
+
+/** File branch of a local submission echo attachment. */
+export interface PendingSubmissionFileAttachment {
+  readonly type: 'file'
+  readonly value: FileAttachmentRef
+}
+
 /** One attachment displayed by a local submission echo, in prompt order. */
 export type PendingSubmissionAttachment =
-  | ({ readonly type: 'image' } & PendingSubmissionImage)
-  | { readonly type: 'file'; readonly attachment: FileAttachmentRef }
+  | PendingSubmissionImageAttachment
+  | PendingSubmissionFileAttachment
 
 /** Client surface selected when a local submission begins. */
 export type PendingSubmissionPlacement = 'transcript' | 'queued' | 'steering'

+ 6 - 3
packages/api/session-controller/src/client/index.ts

@@ -2,7 +2,8 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-agent/types'
-import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
+import type {} from '@deepseek-ai/dsh-client-connection/client'
+import type {} from '@deepseek-ai/dsh-client-file-upload/client'
 import { createSessionControlStream } from './transport.ts'
 import { ClientSessions } from './sessions/service.ts'
 import type { SessionRemotes } from './sessions/remotes.ts'
@@ -61,7 +62,9 @@ export type {
   OpenState,
   PendingSubmission,
   PendingSubmissionAttachment,
+  PendingSubmissionFileAttachment,
   PendingSubmissionImage,
+  PendingSubmissionImageAttachment,
   PendingSubmissionPlacement,
   PromptError,
   QueuedMessage,
@@ -78,6 +81,7 @@ declare module '@deepseek-ai/cordis' {
 /** Required Remote and Context projection services. */
 export const inject = [
   'connection',
+  'fileUpload',
   'typert',
   'remote',
   'remote.commands',
@@ -90,9 +94,8 @@ export const inject = [
  * @param ctx - Client Cordis context.
  */
 export function apply(ctx: Context): void {
-  const connection = ctx.get('connection') as ConnectionHandle
   const remotes = ctx.remote as unknown as SessionRemotes
-  const sessions = new ClientSessions(ctx, remotes, connection.backgroundUploads)
+  const sessions = new ClientSessions(ctx, remotes)
   ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) })
   ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) })
   ctx.remote.$on('api-session/status', (sessionId, running) => {

+ 0 - 3
packages/api/session-controller/src/client/sessions/manager.ts

@@ -25,7 +25,6 @@ import { Notifier } from './notifier.ts'
 import { ProjectionValueStore } from './projection-store.ts'
 import { Session } from './session.ts'
 import type { SessionRemotes } from './remotes.ts'
-import type { BackgroundUploadTransport } from '@deepseek-ai/dsh-client-connection/client'
 
 function sessionSeqCursor(value: number): SessionSeqCursor {
   return value === -1 ? -1 : SessionSeq(value)
@@ -154,7 +153,6 @@ export class SessionManager {
     private readonly remote: SessionRemotes,
     restoredSelection?: SessionId,
     restoredAddress?: SubagentAddress,
-    private readonly backgroundUploads?: BackgroundUploadTransport,
   ) {
     this.selected = restoredSelection
     if (restoredAddress !== undefined) this.addresses.set(restoredAddress.childSessionId, restoredAddress)
@@ -334,7 +332,6 @@ export class SessionManager {
         this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
       },
       projections: this.projectionStore(sessionId),
-      ...(this.backgroundUploads === undefined ? {} : { backgroundUploads: this.backgroundUploads }),
     })
   }
 

+ 0 - 3
packages/api/session-controller/src/client/sessions/service.ts

@@ -32,7 +32,6 @@ import type { AgentContext, ISessions } from '../contract/sessions.ts'
 import { createScope, scopeOf as scopeTagOf } from '../scope.ts'
 import { SessionManager } from './manager.ts'
 import type { SessionRemotes } from './remotes.ts'
-import type { BackgroundUploadTransport } from '@deepseek-ai/dsh-client-connection/client'
 import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
 import type { Session } from './session.ts'
 
@@ -222,7 +221,6 @@ export class ClientSessions implements ISessions {
   constructor(
     private readonly rootCtx: Context,
     remote: SessionRemotes,
-    backgroundUploads?: BackgroundUploadTransport,
   ) {
     this.selection = createSnapshotStore<SessionSelection>(
       {},
@@ -232,7 +230,6 @@ export class ClientSessions implements ISessions {
       remote,
       restored.sessionId,
       restored.subagentAddress,
-      backgroundUploads,
     )
     this.list = createSnapshotStore<SessionListState>({
       ids: [], byId: {}, current: undefined, phase: 'pending',

+ 11 - 10
packages/api/session-controller/src/client/sessions/session.ts

@@ -2,9 +2,7 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import { bytesToBase64, randomUUID } from '@deepseek-ai/dsh-util-crypto'
-import type {
-  BackgroundUploadProgress, BackgroundUploadTransport,
-} from '@deepseek-ai/dsh-client-connection/client'
+import type { FileUploadProgress } from '@deepseek-ai/dsh-client-file-upload/client'
 import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
@@ -76,8 +74,6 @@ export interface SessionOptions {
    * private store (bare object-layer construction).
    */
   projections?: ProjectionValueStore
-  /** Physical large-body carrier supplied by the active browser Connection. */
-  backgroundUploads?: BackgroundUploadTransport
 }
 
 /**
@@ -302,15 +298,17 @@ export class Session implements SessionFace {
   /**
    * Persist one browser file verbatim and stage it for a later prompt on this
    * session (ordinary sessions only; subagent conversations refuse).
-   * @param data - exact file bytes.
+   * @param data - exact bytes, a browser Blob, or a one-shot byte stream.
    * @param name - optional display name; the host sanitizes the stored leaf name.
+   * @param signal - optional cancellation for the active upload.
+   * @param onProgress - optional byte-progress observer for background bodies.
    * @returns the staged-upload receipt and durable file reference, or the business error.
    */
   async uploadFile(
-    data: Blob | Uint8Array,
+    data: Blob | Uint8Array | ReadableStream<Uint8Array>,
     name?: string,
     signal?: AbortSignal,
-    onProgress?: (progress: BackgroundUploadProgress) => void,
+    onProgress?: (progress: FileUploadProgress) => void,
   ): Promise<RemoteResult<SessionUploadFileValue>> {
     if (this.address !== undefined) {
       return {
@@ -322,10 +320,10 @@ export class Session implements SessionFace {
         ),
       }
     }
-    if (data instanceof Blob && this.options.backgroundUploads !== undefined) {
+    if (!(data instanceof Uint8Array) && this.actx?.fileUpload.available === true) {
       const query = new URLSearchParams({ sessionId: this.sessionId })
       if (name !== undefined) query.set('name', name)
-      const response = await this.options.backgroundUploads.post({
+      const response = await this.actx.fileUpload.post({
         path: `${SESSION_FILE_UPLOAD_PATH}?${query.toString()}`,
         body: data,
         headers: { 'content-type': 'application/octet-stream' },
@@ -337,6 +335,9 @@ export class Session implements SessionFace {
       }
       return parseFileUploadResult(response.body)
     }
+    if (!(data instanceof Uint8Array) && !(data instanceof Blob)) {
+      throw new Error('stream file upload requires a bound Client file-upload service')
+    }
     const bytes = data instanceof Uint8Array ? data : new Uint8Array(await data.arrayBuffer())
     return this.remote.session.uploadFile({
       sessionId: this.sessionId,

+ 11 - 7
packages/api/session-controller/tests/session-pending-submissions.client.spec.ts

@@ -84,14 +84,18 @@ describe('beginSubmission', () => {
     const handle = session.beginSubmission({
       mode: 'queue',
       text: '你好',
-      attachments: [{ type: 'image', previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
+      attachments: [{
+        type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 },
+      }],
     })
     expect(session.getSnapshot().promptAttempted).toBe(true)
     expect(session.getSnapshot().pendingSubmissions).toMatchObject([{
       requestId: handle.requestId,
       placement: 'transcript',
       text: '你好',
-      attachments: [{ type: 'image', previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
+      attachments: [{
+        type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 },
+      }],
     }])
   })
 
@@ -168,7 +172,7 @@ describe('observed retirement', () => {
     const handle = session.beginSubmission({
       mode: 'queue',
       text: '发送',
-      attachments: [{ type: 'image', previewUrl: 'blob:p1' }],
+      attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }],
       onRetire: retirement => retirements.push(retirement),
     })
     const refs = [imageRef('att-1')]
@@ -188,7 +192,7 @@ describe('observed retirement', () => {
     const handle = session.beginSubmission({
       mode: 'queue',
       text: '排队',
-      attachments: [{ type: 'image', previewUrl: 'blob:p1' }],
+      attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }],
       onRetire: retirement => retirements.push(retirement),
     })
     const refs = [imageRef('att-q')]
@@ -210,9 +214,9 @@ describe('observed retirement', () => {
       mode: 'queue',
       text: 'mixed',
       attachments: [
-        { type: 'image', previewUrl: 'blob:first' },
-        { type: 'file', attachment: file },
-        { type: 'image', previewUrl: 'blob:last' },
+        { type: 'image', value: { previewUrl: 'blob:first' } },
+        { type: 'file', value: file },
+        { type: 'image', value: { previewUrl: 'blob:last' } },
       ],
       onRetire: retirement => retirements.push(retirement),
     })

+ 72 - 10
packages/api/session-controller/tests/session.client.spec.ts

@@ -1,6 +1,8 @@
 /** Session object lifecycle, event-window transport, commands, and resync behavior. */
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import type { FileUploadService } from '@deepseek-ai/dsh-client-file-upload/client'
 import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
 import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
@@ -23,6 +25,16 @@ function makeSession(
   return { api, session: new Session(SID, fakeRemote(api), options) }
 }
 
+function bindFileUpload(
+  session: Session,
+  post: FileUploadService['post'],
+  available = true,
+): void {
+  const ctx = new Context()
+  ctx.reflect.provide('fileUpload', { available, post })
+  session.bindScope(ctx)
+}
+
 function follow(
   api: FakeApiClient,
   event: SessionEvent,
@@ -50,7 +62,7 @@ describe('Session file upload', () => {
     const progress = vi.fn()
     const post = vi.fn(async (request: {
       path: string
-      body: Blob
+      body: Blob | ReadableStream<Uint8Array>
       headers?: Readonly<Record<string, string>>
       signal?: AbortSignal
       onProgress?: (progress: { loaded: number; total?: number }) => void
@@ -67,7 +79,8 @@ describe('Session file upload', () => {
         }),
       }
     })
-    const { api, session } = makeSession(undefined, { backgroundUploads: { post } })
+    const { api, session } = makeSession()
+    bindFileUpload(session, post)
     const abort = new AbortController()
     const file = new Blob([Uint8Array.of(1, 2, 3, 4)])
 
@@ -96,7 +109,8 @@ describe('Session file upload', () => {
         error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } },
       }),
     }))
-    const { session } = makeSession(undefined, { backgroundUploads: { post } })
+    const { session } = makeSession()
+    bindFileUpload(session, post)
     await expect(session.uploadFile(new Blob([]))).resolves.toMatchObject({
       ok: false,
       error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } },
@@ -119,6 +133,56 @@ describe('Session file upload', () => {
     ])
   })
 
+  it('keeps fixture Blob fallback on the Remote and refuses an uncarried stream', async () => {
+    const { api, session } = makeSession()
+    const post = vi.fn<FileUploadService['post']>()
+    bindFileUpload(session, post, false)
+
+    await expect(session.uploadFile(new Blob([Uint8Array.of(1)]), 'fixture.bin'))
+      .resolves.toMatchObject({ ok: true })
+    const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
+    await expect(session.uploadFile(stream, 'stream.bin'))
+      .rejects.toThrow('stream file upload requires a bound Client file-upload service')
+    expect(post).not.toHaveBeenCalled()
+    expect(api.callsOf('session.uploadFile')).toEqual([
+      { sessionId: SID, data: 'AQ==', name: 'fixture.bin' },
+    ])
+  })
+
+  it('hands a one-shot ReadableStream to the scoped file-upload service', async () => {
+    const post = vi.fn(() => Promise.resolve({
+      status: 200,
+      body: JSON.stringify({
+        ok: true,
+        value: {
+          receiptId: 'stream-receipt',
+          file: { attachmentId: 'stream-file', name: 'stream.bin', bytes: 3 },
+        },
+      }),
+    }))
+    const { session } = makeSession()
+    bindFileUpload(session, post)
+    const stream = new ReadableStream<Uint8Array>({
+      start(controller) {
+        controller.enqueue(Uint8Array.of(1, 2, 3))
+        controller.close()
+      },
+    })
+    await expect(session.uploadFile(stream, 'stream.bin')).resolves.toMatchObject({ ok: true })
+    expect(post).toHaveBeenCalledWith(expect.objectContaining({
+      path: '/api/session/uploadFileBinary?sessionId=fk-s1&name=stream.bin',
+      body: stream,
+    }))
+  })
+
+  it('refuses a stream when a bare Session has no scoped upload service', async () => {
+    const { session } = makeSession()
+    const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
+    await expect(session.uploadFile(stream)).rejects.toThrow(
+      'stream file upload requires a bound Client file-upload service',
+    )
+  })
+
   it('folds non-200 and malformed background responses into transport failures', async () => {
     const bodies: unknown[] = [
       null,
@@ -137,15 +201,13 @@ describe('Session file upload', () => {
       { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: -1 } } },
     ]
     for (const body of bodies) {
-      const { session } = makeSession(undefined, {
-        backgroundUploads: { post: () => Promise.resolve({ status: 200, body: JSON.stringify(body) }) },
-      })
+      const { session } = makeSession()
+      bindFileUpload(session, () => Promise.resolve({ status: 200, body: JSON.stringify(body) }))
       await expect(session.uploadFile(new Blob([])))
         .rejects.toThrow(/file upload transport returned an invalid/)
     }
-    const { session } = makeSession(undefined, {
-      backgroundUploads: { post: () => Promise.resolve({ status: 503, body: 'unavailable' }) },
-    })
+    const { session } = makeSession()
+    bindFileUpload(session, () => Promise.resolve({ status: 503, body: 'unavailable' }))
     await expect(session.uploadFile(new Blob([])))
       .rejects.toThrow('file upload transport failed with HTTP 503')
   })
@@ -155,8 +217,8 @@ describe('Session file upload', () => {
     const api = new FakeApiClient()
     const session = new Session(SID, fakeRemote(api), {
       address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
-      backgroundUploads: { post },
     })
+    bindFileUpload(session, post)
     await expect(session.uploadFile(new Blob([]))).resolves.toMatchObject({
       ok: false,
       error: { code: 'subagent/attachment-invalid', details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' } },

+ 1 - 0
packages/api/session-controller/tsconfig.client.json

@@ -16,6 +16,7 @@
     { "path": "../gateway/tsconfig.client.json" },
     { "path": "../../attachment/attachment" },
     { "path": "../../client/connection/tsconfig.client.json" },
+    { "path": "../../client/file-upload" },
     { "path": "../../client/store" },
     { "path": "../../context/file-reference" },
     { "path": "../../core/session" },

+ 5 - 0
packages/bundle/web-app/cordis.patch.yml

@@ -168,6 +168,11 @@
         # its literals, for example: ['app.internal', ...ctx.webRuntime.trustedHosts].
         trustedHosts: !!js ctx.webRuntime.trustedHosts
 
+    # Raw Blob and ReadableStream uploads run independently of Connection's
+    # RPC and generation service.
+    - id: file-upload
+      name: '@deepseek-ai/dsh-client-file-upload'
+
     - id: api-remotes
       name: '@deepseek-ai/dsh-api-remotes'
 

+ 1 - 0
packages/bundle/web-app/package.json

@@ -44,6 +44,7 @@
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-client-file-upload": "workspace:^",
     "@deepseek-ai/dsh-client-hmr": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-modules": "workspace:^",

+ 2 - 2
packages/client/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/README.md
-README.md: 2358d94f6db0455aaf8fce094a616a273d4b3bab
-README.zh.md: 4fbeacf33dc609102a78418c3679a43f06914bdf
+README.md: 27fa0abba847d99dd5553a36e25077e4fac3b56c
+README.zh.md: 51a2d40a1ed2ea9a7bfe2ffe9ce96d23a4d27e73

+ 1 - 0
packages/client/README.md

@@ -29,6 +29,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
 | [`web/`](web/README.md) | Boots the browser shell | — |
 | [`modules/`](modules/README.md) | Loads browser-side client modules | `ctx.clientModules` / `ctx.modules` |
 | [`connection/`](connection/README.md) | Maintains browser-host RPC communication and event delivery | `ctx.connection` |
+| [`file-upload/`](file-upload/README.md) | Sends raw Blob and byte-stream request bodies outside the page thread | `ctx.fileUpload` |
 | [`store/`](store/README.md) | Provides React-free observable and snapshot-store primitives | — |
 | [`hmr/`](hmr/README.md) | Refreshes client plugins during development | — |
 | [`locale/`](locale/README.md) | Provides localization preferences and message dictionaries | `ctx.locale` |

+ 1 - 0
packages/client/README.zh.md

@@ -29,6 +29,7 @@ kind: "package-group"
 | [`web/`](web/README.zh.md) | 启动浏览器外壳 | — |
 | [`modules/`](modules/README.zh.md) | 加载浏览器侧客户端模块 | `ctx.clientModules` / `ctx.modules` |
 | [`connection/`](connection/README.zh.md) | 维护浏览器与宿主之间的 RPC 通信与事件投递 | `ctx.connection` |
+| [`file-upload/`](file-upload/README.zh.md) | 在页面线程之外发送原始 Blob 与字节流请求体 | `ctx.fileUpload` |
 | [`store/`](store/README.zh.md) | 提供不依赖 React 的 observable 与 snapshot-store 原语 | — |
 | [`hmr/`](hmr/README.zh.md) | 在开发期间刷新客户端插件 | — |
 | [`locale/`](locale/README.zh.md) | 提供本地化偏好与消息词典 | `ctx.locale` |

+ 2 - 2
packages/client/connection/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/connection/README.md
-README.md: e4bd8631508a31f8f586eb1dac47ade329a68622
-README.zh.md: 3fe76a8710870dbabbb39be611a0dc9239671ed3
+README.md: 91e96a34d15463fd4246bf8fd8aee6112aaa54c4
+README.zh.md: fc6686e688170427528cb51db51e166f3c447f26

+ 2 - 2
packages/client/connection/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-The package carries browser-to-Host Remote calls, exact Fetch responses, background Blob uploads, and connection generations. The Client plugin mounts `ctx.connection` with current-page loopback state, generic RPC and background-upload carriers, the active generation and its Host facts, observable recovery state, an immediate reconnect command, and the registration point for one generation source. A generation becomes visible when its source reports ready; source completion, failure, withdrawal, or an explicit stop clears it before `ConnectionController` applies its retry policy.
+The package carries browser-to-Host Remote calls, exact Fetch responses, and connection generations. The Client plugin mounts `ctx.connection` with current-page loopback state, generic RPC, the active generation and its Host facts, observable recovery state, an immediate reconnect command, and the registration point for one generation source. A generation becomes visible when its source reports ready; source completion, failure, withdrawal, or an explicit stop clears it before `ConnectionController` applies its retry policy.
 
 ## Table of Contents
 
@@ -25,7 +25,7 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, backgro
 <a id="use-this-package"></a>
 ## Use this package
 
-The browser uses HTTP POST for Remote unary calls. `connection.backgroundUploads` sends a Blob through a dedicated Worker on served pages and through the existing Host Worker on worker-hosted pages, so the page thread does not materialize large request bytes. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, Host/Origin checks, and exact `GET`/`HEAD`/`POST` route registry. 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.
+The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, Host/Origin checks, and exact `GET`/`HEAD`/`POST` route registry. 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).
 
 -----
 

+ 2 - 2
packages/client/connection/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本包承载浏览器到 Host 的 Remote 调用、精确 Fetch 响应、后台 Blob 上传与 connection generation。Client 插件挂载 `ctx.connection`,其中包含当前页面的 loopback 状态、通用 RPC 与后台上传 carrier、当前 generation 及其 Host 信息、可观察的恢复状态、立即重连命令,以及单一 generation source 的注册点。source 报告 ready 后 generation 才可见;source 结束、失败、被撤回或显式 stop 都会清空它,再由 `ConnectionController` 执行重试策略。
+本包承载浏览器到 Host 的 Remote 调用、精确 Fetch 响应与 connection generation。Client 插件挂载 `ctx.connection`,其中包含当前页面的 loopback 状态、通用 RPC、当前 generation 及其 Host 信息、可观察的恢复状态、立即重连命令,以及单一 generation source 的注册点。source 报告 ready 后 generation 才可见;source 结束、失败、被撤回或显式 stop 都会清空它,再由 `ConnectionController` 执行重试策略。
 
 ## 目录
 
@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-浏览器通过 HTTP POST 执行 Remote 一元调用。`connection.backgroundUploads` 在普通服务页面中通过专用 Worker 发送 Blob,在 Worker-hosted 页面中通过已有 Host Worker 发送,因此页面线程不会物化大请求的字节。API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证、Host/Origin 校验与精确 `GET`/`HEAD`/`POST` 路由注册表。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。
+浏览器通过 HTTP POST 执行 Remote 一元调用。API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证、Host/Origin 校验与精确 `GET`/`HEAD`/`POST` 路由注册表。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。
 
 -----
 

+ 0 - 184
packages/client/connection/src/client/background-upload.ts

@@ -1,184 +0,0 @@
-/** Background browser upload transport for large opaque request bodies. */
-
-import type { RpcFetch } from './rpc.ts'
-
-/** Monotone byte progress reported by a browser upload carrier. */
-export interface BackgroundUploadProgress {
-  readonly loaded: number
-  readonly total?: number
-}
-
-/** One background upload request. */
-export interface BackgroundUploadRequest {
-  readonly path: string
-  readonly body: Blob
-  readonly headers?: Readonly<Record<string, string>>
-  readonly signal?: AbortSignal
-  readonly onProgress?: (progress: BackgroundUploadProgress) => void
-}
-
-/** Small response returned after the background carrier has sent the body. */
-export interface BackgroundUploadResponse {
-  readonly status: number
-  readonly body: string
-}
-
-/** Browser carrier that keeps file reads and network submission off the page thread. */
-export interface BackgroundUploadTransport {
-  /**
-   * Post one Blob without materializing its bytes on the page thread.
-   * @param request - target, body, cancellation, and progress observer.
-   * @returns the response status and text body.
-   */
-  post(request: BackgroundUploadRequest): Promise<BackgroundUploadResponse>
-}
-
-interface UploadWorkerStart {
-  readonly url: string
-  readonly body: Blob
-  readonly headers: Readonly<Record<string, string>>
-}
-
-type UploadWorkerOutput =
-  | { readonly kind: 'progress'; readonly loaded: number; readonly total?: number }
-  | { readonly kind: 'complete'; readonly status: number; readonly body: string }
-  | { readonly kind: 'error'; readonly message: string }
-
-interface UploadWorkerScope {
-  onmessage: ((event: MessageEvent<UploadWorkerStart>) => void) | null
-  postMessage(message: UploadWorkerOutput): void
-}
-
-interface UploadXhr {
-  readonly upload: { onprogress: ((event: ProgressEvent) => void) | null }
-  status: number
-  responseText: string
-  withCredentials: boolean
-  onload: ((event: ProgressEvent) => void) | null
-  onerror: ((event: ProgressEvent) => void) | null
-  open(method: string, url: string): void
-  setRequestHeader(name: string, value: string): void
-  send(body: Blob): void
-}
-
-/**
- * Self-contained Worker body; its string form becomes the Blob Worker source.
- * @param scope - Worker global used for request and progress messages.
- * @param createXhr - XMLHttpRequest factory; injectable for unit coverage.
- */
-export function backgroundUploadWorker(
-  scope: UploadWorkerScope = self,
-  createXhr: () => UploadXhr = () => new XMLHttpRequest(),
-): void {
-  scope.onmessage = (event: MessageEvent<UploadWorkerStart>) => {
-    const request = event.data
-    const xhr = createXhr()
-    xhr.open('POST', request.url)
-    xhr.withCredentials = true
-    for (const [name, value] of Object.entries(request.headers)) xhr.setRequestHeader(name, value)
-    xhr.upload.onprogress = (progress) => {
-      scope.postMessage({
-        kind: 'progress',
-        loaded: progress.loaded,
-        ...(progress.lengthComputable ? { total: progress.total } : {}),
-      } satisfies UploadWorkerOutput)
-    }
-    xhr.onload = () => {
-      scope.postMessage({ kind: 'complete', status: xhr.status, body: xhr.responseText } satisfies UploadWorkerOutput)
-    }
-    xhr.onerror = () => {
-      scope.postMessage({ kind: 'error', message: 'background upload transport failed' } satisfies UploadWorkerOutput)
-    }
-    xhr.send(request.body)
-  }
-}
-
-/**
- * Create a background body carrier. A custom fetch already targets a Host
- * Worker, while the served Web path creates a dedicated upload Worker.
- * @param customFetch - worker-hosted transport hook, when present.
- * @returns the selected background carrier.
- */
-export function createBackgroundUploadTransport(customFetch?: RpcFetch): BackgroundUploadTransport {
-  return customFetch === undefined ? workerTransport() : customTransport(customFetch)
-}
-
-function customTransport(customFetch: RpcFetch): BackgroundUploadTransport {
-  return {
-    async post(request) {
-      const response = await customFetch(resolveUrl(request.path), {
-        method: 'POST',
-        ...(request.headers === undefined ? {} : { headers: request.headers }),
-        body: request.body,
-        ...(request.signal === undefined ? {} : { signal: request.signal }),
-      })
-      return { status: response.status, body: await response.text() }
-    },
-  }
-}
-
-function workerTransport(): BackgroundUploadTransport {
-  return {
-    post(request) {
-      if (typeof Worker !== 'function') {
-        return Promise.reject(new Error('background upload requires Web Worker support'))
-      }
-      const workerUrl = URL.createObjectURL(new Blob([
-        `(${backgroundUploadWorker.toString()})()`,
-      ], { type: 'text/javascript' }))
-      const worker = new Worker(workerUrl, { name: 'dsh-file-upload' })
-      URL.revokeObjectURL(workerUrl)
-      return new Promise((resolve, reject) => {
-        let settled = false
-        const abort = (): void => {
-          settled = true
-          worker.terminate()
-          request.signal?.removeEventListener('abort', abort)
-          reject(new DOMException('The operation was aborted.', 'AbortError'))
-        }
-        const finish = (settle: () => void): void => {
-          if (settled) return
-          settled = true
-          request.signal?.removeEventListener('abort', abort)
-          worker.terminate()
-          settle()
-        }
-        worker.onmessage = (event: MessageEvent<UploadWorkerOutput>) => {
-          const output = event.data
-          if (output.kind === 'progress') {
-            request.onProgress?.({
-              loaded: output.loaded,
-              ...(output.total === undefined ? {} : { total: output.total }),
-            })
-          } else if (output.kind === 'complete') {
-            finish(() => { resolve({ status: output.status, body: output.body }) })
-          } else {
-            finish(() => { reject(new Error(output.message)) })
-          }
-        }
-        worker.onerror = (event) => {
-          finish(() => { reject(new Error(event.message || 'background upload worker failed')) })
-        }
-        if (request.signal?.aborted === true) {
-          abort()
-          return
-        }
-        request.signal?.addEventListener('abort', abort, { once: true })
-        worker.postMessage({
-          url: resolveUrl(request.path).href,
-          body: request.body,
-          headers: request.headers ?? {},
-        } satisfies UploadWorkerStart)
-      })
-    },
-  }
-}
-
-function resolveUrl(path: string): URL {
-  const pageLocation = Reflect.get(globalThis, 'location') as unknown
-  const origin = typeof pageLocation === 'object' && pageLocation !== null
-    && 'origin' in pageLocation && typeof pageLocation.origin === 'string'
-    ? pageLocation.origin
-    : undefined
-  return new URL(path, origin === undefined || origin === 'null' ? 'http://dsh.internal' : origin)
-}

+ 1 - 17
packages/client/connection/src/client/index.ts

@@ -1,4 +1,4 @@
-/** Browser wire client: Remote transport, connection generations, and background uploads. */
+/** Browser wire client: Remote transport and connection generations. */
 import type { Context } from '@deepseek-ai/cordis'
 import {
   ConnectionController,
@@ -10,10 +10,6 @@ import {
 } from './connection.ts'
 import { createFixtureConnectionRpc } from './fixture.ts'
 import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc.ts'
-import {
-  createBackgroundUploadTransport,
-  type BackgroundUploadTransport,
-} from './background-upload.ts'
 import { isLoopbackHostname } from '../loopback-hostname.ts'
 import type { ClientConnectionRpc } from '../rpc.ts'
 
@@ -54,12 +50,6 @@ export type {
   ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult,
 } from '../rpc.ts'
 export type { RpcFetch } from './rpc.ts'
-export type {
-  BackgroundUploadProgress,
-  BackgroundUploadRequest,
-  BackgroundUploadResponse,
-  BackgroundUploadTransport,
-} from './background-upload.ts'
 
 /** Observable identity and Host facts for the active connection generation. */
 export interface ConnectionGenerationState {
@@ -130,8 +120,6 @@ export interface ConnectionHandle {
   readonly state: ConnectionStateSource
   /** Generic logical RPC channels over the same Connection transport. */
   readonly rpc: ClientConnectionRpc
-  /** Large-body carrier; absent for the in-page fixture transport. */
-  readonly backgroundUploads?: BackgroundUploadTransport
   /** Reset retry progression and replace the current attempt immediately. */
   reconnect(): void
   /**
@@ -195,9 +183,6 @@ export function apply(ctx: Context): void {
   const fixtureRpc = fixture ? createFixtureConnectionRpc() : undefined
   const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__
   const rpc = fixtureRpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream)
-  const backgroundUploads = fixtureRpc === undefined
-    ? createBackgroundUploadTransport(transport?.fetch)
-    : undefined
   let generationSource: ConnectionGenerationSource | undefined
   let owner: ConnectionOwner | undefined
   let generationId = 0
@@ -252,7 +237,6 @@ export function apply(ctx: Context): void {
       },
     },
     rpc,
-    ...(backgroundUploads === undefined ? {} : { backgroundUploads }),
     reconnect() {
       owner?.controller.reconnect()
     },

+ 0 - 193
packages/client/connection/tests/background-upload.client.spec.ts

@@ -1,193 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import {
-  backgroundUploadWorker, createBackgroundUploadTransport,
-} from '../src/client/background-upload.ts'
-
-afterEach(() => {
-  vi.restoreAllMocks()
-  vi.unstubAllGlobals()
-})
-
-describe('background upload worker body', () => {
-  it('sends the Blob with credentials and reports progress, completion, and failure', () => {
-    const posted: unknown[] = []
-    const scope: {
-      onmessage: ((event: MessageEvent<{
-        url: string
-        body: Blob
-        headers: Readonly<Record<string, string>>
-      }>) => void) | null
-      postMessage(message: unknown): void
-    } = { onmessage: null, postMessage: (message: unknown) => { posted.push(message) } }
-    const xhr: {
-      upload: { onprogress: ((event: ProgressEvent) => void) | null }
-      status: number
-      responseText: string
-      withCredentials: boolean
-      onload: ((event: ProgressEvent) => void) | null
-      onerror: ((event: ProgressEvent) => void) | null
-      open: ReturnType<typeof vi.fn>
-      setRequestHeader: ReturnType<typeof vi.fn>
-      send: ReturnType<typeof vi.fn>
-    } = {
-      upload: { onprogress: null },
-      status: 201,
-      responseText: '{"ok":true}',
-      withCredentials: false,
-      onload: null,
-      onerror: null,
-      open: vi.fn(),
-      setRequestHeader: vi.fn(),
-      send: vi.fn(),
-    }
-    backgroundUploadWorker(scope, () => xhr as never)
-    const body = new Blob(['large'])
-    scope.onmessage?.({
-      data: { url: 'https://harness.test/upload', body, headers: { 'content-type': 'application/octet-stream' } },
-    } as never)
-    expect(xhr.open).toHaveBeenCalledWith('POST', 'https://harness.test/upload')
-    expect(xhr.withCredentials).toBe(true)
-    expect(xhr.setRequestHeader).toHaveBeenCalledWith('content-type', 'application/octet-stream')
-    expect(xhr.send).toHaveBeenCalledWith(body)
-
-    xhr.upload.onprogress?.({ loaded: 2, total: 4, lengthComputable: true } as ProgressEvent)
-    xhr.upload.onprogress?.({ loaded: 3, total: 0, lengthComputable: false } as ProgressEvent)
-    xhr.onload?.({} as ProgressEvent)
-    xhr.onerror?.({} as ProgressEvent)
-    expect(posted).toEqual([
-      { kind: 'progress', loaded: 2, total: 4 },
-      { kind: 'progress', loaded: 3 },
-      { kind: 'complete', status: 201, body: '{"ok":true}' },
-      { kind: 'error', message: 'background upload transport failed' },
-    ])
-  })
-
-  it('uses the Worker globals when the emitted body supplies no test seams', () => {
-    const posted: unknown[] = []
-    const scope: {
-      onmessage: ((event: MessageEvent) => void) | null
-      postMessage(message: unknown): void
-    } = { onmessage: null, postMessage: (message) => { posted.push(message) } }
-    const xhr = {
-      upload: { onprogress: null },
-      status: 204,
-      responseText: '',
-      withCredentials: false,
-      onload: null,
-      onerror: null,
-      open: vi.fn(),
-      setRequestHeader: vi.fn(),
-      send: vi.fn(),
-    }
-    vi.stubGlobal('self', scope)
-    vi.stubGlobal('XMLHttpRequest', vi.fn(function () { return xhr }))
-    backgroundUploadWorker()
-    scope.onmessage?.({ data: { url: '/upload', body: new Blob(), headers: {} } } as MessageEvent)
-    expect(xhr.send).toHaveBeenCalledOnce()
-  })
-})
-
-describe('background upload page transport', () => {
-  it('hands Blob bodies to a custom Host Worker transport without reading them', async () => {
-    vi.stubGlobal('location', { origin: 'https://preview.test' })
-    const body = new Blob(['opaque'])
-    const fetch = vi.fn((_url: URL, _init?: RequestInit) =>
-      Promise.resolve(new Response('accepted', { status: 202 })))
-    const signal = new AbortController().signal
-    await expect(createBackgroundUploadTransport(fetch).post({
-      path: '/api/upload', body, headers: { 'x-test': 'yes' }, signal,
-    })).resolves.toEqual({ status: 202, body: 'accepted' })
-    expect(fetch).toHaveBeenCalledWith(new URL('https://preview.test/api/upload'), {
-      method: 'POST', headers: { 'x-test': 'yes' }, body, signal,
-    })
-
-    vi.stubGlobal('location', { origin: 'null' })
-    await createBackgroundUploadTransport(fetch).post({ path: '/fallback', body })
-    expect(fetch.mock.calls[1]?.[0]).toEqual(new URL('http://dsh.internal/fallback'))
-    expect(fetch.mock.calls[1]?.[1]).toEqual({ method: 'POST', body })
-  })
-
-  it('fails loud when a served browser has no Worker implementation', async () => {
-    vi.stubGlobal('Worker', undefined)
-    await expect(createBackgroundUploadTransport().post({ path: '/upload', body: new Blob() }))
-      .rejects.toThrow('background upload requires Web Worker support')
-  })
-
-  it('forwards progress and completion from a dedicated Worker and then terminates it', async () => {
-    const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker')
-    const revoked = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
-    class FakeWorker {
-      static last: FakeWorker | undefined
-      onmessage: ((event: MessageEvent) => void) | null = null
-      onerror: ((event: ErrorEvent) => void) | null = null
-      readonly postMessage = vi.fn()
-      readonly terminate = vi.fn()
-      constructor(readonly url: string, readonly options: WorkerOptions) { FakeWorker.last = this }
-    }
-    vi.stubGlobal('Worker', FakeWorker)
-    vi.stubGlobal('location', { origin: 'https://harness.test' })
-    const progress = vi.fn()
-    const body = new Blob(['bytes'])
-    const controller = new AbortController()
-    const pending = createBackgroundUploadTransport().post({
-      path: '/api/upload', body, onProgress: progress, signal: controller.signal,
-    })
-    const worker = FakeWorker.last
-    if (worker === undefined) throw new Error('worker missing')
-    expect(created).toHaveBeenCalledOnce()
-    expect(revoked).toHaveBeenCalledWith('blob:worker')
-    expect(worker.url).toBe('blob:worker')
-    expect(worker.options).toEqual({ name: 'dsh-file-upload' })
-    expect(worker.postMessage).toHaveBeenCalledWith({
-      url: 'https://harness.test/api/upload', body, headers: {},
-    })
-    worker.onmessage?.({ data: { kind: 'progress', loaded: 2 } } as MessageEvent)
-    worker.onmessage?.({ data: { kind: 'progress', loaded: 4, total: 5 } } as MessageEvent)
-    worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent)
-    worker.onmessage?.({ data: { kind: 'complete', status: 500, body: 'late' } } as MessageEvent)
-    await expect(pending).resolves.toEqual({ status: 200, body: 'done' })
-    controller.abort()
-    expect(progress).toHaveBeenCalledWith({ loaded: 2 })
-    expect(progress).toHaveBeenCalledWith({ loaded: 4, total: 5 })
-    expect(worker.terminate).toHaveBeenCalledOnce()
-  })
-
-  it('rejects worker messages, worker errors, and caller cancellation', async () => {
-    vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker')
-    vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
-    class FakeWorker {
-      static all: FakeWorker[] = []
-      onmessage: ((event: MessageEvent) => void) | null = null
-      onerror: ((event: ErrorEvent) => void) | null = null
-      readonly postMessage = vi.fn()
-      readonly terminate = vi.fn()
-      constructor() { FakeWorker.all.push(this) }
-    }
-    vi.stubGlobal('Worker', FakeWorker)
-    const transport = createBackgroundUploadTransport()
-
-    const reported = transport.post({ path: '/upload', body: new Blob() })
-    FakeWorker.all[0]?.onmessage?.({ data: { kind: 'error', message: 'network failed' } } as MessageEvent)
-    await expect(reported).rejects.toThrow('network failed')
-
-    const errored = transport.post({ path: '/upload', body: new Blob() })
-    FakeWorker.all[1]?.onerror?.({ message: 'worker crashed' } as ErrorEvent)
-    await expect(errored).rejects.toThrow('worker crashed')
-
-    const unnamed = transport.post({ path: '/upload', body: new Blob() })
-    FakeWorker.all[2]?.onerror?.({ message: '' } as ErrorEvent)
-    await expect(unnamed).rejects.toThrow('background upload worker failed')
-
-    const controller = new AbortController()
-    const aborted = transport.post({ path: '/upload', body: new Blob(), signal: controller.signal })
-    controller.abort()
-    await expect(aborted).rejects.toMatchObject({ name: 'AbortError' })
-    expect(FakeWorker.all[3]?.terminate).toHaveBeenCalledOnce()
-
-    const already = new AbortController()
-    already.abort()
-    await expect(transport.post({ path: '/upload', body: new Blob(), signal: already.signal }))
-      .rejects.toMatchObject({ name: 'AbortError' })
-    expect(FakeWorker.all[4]?.postMessage).not.toHaveBeenCalled()
-  })
-})

+ 0 - 1
packages/client/connection/tsconfig.client.json

@@ -8,7 +8,6 @@
   "files": [
     "src/api-path.ts",
     "src/client/api.ts",
-    "src/client/background-upload.ts",
     "src/client/connection.ts",
     "src/client/fixture.ts",
     "src/client/index.ts",

+ 6 - 0
packages/client/file-upload/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/client/file-upload/README.md
+README.md: d7cac384c1cef4f52f4f7c09838697d53bba7c4d
+README.zh.md: 6ced413d1813c1808daef10e0cf283cd973eff68

+ 94 - 0
packages/client/file-upload/README.md

@@ -0,0 +1,94 @@
+---
+description: "Background browser uploads for raw Blob and ReadableStream bodies, including worker transfer, progress, and cancellation."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-file-upload
+
+English | [中文](README.zh.md)
+
+## Summary
+
+This package lets browser features upload a `Blob` or `ReadableStream<Uint8Array>` without aggregating its bytes on the page thread. Served pages send each body through a dedicated Worker; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. The standalone `?fixture` page reports the background carrier as unavailable so its Session adapter can keep using the generated in-memory Remote.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+Mount the Client plugin before a consumer that injects `fileUpload`, then call `ctx.fileUpload.post()` with a same-origin path and one raw body.
+
+```yaml
+- id: file-upload
+  name: '@deepseek-ai/dsh-client-file-upload'
+```
+
+The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest inside a dedicated Worker so the service can report browser upload progress, including the total when the browser provides it. A `ReadableStream` transfers to that Worker and feeds Fetch incrementally; progress reports consumed bytes without a total. An `AbortSignal` terminates the dedicated Worker or reaches a page-owned carrier.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+The Client plugin provides one inherited `ctx.fileUpload` service. Its provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Consumers inspect `ctx.fileUpload.available` before selecting it. Without a hook, each non-fixture request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks.
+
+| File | Role |
+|---|---|
+| [`src/client/contract.ts`](src/client/contract.ts) | Cordis service request, response, progress, and page-hook types |
+| [`src/client/runtime.ts`](src/client/runtime.ts) | Dedicated Worker and page-owned carrier implementations |
+| [`src/client/index.ts`](src/client/index.ts) | Client plugin registration and `ctx.fileUpload` declaration |
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+- [Connection](../connection/README.md) — authenticated RPC, exact Host routes, and connection generations.
+- [Session Controller](../../api/session-controller/README.md) — the raw file-upload route and staged Session receipt.
+- [Web Worker runtime](../../experimental/webworker-runtime/README.md) — the page-to-Host Worker request tunnel.
+- [Client group map](../README.md) — browser services and UI feature packages.
+
+-----
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as this package transfers browser request bodies and contributes no model input.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+These limits apply to the transport operation itself.
+
+- **Uploads are not resumable** — a failed or cancelled retry starts from the first byte.
+- **Stream bodies are one-shot** — transferring a `ReadableStream` locks the caller's object, so retry requires a newly created stream.
+- **Stream progress has no total** — callers receive consumed-byte counts because the stream API carries no byte length.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>

+ 94 - 0
packages/client/file-upload/README.zh.md

@@ -0,0 +1,94 @@
+---
+description: "在浏览器后台上传原始 Blob 与 ReadableStream,包括 Worker 转交、进度和取消。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-file-upload
+
+[English](README.md) | 中文
+
+## 概述
+
+本包让浏览器功能上传 `Blob` 或 `ReadableStream<Uint8Array>`,同时避免在页面线程聚合全部字节。普通服务页面通过专用 Worker 发送每个请求体;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。独立的 `?fixture` 页面会报告后台载体不可用,让 Session adapter 继续使用生成的内存 Remote。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+在注入 `fileUpload` 的消费方之前挂载 Client 插件,再用同源路径和一个原始请求体调用 `ctx.fileUpload.post()`。
+
+```yaml
+- id: file-upload
+  name: '@deepseek-ai/dsh-client-file-upload'
+```
+
+本包没有 Cordis 配置字段。`Blob` 在专用 Worker 内通过 XMLHttpRequest 发送,因此服务可以报告浏览器上传进度,并在浏览器提供总量时一并报告。`ReadableStream` 会转移给该 Worker,再增量传入 Fetch;进度只报告已消费字节,不包含总量。`AbortSignal` 会终止专用 Worker,或传递给页面自己提供的载体。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<details>
+<summary>实现细节——点击展开</summary>
+
+Client 插件提供可被各级上下文继承的 `ctx.fileUpload` 服务。其提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。消费者在选择服务前检查 `ctx.fileUpload.available`。没有该钩子时,每个非 fixture 请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。
+
+| 文件 | 职责 |
+|---|---|
+| [`src/client/contract.ts`](src/client/contract.ts) | Cordis 服务的请求、响应、进度与页面钩子类型 |
+| [`src/client/runtime.ts`](src/client/runtime.ts) | 专用 Worker 与页面自有载体实现 |
+| [`src/client/index.ts`](src/client/index.ts) | Client 插件注册与 `ctx.fileUpload` 声明 |
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 进一步探索
+
+- [Connection](../connection/README.zh.md)——认证 RPC、Host 精确路由与 connection generation。
+- [Session Controller](../../api/session-controller/README.zh.md)——原始文件上传路由与按 Session 暂存的凭证。
+- [Web Worker runtime](../../experimental/webworker-runtime/README.zh.md)——页面到 Host Worker 的请求隧道。
+- [客户端组地图](../README.zh.md)——浏览器服务与 UI 功能包。
+
+-----
+
+<a id="model-experience"></a>
+## 模型体验
+
+无。本包只传输浏览器请求体,不提供模型输入。
+
+#### KV Cache 影响
+
+无;本包既不组装也不发送提供方请求。
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+以下限制适用于传输操作本身。
+
+- **上传不能断点续传**:失败或取消后的重试会从第一个字节开始。
+- **stream 请求体只能使用一次**:转移 `ReadableStream` 会锁定调用方的对象,因此重试必须重新创建 stream。
+- **stream 进度没有总量**:stream API 不携带字节长度,因此调用方只能收到已消费字节数。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者工作上下文——点击展开</summary>
+
+无。
+
+</details>

+ 51 - 0
packages/client/file-upload/package.json

@@ -0,0 +1,51 @@
+{
+  "name": "@deepseek-ai/dsh-client-file-upload",
+  "description": "Browser background upload service for Blob and ReadableStream request bodies",
+  "version": "0.1.2-alpha.4",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/client/file-upload"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dsh": {
+    "client": {
+      "inject": [],
+      "platform": "web",
+      "immediately": true
+    }
+  },
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
+  "license": "MIT",
+  "files": [
+    "lib/index.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts"
+  ],
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^"
+  }
+}

+ 49 - 0
packages/client/file-upload/src/client/contract.ts

@@ -0,0 +1,49 @@
+/** Browser request body accepted by the background file-upload service. */
+export type FileUploadBody = Blob | ReadableStream<Uint8Array>
+
+/** Monotone byte progress reported while a browser body is consumed. */
+export interface FileUploadProgress {
+  readonly loaded: number
+  readonly total?: number
+}
+
+/** One background upload request. */
+export interface FileUploadRequest {
+  readonly path: string
+  readonly body: FileUploadBody
+  readonly headers?: Readonly<Record<string, string>>
+  readonly signal?: AbortSignal
+  readonly onProgress?: (progress: FileUploadProgress) => void
+}
+
+/** Small response returned after the service has sent the body. */
+export interface FileUploadResponse {
+  readonly status: number
+  readonly body: string
+}
+
+/** Browser upload service inherited by every Client Cordis scope. */
+export interface FileUploadService {
+  /** Whether this page has a Host-backed background upload carrier. */
+  readonly available: boolean
+  /**
+   * Post one Blob or one-shot byte stream without aggregating it on the page thread.
+   * The call consumes a stream body and rejects with `AbortError` when its signal fires.
+   * @param request - target, body, cancellation, and progress observer.
+   * @returns the response status and text body.
+   */
+  post(request: FileUploadRequest): Promise<FileUploadResponse>
+}
+
+/**
+ * Fetch-shaped carrier installed by a page that owns its Host transport.
+ * @param input - absolute same-origin upload URL.
+ * @param init - raw request body, headers, and cancellation signal.
+ * @returns the Host response.
+ */
+export type FileUploadFetch = (input: URL, init: RequestInit) => Promise<Response>
+
+/** Pre-Cordis hook supplied by a page whose Host runs in another execution context. */
+export interface ClientFileUploadHooks {
+  readonly fetch: FileUploadFetch
+}

+ 32 - 0
packages/client/file-upload/src/client/index.ts

@@ -0,0 +1,32 @@
+/** Browser background-upload Cordis service. */
+
+import type { Context } from '@deepseek-ai/cordis'
+import { FileUploadRuntime } from './runtime.ts'
+
+export type {
+  ClientFileUploadHooks,
+  FileUploadBody,
+  FileUploadFetch,
+  FileUploadProgress,
+  FileUploadRequest,
+  FileUploadResponse,
+  FileUploadService,
+} from './contract.ts'
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    /** Browser service for non-aggregating Blob and byte-stream uploads. */
+    fileUpload: import('./contract.ts').FileUploadService
+  }
+}
+
+/** This transport service has no Cordis dependencies. */
+export const inject: string[] = []
+
+/**
+ * Provide the browser background-upload service.
+ * @param ctx - Client plugin context.
+ */
+export function apply(ctx: Context): void {
+  ctx.plugin(FileUploadRuntime)
+}

+ 248 - 0
packages/client/file-upload/src/client/runtime.ts

@@ -0,0 +1,248 @@
+/** Background browser upload implementation for Blob and byte-stream bodies. */
+
+import { Service, type Context } from '@deepseek-ai/cordis'
+import type {
+  ClientFileUploadHooks,
+  FileUploadBody,
+  FileUploadFetch,
+  FileUploadRequest,
+  FileUploadResponse,
+  FileUploadService,
+} from './contract.ts'
+
+interface UploadWorkerStart {
+  readonly url: string
+  readonly body: FileUploadBody
+  readonly headers: Readonly<Record<string, string>>
+}
+
+type UploadWorkerOutput =
+  | { readonly kind: 'progress'; readonly loaded: number; readonly total?: number }
+  | { readonly kind: 'complete'; readonly status: number; readonly body: string }
+  | { readonly kind: 'error'; readonly message: string }
+
+interface UploadWorkerScope {
+  onmessage: ((event: MessageEvent<UploadWorkerStart>) => void) | null
+  postMessage(message: UploadWorkerOutput): void
+}
+
+interface UploadXhr {
+  readonly upload: { onprogress: ((event: ProgressEvent) => void) | null }
+  status: number
+  responseText: string
+  withCredentials: boolean
+  onload: ((event: ProgressEvent) => void) | null
+  onerror: ((event: ProgressEvent) => void) | null
+  open(method: string, url: string): void
+  setRequestHeader(name: string, value: string): void
+  send(body: Blob): void
+}
+
+type UploadWorkerFetch = (
+  input: string,
+  init: RequestInit & { readonly duplex: 'half' },
+) => Promise<Response>
+
+/**
+ * Self-contained Worker body; its string form becomes the Blob Worker source.
+ * @param scope - Worker global used for requests and progress messages.
+ * @param createXhr - XMLHttpRequest factory used for Blob progress.
+ * @param doFetch - Fetch carrier used for one-shot ReadableStream bodies.
+ */
+export function fileUploadWorker(
+  scope: UploadWorkerScope = self,
+  createXhr: () => UploadXhr = () => new XMLHttpRequest(),
+  doFetch: UploadWorkerFetch = (input, init) => fetch(input, init),
+): void {
+  scope.onmessage = (event: MessageEvent<UploadWorkerStart>) => {
+    const request = event.data
+    if (request.body instanceof Blob) {
+      const xhr = createXhr()
+      xhr.open('POST', request.url)
+      xhr.withCredentials = true
+      for (const [name, value] of Object.entries(request.headers)) xhr.setRequestHeader(name, value)
+      xhr.upload.onprogress = (progress) => {
+        scope.postMessage({
+          kind: 'progress',
+          loaded: progress.loaded,
+          ...(progress.lengthComputable ? { total: progress.total } : {}),
+        } satisfies UploadWorkerOutput)
+      }
+      xhr.onload = () => {
+        scope.postMessage({ kind: 'complete', status: xhr.status, body: xhr.responseText } satisfies UploadWorkerOutput)
+      }
+      xhr.onerror = () => {
+        scope.postMessage({ kind: 'error', message: 'background upload transport failed' } satisfies UploadWorkerOutput)
+      }
+      xhr.send(request.body)
+      return
+    }
+    if (!(request.body instanceof ReadableStream)) {
+      scope.postMessage({ kind: 'error', message: 'background upload worker received an invalid body' })
+      return
+    }
+    const source = request.body
+    void (async () => {
+      const reader = source.getReader()
+      let loaded = 0
+      const body = new ReadableStream<Uint8Array>({
+        async pull(controller) {
+          const item = await reader.read()
+          if (item.done) {
+            controller.close()
+            return
+          }
+          if (!(item.value instanceof Uint8Array)) {
+            throw new TypeError('background upload stream produced a non-Uint8Array chunk')
+          }
+          loaded += item.value.byteLength
+          scope.postMessage({ kind: 'progress', loaded })
+          controller.enqueue(item.value)
+        },
+        async cancel(reason) {
+          await reader.cancel(reason)
+        },
+      })
+      const response = await doFetch(request.url, {
+        method: 'POST',
+        headers: request.headers,
+        credentials: 'include',
+        body,
+        duplex: 'half',
+      })
+      scope.postMessage({
+        kind: 'complete',
+        status: response.status,
+        body: await response.text(),
+      })
+    })().catch((error: unknown) => {
+      scope.postMessage({
+        kind: 'error',
+        message: error instanceof Error ? error.message : String(error),
+      })
+    })
+  }
+}
+
+interface ClientFileUploadGlobal {
+  __DSH_FILE_UPLOAD__?: ClientFileUploadHooks
+}
+
+interface FileUploadTransport {
+  post(request: FileUploadRequest): Promise<FileUploadResponse>
+}
+
+/** Cordis service that owns one background carrier per upload operation. */
+export class FileUploadRuntime extends Service implements FileUploadService {
+  readonly available: boolean
+  private readonly transport: FileUploadTransport
+
+  /** @param ctx - providing Client context. */
+  constructor(ctx: Context) {
+    super(ctx, 'fileUpload')
+    const hook = (globalThis as ClientFileUploadGlobal).__DSH_FILE_UPLOAD__
+    this.available = hook !== undefined || !isFixturePage()
+    this.transport = hook === undefined ? workerTransport() : customTransport(hook.fetch)
+  }
+
+  /**
+   * Post one body with the carrier selected before Cordis boot.
+   * @param request - target, body, cancellation, and progress observer.
+   * @returns the response status and text body.
+   */
+  post(request: FileUploadRequest): Promise<FileUploadResponse> {
+    if (!this.available) return Promise.reject(new Error('background upload is unavailable in fixture mode'))
+    return this.transport.post(request)
+  }
+}
+
+function customTransport(customFetch: FileUploadFetch): FileUploadTransport {
+  return {
+    async post(request) {
+      const init: RequestInit & { duplex?: 'half' } = {
+        method: 'POST',
+        ...(request.headers === undefined ? {} : { headers: request.headers }),
+        body: request.body,
+        ...(request.body instanceof ReadableStream ? { duplex: 'half' as const } : {}),
+        ...(request.signal === undefined ? {} : { signal: request.signal }),
+      }
+      const response = await customFetch(resolveUrl(request.path), init)
+      return { status: response.status, body: await response.text() }
+    },
+  }
+}
+
+function workerTransport(): FileUploadTransport {
+  return {
+    post(request) {
+      if (typeof Worker !== 'function') {
+        return Promise.reject(new Error('background upload requires Web Worker support'))
+      }
+      const workerUrl = URL.createObjectURL(new Blob([
+        `(${fileUploadWorker.toString()})()`,
+      ], { type: 'text/javascript' }))
+      const worker = new Worker(workerUrl, { name: 'dsh-file-upload' })
+      URL.revokeObjectURL(workerUrl)
+      return new Promise((resolve, reject) => {
+        let settled = false
+        const abort = (): void => {
+          settled = true
+          worker.terminate()
+          request.signal?.removeEventListener('abort', abort)
+          reject(new DOMException('The operation was aborted.', 'AbortError'))
+        }
+        const finish = (settle: () => void): void => {
+          if (settled) return
+          settled = true
+          request.signal?.removeEventListener('abort', abort)
+          worker.terminate()
+          settle()
+        }
+        worker.onmessage = (event: MessageEvent<UploadWorkerOutput>) => {
+          const output = event.data
+          if (output.kind === 'progress') {
+            request.onProgress?.({
+              loaded: output.loaded,
+              ...(output.total === undefined ? {} : { total: output.total }),
+            })
+          } else if (output.kind === 'complete') {
+            finish(() => { resolve({ status: output.status, body: output.body }) })
+          } else {
+            finish(() => { reject(new Error(output.message)) })
+          }
+        }
+        worker.onerror = (event) => {
+          finish(() => { reject(new Error(event.message || 'background upload worker failed')) })
+        }
+        if (request.signal?.aborted === true) {
+          abort()
+          return
+        }
+        request.signal?.addEventListener('abort', abort, { once: true })
+        const message: UploadWorkerStart = {
+          url: resolveUrl(request.path).href,
+          body: request.body,
+          headers: request.headers ?? {},
+        }
+        if (request.body instanceof ReadableStream) worker.postMessage(message, [request.body])
+        else worker.postMessage(message)
+      })
+    },
+  }
+}
+
+function resolveUrl(path: string): URL {
+  const pageLocation = Reflect.get(globalThis, 'location') as unknown
+  const origin = typeof pageLocation === 'object' && pageLocation !== null
+    && 'origin' in pageLocation && typeof pageLocation.origin === 'string'
+    ? pageLocation.origin
+    : undefined
+  return new URL(path, origin === undefined || origin === 'null' ? 'http://dsh.internal' : origin)
+}
+
+function isFixturePage(): boolean {
+  const pageLocation = Reflect.get(globalThis, 'location') as unknown
+  return typeof pageLocation === 'object' && pageLocation !== null
+    && 'search' in pageLocation && typeof pageLocation.search === 'string'
+    && new URLSearchParams(pageLocation.search).has('fixture')
+}

+ 4 - 0
packages/client/file-upload/src/index.ts

@@ -0,0 +1,4 @@
+/** Host half of the browser-only file-upload service. */
+
+/** No Host behavior; the Client half provides the browser upload service. */
+export function apply(): void {}

+ 322 - 0
packages/client/file-upload/tests/file-upload.client.spec.ts

@@ -0,0 +1,322 @@
+import { Context } from '@deepseek-ai/cordis'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { apply } from '../src/client/index.ts'
+import { fileUploadWorker, FileUploadRuntime } from '../src/client/runtime.ts'
+import type { ClientFileUploadHooks } from '../src/client/contract.ts'
+
+interface UploadGlobal {
+  __DSH_FILE_UPLOAD__?: ClientFileUploadHooks
+}
+
+afterEach(() => {
+  delete (globalThis as UploadGlobal).__DSH_FILE_UPLOAD__
+  vi.restoreAllMocks()
+  vi.unstubAllGlobals()
+})
+
+describe('file upload worker body', () => {
+  it('sends a Blob with credentials and reports progress, completion, and failure', () => {
+    const posted: unknown[] = []
+    const scope: {
+      onmessage: ((event: MessageEvent<{
+        url: string
+        body: Blob
+        headers: Readonly<Record<string, string>>
+      }>) => void) | null
+      postMessage(message: unknown): void
+    } = { onmessage: null, postMessage: (message: unknown) => { posted.push(message) } }
+    const xhr = {
+      upload: { onprogress: null as ((event: ProgressEvent) => void) | null },
+      status: 201,
+      responseText: '{"ok":true}',
+      withCredentials: false,
+      onload: null as ((event: ProgressEvent) => void) | null,
+      onerror: null as ((event: ProgressEvent) => void) | null,
+      open: vi.fn(),
+      setRequestHeader: vi.fn(),
+      send: vi.fn(),
+    }
+    fileUploadWorker(scope, () => xhr)
+    const body = new Blob(['large'])
+    scope.onmessage?.({
+      data: { url: 'https://harness.test/upload', body, headers: { 'content-type': 'application/octet-stream' } },
+    } as never)
+    expect(xhr.open).toHaveBeenCalledWith('POST', 'https://harness.test/upload')
+    expect(xhr.withCredentials).toBe(true)
+    expect(xhr.setRequestHeader).toHaveBeenCalledWith('content-type', 'application/octet-stream')
+    expect(xhr.send).toHaveBeenCalledWith(body)
+
+    xhr.upload.onprogress?.({ loaded: 2, total: 4, lengthComputable: true } as ProgressEvent)
+    xhr.upload.onprogress?.({ loaded: 3, total: 0, lengthComputable: false } as ProgressEvent)
+    xhr.onload?.({} as ProgressEvent)
+    xhr.onerror?.({} as ProgressEvent)
+    expect(posted).toEqual([
+      { kind: 'progress', loaded: 2, total: 4 },
+      { kind: 'progress', loaded: 3 },
+      { kind: 'complete', status: 201, body: '{"ok":true}' },
+      { kind: 'error', message: 'background upload transport failed' },
+    ])
+  })
+
+  it('streams Uint8Array chunks through fetch and reports consumed bytes', async () => {
+    const posted: unknown[] = []
+    const scope = {
+      onmessage: null as ((event: MessageEvent) => void) | null,
+      postMessage: (message: unknown) => { posted.push(message) },
+    }
+    const fetch = vi.fn(async (_url: string, init: RequestInit & { readonly duplex: 'half' }) => {
+      const chunks: number[][] = []
+      for await (const chunk of init.body as ReadableStream<Uint8Array>) chunks.push([...chunk])
+      expect(chunks).toEqual([[1, 2], [3]])
+      expect(init).toMatchObject({
+        method: 'POST',
+        headers: { 'x-test': 'yes' },
+        credentials: 'include',
+        duplex: 'half',
+      })
+      return new Response('stored', { status: 202 })
+    })
+    const body = new ReadableStream<Uint8Array>({
+      start(controller) {
+        controller.enqueue(Uint8Array.of(1, 2))
+        controller.enqueue(Uint8Array.of(3))
+        controller.close()
+      },
+    })
+    fileUploadWorker(scope, () => { throw new Error('XHR must not handle streams') }, fetch)
+    scope.onmessage?.({ data: { url: 'https://harness.test/upload', body, headers: { 'x-test': 'yes' } } } as never)
+    await vi.waitFor(() => {
+      expect(posted).toEqual([
+        { kind: 'progress', loaded: 2 },
+        { kind: 'progress', loaded: 3 },
+        { kind: 'complete', status: 202, body: 'stored' },
+      ])
+    })
+  })
+
+  it('reports invalid bodies, stream chunks, and fetch failures', async () => {
+    const posted: unknown[] = []
+    const scope = {
+      onmessage: null as ((event: MessageEvent) => void) | null,
+      postMessage: (message: unknown) => { posted.push(message) },
+    }
+    fileUploadWorker(scope, () => { throw new Error('unused') })
+    scope.onmessage?.({ data: { url: '/upload', body: 'bad', headers: {} } } as never)
+    expect(posted).toEqual([{ kind: 'error', message: 'background upload worker received an invalid body' }])
+
+    const badChunk = new ReadableStream({ start(controller) { controller.enqueue('bad'); controller.close() } })
+    fileUploadWorker(
+      scope,
+      () => { throw new Error('unused') },
+      async (_url, init) => {
+        await new Response(init.body).arrayBuffer()
+        return new Response()
+      },
+    )
+    scope.onmessage?.({ data: { url: '/upload', body: badChunk, headers: {} } } as never)
+    await vi.waitFor(() => {
+      expect(posted.at(-1)).toEqual({
+        kind: 'error', message: 'background upload stream produced a non-Uint8Array chunk',
+      })
+    })
+
+    const body = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
+    fileUploadWorker(
+      scope,
+      () => { throw new Error('unused') },
+      () => Promise.reject('offline'),
+    )
+    scope.onmessage?.({ data: { url: '/upload', body, headers: {} } } as never)
+    await vi.waitFor(() => {
+      expect(posted.at(-1)).toEqual({ kind: 'error', message: 'offline' })
+    })
+  })
+
+  it('uses Worker globals when the emitted body supplies no test seams', () => {
+    const posted: unknown[] = []
+    const scope = {
+      onmessage: null as ((event: MessageEvent) => void) | null,
+      postMessage: (message: unknown) => { posted.push(message) },
+    }
+    const xhr = {
+      upload: { onprogress: null },
+      status: 204,
+      responseText: '',
+      withCredentials: false,
+      onload: null,
+      onerror: null,
+      open: vi.fn(),
+      setRequestHeader: vi.fn(),
+      send: vi.fn(),
+    }
+    vi.stubGlobal('self', scope)
+    vi.stubGlobal('XMLHttpRequest', vi.fn(function () { return xhr }))
+    fileUploadWorker()
+    scope.onmessage?.({ data: { url: '/upload', body: new Blob(), headers: {} } } as MessageEvent)
+    expect(xhr.send).toHaveBeenCalledOnce()
+  })
+})
+
+describe('file upload service', () => {
+  it('uses a page-owned Host fetch for Blob and ReadableStream bodies', async () => {
+    vi.stubGlobal('location', { origin: 'https://preview.test' })
+    const fetch = vi.fn((_url: URL, _init?: RequestInit) =>
+      Promise.resolve(new Response('accepted', { status: 202 })))
+    ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch }
+    const ctx = new Context()
+    const fiber = ctx.plugin(FileUploadRuntime)
+    await fiber
+    const blob = new Blob(['opaque'])
+    const signal = new AbortController().signal
+    await expect(ctx.fileUpload.post({
+      path: '/api/upload', body: blob, headers: { 'x-test': 'yes' }, signal,
+    })).resolves.toEqual({ status: 202, body: 'accepted' })
+    expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/api/upload'), {
+      method: 'POST', headers: { 'x-test': 'yes' }, body: blob, signal,
+    })
+
+    const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
+    await ctx.fileUpload.post({ path: '/stream', body: stream })
+    expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/stream'), {
+      method: 'POST', body: stream, duplex: 'half',
+    })
+    await fiber.dispose()
+  })
+
+  it('mounts through the plugin entry and resolves non-browser URLs', async () => {
+    vi.stubGlobal('location', { origin: 'null' })
+    const fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })))
+    ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch }
+    const ctx = new Context()
+    const fiber = ctx.plugin({ apply })
+    await fiber
+    await ctx.fileUpload.post({ path: '/fallback', body: new Blob() })
+    expect(fetch).toHaveBeenCalledWith(new URL('http://dsh.internal/fallback'), {
+      method: 'POST', body: expect.any(Blob),
+    })
+    await fiber.dispose()
+  })
+
+  it('leaves the fixture on its generated Remote fallback', async () => {
+    vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' })
+    const ctx = new Context()
+    const fiber = ctx.plugin(FileUploadRuntime)
+    await fiber
+    expect(ctx.fileUpload.available).toBe(false)
+    await expect(ctx.fileUpload.post({ path: '/upload', body: new Blob() }))
+      .rejects.toThrow('background upload is unavailable in fixture mode')
+    await fiber.dispose()
+  })
+
+  it('fails loud when a served browser has no Worker implementation', async () => {
+    vi.stubGlobal('Worker', undefined)
+    const ctx = new Context()
+    const fiber = ctx.plugin(FileUploadRuntime)
+    await fiber
+    await expect(ctx.fileUpload.post({ path: '/upload', body: new Blob() }))
+      .rejects.toThrow('background upload requires Web Worker support')
+    await fiber.dispose()
+  })
+
+  it('forwards progress and completion from a dedicated Worker and then terminates it', async () => {
+    const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker')
+    const revoked = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
+    class FakeWorker {
+      static last: FakeWorker | undefined
+      onmessage: ((event: MessageEvent) => void) | null = null
+      onerror: ((event: ErrorEvent) => void) | null = null
+      readonly postMessage = vi.fn()
+      readonly terminate = vi.fn()
+      constructor(readonly url: string, readonly options: WorkerOptions) { FakeWorker.last = this }
+    }
+    vi.stubGlobal('Worker', FakeWorker)
+    vi.stubGlobal('location', { origin: 'https://harness.test' })
+    const ctx = new Context()
+    const fiber = ctx.plugin(FileUploadRuntime)
+    await fiber
+    const progress = vi.fn()
+    const blob = new Blob(['bytes'])
+    const pending = ctx.fileUpload.post({ path: '/api/upload', body: blob, onProgress: progress })
+    const worker = FakeWorker.last
+    if (worker === undefined) throw new Error('worker missing')
+    expect(created).toHaveBeenCalledOnce()
+    expect(revoked).toHaveBeenCalledWith('blob:worker')
+    expect(worker.postMessage).toHaveBeenCalledWith({
+      url: 'https://harness.test/api/upload', body: blob, headers: {},
+    })
+    worker.onmessage?.({ data: { kind: 'progress', loaded: 4, total: 5 } } as MessageEvent)
+    worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent)
+    worker.onmessage?.({ data: { kind: 'complete', status: 500, body: 'late' } } as MessageEvent)
+    await expect(pending).resolves.toEqual({ status: 200, body: 'done' })
+    expect(progress).toHaveBeenCalledWith({ loaded: 4, total: 5 })
+    expect(worker.terminate).toHaveBeenCalledOnce()
+    await fiber.dispose()
+  })
+
+  it('transfers stream ownership to the dedicated Worker', async () => {
+    vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker')
+    vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
+    class FakeWorker {
+      static last: FakeWorker | undefined
+      onmessage: ((event: MessageEvent) => void) | null = null
+      onerror: ((event: ErrorEvent) => void) | null = null
+      readonly postMessage = vi.fn()
+      readonly terminate = vi.fn()
+      constructor() { FakeWorker.last = this }
+    }
+    vi.stubGlobal('Worker', FakeWorker)
+    const ctx = new Context()
+    const fiber = ctx.plugin(FileUploadRuntime)
+    await fiber
+    const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
+    const pending = ctx.fileUpload.post({ path: '/stream', body: stream })
+    const worker = FakeWorker.last
+    if (worker === undefined) throw new Error('worker missing')
+    expect(worker.postMessage).toHaveBeenCalledWith(expect.objectContaining({ body: stream }), [stream])
+    worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent)
+    await expect(pending).resolves.toEqual({ status: 200, body: 'done' })
+    await fiber.dispose()
+  })
+
+  it('rejects worker messages, worker errors, and caller cancellation', async () => {
+    vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker')
+    vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
+    class FakeWorker {
+      static all: FakeWorker[] = []
+      onmessage: ((event: MessageEvent) => void) | null = null
+      onerror: ((event: ErrorEvent) => void) | null = null
+      readonly postMessage = vi.fn()
+      readonly terminate = vi.fn()
+      constructor() { FakeWorker.all.push(this) }
+    }
+    vi.stubGlobal('Worker', FakeWorker)
+    const ctx = new Context()
+    const fiber = ctx.plugin(FileUploadRuntime)
+    await fiber
+
+    const reported = ctx.fileUpload.post({ path: '/upload', body: new Blob() })
+    FakeWorker.all[0]?.onmessage?.({ data: { kind: 'error', message: 'network failed' } } as MessageEvent)
+    await expect(reported).rejects.toThrow('network failed')
+
+    const errored = ctx.fileUpload.post({ path: '/upload', body: new Blob() })
+    FakeWorker.all[1]?.onerror?.({ message: 'worker crashed' } as ErrorEvent)
+    await expect(errored).rejects.toThrow('worker crashed')
+
+    const unnamed = ctx.fileUpload.post({ path: '/upload', body: new Blob() })
+    FakeWorker.all[2]?.onerror?.({ message: '' } as ErrorEvent)
+    await expect(unnamed).rejects.toThrow('background upload worker failed')
+
+    const controller = new AbortController()
+    const aborted = ctx.fileUpload.post({ path: '/upload', body: new Blob(), signal: controller.signal })
+    controller.abort()
+    await expect(aborted).rejects.toMatchObject({ name: 'AbortError' })
+    expect(FakeWorker.all[3]?.terminate).toHaveBeenCalledOnce()
+
+    const already = new AbortController()
+    already.abort()
+    await expect(ctx.fileUpload.post({ path: '/upload', body: new Blob(), signal: already.signal }))
+      .rejects.toMatchObject({ name: 'AbortError' })
+    expect(FakeWorker.all[4]?.postMessage).not.toHaveBeenCalled()
+    await fiber.dispose()
+  })
+})

+ 15 - 0
packages/client/file-upload/tsconfig.json

@@ -0,0 +1,15 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    }
+  ]
+}

+ 3 - 0
packages/client/file-upload/tsdown.config.ts

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-file-upload', ['lib/types/index.js'])

+ 5 - 5
packages/client/ui-chat/src/client/chat/MessageItem.tsx

@@ -282,14 +282,14 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }:
         type: 'image',
         image: {
           preview: {
-            url: attachment.previewUrl,
-            ...(attachment.name === undefined ? {} : { name: attachment.name }),
-            ...(attachment.width === undefined ? {} : { width: attachment.width }),
-            ...(attachment.height === undefined ? {} : { height: attachment.height }),
+            url: attachment.value.previewUrl,
+            ...(attachment.value.name === undefined ? {} : { name: attachment.value.name }),
+            ...(attachment.value.width === undefined ? {} : { width: attachment.value.width }),
+            ...(attachment.value.height === undefined ? {} : { height: attachment.value.height }),
           },
         },
       }
-      : { type: 'file', file: attachment.attachment }),
+      : { type: 'file', file: attachment.value }),
     [submission.attachments],
   )
   return (

+ 10 - 6
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -1081,7 +1081,9 @@ describe('ChatView', () => {
           placement: 'steering',
           time: 5_500,
           text: '带图纠偏',
-          attachments: [{ type: 'image', previewUrl: 'blob:steer-preview', name: 'steer.png' }],
+          attachments: [{
+            type: 'image', value: { previewUrl: 'blob:steer-preview', name: 'steer.png' },
+          }],
         }],
       },
     )
@@ -1150,8 +1152,10 @@ describe('ChatView', () => {
           time: 7_000,
           text: '',
           attachments: [
-            { type: 'image', previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 },
-            { type: 'image', previewUrl: 'blob:echo-b' },
+            {
+              type: 'image', value: { previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 },
+            },
+            { type: 'image', value: { previewUrl: 'blob:echo-b' } },
           ],
         }],
       },
@@ -1190,12 +1194,12 @@ describe('ChatView', () => {
           time: 7_500,
           text: '',
           attachments: [
-            { type: 'image', previewUrl: 'blob:first', name: 'first.png' },
+            { type: 'image', value: { previewUrl: 'blob:first', name: 'first.png' } },
             {
               type: 'file',
-              attachment: { attachmentId: 'file-1' as never, name: 'notes.txt', bytes: 23 },
+              value: { attachmentId: 'file-1' as never, name: 'notes.txt', bytes: 23 },
             },
-            { type: 'image', previewUrl: 'blob:last', name: 'last.png' },
+            { type: 'image', value: { previewUrl: 'blob:last', name: 'last.png' } },
           ],
         }],
       },

+ 5 - 5
packages/client/ui-conversation/src/client/queue/QueueDock.tsx

@@ -308,17 +308,17 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
                     {submission.attachments.map((attachment, index) => attachment.type === 'image'
                       ? (
                         <img
-                          key={`${attachment.previewUrl}:${index}`}
+                          key={`${attachment.value.previewUrl}:${index}`}
                           className={css.thumb}
-                          src={attachment.previewUrl}
+                          src={attachment.value.previewUrl}
                           alt={t('queue.image')}
                         />
                       )
                       : (
                         <QueueFile
-                          key={`${attachment.attachment.attachmentId}:${attachment.attachment.name}:${index}`}
-                          attachment={attachment.attachment}
-                          label={t('queue.file', { name: attachment.attachment.name })}
+                          key={`${attachment.value.attachmentId}:${attachment.value.name}:${index}`}
+                          attachment={attachment.value}
+                          label={t('queue.file', { name: attachment.value.name })}
                         />
                       ))}
                   </span>

+ 7 - 5
packages/client/ui-conversation/src/client/service.ts

@@ -246,12 +246,14 @@ export class ConversationController extends Service implements IConversation {
     const pendingAttachments = attachments.map(attachment => attachment.kind === 'image'
       ? {
         type: 'image' as const,
-        previewUrl: attachment.previewUrl,
-        ...(attachment.file.name === '' ? {} : { name: attachment.file.name }),
-        ...(attachment.width === undefined ? {} : { width: attachment.width }),
-        ...(attachment.height === undefined ? {} : { height: attachment.height }),
+        value: {
+          previewUrl: attachment.previewUrl,
+          ...(attachment.file.name === '' ? {} : { name: attachment.file.name }),
+          ...(attachment.width === undefined ? {} : { width: attachment.width }),
+          ...(attachment.height === undefined ? {} : { height: attachment.height }),
+        },
       }
-      : { type: 'file' as const, attachment: uploadFor(attachment).file })
+      : { type: 'file' as const, value: uploadFor(attachment).file })
     const serializeAttachments = (): Promise<Parameters<SessionFace['prompt']>[0]> => Promise.all(
       attachments.map(async attachment => attachment.kind === 'image'
         ? { type: 'image' as const, ...await this.encodeImage(attachment.file) }

+ 5 - 2
packages/client/ui-conversation/tests/queue-dock.client.spec.tsx

@@ -125,10 +125,13 @@ describe('QueueDock', () => {
         time: 1,
         text: '等待上传',
         attachments: [
-          { type: 'image' as const, previewUrl: 'blob:queue-preview', name: 'queue.png' },
+          {
+            type: 'image' as const,
+            value: { previewUrl: 'blob:queue-preview', name: 'queue.png' },
+          },
           {
             type: 'file' as const,
-            attachment: {
+            value: {
               attachmentId: 'file-local' as never,
               name: 'notes.txt',
               bytes: 2447 * 1024 * 1024,

+ 7 - 4
packages/client/ui-conversation/tests/service-orchestration.client.spec.ts

@@ -483,7 +483,10 @@ describe('sendSession submission echo', () => {
       expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({
         mode: 'queue',
         text: '带图',
-        attachments: [expect.objectContaining({ type: 'image', previewUrl: 'blob:echo-1', name: 'a.png' })],
+        attachments: [{
+          type: 'image',
+          value: expect.objectContaining({ previewUrl: 'blob:echo-1', name: 'a.png' }),
+        }],
       }))
       expect(b.prompt).not.toHaveBeenCalled()
       await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() })
@@ -531,9 +534,9 @@ describe('sendSession submission echo', () => {
       expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({
         mode: 'steer',
         attachments: [
-          expect.objectContaining({ type: 'image', name: 'first.png' }),
-          { type: 'file', attachment: { attachmentId: 'mixed-file', name: 'notes.txt', bytes: 1 } },
-          expect.objectContaining({ type: 'image', name: 'last.png' }),
+          { type: 'image', value: expect.objectContaining({ name: 'first.png' }) },
+          { type: 'file', value: { attachmentId: 'mixed-file', name: 'notes.txt', bytes: 1 } },
+          { type: 'image', value: expect.objectContaining({ name: 'last.png' }) },
         ],
       }))
       await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() })

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md
-README.md: bb561f2e9ec02d0a322e9d89919fc9f8515fb64a
-README.zh.md: c90c476c95275220d5f801006b9d557f461d0bef
+README.md: b5c9370bbff33e4385e08c299fc9c1a7669f8e5e
+README.zh.md: 04eee0bfb133bf020fc8989ddb98e54c1dfadf34

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

@@ -28,7 +28,7 @@ Three artifacts from one tsdown pipeline:
 - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`.
 - **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)).
 - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process.
-- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport and the API client. Request frames preserve Blob bodies through structured clone; the Host Worker streams their chunks into the route, so neither browser thread creates a complete byte array for a generic-file upload.
+- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport, the independent file-upload carrier, and the API client. Request frames preserve Blob bodies through structured clone and transfer `ReadableStream<Uint8Array>` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload.
 
 Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture.
 

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

@@ -28,7 +28,7 @@ kind: "package-library"
 - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。
 - **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。
 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。
-- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形传输与 API 客户端。请求帧通过结构化克隆保留 Blob body,Host Worker 将其逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。
+- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形式的传输、独立文件上传载体和 API 客户端。请求帧通过结构化克隆保留 Blob 请求体,并转移 `ReadableStream<Uint8Array>` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。
 
 验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。
 

+ 1 - 0
packages/experimental/webworker-runtime/package.json

@@ -47,6 +47,7 @@
     "@deepseek-ai/dsh-api-gateway": "workspace:^",
     "@deepseek-ai/dsh-bash-sandbox": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-client-file-upload": "workspace:^",
     "@deepseek-ai/dsh-client-modules": "workspace:^",
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-sandbox-local": "workspace:^",

+ 9 - 6
packages/experimental/webworker-runtime/src/client/client.ts

@@ -119,11 +119,14 @@ async function localizeSourceMap(source: string, bundleUrl: string, fetch: Tunne
   }
 }
 
-/** Keep opaque Blobs clone-cheap; normalize text and typed arrays to transferable bytes. */
-function toTunnelBody(body: RequestInit['body']): ArrayBuffer | Blob | undefined {
+/** Keep opaque Blobs and transferable streams intact; normalize other bodies to bytes. */
+function toTunnelBody(
+  body: RequestInit['body'],
+): ArrayBuffer | Blob | ReadableStream<Uint8Array> | undefined {
   if (body === undefined || body === null) return undefined
   if (typeof body === 'string') return encoder.encode(body).buffer
   if (body instanceof Blob) return body
+  if (body instanceof ReadableStream) return body as ReadableStream<Uint8Array>
   if (body instanceof ArrayBuffer) return body
   if (ArrayBuffer.isView(body)) {
     return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength)
@@ -198,21 +201,21 @@ export class WorkerTunnel {
     // must not reach the worker, where a write-shaped route would still run.
     if (signal?.aborted === true) throw new DOMException('The operation was aborted.', 'AbortError')
     const id = this.nextId++
+    const body = init?.body === undefined || init.body === null ? undefined : toTunnelBody(init.body)
     const frame: RequestFrame = {
       t: 'req',
       id,
       method: init?.method ?? 'GET',
       url: new URL(input, globalThis.location.origin).toString(),
       headers: Object.fromEntries(new Headers(init?.headers).entries()),
-      ...(init?.body === undefined || init.body === null
-        ? {}
-        : { body: toTunnelBody(init.body) }),
+      ...(body === undefined ? {} : { body }),
     }
     const response = new Promise<Response>((resolve, reject) => {
       this.unary.set(id, { resolve, reject })
     })
     this.inFlight.set(id, `${frame.method} ${frame.url}`)
-    this.worker.postMessage(frame)
+    if (body instanceof ReadableStream) this.worker.postMessage(frame, [body])
+    else this.worker.postMessage(frame)
     if (signal === undefined || signal === null) return await response
     const raced = this.rejectOnAbort(id, signal)
     try {

+ 9 - 0
packages/experimental/webworker-runtime/src/client/index.ts

@@ -9,6 +9,7 @@
  * @module @deepseek-ai/dsh-experimental-webworker-runtime/client
  */
 import { IMAGE_FILE_NAME } from '../image-layout.ts'
+import type { ClientFileUploadHooks } from '@deepseek-ai/dsh-client-file-upload/client'
 import { PREVIEW_FIXTURE_MANIFEST_FILE } from '../fixture-manifest.ts'
 import { WorkerTunnel, type TunnelFetch } from './client.ts'
 import { applyIndexInjections } from './apply-injections.ts'
@@ -33,6 +34,11 @@ interface ClientTransportGlobal {
   }
 }
 
+/** Upload hook consumed by the independent Client file-upload service. */
+interface ClientFileUploadGlobal {
+  __DSH_FILE_UPLOAD__?: ClientFileUploadHooks
+}
+
 /** Inputs for {@link connectWorkerHost}. */
 export interface WorkerHostConnectOptions {
   /**
@@ -151,6 +157,9 @@ export async function connectWorkerHost(worker: Worker, options?: WorkerHostConn
       // the privileged surface stays reachable off loopback authorities.
       ownsHost: true,
     }
+    ;(globalThis as ClientFileUploadGlobal).__DSH_FILE_UPLOAD__ = {
+      fetch: (input, init) => tunnel.fetch(input, init),
+    }
     await applyIndexInjections(payload.injections, src => tunnel.loadBundle(src))
     ready.resolve()
     return { worker, tunnel, loadBundle: (url: string) => tunnel.loadBundle(url) }

+ 7 - 4
packages/experimental/webworker-runtime/src/transport/frames.ts

@@ -7,14 +7,14 @@
 /** Request identifier minted by the page. */
 export type TunnelRequestId = string | number
 
-/** One request; `body` carries the raw bytes for methods that have one. */
+/** One request; `body` carries cloneable or transferable raw bytes. */
 export interface TunnelRequestFrame {
   readonly t: 'req'
   readonly id: TunnelRequestId
   readonly method: string
   readonly url: string
   readonly headers: Readonly<Record<string, string>>
-  readonly body?: ArrayBuffer | Blob | undefined
+  readonly body?: ArrayBuffer | Blob | ReadableStream<Uint8Array> | undefined
 }
 
 /** Open one Gateway Remote stream over the worker-local carrier. */
@@ -171,8 +171,11 @@ export function parseInboundFrame(data: unknown): TunnelInboundFrame {
     if (typeof value === 'string') headers[key.toLowerCase()] = value
   }
   const body = frame.body
-  if (body !== undefined && !(body instanceof ArrayBuffer) && !(body instanceof Blob)) {
-    throw new Error(`webworker tunnel: request ${String(id)} body must be an ArrayBuffer or Blob`)
+  if (body !== undefined
+    && !(body instanceof ArrayBuffer)
+    && !(body instanceof Blob)
+    && !(body instanceof ReadableStream)) {
+    throw new Error(`webworker tunnel: request ${String(id)} body must be an ArrayBuffer, Blob, or ReadableStream`)
   }
   return { t: 'req', id, method: frame.method, url: frame.url, headers, body }
 }

+ 10 - 0
packages/experimental/webworker-runtime/src/transport/synthetic-http.ts

@@ -74,6 +74,16 @@ export function createSyntheticExchange(frame: TunnelRequestFrame, sink: Respons
         }
         return
       }
+      if (frame.body instanceof ReadableStream) {
+        for await (const chunk of frame.body) {
+          if (aborted) return
+          if (!(chunk instanceof Uint8Array)) {
+            throw new TypeError('webworker tunnel: request stream produced a non-Uint8Array chunk')
+          }
+          if (chunk.byteLength > 0) yield chunk
+        }
+        return
+      }
       if (aborted || frame.body.byteLength === 0) return
       yield new Uint8Array(frame.body)
     },

+ 7 - 3
packages/experimental/webworker-runtime/tests/transport/frames.spec.ts

@@ -22,7 +22,7 @@ describe('tunnel init frame', () => {
 })
 
 describe('tunnel request bodies', () => {
-  it('accepts ArrayBuffer and Blob bodies and rejects other structured-clone values', () => {
+  it('accepts ArrayBuffer, Blob, and ReadableStream bodies and rejects other values', () => {
     const bytes = Uint8Array.of(1, 2).buffer
     const blob = new Blob(['large'])
     expect(parseInboundFrame({
@@ -31,8 +31,12 @@ describe('tunnel request bodies', () => {
     expect(parseInboundFrame({
       t: 'req', id: 2, method: 'POST', url: '/blob', headers: {}, body: blob,
     })).toMatchObject({ body: blob })
+    const stream = new ReadableStream<Uint8Array>()
+    expect(parseInboundFrame({
+      t: 'req', id: 3, method: 'POST', url: '/stream', headers: {}, body: stream,
+    })).toMatchObject({ body: stream })
     expect(() => parseInboundFrame({
-      t: 'req', id: 3, method: 'POST', url: '/bad', headers: {}, body: 'large',
-    })).toThrow('body must be an ArrayBuffer or Blob')
+      t: 'req', id: 4, method: 'POST', url: '/bad', headers: {}, body: 'large',
+    })).toThrow('body must be an ArrayBuffer, Blob, or ReadableStream')
   })
 })

+ 17 - 1
packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts

@@ -33,27 +33,43 @@ type StubListener = (event: { data?: unknown; message?: string }) => void
 function stubWorker(): {
   worker: Worker
   sent: { t: string; id: number }[]
+  transfers: Transferable[][]
   deliver: (frame: unknown) => void
   fail: (message: string) => void
 } {
   const listeners: StubListener[] = []
   const errorListeners: StubListener[] = []
   const sent: { t: string; id: number }[] = []
+  const transfers: Transferable[][] = []
   const worker = {
     addEventListener: (type: string, listener: StubListener) => {
       if (type === 'message') listeners.push(listener)
       if (type === 'error') errorListeners.push(listener)
     },
-    postMessage: (frame: unknown) => { sent.push(frame as { t: string; id: number }) },
+    postMessage: (frame: unknown, transfer?: Transferable[]) => {
+      sent.push(frame as { t: string; id: number })
+      transfers.push(transfer ?? [])
+    },
   } as unknown as Worker
   return {
     worker,
     sent,
+    transfers,
     deliver: (frame) => { for (const listener of listeners) listener({ data: frame }) },
     fail: (message) => { for (const listener of errorListeners) listener({ message }) },
   }
 }
 
+// ReadableStream request bodies transfer ownership instead of copying chunks on the page.
+{
+  const { worker, sent, transfers } = stubWorker()
+  const tunnel = new WorkerTunnel(worker)
+  const body = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
+  void tunnel.fetch('/upload', { method: 'POST', body, duplex: 'half' } as RequestInit)
+  check('a stream request body stays intact', (sent[0] as unknown as { body: unknown }).body, body)
+  check('a stream request body is transferred', transfers[0], [body])
+}
+
 // The opening frame preserves overlay order for deterministic pre-boot mounts.
 {
   const { worker, sent } = stubWorker()

+ 30 - 0
packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts

@@ -98,6 +98,36 @@ describe('worker tunnel Blob requests', () => {
   })
 })
 
+describe('worker tunnel ReadableStream requests', () => {
+  it('streams transferred chunks through the Host Worker route', async () => {
+    const frames: TunnelOutboundFrame[] = []
+    const seen: Uint8Array[] = []
+    const body = new ReadableStream<Uint8Array>({
+      start(controller) {
+        controller.enqueue(bytes(111, 110, 101))
+        controller.enqueue(bytes(116, 119, 111))
+        controller.close()
+      },
+    })
+    const server = new TunnelServer({
+      port: { postMessage: (frame) => { frames.push(frame) } },
+      requestListener: () => Promise.resolve(async (request, response) => {
+        for await (const chunk of request as AsyncIterable<Uint8Array>) seen.push(chunk)
+        const res = response as { writeHead(status: number): void; end(body: string): void }
+        res.writeHead(200)
+        res.end('stored')
+      }),
+    })
+    server.serve(seams(async () => (async function *(): AsyncGenerator { yield undefined })()))
+    server.handleMessage({
+      t: 'req', id: 10, method: 'POST', url: 'http://localhost/upload', headers: {}, body,
+    })
+    await vi.waitFor(() => { expect(frames).toHaveLength(1) })
+    expect(seen.map(chunk => new TextDecoder().decode(chunk))).toEqual(['one', 'two'])
+    expect(frames[0]).toMatchObject({ t: 'res', id: 10, status: 200 })
+  })
+})
+
 describe('worker tunnel logical streams', () => {
   it('drains a pre-boot open through the worker-local Gateway seam', async () => {
     const { server, frames } = harness()

+ 3 - 0
packages/experimental/webworker-runtime/tsconfig.json

@@ -29,6 +29,9 @@
     {
       "path": "../../client/connection/tsconfig.host.json"
     },
+    {
+      "path": "../../client/file-upload"
+    },
     {
       "path": "../../host/webserver"
     },

+ 15 - 0
pnpm-lock.yaml

@@ -780,6 +780,9 @@ importers:
       '@deepseek-ai/dsh-client-connection':
         specifier: workspace:^
         version: link:../../client/connection
+      '@deepseek-ai/dsh-client-file-upload':
+        specifier: workspace:^
+        version: link:../../client/file-upload
       '@deepseek-ai/dsh-client-store':
         specifier: workspace:^
         version: link:../../client/store
@@ -1467,6 +1470,9 @@ importers:
       '@deepseek-ai/dsh-client-connection':
         specifier: workspace:^
         version: link:../../client/connection
+      '@deepseek-ai/dsh-client-file-upload':
+        specifier: workspace:^
+        version: link:../../client/file-upload
       '@deepseek-ai/dsh-client-hmr':
         specifier: workspace:^
         version: link:../../client/hmr
@@ -1722,6 +1728,12 @@ importers:
         specifier: workspace:^
         version: link:../../util/values
 
+  packages/client/file-upload:
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+
   packages/client/hmr:
     dependencies:
       '@deepseek-ai/schemastery':
@@ -4882,6 +4894,9 @@ importers:
       '@deepseek-ai/dsh-client-connection':
         specifier: workspace:^
         version: link:../../client/connection
+      '@deepseek-ai/dsh-client-file-upload':
+        specifier: workspace:^
+        version: link:../../client/file-upload
       '@deepseek-ai/dsh-client-modules':
         specifier: workspace:^
         version: link:../../client/modules

+ 1 - 0
scripts/gen-cordis-catalog.ts

@@ -153,6 +153,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
   dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract',
   launchEnvironment: 'not a service: launcher-provided root accessor value (LaunchEnvironmentSnapshot | undefined) — packages/util/launch-environment/README.md owns this launcher contract',
   connection: 'interface-typed (HostConnectionHandle); implementing class HostConnectionService is declared in rpc-host.ts — packages/client/connection/README.md owns the API',
+  fileUpload: 'client-side browser upload service — packages/client/file-upload/README.md owns the API',
   uiRenderer: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API',
   uiSession: 'client-side Session source adapter — packages/client/ui-session/README.md owns the API',
   uiConversation: 'client-side Conversation registries and assembler — packages/client/ui-conversation/README.md owns the API',

+ 2 - 0
tsconfig.base.json

@@ -157,6 +157,8 @@
       "@deepseek-ai/dsh-client-ui-session": ["./packages/client/ui-session/src"],
       "@deepseek-ai/dsh-client-ui-session/client": ["./packages/client/ui-session/src/client"],
       "@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"],
+      "@deepseek-ai/dsh-client-file-upload": ["./packages/client/file-upload/src"],
+      "@deepseek-ai/dsh-client-file-upload/client": ["./packages/client/file-upload/src/client/index.ts"],
       "@deepseek-ai/dsh-api-remotes": ["./packages/api/remotes/src"],
       "@deepseek-ai/dsh-api-remotes/client": ["./packages/api/remotes/src/client/index.ts"],
       "@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"],

+ 1 - 0
tsconfig.client.json

@@ -54,6 +54,7 @@
     { "path": "./packages/experimental/inspector/tsconfig.client.json" },
     { "path": "./packages/client/hmr" },
     { "path": "./packages/client/connection/tsconfig.client.json" },
+    { "path": "./packages/client/file-upload" },
     { "path": "./packages/typert/registry" },
     { "path": "./packages/api/gateway/tsconfig.client.json" },
     { "path": "./packages/api/session-controller/tsconfig.client.json" },

Some files were not shown because too many files changed in this diff