dsh-multi-chat 1.0.3 → 1.0.4
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/README.md +287 -269
- package/README.zh.md +269 -233
- package/lib/client.js +66 -28
- package/lib/client.js.map +1 -1
- package/lib/index.js +211 -22
- package/lib/invariant.js +23 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/types/client/WallToggle.js +2 -2
- package/lib/types/client/WallToggle.js.map +1 -1
- package/lib/types/client/WallView.js +8 -8
- package/lib/types/client/WallView.js.map +1 -1
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js.map +1 -1
- package/lib/types/client/store.d.ts +1 -1
- package/lib/types/client/store.d.ts.map +1 -1
- package/lib/types/client/store.js +1 -1
- package/lib/types/client/store.js.map +1 -1
- package/lib/types/index.d.ts +80 -80
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +117 -12
- package/lib/types/index.js.map +1 -1
- package/package.json +139 -108
- package/scripts/check-platform-contract.mjs +171 -0
- package/src/client/WallToggle.tsx +52 -52
- package/src/client/WallView.tsx +406 -406
- package/src/client/index.ts +108 -103
- package/src/client/store.ts +40 -40
- package/src/index.ts +715 -618
- package/scripts/sync-vitest-paths.mjs +0 -63
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile, spawn } from "node:child_process";
|
|
2
2
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, readlinkSync } from "node:fs";
|
|
4
4
|
import { networkInterfaces } from "node:os";
|
|
5
5
|
import z from "@deepseek-ai/schemastery";
|
|
6
6
|
import { connect, createServer } from "node:net";
|
|
@@ -542,10 +542,15 @@ function json(res, value, status = 200) {
|
|
|
542
542
|
res.end(JSON.stringify(value));
|
|
543
543
|
}
|
|
544
544
|
/**
|
|
545
|
+
|
|
545
546
|
* Run a command and resolve its stdout text. Rejects on non-zero exit.
|
|
547
|
+
|
|
546
548
|
* @param file - the executable path.
|
|
549
|
+
|
|
547
550
|
* @param args - CLI arguments.
|
|
551
|
+
|
|
548
552
|
* @returns the trimmed stdout.
|
|
553
|
+
|
|
549
554
|
*/
|
|
550
555
|
function execStdout(file, args) {
|
|
551
556
|
return new Promise((resolve, reject) => {
|
|
@@ -559,36 +564,145 @@ function execStdout(file, args) {
|
|
|
559
564
|
});
|
|
560
565
|
}
|
|
561
566
|
/**
|
|
567
|
+
|
|
568
|
+
* Whether an exec failure means the binary itself is absent (ENOENT) rather
|
|
569
|
+
|
|
570
|
+
* than the command running and reporting a non-zero exit.
|
|
571
|
+
|
|
572
|
+
* @param error - the rejection from {@link execStdout}.
|
|
573
|
+
|
|
574
|
+
* @returns true when the executable could not be spawned at all.
|
|
575
|
+
|
|
576
|
+
*/
|
|
577
|
+
function isMissingBinary(error) {
|
|
578
|
+
return error?.code === "ENOENT";
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
|
|
582
|
+
* Read the local TCP sockets in LISTEN state from the kernel's `/proc` tables.
|
|
583
|
+
|
|
584
|
+
* This is the Linux fallback for hosts without `lsof`, which is most
|
|
585
|
+
|
|
586
|
+
* container images — including the ones `dsh web` is commonly run in. Only
|
|
587
|
+
|
|
588
|
+
* this network namespace's sockets are visible, which is exactly the view the
|
|
589
|
+
|
|
590
|
+
* serving instance and the instances it spawns live in.
|
|
591
|
+
|
|
592
|
+
* @param port - restrict the scan to one port when given.
|
|
593
|
+
|
|
594
|
+
* @returns listening port -> owning socket inode ('' when the row has none).
|
|
595
|
+
|
|
596
|
+
*/
|
|
597
|
+
function procListenSockets(port) {
|
|
598
|
+
const found = new Map();
|
|
599
|
+
for (const file of ["/proc/net/tcp", "/proc/net/tcp6"]) {
|
|
600
|
+
let text;
|
|
601
|
+
try {
|
|
602
|
+
text = readFileSync(file, "utf8");
|
|
603
|
+
} catch {
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
for (const line of text.split("\n").slice(1)) {
|
|
607
|
+
const f = line.trim().split(/\s+/);
|
|
608
|
+
if (f.length < 10 || f[3] !== "0A") continue;
|
|
609
|
+
const local = f[1] ?? "";
|
|
610
|
+
const listening = Number.parseInt(local.slice(local.indexOf(":") + 1), 16);
|
|
611
|
+
if (!Number.isInteger(listening) || listening <= 0) continue;
|
|
612
|
+
if (port !== void 0 && listening !== port) continue;
|
|
613
|
+
found.set(listening, f[9] ?? "");
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return found;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
|
|
620
|
+
* Resolve which PIDs own the given socket inodes by scanning `/proc/<pid>/fd`
|
|
621
|
+
|
|
622
|
+
* links. Only processes this user is allowed to inspect are visible — the
|
|
623
|
+
|
|
624
|
+
* same permission bound `lsof` has.
|
|
625
|
+
|
|
626
|
+
* @param inodes - the socket inodes to look for.
|
|
627
|
+
|
|
628
|
+
* @returns the owning PIDs (possibly empty).
|
|
629
|
+
|
|
630
|
+
*/
|
|
631
|
+
function pidsForInodes(inodes) {
|
|
632
|
+
const wanted = new Set([...inodes].filter((inode) => inode !== "").map((inode) => `socket:[${inode}]`));
|
|
633
|
+
if (wanted.size === 0) return [];
|
|
634
|
+
let entries;
|
|
635
|
+
try {
|
|
636
|
+
entries = readdirSync("/proc");
|
|
637
|
+
} catch {
|
|
638
|
+
return [];
|
|
639
|
+
}
|
|
640
|
+
const pids = new Set();
|
|
641
|
+
for (const entry of entries) {
|
|
642
|
+
const pid = Number(entry);
|
|
643
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
644
|
+
let fds;
|
|
645
|
+
try {
|
|
646
|
+
fds = readdirSync(`/proc/${entry}/fd`);
|
|
647
|
+
} catch {
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
for (const fd of fds) try {
|
|
651
|
+
if (wanted.has(readlinkSync(`/proc/${entry}/fd/${fd}`))) {
|
|
652
|
+
pids.add(pid);
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
} catch {}
|
|
656
|
+
}
|
|
657
|
+
return [...pids];
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
|
|
562
661
|
* Resolve the PIDs listening on a local TCP port. Windows uses `netstat`;
|
|
563
|
-
|
|
662
|
+
|
|
663
|
+
* POSIX prefers `lsof` and falls back to the kernel's `/proc` tables on Linux
|
|
664
|
+
|
|
665
|
+
* hosts that do not ship it.
|
|
666
|
+
|
|
564
667
|
* @param port - the listening port.
|
|
668
|
+
|
|
565
669
|
* @returns the listener PIDs (possibly empty).
|
|
670
|
+
|
|
566
671
|
*/
|
|
567
672
|
async function listeningPids(port) {
|
|
568
673
|
if (process.platform === "win32") {
|
|
569
|
-
const stdout
|
|
674
|
+
const stdout = await execStdout("netstat", [
|
|
570
675
|
"-ano",
|
|
571
676
|
"-p",
|
|
572
677
|
"tcp"
|
|
573
678
|
]);
|
|
574
679
|
const pids = new Set();
|
|
575
|
-
for (const line of stdout
|
|
680
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
576
681
|
const m = /^\s*TCP\s+([0-9.]+|\*|\[::\]):(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/.exec(line);
|
|
577
682
|
if (m !== null && Number(m[2]) === port) pids.add(Number(m[3]));
|
|
578
683
|
}
|
|
579
684
|
return [...pids];
|
|
580
685
|
}
|
|
581
|
-
|
|
582
|
-
"
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
686
|
+
try {
|
|
687
|
+
const stdout = await execStdout("lsof", [
|
|
688
|
+
"-ti",
|
|
689
|
+
`tcp:${port}`,
|
|
690
|
+
"-sTCP:LISTEN"
|
|
691
|
+
]);
|
|
692
|
+
return stdout.split(/\s+/).map(Number).filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
693
|
+
} catch (error) {
|
|
694
|
+
if (process.platform !== "linux" || !isMissingBinary(error)) throw error;
|
|
695
|
+
return pidsForInodes(procListenSockets(port).values());
|
|
696
|
+
}
|
|
587
697
|
}
|
|
588
698
|
/**
|
|
699
|
+
|
|
589
700
|
* Terminate one PID. Windows uses `taskkill /F` (force); POSIX sends SIGTERM
|
|
701
|
+
|
|
590
702
|
* then SIGKILL after a grace period.
|
|
703
|
+
|
|
591
704
|
* @param pid - the process id to terminate.
|
|
705
|
+
|
|
592
706
|
*/
|
|
593
707
|
async function killPid(pid) {
|
|
594
708
|
if (process.platform === "win32") {
|
|
@@ -609,13 +723,21 @@ async function killPid(pid) {
|
|
|
609
723
|
} catch {}
|
|
610
724
|
}
|
|
611
725
|
/**
|
|
726
|
+
|
|
612
727
|
* Terminate the DSH instance listening on one local port. The port serving
|
|
728
|
+
|
|
613
729
|
* this wall may also be terminated (the user may want to stop the instance
|
|
730
|
+
|
|
614
731
|
* they are viewing): the kill is deferred a beat so the HTTP response is
|
|
732
|
+
|
|
615
733
|
* written before the process dies, then the listener's PIDs are force-killed.
|
|
734
|
+
|
|
616
735
|
* @param port - the target port.
|
|
736
|
+
|
|
617
737
|
* @param selfPort - this instance's own listening port.
|
|
738
|
+
|
|
618
739
|
* @returns the stop result.
|
|
740
|
+
|
|
619
741
|
*/
|
|
620
742
|
async function stopPort(port, selfPort) {
|
|
621
743
|
try {
|
|
@@ -649,12 +771,19 @@ async function stopPort(port, selfPort) {
|
|
|
649
771
|
}
|
|
650
772
|
}
|
|
651
773
|
/**
|
|
774
|
+
|
|
652
775
|
* Resolve how to launch a new DSH instance. Primary path: the current
|
|
776
|
+
|
|
653
777
|
* process's own entry (`node <bin> web` under `process.argv[1]`), so the new
|
|
778
|
+
|
|
654
779
|
* instance inherits the exact CLI/profile already running. Fallback: the
|
|
780
|
+
|
|
655
781
|
* `dsh` command from PATH when the entry cannot be derived (unusual host
|
|
782
|
+
|
|
656
783
|
* launcher, missing file).
|
|
784
|
+
|
|
657
785
|
* @returns the launcher description.
|
|
786
|
+
|
|
658
787
|
*/
|
|
659
788
|
function resolveLauncher() {
|
|
660
789
|
const first = process.argv[1];
|
|
@@ -674,45 +803,66 @@ function resolveLauncher() {
|
|
|
674
803
|
};
|
|
675
804
|
}
|
|
676
805
|
/**
|
|
806
|
+
|
|
677
807
|
* Collect every distinct local TCP port that is listening, in ONE command
|
|
808
|
+
|
|
678
809
|
* (not one `netstat`/`lsof` per candidate, which the old free-port scan ran
|
|
810
|
+
|
|
679
811
|
* sequentially and could take many seconds on slow Windows boxes). Windows
|
|
680
|
-
|
|
812
|
+
|
|
813
|
+
* parses `netstat`; POSIX parses `lsof` `(LISTEN)` lines, falling back to
|
|
814
|
+
|
|
815
|
+
* `/proc/net/tcp*` on Linux hosts without `lsof`.
|
|
816
|
+
|
|
681
817
|
* @returns the set of busy ports.
|
|
818
|
+
|
|
682
819
|
*/
|
|
683
820
|
async function listeningPorts() {
|
|
684
821
|
const set = new Set();
|
|
685
822
|
if (process.platform === "win32") {
|
|
686
|
-
const stdout
|
|
823
|
+
const stdout = await execStdout("netstat", [
|
|
687
824
|
"-ano",
|
|
688
825
|
"-p",
|
|
689
826
|
"tcp"
|
|
690
827
|
]);
|
|
691
|
-
for (const line of stdout
|
|
828
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
692
829
|
const m = /^\s*TCP\s+([0-9.]+|\*|\[::\]):(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/.exec(line);
|
|
693
830
|
if (m !== null) set.add(Number(m[2]));
|
|
694
831
|
}
|
|
695
832
|
return set;
|
|
696
833
|
}
|
|
697
|
-
|
|
698
|
-
"
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
const
|
|
704
|
-
|
|
834
|
+
try {
|
|
835
|
+
const stdout = await execStdout("lsof", [
|
|
836
|
+
"-nP",
|
|
837
|
+
"-iTCP",
|
|
838
|
+
"-sTCP:LISTEN"
|
|
839
|
+
]);
|
|
840
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
841
|
+
const m = /:(\d+)\s+\(LISTEN\)\s*$/.exec(line.trim());
|
|
842
|
+
if (m !== null) set.add(Number(m[1]));
|
|
843
|
+
}
|
|
844
|
+
return set;
|
|
845
|
+
} catch (error) {
|
|
846
|
+
if (process.platform !== "linux" || !isMissingBinary(error)) throw error;
|
|
847
|
+
return new Set(procListenSockets().keys());
|
|
705
848
|
}
|
|
706
|
-
return set;
|
|
707
849
|
}
|
|
708
850
|
/**
|
|
851
|
+
|
|
709
852
|
* Pick the first free port in [lo, hi] that is neither the serving port nor
|
|
853
|
+
|
|
710
854
|
* already listening. The busy set is resolved once (a single command), then
|
|
855
|
+
|
|
711
856
|
* scanned in memory.
|
|
857
|
+
|
|
712
858
|
* @param lo - first port of the range.
|
|
859
|
+
|
|
713
860
|
* @param hi - last port of the range.
|
|
861
|
+
|
|
714
862
|
* @param selfPort - the port serving this wall (never chosen).
|
|
863
|
+
|
|
715
864
|
* @returns a free port, or undefined when the range is exhausted.
|
|
865
|
+
|
|
716
866
|
*/
|
|
717
867
|
async function pickFreePort(lo, hi, selfPort) {
|
|
718
868
|
const busy = await listeningPorts();
|
|
@@ -724,23 +874,41 @@ async function pickFreePort(lo, hi, selfPort) {
|
|
|
724
874
|
return void 0;
|
|
725
875
|
}
|
|
726
876
|
/**
|
|
877
|
+
|
|
727
878
|
* Spawn a new `dsh web` instance on a port and decide quickly whether it is
|
|
879
|
+
|
|
728
880
|
* viable. Detached so it outlives this process. This is intentionally NOT a
|
|
881
|
+
|
|
729
882
|
* full readiness gate: the wall polls liveness itself, so the create response
|
|
883
|
+
|
|
730
884
|
* returns fast and a still-booting instance simply shows a pane that lights up
|
|
885
|
+
|
|
731
886
|
* when the server finishes. A short bounded wait still catches immediate
|
|
887
|
+
|
|
732
888
|
* spawn failures (bad bin, ENOENT) and very fast boots.
|
|
889
|
+
|
|
733
890
|
*
|
|
891
|
+
|
|
734
892
|
* On a genuine launch failure the child is killed so no orphan lingers; on a
|
|
893
|
+
|
|
735
894
|
* slow-but-viable start the deadline returns ok:true and the child keeps
|
|
895
|
+
|
|
736
896
|
* booting in the background (the pane already mounted, liveness confirms when
|
|
897
|
+
|
|
737
898
|
* alive). The child's stderr is captured and quoted into every failure so a
|
|
899
|
+
|
|
738
900
|
* crash or a bad bin surfaces a concrete reason instead of a bare timeout.
|
|
901
|
+
|
|
739
902
|
* @param launcher - how to spawn the dsh CLI.
|
|
903
|
+
|
|
740
904
|
* @param port - the port for the new instance.
|
|
905
|
+
|
|
741
906
|
* @param timeoutMs - how long to wait before handing back ok:true.
|
|
907
|
+
|
|
742
908
|
* @param pollMs - readiness probe interval while waiting.
|
|
909
|
+
|
|
743
910
|
* @returns ok plus the port, or ok:false with a reason.
|
|
911
|
+
|
|
744
912
|
*/
|
|
745
913
|
async function startInstance(launcher, port, timeoutMs = 3e3, pollMs = 300) {
|
|
746
914
|
const child = spawn(launcher.file, [...launcher.args, String(port)], {
|
|
@@ -797,10 +965,15 @@ async function startInstance(launcher, port, timeoutMs = 3e3, pollMs = 300) {
|
|
|
797
965
|
}
|
|
798
966
|
}
|
|
799
967
|
/**
|
|
968
|
+
|
|
800
969
|
* Interface-name patterns that mark a *virtual* NIC (VM bridge, WSL,
|
|
970
|
+
|
|
801
971
|
* Docker, Hyper-V, VPN adapters, Loopback Pseudo-Instance, etc.). These
|
|
972
|
+
|
|
802
973
|
* interfaces are never reachable from a phone on the same LAN, so they are
|
|
974
|
+
|
|
803
975
|
* dropped from the link list entirely.
|
|
976
|
+
|
|
804
977
|
*/
|
|
805
978
|
const VIRTUAL_IFACE_PATTERNS = [
|
|
806
979
|
/vEthernet/i,
|
|
@@ -823,9 +996,13 @@ const VIRTUAL_IFACE_PATTERNS = [
|
|
|
823
996
|
/bluetooth/i
|
|
824
997
|
];
|
|
825
998
|
/**
|
|
999
|
+
|
|
826
1000
|
* Interface-name patterns that mark a *physical* NIC (Wi-Fi / Ethernet).
|
|
1001
|
+
|
|
827
1002
|
* Matches kept addresses are ordered before any unknown-but-surviving
|
|
1003
|
+
|
|
828
1004
|
* address so the phone-first address is the machine's real NIC.
|
|
1005
|
+
|
|
829
1006
|
*/
|
|
830
1007
|
const PHYSICAL_IFACE_PATTERNS = [
|
|
831
1008
|
/^(wi-?fi|wlan|wireless)/i,
|
|
@@ -836,11 +1013,17 @@ const PHYSICAL_IFACE_PATTERNS = [
|
|
|
836
1013
|
/^w[0-9]+$/i
|
|
837
1014
|
];
|
|
838
1015
|
/**
|
|
1016
|
+
|
|
839
1017
|
* The non-loopback IPv4 addresses of this machine (the LAN reachable URLs).
|
|
1018
|
+
|
|
840
1019
|
* Virtual NICs (VM/WSL/Docker/VPN/loopback pseudo) are filtered out; the
|
|
1020
|
+
|
|
841
1021
|
* remaining addresses are ordered with physical NICs (Wi-Fi/Ethernet) first
|
|
1022
|
+
|
|
842
1023
|
* so the phone shows the actually-reachable LAN address at the top.
|
|
1024
|
+
|
|
843
1025
|
* @returns the address list (possibly empty).
|
|
1026
|
+
|
|
844
1027
|
*/
|
|
845
1028
|
function lanAddresses() {
|
|
846
1029
|
const candidates = [];
|
|
@@ -861,11 +1044,17 @@ function lanAddresses() {
|
|
|
861
1044
|
return candidates.map((c) => c.address);
|
|
862
1045
|
}
|
|
863
1046
|
/**
|
|
1047
|
+
|
|
864
1048
|
* Register the probe routes. Everything lives under `/multi/api` so the
|
|
1049
|
+
|
|
865
1050
|
* plugin is purely additive: exact `ports` (auto-discovery) and `status`
|
|
1051
|
+
|
|
866
1052
|
* (liveness of a specific port list).
|
|
1053
|
+
|
|
867
1054
|
* @param ctx - plugin context carrying the webServer service.
|
|
1055
|
+
|
|
868
1056
|
* @param config - validated {@link MultiWallConfig}.
|
|
1057
|
+
|
|
869
1058
|
*/
|
|
870
1059
|
function apply(ctx, config = {}) {
|
|
871
1060
|
const scanFrom = config.scanFrom ?? 3070;
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/invariant.ts
|
|
2
|
+
const PACKAGE_NAME = "dsh-multi-chat";
|
|
3
|
+
/** Cordis companion plugin name. */
|
|
4
|
+
const name = "dsh-multi-chat-invariant";
|
|
5
|
+
/** Service required before the companion can reserve package ownership. */
|
|
6
|
+
const inject = ["invariants"];
|
|
7
|
+
/**
|
|
8
|
+
* No runtime invariant: the wall contributes the conversation view-ring entry
|
|
9
|
+
* (the wall surface) and the sidebar footer shortcut, whose disposal is
|
|
10
|
+
* proven by the HMR-safety spec — the plugin owns one store handle used by
|
|
11
|
+
* the view entry, emits no cordis events, and holds no cross-plugin mutable
|
|
12
|
+
* state.
|
|
13
|
+
*/
|
|
14
|
+
const install = () => {};
|
|
15
|
+
/**
|
|
16
|
+
* Register this package's invariant companion.
|
|
17
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
18
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
19
|
+
*/
|
|
20
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
21
|
+
|
|
22
|
+
//#endregion
|
|
23
|
+
export { apply, inject, name };
|