@ntdgy/dsh-remote-bridge-plugin 0.0.0-stage → 0.1.0

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/lib/index.js ADDED
@@ -0,0 +1,535 @@
1
+ /**
2
+ * dsh-remote-bridge —— DSH host 侧插件。
3
+ *
4
+ * 职责:
5
+ * 1. 主动出站连接到公网网关(DSH → VPS,VPS 永不主动连入)
6
+ * 2. 自证身份(同进程内直接使用 sessionController,不走 HTTP、不碰 token)
7
+ * 3. 只响应白名单 RPC,绝不透传任意请求
8
+ *
9
+ * 硬性纪律见 docs/decisions/ADR-002-no-http-fallback.md。
10
+ */
11
+ import { Service } from '@deepseek-ai/cordis';
12
+ import z from '@deepseek-ai/schemastery';
13
+ import { RelayClient } from "./relay/client.js";
14
+ import { Dispatcher } from "./relay/dispatcher.js";
15
+ import { forwardToUpstream } from "./relay/http-forward.js";
16
+ import { WsForwarder } from "./relay/ws-forward.js";
17
+ import { collectRegisteredPrefixes } from "./relay/route-observer.js";
18
+ /**
19
+ * 读取路由表,空则短暂重试。
20
+ *
21
+ * 为什么要重试:插件激活顺序不保证 —— 我们在其它插件注册路由之前就可能发 hello,
22
+ * 那一刻 exact/prefixes/upgrades 都是空的。实测 hello 在 :16,dsh-market 的路由在 :17 才有。
23
+ *
24
+ * 上限 3 秒、每 100ms 一次:够覆盖正常激活抖动,又不会让 hello 无限延迟
25
+ * (hello 迟发会拖慢整条链路的可用时间)。
26
+ */
27
+ async function collectWithRetry(webServer) {
28
+ let report = collectRegisteredPrefixes(webServer);
29
+ const deadline = Date.now() + 3000;
30
+ while (report.observed === 0 && Date.now() < deadline) {
31
+ // 必须是**异步**等待:别的插件的注册回调要靠事件循环才跑得起来,
32
+ // 同步忙等只会把 loop 堵死,路由表永远补不上(我第一版就是这么写的)。
33
+ await new Promise((resolve) => setTimeout(resolve, 100));
34
+ report = collectRegisteredPrefixes(webServer);
35
+ }
36
+ return report;
37
+ }
38
+ import { encodeWsDataFrame } from '@ntdgy/dsh-remote-proto';
39
+ /**
40
+ * 解析 DSH webServer 的本机 origin(ADR-005 转发目标)。
41
+ *
42
+ * 拿不到时返回 undefined —— 调用方必须回明确的 upstream_unreachable,
43
+ * 而不是静默失败(否则前端只会看到超时,不知道该去查什么)。
44
+ */
45
+ function resolveUpstreamOrigin(ctx) {
46
+ // webServer 不在 inject 里、因此这里必须能接受 undefined ——
47
+ // 完整理由见 inject 定义处的大段注释(唯一事实源,避免两处注释再次漂移)。
48
+ const webServer = ctx.get('webServer');
49
+ if (!webServer?.getOrigin)
50
+ return undefined;
51
+ try {
52
+ return webServer.getOrigin();
53
+ }
54
+ catch {
55
+ // getOrigin 在 listen 之前会 throw,此时转发能力确实不可用。
56
+ return undefined;
57
+ }
58
+ }
59
+ /**
60
+ * 取本实例的**已认证**应用 URL,上报给网关代持 DSH 会话。
61
+ *
62
+ * DSH 的 `connection` 服务暴露 `authenticatedUrl(baseUrl)`:把本进程当前的
63
+ * launch token 拼进干净的 URL。token **每次 DSH 重启都会变**,且绑定 DSH
64
+ * 看到的 authority —— 只有 DSH 自己知道,网关拼不出来。所以由插件上报。
65
+ *
66
+ * 拿不到就返回 undefined(不是错误):网关会退化为「用户自行带 ?token=」,
67
+ * 功能不受影响,只是需要手动登录。
68
+ */
69
+ function resolveLaunchUrl(ctx) {
70
+ // 逐层返回原因而不是笼统的 undefined —— 上一版只说「不可用」,
71
+ // 排查时不得不再翻一遍代码才知道是哪一步断的。
72
+ // strict=false:cordis 的 ctx.get(name, strict=true) 会要求目标服务的 fiber
73
+ // 处于**就绪态**(state === 2),否则当成不存在。实测我们这里拿到 undefined
74
+ // 就是这个原因 —— connection 已注册但 fiber 尚未就绪(激活时序早于它)。
75
+ // 非严格模式拿到的可能是「已注册但未就绪」的实例,这里用得上:
76
+ // authenticatedUrl 只依赖已构造好的 BrowserAuth,不要求服务已发布。
77
+ const connection = ctx.get('connection', false);
78
+ if (connection === undefined)
79
+ return { reason: 'ctx.get(connection, false) 仍返回 undefined' };
80
+ if (typeof connection.authenticatedUrl !== 'function') {
81
+ return { reason: 'connection.authenticatedUrl 不是函数(DSH 版本可能不支持)' };
82
+ }
83
+ const origin = resolveUpstreamOrigin(ctx);
84
+ if (origin === undefined) {
85
+ return { reason: 'webServer.getOrigin() 不可用(webServer 未注入或尚未 listen)' };
86
+ }
87
+ try {
88
+ const url = connection.authenticatedUrl(origin);
89
+ if (typeof url !== 'string' || !/^https?:\/\//.test(url)) {
90
+ return { reason: 'authenticatedUrl 返回值不是绝对 http(s) URL: ' + String(url) };
91
+ }
92
+ return { url };
93
+ }
94
+ catch (error) {
95
+ return { reason: 'authenticatedUrl 抛错: ' + String(error) };
96
+ }
97
+ }
98
+ import { FollowRegistry } from "./relay/follow.js";
99
+ /**
100
+ * 运行期校验 sessionController 的**行为面**,而不是相信一次类型断言。
101
+ *
102
+ * 背景(T14):原来这里写的是 `ctx.get('sessionController') as unknown as DshFollowSource`。
103
+ * 断言把「服务可能不在」这一事实从类型系统里抹掉了,于是 undefined 被赋给
104
+ * FollowRegistry.source,直到第一次 follow 才炸成
105
+ * "Cannot read properties of undefined (reading 'follow')"——离故障点很远,极难定位。
106
+ *
107
+ * 现在改为按**方法**校验:只要这不是一个带 follow() 的对象就立刻拒绝。
108
+ *
109
+ * T18 教训(**守卫比缺陷更严格**):这里最初写的是 `!'follow' in value`。
110
+ * 服务的常见形态是只实现 get 的惰性代理,`in` 走默认 [[Has]] 落在空 target 上恒为 false,
111
+ * 于是**健康服务**被判成装配错误,history 100% 不可用——守卫自己成了新的单点障碍。
112
+ *
113
+ * 规则:守卫必须比它守护的缺陷更**宽松**,且检查方式必须与被检查对象的**形态**匹配。
114
+ * 对代理/惰性对象要用 `typeof x.prop` 取真实属性值,不要用 `in` / `hasOwnProperty`。
115
+ */
116
+ function isFollowSource(value) {
117
+ if (value === null || typeof value !== 'object')
118
+ return false;
119
+ // typeof 会真实触发一次 get:对惰性代理同样可靠,且比 `in` 宽松。
120
+ return typeof value.follow === 'function';
121
+ }
122
+ export const name = 'remote-bridge';
123
+ /**
124
+ * 只注入我们真正需要的服务。connection 用于兜底自证,sessionController 是主路径。
125
+ *
126
+ * cordis 语义(@deepseek-ai/cordis 4.0.5,fiber.ts:611-623 `_refresh`):
127
+ * 任一 inject 服务缺失,fiber 停在 PENDING —— 不报错、不加载、不打日志。
128
+ * 也就是说"服务名写错"的表现是**插件静默不工作**,而不是启动失败。
129
+ * 因此服务名必须与实装一致(0.2.1-rc.2:`super(ctx, "sessionController")` lib/index.js:2952,
130
+ * `connection` lib/index.js:710),任何服务名调整都要同时改这里。
131
+ *
132
+ * ── 为什么 webServer **不在这里**(I-3)────────────────────────────────
133
+ *
134
+ * 它是**可选能力**,不是主能力。JS 侧三条使用点全部是防御式读取:
135
+ * - resolveUpstreamOrigin / collectHttpPrefixes:ctx.get('webServer'),取不到即降级;
136
+ * - 状态路由:走 ctx.inject(['webServer'], …),本来就是可选注入。
137
+ * 既然没有任何一条路径依赖它"必须就绪",把它写进 inject 只会带来一个后果:
138
+ * 在**没有 web 服务的 profile** 里整插件停在 PENDING(cordis 语义:任一 inject 缺失
139
+ * 即不加载、不报错、不打日志)。那等于用一个可选能力换掉主能力——
140
+ * 窄 RPC 会一起消失,而且**没有任何日志**说明为什么。
141
+ *
142
+ * 这正是本文件历史上踩过的坑:同一段代码上方曾有过一条"webServer 不在 inject 里,
143
+ * 这是刻意的"的注释,而 inject 里却真的写着 webServer —— 注释与代码互相矛盾,
144
+ * 直到有部署在没有 webServer 的环境里静默不工作才可能暴露。
145
+ * 现在两者一致:**inject 里每一项都必须被主路径无条件需要**,可选能力一律走 ctx.inject。
146
+ *
147
+ * 代价(明确接受):在非 web profile 里 ctx.get('webServer') 返回 undefined,
148
+ * 转发与状态路由不可用。这是可接受的:没有 web 服务时本来也没有 GUI 可转发。
149
+ * 调用方会回明确的 upstream_unreachable / 打印 "webServer unavailable",前端能看到原因。
150
+ */
151
+ export const inject = ['connection', 'sessionController'];
152
+ export default class RemoteBridge extends Service {
153
+ static Config = z.object({
154
+ relayUrl: z.string().required().description('公网网关的 WebSocket 地址,例如 wss://dsh.example.com/agent'),
155
+ instanceId: z.string().required().description('本实例的唯一标识'),
156
+ instanceKey: z.string().required().role('secret').description('与网关共享的密钥,用于 HMAC 挑战应答'),
157
+ reconnectBaseMs: z.natural().default(3000).description('重连初始退避'),
158
+ reconnectMaxMs: z.natural().default(60_000).description('重连退避上限'),
159
+ heartbeatIntervalMs: z.natural().default(15_000).description('心跳间隔(毫秒)'),
160
+ });
161
+ // 由 ctx.effect 在插件激活时创建、卸载时销毁;PENDING 期间不持有任何 socket。
162
+ client;
163
+ /**
164
+ * 面板用的运行时快照(只读)。
165
+ *
166
+ * 全部取自既有状态,不新增采集:连接态、断线原因、各类计数都在这里汇合,
167
+ * 供 /bridge-plugin/status 路由读取。
168
+ */
169
+ panelState = { registered: false, reconnects: 0 };
170
+ /**
171
+ * 以下三个引用只用于**观测**(诊断/测试),不参与业务逻辑。
172
+ *
173
+ * T14 的教训:dispatcher / follows 曾完全是 ctx.effect 闭包里的局部量,
174
+ * 插件实例上完全不可达,导致 "Cannot read properties of undefined (reading 'follow')"
175
+ * 这类问题无法从实例外部观察,只能靠复刻缩小范围。暴露成只读字段是为了让同类
176
+ * 问题下次能一眼看到。
177
+ */
178
+ dispatcherRef;
179
+ followsRegistry;
180
+ config;
181
+ constructor(ctx, config) {
182
+ // 注意:cordis 4.0.5 的 Service 基类构造函数只有 (ctx, name),没有
183
+ // config 形参(lib/types/service.d.ts:38),config 归插件自己的签名所有。
184
+ // 归一化由 cordis 在**喂构造参数之前**完成(resolveConfig 在 module 作用域,
185
+ // 对任何 class 插件只调用一次,与是否继承 Service 无关)。
186
+ // 服务名带项目命名空间:'remoteBridge' 已被 @linxin666/dsh-remote-bridge 占用,
187
+ // 两者独立开发、无共享命名空间,同名会让后注册者直接失败。
188
+ super(ctx, 'dshRemoteBridge');
189
+ this.config = config;
190
+ // 最后一道闸:绝不能带着空凭据静默出站。
191
+ //
192
+ // schemastery 的 object schema 把「缺字段」当作「未提供」而不是错误:
193
+ // validate({}) 会返回 { reconnectBaseMs: 3000, ... },**静默丢掉**
194
+ // relayUrl / instanceId / instanceKey。一旦配置层再以空对象喂一次,
195
+ // 插件就会带着空 instanceId 去握手,网关 4401 拒绝、插件无限重连,
196
+ // 而全链路没有任何一条日志——P1 实机验收正是卡在这里。
197
+ // 显式抛错,让它在 dsh 启动诊断里立刻可见,而不是静默空转。
198
+ for (const key of ['relayUrl', 'instanceId', 'instanceKey']) {
199
+ if (typeof config?.[key] !== 'string' || config[key] === '') {
200
+ // ⚠️ 绝不能把整个 config 打进错误信息:instanceKey 是凭据,
201
+ // 审查实测过它会被完整打印到日志/异常里。只报缺了哪个字段。
202
+ throw new Error(`remote-bridge: config.${key} is required but missing or empty`);
203
+ }
204
+ }
205
+ // 生命周期必须挂在 ctx.effect 上,否则热重载会泄漏 socket 与在途请求。
206
+ ctx.effect(() => {
207
+ const lifecycle = new AbortController();
208
+ // client 与 registry 互相引用:帧出口需要 client,client 的 sub-control
209
+ // 回调需要 dispatcher。出口在订阅真实产帧时才会被调用(晚于本函数返回),
210
+ // 因此这里用延迟绑定,避免为构造顺序引入 null 检查。
211
+ let client;
212
+ // 帧流注册表(D1/D4)。
213
+ //
214
+ // 两条出口必须分别接线,**不能合并**:FollowRegistry 的 onFrame 只承载
215
+ // subscribe() 泵出的数据帧(snapshot / event / assistant-stream),终止信号
216
+ // 由 subscribe() 的 onEnd 回调投递,注册表**不会**把 RpcFrameEnd 交给 onFrame。
217
+ // 曾经只接 onFrame,结果是除非正常泵完,否则浏览器永远收不到 frame-end。
218
+ //
219
+ // 服务解析:惰性 + 校验(治本 + 保底)。
220
+ //
221
+ // 惰性:不在 effect 立即执行体里抓快照,每次取用都重新 ctx.get,
222
+ // 这样服务在 PENDING 期间出现、或热重载换实现,都能拿到当下有效的对象。
223
+ //
224
+ // 这里**不缓存**解析结果:T18 的教训是任何"装配期抓一次"的东西都会成为
225
+ // 新的形态假设(缓存住一个后来被替换/尚未就绪的对象)。ctx.get 本身很便宜。
226
+ const resolveSource = () => {
227
+ const candidate = ctx.get('sessionController');
228
+ // 注意:这里**不再**记录历史结果。
229
+ // 面板需要的是「现在能不能用」,而这里是「本次解析是否成功」——
230
+ // 两者语义不同。面板改为每次请求现探(见 probeSessionController)。
231
+ if (!isFollowSource(candidate)) {
232
+ throw new Error('remote-bridge: sessionController is unavailable (ctx.get returned ' +
233
+ `${candidate === undefined ? 'undefined' : typeof candidate}` +
234
+ '); cannot follow sessions. This usually means the inject dependency was not ready.');
235
+ }
236
+ return candidate;
237
+ };
238
+ // 装配体:所有需要"活服务"的动作都推迟到第一次真正使用。
239
+ //
240
+ // 为什么要推迟而不是在这里直接 new FollowRegistry(resolveSource(), …):
241
+ // effect body 的执行时机**可能早于 inject 解析完成**(T14 那个缺陷正是
242
+ // 这种时序的表现)。构造期调用 resolveSource() 会把"此刻服务还没好"
243
+ // 变成一次启动崩溃,等于把守卫挪到最脆弱的位置。
244
+ // 装配体本身是纯数据,不碰任何服务;第一个碰服务的人是 followStream 里的 resolveSource()。
245
+ const follows = new FollowRegistry({
246
+ follow: (request, signal) => resolveSource().follow(request, signal),
247
+ }, (frame) => {
248
+ client.sendFrame(frame);
249
+ });
250
+ this.followsRegistry = follows;
251
+ // ADR-005 T34:WS 升级转发器。sink 直接走 client 的同一通道,
252
+ // 保证 WS 数据帧与 HTTP/窄 RPC 共用一条出站连接(C7)。
253
+ const wsForwarder = new WsForwarder(() => resolveUpstreamOrigin(ctx), {
254
+ ack: (id, ok, error) => client.sendControl({ kind: 'ws-open-ack', id, ok, error }),
255
+ data: (id, opcode, payload) => client.sendBinary(encodeWsDataFrame(id, opcode, payload)),
256
+ close: (id, code, reason, from) => client.sendControl({ kind: 'ws-close', id, code, reason, from }),
257
+ });
258
+ /** 订阅并保证终止信号一定送到浏览器(ADR-004:frame-end 必须显式发送)。 */
259
+ const subscribe = (sessionId, subscriptionId) => {
260
+ follows.subscribe({ sessionId, subscriptionId }, (end) => {
261
+ client.sendFrameEnd(end);
262
+ });
263
+ };
264
+ const dispatcher = new Dispatcher(ctx, config.instanceId, lifecycle.signal, follows, subscribe);
265
+ this.dispatcherRef = dispatcher;
266
+ client = new RelayClient({
267
+ url: config.relayUrl,
268
+ instanceId: config.instanceId,
269
+ instanceKey: config.instanceKey,
270
+ reconnectBaseMs: config.reconnectBaseMs ?? 3000,
271
+ reconnectMaxMs: config.reconnectMaxMs ?? 60_000,
272
+ heartbeatIntervalMs: config.heartbeatIntervalMs ?? 15_000,
273
+ onRequest: (req) => dispatcher.handle(req),
274
+ // ADR-005:HTTP 转发。用 webServer 的 origin 反向请求本机 loopback。
275
+ // 拿不到 origin(webServer 未注入/未监听)时明确回报,不静默失败。
276
+ // ADR-005 运行时自声明:hello 时读 DSH webServer 已注册路由,归并成前缀上报。
277
+ // 读不到就上报空数组(网关侧按「未声明」处理并打印提示),绝不猜测。
278
+ // 只读状态路由:面板取数用。注册在真实 webServer 上,
279
+ // 因此 T37 的运行时自声明会自动把它算进前缀(顺带验证那个机制对自家插件也生效)。
280
+ // 代持 DSH 会话所需的已认证 URL。每次 hello 现取 —— token 随 DSH 重启变化。
281
+ collectLaunchUrl: () => {
282
+ const resolved = resolveLaunchUrl(ctx);
283
+ if (resolved.url === undefined) {
284
+ process.stderr.write('remote-bridge: 未取到已认证应用 URL:' + String(resolved.reason) +
285
+ ';网关将退化为要求用户自行带 ?token= 登录。\n');
286
+ }
287
+ return resolved.url;
288
+ },
289
+ collectHttpPrefixes: async () => {
290
+ const webServer = ctx.get('webServer');
291
+ // 时序:本插件可能在其它插件之前激活,此刻路由表还是空的。
292
+ // 因此先读一次;若为空,短暂重试等待别人注册完(总时长有界)。
293
+ // 这是实测暴露的:hello 发生在 08:10:16,而 dsh-market 的路由在 :17 才就绪。
294
+ const report = await collectWithRetry(webServer);
295
+ // 同时写 stderr:ctx.logger 落到 DSH 自己的日志系统,
296
+ // 排查时用 tail -f 启动输出看不到,容易误以为「什么都没发生」。
297
+ if (report.observed === 0) {
298
+ process.stderr.write('remote-bridge: 未观测到已注册路由(' + report.diagnostic + ')\n');
299
+ }
300
+ if (report.observed === 0) {
301
+ // 带上 webServer 是否存在、三张表的实际类型与条数 —— 只打「未观测到」
302
+ // 无法区分「服务没注入」与「别人还没注册」,会让排查回到翻代码。
303
+ ctx.logger?.warn?.('remote-bridge: 未观测到任何已注册的 HTTP 路由(' + report.diagnostic +
304
+ ');插件路由将不可经网关访问。');
305
+ }
306
+ for (const r of report.rejected) {
307
+ ctx.logger?.warn?.('remote-bridge: 拒绝上报前缀 ' + r.value + '(' + r.reason + ')');
308
+ }
309
+ return report.accepted;
310
+ },
311
+ onHttpRequest: async (frame, body) => {
312
+ const origin = resolveUpstreamOrigin(ctx);
313
+ if (!origin) {
314
+ return {
315
+ frame: {
316
+ kind: 'http-response-error',
317
+ id: frame.id,
318
+ code: 'upstream_unreachable',
319
+ message: 'DSH webServer is not reachable from the plugin (no origin available)',
320
+ },
321
+ };
322
+ }
323
+ return forwardToUpstream(origin, frame, body);
324
+ },
325
+ // ADR-005 T34:WS 升级转发。与 HTTP 共用同一条出站连接。
326
+ onWsOpen: (frame) => wsForwarder.open(frame),
327
+ onWsFlow: (id, action) => wsForwarder.setFlow(id, action),
328
+ onWsClose: (id, code, reason) => wsForwarder.closeUpstream(id, code, reason),
329
+ onWsData: (id, opcode, payload) => wsForwarder.toUpstream(id, opcode, payload),
330
+ onOpen: (connId) => {
331
+ this.panelState.registered = true;
332
+ this.panelState.connectedAt = Date.now();
333
+ if (this.panelState.lastClosedAt !== undefined)
334
+ this.panelState.reconnects += 1;
335
+ ctx.logger.info('remote-bridge: registered as %s (connection %s)', config.instanceId, connId);
336
+ },
337
+ onClose: (reason, code) => {
338
+ // 面板要能反映「为什么断了」,所以这里记原因与时刻(不记凭据)。
339
+ this.panelState.registered = false;
340
+ this.panelState.lastCloseReason = reason + (code >= 0 ? ' (code ' + code + ')' : '');
341
+ this.panelState.lastClosedAt = Date.now();
342
+ // 本地自止(ADR-004 纪律 2)。
343
+ //
344
+ // sub-control 是"网关主动要求终止",socket close 是"对方已不在":
345
+ // 两者意图相同但触发源不同。前者**不可靠**——连接已断就收不到,
346
+ // 所以不能把清理寄托在网关的通知上。插件断连后网关卡 dropInstance
347
+ // 已作废全部订阅,重连后继续泵的每一帧都会被 audit 成 frame_dropped,
348
+ // 等于拿 DSH 侧的 follow 资源烧孤儿帧。这里 abort 是主动止损,不是冗余。
349
+ //
350
+ // 幂等:订阅 end() 只收口一次,与 onSubscriptionControl 可能的重叠调用是 no-op。
351
+ dispatcher.abortAllSubscriptions();
352
+ // I-4:上游 WS 也要在这里收口。
353
+ //
354
+ // 上游 socket 的 close 事件确实也会走到 closeUpstream,所以这不是"永久泄漏";
355
+ // 但它依赖对端**先**关,而我们断开网关后上游两端都已无人驱动——
356
+ // 上游连接会多活一段时间(取决于上游自己的 keepalive),期间白占 DSH 侧资源。
357
+ // 主动收口把"迟早会清"变成"现在就清"。
358
+ wsForwarder.closeAll();
359
+ if (code === 4408) {
360
+ ctx.logger.warn('remote-bridge: heartbeat timeout, gateway closed the connection: %s', reason);
361
+ return;
362
+ }
363
+ ctx.logger.warn('remote-bridge: disconnected: %s', reason);
364
+ },
365
+ onProtocolWarning: (detail) => ctx.logger.warn('remote-bridge: %s', detail),
366
+ // I-5:发送缓冲爆掉、本端主动断连。与网络故障分开报,否则会去查错方向。
367
+ onBackpressure: (buffered) => ctx.logger.error('remote-bridge: outbound buffer overflow (%d bytes) — the gateway is not draining; reconnecting', buffered),
368
+ // 认证被拒不等于网络故障:密钥配错时网络是好的,必须让人一眼看出来。
369
+ // 文案只用网关给的 code/message,永不回显 instanceKey。
370
+ onAuthFailure: (code, message) => ctx.logger.error('remote-bridge: handshake rejected by gateway (%s): %s — check instanceKey for instance %s', code, message, config.instanceId),
371
+ // 网关的退订指令(控制帧,非业务 RPC):定位到订阅即 abort。
372
+ onSubscriptionControl: (subscriptionId, reason) => {
373
+ if (!dispatcher.cancelSubscription(subscriptionId, reason)) {
374
+ ctx.logger.warn('remote-bridge: sub-control for unknown subscription %s (%s)', subscriptionId, reason);
375
+ }
376
+ },
377
+ });
378
+ this.client = client;
379
+ client.start();
380
+ // ── 只读状态路由(面板取数)────────────────────────────────────────
381
+ //
382
+ // 用 ctx.inject 而不是在 effect 里 ctx.get:实测在 effect 体内
383
+ // ctx.get('webServer') 返回 undefined(即使 webServer 已在 inject 列表里),
384
+ // inject 回调拿到的 context 才保证该服务已就绪 —— dshmarket 也是这么写的。
385
+ // 注册后 T37 的运行时自声明会读到它,从而自动放行 /bridge-plugin/。
386
+ ctx.inject(['webServer'], (hostCtx) => {
387
+ const webServer = hostCtx.get('webServer');
388
+ if (!webServer || typeof webServer.register !== 'function') {
389
+ // 不静默:面板会表现为「打不开状态」,没有这条很难查。
390
+ process.stderr.write('remote-bridge: webServer unavailable; /bridge-plugin/status not registered\n');
391
+ return;
392
+ }
393
+ hostCtx.effect(() => {
394
+ const dispose = webServer.register?.({
395
+ kind: 'exact',
396
+ path: '/bridge-plugin/status',
397
+ handler: (req, res) => {
398
+ const response = res;
399
+ const method = req?.method ?? 'GET';
400
+ // I-6:只读状态只回应 GET / HEAD。
401
+ //
402
+ // 这条路由**不在网关的放行白名单里**(prefix-registry.ts),公网到不了,
403
+ // 所以真实风险只是"本机其他进程可读"。因此这里不引入鉴权,
404
+ // 只把"不限方法"收成 405 —— 一个只读端点没有理由接受 PUT/DELETE,
405
+ // 而允许它们会让人误以为它是可写接口。
406
+ if (method !== 'GET' && method !== 'HEAD') {
407
+ response.writeHead(405, {
408
+ 'content-type': 'application/json; charset=utf-8',
409
+ allow: 'GET, HEAD',
410
+ 'cache-control': 'no-store',
411
+ });
412
+ // HEAD 不该有 body;405 对 HEAD 也不会发生(上面已放行),这里只处理其余方法。
413
+ response.end(JSON.stringify({ error: 'method_not_allowed', allow: ['GET', 'HEAD'] }));
414
+ return;
415
+ }
416
+ const body = JSON.stringify(this.buildStatusPayload(ctx, config));
417
+ response.writeHead(200, {
418
+ 'content-type': 'application/json; charset=utf-8',
419
+ 'cache-control': 'no-store',
420
+ });
421
+ // HEAD:头照发、body 不发(语义要求)。
422
+ response.end(method === 'HEAD' ? '' : body);
423
+ },
424
+ });
425
+ return () => dispose?.();
426
+ }, 'remote-bridge: status route');
427
+ });
428
+ return () => {
429
+ this.client = undefined;
430
+ this.dispatcherRef = undefined;
431
+ this.followsRegistry = undefined;
432
+ // 连接断开即 abort 全部订阅(ADR-004 纪律 2):结构性保证,不靠调用方记得。
433
+ dispatcher.abortAllSubscriptions();
434
+ // I-4:插件卸载同样要收口上游 WS。与 onClose 里的那次是两处独立触发点,
435
+ // 都由 closeAll() 自身的幂等性兜住(已删的 id 再关一次是 no-op)。
436
+ wsForwarder.closeAll();
437
+ client.stop();
438
+ // 卸载即取消一切在途 DSH 调用,避免请求落到已销毁的插件上。
439
+ lifecycle.abort();
440
+ };
441
+ }, 'remote-bridge: relay lifecycle');
442
+ }
443
+ /** 供调试/测试读取当前连接状态(已收到 hello 回执才算就绪)。 */
444
+ get connected() {
445
+ return this.client?.ready ?? false;
446
+ }
447
+ /**
448
+ * 组装面板所需的只读状态。
449
+ *
450
+ * 七项全部取自既有运行态,**不新增采集、不猜值**:
451
+ * 连接状态 / instanceId + relayUrl / 心跳间隔 / authFailures /
452
+ * droppedFrames + 已上报前缀 / 断线原因 / sessionControllerReady
453
+ *
454
+ * 安全:绝不包含 instanceKey 或任何凭据。
455
+ */
456
+ buildStatusPayload(ctx, config) {
457
+ const stats = this.client?.stats;
458
+ const webServer = ctx.get('webServer');
459
+ // 已上报前缀现读一次:与 hello 时上报的是同一份计算,不会与线上不一致。
460
+ const report = collectRegisteredPrefixes(webServer);
461
+ return {
462
+ /** 1. 连接状态:只有收到 hello.auth 回执才算 registered。 */
463
+ connection: {
464
+ registered: this.panelState.registered,
465
+ connected: this.client?.ready ?? false,
466
+ connectedAt: this.panelState.connectedAt ?? null,
467
+ reconnects: this.panelState.reconnects,
468
+ },
469
+ /** 2. 身份与网关地址(不含任何密钥)。 */
470
+ identity: {
471
+ instanceId: config.instanceId,
472
+ relayUrl: config.relayUrl,
473
+ },
474
+ /** 3. 心跳间隔(毫秒):网关 hello 回执的权威值。 */
475
+ heartbeat: {
476
+ intervalMs: stats?.heartbeatIntervalMs ?? config.heartbeatIntervalMs ?? null,
477
+ },
478
+ /** 4. 认证失败次数(密钥配错的直接信号)。 */
479
+ authFailures: stats?.authFailures ?? 0,
480
+ /** 5. 丢弃帧数 + 已上报前缀。 */
481
+ droppedFrames: stats?.droppedFrames ?? 0,
482
+ httpPrefixes: {
483
+ reported: report.accepted,
484
+ observed: report.observed,
485
+ },
486
+ /** 6. 断线原因(最近一次)。 */
487
+ lastClose: {
488
+ reason: this.panelState.lastCloseReason ?? null,
489
+ at: this.panelState.lastClosedAt ?? null,
490
+ },
491
+ /** 7. sessionController 是否可用(T14 的 ready 探针)。 */
492
+ // 现探一次,而不是读「上次调用是否成功」。
493
+ //
494
+ // 探针的语义是「现在能不能用」:运维打开面板时服务可能是坏的,也可能从没被调用过。
495
+ // 用历史值会在「从未调用」时恒为 false,把人误导成「服务坏了」——
496
+ // 那是错误信息,比没信息更糟。
497
+ sessionControllerReady: this.probeSessionController(ctx),
498
+ /** 面板自身的时间戳,用于判断数据新鲜度。 */
499
+ sampledAt: Date.now(),
500
+ };
501
+ }
502
+ /** 只读诊断:当前 dispatcher(插件未激活时为 undefined)。 */
503
+ get dispatcher() {
504
+ return this.dispatcherRef;
505
+ }
506
+ /** 只读诊断:当前帧流注册表(插件未激活时为 undefined)。 */
507
+ get follows() {
508
+ return this.followsRegistry;
509
+ }
510
+ /**
511
+ * 只读诊断:sessionController 当下是否可解析且具备 follow()。
512
+ *
513
+ * 不抛错——诊断接口不该在健康检查时炸掉调用方。
514
+ */
515
+ /**
516
+ * 即刻探测 sessionController 是否可用。
517
+ *
518
+ * 每次调用都真实取一次服务并检查形状,不复用历史结果 ——
519
+ * 面板要回答的是「现在能不能用」。
520
+ */
521
+ probeSessionController(ctx) {
522
+ try {
523
+ return isFollowSource(ctx.get('sessionController'));
524
+ }
525
+ catch {
526
+ // 取服务本身抛错也算不可用,但绝不让它把面板请求打挂。
527
+ return false;
528
+ }
529
+ }
530
+ /** 供调试/测试读取当前 sessionController 可用性(实时探测)。 */
531
+ get sessionControllerReady() {
532
+ return isFollowSource(this.ctx.get('sessionController'));
533
+ }
534
+ }
535
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 会话数据归一化。
3
+ *
4
+ * 标题的真实来源(已按 dsh 0.2.1-rc.2 实装核对,见下):
5
+ * `@deepseek-ai/dsh-session-title` 通过 module augmentation 向
6
+ * `SessionProjectionMap` 注册了 `title: string | null`
7
+ * (dsh-session-title/lib/types/types.d.ts:91-100),
8
+ * 而 sessionController 的列表行在 live 与 cold 两条路径上都会带上它:
9
+ *
10
+ * - live :projectionsFor(header, session)
11
+ * → sessionProjections.cachedSnapshot(session) // 纯投影读
12
+ * - cold :projectionsFor(header, undefined)
13
+ * → sessionProjectionCache.cachedSnapshot(header)
14
+ * ?? cachedPredecessorTitle(header) // 纯缓存读
15
+ * (dsh-api-session-controller/lib/index.js:2092-2103 / 1941 / 1991)
16
+ *
17
+ * 关键结论:这两条路径都**不激活 Agent**,
18
+ * 所以 `sessionTitle.get(session)` 那种“要活对象”的写法
19
+ * (design-revision.md D2)既不是唯一解,也不该用。
20
+ * 列表只读 `projections.values.title`,N6(list 永不激活 Agent)因此自然成立。
21
+ *
22
+ * 注意:这里读的是 `projections.values`,不是 `projections` 本身。
23
+ * `projections` 是 SessionProjectionHints `{ kind, asOfSeq, values }`
24
+ * (dsh-api-session-controller/lib/types/types.d.ts:52-65),
25
+ * 基线代码在 `projections` 顶层找 title,因此恒为 null。
26
+ *
27
+ * 冷会话在投影缓存尚未写入时仍会拿不到标题——此时回退 null 并如实呈现,
28
+ * 绝不为取标题去 resume 会话。
29
+ */
30
+ import type { SessionListItem } from '@ntdgy/dsh-remote-proto';
31
+ /** DSH 原始 SessionSummary 的最小形状(只取我们需要的字段)。 */
32
+ interface RawSessionProjectionHints {
33
+ readonly kind?: 'cached' | 'sequenced';
34
+ readonly asOfSeq?: number;
35
+ readonly values?: Record<string, unknown>;
36
+ }
37
+ interface RawSessionSummary {
38
+ sessionId: string;
39
+ updatedAt: number;
40
+ running: boolean;
41
+ projections?: RawSessionProjectionHints;
42
+ }
43
+ export declare function normalizeSessionList(items: readonly RawSessionSummary[]): SessionListItem[];
44
+ /**
45
+ * 从 SessionProjectionHints.values.title 取标题。
46
+ *
47
+ * 只认 DSH 自己注册的 `title` key(字符串或 null);
48
+ * 其它形状一律当作“暂无标题”,不做猜测性展开——
49
+ * 猜测会让前端拿到不属于该会话的标题,比 null 更糟。
50
+ */
51
+ export declare function extractTitle(projections?: RawSessionProjectionHints): string | null;
52
+ export {};
@@ -0,0 +1,25 @@
1
+ /** `SessionProjectionHints.values` 里我们唯一消费的 key。 */
2
+ const TITLE_PROJECTION_KEY = 'title';
3
+ export function normalizeSessionList(items) {
4
+ return items.map((item) => ({
5
+ id: item.sessionId,
6
+ title: extractTitle(item.projections),
7
+ updatedAt: item.updatedAt,
8
+ running: item.running,
9
+ }));
10
+ }
11
+ /**
12
+ * 从 SessionProjectionHints.values.title 取标题。
13
+ *
14
+ * 只认 DSH 自己注册的 `title` key(字符串或 null);
15
+ * 其它形状一律当作“暂无标题”,不做猜测性展开——
16
+ * 猜测会让前端拿到不属于该会话的标题,比 null 更糟。
17
+ */
18
+ export function extractTitle(projections) {
19
+ const value = projections?.values?.[TITLE_PROJECTION_KEY];
20
+ if (typeof value !== 'string')
21
+ return null;
22
+ const trimmed = value.trim();
23
+ return trimmed.length > 0 ? trimmed : null;
24
+ }
25
+ //# sourceMappingURL=sessions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessions.js","sourceRoot":"","sources":["../../src/local/sessions.ts"],"names":[],"mappings":"AA+BA,oDAAoD;AACpD,MAAM,oBAAoB,GAAG,OAAO,CAAA;AAgBpC,MAAM,UAAU,oBAAoB,CAAC,KAAmC;IACtE,OAAO,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QAC1B,EAAE,EAAE,IAAI,CAAC,SAAS;QAClB,KAAK,EAAE,YAAY,CAAC,IAAI,CAAC,WAAW,CAAC;QACrC,SAAS,EAAE,IAAI,CAAC,SAAS;QACzB,OAAO,EAAE,IAAI,CAAC,OAAO;KACtB,CAAC,CAAC,CAAA;AACL,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,YAAY,CAAC,WAAuC;IAClE,MAAM,KAAK,GAAG,WAAW,EAAE,MAAM,EAAE,CAAC,oBAAoB,CAAC,CAAA;IACzD,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAA;IAC1C,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAA;IAC5B,OAAO,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAA;AAC5C,CAAC"}