Skip to content

Frames

attachFrame() puts another website inside your page and hands back a Frame you navigate and drive. This page covers the two backends a frame runs on, every option, goto() and url(), frame.fetch() and its two refusals, what is refused with which message, and the timeouts on each path.

The page inside the <iframe> is the real site. Every read and action on it goes through locators and actions and permissions and consent:

app.ts
const
const frame: Frame
frame
= await
function attachFrame(options: AttachFrameOptions): Promise<Frame>
attachFrame
({
iframe: HTMLIFrameElement
iframe
:
var document: Document

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

MDN Reference

document
.
ParentNode.querySelector<"iframe">(selectors: "iframe"): HTMLIFrameElement | null (+4 overloads)

Returns the first element that is a descendant of node that matches selectors.

MDN Reference

querySelector
('iframe')! })
await
const frame: Frame
frame
.
function goto(url: string, options?: GotoOptions): Promise<void>
goto
('https://example.org/catalog')
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
() // 'Catalog', after one sheet for read.text on the extension
const frame: Frame
frame
.
function url(): string
url
() // 'https://example.org/catalog'

The call resolves once a backend has taken the iframe. A backend is where a call runs. On the extension, the content script has registered the iframe, installed the header rule for any domains and recorded the embed.iframe consent. On the cloud, the fkn.app page has answered the handshake and the render proxy, the cloud frame backend, has reported ready.

Both backends mark the frame for the user: the extension pushes an identity pill into the framed document, and the cloud draws an identity bar naming your app.

The Frame is the same object on either backend. Past goto() and url() it carries addStyleTag(), fetch(), frameLocator(), owner() and ensure(), and locator() starts a locator chain, a Locator built by chaining selectors. The root has no click() or getByRole(), so reach an element through locator() first.

A blank iframe holds no document a locator can reach until the first goto() commits one. On the extension a blank frame gets no content script, so a locator call before that navigation waits out its 30,000 ms deadline.

The demo below attaches a blank iframe with domains: ['en.wikipedia.org', 'wikipedia.org'] and syncCookies: false, navigates it to the Wikipedia search page, and restyles that page with addStyleTag(). It lands on the extension when the extension is exposed, and on the cloud render proxy otherwise. syncCookies: false gives the cloud attachment a session of its own, which frame.fetch() needs there, and on the extension it means none of the user’s cookies are copied in.

With the extension exposed, the button first puts read.text, act.type and act.click on one consent sheet through permissions.request(), the call that asks for several at once. The consent sheet is what the extension shows a user before an action above severity 0, and a refusal there stops the run. Without the extension the same three run on the render proxy with no prompt. The run reads the heading, fills the search box and presses the search button.

Acting on a real site, guidedOpen in new tab

The extension backend drives the iframe in place through the FKN extension’s content script, so the user’s session can travel with it. The cloud backend loads the site through the render proxy, needs nothing installed and copies none of the user’s cookies.

The root attachFrame() takes the extension backend when the extension is exposed, when lockdown is asked for, or when the realm, the JavaScript execution context the call runs in, has no window. Otherwise it takes the render proxy after a short wait for the extension, see backends. The Frame carries no tag saying which backend it landed on, so ask isExtensionExposed() right after the attach:

app.ts
const
const frame: Frame
frame
= await
function attachFrame(options: AttachFrameOptions): Promise<Frame>
attachFrame
({
iframe: HTMLIFrameElement
iframe
:
var document: Document

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

MDN Reference

document
.
ParentNode.querySelector<"iframe">(selectors: "iframe"): HTMLIFrameElement | null (+4 overloads)

Returns the first element that is a descendant of node that matches selectors.

MDN Reference

querySelector
('iframe')!,
domains?: string[] | undefined
domains
: ['example.org'] })
const
const onExtension: boolean
onExtension
=
function isExtensionExposed(): boolean

Unchanged on purpose, and still exported: a page half built before versioning calls exactly this, and an extension that announces an ABI must keep answering it the same way. Backwards compatibility here runs in BOTH directions, which is the only property that matters when neither end updates on demand.

isExtensionExposed
() // true when the attach landed on the extension, false when it went to the cloud
if (
const onExtension: boolean
onExtension
) {
// the user's example.org cookies came in with the attach
await
const frame: Frame
frame
.
function goto(url: string, options?: GotoOptions): Promise<void>
goto
('https://example.org/account')
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
('.username').
textContent: (_options?: LocatorOptions | undefined) => Promise<string>
textContent
({
reason?: string | undefined
reason
: 'Show who is signed in' }) // the signed-in name, after one sheet for read.text
} else {
await
const frame: Frame
frame
.
function goto(url: string, options?: GotoOptions): Promise<void>
goto
('https://example.org/catalog') // the render proxy's session, none of the user's cookies
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
() // 'Catalog', no sheet on the cloud backend
}

The frame holds the user’s example.org cookies on the extension and none on the cloud. The root call never opens the install card: a page without the extension goes to the cloud, and only extension.attachFrame() asks the user to install. attachFrame() needs a window realm on either backend, so drive frames from the page and not from workers.

attachFrame() takes one object, and both backends apply the same defaults:

OptionDefaultWhat it does
iframerequiredThe <iframe> to attach, already in the document.
domains[]The hosts the frame will hold. Extension: framing headers lifted and cookies copied for them. Cloud: the hosts frame.fetch() may reach.
syncCookiestrueExtension: copy the user’s cookies for domains into the frame. Cloud: the shared cookie jar, or a session of its own when false.
lockdownfalseExtension only, and asking for it picks that backend. Serves the declared domains under default-src 'none'.

domains on the extension installs one session rule for those hosts, in this tab only. The rule removes X-Frame-Options, Content-Security-Policy and Content-Security-Policy-Report-Only from the framed document’s responses and its subresources. With lockdown it still removes X-Frame-Options, sets Content-Security-Policy to default-src 'none' instead of removing it, and leaves Content-Security-Policy-Report-Only alone.

Without domains a site that forbids framing stays blank until a goto(), which adds its target host to the rule. On the cloud the list is normalised to bare lowercase hostnames of at most 253 characters, and anything else is dropped rather than repaired.

syncCookies on the extension copies a cookie whose domain equals a declared host, or is a subdomain of one that itself contains a dot. The copy runs only for the attach domains and each goto() target, so an attach without domains copies nothing and the frame starts logged out.

The two together are the shape the guided demo above uses:

app.ts
const
const frame: Frame
frame
= await
function attachFrame(options: AttachFrameOptions): Promise<Frame>
attachFrame
({
iframe: HTMLIFrameElement
iframe
:
var document: Document

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

MDN Reference

document
.
ParentNode.querySelector<"iframe">(selectors: "iframe"): HTMLIFrameElement | null (+4 overloads)

Returns the first element that is a descendant of node that matches selectors.

MDN Reference

querySelector
('iframe')!,
domains?: string[] | undefined
domains
: ['example.org', 'widget.example.org'],
syncCookies?: boolean | undefined
syncCookies
: false,
})
await
const frame: Frame
frame
.
function goto(url: string, options?: GotoOptions): Promise<void>
goto
('https://example.org/catalog') // resolves on the frame's load event
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
() // 'Catalog', after one sheet for read.text, from a document holding none of the user's cookies

Nothing of the user’s is in that frame on either backend, and the two hosts are what the extension lifts framing headers for and a later cloud frame.fetch() may reach.

lockdown also stops the site’s own scripts and styles, since default-src 'none' covers them too. A header policy cannot cover a document that is already loading, so it needs domains or a later goto(): attachFrame({ iframe, domains: ['example.org'], lockdown: true }) works, and an iframe with a src and no domains is refused.

goto(url, options?) navigates the frame and resolves once the new document is there. Both backends check the target against the platform rule first, so a URL on fkn.app, fkn.dev, sdbx.app or a subdomain of them is refused before anything moves, with attachFrame: refusing to target the extension's own pages or an FKN platform origin.

A navigation that passes resolves on the new document, and url() reports what you asked for:

app.ts
await
const frame: Frame
frame
.
function goto(url: string, options?: GotoOptions): Promise<void>
goto
('https://example.org/catalog') // resolves on the frame's load event
const frame: Frame
frame
.
function url(): string
url
() // 'https://example.org/catalog', the url you asked for
await
const frame: Frame
frame
.
function goto(url: string, options?: GotoOptions): Promise<void>
goto
('https://example.org/player', {
waitUntil?: "documentstart" | "load" | undefined
waitUntil
: 'documentstart' }) // back before the page finished loading

waitUntil defaults to 'load': the call resolves on the iframe’s load event. On the extension it rejects after 30,000 ms with frame load timed out after 30000ms for <href>. On the cloud the render proxy raises its own frame load timed out after 30000ms for <url>. When the render proxy never answers at all, the library gives up 5,000 ms after that deadline with cloud.attachFrame: the render proxy did not answer goto; the frame may have been detached or its page reloaded.

'documentstart' resolves as soon as the new document exists. On the extension that is when its content script announces itself, or frame goto: documentstart timed out after the same deadline. On the cloud it is at once, and a locator call made before the document commits gets navigation pending; the target document has not committed yet and is retried until it does.

On the extension every goto() is itself a consent, embed.open, at severity 0: no sheet, one auto row in the activity log, the on-device record of what an app did, scoped to the URL you asked for. A relative url resolves against your page, not the frame, so pass absolute URLs. options.domains extends the extension’s header rule and cookie copy for that navigation, and the cloud ignores it.

url() is the URL the app last asked for, on both backends: the attach target, or the target of your last goto(). A navigation the framed document performs on its own is invisible to it, and on the extension a frame that leaves the origins you declared refuses reads with frame: this frame no longer holds the document the app attached it to.

extension.attachFrame() and cloud.attachFrame() skip the choice and take the same options, except that cloud.attachFrame() refuses lockdown: true with cloud.attachFrame does not support lockdown; use the extension backend for a sealed frame.

The extension one waits up to 1,000 ms for an ok handshake (or 150 ms after load). That is stricter than the root call’s marker read, and an extension announcing an ABI below the floor rejects with ExtensionOutdatedError before any card is shown. With no handshake it opens the install card drawn by the broker, the connection your app holds into FKN, and stays pending while the card is open. Pass null to setMissingExtensionHandler() to draw that state yourself, and the call rejects quietly instead:

app.ts
function setMissingExtensionHandler(handler: MissingExtensionHandler | null): void
setMissingExtensionHandler
(null) // a missing extension rejects instead of opening the install card
try {
// syncCookies defaults to true, so the user's example.org cookies travel in
const
const frame: extension.Frame
frame
= await
(alias) namespace extension
import extension
extension
.
extension_d_exports.attachFrame({ iframe, domains, syncCookies, lockdown }: extension.AttachFrameOptions): Promise<extension.Frame>
export extension_d_exports.attachFrame
attachFrame
({
iframe: HTMLIFrameElement
iframe
:
var document: Document

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

MDN Reference

document
.
ParentNode.querySelector<"iframe">(selectors: "iframe"): HTMLIFrameElement | null (+4 overloads)

Returns the first element that is a descendant of node that matches selectors.

MDN Reference

querySelector
('iframe')!,
domains?: string[] | undefined
domains
: ['example.org'] })
await
const frame: extension.Frame
frame
.
function goto(url: string, options?: extension.GotoOptions): Promise<void>
goto
('https://example.org/account')
await
const frame: extension.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
('.username').
textContent: (_options?: LocatorOptions | undefined) => Promise<string>
textContent
({
reason?: string | undefined
reason
: 'Show who is signed in' }) // the signed-in name, after one sheet for read.text
} catch (
var error: unknown
error
) {
if (
var error: unknown
error
instanceof
class ExtensionOutdatedError

Thrown instead of the generic "not installed" message when the extension IS there and merely too old. Carries both numbers so an app can say which, and link its listing.

A named class rather than a message match: only the name survives a structured-clone hop, and the codebase has been bitten before by an error whose identity was its text.

ExtensionOutdatedError
)
var error: extension.ExtensionOutdatedError
error
.
ExtensionOutdatedError.required: number
required
// the ABI this page needs
else (
var error: unknown
error
as
interface Error
Error
).
Error.message: string
message
// 'The FKN WebExtension is not installed, enabled or not exposed on this page.'
}

The ExtensionOutdatedError branch cannot run today, because the floor, REQUIRED_EXTENSION_ABI, is 0. The rejection you will see is The FKN WebExtension is not installed, enabled or not exposed on this page. ExtensionOutdatedError is still the one refusal here you can test with instanceof, since it is thrown in your own realm. Everything raised across the hop is matched by name, see handling errors.

The cloud one always takes the render proxy, even with the extension installed:

app.ts
// a sandbox attribute, if present, must include allow-scripts and allow-same-origin
const
const frame: Frame
frame
= await
(alias) namespace cloud
import cloud
cloud
.
cloud_d_exports.attachFrame({ iframe, domains, syncCookies, lockdown }: AttachFrameOptions): Promise<Frame>
export cloud_d_exports.attachFrame
attachFrame
({
iframe: HTMLIFrameElement
iframe
:
var document: Document

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

MDN Reference

document
.
ParentNode.querySelector<"iframe">(selectors: "iframe"): HTMLIFrameElement | null (+4 overloads)

Returns the first element that is a descendant of node that matches selectors.

MDN Reference

querySelector
('iframe')!,
syncCookies?: boolean | undefined
syncCookies
: false })
await
const frame: Frame
frame
.
function goto(url: string, options?: GotoOptions): Promise<void>
goto
('https://example.org/catalog')
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
() // 'Catalog', no sheet and no activity log on the cloud backend

It rewrites the iframe’s src to https://fkn.app/attach-frame?url=..., connects to that page within 20,000 ms, waits up to 65,000 ms for the render proxy to report ready, and on either failure restores src, allow and referrerPolicy before rethrowing. A live attachment holds one of the busy tokens, the reasons a realm reports itself busy, under the name attached frame until the iframe leaves the document or the page hides.

frame.fetch(url, init?) issues a request from inside the framed document, with that document’s cookies, Origin and Referer, and resolves with the whole response. It lives on the Frame and after frameLocator(), never on an element locator. Two refusals can meet it, one per backend, and they do not share a shape:

app.ts
// on the extension the user's example.org session travels in, syncCookies defaults to true
const
const frame: Frame
frame
= await
function attachFrame(options: AttachFrameOptions): Promise<Frame>
attachFrame
({
iframe: HTMLIFrameElement
iframe
:
var document: Document

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

MDN Reference

document
.
ParentNode.querySelector<"iframe">(selectors: "iframe"): HTMLIFrameElement | null (+4 overloads)

Returns the first element that is a descendant of node that matches selectors.

MDN Reference

querySelector
('iframe')!,
domains?: string[] | undefined
domains
: ['example.org'] })
// on the cloud a fetch needs its own session, so attach with syncCookies: false there
await
const frame: Frame
frame
.
function goto(url: string, options?: GotoOptions): Promise<void>
goto
('https://example.org/')
try {
// GET, HEAD and OPTIONS ask for frame.fetchRead, every other method for frame.fetchWrite
const
const result: FrameFetchResult
result
= await
const frame: Frame
frame
.
fetch: (url: string, init?: FrameFetchOptions | undefined) => Promise<FrameFetchResult>
fetch
('https://example.org/api/catalog.json', {
reason?: string | undefined
reason
: 'Load your catalog' })
const result: FrameFetchResult
result
.
status: number
status
// 200 when the catalog answered, and on the extension one receipt row in the activity log
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
(new
var TextDecoder: new (label?: string, options?: TextDecoderOptions) => TextDecoder

The TextDecoder interface represents a decoder for a specific text encoding, such as UTF-8, ISO-8859-2, KOI8-R, GBK, etc.

MDN Reference

TextDecoder
().
TextDecoder.decode(input?: AllowSharedBufferSource, options?: TextDecodeOptions): string

The TextDecoder.decode() method returns a string containing text decoded from the buffer passed as a parameter.

MDN Reference

decode
(
const result: FrameFetchResult
result
.
body: ArrayBuffer
body
)) // the catalog, from the ArrayBuffer body
} catch (
var error: unknown
error
) {
const {
const name: string
name
,
const message: string
message
} =
var error: unknown
error
as
interface Error
Error
if (
const name: string
name
=== 'PermissionDeniedError')
const message: string
message
// 'Permission denied: frame.fetchRead (this frame)', the extension's sheet said no
else if (
function isLocatorDenied(error: unknown): boolean
isLocatorDenied
(
var error: unknown
error
))
const message: string
message
// refused by the cloud gate, here for the shared cookie jar
else if (
function isTerminalError(error: unknown): boolean
isTerminalError
(
var error: unknown
error
))
const message: string
message
// cannot run on this path, not retried
else throw
var error: unknown
error
}

Neither exported guard matches the first branch. A refusal on the extension’s sheet arrives under the name PermissionDeniedError with Permission denied: frame.fetchRead (this frame), so test error.name first, as a refusal neither guard matches shows. The second is the cloud’s, a terminal LocatorDeniedError the library raises itself. This attach meets it there at once, because the default syncCookies means the shared cookie jar.

The result is { status, statusText, ok, url, redirected, type, headers, body }, with headers as [name, value] pairs and body an ArrayBuffer. Its type is not exported by name (TypeScript). method defaults to GET, redirect to 'follow' and credentials to 'include'. There is no signal, and the body arrives whole.

On the extension the two keys, frame.fetchRead and frame.fetchWrite, sit at severity 3, see permission keys, and the reason you pass is shown on the sheet. Every call writes one receipt row to the activity log. A failing call is written once per identical call per 60,000 ms window, so the retries do not multiply rows.

On the cloud the call needs all four of these, and each miss is a terminal LocatorDeniedError the library raises before anything crosses:

What passes raises the broker’s card, described under consent on the cloud backend. The card never shows your reason, and a refusal there is frame.fetch: the user did not grant this. Past the card, the render proxy’s shell today refuses the request under its default addressing, with the terminal fetch is not available while the proxied document is on its own origin, so read that error rather than building on the path, see limitations.

Most refusals come before the iframe is touched. These are the six you are most likely to meet:

MessageWhat happened
cloud.attachFrame needs a window realmcloud.attachFrame() called with no window, in a worker for example.
attachFrame: the iframe must be connected to the document before attachingThe iframe is not in the document yet, on the extension.
cloud.attachFrame: this iframe is already attached; navigate with the Frame returned by that attach or use a fresh iframeThe src already points at the fkn.app page from an earlier attach.
cloud.attachFrame: timed out connecting to the render proxy pageNo handshake within 20,000 ms, and the iframe is restored.
attachFrame: lockdown needs domains when the frame already has a src; pass domains or load it via gotolockdown: true on an iframe that has a src and no domains.
frame: this frame no longer holds the document the app attached it toThe framed document navigated off the origins the attach declared. Terminal.

The platform rule runs on the src at attach and on every goto() target, page-side and again inside the extension’s content script and the fkn.app page that hosts the render proxy. The full list is on every error.

Every machine wait on the way to a working frame is bounded, and the waits on a person are not:

CallWaitHow long
attachFrame()the backend decision150 ms after the document is complete without the extension, 10,000 ms at most
extension.attachFrame()the extension’s handshake1,000 ms, or 150 ms after load, then the install card
cloud.attachFrame()the render proxy handshake20,000 ms, then cloud.attachFrame: timed out connecting to the render proxy page
cloud.attachFrame()the render proxy ready65,000 ms, then cloud.attachFrame: the render proxy never became ready
goto()the new document30,000 ms on the extension, plus 5,000 ms in the library on the cloud, not adjustable through GotoOptions

The install card that extension.attachFrame() opens stays up until the user dismisses it, and the call waits with it unless setMissingExtensionHandler(null) removed it. The broker’s card for frame.fetch() on the cloud backend waits the same way, and a dismissal there starts a 10,000 ms cooldown during which the call fails closed. The consent sheet is raised before the timer starts, so it never counts against a deadline. A locator action’s own deadline is under options, and the other knobs are in limits and timeouts.

The demo below runs on the extension backend only. It waits for the extension, shows an install hint until it is exposed, then attaches a same-origin mock player with no domains, so no header rule and no cookie copy. Six buttons drive it, and every rejection is caught and shown, since a denial or a timeout is a normal outcome:

  • a click on play, .controls then #play
  • a read of the title
  • a fill of the search box
  • an ensure('click', { subtree: true }) on the whole .controls box, an area grant covering everything inside it
  • two clicks covered by that area grant
  • a click outside the box, which prompts again
Locators, consent and the activity logOpen in new tab

Revoke a grant from the extension’s popup or dashboard to see the sheet come back. The same six buttons are walked from the locator side in locators and actions.