diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..0a3c435 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,26 @@ +name: CI +on: + - push + - pull_request +jobs: + test: + name: Node.js ${{ matrix.node-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-latest + node-version: + - 16 + - 14 + - 12 + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f98fed0..0000000 --- a/.travis.yml +++ /dev/null @@ -1,5 +0,0 @@ -language: node_js -node_js: - - '12' - - '10' - - '8' diff --git a/index.d.ts b/index.d.ts index d930546..9f327b9 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,64 +1,62 @@ -/// -import {ListenOptions} from 'net'; +import {ListenOptions} from 'node:net'; -declare namespace getPort { - interface Options extends Omit { - /** - A preferred port or an iterable of preferred ports to use. - */ - readonly port?: number | Iterable; +export interface Options extends Omit { + /** + A preferred port or an iterable of preferred ports to use. + */ + readonly port?: number | Iterable; - /** - The host on which port resolution should be performed. Can be either an IPv4 or IPv6 address. - */ - readonly host?: string; - } -} + /** + Ports that should not be returned. + + You could, for example, pass it the return value of the `portNumbers()` function. + */ + readonly exclude?: Iterable; -declare const getPort: { /** - Get an available TCP port number. + The host on which port resolution should be performed. Can be either an IPv4 or IPv6 address. - @returns Port number. + By default, it checks availability on all local addresses defined in [OS network interfaces](https://nodejs.org/api/os.html#os_os_networkinterfaces). If this option is set, it will only check the given host. + */ + readonly host?: string; +} - @example - ``` - import getPort = require('get-port'); +/** +Get an available TCP port number. - (async () => { - console.log(await getPort()); - //=> 51402 +@returns Port number. - // Pass in a preferred port - console.log(await getPort({port: 3000})); - // Will use 3000 if available, otherwise fall back to a random port +@example +``` +import getPort from 'get-port'; - // Pass in an array of preferred ports - console.log(await getPort({port: [3000, 3001, 3002]})); - // Will use any element in the preferred ports array if available, otherwise fall back to a random port - })(); - ``` - */ - (options?: getPort.Options): Promise; +console.log(await getPort()); +//=> 51402 - /** - Make a range of ports `from`...`to`. +// Pass in a preferred port +console.log(await getPort({port: 3000})); +// Will use 3000 if available, otherwise fall back to a random port - @param from - First port of the range. Must be in the range `1024`...`65535`. - @param to - Last port of the range. Must be in the range `1024`...`65535` and must be greater than `from`. - @returns The ports in the range. +// Pass in an array of preferred ports +console.log(await getPort({port: [3000, 3001, 3002]})); +// Will use any element in the preferred ports array if available, otherwise fall back to a random port +``` +*/ +export default function getPort(options?: Options): Promise; - @example - ``` - import getPort = require('get-port'); +/** +Generate port numbers in the given range `from`...`to`. - (async () => { - console.log(await getPort({port: getPort.makeRange(3000, 3100)})); - // Will use any port from 3000 to 3100, otherwise fall back to a random port - })(); - ``` - */ - makeRange(from: number, to: number): Iterable; -}; +@param from - The first port of the range. Must be in the range `1024`...`65535`. +@param to - The last port of the range. Must be in the range `1024`...`65535` and must be greater than `from`. +@returns The port numbers in the range. + +@example +``` +import getPort, {portNumbers} from 'get-port'; -export = getPort; +console.log(await getPort({port: portNumbers(3000, 3100)})); +// Will use any port from 3000 to 3100, otherwise fall back to a random port +``` +*/ +export function portNumbers(from: number, to: number): Iterable; diff --git a/index.js b/index.js index f3e2210..06e993a 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,5 @@ -'use strict'; -const net = require('net'); +import net from 'node:net'; +import os from 'node:os'; class Locked extends Error { constructor(port) { @@ -9,7 +9,7 @@ class Locked extends Error { const lockedPorts = { old: new Set(), - young: new Set() + young: new Set(), }; // On this interval, the old locked ports are discarded, @@ -17,20 +17,59 @@ const lockedPorts = { // and a new young set for locked ports are created. const releaseOldLockedPortsIntervalMs = 1000 * 15; +const minPort = 1024; +const maxPort = 65_535; + // Lazily create interval on first use let interval; -const getAvailablePort = options => new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on('error', reject); - server.listen(options, () => { - const {port} = server.address(); - server.close(() => { - resolve(port); +const getLocalHosts = () => { + const interfaces = os.networkInterfaces(); + + // Add undefined value for createServer function to use default host, + // and default IPv4 host in case createServer defaults to IPv6. + const results = new Set([undefined, '0.0.0.0']); + + for (const _interface of Object.values(interfaces)) { + for (const config of _interface) { + results.add(config.address); + } + } + + return results; +}; + +const checkAvailablePort = options => + new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on('error', reject); + + server.listen(options, () => { + const {port} = server.address(); + server.close(() => { + resolve(port); + }); }); }); -}); + +const getAvailablePort = async (options, hosts) => { + if (options.host || options.port === 0) { + return checkAvailablePort(options); + } + + for (const host of hosts) { + try { + await checkAvailablePort({port: options.port, host}); // eslint-disable-line no-await-in-loop + } catch (error) { + if (!['EADDRNOTAVAIL', 'EINVAL'].includes(error.code)) { + throw error; + } + } + } + + return options.port; +}; const portCheckSequence = function * (ports) { if (ports) { @@ -40,11 +79,34 @@ const portCheckSequence = function * (ports) { yield 0; // Fall back to 0 if anything else failed }; -module.exports = async options => { +export default async function getPorts(options) { let ports; + let exclude = new Set(); if (options) { - ports = typeof options.port === 'number' ? [options.port] : options.port; + if (options.port) { + ports = typeof options.port === 'number' ? [options.port] : options.port; + } + + if (options.exclude) { + const excludeIterable = options.exclude; + + if (typeof excludeIterable[Symbol.iterator] !== 'function') { + throw new TypeError('The `exclude` option must be an iterable.'); + } + + for (const element of excludeIterable) { + if (typeof element !== 'number') { + throw new TypeError('Each item in the `exclude` option must be a number corresponding to the port you want excluded.'); + } + + if (!Number.isSafeInteger(element)) { + throw new TypeError(`Number ${element} in the exclude option is not a safe integer and can't be used`); + } + } + + exclude = new Set(excludeIterable); + } } if (interval === undefined) { @@ -59,18 +121,25 @@ module.exports = async options => { } } + const hosts = getLocalHosts(); + for (const port of portCheckSequence(ports)) { try { - let availablePort = await getAvailablePort({...options, port}); // eslint-disable-line no-await-in-loop + if (exclude.has(port)) { + continue; + } + + let availablePort = await getAvailablePort({...options, port}, hosts); // eslint-disable-line no-await-in-loop while (lockedPorts.old.has(availablePort) || lockedPorts.young.has(availablePort)) { if (port !== 0) { throw new Locked(port); } - availablePort = await getAvailablePort({...options, port}); // eslint-disable-line no-await-in-loop + availablePort = await getAvailablePort({...options, port}, hosts); // eslint-disable-line no-await-in-loop } lockedPorts.young.add(availablePort); + return availablePort; } catch (error) { if (!['EADDRINUSE', 'EACCES'].includes(error.code) && !(error instanceof Locked)) { @@ -80,22 +149,22 @@ module.exports = async options => { } throw new Error('No available ports found'); -}; +} -module.exports.makeRange = (from, to) => { +export function portNumbers(from, to) { if (!Number.isInteger(from) || !Number.isInteger(to)) { throw new TypeError('`from` and `to` must be integer numbers'); } - if (from < 1024 || from > 65535) { - throw new RangeError('`from` must be between 1024 and 65535'); + if (from < minPort || from > maxPort) { + throw new RangeError(`'from' must be between ${minPort} and ${maxPort}`); } - if (to < 1024 || to > 65536) { - throw new RangeError('`to` must be between 1024 and 65536'); + if (to < minPort || to > maxPort) { + throw new RangeError(`'to' must be between ${minPort} and ${maxPort}`); } - if (to < from) { + if (from > to) { throw new RangeError('`to` must be greater than or equal to `from`'); } @@ -106,4 +175,4 @@ module.exports.makeRange = (from, to) => { }; return generator(from, to); -}; +} diff --git a/index.test-d.ts b/index.test-d.ts index f72bc46..d440ae2 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -1,9 +1,11 @@ import {expectType} from 'tsd'; -import getPort = require('.'); +import getPort, {portNumbers} from './index.js'; expectType>(getPort()); expectType>(getPort({port: 3000})); +expectType>(getPort({exclude: [3000]})); +expectType>(getPort({exclude: [3000, 3001]})); expectType>(getPort({port: [3000, 3001, 3002]})); expectType>(getPort({host: 'https://localhost'})); expectType>(getPort({ipv6Only: true})); -expectType>(getPort.makeRange(1024, 1025)); +expectType>(portNumbers(1024, 1025)); diff --git a/license b/license index e7af2f7..fa7ceba 100644 --- a/license +++ b/license @@ -1,6 +1,6 @@ MIT License -Copyright (c) Sindre Sorhus (sindresorhus.com) +Copyright (c) Sindre Sorhus (https://sindresorhus.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/package.json b/package.json index 7b9448c..455ed40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "get-port", - "version": "5.1.1", + "version": "6.1.2", "description": "Get an available port", "license": "MIT", "repository": "sindresorhus/get-port", @@ -8,10 +8,12 @@ "author": { "name": "Sindre Sorhus", "email": "sindresorhus@gmail.com", - "url": "sindresorhus.com" + "url": "https://sindresorhus.com" }, + "type": "module", + "exports": "./index.js", "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "scripts": { "test": "xo && ava && tsd" @@ -38,9 +40,11 @@ "chosen" ], "devDependencies": { - "@types/node": "^12.12.21", - "ava": "^2.4.0", - "tsd": "^0.11.0", - "xo": "^0.25.3" - } + "@types/node": "^16.10.2", + "ava": "^3.15.0", + "tsd": "^0.17.0", + "typescript": "^4.4.3", + "xo": "^0.45.0" + }, + "sideEffects": false } diff --git a/readme.md b/readme.md index 05d78fb..b6cd616 100644 --- a/readme.md +++ b/readme.md @@ -1,49 +1,47 @@ -# get-port [![Build Status](https://travis-ci.org/sindresorhus/get-port.svg?branch=master)](https://travis-ci.org/sindresorhus/get-port) +# get-port -> Get an available [TCP port](https://en.wikipedia.org/wiki/Port_(computer_networking)) +> Get an available [TCP port](https://en.wikipedia.org/wiki/Port_(computer_networking)). ## Install -``` -$ npm install get-port +```sh +npm install get-port ``` ## Usage ```js -const getPort = require('get-port'); +import getPort from 'get-port'; -(async () => { - console.log(await getPort()); - //=> 51402 -})(); +console.log(await getPort()); +//=> 51402 ``` Pass in a preferred port: ```js -(async () => { - console.log(await getPort({port: 3000})); - // Will use 3000 if available, otherwise fall back to a random port -})(); +import getPort from 'get-port'; + +console.log(await getPort({port: 3000})); +// Will use 3000 if available, otherwise fall back to a random port ``` Pass in an array of preferred ports: ```js -(async () => { - console.log(await getPort({port: [3000, 3001, 3002]})); - // Will use any element in the preferred ports array if available, otherwise fall back to a random port -})(); +import getPort from 'get-port'; + +console.log(await getPort({port: [3000, 3001, 3002]})); +// Will use any element in the preferred ports array if available, otherwise fall back to a random port ``` -Use the `makeRange()` helper in case you need a port in a certain range: +Use the `portNumbers()` helper in case you need a port in a certain range: ```js -(async () => { - console.log(await getPort({port: getPort.makeRange(3000, 3100)})); - // Will use any port from 3000 to 3100, otherwise fall back to a random port -})(); +import getPort, {portNumbers} from 'get-port'; + +console.log(await getPort({port: portNumbers(3000, 3100)})); +// Will use any port from 3000 to 3100, otherwise fall back to a random port ``` ## API @@ -62,29 +60,39 @@ Type: `number | Iterable` A preferred port or an iterable of preferred ports to use. +##### exclude + +Type: `Iterable` + +Ports that should not be returned. + +You could, for example, pass it the return value of the `portNumbers()` function. + ##### host Type: `string` The host on which port resolution should be performed. Can be either an IPv4 or IPv6 address. -### getPort.makeRange(from, to) +By default, it checks availability on all local addresses defined in [OS network interfaces](https://nodejs.org/api/os.html#os_os_networkinterfaces). If this option is set, it will only check the given host. + +### portNumbers(from, to) -Make a range of ports `from`...`to`. +Generate port numbers in the given range `from`...`to`. -Returns an `Iterable` for ports in the given range. +Returns an `Iterable` for port numbers in the given range. #### from Type: `number` -First port of the range. Must be in the range `1024`...`65535`. +The first port of the range. Must be in the range `1024`...`65535`. #### to Type: `number` -Last port of the range. Must be in the range `1024`...`65535` and must be greater than `from`. +The last port of the range. Must be in the range `1024`...`65535` and must be greater than `from`. ## Beware diff --git a/test.js b/test.js index ba4c794..9ff8b63 100644 --- a/test.js +++ b/test.js @@ -1,7 +1,7 @@ -import {promisify} from 'util'; -import net from 'net'; +import {promisify} from 'node:util'; +import net from 'node:net'; import test from 'ava'; -import getPort from '.'; +import getPort, {portNumbers} from './index.js'; test('port can be bound when promise resolves', async t => { const port = await getPort(); @@ -56,7 +56,7 @@ test('preferred port given IPv4 host', async t => { const desiredPort = 8081; const port = await getPort({ port: desiredPort, - host: '0.0.0.0' + host: '0.0.0.0', }); t.is(port, desiredPort); @@ -66,7 +66,7 @@ test('preferred ports', async t => { const desiredPorts = [9910, 9912, 9913]; const port = await getPort({ port: desiredPorts, - host: '0.0.0.0' + host: '0.0.0.0', }); t.is(port, desiredPorts[0]); @@ -79,7 +79,7 @@ test('first port in preferred ports array is unavailable', async t => { await promisify(server.listen.bind(server))(desiredPorts[0]); const port = await getPort({ - port: desiredPorts + port: desiredPorts, }); t.is(port, desiredPorts[1]); @@ -94,11 +94,11 @@ test('all preferred ports in array are unavailable', async t => { await promisify(server2.listen.bind(server2))(desiredPorts[1]); const port = await getPort({ - port: desiredPorts + port: desiredPorts, }); t.is(typeof port, 'number'); - t.true(port > 0 && port < 65536); + t.true(port > 0 && port < 65_536); t.not(port, desiredPorts[0]); t.not(port, desiredPorts[1]); }); @@ -107,49 +107,89 @@ test('non-array iterables work', async t => { const desiredPorts = (function * () { yield 9920; })(); + const port = await getPort({ port: desiredPorts, - host: '0.0.0.0' + host: '0.0.0.0', }); + t.is(port, 9920); }); -test('makeRange throws on invalid ranges', t => { - t.throws(() => { - getPort.makeRange(1025, 1024); - }); +test('portNumbers throws on invalid ranges', t => { + t.throws(() => portNumbers('abc', 3000), {instanceOf: TypeError}, '`from` is not an integer number'); + t.throws(() => portNumbers(3000, 'abc'), {instanceOf: TypeError}, '`to` is not an integer number'); - // Invalid port values - t.throws(() => { - getPort.makeRange(0, 0); - }); - t.throws(() => { - getPort.makeRange(1023, 1023); - }); - t.throws(() => { - getPort.makeRange(65536, 65536); - }); + t.throws(() => portNumbers(1023, 1024), {instanceOf: RangeError}, '`from` is less than the minimum port'); + t.throws(() => portNumbers(65_536, 65_536), {instanceOf: RangeError}, '`from` is greater than the maximum port'); + + t.throws(() => portNumbers(1024, 1023), {instanceOf: RangeError}, '`to` is less than the minimum port'); + t.throws(() => portNumbers(65_535, 65_537), {instanceOf: RangeError}, '`to` is greater than the maximum port'); + + t.throws(() => portNumbers(1025, 1024), {instanceOf: RangeError}, '`from` is less than `to`'); }); -test('makeRange produces valid ranges', t => { - t.deepEqual([...getPort.makeRange(1024, 1024)], [1024]); - t.deepEqual([...getPort.makeRange(1024, 1025)], [1024, 1025]); - t.deepEqual([...getPort.makeRange(1024, 1027)], [1024, 1025, 1026, 1027]); +test('portNumbers produces valid ranges', t => { + t.deepEqual([...portNumbers(1024, 1024)], [1024]); + t.deepEqual([...portNumbers(1024, 1025)], [1024, 1025]); + t.deepEqual([...portNumbers(1024, 1027)], [1024, 1025, 1026, 1027]); }); -test('ports are locked for up to 30 seconds', async t => { - // Speed up the test by overriding `setInterval`. - const {setInterval} = global; - global.setInterval = (fn, timeout) => setInterval(fn, timeout / 100); +test('exclude produces ranges that exclude provided exclude list', async t => { + const exclude = [1024, 1026]; + const foundPorts = await getPort({exclude, port: portNumbers(1024, 1026)}); - delete require.cache[require.resolve('.')]; - const getPort = require('.'); - const timeout = promisify(setTimeout); - const port = await getPort(); - const port2 = await getPort({port}); - t.not(port2, port); - await timeout(300); // 30000 / 100 - const port3 = await getPort({port}); - t.is(port3, port); - global.setInterval = setInterval; + // We should not find any of the exclusions in `foundPorts`. + t.is(foundPorts, 1025); +}); + +test('exclude throws error if not provided with a valid iterator', async t => { + const exclude = 42; + await t.throwsAsync(getPort({exclude})); +}); + +test('exclude throws error if provided iterator contains items which are non number', async t => { + const exclude = ['foo']; + await t.throwsAsync(getPort({exclude})); +}); + +test('exclude throws error if provided iterator contains items which are unsafe numbers', async t => { + const exclude = [Number.NaN]; + await t.throwsAsync(getPort({exclude})); +}); + +// TODO: Re-enable this test when ESM supports import hooks. +// test('ports are locked for up to 30 seconds', async t => { +// // Speed up the test by overriding `setInterval`. +// const {setInterval} = global; +// global.setInterval = (fn, timeout) => setInterval(fn, timeout / 100); + +// delete require.cache[require.resolve('.')]; +// const getPort = require('.'); +// const timeout = promisify(setTimeout); +// const port = await getPort(); +// const port2 = await getPort({port}); +// t.not(port2, port); +// await timeout(300); // 30000 / 100 +// const port3 = await getPort({port}); +// t.is(port3, port); +// global.setInterval = setInterval; +// }); + +const bindPort = async ({port, host}) => { + const server = net.createServer(); + await promisify(server.listen.bind(server))({port, host}); + return server; +}; + +test('preferred ports is bound up with different hosts', async t => { + const desiredPorts = [10_990, 10_991, 10_992, 10_993]; + + await bindPort({port: desiredPorts[0]}); + await bindPort({port: desiredPorts[1], host: '0.0.0.0'}); + await bindPort({port: desiredPorts[2], host: '127.0.0.1'}); + + const port = await getPort({port: desiredPorts}); + + t.is(port, desiredPorts[3]); });