Skip to content

Commit b6e7935

Browse files
Ethan-Arrowoodaduh95
authored andcommitted
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: #21482 Assisted-by: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ethan Arrowood <ethan@arrowood.dev> PR-URL: #63965 Reviewed-By: Tim Perry <pimterry@gmail.com> Reviewed-By: James M Snell <jasnell@gmail.com>
1 parent db95655 commit b6e7935

9 files changed

Lines changed: 435 additions & 6 deletions

doc/api/net.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,48 @@ changes:
452452
Calls [`server.close()`][] and returns a promise that fulfills when the
453453
server has closed.
454454

455+
### `server[Symbol.asyncIterator]()`
456+
457+
<!-- YAML
458+
added: REPLACEME
459+
-->
460+
461+
> Stability: 1 - Experimental
462+
463+
* Returns: {AsyncIterator} An async iterator that yields each incoming
464+
[`net.Socket`][].
465+
466+
Returns an async iterator over the server's incoming connections, allowing them
467+
to be consumed with `for await...of` as an alternative to the [`'connection'`][]
468+
event. Iteration ends when the server emits [`'close'`][], and rejects if the
469+
server emits [`'error'`][].
470+
471+
The loop only advances to the next connection once the current iteration's body
472+
has finished awaiting, so connection handling should be dispatched to a separate
473+
async task rather than awaited inline. Otherwise connections are serialized:
474+
each one waits for the previous to be fully handled.
475+
476+
```mjs
477+
import { createServer } from 'node:net';
478+
479+
const server = createServer().listen(8124);
480+
481+
async function handleConnection(socket) {
482+
// ...handle the connection, awaiting as needed.
483+
socket.end('hello world!');
484+
}
485+
486+
for await (const socket of server) {
487+
// Dispatch handling to a separate task so the loop keeps accepting
488+
// connections instead of serializing them.
489+
handleConnection(socket);
490+
}
491+
```
492+
493+
The server does not stop accepting connections while the loop body runs, so a
494+
consumer slower than the connection rate can buffer them without bound. Use
495+
[`server.maxConnections`][] to bound concurrency.
496+
455497
### `server.getConnections(callback)`
456498

457499
<!-- YAML
@@ -2276,6 +2318,82 @@ net.isIPv6('::1'); // returns true
22762318
net.isIPv6('fhqwhgads'); // returns false
22772319
```
22782320

2321+
## `net/promises` API
2322+
2323+
<!-- YAML
2324+
added: REPLACEME
2325+
-->
2326+
2327+
> Stability: 1 - Experimental
2328+
2329+
The `net/promises` API provides a set of `net` functions that return `Promise`
2330+
objects rather than relying on events. The API is accessible via
2331+
`require('node:net').promises` or `require('node:net/promises')`.
2332+
2333+
### `netPromises.connect(options)`
2334+
2335+
### `netPromises.connect(path)`
2336+
2337+
### `netPromises.connect(port[, host])`
2338+
2339+
<!-- YAML
2340+
added: REPLACEME
2341+
-->
2342+
2343+
* `options` {Object} Accepts the same arguments as [`net.connect()`][]. May
2344+
include a `signal` {AbortSignal} that can be used to abort an in-progress
2345+
connection attempt.
2346+
* Returns: {Promise} Fulfills with a connected [`net.Socket`][].
2347+
2348+
A promise-based alternative to [`net.connect()`][]. The returned promise is
2349+
fulfilled with the socket once its [`'connect'`][] event fires, and is rejected
2350+
if the connection fails or the `signal` is aborted. When the promise rejects,
2351+
the underlying socket is destroyed.
2352+
2353+
This API is named for the action it performs and awaits — connecting — to
2354+
parallel [`netPromises.listen()`][]. It is not named `createConnection()`,
2355+
because that name belongs to the socket-factory taxonomy of the callback API,
2356+
which has no counterpart here.
2357+
2358+
```mjs
2359+
import { connect } from 'node:net/promises';
2360+
2361+
const socket = await connect({ port: 8124 });
2362+
socket.write('hello world!');
2363+
socket.end();
2364+
```
2365+
2366+
### `netPromises.listen([options])`
2367+
2368+
<!-- YAML
2369+
added: REPLACEME
2370+
-->
2371+
2372+
* `options` {Object} Accepts the same options as [`net.createServer()`][] and
2373+
[`server.listen()`][], plus:
2374+
* `connectionListener` {Function} Automatically set as a listener for the
2375+
[`'connection'`][] event.
2376+
* `signal` {AbortSignal} An `AbortSignal` that may be used to abort the
2377+
server. Aborting before the server is listening rejects the returned
2378+
promise with an `AbortError`; aborting at any later point closes the
2379+
server, matching the `signal` option of [`server.listen()`][].
2380+
* Returns: {Promise} Fulfills with a listening [`net.Server`][].
2381+
2382+
Creates a [`net.Server`][] and begins listening. The returned promise is
2383+
fulfilled with the server once its [`'listening'`][] event fires, and is
2384+
rejected if the server fails to bind or the `signal` is aborted before it is
2385+
listening. When the promise rejects, the server is closed.
2386+
2387+
The resolved server is async iterable, so incoming connections can be consumed
2388+
with `for await...of` (see `server[Symbol.asyncIterator]()`).
2389+
2390+
```mjs
2391+
import { listen } from 'node:net/promises';
2392+
2393+
const server = await listen({ port: 8124 });
2394+
console.log('listening on', server.address().port);
2395+
```
2396+
22792397
[IPC]: #ipc-support
22802398
[Identifying paths for IPC connections]: #identifying-paths-for-ipc-connections
22812399
[RFC 8305]: https://www.rfc-editor.org/rfc/rfc8305.txt
@@ -2310,6 +2428,7 @@ net.isIPv6('fhqwhgads'); // returns false
23102428
[`net.createServer()`]: #netcreateserveroptions-connectionlistener
23112429
[`net.getDefaultAutoSelectFamily()`]: #netgetdefaultautoselectfamily
23122430
[`net.getDefaultAutoSelectFamilyAttemptTimeout()`]: #netgetdefaultautoselectfamilyattempttimeout
2431+
[`netPromises.listen()`]: #netpromiseslistenoptions
23132432
[`new net.Socket(options)`]: #new-netsocketoptions
23142433
[`readable.setEncoding()`]: stream.md#readablesetencodingencoding
23152434
[`server.address()`]: #serveraddress

lib/internal/net/promises.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
'use strict';
2+
3+
const { once } = require('events');
4+
const {
5+
validateAbortSignal,
6+
validateObject,
7+
} = require('internal/validators');
8+
const { kEmptyObject } = require('internal/util');
9+
10+
// Lazily loaded to avoid a require cycle with the `net` module, which exposes
11+
// this namespace through its `promises` getter.
12+
let net;
13+
function lazyNet() {
14+
net ??= require('net');
15+
return net;
16+
}
17+
18+
// Resolves with a connected `net.Socket` once the `'connect'` event fires, and
19+
// rejects if the connection fails or the optional `signal` is aborted.
20+
async function connect(...args) {
21+
const lazy = lazyNet();
22+
const options = lazy._normalizeArgs(args)[0];
23+
const { signal } = options;
24+
if (signal !== undefined) {
25+
validateAbortSignal(signal, 'options.signal');
26+
signal.throwIfAborted();
27+
}
28+
29+
// Strip the signal so the socket does not also install its own abort
30+
// handling; rejecting and destroying below fully tears the socket down.
31+
const socket = lazy.connect({ ...options, signal: undefined });
32+
33+
try {
34+
await once(socket, 'connect', signal !== undefined ? { signal } : kEmptyObject);
35+
} catch (err) {
36+
socket.destroy();
37+
throw err;
38+
}
39+
return socket;
40+
}
41+
42+
// Creates a server and resolves with it once it is listening, rejecting if it
43+
// fails to bind or the optional `signal` is aborted.
44+
async function listen(options = kEmptyObject) {
45+
validateObject(options, 'options');
46+
const { signal, connectionListener } = options;
47+
if (signal !== undefined) {
48+
validateAbortSignal(signal, 'options.signal');
49+
signal.throwIfAborted();
50+
}
51+
52+
const lazy = lazyNet();
53+
const server = lazy.createServer(options, connectionListener);
54+
55+
try {
56+
// Default to an ephemeral port when no listen target is supplied, matching
57+
// `server.listen()` with no arguments; passing a target-less options object
58+
// (the `{}` default, or e.g. `{ signal }`) straight to listen() would throw
59+
// ERR_INVALID_ARG_VALUE. `signal` is passed through so net installs its own
60+
// close-on-abort handler: the signal aborts the server for its entire
61+
// lifetime, not just the pending listen.
62+
const hasListenTarget = options.port !== undefined ||
63+
options.path !== undefined ||
64+
options.fd !== undefined ||
65+
options.handle !== undefined;
66+
server.listen(hasListenTarget ? options : { ...options, port: 0 });
67+
await once(server, 'listening', signal !== undefined ? { signal } : kEmptyObject);
68+
} catch (err) {
69+
// On abort, net's signal handler already closes the server, so closing
70+
// again would be redundant; on other failures (e.g. a bind error) there
71+
// is no such handler, so close it here.
72+
if (!signal?.aborted) {
73+
server.close();
74+
}
75+
throw err;
76+
}
77+
return server;
78+
}
79+
80+
module.exports = {
81+
connect,
82+
listen,
83+
get isIP() { return lazyNet().isIP; },
84+
get isIPv4() { return lazyNet().isIPv4; },
85+
get isIPv6() { return lazyNet().isIPv6; },
86+
get BlockList() { return lazyNet().BlockList; },
87+
get SocketAddress() { return lazyNet().SocketAddress; },
88+
};

lib/net.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const {
3737
ObjectSetPrototypeOf,
3838
Symbol,
3939
SymbolAsyncDispose,
40+
SymbolAsyncIterator,
4041
SymbolDispose,
4142
} = primordials;
4243

@@ -156,6 +157,8 @@ let cluster;
156157
let dns;
157158
let BlockList;
158159
let SocketAddress;
160+
let netPromises;
161+
let kFirstEventParam;
159162
let autoSelectFamilyDefault = getOptionValue('--network-family-autoselection');
160163
let autoSelectFamilyAttemptTimeoutDefault = getOptionValue('--network-family-autoselection-attempt-timeout');
161164

@@ -2854,6 +2857,14 @@ Server.prototype[SymbolAsyncDispose] = async function() {
28542857
await FunctionPrototypeCall(promisify(this.close), this);
28552858
};
28562859

2860+
Server.prototype[SymbolAsyncIterator] = function() {
2861+
kFirstEventParam ??= require('internal/events/symbols').kFirstEventParam;
2862+
return EventEmitter.on(this, 'connection', {
2863+
close: ['close'],
2864+
[kFirstEventParam]: true,
2865+
});
2866+
};
2867+
28572868
Server.prototype._emitCloseIfDrained = function() {
28582869
debug('SERVER _emitCloseIfDrained');
28592870

@@ -2945,6 +2956,10 @@ module.exports = {
29452956
connect,
29462957
createConnection: connect,
29472958
createServer,
2959+
get promises() {
2960+
netPromises ??= require('internal/net/promises');
2961+
return netPromises;
2962+
},
29482963
isIP: isIP,
29492964
isIPv4: isIPv4,
29502965
isIPv6: isIPv6,

lib/net/promises.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
'use strict';
2+
3+
module.exports = require('internal/net/promises');
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
'use strict';
2+
const common = require('../common');
3+
const assert = require('assert');
4+
const net = require('net');
5+
const { once } = require('events');
6+
const { connect } = require('net/promises');
7+
8+
(async () => {
9+
// Resolves with a connected socket and round-trips data.
10+
{
11+
const server = net.createServer((socket) => {
12+
socket.end('hello');
13+
}).listen(0);
14+
await once(server, 'listening');
15+
const socket = await connect({ port: server.address().port });
16+
assert.strictEqual(socket.connecting, false);
17+
const chunks = [];
18+
for await (const chunk of socket) {
19+
chunks.push(chunk);
20+
}
21+
assert.strictEqual(Buffer.concat(chunks).toString(), 'hello');
22+
server.close();
23+
}
24+
25+
// net.promises is the same object as require('net/promises').
26+
assert.strictEqual(net.promises, require('net/promises'));
27+
28+
// Rejects when the connection is refused.
29+
{
30+
const server = net.createServer().listen(0);
31+
await once(server, 'listening');
32+
const { port } = server.address();
33+
server.close();
34+
await once(server, 'close');
35+
await assert.rejects(connect({ port }), { code: 'ECONNREFUSED' });
36+
}
37+
38+
// A pre-aborted signal rejects with an AbortError.
39+
{
40+
await assert.rejects(
41+
connect({ port: 0, signal: AbortSignal.abort() }),
42+
{ name: 'AbortError' });
43+
}
44+
45+
// Aborting while connecting rejects with an AbortError.
46+
{
47+
const server = net.createServer().listen(0);
48+
await once(server, 'listening');
49+
const controller = new AbortController();
50+
const promise = connect({ port: server.address().port, signal: controller.signal });
51+
controller.abort();
52+
await assert.rejects(promise, { name: 'AbortError' });
53+
server.close();
54+
}
55+
56+
// An invalid signal throws.
57+
{
58+
await assert.rejects(
59+
connect({ port: 0, signal: 'INVALID_SIGNAL' }),
60+
{ code: 'ERR_INVALID_ARG_TYPE' });
61+
}
62+
})().then(common.mustCall());

0 commit comments

Comments
 (0)