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.
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:
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.
The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.
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
Destroy the stream. Optionally emit an 'error' event, and emit a 'close' event (unless emitClose is set to false). After this call, the readable
stream will release any internal resources and subsequent calls to push() will be ignored.
Once destroy() has been called any further calls will be a no-op and no
further errors except from _destroy() may be emitted as 'error'.
Implementors should not override this method, but instead implement readable._destroy().
@since ― v8.0.0
@param ― error Error which will be passed as payload in 'error' event
destroy() // ends the socket and releases its busy token
constreport: (message:Report) =>void
report({
type: "closed"
type: 'closed' }) // the page waits for this before it terminates the worker
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).
@since ― v0.5.10
remotePort }) // runs once the relay has answered, and not before
The writable.write() method writes some data to the stream, and calls the
supplied callback once the data has been fully handled. If an error
occurs, the callback will be called with the error as its
first argument. The callback is called asynchronously and before 'error' is
emitted.
The return value is true if the internal buffer is less than the highWaterMark configured when the stream was created after admitting chunk.
If false is returned, further attempts to write data to the stream should
stop until the 'drain' event is emitted.
While a stream is not draining, calls to write() will buffer chunk, and
return false. Once all currently buffered chunks are drained (accepted for
delivery by the operating system), the 'drain' event will be emitted.
Once write() returns false, do not write more chunks
until the 'drain' event is emitted. While calling write() on a stream that
is not draining is allowed, Node.js will buffer all written chunks until
maximum memory usage occurs, at which point it will abort unconditionally.
Even before it aborts, high memory usage will cause poor garbage collector
performance and high RSS (which is not typically released back to the system,
even after the memory is no longer required). Since TCP sockets may never
drain if the remote peer does not read the data, writing a socket that is
not draining may lead to a remotely exploitable vulnerability.
Writing data while the stream is not draining is particularly
problematic for a Transform, because the Transform streams are paused
by default until they are piped or a 'data' or 'readable' event handler
is added.
If the data to be written can be generated or fetched on demand, it is
recommended to encapsulate the logic into a Readable and use
pipe
. However, if calling write() is preferred, it is
possible to respect backpressure and avoid memory issues using the 'drain' event:
functionwrite(data, cb) {
if (!stream.write(data)) {
stream.once('drain', cb);
} else {
process.nextTick(cb);
}
}
// Wait for cb to be called before doing any other write.
write('hello', () => {
console.log('Write completed, do more writes now.');
});
A Writable stream in object mode will always ignore the encoding argument.
Writes data to the stream, with an explicit encoding for string data.
Adds the listener function to the end of the listeners array for the
event named eventName. No checks are made to see if the listener has
already been added. Multiple calls passing the same combination of eventName
and listener will result in the listener being added, and called, multiple
times.
server.on('connection', (stream) => {
console.log('someone connected!');
});
Returns a reference to the EventEmitter, so that calls can be chained.
By default, event listeners are invoked in the order they are added. The
emitter.prependListener() method can be used as an alternative to add the
event listener to the beginning of the listeners array.
Adds the listener function to the end of the listeners array for the
event named eventName. No checks are made to see if the listener has
already been added. Multiple calls passing the same combination of eventName
and listener will result in the listener being added, and called, multiple
times.
server.on('connection', (stream) => {
console.log('someone connected!');
});
Returns a reference to the EventEmitter, so that calls can be chained.
By default, event listeners are invoked in the order they are added. The
emitter.prependListener() method can be used as an alternative to add the
event listener to the beginning of the listeners array.
message })) // a refused connect lands here, and nothing else fires
let socket:net.Socket|undefined
socket=
constopened: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 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.
The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.
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.
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:
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.
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
constshowFailure: (message:string) =>void
showFailure(
constmessage:string
message) // shown, where a dropped rejection would leave the engine waiting
constengine:Worker
engine.
Worker.terminate(): void
The terminate() method of the Worker interface immediately terminates the Worker.
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) namespaceshell
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
constclosed: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.
@param ― executor A callback used to initialize the promise. This callback is passed two arguments:
a resolve callback used to resolve the promise with a value or the result of another promise,
and a reject callback used to reject the promise with a provided reason or error.
The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.
abort() // the relay ends, and its token goes with it
(alias) namespaceshell
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
constengine:Worker
engine.
Worker.terminate(): void
The terminate() method of the Worker interface immediately terminates the Worker.
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:
The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.
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:
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.
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.
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:
message// @fkn/lib: no broker connection within 8000ms, so the engine start could not be requested
constreport: (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.