@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.
@@ -0,0 +1,307 @@
1
+ /**
2
+ * 单条帧流的时间预算。
3
+ *
4
+ * 空闲超时(收到上一条帧之后多久没有新帧)与生存上限(一个订阅最长活多久)
5
+ * 是两件事,必须分开:前者防僵尸订阅,后者防会话长期被一个订阅钉住。
6
+ * 取值都远大于一次正常 turn 的时长,误杀风险留给 frame-end 的 reason 体现。
7
+ */
8
+ export const FOLLOW_IDLE_TIMEOUT_MS = 30 * 60_000;
9
+ export const FOLLOW_MAX_LIFETIME_MS = 2 * 60 * 60_000;
10
+ /** cursor 缓存上限与 TTL(D4:避免无限增长)。 */
11
+ export const CURSOR_CACHE_MAX_ENTRIES = 512;
12
+ export const CURSOR_CACHE_TTL_MS = 10 * 60_000;
13
+ /**
14
+ * 订阅注册表。
15
+ *
16
+ * 一个实例持有一个 registry;transport 在连接断开时调 `abortAll()`,
17
+ * 插件卸载时同理——这样"连接断开即 abort"是结构性保证,不靠调用方记得。
18
+ */
19
+ export class FollowRegistry {
20
+ source;
21
+ cursors = new CursorCache();
22
+ active = new Map();
23
+ onFrame;
24
+ /**
25
+ * @param source - DSH sessionController(或同形状的替身,便于单测)。
26
+ * @param onFrame - 帧出口。返回值被忽略;出口自身决定这条帧投给哪个浏览器连接。
27
+ */
28
+ constructor(source, onFrame) {
29
+ this.source = source;
30
+ this.onFrame = onFrame;
31
+ }
32
+ get size() {
33
+ return this.active.size;
34
+ }
35
+ /**
36
+ * 订阅一个会话。
37
+ *
38
+ * @param target - 会话 id 与派生出的 subscriptionId。
39
+ * @param onEnd - 流终止时回调一次(正常结束、被 abort、出错都走这里)。
40
+ * @returns 句柄;调用方必须保存它以便退订,
41
+ * 同时应当在连接关闭时调用 `abortAll()`。
42
+ */
43
+ subscribe(target, onEnd) {
44
+ const { sessionId, subscriptionId } = target;
45
+ // 同一 subscriptionId 重复订阅直接拒绝:订阅号唯一性由 ADR-004 保证,
46
+ // 允许覆盖会让旧订阅的 abort 落在新订阅上。
47
+ if (this.active.has(subscriptionId)) {
48
+ throw new Error(`subscription already active: ${subscriptionId}`);
49
+ }
50
+ const controller = new AbortController();
51
+ const lifetime = AbortSignal.timeout(FOLLOW_MAX_LIFETIME_MS);
52
+ const stop = this.combine(controller.signal, lifetime);
53
+ let closed = false;
54
+ let seq = 0;
55
+ const finish = (reason, message) => {
56
+ if (closed)
57
+ return;
58
+ closed = true;
59
+ this.active.delete(subscriptionId);
60
+ onEnd({
61
+ kind: 'frame-end',
62
+ subscriptionId,
63
+ reason,
64
+ ...(message === undefined
65
+ ? {}
66
+ : { error: { code: mapFollowErrorCode(reason), message } }),
67
+ });
68
+ };
69
+ const subscription = {
70
+ subscriptionId,
71
+ controller,
72
+ get closed() {
73
+ return closed;
74
+ },
75
+ end(reason) {
76
+ if (closed)
77
+ return;
78
+ // 先 abort DSH 侧,让 for await 立刻退出;finish 在 pump 的 finally 里只会跑一次。
79
+ controller.abort();
80
+ finish(reason);
81
+ },
82
+ };
83
+ this.active.set(subscriptionId, subscription);
84
+ void this.pump(sessionId, subscriptionId, stop, (frame) => {
85
+ seq += 1;
86
+ this.onFrame({ kind: 'frame', subscriptionId, seq, frame });
87
+ }).then(() => finish('completed'), (error) => {
88
+ // abort 是正常退订路径,不该被记成 error。
89
+ if (stop.aborted) {
90
+ finish('aborted');
91
+ return;
92
+ }
93
+ finish('error', error instanceof Error ? error.message : String(error));
94
+ });
95
+ return subscription;
96
+ }
97
+ /**
98
+ * 终止单个订阅(网关 sub-control / 同号重入)。
99
+ *
100
+ * 走的是与 `end()` 完全相同的路径:controller.abort() 让 DSH 侧 follower 立刻拆除,
101
+ * 再由幂等的 finish 发一次 frame-end。因此无论从哪条入口终止,
102
+ * frame-end 都只发一次,注册表也不会残留条目。
103
+ *
104
+ * @param subscriptionId - 订阅号;由网关视角给出,与其发起 follow 的 req.id 一致。
105
+ * @param reason - 终止原因,写进 frame-end。
106
+ * @returns true 表示确实终止了一条活跃订阅;未知 id 静默返回 false,不抛也不告警——
107
+ * 网关与插件对订阅的可见性存在天然时差(刚结束 / 已超时),把它当错误会刷屏。
108
+ */
109
+ abortSubscription(subscriptionId, reason = 'aborted') {
110
+ const subscription = this.active.get(subscriptionId);
111
+ if (subscription === undefined)
112
+ return false;
113
+ subscription.end(reason);
114
+ return true;
115
+ }
116
+ /** 插件卸载 / 连接断开:终止全部订阅,签名与 ADR-004 的三条纪律一一对应。 */
117
+ abortAll(reason = 'aborted') {
118
+ // 遍历快照:end() 会从 this.active 里删除当前项,直接迭代 Map 会漏项。
119
+ for (const subscriptionId of [...this.active.keys()]) {
120
+ this.abortSubscription(subscriptionId, reason);
121
+ }
122
+ }
123
+ /**
124
+ * session.history 的 throughSeq 缺省解析(D4)。
125
+ *
126
+ * 只跑一趟"取首帧就退订"的短路 follow,不会长期持有会话;
127
+ * @param signal - 调用方期限(通常是一条 RPC 的超时信号)。
128
+ */
129
+ async resolveCursor(sessionId, signal) {
130
+ const cached = this.cursors.get(sessionId);
131
+ if (cached !== undefined)
132
+ return cached;
133
+ // 显式用 AbortController 而不是 AbortSignal.timeout:前者能主动 abort,
134
+ // 这样"拿到首帧立刻退订"是真实的资源回收,而不是等超时自然结束。
135
+ const controller = new AbortController();
136
+ const stop = signal === undefined ? controller.signal : AbortSignal.any([controller.signal, signal]);
137
+ const lifetime = setTimeout(() => controller.abort(), Math.min(FOLLOW_IDLE_TIMEOUT_MS, FOLLOW_MAX_LIFETIME_MS));
138
+ try {
139
+ for await (const frame of this.followStream({ address: { kind: 'session', sessionId } }, stop)) {
140
+ if (frame.type !== 'snapshot')
141
+ continue;
142
+ // 只取首帧 cursor:这是"短暂 follow",不是订阅。
143
+ this.cursors.set(sessionId, frame.cursor);
144
+ return frame.cursor;
145
+ }
146
+ return undefined;
147
+ }
148
+ finally {
149
+ clearTimeout(lifetime);
150
+ // 只 abort 我们自己的控制器;调用方信号不归我们关。
151
+ controller.abort();
152
+ }
153
+ }
154
+ /**
155
+ * 唯一的 `source.follow` 取用点。
156
+ *
157
+ * T16:`this.source.follow(...)` 在本文件出现过三处,任何一处拿到 undefined
158
+ * 都会炸成 "Cannot read properties of undefined (reading 'follow')" ——
159
+ * 这个信息里没有任何关于「谁给我一个坏 source」的线索,是本次排查卡住的直接原因。
160
+ *
161
+ * T18 教训(**守卫比缺陷更严格**):这一版最初的守卫写成 `!'follow' in source`。
162
+ * 而 source 可能是装配层传入的惰性代理——只实现 `get` 的 Proxy,
163
+ * `'follow' in proxy` 走默认 [[Has]] 落在空 target 上,恒为 false,
164
+ * 于是**健康服务**被判成装配错误,history 100% 不可用:守卫自己成了新的单点障碍。
165
+ *
166
+ * 规则(写在这里供后来者沿用):
167
+ * 1. 守卫必须比它守护的缺陷更**宽松**——宁可漏过交给下游,也不要误杀正常路径;
168
+ * 2. 检查方式必须与被检查对象的**形态**匹配——对代理/惰性对象用 `typeof x.prop`
169
+ * 取真实属性值,不要用 `in` / `hasOwnProperty`(两者都受 [[Has]] 与自有属性限制)。
170
+ *
171
+ * `typeof` 会真实触发一次 get,因此对惰性 source 既能拿到函数,也不会误判。
172
+ */
173
+ followStream(request, signal) {
174
+ const source = this.source;
175
+ if (source === null || typeof source !== 'object') {
176
+ throw new Error('remote-bridge: FollowRegistry.source is not an object — assembly bug, ' +
177
+ `received ${source === null ? 'null' : typeof source}. This is the plugin wiring, not DSH data.`);
178
+ }
179
+ // 用 typeof 取真实值:对惰性代理(只实现 get)同样可靠。
180
+ const follow = source.follow;
181
+ if (typeof follow !== 'function') {
182
+ throw new Error(`remote-bridge: FollowRegistry.source.follow is not a function (got ${typeof follow}) — assembly bug.`);
183
+ }
184
+ return follow.call(source, request, signal);
185
+ }
186
+ /** 已解析的 cursor 直接记下,避免 history 再开一次 follow。 */
187
+ rememberCursor(sessionId, cursor) {
188
+ this.cursors.set(sessionId, cursor);
189
+ }
190
+ /**
191
+ * 一次"取首帧 cursor 就退订"的短暂 follow。
192
+ *
193
+ * 这是 D4 给 history 用的入口,语义与 {@link resolveCursor} 相同,
194
+ * 区别只在返回值形态:history 需要把"DSH 侧失败"与"没有快照帧"分开报错,
195
+ * 因此这里把两种结果区分开,而不是把失败折叠成 undefined。
196
+ *
197
+ * @returns `{ cursor }` 取到切口;`{ missing: true }` 流结束但没给 snapshot 帧。
198
+ * 失败(会话不存在等)直接抛出,由调用方映射成 RPC 错误码——不静默吞掉。
199
+ */
200
+ async peekCursor(sessionId, signal) {
201
+ const cached = this.cursors.get(sessionId);
202
+ if (cached !== undefined)
203
+ return { cursor: cached };
204
+ const controller = new AbortController();
205
+ const stop = signal === undefined ? controller.signal : AbortSignal.any([controller.signal, signal]);
206
+ const lifetime = setTimeout(() => controller.abort(), FOLLOW_MAX_LIFETIME_MS);
207
+ try {
208
+ for await (const frame of this.followStream({ address: { kind: 'session', sessionId } }, stop)) {
209
+ if (frame.type !== 'snapshot')
210
+ continue;
211
+ this.cursors.set(sessionId, frame.cursor);
212
+ return { cursor: frame.cursor };
213
+ }
214
+ return { missing: true };
215
+ }
216
+ finally {
217
+ clearTimeout(lifetime);
218
+ controller.abort();
219
+ }
220
+ }
221
+ /**
222
+ * 泵帧。
223
+ *
224
+ * 生命周期保证:
225
+ * - for await 只在 stop 未 abort 时推进;
226
+ * - 空闲计时器每次 yield 重置,超时直接 end('aborted'),不看错误形态;
227
+ * - 无论正常结束还是抛错,finally 一定 abort,DSH 侧的 follower 因此被拆除。
228
+ */
229
+ async pump(sessionId, subscriptionId, stop, emit) {
230
+ let idle;
231
+ const resetIdle = () => {
232
+ if (idle)
233
+ clearTimeout(idle);
234
+ idle = setTimeout(() => {
235
+ const subscription = this.active.get(subscriptionId);
236
+ subscription?.end('aborted');
237
+ }, FOLLOW_IDLE_TIMEOUT_MS);
238
+ };
239
+ try {
240
+ resetIdle();
241
+ const stream = this.followStream({ address: { kind: 'session', sessionId }, assistantStream: true }, stop);
242
+ for await (const frame of stream) {
243
+ if (stop.aborted)
244
+ break;
245
+ resetIdle();
246
+ // snapshot 的 cursor 是 history 的唯一合法切口(D4),先入缓存再外发。
247
+ if (frame.type === 'snapshot' && Number.isSafeInteger(frame.cursor)) {
248
+ this.cursors.set(sessionId, frame.cursor);
249
+ }
250
+ emit(frame);
251
+ }
252
+ }
253
+ finally {
254
+ if (idle)
255
+ clearTimeout(idle);
256
+ this.active.get(subscriptionId)?.controller.abort();
257
+ }
258
+ }
259
+ combine(...signals) {
260
+ return AbortSignal.any([...signals]);
261
+ }
262
+ }
263
+ /**
264
+ * cursor 缓存。
265
+ *
266
+ * 有界是硬要求(D4):条目数上限 + TTL,双约束都命中即淘汰,
267
+ * 免得长跑进程里每个开过的会话都留一条记录。
268
+ */
269
+ class CursorCache {
270
+ entries = new Map();
271
+ get(sessionId) {
272
+ const entry = this.entries.get(sessionId);
273
+ if (entry === undefined)
274
+ return undefined;
275
+ if (Date.now() - entry.at > CURSOR_CACHE_TTL_MS) {
276
+ this.entries.delete(sessionId);
277
+ return undefined;
278
+ }
279
+ // 命中即续期:正在用的会话不应被淘汰。
280
+ entry.at = Date.now();
281
+ return entry.cursor;
282
+ }
283
+ set(sessionId, cursor) {
284
+ this.entries.delete(sessionId);
285
+ this.entries.set(sessionId, { cursor, at: Date.now() });
286
+ this.evict();
287
+ }
288
+ evict() {
289
+ const now = Date.now();
290
+ for (const [key, entry] of this.entries) {
291
+ if (now - entry.at <= CURSOR_CACHE_TTL_MS)
292
+ break;
293
+ this.entries.delete(key);
294
+ }
295
+ while (this.entries.size > CURSOR_CACHE_MAX_ENTRIES) {
296
+ const oldest = this.entries.keys().next();
297
+ if (oldest.done === true)
298
+ break;
299
+ this.entries.delete(oldest.value);
300
+ }
301
+ }
302
+ }
303
+ /** 帧流错误码 -> proto 错误码。DSH 侧失败一律不给前端伪装成成功。 */
304
+ function mapFollowErrorCode(reason) {
305
+ return reason === 'error' ? 'internal' : 'timeout';
306
+ }
307
+ //# sourceMappingURL=follow.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"follow.js","sourceRoot":"","sources":["../../src/relay/follow.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ import { type HttpRequestFrame, type HttpResponseErrorFrame, type HttpResponseFrame } from '@ntdgy/dsh-remote-proto';
2
+ /** 单次上游请求超时。 */
3
+ export declare const UPSTREAM_TIMEOUT_MS = 55000;
4
+ export interface UpstreamResult {
5
+ frame: HttpResponseFrame | HttpResponseErrorFrame;
6
+ /** 成功时的原始 body;失败时缺省。 */
7
+ body?: Uint8Array;
8
+ }
9
+ /**
10
+ * 把被 latin1 误解码的 UTF-8 头值还原回 Unicode。
11
+ *
12
+ * 背景:Node 的 `res.headers` 按 latin1 解码 header 字节。上游若发
13
+ * 合法的 UTF-8(如中文文件名),到这里会变成一串 0x80-0xFF 的字符。
14
+ * 网关侧看到"全是 latin1"就原样放行 —— 文件名最终是乱码。
15
+ *
16
+ * 还原策略(保守,宁可不动也不要搞坏):
17
+ * · 没有 0x80-0xFF 的字符 → 原样返回(纯 ASCII,不可能有问题)
18
+ * · 有 → 试 latin1→utf8;能解出合法 UTF-8 且不含 U+FFFD 才采用
19
+ * · 任何一步失败 → 原样返回(保持 Node 给出的值,至少有确定性)
20
+ *
21
+ * 为什么可以这样判:latin1 解码会把每个字节变成同码点字符,
22
+ * 而 UTF-8 的续字节落在 0x80-0xBF —— 两者组合能唯一还原。
23
+ */
24
+ export declare function recoverUtf8Header(value: string): string;
25
+ /**
26
+ * 把一个转发请求执行到本机 DSH,返回可直接回传的帧。
27
+ *
28
+ * @param origin DSH 监听的真实 origin,如 http://127.0.0.1:3080
29
+ */
30
+ export declare function forwardToUpstream(origin: string, frame: HttpRequestFrame, body?: Uint8Array): Promise<UpstreamResult>;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * HTTP 转发执行器(ADR-005):把网关转来的请求打给本机 DSH。
3
+ *
4
+ * 为什么是「反向请求本机 loopback」而不是「直接喂给 webServer」:
5
+ * dsh-host-webserver 只暴露 register/registerUpgrade/registerFallback/getOrigin,
6
+ * 真正的 dispatch(handle)是私有的,插件调不到;fallback 席位已被 SPA dist server 占着,
7
+ * 再注册会 throw。因此用 getOrigin() 拿到 http://127.0.0.1:<port> 再请求一次。
8
+ * 代价是一次同机 loopback 往返,可忽略;DSH 侧零新增监听,C1/C7 均不破坏。
9
+ *
10
+ * ⚠️ 必须用 http.request 而不是 fetch:fetch 会**静默丢弃** headers.Host(T32 踩过),
11
+ * 而 DSH 的 cookie 绑定它看到的 Host,Host 传不对就认证失败。
12
+ */
13
+ import { request as httpRequest } from 'node:http';
14
+ import { request as httpsRequest } from 'node:https';
15
+ import { HTTP_BODY_MAX_BYTES, isForwardableHeader, } from '@ntdgy/dsh-remote-proto';
16
+ /** 单次上游请求超时。 */
17
+ export const UPSTREAM_TIMEOUT_MS = 55_000;
18
+ /**
19
+ * 把被 latin1 误解码的 UTF-8 头值还原回 Unicode。
20
+ *
21
+ * 背景:Node 的 `res.headers` 按 latin1 解码 header 字节。上游若发
22
+ * 合法的 UTF-8(如中文文件名),到这里会变成一串 0x80-0xFF 的字符。
23
+ * 网关侧看到"全是 latin1"就原样放行 —— 文件名最终是乱码。
24
+ *
25
+ * 还原策略(保守,宁可不动也不要搞坏):
26
+ * · 没有 0x80-0xFF 的字符 → 原样返回(纯 ASCII,不可能有问题)
27
+ * · 有 → 试 latin1→utf8;能解出合法 UTF-8 且不含 U+FFFD 才采用
28
+ * · 任何一步失败 → 原样返回(保持 Node 给出的值,至少有确定性)
29
+ *
30
+ * 为什么可以这样判:latin1 解码会把每个字节变成同码点字符,
31
+ * 而 UTF-8 的续字节落在 0x80-0xBF —— 两者组合能唯一还原。
32
+ */
33
+ export function recoverUtf8Header(value) {
34
+ let hasHighByte = false;
35
+ for (let i = 0; i < value.length; i += 1) {
36
+ const code = value.charCodeAt(i);
37
+ if (code > 0x7f) {
38
+ hasHighByte = true;
39
+ break;
40
+ }
41
+ }
42
+ if (!hasHighByte)
43
+ return value;
44
+ try {
45
+ const recovered = Buffer.from(value, 'latin1').toString('utf8');
46
+ // 还原失败时 Node 会插入 U+FFFD;出现它就说明原串不是 UTF-8 字节。
47
+ if (recovered.includes('\uFFFD'))
48
+ return value;
49
+ return recovered;
50
+ }
51
+ catch {
52
+ return value;
53
+ }
54
+ }
55
+ function errorResult(id, code, message) {
56
+ return { frame: { kind: 'http-response-error', id, code, message } };
57
+ }
58
+ /**
59
+ * 把一个转发请求执行到本机 DSH,返回可直接回传的帧。
60
+ *
61
+ * @param origin DSH 监听的真实 origin,如 http://127.0.0.1:3080
62
+ */
63
+ export function forwardToUpstream(origin, frame, body) {
64
+ return new Promise((resolve) => {
65
+ let url;
66
+ try {
67
+ url = new URL(frame.path, origin);
68
+ }
69
+ catch {
70
+ resolve(errorResult(frame.id, 'internal', 'invalid path'));
71
+ return;
72
+ }
73
+ // 只允许打本机 origin:路径里带绝对 URL 时必须仍落在 origin 内(防 SSRF)。
74
+ if (url.origin !== new URL(origin).origin) {
75
+ resolve(errorResult(frame.id, 'internal', 'path escapes upstream origin'));
76
+ return;
77
+ }
78
+ const headers = {};
79
+ for (const [name, value] of Object.entries(frame.headers ?? {})) {
80
+ // content-length 由实际 body 重算(网关已按真实字节设置,这里再兜一次)。
81
+ if (!isForwardableHeader(name) && name.toLowerCase() !== 'content-length')
82
+ continue;
83
+ headers[name] = value;
84
+ }
85
+ if (frame.bodyLength > 0)
86
+ headers['content-length'] = String(frame.bodyLength);
87
+ // ── Host:原样保留浏览器/网关发来的值,不改成 loopback ────────────────
88
+ //
89
+ // DSH 的 cookie 绑它看到的 Host(client-connection 的 requestAudience)。
90
+ // 若这里改写成 127.0.0.1:<port>,DSH 会签发 authority=127.0.0.1 的 cookie,
91
+ // 浏览器拿不到也用不上——实测现象就是「能拿到 303 + set-cookie,但后续全 401」。
92
+ //
93
+ // 因此走 T32 裁决的方案 A:透传浏览器 Host,并在 DSH 侧用
94
+ // dsh web --trusted-host <公网域名> 把它登记进 trustedHosts。
95
+ // 两者缺一:缺 Host 透传 → cookie 绑错 authority;缺 trustedHosts → /api 全 403。
96
+ if (!headers['host'])
97
+ headers['host'] = url.host;
98
+ headers['accept-encoding'] = 'identity';
99
+ const isHttps = url.protocol === 'https:';
100
+ const doRequest = isHttps ? httpsRequest : httpRequest;
101
+ const req = doRequest({
102
+ protocol: url.protocol,
103
+ hostname: url.hostname,
104
+ port: url.port || (isHttps ? 443 : 80),
105
+ path: url.pathname + url.search,
106
+ method: frame.method,
107
+ headers,
108
+ }, (res) => {
109
+ const chunks = [];
110
+ let total = 0;
111
+ let aborted = false;
112
+ res.on('data', (chunk) => {
113
+ if (aborted)
114
+ return;
115
+ total += chunk.length;
116
+ if (total > HTTP_BODY_MAX_BYTES) {
117
+ // 超限立即断开上游,不继续读,避免把内存撑爆。
118
+ aborted = true;
119
+ res.destroy();
120
+ resolve(errorResult(frame.id, 'body_too_large', 'response exceeds ' + HTTP_BODY_MAX_BYTES + ' bytes'));
121
+ return;
122
+ }
123
+ chunks.push(chunk);
124
+ });
125
+ res.on('end', () => {
126
+ if (aborted)
127
+ return;
128
+ const body = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)), total);
129
+ const outHeaders = {};
130
+ for (const [name, value] of Object.entries(res.headers)) {
131
+ if (value === undefined)
132
+ continue;
133
+ if (!isForwardableHeader(name))
134
+ continue;
135
+ // set-cookie 不能用 ', ' 合并:cookie 的 Expires 属性本身含逗号
136
+ // ("Expires=Fri, 04 Dec 2026 ..."),拼起来就再也切不开了。
137
+ // 用换行分隔是 Node 对多值头的通用约定,接收方按行拆即可。
138
+ //
139
+ // ⚠️ 头值还要做一次 UTF-8 还原(安全审查的 P2-2):
140
+ // Node 的 res.headers 把 header 按 **latin1** 解码,上游发
141
+ // UTF-8 的 "报告.txt" 到这里会变成 "报告.txt" ——
142
+ // 每个字符都 ≤ 0xFF,网关侧会当成"合法 latin1"直接透传,
143
+ // 于是 RFC 5987 转换永不触发,浏览器下载的文件名是乱码。
144
+ //
145
+ // 判据:字符串里出现 0x80-0xFF 的连续段、且按 latin1→utf8
146
+ // 还原后能解出合法 UTF-8 多字节序列,就认为原本是 UTF-8。
147
+ const raw = Array.isArray(value) ? value.join('\n') : value;
148
+ outHeaders[name] = recoverUtf8Header(raw);
149
+ }
150
+ resolve({
151
+ frame: { kind: 'http-response', id: frame.id, status: res.statusCode ?? 502, headers: outHeaders, bodyLength: body.length },
152
+ body,
153
+ });
154
+ });
155
+ res.on('error', (err) => {
156
+ if (aborted)
157
+ return;
158
+ aborted = true;
159
+ resolve(errorResult(frame.id, 'upstream_unreachable', err.message));
160
+ });
161
+ });
162
+ req.setTimeout(UPSTREAM_TIMEOUT_MS, () => {
163
+ req.destroy();
164
+ resolve(errorResult(frame.id, 'upstream_timeout', 'upstream did not respond in time'));
165
+ });
166
+ req.on('error', (err) => {
167
+ resolve(errorResult(frame.id, 'upstream_unreachable', err.message));
168
+ });
169
+ // 请求体:POST 的 /api/<endpoint> 靠它传参。body 由二进制帧送达,
170
+ // 调用方在 frame.bodyLength > 0 时把字节附在 body 参数里。
171
+ if (frame.bodyLength > 0) {
172
+ if (!body || body.byteLength !== frame.bodyLength) {
173
+ req.destroy();
174
+ resolve(errorResult(frame.id, 'internal', 'request body missing or truncated'));
175
+ return;
176
+ }
177
+ req.end(Buffer.from(body.buffer, body.byteOffset, body.byteLength));
178
+ return;
179
+ }
180
+ req.end();
181
+ });
182
+ }
183
+ //# sourceMappingURL=http-forward.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http-forward.js","sourceRoot":"","sources":["../../src/relay/http-forward.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 运行时路由观测:读取 DSH webServer 上**已注册**的 HTTP 路径,归并成前缀后上报网关。
3
+ *
4
+ * 为什么观测而不是推断:包名与路由常常不一致(dshmarket 的路由是 /dsh-market/,
5
+ * 不是 /dshmarket/)。推断猜错了与猜对了在日志上无法区分,运维没法判断该不该信 ——
6
+ * 而观测到的是**真实存在**的路由,可审计。
7
+ *
8
+ * 技术依据(dsh-host-webserver 0.2.1-rc.2,lib/index.js:267-269):
9
+ * exact = new Map(); prefixes = new Map(); upgrades = new Map();
10
+ * 三者都是**类字段初始化的普通 Map**(非 #private),运行时可直接遍历。
11
+ * register()(:326-333)就是往 exact/prefixes 里 set。
12
+ *
13
+ * 时机:hello 发生在运行时、晚于所有插件注册,取到的正是完整快照。
14
+ */
15
+ /** 单条前缀长度上限。 */
16
+ export declare const MAX_PREFIX_LENGTH = 64;
17
+ /** 单实例上报条数上限。 */
18
+ export declare const MAX_PREFIXES_PER_INSTANCE = 32;
19
+ export interface PrefixReport {
20
+ /** 通过校验、可上报的前缀。 */
21
+ accepted: string[];
22
+ /** 被拒绝的原始值及原因(用于本地日志)。 */
23
+ rejected: Array<{
24
+ value: string;
25
+ reason: string;
26
+ }>;
27
+ /** 观测到的原始路由条数,便于判断「一条都没看到」是不是异常。 */
28
+ observed: number;
29
+ /**
30
+ * 诊断信息:webServer 是否存在、三张表的实际类型与条数。
31
+ *
32
+ * 只在 observed === 0 时有意义 —— 那时的关键问题是「为什么一条都没读到」,
33
+ * 而这只有 webServer / 字段类型 / 条数能回答。只打「未观测到路由」等于没给线索
34
+ * (T37 部署后就是因此要回去翻代码才定位到 inject 缺 webServer)。
35
+ */
36
+ diagnostic: string;
37
+ }
38
+ /**
39
+ * 校验单条前缀。
40
+ *
41
+ * 拒绝规则(任何一条命中即拒):
42
+ * - 不以 '/' 开头或不以 '/' 结尾(前缀必须是目录形式,避免 /ab 误匹配 /abc)
43
+ * - 含 '..'(路径穿越)、':'(协议)、'*'(通配)
44
+ * - 长度超过 MAX_PREFIX_LENGTH
45
+ * - 含空白或控制字符
46
+ */
47
+ export declare function validatePrefix(raw: unknown): {
48
+ ok: true;
49
+ value: string;
50
+ } | {
51
+ ok: false;
52
+ reason: string;
53
+ };
54
+ /**
55
+ * 从一条完整路径推出它的前缀。
56
+ *
57
+ * 规则:取第一段(/dsh-market/status → /dsh-market/)。
58
+ * 对根级路径(/favicon.svg)返回 undefined —— 根级资源不构成可放行前缀。
59
+ */
60
+ export declare function prefixOf(pathname: string): string | undefined;
61
+ /**
62
+ * 读取 webServer 已注册路由,归并成前缀集合。
63
+ *
64
+ * @param webServer DSH 的 webServer 服务实例(可以是 undefined,表示该 profile 没有 web 面)
65
+ */
66
+ export declare function collectRegisteredPrefixes(webServer: unknown): PrefixReport;