Skip to content

Ship a package

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:

vite.config.ts
import {
function defineConfig(config: UserConfig): UserConfig (+5 overloads)

Type helper to make it easier to use vite.config.ts accepts a direct

UserConfig

object, or a function that returns it. The function receives a

ConfigEnv

object.

defineConfig
} from 'vite'
import {
const fkn: (options?: {
fs?: boolean;
net?: boolean;
dgram?: boolean;
http?: boolean;
}) => Plugin<any>[]
fkn
} from '@fkn/vite-plugin'
export default
function defineConfig(config: UserConfig): UserConfig (+5 overloads)

Type helper to make it easier to use vite.config.ts accepts a direct

UserConfig

object, or a function that returns it. The function receives a

ConfigEnv

object.

defineConfig
({
UserConfig.plugins?: PluginOption[] | undefined

Array of vite plugins to use.

plugins
: [
function fkn(options?: {
fs?: boolean;
net?: boolean;
dgram?: boolean;
http?: boolean;
}): Plugin<any>[]
fkn
()], // the shims the library's root imports, so the bundle evaluates in the tenant
build?: BuildEnvironmentOptions | undefined

Build specific options

build
: {
BuildEnvironmentOptions.target?: false | EsbuildTarget | undefined

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

For custom targets, see https://esbuild.github.io/api/#target and https://esbuild.github.io/content-types/#javascript for more details.

@default'baseline-widely-available'

target
: 'esnext',
BuildEnvironmentOptions.lib?: false | LibraryOptions | undefined

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.

@defaultfalse

lib
: {
LibraryOptions.entry: InputOption

Path of library entry

entry
: 'src/package.ts',
LibraryOptions.formats?: LibraryFormats[] | undefined

Output bundle formats

@default['es', 'umd']

formats
: ['es'],
LibraryOptions.fileName?: string | ((format: ModuleFormat, entryName: string) => string) | undefined

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:

package.ts
import {
const onConnect: <T = unknown>(createPayload: ConnectPayload, handler?: (connection: IncomingConnection<T>) => void) => {
unsubscribe: () => void;
}

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.

onConnect
} from '@fkn/lib/packages'
import type {
type IncomingConnectionInfo = {
from: string;
protocol: string | null;
uri: string;
name: string;
version: string;
}
IncomingConnectionInfo
} from '@fkn/lib/packages'
type
type HostApi = {
appVersion: string;
}
HostApi
= {
appVersion: string
appVersion
: string }
const
const payload: {
search: (text: string) => Promise<string[]>;
}
payload
= {
search: (text: string) => Promise<string[]>
search
: async (
text: string
text
: string) => [`result for ${
text: string
text
}`] }
export type
type SourceApi = {
search: (text: string) => Promise<string[]>;
}
SourceApi
= typeof
const payload: {
search: (text: string) => Promise<string[]>;
}
payload
const
const serves: (info: IncomingConnectionInfo) => boolean
serves
= (
info: IncomingConnectionInfo
info
:
type IncomingConnectionInfo = {
from: string;
protocol: string | null;
uri: string;
name: string;
version: string;
}
IncomingConnectionInfo
) =>
info: IncomingConnectionInfo
info
.
protocol: string | null

the contract tag the app passed to connect(), e.g. 'stub-source@1'

protocol
=== 'example-source@1' // the tag the host passed to connect(), null when it passed none
onConnect<HostApi>(createPayload: ConnectPayload, handler?: ((connection: IncomingConnection<HostApi>) => void) | undefined): {
unsubscribe: () => void;
}

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.

onConnect
<
type HostApi = {
appVersion: string;
}
HostApi
>(
info: IncomingConnectionInfo
info
=> {
if (!
const serves: (info: IncomingConnectionInfo) => boolean
serves
(
info: IncomingConnectionInfo
info
)) throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
(`unsupported protocol ${
info: IncomingConnectionInfo
info
.
protocol: string | null

the contract tag the app passed to connect(), e.g. 'stub-source@1'

protocol
}`) // refused, and the host rejects with this message
return
const payload: {
search: (text: string) => Promise<string[]>;
}
payload
// built once per connection, and every function on it becomes async on the host's side
},
connection: IncomingConnection<HostApi>
connection
=> {
connection: IncomingConnection<HostApi>
connection
.
protocol: string | null

the contract tag the app passed to connect(), e.g. 'stub-source@1'

protocol
// 'example-source@1', the host's contract tag
connection: IncomingConnection<HostApi>
connection
.
from: string

the connecting app's identity: its package uri when it runs on a sandbox origin, else its page origin

from
// 'https://example.org', the host app's page origin
connection: IncomingConnection<HostApi>
connection
.
remote: {
appVersion: string;
}

the package's exposed payload

remote
.
appVersion: string
appVersion
// '1.2.0', the host's payload, plain data so no await
connection: IncomingConnection<HostApi>
connection
.
closed: Promise<void>

settles when the package side of the connection dies (uninstall, reload, crash) - reconnect by calling connect() again

closed
.
Promise<void>.then<void, never>(onfulfilled?: ((value: void) => void | PromiseLike<void>) | null | undefined, onrejected?: ((reason: any) => PromiseLike<never>) | null | undefined): Promise<void>

Attaches callbacks for the resolution and/or rejection of the Promise.

@paramonfulfilled The callback to execute when the Promise is resolved.

@paramonrejected The callback to execute when the Promise is rejected.

@returnsA Promise for the completion of which ever callback is executed.

then
(() =>
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
('the host disconnected'))
}) // 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
function isVisible(): boolean

True while a host app is showing this package's frame. Packages start hidden.

isVisible
() // false, a package starts hidden
let
let shown: boolean
shown
= false
function onVisibilityChange(handler: (visible: boolean) => void): {
unsubscribe: () => void;
}

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.

onVisibilityChange
(
visible: boolean
visible
=> { // called at once with false, then on every change
var document: Document

window.document returns a reference to the document contained in the window.

MDN Reference

document
.
Document.body: HTMLElement

The Document.body property represents the null if no such element exists.

MDN Reference

body
.
HTMLElement.hidden: boolean

The HTMLElement property hidden reflects the value of the element's hidden attribute.

MDN Reference

hidden
= !
visible: boolean
visible
if (
visible: boolean
visible
) {
let shown: boolean
shown
= true
const drawPicker: () => void
drawPicker
() // the frame is on screen now, so this is the moment to draw
return
}
if (
let shown: boolean
shown
)
const settlePicker: (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:

package.ts
import {
const fetch: (input: RequestInfo | URL, init?: RequestInit & {
reason?: string;
render?: boolean;
}) => Promise<Response>
fetch
} from '@fkn/lib'
const
const response: Response
response
= await
function fetch(input: RequestInfo | URL, init?: RequestInit & {
reason?: string;
render?: boolean;
}): Promise<Response>
fetch
('https://example.org/api/catalog.json') // a Response, and the cloud path meters under this package rather than the host
const response: 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.

MDN Reference

ok
// true
const
const catalog: any
catalog
= await
const response: Response
response
.
Body.json(): Promise<any>
json
() // the package's own data

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:

package.ts
try {
await
function 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: {
items: string[];
}
catalog
)) // one object, keyed on the package, so every host app that installs it shares this copy
} catch (
var error: unknown
error
) {
if (!(
var error: unknown
error
instanceof
class StorageLockedError
StorageLockedError
)) throw
var error: unknown
error
// locked and the unlock card was dismissed, so this run keeps no cache and the next write asks again
}
const
const saved: string | Buffer<ArrayBufferLike>
saved
= await
function readFile(path: import("node:fs").PathLike, options?: ReadOptions): Promise<Buffer | string>
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}:

package.json
{
"name": "@example/subtitles-plugin",
"version": "1.4.2",
"main": "dist/index.js",
"files": ["dist"],
"keywords": ["fkn", "fkn-type:plugin", "fkn-plugin--example"]
}

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.

A host app that installed the package calls packages.connect('npm:@example/subtitles-plugin', { protocol: 'example-source@1' }), your createPayload runs, and the handler receives the host’s origin in from. On the host, await connection.remote.search('Sintel') answers ['result for Sintel']. When that is not what you see:

  1. 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.
  2. 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.
  3. 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.
  4. packages.connect: the package refused the connection carrying your own message means createPayload threw. When the host passed a protocol you do not serve, that is the intended answer.
  5. packages.connect: the package did not complete the connection, code 'timeout', after 30 seconds, means createPayload had not resolved when the host’s handshake clock ran out. Move the slow work into the handler.
  6. Under mount the same failures read packages.mount: the package did not register a connection handler and packages.mount: the package failed to boot, and a boot failure arrives as the timeout there, because the tenant’s report is addressed to fkn.app and never reaches an iframe the host mounted. Ask the host to connect() once to read the report in the package’s own words.

account.info() inside a package answers for the host app’s connection, with the username and premium status and nothing more, see reading the account.