dsh-remote-web 0.7.6-beta.3 → 0.7.6-beta.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.6-beta.3",
3
+ "version": "0.7.6-beta.5",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -631,7 +631,10 @@ export function dcSend(dc, obj) {
631
631
  * · 每轮发送前先排空控制车道,并检查 `ws.bufferedAmount`:超过高水位就停手等它排空。
632
632
  * 于是控制帧前面最多只有「高水位 + 一片」的待发字节 —— 默认 64KB + 64KB,手机 1Mbps
633
633
  * 下 ≈1s,8Mbps 下 ≈0.13s,远小于 router 的 20s,也满足「大响应传输中 1s 内应答」目标。
634
- * · 小帧(交互消息 / SSE 块)单独一条车道,优先于大帧的下一片(交互不被整帧传输拖住)。
634
+ * · **数据帧严格 FIFO**(2026-10-24 修):早期实现让小帧(交互/SSE 块)插到大帧下一片之前,
635
+ * 这在同一 WS 流里是**乱序** —— 生产表现为手机远程页「历史加载失败:session event stream
636
+ * emitted an entry before its opening cursor」。现在只有一个数据队列,大帧按片就地推进,
637
+ * 绝不因大小/类型被后来的帧超越;只有控制帧(不承载有序数据)保留插队权。
635
638
  * · 阈值 / 分片大小可用环境变量调(见 tunnelBulkOptionsFromEnv),弱网部署可再调低。
636
639
  */
637
640
  export const TUNNEL_BULK_HWM_DEFAULT = 64 * 1024; // 发送缓冲高水位(字节)
@@ -680,13 +683,19 @@ export function makePrioritySender({
680
683
  let pumping = false;
681
684
  let scheduled = false;
682
685
  let timerPending = false;
683
- const smallQ = []; // 小帧(可整帧直发):交互消息 / SSE 块 —— 优先于大帧的下一片
684
- const bigQ = []; // 大帧:{ frames: string[], i }
685
- const stats = { controlSent: 0, smallSent: 0, bigFrames: 0, bigChunks: 0, waits: 0, dropped: 0 };
686
-
687
- function enqueueBulk(text) {
686
+ // 🔴 2026-10-24 事故修复:数据帧**必须严格 FIFO**。
687
+ // 旧实现分「小帧车道 / 大帧车道」,小帧会插到大帧下一片之前 —— 这违反了同一 WS 流里
688
+ // 消息的顺序语义,生产表现为手机远程页「历史加载失败:session event stream emitted an
689
+ // entry before its opening cursor(gateway/internal)」:事件流请求/响应被乱序送达,
690
+ // 客户端的游标推进到了前面,重连时服务端按该游标回放就触发 cursor 错乱。
691
+ // 现在:控制帧仍可插队(它们是协议控制帧,不承载有序数据);**数据帧只有一个队列**,
692
+ // 大帧按片就地推进,绝不因大小/类型被后来的帧超越。
693
+ const dataQ = []; // { frames: string[], i: number };frames.length===1 = 未分片小帧
694
+ const stats = { controlSent: 0, dataSent: 0, bigFrames: 0, bigChunks: 0, waits: 0, dropped: 0 };
695
+
696
+ function enqueueData(text) {
688
697
  if (text.length <= chunkSize) {
689
- smallQ.push(text);
698
+ dataQ.push({ frames: [text], i: 0 });
690
699
  return;
691
700
  }
692
701
  const cid = `c${++chunkSeq}`;
@@ -696,7 +705,7 @@ export function makePrioritySender({
696
705
  frames[i] = JSON.stringify({ __chunk: { id: cid, n, i, data: text.slice(i * chunkSize, (i + 1) * chunkSize) } });
697
706
  }
698
707
  stats.bigFrames += 1;
699
- bigQ.push({ frames, i: 0 });
708
+ dataQ.push({ frames, i: 0 });
700
709
  }
701
710
 
702
711
  function scheduleNext(waitMs) {
@@ -716,7 +725,7 @@ export function makePrioritySender({
716
725
  if (pumping || closed) return;
717
726
  pumping = true;
718
727
  let yieldMode = ""; // ""=没有剩余,"schedule"=让出事件循环,"poll"=等缓冲排空
719
- let budget = 32; // 一轮最多发多少条小帧
728
+ let budget = 64; // 一轮最多发多少条未分片小帧(大帧每轮只发一片)
720
729
  try {
721
730
  for (;;) {
722
731
  // ① 控制车道永远先走:哪怕大帧正传着 16MB,控制帧也在**下一轮**第一时间出去。
@@ -725,31 +734,31 @@ export function makePrioritySender({
725
734
  try { rawSend(frame); stats.controlSent += 1; } catch { closed = true; }
726
735
  }
727
736
  if (closed) { yieldMode = ""; break; }
728
- // ② 小帧优先于大帧的下一片:大包传输期间,交互消息 / SSE 块不该排在它后面。
729
- if (smallQ.length > 0 && budget > 0) {
730
- try { rawSend(smallQ.shift()); stats.smallSent += 1; } catch { closed = true; break; }
737
+ if (dataQ.length === 0) { yieldMode = ""; break; }
738
+ if (budget <= 0) { yieldMode = "schedule"; break; }
739
+ const head = dataQ[0];
740
+ if (head.frames.length === 1) {
741
+ // 未分片小帧:可连着多发几条,但绝不越过队头(保证 FIFO)
742
+ try { rawSend(head.frames[0]); stats.dataSent += 1; } catch { closed = true; break; }
743
+ dataQ.shift();
731
744
  budget -= 1;
732
745
  continue; // 回环顶先查控制车道
733
746
  }
734
- if (smallQ.length > 0) { yieldMode = "schedule"; break; } // 一轮预算用完
735
- if (bigQ.length === 0) { yieldMode = ""; break; }
736
- // ③ 背压:已入队字节超过高水位 → 停手等排空,别把控制帧埋进几十秒的队列里。
747
+ // 队头是大帧:每轮只推一片,片间让出事件循环(控制帧/入站帧不被饿死),顺序不变
737
748
  let pending = 0;
738
749
  try { pending = Number(bufferedAmount()) || 0; } catch { pending = 0; }
739
750
  if (pending > bulkHighWater) { stats.waits += 1; yieldMode = "poll"; break; }
740
- const item = bigQ[0];
741
- try { rawSend(item.frames[item.i]); stats.bigChunks += 1; } catch { closed = true; break; }
742
- item.i += 1;
743
- if (item.i >= item.frames.length) bigQ.shift();
744
- yieldMode = "schedule"; // 大帧:一片一让,给入站帧 / 控制帧 / 小帧机会
751
+ try { rawSend(head.frames[head.i]); stats.dataSent += 1; stats.bigChunks += 1; } catch { closed = true; break; }
752
+ head.i += 1;
753
+ if (head.i >= head.frames.length) dataQ.shift();
754
+ yieldMode = "schedule";
745
755
  break;
746
756
  }
747
757
  } finally {
748
758
  pumping = false;
749
759
  if (closed) {
750
760
  controlQ.length = 0;
751
- smallQ.length = 0;
752
- bigQ.length = 0;
761
+ dataQ.length = 0;
753
762
  } else if (yieldMode) {
754
763
  scheduleNext(yieldMode === "poll" ? pollMs : 0);
755
764
  }
@@ -761,7 +770,7 @@ export function makePrioritySender({
761
770
  try { text = JSON.stringify(obj); } catch { return false; }
762
771
  if (closed) { stats.dropped += 1; return false; }
763
772
  if (isControlFrame(obj)) controlQ.push(text);
764
- else enqueueBulk(text);
773
+ else enqueueData(text);
765
774
  pump();
766
775
  return true;
767
776
  }
@@ -770,8 +779,7 @@ export function makePrioritySender({
770
779
  function close() {
771
780
  closed = true;
772
781
  controlQ.length = 0;
773
- smallQ.length = 0;
774
- bigQ.length = 0;
782
+ dataQ.length = 0;
775
783
  }
776
784
 
777
785
  return {
@@ -779,11 +787,11 @@ export function makePrioritySender({
779
787
  close,
780
788
  stats: () => ({
781
789
  ...stats,
782
- queued: smallQ.length + bigQ.length,
790
+ queued: dataQ.length,
783
791
  controlQueued: controlQ.length,
784
792
  closed
785
793
  }),
786
- hasBacklog: () => smallQ.length > 0 || bigQ.length > 0 || controlQ.length > 0
794
+ hasBacklog: () => dataQ.length > 0 || controlQ.length > 0
787
795
  };
788
796
  }
789
797
 
@@ -1223,6 +1223,18 @@ async function upstreamReachable(url = upstreamUrl(), timeoutMs = 1200) {
1223
1223
  */
1224
1224
  async function isDshWebUp(timeoutMs = 1200) {
1225
1225
  if (await upstreamReachable(upstreamUrl(), timeoutMs)) return true;
1226
+ // 🔴 2026-10-25(设备掉线实测):Electron 每次启动可能换端口。端口文件由 dsh web 内的插件
1227
+ // 在每次启动时重写,比进程内的 discoveredUpstream 缓存新 —— 先直接试文件,别被旧缓存挡住。
1228
+ try {
1229
+ const fromFile = String(fs.readFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), "utf8")).trim();
1230
+ if (/^https?:\/\/[^\s]+$/i.test(fromFile)) {
1231
+ const normalized = fromFile.replace(/\/+$/, "");
1232
+ if (normalized !== upstreamUrl() && (await upstreamReachable(normalized, timeoutMs))) {
1233
+ discoveredUpstream = normalized;
1234
+ return true;
1235
+ }
1236
+ }
1237
+ } catch { /* 没有端口文件:继续走动态发现 */ }
1226
1238
  const before = upstreamUrl();
1227
1239
  const after = await refreshUpstreamDiscovery(true);
1228
1240
  if (after === before) return false;
@@ -1859,6 +1871,9 @@ async function runBridge() {
1859
1871
  const alive = await checkUpstream();
1860
1872
  if (alive && childStopped(bridgeProc)) {
1861
1873
  starting = true;
1874
+ // dsh 重启可能换端口(Electron 每次启动端口可能变):拉起前**强制重新发现**,
1875
+ // 否则 discoveredUpstream 缓存旧端口 → bridge 连不上上游 → 设备掉线(2026-10-25 实测)。
1876
+ await refreshUpstreamDiscovery(true);
1862
1877
  console.log("[dsh-remote] dsh web 在线,启动 bridge...");
1863
1878
  // 清除代理环境变量(bridge 需直连 relay,不受本机代理影响)
1864
1879
  const childEnv = {
@@ -1913,6 +1928,8 @@ async function runBridge() {
1913
1928
  setTimeout(() => { starting = false; }, 5000);
1914
1929
  } else if (!alive && bridgeProc && bridgeProc.exitCode === null) {
1915
1930
  console.log("[dsh-remote] dsh web 离线,停止 bridge...");
1931
+ // dsh 重启后端口可能变化:清掉进程内缓存,下一轮优先用插件新写的端口文件探测
1932
+ discoveredUpstream = "";
1916
1933
  await stopChildGracefully(bridgeProc); // 同上:Windows 上 kill() 跑不到退出钩子
1917
1934
  }
1918
1935
  };