dsh-multi-chat 1.0.2 → 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/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
- * POSIX uses `lsof` (present on macOS and most Linux installs).
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$1 = await execStdout("netstat", [
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$1.split(/\r?\n/)) {
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
- const stdout = await execStdout("lsof", [
582
- "-ti",
583
- `tcp:${port}`,
584
- "-sTCP:LISTEN"
585
- ]);
586
- return stdout.split(/\s+/).map(Number).filter((pid) => Number.isInteger(pid) && pid > 0);
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
- * parses `netstat`; POSIX parses `lsof` `(LISTEN)` lines.
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$1 = await execStdout("netstat", [
823
+ const stdout = await execStdout("netstat", [
687
824
  "-ano",
688
825
  "-p",
689
826
  "tcp"
690
827
  ]);
691
- for (const line of stdout$1.split(/\r?\n/)) {
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
- const stdout = await execStdout("lsof", [
698
- "-nP",
699
- "-iTCP",
700
- "-sTCP:LISTEN"
701
- ]);
702
- for (const line of stdout.split(/\r?\n/)) {
703
- const m = /:(\d+)\s+\(LISTEN\)\s*$/.exec(line.trim());
704
- if (m !== null) set.add(Number(m[1]));
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;
@@ -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 };