net: add experimental net/promises API

Add an experimental `net/promises` namespace, accessible via
`require('node:net/promises')` or `net.promises`, mirroring the existing
`fs/promises` and `dns/promises` modules.

It provides promise-based variants of net's one-shot lifecycle
operations:

- `connect()` returns a promise that fulfills with a connected Socket
  once the 'connect' event fires, and rejects on connection failure or
  when an optional AbortSignal is aborted, destroying the socket.
- `listen()` creates a server, begins listening, and returns a promise
  that fulfills with the listening Server once the 'listening' event
  fires. It rejects if the server fails to bind or an optional
  AbortSignal is aborted, closing the server.

The functions are named for the actions they await, forming a parallel
`connect()`/`listen()` pair. This is intentionally not the callback
API's factory taxonomy: there `createConnection()` is canonical because
it both creates a socket and initiates connecting, whereas
`createServer()` is a pure factory that does not listen. A
`createConnection`/`createServer` pairing would be inconsistent here
because `createServer` has no completion to await, so no
`createConnection` alias is provided.

Refs: https://github.com/nodejs/node/issues/21482
Assisted-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Ethan Arrowood <ethan@arrowood.dev>
PR-URL: https://github.com/nodejs/node/pull/63965
Reviewed-By: Tim Perry <pimterry@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
This commit is contained in:
Ethan Arrowood authored and GitHub committed 2026-07-28 19:29:48 +00:00
1 parent 853039ccc5
commit 6d8baeabb0
9 files changed
+435 -6

No files matched your search

+119
View File
@@ -452,6 +452,48 @@ changes:
Calls [`server.close()`][] and returns a promise that fulfills when the
server has closed.
### `server[Symbol.asyncIterator]()`
<!-- YAML
added: REPLACEME
-->
> Stability: 1 - Experimental
* Returns: {AsyncIterator} An async iterator that yields each incoming
[`net.Socket`][].
Returns an async iterator over the server's incoming connections, allowing them
to be consumed with `for await...of` as an alternative to the [`'connection'`][]
event. Iteration ends when the server emits [`'close'`][], and rejects if the
server emits [`'error'`][].
The loop only advances to the next connection once the current iteration's body
has finished awaiting, so connection handling should be dispatched to a separate
async task rather than awaited inline. Otherwise connections are serialized:
each one waits for the previous to be fully handled.
```mjs
import { createServer } from 'node:net';
const server = createServer().listen(8124);
async function handleConnection(socket) {
// ...handle the connection, awaiting as needed.
socket.end('hello world!');
}
for await (const socket of server) {
// Dispatch handling to a separate task so the loop keeps accepting
// connections instead of serializing them.
handleConnection(socket);
}
```
The server does not stop accepting connections while the loop body runs, so a
consumer slower than the connection rate can buffer them without bound. Use
[`server.maxConnections`][] to bound concurrency.
### `server.getConnections(callback)`
<!-- YAML
@@ -2282,6 +2324,82 @@ net.isIPv6('::1'); // returns true
net.isIPv6('fhqwhgads'); // returns false
```
## `net/promises` API
<!-- YAML
added: REPLACEME
-->
> Stability: 1 - Experimental
The `net/promises` API provides a set of `net` functions that return `Promise`
objects rather than relying on events. The API is accessible via
`require('node:net').promises` or `require('node:net/promises')`.
### `netPromises.connect(options)`
### `netPromises.connect(path)`
### `netPromises.connect(port[, host])`
<!-- YAML
added: REPLACEME
-->
* `options` {Object} Accepts the same arguments as [`net.connect()`][]. May
include a `signal` {AbortSignal} that can be used to abort an in-progress
connection attempt.
* Returns: {Promise} Fulfills with a connected [`net.Socket`][].
A promise-based alternative to [`net.connect()`][]. The returned promise is
fulfilled with the socket once its [`'connect'`][] event fires, and is rejected
if the connection fails or the `signal` is aborted. When the promise rejects,
the underlying socket is destroyed.
This API is named for the action it performs and awaits — connecting — to
parallel [`netPromises.listen()`][]. It is not named `createConnection()`,
because that name belongs to the socket-factory taxonomy of the callback API,
which has no counterpart here.
```mjs
import { connect } from 'node:net/promises';
const socket = await connect({ port: 8124 });
socket.write('hello world!');
socket.end();
```
### `netPromises.listen([options])`
<!-- YAML
added: REPLACEME
-->
* `options` {Object} Accepts the same options as [`net.createServer()`][] and
[`server.listen()`][], plus:
* `connectionListener` {Function} Automatically set as a listener for the
[`'connection'`][] event.
* `signal` {AbortSignal} An `AbortSignal` that may be used to abort the
server. Aborting before the server is listening rejects the returned
promise with an `AbortError`; aborting at any later point closes the
server, matching the `signal` option of [`server.listen()`][].
* Returns: {Promise} Fulfills with a listening [`net.Server`][].
Creates a [`net.Server`][] and begins listening. The returned promise is
fulfilled with the server once its [`'listening'`][] event fires, and is
rejected if the server fails to bind or the `signal` is aborted before it is
listening. When the promise rejects, the server is closed.
The resolved server is async iterable, so incoming connections can be consumed
with `for await...of` (see `server[Symbol.asyncIterator]()`).
```mjs
import { listen } from 'node:net/promises';
const server = await listen({ port: 8124 });
console.log('listening on', server.address().port);
```
[IPC]: #ipc-support
[Identifying paths for IPC connections]: #identifying-paths-for-ipc-connections
[RFC 8305]: https://www.rfc-editor.org/rfc/rfc8305.txt
@@ -2316,6 +2434,7 @@ net.isIPv6('fhqwhgads'); // returns false
[`net.createServer()`]: #netcreateserveroptions-connectionlistener
[`net.getDefaultAutoSelectFamily()`]: #netgetdefaultautoselectfamily
[`net.getDefaultAutoSelectFamilyAttemptTimeout()`]: #netgetdefaultautoselectfamilyattempttimeout
[`netPromises.listen()`]: #netpromiseslistenoptions
[`new net.Socket(options)`]: #new-netsocketoptions
[`readable.setEncoding()`]: stream.md#readablesetencodingencoding
[`server.address()`]: #serveraddress
+88
View File
@@ -0,0 +1,88 @@
'use strict';
const { once } = require('events');
const {
validateAbortSignal,
validateObject,
} = require('internal/validators');
const { kEmptyObject } = require('internal/util');
// Lazily loaded to avoid a require cycle with the `net` module, which exposes
// this namespace through its `promises` getter.
let net;
function lazyNet() {
net ??= require('net');
return net;
}
// Resolves with a connected `net.Socket` once the `'connect'` event fires, and
// rejects if the connection fails or the optional `signal` is aborted.
async function connect(...args) {
const lazy = lazyNet();
const options = lazy._normalizeArgs(args)[0];
const { signal } = options;
if (signal !== undefined) {
validateAbortSignal(signal, 'options.signal');
signal.throwIfAborted();
}
// Strip the signal so the socket does not also install its own abort
// handling; rejecting and destroying below fully tears the socket down.
const socket = lazy.connect({ ...options, signal: undefined });
try {
await once(socket, 'connect', signal !== undefined ? { signal } : kEmptyObject);
} catch (err) {
socket.destroy();
throw err;
}
return socket;
}
// Creates a server and resolves with it once it is listening, rejecting if it
// fails to bind or the optional `signal` is aborted.
async function listen(options = kEmptyObject) {
validateObject(options, 'options');
const { signal, connectionListener } = options;
if (signal !== undefined) {
validateAbortSignal(signal, 'options.signal');
signal.throwIfAborted();
}
const lazy = lazyNet();
const server = lazy.createServer(options, connectionListener);
try {
// Default to an ephemeral port when no listen target is supplied, matching
// `server.listen()` with no arguments; passing a target-less options object
// (the `{}` default, or e.g. `{ signal }`) straight to listen() would throw
// ERR_INVALID_ARG_VALUE. `signal` is passed through so net installs its own
// close-on-abort handler: the signal aborts the server for its entire
// lifetime, not just the pending listen.
const hasListenTarget = options.port !== undefined ||
options.path !== undefined ||
options.fd !== undefined ||
options.handle !== undefined;
server.listen(hasListenTarget ? options : { ...options, port: 0 });
await once(server, 'listening', signal !== undefined ? { signal } : kEmptyObject);
} catch (err) {
// On abort, net's signal handler already closes the server, so closing
// again would be redundant; on other failures (e.g. a bind error) there
// is no such handler, so close it here.
if (!signal?.aborted) {
server.close();
}
throw err;
}
return server;
}
module.exports = {
connect,
listen,
get isIP() { return lazyNet().isIP; },
get isIPv4() { return lazyNet().isIPv4; },
get isIPv6() { return lazyNet().isIPv6; },
get BlockList() { return lazyNet().BlockList; },
get SocketAddress() { return lazyNet().SocketAddress; },
};
+15
View File
@@ -37,6 +37,7 @@ const {
ObjectSetPrototypeOf,
Symbol,
SymbolAsyncDispose,
SymbolAsyncIterator,
SymbolDispose,
} = primordials;
@@ -156,6 +157,8 @@ let cluster;
let dns;
let BlockList;
let SocketAddress;
let netPromises;
let kFirstEventParam;
let autoSelectFamilyDefault = getOptionValue('--network-family-autoselection');
let autoSelectFamilyAttemptTimeoutDefault = getOptionValue('--network-family-autoselection-attempt-timeout');
@@ -2854,6 +2857,14 @@ Server.prototype[SymbolAsyncDispose] = async function() {
await FunctionPrototypeCall(promisify(this.close), this);
};
Server.prototype[SymbolAsyncIterator] = function() {
kFirstEventParam ??= require('internal/events/symbols').kFirstEventParam;
return EventEmitter.on(this, 'connection', {
close: ['close'],
[kFirstEventParam]: true,
});
};
Server.prototype._emitCloseIfDrained = function() {
debug('SERVER _emitCloseIfDrained');
@@ -2945,6 +2956,10 @@ module.exports = {
connect,
createConnection: connect,
createServer,
get promises() {
netPromises ??= require('internal/net/promises');
return netPromises;
},
isIP: isIP,
isIPv4: isIPv4,
isIPv6: isIPv6,
+3
View File
@@ -0,0 +1,3 @@
'use strict';
module.exports = require('internal/net/promises');
@@ -0,0 +1,62 @@
'use strict';
const common = require('../common');
const assert = require('assert');
const net = require('net');
const { once } = require('events');
const { connect } = require('net/promises');
(async () => {
// Resolves with a connected socket and round-trips data.
{
const server = net.createServer((socket) => {
socket.end('hello');
}).listen(0);
await once(server, 'listening');
const socket = await connect({ port: server.address().port });
assert.strictEqual(socket.connecting, false);
const chunks = [];
for await (const chunk of socket) {
chunks.push(chunk);
}
assert.strictEqual(Buffer.concat(chunks).toString(), 'hello');
server.close();
}
// net.promises is the same object as require('net/promises').
assert.strictEqual(net.promises, require('net/promises'));
// Rejects when the connection is refused.
{
const server = net.createServer().listen(0);
await once(server, 'listening');
const { port } = server.address();
server.close();
await once(server, 'close');
await assert.rejects(connect({ port }), { code: 'ECONNREFUSED' });
}
// A pre-aborted signal rejects with an AbortError.
{
await assert.rejects(
connect({ port: 0, signal: AbortSignal.abort() }),
{ name: 'AbortError' });
}
// Aborting while connecting rejects with an AbortError.
{
const server = net.createServer().listen(0);
await once(server, 'listening');
const controller = new AbortController();
const promise = connect({ port: server.address().port, signal: controller.signal });
controller.abort();
await assert.rejects(promise, { name: 'AbortError' });
server.close();
}
// An invalid signal throws.
{
await assert.rejects(
connect({ port: 0, signal: 'INVALID_SIGNAL' }),
{ code: 'ERR_INVALID_ARG_TYPE' });
}
})().then(common.mustCall());
+90
View File
@@ -0,0 +1,90 @@
'use strict';
const common = require('../common');
const assert = require('assert');
const net = require('net');
const { once } = require('events');
const { listen } = require('net/promises');
(async () => {
// Resolves with a listening server.
{
const server = await listen({ port: 0 });
assert.strictEqual(server.listening, true);
assert.strictEqual(typeof server.address().port, 'number');
server.close();
}
// With no listen target, defaults to an ephemeral port (like
// `server.listen()` with no arguments) rather than throwing.
{
const server = await listen();
assert.strictEqual(server.listening, true);
assert.strictEqual(typeof server.address().port, 'number');
server.close();
}
// A target-less options bag still defaults to an ephemeral port while
// honoring other options such as `host`.
{
const server = await listen({ host: '127.0.0.1' });
assert.strictEqual(server.listening, true);
assert.strictEqual(server.address().address, '127.0.0.1');
assert.strictEqual(typeof server.address().port, 'number');
server.close();
}
// The signal aborts the server for its entire lifetime, not just the
// pending listen: aborting after it is listening closes the server.
{
const controller = new AbortController();
const server = await listen({ port: 0, signal: controller.signal });
assert.strictEqual(server.listening, true);
const closed = once(server, 'close');
controller.abort();
await closed;
assert.strictEqual(server.listening, false);
}
// The connectionListener option receives incoming connections.
{
const server = await listen({
port: 0,
connectionListener: common.mustCall((socket) => {
socket.end();
server.close();
}),
});
const client = net.connect(server.address().port);
client.resume();
}
// A pre-aborted signal rejects with an AbortError.
{
await assert.rejects(
listen({ port: 0, signal: AbortSignal.abort() }),
{ name: 'AbortError' });
}
// Aborting while binding rejects with an AbortError and closes the server.
{
const controller = new AbortController();
const promise = listen({ port: 0, signal: controller.signal });
controller.abort();
await assert.rejects(promise, { name: 'AbortError' });
}
// An invalid signal throws.
{
await assert.rejects(
listen({ port: 0, signal: 'INVALID_SIGNAL' }),
{ code: 'ERR_INVALID_ARG_TYPE' });
}
// Rejects when the address is already in use.
{
const first = await listen({ port: 0 });
const { port } = first.address();
await assert.rejects(listen({ port }), { code: 'EADDRINUSE' });
first.close();
}
})().then(common.mustCall());
@@ -0,0 +1,50 @@
'use strict';
const common = require('../common');
const assert = require('assert');
const net = require('net');
const { once } = require('events');
(async () => {
// `for await...of` yields each incoming connection as a net.Socket.
{
const server = net.createServer().listen(0);
await once(server, 'listening');
const { port } = server.address();
const total = 3;
const clients = [];
for (let i = 0; i < total; i++) {
clients.push(net.connect(port));
}
let count = 0;
for await (const socket of server) {
assert.ok(socket instanceof net.Socket);
socket.end();
if (++count === total) break;
}
assert.strictEqual(count, total);
// Breaking out of the loop stops iterating but does not close the server.
assert.strictEqual(server.listening, true);
server.close();
for (const client of clients) {
client.destroy();
}
}
// Iteration ends cleanly when the server closes.
{
const server = net.createServer().listen(0);
await once(server, 'listening');
process.nextTick(() => server.close());
let count = 0;
for await (const socket of server) {
count++;
socket.end();
}
assert.strictEqual(count, 0);
}
})().then(common.mustCall());
@@ -52,11 +52,12 @@ replServer.complete("import\t( 'n", common.mustSucceed((data) => {
assert.notStrictEqual(lastIndex, -1);
});
assert.strictEqual(completions[lastIndex + 1], '');
// There is only one Node.js module that starts with n:
// The Node.js modules that start with n:
assert.strictEqual(completions[lastIndex + 2], 'net');
assert.strictEqual(completions[lastIndex + 3], '');
assert.strictEqual(completions[lastIndex + 3], 'net/promises');
assert.strictEqual(completions[lastIndex + 4], '');
// It's possible to pick up non-core modules too
for (const completion of completions.slice(lastIndex + 4)) {
for (const completion of completions.slice(lastIndex + 5)) {
assert.match(completion, /^n/);
}
}));
@@ -67,11 +67,12 @@ const { startNewREPLServer } = require('../common/repl');
assert.notStrictEqual(lastIndex, -1);
}
assert.strictEqual(data[0][lastIndex + 1], '');
// There is only one Node.js module that starts with n:
// The Node.js modules that start with n:
assert.strictEqual(data[0][lastIndex + 2], 'net');
assert.strictEqual(data[0][lastIndex + 3], '');
assert.strictEqual(data[0][lastIndex + 3], 'net/promises');
assert.strictEqual(data[0][lastIndex + 4], '');
// It's possible to pick up non-core modules too
for (const completion of data[0].slice(lastIndex + 4)) {
for (const completion of data[0].slice(lastIndex + 5)) {
assert.match(completion, /^n/);
}
})