You can hold a port that peers on the internet can reach, and know its number, or know that you have none. This page covers reserving that port on UDP and TCP together from a worker, reading a refusal, getting the port back after the relay session drops, and reporting the result to the page as a state rather than an exception.
The port lives on the relay, the server that holds the real socket at the far end of net and dgram. The end state is an app that either knows the port peers can reach it on, or says plainly that it has none. What the recipe costs:
UsescreateServer, listen, address and close from @fkn/lib/net; createSocket, bind and address from @fkn/lib/dgram; BrokerUnreachableError from @fkn/lib/api
Needs nothing installed
Proven by four integrations
Every step but the last carries the [Worker] badge, defined on recipes. The engine runs in a worker, a realm (one JavaScript execution context) of its own, that the page has relayed with await relayWorker(worker, { unregisterSignal }) so its socket calls reach the broker, the connection the page holds into FKN. run sockets in a worker builds that worker. The same calls run on the page for an engine without a worker, as the optional step shows.
Ask for port 0 and let the relay report the number it granted. The datagram socket goes first because peers record its number from the datagrams themselves, so the TCP listener has to match it rather than the other way round:
Adds the listener function to the end of the listeners array for the
event named eventName. No checks are made to see if the listener has
already been added. Multiple calls passing the same combination of eventName
and listener will result in the listener being added, and called, multiple
times.
server.on('connection', (stream) => {
console.log('someone connected!');
});
Returns a reference to the EventEmitter, so that calls can be chained.
By default, event listeners are invoked in the order they are added. The
emitter.prependListener() method can be used as an alternative to add the
event listener to the beginning of the listeners array.
For UDP sockets, causes the dgram.Socket to listen for datagram
messages on a named port and optional address. If port is not
specified or is 0, the operating system will attempt to bind to a
random port. If address is not specified, the operating system will
attempt to listen on all addresses. Once binding is complete, a 'listening' event is emitted and the optional callback function is
called.
Specifying both a 'listening' event listener and passing a callback to the socket.bind() method is not harmful but not very
useful.
A bound datagram socket keeps the Node.js process running to receive
datagram messages.
If binding fails, an 'error' event is generated. In rare case (e.g.
attempting to bind with a closed socket), an Error may be thrown.
Example of a UDP server listening on port 41234:
import dgram from'node:dgram';
constserver= dgram.createSocket('udp4');
server.on('error', (err) => {
console.error(`server error:\n${err.stack}`);
server.close();
});
server.on('message', (msg, rinfo) => {
console.log(`server got: ${msg} from ${rinfo.address}:${rinfo.port}`);
Returns an object containing the address information for a socket.
For UDP sockets, this object will contain address, family, and port properties.
This method throws EBADF if called on an unbound socket.
@since ― v0.1.99
address().
AddressInfo.port: number
port// the number the relay gave
})
bind runs the callback and emits listening once the relay has answered, and address() answers the granted port from then on. Before that it throws EBADF, so read it inside the callback. The socket holds the busy token udp socket, one of the reasons its realm reports itself busy, from bind until close, as TCP and UDP sockets explains.
Name the number the datagram socket holds, and pass '0.0.0.0' as the host. A listen() with no host binds '::' first and falls back to '0.0.0.0', which pays the broker deadline twice when there is no broker:
Adds the listener function to the end of the listeners array for the
event named eventName. No checks are made to see if the listener has
already been added. Multiple calls passing the same combination of eventName
and listener will result in the listener being added, and called, multiple
times.
server.on('connection', (stream) => {
console.log('someone connected!');
});
Returns a reference to the EventEmitter, so that calls can be chained.
By default, event listeners are invoked in the order they are added. The
emitter.prependListener() method can be used as an alternative to add the
event listener to the beginning of the listeners array.
Start a server listening for connections. A net.Server can be a TCP or
an IPC server depending on what it listens to.
Possible signatures:
server.listen(handle[, backlog][, callback])
server.listen(options[, callback])
server.listen(path[, backlog][, callback]) for IPC servers
server.listen([port[, host[, backlog]]][, callback]) for TCP servers
This function is asynchronous. When the server starts listening, the 'listening' event will be emitted. The last parameter callbackwill be added as a listener for the 'listening'
event.
All listen() methods can take a backlog parameter to specify the maximum
length of the queue of pending connections. The actual length will be determined
by the OS through sysctl settings such as tcp_max_syn_backlog and somaxconn on Linux. The default value of this parameter is 511 (not 512).
All
Socket
are set to SO_REUSEADDR (see socket(7) for
details).
The server.listen() method can be called again if and only if there was an
error during the first server.listen() call or server.close() has been
called. Otherwise, an ERR_SERVER_ALREADY_LISTEN error will be thrown.
One of the most common errors raised when listening is EADDRINUSE.
This happens when another server is already listening on the requestedport/path/handle. One way to handle this would be to retry
after a certain amount of time:
Returns the bound address, the address family name, and port of the server
as reported by the operating system if listening on an IP socket
(useful to find which port was assigned when getting an OS-assigned address):{ port: 12346, family: 'IPv4', address: '127.0.0.1' }.
For a server listening on a pipe or Unix domain socket, the name is returned
as a string.
constserver= net.createServer((socket) => {
socket.end('goodbye\n');
}).on('error', (err) => {
// Handle errors here.
throw err;
});
// Grab an arbitrary unused port.
server.listen(() => {
console.log('opened server on', server.address());
});
server.address() returns null before the 'listening' event has been
emitted or after calling server.close().
Stops the server from accepting new connections and keeps existing
connections. This function is asynchronous, the server is finally closed
when all connections are ended and the server emits a 'close' event.
The optional callback will be called once the 'close' event occurs. Unlike
that event, it will be called with an Error as its only argument if the server
was not open when it was closed.
@since ― v0.1.90
@param ― callback Called when the server is closed.
close() // another number is not the reservation, so let it go and keep the datagram socket
})
The listener is your reservation only when the granted port is the one you named. Another number would make your announcement wrong, so treat it as a refusal: close the server and keep the datagram socket, since a port held on UDP alone is still a port peers can reach.
Never name a port you do not already hold. This number is the one exception, because your own datagram socket holds it. Everywhere else ask for 0, and name a port only to reclaim one this app held a moment ago, as the reopen loop below does. The relay may refuse a port, and the error names the reason.
A refused bind emits error and nothing else: no listening, no callback and no close. A successful bind emits listening, and an error after that is the relay session going away. The same two events tell a refusal from a drop only with a flag that records which came first:
new <boolean>(executor: (resolve: (value:boolean|PromiseLike<boolean>) =>void, reject: (reason?:any) =>void) =>void) =>Promise<boolean>
Creates a new Promise.
@param ― executor A callback used to initialize the promise. This callback is passed two arguments:
a resolve callback used to resolve the promise with a value or the result of another promise,
and a reject callback used to reject the promise with a provided reason or error.
For UDP sockets, causes the dgram.Socket to listen for datagram
messages on a named port and optional address. If port is not
specified or is 0, the operating system will attempt to bind to a
random port. If address is not specified, the operating system will
attempt to listen on all addresses. Once binding is complete, a 'listening' event is emitted and the optional callback function is
called.
Specifying both a 'listening' event listener and passing a callback to the socket.bind() method is not harmful but not very
useful.
A bound datagram socket keeps the Node.js process running to receive
datagram messages.
If binding fails, an 'error' event is generated. In rare case (e.g.
attempting to bind with a closed socket), an Error may be thrown.
Example of a UDP server listening on port 41234:
import dgram from'node:dgram';
constserver= dgram.createSocket('udp4');
server.on('error', (err) => {
console.error(`server error:\n${err.stack}`);
server.close();
});
server.on('message', (msg, rinfo) => {
console.log(`server got: ${msg} from ${rinfo.address}:${rinfo.port}`);
Close the underlying socket and stop listening for data on it. If a callback is
provided, it is added as a listener for the 'close' event.
@since ― v0.1.99
@param ― callback Called when the socket has been closed.
close(() => {}) // the callback never runs: there is no listener to close
The contract is asymmetric in a way no signature shows. A refused bind holds nothing open: the busy token is released at once, and close() finds no listener to close, so it returns early, runs no callback and emits no close. A promise wrapped around that close() never settles. A net.Server behaves the same way, as listening describes.
The error listener stays attached for the holder’s whole life. An emitter with no error listener throws on emit, and a session drop arrives on that event later, so removing the listener turns a harmless drop into an uncaught throw in the worker.
Your sockets share one relay session, and a lost session closes every socket on it. Nothing reconnects on its own: the next listen dials again, as the transports underneath explains. When the session drops from under a listener, error fires with WebVPN session closed if you listen for it, then close. When the relay releases the binding from its side, close fires alone.
So close after listening is the trigger. Peers already know the old number, so the loop asks for it by name on its first tries and takes whatever is free after that, waiting longer between attempts each time:
engine.ts
exporttype
typeReachable= {
type:"reachable";
port:number|null;
}
Reachable= {
type: "reachable"
type:'reachable',
port: number |null
port:number|null }
const
constreport: (port:number|null) =>void
report= (
port: number |null
port:number|null) =>
var self:Window&typeof globalThis
The Window.self read-only property returns the window itself, as a WindowProxy.
The window.postMessage() method safely enables cross-origin communication between Window objects; e.g., between a page and a pop-up that it spawned, or between a page and an iframe embedded within it.
Adds the listener function to the end of the listeners array for the
event named eventName. No checks are made to see if the listener has
already been added. Multiple calls passing the same combination of eventName
and listener will result in the listener being added, and called, multiple
times.
server.on('connection', (stream) => {
console.log('someone connected!');
});
Returns a reference to the EventEmitter, so that calls can be chained.
By default, event listeners are invoked in the order they are added. The
emitter.prependListener() method can be used as an alternative to add the
event listener to the beginning of the listeners array.
An intrinsic object that provides basic mathematics functionality and constants.
Math.
Math.min(...values: number[]): number
Returns the smaller of a set of supplied numeric expressions.
@param ― values Numeric expressions to be evaluated.
min(
let attempts:number
attempts,
constdelays:number[]
delays.
Array<number>.length: number
Gets or sets the length of the array. This is a number one higher than the highest index in the array.
length-1)])
return
}
const
constaddress:string|AddressInfo|null
address=
constserver:net.Server
server.
Server$1.address(): string | AddressInfo |null
Returns the bound address, the address family name, and port of the server
as reported by the operating system if listening on an IP socket
(useful to find which port was assigned when getting an OS-assigned address):{ port: 12346, family: 'IPv4', address: '127.0.0.1' }.
For a server listening on a pipe or Unix domain socket, the name is returned
as a string.
constserver= net.createServer((socket) => {
socket.end('goodbye\n');
}).on('error', (err) => {
// Handle errors here.
throw err;
});
// Grab an arbitrary unused port.
server.listen(() => {
console.log('opened server on', server.address());
});
server.address() returns null before the 'listening' event has been
emitted or after calling server.close().
attempts=0// a healed listener starts the ladder over
constreport: (port:number|null) =>void
report(
let held:number
held)
}
Every attempt makes a new server, and the identity check keeps a discarded server’s close from touching its replacement. The delays are yours: the library has no backoff of its own, and a bind that reaches the broker settles as listening or error. The datagram socket gets the same loop with bind, and its close arrives with an error first only when you listen for one.
The engine posts what it holds, and the page turns that into a state. The port is held in the worker’s realm, so the page never sees the sockets, only the message:
app.ts
const
constengine:Worker
engine=new
var Worker:new (scriptURL:string|URL, options?:WorkerOptions) =>Worker
The Worker interface of the Web Workers API represents a background task that can be created via script, which can send messages back to its creator.
The signal read-only property of the AbortController interface returns an AbortSignal object instance, which can be used to communicate with/abort an asynchronous operation as desired.
The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.
port}`:'no inbound port'// the UI says "no inbound port" rather than throwing
})
A refused port is an answer, not a failure. The engine’s outbound connections still work, and the page says what changed. Show none in words rather than colour alone, and keep starting on screen until the engine answers, since a bind waits up to 8,000 ms for the broker before it fails.
server.address() answers an object while the listener is bound, and its port equals the number the datagram socket holds. When a peer dials that port, the server emits connection with a socket whose remoteAddress and remotePort are readable at once. The page shows the number, and it shows no inbound port after the relay session drops, until the loop heals it.
Neither event has fired. The bind is still inside the broker deadline, and listen() with no host waits twice, so pass '0.0.0.0'.
listening fired, and address().port is not the number you named. The relay granted another port, so the TCP half of the reservation is not held. Close the server and keep the datagram socket.
close fired after listening. The relay released the binding or the session dropped, and the loop above reopens. An error comes first only on a holder with an error listener.
An engine on the page reserves the port with the same calls and no relayWorker. There the deadline error means the broker frame, the hidden fkn.app iframe the library mounts, never connected. In a worker it means nobody relayed the worker. BrokerUnreachableError is created in the realm that opened the socket, so instanceof tells it from a relay refusal in both places:
Adds the listener function to the end of the listeners array for the
event named eventName. No checks are made to see if the listener has
already been added. Multiple calls passing the same combination of eventName
and listener will result in the listener being added, and called, multiple
times.
server.on('connection', (stream) => {
console.log('someone connected!');
});
Returns a reference to the EventEmitter, so that calls can be chained.
By default, event listeners are invoked in the order they are added. The
emitter.prependListener() method can be used as an alternative to add the
event listener to the beginning of the listeners array.
Adds the listener function to the end of the listeners array for the
event named eventName. No checks are made to see if the listener has
already been added. Multiple calls passing the same combination of eventName
and listener will result in the listener being added, and called, multiple
times.
server.on('connection', (stream) => {
console.log('someone connected!');
});
Returns a reference to the EventEmitter, so that calls can be chained.
By default, event listeners are invoked in the order they are added. The
emitter.prependListener() method can be used as an alternative to add the
event listener to the beginning of the listeners array.
For UDP sockets, causes the dgram.Socket to listen for datagram
messages on a named port and optional address. If port is not
specified or is 0, the operating system will attempt to bind to a
random port. If address is not specified, the operating system will
attempt to listen on all addresses. Once binding is complete, a 'listening' event is emitted and the optional callback function is
called.
Specifying both a 'listening' event listener and passing a callback to the socket.bind() method is not harmful but not very
useful.
A bound datagram socket keeps the Node.js process running to receive
datagram messages.
If binding fails, an 'error' event is generated. In rare case (e.g.
attempting to bind with a closed socket), an Error may be thrown.
Example of a UDP server listening on port 41234:
import dgram from'node:dgram';
constserver= dgram.createSocket('udp4');
server.on('error', (err) => {
console.error(`server error:\n${err.stack}`);
server.close();
});
server.on('message', (msg, rinfo) => {
console.log(`server got: ${msg} from ${rinfo.address}:${rinfo.port}`);
The class survives because it never crosses a realm. Every relay refusal arrives as a plain Error whose message is the only signal, as handling errors explains.