arelos 0.2.0 → 0.2.1

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.
Files changed (2) hide show
  1. package/dist/ports.js +81 -13
  2. package/package.json +1 -1
package/dist/ports.js CHANGED
@@ -1,24 +1,47 @@
1
1
  /**
2
2
  * Zero-dependency free-port detection via node:net (spec §1 Step 5).
3
3
  *
4
- * A port must be probed on BOTH loopback families: a server listening only on
5
- * [::1] (IPv6) leaves 127.0.0.1 bindable, so an IPv4-only probe reports the
6
- * port "free" while browsers (which resolve localhost to ::1 first) hit the
7
- * other process. Field-tested: a Node dev server on [::1]:1347 made the
8
- * installer pre-fill an occupied port. A port counts as free only if every
9
- * loopback family available on this machine can bind it.
4
+ * A port must be probed on every listener shape a process can occupy it
5
+ * with, not just the specific loopback addresses:
6
+ *
7
+ * - A server listening only on [::1] (IPv6) leaves 127.0.0.1 bindable, so an
8
+ * IPv4-only probe reports the port "free" while browsers (which resolve
9
+ * localhost to ::1 first) hit the other process. Field-tested: a Node dev
10
+ * server on [::1]:1347 made the installer pre-fill an occupied port.
11
+ * - A server listening on the WILDCARD address (e.g. `Bun.serve()` with no
12
+ * `hostname`, or `net.createServer().listen(port)` with no host) binds
13
+ * every local address in that family, including both loopback addresses,
14
+ * yet a bind-probe of 127.0.0.1/::1 alone can still report "free" on
15
+ * macOS: binding a specific address with SO_REUSEADDR (Node's default) can
16
+ * succeed even though a wildcard listener already holds the port. Real
17
+ * incident: `isPortFree(5274)` reported free while a Bun server held
18
+ * `*:5274`, and the installer wrote that occupied port into config.
19
+ *
20
+ * The fix probes both shapes with two independent techniques:
21
+ * 1. Bind-probes on the specific loopback addresses AND the wildcard
22
+ * addresses for each family. A wildcard bind-probe fails with
23
+ * EADDRINUSE whenever *any* listener (wildcard or specific) holds the
24
+ * port in that family — it is the strongest bind test available.
25
+ * 2. A short-timeout TCP connect-probe to 127.0.0.1 and ::1. A successful
26
+ * connection proves the port is occupied even in edge cases the bind
27
+ * probes can't reach; ECONNREFUSED proves free for that family; a
28
+ * timeout or other inconclusive result is never treated as occupied on
29
+ * its own.
30
+ *
31
+ * A port counts as free only if every applicable bind probe succeeds AND no
32
+ * connect probe manages to connect.
10
33
  */
11
- import { createServer } from "node:net";
34
+ import { createServer, connect as netConnect } from "node:net";
12
35
  /** Error codes meaning "this address family isn't available on this machine". */
13
36
  const FAMILY_UNAVAILABLE = new Set(["EADDRNOTAVAIL", "EAFNOSUPPORT", "EINVAL"]);
14
- /** Try to bind `port` on one loopback host and report what happened. */
37
+ /** Try to bind `port` on one host (specific address or wildcard) and report what happened. */
15
38
  function probeBind(port, host) {
16
39
  return new Promise((resolve) => {
17
40
  const server = createServer();
18
41
  server.once("error", (err) => {
19
42
  server.close();
20
43
  if (err.code && FAMILY_UNAVAILABLE.has(err.code)) {
21
- // No IPv6 (or IPv4) loopback on this machine — nothing to conflict with.
44
+ // No IPv6 (or IPv4) support on this machine — nothing to conflict with.
22
45
  resolve("family-unavailable");
23
46
  }
24
47
  else {
@@ -33,13 +56,58 @@ function probeBind(port, host) {
33
56
  server.listen(port, host);
34
57
  });
35
58
  }
59
+ const CONNECT_PROBE_TIMEOUT_MS = 250;
60
+ /**
61
+ * Try to TCP-connect to `host:port` with a short timeout. A successful
62
+ * connection means something is definitely listening there. ECONNREFUSED
63
+ * means nothing is listening (free, for that family). Anything else
64
+ * (timeout, unreachable, family unavailable) is inconclusive and must not be
65
+ * treated as proof of occupation by itself.
66
+ */
67
+ function probeConnect(port, host) {
68
+ return new Promise((resolve) => {
69
+ let settled = false;
70
+ let socket;
71
+ const finish = (result) => {
72
+ if (settled)
73
+ return;
74
+ settled = true;
75
+ socket.destroy();
76
+ resolve(result);
77
+ };
78
+ socket = netConnect({ port, host });
79
+ socket.setTimeout(CONNECT_PROBE_TIMEOUT_MS);
80
+ socket.once("connect", () => finish("occupied"));
81
+ socket.once("timeout", () => finish("inconclusive"));
82
+ socket.once("error", (err) => {
83
+ if (err.code === "ECONNREFUSED") {
84
+ finish("free");
85
+ }
86
+ else {
87
+ // EHOSTUNREACH, ENETUNREACH, family unavailable, etc. — inconclusive.
88
+ finish("inconclusive");
89
+ }
90
+ });
91
+ });
92
+ }
36
93
  /**
37
- * Resolve true if `port` is free to bind on ALL applicable loopback families
38
- * (127.0.0.1 and ::1), false if any listener holds it on either family.
94
+ * Resolve true if `port` is free on every probe: bind-probes on the specific
95
+ * loopback addresses and the wildcard addresses for both families, plus
96
+ * connect-probes to the loopback addresses. False if any bind probe reports
97
+ * "taken" or any connect probe successfully connects.
39
98
  */
40
99
  export async function isPortFree(port) {
41
- const [v4, v6] = await Promise.all([probeBind(port, "127.0.0.1"), probeBind(port, "::1")]);
42
- return v4 !== "taken" && v6 !== "taken";
100
+ const [v4Loopback, v6Loopback, v4Wildcard, v6Wildcard, v4Connect, v6Connect] = await Promise.all([
101
+ probeBind(port, "127.0.0.1"),
102
+ probeBind(port, "::1"),
103
+ probeBind(port, "0.0.0.0"),
104
+ probeBind(port, "::"),
105
+ probeConnect(port, "127.0.0.1"),
106
+ probeConnect(port, "::1"),
107
+ ]);
108
+ const bindsOk = [v4Loopback, v6Loopback, v4Wildcard, v6Wildcard].every((result) => result !== "taken");
109
+ const noConnectSucceeded = v4Connect !== "occupied" && v6Connect !== "occupied";
110
+ return bindsOk && noConnectSucceeded;
43
111
  }
44
112
  /**
45
113
  * Find the first free port at or above `start`, scanning upward. Stops at
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arelos",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Installer and service manager for a self-hosted Arel OS on macOS.",
5
5
  "type": "module",
6
6
  "bin": {