You can publish a package, an npm module that FKN loads on a sandbox origin of its own, that any host app installs by uri, connects to and shows. This page covers the one-file bundle, answering connections with onConnect, drawing only while the host is showing you, fetching and storing on the package’s own metering, and the manifest keywords that make the package findable.
The end state is a published package a host app installs by uri, connects to, and shows. What the recipe costs:
The host app is the app that installs and connects to a package. The tenant is the realm your package runs in: one JavaScript execution context, on an origin of its own, with its own broker, the connection a realm holds into FKN. The [Build] and [Package] badges are defined on recipes.
The package on this page is npm:@example/subtitles-plugin, the subtitle source the media library installs, and its code lives in package.ts. The host’s side of the same contract is on install a package and show its UI.
The broker reads main from your manifest, and the tenant loads that one path as a module script and resolves nothing else. Every dependency, @fkn/lib included, has to be inside that file. Vite’s library mode produces exactly that, and fkn() supplies the buffer, events and stream shims the library’s root imports:
Compatibility transform target. The transform is performed with esbuild
and the lowest supported target is es2015. Note this only handles
syntax transformation and does not cover polyfills
Default: 'baseline-widely-available' - transpile targeting browsers that
are included in the Baseline Widely Available on 2026-01-01.
(Chrome 111+, Edge 111+, Firefox 114+, Safari 16.4+).
Another special value is 'esnext' - which only performs minimal transpiling
(for minification compat).
Build in library mode. The value should be the global name of the lib in
UMD mode. This will produce esm + cjs + umd bundle formats with default
configurations that are suitable for distributing libraries.
The name of the package file output. The default file name is the name option
of the project package.json. It can also be defined as a function taking the
format as an argument.
fileName: () =>'index.js' }, // one file, dist/index.js, with every dependency inside it
},
})
The build writes one ES module to dist/index.js, and the manifest in the last step points main at it.
Without the plugin, the library’s root pulls in Node’s stream, which Vite leaves out of a browser bundle with a warning. The build succeeds, and the bundle throws when the tenant evaluates it, before onConnect has run. The host app then sees a connect timeout and nothing names the cause, which check it worked walks through.
Narrow subpaths such as @fkn/lib/packages keep the bundle smaller, and the plugin covers the root where a step below needs it. What the plugin wires is on @fkn/vite-plugin, and what a bundler has to supply without it is on install.
onConnect(createPayload, handler?) from @fkn/lib/packages serves the host apps that connect to your package. Call it during evaluation: the first call announces the package as ready, and a host waits for that announcement up to 30 seconds before it gives up. The first argument runs once per incoming connection and returns what that host sees as its remote. The second argument is the handler, which receives the same info plus the host’s own payload:
Serve connections from apps that installed this package. The first argument is called once per
incoming connection with the connection info and returns the payload exposed to that app (its
remote). The latest registration receives new connections; existing connections are unaffected.
Serve connections from apps that installed this package. The first argument is called once per
incoming connection with the connection info and returns the payload exposed to that app (its
remote). The latest registration receives new connections; existing connections are unaffected.
}) // the returned unsubscribe() stops new connections, and the open ones keep running
protocol is the only versioning the connection has. The host passes it to connect(), the broker cuts it to 64 characters, and it arrives here as info.protocol, so name your contract in it and refuse the rest. A throw inside createPayload refuses the connection: the host rejects with packages.connect: the package refused the connection carrying your error’s message, under 'unavailable', and the package logs a warning.
createPayload may be async. The payload is exposed only once it resolves, and the host’s handshake clock of 30 seconds runs meanwhile, so keep it quick and do the slow work in the handler. The fields on the info, and what a mounted host sends instead, are on answering from the package.
A package starts hidden. Under show() the broker keeps your frame off screen until a host shows it, and isVisible() and onVisibilityChange(handler) say whether any host is showing it right now. The handler runs at once with the current state and then on every change, so a package that reads the first call as a dismissal gets it wrong:
package.ts
functionisVisible():boolean
True while a host app is showing this package's frame. Packages start hidden.
Observe whether a host app is showing this package's frame, so it can render its UI only while on
screen. The handler is called immediately with the current state, and on every change after.
drawPicker() // the frame is on screen now, so this is the moment to draw
return
}
if (
let shown:boolean
shown)
constsettlePicker: (choice:string|null) =>void
settlePicker(null) // the host hid a frame it had shown, which is a dismissal
let shown:boolean
shown=false
})
The first call arrives with false and draws nothing. The broker sends true when the first host shows the frame and false when the last one hides it, and a host that took the frame down mid-interaction is answered with null rather than left waiting.
Under mount the host sends true right after the port, since a frame in its own layout is on screen by construction. A handler that throws is swallowed. The host’s side of show and hide is on showing a package’s frame.
Inside a package the root fetch from @fkn/lib chooses its backend per call, the extension when the page carries its marker and the cloud otherwise, as on how the root fetch decides. This import is from the root, which the plugin in the first step makes safe to bundle:
fetch('https://example.org/api/catalog.json') // a Response, and the cloud path meters under this package rather than the host
constresponse:Response
response.
Response.ok: boolean
The ok read-only property of the Response interface contains a Boolean stating whether the response was successful (status in the range 200-299) or not.
Traffic that goes through the cloud meters under the package’s own scope, never the host app’s, and cloud.quota() inside the package reads that counter. Extension traffic is never metered. The metered volume and the rates are on account and quota.
@fkn/lib/cloud/fs/promises is the account file system, and inside a package it is keyed on the package rather than on the host app. Two host apps that install the same package therefore share one cache, written once:
readFile('library/catalog.json', 'utf8') // the same bytes from a second host app
Storage needs the account the host app connected. A package cannot connect one for itself, and account.info() inside it answers for the host. A write while that account is locked raises the unlock card inside the call and waits, and StorageLockedError arrives only once the card was dismissed or could not be shown, so a failed cache write on a first run is normal and costs only the cache, see locked. How objects are keyed and what the quota covers is on scope and paths.
packages.search and packages.pick ask the npm registry for packages carrying three keywords: fkn, fkn-type:<type> for the kind a host queries, and fkn-<type>--<id> for that host’s own scope. A host calling pick({ type: 'plugin', id: 'example' }) finds the manifest that carries all three, and type and id each match [a-z0-9][a-z0-9-]{0,31}:
main is the path the broker reads, and files is what makes npm publish ship it. The host installs npm:@example/subtitles-plugin, pinned to the latest dist-tag unless it names a version. npm:<name>@<version> has to fit the sandbox origin label, roughly 40 characters of it, so a long name is refused as 'unaddressable'. The search index can lag a publish by hours, and install resolves the version from the registry again, see finding packages.
The host rejects with packages.connect: '<uri>' did not register a connection handler, code 'timeout', after 30 seconds, and nothing in its console names a cause. The bundle threw while the tenant evaluated it, before onConnect ran, and the build reported that as a warning at most. Load dist/index.js as a module script on a blank page of your own and read that page’s console, where the throw shows at once. A bundler configuration without fkn() is the usual cause. onConnect listens only inside a frame, so that page shows you the error and never a connection.
The same timeout with a bundle that evaluates cleanly means onConnect ran too late or not at all. Call it during evaluation, before any fetch or read, since those wait with no deadline of their own. A main the published tarball does not carry loads nothing and reports nothing, so it lands here too: check that files includes the built directory.
packages.connect: '<uri>' failed to boot: <failure>, code 'unavailable', means the tenant could not load the package at all, such as a manifest that names no main, and the text after the colon is its report.