@skillpie/dsh-remote 0.0.0-stage → 1.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/README.md CHANGED
@@ -1,3 +1,96 @@
1
- # Temporary Holding Version
1
+ # dsh-remote
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DeepSeek Harness(DSH)插件:像 ZCodeOnline 远程协助一样,用 **8 位远程码** 在浏览器里访问任意一台装有本插件的 DSH 宿主机 —— 打开 `https://dsh.skillpie.cn/12345678/` 即得到**该宿主机完整的真实 Web GUI**(会话、工具渲染、交付物、终端、设置无一裁剪),而不是另做一套简化前端。
4
+
5
+ 纯插件形态:只装在 profile 里,**不改任何 DSH 源码**,官方 DSH 升级后直接可用。
6
+
7
+ ## 工作模型
8
+
9
+ ```
10
+ 宿主机(你自己的 DSH + dsh-remote) 中转站(dsh.skillpie.cn 的 DSH + relay.enabled)
11
+ │ 出站 wss /dsh-remote/host ──────────────▶ /<code>/ 与 /<code>/api/remote.mux 随注册挂载
12
+ │ (无需公网 IP/端口) │
13
+ └──────── 字节级桥接 ◀─────────────────────┴── 访客浏览器 https://dsh.skillpie.cn/12345678/
14
+ ```
15
+
16
+ - **GUI 透传(`mode: 'gui'`,默认)**:中转面把 `/<code>/…` 的 HTTP 请求剥掉前缀后经控制通道转发给宿主机;宿主机侧的 GUI 桥用进程内 launch token 自动铸造 owner 会话 cookie,对自己的 loopback web 服务完成请求;响应(含流式内容)原样回传。GUI 唯一的多路 WebSocket `/api/remote.mux` 按帧拼接(JSON 文本帧与二进制帧双向透传)。
17
+ - **身份与会话**:远程码/宿主 ID/token 持久化在凭据记录 `dsh-remote/identity`(重启不漂移);token 只在中转站存哈希,重连凭 token 顶替旧路由,fail-closed 自愈。
18
+ - **多宿主机隔离**:同一中转域名下,每台宿主机挂在自己的 `/<code>/` 前缀;owner cookie 由宿主机侧持有并逐请求注入,**不落到访客浏览器**,访客之间也互不可见。
19
+ - **入口**:GUI 左侧栏底部(账号区上方)的「远程控制」按钮,点击弹窗进入属主面板;侧栏菜单不再有独立入口。侧栏收起时按钮自动变为纯图标。
20
+ - **`mode: 'console'`(可选)**:轻量只读/控制页(会话列表、消息流、发送/中断),供想要最小暴露面或低带宽的场景使用。
21
+ - **直连模式(`direct: true`,默认开)**:同一套桥接同时挂在本机 web 服务上,`http://<host>:<port>/<code>/` 可用——局域网使用与本地联调都走它。
22
+
23
+ ## 安装
24
+
25
+ 本机(宿主面,默认即可,装完重启 GUI):
26
+
27
+ ```text
28
+ 在 DSH 的插件管理器安装本目录即可,无需配置。
29
+ ```
30
+
31
+ 服务器(dsh.skillpie.cn 等,官方 npm 形态 DSH):安装同一包,并在该 profile 补丁层覆盖:
32
+
33
+ ```yaml
34
+ - id: dsh-remote
35
+ name: dsh-remote
36
+ config:
37
+ enabled: false # 服务器自身不必外连别的中转站
38
+ direct: false
39
+ relay:
40
+ enabled: true
41
+ mode: gui # 默认值;console 为轻量页
42
+ maxVisitorsPerHost: 16
43
+ ```
44
+
45
+ 中转面复用 DSH 自带的 web 服务(nginx 只需透传 Host 与 WebSocket 升级),根路径 `/<code>/` 就绪,无需额外部署单元。
46
+
47
+ ## 配置
48
+
49
+ | 键 | 默认 | 说明 |
50
+ | --- | --- | --- |
51
+ | `mode` | `gui` | 访客体验:`gui` 真实 GUI 透传 / `console` 轻量页 |
52
+ | `enabled` | `true` | 宿主面总开关(外连中转站) |
53
+ | `relayUrl` | `wss://dsh.skillpie.cn` | 中转站 origin |
54
+ | `allowControl` | `true` | 仅 console 模式:`false` 为只读;gui 模式下见下方安全说明 |
55
+ | `direct` | `true` | 本机 web 服务挂 `/<code>/` 直连路由 |
56
+ | `code` | 自动生成 | 8 位远程码(1-9 开头),可手动指定 |
57
+ | `hostId` / `token` | 自动 | 运行身份,自动持久化,勿手改 |
58
+ | `name` | 主机名 | 设备名(属主面板/中转面显示) |
59
+ | `relay.enabled` | `false` | **服务器侧**启用中转面 |
60
+ | `relay.mode` | `gui` | 中转面给访客的体验(同上) |
61
+ | `relay.maxVisitorsPerHost` | `8` | 单宿主机并发访客/拼接上限 |
62
+
63
+ ## 地址栏隐私(远程码不落 URL)
64
+
65
+ 打开 `https://dsh.skillpie.cn/12345678/` 后,页面脚本会把远程码存入浏览器(localStorage + sessionStorage)、立刻用 `history.replaceState` 把地址栏改写为 `https://dsh.skillpie.cn/`,并把 `<base>` 钉到隧道前缀保证资源继续加载。同时注册一个 origin 级 Service Worker(`/dsh-remote-sw.js`):之后直接访问裸域名也会自动恢复到对应宿主机的隧道,**全程不需要把码拼回 URL**。
66
+
67
+ - 转发/收藏裸域名即可再次进入(同一浏览器);`?gui` 参数可跳过恢复(例如访客想看中转站自己的 GUI 或排除故障)。
68
+ - 携带中转站属主会话 cookie(`dsh-auth-*`)的浏览器不受 SW 影响,裸域名始终是中转站自己的 GUI。
69
+ - 宿主机离线时,SW 回源得到的是离线占位页,不会渲染任何宿主内容。
70
+ - 清除站点数据(或换浏览器)后裸域名不再自动恢复,需要原始带码链接重新进入。
71
+ - 码以明文存在访客浏览器 localStorage 中——这是为「无需重复输入」做的权衡;公用电脑请勿勾选记住,或用后清除站点数据。
72
+
73
+ ## 访客文件下载
74
+
75
+ 远程访问时,文件预览页的打开方式菜单(LibreOffice/WorkBuddy/… 那个)会在**最前面**多出一项「下载文件」:点击后把宿主机磁盘上的该文件经隧道流回访客浏览器,由浏览器弹出保存框存到本地——GUI 本身的菜单只能把路径交给宿主机桌面,远程访客原本无从取回生成的文件(xlsx/pdf/任意类型均可)。
76
+
77
+ - 路径来自预览页头的完整路径显示;页面找不到路径时,退用最近一次通过 mux 请求过的文件路径;都拿不到会提示「无法定位文件」。
78
+ - 下载路由 `/dsh-remote-file` 挂在宿主机自己的 web 服务上,仅接受 GET/HEAD,经隧道访问时由宿主会话 cookie 保护;另外校验一个由宿主 hostId 派生的密钥(`x-dsh-remote-key`,自动注入),防止本机回环的其他进程任意读文件。只允许下载普通文件,单文件上限 512MB。
79
+ - 仅访客态注入该菜单项,宿主机本地 GUI 不受影响。
80
+
81
+ ## 安全(重要)
82
+
83
+ - **gui 模式下访客拥有与属主等价的完整 GUI 权限**(可见所有会话、可操作界面、含设置页)。远程码就是分享授权:**只把链接给你信任的人**;不再需要时用属主面板「重新生成远程码」立即作废旧链接(浏览器缓存的旧码会失效,SW 恢复到离线占位页)。
84
+ - 远程码/宿主 token:token 只存哈希;owner 会话 cookie 只在宿主机进程内持有并逐请求注入,不进入访客浏览器。
85
+ - 每台宿主机限制并发访客;请求体、帧大小、空闲时间均有上限;帧处理器带异常边界,任何桥接错误只断开对应连接,不会影响宿主进程。
86
+ - 局限:gui 模式是多宿主共享入口,属主面板的「在线访客」仅统计拼接连接;首次加载资源走隧道一次(之后浏览器缓存生效)。Service Worker 需要安全上下文(https 或 localhost),LAN 明文 http 下仅地址栏摘除生效,无自动恢复。
87
+
88
+ ## 验证(本仓库)
89
+
90
+ - `node test/smoke.mjs` — 轻量控制台模式(注册/欢迎/历史/发送/中断/白名单/双通道扇出)
91
+ - `node test/gui-smoke.mjs` — GUI 透传模式(302 挂载、cookie 仅宿主侧、POST 透传、资源代理、WS 拼接多帧 echo、离线页)
92
+ - `node test/live-check.mjs [origin] [code]` — 对真实宿主进程的只读冒烟(welcome/会话列表/observe)
93
+
94
+ ## License
95
+
96
+ MIT
@@ -0,0 +1,11 @@
1
+ # dsh-remote bundle patch.
2
+ #
3
+ # One dual-role row: the same apply() wires the host role (outbound relay
4
+ # client, visitor command engine, owner panel API, optional local direct
5
+ # routes) and, when config.relay.enabled is true, the relay role (registry,
6
+ # /<code> console routes, host control-channel upgrade) for a server-side
7
+ # deployment such as dsh.skillpie.cn.
8
+ - insert:
9
+ - id: dsh-remote
10
+ name: "@skillpie/dsh-remote"
11
+ config: {}
package/icon.svg ADDED
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
2
+ <!-- dsh-remote: one host radiating an encrypted link to a visitor dot -->
3
+ <rect x="4" y="4" width="56" height="56" rx="14" fill="currentColor" opacity="0.12"/>
4
+ <circle cx="22" cy="42" r="7" fill="currentColor"/>
5
+ <path d="M32 32a14.5 14.5 0 0 1 10 4.2" stroke="currentColor" stroke-width="4" stroke-linecap="round" opacity="0.55"/>
6
+ <path d="M37 24a24 24 0 0 1 12.5 6.5" stroke="currentColor" stroke-width="4" stroke-linecap="round" opacity="0.35"/>
7
+ <circle cx="46" cy="24" r="5.5" fill="currentColor" opacity="0.85"/>
8
+ <g fill="currentColor" opacity="0.6">
9
+ <rect x="12" y="12" width="5" height="5" rx="1.5"/>
10
+ <rect x="20" y="12" width="5" height="5" rx="1.5"/>
11
+ <rect x="28" y="12" width="5" height="5" rx="1.5"/>
12
+ <rect x="36" y="12" width="5" height="5" rx="1.5"/>
13
+ </g>
14
+ </svg>
package/lib/bridge.js ADDED
@@ -0,0 +1,377 @@
1
+ /**
2
+ * dsh-remote — host-side GUI bridge (`gui` mode).
3
+ *
4
+ * Serves the relay's splice frames from this process's own web server over
5
+ * loopback, so a visitor at `https://<relay>/<code>/…` uses the real DSH Web
6
+ * GUI — sessions, tools, deliverables, the mux WebSocket — exactly as the
7
+ * owner does locally.
8
+ *
9
+ * Authentication: the bridge mints one owner session cookie from the process
10
+ * launch token (`connection.authenticatedUrl`) and attaches it to every
11
+ * loopback request; a 401 forces one re-mint. Request headers that would
12
+ * otherwise break the loopback trust fence (`origin`, `referer`, `sec-*`,
13
+ * `host`, hop-by-hop) are stripped, leaving a plain owner request.
14
+ */
15
+ import WebSocket from 'ws'
16
+ import { encodeBin, decodeBin } from './protocol.js'
17
+
18
+ const MAX_REQUEST_BODY = 64 * 1024 * 1024
19
+ const READ_TIMEOUT_MS = 30_000
20
+ const STRIP_HEADERS = new Set([
21
+ 'host', 'origin', 'referer', 'referrer', 'connection', 'keep-alive',
22
+ 'transfer-encoding', 'upgrade', 'accept-encoding', 'content-length',
23
+ 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions',
24
+ 'sec-websocket-accept', 'sec-websocket-protocol',
25
+ ])
26
+
27
+ /** ws message data (string | Buffer | Buffer[] | ArrayBuffer) → Buffer. */
28
+ function toBuffer(data) {
29
+ if (Buffer.isBuffer(data)) return data
30
+ if (Array.isArray(data)) return Buffer.concat(data)
31
+ if (data instanceof ArrayBuffer) return Buffer.from(new Uint8Array(data))
32
+ return Buffer.from(String(data), 'utf8')
33
+ }
34
+
35
+ /** Case-insensitive header bag → plain object without stripped names. */
36
+ function sanitizeHeaders(headers = {}) {
37
+ const out = {}
38
+ for (const [name, value] of Object.entries(headers)) {
39
+ const lower = name.toLowerCase()
40
+ if (STRIP_HEADERS.has(lower) || lower.startsWith('sec-')) continue
41
+ out[lower] = Array.isArray(value) ? value.join(', ') : String(value)
42
+ }
43
+ return out
44
+ }
45
+
46
+ export class GuiBridge {
47
+ /**
48
+ * @param services { webServer(): object|undefined, connection(): object|undefined }
49
+ * @param log fn(kind, text)
50
+ */
51
+ constructor({ services, log = () => {} }) {
52
+ this.services = services
53
+ this.log = log
54
+ /** channel → { channel, offMessage, offClose } */
55
+ this.channels = new Map()
56
+ this.cookieHeader = undefined
57
+ this.httpHandlers = new Map()
58
+ this.wsPipes = new Map()
59
+ this.seq = 0
60
+ this.closed = false
61
+ }
62
+
63
+ attach(channel) {
64
+ this.detach(channel)
65
+ const record = { channel, offMessage: undefined, offClose: undefined }
66
+ this.channels.set(channel, record)
67
+ record.offMessage = channel.onMessage((text) => {
68
+ try {
69
+ this.onFrame(text, channel)
70
+ } catch (error) {
71
+ this.log('bridge', `帧处理异常(已隔离): ${error?.message ?? error}`)
72
+ }
73
+ })
74
+ record.offClose = channel.onClose(() => this.detach(channel))
75
+ this.log('bridge', 'GUI 桥已挂载')
76
+ }
77
+
78
+ /** Remove one transport; undefined removes all (plugin unload). */
79
+ detach(channel) {
80
+ const targets = channel === undefined ? [...this.channels.keys()] : [channel]
81
+ for (const target of targets) {
82
+ const record = this.channels.get(target)
83
+ if (!record) continue
84
+ this.channels.delete(target)
85
+ record.offMessage?.()
86
+ record.offClose?.()
87
+ }
88
+ if (channel === undefined) {
89
+ for (const handler of this.httpHandlers.values()) handler.abort?.()
90
+ this.httpHandlers.clear()
91
+ for (const pipe of this.wsPipes.values()) {
92
+ try { pipe.inner.close() } catch { pipe.inner.terminate() }
93
+ }
94
+ this.wsPipes.clear()
95
+ }
96
+ }
97
+
98
+ send(frame) {
99
+ for (const record of this.channels.values()) record.channel.send(frame)
100
+ }
101
+
102
+ origin() {
103
+ const webServer = this.services.webServer?.()
104
+ if (!webServer) return undefined
105
+ try { return webServer.getOrigin() } catch { return undefined }
106
+ }
107
+
108
+ onFrame(data, channel) {
109
+ if (this.closed) return
110
+ if (typeof data !== 'string') {
111
+ const decoded = decodeBin(Buffer.isBuffer(data) ? data : Buffer.from(data))
112
+ if (!decoded) return
113
+ const { meta, payload } = decoded
114
+ if (meta.t === 'hchunk') this.onRequestChunk(meta.id, payload, channel)
115
+ else if (meta.t === 'wdata') this.onWsData(meta.id, payload, channel)
116
+ return
117
+ }
118
+ let frame
119
+ try { frame = JSON.parse(data) } catch { return }
120
+ if (!frame || typeof frame !== 'object') return
121
+ switch (frame.t) {
122
+ case 'hreq': this.onHttpRequest(frame, channel); return
123
+ case 'hend': this.onRequestEnd(frame.id); return
124
+ case 'habort': this.onRequestAbort(frame.id); return
125
+ case 'wopen': this.onWsOpen(frame, channel); return
126
+ case 'wclose': this.onWsClose(frame); return
127
+ case 'wdata-text': this.onWsText(frame, channel); return
128
+ case 'hb': return
129
+ default: return
130
+ }
131
+ }
132
+
133
+ /** Mux wire frames are JSON text: forward them as socket text frames. */
134
+ onWsText(frame, channel) {
135
+ if (typeof frame.id !== 'string' || typeof frame.text !== 'string') return
136
+ const pipe = this.wsPipes.get(frame.id)
137
+ if (!pipe) return
138
+ if (pipe.inner.readyState === 1) {
139
+ pipe.inner.send(frame.text)
140
+ return
141
+ }
142
+ if (pipe.inner.readyState === 0) {
143
+ pipe.backlogBytes += frame.text.length
144
+ if (pipe.backlogBytes <= 8 * 1024 * 1024) {
145
+ pipe.backlog.push(frame.text)
146
+ return
147
+ }
148
+ }
149
+ channel.send({ t: 'wclose', id: frame.id, code: 1011, reason: 'inner not open' })
150
+ pipe.closed = true
151
+ this.wsPipes.delete(frame.id)
152
+ try { pipe.inner.terminate() } catch { /* gone */ }
153
+ }
154
+
155
+ onRequestAbort(id) {
156
+ const entry = this.httpHandlers.get(id)
157
+ if (!entry) return
158
+ entry.markEnded()
159
+ entry.abort?.()
160
+ this.httpHandlers.delete(id)
161
+ }
162
+
163
+ async ensureSession(force = false) {
164
+ if (!force && this.cookieHeader) return this.cookieHeader
165
+ const connection = this.services.connection?.()
166
+ const origin = this.origin()
167
+ if (!connection || !origin) throw new Error('connection 服务不可用')
168
+ const url = connection.authenticatedUrl(origin + '/')
169
+ const controller = new AbortController()
170
+ const timer = setTimeout(() => controller.abort(), READ_TIMEOUT_MS * 1000)
171
+ try {
172
+ const res = await fetch(url, { redirect: 'manual', signal: controller.signal })
173
+ const cookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []
174
+ const pairs = cookies.map((line) => line.split(';')[0]).filter((pair) => pair.includes('='))
175
+ if (pairs.length === 0) throw new Error('宿主机未签发会话 cookie')
176
+ this.cookieHeader = pairs.join('; ')
177
+ this.log('bridge', '已铸造访客会话 cookie')
178
+ return this.cookieHeader
179
+ } finally {
180
+ clearTimeout(timer)
181
+ }
182
+ }
183
+
184
+ async onHttpRequest(frame, channel) {
185
+ const { id, m, p } = frame
186
+ if (typeof id !== 'string' || typeof m !== 'string' || typeof p !== 'string') return
187
+ const entry = { chunks: [], size: 0, ended: false, abort: undefined }
188
+ // wss 路径上 hchunk/hend 是 hreq 之后的独立帧;fetch 必须等 body 到齐
189
+ // (pipe 同步送达时侥幸掩盖过这个时序问题)。
190
+ entry.markEnded = () => {
191
+ entry.ended = true
192
+ try { entry.readyResolve?.() } catch { /* noop */ }
193
+ }
194
+ entry.ready = new Promise((resolve) => { entry.readyResolve = resolve })
195
+ entry.channel = channel
196
+ this.httpHandlers.set(id, entry)
197
+ try {
198
+ const origin = this.origin()
199
+ if (!origin) throw new Error('webServer 服务不可用')
200
+ let cookie = await this.ensureSession()
201
+ // body 就绪后再发 fetch:hend 之前 entry.chunks 可能还是空的。
202
+ await Promise.race([
203
+ entry.ready,
204
+ new Promise((resolve) => setTimeout(resolve, 30_000)),
205
+ ])
206
+ let status
207
+ let res
208
+ for (let attempt = 0; attempt < 2; attempt++) {
209
+ const headers = { ...sanitizeHeaders(frame.h), cookie }
210
+ if (entry.size > 0) headers['content-length'] = String(entry.size)
211
+ const controller = new AbortController()
212
+ entry.abort = () => controller.abort()
213
+ const timer = setTimeout(() => controller.abort(), 120_000)
214
+ try {
215
+ res = await fetch(origin + p, {
216
+ method: m,
217
+ headers,
218
+ body: m === 'GET' || m === 'HEAD' ? undefined : Buffer.concat(entry.chunks),
219
+ signal: controller.signal,
220
+ duplex: 'half',
221
+ })
222
+ } finally {
223
+ clearTimeout(timer)
224
+ }
225
+ status = res.status
226
+ if (status !== 401) break
227
+ // Session expired or invalid: mint a fresh one and retry once.
228
+ cookie = await this.ensureSession(true)
229
+ }
230
+ const outHeaders = {}
231
+ res.headers.forEach((value, name) => {
232
+ const lower = name.toLowerCase()
233
+ if (lower === 'set-cookie' || lower === 'transfer-encoding' || lower === 'connection' || lower === 'content-encoding') return
234
+ outHeaders[lower] = value
235
+ })
236
+ const setCookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []
237
+ channel.send({ t: 'hres', id, s: res.status, h: outHeaders, sc: setCookies })
238
+ if (res.body) {
239
+ for await (const chunk of res.body) {
240
+ if (this.closed || !this.httpHandlers.has(id)) return
241
+ channel.sendRaw(encodeBin({ t: 'hchunk', id }, Buffer.from(chunk)))
242
+ }
243
+ }
244
+ channel.send({ t: 'hend', id })
245
+ } catch (error) {
246
+ channel.send({ t: 'hfail', id, error: error?.message ?? String(error) })
247
+ } finally {
248
+ this.httpHandlers.delete(id)
249
+ }
250
+ }
251
+
252
+ onRequestChunk(id, payload) {
253
+ const entry = this.httpHandlers.get(id)
254
+ if (!entry || entry.ended) return
255
+ entry.size += payload.length
256
+ if (entry.size > MAX_REQUEST_BODY) {
257
+ entry.markEnded()
258
+ entry.channel?.send({ t: 'hfail', id, error: 'request body too large' })
259
+ return
260
+ }
261
+ entry.chunks.push(payload)
262
+ }
263
+
264
+ onRequestEnd(id) {
265
+ const entry = this.httpHandlers.get(id)
266
+ if (entry) entry.markEnded()
267
+ }
268
+
269
+ async onWsOpen(frame, channel) {
270
+ const { id, p } = frame
271
+ if (typeof id !== 'string' || typeof p !== 'string') return
272
+ const origin = this.origin()
273
+ if (!origin) {
274
+ channel.send({ t: 'wfail', id, error: 'webServer 服务不可用' })
275
+ return
276
+ }
277
+ // The mux WS often races the first HTTP request (cached document), so
278
+ // mint the owner session here too; a stale cookie re-mints once on 401.
279
+ let cookie
280
+ try {
281
+ cookie = await this.ensureSession()
282
+ } catch (error) {
283
+ channel.send({ t: 'wfail', id, error: `会话铸造失败: ${error?.message ?? error}` })
284
+ return
285
+ }
286
+ let inner
287
+ try {
288
+ inner = new WebSocket(origin + p, {
289
+ headers: {
290
+ ...sanitizeHeaders(frame.h),
291
+ cookie,
292
+ },
293
+ handshakeTimeout: 10_000,
294
+ maxPayload: 64 * 1024 * 1024,
295
+ })
296
+ } catch (error) {
297
+ channel.send({ t: 'wfail', id, error: error?.message ?? String(error) })
298
+ return
299
+ }
300
+ const pipe = { inner, closed: false, channel, backlog: [], backlogBytes: 0 }
301
+ this.wsPipes.set(id, pipe)
302
+ inner.on('open', () => {
303
+ if (pipe.closed) return
304
+ // The visitor may have raced ahead of the inner handshake; replay now.
305
+ const backlog = pipe.backlog.splice(0)
306
+ pipe.backlogBytes = 0
307
+ for (const item of backlog) {
308
+ if (inner.readyState !== 1) break
309
+ try { inner.send(typeof item === 'string' ? item : Buffer.from(item)) } catch { /* peer state raced */ }
310
+ }
311
+ channel.send({ t: 'wok', id })
312
+ this.log('bridge', `实时通道已拼接 ${p}`)
313
+ })
314
+ inner.on('message', (data, isBinary) => {
315
+ try {
316
+ if (pipe.closed || this.closed) return
317
+ if (isBinary) {
318
+ channel.sendRaw(encodeBin({ t: 'wdata', id }, toBuffer(data)))
319
+ return
320
+ }
321
+ const text = typeof data === 'string' ? data : toBuffer(data).toString('utf8')
322
+ channel.send({ t: 'wdata-text', id, text })
323
+ } catch (error) {
324
+ this.log('bridge', `origin 帧处理异常(已隔离): ${error?.message ?? error}`)
325
+ }
326
+ })
327
+ inner.on('close', (code, reason) => {
328
+ if (pipe.closed) return
329
+ pipe.closed = true
330
+ this.wsPipes.delete(id)
331
+ channel.send({ t: 'wclose', id, code: code ?? 1005, reason: reason?.toString?.() ?? '' })
332
+ })
333
+ inner.on('unexpected-response', (_req, res) => {
334
+ if (pipe.closed) return
335
+ if (res.statusCode === 401 && this.cookieHeader === cookie) {
336
+ // Stale owner session: drop it so the next attempt re-mints.
337
+ this.cookieHeader = undefined
338
+ }
339
+ })
340
+ inner.on('error', (error) => {
341
+ if (pipe.closed) return
342
+ pipe.closed = true
343
+ this.wsPipes.delete(id)
344
+ channel.send({ t: 'wfail', id, error: error?.message ?? String(error) })
345
+ })
346
+ }
347
+
348
+ onWsData(id, payload, channel) {
349
+ const pipe = this.wsPipes.get(id)
350
+ if (!pipe) return
351
+ if (pipe.inner.readyState === 1) {
352
+ pipe.inner.send(payload)
353
+ return
354
+ }
355
+ if (pipe.inner.readyState === 0) {
356
+ // Still handshaking with the loopback origin: hold frames in order.
357
+ pipe.backlogBytes += payload.length
358
+ if (pipe.backlogBytes <= 8 * 1024 * 1024) {
359
+ pipe.backlog.push(payload)
360
+ return
361
+ }
362
+ }
363
+ channel.send({ t: 'wclose', id, code: 1011, reason: 'inner not open' })
364
+ pipe.closed = true
365
+ this.wsPipes.delete(id)
366
+ try { pipe.inner.terminate() } catch { /* gone */ }
367
+ }
368
+
369
+ onWsClose(frame) {
370
+ const pipe = this.wsPipes.get(frame.id)
371
+ if (!pipe) return
372
+ pipe.closed = true
373
+ this.wsPipes.delete(frame.id)
374
+ try { pipe.inner.close(frame.code ?? 1000, frame.reason ?? '') } catch { pipe.inner.terminate() }
375
+ }
376
+
377
+ }