@leaf233/dsh-llm-rate-limiter 0.2.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.2.1] - 2026-09-25
8
+
9
+ ### Fixed
10
+ - **Live status panel stopped working on DSH 0.1.5-rc.3** — the channel silently failed to mount and the panel sat on "○ 重试中" forever. Root cause is upstream: DSH 0.1.5-rc.3 changed `@deepseek-ai/dsh-client-connection`'s own `inject` from `["webServer", "credentials"]` to `["credentials"]`, while `HostConnectionService.register()` still dereferences `owner.webServer`. Cordis rebinds a cross-fiber service's `ctx` to the *reader's* fiber, so `connection.rpc.handle()` threw `cannot get property "webServer" without inject` — which the plugin's own try/catch swallowed.
11
+ - The plugin now falls back to a **self-registered `kind: "prefix"` route** on its own fiber (which can see `webServer`) and reuses `connection.requestRejection()` for the 403/401 fence. Both carriers speak the identical wire protocol, so **`lib/client.js` is unchanged**.
12
+
13
+ ### Added
14
+ - `createChannelRoute({ channel, handler, reject })` and `endpointFromPath()` in `lib/status-rpc.js` — a Connection-RPC-compatible webserver prefix route, mirroring the framework's `rpcFetchHandler` request-shape decisions (404 / 415 / 413 / 400 / `gateway/bad-request` / 500 / 200)
15
+ - `test-status-route.mjs` (73 assertions) in four layers: route grammar, request-shape decisions, a simulated 0.1.5 regression (proves both the fallback *and* the preference for `rpc.handle` when it works), and the real 0.1.5 package (auto-skips when DSH is absent)
16
+ - `tools/verify-live-route.mjs` (19 assertions) — boots the real `dsh-host-webserver` on a real socket plus the real `dsh-client-connection`, then issues real HTTP requests; exits 2 (skipped) without a DSH install
17
+ - `pnpm run verify:live` script
18
+
19
+ ### Changed
20
+ - `dsh.compatibility.dshReleases` now declares `0.1.5-rc.3` as compatible alongside `0.1.2-rc.1`
21
+ - The status channel's mount log now names the active carrier (`connection.rpc` vs `self-registered route`), and a failed path-1 registration is reported as a warning instead of being swallowed
22
+ - If `connection.requestRejection()` is unavailable or throws, the plugin **refuses to mount** rather than publishing an unauthenticated route
23
+
24
+ ### Notes
25
+ - Path 1 (`connection.rpc.handle`) is still preferred: it keeps route ownership, request validation, and withdrawal with the framework. The fallback exists only for hosts where path 1 is broken.
26
+ - When DSH fixes the upstream `inject` mismatch, path 1 succeeds again and the fallback goes unused — no plugin change required.
27
+ - Verified on: DSH `0.1.5-rc.3` (client-connection 0.1.5-rc.3, host-webserver 0.1.5-rc.3), Cordis 4.0.2, Node 24.21.0. Test totals: 19 + 61 + 70 + 77 + 73 = **300 assertions**, plus 31 in `tools/verify-install.mjs` and 19 in `tools/verify-live-route.mjs`. Producer: **deepseek-v4.1-flash**.
28
+
7
29
  ## [0.2.0] - 2026-09-13
8
30
 
9
31
  ### Added
package/COMPATIBILITY.md CHANGED
@@ -1,8 +1,73 @@
1
1
  # dsh-llm-rate-limiter — DSH 版本兼容性检测报告
2
2
 
3
- > 检测日期: 2026-07-28
4
- > 本地 DSH 版本: **0.1.2-rc.1**
5
- > 测试方式: 源码分析(npm registry 在沙箱中被拦截,无法查询其他版本)
3
+ > 初检日期: 2026-07-28 · 复检日期: **2026-09-25**
4
+ > 已验证 DSH 版本: **0.1.2-rc.1**、**0.1.5-rc.3**
5
+ > 测试方式: 源码分析 + 可执行回归测试(`test-status-route.mjs`、`tools/verify-live-route.mjs` 走真实 socket)
6
+
7
+ ---
8
+
9
+ ## 〇、0.1.5-rc.3 回归与修复(2026-09-25 复检)
10
+
11
+ ### 现象
12
+ 升级到 DSH **0.1.5-rc.3** 后,状态面板永久停在「○ 重试中」,实时数据不再刷新;限速本身与设置页配置完全正常。
13
+
14
+ ### 根因(已用真实包 + 真实 Cordis 复现,非推断)
15
+ DSH 0.1.5-rc.3 把 **`@deepseek-ai/dsh-client-connection` 这个框架插件自身**的 `inject` 从
16
+ `["webServer", "credentials"]` 改成了 `["credentials"]`(lib/index.js:736),
17
+ 但其 `HostConnectionService.register()` 仍然直接解引用 `owner.webServer`:
18
+
19
+ ```js
20
+ // dsh-client-connection/lib/index.js:618
21
+ return owner.effect(() => owner.webServer.register(route), `... rpc channel`);
22
+ ```
23
+
24
+ 而 `connection.rpc` 的 getter 取 `const owner = this.ctx`,Cordis 的 `createTraceable`
25
+ 会把跨 fiber 读取的 service 的 `ctx` 重绑定到**读取者**的 fiber
26
+ (实测 `owner.fiber === consumer.fiber` 为 true)。因此 `owner.webServer` 的解析从读取者的
27
+ inject 出发 —— 读取者只 inject 了 `["connection"]`,于是抛:
28
+
29
+ ```
30
+ cannot get property "webServer" without inject
31
+ ```
32
+
33
+ 该异常发生在插件 `ctx.inject(["connection"], ...)` 回调内,被插件自己的 try/catch 吞掉,
34
+ 只留一条 `logger.warn`,于是通道**静默未注册**。
35
+
36
+ ### 线上证据
37
+ | 探测 | 修复前 | 含义 |
38
+ |---|---|---|
39
+ | `GET /api/xxx` | 401 | `/api` 路由存在,仅认证被拒 |
40
+ | `GET /llm-rate-limiter/snapshot` | **404** | 该前缀**无路由**(走 SPA fallback) |
41
+ | `POST /llm-rate-limiter/snapshot` | **405** | 同上 |
42
+
43
+ 而 `dsh --profile web --dump-config` 中插件条目仍存在 → 插件确实被加载,只是通道注册失败。
44
+
45
+ ### 交叉验证(隔离变量)
46
+ | 实验 | 结果 |
47
+ |---|---|
48
+ | 0.1.2 代码 + fiber `[webServer,credentials]` | ✅ 注册成功 |
49
+ | 0.1.2 代码 + fiber `[credentials]` | ❌ 失败 |
50
+ | 0.1.5 代码 + fiber `[webServer,credentials]` | ✅ 注册成功 |
51
+ | 0.1.5 代码 + fiber `[credentials]`(真实形态) | ❌ 失败 |
52
+
53
+ → 差异**只由 inject 列表决定**,与两版 `register` 代码差异无关。另实测:仅给**消费者**外层 fiber 加
54
+ `webServer`(或嵌套 `ctx.inject(["connection","webServer"])`)**均无效** —— 因为 `owner` 绑定的是
55
+ connection service 自身那条 fiber。
56
+
57
+ ### 修复(v0.2.1,方案 E)
58
+ 保留路径 1 为首选,新增路径 2 兜底,两者**同一套 wire 协议**,浏览器端零改动:
59
+
60
+ | 路径 | 触发条件 | 做法 |
61
+ |---|---|---|
62
+ | 1(首选) | `connection.rpc.handle()` 正常 | 框架持有路由、请求校验与 fiber 级撤销 |
63
+ | 2(兜底) | 路径 1 抛错 | 插件在**自己的 fiber**(能看到 `webServer`)注册 `kind:"prefix"` 路由,并复用 `connection.requestRejection()` 保留 403/401 栅栏 |
64
+
65
+ - 若 `connection.requestRejection()` 缺失或抛错,插件**拒绝挂载**,绝不发布未鉴权路由
66
+ - 上游修复该 inject 不一致后,路径 1 会自动重新生效,无需再改插件
67
+
68
+ ### 回归测试
69
+ - `test-status-route.mjs`(73 断言)四层:路由语法、请求形态判定、**模拟 0.1.5 回归**(同时验证「兜底生效」与「路径 1 可用时优先」)、真实 0.1.5 包(无 DSH 时自动跳过)
70
+ - `tools/verify-live-route.mjs`(19 断言):真实 `dsh-host-webserver` 真 socket + 真实 `dsh-client-connection`,发真实 HTTP 请求;无 DSH 时退出码 2(跳过)
6
71
 
7
72
  ---
8
73
 
@@ -34,7 +99,8 @@
34
99
 
35
100
  | # | API | 所属包 | 本地存在 | 稳定性评估 |
36
101
  |---|-----|--------|----------|------------|
37
- | 1 | `ctx.inject(["connection"], cb)` + `conn.rpc.handle(channel, handler)` | dsh-client-connection | ✅ `HostConnectionService.register`(lib/index.js 572-589) | **公开通用 API**;注册包在 `owner.effect` 中,卸载自动撤通道。通道名须匹配 `/^\/[A-Za-z0-9._~-]+$/` 且非 `/api` |
102
+ | 1 | `ctx.inject(["connection"], cb)` + `conn.rpc.handle(channel, handler)` | dsh-client-connection | ⚠️ 0.1.2 可用 / **0.1.5 抛错** | 首选路径。0.1.5-rc.3 起因该包自身 `inject` 缺 `webServer` 而抛 `cannot get property "webServer" without inject`,故 v0.2.1 增加路径 2 兜底 |
103
+ | 1b | `conn.requestRejection(req)` + `ctx.inject(["webServer"], cb)` + `webServer.register({kind:"prefix",...})` | dsh-client-connection + dsh-host-webserver | ✅ 0.1.2 / 0.1.5 | **v0.2.1 兜底路径**。复用框架的 403/401 栅栏,自行注册前缀路由;wire 协议与路径 1 逐字一致 |
38
104
  | 2 | handler 签名 `(endpoint, payload, signal) => envelope` | dsh-client-connection | ✅ `rpcFetchHandler`(605-631) | 框架强制 POST + `application/json`(否则 404/415),并自动套 `requestRejection`(Host/Origin 不信→403,未认证→401) |
39
105
  | 3 | envelope `{ok:true,value}` / `{ok:false,error:{code,message,details}}` | dsh-client-connection | ✅ 与 `clientRequestSchema` / `errorResponse` 一致 | 契约字面量,dsh-context 逐字复制同一范式 |
40
106
  | 4 | 客户端 `ctx.get("connection")?.rpc.call(channel, endpoint, payload, signal)` | dsh-client-connection | ✅ `createWebConnectionRpc.call`(client.js 4606-4628) | 传输失败 reject;返回 `result`(即 envelope)。经 `rpcCallOf` 反射读取,服务缺席/敌意时降级为 `undefined` |
@@ -143,8 +209,13 @@
143
209
  - [ ] `settings.register` 返回值结构是否变化
144
210
  - [ ] `settingsScope.set/mutate` 接口是否变化
145
211
  - [ ] `settings.plugin.item` slot 是否仍可注入
146
- - [ ] **状态通道(v0.2.0)**:`connection.rpc.handle` 仍为公开 API 且通道名规则未变
147
- - [ ] **状态通道(v0.2.0)**:`snapshot` / `reset` 端点往返成功(面板显示"● 实时"而非"状态通道不可用")
148
- - [ ] **状态通道(v0.2.0)**:折叠卡片后 DevTools Network 无 `/llm-rate-limiter/snapshot` 轮询
149
- - [ ] **状态通道(v0.2.0)**:无 connection 服务的组合下,卡片配置区仍可用且面板显示降级文案
212
+ - [ ] **状态通道**:`connection.rpc.handle` 是否仍抛 `webServer` 相关错误(0.1.5-rc.3 会)
213
+ - [ ] **状态通道**:`connection.requestRejection` 仍为公开 API(兜底路径的鉴权依赖它)
214
+ - [ ] **状态通道**:`webServer.register({kind:"prefix"})` 仍接受前缀路由
215
+ - [ ] **状态通道**:`snapshot` / `reset` 端点往返成功(面板显示"● 实时"而非"重试中")
216
+ - [ ] **状态通道**:折叠卡片后 DevTools Network 无 `/llm-rate-limiter/snapshot` 轮询
217
+ - [ ] **状态通道**:无 connection 服务的组合下,卡片配置区仍可用且面板显示降级文案
218
+ - [ ] **状态通道**:直接 `POST /llm-rate-limiter/snapshot` 应返回 401(路由存在且被栅栏保护),而非 404/405(路由缺失)
219
+
220
+ > 上述清单已由 `test-status-route.mjs` + `tools/verify-live-route.mjs` 自动覆盖,升级 DSH 后先跑 `pnpm test && pnpm run verify:live`。
150
221
  4. **如果 cordis 升级到 5.0+**:整个 `apply(ctx)` 接口、`ctx.effect()`、`ctx.on()` 签名可能重写,需要全面适配
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
5
  [![DSH 0.1.x](https://img.shields.io/badge/DSH-0.1.x-brightgreen.svg)](COMPATIBILITY.md)
6
6
  [![Cordis 4.x](https://img.shields.io/badge/Cordis-%3E%3D4.0.2-brightgreen.svg)](package.json)
7
- [![Tests](https://img.shields.io/badge/tests-19%2F19%20passing-brightgreen.svg)](test-strategies.mjs)
7
+ [![Tests](https://img.shields.io/badge/tests-300%20passing-brightgreen.svg)](test-status-route.mjs)
8
8
 
9
9
  Per-model LLM call rate limiter for [DeepSeek Harness](https://github.com/deepseek-ai/dsh) with queue/reject support and interactive GUI configuration.
10
10
 
@@ -39,7 +39,8 @@ Expanding the card shows a live panel at the top of its body:
39
39
 
40
40
  | Aspect | Behaviour |
41
41
  |--------|-----------|
42
- | Data channel | Framework `connection.rpc` channel `/llm-rate-limiter` — authenticated (401/403 fence), POST+JSON, auto-cleaned with the plugin fiber |
42
+ | Data channel | Channel `/llm-rate-limiter` — authenticated (401/403 fence), POST+JSON, auto-cleaned with the plugin fiber |
43
+ | Carrier | Prefers the framework's `connection.rpc.handle()`; falls back to a self-registered prefix route that reuses `connection.requestRejection()` when the framework path is broken (see below) |
43
44
  | Cadence | 1 s polling while the card is expanded; backs off 2 s → 4 s → 8 s after failures |
44
45
  | Collapsed card | The panel unmounts, so **no polling runs at all** |
45
46
  | Endpoints | `snapshot` (live counters) and `reset` (zero the statistics) |
@@ -49,6 +50,30 @@ Expanding the card shows a live panel at the top of its body:
49
50
 
50
51
  > 🧠 **From Hindsight memory (dsh-context-host-client)** — the channel idiom is dsh-context's: `ctx.inject(["connection"])` → `conn.rpc.handle(channel, handler)`, with the browser side resolving `ctx.get("connection")?.rpc.call` defensively so a missing service degrades instead of throwing.
51
52
 
53
+ ### Carrier fallback (DSH 0.1.5-rc.3)
54
+
55
+ DSH 0.1.5-rc.3 changed `@deepseek-ai/dsh-client-connection`'s own `inject` from
56
+ `["webServer", "credentials"]` to `["credentials"]`, but its
57
+ `HostConnectionService.register()` still dereferences `owner.webServer`.
58
+ Cordis rebinds a cross-fiber service's `ctx` to the *reader's* fiber, so
59
+ `connection.rpc.handle()` throws:
60
+
61
+ ```
62
+ cannot get property "webServer" without inject
63
+ ```
64
+
65
+ The plugin now detects that and mounts the same channel itself:
66
+
67
+ | Path | When | How |
68
+ |------|------|-----|
69
+ | 1 (preferred) | `connection.rpc.handle()` works | The framework owns the route, request validation, and fiber-scoped withdrawal |
70
+ | 2 (fallback) | Path 1 throws | The plugin registers a `kind: "prefix"` route on its own fiber (which *can* see `webServer`) and reuses `connection.requestRejection()` for the 403/401 fence |
71
+
72
+ Both carriers speak the identical wire protocol, so **the browser half is
73
+ unchanged** — the panel cannot tell which one is live. If
74
+ `connection.requestRejection()` is unavailable, the plugin refuses to mount
75
+ rather than publishing an unauthenticated route.
76
+
52
77
 
53
78
  ---
54
79
 
@@ -189,8 +214,18 @@ cd dsh-llm-rate-limiter
189
214
  # Install deps
190
215
  pnpm install
191
216
 
192
- # Run tests (19 tests)
193
- node test-strategies.mjs
217
+ # Run the full suite (300 assertions across 5 files)
218
+ pnpm test
219
+
220
+ # Individual suites
221
+ node test-strategies.mjs # 19 — rate-limit algorithms
222
+ node test-status-rpc.mjs # 61 — channel handler + counters
223
+ node test-client-bundle.mjs # 70 — real client bundle on a miniature React runtime
224
+ node test-host-integration.mjs # 77 — real apply() wiring
225
+ node test-status-route.mjs # 73 — self-registered route + the 0.1.5 regression
226
+
227
+ # Live HTTP proof against a real DSH install (real socket, real webserver)
228
+ pnpm run verify:live # 19 — exits 2 (skipped) when DSH is absent
194
229
 
195
230
  # Run E2E rate-limit test
196
231
  node test-3rpm.mjs
@@ -207,7 +242,8 @@ The plugin uses a live symlink when installed via `link:` — edits to `lib/` ta
207
242
 
208
243
  | DSH Version | Status | Notes |
209
244
  |-------------|--------|-------|
210
- | 0.1.x (RC) | ✅ Tested | Verified against 0.1.2-rc.1, cordis 4.0.2 |
245
+ | 0.1.2-rc.1 | ✅ Tested | Original target; `connection.rpc` carrier |
246
+ | 0.1.5-rc.3 | ✅ Tested | Requires the carrier fallback (see above); verified over real HTTP |
211
247
  | 0.2.x | ⚠️ Untested | May need API adjustments |
212
248
  | Cordis 5+ | ⚠️ Untested | Major version change likely requires rewrite |
213
249
 
package/lib/index.js CHANGED
@@ -21,7 +21,7 @@
21
21
  import { TokenBucketStrategy } from "./strategies/token-bucket.js";
22
22
  import { SlidingWindowStrategy } from "./strategies/sliding-window.js";
23
23
  import { RateLimiterConfig } from "./types/config.js";
24
- import { CHANNEL, createStatusChannel } from "./status-rpc.js";
24
+ import { CHANNEL, createChannelRoute, createStatusChannel } from "./status-rpc.js";
25
25
 
26
26
  const name = "llm-rate-limiter";
27
27
  const SETTINGS_NS = "llm-rate-limiter";
@@ -184,28 +184,90 @@ function apply(ctx) {
184
184
  }, "rate-limiter: settings scope teardown");
185
185
  });
186
186
 
187
- // ── Status RPC channel (dynamic inject) ────────────────────
187
+ // ── Status channel (dynamic inject) ────────────────────────
188
188
  // Same defensive shape as the settings wiring: on a host without a
189
189
  // connection service the callback never runs and the plugin stays silent.
190
- // The registration is owned by this fiber, so unloading withdraws it.
190
+ // Either path below registers inside an effect owned by this fiber, so
191
+ // unloading the plugin withdraws the channel automatically.
192
+ //
193
+ // Two mounting paths exist, and both speak the same wire protocol, so the
194
+ // browser half never needs to know which one is live:
195
+ //
196
+ // 1. connection.rpc.handle(channel, handler) — the framework's generic
197
+ // channel registry (preferred: the framework owns the route, the
198
+ // request validation, and the withdrawal).
199
+ //
200
+ // 2. a self-registered prefix route reusing connection.requestRejection()
201
+ // — the fallback for hosts where path 1 is broken. DSH 0.1.5-rc.3
202
+ // changed dsh-client-connection's own `inject` from
203
+ // ["webServer", "credentials"] to ["credentials"] while its
204
+ // HostConnectionService.register() still dereferences
205
+ // `owner.webServer`, and Cordis rebinds a cross-fiber service's `ctx`
206
+ // to the *reader's* fiber — so rpc.handle() throws
207
+ // 'cannot get property "webServer" without inject'. Registering the
208
+ // route on our own fiber (which injects webServer below) avoids that
209
+ // while still using the framework's own 403/401 fence.
191
210
  ctx.inject(["connection"], (scopedCtx) => {
192
211
  const connection = scopedCtx.get("connection");
212
+ const channel = createStatusChannel({ buildSnapshot, resetTotals });
213
+
214
+ // ── Path 1: the framework's generic channel registry ──
193
215
  const handle = typeof connection?.rpc?.handle === "function"
194
216
  ? connection.rpc.handle.bind(connection.rpc)
195
217
  : undefined;
196
- if (handle === undefined) return; // older host: degrade silently
197
218
 
219
+ if (handle !== undefined) {
220
+ try {
221
+ scopedCtx.effect(() => {
222
+ const unregister = handle(CHANNEL, channel);
223
+ return () => { unregister(); };
224
+ }, "rate-limiter: status rpc channel");
225
+ logger?.info("rate limiter status channel mounted via connection.rpc (%s)", CHANNEL);
226
+ return;
227
+ } catch (err) {
228
+ logger?.warn(
229
+ "connection.rpc channel registration failed (%s); falling back to a self-registered route",
230
+ err instanceof Error ? err.message : String(err),
231
+ );
232
+ }
233
+ }
234
+
235
+ // ── Path 2: self-registered route (needs webServer on THIS fiber) ──
236
+ // The fence is mandatory: without connection.requestRejection() we would
237
+ // be publishing an unauthenticated route, so we refuse to mount at all.
238
+ // Probing once here turns a broken fence into a mount-time refusal instead
239
+ // of a per-request surprise.
198
240
  try {
199
- scopedCtx.effect(() => {
200
- const unregister = handle(CHANNEL, createStatusChannel({ buildSnapshot, resetTotals }));
201
- return () => { unregister(); };
202
- }, "rate-limiter: status rpc channel");
241
+ if (typeof connection?.requestRejection !== "function") {
242
+ logger?.warn("connection.requestRejection unavailable; status channel not mounted");
243
+ return;
244
+ }
245
+ connection.requestRejection({ headers: {} });
203
246
  } catch (err) {
204
- logger?.warn("status channel registration failed: %s", err instanceof Error ? err.message : String(err));
247
+ logger?.warn(
248
+ "connection.requestRejection unusable (%s); refusing to mount an unfenced status route",
249
+ err instanceof Error ? err.message : String(err),
250
+ );
205
251
  return;
206
252
  }
207
253
 
208
- logger?.info("rate limiter status channel mounted (%s)", CHANNEL);
254
+ scopedCtx.inject(["webServer"], (webCtx) => {
255
+ const webServer = webCtx.get("webServer");
256
+ if (typeof webServer?.register !== "function") return; // no web carrier: degrade silently
257
+
258
+ try {
259
+ webCtx.effect(() => webServer.register(createChannelRoute({
260
+ channel: CHANNEL,
261
+ handler: channel,
262
+ reject: (req) => connection.requestRejection(req),
263
+ })), "rate-limiter: status route (self-registered)");
264
+ } catch (err) {
265
+ logger?.warn("status channel registration failed: %s", err instanceof Error ? err.message : String(err));
266
+ return;
267
+ }
268
+
269
+ logger?.info("rate limiter status channel mounted via self-registered route (%s)", CHANNEL);
270
+ });
209
271
  });
210
272
 
211
273
  // ── LLM stream interceptor ─────────────────────────────────
package/lib/status-rpc.js CHANGED
@@ -2,15 +2,27 @@
2
2
  * dsh-llm-rate-limiter — status RPC channel (Host half).
3
3
  *
4
4
  * Exposes live rate-limiter statistics to the browser over the framework's
5
- * generic Connection RPC channel — the same idiom dsh-context uses for its
6
- * detail channel. The framework supplies POST+JSON transport, the Host/Origin
7
- * fence (403) and browser authentication (401); this module only answers
8
- * endpoints and shapes envelopes.
5
+ * generic Connection RPC channel. The framework supplies POST+JSON transport,
6
+ * the Host/Origin fence (403) and browser authentication (401); this module
7
+ * only answers endpoints and shapes envelopes.
9
8
  *
10
- * Registration is owned by the calling fiber (`register()` wraps
11
- * `owner.webServer.register` in `owner.effect`), so unloading the plugin
12
- * withdraws the channel automatically — there is no connection bookkeeping
13
- * here.
9
+ * Two mounting paths exist, and both speak the *same* wire protocol so the
10
+ * browser half never needs to know which one is active:
11
+ *
12
+ * 1. `connection.rpc.handle(channel, handler)` — the framework's own generic
13
+ * channel registry. Preferred: the framework owns the route, the request
14
+ * validation, and the fiber-scoped withdrawal.
15
+ *
16
+ * 2. `createChannelRoute({ channel, handler, reject })` — a self-registered
17
+ * prefix route for hosts where path 1 is broken. DSH 0.1.5-rc.3 changed
18
+ * `@deepseek-ai/dsh-client-connection`'s own `inject` from
19
+ * `["webServer", "credentials"]` to `["credentials"]` while
20
+ * `HostConnectionService.register()` still dereferences
21
+ * `owner.webServer`, so `rpc.handle` throws
22
+ * `cannot get property "webServer" without inject`. This route is
23
+ * registered on our own fiber (which *can* see `webServer`) and reuses the
24
+ * connection service's public `requestRejection()` fence, so the 403/401
25
+ * policy is still the framework's.
14
26
  *
15
27
  * Envelope contract (mirrors the Connection wire schema):
16
28
  * success: { ok: true, value: <JSON-safe> }
@@ -25,6 +37,27 @@
25
37
  */
26
38
  const CHANNEL = "/llm-rate-limiter";
27
39
 
40
+ /**
41
+ * Endpoint grammar for one path segment, copied verbatim from the framework's
42
+ * `ENDPOINT_SEGMENT_PATTERN` in dsh-client-connection. Keeping it identical is
43
+ * what makes the self-registered route accept and reject exactly the same URLs
44
+ * as `rpc.handle` would.
45
+ */
46
+ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
47
+
48
+ /**
49
+ * Buffered-body cap for the self-registered route. Both endpoints are called
50
+ * with an empty payload, so this is a defensive memory bound rather than a
51
+ * real limit (the framework uses its own, much larger, carrier cap).
52
+ */
53
+ const MAX_REQUEST_BODY_BYTES = 1024 * 1024;
54
+
55
+ /**
56
+ * Correlation id echoed when the request envelope cannot be parsed at all —
57
+ * the same literal the framework uses (`INVALID_REQUEST_RPC_ID`).
58
+ */
59
+ const INVALID_REQUEST_RPC_ID = "invalid-request";
60
+
28
61
  /**
29
62
  * Build a failure envelope. Field-for-field the same shape the Connection
30
63
  * host accepts, so callers can rely on `ok` discrimination alone.
@@ -64,4 +97,189 @@ function createStatusChannel({ buildSnapshot, resetTotals }) {
64
97
  };
65
98
  }
66
99
 
67
- export { CHANNEL, createStatusChannel, failure, success };
100
+ /* ── self-registered route (the DSH 0.1.5-rc.3 fallback) ───────────────── */
101
+
102
+ /** Narrow an unknown JSON value to a plain object. */
103
+ function isRecord(value) {
104
+ return typeof value === "object" && value !== null && !Array.isArray(value);
105
+ }
106
+
107
+ /**
108
+ * Extract the endpoint from a channel-relative pathname, mirroring the
109
+ * framework's `endpointFromPath`: a single `channel/<endpoint>` segment whose
110
+ * segments all satisfy {@link ENDPOINT_SEGMENT_PATTERN}.
111
+ *
112
+ * @param {string} channel - the channel prefix (e.g. `/llm-rate-limiter`).
113
+ * @param {string} pathname - request pathname.
114
+ * @returns {string | undefined} the endpoint, or undefined when the URL is not
115
+ * a well-formed member of this channel.
116
+ */
117
+ function endpointFromPath(channel, pathname) {
118
+ if (!pathname.startsWith(`${channel}/`)) return undefined;
119
+ const endpoint = pathname.slice(channel.length + 1);
120
+ const segments = endpoint.split("/");
121
+ if (segments.some((segment) => segment === "" || segment === "." || segment === ".." || !ENDPOINT_SEGMENT_PATTERN.test(segment))) return undefined;
122
+ return endpoint;
123
+ }
124
+
125
+ /**
126
+ * Read and decode the request body, refusing anything above `limit` bytes so a
127
+ * hostile caller cannot make the host buffer without bound.
128
+ *
129
+ * @param {import("node:http").IncomingMessage} req - node request stream.
130
+ * @param {number} limit - maximum accepted byte count.
131
+ * @returns {Promise<string>} the decoded UTF-8 body.
132
+ * @throws {Error & { code: "TOO_LARGE" }} when the body exceeds `limit`.
133
+ */
134
+ async function readBody(req, limit) {
135
+ const chunks = [];
136
+ let received = 0;
137
+ for await (const chunk of req) {
138
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
139
+ received += buffer.byteLength;
140
+ if (received > limit) {
141
+ const error = new Error("request body too large");
142
+ error.code = "TOO_LARGE";
143
+ throw error;
144
+ }
145
+ chunks.push(buffer);
146
+ }
147
+ return Buffer.concat(chunks).toString("utf8");
148
+ }
149
+
150
+ /**
151
+ * Write one `server-response` envelope as JSON — byte-for-byte the shape
152
+ * `Response.json(...)` produces on the framework's own route.
153
+ *
154
+ * @param {import("node:http").ServerResponse} res - response to own.
155
+ * @param {string} rpcId - correlation id to echo.
156
+ * @param {object} result - the `{ ok, value }` / `{ ok, error }` envelope.
157
+ */
158
+ function sendEnvelope(res, rpcId, result) {
159
+ res.writeHead(200, { "content-type": "application/json" });
160
+ res.end(JSON.stringify({ type: "server-response", rpcId, result }));
161
+ }
162
+
163
+ /**
164
+ * Build a webserver prefix route that speaks the Connection RPC wire protocol.
165
+ *
166
+ * This is the fallback carrier for {@link createStatusChannel} on hosts where
167
+ * `connection.rpc.handle` throws. Every request-shape decision mirrors the
168
+ * framework's `rpcFetchHandler` so the browser half is byte-compatible with the
169
+ * preferred path:
170
+ *
171
+ * | condition | response |
172
+ * |---|---|
173
+ * | fence/auth rejected by `reject` | `403` (or `401`) + `forbidden`/`unauthorized` |
174
+ * | non-POST, or URL outside the channel | `404 not found` |
175
+ * | `content-type` not `application/json` | `415` |
176
+ * | body over {@link MAX_REQUEST_BODY_BYTES} | `413` |
177
+ * | body not JSON | `400 body is not JSON` |
178
+ * | envelope not a `client-request` | `gateway/bad-request` envelope |
179
+ * | `method` ≠ endpoint | `gateway/bad-request` envelope |
180
+ * | handler threw | `500 handler failure: ...` |
181
+ * | otherwise | `200` + `server-response` envelope |
182
+ *
183
+ * @param {object} deps
184
+ * @param {string} deps.channel - channel prefix to own (e.g. `/llm-rate-limiter`).
185
+ * @param {(endpoint: string, payload: unknown, signal?: AbortSignal) => Promise<object>} deps.handler - endpoint dispatcher.
186
+ * @param {(req: import("node:http").IncomingMessage) => number | undefined} deps.reject - the framework's `connection.requestRejection` fence; returns an HTTP status to refuse with, or undefined to allow.
187
+ * @returns {{ kind: "prefix", path: string, handler: (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => Promise<void> }}
188
+ */
189
+ function createChannelRoute({ channel, handler, reject }) {
190
+ return {
191
+ kind: "prefix",
192
+ path: channel,
193
+ handler: async (req, res) => {
194
+ // 1. The framework's own fence: Host/Origin trust (403) then browser
195
+ // authentication (401). Reusing it keeps the security policy identical.
196
+ const rejection = reject(req);
197
+ if (rejection !== undefined) {
198
+ res.writeHead(rejection);
199
+ res.end(rejection === 401 ? "unauthorized" : "forbidden");
200
+ return;
201
+ }
202
+
203
+ // 2. POST-only, and only inside this channel (framework: 404 otherwise).
204
+ const url = new URL(req.url ?? "/", "http://dsh.internal");
205
+ const endpoint = endpointFromPath(channel, url.pathname);
206
+ if (req.method !== "POST" || endpoint === undefined) {
207
+ res.writeHead(404);
208
+ res.end("not found");
209
+ return;
210
+ }
211
+
212
+ // 3. JSON only (framework: 415).
213
+ const mediaType = String(req.headers["content-type"] ?? "").split(";", 1)[0].trim().toLowerCase();
214
+ if (mediaType !== "application/json") {
215
+ res.writeHead(415);
216
+ res.end("content type must be application/json");
217
+ return;
218
+ }
219
+
220
+ // 4. Buffer the body under a defensive cap, then parse it.
221
+ let raw;
222
+ try {
223
+ raw = await readBody(req, MAX_REQUEST_BODY_BYTES);
224
+ } catch (error) {
225
+ if (error?.code === "TOO_LARGE") {
226
+ res.writeHead(413, { connection: "close" });
227
+ res.end();
228
+ req.destroy?.();
229
+ return;
230
+ }
231
+ throw error;
232
+ }
233
+ let body;
234
+ try {
235
+ body = JSON.parse(raw);
236
+ } catch {
237
+ res.writeHead(400);
238
+ res.end("body is not JSON");
239
+ return;
240
+ }
241
+
242
+ // 5. Validate the client-request envelope; echo the correlation id
243
+ // whenever it is a string, exactly like the framework does.
244
+ const rpcId = typeof body?.rpcId === "string" ? body.rpcId : INVALID_REQUEST_RPC_ID;
245
+ if (!isRecord(body) || body.type !== "client-request" || typeof body.rpcId !== "string" || typeof body.method !== "string") {
246
+ sendEnvelope(res, rpcId, failure("gateway/bad-request", "invalid client-request message", { issues: [] }));
247
+ return;
248
+ }
249
+ if (body.method !== endpoint) {
250
+ sendEnvelope(res, rpcId, failure(
251
+ "gateway/bad-request",
252
+ `method ${JSON.stringify(body.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
253
+ { issues: [] },
254
+ ));
255
+ return;
256
+ }
257
+
258
+ // 6. Dispatch. Tie the signal to the response lifetime, as the framework's
259
+ // http bridge does, so a dropped client can cancel the work.
260
+ const abort = new AbortController();
261
+ res.on?.("close", () => {
262
+ if (!res.writableEnded) abort.abort();
263
+ });
264
+ let result;
265
+ try {
266
+ result = await handler(endpoint, body.payload, abort.signal);
267
+ } catch (error) {
268
+ res.writeHead(500);
269
+ res.end(`handler failure: ${String(error)}`);
270
+ return;
271
+ }
272
+ sendEnvelope(res, rpcId, result);
273
+ },
274
+ };
275
+ }
276
+
277
+ export {
278
+ CHANNEL,
279
+ MAX_REQUEST_BODY_BYTES,
280
+ createChannelRoute,
281
+ createStatusChannel,
282
+ endpointFromPath,
283
+ failure,
284
+ success,
285
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leaf233/dsh-llm-rate-limiter",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Per-model LLM call rate limiter for DeepSeek Harness with queue support and a live status panel",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -21,13 +21,15 @@
21
21
  },
22
22
  "compatibility": {
23
23
  "dshReleases": {
24
- "0.1.2-rc.1": "compatible"
24
+ "0.1.2-rc.1": "compatible",
25
+ "0.1.5-rc.3": "compatible"
25
26
  }
26
27
  }
27
28
  },
28
29
  "scripts": {
29
30
  "build": "echo 'no build needed'",
30
- "test": "node test-strategies.mjs"
31
+ "test": "node test-strategies.mjs && node test-status-rpc.mjs && node test-client-bundle.mjs && node test-host-integration.mjs && node test-status-route.mjs",
32
+ "verify:live": "node tools/verify-live-route.mjs"
31
33
  },
32
34
  "repository": {
33
35
  "type": "git",