You can run an engine compiled to WebAssembly, written in C, C++, Go or Rust against Node’s net and dgram, unchanged on FKN sockets. This page covers taking the two modules as options, wiring the socket calls the compiled code makes back into JavaScript, copying every buffer you read, and reading a socket’s addresses once the relay has answered.
The relay is the server that holds the real socket at the far end of net and dgram. At the end, every socket call the engine makes reaches the relay, every read is a copy the engine owns, and every connection it accepts carries usable addresses. What the recipe costs:
Usesconnect and createServer from @fkn/lib/net, createSocket from @fkn/lib/dgram
Needs nothing installed
Proven by four integrations
The first step carries the [Build] badge and the rest carry [Page], both defined on recipes. engine.ts is the engine’s host file, the JavaScript that loads the compiled module and answers its socket calls, and app.ts is the app that hands it the modules. The engine usually runs in a worker, and the optional step says what that adds.
An engine that imports @fkn/lib/net itself ties every host to the library, and to one copy of it. Take net and dgram as options instead. The app passes the library’s modules, a test passes Node’s, and the engine’s package declares @fkn/lib as an optional peer dependency, optional: true under peerDependenciesMeta, and imports nothing from it. The host file names the calls it makes and the members it reads on what they return:
engine.ts
exporttype
typeSocket= {
readonlyremoteAddress?:string;
readonlyremotePort?:number;
readonlylocalAddress?:string;
readonlylocalPort?:number;
write: (chunk:Uint8Array) =>boolean;
end: () =>unknown;
destroy: () =>unknown;
on: (event:string, listener: (...args:any[]) =>void) =>unknown;
A typed array of 8-bit unsigned integer values. The contents are initialized to 0. If the
requested number of bytes could not be allocated an exception is raised.
Uint8Array) =>boolean
end: () => unknown
end: () =>unknown
destroy: () => unknown
destroy: () =>unknown
on: (event:string, listener: (...args:any[]) =>void) => unknown
A typed array of 8-bit unsigned integer values. The contents are initialized to 0. If the
requested number of bytes could not be allocated an exception is raised.
Modules names one call on each module and the members the engine reads on what they return, by the names Node declares, so node:net and node:dgram satisfy it and the same file runs in a plain Node test with no broker, as testing shows. Add createServer the same way when the engine accepts peers. The app is the one file that imports the library:
dgram }) // the library's modules, and the compiled code never learns which
constengine: {
connect: (host:string, port:number) =>number;
open: (type:"udp4"|"udp6") =>Datagram;
}
engine.
connect: (host:string, port:number) => number
connect('67.215.246.10', 6881) // 1, and the socket behind it is dialing through the relay
The engine never learns which module it holds. With the library’s, the bytes travel through the broker, the connection your page holds into FKN, to the relay. The bundle has to shim stream, events and buffer for the library’s module bodies to run, see install and bundle an app that uses sockets.
The compiled code holds a number for each socket and passes it back on every later call, so the host keeps a table from number to socket, and each call becomes one call on the module. On the page, against @fkn/lib/net directly, the three calls an engine makes most read like this:
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.
A typed array of 8-bit unsigned integer values. The contents are initialized to 0. If the
requested number of bytes could not be allocated an exception is raised.
Returns a specified element from the Map object. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map.
@returns ― Returns the element associated with the specified key. If no element is associated with the specified key, undefined is returned.
The writable.write() method writes some data to the stream, and calls the
supplied callback once the data has been fully handled. If an error
occurs, the callback will be called with the error as its
first argument. The callback is called asynchronously and before 'error' is
emitted.
The return value is true if the internal buffer is less than the highWaterMark configured when the stream was created after admitting chunk.
If false is returned, further attempts to write data to the stream should
stop until the 'drain' event is emitted.
While a stream is not draining, calls to write() will buffer chunk, and
return false. Once all currently buffered chunks are drained (accepted for
delivery by the operating system), the 'drain' event will be emitted.
Once write() returns false, do not write more chunks
until the 'drain' event is emitted. While calling write() on a stream that
is not draining is allowed, Node.js will buffer all written chunks until
maximum memory usage occurs, at which point it will abort unconditionally.
Even before it aborts, high memory usage will cause poor garbage collector
performance and high RSS (which is not typically released back to the system,
even after the memory is no longer required). Since TCP sockets may never
drain if the remote peer does not read the data, writing a socket that is
not draining may lead to a remotely exploitable vulnerability.
Writing data while the stream is not draining is particularly
problematic for a Transform, because the Transform streams are paused
by default until they are piped or a 'data' or 'readable' event handler
is added.
If the data to be written can be generated or fetched on demand, it is
recommended to encapsulate the logic into a Readable and use
pipe
. However, if calling write() is preferred, it is
possible to respect backpressure and avoid memory issues using the 'drain' event:
functionwrite(data, cb) {
if (!stream.write(data)) {
stream.once('drain', cb);
} else {
process.nextTick(cb);
}
}
// Wait for cb to be called before doing any other write.
write('hello', () => {
console.log('Write completed, do more writes now.');
});
A Writable stream in object mode will always ignore the encoding argument.
Writes data to the stream, with an explicit encoding for string data.
Returns a specified element from the Map object. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map.
@returns ― Returns the element associated with the specified key. If no element is associated with the specified key, undefined is returned.
Destroy the stream. Optionally emit an 'error' event, and emit a 'close' event (unless emitClose is set to false). After this call, the readable
stream will release any internal resources and subsequent calls to push() will be ignored.
Once destroy() has been called any further calls will be a no-op and no
further errors except from _destroy() may be emitted as 'error'.
Implementors should not override this method, but instead implement readable._destroy().
@since ― v8.0.0
@param ― error Error which will be passed as payload in 'error' event
Returns a specified element from the Map object. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map.
@returns ― Returns the element associated with the specified key. If no element is associated with the specified key, undefined is returned.
new (elements:Iterable<number>) =>Uint8Array<ArrayBuffer> (+6 overloads)
Uint8Array([19, 66, 105, 116])) // true, and the bytes go out once the relay has answered
connect hands back the Socket at once and dials through the relay in the background, so the number maps to a live socket before the relay has answered, and a write in that window is queued rather than refused. The error listener goes on the line after connect. A refused dial emits error and nothing else, no close and no listener call, and an error with no listener throws. What arrives there is a message: getaddrinfo ENOTFOUND <hostname> when the name has no answer, tcp connect to <address>:<port> timed out after 12000ms when the relay does not acknowledge within 12,000 ms, and the operating system’s own text when the peer refused.
None of those carries an errno, so an engine that maps error.errno to its own table is mapping its own fallback, as testing explains.
The compiled code reads from its own linear memory, so every byte a socket delivers is copied into that memory at some point, and the question is only when. A data chunk arrives as a Buffer, a view over memory the library allocated, and an engine keeps it in a queue until its next tick drains it. Copy it in the listener, before the listener returns, so the queue holds memory the engine owns:
A typed array of 8-bit unsigned integer values. The contents are initialized to 0. If the
requested number of bytes could not be allocated an exception is raised.
Uint8Array[] = [] // what the compiled code's read drains, in arrival order
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.
buffer// false, so the next data event cannot reach it
constinbox:Uint8Array<ArrayBufferLike>[]
inbox.
Array<Uint8Array<ArrayBufferLike>>.push(...items: Uint8Array<ArrayBufferLike>[]): number
Appends new elements to the end of an array, and returns the new length of the array.
@param ― items New elements to add to the array.
push(
constcopy:Uint8Array<ArrayBuffer>
copy)
})
new Uint8Array(chunk) copies. chunk.slice() does not: on a Buffer, slice is an alias of subarray and returns a view over the same memory, and so does Buffer.from(chunk.buffer). Neither the library nor the shape above promises what happens to the memory behind a chunk once your listener returns, and the failure when it changes reads as a peer’s fault rather than the socket’s: a piece that hashes wrong, then a peer dropped for data that arrived intact. One integration traced exactly that failure to chunks it had kept rather than copied.
The same rule holds for a datagram. message hands you a Buffer and an rinfo with the sender’s address and port, and the engine’s receive queue takes a copy of the bytes and the two fields it answers with:
A typed array of 8-bit unsigned integer values. The contents are initialized to 0. If the
requested number of bytes could not be allocated an exception is raised.
Uint8Array,
address: string
address:string,
port: number
port:number }[] = [] // what the compiled code's recvfrom drains
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}`);
Broadcasts a datagram on the socket.
For connectionless sockets, the destination port and address must be
specified. Connected sockets, on the other hand, will use their associated
remote endpoint, so the port and address arguments must not be set.
The msg argument contains the message to be sent.
Depending on its type, different behavior can apply. If msg is a Buffer,
any TypedArray or a DataView,
the offset and length specify the offset within the Buffer where the
message begins and the number of bytes in the message, respectively.
If msg is a String, then it is automatically converted to a Buffer with 'utf8' encoding. With messages that
contain multi-byte characters, offset and length will be calculated with
respect to byte length and not the character position.
If msg is an array, offset and length must not be specified.
The address argument is a string. If the value of address is a host name,
DNS will be used to resolve the address of the host. If address is not
provided or otherwise nullish, '127.0.0.1' (for udp4 sockets) or '::1' (for udp6 sockets) will be used by default.
If the socket has not been previously bound with a call to bind, the socket
is assigned a random port number and is bound to the "all interfaces" address
('0.0.0.0' for udp4 sockets, '::0' for udp6 sockets.)
An optional callback function may be specified to as a way of reporting
DNS errors or for determining when it is safe to reuse the buf object.
DNS lookups delay the time to send for at least one tick of the
Node.js event loop.
The only way to know for sure that the datagram has been sent is by using a callback. If an error occurs and a callback is given, the error will be
passed as the first argument to the callback. If a callback is not given,
the error is emitted as an 'error' event on the socket object.
Offset and length are optional but both must be set if either are used.
They are supported only when the first argument is a Buffer, a TypedArray,
or a DataView.
This method throws ERR_SOCKET_BAD_PORT if called on an unbound socket.
Example of sending a UDP packet to a port on localhost;
Example of sending a UDP packet composed of multiple buffers to a port on127.0.0.1;
import dgram from'node:dgram';
import { Buffer } from'node:buffer';
constbuf1= Buffer.from('Some ');
constbuf2= Buffer.from('bytes');
constclient= dgram.createSocket('udp4');
client.send([buf1, buf2], 41234, (err) => {
client.close();
});
Sending multiple buffers might be faster or slower depending on the
application and operating system. Run benchmarks to
determine the optimal strategy on a case-by-case basis. Generally speaking,
however, sending multiple buffers is faster.
Example of sending a UDP packet using a socket connected to a port on localhost:
rinfo.size equals message.length, and rinfo.family is 'IPv4' or 'IPv6'. The send callback reports bytes handed to the transport, not delivered. The rest of the datagram surface is on UDP.
The compiled code asks for a socket’s addresses whenever it likes, often before the relay has answered. Node answers undefined for an address it does not have yet. The library throws Socket is not connected from each of remoteAddress, remotePort, localAddress and localPort until the relay has answered, and answers from the connect event on. A socket the server accepted can name its peer inside the connection handler, because the library publishes its addresses before it emits. Read each getter inside a guard of its own, and answer the engine’s not-connected code on a throw:
The string representation of the remote IP address. For example,'74.125.127.100' or '2001:4860:a005::68'. Value may be undefined if
the socket is destroyed (for example, if the client disconnected).
The numeric representation of the remote port. For example, 80 or 21. Value may be undefined if
the socket is destroyed (for example, if the client disconnected).
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.
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.
peerOf answers null until the relay has answered and the addresses from then on, so the engine’s own lookup returns its not-connected code instead of an exception inside the compiled code. An accepted socket answers at once. Hand a socket to the engine only once it can name its peer, since an engine that cannot name a peer it accepted may drop it without a report. The listener itself is on hold an inbound port.
Send the engine two chunks on one TCP connection, or two datagrams, and read them back through the engine’s own read call. Each read matches what the peer sent, byte for byte, and the first still matches after the second has arrived. A dial to a public address runs the engine’s connect callback with the peer’s address on the socket, and an accepted peer reaches the accept path with one too. When that is not what you see:
Data that is right on the first read and wrong on the second. A chunk was kept as a view rather than a copy, in the listener or in the engine’s own memory. Copy in the data and message listeners, and copy again into linear memory when the compiled code reads.
The engine throws on a fresh connection. An address getter was read before the relay had answered and threw Socket is not connected: on a socket the engine dialed that is any read before connect fires, while an accepted socket can name its peer inside the connection handler.
A write fails with Socket not connected, on the write callback and then on error. The engine wrote on a socket whose connect() was never called, so its number was handed out before the socket was dialed.
Most integrations this recipe is drawn from run the engine on a worker thread, relayed from the page with await relayWorker(worker, { unregisterSignal }) so its socket calls reach the broker, and the host file above runs there unchanged, see run sockets in a worker.
An engine that accepts peers reserves its port on UDP and TCP together before it starts, keeps it across a relay session that drops, and reports a refusal as a state rather than an exception, see hold an inbound port.