@mrrisega/dsh-remote 0.7.3-beta.3 → 0.7.4-beta.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/clients/dsh-remote/dsh-bridge.mjs +206 -5
- package/clients/dsh-remote/e2ee-shim-script.js +109 -43
- package/clients/dsh-remote/mobile-adapter.mjs +454 -1
- package/clients/dsh-remote/test/bridge-file-transfer.test.mjs +281 -0
- package/clients/dsh-remote/test/e2ee-shim-retry.test.mjs +249 -0
- package/clients/dsh-remote/test/lib-e2ee-shim.mjs +1 -0
- package/clients/dsh-remote/test/mobile-adapter-resume.test.mjs +460 -0
- package/clients/dsh-remote/test/mobile-adapter-upload-hint.test.mjs +157 -0
- package/package.json +1 -1
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +206 -5
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +109 -43
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +454 -1
- package/packages/dsh-remote-web/test/mobile-adapter-resume.test.mjs +125 -0
|
@@ -484,6 +484,26 @@ const CHUNK_SIZE = 200 * 1024;
|
|
|
484
484
|
// 单个 HTTP 请求的兜底超时(秒)。dsh 的 prompt 等操作可能跑很久,给足余量。
|
|
485
485
|
const HTTP_TIMEOUT_MS = 120_000;
|
|
486
486
|
|
|
487
|
+
/**
|
|
488
|
+
* 隧道 WS **接收**上限,与 relay-router 的 `WS_MAX_PAYLOAD`(256MB) 对齐。
|
|
489
|
+
*
|
|
490
|
+
* 【为什么要显式抬高】`ws` 的**客户端**默认 `maxPayload = 100MiB`;而 router → bridge
|
|
491
|
+
* 方向的帧**不分块**(router 的 `sendToBridge` 直接 `ws.send(JSON.stringify(frame))`),
|
|
492
|
+
* 最大的一帧来自上传:`/api/session/uploadFileBinary` 的请求体在 router 侧被
|
|
493
|
+
* `bodyBuf.toString("base64")` 后塞进帧里 —— router `MAX_BODY_BYTES = 64MiB`,
|
|
494
|
+
* 64MiB 的请求体 = 89.5MB 的 base64 字符串,离 100MiB 只剩 ~15% 余量。
|
|
495
|
+
* 一旦越界,`ws` 以 1009(message too big) 直接关闭:bridge 掉线 → router 对所有
|
|
496
|
+
* 后续请求回 502 `device_offline`(生产上"连续 502、怎么重试都失败"就是这个形态),
|
|
497
|
+
* 而**用户只看到"上传失败"**。对齐 256MB 后这一侧不再是瓶颈(内存风险仍由 router 的
|
|
498
|
+
* MAX_BODY_BYTES 兜住,这里只是不再替 router 提前掐线)。
|
|
499
|
+
*/
|
|
500
|
+
export const TUNNEL_WS_MAX_PAYLOAD = 256 * 1024 * 1024;
|
|
501
|
+
/** 隧道 `new WebSocket` 的选项(导出给用例钉住 maxPayload,避免以后被"顺手删掉")。 */
|
|
502
|
+
export const TUNNEL_WS_OPTIONS = Object.freeze({ followRedirects: false, maxPayload: TUNNEL_WS_MAX_PAYLOAD });
|
|
503
|
+
|
|
504
|
+
/** 需要「长请求」可观测性的路径:大文件上传最典型(分钟级、几十 MB、失败无解释)。 */
|
|
505
|
+
const LONG_REQUEST_PATH_RE = /^\/api\/session\/uploadFileBinary(?:\?|$)/;
|
|
506
|
+
|
|
487
507
|
// ---------- E2EE 运行时(默认禁用;main() 启动时按配置/服务端开关初始化) ----------
|
|
488
508
|
|
|
489
509
|
/**
|
|
@@ -736,6 +756,125 @@ export async function maybeCompressResponse({ buf, contentType, contentEncoding,
|
|
|
736
756
|
return { buf: out, headers: { "content-encoding": "gzip" } };
|
|
737
757
|
}
|
|
738
758
|
|
|
759
|
+
/**
|
|
760
|
+
* 帧里请求体的**字节数**(http 帧一律 base64;旧协议是原始文本按 UTF-8 计)。
|
|
761
|
+
* 只用于日志/错误回包里的 `bytes`,算错不影响转发。
|
|
762
|
+
*/
|
|
763
|
+
export function requestBodyBytes(body, isB64) {
|
|
764
|
+
if (body === undefined || body === null || body === "") return 0;
|
|
765
|
+
const s = String(body);
|
|
766
|
+
if (!isB64) return Buffer.byteLength(s, "utf8");
|
|
767
|
+
// base64 → 字节数 = 去掉 padding 后的长度 * 3/4(避免为了记日志再解一遍几十 MB)
|
|
768
|
+
const pad = s.endsWith("==") ? 2 : s.endsWith("=") ? 1 : 0;
|
|
769
|
+
return Math.max(0, Math.floor((s.length * 3) / 4) - pad);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* 把「上游 fetch 失败」翻译成**用户能懂、客服能查**的 code + message。
|
|
774
|
+
*
|
|
775
|
+
* 背景(生产 2026-10-20~22):上传接口的 22 次 502 里有 24 字节的 `{"error":"fetch failed"}`
|
|
776
|
+
* —— 手机端只看到"上传失败",客服拿到的日志也只有一句 undici 的 `fetch failed`,
|
|
777
|
+
* 既分不清"上游没起来"和"请求太大超时",也没法告诉用户该做什么。分类后:
|
|
778
|
+
* - `upstream_timeout` 本机 dsh web 120s 没响应(大文件/上游卡住);
|
|
779
|
+
* - `upstream_unreachable` 连不上本机 dsh web(退出/换端口/连接被重置);
|
|
780
|
+
* - `upstream_error` 其余(带原始 message 与 cause code)。
|
|
781
|
+
* 纯函数、无 IO —— 502 回包与日志共用同一份结论,导出给用例钉住 code。
|
|
782
|
+
*
|
|
783
|
+
* @param {any} e fetch 抛出的错误(可能带 undici 的 `cause.code`)
|
|
784
|
+
* @returns {{code:string,message:string,detail:string}}
|
|
785
|
+
*/
|
|
786
|
+
export function classifyUpstreamError(e) {
|
|
787
|
+
const name = String((e && e.name) || "");
|
|
788
|
+
const msg = String((e && e.message) || e || "");
|
|
789
|
+
const cause = e && typeof e.cause === "object" && e.cause ? e.cause : null;
|
|
790
|
+
const causeCode = String((cause && cause.code) || (e && e.code) || "").toUpperCase();
|
|
791
|
+
if (name === "TimeoutError" || name === "AbortError" || /timed out|timeout|aborted due to timeout/i.test(msg)) {
|
|
792
|
+
return {
|
|
793
|
+
code: "upstream_timeout",
|
|
794
|
+
message: `本机 dsh web 在 ${Math.round(HTTP_TIMEOUT_MS / 1000)} 秒内没有响应(文件太大或电脑端卡住),请稍后重试;反复失败请确认电脑端 DSH 仍在运行。`,
|
|
795
|
+
detail: causeCode || name || "timeout"
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
if (/ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH|EPIPE|UND_ERR|socket hang up|fetch failed|other side closed|terminated/i.test(causeCode + " " + msg)) {
|
|
799
|
+
return {
|
|
800
|
+
code: "upstream_unreachable",
|
|
801
|
+
message: "连不上电脑端的 dsh web(可能已退出或换了端口);bridge 会自动重新发现,请稍后重试。",
|
|
802
|
+
detail: causeCode || msg.slice(0, 80)
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
return {
|
|
806
|
+
code: "upstream_error",
|
|
807
|
+
message: `转发到本机 dsh web 失败: ${msg || "未知错误"}`,
|
|
808
|
+
detail: causeCode
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** 在 `/api/file?path=...` 的 path 参数里取文件名(取最后一段;非法输入回落 download)。 */
|
|
813
|
+
export function downloadFilenameOf(filePath) {
|
|
814
|
+
const raw = String(filePath || "").replace(/[\x00-\x1f\x7f]/g, "");
|
|
815
|
+
const cleaned = raw.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
816
|
+
const base = cleaned.split("/").filter(Boolean).pop() || "";
|
|
817
|
+
return base && base !== "." && base !== ".." ? base : "download";
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* 下载语义(C):给 `/api/file` 的**导航**请求补 `Content-Disposition: attachment`。
|
|
822
|
+
*
|
|
823
|
+
* 【为什么需要】上游 dsh web 的 `/api/file` 只给 `Content-Type`,**没有** Content-Disposition
|
|
824
|
+
* (dsh-api-session-controller 的 serveFile)。iOS Safari 于是把 PDF/大图**内联**打开;
|
|
825
|
+
* 而上游同时带 `Content-Security-Policy: sandbox; default-src 'none'`,内联页经常直接空白 ——
|
|
826
|
+
* 用户看到的正是"下载只能在电脑端查看"。加 attachment 让手机浏览器落到下载管理器。
|
|
827
|
+
*
|
|
828
|
+
* 【为什么只对导航加】同一个 `/api/file` 也被官方前端当 `<img src>` 用(聊天里的绝对路径图片),
|
|
829
|
+
* 无差别加 attachment 有把图片预览打成下载的风险。判定顺序:
|
|
830
|
+
* 1. 方法必须是 GET,路径必须是 `/api/file`;
|
|
831
|
+
* 2. 响应必须是 2xx 且**有实体**(200/206):4xx/5xx 是错误页,加了 attachment 会
|
|
832
|
+
* 让浏览器把「登录失效/文件太大」的报错正文**下载成文件**,比原来更糟;
|
|
833
|
+
* 3. 上游**已经**给了 content-disposition(attachment 或 inline)→ 一律不覆盖(上游语义优先);
|
|
834
|
+
* 4. `?download=1/0` 显式指定:1 强制加、0 强制不加;
|
|
835
|
+
* 5. 没有显式参数时看导航特征:有 `sec-fetch-dest` 就只认 `document`;
|
|
836
|
+
* 没有(老浏览器)则要求 Accept 含 `text/html`(导航)。
|
|
837
|
+
* `image`/`empty`(fetch/XHR)/`iframe` 一律不动 —— 子资源加载本来就不受 attachment 影响,
|
|
838
|
+
* 但没必要冒任何风险。
|
|
839
|
+
*
|
|
840
|
+
* @param {{method?:string,path?:string,status?:number,reqHeaders?:object,resHeaders?:object}} args
|
|
841
|
+
* @returns {string|null} 需要附加的 Content-Disposition 值;null = 不附加
|
|
842
|
+
*/
|
|
843
|
+
export function downloadDispositionOf({ method = "GET", path = "", status = 200, reqHeaders = {}, resHeaders = {} } = {}) {
|
|
844
|
+
if (String(method || "").toUpperCase() !== "GET") return null;
|
|
845
|
+
if (status !== 200 && status !== 206) return null; // 错误页不伪装成下载
|
|
846
|
+
const raw = String(path || "");
|
|
847
|
+
const q = raw.indexOf("?");
|
|
848
|
+
const pathname = q === -1 ? raw : raw.slice(0, q);
|
|
849
|
+
const search = q === -1 ? "" : raw.slice(q + 1);
|
|
850
|
+
if (pathname !== "/api/file") return null;
|
|
851
|
+
if (headerValue(resHeaders, "content-disposition")) return null; // 上游已有 → 不覆盖
|
|
852
|
+
let params = null;
|
|
853
|
+
try {
|
|
854
|
+
params = new URLSearchParams(search);
|
|
855
|
+
} catch {
|
|
856
|
+
params = null;
|
|
857
|
+
}
|
|
858
|
+
const explicit = params ? params.get("download") : null;
|
|
859
|
+
if (explicit === "0" || explicit === "false") return null;
|
|
860
|
+
if (explicit !== "1" && explicit !== "true") {
|
|
861
|
+
const dest = headerValue(reqHeaders, "sec-fetch-dest").toLowerCase();
|
|
862
|
+
if (dest) {
|
|
863
|
+
if (dest !== "document") return null;
|
|
864
|
+
} else if (!headerValue(reqHeaders, "accept").toLowerCase().includes("text/html")) {
|
|
865
|
+
return null; // 没有导航特征(老浏览器) → 保守不动
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
const name = downloadFilenameOf(params ? params.get("path") : "");
|
|
869
|
+
// RFC 5987 的 ext-value:Safari/Chrome/Firefox 都认 filename*;同时给一个 ASCII 回退,
|
|
870
|
+
// 免得只认 filename= 的老客户端把文件存成 URL 里的 "file"(连带丢扩展名)。
|
|
871
|
+
// 非 ASCII 名(中文等)的 ASCII 回退统一成 download.<ext> —— 比一串下划线可读。
|
|
872
|
+
const encoded = encodeURIComponent(name).replace(/['()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
|
|
873
|
+
const ext = (/\.([A-Za-z0-9]{1,8})$/.exec(name) || [""])[0];
|
|
874
|
+
const ascii = /[^\x20-\x7e]/.test(name) ? `download${ext}` : name.replace(/["\\]/g, "_") || "download";
|
|
875
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
|
|
876
|
+
}
|
|
877
|
+
|
|
739
878
|
/**
|
|
740
879
|
* 进行中的 SSE 流:frameId → AbortController。
|
|
741
880
|
* 手机侧断开时,中继会发 `http-abort`,这里据此掐掉上游(否则电脑上会留一条永不结束的连接)。
|
|
@@ -868,6 +1007,13 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
|
868
1007
|
// 若我们自行 gzip,必须在 sanitize 之后把 content-encoding: gzip 补回,手机才能正确解压。
|
|
869
1008
|
const headers = sanitizeResponseHeaders(Object.fromEntries(res.headers.entries()));
|
|
870
1009
|
const contentType = res.headers.get("content-type") || "";
|
|
1010
|
+
// 下载语义(C):`/api/file` 的**导航**响应补 Content-Disposition: attachment。
|
|
1011
|
+
// 上游只给 Content-Type(iOS 会内联打开 PDF/大图,叠加 sandbox CSP 后常常一片空白),
|
|
1012
|
+
// 手机浏览器就不会落到下载管理器 —— 判据见 downloadDispositionOf(子资源/图片不动)。
|
|
1013
|
+
{
|
|
1014
|
+
const disposition = downloadDispositionOf({ method, path: safe, status: res.status, reqHeaders, resHeaders: headers });
|
|
1015
|
+
if (disposition) headers["content-disposition"] = disposition;
|
|
1016
|
+
}
|
|
871
1017
|
if (!contentType.includes("text/event-stream")) { // SSE 长连不缓冲不注入(见模块注释)
|
|
872
1018
|
const m = maybeInjectMobileAdapter({ buf, contentType });
|
|
873
1019
|
if (m.injected) {
|
|
@@ -980,6 +1126,9 @@ export async function handleHttpFrame(dchOrSend, frame) {
|
|
|
980
1126
|
const send = toSender(dchOrSend);
|
|
981
1127
|
const { id, method = "GET", path = "/", headers = {}, body, bodyBase64: isB64 } = frame;
|
|
982
1128
|
const t0 = Date.now();
|
|
1129
|
+
// 长请求(大文件上传)的可观测性:上行字节与耗时都要进日志/错误回包,见 B 段注释。
|
|
1130
|
+
const bytesIn = requestBodyBytes(body, !!isB64);
|
|
1131
|
+
const isLongRequest = LONG_REQUEST_PATH_RE.test(String(path));
|
|
983
1132
|
// 页面还活着:任何后续请求都撤掉「镜像页加载」看门狗(见 armMirrorLoadWatch)
|
|
984
1133
|
if (mirrorLoadWatch) { mirrorLoadWatch = null; }
|
|
985
1134
|
// 桌面授权引导(方案A):POST /_e2ee/intro(device/channel 形态归一)—— 本地应答,不连上游
|
|
@@ -1036,17 +1185,67 @@ export async function handleHttpFrame(dchOrSend, frame) {
|
|
|
1036
1185
|
const reply = await doHttp(method, path, headers, body, !!isB64);
|
|
1037
1186
|
reply.id = id;
|
|
1038
1187
|
reply.type = "http";
|
|
1039
|
-
send(reply);
|
|
1040
|
-
|
|
1188
|
+
const sent = send(reply);
|
|
1189
|
+
const ms = Date.now() - t0;
|
|
1190
|
+
const outBytes = Math.floor((reply.body.length * 3) / 4);
|
|
1191
|
+
if (isLongRequest) {
|
|
1192
|
+
// 长请求(大文件上传)单独记:上行字节、总耗时、状态码 —— 客服排查"上传失败"最需要这三样。
|
|
1193
|
+
console.log(`[bridge] ${method} ${path} → ${reply.status} (${ms}ms, 上行 ${(bytesIn / 1024 / 1024).toFixed(2)}MB, 下行 ${(outBytes / 1024).toFixed(0)}KB)`);
|
|
1194
|
+
} else {
|
|
1195
|
+
console.log(`[bridge] ${method} ${path} → ${reply.status} (${ms}ms, ${(outBytes / 1024).toFixed(0)}KB)`);
|
|
1196
|
+
}
|
|
1197
|
+
reportUndelivered(sent, { method, path, phase: "reply", ms, bytesIn, outBytes });
|
|
1041
1198
|
} catch (e) {
|
|
1042
1199
|
// 上游不可达是「端口可能变了」的最强信号(Desktop 换端口 / dsh web 重启到别的端口):
|
|
1043
1200
|
// 触发一次带冷却的重新发现,后续请求自动跟上,不必重启 bridge。
|
|
1044
1201
|
void refreshUpstream("上游请求失败");
|
|
1045
|
-
|
|
1046
|
-
|
|
1202
|
+
const ms = Date.now() - t0;
|
|
1203
|
+
const info = classifyUpstreamError(e);
|
|
1204
|
+
const detail = info.detail ? `, detail=${info.detail}` : "";
|
|
1205
|
+
const tag = isLongRequest ? "长请求" : "普通请求";
|
|
1206
|
+
const line = `[bridge] ${method} ${path} 上游错误(${info.code}): ${info.message}(${tag}, ${ms}ms, 上行 ${bytesIn}B${detail})`;
|
|
1207
|
+
if (isLongRequest || info.code !== "upstream_error") console.error(line);
|
|
1208
|
+
else console.log(line);
|
|
1209
|
+
// 502 body:从 `{"error":"fetch failed"}`(24 字节、用户与客服都看不懂)改成
|
|
1210
|
+
// `{error:{code,message,...}}` —— 与 bridge/router 其它错误回包同一形态。
|
|
1211
|
+
// 兼容:保留顶层 code/message,老读取方从 error 字符串改读这两个即可。
|
|
1212
|
+
const payload = {
|
|
1213
|
+
error: {
|
|
1214
|
+
code: info.code,
|
|
1215
|
+
message: info.message,
|
|
1216
|
+
method,
|
|
1217
|
+
path,
|
|
1218
|
+
bytes: bytesIn,
|
|
1219
|
+
durationMs: ms,
|
|
1220
|
+
...(info.detail ? { detail: info.detail } : {})
|
|
1221
|
+
},
|
|
1222
|
+
code: info.code,
|
|
1223
|
+
message: info.message
|
|
1224
|
+
};
|
|
1225
|
+
const sent = send({
|
|
1226
|
+
id,
|
|
1227
|
+
type: "http",
|
|
1228
|
+
status: 502,
|
|
1229
|
+
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
|
|
1230
|
+
body: Buffer.from(JSON.stringify(payload)).toString("base64"),
|
|
1231
|
+
bodyBase64: true
|
|
1232
|
+
});
|
|
1233
|
+
reportUndelivered(sent, { method, path, phase: "502", ms, bytesIn, outBytes: 0 });
|
|
1047
1234
|
}
|
|
1048
1235
|
}
|
|
1049
1236
|
|
|
1237
|
+
/**
|
|
1238
|
+
* 回包没送进隧道(ws 已断/缓冲写失败)时的**唯一在线证据**。
|
|
1239
|
+
*
|
|
1240
|
+
* 中继侧对"bridge 断开"只会回一句 `502 device_offline`,bridge 这边如果静默吞掉,
|
|
1241
|
+
* 事后就分不清是"桥没收到请求"还是"桥回了但没送出去"。这里只在 send 明确返回 false
|
|
1242
|
+
* (makeChunkedSender 捕获到 ws.send 抛错)时记一行 —— 不改变任何行为。
|
|
1243
|
+
*/
|
|
1244
|
+
function reportUndelivered(sent, { method, path, phase, ms, bytesIn, outBytes }) {
|
|
1245
|
+
if (sent !== false) return;
|
|
1246
|
+
console.error(`[bridge] ${method} ${path} ${phase} 回包未能送入中继(tunnel_closed): ${ms}ms, 上行 ${bytesIn}B, 下行 ${outBytes}B`);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1050
1249
|
/** 桌面授权引导(方案A):POST /_e2ee/intro —— 桥端本地应答,不连上游。
|
|
1051
1250
|
* 路由层已按 dsh_token cookie 完成同账号授权(device/channel 形态都只到本设备);
|
|
1052
1251
|
* 这里仅当桥端 E2EE 启用时一次性下发派生 MK(内存转发,不落盘不写日志)。 */
|
|
@@ -1536,7 +1735,9 @@ async function registerDeviceInAccount(token) {
|
|
|
1536
1735
|
|
|
1537
1736
|
function connectTunnel(token) {
|
|
1538
1737
|
const endpoint = tunnelEndpoint(TUNNEL_URL);
|
|
1539
|
-
|
|
1738
|
+
// maxPayload 必须显式给:ws 客户端默认 100MiB,而 router→bridge 的单帧不分块,
|
|
1739
|
+
// 大文件上传的 base64 请求体可以顶到 89.5MB(见 TUNNEL_WS_MAX_PAYLOAD 注释)。
|
|
1740
|
+
const ws = new WebSocket(endpoint, TUNNEL_WS_OPTIONS);
|
|
1540
1741
|
const send = makeChunkedSender((s) => { if (ws.readyState === WebSocket.OPEN) ws.send(s); });
|
|
1541
1742
|
let heartbeat;
|
|
1542
1743
|
let pongReceived = true;
|
|
@@ -571,9 +571,56 @@
|
|
|
571
571
|
var noBody = status === 204 || status === 205 || status === 304 || bodyU8.length === 0;
|
|
572
572
|
return new Response(noBody ? null : bodyU8, { status: status, headers: hdrs });
|
|
573
573
|
}
|
|
574
|
+
/* ---- 网络层失败的一次极短重试(D,2026-10-23) ----------------------------------
|
|
575
|
+
* 为什么:大文件读(`/api/workspaceFiles/readBytes`)、会话历史(`/api/session/page`)、
|
|
576
|
+
* Office/PDF 渲染(`/api/officeToPdf/`)在弱网/中继限速下经常"读到一半断"——浏览器只
|
|
577
|
+
* 看到一次 fetch reject 或空响应,上层(文档预览/会话列表)直接落到失败态,用户得手点
|
|
578
|
+
* 「重新读取文件」。这些都是**幂等读**,重发一次几乎无副作用,却能吃掉大部分瞬时断流。
|
|
579
|
+
* 边界:
|
|
580
|
+
* - 只白名单幂等读路径;上传/写入路径永不重试;
|
|
581
|
+
* - 最多 1 次,间隔 300ms;
|
|
582
|
+
* - 只有**网络层失败**(fetch reject / 信封响应体为空或非 JSON)才重试;
|
|
583
|
+
* 已有响应体的 4xx/5xx(明文错误页)一律不重试;解封/解压失败(密钥问题)不重试;
|
|
584
|
+
* - 每次重试都重新封**新 nonce 的信封**:桥端 http 防重放是 5 分钟 nonce 滑动窗口,
|
|
585
|
+
* 复用同一个信封会被判 replay;
|
|
586
|
+
* - 调用方已 abort(切页/锁屏)→ 不再重试。
|
|
587
|
+
*/
|
|
588
|
+
var SE_RETRY_PATH_PREFIXES = ["/api/workspaceFiles/", "/api/session/page", "/api/officeToPdf/"];
|
|
589
|
+
var SE_RETRY_DELAY_MS = 300;
|
|
590
|
+
/**
|
|
591
|
+
* 路径是否命中幂等读白名单。
|
|
592
|
+
*
|
|
593
|
+
* ⚠️ 必须用 `seUpstreamPathOf()` 归一后再匹配 —— 与加密策略(seShouldEncryptHttp)
|
|
594
|
+
* **同一口径**:device 形态下客户端会把路径改写成 `/remote/<deviceId>/api/...`,
|
|
595
|
+
* 直接拿 `urlObj.pathname` 匹配会漏掉这类请求(同样是被加密的读,失败同样该重试)。
|
|
596
|
+
* 匹配规则:目录式前缀(以 "/" 结尾)按前缀;其余(如 `/api/session/page`)要求**精确相等** ——
|
|
597
|
+
* 这样 `/api/session/pages`、`/api/session/page/extra` 都不会被误命中。
|
|
598
|
+
*/
|
|
599
|
+
function seRetryableReadPath(fullPath) {
|
|
600
|
+
var up = "";
|
|
601
|
+
try { up = seUpstreamPathOf(fullPath); } catch (e) { return false; }
|
|
602
|
+
var p = String(up || "");
|
|
603
|
+
var q = p.indexOf("?");
|
|
604
|
+
if (q !== -1) p = p.slice(0, q);
|
|
605
|
+
for (var i = 0; i < SE_RETRY_PATH_PREFIXES.length; i++) {
|
|
606
|
+
var pre = SE_RETRY_PATH_PREFIXES[i];
|
|
607
|
+
if (pre.charAt(pre.length - 1) === "/") {
|
|
608
|
+
if (p.indexOf(pre) === 0) return true; // 目录式前缀(/api/workspaceFiles/、/api/officeToPdf/)
|
|
609
|
+
} else if (p === pre) {
|
|
610
|
+
return true; // 精确路径(/api/session/page)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
function seDelay(ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); }
|
|
616
|
+
/** 与 fetch 的 abort 语义一致(调用方已 abort 时不该再重试,直接抛 AbortError)。 */
|
|
617
|
+
function seAbortSignalError() {
|
|
618
|
+
try { return new DOMException("The operation was aborted.", "AbortError"); } catch (e) { var err = new Error("The operation was aborted."); err.name = "AbortError"; return err; }
|
|
619
|
+
}
|
|
574
620
|
/**
|
|
575
621
|
* fetch 包装核心:命中策略 → 密封→发→响应信封解密→重建 Response;
|
|
576
622
|
* 未命中/跨源/无法读取 body/明文响应 → 原样透传(零行为)。
|
|
623
|
+
* 幂等读路径的网络层失败最多重试一次(见上面 SE_RETRY_* 注释)。
|
|
577
624
|
* @param {function} origFetch
|
|
578
625
|
* @param {{sess: SeSession, origin: string}} ctx
|
|
579
626
|
*/
|
|
@@ -599,50 +646,69 @@
|
|
|
599
646
|
}
|
|
600
647
|
var upstream = seUpstreamPathOf(fullPath);
|
|
601
648
|
var plain = seEncodeHttpReqPlain({ method: req.method, path: upstream, headers: seHeadersForEnvelope(req.headers), bodyBytes: bodyBytes });
|
|
602
|
-
var
|
|
603
|
-
var
|
|
604
|
-
var
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
var
|
|
642
|
-
|
|
643
|
-
|
|
649
|
+
var attempts = seRetryableReadPath(fullPath) ? 2 : 1;
|
|
650
|
+
var lastErr = null;
|
|
651
|
+
for (var attempt = 0; attempt < attempts; attempt++) {
|
|
652
|
+
if (attempt > 0) {
|
|
653
|
+
if (req.signal && req.signal.aborted) throw seAbortSignalError();
|
|
654
|
+
await seDelay(SE_RETRY_DELAY_MS);
|
|
655
|
+
if (req.signal && req.signal.aborted) throw seAbortSignalError();
|
|
656
|
+
}
|
|
657
|
+
var n = seRandomBytes(SE_NONCE_BYTES);
|
|
658
|
+
var nonceB64 = seBytesToB64(n);
|
|
659
|
+
var env = await ctx.sess.seal({ kind: "http", dir: SE_DIR_P2B, counter: 0, data: plain, nonceBytes: n });
|
|
660
|
+
var outerHeaders = seEnvelopeHeaders(ctx.sess.sessId);
|
|
661
|
+
var cookieH = seHeaderValueOf(req.headers, "cookie");
|
|
662
|
+
if (cookieH && req.credentials !== "omit") outerHeaders.cookie = cookieH; // node 测试显式 cookie;浏览器同源自动带
|
|
663
|
+
var res;
|
|
664
|
+
try {
|
|
665
|
+
res = await origFetch(urlObj.href, {
|
|
666
|
+
method: "POST",
|
|
667
|
+
headers: outerHeaders,
|
|
668
|
+
body: JSON.stringify(env),
|
|
669
|
+
credentials: req.credentials,
|
|
670
|
+
signal: req.signal
|
|
671
|
+
});
|
|
672
|
+
} catch (e) {
|
|
673
|
+
lastErr = e;
|
|
674
|
+
if (attempt + 1 < attempts) continue; // 网络层失败 → 白名单读路径再来一次
|
|
675
|
+
throw e; // 与改动前一致:不吞 fetch 异常(调用方按网络错误处理)
|
|
676
|
+
}
|
|
677
|
+
/* router/桥端明文错误页 → 原样透传(零行为),但**先看一眼**是不是桥端在说"会话我不认识"
|
|
678
|
+
(x-dsh-e2ee-error)——是的话清掉本机失效交接单并给出明确原因(见 seNoteE2eeError)。
|
|
679
|
+
注意:这里已经有完整响应体,4xx/5xx 也**不重试**。 */
|
|
680
|
+
if (!seIsEnvelopeResponse(res)) { seNoteE2eeError(res, ctx); return res; }
|
|
681
|
+
var envResp = null;
|
|
682
|
+
try { envResp = await res.json(); } catch (e) {
|
|
683
|
+
// 信封响应体为空/被截断(网络层) → 白名单读路径再来一次
|
|
684
|
+
if (attempt + 1 < attempts) continue;
|
|
685
|
+
seNotifyFail("响应信封非法");
|
|
686
|
+
return seErrorResponse("bad_envelope", "⚠ 无法解密:响应信封非法");
|
|
687
|
+
}
|
|
688
|
+
var opened = null;
|
|
689
|
+
try {
|
|
690
|
+
opened = await ctx.sess.open({ kind: "http-resp", dir: SE_DIR_B2P, env: envResp, counter: envResp.c == null ? 0 : envResp.c, reqNonceB64: nonceB64 });
|
|
691
|
+
} catch (e) {
|
|
692
|
+
// 密钥不一致/被篡改/会话过期:重试解决不了,保持原有显式失败
|
|
693
|
+
var code = e && e.code ? e.code : "auth_failed";
|
|
694
|
+
seNotifyFail("响应解封失败: " + (e && e.message || code));
|
|
695
|
+
return seErrorResponse(code, "⚠ 无法解密(" + code + "):密钥不一致、传输被篡改或会话已过期。请返回设备列表重新解锁。");
|
|
696
|
+
}
|
|
697
|
+
var rp = null;
|
|
698
|
+
try { rp = seDecodeHttpRespPlain(opened.data); } catch (e) {
|
|
699
|
+
seNotifyFail("响应明文非法");
|
|
700
|
+
return seErrorResponse("bad_plain", "⚠ 无法解密:响应明文非法");
|
|
701
|
+
}
|
|
702
|
+
var bodyU8 = rp.bodyBytes instanceof Uint8Array ? rp.bodyBytes : (rp.bodyB64 ? seB64ToBytes(rp.bodyB64) : new Uint8Array(0));
|
|
703
|
+
if (rp.enc === "gzip" && bodyU8.length) {
|
|
704
|
+
var un = await seGunzip(bodyU8);
|
|
705
|
+
if (!un) return seErrorResponse("gunzip", "⚠ 无法解压:响应 gzip 解压失败");
|
|
706
|
+
bodyU8 = un;
|
|
707
|
+
}
|
|
708
|
+
return seBuildResponse(rp.status, rp.headers, bodyU8);
|
|
644
709
|
}
|
|
645
|
-
return
|
|
710
|
+
/* 每轮都以 return/throw 收尾,这里只是兜底(避免静态检查报"可能不返回") */
|
|
711
|
+
throw lastErr || seErr("e2ee_failed", "e2ee: fetch 包装未产生结果");
|
|
646
712
|
}
|
|
647
713
|
|
|
648
714
|
/* ---- 数据 WS 代理(逐消息信封;上层看到的是解密后的原文) ---- */
|