@deepseek-ai/dsh-client-connection 0.1.6-alpha.2 → 0.1.7-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/client/connection/README.md
5
- README.md: f72dd40db4c6414c368e039300198151cf2bd96a
6
- README.zh.md: 4644dfb23074338bdd5888d20e0f0115811115d8
5
+ README.md: e50f4fb6838ae27b545bd2b3c4683224f7eb37c9
6
+ README.zh.md: 1bf0733bd660049a5b99a7e7966d65121b0dea3f
package/README.md CHANGED
@@ -27,20 +27,24 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con
27
27
 
28
28
  A static desktop page can provide `__DSH_TRANSPORT__.streamBaseUrl` for the HTTP origin of its owned Host. The Gateway uses that origin for its WebSocket while HTTP transport remains independently selected. The desktop carrier owns authentication; setting the origin alone grants no access.
29
29
 
30
- The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md).
30
+ Unary RPC requests use JSON. A Host handler may return byte attachments that it has already separated from the JSON-compatible result value, with each attachment naming its result-relative path. Connection writes these values as multipart parts. The JSON `metadata` part contains the RPC response envelope with `null` placeholders and an attachment table recording each path, codec, and part identifier. Paths use string keys and numeric array indices; no business field name is reserved. The Client validates the envelope, `rpcId`, attachment table, and parts, then restores each byte value as an `ArrayBuffer`-backed view. Results without attachments, including base64 strings, and failures remain JSON. Logical RPC carriers return decoded native values directly. Connection does not discover binary fields or depend on Typert; the handler that owns a result protocol performs any type-directed or runtime projection before returning. Binary parameters, events, and streamed binary results are unsupported.
31
+
32
+ The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams, including a stream's uplink, through `connection.rpc.open` without opening a WebSocket. The browser plugin reads the page transport, recovery settings, and location, then delegates to `installConnection(ctx, options)`. A composition that owns its carrier may call the same installer directly; the whole-client test tier does so. Each invocation creates one Context-owned service, so several Client trees can use different carriers in one realm. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md).
31
33
 
32
34
  -----
33
35
 
34
36
  <a id="browser-authentication-and-request-trust"></a>
35
37
  ## Browser authentication and request trust
36
38
 
37
- Every Host RPC method and WebSocket stream requires one browser session; there is no method-specific loopback tier. Each process mints a random launch token. `dsh-web-app` prints and opens the ordinary root URL with `?token=...`; `frontend-static` delegates root and index requests to `ctx.connection.authorizeIndex`, which accepts that token only on `GET /`, writes an authority-bound signed cookie, and redirects to clean `/`. A missing, expired, malformed, or wrong-authority cookie returns 401 before RPC dispatch. Static assets remain public. The HTTP carrier accepts no query token outside the root exchange and no Authorization-header token.
39
+ Every Host RPC method and WebSocket stream requires one browser session; there is no method-specific loopback tier. Each process mints a random launch token. `dsh-web-app` prints and opens its application URL with `?token=...`, preserving the caller's authority and mount; `frontend-static` delegates root and index requests to `ctx.connection.authorizeIndex`, which accepts that token only on `GET /`, writes an authority-bound signed cookie, and redirects to clean `./`, which drops the token and keeps the request's directory. A missing, expired, malformed, or wrong-authority cookie returns 401 before RPC dispatch. Static assets remain public. The HTTP carrier accepts no query token outside the root exchange and no Authorization-header token.
38
40
 
39
41
  The cookie signing secret is the owner-scoped `client-connection/browser-session` grant record in `ctx.credentials`. The local provider persists it in `$DSH_HOME/.credentials.yaml`; `BrowserAuth` loads or creates the record during Connection activation and retains the secret in memory, so request authentication is synchronous. Deleting or replacing the record takes effect on the next Connection activation. Cookies carry an absolute issue/expiry interval, defaulting to 30 days through `cookieMaxAgeDays`, and bind the normalized hostname plus port in both their deterministic name and signed payload. They are host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`; they deliberately omit `Secure` because the shipped server uses loopback HTTP.
40
42
 
41
43
  Before authentication, every request still passes `src/api-request-trust.ts`. Its `Host` must be loopback or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries, both sides WHATWG-normalized. An attached `Origin` must equal that Host and `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` remains unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md).
42
44
 
43
- Authenticated shared HTTP requests pass through the `connection/request` waterfall before body transfer. A listener may refuse new requests or await `next()` through response completion; removing its owning fiber removes admission behavior. Desktop uses this hook to lock new API work during an approved installation without canceling already-admitted work. WebSocket stream ownership remains with API Gateway.
45
+ Every admitted request speaks for one Peer, the operator. `ctx.connection.operator` is that `PeerScope`: its `ctx` is a Cordis scope that owns connection-lifetime registrations and is disposed with the Connection. `ctx.connection.admit(request)` runs the trust and authentication checks and answers with the rejection status or the operator; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. `OperatorPeer` is exported so a composition without Connection, such as the Gateway's in-process carrier, owns an operator scope with the same contract.
46
+
47
+ Authenticated shared HTTP requests pass through the `connection/request` waterfall before body transfer. A listener may refuse new requests or await `next()` through response completion; removing its owning fiber removes admission behavior. Desktop uses this hook to lock new API work during an approved installation without canceling already-admitted work. Client disconnection aborts the handler signal; the bridge stops socket writes and drains any remaining response chunks. WebSocket stream ownership remains with API Gateway.
44
48
 
45
49
  <a id="connection-generation"></a>
46
50
  ## Connection generation
package/README.zh.md CHANGED
@@ -27,20 +27,24 @@ kind: "package-reference"
27
27
 
28
28
  静态桌面页面可以通过 `__DSH_TRANSPORT__.streamBaseUrl` 提供其所拥有 Host 的 HTTP origin。Gateway 将该 origin 用于 WebSocket,HTTP 传输仍独立选择。桌面载体负责认证;仅设置 origin 不会授予访问权限。
29
29
 
30
- 浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。
30
+ 一元 RPC 请求使用 JSON。Host handler 可以返回已经从 JSON 兼容结果值中分离的字节附件,每个附件标明其相对于结果的路径。Connection 将这些值写为 multipart 部分。JSON `metadata` 部分包含带 `null` 占位值的 RPC 响应信封,以及记录各路径、codec 和部分标识符的附件表。路径使用字符串键与数字数组下标,不保留任何业务字段名。Client 校验信封、`rpcId`、附件表和各部分,再将每个字节值恢复为以 `ArrayBuffer` 为底层缓冲区的视图。没有附件的结果(包括 base64 字符串)与失败仍使用 JSON。逻辑 RPC 载体直接返回解码后的原生值。Connection 不识别二进制字段,也不依赖 Typert;拥有结果协议的 handler 在返回前执行按类型或按运行时值的投影。不支持二进制参数、事件和二进制流式结果。
31
+
32
+ 浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流(包括流的上行),不打开 WebSocket。浏览器插件读取页面 transport、恢复设置与 location,再委托 `installConnection(ctx, options)`。持有自身载体的组合可以直接调用同一个安装函数;整机客户端测试档就是这一消费者。每次调用都会创建一个归所属 Context 的服务,因此同一 realm 中的多棵 Client 树可以使用不同载体。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。
31
33
 
32
34
  -----
33
35
 
34
36
  <a id="browser-authentication-and-request-trust"></a>
35
37
  ## 浏览器认证与请求信任
36
38
 
37
- 每个 Host RPC 方法和 WebSocket 流都要求一个浏览器会话,不存在按方法区分的 loopback 层。每个进程生成一个随机启动令牌。`dsh-web-app` 打印并打开带 `?token=...` 的普通根 URL;`frontend-static` 把根路径和 index 请求交给 `ctx.connection.authorizeIndex`,后者只在 `GET /` 接受该令牌,写入绑定 authority 的签名 cookie,再重定向到干净的 `/`。缺失、过期、畸形或 authority 不匹配的 cookie 会在 RPC 分发前得到 401。静态资源保持公开。HTTP 载体不在根路径交换之外接受 query token,也不接受 Authorization header token。
39
+ 每个 Host RPC 方法和 WebSocket 流都要求一个浏览器会话,不存在按方法区分的 loopback 层。每个进程生成一个随机启动令牌。`dsh-web-app` 打印并打开带 `?token=...` 的应用 URL,保留调用方的 authority 与挂载;`frontend-static` 把根路径和 index 请求交给 `ctx.connection.authorizeIndex`,后者只在 `GET /` 接受该令牌,写入绑定 authority 的签名 cookie,再重定向到干净的 `./`,移除令牌并保留请求目录。缺失、过期、畸形或 authority 不匹配的 cookie 会在 RPC 分发前得到 401。静态资源保持公开。HTTP 载体不在根路径交换之外接受 query token,也不接受 Authorization header token。
38
40
 
39
41
  cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$DSH_HOME/.credentials.yaml`;`BrowserAuth` 在 Connection 激活期间加载或创建该记录,并把密钥留在内存中,因此请求认证同步执行。删除或替换该记录会在下一次 Connection 激活时生效。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`;随附服务器使用 loopback HTTP,因此刻意不设置 `Secure`。
40
42
 
41
43
  认证之前,每个请求仍经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback,或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化。若附带 `Origin`,它必须等于该 Host;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 仍不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。
42
44
 
43
- 通过认证的共享 HTTP 请求在传输请求体之前经过 `connection/request` waterfall。监听器可以拒绝新请求,或等待 `next()` 直到响应完成;释放所属 fiber 会移除准入行为。Desktop 使用此扩展点,在已批准的安装期间锁住新的 API 工作,而不取消已接纳的工作。WebSocket 流仍由 API Gateway 负责。
45
+ 每个被接纳的请求都代表同一个 Peer——操作者。`ctx.connection.operator` 就是这个 `PeerScope`:其 `ctx` 是拥有连接期注册的 Cordis scope,随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或操作者作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。`OperatorPeer` 对外导出,供没有 Connection 的组合(例如 Gateway 的进程内载体)以同一约定拥有一个操作者 scope。
46
+
47
+ 通过认证的共享 HTTP 请求在传输请求体之前经过 `connection/request` waterfall。监听器可以拒绝新请求,或等待 `next()` 直到响应完成;释放所属 fiber 会移除准入行为。Desktop 使用此扩展点,在已批准的安装期间锁住新的 API 工作,而不取消已接纳的工作。客户端断开会中止处理函数的信号;桥接器停止写入 socket,并排空剩余响应块。WebSocket 流仍由 API Gateway 负责。
44
48
 
45
49
  <a id="connection-generation"></a>
46
50
  ## Connection generation
package/lib/client.js CHANGED
@@ -28,6 +28,43 @@ window.__ModuleLoader__.load({
28
28
  for (const key of keys) if (forced || source[key] !== void 0) result[key] = source[key];
29
29
  return result;
30
30
  }
31
+ /** Shared config references used by schema validators and plugin runtimes. */
32
+ const write = Symbol.for("cosmokit.volatile.write");
33
+ function snapshot(value, ancestors = /* @__PURE__ */ new Set()) {
34
+ if (typeof value === "function") throw new TypeError("volatile config cannot contain functions");
35
+ if (value === null || typeof value !== "object") return value;
36
+ if (ancestors.has(value)) throw new TypeError("volatile config cannot contain cycles");
37
+ ancestors.add(value);
38
+ try {
39
+ if (Array.isArray(value)) return Object.freeze(value.map((item) => snapshot(item, ancestors)));
40
+ if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new TypeError("volatile config objects must be plain objects or arrays");
41
+ return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, item]) => [key, snapshot(item, ancestors)])));
42
+ } finally {
43
+ ancestors.delete(value);
44
+ }
45
+ }
46
+ /**
47
+ * Create a detached reference containing an immutable copy of the supplied data.
48
+ * @param value - validated config data; class instances and functions are unsupported.
49
+ * @returns a reference whose value is updated only by its owning runtime.
50
+ */
51
+ function createVolatile(value) {
52
+ let current = snapshot(value);
53
+ return Object.freeze({
54
+ get: () => current,
55
+ [write]: (value) => {
56
+ current = value;
57
+ }
58
+ });
59
+ }
60
+ /**
61
+ * Identify references across ESM/CJS copies of the shared library.
62
+ * @param value - a parsed config value.
63
+ * @returns whether the value implements the shared reference protocol.
64
+ */
65
+ function isVolatile(value) {
66
+ return typeof value === "object" && value !== null && write in value;
67
+ }
31
68
  /** Test values using `instanceof` with a `toStringTag` fallback. */
32
69
  function is(type, value) {
33
70
  if (arguments.length === 1) return (value) => is(type, value);
@@ -108,26 +145,47 @@ window.__ModuleLoader__.load({
108
145
  }
109
146
  return result;
110
147
  }
111
- /** Deeply compare arrays, dates, regexps, buffers, and plain object fields. */
148
+ /**
149
+ * Compare values recursively, treating two volatile references as equal regardless of value.
150
+ * Strict comparison distinguishes null/undefined, treats opaque objects by identity,
151
+ * compares URLs by normalized href, treats array holes as undefined, and considers distinct cyclic structures unequal.
152
+ * @param a - first value.
153
+ * @param b - second value.
154
+ * @param strict - whether to require strict data equality outside volatile references.
155
+ * @returns whether the values compare equal.
156
+ */
112
157
  function deepEqual(a, b, strict) {
113
- if (a === b) return true;
114
- if (!strict && isNullable(a) && isNullable(b)) return true;
115
- if (typeof a !== typeof b) return false;
116
- if (typeof a !== "object") return false;
117
- if (!a || !b) return false;
118
- function check(test, then) {
119
- return test(a) ? test(b) ? then(a, b) : false : test(b) ? false : void 0;
158
+ const ancestors = /* @__PURE__ */ new Set();
159
+ function compare(a, b) {
160
+ if (a === b) return true;
161
+ if (isVolatile(a) || isVolatile(b)) return isVolatile(a) && isVolatile(b);
162
+ if (!strict && isNullable(a) && isNullable(b)) return true;
163
+ if (typeof a !== typeof b || typeof a !== "object" || !a || !b) return false;
164
+ if (ancestors.has(a)) return false;
165
+ function check(test, then) {
166
+ return test(a) ? test(b) ? then(a, b) : false : test(b) ? false : void 0;
167
+ }
168
+ ancestors.add(a);
169
+ try {
170
+ return check(Array.isArray, (a, b) => {
171
+ if (a.length !== b.length) return false;
172
+ for (let index = 0; index < a.length; index++) if (!compare(a[index], b[index])) return false;
173
+ return true;
174
+ }) ?? check(is("Date"), (a, b) => a.valueOf() === b.valueOf()) ?? check(is("URL"), (a, b) => a.href === b.href) ?? check(is("RegExp"), (a, b) => a.source === b.source && a.flags === b.flags) ?? check(isArrayBufferLike, (a, b) => {
175
+ if (a.byteLength !== b.byteLength) return false;
176
+ const viewA = new Uint8Array(a);
177
+ const viewB = new Uint8Array(b);
178
+ for (let i = 0; i < viewA.length; i++) if (viewA[i] !== viewB[i]) return false;
179
+ return true;
180
+ }) ?? ((!strict || [a, b].every((value) => Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) && Object.keys({
181
+ ...a,
182
+ ...b
183
+ }).every((key) => compare(a[key], b[key])));
184
+ } finally {
185
+ ancestors.delete(a);
186
+ }
120
187
  }
121
- return check(Array.isArray, (a, b) => a.length === b.length && a.every((item, index) => deepEqual(item, b[index]))) ?? check(is("Date"), (a, b) => a.valueOf() === b.valueOf()) ?? check(is("RegExp"), (a, b) => a.source === b.source && a.flags === b.flags) ?? check(isArrayBufferLike, (a, b) => {
122
- if (a.byteLength !== b.byteLength) return false;
123
- const viewA = new Uint8Array(a);
124
- const viewB = new Uint8Array(b);
125
- for (let i = 0; i < viewA.length; i++) if (viewA[i] !== viewB[i]) return false;
126
- return true;
127
- }) ?? Object.keys({
128
- ...a,
129
- ...b
130
- }).every((key) => deepEqual(a[key], b[key], strict));
188
+ return compare(a, b);
131
189
  }
132
190
  /** Time constants plus parsing and formatting helpers. */
133
191
  var Time;
@@ -376,6 +434,7 @@ window.__ModuleLoader__.load({
376
434
  return schema;
377
435
  };
378
436
  Schema.prototype.simplify = function simplify(value) {
437
+ if (isVolatile(value)) value = value.get();
379
438
  if (deepEqual(value, this.meta.default, this.type === "dict")) return null;
380
439
  if (isNullable(value)) return value;
381
440
  if (this.type === "object" || this.type === "dict") {
@@ -432,12 +491,49 @@ window.__ModuleLoader__.load({
432
491
  };
433
492
  return schema;
434
493
  } });
494
+ Schema.prototype.volatile = function volatile() {
495
+ if (this.meta.volatile) throw new TypeError("volatile schema is already wrapped");
496
+ return this.extra("volatile", true);
497
+ };
435
498
  const resolvers = {};
499
+ const checkedVolatile = Symbol("checked-volatile-schema");
500
+ function validateVolatileSchema(schema, path = [], blocked = false, seen = /* @__PURE__ */ new Map()) {
501
+ const states = seen.get(schema) ?? /* @__PURE__ */ new Set();
502
+ if (states.has(blocked)) return;
503
+ states.add(blocked);
504
+ seen.set(schema, states);
505
+ if (schema.meta?.volatile && blocked) throw new ValidationError("volatile fields require a fixed object path without an enclosing volatile field", { path });
506
+ const nested = blocked || !!schema.meta?.volatile;
507
+ if (schema.dict) for (const [key, child] of Object.entries(schema.dict)) validateVolatileSchema(child, [...path, key], nested, seen);
508
+ if (schema.sKey) validateVolatileSchema(schema.sKey, [...path, "<key>"], true, seen);
509
+ if (schema.inner && (schema.type !== "lazy" || schema.inner[kSchema])) validateVolatileSchema(schema.inner, [...path, "*"], true, seen);
510
+ if (schema.list) for (let index = 0; index < schema.list.length; index++) validateVolatileSchema(schema.list[index], [...path, String(index)], true, seen);
511
+ }
436
512
  Schema.extend = function extend(type, resolve) {
437
513
  resolvers[type] = resolve;
438
514
  };
439
515
  Schema.resolve = function resolve(data, schema, options = {}, strict = false) {
440
516
  if (!schema) return [data];
517
+ if (!options[checkedVolatile]) {
518
+ validateVolatileSchema(schema, options.path);
519
+ options = {
520
+ ...options,
521
+ [checkedVolatile]: true
522
+ };
523
+ }
524
+ if (schema.meta?.volatile) {
525
+ const inner = Schema(schema);
526
+ inner.meta = {
527
+ ...schema.meta,
528
+ volatile: false
529
+ };
530
+ const [value, adapted] = Schema.resolve(data, inner, options, strict);
531
+ try {
532
+ return [createVolatile(value), adapted];
533
+ } catch (error) {
534
+ throw new ValidationError(error instanceof Error ? error.message : String(error), options);
535
+ }
536
+ }
441
537
  if (options.ignore?.(data, schema)) return [data];
442
538
  if (isNullable(data) && schema.type !== "lazy") {
443
539
  if (schema.meta.required) throw new ValidationError(`missing required value`, options);
@@ -540,6 +636,7 @@ window.__ModuleLoader__.load({
540
636
  ...schema.meta,
541
637
  ...schema.inner.meta
542
638
  };
639
+ validateVolatileSchema(schema.inner, options.path, true);
543
640
  }
544
641
  return Schema.resolve(data, schema.inner, options, strict);
545
642
  });
@@ -639,7 +736,7 @@ window.__ModuleLoader__.load({
639
736
  } catch (e) {
640
737
  if (!options?.autofix) throw e;
641
738
  delete data[key];
642
- return schema.meta.default;
739
+ return schema.meta.volatile ? createVolatile(schema.meta.default) : schema.meta.default;
643
740
  }
644
741
  }
645
742
  Schema.extend("array", (data, { inner, meta }, options) => {
@@ -1101,7 +1198,6 @@ window.__ModuleLoader__.load({
1101
1198
  //#endregion
1102
1199
  //#region lib/types/client/rpc.js
1103
1200
  /** Browser caller for generic Connection unary RPC channels. */
1104
- const INTERNAL_BASE = "http://dsh.internal";
1105
1201
  const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/;
1106
1202
  const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
1107
1203
  /**
@@ -1122,24 +1218,73 @@ window.__ModuleLoader__.load({
1122
1218
  method: endpoint,
1123
1219
  payload
1124
1220
  };
1125
- const response = await send(new URL(`${channel}/${endpoint}`, resolveBase()), {
1221
+ const response = await send(`${channel}/${endpoint}`.slice(1), {
1126
1222
  method: "POST",
1127
1223
  headers: { "content-type": "application/json" },
1128
1224
  body: JSON.stringify(message),
1129
1225
  ...signal === void 0 ? {} : { signal }
1130
1226
  });
1131
1227
  if (!response.ok) throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`);
1132
- const full = parseConnectionResponse(await response.json());
1228
+ const full = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === "multipart/form-data" ? await parseBinaryResponse(response) : parseConnectionResponse(await response.json());
1229
+ signal?.throwIfAborted();
1133
1230
  if (full.rpcId !== rpcId) throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`);
1134
1231
  return full.result;
1135
1232
  },
1136
- ...openStream === void 0 ? {} : { open(channel, endpoint, payload, signal) {
1233
+ ...openStream === void 0 ? {} : { open(channel, endpoint, payload, signal, uplink) {
1137
1234
  assertTarget(channel, endpoint);
1138
1235
  if (channel !== "/api") throw new Error(`connection: worker-local streams require the /api channel, got ${JSON.stringify(channel)}`);
1139
- return openStream(endpoint, payload, signal);
1236
+ return openStream(endpoint, payload, signal, uplink);
1140
1237
  } }
1141
1238
  };
1142
1239
  }
1240
+ async function parseBinaryResponse(response) {
1241
+ const body = await response.formData();
1242
+ const fields = /* @__PURE__ */ new Map();
1243
+ for (const [name, value] of body) {
1244
+ if (fields.has(name)) throw new TypeError("connection: invalid binary response fields");
1245
+ fields.set(name, value);
1246
+ }
1247
+ const metadata = fields.get("metadata");
1248
+ fields.delete("metadata");
1249
+ if (typeof metadata !== "string") throw new TypeError("connection: invalid binary response fields");
1250
+ const envelope = JSON.parse(metadata);
1251
+ const full = parseConnectionResponse(envelope);
1252
+ if (!full.result.ok || !isRecord(envelope) || !Array.isArray(envelope.attachments) || envelope.attachments.length === 0) throw new TypeError("connection: invalid binary response result");
1253
+ const root = { value: full.result.value };
1254
+ for (const attachment of envelope.attachments) {
1255
+ if (!isRecord(attachment) || attachment.codec !== "bytes" || typeof attachment.part !== "string" || !Array.isArray(attachment.path)) throw new TypeError("connection: invalid binary response attachment");
1256
+ const data = fields.get(attachment.part);
1257
+ fields.delete(attachment.part);
1258
+ if (!(data instanceof Blob)) throw new TypeError("connection: invalid binary response fields");
1259
+ let parent = root;
1260
+ let key = "value";
1261
+ for (const segment of attachment.path) {
1262
+ const value = Reflect.get(parent, key);
1263
+ if (typeof value !== "object" || value === null) throw new TypeError("connection: invalid binary response path");
1264
+ if (Array.isArray(value)) {
1265
+ if (typeof segment !== "number" || !Number.isSafeInteger(segment) || segment < 0 || segment >= value.length) throw new TypeError("connection: invalid binary response path");
1266
+ } else if (typeof segment !== "string") throw new TypeError("connection: invalid binary response path");
1267
+ if (!Object.hasOwn(value, segment)) throw new TypeError("connection: invalid binary response path");
1268
+ parent = value;
1269
+ key = segment;
1270
+ }
1271
+ if (Reflect.get(parent, key) !== null) throw new TypeError("connection: invalid binary response placeholder");
1272
+ Object.defineProperty(parent, key, {
1273
+ value: new Uint8Array(await data.arrayBuffer()),
1274
+ enumerable: true,
1275
+ writable: true,
1276
+ configurable: true
1277
+ });
1278
+ }
1279
+ if (fields.size !== 0) throw new TypeError("connection: invalid binary response fields");
1280
+ return {
1281
+ rpcId: full.rpcId,
1282
+ result: {
1283
+ ok: true,
1284
+ value: root.value
1285
+ }
1286
+ };
1287
+ }
1143
1288
  function parseConnectionResponse(value) {
1144
1289
  if (!isRecord(value) || value.type !== "server-response" || typeof value.rpcId !== "string") throw new TypeError("connection: invalid server-response envelope");
1145
1290
  const result = value.result;
@@ -1169,10 +1314,6 @@ window.__ModuleLoader__.load({
1169
1314
  function isRecord(value) {
1170
1315
  return typeof value === "object" && value !== null && !Array.isArray(value);
1171
1316
  }
1172
- function resolveBase() {
1173
- const location = globalThis.location;
1174
- return location?.origin !== void 0 && location.origin !== "null" ? location.origin : INTERNAL_BASE;
1175
- }
1176
1317
  function assertTarget(channel, endpoint) {
1177
1318
  const segments = endpoint.split("/");
1178
1319
  if (!CHANNEL_PATTERN.test(channel) || segments.some((segment) => segment === "" || segment === "." || segment === ".." || !ENDPOINT_SEGMENT_PATTERN.test(segment))) throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`);
package/lib/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
2
  import { Readable } from "node:stream";
3
- import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import { credentialKey } from "@deepseek-ai/dsh-credentials";
5
5
  import { Service } from "@deepseek-ai/cordis";
6
6
  import { z as z$1 } from "zod";
7
+ import { createScope } from "@deepseek-ai/dsh-scope";
7
8
  //#region lib/types/api-path.js
8
9
  /**
9
10
  * The /api URL prefix — single source for both halves of the web transport.
@@ -24,7 +25,7 @@ const API_PATH = "/api";
24
25
  const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024;
25
26
  /**
26
27
  * Bridge one node:http request to the fetch-shaped handler (client close
27
- * aborts; response bodies stream out chunk by chunk).
28
+ * aborts; response writes respect backpressure and stop on disconnect).
28
29
  * @param req - incoming node:http request.
29
30
  * @param res - node:http response the bridge writes and owns to completion.
30
31
  * @param apiHandler - fetch-shaped API carrier the request is dispatched to.
@@ -90,15 +91,18 @@ async function bridge(req, res, apiHandler, maxRequestBodyBytes = DEFAULT_MAX_RE
90
91
  if (requestUnread) req.destroy();
91
92
  return;
92
93
  }
93
- for await (const chunk of response.body) if (!res.write(chunk)) await new Promise((resolve) => {
94
- const done = () => {
95
- res.off("drain", done);
96
- res.off("close", done);
97
- resolve();
98
- };
99
- res.once("drain", done);
100
- res.once("close", done);
101
- });
94
+ for await (const chunk of response.body) {
95
+ if (abort.signal.aborted) continue;
96
+ if (!res.write(chunk) && !res.destroyed) await new Promise((resolve) => {
97
+ const done = () => {
98
+ res.off("drain", done);
99
+ res.off("close", done);
100
+ resolve();
101
+ };
102
+ res.once("drain", done);
103
+ res.once("close", done);
104
+ });
105
+ }
102
106
  res.end();
103
107
  if (requestUnread) req.destroy();
104
108
  }
@@ -363,22 +367,20 @@ var BrowserAuth = class BrowserAuth {
363
367
  return new BrowserAuth(processOwner, await initializeSecret(credentials), maxAgeDays);
364
368
  }
365
369
  /**
366
- * Add this process's launch token to the ordinary application root URL.
367
- * @param baseUrl - canonical browser origin without credentials.
368
- * @returns root URL carrying the process token as its sole authentication input.
370
+ * Add this process's launch token to the caller's application URL.
371
+ * @param baseUrl - clean browser URL whose authority and mount are preserved.
372
+ * @returns the same URL carrying the process token as its sole authentication input.
369
373
  */
370
374
  authenticatedUrl(baseUrl) {
371
375
  const url = new URL(baseUrl);
372
- url.pathname = "/";
373
- url.search = "";
374
- url.hash = "";
375
376
  url.searchParams.set(TOKEN_QUERY, this.launchToken);
376
377
  return url.href;
377
378
  }
378
379
  /**
379
380
  * Authenticate an index request. A valid root query token mints the cookie
380
- * and redirects to clean `/`; a valid cookie lets the caller serve the
381
- * index; every other request receives the same minimal 401 response.
381
+ * and redirects to the directory-relative clean `./`; a valid cookie lets
382
+ * the caller serve the index; every other request receives the same minimal
383
+ * 401 response.
382
384
  * @param req - incoming root or configured-index request.
383
385
  * @param res - response owned when this method returns false.
384
386
  * @returns true only when the caller may serve index.html.
@@ -400,7 +402,7 @@ var BrowserAuth = class BrowserAuth {
400
402
  }, this.secret);
401
403
  res.writeHead(303, {
402
404
  "cache-control": "no-store",
403
- "location": "/",
405
+ "location": "./",
404
406
  "referrer-policy": "no-referrer",
405
407
  "set-cookie": sessionCookie(cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1e3))
406
408
  });
@@ -410,7 +412,7 @@ var BrowserAuth = class BrowserAuth {
410
412
  if (req.method === "GET" && url.pathname === "/" && this.isAuthenticated(req)) {
411
413
  res.writeHead(303, {
412
414
  "cache-control": "no-store",
413
- "location": "/",
415
+ "location": "./",
414
416
  "referrer-policy": "no-referrer"
415
417
  });
416
418
  res.end();
@@ -514,6 +516,33 @@ const serverResponseSchema = z$1.object({
514
516
  /** Either Connection RPC envelope direction. */
515
517
  const rpcMessageSchema = z$1.discriminatedUnion("type", [clientRequestSchema, serverResponseSchema]);
516
518
  //#endregion
519
+ //#region lib/types/operator-peer.js
520
+ /**
521
+ * The operator Peer: the one party this Host answers to. Connection owns it
522
+ * for its own lifetime, admits every request as it, and hands it to each
523
+ * Remote call as `invocation.peer`.
524
+ * @module @deepseek-ai/dsh-client-connection/src/operator-peer
525
+ */
526
+ /**
527
+ * The operator's scope. The instance is its own scope key, so `scopeOf(peer.ctx)`
528
+ * returns it and events dispatched with `scopeTarget(subject, peer)` reach
529
+ * listeners registered through `peer.ctx` and nobody else.
530
+ */
531
+ var OperatorPeer = class {
532
+ id = randomUUID();
533
+ ctx;
534
+ scope;
535
+ /** @param owner - Connection plugin context the scope fiber hangs under. */
536
+ constructor(owner) {
537
+ this.scope = createScope(owner, this);
538
+ this.ctx = this.scope.ctx;
539
+ }
540
+ /** Tear down every connection-lifetime registration; racing calls share one completion. */
541
+ dispose() {
542
+ return this.scope.dispose();
543
+ }
544
+ };
545
+ //#endregion
517
546
  //#region lib/types/rpc-host.js
518
547
  /** Host registry and HTTP adapter for generic Connection RPC channels. */
519
548
  const INVALID_REQUEST_RPC_ID = RpcId("invalid-request");
@@ -523,6 +552,8 @@ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
523
552
  var HostConnectionService = class extends Service {
524
553
  trustedHosts;
525
554
  browserAuth;
555
+ /** The operator Peer every admitted request speaks for. */
556
+ operator;
526
557
  interceptors = /* @__PURE__ */ new Map();
527
558
  fetchRoutes = /* @__PURE__ */ new Map();
528
559
  /**
@@ -535,6 +566,8 @@ var HostConnectionService = class extends Service {
535
566
  super(ctx, "connection");
536
567
  this.trustedHosts = trustedHosts;
537
568
  this.browserAuth = browserAuth;
569
+ this.operator = new OperatorPeer(ctx);
570
+ ctx.effect(() => () => this.operator.dispose(), "client-connection: operator Peer");
538
571
  }
539
572
  /** Generic channel registry scoped to the Context reading this service. */
540
573
  get rpc() {
@@ -554,6 +587,11 @@ var HostConnectionService = class extends Service {
554
587
  if (!isTrustedApiRequest(request, this.trustedHosts)) return 403;
555
588
  return this.browserAuth.isAuthenticated(request) ? void 0 : 401;
556
589
  }
590
+ /** A request that passes the fence and authentication speaks for the operator. */
591
+ admit(request) {
592
+ const rejection = this.requestRejection(request);
593
+ return rejection === void 0 ? { peer: this.operator } : { rejection };
594
+ }
557
595
  /** Authenticate an index request through the process-token exchange or cookie. */
558
596
  authorizeIndex(request, response) {
559
597
  return this.browserAuth.authorizeIndex(request, response);
@@ -601,15 +639,15 @@ var HostConnectionService = class extends Service {
601
639
  }
602
640
  register(owner, channel, handler) {
603
641
  assertChannel(channel);
604
- const fetchHandler = rpcFetchHandler(channel, handler);
642
+ const fetchHandler = rpcFetchHandler(channel, handler, this.operator);
605
643
  const route = {
606
644
  kind: "prefix",
607
645
  path: channel,
608
646
  handler: async (req, res) => {
609
- const rejection = this.requestRejection(req);
610
- if (rejection !== void 0) {
611
- res.writeHead(rejection);
612
- res.end(rejection === 401 ? "unauthorized" : "forbidden");
647
+ const admission = this.admit(req);
648
+ if ("rejection" in admission) {
649
+ res.writeHead(admission.rejection);
650
+ res.end(admission.rejection === 401 ? "unauthorized" : "forbidden");
613
651
  return;
614
652
  }
615
653
  await bridge(req, res, fetchHandler);
@@ -621,7 +659,7 @@ var HostConnectionService = class extends Service {
621
659
  if (channel !== "/api") throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`);
622
660
  const interceptor = {
623
661
  matches,
624
- fetchHandler: rpcFetchHandler(channel, handler)
662
+ fetchHandler: rpcFetchHandler(channel, handler, this.operator)
625
663
  };
626
664
  return owner.effect(() => {
627
665
  if (this.interceptors.has(channel)) throw new Error(`connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`);
@@ -632,7 +670,7 @@ var HostConnectionService = class extends Service {
632
670
  }, `client-connection: ${channel} rpc interceptor`);
633
671
  }
634
672
  };
635
- function rpcFetchHandler(channel, handler) {
673
+ function rpcFetchHandler(channel, handler, peer) {
636
674
  return {
637
675
  requestBodyMode: () => "buffered",
638
676
  async fetch(request) {
@@ -654,7 +692,7 @@ function rpcFetchHandler(channel, handler) {
654
692
  details: { issues: [] }
655
693
  });
656
694
  try {
657
- const result = await handler(endpoint, message.payload, request.signal);
695
+ const result = await handler(endpoint, message.payload, request.signal, peer);
658
696
  return fullResponse(message.rpcId, result);
659
697
  } catch (error) {
660
698
  return new Response(`handler failure: ${String(error)}`, { status: 500 });
@@ -683,12 +721,36 @@ function errorResponse(rpcId, error) {
683
721
  });
684
722
  }
685
723
  function fullResponse(rpcId, result) {
724
+ if (!result.ok) {
725
+ const body = {
726
+ type: "server-response",
727
+ rpcId,
728
+ result
729
+ };
730
+ return Response.json(body);
731
+ }
732
+ const { attachments, ...success } = result;
686
733
  const body = {
687
734
  type: "server-response",
688
735
  rpcId,
689
- result
736
+ result: success
690
737
  };
691
- return Response.json(body);
738
+ if (attachments === void 0 || attachments.length === 0) return Response.json(body);
739
+ const parts = new FormData();
740
+ const attachmentMetadata = attachments.map((attachment, index) => {
741
+ const part = `bytes-${index}`;
742
+ parts.set(part, new Blob([new Uint8Array(attachment.bytes)]));
743
+ return {
744
+ path: [...attachment.path],
745
+ codec: "bytes",
746
+ part
747
+ };
748
+ });
749
+ parts.set("metadata", JSON.stringify({
750
+ ...body,
751
+ attachments: attachmentMetadata
752
+ }));
753
+ return new Response(parts);
692
754
  }
693
755
  function assertChannel(channel) {
694
756
  if (!CHANNEL_PATTERN.test(channel) || channel === "/api") throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`);
@@ -769,10 +831,10 @@ async function apply(ctx, config) {
769
831
  kind: "prefix",
770
832
  path: API_PATH,
771
833
  handler: async (req, res) => {
772
- const rejection = connection.requestRejection(req);
773
- if (rejection !== void 0) {
774
- res.writeHead(rejection);
775
- res.end(rejection === 401 ? "unauthorized" : "forbidden");
834
+ const admission = connection.admit(req);
835
+ if ("rejection" in admission) {
836
+ res.writeHead(admission.rejection);
837
+ res.end(admission.rejection === 401 ? "unauthorized" : "forbidden");
776
838
  return;
777
839
  }
778
840
  await webCtx.waterfall("connection/request", req, res, () => bridge(req, res, fetchHandler, maxRequestBodyBytes));
@@ -785,4 +847,4 @@ async function apply(ctx, config) {
785
847
  });
786
848
  }
787
849
  //#endregion
788
- export { API_PATH, Config, HostConnectionService, RpcId, apply, clientRequestSchema, inject, name, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, rpcResultSchema, serverResponseSchema, transportError };
850
+ export { API_PATH, Config, HostConnectionService, OperatorPeer, RpcId, apply, clientRequestSchema, inject, name, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, rpcResultSchema, serverResponseSchema, transportError };
@@ -21,15 +21,16 @@ export declare class BrowserAuth {
21
21
  */
22
22
  static create(processOwner: object, credentials: CredentialProvider, maxAgeDays: number): Promise<BrowserAuth>;
23
23
  /**
24
- * Add this process's launch token to the ordinary application root URL.
25
- * @param baseUrl - canonical browser origin without credentials.
26
- * @returns root URL carrying the process token as its sole authentication input.
24
+ * Add this process's launch token to the caller's application URL.
25
+ * @param baseUrl - clean browser URL whose authority and mount are preserved.
26
+ * @returns the same URL carrying the process token as its sole authentication input.
27
27
  */
28
28
  authenticatedUrl(baseUrl: string): string;
29
29
  /**
30
30
  * Authenticate an index request. A valid root query token mints the cookie
31
- * and redirects to clean `/`; a valid cookie lets the caller serve the
32
- * index; every other request receives the same minimal 401 response.
31
+ * and redirects to the directory-relative clean `./`; a valid cookie lets
32
+ * the caller serve the index; every other request receives the same minimal
33
+ * 401 response.
33
34
  * @param req - incoming root or configured-index request.
34
35
  * @param res - response owned when this method returns false.
35
36
  * @returns true only when the caller may serve index.html.
@@ -1,9 +1,12 @@
1
1
  /** Browser caller for generic Connection unary RPC channels. */
2
2
  import type { ClientConnectionRpc } from '../rpc.ts';
3
- /** Transport this caller posts through; same signature as the global `fetch`. */
4
- export type RpcFetch = (input: URL, init: RequestInit) => Promise<Response>;
5
- /** Worker-local opener for decoded Gateway Remote streams. */
6
- export type RpcStreamOpen = (endpoint: string, payload: unknown, signal: AbortSignal) => AsyncIterable<unknown>;
3
+ /**
4
+ * Transport this caller posts through; same signature as the global `fetch`.
5
+ * Receives the document-relative route so a carrier resolves it against its own base.
6
+ */
7
+ export type RpcFetch = (input: string | URL, init: RequestInit) => Promise<Response>;
8
+ /** Worker-local opener for decoded Gateway Remote streams; `uplink` carries the Client's items for the stream. */
9
+ export type RpcStreamOpen = (endpoint: string, payload: unknown, signal: AbortSignal, uplink?: AsyncIterable<unknown>) => AsyncIterable<unknown>;
7
10
  /**
8
11
  * Create the browser-backed generic RPC caller.
9
12
  * @param doFetch - transport override; defaults to the page's global fetch.
@@ -2,20 +2,31 @@
2
2
  * node:http ↔ WHATWG fetch bridge for the /api transport (host side of the
3
3
  * web carrier; the fetch-shaped handler itself is transport-agnostic).
4
4
  */
5
- import type { IncomingMessage, ServerResponse } from 'node:http';
5
+ import type { IncomingMessage } from 'node:http';
6
6
  import type { ConnectionFetchHandler } from './rpc.ts';
7
7
  /** Default carrier cap for all HTTP RPC bodies: sized for the default
8
8
  * aggregate image limit (200 MiB) after base64 expansion plus envelope
9
9
  * headroom (~267.7 MiB required), rounded up for slack. The bridge buffers
10
10
  * each body in memory, so this cap is also the per-request resident bound. */
11
11
  export declare const DEFAULT_MAX_REQUEST_BODY_BYTES: number;
12
+ interface BridgeServerResponse {
13
+ readonly destroyed: boolean;
14
+ readonly writableEnded: boolean;
15
+ on(event: 'close', listener: () => void): this;
16
+ off(event: 'close' | 'drain', listener: () => void): this;
17
+ once(event: 'close' | 'drain', listener: () => void): this;
18
+ writeHead(statusCode: number, headers?: Record<string, string>): unknown;
19
+ write(chunk: Uint8Array): boolean;
20
+ end(): unknown;
21
+ }
12
22
  /**
13
23
  * Bridge one node:http request to the fetch-shaped handler (client close
14
- * aborts; response bodies stream out chunk by chunk).
24
+ * aborts; response writes respect backpressure and stop on disconnect).
15
25
  * @param req - incoming node:http request.
16
26
  * @param res - node:http response the bridge writes and owns to completion.
17
27
  * @param apiHandler - fetch-shaped API carrier the request is dispatched to.
18
28
  * @param maxRequestBodyBytes - maximum bytes buffered for a buffered route.
19
29
  */
20
- export declare function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: ConnectionFetchHandler, maxRequestBodyBytes?: number): Promise<void>;
30
+ export declare function bridge(req: IncomingMessage, res: BridgeServerResponse, apiHandler: ConnectionFetchHandler, maxRequestBodyBytes?: number): Promise<void>;
31
+ export {};
21
32
  //# sourceMappingURL=http-bridge.d.ts.map
@@ -3,8 +3,10 @@ import type { Context } from '@deepseek-ai/cordis';
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http';
4
4
  import z from '@deepseek-ai/schemastery';
5
5
  import { type ConnectionRecoveryConfig } from './recovery-config.ts';
6
- export type { ConnectionFetchMethod, ConnectionFetchHandler, ConnectionFetchRoute, ConnectionIndexRequest, ConnectionIndexResponse, ConnectionRpcEndpointMatcher, ConnectionRpcFailure, ConnectionRpcHandler, ConnectionRequestRejection, ConnectionRpcResult, ConnectionRequestBodyMode, ConnectionTrustRequest, ClientRequest, HostConnectionHandle, HostConnectionFetch, HostConnectionRpc, RpcMessage, ServerResponse, } from './rpc.ts';
6
+ export type { PeerAdmission, ConnectionFetchMethod, ConnectionFetchHandler, ConnectionFetchRoute, ConnectionIndexRequest, ConnectionIndexResponse, ConnectionRpcEndpointMatcher, ConnectionRpcAttachment, ConnectionRpcFailure, ConnectionRpcHandler, ConnectionRpcHandlerResult, ConnectionRequestRejection, ConnectionRpcResult, ConnectionRequestBodyMode, ConnectionTrustRequest, ClientRequest, HostConnectionHandle, HostConnectionFetch, HostConnectionRpc, RpcMessage, ServerResponse, } from './rpc.ts';
7
+ export type { PeerId, PeerScope, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol';
7
8
  export { RpcId, transportError } from './rpc.ts';
9
+ export { OperatorPeer } from './operator-peer.ts';
8
10
  export { clientRequestSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, rpcResultSchema, serverResponseSchema, } from './rpc-schema.ts';
9
11
  export { HostConnectionService } from './rpc-host.ts';
10
12
  export { API_PATH } from './api-path.ts';
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The operator Peer: the one party this Host answers to. Connection owns it
3
+ * for its own lifetime, admits every request as it, and hands it to each
4
+ * Remote call as `invocation.peer`.
5
+ * @module @deepseek-ai/dsh-client-connection/src/operator-peer
6
+ */
7
+ import type { Context } from '@deepseek-ai/cordis';
8
+ import type { PeerId, PeerScope } from '@deepseek-ai/dsh-typert-protocol';
9
+ /**
10
+ * The operator's scope. The instance is its own scope key, so `scopeOf(peer.ctx)`
11
+ * returns it and events dispatched with `scopeTarget(subject, peer)` reach
12
+ * listeners registered through `peer.ctx` and nobody else.
13
+ */
14
+ export declare class OperatorPeer implements PeerScope {
15
+ readonly id: PeerId;
16
+ readonly ctx: Context;
17
+ private readonly scope;
18
+ /** @param owner - Connection plugin context the scope fiber hangs under. */
19
+ constructor(owner: Context);
20
+ /** Tear down every connection-lifetime registration; racing calls share one completion. */
21
+ dispose(): Promise<void>;
22
+ }
23
+ //# sourceMappingURL=operator-peer.d.ts.map
@@ -1,7 +1,8 @@
1
1
  /** Host registry and HTTP adapter for generic Connection RPC channels. */
2
2
  import { Context, Service } from '@deepseek-ai/cordis';
3
+ import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol';
3
4
  import type { BrowserAuth } from './browser-auth.ts';
4
- import type { ConnectionIndexRequest, ConnectionIndexResponse, ConnectionFetchHandler, HostConnectionFetch, ConnectionRequestRejection, ConnectionTrustRequest, HostConnectionHandle, HostConnectionRpc } from './rpc.ts';
5
+ import type { PeerAdmission, ConnectionIndexRequest, ConnectionIndexResponse, ConnectionFetchHandler, HostConnectionFetch, ConnectionRequestRejection, ConnectionTrustRequest, HostConnectionHandle, HostConnectionRpc } from './rpc.ts';
5
6
  declare module '@deepseek-ai/cordis' {
6
7
  interface Context {
7
8
  /** Host Connection transport and RPC registrations. */
@@ -12,6 +13,8 @@ declare module '@deepseek-ai/cordis' {
12
13
  export declare class HostConnectionService extends Service implements HostConnectionHandle {
13
14
  private readonly trustedHosts;
14
15
  private readonly browserAuth;
16
+ /** The operator Peer every admitted request speaks for. */
17
+ readonly operator: PeerScope;
15
18
  private readonly interceptors;
16
19
  private readonly fetchRoutes;
17
20
  /**
@@ -27,6 +30,8 @@ export declare class HostConnectionService extends Service implements HostConnec
27
30
  get fetch(): HostConnectionFetch;
28
31
  /** Apply the configured Host/Origin fence, then browser authentication. */
29
32
  requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection;
33
+ /** A request that passes the fence and authentication speaks for the operator. */
34
+ admit(request: ConnectionTrustRequest): PeerAdmission;
30
35
  /** Authenticate an index request through the process-token exchange or cookie. */
31
36
  authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean;
32
37
  /** Add this process's launch token to the clean application URL. */
@@ -1,5 +1,6 @@
1
1
  /** Generic unary RPC contracts shared by the Host and Client Connection halves. */
2
2
  import type { Branded } from '@deepseek-ai/dsh-brand';
3
+ import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol';
3
4
  /** Correlation id minted by a caller and echoed by the Connection response. */
4
5
  export type RpcId = Branded<'rpc-id'>;
5
6
  /**
@@ -22,6 +23,23 @@ export type ConnectionRpcResult<T> = {
22
23
  readonly ok: false;
23
24
  readonly error: ConnectionRpcFailure;
24
25
  };
26
+ /** One binary value separated from a successful RPC result before transport framing. */
27
+ export interface ConnectionRpcAttachment {
28
+ /** Result-relative path occupied by the attachment's `null` placeholder. */
29
+ readonly path: readonly (string | number)[];
30
+ /** Byte view carried outside the JSON response metadata. */
31
+ readonly bytes: Uint8Array;
32
+ }
33
+ /** Successful or failed handler result ready for Connection transport framing. */
34
+ export type ConnectionRpcHandlerResult = {
35
+ readonly ok: true;
36
+ readonly value: unknown;
37
+ /** Binary fields already projected by the handler that owns the result protocol. */
38
+ readonly attachments?: readonly ConnectionRpcAttachment[];
39
+ } | {
40
+ readonly ok: false;
41
+ readonly error: ConnectionRpcFailure;
42
+ };
25
43
  /** Historical short name for a generic Connection result. */
26
44
  export type RpcResult<T> = ConnectionRpcResult<T>;
27
45
  /**
@@ -72,8 +90,17 @@ export interface ConnectionIndexResponse {
72
90
  writeHead(status: number, headers?: Readonly<Record<string, string>>): unknown;
73
91
  end(body?: string): unknown;
74
92
  }
75
- /** Handler invoked after Connection has decoded the transport envelope. */
76
- export type ConnectionRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<ConnectionRpcResult<unknown>>;
93
+ /** Outcome of admitting one request: the operator Peer it speaks for, or the status refusing it. */
94
+ export type PeerAdmission = {
95
+ readonly peer: PeerScope;
96
+ } | {
97
+ readonly rejection: 401 | 403;
98
+ };
99
+ /**
100
+ * Handler invoked after Connection has decoded the transport envelope.
101
+ * `peer` is the Peer the request was admitted as: the operator.
102
+ */
103
+ export type ConnectionRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal, peer: PeerScope) => Promise<ConnectionRpcHandlerResult>;
77
104
  /** Synchronous ownership test for one endpoint on a shared RPC channel. */
78
105
  export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean;
79
106
  /** HTTP methods supported by exact Fetch routes on the shared API channel. */
@@ -118,12 +145,14 @@ export interface HostConnectionRpc {
118
145
  */
119
146
  intercept(channel: '/api', matches: ConnectionRpcEndpointMatcher, handler: ConnectionRpcHandler): () => Promise<void>;
120
147
  }
121
- /** Host `ctx.connection` shape consumed by transport-independent adapters. */
148
+ /** Host `ctx.connection` members consumed by transport-independent adapters. */
122
149
  export interface HostConnectionHandle {
123
150
  /** Generic RPC channel registry. */
124
151
  readonly rpc: HostConnectionRpc;
125
152
  /** Exact Fetch routes for streaming or browser-native responses. */
126
153
  readonly fetch: HostConnectionFetch;
154
+ /** The operator Peer every admitted request speaks for; its scope lives as long as Connection. */
155
+ readonly operator: PeerScope;
127
156
  /**
128
157
  * Compose exact Fetch routes and the shared-channel RPC interceptor.
129
158
  * @param channel - shared channel mounted by Connection.
@@ -137,6 +166,13 @@ export interface HostConnectionHandle {
137
166
  * @returns rejection status, or undefined when the route may accept the request.
138
167
  */
139
168
  requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection;
169
+ /**
170
+ * Admit one request: it passes {@link requestRejection} and speaks for the
171
+ * operator, or it is refused with that status.
172
+ * @param request - request headers from the HTTP or upgrade request.
173
+ * @returns the operator Peer, or the rejection status.
174
+ */
175
+ admit(request: ConnectionTrustRequest): PeerAdmission;
140
176
  /**
141
177
  * Authenticate one frontend index request, owning a token redirect or 401.
142
178
  * @param request - root or configured-index HTTP request.
@@ -146,8 +182,8 @@ export interface HostConnectionHandle {
146
182
  authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean;
147
183
  /**
148
184
  * Add the fresh process token to an ordinary Web application URL.
149
- * @param baseUrl - clean canonical browser origin.
150
- * @returns root URL accepted by {@link authorizeIndex} for initial login.
185
+ * @param baseUrl - clean application URL whose authority and mount are preserved.
186
+ * @returns tokenized URL for initial login; a mount proxy strips its prefix before {@link authorizeIndex}.
151
187
  */
152
188
  authenticatedUrl(baseUrl: string): string;
153
189
  }
@@ -187,8 +223,9 @@ export interface ClientConnectionRpc {
187
223
  * @param endpoint - channel-relative endpoint such as `session/follow`.
188
224
  * @param payload - channel-owned request payload.
189
225
  * @param signal - caller cancellation for this logical stream.
226
+ * @param uplink - Client uplink items the Host method reads through `invocation.uplink()`.
190
227
  * @returns decoded stream values from the in-process carrier.
191
228
  */
192
- readonly open?: (channel: string, endpoint: string, payload: unknown, signal: AbortSignal) => AsyncIterable<unknown>;
229
+ readonly open?: (channel: string, endpoint: string, payload: unknown, signal: AbortSignal, uplink?: AsyncIterable<unknown>) => AsyncIterable<unknown>;
193
230
  }
194
231
  //# sourceMappingURL=rpc.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-client-connection",
3
3
  "description": "Authenticated RPC transport and generation lifecycle",
4
- "version": "0.1.6-alpha.2",
4
+ "version": "0.1.7-alpha.1",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -35,8 +35,8 @@
35
35
  "license": "MIT",
36
36
  "dependencies": {
37
37
  "zod": "^4.4.3",
38
- "@deepseek-ai/dsh-credentials": "^0.1.6-alpha.2",
39
- "@deepseek-ai/schemastery": "^3.18.2"
38
+ "@deepseek-ai/schemastery": "^3.18.3",
39
+ "@deepseek-ai/dsh-credentials": "^0.1.7-alpha.1"
40
40
  },
41
41
  "files": [
42
42
  "lib/index.js",
@@ -44,14 +44,17 @@
44
44
  "lib/types/**/*.d.ts"
45
45
  ],
46
46
  "peerDependencies": {
47
- "@deepseek-ai/cordis": "^4.0.2"
47
+ "@deepseek-ai/cordis": "^4.0.3",
48
+ "@deepseek-ai/dsh-scope": "^0.1.7-alpha.1"
48
49
  },
49
50
  "devDependencies": {
50
- "@deepseek-ai/dsh-attachment": "^0.1.6-alpha.2",
51
- "@deepseek-ai/cordis": "^4.0.2",
52
- "@deepseek-ai/dsh-brand": "^0.1.6-alpha.2",
53
- "@deepseek-ai/dsh-host-webserver": "^0.1.6-alpha.2",
54
- "@deepseek-ai/dsh-session": "^0.1.6-alpha.2",
55
- "@deepseek-ai/dsh-llm": "^0.1.6-alpha.2"
51
+ "@deepseek-ai/dsh-attachment": "^0.1.7-alpha.1",
52
+ "@deepseek-ai/cordis": "^4.0.3",
53
+ "@deepseek-ai/dsh-brand": "^0.1.7-alpha.1",
54
+ "@deepseek-ai/dsh-host-webserver": "^0.1.7-alpha.1",
55
+ "@deepseek-ai/dsh-llm": "^0.1.7-alpha.1",
56
+ "@deepseek-ai/dsh-scope": "^0.1.7-alpha.1",
57
+ "@deepseek-ai/dsh-session": "^0.1.7-alpha.1",
58
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.7-alpha.1"
56
59
  }
57
60
  }