Skip to content

Hold an inbound port

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:

  • Uses createServer, 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:

engine.ts
import * as
import dgram
dgram
from '@fkn/lib/dgram'
const
const udp: dgram.Socket
udp
=
import dgram
dgram
.
function createSocket(options: SocketType | SocketOptions, callback?: (msg: Buffer, rinfo: RemoteInfo) => void): dgram.Socket
export createSocket
createSocket
('udp4')
const udp: dgram.Socket
udp
.
NodeJS.EventEmitter<any>.on<string | symbol>(eventName: string | symbol, listener: (...args: any[]) => void): dgram.Socket

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.

import { EventEmitter } from 'node:events';
const myEE = new EventEmitter();
myEE.on('foo', () => console.log('a'));
myEE.prependListener('foo', () => console.log('b'));
myEE.emit('foo');
// Prints:
// b
// a

@sincev0.1.101

@parameventName The name of the event.

@paramlistener The callback function

on
('error', (
error: Error
error
:
interface Error
Error
) =>
error: Error
error
.
Error.message: string
message
) // a refusal lands here, and listening never fires
const udp: dgram.Socket
udp
.
Socket.bind(port?: number | undefined, address?: string | undefined, callback?: (() => void) | undefined): dgram.Socket (+3 overloads)

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';
const server = 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}`);
});
server.on('listening', () => {
const address = server.address();
console.log(`server listening ${address.address}:${address.port}`);
});
server.bind(41234);
// Prints: server listening 0.0.0.0:41234

bind
(0, '0.0.0.0', () => {
const udp: dgram.Socket
udp
.
Socket.address(): AddressInfo

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.

@sincev0.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:

engine.ts
const
const server: net.Server
server
=
import net
net
.
function createServer(options?: ServerOpts | ((socket: net.Socket) => void), connectionListener?: (socket: net.Socket) => void): net.Server
export createServer
createServer
()
const server: net.Server
server
.
NodeJS.EventEmitter<any>.on<string | symbol>(eventName: string | symbol, listener: (...args: any[]) => void): net.Server

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.

import { EventEmitter } from 'node:events';
const myEE = new EventEmitter();
myEE.on('foo', () => console.log('a'));
myEE.prependListener('foo', () => console.log('b'));
myEE.emit('foo');
// Prints:
// b
// a

@sincev0.1.101

@parameventName The name of the event.

@paramlistener The callback function

on
('error', (
error: Error
error
:
interface Error
Error
) =>
error: Error
error
.
Error.message: string
message
) // the relay refused the number, and listening never fires
const server: net.Server
server
.
Server$1.listen(port?: number | undefined, hostname?: string | undefined, listeningListener?: (() => void) | undefined): net.Server (+9 overloads)

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:

server.on('error', (e) => {
if (e.code === 'EADDRINUSE') {
console.error('Address in use, retrying...');
setTimeout(() => {
server.close();
server.listen(PORT, HOST);
}, 1000);
}
});

listen
(
const port: number
port
, '0.0.0.0', () => {
const
const address: string | AddressInfo | null
address
=
const server: 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.

const server = 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().

@sincev0.1.90

address
()
const
const held: boolean
held
= typeof
const address: string | AddressInfo | null
address
=== 'object' &&
const address: AddressInfo | null
address
!== null &&
const address: AddressInfo
address
.
AddressInfo.port: number
port
===
const port: number
port
const held: boolean
held
// the two numbers match, or the attempt fails
if (!
const held: boolean
held
)
const server: net.Server
server
.
Server$1.close(callback?: ((err?: Error | undefined) => void) | undefined): net.Server

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.

@sincev0.1.90

@paramcallback 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:

engine.ts
import * as
import dgram
dgram
from '@fkn/lib/dgram'
type
type Holder = {
once(event: "listening", listener: () => void): unknown;
on(event: "error", listener: (error: Error) => void): unknown;
}
Holder
= {
function once(event: "listening", listener: () => void): unknown
once
(
event: "listening"
event
: 'listening',
listener: () => void
listener
: () => void): unknown
function on(event: "error", listener: (error: Error) => void): unknown
on
(
event: "error"
event
: 'error',
listener: (error: Error) => void
listener
: (
error: Error
error
:
interface Error
Error
) => void): unknown
}
// true on listening, false on a refusal, and the error listener stays for the holder's whole life
const
const bindOnce: (holder: Holder, start: () => void) => Promise<boolean>
bindOnce
= (
holder: Holder
holder
:
type Holder = {
once(event: "listening", listener: () => void): unknown;
on(event: "error", listener: (error: Error) => void): unknown;
}
Holder
,
start: () => void
start
: () => void) => new
var Promise: PromiseConstructor
new <boolean>(executor: (resolve: (value: boolean | PromiseLike<boolean>) => void, reject: (reason?: any) => void) => void) => Promise<boolean>

Creates a new Promise.

@paramexecutor 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.

Promise
<boolean>(
resolve: (value: boolean | PromiseLike<boolean>) => void
resolve
=> {
let
let settled: boolean
settled
= false
holder: Holder
holder
.
function once(event: "listening", listener: () => void): unknown
once
('listening', () => {
let settled: boolean
settled
= true
resolve: (value: boolean | PromiseLike<boolean>) => void
resolve
(true)
})
holder: Holder
holder
.
function on(event: "error", listener: (error: Error) => void): unknown
on
('error', () => {
if (
let settled: boolean
settled
) return // a later drop belongs to the reopen loop
let settled: boolean
settled
= true
resolve: (value: boolean | PromiseLike<boolean>) => void
resolve
(false) // 'error' fired and 'listening' never will
})
start: () => void
start
()
})
const
const udp: dgram.Socket
udp
=
import dgram
dgram
.
function createSocket(options: SocketType | SocketOptions, callback?: (msg: Buffer, rinfo: RemoteInfo) => void): dgram.Socket
export createSocket
createSocket
('udp4')
const
const bound: boolean
bound
= await
const bindOnce: (holder: Holder, start: () => void) => Promise<boolean>
bindOnce
(
const udp: dgram.Socket
udp
, () =>
const udp: dgram.Socket
udp
.
Socket.bind(port?: number | undefined, address?: string | undefined, callback?: (() => void) | undefined): dgram.Socket (+3 overloads)

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';
const server = 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}`);
});
server.on('listening', () => {
const address = server.address();
console.log(`server listening ${address.address}:${address.port}`);
});
server.bind(41234);
// Prints: server listening 0.0.0.0:41234

bind
(0, '0.0.0.0')) // false on a refusal, and the socket holds nothing
if (!
const bound: boolean
bound
)
const udp: dgram.Socket
udp
.
Socket.close(callback?: (() => void) | undefined): dgram.Socket

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.

@sincev0.1.99

@paramcallback 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
export type
type Reachable = {
type: "reachable";
port: number | null;
}
Reachable
= {
type: "reachable"
type
: 'reachable',
port: number | null
port
: number | null }
const
const report: (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.

MDN Reference

self
.
function postMessage(message: any, options?: WindowPostMessageOptions): void (+1 overload)

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.

MDN Reference

postMessage
({
type: "reachable"
type
: 'reachable',
port: number | null
port
} satisfies
type Reachable = {
type: "reachable";
port: number | null;
}
Reachable
)
const
const delays: number[]
delays
= [1_000, 5_000, 30_000] // your own ladder, the library has none
let
let held: number
held
= 6881 // the number peers already know
let
let attempts: number
attempts
= 0
let
let current: net.Server | undefined
current
:
import net
net
.
class Server
export Server
Server
| undefined
let
let stopping: boolean
stopping
= false
const
const reopen: () => Promise<void>
reopen
= async ():
interface Promise<T>

Represents the completion of an asynchronous operation

Promise
<void> => {
const
const server: net.Server
server
=
import net
net
.
function createServer(options?: ServerOpts | ((socket: net.Socket) => void), connectionListener?: (socket: net.Socket) => void): net.Server
export createServer
createServer
()
let current: net.Server | undefined
current
=
const server: net.Server
server
const server: net.Server
server
.
NodeJS.EventEmitter<any>.on<string | symbol>(eventName: string | symbol, listener: (...args: any[]) => void): net.Server

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.

import { EventEmitter } from 'node:events';
const myEE = new EventEmitter();
myEE.on('foo', () => console.log('a'));
myEE.prependListener('foo', () => console.log('b'));
myEE.emit('foo');
// Prints:
// b
// a

@sincev0.1.101

@parameventName The name of the event.

@paramlistener The callback function

on
('close', () => {
if (
const server: net.Server
server
!==
let current: net.Server | undefined
current
||
let stopping: false
stopping
) return // a discarded server never touches its replacement
const report: (port: number | null) => void
report
(null) // no inbound port on the page until this heals
function setTimeout<[]>(callback: () => void, delay?: number): NodeJS.Timeout (+1 overload)
setTimeout
(
const reopen: () => Promise<void>
reopen
,
const delays: number[]
delays
[
var Math: Math

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.

@paramvalues Numeric expressions to be evaluated.

min
(
let attempts: number
attempts
,
const delays: 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)])
})
const
const want: number
want
=
let attempts: number
attempts
< 2 ?
let held: number
held
: 0 // reclaim the same number first, then take whatever is free
let attempts: number
attempts
+= 1
if (!await
const bindOnce: (holder: Holder, start: () => void) => Promise<boolean>
bindOnce
(
const server: net.Server
server
, () =>
const server: net.Server
server
.
Server$1.listen(port?: number | undefined, hostname?: string | undefined, backlog?: number | undefined, listeningListener?: (() => void) | undefined): net.Server (+9 overloads)

Starts a TCP listener through WebVPN. Port and host forms are supported. IPC paths and existing Node handles are not supported and reject at runtime.

listen
(
const want: number
want
, '0.0.0.0'))) { // refused, so nothing is open to close
function setTimeout<[]>(callback: () => void, delay?: number): NodeJS.Timeout (+1 overload)
setTimeout
(
const reopen: () => Promise<void>
reopen
,
const delays: number[]
delays
[
var Math: Math

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.

@paramvalues Numeric expressions to be evaluated.

min
(
let attempts: number
attempts
,
const delays: 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
const address: string | AddressInfo | null
address
=
const server: 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.

const server = 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().

@sincev0.1.90

address
()
if (
const address: string | AddressInfo | null
address
&& typeof
const address: string | AddressInfo
address
=== 'object')
let held: number
held
=
const address: AddressInfo
address
.
AddressInfo.port: number
port
// the number peers can reach now
let attempts: number
attempts
= 0 // a healed listener starts the ladder over
const report: (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
const engine: 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.

MDN Reference

Worker
(new
var URL: new (url: string | URL, base?: string | URL) => URL

The URL interface is used to parse, construct, normalize, and encode URL.

MDN Reference

URL
('./engine.ts', import.

The type of import.meta.

If you need to declare that a given property exists on import.meta, this type may be augmented via interface merging.

meta
.
ImportMeta.url: string
url
), {
WorkerOptions.type?: WorkerType | undefined
type
: 'module' })
const
const stop: AbortController
stop
= new
var AbortController: new () => AbortController

The AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired.

MDN Reference

AbortController
()
await
function relayWorker(worker: Worker, options?: {
unregisterSignal?: AbortSignal;
originA?: string;
originB?: string;
}): Promise<void>
relayWorker
(
const engine: Worker
engine
, {
unregisterSignal?: AbortSignal | undefined
unregisterSignal
:
const stop: AbortController
stop
.
AbortController.signal: AbortSignal

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.

MDN Reference

signal
}) // the engine's binds reach the broker from here on
type
type Inbound = {
state: "starting";
} | {
state: "held";
port: number;
} | {
state: "none";
}
Inbound
= {
state: "starting"
state
: 'starting' } | {
state: "held"
state
: 'held',
port: number
port
: number } | {
state: "none"
state
: 'none' }
let
let inbound: Inbound
inbound
:
type Inbound = {
state: "starting";
} | {
state: "held";
port: number;
} | {
state: "none";
}
Inbound
= {
state: "starting"
state
: 'starting' } // on screen until the engine answers
const engine: Worker
engine
.
Worker.addEventListener<"message">(type: "message", listener: (this: Worker, ev: MessageEvent<any>) => any, options?: boolean | AddEventListenerOptions): void (+1 overload)

The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.

MDN Reference

addEventListener
('message', (
event: MessageEvent<Reachable>
event
:
interface MessageEvent<T = any>

The MessageEvent interface represents a message received by a target object.

MDN Reference

MessageEvent
<
type Reachable = {
type: "reachable";
port: number | null;
}
Reachable
>) => {
if (
event: MessageEvent<Reachable>
event
.
MessageEvent<Reachable>.data: Reachable

The data read-only property of the The data sent by the message emitter; this can be any data type, depending on what originated this event.

MDN Reference

data
.
type: "reachable"
type
!== 'reachable') return
let inbound: Inbound
inbound
=
event: MessageEvent<Reachable>
event
.
MessageEvent<Reachable>.data: Reachable

The data read-only property of the The data sent by the message emitter; this can be any data type, depending on what originated this event.

MDN Reference

data
.
port: number | null
port
=== null ? {
state: "none"
state
: 'none' } : {
state: "held"
state
: 'held',
port: number
port
:
event: MessageEvent<Reachable>
event
.
MessageEvent<Reachable>.data: Reachable

The data read-only property of the The data sent by the message emitter; this can be any data type, depending on what originated this event.

MDN Reference

data
.
port: number
port
}
const status: HTMLElement
status
.
Element.textContent: string | null
textContent
=
let inbound: {
state: "held";
port: number;
} | {
state: "none";
}
inbound
.
state: "held" | "none"
state
=== 'held' ? `reachable on port ${
let inbound: {
state: "held";
port: number;
}
inbound
.
port: number
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.

When it does not:

  1. error fires and listening never does. That is a refusal, not a timeout, and the message names the reason: webvpn: socket capacity reached, webvpn: socket control queue saturated, webvpn: account session capacity reached, webvpn: token not accepted, or webvpn: egress to a non-public address refused for a bind address that is not public. WebVPN setup timeout: <what> took over <ms>ms and FKN WebVPN: no relay reachable are worth a retry after a delay.
  2. The error is a BrokerUnreachableError. No broker answered within 8,000 ms, or 1,000 ms once any call in the realm has missed that deadline. In a worker that means nobody relayed it, see run sockets in a worker.
  3. 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'.
  4. 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.
  5. 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.

Optional: Tell the deadline from a refusal

Section titled “Optional: Tell the deadline from a refusal”

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:

app.ts
import * as
import dgram
dgram
from '@fkn/lib/dgram'
import {
class BrokerUnreachableError
BrokerUnreachableError
} from '@fkn/lib/api'
const
const udp: dgram.Socket
udp
=
import dgram
dgram
.
function createSocket(options: SocketType | SocketOptions, callback?: (msg: Buffer, rinfo: RemoteInfo) => void): dgram.Socket
export createSocket
createSocket
('udp4')
const udp: dgram.Socket
udp
.
NodeJS.EventEmitter<any>.on<string | symbol>(eventName: string | symbol, listener: (...args: any[]) => void): dgram.Socket

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.

import { EventEmitter } from 'node:events';
const myEE = new EventEmitter();
myEE.on('foo', () => console.log('a'));
myEE.prependListener('foo', () => console.log('b'));
myEE.emit('foo');
// Prints:
// b
// a

@sincev0.1.101

@parameventName The name of the event.

@paramlistener The callback function

on
('error', (
error: Error
error
:
interface Error
Error
) => {
error: Error
error
instanceof
class BrokerUnreachableError
BrokerUnreachableError
// true when no broker answered within 8000 ms, and false for a relay refusal
error: Error
error
.
Error.message: string
message
// '@fkn/lib: no broker connection within 8000ms, so a udp socket could not be requested'
})
const udp: dgram.Socket
udp
.
NodeJS.EventEmitter<any>.on<string | symbol>(eventName: string | symbol, listener: (...args: any[]) => void): dgram.Socket

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.

import { EventEmitter } from 'node:events';
const myEE = new EventEmitter();
myEE.on('foo', () => console.log('a'));
myEE.prependListener('foo', () => console.log('b'));
myEE.emit('foo');
// Prints:
// b
// a

@sincev0.1.101

@parameventName The name of the event.

@paramlistener The callback function

on
('listening', () =>
const udp: dgram.Socket
udp
.
Socket.address(): AddressInfo

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.

@sincev0.1.99

address
().
AddressInfo.port: number
port
) // the number the relay gave
const udp: dgram.Socket
udp
.
Socket.bind(port?: number | undefined, address?: string | undefined, callback?: (() => void) | undefined): dgram.Socket (+3 overloads)

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';
const server = 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}`);
});
server.on('listening', () => {
const address = server.address();
console.log(`server listening ${address.address}:${address.port}`);
});
server.bind(41234);
// Prints: server listening 0.0.0.0:41234

bind
(0, '0.0.0.0')

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.