portdiag 0.0.0-stage → 0.1.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.
@@ -0,0 +1,56 @@
1
+ # Contributing
2
+
3
+ Thanks for considering a contribution. Portdiag is intentionally small: it diagnoses one TCP/IPv4 endpoint and never changes networking settings or stops processes. Please keep changes within that scope.
4
+
5
+ ## Before opening an issue
6
+
7
+ Search existing issues first. For a bug, include the portdiag and Node.js versions, operating system, exact command, expected result, and actual result. Remove private addresses or other sensitive information from logs before posting.
8
+
9
+ ## Before opening a pull request
10
+
11
+ Discuss larger behavior changes in an issue first. Keep pull requests focused, explain the user-visible reason for the change, and add or update tests for behavior changes.
12
+
13
+ ### Branch names
14
+
15
+ Use a short type and kebab-case summary. CI rejects pull requests with an unstructured branch name. Fork contributors may prefix the type with their username:
16
+
17
+ - `feat/json-output`
18
+ - `fix/windows-listener-detection`
19
+ - `docs/contributing-guide`
20
+ - `test/cli-output`
21
+ - `AnggaaIs/fix/windows-listener-detection`
22
+
23
+ ### Commit messages
24
+
25
+ Every commit and pull request title must follow Conventional Commits. The check runs in CI and blocks merging when a message does not match.
26
+
27
+ ```text
28
+ type(scope): lowercase summary
29
+ ```
30
+
31
+ Allowed types are `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, and `revert`. Scope is optional. Keep the full first line to 72 characters or fewer. Use `!` before the colon for a breaking change.
32
+
33
+ Good examples:
34
+
35
+ ```text
36
+ feat(cli): add json output
37
+ fix(windows): detect active listeners
38
+ docs: clarify supported platforms
39
+ ```
40
+
41
+ Messages such as `update stuff`, `fix: Fix bug`, or `misc changes` fail CI. Squash merge is the only merge method, and the PR title becomes the resulting commit message, so keep both commits and PR title in this format.
42
+
43
+ Run these checks from the project directory:
44
+
45
+ ```sh
46
+ npm test
47
+ npm pack --dry-run
48
+ ```
49
+
50
+ The Conventional Commit check can also be run by CI on pull requests. To check a title locally, use the same format and length rules above.
51
+
52
+ There are no runtime dependencies. Please don't add one for work the Node.js standard library can handle, and don't publish a package release from a pull request.
53
+
54
+ ## Scope
55
+
56
+ The project currently supports TCP over IPv4 on Windows, macOS, and Linux. It is read-only apart from the temporary TCP bind probe. Process termination, UDP, IPv6, scanning, and configuration systems are out of scope.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 portdiag contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,104 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # portdiag: TCP Port Diagnostics for Node.js
2
+
3
+ [![Tests](https://github.com/AnggaaIs/portdiag/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/AnggaaIs/portdiag/actions/workflows/test.yml)
4
+
5
+ Ever started a Node.js dev server and got an `EADDRINUSE` or “port already in use” error? `portdiag` checks a specific local TCP/IPv4 port and tells you what it can find, without killing anything or changing your network settings. It works on Windows, macOS, and Linux.
6
+
7
+ ```console
8
+ $ npx portdiag 3000
9
+ portdiag
10
+ ● In use 127.0.0.1:3000
11
+
12
+ Windows reports a listener on this endpoint.
13
+ PID: 1234
14
+
15
+ Next: Check the listening process before deciding whether to stop it.
16
+ ```
17
+
18
+ ## Try it
19
+
20
+ Once published, run it without installing:
21
+
22
+ ```sh
23
+ npx portdiag 3000
24
+ npx portdiag 3000 --host 192.168.1.10
25
+ npx portdiag 3000 --json
26
+ ```
27
+
28
+ Or install it with `npm install --global portdiag` and use `portdiag` directly. The default address is `127.0.0.1`; `--host` takes an IPv4 address. Use `portdiag --help` for the full command list.
29
+
30
+ The color in the terminal is just there to help status stand out. Piped output stays plain, and setting `NO_COLOR` disables color too.
31
+
32
+ ## What it checks
33
+
34
+ Portdiag asks the operating system to bind a temporary TCP listener to the exact address and port you gave it, then closes that listener. On Windows, a successful bind by itself can be misleading: Windows may allow another socket to bind an endpoint that already has a listener. Portdiag checks `netstat` as well so it doesn’t report that endpoint as free. If `netstat` isn’t available, it says the result is unknown rather than guessing.
35
+
36
+ When binding is denied on Windows, it also checks for an excluded TCP port range with `netsh`. A matching range may explain the problem, but it isn’t proof of the only cause. For an occupied port, portdiag may find a PID with `netstat` (Windows) or `lsof` (macOS/Linux). PID lookup is best effort; if the result is missing or ambiguous, it reports no PID.
37
+
38
+ The result is only about the address checked. `127.0.0.1:3000` and `192.168.1.10:3000` are different endpoints. And “available” means the bind worked at that moment. Another process can still grab the port a moment later.
39
+
40
+ ### Reading the result
41
+
42
+ - **Available:** the bind check succeeded for the address shown. Start your app soon; another process could still claim it first.
43
+ - **In use:** a listener conflicts with that endpoint. A PID is shown only when the operating system identifies one reliably.
44
+ - **Reserved:** Windows reports that the port falls in an excluded TCP range. That may explain the bind failure, but it is not proof of the only cause.
45
+ - **Permission denied:** the operating system rejected the bind. Portdiag won’t assume that running as administrator is the right fix.
46
+ - **Address unavailable:** the requested IPv4 address is not assigned to this machine.
47
+ - **Unknown:** the available evidence wasn’t enough to identify the cause. Check the OS error shown in JSON output, if present.
48
+
49
+ Portdiag supports Windows, macOS, and Linux, requires Node.js 22 or newer, and has no runtime dependencies. It checks TCP over IPv4 only; it does not inspect UDP or IPv6. It won’t stop processes, modify excluded ranges, scan other ports, ask for admin access, or send network requests.
50
+
51
+ ### Platform checks
52
+
53
+ Portdiag checks for optional OS tools when it needs them; there’s no separate setup or `doctor` command.
54
+
55
+ | Platform | Main check | If an OS tool is unavailable |
56
+ | --- | --- | --- |
57
+ | Windows | TCP bind probe, then `netstat` cross-check; `netsh` for excluded ranges | Without `netstat`, status is `unknown` if the bind succeeded but an existing listener couldn’t be ruled out. Without `netsh`, the bind diagnosis remains, but no excluded range is reported. |
58
+ | macOS | TCP bind probe; `lsof` for a PID | The diagnosis still works; `pid` is `null`. |
59
+ | Linux | TCP bind probe; `lsof` for a PID | The diagnosis still works; `pid` is `null`. |
60
+
61
+ GitHub Actions is configured to run the test suite on Windows, macOS, and Linux. Actual results are available after the workflow runs for a pushed branch or pull request.
62
+
63
+ ## Use it from JavaScript
64
+
65
+ ```js
66
+ import { diagnosePort } from 'portdiag';
67
+
68
+ const result = await diagnosePort(3000);
69
+ console.log(result.status, result.explanation);
70
+ ```
71
+
72
+ `diagnosePort(port, options?)` accepts an integer port from `1` to `65535`. Set `options.host` to choose another IPv4 address. Invalid ports reject with `RangeError`; invalid addresses reject with `TypeError`.
73
+
74
+ The returned object is JSON-serializable:
75
+
76
+ ```js
77
+ {
78
+ port: 3000,
79
+ host: '127.0.0.1',
80
+ status: 'available', // available | in_use | reserved | permission_denied | address_unavailable | unknown
81
+ code: null, // original OS bind error, or null
82
+ pid: null, // best effort
83
+ range: null, // { start, end } for a matching Windows range
84
+ explanation: '...',
85
+ suggestion: '...'
86
+ }
87
+ ```
88
+
89
+ The CLI exits with `0` when available, `1` when unavailable, `2` for invalid arguments, and `3` when it can’t complete the diagnosis.
90
+
91
+ ## Working on portdiag
92
+
93
+ ```sh
94
+ npm test
95
+ npm pack --dry-run
96
+ ```
97
+
98
+ The test suite uses Node’s built-in test runner; GitHub Actions runs it on Windows, macOS, and Linux. Pull requests must use [Conventional Commits](CONTRIBUTING.md#commit-messages); CI checks every commit and the PR title. Bug and feature forms are in `.github/ISSUE_TEMPLATE/`. See [CONTRIBUTING.md](CONTRIBUTING.md) before sending a pull request and [SECURITY.md](SECURITY.md) to report a vulnerability privately. Before publishing, run both commands above, confirm the npm name is still available, and update the package version. Publish from an authenticated npm account with:
99
+
100
+ ```sh
101
+ npm publish --access public
102
+ ```
103
+
104
+ After that, `npx portdiag` will install and run the published CLI. The package is MIT licensed; see [LICENSE](LICENSE).
package/SECURITY.md ADDED
@@ -0,0 +1,9 @@
1
+ # Security policy
2
+
3
+ Please don't report security vulnerabilities in a public issue.
4
+
5
+ Use GitHub's **Report a vulnerability** option in the repository's Security tab to send a private report to the maintainers. Include the affected version, steps to reproduce, and any relevant impact. Please don't include real secrets, credentials, or private network data in the report.
6
+
7
+ If private vulnerability reporting is not enabled for the repository, contact the maintainers privately through the contact details on their GitHub profile. Please allow time for a response before sharing the issue publicly.
8
+
9
+ Portdiag does not send telemetry or make network requests. It runs local operating-system inspection commands where available and briefly opens a TCP listener for the bind check.
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from 'node:fs/promises';
3
+ import { diagnosePort } from '../src/index.js';
4
+
5
+ const help = `Usage: portdiag <port> [--host <ipv4-address>] [--json]
6
+ portdiag --help
7
+ portdiag --version
8
+
9
+ Checks whether a TCP/IPv4 endpoint can be bound. Default host: 127.0.0.1.`;
10
+
11
+ function invalid(message) {
12
+ console.error(`Error: ${message}\n\n${help}`);
13
+ process.exitCode = 2;
14
+ }
15
+
16
+ const useColor = Boolean(process.stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== 'dumb');
17
+ const paint = (text, code) => useColor ? `\u001B[${code}m${text}\u001B[0m` : text;
18
+
19
+ function printResult(result) {
20
+ const display = {
21
+ available: ['✓ Available', '32'],
22
+ in_use: ['● In use', '31'],
23
+ reserved: ['● Reserved', '35'],
24
+ permission_denied: ['! Permission denied', '33'],
25
+ address_unavailable: ['! Address unavailable', '33'],
26
+ unknown: ['? Unknown', '90'],
27
+ }[result.status];
28
+
29
+ const endpoint = `${result.host}:${result.port}`;
30
+ const reason = {
31
+ available: 'The TCP bind check succeeded just now.',
32
+ in_use: result.code
33
+ ? 'The operating system reports this endpoint is already in use.'
34
+ : 'Windows reports a listener on this endpoint.',
35
+ reserved: 'A Windows excluded port range may explain the bind failure.',
36
+ permission_denied: 'The operating system denied the bind request.',
37
+ address_unavailable: 'This IPv4 address is not assigned to this machine.',
38
+ unknown: `Could not determine why the bind failed${result.code ? ` (${result.code})` : ''}.`,
39
+ }[result.status];
40
+
41
+ console.log(paint('portdiag', '1'));
42
+ console.log(`${paint(display[0], display[1])} ${endpoint}`);
43
+ console.log(`\n${reason}`);
44
+ if (result.pid !== null) console.log(`PID: ${result.pid}`);
45
+ if (result.range) console.log(`Range: ${result.range.start}-${result.range.end}`);
46
+ console.log(`\nNext: ${result.suggestion}`);
47
+ }
48
+
49
+ const args = process.argv.slice(2);
50
+ if (args.length === 1 && args[0] === '--help') {
51
+ console.log(help);
52
+ } else if (args.length === 1 && args[0] === '--version') {
53
+ const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
54
+ console.log(pkg.version);
55
+ } else {
56
+ let portText;
57
+ let host = '127.0.0.1';
58
+ let hostSeen = false;
59
+ let json = false;
60
+ for (let i = 0; i < args.length; i++) {
61
+ if (args[i] === '--json') {
62
+ if (json) { invalid('duplicate --json flag'); break; }
63
+ json = true;
64
+ } else if (args[i] === '--host') {
65
+ if (hostSeen || !args[i + 1] || args[i + 1].startsWith('--')) { invalid('--host requires one IPv4 address'); break; }
66
+ hostSeen = true;
67
+ host = args[++i];
68
+ } else if (args[i].startsWith('--')) {
69
+ invalid(`unsupported flag: ${args[i]}`); break;
70
+ } else if (portText === undefined) {
71
+ portText = args[i];
72
+ } else {
73
+ invalid('provide exactly one port'); break;
74
+ }
75
+ }
76
+ if (!process.exitCode) {
77
+ if (portText === undefined || !/^\d+$/.test(portText)) invalid('port must be an integer from 1 through 65535');
78
+ else if (Number(portText) < 1 || Number(portText) > 65535) invalid('port must be an integer from 1 through 65535');
79
+ else {
80
+ try {
81
+ const result = await diagnosePort(Number(portText), { host });
82
+ if (json) console.log(JSON.stringify(result, null, 2));
83
+ else printResult(result);
84
+ process.exitCode = result.status === 'available' ? 0 : result.status === 'unknown' ? 3 : 1;
85
+ } catch (error) {
86
+ if (error instanceof TypeError || error instanceof RangeError) {
87
+ invalid(error.message);
88
+ } else {
89
+ console.error(`Diagnosis failed: ${error.message}`);
90
+ process.exitCode = 3;
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
- {
2
- "name": "portdiag",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "portdiag",
3
+ "version": "0.1.0",
4
+ "description": "Diagnose why a local TCP port cannot be bound",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/AnggaaIs/portdiag.git"
8
+ },
9
+ "homepage": "https://github.com/AnggaaIs/portdiag#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/AnggaaIs/portdiag/issues"
12
+ },
13
+ "keywords": [
14
+ "port",
15
+ "tcp",
16
+ "diagnostic",
17
+ "port-diagnostics",
18
+ "port-checker",
19
+ "bind",
20
+ "windows",
21
+ "networking",
22
+ "developer-tools"
23
+ ],
24
+ "type": "module",
25
+ "bin": {
26
+ "portdiag": "bin/portdiag.js"
27
+ },
28
+ "exports": {
29
+ ".": "./src/index.js"
30
+ },
31
+ "files": [
32
+ "bin/",
33
+ "src/",
34
+ "README.md",
35
+ "CONTRIBUTING.md",
36
+ "SECURITY.md",
37
+ "LICENSE"
38
+ ],
39
+ "engines": {
40
+ "node": ">=22"
41
+ },
42
+ "scripts": {
43
+ "test": "node --test"
44
+ },
45
+ "license": "MIT"
46
+ }
package/src/index.js ADDED
@@ -0,0 +1,107 @@
1
+ import net from 'node:net';
2
+ import { getExcludedRange } from './windows.js';
3
+ import { findListeningPid, inspectWindowsListener } from './process.js';
4
+
5
+ function validate(port, host) {
6
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
7
+ throw new RangeError('port must be an integer from 1 through 65535');
8
+ }
9
+ if (typeof host !== 'string' || !net.isIPv4(host)) {
10
+ throw new TypeError('host must be a valid IPv4 address');
11
+ }
12
+ }
13
+
14
+ function probe(port, host) {
15
+ return new Promise((resolve) => {
16
+ const server = net.createServer();
17
+ server.once('error', (error) => resolve({ error }));
18
+ server.listen({ port, host, exclusive: true }, () => {
19
+ server.close((error) => resolve({ error: error ?? null }));
20
+ });
21
+ });
22
+ }
23
+
24
+ /** @typedef {'available'|'in_use'|'reserved'|'permission_denied'|'address_unavailable'|'unknown'} PortStatus */
25
+
26
+ /**
27
+ * @typedef {Object} PortDiagnosis
28
+ * @property {number} port The checked TCP port.
29
+ * @property {string} host The checked IPv4 address.
30
+ * @property {PortStatus} status Diagnosis status.
31
+ * @property {string|null} code Original operating-system bind error code, if binding failed.
32
+ * @property {number|null} pid Best-effort PID of a conflicting listener.
33
+ * @property {{start: number, end: number}|null} range Matching Windows excluded range, if found.
34
+ * @property {string} explanation Plain-language summary of the evidence.
35
+ * @property {string} suggestion A practical next step.
36
+ */
37
+
38
+ /**
39
+ * Check whether a TCP/IPv4 endpoint can be bound right now.
40
+ *
41
+ * @param {number} port Integer from 1 through 65535.
42
+ * @param {{host?: string}} [options] Optional address; defaults to `127.0.0.1`.
43
+ * @returns {Promise<PortDiagnosis>} Serializable diagnosis result.
44
+ * @throws {RangeError} If `port` is outside the supported range or not an integer.
45
+ * @throws {TypeError} If `options.host` is not a valid IPv4 address.
46
+ */
47
+ export async function diagnosePort(port, options = {}) {
48
+ const host = options.host ?? '127.0.0.1';
49
+ const platform = options.platform ?? process.platform;
50
+ validate(port, host);
51
+
52
+ const result = {
53
+ port, host, status: 'unknown', code: null, pid: null, range: null,
54
+ explanation: '', suggestion: '',
55
+ };
56
+ const outcome = await probe(port, host);
57
+ if (!outcome.error) {
58
+ if (platform === 'win32') {
59
+ const inspection = await inspectWindowsListener(port, host);
60
+ if (!inspection.inspected) {
61
+ result.explanation = `The bind probe succeeded for ${host}:${port}, but Windows listener inspection was unavailable, so existing use could not be ruled out.`;
62
+ result.suggestion = 'Retry when netstat is available to verify whether another process is listening.';
63
+ return result;
64
+ }
65
+ if (inspection.listener) {
66
+ result.status = 'in_use';
67
+ result.pid = inspection.listener.pid;
68
+ result.explanation = `Windows netstat reports a TCP listener that conflicts with ${host}:${port}.`;
69
+ result.suggestion = 'Check the listening process before deciding whether to stop it.';
70
+ return result;
71
+ }
72
+ }
73
+ result.status = 'available';
74
+ result.explanation = `TCP endpoint ${host}:${port} accepted a bind probe and was available at the time of checking.`;
75
+ result.suggestion = 'Start your app soon; another process could still claim the port.';
76
+ return result;
77
+ }
78
+
79
+ result.code = outcome.error.code ?? null;
80
+ if (result.code === 'EADDRINUSE') {
81
+ result.status = 'in_use';
82
+ result.explanation = `Another process or socket is already using ${host}:${port}.`;
83
+ result.suggestion = 'Check the listening process before deciding whether to stop it.';
84
+ result.pid = await findListeningPid(port, platform, undefined, host);
85
+ } else if (result.code === 'EACCES' || result.code === 'EPERM') {
86
+ result.status = 'permission_denied';
87
+ result.explanation = `The operating system denied binding to ${host}:${port}.`;
88
+ result.suggestion = 'Check local security policy and whether the port is reserved; do not change system settings without understanding the cause.';
89
+ if (platform === 'win32') {
90
+ result.range = await getExcludedRange(port);
91
+ if (result.range) {
92
+ result.status = 'reserved';
93
+ result.explanation = `Port ${port} falls in the Windows excluded TCP range ${result.range.start}-${result.range.end}; this may explain the bind denial.`;
94
+ result.suggestion = 'Choose a port outside this range.';
95
+ }
96
+ }
97
+ } else if (result.code === 'EADDRNOTAVAIL') {
98
+ result.status = 'address_unavailable';
99
+ result.explanation = `The local IPv4 address ${host} is not available on this machine.`;
100
+ result.suggestion = 'Choose an IPv4 address assigned to this machine.';
101
+ } else {
102
+ result.status = 'unknown';
103
+ result.explanation = `The bind probe failed${result.code ? ` with ${result.code}` : ''}; available evidence does not identify the cause.`;
104
+ result.suggestion = 'Check the operating-system error and retry the diagnosis.';
105
+ }
106
+ return result;
107
+ }
package/src/process.js ADDED
@@ -0,0 +1,65 @@
1
+ import { execFile } from 'node:child_process';
2
+ import net from 'node:net';
3
+
4
+ function run(file, args, execute = execFile) {
5
+ return new Promise((resolve) => {
6
+ try {
7
+ execute(file, args, {
8
+ shell: false, windowsHide: true, timeout: 3000, maxBuffer: 64 * 1024,
9
+ }, (error, stdout) => resolve(error ? null : stdout));
10
+ } catch {
11
+ resolve(null);
12
+ }
13
+ });
14
+ }
15
+
16
+ export function parseWindowsListener(output, port, host) {
17
+ const listeners = [];
18
+ for (const line of output.split(/\r?\n/)) {
19
+ const fields = line.trim().split(/\s+/);
20
+ if (fields.length < 5 || !/^TCP$/i.test(fields[0]) || !/^LISTENING$/i.test(fields[3])) continue;
21
+ const endpoint = fields[1].match(/^(.+):(\d+)$/);
22
+ if (!endpoint || Number(endpoint[2]) !== port) continue;
23
+ const address = endpoint[1];
24
+ if (!net.isIPv4(address)) continue;
25
+ const conflicts = host === '0.0.0.0'
26
+ ? true
27
+ : address === host || address === '0.0.0.0';
28
+ if (!conflicts) continue;
29
+ const value = Number(fields[4]);
30
+ listeners.push({ address, pid: Number.isSafeInteger(value) && value > 0 ? value : null });
31
+ }
32
+ if (listeners.length === 0) return null;
33
+ if (listeners.some(({ pid }) => pid === null)) return { pid: null };
34
+ const pids = new Set(listeners.map(({ pid }) => pid).filter((pid) => pid !== null));
35
+ return { pid: pids.size === 1 ? [...pids][0] : null };
36
+ }
37
+
38
+ export function parseLsofPid(output) {
39
+ const pids = new Set();
40
+ for (const value of output.trim().split(/\s+/)) {
41
+ if (!/^\d+$/.test(value)) continue;
42
+ const pid = Number(value);
43
+ if (Number.isSafeInteger(pid) && pid > 0) pids.add(pid);
44
+ }
45
+ return pids.size === 1 ? [...pids][0] : null;
46
+ }
47
+
48
+ export async function inspectWindowsListener(port, host, execute = execFile) {
49
+ const output = await run('netstat', ['-ano', '-p', 'tcp'], execute);
50
+ if (output === null) return { inspected: false, listener: null };
51
+ return { inspected: true, listener: parseWindowsListener(output, port, host) };
52
+ }
53
+
54
+ export async function findListeningPid(port, platform = process.platform, execute = execFile, host = '0.0.0.0') {
55
+ if (platform === 'win32') {
56
+ const { listener } = await inspectWindowsListener(port, host, execute);
57
+ return listener?.pid ?? null;
58
+ }
59
+ if (platform === 'linux' || platform === 'darwin') {
60
+ const output = await run('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], execute);
61
+ if (output === null) return null;
62
+ return parseLsofPid(output);
63
+ }
64
+ return null;
65
+ }
package/src/windows.js ADDED
@@ -0,0 +1,30 @@
1
+ import { execFile } from 'node:child_process';
2
+
3
+ export function parseExcludedRanges(output) {
4
+ const ranges = [];
5
+ for (const line of output.split(/\r?\n/)) {
6
+ const match = line.match(/^\s*(\d+)\s+(\d+)(?:\s+\*)?\s*$/);
7
+ if (!match) continue;
8
+ const start = Number(match[1]);
9
+ const count = Number(match[2]);
10
+ const end = start + count - 1;
11
+ if (start > 0 && count > 0 && end <= 65535) ranges.push({ start, end });
12
+ }
13
+ return ranges;
14
+ }
15
+
16
+ export function findExcludedRange(port, output) {
17
+ return parseExcludedRanges(output).find(({ start, end }) => port >= start && port <= end) ?? null;
18
+ }
19
+
20
+ export function getExcludedRange(port, execute = execFile) {
21
+ return new Promise((resolve) => {
22
+ try {
23
+ execute('netsh', ['interface', 'ipv4', 'show', 'excludedportrange', 'protocol=tcp'], {
24
+ shell: false, windowsHide: true, timeout: 3000, maxBuffer: 64 * 1024,
25
+ }, (error, stdout) => resolve(error ? null : findExcludedRange(port, stdout)));
26
+ } catch {
27
+ resolve(null);
28
+ }
29
+ });
30
+ }