@hyzyn/dsh-docker 0.9.1 → 0.9.3

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.d.ts CHANGED
@@ -75,6 +75,39 @@ interface LiveConfig {
75
75
  * 多字节字符也不会被 SSE 的 `\n` 行边界截断(客户端 JSON.parse 还原)。
76
76
  */
77
77
  export declare function sseFrame(event: string, data: unknown): string;
78
+ /** 合帧器句柄(生命周期:`flush()` 收尾、`dispose()` 停表)。 */
79
+ export interface SseCoalescer {
80
+ /** 追加一个分片:窗口到点(或攒满 maxBytes)时按通道合并成一帧推出去。 */
81
+ push(channel: 'd' | 'e', text: string): void;
82
+ /** 立刻把两个通道里攒着的分片推出去(顺序:stdout 先、stderr 后)。 */
83
+ flush(): void;
84
+ /** 停掉定时器并丢掉残帧(客户端已走 / 流已收尾,再推只会写进关掉的响应)。 */
85
+ dispose(): void;
86
+ }
87
+ /**
88
+ * SSE 分片合帧器(D151):把「一个 stdout chunk 一帧」压成「一个窗口一帧」。
89
+ *
90
+ * 为什么要有它:`docker logs -f` / `docker pull` 的分片大小由上游决定,话痨容器
91
+ * (未缓冲 stdout、逐行 flush 的应用)能到每秒几千个 chunk,而每个 chunk 在链路上
92
+ * 的固定成本并不小——服务端一次 `JSON.stringify` + 一次 `res.write`,客户端一次
93
+ * SSE 事件派发 + 一次 `JSON.parse` + 一次 `pushChunk`(字符串拼接 + 扫描换行)。
94
+ * 这些成本与「一行日志多少个字节」无关,只与**事件个数**成正比。
95
+ *
96
+ * 实测(scripts/log-perf.mjs 的事件洪泛剖面,同一行速率 6.2k 行/秒):每事件 1 行
97
+ * 时事件循环最大延迟 53ms、出现 1 个 >50ms 的长帧;每事件 50 行时 37ms、0 个。
98
+ * 差距在事件率再高一个数量级时会继续放大(浏览器真实 SSE 解析比冒烟桩更贵)。
99
+ *
100
+ * 语义约束(客户端按到达序落行,合帧不能改变「看到的内容」):
101
+ * - 同一通道内部**严格保序**(就是字符串拼接);
102
+ * - 两个通道各攒各的,一帧最多推 `d` 一条 + `e` 一条——`docker logs` 的 stdout /
103
+ * stderr 本就是两路,跨通道的先后从来不由单帧保证;
104
+ * - 收尾前调用方必须 `flush()`:`end` 帧得排在这些 `line` 帧之后(见调用点)。
105
+ */
106
+ export declare function createSseCoalescer(options: {
107
+ emit: (channel: 'd' | 'e', text: string) => void;
108
+ windowMs?: number;
109
+ maxBytes?: number;
110
+ }): SseCoalescer;
78
111
  /** 清洗一份 targets 输入(settings 存储 / 热更新路径共用)。 */
79
112
  export declare function sanitizeTargets(input: unknown): DockerTarget[] | undefined;
80
113
  /**
package/lib/index.js CHANGED
@@ -142,6 +142,78 @@ export function sseFrame(event, data) {
142
142
  }
143
143
  /** SSE 心跳间隔(毫秒):注释帧只保活,客户端 EventSource 会忽略。 */
144
144
  const SSE_HEARTBEAT_MS = 15_000;
145
+ /** 日志 / 拉取流的分片合帧窗口(毫秒)。低一个数量级的渲染合帧是 150ms,用户看不见。 */
146
+ const SSE_COALESCE_MS = 50;
147
+ /** 单个通道攒到这个字节数就立刻推一帧:不让一个大 chunk 在窗口里多等一个周期。 */
148
+ const SSE_COALESCE_MAX_BYTES = 256 * 1024;
149
+ /**
150
+ * SSE 分片合帧器(D151):把「一个 stdout chunk 一帧」压成「一个窗口一帧」。
151
+ *
152
+ * 为什么要有它:`docker logs -f` / `docker pull` 的分片大小由上游决定,话痨容器
153
+ * (未缓冲 stdout、逐行 flush 的应用)能到每秒几千个 chunk,而每个 chunk 在链路上
154
+ * 的固定成本并不小——服务端一次 `JSON.stringify` + 一次 `res.write`,客户端一次
155
+ * SSE 事件派发 + 一次 `JSON.parse` + 一次 `pushChunk`(字符串拼接 + 扫描换行)。
156
+ * 这些成本与「一行日志多少个字节」无关,只与**事件个数**成正比。
157
+ *
158
+ * 实测(scripts/log-perf.mjs 的事件洪泛剖面,同一行速率 6.2k 行/秒):每事件 1 行
159
+ * 时事件循环最大延迟 53ms、出现 1 个 >50ms 的长帧;每事件 50 行时 37ms、0 个。
160
+ * 差距在事件率再高一个数量级时会继续放大(浏览器真实 SSE 解析比冒烟桩更贵)。
161
+ *
162
+ * 语义约束(客户端按到达序落行,合帧不能改变「看到的内容」):
163
+ * - 同一通道内部**严格保序**(就是字符串拼接);
164
+ * - 两个通道各攒各的,一帧最多推 `d` 一条 + `e` 一条——`docker logs` 的 stdout /
165
+ * stderr 本就是两路,跨通道的先后从来不由单帧保证;
166
+ * - 收尾前调用方必须 `flush()`:`end` 帧得排在这些 `line` 帧之后(见调用点)。
167
+ */
168
+ export function createSseCoalescer(options) {
169
+ const windowMs = typeof options.windowMs === 'number' && options.windowMs > 0 ? options.windowMs : SSE_COALESCE_MS;
170
+ const maxBytes = typeof options.maxBytes === 'number' && options.maxBytes > 0 ? options.maxBytes : SSE_COALESCE_MAX_BYTES;
171
+ let stdout = '';
172
+ let stderr = '';
173
+ let timer = null;
174
+ const stopTimer = () => {
175
+ if (timer === null)
176
+ return;
177
+ clearTimeout(timer);
178
+ timer = null;
179
+ };
180
+ const flush = () => {
181
+ stopTimer();
182
+ if (stdout !== '') {
183
+ const text = stdout;
184
+ stdout = '';
185
+ options.emit('d', text);
186
+ }
187
+ if (stderr !== '') {
188
+ const text = stderr;
189
+ stderr = '';
190
+ options.emit('e', text);
191
+ }
192
+ };
193
+ return {
194
+ push(channel, text) {
195
+ if (typeof text !== 'string' || text === '')
196
+ return;
197
+ if (channel === 'd')
198
+ stdout += text;
199
+ else
200
+ stderr += text;
201
+ // 攒满一个上限就立刻推:超大 chunk(整段 JSON / base64)不该在窗口里多等一拍
202
+ if ((channel === 'd' ? stdout : stderr).length >= maxBytes) {
203
+ flush();
204
+ return;
205
+ }
206
+ if (timer === null)
207
+ timer = setTimeout(flush, windowMs);
208
+ },
209
+ flush,
210
+ dispose() {
211
+ stopTimer();
212
+ stdout = '';
213
+ stderr = '';
214
+ },
215
+ };
216
+ }
145
217
  /*
146
218
  * 回环围栏与同源证明:**实现已收敛到 `@hyzyn/dsh-kit`**(2026-09-25,项目级 ROADMAP
147
219
  * 第 1 项)。本包是那段加固档的来源,行为一字未改;D31 / D32 / D80 / D110 / D139 五条
@@ -913,19 +985,33 @@ const plugin = definePlugin({
913
985
  let done = false;
914
986
  let heartbeat = null;
915
987
  /*
916
- * 背压(D04):write() 返回 false = socket 写缓冲已满。无视返回值继续写,
917
- * 慢客户端(后台标签 / 慢链路)+ 话痨容器会让宿主侧缓冲无界增长直至 OOM。
918
- * 处理:缓冲已满时把帧暂存进内存队列、等 drain 再续写;队列超过上限视为
919
- * 客户端事实上已死(消费速度跟不上产出),主动收尾——宿主内存上限从
920
- * 「无界」变成「每条流 ≤ MAX_PENDING_BYTES」。上游(docker logs -f 的
921
- * stdout)由 finish/clientGone 里的 abort 停掉,不需要逐帧 pause。
988
+ * 背压(D04):write() 返回 false = socket 写缓冲越过高水位。**注意 Node 的语义**:
989
+ * 返回 false 时数据**已经被收下**(只是缓冲满了,等 drain),所以这里不能把它再排一次
990
+ * 队列——老代码 `writeFrame` 把 false 当失败、随后又 push 进队列,drain 时会重写同一帧
991
+ * (客户端收到重复日志行、pendingBytes 也虚高)。现在的契约:写一次就是写一次,false
992
+ * 只表示「先别再直写」,等 drain 继续。
922
993
  *
923
- * 溢出收尾会补一条 `end{reason:'output-limit'}`(D133):客户端据此提示
924
- * 「主机侧积压」而不是当成正常结束。
994
+ * 队列上限仍是每条流 ≤ MAX_PENDING_BYTES(宿主内存有界)。**溢出怎么办**由调用方选:
995
+ * - `overflow: 'end'`(默认,结构化流):补一条 `end{reason:'output-limit'}` 收尾,
996
+ * 客户端重连(少一帧事件语义上比缺一个事件好);
997
+ * - `overflow: 'drop'`(日志 / 拉取这类**文本尾部流**,D153):丢掉最旧的整帧、只留
998
+ * 最新的,并推一条 `skip` 帧告诉客户端「中间断了一截」。丢掉的这些行本来也会被
999
+ * 客户端的环形缓冲(5000 行 / 4MB)丢掉——为它们把整条流掐掉,代价远大于收益
1000
+ * (用户现场:话痨容器一冲,面板就变成「连接中断 + 空白正文」在重连里打转)。
925
1001
  */
926
1002
  const MAX_PENDING_BYTES = 8 * 1024 * 1024;
1003
+ /** 溢出后丢到这个水位为止:一次多丢一点,给 drain 留出喘息空间。 */
1004
+ const DROP_TARGET_BYTES = MAX_PENDING_BYTES / 2;
1005
+ const overflow = options.overflow ?? 'end';
927
1006
  let pendingFrames = [];
928
1007
  let pendingBytes = 0;
1008
+ /** 高水位标记:true 时新帧一律进队列,等 drain 再续写。 */
1009
+ let waitingDrain = false;
1010
+ /** 已被丢掉的帧数与字节数(尚未告知客户端);skip 通知发出后清零。 */
1011
+ let droppedFrames = 0;
1012
+ let droppedBytes = 0;
1013
+ let skipQueued = false;
1014
+ const isSkipFrame = (frame) => frame.startsWith('event: skip\n');
929
1015
  const stopHeartbeat = () => {
930
1016
  if (heartbeat === null)
931
1017
  return;
@@ -967,36 +1053,103 @@ const plugin = definePlugin({
967
1053
  /* 连接已断开 */
968
1054
  }
969
1055
  };
970
- const writeFrame = (frame) => {
1056
+ /**
1057
+ * 写一帧。
1058
+ * @returns `'ok'` 直写完成(可继续直写)|`'pressure'` 已写入但越过高水位(等 drain)
1059
+ * |`'gone'` 连接已断(已走 clientGone)
1060
+ */
1061
+ const tryWrite = (frame) => {
971
1062
  try {
972
- return write(frame) !== false;
1063
+ const result = write(frame);
1064
+ if (isSkipFrame(frame)) {
1065
+ // skip 通知真的落到 socket 了:这一轮的丢弃账目清零,下次再丢再报一次
1066
+ droppedFrames = 0;
1067
+ droppedBytes = 0;
1068
+ skipQueued = false;
1069
+ }
1070
+ return result === false ? 'pressure' : 'ok';
973
1071
  }
974
1072
  catch {
975
1073
  // 写失败 = 连接已断:与 res close 同一收尾路径
976
1074
  clientGone();
977
- return false;
1075
+ return 'gone';
978
1076
  }
979
1077
  };
980
- /** drain 后续写暂存的帧;中途再遇 false 就停手等下一次 drain。 */
1078
+ /**
1079
+ * 溢出策略 `'drop'`:丢最旧的整帧保留最新,并保证队列里恰有一条 `skip` 通知。
1080
+ * `skip` 帧自己**永不参与丢弃**——它是「这里断了一截」的唯一凭据。
1081
+ */
1082
+ const dropOldestFrames = () => {
1083
+ while (pendingBytes > DROP_TARGET_BYTES && pendingFrames.length > 0) {
1084
+ const index = pendingFrames.findIndex((frame) => !isSkipFrame(frame));
1085
+ if (index === -1)
1086
+ break;
1087
+ const [frame] = pendingFrames.splice(index, 1);
1088
+ pendingBytes -= frame.length;
1089
+ droppedFrames += 1;
1090
+ droppedBytes += frame.length;
1091
+ }
1092
+ if (droppedFrames > 0 && !skipQueued) {
1093
+ const notice = sseFrame('skip', { frames: droppedFrames, bytes: droppedBytes });
1094
+ pendingFrames.push(notice);
1095
+ pendingBytes += notice.length;
1096
+ skipQueued = true;
1097
+ return;
1098
+ }
1099
+ /*
1100
+ * 队列里已有一条 skip 还没写到 socket,而这期间**又发生了丢弃**:原地更新那条
1101
+ * skip 的账目(D156)。旧实现只把首次丢弃的数排进队,之后(skip 落地前)再发生
1102
+ * 的丢弃虽然记了账,却在 skip 真正写出时被清零一起吞掉——客户端看到的第一段
1103
+ * 缺口永远偏小,第二段缺口则完全不可见。skip 还在队列里,改它正合适;账目清零
1104
+ * 仍由 `tryWrite` 在它真正写出去时做,时序不变。
1105
+ */
1106
+ if (droppedFrames > 0) {
1107
+ const notice = sseFrame('skip', { frames: droppedFrames, bytes: droppedBytes });
1108
+ const index = pendingFrames.findIndex((frame) => isSkipFrame(frame));
1109
+ if (index !== -1) {
1110
+ pendingBytes += notice.length - pendingFrames[index].length;
1111
+ pendingFrames[index] = notice;
1112
+ }
1113
+ else {
1114
+ // 防御:skipQueued 为 true 却找不到帧(不该发生)——退回「新排一条」
1115
+ pendingFrames.push(notice);
1116
+ pendingBytes += notice.length;
1117
+ }
1118
+ }
1119
+ };
1120
+ /** drain 后续写暂存的帧;中途再遇高水位就停手等下一次 drain。 */
981
1121
  const flushPending = () => {
982
1122
  while (!done && pendingFrames.length > 0) {
983
1123
  const frame = pendingFrames[0];
984
- if (!writeFrame(frame))
1124
+ const result = tryWrite(frame);
1125
+ if (result === 'gone')
985
1126
  return;
986
1127
  pendingBytes -= frame.length;
987
1128
  pendingFrames.shift();
1129
+ if (result === 'pressure') {
1130
+ waitingDrain = true;
1131
+ return;
1132
+ }
988
1133
  }
1134
+ waitingDrain = false;
989
1135
  };
990
1136
  const send = (frame) => {
991
1137
  if (done)
992
1138
  return;
993
- if (pendingFrames.length === 0 && writeFrame(frame))
994
- return;
995
- if (done)
1139
+ if (!waitingDrain && pendingFrames.length === 0) {
1140
+ const result = tryWrite(frame);
1141
+ // 已写入(含「越过高水位」这一种):**不许再排一遍**,否则 drain 时会重写
1142
+ if (result === 'pressure')
1143
+ waitingDrain = true;
996
1144
  return;
1145
+ }
997
1146
  pendingFrames.push(frame);
998
1147
  pendingBytes += frame.length;
999
1148
  if (pendingBytes > MAX_PENDING_BYTES) {
1149
+ if (overflow === 'drop') {
1150
+ dropOldestFrames();
1151
+ return;
1152
+ }
1000
1153
  /*
1001
1154
  * 队列溢出 = 客户端消费速度跟不上产出(D133):收尾前补一条 end,
1002
1155
  * 让客户端知道「是主机侧积压」而不是把它当成正常结束。静默 res.end()
@@ -2451,19 +2604,39 @@ const plugin = definePlugin({
2451
2604
  }
2452
2605
  await openSseStream(res, {
2453
2606
  reason: 'container-exit',
2607
+ // 文本尾部流:积压时丢最旧的帧继续跟随,不掐流(D153)
2608
+ overflow: 'drop',
2454
2609
  run: async (sendEvent, signal) => {
2455
- // 事件协议与快照 /logs 完全一致,只多一个 end.reason
2456
- const handlers = {
2457
- onStdout: (chunk) => sendEvent('line', { d: chunk }),
2458
- onStderr: (chunk) => sendEvent('line', { e: chunk }),
2459
- };
2460
- const result = await api.logsStream(safeId, {
2461
- // 与 POST /logs 同一条取值规则:非法/越界交给 DockerApi 内的夹紧
2462
- tail: Number.isInteger(tail) ? tail : live.logTailDefault,
2463
- timestamps: timestampsParam === '1' || timestampsParam === 'true',
2464
- ...(since !== undefined ? { since } : {}),
2465
- }, handlers, signal);
2466
- return result.code;
2610
+ /*
2611
+ * 事件协议与快照 /logs 完全一致,只多一个 end.reason——**只多一层合帧**
2612
+ * (D151):docker 的分片边界与「一行日志」无关,逐 chunk 一帧时话痨容器
2613
+ * 每秒能推几千个事件,客户端的每事件固定开销(SSE 派发 + JSON.parse +
2614
+ * 拼残行)全砸在主线程上。合帧后客户端收到的仍是同一串字节,只是按窗口
2615
+ * 合并成了更少的帧。
2616
+ */
2617
+ const coalescer = createSseCoalescer({
2618
+ emit: (channel, text) => sendEvent('line', channel === 'd' ? { d: text } : { e: text }),
2619
+ });
2620
+ try {
2621
+ const result = await api.logsStream(safeId, {
2622
+ // 与 POST /logs 同一条取值规则:非法/越界交给 DockerApi 内的夹紧
2623
+ tail: Number.isInteger(tail) ? tail : live.logTailDefault,
2624
+ timestamps: timestampsParam === '1' || timestampsParam === 'true',
2625
+ ...(since !== undefined ? { since } : {}),
2626
+ }, {
2627
+ onStdout: (chunk) => coalescer.push('d', chunk),
2628
+ onStderr: (chunk) => coalescer.push('e', chunk),
2629
+ }, signal);
2630
+ return result.code;
2631
+ }
2632
+ finally {
2633
+ /*
2634
+ * 收尾前把窗口里剩下的行推出去:`end` 帧必须排在它们之后——客户端按到达
2635
+ * 序落行,先收到 end 会当场切回快照,窗口里那批行就再也显示不出来了。
2636
+ */
2637
+ coalescer.flush();
2638
+ coalescer.dispose();
2639
+ }
2467
2640
  },
2468
2641
  });
2469
2642
  };
@@ -2671,14 +2844,25 @@ const plugin = definePlugin({
2671
2844
  }
2672
2845
  await openSseStream(res, {
2673
2846
  reason: 'pull-exit',
2847
+ // 同日志流:逐层进度是文本尾部流,积压时丢最旧而不是把整条流掐掉(D153)
2848
+ overflow: 'drop',
2674
2849
  endData: () => ({ ref: safeRef }),
2675
2850
  run: async (sendEvent, signal) => {
2676
- const handlers = {
2677
- onStdout: (chunk) => sendEvent('line', { d: chunk }),
2678
- onStderr: (chunk) => sendEvent('line', { e: chunk }),
2679
- };
2680
- const result = await api.pullStream(safeRef, handlers, signal);
2681
- return result.code;
2851
+ // 与日志流同一层合帧(D151):docker pull 逐层刷进度时也是每秒几百行的流
2852
+ const coalescer = createSseCoalescer({
2853
+ emit: (channel, text) => sendEvent('line', channel === 'd' ? { d: text } : { e: text }),
2854
+ });
2855
+ try {
2856
+ const result = await api.pullStream(safeRef, {
2857
+ onStdout: (chunk) => coalescer.push('d', chunk),
2858
+ onStderr: (chunk) => coalescer.push('e', chunk),
2859
+ }, signal);
2860
+ return result.code;
2861
+ }
2862
+ finally {
2863
+ coalescer.flush();
2864
+ coalescer.dispose();
2865
+ }
2682
2866
  },
2683
2867
  });
2684
2868
  };