Skip to content

Run sockets in a worker

A worker opens a TCP connection through the relay, the page can stop it, and a failure arrives as a rejection instead of a call that never settles. This page covers the engine, the relay call and its two refusals, the teardown, and how your own messages share the worker’s channel with the relay.

  • Uses relayWorker and its unregisterSignal, @fkn/lib/net, @fkn/lib/dgram, apiWithin and BrokerUnreachableError from @fkn/lib/api, shell.busyReasons
  • Needs nothing installed
  • Proven by six integrations

The relay is the server that holds the real socket at the far end of net and dgram. The broker is the connection your page holds into FKN. A worker is a realm of its own, one JavaScript execution context, and it holds no broker, so the page relays the worker and every socket call it makes travels over the page’s broker to the relay. The shape is two files: engine.ts runs in the worker and app.ts on the page.

The engine imports @fkn/lib/net exactly as a page would, and @fkn/lib/dgram the same way. It takes commands from the page over postMessage and answers with reports, so the page never touches the socket:

engine.ts
import * as
import net
net
from '@fkn/lib/net'
import {
(alias) namespace shell
import shell
shell
} from '@fkn/lib'
export type
type Command = {
type: "connect";
host: string;
port: number;
} | {
type: "busy";
} | {
type: "close";
}
Command
= {
type: "connect"
type
: 'connect',
host: string
host
: string,
port: number
port
: number } | {
type: "busy"
type
: 'busy' } | {
type: "close"
type
: 'close' }
export type
type Report = {
type: "connected";
port: number;
} | {
type: "chunk";
text: string;
} | {
type: "error";
message: string;
} | {
type: "busy";
reasons: string[];
} | {
type: "closed";
}
Report
=
| {
type: "connected"
type
: 'connected',
port: number
port
: number }
| {
type: "chunk"
type
: 'chunk',
text: string
text
: string }
| {
type: "error"
type
: 'error',
message: string
message
: string }
| {
type: "busy"
type
: 'busy',
reasons: string[]
reasons
: string[] }
| {
type: "closed"
type
: 'closed' }
const
const COMMANDS: Set<"connect" | "busy" | "close">
COMMANDS
= new
var Set: SetConstructor
new <"connect" | "busy" | "close">(iterable?: Iterable<"connect" | "busy" | "close"> | null | undefined) => Set<"connect" | "busy" | "close"> (+1 overload)
Set
<
type Command = {
type: "connect";
host: string;
port: number;
} | {
type: "busy";
} | {
type: "close";
}
Command
['type']>(['connect', 'busy', 'close'])
const
const report: (message: Report) => void
report
= (
message: Report
message
:
type Report = {
type: "connected";
port: number;
} | {
type: "chunk";
text: string;
} | {
type: "error";
message: string;
} | {
type: "busy";
reasons: string[];
} | {
type: "closed";
}
Report
) =>
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
(
message: Report
message
)
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<any>
event
:
interface MessageEvent<T = any>

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

MDN Reference

MessageEvent
) => {
if (!
const COMMANDS: Set<"connect" | "busy" | "close">
COMMANDS
.
Set<"connect" | "busy" | "close">.has(value: "connect" | "busy" | "close"): boolean

@returnsa boolean indicating whether an element with the specified value exists in the Set or not.

has
(
event: MessageEvent<any>
event
.
MessageEvent<any>.data: any

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
?.
any
type
)) return // the relay's own frames arrive here too, with a type that is not yours
const
const command: Command
command
=
event: MessageEvent<any>
event
.
MessageEvent<any>.data: any

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
as
type Command = {
type: "connect";
host: string;
port: number;
} | {
type: "busy";
} | {
type: "close";
}
Command
if (
const command: Command
command
.
type: "connect" | "busy" | "close"
type
=== 'busy')
const report: (message: Report) => void
report
({
type: "busy"
type
: 'busy',
reasons: string[]
reasons
:
(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
() }) // ['tcp socket (1)'] while the socket is open
if (
const command: Command
command
.
type: "connect" | "busy" | "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
const report: (message: Report) => void
report
({
type: "closed"
type
: 'closed' }) // the page waits for this before it terminates the worker
}
if (
const command: Command
command
.
type: "connect" | "busy" | "close"
type
!== 'connect') 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 report: (message: Report) => void
report
({
type: "connected"
type
: 'connected',
port: number
port
:
const opened: net.Socket
opened
.
Socket$2.remotePort: number

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

@sincev0.5.10

remotePort
}) // runs once the relay has answered, and not before
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
) =>
const report: (message: Report) => void
report
({
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') })) // the response head first, then the catalog
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
) =>
const report: (message: Report) => void
report
({
type: "error"
type
: 'error',
message: string
message
:
error: Error
error
.
Error.message: string
message
})) // a refused connect lands here, and nothing else fires
let socket: net.Socket | undefined
socket
=
const opened: net.Socket
opened
})

connect hands back the Socket at once and runs the listener only when the relay has answered, so the connected report is the first sign that the socket is open. The data handler posts one report per chunk, and the error handler catches a refused connect, which emits error and nothing else. The busy command answers with the worker’s own tally, which check it worked relies on. The engine uses Node’s Buffer, which a browser bundle has to shim in the worker chunk as well as on the page, see install and bundle an app that uses sockets.

Inside a worker the library’s transport is { receive: self, emit: self }, inert until the page relays it, so every call parks with no listening, no error and no rejection. Only the 8,000 ms deadline on connect, listen and bind turns that wait into an error. That state is explained on workers.

Which realm holds the connection is what decides between an engine that works and one that waits. The page acquires a broker connection, over a port from a parent FKN realm or by mounting the broker frame, the hidden fkn.app iframe the library creates. The dashed relayWorker arrow lets the worker ride that connection, and the worker whose arrow points at itself is the same worker with nobody relaying it:

flowchart TD
  realm["Your page"] --> ask{"Parent answers?"}
  ask -->|"port and ack"| port["MessagePort"]
  ask -->|"silent for 2s"| mount["Mount /api frame"]
  mount --> commit["Wait for commit"]
  commit --> pair["Window pair"]
  port --> expose["expose fkn-api"]
  pair --> expose
  expose --> queue["Connection queue"]
  queue --> facade["Epoch facade"]
  facade --> promise["apiPromise"]
  worker["Relayed worker"] -.->|"relayWorker"| expose
  parked["Unrelayed worker"] -.->|"self to self"| parked

Only the page’s realm holds the connection. Everything above the workers is the page’s own broker, described on how it works, and the 2 s it waits for a parent is PARENT_ANSWER_MS. The unrelayed worker connects to nothing, and only a socket call ever reports it.

The page creates the worker, relays it, and only then sends the first command. relayWorker is async, so await it:

app.ts
import type {
type Command = {
type: "connect";
host: string;
port: number;
} | {
type: "busy";
} | {
type: "close";
}
Command
,
type Report = {
type: "connected";
port: number;
} | {
type: "chunk";
text: string;
} | {
type: "error";
message: string;
} | {
type: "busy";
reasons: 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, and the engine's socket calls now travel over this page's broker
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: "connected";
port: number;
} | {
type: "chunk";
text: string;
} | {
type: "error";
message: string;
} | {
type: "busy";
reasons: 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: "busy" | "connected" | "chunk" | "error" | "closed"
type
=== 'connected')
event: MessageEvent<Report>
event
.
MessageEvent<Report>.data: {
type: "connected";
port: number;
}

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

MDN Reference

data
.
port: number
port
// 80, the connect callback ran inside the engine
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: "busy" | "connected" | "chunk" | "error" | "closed"
type
=== 'chunk')
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 head, then the catalog
})
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: "busy";
} | {
type: "close";
}
Command
)

relayWorker forwards the library’s channel both ways between this page’s broker transport and the worker, and it resolves once that relay is registered, not once the engine has connected. The engine’s first socket call still waits for the broker, bounded by the deadline above. Its options and its token are on workers.

relayWorker refuses in two conditions, and both arrive as a rejection of the promise it returns. Called from inside a worker, it rejects with FKN @fkn/lib: relayWorker must be called from the main thread. Called on a page whose realm holds no broker transport, for example after the broker frame left the document, it rejects with FKN @fkn/lib: relayWorker found no FKN transport in this realm.

A rejection you drop leaves an engine whose every socket call waits and then fails, with nothing on screen to say why. Catch it, show it, and send the first command only once the relay is registered:

app.ts
try {
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
})
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: "busy";
} | {
type: "close";
}
Command
) // sent only once the relay is registered
} catch (
var error: unknown
error
) {
const {
const message: string
message
} =
var error: unknown
error
as
interface Error
Error
const message: string
message
.
String.startsWith(searchString: string, position?: number): boolean

Returns true if the sequence of elements of searchString converted to a String is the same as the corresponding elements of this object (converted to a String) starting at position. Otherwise returns false.

startsWith
('FKN @fkn/lib: relayWorker') // true for both refusals, and the rest of the sentence says which
const showFailure: (message: string) => void
showFailure
(
const message: string
message
) // shown, where a dropped rejection would leave the engine waiting
const engine: Worker
engine
.
Worker.terminate(): void

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

MDN Reference

terminate
() // nothing it opens would reach the relay
}

Both refusals are minted on the page, so the whole error reaches you and message is the sentence to show. Neither is worth a retry: the first needs the call moved to the page, and the second needs a page that holds a broker. How an error crosses a realm, and which fields survive, is on handling errors.

The relay holds the busy token relayed worker from the moment it is registered until unregisterSignal aborts. A busy token is one of the reasons a realm reports itself busy, and shell.busyReasons() lists the ones this realm holds. Tear down in order: close the socket inside the engine, abort the signal, then terminate the worker, because terminate() ends the worker at once and a close command posted just before it may never run:

app.ts
(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)'], the socket is in the engine's own tally
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: "connected";
port: number;
} | {
type: "chunk";
text: string;
} | {
type: "error";
message: string;
} | {
type: "busy";
reasons: 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" | "connected" | "chunk" | "busy" | "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: "busy";
} | {
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
() // [], released before abort() returned
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

The token goes before abort() returns. A relay you never abort forwards for the life of the page and holds its token the whole time, so keep the controller next to the worker and abort it wherever you terminate. Every holder of a token is listed on connection and lifecycle.

The relay forwards its frames over the same message event your commands and reports use, in both directions, and each frame carries a type of its own. A listener that treats every message as one of yours reads those frames as commands. Both sides check an allow list first, the engine with COMMANDS above and the page like this:

app.ts
const
const REPORTS: Set<"connected" | "chunk" | "error" | "busy" | "closed">
REPORTS
= new
var Set: SetConstructor
new <"connected" | "chunk" | "error" | "busy" | "closed">(iterable?: Iterable<"connected" | "chunk" | "error" | "busy" | "closed"> | null | undefined) => Set<"connected" | "chunk" | "error" | "busy" | "closed"> (+1 overload)
Set
<
type Report = {
type: "connected";
port: number;
} | {
type: "chunk";
text: string;
} | {
type: "error";
message: string;
} | {
type: "busy";
reasons: string[];
} | {
type: "closed";
}
Report
['type']>(['connected', 'chunk', 'error', 'busy', 'closed'])
const
const isReport: (data: unknown) => data is Report
isReport
= (
data: unknown
data
: unknown):
data: unknown
data
is
type Report = {
type: "connected";
port: number;
} | {
type: "chunk";
text: string;
} | {
type: "error";
message: string;
} | {
type: "busy";
reasons: string[];
} | {
type: "closed";
}
Report
=>
typeof
data: unknown
data
=== 'object' &&
data: object | null
data
!== null &&
const REPORTS: Set<"connected" | "chunk" | "error" | "busy" | "closed">
REPORTS
.
Set<"connected" | "chunk" | "error" | "busy" | "closed">.has(value: "connected" | "chunk" | "error" | "busy" | "closed"): boolean

@returnsa boolean indicating whether an element with the specified value exists in the Set or not.

has
((
data: object
data
as
type Report = {
type: "connected";
port: number;
} | {
type: "chunk";
text: string;
} | {
type: "error";
message: string;
} | {
type: "busy";
reasons: string[];
} | {
type: "closed";
}
Report
).
type: "connected" | "chunk" | "error" | "busy" | "closed"
type
)
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<any>
event
:
interface MessageEvent<T = any>

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

MDN Reference

MessageEvent
) => {
if (!
const isReport: (data: unknown) => data is Report
isReport
(
event: MessageEvent<any>
event
.
MessageEvent<any>.data: any

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
)) return // the relay's frames stop here, and so does anything else that is not yours
if (
event: MessageEvent<any>
event
.
MessageEvent<any>.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: "connected" | "chunk" | "error" | "busy" | "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<any>
event
.
MessageEvent<any>.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
) // yours, one entry per chunk
})

The relay attaches its own listener with addEventListener, so yours sits beside it and both receive every message. The check costs one lookup per message and keeps the relay’s traffic out of your handlers. The channel itself, one connection per realm on the key fkn-api, is described on how it works.

The engine posts connected, then a chunk carrying the response head. Send it the busy command and it answers ['tcp socket (1)'] while the socket is open. The page’s own shell.busyReasons() answers ['relayed worker (1)'] and never lists the socket, because the tally is per realm. When that is not what you see:

  1. The page’s list shows no tcp socket. That is correct even when the socket is open: the page cannot see the engine’s tally, so ask the engine.
  2. No connected report arrives, and after 8,000 ms the engine reports @fkn/lib: no broker connection within 8000ms, so an outbound tcp socket could not be requested. No broker connection reached the worker in time. The usual cause is a page that never called relayWorker, or called it only after the deadline had passed. The other is a page whose own broker never connected, and the same connect made on the page then fails the same way.
  3. Nothing arrives at all, and no error either. The engine’s first call is one with no deadline, dns.lookup, cloud.fetch or cloud.fs, and it is waiting for a broker the worker does not have. Put the socket call first, or bound the wait as the optional step below shows.
  4. relayWorker itself rejected. The message names the condition, and neither is worth a retry.

Every later socket call in a worker that missed the deadline once waits only 1,000 ms, so a second failure arrives fast rather than after another long wait.

dns.lookup, cloud.fetch and cloud.fs wait for the broker with no deadline, so an engine that resolves a name before it connects never reaches the socket call that would have reported the missing relay. apiWithin from @fkn/lib/api is the bounded wait the socket calls use, and the engine can call it first:

engine.ts
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
('the engine start') // resolves once the page has relayed this worker and the broker answered
} 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 the engine start could not be requested
const report: (message: {
type: "error";
message: string;
}) => void
report
({
type: "error"
type
: 'error',
message: string
message
:
var error: BrokerUnreachableError
error
.
Error.message: string
message
}) // after 8 seconds, where a lookup would have waited forever
}
}

The rejection lands after 8,000 ms, and after 1,000 ms for every later apiWithin in the worker once one deadline was missed. The phrase you pass is the one the message repeats after so. BrokerUnreachableError is created in the worker, so instanceof works there, and the page sees only what the engine reports.