Skip to content

Quickstart

You can have a link inspector running on your own dev server in six steps: paste a URL, fetch it through the FKN cloud even though the site sends no CORS headers, show the title it found, keep the last results across reloads, then render the same page in a frame and read its heading. This page covers the install, the fetch and how to check it, the file that survives a reload, the attached frame, and a table of inputs that proves your handler tells a refusal from a rejection.

Nothing on this path needs an account, an install or a consent prompt. A backend is where a call runs, and every call below runs on the cloud backend, FKN’s own infrastructure, which the library reaches through the broker frame, a hidden fkn.app iframe it mounts on your page. How the root exports choose a backend is on backends.

Each step opens with a badge naming where its code runs, [Build] for the bundler configuration and [Page] for app.ts, the app’s own file, as defined on recipes. The page holds a text input, a list and an iframe, which the blocks refer to as input, list and iframe. The markup around them is yours.

[Build] Install the library and the plugin

Section titled “[Build] Install the library and the plugin”

In a Vite project, add the library and the plugin:

Terminal window
npm install @fkn/lib
npm install --save-dev @fkn/vite-plugin

@fkn/lib is the whole library in one package. The root import reaches buffer, events and stream, three Node modules a browser bundle has to supply, and @fkn/vite-plugin supplies them. It is one entry in your Vite plugins:

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
()], // buffer, events and stream are supplied, so the root import of @fkn/lib resolves
})

That entry is the whole build configuration. What else the plugin wires is on @fkn/vite-plugin, and what a bundler other than Vite has to provide is on install.

Load app.ts from your page as a module script. The library appends the broker frame to document.body when it evaluates, so the body has to exist by then, see install.

cloud.fetch takes the same arguments as the platform’s fetch and returns the same Response. The request leaves from the proxy, the FKN server that makes the request on your behalf, so the page’s cross-origin rules do not apply to it and the site needs no CORS header. Fetch the pasted URL and read the title out of the HTML:

app.ts
import {
(alias) namespace cloud
import cloud
cloud
} from '@fkn/lib'
type
type Result = {
url: string;
title: string;
status: number;
}
Result
= {
url: string
url
: string,
title: string
title
: string,
status: number
status
: number }
const
const inspect: (url: string) => Promise<Result>
inspect
= async (
url: string
url
: string):
interface Promise<T>

Represents the completion of an asynchronous operation

Promise
<
type Result = {
url: string;
title: string;
status: number;
}
Result
> => {
const
const response: Response
response
= await
(alias) namespace cloud
import cloud
cloud
.
cloud_d_exports.fetch(input: ProxyFetchInput, init?: ProxyFetchInit): Promise<Response>
export cloud_d_exports.fetch
fetch
(
url: string
url
) // through the proxy, so the page's cross-origin rules do not apply
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 when example.org answered 200
const response: Response
response
.
Response.url: string

The url read-only property of the Response interface contains the URL of the response.

MDN Reference

url
// '', the Response was rebuilt on your side
const
const html: string
html
= await
const response: Response
response
.
Body.text(): Promise<string>
text
()
const
const title: string
title
= new
var DOMParser: new () => DOMParser

The DOMParser interface provides the ability to parse XML or HTML source code from a string into a DOM Document.

MDN Reference

DOMParser
().
DOMParser.parseFromString(string: string, type: DOMParserSupportedType): Document

The parseFromString() method of the DOMParser interface parses a string containing either HTML or XML, returning an HTMLDocument or an XMLDocument.

MDN Reference

parseFromString
(
const html: string
html
, 'text/html').
Document.title: string

The document.title property gets or sets the current title of the document.

MDN Reference

title
// 'Example Domain'
return {
url: string
url
,
title: string
title
,
status: number
status
:
const response: Response
response
.
Response.status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
}
}
const
const result: Result
result
= await
const inspect: (url: string) => Promise<Result>
inspect
('https://example.org/')
const result: Result
result
.
title: string
title
// 'Example Domain', the title of example.org, parsed out of the body

response.ok and response.status are the site’s own. response.url is '' because the cloud rebuilds the Response on your side, and that empty string is how you can tell the proxy answered. Wire inspect to the input’s change event and put each result.title in the list.

The root fetch from @fkn/lib makes the same call and picks the extension, the FKN browser extension, when it is on the page. The quickstart pins cloud.fetch so the check in the next step holds on every page. How the root call decides is on fetch().

Paste https://example.org/ and press Enter. Two things show the request went through the cloud: Example Domain appears in the list, and the Network panel of your browser’s developer tools shows no request to example.org. The request that carried it went to the proxy, from the broker frame, and names the target in its fkn-proxy-hostname request header.

A refusal from the proxy is not a rejection. The Response resolves with the proxy’s own status and a JSON body naming the reason, so read response.ok before the body:

app.ts
const
const response: Response
response
= await
(alias) namespace cloud
import cloud
cloud
.
cloud_d_exports.fetch(input: ProxyFetchInput, init?: ProxyFetchInit): Promise<Response>
export cloud_d_exports.fetch
fetch
('http://192.168.1.10/') // a private address, which the proxy refuses
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
// false, and nothing threw
const response: Response
response
.
Response.status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
// 403, the proxy's own status
const
const body: {
error: string;
}
body
: {
error: string
error
: string } = await
const response: Response
response
.
Body.json(): Promise<any>
json
()
const body: {
error: string;
}
body
.
error: string
error
// 'egress refused (non-public target)'

The refusal arrived as an ordinary Response. A handler that shows body.error in the list when response.ok is false covers every answer the proxy gives on its own behalf.

When the title does not appear, the failure is in one of five places. Test them in this order:

  1. response.ok is false. A 403 with egress refused (non-public target) means the host resolved to a non-public address or did not resolve at all. A 502 with upstream fetch failed: <e> means the proxy reached the address and the site refused or stalled. A 429 with rate limit exceeded means this caller is past its fair-use limit for now.
  2. The call rejected with fetch refuses FKN platform domains (fkn.app). The pasted host is one of FKN’s own, which every backend refuses before anything leaves the page, see platform hosts.
  3. The call rejected with FKN cloud.fetch: no proxy is available. The broker, the connection your page holds into FKN, could not read its list of proxies. Retry after 30,000 ms, when it reads the list again.
  4. The call never settles. cloud.fetch waits for the broker with no deadline, so a broker frame that never connects leaves it pending. Look for the hidden fkn.app iframe in document.body. The library appends it when it evaluates, and the body has to exist by then, which a module script guarantees. When the frame is there and the page is served with cross-origin isolation headers, see install.
  5. response.ok is true and the title is empty. The URL answered something other than an HTML document with a title, so there was nothing to parse.

Every other message has a row on every error.

fs is the default file system of @fkn/lib, a path-based subset of Node’s fs that keeps every file on this device and, when an account is connected, a copy in the account. The account is the FKN identity a person carries between sites, and nothing here connects one, so the list stays on this device. The three file systems are on storage.

Write the list after every inspect:

app.ts
await
(alias) namespace fs
import fs
fs
.
index_d_exports.mount(): Promise<void>
export index_d_exports.mount
mount
() // fills the in-memory layer from this device, once per page load
const
const save: (results: Result[]) => Promise<void>
save
= async (
results: Result[]
results
:
type Result = {
url: string;
title: string;
status: number;
}
Result
[]) => {
await
(alias) namespace fs
import fs
fs
.
const index_d_exports.promises: {
readFile: (path: import("node:fs").PathLike, options?: ReadOptions) => Promise<Buffer | string>;
writeFile: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => Promise<void>;
appendFile: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => Promise<void>;
stat: (path: import("node:fs").PathLike) => Promise<fs.Stats>;
lstat: (path: import("node:fs").PathLike) => Promise<fs.Stats>;
... 6 more ...;
access: (path: import("node:fs").PathLike) => Promise<void>;
}
export index_d_exports.promises
promises
.
writeFile: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => Promise<void>
writeFile
('library/links.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
(
results: Result[]
results
)) // in memory now, on this device 250 ms later
await
(alias) namespace fs
import fs
fs
.
index_d_exports.flush(): Promise<void>
export index_d_exports.flush
flush
() // the write to this device runs now rather than on the timer
}

mount() fills the in-memory layer, the copy of every file that the synchronous calls read. The promise forms await it on their own, so calling it first only moves that wait to page load. A write goes to memory and reaches this device’s storage 250 ms later, or when flush() runs, and a reload after either still lists it.

Read it back when the page loads, and treat a missing file as an empty list:

app.ts
const
const load: () => Promise<Result[]>
load
= async ():
interface Promise<T>

Represents the completion of an asynchronous operation

Promise
<
type Result = {
url: string;
title: string;
status: number;
}
Result
[]> => {
try {
return
var JSON: JSON

An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.

JSON
.
JSON.parse(text: string, reviver?: (this: any, key: string, value: any) => any): any

Converts a JavaScript Object Notation (JSON) string into an object.

@paramtext A valid JSON string.

@paramreviver A function that transforms the results. This function is called for each member of the object. If a member contains nested objects, the nested objects are transformed before the parent object is.

@throws{SyntaxError} If text is not valid JSON.

parse
(
var String: StringConstructor
(value?: any) => string

Allows manipulation and formatting of text strings and determination and location of substrings within strings.

String
(await
(alias) namespace fs
import fs
fs
.
const index_d_exports.promises: {
readFile: (path: import("node:fs").PathLike, options?: ReadOptions) => Promise<Buffer | string>;
writeFile: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => Promise<void>;
appendFile: (path: import("node:fs").PathLike, data: WriteData, options?: WriteOptions) => Promise<void>;
stat: (path: import("node:fs").PathLike) => Promise<fs.Stats>;
lstat: (path: import("node:fs").PathLike) => Promise<fs.Stats>;
... 6 more ...;
access: (path: import("node:fs").PathLike) => Promise<void>;
}
export index_d_exports.promises
promises
.
readFile: (path: import("node:fs").PathLike, options?: ReadOptions) => Promise<Buffer | string>
readFile
('library/links.json', 'utf8'))) // the results from the last visit
} catch (
function (local var) error: unknown
error
) {
if ((
function (local var) error: unknown
error
as
namespace NodeJS
NodeJS
.
interface NodeJS.ErrnoException
ErrnoException
).
NodeJS.ErrnoException.code?: string | undefined
code
=== 'ENOENT') return [] // nothing saved yet, so the list starts empty
throw
function (local var) error: unknown
error
}
}
const
const results: Result[]
results
= await
const load: () => Promise<Result[]>
load
() // [] on the first visit, and the saved list after a reload

The read is wrapped in String(...) because the return type is Buffer | string whatever encoding you pass. A missing file rejects with an Error whose code is ENOENT, the one code that means nothing is there, so the first visit starts empty rather than failing. Every other code is rethrown, see error codes. The account’s own file system, cloud.fs, reports absence as a StorageNotFoundError that isNotFound() recognises, described on the same page.

attachFrame() puts another site inside your iframe and hands back a Frame you navigate and drive, an attached frame. With nothing installed, the root call takes the render proxy, the cloud frame backend, 150 ms after the document has loaded without the extension. The iframe has to be in the document already, with no sandbox attribute or one that includes allow-scripts and allow-same-origin:

app.ts
const
const frame: Frame
frame
= await
function attachFrame(options: AttachFrameOptions): Promise<Frame>
attachFrame
({
iframe: HTMLIFrameElement
iframe
}) // the render proxy took the iframe, with no install card on the way
await
const frame: Frame
frame
.
function goto(url: string, options?: GotoOptions): Promise<void>
goto
('https://example.org/') // resolves once example.org has loaded inside your iframe

The site renders inside your own iframe, under an identity bar the cloud draws naming your app. goto() resolves on the frame’s load event, and every wait on the way to a working frame is bounded, see timeouts.

Read the heading out of it:

app.ts
const
const heading: string
heading
= await
const frame: Frame
frame
.
locator: (selector: string) => Locator$1<Extended<{
readonly element: {
readonly selectors: {
readonly locator: {
readonly to: "element";
readonly css: true;
readonly resolve: (context: LocatorContext, selector: string) => Element[];
readonly render: (selector: unknown) => {
fragment: string;
separator: "descend";
};
};
readonly frameLocator: {
readonly to: "frame";
readonly css: true;
readonly barrier: "down";
readonly resolve: (context: LocatorContext, selector: string) => Element[];
readonly render: (selector: unknown) => {
fragment: string;
separator: "down";
};
};
readonly getByRole: {
readonly to: "element";
readonly resolve: (context: LocatorContext, role: string) => Element[];
readonly render: (role: unknown) => {
fragment: string;
};
};
readonly getByText: {
readonly to: "element";
readonly resolve: (context: LocatorContext, text: string) => Element[];
readonly render: (text: unknown) => {
fragment: string;
};
};
readonly getByTestId: {
readonly to: "element";
readonly resolve: (context: LocatorContext, testId: string) => Element[];
readonly render: (testId: unknown) => {
fragment: string;
};
};
readonly first: {
readonly to: "element";
readonly resolve: (context: LocatorContext) => Element[];
readonly render: () => {
fragment: string;
};
};
readonly nth: {
readonly to: "element";
readonly resolve: (context: LocatorContext, index: number) => Element[];
readonly render: (index: unknown) => {
fragment: string;
};
};
};
readonly operations: {
readonly click: {
readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void;
};
readonly fill: {
readonly resolve: (context: LocatorContext, value: string, _options?: OperationOptions) => void;
};
readonly hover: {
readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void;
};
readonly textContent: {
readonly resolve: (context: LocatorContext, _options?: OperationOptions) => string;
};
readonly getAttribute: {
readonly resolve: (context: LocatorContext, name: string, _options?: OperationOptions) => string | null;
};
readonly isVisible: {
readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean;
};
readonly count: {
readonly resolve: (context: LocatorContext, _options?: OperationOptions) => number;
};
readonly exists: {
readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean;
};
};
};
readonly frame: {
readonly selectors: {
readonly locator: {
readonly to: "element";
readonly css: true;
readonly resolve: (context: LocatorContext, selector: string) => Element[];
readonly render: (selector: unknown) => {
fragment: string;
separator: "descend";
};
};
readonly frameLocator: {
readonly to: "frame";
readonly css: true;
readonly barrier: "down";
readonly resolve: (context: LocatorContext, selector: string) => Element[];
readonly render: (selector: unknown) => {
fragment: string;
separator: "down";
};
};
readonly owner: {
readonly to: "frame";
readonly barrier: "up";
readonly resolve: (_context: LocatorContext) => Element[];
readonly render: () => {
fragment: string;
separator: "up";
};
};
};
readonly operations: {
readonly addStyleTag: {
readonly resolve: (context: LocatorContext, options: AddStyleTagOptions$1) => void;
};
readonly fetch: {
readonly kind: ChainKind;
readonly ...
locator
('h1').
textContent: (_options?: LocatorOptions | undefined) => Promise<string>
textContent
() // 'Example Domain', read out of the framed page with no prompt

locator('h1') starts a locator chain, a Locator built by chaining selectors, and nothing touches the page until textContent() runs. The action needs exactly one match. It is retried for 30,000 ms and then rejects with No elements found on a page with no h1, or with Strict mode violation: locator resolved to 2 elements on a page with two. Every selector and action is on locators and actions, and every option attachFrame() takes is on frames.

The demo below is this step on a real site. It attaches a blank iframe, navigates it to the Wikipedia search page and restyles it, then reads the heading, fills the search box and presses the search button, on the render proxy when no extension is on the page:

Acting on a real site, guidedOpen in new tab

The same call you wrote read the heading there. Open it in a new tab and the whole run happens on a page of its own.

Four inputs to paste, with the row that makes the other three mean something first:

What you put inWhat you getHow to check
A public URL that answers, https://example.org/The title, and a Response with ok trueThe control: when this row fails, nothing below it means anything
A hostname that does not resolve, or a private address such as http://192.168.1.10/A resolved Response, status 403, body {"error":"egress refused (non-public target)"}Read response.ok and the body. Nothing throws
A public host that refuses the connection or gives no answer within 10,000 msA resolved Response, status 502, body {"error":"upstream fetch failed: <e>"}The same shape, and this one is worth a retry
https://fkn.app/A thrown Error with the message fetch refuses FKN platform domains (fkn.app)This one rejects, which is the difference the table is teaching

The last row is the one refusal on this path that throws, because the library refuses the target itself before anything reaches the proxy:

app.ts
try {
await
(alias) namespace cloud
import cloud
cloud
.
cloud_d_exports.fetch(input: ProxyFetchInput, init?: ProxyFetchInit): Promise<Response>
export cloud_d_exports.fetch
fetch
('https://fkn.app/')
} catch (
var error: unknown
error
) {
(
var error: unknown
error
as
interface Error
Error
).
Error.message: string
message
// 'fetch refuses FKN platform domains (fkn.app)', thrown before anything left the page
}

Nothing left the page, so there is no Response to read. A handler that shows the title for the first row, body.error for the second and third, and the thrown message for the fourth tells the three shapes apart rather than merely not crashing.

Optional: Use the user’s own browser session

Section titled “Optional: Use the user’s own browser session”

With the extension on the page, a request can leave from the user’s own browser and carry their session, with the cloud as the fallback when it is not there, see use the extension or the cloud per request.

connect() asks the person to use their FKN account on this site, which gives fs a second copy of every file in the account and cloud.quota() a metered volume to report, see account and quota.

@fkn/lib/net and @fkn/lib/dgram are Node’s net and dgram in the browser, with the bytes carried through the relay, the server that holds the real socket at the far end, see TCP and UDP sockets.

A worker holds no broker of its own, so the page relays it with await relayWorker(worker, { unregisterSignal }) and the engine inside opens sockets exactly as a page would, see run sockets in a worker.

A package is an npm module FKN loads on a sandbox origin of its own, and your app installs one, connects to it and draws it inside its own layout, see install a package and show its UI.

A production build supplies the same three shims, and a page served with cross-origin isolation headers needs two more lines in the config, see install.