You can test code that opens sockets on Node’s own net and dgram, in a plain Node process, because the library’s socket surface is Node’s on purpose. This page covers that swap, the mock for a module you only pass through, what such a test does not see, the four failures you can produce on purpose, and what cannot be simulated.
@fkn/lib/net and @fkn/lib/dgram carry the same classes, events and callbacks as Node’s net and dgram. The bytes travel through the broker, the connection your page holds into FKN, and out through the relay, the server that holds the real socket. Code that opens sockets can therefore take its modules as options and run on either, see TCP and UDP sockets.
Each library entry has a Node module a test can pass instead:
Import
In a test
@fkn/lib/net
node:net
@fkn/lib/dgram
node:dgram
@fkn/lib/http
node:http
@fkn/lib/dns
lookup from node:dns/promises, which rejects on a name that does not resolve (the library resolves undefined)
The dependency shape makes that possible. A package that opens sockets declares @fkn/lib as an optional peer dependency, optional: true under peerDependenciesMeta, and never imports it. The host app passes the modules in, and the package’s own test passes Node’s. An engine written that way names the surface it uses:
engine.ts
exporttype
typeNetModule= {
connect: (options: {
host:string;
port:number;
}, listener?: () =>void) => {
write: (chunk:string) =>boolean;
on: (event:string, listener: (...args:any[]) =>void) =>unknown;
destroy: () =>unknown;
};
}
NetModule= {
connect: (options: {
host:string;
port:number;
}, listener?: () =>void) => {
write: (chunk:string) => boolean;
on: (event:string, listener: (...args:any[]) =>void) => unknown;
destroy: () => unknown;
}
connect: (
options: {
host: string;
port: number;
}
options: {
host: string
host:string,
port: number
port:number },
listener: (() =>void) |undefined
listener?: () =>void) => {
write: (chunk:string) => boolean
write: (
chunk: string
chunk:string) =>boolean
on: (event:string, listener: (...args:any[]) =>void) => unknown
new <string>(executor: (resolve: (value:string|PromiseLike<string>) =>void, reject: (reason?:any) =>void) =>void) =>Promise<string>
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.
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.
Removes the leading and trailing white space and line terminator characters from a string.
trim())
constsocket: {
write: (chunk:string) =>boolean;
on: (event:string, listener: (...args:any[]) =>void) =>unknown;
destroy: () =>unknown;
}
socket.
destroy: () => unknown
destroy()
})
constsocket: {
write: (chunk:string) =>boolean;
on: (event:string, listener: (...args:any[]) =>void) =>unknown;
destroy: () =>unknown;
}
socket.
on: (event:string, listener: (...args:any[]) =>void) => unknown
on('error',
reject: (reason?:any) =>void
reject) // a refused connect lands here on both modules
}),
})
NetModule names one call, connect, and the three members the engine uses on the socket it returns. Both modules satisfy it. The app passes net from @fkn/lib/net, and a test passes Node’s module and a server in the same process:
If allowHalfOpen is set to true, when the other end of the socket
signals the end of transmission, the server will only send back the end of
transmission when socket.end() is explicitly called. For example, in the
context of TCP, when a FIN packed is received, a FIN packed is sent
back only when socket.end() is explicitly called. Until then the
connection is half-closed (non-readable but still writable). See 'end' event and RFC 1122 (section 4.2.2.13) for more information.
If pauseOnConnect is set to true, then the socket associated with each
incoming connection will be paused, and no data will be read from its handle.
This allows connections to be passed between processes without any data being
read by the original process. To begin reading data from a paused socket, call socket.resume().
The server can be a TCP server or an IPC server, depending on what it listen() to.
Here is an example of a TCP echo server which listens for connections
on port 8124:
import net from'node:net';
constserver= net.createServer((c) => {
// 'connection' listener.
console.log('client connected');
c.on('end', () => {
console.log('client disconnected');
});
c.write('hello\r\n');
c.pipe(c);
});
server.on('error', (err) => {
throw err;
});
server.listen(8124, () => {
console.log('server bound');
});
Test this by using telnet:
Terminal window
telnetlocalhost8124
To listen on the socket /tmp/echo.sock:
server.listen('/tmp/echo.sock', () => {
console.log('server bound');
});
Use nc to connect to a Unix domain socket server:
Terminal window
nc-U/tmp/echo.sock
@since ― v0.5.0
@param ― connectionListener Automatically set as a listener for the 'connection' event.
Half-closes the socket, with one final chunk of data.
@see ― Socket.end for full details.
@since ― v0.1.90
@param ― callback Optional callback for when the socket is finished.
@return ― The socket itself.
end('pong\n'))
awaitnew
var Promise:PromiseConstructor
new <void>(executor: (resolve: (value:void|PromiseLike<void>) =>void, reject: (reason?:any) =>void) =>void) =>Promise<void>
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.
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.
The test never imports the library, so it needs no broker and no shim for stream, events and buffer. Under @fkn/vite-plugin the alias sends node:net to @fkn/lib/net, and taking the module as an option keeps that choice in the test.
When the code under test hands the module on and never calls it, an empty mock is enough. Nothing from the library loads. The dynamic import keeps the mock in front of the module under test on any runner, not only the ones that hoist it:
Gets or sets the length of the array. This is a number one higher than the highest index in the array.
length).
toBe: (expected:unknown) =>void
toBe(1) // passes, and nothing from the library ran
})
The empty object is the whole mock. A test that reaches a connect or a bind outgrows it, since net.connect is then undefined and the call throws a TypeError of the test’s own making. That test wants the swap above, or one of the failures below.
A test on Node’s modules proves the engine’s logic and says nothing about the error path. Node reports a failed connect as an Error with code, syscall and errno, and errno is libuv’s negative number. A WebAssembly engine built against WASI keeps a positive table of its own. The library’s socket errors carry neither: no errno, and no code apart from ERR_BUFFER_OUT_OF_BOUNDS on the range form of send.
The code the broker sets beside getaddrinfo ENOTFOUND does not reach you either. Custom properties do not survive the hop out of the broker into your realm, the JavaScript context your code runs in, see handling errors.
An engine that maps error.errno to a numeric code is therefore mapping its own fallback under the library. A test that asserts on errno never touches that path. Assert on error.message, the one field the library promises, and expect different wording: Node writes connect ECONNREFUSED 127.0.0.1:6881, and the relay hands back the operating system’s own text.
A test that passed on Node can still behave differently on the library, and TCP and UDP sockets lists every such difference. Limitations has the ones that stay on purpose.
Four failures are deterministic today, and each has a row on every error. The first and the last need no broker. The middle two need a page with a broker, and the third a relay behind it:
error instanceof BrokerUnreachableError, a class the library creates in your realm
The first row is the library’s own check, run before anything leaves the page. cloud.fetch refuses the same hosts, and fkn.dev and sdbx.app are on the list too, see platform hosts:
The second call reaches the proxy, the server cloud.fetch sends a request through, and comes back as a Response rather than a rejection. The row names cloud.fetch because the root fetch would ask for consent for that URL when the extension is installed, see local network targets.
For the third row, ask for any port with listen(0, '0.0.0.0'), wait for listening, read the granted port from server.address(), then listen a second server on that port and host. The second bind gets error, never listening, and holds nothing open, see listening.
The fourth row is the three calls that give up on a missing broker. A worker that opens a socket before the page has called await relayWorker(worker, { unregisterSignal }) waits for a broker that never comes, and connect, listen and bind are the calls that give up. apiWithin from @fkn/lib/api runs the same 8,000 ms race with a name of your choosing in the message:
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.
The first miss switches the realm’s deadline to 1,000 ms for every later call, so the socket gives up faster than the apiWithin call did. Every other call from that worker has no deadline of its own and stays pending for its whole life, see the worker nobody relayed.
The four rows above are refusals the library or the platform performs on any input, never the result of a switch. There is no test mode and no fixture that produces one on demand, and the published package reads no setting that changes what a call answers.
A stale key epoch, fkn:e2e-stale-epoch, needs a file written under a key the account has since reset. A relay that goes away in the middle, WebVPN session closed, needs a session to drop under an open socket.
Test those by writing the handler, with the predicate for each family from handling errors. Each predicate reads name, message or a class you can construct. new StorageLockedError() comes from @fkn/lib/cloud/fs and new Error(E2E_STALE_EPOCH_MESSAGE) from @fkn/lib/messages. A consent refusal needs only an Error named PermissionDeniedError, and a dropped relay only one reading WebVPN session closed.
Each takes the branch the real error would. Hand the handler the error you built, and let the platform supply the real one when it does.