From 767b74d36be6c85df84485bcd1459a254838971e Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Wed, 17 Jun 2026 16:02:45 +0200 Subject: [PATCH 1/2] 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 Signed-off-by: Ethan Arrowood --- doc/api/net.md | 72 +++++++++++++++++++ lib/internal/net/promises.js | 81 ++++++++++++++++++++++ lib/net.js | 5 ++ lib/net/promises.js | 3 + test/parallel/test-net-promises-connect.js | 62 +++++++++++++++++ test/parallel/test-net-promises-listen.js | 58 ++++++++++++++++ 6 files changed, 281 insertions(+) create mode 100644 lib/internal/net/promises.js create mode 100644 lib/net/promises.js create mode 100644 test/parallel/test-net-promises-connect.js create mode 100644 test/parallel/test-net-promises-listen.js diff --git a/doc/api/net.md b/doc/api/net.md index 9ee3c1497397da..6da812799f9830 100644 --- a/doc/api/net.md +++ b/doc/api/net.md @@ -2084,6 +2084,77 @@ net.isIPv6('::1'); // returns true net.isIPv6('fhqwhgads'); // returns false ``` +## `net/promises` API + + + +> 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])` + + + +* `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])` + + + +* `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 + listening server. +* 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. When the +promise rejects, the server is closed. + +```mjs +import { listen } from 'node:net/promises'; + +const server = await listen({ port: 8124 }); +console.log('server bound to', 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 @@ -2114,6 +2185,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.close()`]: #serverclosecallback diff --git a/lib/internal/net/promises.js b/lib/internal/net/promises.js new file mode 100644 index 00000000000000..e9fcab2e1c3ae0 --- /dev/null +++ b/lib/internal/net/promises.js @@ -0,0 +1,81 @@ +'use strict'; + +const { once } = require('events'); +const { + validateAbortSignal, + validateObject, +} = require('internal/validators'); +const { AbortError } = require('internal/errors'); +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'); + if (signal.aborted) { + throw new AbortError(undefined, { cause: signal.reason }); + } + } + + // 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'); + if (signal.aborted) { + throw new AbortError(undefined, { cause: signal.reason }); + } + } + + const lazy = lazyNet(); + // Strip the signal so listen() does not install its own close-on-abort + // handler; rejecting and closing below tears the server down. + const serverOptions = { ...options, signal: undefined }; + const server = lazy.createServer(serverOptions, connectionListener); + + try { + server.listen(serverOptions); + await once(server, 'listening', signal !== undefined ? { signal } : kEmptyObject); + } catch (err) { + 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; }, +}; diff --git a/lib/net.js b/lib/net.js index ee4bc9943e4d52..19a55af6e61ccd 100644 --- a/lib/net.js +++ b/lib/net.js @@ -146,6 +146,7 @@ let cluster; let dns; let BlockList; let SocketAddress; +let netPromises; let autoSelectFamilyDefault = getOptionValue('--network-family-autoselection'); let autoSelectFamilyAttemptTimeoutDefault = getOptionValue('--network-family-autoselection-attempt-timeout'); @@ -2576,6 +2577,10 @@ module.exports = { connect, createConnection: connect, createServer, + get promises() { + netPromises ??= require('internal/net/promises'); + return netPromises; + }, isIP: isIP, isIPv4: isIPv4, isIPv6: isIPv6, diff --git a/lib/net/promises.js b/lib/net/promises.js new file mode 100644 index 00000000000000..b87773474a08a3 --- /dev/null +++ b/lib/net/promises.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('internal/net/promises'); diff --git a/test/parallel/test-net-promises-connect.js b/test/parallel/test-net-promises-connect.js new file mode 100644 index 00000000000000..89556690195557 --- /dev/null +++ b/test/parallel/test-net-promises-connect.js @@ -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()); diff --git a/test/parallel/test-net-promises-listen.js b/test/parallel/test-net-promises-listen.js new file mode 100644 index 00000000000000..d590564203da00 --- /dev/null +++ b/test/parallel/test-net-promises-listen.js @@ -0,0 +1,58 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); +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(); + } + + // 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()); From 9885d70bcb2f83b2e629c8521d668c83e2036064 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Thu, 18 Jun 2026 11:15:38 +0200 Subject: [PATCH 2/2] net: tie listen() signal to server lifetime, async-iterable Server Address review feedback on the net/promises API: - `listen()` now passes its `signal` through to `server.listen()` so the signal aborts the server for its entire lifetime rather than only the pending listen. Aborting before the server is listening rejects the returned promise; aborting afterwards closes the server, matching net.Server's own `signal` behavior. - `net.Server` is now async iterable: `for await (const socket of server)` yields each incoming connection as an alternative to the 'connection' event, mirroring readline.Interface. Also update test-repl-tab-complete-require and test-repl-tab-complete-import to account for the new `net/promises` builtin appearing in completions. Refs: https://github.com/nodejs/node/issues/21482 Assisted-by: Claude Opus 4.8 Signed-off-by: Ethan Arrowood --- doc/api/net.md | 44 ++++++++++++++-- lib/internal/net/promises.js | 17 ++++--- lib/net.js | 10 ++++ test/parallel/test-net-promises-listen.js | 13 +++++ .../test-net-server-async-iterator.js | 50 +++++++++++++++++++ .../parallel/test-repl-tab-complete-import.js | 7 +-- .../test-repl-tab-complete-require.js | 7 +-- 7 files changed, 132 insertions(+), 16 deletions(-) create mode 100644 test/parallel/test-net-server-async-iterator.js diff --git a/doc/api/net.md b/doc/api/net.md index 6da812799f9830..bfc625893cf801 100644 --- a/doc/api/net.md +++ b/doc/api/net.md @@ -447,6 +447,35 @@ changes: Calls [`server.close()`][] and returns a promise that fulfills when the server has closed. +### `server[Symbol.asyncIterator]()` + + + +> 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'`][]. + +```mjs +import { createServer } from 'node:net'; + +const server = createServer().listen(8124); +for await (const socket of server) { + socket.end('hello world!'); +} +``` + +Connections are buffered while the loop body is busy; the server does not stop +accepting them, so a consumer that is slower than the connection rate can buffer +without bound. Use [`server.maxConnections`][] to bound concurrency. + ### `server.getConnections(callback)`