@fkn/lib gives a browser page sockets, a file system and a way into other sites, and a boundary that wide cannot be hidden completely. This page lists the places where it stays visible to you, one surface at a time, with the reason and the workaround where there is one.
Some of these limits are caught by TypeScript before your code runs. The rest you meet at runtime as a refusal with a message:
frame.click() // the element members come after the first locator()
Error ts(2339) ― Property 'click' does not exist on type 'Frame'.
The first line is the shape every frame action takes: a chain that starts with locator(). The second line is the compile error for skipping it. Every refusal below links to its row on every error, and every number links to its row on limits and timeouts.
The cloud path never carries the user’s session: cloud.fetch sends the request through the proxy, the FKN service that fetches on the page’s behalf, and forwards no credentials.
The root fetch with credentials: 'include' runs only through the extension and never falls back to the cloud path. A call that needs the user’s cookies is refused when the extension is missing rather than sent anonymously:
A string indicating whether credentials will be sent with the request always, never, or only when sent to a same-origin URL. Sets request's credentials.
credentials: 'include',
reason?: string |undefined
reason: 'Load your profile' })
} catch (
var error:unknown
error) {
(
var error:unknown
erroras
interfaceError
Error).
Error.message: string
message// 'The FKN WebExtension is not installed, enabled or not exposed on this page.', and nothing went out
}
The call rejects with the not-installed message and sends nothing.
On the cloud path a redirect is data: the proxy follows none, and redirect is not forwarded. The response is rebuilt twice on the way back. url is '', redirected is false, and statusText is '' unless the upstream status matched the transport’s, see cloud.fetch().
A Request passed to cloud.fetch has already lost its Cookie, Origin and Referer headers. The browser drops those names when the Request is built. Pass a string URL with init.headers when one of them has to travel, see headers the page cannot set.
An extension store build up to 0.1.54 loses its link to its own service worker once Chrome stops that worker for being idle, so every later extension.fetch, cookie read, header rule, and attach or goto() on cookies: 'native' stays pending until the page reloads. The next store release reconnects instead, and rejects only a call the stop cut off, with BackgroundStoppedError, see a call the extension never finished.
The extension path buffers the whole request body, so there is no streaming upload there. redirect: 'manual' on that path answers Response.error(), with status 0 and type 'error'. set-cookie is readable on neither path, because the browser drops it from every Response an app holds.
The cloud path reaches public addresses only. The proxy answers 403 egress refused (non-public target) to anything else. A local network target belongs to the extension path, behind its own consent, see local network targets.
cloud.fetch and promptInstall have no deadline. Both wait on the broker connection, and init.signal bounds only the transfer after the call reached the broker. To bound the wait itself, write your own race, shaped like apiWithin from @fkn/lib/api, see connecting.
The relay is the FKN service that holds the real socket at the far end of net and dgram. It carries TCP and UDP and nothing above them. http is HTTP/1.1 in the clear, and there is no https entry at all:
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.
https: changes only the default port, so a TLS server cannot answer the request. Use cloud.fetch for an HTTPS origin, and keep http for a plaintext service you control, see no https.
A URL string names its port or loses it. The library copies the URL’s port as given, empty when there is none, so http.get('https://example.org/api/catalog.json') sends Host: example.org:0 and dials port 0. Name the port in the URL, or pass the options form as above.
The relay reaches public addresses only. A loopback or wildcard target pairs with a net.Server listening in the same data plane, the shared worker behind the broker that the pages of one origin share. Any other non-public target is refused with webvpn: egress to a non-public address refused. The relay may refuse a port too, and the error message names the reason.
bytesRead, bytesWritten, bufferSize, connecting, pending and readyState on a Socket, and connections, maxConnections and listening on a Server, are declared and never assigned. The address getters throw Socket is not connected before the relay answered, where Node answers undefined. Server.getConnections throws Method not implemented., ref and unref do nothing, setTypeOfService sends nothing, and there is no TCP setTTL.
new dgram.Socket() throws Missing options, so build sockets with createSocket. A send callback means the datagram was handed to the transport, not that it was delivered: the broker drops what it cannot deliver and reports nothing back. The buffer size getters echo your last request, the send queue getters are always 0, and the multicast interface and source-specific memberships do nothing, see UDP.
dns is lookup alone, with no cache and no deadline. A name with no answer resolves undefined, or [] with all: true, rather than rejecting. The socket resolvers turn that into getaddrinfo ENOTFOUND <hostname> on the error event, see dns.lookup().
No code survives the broker hop. Match the socket errors and the relay’s refusals on error.message, see handling errors.
There is no synchronous network, so cloud.fs has no *Sync member, and a synchronous call on it is a compile error. It also leaves out appendFile, exists, mount, flush, remount and pull, see cloud.fs is async only. On the hybrid fs, the synchronous forms read the in-memory layer. mount() fills that layer, and only remount() refreshes it:
readFileSync('library/catalog.json', 'utf8')) // the account's bytes, now in the sync layer
The first read answers the old bytes. The account is the FKN identity a person carries between sites, and a local copy always wins on read, because fs asks the account only when OPFS has nothing at the path. pull writes OPFS alone, so another device’s write stays invisible until pull() plus remount(), see the read rule.
mount() loads the whole scope into memory, and a path that vanished stays there until the page reloads. An incomplete mount is re-run by the next asynchronous call after 5 seconds.
None of fs, cloud.fs or opfs carries every Node fs member. The ones all three leave out are listed on what is covered. withFileTypes is ignored rather than refused: the callback readdir drops its options, so you get names rather than Dirents.
Directories live in memory only, so an empty directory does not survive a reload. A delete does not reach another device, because there are no tombstones, see deletes.
flush() cannot fail: it catches every backing failure and resolves. The account copy is the replicated copy of a file in the account, and only cloud.fs.writeFile resolves after the account copy is durable. A hybrid write resolves once OPFS has it and replicates in the background.
The account holds objects rather than a tree. cloud.fs.rename is a read, a write and a delete, and it is not atomic. mkdir there does nothing, rmdir is rm, and stat and readdir list the whole scope on every call.
The library sends a cloud.fs path as given on readFile, writeFile, unlink and a non-recursive rm, so write it relative. fs and opfs normalise their paths for you, see scope and paths.
A cloud write while the api is unreachable is refused with storage: api unreachable rather than guessed, because a write needs the api for the key state. An export or import of the account’s data belongs to fkn.app and is not an app’s to run.
The library rebuilds a StorageNotFoundError for a read or a write of a missing path, and not for unlink. On a delete of a missing path, test the message rather than isNotFound().
A worker realm has no localStorage, so the hybrid’s pending write queues do not persist there and no timer is armed for the drain, the background process that replicates queued writes to the account. OPFS itself works in a worker, see what works in a relayed worker.
An attached frame, the Frame that attachFrame returns, reports what you asked of it and never where the embedded page went on its own. A backend is the place a call runs: the cloud, the extension or the desktop. A frame runs on the first two, and url() is the URL the app last passed to attachFrame or goto() on either of them. A locator action is a synthetic event rather than a real pointer:
Navigates the frame to url, which must pass the rules an iframe src does, and adds its host
to the attachment. Resolves at options.waitUntil, and rejects with TimeoutError past
options.timeout (GotoOptions).
goto('https://example.org/')
constframe:Frame
frame.
functionurl():string
The url last given to attachFrame or goto, never the frame's live location: a page that moves itself does not change it.
url() // 'https://example.org/', whatever the page navigated to since
The click in the middle is synthetic: the pointer and mouse events land at the centre of the element, isTrusted is false, and nothing scrolls or takes focus.
The last two lines show the selectors, which are narrower than Playwright’s. getByRole(role) matches an explicit role attribute only. getByText(text) keeps ancestors as well, so a container matches along with its control.
A locator chain, a Locator built by chaining selectors, has no waitFor, press, type, selectOption, check, screenshot, evaluate, innerText, boundingBox, getByLabel or getByPlaceholder, and no exact or hasText filter. evaluate() and addScriptTag() run on the attached Frame only, never on a locator or a nested frameLocator(). isVisible() checks styles and the box only, so opacity, clipping and occlusion are not considered. videoElement() answers only for a <video>, see video.
addStyleTag strips url(), @import, @font-face and the other image functions and at-rules. CSS the parser rejects injects nothing, silently. noSanitize: true skips that sanitiser. A permission key is one entry in the extension’s permission model, and noSanitize costs the key media.appearU, the only member of Evaluation, which prompts on both backends, see styling the page.
frame.fetch is not streamed: the whole body arrives in one ArrayBuffer, and there is no signal.
From the first extension store release after 0.1.54, browser rules in your app’s tab block an attached site’s requests to FKN hosts, and those of the shared and service workers it starts, see what is refused. Three fall outside them: on Chromium, a WebSocket a shared worker opens, which those rules never see; a shared worker started from a data: url, which names no site to match; and a service worker the site registered, once your document navigated and retired the rules. None of them holds a capability of the extension’s, so each reaches only what a page of its own could.
The two backends differ where the machinery does. Both ask for the same four categories of access, the extension through its consent sheet and the cloud through the broker’s card:
A request that passes those two checks asks the broker for consent. What the render proxy answers after the card is its own, so read the error rather than assuming the path is open, see fetching as the frame.
An allowed category on the card is remembered until the person removes it from the FKN bar. The cloud backend stores no denial and writes no activity log. The render proxy checks the stored grant for every gated operation, so the card is asked for by @fkn/lib and enforced behind the call whichever way the operation arrives.
On the extension backend, lockdown needs domains or a later goto(). cookies: 'native' copies nothing without domains or a goto(), so a preloaded iframe attached bare starts logged out. A blank iframe has no child document to answer until a goto(). The cloud backend refuses an iframe that is already attached, see what is refused.
A consent refusal arrives as an Error named PermissionDeniedError, reading Permission denied: <category> on <site> (<key> <scope>). Neither isLocatorDenied nor isTerminalError matches it, so compare error.name or use isPermissionDenied.
A category ask needs hosts, and permissions.request throws a TypeError without them. The pre-category { key, scope } form is still forwarded to any extension version, and a locator key scoped to a selector answers allow: false there, since a selector names no site, with '*' and no scope at all the exception: those name the whole document and are asked on your own page’s host. A sheet your own UI covers is dismissed as a deny once, after three occlusion misses, and no member reads or revokes a grant, see the activity log.
A timeout is a TimeoutError carrying the last attempt’s message, which is No elements found for a missing element. A missing selector and a slow page therefore look the same, see the locator timeout.
A window, attachFrame({ window }), opens on the cloud backend on 'persistent' and 'ephemeral'. On cookies: 'native' it is a real browser window on the extension from the extension’s first store release after 0.1.54, and every store build through 0.1.54 refuses it by name with ExtensionOperationUnsupportedError, operation 'attachWindow'. An iframe attached on 'native' holds the person’s own browser session rather than your cloud jar, so a sign-in in a cloud window never reaches it, where one in an extension window does, see a window on the extension.
An extension window is known by its tab, so it keeps answering after its page cuts the link to yours, but postMessage then cannot reach it. An extension installed moments ago, whose worker never ran, can miss the window and reject the attach after 2 seconds, closing it; the next attach is adopted. The window keeps the page’s content security policy, so evaluate is refused on a page that forbids eval.
The window opens on the activation of the event that called attachFrame, so an await before the call, the popup blocker, or a frame sandboxed without allow-popups gets FrameWindowBlockedError. A page served with Cross-Origin-Opener-Policy: same-origin cannot keep its window at all, where same-origin-allow-popups can. A page with an opaque origin, a file: page or a frame sandboxed without allow-same-origin, is refused before anything opens.
Only cookies travel between a window and an app outside the fkn.app site. The proxied site’s localStorage and IndexedDB in the window are kept per window and cleared when it closes, so a site that keeps its session in storage signs the user in to the window alone. While a window is open the library keeps a hidden fkn.app iframe in your page to reach your jar, and a framework that replaces the page’s children takes it along: the console warns, and a sign-in in the window no longer reaches your jar.
The 'persistent' jar is sealed at rest for everyone, under a key the render proxy generates for that jar and keeps beside it on the device, and three limits come with it.
A copy of the device’s files holds the key as well as the jar, since the browser writes even a non-extractable key to disk, so it opens the jar. Where an earlier build kept the jar in the clear, Chrome can keep that text in its files for a time after the seal, where Firefox removes it at once. The proxied site’s storage and the jar a running frame holds in memory are not sealed, see the persistent jar on this device.
Install a package for this app behind an FKN-rendered confirm, or with { noConfirm: true } for a notice instead of a prompt. Resolves null when the user declines.
Show an installed, connected package's frame over this page, aligned to element (or an explicit
rect). The package renders its own UI there; the app keeps the space in its own layout. Take it
back down with hide() on the returned view, or with packages.hide(uri). Throws a PackagesError
with code 'not-installed' when the package has not been connected by this app.
A placeholder the package frame is aligned to for as long as the view lives. The frame tracks its
rect every animation frame, is clipped by its scrolling ancestors, and follows its border-radius,
so it reads as inline content even though it renders in FKN's overlay.
element:
constslot:HTMLElement
slot }) // rejects with code 'not-installed' until connect() ran
The record comes back without a version, with the pin beside it. show() needs a live connect() by this app, see showing a package’s frame. search and pick query npm and nowhere else, cut free text to 128 characters, and offer no pagination beyond size. pick returns only the packages that installed.
The broker derives who is calling from the browser-set origin of the connection, never from a string you pass. There is no update or repin member. install returns the existing record without a prompt, so install again with a version to change the pin.
show() cannot layer or z-order the frame, and cannot receive events from it. With rect alone it tracks nothing, and it drops a percentage corner radius. A package cannot place its own frame: the broker answers packages.show: a package cannot place its own frame.
mount() writes only src, never your sandbox or allow. A sandbox that is too tight is refused by name up front, and allow cannot be added after navigation. A boot failure under mount() arrives as a 'timeout', packages.mount: the package did not register a connection handler, and not as the package’s own message. That message never reaches an iframe you mounted yourself, see mounting into your own iframe.
closed is the only reconnect signal, and it does not settle after a failed handshake. A retry is yours to schedule. onConnect in a top-level window does nothing, accepts a port from window.parent only, and cannot run in a worker, where attach() is the worker’s half, see answering from the package and attaching in a worker.
info.from and info.protocol are asserted by the embedder and are not proof of who is calling. The protocol tag, cut to 64 characters, is the only versioning a connection has.
A package’s account.info() and login() act on the connection of the host app, the app that installed and connected to the package. Its quota(), cloud.fs and cloud.fetch meter and store under the package’s own scope. A package cannot applyUpdate(): the broker answers false to a nested caller.
PackagesError.code survives only because the library rebuilds the error in your realm from the broker’s structured refusal. Nothing else crosses the hop with a code, see handling errors.
A room is a realtime channel several browsers open by name or join from an invite the app shares. What the platform relays is ciphertext, and a room nobody has claimed keeps none of it:
the room key, base64url. The server never sees it. Anyone holding it and the id can join.
key// minted in this browser, and never sent to the platform
await
constroom:rooms.Room
room.
members: () =>Promise<rooms.RoomMember[]>
members() // the members present now, and nobody who was here before you joined
constroom:rooms.Room
room.
mailbox: rooms.RoomMailbox |null
what the mailbox holds, as the room last said. Null while the room keeps no messages: nobody has
claimed it, or its claim keeps none. The account can clear the room, unclaim it, turn its messages
off or on, or set a lower cap from its fkn.app settings, which the app hears as a deleted or a
claim event; usage() reads the figures again.
mailbox// null until the room is claimed with a mailbox, so nothing sent before you joined can be replayed
A room nobody has claimed stores nothing it relays, so a joiner sees nothing sent before it arrived. A claim keeps a mailbox unless it asks for none, and it needs a signed-in premium account. A room keeps its state in its own storage, so a platform deploy or restart loses none of it and the broker rejoins within the hold, see reconnecting.
A name is scoped to your app unless it starts global/, so another app cannot open your room by name, and two apps holding one invite still share one room. The platform never holds a key, so it cannot hand one back: lose the key of a room that is still in use, or of a claimed one, and every join without it is refused.
A guest is blocked by the tab and by the network, so closing the tab and changing network makes a new visitor, and the network half also reaches a bystander on the same address. A guest owner’s identity lives in the tab: close it and nobody can grant or revoke in that room again, although members already holding remove or block keep acting. Ownership moves only with a claim, which makes the claiming account the owner, acting through the app that claimed the room unless it is a global/ one (who changes a claimed room), and a member id never changes silently: a rejoin that would seat you as someone else reports the room closed instead, see reconnecting.
The platform cannot read a message, so moderation is the owner’s: live in any room, and with delete in a claimed one that keeps messages. Rooms are cloud only: every other capability can run against your own machine, and a rendezvous between strangers cannot. A free account’s or a guest’s room bytes count toward the daily volume and a premium account’s are not metered, see limits.
The library needs a window to draw in and a broker to talk to. A realm, one JavaScript execution context such as a window or a worker, loses a known set of members when it lacks one of them. In a worker the page relayed with await relayWorker(worker, { unregisterSignal }), everything that needs only the broker works, and what needs a document does not:
What a shell reload would sever right now, as human-readable reasons, empty when nothing is bound
to the broker connection: open sockets and listening servers, a streaming proxy response, a mounted
package, a live frame attachment, unflushed write-behind. Empty is not a promise that a reload is
free, only that this realm holds nothing the lib knows about.
It is per REALM: a worker that imports @fkn/lib/net keeps its own tally, which the window cannot
see. An app whose transfers live in a worker should ask the worker, not the page.
busyReasons() // [], this worker's own tally, which the page never sees
busyReasons() still answers, with this realm’s own tally. The table sorts the rest:
Realm
Works
Does not
a window
everything
a worker the page relayed
the sockets, the cloud calls, fs and opfs, the broker-routed packages.* and rooms.* calls, busyReasons()
anything that draws, asks, or attaches a frame
a worker nobody relayed
opfs, the hybrid fs on its local half, the local helpers
net.connect, server.listen, socket.bind, and everything else that reaches the broker
Node
the type-only entries and the local helpers
apiPromise never settles
A few of these deserve more detail than the table can carry.
The broker frame is the hidden fkn.app/api iframe the library talks to FKN through. In a window, importing any entry other than opfs, opfs/promises, react, messages, contract, wire, attach-policy and desktop mounts it as soon as the module is evaluated, see entry points. The mount needs document.body, so import the library from a module script or after the body exists.
A frame you created yourself is adopted only when its src matches byte for byte, ?coi=1 included on a cross-origin-isolated page. It is taken as built, so give it allow="cross-origin-isolated" there yourself, see the broker frame.
The same frame is the overlay that draws the cards. Its styles on iframe[title="FKN"] belong to the library, and an ancestor with transform, filter, opacity below 1, visibility: hidden or display: none breaks the clip. The library reports a card it cannot show to the console once per kind and enforces nothing, see the overlay projector.
In a worker nobody relayed, net.connect, server.listen and socket.bind are the only calls that give up with an error. They fail on the error event with a BrokerUnreachableError after 8 seconds and then 1 second per call. The storage availability probe is bounded too, at 8 seconds and then 1 second, so a hybrid fs call settles on its local half once the probe answers 'unknown'. Everything else that reaches the broker waits for as long as the worker lives.
cloud.available() and hasTransport are true in any window or worker, connected or not, and false in Node. Neither is a health check, see what available() means.
An error crossing the broker keeps only name, message, stack and cause. Every error class the library hands you was created in your own realm. The rest is matched by name or message, see handling errors.
A pinned version of the library keeps working against a newer broker, because the capability logic lives in the broker and the library passes calls through. Against an older broker it works because the facade answers a property read against the connected broker, so every member that arrived later is probed before it is called, see version compatibility.