Skip to content

Commit

Permalink
Check all interfaces
Browse files Browse the repository at this point in the history
Inspired by the implementation in
https://github.com/sindresorhus/get-port/blob/0760c987c17581395d4e30432881dcb0ca6ca94a/index.js, make sure the port
is available on all interfaces.
  • Loading branch information
novemberborn committed Jul 10, 2022
1 parent 9e1d84d commit d6c36f9
Show file tree
Hide file tree
Showing 2 changed files with 124 additions and 5 deletions.
60 changes: 56 additions & 4 deletions source/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import crypto from 'node:crypto';
import net from 'node:net';
import os from 'node:os';
import {SharedContext} from '@ava/cooperate';

const context = new SharedContext(import.meta.url);

const localHosts = new Set([
undefined, // Default interfaces,
'0.0.0.0', // Ensure we check IPv4,
...Object.values(os.networkInterfaces()).flatMap(interfaces => interfaces?.map(info => info.address)),
]);

// Reserve a range of 16 addresses at a random offset.
const reserveRange = async (): Promise<number[]> => {
let from: number;
Expand All @@ -15,24 +22,69 @@ const reserveRange = async (): Promise<number[]> => {
return context.reserve(...range);
};

const enum Availability {
AVAILABLE,
UNAVAILABLE,
UNKNOWN,
}

// Listen on the port to make sure it's available.
const confirmAvailable = async (port: number, options?: net.ListenOptions): Promise<boolean> => new Promise((resolve, reject) => {
const confirmAvailableForHost = async ({
host,
listenOptions,
port,
unknowable,
}: {
host: string | undefined;
listenOptions?: net.ListenOptions;
port: number;
unknowable: boolean;
}): Promise<Availability> => new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on('error', (error: Error & {code: string}) => {
if (error.code === 'EADDRINUSE' || error.code === 'EACCESS') {
resolve(false);
resolve(Availability.UNAVAILABLE);
} else if (unknowable && (error.code === 'EADDRNOTAVAIL' || error.code === 'EINVAL')) { // https://github.com/sindresorhus/get-port/blob/0760c987c17581395d4e30432881dcb0ca6ca94a/index.js#L65
resolve(Availability.UNKNOWN); // The address itself is not available, so we can't check.
} else {
reject(error);
}
});
server.listen({...options, port}, () => {
server.listen({...listenOptions, host, port}, () => {
server.close(() => {
resolve(true);
resolve(Availability.AVAILABLE);
});
});
});

const confirmAvailable = async (port: number, listenOptions?: net.ListenOptions): Promise<boolean> => {
if (listenOptions?.host !== undefined) {
const available = await confirmAvailableForHost({
host: listenOptions.host,
listenOptions,
port,
unknowable: false,
});
return available === Availability.AVAILABLE;
}

for await (const host of localHosts) {
const available = await confirmAvailableForHost({
host,
listenOptions,
port,
unknowable: true,
});

if (available === Availability.UNAVAILABLE) {
return false;
}
}

return true;
};

let available: Promise<number[]> = reserveRange();
export default async function getPort(options?: Omit<net.ListenOptions, 'port'>): Promise<number> {
const promise = available;
Expand Down
69 changes: 68 additions & 1 deletion test/test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import net from 'node:net';
import os from 'node:os';
import {promisify} from 'node:util';
import test from 'ava';
import getPort from '../source/index.js';

test('gets up to 16 ports in a block', async t => {
test.serial('gets up to 16 ports in a block', async t => {
const first = await getPort();
let count = 1;

Expand All @@ -23,6 +24,72 @@ test('gets up to 16 ports in a block', async t => {
t.log({count, first, newBlock});
});

function * range(from: number, to: number) {
for (let i = from; i < to; i++) {
yield i;
}
}

test.serial('skips used ports', async t => {
const first = await getPort();
t.log({first});

let attempt;
for await (const i of range(1, 16)) {
attempt?.discard();
attempt = await t.try(async tt => {
const reserved = first + i;
tt.log({reserved});

const server = net.createServer();
tt.teardown(() => server.close());

const listen: (port: number) => Promise<void> = promisify(server.listen.bind(server));
await listen(reserved);

const port = await getPort();
tt.log({port});
tt.true(port > reserved);
});

if (attempt.passed) {
break;
}
}

attempt?.commit();
});

test.serial('fails on invalid hosts', async t => {
let code;
let host;
for await (const info of Object.values(os.networkInterfaces()).flatMap(interfaces => interfaces ?? [])) {
const server = net.createServer();
t.teardown(() => server.close());

const unavailable = await new Promise<{code: 'EADDRNOTAVAIL' | 'EINVAL'; host: string} | undefined>((resolve, reject) => {
server.on('error', (error: Error & {code: string}) => {
if (error.code === 'EADDRNOTAVAIL' || error.code === 'EINVAL') {
resolve({code: error.code, host: info.address});
} else {
reject(error);
}
});
server.listen({host: info.address, port: 0}, () => {
resolve(undefined);
});
});

if (unavailable !== undefined) {
({code, host} = unavailable);
break;
}
}

t.not(host, undefined);
await t.throwsAsync(getPort({host}), {code});
});

test('port can be bound', async t => {
const server = net.createServer();
t.teardown(() => server.close());
Expand Down

0 comments on commit d6c36f9

Please sign in to comment.