Skip to content

Workers

A relayed worker is a Worker whose @fkn/lib calls travel over the broker, the connection your page holds into FKN. This page covers what relayWorker does and its options, the busy token it holds, what a worker looks like when nobody relayed it, and which calls work inside one.

A realm is one JavaScript execution context, such as a window or a worker. A worker is a realm of its own, and it cannot open a broker connection by itself, so the page relays its calls. The shape is two files: an engine that opens the sockets and a page that relays it. run sockets in a worker is the recipe for building one:

engine.ts
import * as
import net
net
from '@fkn/lib/net'
export type
type Command = {
type: "connect";
host: string;
port: number;
} | {
type: "close";
}
Command
= {
type: "connect"
type
: 'connect',
host: string
host
: string,
port: number
port
: number } | {
type: "close"
type
: 'close' }
export type
type Report = {
type: "chunk";
text: string;
} | {
type: "error";
message: string;
} | {
type: "closed";
}
Report
= {
type: "chunk"
type
: 'chunk',
text: string
text
: string } | {
type: "error"
type
: 'error',
message: string
message
: string } | {
type: "closed"
type
: 'closed' }
let
let socket: net.Socket | undefined
socket
:
import net
net
.
class Socket
export Socket
Socket
| undefined
var self: Window & typeof globalThis

The Window.self read-only property returns the window itself, as a WindowProxy.

MDN Reference

self
.
addEventListener<"message">(type: "message", listener: (this: Window, 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<Command>
event
:
interface MessageEvent<T = any>

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

MDN Reference

MessageEvent
<
type Command = {
type: "connect";
host: string;
port: number;
} | {
type: "close";
}
Command
>) => {
const
const command: Command
command
=
event: MessageEvent<Command>
event
.
MessageEvent<Command>.data: Command

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
if (
const command: Command
command
.
type: "connect" | "close"
type
=== 'close') {
let socket: net.Socket | undefined
socket
?.
Stream.Readable.destroy(error?: Error): net.Socket

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().

@sincev8.0.0

@paramerror Error which will be passed as payload in 'error' event

destroy
() // ends the socket and releases its busy token
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: "closed"
type
: 'closed' } satisfies
type Report = {
type: "chunk";
text: string;
} | {
type: "error";
message: string;
} | {
type: "closed";
}
Report
) // the page waits for this before it terminates the worker
return
}
const
const opened: net.Socket
opened
=
import net
net
.
function connect(options: SocketConnectOpts, connectionListener?: () => void): net.Socket
export connect
connect
({
TcpSocketConnectOpts.host?: string | undefined
host
:
const command: {
type: "connect";
host: string;
port: number;
}
command
.
host: string
host
,
TcpSocketConnectOpts.port: number
port
:
const command: {
type: "connect";
host: string;
port: number;
}
command
.
port: number
port
}, () => {
const opened: net.Socket
opened
.
Socket$2.write(buffer: Uint8Array | string, cb?: (err?: Error) => void): boolean (+1 overload)

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:

function write(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.

write
('GET /api/catalog.json HTTP/1.0\r\nHost: example.org\r\n\r\n')
})
const opened: net.Socket
opened
.
Stream.Duplex.on<"data">(eventName: "data", listener: (chunk: any) => 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
('data', (
chunk: Buffer<ArrayBufferLike>
chunk
:
interface Buffer<TArrayBuffer extends ArrayBufferLike = ArrayBufferLike>
Buffer
) => {
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: "chunk"
type
: 'chunk',
text: string
text
:
chunk: Buffer<ArrayBufferLike>
chunk
.
Buffer<ArrayBufferLike>.toString(encoding?: BufferEncoding, start?: number, end?: number): string

Decodes buf to a string according to the specified character encoding inencoding. start and end may be passed to decode only a subset of buf.

If encoding is 'utf8' and a byte sequence in the input is not valid UTF-8, then each invalid byte is replaced with the replacement character U+FFFD.

The maximum length of a string instance (in UTF-16 code units) is available as

constants.MAX_STRING_LENGTH

.

import { Buffer } from 'node:buffer';
const buf1 = Buffer.allocUnsafe(26);
for (let i = 0; i < 26; i++) {
// 97 is the decimal ASCII value for 'a'.
buf1[i] = i + 97;
}
console.log(buf1.toString('utf8'));
// Prints: abcdefghijklmnopqrstuvwxyz
console.log(buf1.toString('utf8', 0, 5));
// Prints: abcde
const buf2 = Buffer.from('tést');
console.log(buf2.toString('hex'));
// Prints: 74c3a97374
console.log(buf2.toString('utf8', 0, 3));
// Prints: té
console.log(buf2.toString(undefined, 0, 3));
// Prints: té

@sincev0.1.90

@paramencoding The character encoding to use.

@paramstart The byte offset to start decoding at.

@paramend The byte offset to stop decoding at (not inclusive).

toString
('utf8') } satisfies
type Report = {
type: "chunk";
text: string;
} | {
type: "error";
message: string;
} | {
type: "closed";
}
Report
) // posted to the page, one chunk per read
})
const opened: net.Socket
opened
.
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
) => {
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: "error"
type
: 'error',
message: string
message
:
error: Error
error
.
Error.message: string
message
} satisfies
type Report = {
type: "chunk";
text: string;
} | {
type: "error";
message: string;
} | {
type: "closed";
}
Report
) // a refused connect lands here, and nothing else fires
})
let socket: net.Socket | undefined
socket
=
const opened: net.Socket
opened
})
app.ts
import type {
type Command = {
type: "connect";
host: string;
port: number;
} | {
type: "close";
}
Command
,
type Report = {
type: "chunk";
text: string;
} | {
type: "error";
message: string;
} | {
type: "closed";
}
Report
} from './engine'
import {
const relayWorker: (worker: Worker, options?: {
unregisterSignal?: AbortSignal;
originA?: string;
originB?: string;
}) => Promise<void>
relayWorker
} from '@fkn/lib'
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
}) // registered, the engine's calls can now reach the broker
const
const chunks: string[]
chunks
: string[] = []
const
const failures: string[]
failures
: string[] = []
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<Report>
event
:
interface MessageEvent<T = any>

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

MDN Reference

MessageEvent
<
type Report = {
type: "chunk";
text: string;
} | {
type: "error";
message: string;
} | {
type: "closed";
}
Report
>) => {
if (
event: MessageEvent<Report>
event
.
MessageEvent<Report>.data: Report

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: "chunk" | "error" | "closed"
type
=== 'chunk')
const chunks: string[]
chunks
.
Array<string>.push(...items: string[]): number

Appends new elements to the end of an array, and returns the new length of the array.

@paramitems New elements to add to the array.

push
(
event: MessageEvent<Report>
event
.
MessageEvent<Report>.data: {
type: "chunk";
text: string;
}

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
.
text: string
text
) // the response, one entry per chunk
else if (
event: MessageEvent<Report>
event
.
MessageEvent<Report>.data: {
type: "error";
message: string;
} | {
type: "closed";
}

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: "error" | "closed"
type
=== 'error')
const failures: string[]
failures
.
Array<string>.push(...items: string[]): number

Appends new elements to the end of an array, and returns the new length of the array.

@paramitems New elements to add to the array.

push
(
event: MessageEvent<Report>
event
.
MessageEvent<Report>.data: {
type: "error";
message: string;
}

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
.
message: string
message
) // the engine's error, kept apart from the response
})
const engine: Worker
engine
.
Worker.postMessage(message: any, options?: StructuredSerializeOptions): void (+1 overload)

The postMessage() method of the Worker interface sends a message to the worker.

MDN Reference

postMessage
({
type: "connect"
type
: 'connect',
host: string
host
: 'example.org',
port: number
port
: 80 } satisfies
type Command = {
type: "connect";
host: string;
port: number;
} | {
type: "close";
}
Command
)

The engine imports the library exactly as your page does. On a page, @fkn/lib mounts the broker frame, a hidden fkn.app iframe, and talks to the broker through it. Inside a worker there is no document to mount that frame in, so the library’s transport is { receive: self, emit: self }, connected to nothing until the page relays it. Every call the worker makes after that travels over the page’s connection, as how it works explains.

relayWorker forwards the fkn-api channel both ways, between this realm’s broker transport and the worker, so both ends have to exist. It is async, and its two refusals arrive as rejections of the promise it returns. Await it, so that a refusal reaches your code as a rejection you can catch.

Without a window it rejects with FKN @fkn/lib: relayWorker must be called from the main thread. With no broker transport in this realm, for example when the broker frame has left the document, it rejects with FKN @fkn/lib: relayWorker found no FKN transport in this realm.

Once the relay is registered and until unregisterSignal aborts, it holds the busy token relayed worker. A busy token is one of the reasons a realm reports itself busy, and shell.busyReasons() lists the ones held. Aborting the signal releases the token before abort() returns:

app.ts
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
})
(alias) namespace shell
import shell
shell
.
shell_d_exports.busyReasons(): string[]
export shell_d_exports.busyReasons

What a shell reload would sever right now, as a list of human-readable reasons, empty when nothing is bound to the broker connection.

This is the evidence for deciding whether to call applyUpdate yet: open sockets and listening servers, a streaming proxy response, a mounted package, a live frame attachment, unflushed write-behind. An empty list is not a promise that a reload is free, only that this realm holds nothing the lib knows about, so an app with state of its own should weigh that too.

It is per REALM, and that distinction has already caused a wrong reading once. A worker that imports @fkn/lib/net keeps its own tally, which this function cannot see from the window. An app whose transfers live in a worker should ask the worker, not the page.

busyReasons
() // ['relayed worker (1)']
const stop: AbortController
stop
.
AbortController.abort(reason?: any): void

The abort() method of the AbortController interface aborts an asynchronous operation before it has completed.

MDN Reference

abort
()
(alias) namespace shell
import shell
shell
.
shell_d_exports.busyReasons(): string[]
export shell_d_exports.busyReasons

What a shell reload would sever right now, as a list of human-readable reasons, empty when nothing is bound to the broker connection.

This is the evidence for deciding whether to call applyUpdate yet: open sockets and listening servers, a streaming proxy response, a mounted package, a live frame attachment, unflushed write-behind. An empty list is not a promise that a reload is free, only that this realm holds nothing the lib knows about, so an app with state of its own should weigh that too.

It is per REALM, and that distinction has already caused a wrong reading once. A worker that imports @fkn/lib/net keeps its own tally, which this function cannot see from the window. An app whose transfers live in a worker should ask the worker, not the page.

busyReasons
() // [], released before abort() returned

Neither reading counts anything the engine holds. The tally is per realm, as connection and lifecycle explains.

Every option can be left out, and the exact signature is in the generated API reference:

OptionDefaultWhat it does
unregisterSignalAn AbortSignal that ends the relay and releases the busy token when aborted. A signal that was already aborted when you call relayWorker releases the token at once.
originAhttps://fkn.app, the origin baked into the published buildThe targetOrigin used when posting into the broker frame.
originB'*'The targetOrigin handed to osra when posting into the worker.

unregisterSignal is the only way to end a relay. Keep the controller next to the worker and abort it when you terminate the worker. Otherwise the token stays held for the life of the page.

originA and originB apply only on the window pair of a mounted broker frame. A realm nested under a broker document reaches it over a granted MessagePort, which has no origin to compare against, so both options are ignored there.

originB restricts nothing even on the window pair, because a worker does not check the targetOrigin it is handed. Only originA decides where a post is delivered.

relayWorker resolves once the relay is registered, not once the worker has connected to the broker. The worker’s first call still waits for the broker. That wait is bounded by 8 seconds for a socket and unbounded for everything else, as connection and lifecycle explains.

A worker that opens a socket before the page relays it does not fail at once. It waits: apiPromise, the library’s promise of a broker connection, stays pending until the page relays the worker, and for the life of the worker if nobody does. Its transport is connected to nothing, so every call parks. Nothing rejects, and no socket emits connect or listening.

Only the deadline on connect, listen and bind turns that wait into something you can see. Those three give up with a BrokerUnreachableError on the socket’s or the server’s error event after 8 seconds, then after 1 second once a deadline was missed:

engine.ts
import * as
import net
net
from '@fkn/lib/net'
import * as
import dgram
dgram
from '@fkn/lib/dgram'
import {
const lookup: <T extends boolean = false>(hostname: string, options?: {
all?: T;
family?: 0 | 4 | 6;
}) => Promise<T extends true ? AddressLookupResult[] : AddressLookupResult | undefined>
lookup
} from '@fkn/lib/dns'
import {
class BrokerUnreachableError
BrokerUnreachableError
} from '@fkn/lib/api'
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
: 'example.org',
TcpSocketConnectOpts.port: number
port
: 80 })
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 after 8 seconds, nobody relayed this worker
error: Error
error
.
Error.message: string
message
// @fkn/lib: no broker connection within 8000ms, so an outbound tcp socket could not be requested
})
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
.
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
(6881)
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
) // @fkn/lib: no broker connection within 8000ms, so a udp socket could not be requested
await
lookup<false>(hostname: string, options?: {
all?: false | undefined;
family?: 0 | 4 | 6;
} | undefined): Promise<AddressLookupResult | undefined>
lookup
('example.org') // never settles here, nothing bounds it

Both sockets fail together, because both waits began before either deadline was missed. lookup is the contrast. It has no deadline, and neither do cloud.fetch and cloud.fs, so all three stay pending forever.

The order matters. An engine that resolves a name before it connects never reaches the socket call that would have told you.

fs and opfs are the exception. Both write OPFS with no broker at all, and opfs never asks for one. An fs write reaches OPFS first, then waits on the cloud availability probe before it resolves.

In a worker nobody relayed that probe times out, after 8 seconds, then 1 second once a probe has timed out, so the write resolves late rather than never. mount() and list() wait the same way, because both list the account. See sync and conflicts.

Everything that only needs the broker works. Everything that needs a document or a window answers false or null, does nothing, or throws:

CallIn a relayed worker
net, dgram, httpwork, with connect, listen and bind bounded by the broker deadline, see TCP and UDP sockets
dns.lookup, cloud.fetch, cloud.quotawork, waiting for the broker with no deadline
cloud.fs.*works, waiting for the broker with no deadline, see storage
fetch (root)takes the cloud path, and credentials: 'include' rejects with fetch with credentials needs the FKN extension, which only exists in window realms
fs, opfswrite OPFS. fs waits on the availability probe before a write resolves, replicates to the account over the relay, and keeps no queue in a worker because the queue lives in localStorage, see sync and conflicts
packages.search, packages.list, packages.install, packages.uninstall, packages.pick, packages.connect, packages.attachno realm check in the library, and attach is the one made for a worker, see packages
packages.onConnect, packages.isVisible, packages.onVisibilityChangeno-ops without a window
packages.show, packages.hide, packages.mounttouch the DOM, so they need a window
rooms.available, rooms.create, rooms.join, and every method on a Roomwork through the relayed broker. The three entry calls bound the wait for it with the broker deadline, so available() is false in a worker nobody relayed and create and join reject rooms: rooms are unavailable, see rooms
extension.available(), isExtensionExposed()false: there is no document to find the extension on
attachFrameneeds a window realm, see frames
cloud.attachFramethrows cloud.attachFrame needs a window realm
promptInstall, connect, account.login, shell.updateReady, shell.applyUpdateresolve false
promptRelay, account.inforesolve null
account.logout, account.onChange, shell.onUpdate, shell.onUpdateTakenno-ops, and the subscriptions never fire
shell.busyReasons()the worker’s own tally
relayWorkerrejects with FKN @fkn/lib: relayWorker must be called from the main thread
desktop.*throws as it does everywhere, while available() answers false and fs.available() resolves false

The same calls in code, from inside the engine:

engine.ts
import {
(alias) namespace cloud
import cloud
cloud
,
const connect: () => Promise<boolean>
connect
,
const promptInstall: (reason?: string) => Promise<boolean>
promptInstall
,
const promptRelay: () => Promise<string | null>
promptRelay
,
(alias) namespace account
import account
account
,
(alias) namespace shell
import shell
shell
,
(alias) namespace extension
import extension
extension
} from '@fkn/lib'
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
('https://example.org/api/catalog.json') // relayed through the page
(alias) namespace shell
import shell
shell
.
shell_d_exports.busyReasons(): string[]
export shell_d_exports.busyReasons

What a shell reload would sever right now, as a list of human-readable reasons, empty when nothing is bound to the broker connection.

This is the evidence for deciding whether to call applyUpdate yet: open sockets and listening servers, a streaming proxy response, a mounted package, a live frame attachment, unflushed write-behind. An empty list is not a promise that a reload is free, only that this realm holds nothing the lib knows about, so an app with state of its own should weigh that too.

It is per REALM, and that distinction has already caused a wrong reading once. A worker that imports @fkn/lib/net keeps its own tally, which this function cannot see from the window. An app whose transfers live in a worker should ask the worker, not the page.

busyReasons
() // ['streamed response (1)'], this worker's own tally
const
const catalog: any
catalog
= await
const response: Response
response
.
Body.json(): Promise<any>
json
() // the app's own data
await
(alias) namespace cloud
import cloud
cloud
.
namespace cloud_d_exports.fs
export cloud_d_exports.fs
fs
.
const fs_d_exports.promises: {
readFile: (path: import("node:fs").PathLike, options?: ReadOptions) => Promise<Buffer | string>;
writeFile: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => Promise<void>;
unlink: (path: import("node:fs").PathLike) => Promise<void>;
rename: (from: import("node:fs").PathLike, to: import("node:fs").PathLike) => Promise<void>;
readdir: (path: import("node:fs").PathLike) => Promise<string[]>;
mkdir: (_path?: import("node:fs").PathLike, _options?: MakeOptions) => Promise<void>;
... 4 more ...;
access: (path: import("node:fs").PathLike) => Promise<void>;
}
export fs_d_exports.promises
promises
.
writeFile: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => Promise<void>
writeFile
('library/catalog.json',
var JSON: JSON

An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.

JSON
.
JSON.stringify(value: any, replacer?: (this: any, key: string, value: any) => any, space?: string | number): string (+1 overload)

Converts a JavaScript value to a JavaScript Object Notation (JSON) string.

@paramvalue A JavaScript value, usually an object or array, to be converted.

@paramreplacer A function that transforms the results.

@paramspace Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.

@throws{TypeError} If a circular reference or a BigInt value is found.

stringify
(
const catalog: any
catalog
)) // the cloud-only file system, written from the worker
(alias) namespace shell
import shell
shell
.
shell_d_exports.busyReasons(): string[]
export shell_d_exports.busyReasons

What a shell reload would sever right now, as a list of human-readable reasons, empty when nothing is bound to the broker connection.

This is the evidence for deciding whether to call applyUpdate yet: open sockets and listening servers, a streaming proxy response, a mounted package, a live frame attachment, unflushed write-behind. An empty list is not a promise that a reload is free, only that this realm holds nothing the lib knows about, so an app with state of its own should weigh that too.

It is per REALM, and that distinction has already caused a wrong reading once. A worker that imports @fkn/lib/net keeps its own tally, which this function cannot see from the window. An app whose transfers live in a worker should ask the worker, not the page.

busyReasons
() // []
(alias) namespace extension
import extension
extension
.
extension_d_exports.available(): boolean
export extension_d_exports.available
available
() // false
await
function promptInstall(reason?: string): Promise<boolean>
promptInstall
() // false
await
function connect(): Promise<boolean>
connect
() // false
await
function promptRelay(): Promise<string | null>
promptRelay
() // null
await
(alias) namespace account
import account
account
.
account_d_exports.info(): Promise<account.AccountInfo | null>
export account_d_exports.info
info
() // null

The fetch and the write need nothing from the page beyond the relay, while every prompt answers at once with nothing to show. A worker that fetches and nothing more can import @fkn/lib/cloud/fetch and skip the socket shims. entry points lists what each entry reaches.

The worker’s tally is the one that knows about its transfers, so ask the worker before you reload. The tally is explained on connection and lifecycle.

Prompts, consent and frames stay page work. Ask the user from the page, drive frames from the page, and hand the worker what it needs. The detail is on permissions and consent and frames.

Close the worker’s sockets from inside the worker, then abort the signal, then terminate the worker, in that order. terminate() ends the worker at once, so a close posted just before it may never run. The engine above destroys its socket on close and answers closed, and the page waits for that answer before it goes on:

app.ts
const
const closed: Promise<void>
closed
= 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 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<Report>
event
:
interface MessageEvent<T = any>

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

MDN Reference

MessageEvent
<
type Report = {
type: "chunk";
text: string;
} | {
type: "error";
message: string;
} | {
type: "closed";
}
Report
>) => {
if (
event: MessageEvent<Report>
event
.
MessageEvent<Report>.data: Report

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: "error" | "chunk" | "closed"
type
=== 'closed')
resolve: (value: void | PromiseLike<void>) => void
resolve
()
})
})
const engine: Worker
engine
.
Worker.postMessage(message: any, options?: StructuredSerializeOptions): void (+1 overload)

The postMessage() method of the Worker interface sends a message to the worker.

MDN Reference

postMessage
({
type: "close"
type
: 'close' } satisfies
type Command = {
type: "connect";
host: string;
port: number;
} | {
type: "close";
}
Command
) // the engine destroys its socket, then answers
await
const closed: Promise<void>
closed
const stop: AbortController
stop
.
AbortController.abort(reason?: any): void

The abort() method of the AbortController interface aborts an asynchronous operation before it has completed.

MDN Reference

abort
() // the relay ends, and its token goes with it
(alias) namespace shell
import shell
shell
.
shell_d_exports.busyReasons(): string[]
export shell_d_exports.busyReasons

What a shell reload would sever right now, as a list of human-readable reasons, empty when nothing is bound to the broker connection.

This is the evidence for deciding whether to call applyUpdate yet: open sockets and listening servers, a streaming proxy response, a mounted package, a live frame attachment, unflushed write-behind. An empty list is not a promise that a reload is free, only that this realm holds nothing the lib knows about, so an app with state of its own should weigh that too.

It is per REALM, and that distinction has already caused a wrong reading once. A worker that imports @fkn/lib/net keeps its own tally, which this function cannot see from the window. An app whose transfers live in a worker should ask the worker, not the page.

busyReasons
() // []
const engine: Worker
engine
.
Worker.terminate(): void

The terminate() method of the Worker interface immediately terminates the Worker.

MDN Reference

terminate
() // nothing is left for the worker to hold

run sockets in a worker is the recipe for the whole worker, from build to teardown. Nothing in the library acts on a token: it is evidence for your own reload decision, as connection and lifecycle explains.

The three messages this page owns, each with its cause:

MessageWhat happened
FKN @fkn/lib: relayWorker must be called from the main threadrelayWorker ran where there is no window, such as inside the worker itself. Call it from the page.
FKN @fkn/lib: relayWorker found no FKN transport in this realmThis realm holds neither a mounted broker frame nor a port from a parent FKN realm. A broker frame removed from the document counts as missing, see how it works.
packages: no FKN transport in this realm - use relayWorker to bridge workersA packages.* call ran with neither window nor self. A worker has self, so in a worker nobody relayed the same call waits instead, see the worker nobody relayed.

The socket deadline has its own row, BrokerUnreachableError, and every other message is on every error.

Call relayWorker before the worker’s first call, and await it.