Skip to content

Frames

attachFrame() puts another website inside your page, or in a window your page opens, and hands back a Frame you navigate and drive. This page covers the two backends a frame runs on and the cookies option that picks between them, every option, blank pages, seeding a fresh jar, goto() and url(), opening a window, running code in the page, clearing cookies, 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> (+1 overload)

Attaches FKN to an iframe the app mounted, or with { window } opens the attachment in a window of its own. cookies picks the jar and with it the backend (AttachCookies): 'persistent', the default, and 'ephemeral' run on the cloud render proxy whatever is installed, and never wait for the extension; 'native' runs on the extension, waiting for it to expose itself while the page loads (at most 10000 ms) and showing the install prompt when it does not. A blank attach is the cloud render proxy's alone, so beside 'native' it is a TypeError. frame.backend() says which backend serves an attachment.

A window opens before the first await, so call this directly in the click or key handler whose activation opens it. On 'persistent' or 'ephemeral' it opens on the cloud, extension or not. On 'native' it opens as a real browser window on the extension, with no exposure wait; an extension that does not announce attachWindow, or none at all, is refused at once with ExtensionOperationUnsupportedError (operation 'attachWindow'), before anything opens or is waited for, so the same click can still open one on another value.

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>

Navigates the frame to url, which must pass the rules an iframe src does, and adds its host to the attachment. Resolves at options.waitUntil, and rejects with TimeoutError past options.timeout (GotoOptions).

goto
('https://example.org/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 Site data row on the cloud card
const frame: Frame
frame
.
function url(): string

The url last given to attachFrame or goto, never the frame's live location: a page that moves itself does not change it.

url
() // 'https://example.org/catalog'

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

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

The Frame is the same object on either backend. Past goto() and url() it carries backend(), requestPermissions(), addStyleTag(), fetch(), frameLocator(), getByTestId() and ensure(), the members that run code and carry messages (evaluate(), addScriptTag(), postMessage(), on() and off()), and clearCookies(). 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, unless it was attached on a blank page. 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'], navigates it to the Wikipedia search page, and restyles that page with addStyleTag(). With the extension exposed it attaches with cookies: 'native', on the extension and the person’s own Wikipedia cookies. Otherwise it attaches with cookies: 'ephemeral', on the cloud render proxy and a jar of its own, which frame.fetch() needs there.

The button first asks for two categories, Site data and Interaction, on one prompt through frame.requestPermissions(), 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 the cloud backend draws the broker’s card in its place, so both paths ask. A refusal stops the run, and what follows is one read, one fill and one click, all covered by the answer.

On an extension below ABI 2, an install still on 0.1.3, the store build before 0.1.53, the demo falls back to the pre-category ask and puts read.text, act.type and act.click on one sheet instead. That build also fails a gated locator call with Unknown locator kind, so the run needs 0.1.53 or newer to finish, and the stores serve 0.1.54.

Acting on a real site, guidedOpen in new tab

The cloud backend loads the site through the render proxy, needs nothing installed and runs on one of FKN’s own cookie jars. The extension backend drives the iframe in place through the FKN extension’s content script, so the person’s own browser session can travel with it.

cookies picks the jar an attachment runs on, and the jar decides the backend. One attachment has one jar, so which cookie a request carries always has one answer:

cookiesBackendThe jar
'persistent', the defaultthe cloud, whatever is installedthe render proxy’s jar of your app’s top-level site, https://fkn.app for every fkn.app app, which every app of that site shares and keeps across visits on this device. Your inline frames and your windows use it too. It is kept sealed on this device, signed in to FKN or not, see the persistent jar on this device.
'ephemeral'the cloud, whatever is installeda fresh jar of the attachment’s own, with the site’s storage under a namespace of its own, both gone with it, and seeded from storageState when you pass one.
'native'the extensionthe person’s own browser cookies for domains and every goto() host, copied into the browser’s partition for your app’s top-level site on the attach and on every goto().

HttpOnly values never reach your code on any of the three. The root attachFrame() never waits for the extension on 'persistent' or 'ephemeral'. On 'native' it waits for the extension to expose itself while the page loads, 150 ms once the document is complete and 10,000 ms at most, and shows the install card when it does not. frame.backend() says which backend serves an attachment, fixed for its life:

app.ts
const
const 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')!
const
const frame: Frame
frame
= await
function attachFrame(options: AttachFrameOptions): Promise<Frame> (+1 overload)

Attaches FKN to an iframe the app mounted, or with { window } opens the attachment in a window of its own. cookies picks the jar and with it the backend (AttachCookies): 'persistent', the default, and 'ephemeral' run on the cloud render proxy whatever is installed, and never wait for the extension; 'native' runs on the extension, waiting for it to expose itself while the page loads (at most 10000 ms) and showing the install prompt when it does not. A blank attach is the cloud render proxy's alone, so beside 'native' it is a TypeError. frame.backend() says which backend serves an attachment.

A window opens before the first await, so call this directly in the click or key handler whose activation opens it. On 'persistent' or 'ephemeral' it opens on the cloud, extension or not. On 'native' it opens as a real browser window on the extension, with no exposure wait; an extension that does not announce attachWindow, or none at all, is refused at once with ExtensionOperationUnsupportedError (operation 'attachWindow'), before anything opens or is waited for, so the same click can still open one on another value.

attachFrame
({
iframe: HTMLIFrameElement
iframe
,
domains?: string[] | undefined
domains
: ['example.org'],
cookies?: AttachCookies | undefined

Which jar, and with it which backend: 'persistent' when absent. See AttachCookies.

cookies
:
function isExtensionExposed(): boolean

Unchanged on purpose: a page half built before versioning calls exactly this, so an extension that announces an ABI keeps answering it the same way. An outdated extension also answers true.

isExtensionExposed
() ? 'native' : 'persistent', // read once the page has loaded, since the marker lands a tick after it starts
})
if (
const frame: Frame
frame
.
function backend(): "cloud" | "extension" | undefined

Which backend serves this attachment, fixed for its life. The same call can draw a card on one backend and not the other, and spends a different identity on each, so an app must be able to tell. Undefined only for a Frame an app built itself with createFrame from a backend that names none.

backend
() === 'extension') {
// the person's own example.org cookies came in with the attach
await
const frame: Frame
frame
.
function goto(url: string, options?: GotoOptions): Promise<void>

Navigates the frame to url, which must pass the rules an iframe src does, and adds its host to the attachment. Resolves at options.waitUntil, and rejects with TimeoutError past options.timeout (GotoOptions).

goto
('https://example.org/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 Site data row on the sheet
} else {
await
const frame: Frame
frame
.
function goto(url: string, options?: GotoOptions): Promise<void>

Navigates the frame to url, which must pass the rules an iframe src does, and adds its host to the attachment. Resolves at options.waitUntil, and rejects with TimeoutError past options.timeout (GotoOptions).

goto
('https://example.org/catalog') // your app's cloud jar, none of the person's browser 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', after one Site data row on the cloud card
}

The same call can draw a card on one backend and a sheet on the other, and spends a different identity on each, so the Frame names its backend and your app can tell. attachFrame() needs a window realm on either backend, so drive frames from the page and not from workers.

cookies replaced syncCookies in @fkn/lib 0.9.42. A syncCookies key, true or false, is refused with attachFrame: syncCookies was replaced by cookies before anything is attached. true is 'persistent', or 'native' where it meant the person’s own cookies on the extension, and false is 'ephemeral'. Since @fkn/lib 0.9.43 storageState, Playwright’s name for a context’s starting cookies and localStorage, seeds an 'ephemeral' jar, see seeding a fresh jar.

The 'persistent' jar is kept in the render proxy’s storage on this device, one jar per top-level site, and it is written sealed (AES-GCM) for everyone, signed in to FKN or not. The key is the jar’s own: the render proxy generates it the first time that site’s jar loads on this device, and the browser keeps it, non-extractable, beside the jar. Every page that opens the jar opens its key with it, so the jar acts as a clear one would: no card, no wait, no signed-out state, and a reset of the FKN account’s key leaves it alone. Your code changes nothing for it: the same attach, the same goto(), the same cookies in requests, and clearCookies() as before.

No cookie of the jar is in the clear in its records on disk, HttpOnly ones included, so a search of the browser’s files, or a backup index of them, does not find cookie text there.

What the seal does not keep, stated plainly:

  • The key sits beside the jar. The browser writes even a non-extractable key to its files, so a copy of the device’s files holds what opens the jar.
  • Older clear text can stay for a time on Chrome. Where an earlier build kept the jar in the clear, Chrome can keep that text in its files for a while after the seal. Firefox removes it at once.
  • Nothing else is sealed: the proxied site’s localStorage, IndexedDB and caches are never sealed, and while a frame runs, its jar is in memory to build each request.

Each jar’s first load since 2026-10-06 seals what earlier builds left: a jar kept in the clear is taken in and sealed, sign-ins included. A jar sealed between 2026-10-05 and 2026-10-06 under a key from the FKN account’s own cannot be opened without that key, so it was dropped on that first load, and the person signs in to those sites once more.

attachFrame() takes one object:

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, and sites frame.clearCookies() reaches.
cookies'persistent'The jar, and with it the backend, see which jar, which backend.
lockdownfalseExtension only, so it needs cookies: 'native'. Serves the declared domains under default-src 'none'.
permissions[]Categories of access to ask for on one prompt, as the attach’s last step, see several at once.
blanknone{ url }: start on an empty page presented at that url, with nothing requested from the site, see starting on a blank page.
storageStatenoneWith cookies: 'ephemeral' only: cookies and localStorage put in the attachment’s own jar before its first page is requested, see seeding a fresh jar.

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. A domain the rule would carry onto an FKN platform host (one of them, one under one, or a parent such as app) is refused with attachFrame: refusing to target FKN platform domains before anything is armed.

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.

cookies: 'native' 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.

An attachment that should hold nothing of anyone’s takes 'ephemeral':

app.ts
const
const frame: Frame
frame
= await
function attachFrame(options: AttachFrameOptions): Promise<Frame> (+1 overload)

Attaches FKN to an iframe the app mounted, or with { window } opens the attachment in a window of its own. cookies picks the jar and with it the backend (AttachCookies): 'persistent', the default, and 'ephemeral' run on the cloud render proxy whatever is installed, and never wait for the extension; 'native' runs on the extension, waiting for it to expose itself while the page loads (at most 10000 ms) and showing the install prompt when it does not. A blank attach is the cloud render proxy's alone, so beside 'native' it is a TypeError. frame.backend() says which backend serves an attachment.

A window opens before the first await, so call this directly in the click or key handler whose activation opens it. On 'persistent' or 'ephemeral' it opens on the cloud, extension or not. On 'native' it opens as a real browser window on the extension, with no exposure wait; an extension that does not announce attachWindow, or none at all, is refused at once with ExtensionOperationUnsupportedError (operation 'attachWindow'), before anything opens or is waited for, so the same click can still open one on another value.

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'],
cookies?: AttachCookies | undefined

Which jar, and with it which backend: 'persistent' when absent. See AttachCookies.

cookies
: 'ephemeral',
})
await
const frame: Frame
frame
.
function goto(url: string, options?: GotoOptions): Promise<void>

Navigates the frame to url, which must pass the rules an iframe src does, and adds its host to the attachment. Resolves at options.waitUntil, and rejects with TimeoutError past options.timeout (GotoOptions).

goto
('https://example.org/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 Site data row, from a jar no other attachment shares

That frame runs on the cloud whatever is installed, on a jar that ends with the attachment, and the two hosts are what a later frame.fetch() may reach.

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

permissions asks before the attach resolves, on the document’s host and the declared domains, and a refusal does not reject the attach: it surfaces on the first refused operation. It needs a host to name, so a blank frame with no domains throws frame.requestPermissions: navigate the frame or declare domains first. An extension below ABI 2 skips the ask with a console warning rather than failing the attach:

app.ts
const
const frame: Frame
frame
= await
function attachFrame(options: AttachFrameOptions): Promise<Frame> (+1 overload)

Attaches FKN to an iframe the app mounted, or with { window } opens the attachment in a window of its own. cookies picks the jar and with it the backend (AttachCookies): 'persistent', the default, and 'ephemeral' run on the cloud render proxy whatever is installed, and never wait for the extension; 'native' runs on the extension, waiting for it to expose itself while the page loads (at most 10000 ms) and showing the install prompt when it does not. A blank attach is the cloud render proxy's alone, so beside 'native' it is a TypeError. frame.backend() says which backend serves an attachment.

A window opens before the first await, so call this directly in the click or key handler whose activation opens it. On 'persistent' or 'ephemeral' it opens on the cloud, extension or not. On 'native' it opens as a real browser window on the extension, with no exposure wait; an extension that does not announce attachWindow, or none at all, is refused at once with ExtensionOperationUnsupportedError (operation 'attachWindow'), before anything opens or is waited for, so the same click can still open one on another value.

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'],
permissions?: CategoryRequest[] | undefined

Whole categories to ask for on one sheet or card right after the attach, before it resolves. A refusal does not reject the attach: it surfaces on the first refused operation.

permissions
: [
{
category: PermissionCategory
category
: 'storage',
reason: string
reason
: 'Read the track names' },
{
category: PermissionCategory
category
: 'interaction',
reason: string
reason
: 'Control the player from this app' },
],
})
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
() // covered by the answer, no second prompt

blank: { url } starts the frame on an empty page presented at url, with nothing requested from that site for it. It is for running your app’s own code, an attestation VM or a session client, in the origin that code needs, without loading or running the site’s page:

app.ts
const
const engine: Frame
engine
= await
function attachFrame(options: AttachFrameOptions): Promise<Frame> (+1 overload)

Attaches FKN to an iframe the app mounted, or with { window } opens the attachment in a window of its own. cookies picks the jar and with it the backend (AttachCookies): 'persistent', the default, and 'ephemeral' run on the cloud render proxy whatever is installed, and never wait for the extension; 'native' runs on the extension, waiting for it to expose itself while the page loads (at most 10000 ms) and showing the install prompt when it does not. A blank attach is the cloud render proxy's alone, so beside 'native' it is a TypeError. frame.backend() says which backend serves an attachment.

A window opens before the first await, so call this directly in the click or key handler whose activation opens it. On 'persistent' or 'ephemeral' it opens on the cloud, extension or not. On 'native' it opens as a real browser window on the extension, with no exposure wait; an extension that does not announce attachWindow, or none at all, is refused at once with ExtensionOperationUnsupportedError (operation 'attachWindow'), before anything opens or is waited for, so the same click can still open one on another value.

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')!,
blank?: BlankPage | undefined

Starts the frame on an empty page presented at blank.url, with nothing requested from that site for it. For running the app's own code (an attestation VM, a session client) in the origin it needs, without loading or running the site's page.

The document is exactly <!doctype html><html><head><meta charset="utf-8"></head><body></body></html>, served 200 with content-type: text/html; charset=utf-8 and cache-control: no-store: no content security policy, no Set-Cookie. Its location, origin, document.URL, document.domain and baseURI read blank.url, and document.referrer reads '' on the first load (after a reload it reads the page's own url, as on any proxied page). url() returns it. evaluate, postMessage and the message and document events work as on any page. Requests the page's code makes later go out as that page's would, through WebVPN, on the attachment's jar: with cookies: 'persistent', the default, the cloud jar of the app's top-level site, which every app of that site shares (HttpOnly values are never readable in the page); with 'ephemeral' a jar of the attachment's own, and site storage under a namespace of its own, both gone with it.

With cookies: 'ephemeral' nothing of the user's is reachable, so calls the Evaluation grant covers ask no card while the frame shows the empty page: until the attachment's first goto, or until code in the page moves it to another url, even on the same host, after which they ask as on any page. With 'persistent' the Evaluation card names the url's host, as for any page.

The empty page answers its url until the first goto: a reload of the page, or a replacement of the render proxy's own document, brings it back and fires document, so an installer must be idempotent. After the first goto the url loads from the site like any other.

Always the cloud render proxy, with or without the extension: only it can present an origin without loading it. frame.backend() then answers 'cloud', and the jar is the cloud's, never the browser's.

Refused, in this order, before anything is attached, and after the refusals of cookies (AttachCookies), so through cloud.attachFrame lockdown and 'native' meet those first:

  • TypeError attachFrame: blank must be an object { url } (null, a string)
  • TypeError attachFrame: blank takes { url }, not "<key>" (an unknown key)
  • TypeError attachFrame: blank.url must be an absolute http or https url, not "<value>"
  • TypeError attachFrame: blank needs an iframe with no src; this one has "<src>"
  • TypeError attachFrame: blank does not combine with lockdown
  • TypeError attachFrame: blank runs on the cloud render proxy, which has no browser cookies; pass cookies: 'persistent' or 'ephemeral' (with cookies: 'native')
  • Error attachFrame: refusing to target the extension's own pages or an FKN platform origin, the scope check every attach url meets
  • Error attachFrame: a blank page is served by the cloud render proxy, which this build does not configure

After the handshake, with the iframe put back on about:blank and nothing requested from the site: the terminal LocatorUnsupportedError cloud.attachFrame: this FKN page predates blank pages; reload the app to load the current one. The handshake and ready limits are every cloud attach's, 20000 ms and 65000 ms.

Also refused, before anything opens or is dispatched: with window, the TypeError attachFrame: blank does not apply to a window, since a window opened with no url is already called blank there; and through extension.attachFrame with cookies: 'native', ExtensionOperationUnsupportedError (operation 'blank') attachFrame: a blank page is served by the cloud render proxy only; call cloud.attachFrame.

blank
: {
url: string

Absolute http or https url the empty page is presented at. The fragment is ignored for matching.

url
: 'https://www.example.org/' }, // nothing is requested from www.example.org for this page
cookies?: AttachCookies | undefined

Which jar, and with it which backend: 'persistent' when absent. See AttachCookies.

cookies
: 'ephemeral', // a jar of its own, so evaluate and addScriptTag ask no card here
})
const engine: Frame
engine
.
function backend(): "cloud" | "extension" | undefined

Which backend serves this attachment, fixed for its life. The same call can draw a card on one backend and not the other, and spends a different identity on each, so an app must be able to tell. Undefined only for a Frame an app built itself with createFrame from a backend that names none.

backend
() // 'cloud', whatever is installed
const engine: Frame
engine
.
function url(): string

The url last given to attachFrame or goto, never the frame's live location: a page that moves itself does not change it.

url
() // 'https://www.example.org/'
await
const engine: Frame
engine
.
function addScriptTag(options: AddScriptTagOptions): Promise<void>

Runs content as a classic inline script of the attached document, as one of that page's own scripts would run: its top-level declarations stay on the page's global for later scripts and evaluate calls, document.currentScript is the script while it runs, and the page's own error listeners see its top-level error. Mirage rewrites it as it rewrites the page's scripts, and the page's eval refusal and Trusted Types requirement do not refuse it, as for evaluate. Playwright's name and its content option; its url, path and type are not served, and FKN's sourceUrl names the script rather than loading one.

Resolves undefined once the script ran; what it installed lives as long as the document, and the document event says when that is gone. Same grant, limit and timing as evaluate: the Evaluation grant (one card covers both), 30000 ms counted after any consent card, the attached frame only, and after a goto the document that goto brought, waited for as evaluate waits. It runs once: it never rides the locator retry loop, and a call whose document left before it answered rejects and is not run again. Cloud only for now: the extension refuses it by name.

Refused:

  • TypeError, before anything is sent, in this order: frame.addScriptTag: options must be an object { content }; for Playwright's url, path and type, frame.addScriptTag: "<key>" is not served; it runs content as a classic inline script; frame.addScriptTag: unknown option "<key>"; frame.addScriptTag: content must be a string; frame.addScriptTag: sourceUrl must be a non-empty string with no line break.
  • an Error carrying the page's own name and message: the script's top-level throw, after it ran.
  • LocatorDeniedError: no Evaluation grant, the frame holds no page, or its document is outside the attachment. Against an FKN page that predates addScriptTag the call arrives with no proof of a grant and the render proxy refuses it: frame.addScriptTag: the call arrived without an Evaluation grant, so it was not run.
  • cloud, LocatorUnsupportedError frame.addScriptTag: this render proxy predates addScriptTag; reload the app.
  • cloud, LocatorError: the document of the goto before it had not come by that goto's deadline, or the app started another goto first, so the script was not run.
  • extension, ExtensionOperationUnsupportedError (operation 'addScriptTag') frame.addScriptTag: an extension frame does not run scripts through mirage yet; attach with cloud.attachFrame, before anything is dispatched.
  • TimeoutError frame.addScriptTag: no answer within 30000ms; the script may or may not have run.
  • the terminal detach error once the attachment ended.

addScriptTag
({
content: string

The script's source text.

content
: 'globalThis.engineReady = true',
sourceUrl?: string | undefined

Names the script in stack traces through //# sourceURL. Absent, its frames name the document.

sourceUrl
: 'engine.js' })
await
const engine: Frame
engine
.
evaluate<string[], undefined>(pageFunction: string | ((arg: undefined) => string[] | Promise<string[]>), arg?: undefined): Promise<string[]>

Runs pageFunction inside the attached site's OWN page realm, as a script of that page would: it sees the site's window, its globals and its DOM. The function is sent as source (Function.prototype.toString), so it captures NOTHING from the caller's scope; everything it needs must come through arg. A plain source string is run as an expression, the way Playwright does, and is not called. A promise, returned by the function or produced by the expression, is awaited.

arg is structured-cloned at the call, in the app's realm, on both backends, and the result comes back by structured clone, so an ArrayBuffer or a typed array of bytes survives the round trip. A value structured clone refuses in arg (a function, a DOM node, an untransferred port) rejects with the platform's DataCloneError before anything is sent; a port goes to the page with postMessage. A thrown error or a rejected promise comes back as an Error carrying the page's own name and message. A result that cannot be cloned (a function, a DOM node) is a LocatorInvalidError, never a silent undefined and never a live handle into the page.

Every limit is refused by name, never a call left pending:

  • time, both backends: the code has 30 seconds to settle, counted after any consent card, and then the call rejects with TimeoutError. Only the wait ends there; code that never settles keeps running in the page.
  • size, extension only, a LocatorInvalidError: the source with its arg, and the result, each carry at most 32 MiB (binary data by its bytes, text by its UTF-8 size), the most its message relay carries.
  • Blob, extension only, a LocatorInvalidError: the relay cannot carry one, so pass or return await blob.arrayBuffer(). The cloud backend has neither limit, and returns a Blob as a Blob.

Needs the Evaluation grant on both backends; an app without it is refused by name. It runs on the attached frame only: a nested FrameLocator does not offer it, and a call there anyway is refused.

Called after goto resolved, whichever waitUntil, it runs once on the document that goto brought, never on the one it replaced. On the cloud backend the call waits for that document when it has not committed yet, and the wait counts toward the 30 seconds; if it has not come by the goto's own deadline (the goto failed, or loaded something that is not a page), or the app started another goto first, the call rejects with a LocatorError saying the code was not run.

On the extension it also needs an extension new enough to run code (ABI 3, older ones are refused with ExtensionOperationUnsupportedError), and it runs where the page's content security policy allows it: a frame whose host the app declared has its CSP replaced, so code runs; an undeclared frame, and every attached window, keeps its CSP, and a page that forbids eval refuses with a named error. On the cloud backend the code runs in the proxied document's realm, which mirage virtualizes: the page's own eval refusal and Trusted Types requirement do not refuse the source evaluate compiles, and every string that code compiles itself (eval, Function, a string timer) is held to them, as the page's own are.

evaluate
(() => [
var location: Location

The Window.location read-only property returns a Location object with information about the current location of the document.

MDN Reference

location
.
Location.origin: string

The origin read-only property of the Location interface returns a string containing the Unicode serialization of the origin of the location's URL.

MDN Reference

origin
,
var document: Document

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

MDN Reference

document
.
Document.referrer: string

The Document.referrer property returns the URI of the page that linked to this page.

MDN Reference

referrer
]) // ['https://www.example.org', '']

The document is exactly <!doctype html><html><head><meta charset="utf-8"></head><body></body></html>, served 200 with content-type: text/html; charset=utf-8 and cache-control: no-store: no content security policy and no Set-Cookie. Its location, origin, document.URL, document.domain and baseURI read blank.url, and document.referrer reads '' on the first load. evaluate(), addScriptTag(), postMessage() and the message and document events work as on any page.

Requests the page’s code makes go out as that page’s would, through WebVPN, on the attachment’s jar. With 'persistent' that is your app’s cloud jar, which every app of your top-level site shares. With 'ephemeral' it is a jar of the attachment’s own, gone with it. Coming with the next FKN extension store release, and not served yet: on 'persistent' an installed extension sends the page’s own fetch and XHR requests to the attachment’s hosts from the browser, on the same jar and with none of the browser’s own cookies.

With 'ephemeral' nothing of the user’s is reachable, so the calls the Evaluation grant covers ask no card while the frame shows the empty page: until the first goto(), or until code in the page moves it to another url, even on the same host. After that they ask as on any page. With 'persistent' the Evaluation card names the url’s host, as for any page.

The empty page answers its url until the first goto(). A reload of the page, or a replacement of the render proxy’s own document, brings it back and fires document, so whatever you install must be safe to install twice. After the first goto() the url loads from the site like any other.

A blank page is always served by the cloud render proxy, since only it can present an origin without loading it. It is refused before anything is attached, in this order:

extension.attachFrame() refuses it with ExtensionOperationUnsupportedError, operation 'blank', and a window with attachFrame: blank does not apply to a window. An fkn.app page served from the browser’s cache that is older than blank pages ends the attach after the handshake with cloud.attachFrame: this FKN page predates blank pages, with the iframe put back on about:blank and nothing requested from the site.

storageState starts an 'ephemeral' attachment with cookies and localStorage already in place, as Playwright’s browser.newContext({ storageState }) starts a context. The seed goes into the attachment’s own jar and storage namespace before its first page is requested, so that request already carries the cookies and the page’s first script already reads its localStorage:

app.ts
const
const frame: Frame
frame
= await
function attachFrame(options: AttachFrameOptions): Promise<Frame> (+1 overload)

Attaches FKN to an iframe the app mounted, or with { window } opens the attachment in a window of its own. cookies picks the jar and with it the backend (AttachCookies): 'persistent', the default, and 'ephemeral' run on the cloud render proxy whatever is installed, and never wait for the extension; 'native' runs on the extension, waiting for it to expose itself while the page loads (at most 10000 ms) and showing the install prompt when it does not. A blank attach is the cloud render proxy's alone, so beside 'native' it is a TypeError. frame.backend() says which backend serves an attachment.

A window opens before the first await, so call this directly in the click or key handler whose activation opens it. On 'persistent' or 'ephemeral' it opens on the cloud, extension or not. On 'native' it opens as a real browser window on the extension, with no exposure wait; an extension that does not announce attachWindow, or none at all, is refused at once with ExtensionOperationUnsupportedError (operation 'attachWindow'), before anything opens or is waited for, so the same click can still open one on another value.

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'],
cookies?: AttachCookies | undefined

Which jar, and with it which backend: 'persistent' when absent. See AttachCookies.

cookies
: 'ephemeral', // the one value that takes a seed
storageState?: StorageState | undefined

Seeds an 'ephemeral' jar before the attachment's first document; with any other value a TypeError. See StorageState.

storageState
: {
cookies?: StorageStateCookie[] | undefined
cookies
: [
{
name: string

Non-empty, with no =, ; or control character.

name
: 'session',
value: string

With no ; or control character.

value
: 'b2f1c9',
domain: string

.youtube.com for a cookie its subdomains also receive, www.youtube.com for a host-only one, as Playwright writes it.

domain
: '.example.org',
httpOnly?: boolean | undefined
httpOnly
: true,
secure?: boolean | undefined
secure
: true,
expires?: number | undefined

Milliseconds since the epoch, where Playwright's is seconds; -1 or absent for a session cookie.

expires
:
var Date: DateConstructor

Enables basic storage and retrieval of dates and times.

Date
.
DateConstructor.now(): number

Returns the number of milliseconds elapsed since midnight, January 1, 1970 Universal Coordinated Time (UTC).

now
() + 86_400_000 },
],
origins?: {
origin: string;
localStorage: {
name: string;
value: string;
}[];
}[] | undefined

Each origin's localStorage items, read by every page of that origin in the attachment.

origins
: [{
origin: string
origin
: 'https://example.org',
localStorage: {
name: string;
value: string;
}[]
localStorage
: [{
name: string
name
: 'theme',
value: string
value
: 'dark' }] }],
},
})
await
const frame: Frame
frame
.
function goto(url: string, options?: GotoOptions): Promise<void>

Navigates the frame to url, which must pass the rules an iframe src does, and adds its host to the attachment. Resolves at options.waitUntil, and rejects with TimeoutError past options.timeout (GotoOptions).

goto
('https://example.org/account') // the first request already carries session=b2f1c9
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
() // the name that session belongs to, after one Site data row on the cloud card

The shape is Playwright’s, with cookies and origins under the same names, and an origins entry the same { origin, localStorage: [{ name, value }] }. It differs in five places:

Playwright’s storageStateFKN’s
a cookie’s expiresseconds since the epoch, -1 for a session cookiemilliseconds since the epoch, -1 or absent for a session cookie
path, httpOnly, secure, sameSiterequired on every cookieoptional, '/', false, false and 'Lax' when absent; name, value and domain are required
cookies, originsboth requiredeither may be absent
what it takesthe object, or the path of a file holding itthe object only, and a key outside the shape is refused by name, an indexedDB entry that Playwright’s storageState({ indexedDB: true }) writes among them
a partitioned (CHIPS) cookiecarries partitionKey, and from Chromium _crHasCrossSiteAncestor beside ittakes no partition: both keys are refused by name, and a seeded cookie is never partitioned

A cookie’s domain is written as Playwright writes it, .example.org for a cookie its subdomains also receive and www.example.org for a host-only one, and an origin as URL.origin writes it, with no trailing slash. A state Playwright saved needs up to three changes, all made by the sample below:

  • every expires times 1000
  • on a partitioned cookie from Chromium, partitionKey and _crHasCrossSiteAncestor left out, so it is seeded as an ordinary cookie of its domain; leave the whole cookie out instead if it should reach its site only under the top-level site its partitionKey names
  • on a cookie Firefox saved, sameSite: 'None' without secure made 'Lax': Firefox saves 'None' for a cookie that set no SameSite, which Chromium reads as 'Lax', and FKN refuses 'None' without secure as a browser would
app.ts
const
const storageState: StorageState
storageState
:
type StorageState = {
cookies?: StorageStateCookie[];
origins?: {
origin: string;
localStorage: {
name: string;
value: string;
}[];
}[];
}

A jar's starting state, in the shape Playwright's browser.newContext({ storageState }) takes, except that a cookie's expires is milliseconds here where Playwright's is seconds. Seeded into an 'ephemeral' attachment's fresh jar and its storage namespace before the attachment's first document is requested, so that request already carries its cookies and the page's first script already reads its localStorage. With blank, whose empty page requests nothing and runs no code, it is seeded right after that page is in place, before the attach resolves.

Accepted only with cookies: 'ephemeral': with 'persistent' an app-written cookie would land in the jar every app of the top-level site shares, and with 'native' in the person's own session. The seed travels on FKN's own channels, never in a url the app page could read. A seeded HttpOnly cookie is sent to its site and never readable in the page, as any HttpOnly cookie, and it never comes back to app code.

Refused by name, before anything is attached (AttachCookies has the order): a field of another shape; a name or value with ; or a control character, or a name with =; a cookie a browser does not store (SameSite=None or __Secure- without secure, __Host- without secure, a host-only domain and path '/'); an origin that is not an absolute http or https origin; and an expires that reads as seconds. Against an FKN page or render proxy that predates it, after the iframe is put back on about:blank (or the window closed) and before anything is requested from the site: the terminal LocatorUnsupportedError attachFrame: this FKN page predates storageState; reload the app to load the current one.

StorageState
= {
// the eight fields FKN's shape has, so a partitioned cookie's partitionKey and _crHasCrossSiteAncestor stay behind
cookies?: StorageStateCookie[] | undefined
cookies
:
const saved: {
cookies: PlaywrightCookie[];
origins: {
origin: string;
localStorage: {
name: string;
value: string;
}[];
}[];
}
saved
.
cookies: PlaywrightCookie[]
cookies
.
Array<PlaywrightCookie>.map<{
name: string;
value: string;
domain: string;
path: string;
httpOnly: boolean;
secure: boolean;
expires: number;
sameSite: "Strict" | "Lax" | "None";
}>(callbackfn: (value: PlaywrightCookie, index: number, array: PlaywrightCookie[]) => {
name: string;
value: string;
domain: string;
path: string;
httpOnly: boolean;
secure: boolean;
expires: number;
sameSite: "Strict" | "Lax" | "None";
}, thisArg?: any): {
name: string;
value: string;
domain: string;
path: string;
httpOnly: boolean;
secure: boolean;
expires: number;
sameSite: "Strict" | "Lax" | "None";
}[]

Calls a defined callback function on each element of an array, and returns an array that contains the results.

@param ― callbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.

@param ― thisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.

map
(({
name: string
name
,
value: string
value
,
domain: string
domain
,
path: string
path
,
expires: number
expires
,
httpOnly: boolean
httpOnly
,
secure: boolean
secure
,
sameSite: "Strict" | "Lax" | "None"
sameSite
}) => ({
name: string
name
,
value: string
value
,
domain: string
domain
,
path: string
path
,
httpOnly: boolean
httpOnly
,
secure: boolean
secure
,
expires: number
expires
:
expires: number
expires
=== -1 ? -1 :
expires: number
expires
* 1000, // seconds to milliseconds
sameSite: "Strict" | "Lax" | "None"
sameSite
:
sameSite: "Strict" | "Lax" | "None"
sameSite
=== 'None' && !
secure: boolean
secure
? 'Lax' :
sameSite: "Strict" | "Lax" | "None"
sameSite
, // Firefox's 'None' for a cookie that set no SameSite
})),
origins?: {
origin: string;
localStorage: {
name: string;
value: string;
}[];
}[] | undefined

Each origin's localStorage items, read by every page of that origin in the attachment.

origins
:
const saved: {
cookies: PlaywrightCookie[];
origins: {
origin: string;
localStorage: {
name: string;
value: string;
}[];
}[];
}
saved
.
origins: {
origin: string;
localStorage: {
name: string;
value: string;
}[];
}[]
origins
.
Array<{ origin: string; localStorage: { name: string; value: string; }[]; }>.map<{
origin: string;
localStorage: {
name: string;
value: string;
}[];
}>(callbackfn: (value: {
origin: string;
localStorage: {
name: string;
value: string;
}[];
}, index: number, array: {
origin: string;
localStorage: {
name: string;
value: string;
}[];
}[]) => {
origin: string;
localStorage: {
name: string;
value: string;
}[];
}, thisArg?: any): {
origin: string;
localStorage: {
name: string;
value: string;
}[];
}[]

Calls a defined callback function on each element of an array, and returns an array that contains the results.

@param ― callbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.

@param ― thisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.

map
(({
origin: string
origin
,
localStorage: {
name: string;
value: string;
}[]
localStorage
}) => ({
origin: string
origin
,
localStorage: {
name: string;
value: string;
}[]
localStorage
})), // an indexedDB entry stays behind
}
const
const frame: Frame
frame
= await
function attachFrame(options: AttachFrameOptions): Promise<Frame> (+1 overload)

Attaches FKN to an iframe the app mounted, or with { window } opens the attachment in a window of its own. cookies picks the jar and with it the backend (AttachCookies): 'persistent', the default, and 'ephemeral' run on the cloud render proxy whatever is installed, and never wait for the extension; 'native' runs on the extension, waiting for it to expose itself while the page loads (at most 10000 ms) and showing the install prompt when it does not. A blank attach is the cloud render proxy's alone, so beside 'native' it is a TypeError. frame.backend() says which backend serves an attachment.

A window opens before the first await, so call this directly in the click or key handler whose activation opens it. On 'persistent' or 'ephemeral' it opens on the cloud, extension or not. On 'native' it opens as a real browser window on the extension, with no exposure wait; an extension that does not announce attachWindow, or none at all, is refused at once with ExtensionOperationUnsupportedError (operation 'attachWindow'), before anything opens or is waited for, so the same click can still open one on another value.

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')!,
cookies?: AttachCookies | undefined

Which jar, and with it which backend: 'persistent' when absent. See AttachCookies.

cookies
: 'ephemeral',
storageState?: StorageState | undefined

Seeds an 'ephemeral' jar before the attachment's first document; with any other value a TypeError. See StorageState.

storageState
})

The seed reaches the attachment’s own jar and storage namespace and nothing else: never the jar your 'persistent' attachments share, never the person’s browser cookies, and it is gone with the attachment. It travels on FKN’s own channels, never in a url your page could read, and the library seeds the copy it checked, so a change to the object afterwards changes nothing. A seeded HttpOnly cookie is sent to its site and is never readable in the page, as any HttpOnly cookie, and nothing about the jar comes back to your code.

When the iframe has a src, or a window a url, the attach starts on no page, seeds, and loads that url as its first goto(). It then resolves once that page has loaded, and a failure on the way rejects the attach with the iframe put back on about:blank, or the window closed. A window still opens at once, on the click’s activation.

With blank the seed is in place before the attach resolves. Each origin’s localStorage items are written before that origin’s first page in the attachment runs a script, whichever goto() reaches it.

storageState is refused before anything is attached, in this order, after the cookies refusals above:

extension.attachFrame() serves 'native' alone, so it refuses 'ephemeral' as it refuses any value but that, with operation 'cookies'. Once the render proxy has answered, an fkn.app page or a render proxy older than storageState ends the attach with the terminal attachFrame: this FKN page predates storageState, before anything is requested from the site, and a seed not taken within 20,000 ms with the TimeoutError attachFrame: the render proxy did not take the storageState seed. With blank, a frame host older than seeding, which never confirms the localStorage items, rejects the attach after 5,000 ms with mirage: the frame host did not answer the localStorage seed.

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 a name FKN owns or a subdomain of one is refused before anything moves: on the cloud with attachFrame: refusing to target the extension's own pages or an FKN platform origin, on the extension with frame.goto: refusing to target FKN platform domains, which a domains entry reaching one also gets.

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>

Navigates the frame to url, which must pass the rules an iframe src does, and adds its host to the attachment. Resolves at options.waitUntil, and rejects with TimeoutError past options.timeout (GotoOptions).

goto
('https://example.org/catalog') // resolves on the frame's load event
const frame: Frame
frame
.
function url(): string

The url last given to attachFrame or goto, never the frame's live location: a page that moves itself does not change it.

url
() // 'https://example.org/catalog', the url you asked for
await
const frame: Frame
frame
.
function goto(url: string, options?: GotoOptions): Promise<void>

Navigates the frame to url, which must pass the rules an iframe src does, and adds its host to the attachment. Resolves at options.waitUntil, and rejects with TimeoutError past options.timeout (GotoOptions).

goto
('https://example.org/player', {
waitUntil?: "commit" | "load" | undefined

When goto resolves: 'load', the default, once the document it brings fired load; 'commit', Playwright's, once that document holds the frame, possibly before the page's own scripts ran and whether or not it ever fires load. Either way a call made after it runs on the document the goto brought, never on the one it replaced, and the goto rejects with TimeoutError when that document has not come by its deadline.

A goto that changes only the fragment of the url the page is at keeps its document. On the cloud backend it resolves, either way, once the page reports the new fragment. Two pages are the exception there, where such a goto brings a new document like any other: one reached through a redirect, and one that followed a link onto another origin. On the extension (measured on Chromium) one awaited to 'load' resolves, since the frame fires load for it, and one awaited to 'commit' runs out at its timeout, since no new document starts.

waitUntil
: 'commit',
timeout?: number | undefined

How long the goto may take, in milliseconds: 30000 when absent. Past it the goto rejects with TimeoutError. A positive number: 0 is a TypeError, since every call ends by its deadline and so does not take Playwright's "0 disables the timeout".

timeout
: 10_000 }) // once the new document holds the frame

waitUntil takes Playwright’s two values that both backends serve. 'load', the default, resolves once the document the goto brought fired load. 'commit' resolves once that document holds the frame, possibly before the page’s own scripts ran and whether or not it ever fires load. Either way a call made after it runs on the document the goto brought, never on the one it replaced.

Any other value is a TypeError before anything moves, frame.goto: waitUntil must be 'load' or 'commit': 'documentstart' is now 'commit', and 'domcontentloaded' and 'networkidle' are not served.

timeout is the goto’s deadline, 30,000 ms when absent, and a positive number: 0 is refused, since every call here ends by its deadline. Past it the goto rejects with a TimeoutError, exported from the root and minted in your realm, so instanceof works:

app.ts
try {
await
const frame: Frame
frame
.
function goto(url: string, options?: GotoOptions): Promise<void>

Navigates the frame to url, which must pass the rules an iframe src does, and adds its host to the attachment. Resolves at options.waitUntil, and rejects with TimeoutError past options.timeout (GotoOptions).

goto
('https://example.org/slow', {
timeout?: number | undefined

How long the goto may take, in milliseconds: 30000 when absent. Past it the goto rejects with TimeoutError. A positive number: 0 is a TypeError, since every call ends by its deadline and so does not take Playwright's "0 disables the timeout".

timeout
: 5_000 })
} catch (
var error: unknown
error
) {
if (!(
var error: unknown
error
instanceof
class TimeoutError

A call that ran out of time: its deadline passed before it settled. One class for every call that has a deadline, as Playwright's TimeoutError, named 'TimeoutError' so a check by name works as well as instanceof. It is minted where the call was made, never in the realm that ran it, so it is an instance of this class in the caller's realm.

message is what the call reported at its deadline, unchanged from the error it replaces. cause is the last retryable error an attempt met before the deadline, so a locator call that never found its element says why; it is absent when no attempt failed (one never settled, or the call is not retried). Not terminal: nothing retries a call past its own deadline.

TimeoutError
)) throw
var error: unknown
error
var error: TimeoutError
error
.
Error.message: string
message
// 'frame load timed out after 5000ms for https://example.org/slow'
}

The message is the backend’s own. On the extension a 'load' goto ends with frame load timed out after <ms>ms for <href> and a 'commit' one with frame goto: documentstart timed out. On the cloud the render proxy raises frame load timed out after <ms>ms for <url> or navigation to <url> did not commit a document within <ms>ms. When the render proxy never answers at all, the library gives up 5,000 ms after the deadline with cloud.attachFrame: the render proxy did not answer goto, a TimeoutError too.

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 a frame that leaves the hosts of the attachment refuses reads with frame: this frame no longer holds the document the app attached it to.

extension.attachFrame() and cloud.attachFrame() call one backend and take the same options, each with the cookies it serves. extension.attachFrame() serves 'native' alone, so any other value, and no cookies at all, is refused with ExtensionOperationUnsupportedError, operation 'cookies', before any wait. cloud.attachFrame() refuses 'native' with cloud.attachFrame: cookies: 'native' needs the FKN browser extension, and lockdown with the refusal above.

The extension one waits up to 1,000 ms for an ok handshake (or 150 ms after load). 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 {
// 'native' is the one value the extension serves, and the person's example.org cookies travel in
const
const frame: extension.Frame
frame
= await
(alias) namespace extension
import extension
extension
.
extension_d_exports.attachFrame(options: extension.AttachFrameOptions): Promise<extension.Frame> (+1 overload)
export extension_d_exports.attachFrame

Attaches through the extension, on cookies: 'native' only: the other values, the default among them, are the cloud render proxy's, refused here with ExtensionOperationUnsupportedError (operation 'cookies') before any exposure wait (AttachCookies). A domains entry, of the attach or of a goto, or a goto target's host, that the frame's header rule would carry onto an FKN platform host (one of them, one under one, or a parent such as app) is refused with an Error before anything is armed.

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'],
cookies?: extension.AttachCookies | undefined

Which jar, and with it which backend: 'persistent' when absent. See AttachCookies.

cookies
: 'native' })
await
const frame: extension.Frame
frame
.
function goto(url: string, options?: extension.GotoOptions): Promise<void>

Navigates the frame to url, which must pass the rules an iframe src does, and adds its host to the attachment. Resolves at options.waitUntil, and rejects with TimeoutError past options.timeout (GotoOptions).

goto
('https://example.org/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 Site data row on the sheet
} 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 installed but too old. Carries both numbers so an app can say which and link its listing. Match it by name, which survives a structured-clone hop.

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 does not run on the attach itself, because the floor, REQUIRED_EXTENSION_ABI, is 0, so the rejection you will see here is The FKN WebExtension is not installed, enabled or not exposed on this page. It is frame.requestPermissions and the category form of permissions.request that raise it, on an extension below ABI 2. It is one of the refusals 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, as the root call does on its two values:

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(options: AttachFrameOptions): Promise<Frame> (+1 overload)
export cloud_d_exports.attachFrame

Attaches the render proxy to an iframe the app mounted, or opens it in a window of its own with { window }. A window opens before the first await, so call this directly in a click handler. Serves cookies: 'persistent', the default, and 'ephemeral'; 'native', the browser's own cookies, is a TypeError before anything is attached or opened (AttachCookies).

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')!,
cookies?: AttachCookies | undefined

Which jar, and with it which backend: 'persistent' when absent. See AttachCookies.

cookies
: 'ephemeral' })
await
const frame: Frame
frame
.
function goto(url: string, options?: GotoOptions): Promise<void>

Navigates the frame to url, which must pass the rules an iframe src does, and adds its host to the attachment. Resolves at options.waitUntil, and rejects with TimeoutError past options.timeout (GotoOptions).

goto
('https://example.org/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 Site data row on the cloud card, and no activity log there

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.

attachFrame({ window }) opens the attachment in a child window instead of an iframe of yours, and resolves with a WindowFrame. It is built for sign-in pages, and your app reads the outcome through the same locators it uses inline. The iframe path is unchanged.

Call attachFrame directly inside the click or key handler, with no await before it. The window opens before the call’s first await, on that event’s activation, and the browser’s popup rules apply to it as to any window.open:

app.ts
const signIn: HTMLButtonElement
signIn
.
HTMLButtonElement.addEventListener<"click">(type: "click", listener: (this: HTMLButtonElement, ev: PointerEvent) => any, options?: boolean | AddEventListenerOptions): void (+1 overload)

The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.

MDN Reference

The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.

MDN Reference

addEventListener
('click', async () => {
try {
// the first call in the handler, so the click's activation opens the window
const
const login: WindowFrame
login
= await
function attachFrame(options: AttachWindowOptions): Promise<WindowFrame> (+1 overload)

Attaches FKN to an iframe the app mounted, or with { window } opens the attachment in a window of its own. cookies picks the jar and with it the backend (AttachCookies): 'persistent', the default, and 'ephemeral' run on the cloud render proxy whatever is installed, and never wait for the extension; 'native' runs on the extension, waiting for it to expose itself while the page loads (at most 10000 ms) and showing the install prompt when it does not. A blank attach is the cloud render proxy's alone, so beside 'native' it is a TypeError. frame.backend() says which backend serves an attachment.

A window opens before the first await, so call this directly in the click or key handler whose activation opens it. On 'persistent' or 'ephemeral' it opens on the cloud, extension or not. On 'native' it opens as a real browser window on the extension, with no exposure wait; an extension that does not announce attachWindow, or none at all, is refused at once with ExtensionOperationUnsupportedError (operation 'attachWindow'), before anything opens or is waited for, so the same click can still open one on another value.

attachFrame
({
window: FrameWindowOptions
window
: {
url?: string | undefined

Where the window opens: an http or https address, which must also pass the rules an iframe src does. Omitted, the window opens blank and the app navigates it with goto, which is how an app keeps the click's activation when the url needs an await first.

url
: 'https://example.org/login' },
domains?: string[] | undefined
domains
: ['example.org', 'accounts.example.org'],
})
const login: WindowFrame
login
.
function url(): string

The url last given to attachFrame or goto, never the frame's live location: a page that moves itself does not change it.

url
() // 'https://example.org/login'
const signIn: HTMLButtonElement
signIn
.
HTMLButtonElement.disabled: boolean

The HTMLButtonElement.disabled property indicates whether the control is disabled, meaning that it does not accept any clicks.

MDN Reference

disabled
= true
const login: WindowFrame
login
.
closed: Promise<void>

Resolves once the attachment has ended, and never rejects. Calls made after it resolved reject with the terminal detach error. On the cloud backend: the window was closed by anyone, reloaded, or left the page FKN attached; it stopped answering (a crash, a frozen page); it lost the app's cookie jar mid-session; or the app page went away. On the extension: the window was closed by anyone, close() ran, its page went to an FKN host (a goto redirected there rejects with the detach error), or the app page went away; the last two leave the window open for the person. Any other reload or navigation in the window does not end it, since the Frame follows the window's page.

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.

@param ― onfulfilled The callback to execute when the Promise is resolved.

@param ― onrejected The callback to execute when the Promise is rejected.

@returns ― A Promise for the completion of which ever callback is executed.

then
(() => {
const signIn: HTMLButtonElement
signIn
.
HTMLButtonElement.disabled: boolean

The HTMLButtonElement.disabled property indicates whether the control is disabled, meaning that it does not accept any clicks.

MDN Reference

disabled
= false }) // resolves whoever ends the window
} catch (
function (local var) error: unknown
error
) {
if (!(
function (local var) error: unknown
error
instanceof
class FrameWindowBlockedError

window.open returned null, so nothing was opened: the call ran without user activation, the popup blocker refused it, or the calling frame is sandboxed without allow-popups.

FrameWindowBlockedError
)) throw
function (local var) error: unknown
error
const signIn: HTMLButtonElement
signIn
.
Element.textContent: string | null
textContent
= 'Allow pop-ups for this page, then click again'
}
})

A call outside a user gesture, one the popup blocker refuses, and one from a frame sandboxed without allow-popups all reject with FrameWindowBlockedError, a class minted in your realm. When the address needs an await first, open the window blank with window: {} and goto() it once the address is known. A page served with Cross-Origin-Opener-Policy: same-origin cannot keep its window, and the attach rejects with cloud.attachFrame: the window closed before it connected, so serve it with same-origin-allow-popups instead.

The window takes these options, and iframe, lockdown or blank beside them is a TypeError:

OptionDefaultWhat it does
window.urlblankWhere the window opens, an http or https address held to the same rules as an iframe src. Omitted, goto() navigates it later.
window.width, window.height500, 700The inner size asked for, in CSS pixels. The browser may clamp it.
domains[]Every host the window will hold. Declare each host the sign-in passes through.
cookies'persistent''persistent' is the cookie jar of the app that opened the window. 'ephemeral' gives the window a jar of its own that ends with it. Both run on the cloud. 'native' is a real browser window on the extension, on the person’s own browser session, from its first store release after 0.1.54, see a window on the extension.
storageStatenoneWith cookies: 'ephemeral' only: cookies and localStorage put in the window’s jar before window.url loads, see seeding a fresh jar.
permissions[]Categories to ask for once the window has connected: on a card drawn in the window on the cloud, on the extension’s sheet over your page on 'native'.

On the cloud, a WindowFrame is the whole Frame: locators, goto(), url(), requestPermissions(), clearCookies(), and fetch() under the cloud’s rules. It adds two members. close() commits the window’s cookie changes to its jar, waiting at most 2,000 ms, then closes the window, and it is idempotent and never rejects.

closed resolves once the attachment has ended, whoever ended it: close(), the user, a reload or a navigation off the page FKN attached, a window that stopped answering, or your page going away. It never rejects. Every call after it rejects with a terminal error, cloud.attachFrame: the attached window closed, reloaded or left the page FKN attached; attach a fresh window, or cloud.attachFrame: the attached window stopped answering; attach a fresh window for a window that went silent.

On 'persistent', the default, a window uses the cookie jar of the app that opened it, the same jar as that app’s inline frames on 'persistent', and never another app’s. A sign-in in the window therefore reaches your inline frames: close() the window, goto() the inline frame again, and it loads signed in. cookies: 'ephemeral' gives the window a jar of its own instead, which ends with the window and can start from a storageState.

Cookies are all that carry over for an app outside the fkn.app site. The proxied site’s localStorage and IndexedDB in a window are kept per window and cleared when it closes, so a site that keeps its session in storage rather than in cookies does not carry it back to the inline frame.

A cloud window asks for the same four categories per website as an inline frame, on a card it draws itself, and keeps the answers for as long as it is open, see consent on the cloud backend. exists(), count() and isVisible() are severity 0 and never ask, which is how an app learns that a sign-in finished. A read while the window is on a host outside domains, the attach target and your goto() targets is refused with frame: this frame no longer holds the document the app attached it to, a terminal error.

On 'persistent' and 'ephemeral' a window runs on the cloud backend, extension installed or not. The whole flow is the recipe sign in through a window.

From the extension’s first store release after 0.1.54, cookies: 'native' opens a real browser window, a popup of the person’s own browser like one they opened themselves, on their own browser session for every site it shows. There is no FKN page in it: your page opens it with window.open on the click, the extension adopts that window by the tab the browser reports for it, and the WindowFrame follows whatever page the window holds. A sign-in there lands in the person’s own browser cookies, so an iframe attached on 'native' sees it on its next goto().

app.ts
const signIn: HTMLButtonElement
signIn
.
HTMLButtonElement.addEventListener<"click">(type: "click", listener: (this: HTMLButtonElement, ev: PointerEvent) => any, options?: boolean | AddEventListenerOptions): void (+1 overload)

The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.

MDN Reference

The addEventListener() method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.

MDN Reference

addEventListener
('click', async () => {
try {
// the first call in the handler: on 'native' the window opens on this click with no exposure wait
const
const login: WindowFrame
login
= await
function attachFrame(options: AttachWindowOptions): Promise<WindowFrame> (+1 overload)

Attaches FKN to an iframe the app mounted, or with { window } opens the attachment in a window of its own. cookies picks the jar and with it the backend (AttachCookies): 'persistent', the default, and 'ephemeral' run on the cloud render proxy whatever is installed, and never wait for the extension; 'native' runs on the extension, waiting for it to expose itself while the page loads (at most 10000 ms) and showing the install prompt when it does not. A blank attach is the cloud render proxy's alone, so beside 'native' it is a TypeError. frame.backend() says which backend serves an attachment.

A window opens before the first await, so call this directly in the click or key handler whose activation opens it. On 'persistent' or 'ephemeral' it opens on the cloud, extension or not. On 'native' it opens as a real browser window on the extension, with no exposure wait; an extension that does not announce attachWindow, or none at all, is refused at once with ExtensionOperationUnsupportedError (operation 'attachWindow'), before anything opens or is waited for, so the same click can still open one on another value.

attachFrame
({
window: FrameWindowOptions
window
: {
url?: string | undefined

Where the window opens: an http or https address, which must also pass the rules an iframe src does. Omitted, the window opens blank and the app navigates it with goto, which is how an app keeps the click's activation when the url needs an await first.

url
: 'https://example.org/login' },
domains?: string[] | undefined
domains
: ['example.org'],
cookies?: AttachCookies | undefined

The window's jar, with an iframe's values (AttachCookies). 'persistent', the default: the jar of the app that opened it, the same as that app's inline frames; a window whose app is on another site shares cookies only with those frames, and its site storage stays its own. 'ephemeral': a jar of the window's own that ends with it, seeded from storageState. Both run on the cloud backend whatever is installed. 'native': a real browser window on the extension, on the person's own browser session for every site it shows, as a window they opened themselves would be (WindowFrame says what differs there).

cookies
: 'native' })
const login: WindowFrame
login
.
function backend(): "cloud" | "extension" | undefined

Which backend serves this attachment, fixed for its life. The same call can draw a card on one backend and not the other, and spends a different identity on each, so an app must be able to tell. Undefined only for a Frame an app built itself with createFrame from a backend that names none.

backend
() // 'extension'
await
const login: WindowFrame
login
.
closed: Promise<void>

Resolves once the attachment has ended, and never rejects. Calls made after it resolved reject with the terminal detach error. On the cloud backend: the window was closed by anyone, reloaded, or left the page FKN attached; it stopped answering (a crash, a frozen page); it lost the app's cookie jar mid-session; or the app page went away. On the extension: the window was closed by anyone, close() ran, its page went to an FKN host (a goto redirected there rejects with the detach error), or the app page went away; the last two leave the window open for the person. Any other reload or navigation in the window does not end it, since the Frame follows the window's page.

closed
// the person closed it, close() ran, it reached an FKN host, or this page went away
} catch (
function (local var) error: unknown
error
) {
if (!(
function (local var) error: unknown
error
instanceof
class ExtensionOperationUnsupportedError

Thrown when the extension is current but does not serve the operation being asked for. message replaces the default advice where updating the extension cannot help.

ExtensionOperationUnsupportedError
)) throw
function (local var) error: unknown
error
function (local var) error: ExtensionOperationUnsupportedError
error
.
ExtensionOperationUnsupportedError.operation: string
operation
// 'attachWindow': no extension on the page, or one that does not serve windows
}
})

The call reads the extension’s handshake once and does not wait for it, since the window has to open on the click. With no extension on the page it is refused with attachFrame: a window on cookies: 'native' needs the FKN WebExtension, and with one that does not announce attachWindow (every store build through 0.1.54) with The FKN WebExtension does not support "attachWindow". Both are ExtensionOperationUnsupportedError with operation 'attachWindow', thrown before anything opens and with no install card, so the same click can still open a window on 'persistent'. A store build that serves windows announces ABI 5.

Where it differs from a cloud window:

  • It follows the page. A reload or a navigation in the window, the person’s or the site’s, does not end the attachment, and reads answer only while the window is on a host the attach, domains or a goto() named, as for an iframe. closed resolves when anyone closes the window, close() runs, its page goes to an FKN host, or your page goes away; the last two leave the window open for the person. Every call after it rejects with extension.attachFrame: the attached window closed; attach a fresh window.
  • close() waits for nothing. The window ran on the person’s own cookies, which the browser has already written.
  • Messages need the window’s link to your page. The page reaches your app with opener.postMessage(x, appOrigin), and frame.postMessage() reaches it, while that link holds. A page served with a Cross-Origin-Opener-Policy cuts it as it loads, and postMessage is then refused with frame.postMessage: this window's page no longer keeps a link to the app's page. Every other call still answers, since the extension knows the window by its tab.
  • Consent is asked on your page. The sheet is drawn over your app’s page, as every extension sheet is, never in the window.
  • evaluate keeps the page’s content security policy, so a page that forbids eval refuses with a named error.
  • It serves what an extension iframe serves. addScriptTag() and clearCookies() are refused by name, as on an iframe, storageState needs 'ephemeral', and the cloud’s FrameWindowRefusedError never comes from it. FrameWindowBlockedError still means the browser did not open the window.

While the window is attached, the extension blocks the attached site’s requests from that tab to FKN hosts, as it does for an iframe, see what is refused. A window adopted in the first seconds after the extension was installed, before its worker ever ran, can be missed: the attach then rejects with extension.attachFrame: the window this page opened was not found and closes the window, and the next attempt is adopted.

evaluate(pageFunction, arg?) runs a function inside the site’s own page realm, as a script of that page would: it sees the site’s window, its globals and its DOM. The function is sent as source, so it captures nothing from your scope and everything it needs comes through arg, which is structured-cloned at the call. The result comes back by structured clone:

app.ts
const
const title: string
title
= await
const frame: Frame
frame
.
evaluate<string, string>(pageFunction: string | ((arg: string) => string | Promise<string>), arg?: string | undefined): Promise<string>

Runs pageFunction inside the attached site's OWN page realm, as a script of that page would: it sees the site's window, its globals and its DOM. The function is sent as source (Function.prototype.toString), so it captures NOTHING from the caller's scope; everything it needs must come through arg. A plain source string is run as an expression, the way Playwright does, and is not called. A promise, returned by the function or produced by the expression, is awaited.

arg is structured-cloned at the call, in the app's realm, on both backends, and the result comes back by structured clone, so an ArrayBuffer or a typed array of bytes survives the round trip. A value structured clone refuses in arg (a function, a DOM node, an untransferred port) rejects with the platform's DataCloneError before anything is sent; a port goes to the page with postMessage. A thrown error or a rejected promise comes back as an Error carrying the page's own name and message. A result that cannot be cloned (a function, a DOM node) is a LocatorInvalidError, never a silent undefined and never a live handle into the page.

Every limit is refused by name, never a call left pending:

  • time, both backends: the code has 30 seconds to settle, counted after any consent card, and then the call rejects with TimeoutError. Only the wait ends there; code that never settles keeps running in the page.
  • size, extension only, a LocatorInvalidError: the source with its arg, and the result, each carry at most 32 MiB (binary data by its bytes, text by its UTF-8 size), the most its message relay carries.
  • Blob, extension only, a LocatorInvalidError: the relay cannot carry one, so pass or return await blob.arrayBuffer(). The cloud backend has neither limit, and returns a Blob as a Blob.

Needs the Evaluation grant on both backends; an app without it is refused by name. It runs on the attached frame only: a nested FrameLocator does not offer it, and a call there anyway is refused.

Called after goto resolved, whichever waitUntil, it runs once on the document that goto brought, never on the one it replaced. On the cloud backend the call waits for that document when it has not committed yet, and the wait counts toward the 30 seconds; if it has not come by the goto's own deadline (the goto failed, or loaded something that is not a page), or the app started another goto first, the call rejects with a LocatorError saying the code was not run.

On the extension it also needs an extension new enough to run code (ABI 3, older ones are refused with ExtensionOperationUnsupportedError), and it runs where the page's content security policy allows it: a frame whose host the app declared has its CSP replaced, so code runs; an undeclared frame, and every attached window, keeps its CSP, and a page that forbids eval refuses with a named error. On the cloud backend the code runs in the proxied document's realm, which mirage virtualizes: the page's own eval refusal and Trusted Types requirement do not refuse the source evaluate compiles, and every string that code compiles itself (eval, Function, a string timer) is held to them, as the page's own are.

evaluate
((
selector: string
selector
) =>
var document: Document

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

MDN Reference

document
.
ParentNode.querySelector<Element>(selectors: string): Element | null (+4 overloads)

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

MDN Reference

querySelector
(
selector: string
selector
)?.
Element.textContent: string | undefined
textContent
?? '', 'h1') // 'Catalog'
await
const frame: Frame
frame
.
function addScriptTag(options: AddScriptTagOptions): Promise<void>

Runs content as a classic inline script of the attached document, as one of that page's own scripts would run: its top-level declarations stay on the page's global for later scripts and evaluate calls, document.currentScript is the script while it runs, and the page's own error listeners see its top-level error. Mirage rewrites it as it rewrites the page's scripts, and the page's eval refusal and Trusted Types requirement do not refuse it, as for evaluate. Playwright's name and its content option; its url, path and type are not served, and FKN's sourceUrl names the script rather than loading one.

Resolves undefined once the script ran; what it installed lives as long as the document, and the document event says when that is gone. Same grant, limit and timing as evaluate: the Evaluation grant (one card covers both), 30000 ms counted after any consent card, the attached frame only, and after a goto the document that goto brought, waited for as evaluate waits. It runs once: it never rides the locator retry loop, and a call whose document left before it answered rejects and is not run again. Cloud only for now: the extension refuses it by name.

Refused:

  • TypeError, before anything is sent, in this order: frame.addScriptTag: options must be an object { content }; for Playwright's url, path and type, frame.addScriptTag: "<key>" is not served; it runs content as a classic inline script; frame.addScriptTag: unknown option "<key>"; frame.addScriptTag: content must be a string; frame.addScriptTag: sourceUrl must be a non-empty string with no line break.
  • an Error carrying the page's own name and message: the script's top-level throw, after it ran.
  • LocatorDeniedError: no Evaluation grant, the frame holds no page, or its document is outside the attachment. Against an FKN page that predates addScriptTag the call arrives with no proof of a grant and the render proxy refuses it: frame.addScriptTag: the call arrived without an Evaluation grant, so it was not run.
  • cloud, LocatorUnsupportedError frame.addScriptTag: this render proxy predates addScriptTag; reload the app.
  • cloud, LocatorError: the document of the goto before it had not come by that goto's deadline, or the app started another goto first, so the script was not run.
  • extension, ExtensionOperationUnsupportedError (operation 'addScriptTag') frame.addScriptTag: an extension frame does not run scripts through mirage yet; attach with cloud.attachFrame, before anything is dispatched.
  • TimeoutError frame.addScriptTag: no answer within 30000ms; the script may or may not have run.
  • the terminal detach error once the attachment ended.

addScriptTag
({
content: string

The script's source text.

content
: 'function greet(name) { return `hello ${name}` }',
sourceUrl?: string | undefined

Names the script in stack traces through //# sourceURL. Absent, its frames name the document.

sourceUrl
: 'greet.js' })
await
const frame: Frame
frame
.
evaluate<any, undefined>(pageFunction: string | ((arg: undefined) => any), arg?: undefined): Promise<any>

Runs pageFunction inside the attached site's OWN page realm, as a script of that page would: it sees the site's window, its globals and its DOM. The function is sent as source (Function.prototype.toString), so it captures NOTHING from the caller's scope; everything it needs must come through arg. A plain source string is run as an expression, the way Playwright does, and is not called. A promise, returned by the function or produced by the expression, is awaited.

arg is structured-cloned at the call, in the app's realm, on both backends, and the result comes back by structured clone, so an ArrayBuffer or a typed array of bytes survives the round trip. A value structured clone refuses in arg (a function, a DOM node, an untransferred port) rejects with the platform's DataCloneError before anything is sent; a port goes to the page with postMessage. A thrown error or a rejected promise comes back as an Error carrying the page's own name and message. A result that cannot be cloned (a function, a DOM node) is a LocatorInvalidError, never a silent undefined and never a live handle into the page.

Every limit is refused by name, never a call left pending:

  • time, both backends: the code has 30 seconds to settle, counted after any consent card, and then the call rejects with TimeoutError. Only the wait ends there; code that never settles keeps running in the page.
  • size, extension only, a LocatorInvalidError: the source with its arg, and the result, each carry at most 32 MiB (binary data by its bytes, text by its UTF-8 size), the most its message relay carries.
  • Blob, extension only, a LocatorInvalidError: the relay cannot carry one, so pass or return await blob.arrayBuffer(). The cloud backend has neither limit, and returns a Blob as a Blob.

Needs the Evaluation grant on both backends; an app without it is refused by name. It runs on the attached frame only: a nested FrameLocator does not offer it, and a call there anyway is refused.

Called after goto resolved, whichever waitUntil, it runs once on the document that goto brought, never on the one it replaced. On the cloud backend the call waits for that document when it has not committed yet, and the wait counts toward the 30 seconds; if it has not come by the goto's own deadline (the goto failed, or loaded something that is not a page), or the app started another goto first, the call rejects with a LocatorError saying the code was not run.

On the extension it also needs an extension new enough to run code (ABI 3, older ones are refused with ExtensionOperationUnsupportedError), and it runs where the page's content security policy allows it: a frame whose host the app declared has its CSP replaced, so code runs; an undeclared frame, and every attached window, keeps its CSP, and a page that forbids eval refuses with a named error. On the cloud backend the code runs in the proxied document's realm, which mirage virtualizes: the page's own eval refusal and Trusted Types requirement do not refuse the source evaluate compiles, and every string that code compiles itself (eval, Function, a string timer) is held to them, as the page's own are.

evaluate
(() => (
module globalThis
globalThis
as any).
any
greet
('FKN')) // 'hello FKN', the script's declaration stayed on the page's global

addScriptTag({ content, sourceUrl? }) is Playwright’s name and its content option: it runs content as a classic inline script of the attached document, so its top-level declarations stay on the page’s global for later scripts and evaluate() calls, and the page’s own error listeners see its top-level error. sourceUrl names the script in stack traces. Playwright’s url, path and type are refused by name, is not served; it runs content as a classic inline script. It resolves undefined once the script ran, runs once and never inside the locator retry loop, and a top-level throw comes back as an Error with the page’s own name and message.

Both need the Evaluation grant (one card covers both), run on the attached frame only, never on a nested frameLocator(), and have 30 seconds to settle, counted after any consent card, before they reject with a TimeoutError, see frame.evaluate: the code did not settle within. Called after goto() resolved, either runs on the document that goto brought.

On the cloud the code runs in the proxied document’s realm, which mirage virtualizes: the page’s own eval refusal and Trusted Types requirement do not refuse what evaluate() and addScriptTag() compile, and every string that code compiles itself is held to them, as the page’s own are. addScriptTag() is cloud only for now. The extension refuses it with ExtensionOperationUnsupportedError, operation 'addScriptTag', and runs evaluate() in the page’s main world, where an undeclared frame, and every attached window, keeps its content security policy.

postMessage(message, targetOrigin) delivers a real message event to the page, as iframe.contentWindow.postMessage would, and on(type, listener, { signal }) listens to the page. on and off are Playwright’s names, and { signal } is FKN’s addition:

app.ts
const
const listening: AbortController
listening
= new
var AbortController: new () => AbortController

The AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired.

MDN Reference

AbortController
()
const frame: Frame
frame
.
on<"message">(type: "message", listener: FrameEventListener<"message">, options?: {
signal?: AbortSignal;
}): void

message: what the page posted to the app, with its origin and ports, and this Frame as source. The page reaches the app with parent.postMessage(x, appOrigin), top.postMessage(x, appOrigin), or event.source.postMessage(x, event.origin) on a message the app sent it; in an extension window, with opener.postMessage(x, appOrigin). A document outside the attachment is dropped, never masked. On the cloud backend a post to '*' does not reach the app: there the page is its own parent, so it cannot be told apart from the page messaging itself, and it stays with the page. The extension delivers it, as a real parent would receive it.

document: a new document arrived in the frame. What evaluate installed in the one before is gone, with every port it held, and nothing is reinstalled for the app: this is the signal to install again. It can fire twice for one document (a return from the back/forward cache), so an install should be idempotent. Cloud: fires for a document inside the attachment, with its origin, and not for the document the frame already held when the first listener was added. Extension: fires on every load of an iframe and every new document of a window, with origin ''.

Listeners stay on the Frame across navigations and end with the attachment, or when signal aborts, FKN's addition to Playwright's on. A listener added twice is called once, and one whose signal already aborted is not added. A listener that throws is reported and does not stop the others. An unknown type, or a listener that is not a function, is a TypeError. On the cloud backend, against an FKN page that predates messaging, nothing arrives and the console says so once.

on
('message', (
event: FrameMessageEvent
event
) => {
event: FrameMessageEvent
event
.
origin: string

The page's origin as the site knows it (https://anilist.co), never FKN's own.

origin
// 'https://example.org', the page's origin as the site knows it
event: FrameMessageEvent
event
.
data: unknown
data
// what the page posted with parent.postMessage(data, appOrigin)
}, {
signal?: AbortSignal | undefined
signal
:
const listening: AbortController
listening
.
AbortController.signal: AbortSignal

The signal read-only property of the AbortController interface returns an AbortSignal object instance, which can be used to communicate with/abort an asynchronous operation as desired.

MDN Reference

signal
})
const frame: Frame
frame
.
on<"document">(type: "document", listener: FrameEventListener<"document">, options?: {
signal?: AbortSignal;
}): void

message: what the page posted to the app, with its origin and ports, and this Frame as source. The page reaches the app with parent.postMessage(x, appOrigin), top.postMessage(x, appOrigin), or event.source.postMessage(x, event.origin) on a message the app sent it; in an extension window, with opener.postMessage(x, appOrigin). A document outside the attachment is dropped, never masked. On the cloud backend a post to '*' does not reach the app: there the page is its own parent, so it cannot be told apart from the page messaging itself, and it stays with the page. The extension delivers it, as a real parent would receive it.

document: a new document arrived in the frame. What evaluate installed in the one before is gone, with every port it held, and nothing is reinstalled for the app: this is the signal to install again. It can fire twice for one document (a return from the back/forward cache), so an install should be idempotent. Cloud: fires for a document inside the attachment, with its origin, and not for the document the frame already held when the first listener was added. Extension: fires on every load of an iframe and every new document of a window, with origin ''.

Listeners stay on the Frame across navigations and end with the attachment, or when signal aborts, FKN's addition to Playwright's on. A listener added twice is called once, and one whose signal already aborted is not added. A listener that throws is reported and does not stop the others. An unknown type, or a listener that is not a function, is a TypeError. On the cloud backend, against an FKN page that predates messaging, nothing arrives and the console says so once.

on
('document', () => {
// a new document arrived: what evaluate installed in the last one is gone, so install it again
})
await
const frame: Frame
frame
.
function postMessage(message: unknown, targetOrigin: string, transfer?: Transferable[]): Promise<void> (+1 overload)

Delivers message to the page in the frame as a real message event, the way iframe.contentWindow.postMessage would. The page sees a trusted event: data is the message, ports are the MessagePorts transfer moved, source is the page's own parent, and origin is this app's origin. So a page script that evaluate installed can take event.ports[0] and run any port protocol (osra included) over it, and no FKN code sits on that port afterwards.

targetOrigin is the page's origin as the site knows it (https://anilist.co), parsed as the platform parses it, so a trailing slash or a path is fine. The message is delivered only if the frame's document is on that origin, and otherwise dropped silently, as Window.postMessage does. '*', the default, means whatever document the frame holds; the extension refuses it for now.

It resolves once the message was handed to the browser for the document the frame holds, in the order the calls were made. That says nothing about whether the page listened, and a message dropped for its origin resolves the same way. Sent after goto resolved, it goes to the document that goto brought, as evaluate runs there.

Refused, and never sent again. On the cloud backend a refusal that depends on the grant or on the frame's document comes after transfer was detached, and loses what it moved with the message; every other refusal comes before anything is detached.

  • TypeError, before anything is sent or detached: a targetOrigin that is not '*' or an origin ('/' included), or a transfer that is not an array. A message that cannot be cloned is the platform's own DataCloneError.
  • LocatorDeniedError naming nothing: targetOrigin names a host outside the attachment, or the frame's document is outside it.
  • LocatorDeniedError "frame: navigate the frame before postMessage": the frame holds no page.
  • cloud, LocatorDeniedError: the app has no Evaluation grant, which is asked for on first use as evaluate asks. Messaging sits under Evaluation because the message arrives as the page's own traffic and a port handed over is a live channel into the page.
  • cloud, LocatorUnsupportedError: an FKN page or render proxy that predates messaging.
  • cloud, LocatorError: the document changed during the call, so the message may or may not have reached it and is not sent to the next one; or the document of the goto before it had not come by that goto's deadline (the goto failed, or loaded something that is not a page) or before the app started another goto, so nothing was sent.
  • extension, LocatorUnsupportedError: '*', which needs an extension release that reports the frame's origin. Pass the origin.
  • extension, LocatorDeniedError: a window whose page cut its link to the app's page (WindowFrame).
  • the terminal detach error once the attachment ended.

postMessage
({
type: string
type
: 'hello' }, 'https://example.org') // dropped silently unless the frame's document is on that origin
const listening: AbortController
listening
.
AbortController.abort(reason?: any): void

The abort() method of the AbortController interface aborts an asynchronous operation before it has completed.

MDN Reference

abort
() // removes the message listener, as frame.off('message', listener) does

A listener stays on the Frame across navigations and ends with the attachment, off(), or its signal. An unknown type is refused with the event type must be 'message' or 'document', and a listener that is not a function with frame.on: the listener must be a function. document can fire twice for one document, so make what it installs idempotent.

clearCookies(options?) removes cookies from the attachment’s jar, as Playwright’s browserContext.clearCookies removes them from a context. It is how an app signs a user out of a site they signed in to inside an FKN frame: that session lives in FKN’s jar, never in your app, so your app cannot remove it any other way:

app.ts
await
const player: Frame
player
.
function clearCookies(options?: ClearCookiesOptions): Promise<void>

Removes cookies from this attachment's cookie jar, as Playwright's browserContext.clearCookies removes them from a context. With no options it removes every cookie of the sites this attachment reaches; with options, only the ones that match every option given (ClearCookiesOptions). Each option is a string for now: a RegExp is refused.

For signing out of a site the user signed in to inside an FKN frame: that session lives in FKN's jar, never in the app, so the app cannot remove it any other way.

Which jar: with cookies: 'persistent', the default, the cloud jar of the app's top-level site, which every app of that site shares (every fkn.app app shares one), so a removal there signs every one of those apps out of that site. With 'ephemeral' the attachment's own jar.

Which cookies: only those of a site this attachment reaches, a site being a host's registered domain under the Public Suffix List, private section included: the attach url's host (the empty page's, for blank), each goto target's host from the goto's send, and domains. www.youtube.com reaches every *.youtube.com cookie and no google.com one, the rule browsers follow for Clear-Site-Data: "cookies". A host under the same name is still another site when a suffix lies between: amazonaws.com reaches no mybucket.s3.amazonaws.com cookie, since s3.amazonaws.com is a suffix. A host that is itself a public suffix (com, co.uk, github.io) is no site, and reaches only the cookies set for exactly that host. A Partitioned cookie matches in every partition.

Once it resolves: no request a document of this attachment starts carries a removed cookie, its documents' document.cookie lists none, and the removal is committed to the jar, so an attachment created afterwards never sees one. Another live attachment on the same jar (another tab, another app's frame) drops them when it hears the commit, normally within milliseconds; a request it started before then may still carry one. A request already in flight keeps what it was sent with, and a later response may set cookies again, as in any browser.

It resolves undefined and never says what or how much it removed, so it cannot tell an app whether the user had a session on a site. It takes the same steps and the same store write whether or not a cookie matched, so neither how long it takes nor which refusal it meets says so either. That is why a RegExp is refused before anything is sent: it would be tested in the render proxy against cookies the app cannot read, and a pattern slow on some of them would make the call's duration, or a TimeoutError, say whether the jar holds one. Calling it again is harmless. It does not tell the site: the session stays valid there until it lapses, and FKN no longer holds it.

No consent card on either jar, and a frame holding no page is served. Cloud only. It runs once, never inside the locator retry loop, and waits at most 30000 ms.

Refused:

  • TypeError, before anything is sent, in this order: frame.clearCookies: options must be an object; frame.clearCookies: unknown option "<key>"; frame.clearCookies: <key> is a RegExp, and clearCookies takes strings for name, domain and path for now; frame.clearCookies: <key> must be a string; frame.clearCookies: <key> must not be empty; leave it out to match every <key>.
  • LocatorDeniedError frame.clearCookies: this attachment reaches no site yet; attach a url, goto one, or declare it in domains.
  • LocatorDeniedError frame.clearCookies: <domain> is not on a site this attachment reaches; goto it or declare it in domains, for a string domain.
  • cloud, LocatorUnsupportedError frame.clearCookies: this FKN page predates clearCookies; reload the app to load the current one, with nothing sent.
  • cloud, LocatorUnsupportedError frame.clearCookies: this render proxy predates clearCookies; reload the app.
  • cloud, Error frame.clearCookies: the cookies are gone from this attachment, but its jar did not take the removal, so other attachments may still send them; call it again.
  • cloud, TimeoutError frame.clearCookies: the render proxy did not answer within 30000ms; the cookies may or may not be gone, and calling it again is safe.
  • extension, ExtensionOperationUnsupportedError (operation 'clearCookies') frame.clearCookies: an extension frame runs on the browser's own cookies (cookies: 'native'), which FKN does not clear; attach with cookies: 'persistent' to clear the app's jar, before anything is dispatched.
  • the terminal detach error once the attachment ended.

clearCookies
({
name?: string | undefined

Only cookies with this name.

name
: 'session',
domain?: string | undefined

Only cookies with this domain, in the form Playwright reports it: .youtube.com for a cookie its subdomains also receive, www.youtube.com for a host-only one, so 'youtube.com' matches neither of those. It must be on a site this attachment reaches.

domain
: '.example.org' }) // one cookie, as Playwright reports its domain
await
const player: Frame
player
.
function clearCookies(options?: ClearCookiesOptions): Promise<void>

Removes cookies from this attachment's cookie jar, as Playwright's browserContext.clearCookies removes them from a context. With no options it removes every cookie of the sites this attachment reaches; with options, only the ones that match every option given (ClearCookiesOptions). Each option is a string for now: a RegExp is refused.

For signing out of a site the user signed in to inside an FKN frame: that session lives in FKN's jar, never in the app, so the app cannot remove it any other way.

Which jar: with cookies: 'persistent', the default, the cloud jar of the app's top-level site, which every app of that site shares (every fkn.app app shares one), so a removal there signs every one of those apps out of that site. With 'ephemeral' the attachment's own jar.

Which cookies: only those of a site this attachment reaches, a site being a host's registered domain under the Public Suffix List, private section included: the attach url's host (the empty page's, for blank), each goto target's host from the goto's send, and domains. www.youtube.com reaches every *.youtube.com cookie and no google.com one, the rule browsers follow for Clear-Site-Data: "cookies". A host under the same name is still another site when a suffix lies between: amazonaws.com reaches no mybucket.s3.amazonaws.com cookie, since s3.amazonaws.com is a suffix. A host that is itself a public suffix (com, co.uk, github.io) is no site, and reaches only the cookies set for exactly that host. A Partitioned cookie matches in every partition.

Once it resolves: no request a document of this attachment starts carries a removed cookie, its documents' document.cookie lists none, and the removal is committed to the jar, so an attachment created afterwards never sees one. Another live attachment on the same jar (another tab, another app's frame) drops them when it hears the commit, normally within milliseconds; a request it started before then may still carry one. A request already in flight keeps what it was sent with, and a later response may set cookies again, as in any browser.

It resolves undefined and never says what or how much it removed, so it cannot tell an app whether the user had a session on a site. It takes the same steps and the same store write whether or not a cookie matched, so neither how long it takes nor which refusal it meets says so either. That is why a RegExp is refused before anything is sent: it would be tested in the render proxy against cookies the app cannot read, and a pattern slow on some of them would make the call's duration, or a TimeoutError, say whether the jar holds one. Calling it again is harmless. It does not tell the site: the session stays valid there until it lapses, and FKN no longer holds it.

No consent card on either jar, and a frame holding no page is served. Cloud only. It runs once, never inside the locator retry loop, and waits at most 30000 ms.

Refused:

  • TypeError, before anything is sent, in this order: frame.clearCookies: options must be an object; frame.clearCookies: unknown option "<key>"; frame.clearCookies: <key> is a RegExp, and clearCookies takes strings for name, domain and path for now; frame.clearCookies: <key> must be a string; frame.clearCookies: <key> must not be empty; leave it out to match every <key>.
  • LocatorDeniedError frame.clearCookies: this attachment reaches no site yet; attach a url, goto one, or declare it in domains.
  • LocatorDeniedError frame.clearCookies: <domain> is not on a site this attachment reaches; goto it or declare it in domains, for a string domain.
  • cloud, LocatorUnsupportedError frame.clearCookies: this FKN page predates clearCookies; reload the app to load the current one, with nothing sent.
  • cloud, LocatorUnsupportedError frame.clearCookies: this render proxy predates clearCookies; reload the app.
  • cloud, Error frame.clearCookies: the cookies are gone from this attachment, but its jar did not take the removal, so other attachments may still send them; call it again.
  • cloud, TimeoutError frame.clearCookies: the render proxy did not answer within 30000ms; the cookies may or may not be gone, and calling it again is safe.
  • extension, ExtensionOperationUnsupportedError (operation 'clearCookies') frame.clearCookies: an extension frame runs on the browser's own cookies (cookies: 'native'), which FKN does not clear; attach with cookies: 'persistent' to clear the app's jar, before anything is dispatched.
  • the terminal detach error once the attachment ended.

clearCookies
() // every cookie of every site this attachment reaches
await
const player: Frame
player
.
function goto(url: string, options?: GotoOptions): Promise<void>

Navigates the frame to url, which must pass the rules an iframe src does, and adds its host to the attachment. Resolves at options.waitUntil, and rejects with TimeoutError past options.timeout (GotoOptions).

goto
(
const player: Frame
player
.
function url(): string

The url last given to attachFrame or goto, never the frame's live location: a page that moves itself does not change it.

url
()) // the same page again, signed out

With no options it removes every cookie of the sites the attachment reaches. With options it removes only the ones that match every option given: name, domain in the form Playwright reports it (.example.org for a cookie its subdomains also receive, www.example.org for a host-only one), and path, each matched exactly. Each is a string for now: a RegExp is refused with is a RegExp, and clearCookies takes strings for name, domain and path for now, since a pattern tested against cookies your app cannot read would let its running time say which of them exist. An empty string is refused where Playwright would read it as no filter.

Which jar: on 'persistent', the default, your app’s cloud jar, which every app of your top-level site shares (every fkn.app app shares one), so a removal there signs every one of those apps out of that site. On 'ephemeral', the attachment’s own jar. The seal on this device changes none of this: the jar opens with its own key, so a removal is never refused for want of one.

Which cookies: only those of a site the attachment reaches, a site being a host’s registered domain under the Public Suffix List: the attach url’s host (the empty page’s, for blank), each goto() target’s host, and domains. www.example.org reaches every *.example.org cookie and no example.net one, the rule browsers follow for Clear-Site-Data: "cookies". A host that is itself a public suffix, such as github.io, reaches only the cookies set for exactly that host. A domain off those sites is refused with is not on a site this attachment reaches, and an attachment with no site at all with frame.clearCookies: this attachment reaches no site yet.

Once it resolves, no request a document of the attachment starts carries a removed cookie, and the removal is committed to the jar, so an attachment created afterwards never sees one. Another live attachment on the same jar drops them when it hears the commit, normally within milliseconds. It resolves undefined and never says what or how much it removed, so it cannot tell your app whether the user had a session on a site, and calling it again is harmless. It does not tell the site: the session stays valid there until it lapses, and FKN no longer holds it.

It asks no consent card, runs once, never inside the locator retry loop, and waits at most 30,000 ms before it rejects with a TimeoutError. It is cloud only: an extension frame runs on the person’s own browser cookies, which FKN does not clear, and is refused with ExtensionOperationUnsupportedError, operation 'clearCookies'.

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
// the default, 'persistent', runs on the cloud's shared jar, where frame.fetch is refused
const
const frame: Frame
frame
= await
function attachFrame(options: AttachFrameOptions): Promise<Frame> (+1 overload)

Attaches FKN to an iframe the app mounted, or with { window } opens the attachment in a window of its own. cookies picks the jar and with it the backend (AttachCookies): 'persistent', the default, and 'ephemeral' run on the cloud render proxy whatever is installed, and never wait for the extension; 'native' runs on the extension, waiting for it to expose itself while the page loads (at most 10000 ms) and showing the install prompt when it does not. A blank attach is the cloud render proxy's alone, so beside 'native' it is a TypeError. frame.backend() says which backend serves an attachment.

A window opens before the first await, so call this directly in the click or key handler whose activation opens it. On 'persistent' or 'ephemeral' it opens on the cloud, extension or not. On 'native' it opens as a real browser window on the extension, with no exposure wait; an extension that does not announce attachWindow, or none at all, is refused at once with ExtensionOperationUnsupportedError (operation 'attachWindow'), before anything opens or is waited for, so the same click can still open one on another value.

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'] })
// so attach with cookies: 'ephemeral' on the cloud, or 'native' for the person's session on the extension
await
const frame: Frame
frame
.
function goto(url: string, options?: GotoOptions): Promise<void>

Navigates the frame to url, which must pass the rules an iframe src does, and adds its host to the attachment. Resolves at options.waitUntil, and rejects with TimeoutError past options.timeout (GotoOptions).

goto
('https://example.org/')
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.

@param ― text A valid JSON string.

@param ― reviver 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: network on example.org (frame.fetchRead this frame)', the 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: network on <host> (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 cookies is 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.

From the first extension store release after 0.1.54, a fetch that fails once it may have been sent, on a network error, a cross-origin read the browser refused, or a redirect hop onto an FKN host the extension blocked, ends the call with the terminal frame.fetch: <error>; not retried, since the request may already have been sent. It is not sent again every 50 ms to the deadline, so a POST goes out at most once.

On the extension the two keys, frame.fetchRead and frame.fetchWrite, sit at severity 3 and both belong to Network, so one row covers reading and writing, see permission keys. The reason you pass is shown on that row, with the chip load or send naming which one is needed now. Every call writes one receipt row to the activity log, and 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 network on <host>. 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 nine you are most likely to meet:

MessageWhat happened
attachFrame: syncCookies was replaced by cookiesAn options object written before 0.9.42, see which jar, which backend.
attachFrame: cookies: '<value>' runs on the cloud render proxyextension.attachFrame() without cookies: 'native'.
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 hosts the attach declared. Terminal.
attachFrame: the browser did not open the windowFrameWindowBlockedError: the call to open a window ran without the click’s activation, or popups are blocked.

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.

From the first extension store release after 0.1.54, the attached site cannot reach a platform host either. Before the extension hands the frame to your app, and before each goto() navigates it, it installs browser rules in your app’s tab, so a request the site sends to an FKN host fails as a network error: its own fetch, a WebSocket, code evaluate() ran there, a frame inside it, or a redirect hop of a frame.fetch. Requests from the shared and service workers that site starts are blocked the same way, with the exceptions on limitations. Your app’s own requests are untouched, so an app served from an FKN domain attaches and fetches as before, and a browser that refuses the rules ends the call with could not install the rule that keeps an attached site off FKN platform domains.

A window on the extension gets the same rules on its own tab, armed before the attach resolves and kept across every page the window shows until it closes; a window whose rules the browser refuses is closed, with so the window was closed.

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

CallWaitHow long
attachFrame({ cookies: 'native' })the extension’s exposure150 ms after the document is complete without the extension, 10,000 ms at most, then the install card. The other two values do not wait.
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
attachFrame({ window }) on the cloudthe window’s channel60,000 ms, then cloud.attachFrame: timed out connecting to the window
attachFrame({ window }) on the cloudthe window ready65,000 ms, then cloud.attachFrame: the window never became ready
attachFrame({ window, cookies: 'native' })the extension hearing of the window2,000 ms, then extension.attachFrame: the window this page opened was not found, and the window is closed. There is no exposure wait.
goto()the new documenttimeout, 30,000 ms by default, plus 5,000 ms in the library on the cloud, then a TimeoutError
evaluate(), addScriptTag()the code settling30,000 ms, counted after any consent card, then a TimeoutError
clearCookies()the render proxy’s answer30,000 ms, then a TimeoutError
attachFrame({ storageState })the render proxy taking the seed20,000 ms, then attachFrame: the render proxy did not take the storageState seed, a TimeoutError

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 cookies: 'native' and 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
  • a frame.requestPermissions() for Interaction, one row covering the whole page
  • two clicks inside .controls, covered by that row
  • a click outside the box, covered by the same row, since the grant names the site
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. On an extension below ABI 2 the fourth button falls back to the pre-category ask, and the click outside the box then prompts again. The same six buttons are walked from the locator side in locators and actions.