dsh-multi-chat 0.5.1 → 0.5.2

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-multi-chat",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "DSH 多对话:在官方 DeepSeek Harness Web 界面里并排运行和监控多个对话实例(多窗口墙),并内置带口令认证的手机访问网关。npx dsh-multi-chat install|start|stop",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,6 +18,9 @@ import { connect, createServer } from "node:net";
18
18
  * `127.0.0.1:<target-port>`, rewriting Host/Origin so the official `/api`
19
19
  * browser-trust fence treats it as a local request. WebSocket upgrades pass
20
20
  * through untouched because the socket is piped byte-for-byte after the head.
21
+ * The HTML document response is buffered once so a `crypto.randomUUID`
22
+ * polyfill can be injected for phones (insecure-origin HTTP); all other
23
+ * responses stream through unchanged.
21
24
  *
22
25
  * Zero dependencies: node:net + node:crypto only.
23
26
  * @module @deepseek-ai/dsh-client-ui-multi-wall/gateway
@@ -140,6 +143,29 @@ cache-control: no-store\r
140
143
  socket.end();
141
144
  }
142
145
  /**
146
+ * `crypto.randomUUID` exists only in a *secure context* (HTTPS or localhost).
147
+ * A phone reaching the instance via `http://<LAN-IP>:<gateway-port>` is an
148
+ * insecure origin, so the official DSH client throws
149
+ * `crypto.randomUUID is not a function` on its first RPC and renders blank.
150
+ * This polyfill supplies the same RFC-4122 v4 form using
151
+ * `crypto.getRandomValues`, which insecure origins still expose.
152
+ */
153
+ const POLYFILL_SCRIPT = "<script>(function(){if(typeof crypto!==\"undefined\"&&typeof crypto.randomUUID===\"undefined\"&&typeof crypto.getRandomValues===\"function\"){crypto.randomUUID=function(){var b=crypto.getRandomValues(new Uint8Array(16));b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;var h=Array.prototype.map.call(b,function(x){return x.toString(16).padStart(2,\"0\")}).join(\"\");return h.slice(0,8)+\"-\"+h.slice(8,12)+\"-\"+h.slice(12,16)+\"-\"+h.slice(16,20)+\"-\"+h.slice(20);};}})();<\/script>";
154
+ /**
155
+ * Rewrite a buffered HTML response body to inject the `randomUUID` polyfill
156
+ * into the top of `<head>` (before the DSH bootstrap script runs). Returns the
157
+ * new body, or `null` when the polyfill is not needed (already present, or the
158
+ * response carries no `<head>` to inject into).
159
+ */
160
+ function injectPolyfill(body) {
161
+ const html = body.toString("utf8");
162
+ if (html.includes("randomUUID")) return null;
163
+ const headMatch = /<head[^>]*>/i.exec(html);
164
+ if (headMatch === null) return null;
165
+ const insertAt = headMatch.index + headMatch[0].length;
166
+ return Buffer.from(html.slice(0, insertAt) + POLYFILL_SCRIPT + html.slice(insertAt), "utf8");
167
+ }
168
+ /**
143
169
  * Rebuild the request head for the loopback target. Host and Origin point at
144
170
  * `127.0.0.1:<target-port>` so the DSH `/api` browser-trust fence sees a local
145
171
  * request. Non-upgrade requests get `connection: close` (fresh connection per
@@ -258,6 +284,110 @@ connection: close\r
258
284
  socket.once("end", done);
259
285
  }
260
286
  };
287
+ /**
288
+ * Decode an HTTP/1.1 `transfer-encoding: chunked` body back to its raw
289
+ * bytes. Returns `null` on malformed framing (caller then relays verbatim).
290
+ */
291
+ const decodeChunked = (raw) => {
292
+ const out = [];
293
+ let i = 0;
294
+ const text = raw.toString("latin1");
295
+ while (true) {
296
+ const crlf = text.indexOf("\r\n", i);
297
+ if (crlf === -1) return null;
298
+ const sizeHex = text.slice(i, crlf).trim();
299
+ const size = parseInt(sizeHex, 16);
300
+ if (!Number.isFinite(size) || size < 0) return null;
301
+ i = crlf + 2;
302
+ if (size === 0) return Buffer.concat(out);
303
+ if (i + size > raw.length) return null;
304
+ out.push(raw.subarray(i, i + size));
305
+ i += size + 2;
306
+ }
307
+ };
308
+ /**
309
+ * Relay the DSH target's response to the phone client. HTML document
310
+ * responses (the DSH shell) are buffered so the `randomUUID` polyfill can be
311
+ * injected before the bootstrap script runs; every other response
312
+ * (assets, API JSON, SSE streams, WebSocket upgrades) is piped byte-for-byte
313
+ * so streaming and framing stay untouched.
314
+ */
315
+ const relayTarget = (socket, target) => {
316
+ let head = Buffer.alloc(0);
317
+ let bodyParts = null;
318
+ let relayed = false;
319
+ const pipeThrough = () => {
320
+ if (relayed) return;
321
+ relayed = true;
322
+ if (head.length > 0) socket.write(head);
323
+ if (bodyParts !== null && bodyParts.length > 0) {
324
+ socket.write(Buffer.concat(bodyParts));
325
+ bodyParts = null;
326
+ }
327
+ target.pipe(socket);
328
+ };
329
+ const finishInjection = (fullHead, fullBody) => {
330
+ const rewritten = injectPolyfill(fullBody);
331
+ if (rewritten === null) {
332
+ socket.write(fullHead);
333
+ socket.write(fullBody);
334
+ socket.end();
335
+ return;
336
+ }
337
+ const lines = fullHead.toString("latin1").split("\r\n");
338
+ const filtered = [];
339
+ for (const line of lines) {
340
+ const lower = line.toLowerCase();
341
+ if (lower.startsWith("transfer-encoding:")) continue;
342
+ if (lower.startsWith("content-length:")) continue;
343
+ if (lower.startsWith("connection:")) continue;
344
+ filtered.push(line);
345
+ }
346
+ while (filtered.length > 0 && filtered[filtered.length - 1] === "") filtered.pop();
347
+ filtered.push(`content-length: ${rewritten.length}`, "connection: close", "", "");
348
+ socket.write(Buffer.from(filtered.join("\r\n"), "latin1"));
349
+ socket.write(rewritten);
350
+ socket.end();
351
+ };
352
+ target.on("data", (chunk) => {
353
+ if (relayed) return;
354
+ if (head.indexOf("\r\n\r\n") === -1) {
355
+ head = head.length === 0 ? chunk : Buffer.concat([head, chunk]);
356
+ const newIdx = head.indexOf("\r\n\r\n");
357
+ if (newIdx === -1) {
358
+ if (head.length > 65536) pipeThrough();
359
+ return;
360
+ }
361
+ const headBuf = head.subarray(0, newIdx + 4);
362
+ const bodyStart = head.subarray(newIdx + 4);
363
+ const headText = headBuf.toString("latin1");
364
+ const status = /^HTTP\/\d\.\d (\d{3})/.exec(headText)?.[1] ?? "";
365
+ const contentType = /^content-type:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? "";
366
+ const ce = /^content-encoding:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? "";
367
+ const isHtml = status === "200" && /text\/html/i.test(contentType);
368
+ const compressed = ce !== "" && !/identity/i.test(ce);
369
+ if (!isHtml || compressed) {
370
+ pipeThrough();
371
+ return;
372
+ }
373
+ bodyParts = bodyStart.length > 0 ? [bodyStart] : [];
374
+ return;
375
+ }
376
+ if (bodyParts !== null) bodyParts.push(chunk);
377
+ });
378
+ target.once("end", () => {
379
+ if (relayed || bodyParts === null) {
380
+ if (!relayed) socket.end();
381
+ return;
382
+ }
383
+ const headBuf = head.subarray(0, head.indexOf("\r\n\r\n") + 4);
384
+ const headText = headBuf.toString("latin1");
385
+ const rawBody = Buffer.concat(bodyParts);
386
+ const te = /^transfer-encoding:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? "";
387
+ finishInjection(headBuf, /chunked/i.test(te) ? decodeChunked(rawBody) ?? rawBody : rawBody);
388
+ });
389
+ target.once("error", () => socket.destroy());
390
+ };
261
391
  const handleSocket = (socket) => {
262
392
  let buffer = Buffer.alloc(0);
263
393
  let headDone = false;
@@ -300,7 +430,7 @@ connection: close\r
300
430
  if (rest.length > 0) target.write(rest);
301
431
  proxying = true;
302
432
  socket.pipe(target);
303
- target.pipe(socket);
433
+ relayTarget(socket, target);
304
434
  });
305
435
  target.on("error", (error) => {
306
436
  if (!connected) respondPlain(socket, "502", `cannot reach target 127.0.0.1:${targetPort}: ${error.message}`);