dsh-multi-chat 0.5.1 → 0.6.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.
package/package.json
CHANGED
|
@@ -242,27 +242,27 @@ window.__ModuleLoader__.load({
|
|
|
242
242
|
document.head.appendChild(tag);
|
|
243
243
|
}
|
|
244
244
|
var WallView_module_css_default = {
|
|
245
|
-
"
|
|
246
|
-
"
|
|
247
|
-
"confirm": "ouhzTG_confirm",
|
|
248
|
-
"field": "ouhzTG_field",
|
|
245
|
+
"paneActions": "ouhzTG_paneActions",
|
|
246
|
+
"linkBar": "ouhzTG_linkBar",
|
|
249
247
|
"wall": "ouhzTG_wall",
|
|
250
|
-
"
|
|
248
|
+
"action": "ouhzTG_action",
|
|
249
|
+
"danger": "ouhzTG_danger",
|
|
250
|
+
"paneBody": "ouhzTG_paneBody",
|
|
251
|
+
"title": "ouhzTG_title",
|
|
251
252
|
"hint": "ouhzTG_hint",
|
|
253
|
+
"empty": "ouhzTG_empty",
|
|
254
|
+
"zoomed": "ouhzTG_zoomed",
|
|
255
|
+
"field": "ouhzTG_field",
|
|
256
|
+
"paneHead": "ouhzTG_paneHead",
|
|
252
257
|
"status": "ouhzTG_status",
|
|
253
258
|
"paneTitle": "ouhzTG_paneTitle",
|
|
254
|
-
"
|
|
255
|
-
"linkText": "ouhzTG_linkText",
|
|
256
|
-
"paneHead": "ouhzTG_paneHead",
|
|
257
|
-
"paneBody": "ouhzTG_paneBody",
|
|
258
|
-
"empty": "ouhzTG_empty",
|
|
259
|
+
"grid": "ouhzTG_grid",
|
|
259
260
|
"dot": "ouhzTG_dot",
|
|
260
|
-
"toolbar": "ouhzTG_toolbar",
|
|
261
|
-
"linkBar": "ouhzTG_linkBar",
|
|
262
|
-
"title": "ouhzTG_title",
|
|
263
261
|
"pane": "ouhzTG_pane",
|
|
264
|
-
"
|
|
265
|
-
"
|
|
262
|
+
"confirm": "ouhzTG_confirm",
|
|
263
|
+
"toolbar": "ouhzTG_toolbar",
|
|
264
|
+
"controls": "ouhzTG_controls",
|
|
265
|
+
"linkText": "ouhzTG_linkText"
|
|
266
266
|
};
|
|
267
267
|
//#endregion
|
|
268
268
|
//#region src/client/WallView.tsx
|
|
@@ -294,6 +294,27 @@ window.__ModuleLoader__.load({
|
|
|
294
294
|
/** Embed flag appended to every pane URL; such pages register no wall UI. */
|
|
295
295
|
const EMBED_FLAG = "multi-wall=embed";
|
|
296
296
|
/**
|
|
297
|
+
* Whether this wall is being viewed through the phone gateway (a non-loopback
|
|
298
|
+
* host) rather than on the machine running DSH. When remote, pane iframes must
|
|
299
|
+
* load through the gateway's `/gw/<port>` route — a phone's `127.0.0.1` points
|
|
300
|
+
* at the phone itself, not the host.
|
|
301
|
+
*/
|
|
302
|
+
function isRemoteViewer() {
|
|
303
|
+
const host = typeof window !== "undefined" ? window.location.hostname : "";
|
|
304
|
+
return host !== "localhost" && host !== "127.0.0.1" && host !== "::1";
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The iframe URL for a pane. Local viewers embed the loopback instance
|
|
308
|
+
* directly; remote (phone) viewers route through the gateway that is already
|
|
309
|
+
* serving this page, appending the port so the gateway proxies to it.
|
|
310
|
+
* @param port - target DSH instance port.
|
|
311
|
+
* @returns the pane URL.
|
|
312
|
+
*/
|
|
313
|
+
function paneUrl(port) {
|
|
314
|
+
if (isRemoteViewer()) return `${window.location.origin}/gw/${port}/?${EMBED_FLAG}`;
|
|
315
|
+
return `http://127.0.0.1:${port}/?${EMBED_FLAG}`;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
297
318
|
* One pane: header (port, liveness dot, zoom/refresh/open/stop/remove) plus
|
|
298
319
|
* the embedded original DSH UI.
|
|
299
320
|
*/
|
|
@@ -338,7 +359,7 @@ window.__ModuleLoader__.load({
|
|
|
338
359
|
className: WallView_module_css_default.action,
|
|
339
360
|
title: t("openTab"),
|
|
340
361
|
onClick: () => {
|
|
341
|
-
window.open(
|
|
362
|
+
window.open(paneUrl(port), "_blank");
|
|
342
363
|
},
|
|
343
364
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRightUpOutline16, { size: 14 })
|
|
344
365
|
}),
|
|
@@ -363,7 +384,7 @@ window.__ModuleLoader__.load({
|
|
|
363
384
|
className: WallView_module_css_default.paneBody,
|
|
364
385
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("iframe", {
|
|
365
386
|
title: `DSH :${port}`,
|
|
366
|
-
src:
|
|
387
|
+
src: paneUrl(port),
|
|
367
388
|
loading: "lazy"
|
|
368
389
|
})
|
|
369
390
|
})]
|
|
@@ -660,8 +681,8 @@ window.__ModuleLoader__.load({
|
|
|
660
681
|
document.head.appendChild(tag);
|
|
661
682
|
}
|
|
662
683
|
var WallToggle_module_css_default = {
|
|
663
|
-
"
|
|
664
|
-
"
|
|
684
|
+
"row": "bmjS6q_row",
|
|
685
|
+
"label": "bmjS6q_label"
|
|
665
686
|
};
|
|
666
687
|
//#endregion
|
|
667
688
|
//#region src/client/WallToggle.tsx
|
|
@@ -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,15 +143,40 @@ 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
|
|
146
172
|
* request keeps keep-alive from smuggling unrewritten Host headers); upgrades
|
|
147
|
-
* (WebSocket) keep `connection: Upgrade`.
|
|
173
|
+
* (WebSocket) keep `connection: Upgrade`. When `pathOverride` is set (a
|
|
174
|
+
* `/gw/<port>` route), the request line's path becomes the de-prefixed path.
|
|
148
175
|
*/
|
|
149
|
-
function rewriteHead(parsed, targetPort) {
|
|
176
|
+
function rewriteHead(parsed, targetPort, pathOverride) {
|
|
150
177
|
const upgrade = parsed.map.upgrade !== void 0;
|
|
151
|
-
const
|
|
178
|
+
const requestTarget = pathOverride !== void 0 ? pathOverride : parsed.target;
|
|
179
|
+
const lines = [`${parsed.method} ${requestTarget} ${parsed.version}`];
|
|
152
180
|
for (const [name, value] of parsed.headers) if (name === "host") lines.push(`host: 127.0.0.1:${targetPort}`);
|
|
153
181
|
else if (name === "origin") lines.push(`origin: http://127.0.0.1:${targetPort}`);
|
|
154
182
|
else if (name === "connection" || name === "proxy-connection") {} else lines.push(`${name}: ${value}`);
|
|
@@ -161,7 +189,8 @@ function rewriteHead(parsed, targetPort) {
|
|
|
161
189
|
* @returns a handle with the assigned port and token, and a close().
|
|
162
190
|
*/
|
|
163
191
|
function startGateway(options) {
|
|
164
|
-
const { targetPort, port: requestedPort, token, name, maxAgeHours = 12, log = () => {} } = options;
|
|
192
|
+
const { targetPort, port: requestedPort, token, name, maxAgeHours = 12, routedPorts, log = () => {} } = options;
|
|
193
|
+
const allowedRouted = new Set(routedPorts ?? []);
|
|
165
194
|
const hmac = (data) => createHmac("sha256", token).update(data).digest("hex");
|
|
166
195
|
const signSession = (exp) => {
|
|
167
196
|
const payload = Buffer.from(JSON.stringify({ exp })).toString("base64url");
|
|
@@ -258,6 +287,110 @@ connection: close\r
|
|
|
258
287
|
socket.once("end", done);
|
|
259
288
|
}
|
|
260
289
|
};
|
|
290
|
+
/**
|
|
291
|
+
* Decode an HTTP/1.1 `transfer-encoding: chunked` body back to its raw
|
|
292
|
+
* bytes. Returns `null` on malformed framing (caller then relays verbatim).
|
|
293
|
+
*/
|
|
294
|
+
const decodeChunked = (raw) => {
|
|
295
|
+
const out = [];
|
|
296
|
+
let i = 0;
|
|
297
|
+
const text = raw.toString("latin1");
|
|
298
|
+
while (true) {
|
|
299
|
+
const crlf = text.indexOf("\r\n", i);
|
|
300
|
+
if (crlf === -1) return null;
|
|
301
|
+
const sizeHex = text.slice(i, crlf).trim();
|
|
302
|
+
const size = parseInt(sizeHex, 16);
|
|
303
|
+
if (!Number.isFinite(size) || size < 0) return null;
|
|
304
|
+
i = crlf + 2;
|
|
305
|
+
if (size === 0) return Buffer.concat(out);
|
|
306
|
+
if (i + size > raw.length) return null;
|
|
307
|
+
out.push(raw.subarray(i, i + size));
|
|
308
|
+
i += size + 2;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
/**
|
|
312
|
+
* Relay the DSH target's response to the phone client. HTML document
|
|
313
|
+
* responses (the DSH shell) are buffered so the `randomUUID` polyfill can be
|
|
314
|
+
* injected before the bootstrap script runs; every other response
|
|
315
|
+
* (assets, API JSON, SSE streams, WebSocket upgrades) is piped byte-for-byte
|
|
316
|
+
* so streaming and framing stay untouched.
|
|
317
|
+
*/
|
|
318
|
+
const relayTarget = (socket, target) => {
|
|
319
|
+
let head = Buffer.alloc(0);
|
|
320
|
+
let bodyParts = null;
|
|
321
|
+
let relayed = false;
|
|
322
|
+
const pipeThrough = () => {
|
|
323
|
+
if (relayed) return;
|
|
324
|
+
relayed = true;
|
|
325
|
+
if (head.length > 0) socket.write(head);
|
|
326
|
+
if (bodyParts !== null && bodyParts.length > 0) {
|
|
327
|
+
socket.write(Buffer.concat(bodyParts));
|
|
328
|
+
bodyParts = null;
|
|
329
|
+
}
|
|
330
|
+
target.pipe(socket);
|
|
331
|
+
};
|
|
332
|
+
const finishInjection = (fullHead, fullBody) => {
|
|
333
|
+
const rewritten = injectPolyfill(fullBody);
|
|
334
|
+
if (rewritten === null) {
|
|
335
|
+
socket.write(fullHead);
|
|
336
|
+
socket.write(fullBody);
|
|
337
|
+
socket.end();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const lines = fullHead.toString("latin1").split("\r\n");
|
|
341
|
+
const filtered = [];
|
|
342
|
+
for (const line of lines) {
|
|
343
|
+
const lower = line.toLowerCase();
|
|
344
|
+
if (lower.startsWith("transfer-encoding:")) continue;
|
|
345
|
+
if (lower.startsWith("content-length:")) continue;
|
|
346
|
+
if (lower.startsWith("connection:")) continue;
|
|
347
|
+
filtered.push(line);
|
|
348
|
+
}
|
|
349
|
+
while (filtered.length > 0 && filtered[filtered.length - 1] === "") filtered.pop();
|
|
350
|
+
filtered.push(`content-length: ${rewritten.length}`, "connection: close", "", "");
|
|
351
|
+
socket.write(Buffer.from(filtered.join("\r\n"), "latin1"));
|
|
352
|
+
socket.write(rewritten);
|
|
353
|
+
socket.end();
|
|
354
|
+
};
|
|
355
|
+
target.on("data", (chunk) => {
|
|
356
|
+
if (relayed) return;
|
|
357
|
+
if (head.indexOf("\r\n\r\n") === -1) {
|
|
358
|
+
head = head.length === 0 ? chunk : Buffer.concat([head, chunk]);
|
|
359
|
+
const newIdx = head.indexOf("\r\n\r\n");
|
|
360
|
+
if (newIdx === -1) {
|
|
361
|
+
if (head.length > 65536) pipeThrough();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const headBuf = head.subarray(0, newIdx + 4);
|
|
365
|
+
const bodyStart = head.subarray(newIdx + 4);
|
|
366
|
+
const headText = headBuf.toString("latin1");
|
|
367
|
+
const status = /^HTTP\/\d\.\d (\d{3})/.exec(headText)?.[1] ?? "";
|
|
368
|
+
const contentType = /^content-type:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? "";
|
|
369
|
+
const ce = /^content-encoding:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? "";
|
|
370
|
+
const isHtml = status === "200" && /text\/html/i.test(contentType);
|
|
371
|
+
const compressed = ce !== "" && !/identity/i.test(ce);
|
|
372
|
+
if (!isHtml || compressed) {
|
|
373
|
+
pipeThrough();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
bodyParts = bodyStart.length > 0 ? [bodyStart] : [];
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (bodyParts !== null) bodyParts.push(chunk);
|
|
380
|
+
});
|
|
381
|
+
target.once("end", () => {
|
|
382
|
+
if (relayed || bodyParts === null) {
|
|
383
|
+
if (!relayed) socket.end();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const headBuf = head.subarray(0, head.indexOf("\r\n\r\n") + 4);
|
|
387
|
+
const headText = headBuf.toString("latin1");
|
|
388
|
+
const rawBody = Buffer.concat(bodyParts);
|
|
389
|
+
const te = /^transfer-encoding:\s*([^\r\n]+)/im.exec(headText)?.[1] ?? "";
|
|
390
|
+
finishInjection(headBuf, /chunked/i.test(te) ? decodeChunked(rawBody) ?? rawBody : rawBody);
|
|
391
|
+
});
|
|
392
|
+
target.once("error", () => socket.destroy());
|
|
393
|
+
};
|
|
261
394
|
const handleSocket = (socket) => {
|
|
262
395
|
let buffer = Buffer.alloc(0);
|
|
263
396
|
let headDone = false;
|
|
@@ -282,6 +415,19 @@ connection: close\r
|
|
|
282
415
|
handleLogin(socket, parsed, buffer.subarray(parsed.headBytes));
|
|
283
416
|
return;
|
|
284
417
|
}
|
|
418
|
+
let routePort = null;
|
|
419
|
+
let routePath;
|
|
420
|
+
const routeMatch = /^\/gw\/(\d+)(\/.*)?$/.exec(path);
|
|
421
|
+
if (routeMatch !== null) {
|
|
422
|
+
const candidate = Number(routeMatch[1]);
|
|
423
|
+
if (!Number.isInteger(candidate) || candidate <= 0 || !allowedRouted.has(candidate)) {
|
|
424
|
+
respondPlain(socket, "403", "port not routable");
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
routePort = candidate;
|
|
428
|
+
routePath = routeMatch[2] && routeMatch[2] !== "" ? routeMatch[2] : "/";
|
|
429
|
+
}
|
|
430
|
+
const effectivePort = routePort ?? targetPort;
|
|
285
431
|
const kind = authKind(parsed);
|
|
286
432
|
if (kind === "none") {
|
|
287
433
|
respondLogin(socket, name, path === "/" ? "/" : path);
|
|
@@ -291,19 +437,19 @@ connection: close\r
|
|
|
291
437
|
respondSessionRedirect(socket, parsed, `dsh_gw_session=${signSession(Date.now() + maxAgeHours * 36e5)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeHours * 3600}`);
|
|
292
438
|
return;
|
|
293
439
|
}
|
|
294
|
-
const target = connect(
|
|
440
|
+
const target = connect(effectivePort, "127.0.0.1");
|
|
295
441
|
const rest = buffer.subarray(parsed.headBytes);
|
|
296
442
|
let connected = false;
|
|
297
443
|
target.on("connect", () => {
|
|
298
444
|
connected = true;
|
|
299
|
-
target.write(rewriteHead(parsed,
|
|
445
|
+
target.write(rewriteHead(parsed, effectivePort, routePath));
|
|
300
446
|
if (rest.length > 0) target.write(rest);
|
|
301
447
|
proxying = true;
|
|
302
448
|
socket.pipe(target);
|
|
303
|
-
|
|
449
|
+
relayTarget(socket, target);
|
|
304
450
|
});
|
|
305
451
|
target.on("error", (error) => {
|
|
306
|
-
if (!connected) respondPlain(socket, "502", `cannot reach target 127.0.0.1:${
|
|
452
|
+
if (!connected) respondPlain(socket, "502", `cannot reach target 127.0.0.1:${effectivePort}: ${error.message}`);
|
|
307
453
|
socket.destroy();
|
|
308
454
|
});
|
|
309
455
|
socket.on("error", () => target.destroy());
|
|
@@ -687,7 +833,8 @@ function lanAddresses() {
|
|
|
687
833
|
if (virtual) continue;
|
|
688
834
|
candidates.push({
|
|
689
835
|
address: iface.address,
|
|
690
|
-
physical
|
|
836
|
+
physical,
|
|
837
|
+
virtual: false
|
|
691
838
|
});
|
|
692
839
|
}
|
|
693
840
|
}
|
|
@@ -830,11 +977,15 @@ function apply(ctx, config = {}) {
|
|
|
830
977
|
const token = config.gatewayToken && config.gatewayToken !== "" ? config.gatewayToken : randomBytes(6).toString("hex");
|
|
831
978
|
const gatewayPort = config.gatewayPort && config.gatewayPort !== 0 ? config.gatewayPort : port + 5e3;
|
|
832
979
|
gatewayTargetPort = port;
|
|
980
|
+
const routed = [];
|
|
981
|
+
for (let p = scanFrom; p <= scanTo; p++) routed.push(p);
|
|
982
|
+
for (const p of fixedPorts) if (!routed.includes(p)) routed.push(p);
|
|
833
983
|
return startGateway({
|
|
834
984
|
targetPort: port,
|
|
835
985
|
port: gatewayPort,
|
|
836
986
|
token,
|
|
837
987
|
name: "DSH",
|
|
988
|
+
routedPorts: routed,
|
|
838
989
|
log: (msg) => ctx.logger.info(`multi-wall gateway: ${msg}`)
|
|
839
990
|
}).then((handle) => {
|
|
840
991
|
gateway = handle;
|