Skip to content

Testing

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:

ImportIn a test
@fkn/lib/netnode:net
@fkn/lib/dgramnode:dgram
@fkn/lib/httpnode:http
@fkn/lib/dnslookup 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
export type
type NetModule = {
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
on
: (
event: string
event
: string,
listener: (...args: any[]) => void
listener
: (...
args: any[]
args
: any[]) => void) => unknown
destroy: () => unknown
destroy
: () => unknown
}
}
export const
const createEngine: ({ net }: {
net: NetModule;
}) => {
ping: (host: string, port: number) => Promise<string>;
}
createEngine
= ({
net: NetModule
net
}: {
net: NetModule
net
:
type NetModule = {
connect: (options: {
host: string;
port: number;
}, listener?: () => void) => {
write: (chunk: string) => boolean;
on: (event: string, listener: (...args: any[]) => void) => unknown;
destroy: () => unknown;
};
}
NetModule
}) => ({
ping: (host: string, port: number) => Promise<string>
ping
: (
host: string
host
: string,
port: number
port
: number) => new
var Promise: PromiseConstructor
new <string>(executor: (resolve: (value: string | PromiseLike<string>) => void, reject: (reason?: any) => void) => void) => Promise<string>

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
<string>((
resolve: (value: string | PromiseLike<string>) => void
resolve
,
reject: (reason?: any) => void
reject
) => {
const
const socket: {
write: (chunk: string) => boolean;
on: (event: string, listener: (...args: any[]) => void) => unknown;
destroy: () => unknown;
}
socket
=
net: NetModule
net
.
connect: (options: {
host: string;
port: number;
}, listener?: () => void) => {
write: (chunk: string) => boolean;
on: (event: string, listener: (...args: any[]) => void) => unknown;
destroy: () => unknown;
}
connect
({
host: string
host
,
port: number
port
}, () =>
const socket: {
write: (chunk: string) => boolean;
on: (event: string, listener: (...args: any[]) => void) => unknown;
destroy: () => unknown;
}
socket
.
write: (chunk: string) => boolean
write
('ping\n')) // the same call on node:net and on @fkn/lib/net
const socket: {
write: (chunk: string) => boolean;
on: (event: string, listener: (...args: any[]) => void) => unknown;
destroy: () => unknown;
}
socket
.
on: (event: string, listener: (...args: any[]) => void) => unknown
on
('data', (
chunk: Uint8Array<ArrayBufferLike>
chunk
:
interface Uint8Array<TArrayBuffer extends ArrayBufferLike = ArrayBufferLike>

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
) => {
resolve: (value: string | PromiseLike<string>) => void
resolve
(new
var TextDecoder: new (label?: string, options?: TextDecoderOptions) => TextDecoder

The TextDecoder interface represents a decoder for a specific text encoding, such as UTF-8, ISO-8859-2, KOI8-R, GBK, etc.

MDN Reference

TextDecoder
().
TextDecoder.decode(input?: AllowSharedBufferSource, options?: TextDecodeOptions): string

The TextDecoder.decode() method returns a string containing text decoded from the buffer passed as a parameter.

MDN Reference

decode
(
chunk: Uint8Array<ArrayBufferLike>
chunk
).
String.trim(): string

Removes the leading and trailing white space and line terminator characters from a string.

trim
())
const socket: {
write: (chunk: string) => boolean;
on: (event: string, listener: (...args: any[]) => void) => unknown;
destroy: () => unknown;
}
socket
.
destroy: () => unknown
destroy
()
})
const socket: {
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:

app.test.ts
import * as
module "node:net"
net
from 'node:net'
import {
const test: (name: string, fn: () => unknown, timeout?: number) => void
test
,
const expect: (value: unknown) => {
toBe: (expected: unknown) => void;
}
expect
} from 'vitest'
import {
const createEngine: ({ net }: {
net: NetModule;
}) => {
ping: (host: string, port: number) => Promise<string>;
}
createEngine
} from './engine'
function test(name: string, fn: () => unknown, timeout?: number): void
test
('the engine speaks to a server in the same process', async () => {
const
const server: net.Server
server
=
module "node:net"
net
.
function createServer(connectionListener?: (socket: net.Socket) => void): net.Server (+1 overload)

Creates a new TCP or IPC server.

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';
const server = 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
telnet localhost 8124

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

@sincev0.5.0

@paramconnectionListener Automatically set as a listener for the 'connection' event.

createServer
((
peer: net.Socket
peer
) =>
peer: net.Socket
peer
.
Socket.end(buffer: Uint8Array | string, callback?: () => void): net.Socket (+2 overloads)

Half-closes the socket, with one final chunk of data.

@seeSocket.end for full details.

@sincev0.1.90

@paramcallback Optional callback for when the socket is finished.

@returnThe socket itself.

end
('pong\n'))
await new
var Promise: PromiseConstructor
new <void>(executor: (resolve: (value: void | PromiseLike<void>) => void, reject: (reason?: any) => void) => void) => Promise<void>

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
<void>(
resolve: (value: void | PromiseLike<void>) => void
resolve
=>
const server: net.Server
server
.
Server.listen(port?: number, hostname?: string, listeningListener?: (() => void) | undefined): net.Server (+8 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
(0, '127.0.0.1', () =>
resolve: (value: void | PromiseLike<void>) => void
resolve
()))
const {
const port: number
port
} =
const server: net.Server
server
.
Server.address(): net.AddressInfo | string | 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
() as
module "node:net"
net
.
interface AddressInfo
AddressInfo
const
const engine: {
ping: (host: string, port: number) => Promise<string>;
}
engine
=
function createEngine({ net }: {
net: NetModule;
}): {
ping: (host: string, port: number) => Promise<string>;
}
createEngine
({
net: NetModule
net
})
function expect(value: unknown): {
toBe: (expected: unknown) => void;
}
expect
(await
const engine: {
ping: (host: string, port: number) => Promise<string>;
}
engine
.
ping: (host: string, port: number) => Promise<string>
ping
('127.0.0.1',
const port: number
port
)).
toBe: (expected: unknown) => void
toBe
('pong') // passes, with node:net passed in and no broker anywhere
const server: net.Server
server
.
Server.close(callback?: ((err?: Error) => 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
()
})

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:

app.test.ts
import {
const test: (name: string, fn: () => unknown, timeout?: number) => void
test
,
const expect: (value: unknown) => {
toBe: (expected: unknown) => void;
}
expect
,
const vi: {
mock: (path: string, factory?: () => unknown) => void;
}
vi
} from 'vitest'
const vi: {
mock: (path: string, factory?: () => unknown) => void;
}
vi
.
mock: (path: string, factory?: () => unknown) => void
mock
('@fkn/lib/net', () => ({})) // the module is only passed through, so an empty object is enough
function test(name: string, fn: () => unknown, timeout?: number): void
test
('adds a catalog entry without opening a socket', async () => {
const {
const createLibrary: () => {
engine: {
ping: (host: string, port: number) => Promise<string>;
};
add: (entry: {
title: string;
}) => {
title: string;
}[];
}
createLibrary
} = await import('./app')
const
const library: {
engine: {
ping: (host: string, port: number) => Promise<string>;
};
add: (entry: {
title: string;
}) => {
title: string;
}[];
}
library
=
const createLibrary: () => {
engine: {
ping: (host: string, port: number) => Promise<string>;
};
add: (entry: {
title: string;
}) => {
title: string;
}[];
}
createLibrary
()
function expect(value: unknown): {
toBe: (expected: unknown) => void;
}
expect
(
const library: {
engine: {
ping: (host: string, port: number) => Promise<string>;
};
add: (entry: {
title: string;
}) => {
title: string;
}[];
}
library
.
add: (entry: {
title: string;
}) => {
title: string;
}[]
add
({
title: string
title
: 'Sintel' }).
Array<{ title: string; }>.length: number

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:

What you doWhat you getHow to check
fetch('https://fkn.app/')a rejection, fetch refuses FKN platform domains (fkn.app)catch it and read error.message
cloud.fetch('http://192.168.1.10/')a resolved Response, status 403, body {"error":"egress refused (non-public target)"}response.ok is false, and the body’s error names the refusal
listen on a port this page already holdserror and never listening, and the message names the relay’s reasonattach error before listen, and assert the callback never ran
connect, listen or bind from a worker nobody relayedBrokerUnreachableError on error after 8,000 ms, or after 9,000 ms for a listen with no host, which binds twiceerror 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:

app.ts
import {
const fetch: (input: RequestInfo | URL, init?: RequestInit & {
reason?: string;
render?: boolean;
}) => Promise<Response>
fetch
,
(alias) namespace cloud
import cloud
cloud
} from '@fkn/lib'
try {
await
function fetch(input: RequestInfo | URL, init?: RequestInit & {
reason?: string;
render?: boolean;
}): Promise<Response>
fetch
('https://fkn.app/')
} catch (
var error: unknown
error
) {
if (
var error: unknown
error
instanceof
var Error: ErrorConstructor
Error
)
var error: Error
error
.
Error.message: string
message
// fetch refuses FKN platform domains (fkn.app)
}
const
const response: Response
response
= await
(alias) namespace cloud
import cloud
cloud
.
cloud_d_exports.fetch(input: ProxyFetchInput, init?: ProxyFetchInit): Promise<Response>
export cloud_d_exports.fetch
fetch
('http://192.168.1.10/')
const response: Response
response
.
Response.status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
// 403, the proxy's own status
const {
const error: string
error
} = await
const response: Response
response
.
Body.json(): Promise<any>
json
() as {
error: string
error
: string }
const error: string
error
// egress refused (non-public target)

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:

app.ts
import * as
import net
net
from '@fkn/lib/net'
import {
const apiWithin: (what: string) => Promise<{
cloud: {
fetch: (input: ProxyFetchInput, init: ProxyFetchInit) => Promise<Response>;
quota: () => Promise<{
overQuota: boolean;
remaining: number;
usedBytes: number;
limitBytes: number;
premium: boolean;
bytesPerSecond: number;
}>;
dns: {
lookup: (hostname: string, options?: {
all?: boolean | undefined;
family?: 0 | 4 | 6;
} | undefined) => Promise<{
address: string;
family: 0 | 4 | 6;
} | {
address: string;
family: 0 | 4 | 6;
}[] | undefined>;
};
fs: {
available: () => Promise<boolean>;
... 11 more ...;
setAdoptSource: (next: AdoptState | null, run: (() => Promise<void>) | null) => Promise<...>;
};
webvpn: {
...;
};
};
... 16 more ...;
hideInstallPrompt: () => Promise<...>;
}>
apiWithin
,
class BrokerUnreachableError
BrokerUnreachableError
} from '@fkn/lib/api'
try {
await
function apiWithin(what: string): Promise<{
cloud: {
fetch: (input: ProxyFetchInput, init: ProxyFetchInit) => Promise<Response>;
quota: () => Promise<{
overQuota: boolean;
remaining: number;
usedBytes: number;
limitBytes: number;
premium: boolean;
bytesPerSecond: number;
}>;
dns: {
lookup: (hostname: string, options?: {
all?: boolean | undefined;
family?: 0 | 4 | 6;
} | undefined) => Promise<{
address: string;
family: 0 | 4 | 6;
} | {
address: string;
family: 0 | 4 | 6;
}[] | undefined>;
};
fs: {
available: () => Promise<boolean>;
... 11 more ...;
setAdoptSource: (next: AdoptState | null, run: (() => Promise<void>) | null) => Promise<...>;
};
webvpn: {
...;
};
};
... 16 more ...;
hideInstallPrompt: () => Promise<...>;
}>
apiWithin
('a peer connection')
} catch (
var error: unknown
error
) {
if (
var error: unknown
error
instanceof
class BrokerUnreachableError
BrokerUnreachableError
) {
var error: BrokerUnreachableError
error
.
Error.message: string
message
// @fkn/lib: no broker connection within 8000ms, so a peer connection could not be requested
}
}
const
const socket: net.Socket
socket
=
import net
net
.
function connect(options: SocketConnectOpts, connectionListener?: () => void): net.Socket
export connect
connect
({
TcpSocketConnectOpts.host?: string | undefined
host
: '67.215.246.10',
TcpSocketConnectOpts.port: number
port
: 6881 })
const socket: net.Socket
socket
.
Stream.Duplex.on<"error">(eventName: "error", listener: (err: Error) => void): net.Socket (+1 overload)

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, and after 1000 ms this time
})

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.

Everything else depends on state a test does not hold. A consent refusal, Permission denied: <key> (<scope>), needs a person at the consent sheet, the prompt the extension shows before an action that needs approval. A locked account, storage locked: this account stores encrypted data, unlock it from the FKN card or unlock(), needs encryption enrolled, no usable key in this session, and an unlock card the user dismissed.

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.