Frames
attachFrame() puts another website inside your page and hands back a Frame you navigate and drive. This page covers the two backends a frame runs on, every option, goto() and url(), frame.fetch() and its two refusals, what is refused with which message, and the timeouts on each path.
The page inside the <iframe> is the real site. Every read and action on it goes through locators and actions and permissions and consent:
const const frame: Frame
frame = await function attachFrame(options: AttachFrameOptions): Promise<Frame>
attachFrame({ iframe: HTMLIFrameElement
iframe: var document: Document
window.document returns a reference to the document contained in the window.
document.ParentNode.querySelector<"iframe">(selectors: "iframe"): HTMLIFrameElement | null (+4 overloads)
Returns the first element that is a descendant of node that matches selectors.
querySelector('iframe')! })await const frame: Frame
frame.function goto(url: string, options?: GotoOptions): Promise<void>
goto('https://example.org/catalog')
await const frame: Frame
frame.locator: (selector: string) => Locator$1<Extended<{ readonly element: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly getByRole: { readonly to: "element"; readonly resolve: (context: LocatorContext, role: string) => Element[]; readonly render: (role: unknown) => { fragment: string; }; }; readonly getByText: { readonly to: "element"; readonly resolve: (context: LocatorContext, text: string) => Element[]; readonly render: (text: unknown) => { fragment: string; }; }; readonly getByTestId: { readonly to: "element"; readonly resolve: (context: LocatorContext, testId: string) => Element[]; readonly render: (testId: unknown) => { fragment: string; }; }; readonly first: { readonly to: "element"; readonly resolve: (context: LocatorContext) => Element[]; readonly render: () => { fragment: string; }; }; readonly nth: { readonly to: "element"; readonly resolve: (context: LocatorContext, index: number) => Element[]; readonly render: (index: unknown) => { fragment: string; }; }; }; readonly operations: { readonly click: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly fill: { readonly resolve: (context: LocatorContext, value: string, _options?: OperationOptions) => void; }; readonly hover: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly textContent: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => string; }; readonly getAttribute: { readonly resolve: (context: LocatorContext, name: string, _options?: OperationOptions) => string | null; }; readonly isVisible: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; readonly count: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => number; }; readonly exists: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; }; }; readonly frame: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly owner: { readonly to: "frame"; readonly barrier: "up"; readonly resolve: (_context: LocatorContext) => Element[]; readonly render: () => { fragment: string; separator: "up"; }; }; }; readonly operations: { readonly addStyleTag: { readonly resolve: (context: LocatorContext, options: AddStyleTagOptions$1) => void; }; readonly fetch: { readonly kind: ChainKind; readonly ...
locator('h1').textContent: (_options?: LocatorOptions | undefined) => Promise<string>
textContent() // 'Catalog', after one sheet for read.text on the extensionconst frame: Frame
frame.function url(): string
url() // 'https://example.org/catalog'The call resolves once a backend has taken the iframe. A backend is where a call runs. On the extension, the content script has registered the iframe, installed the header rule for any domains and recorded the embed.iframe consent. On the cloud, the fkn.app page has answered the handshake and the render proxy, the cloud frame backend, has reported ready.
Both backends mark the frame for the user: the extension pushes an identity pill into the framed document, and the cloud draws an identity bar naming your app.
The Frame is the same object on either backend. Past goto() and url() it carries addStyleTag(), fetch(), frameLocator(), owner() and ensure(), and locator() starts a locator chain, a Locator built by chaining selectors. The root has no click() or getByRole(), so reach an element through locator() first.
A blank iframe holds no document a locator can reach until the first goto() commits one. On the extension a blank frame gets no content script, so a locator call before that navigation waits out its 30,000 ms deadline.
Try it live
Section titled “Try it live”The demo below attaches a blank iframe with domains: ['en.wikipedia.org', 'wikipedia.org'] and syncCookies: false, navigates it to the Wikipedia search page, and restyles that page with addStyleTag(). It lands on the extension when the extension is exposed, and on the cloud render proxy otherwise. syncCookies: false gives the cloud attachment a session of its own, which frame.fetch() needs there, and on the extension it means none of the user’s cookies are copied in.
With the extension exposed, the button first puts read.text, act.type and act.click on one consent sheet through permissions.request(), the call that asks for several at once. The consent sheet is what the extension shows a user before an action above severity 0, and a refusal there stops the run. Without the extension the same three run on the render proxy with no prompt. The run reads the heading, fills the search box and presses the search button.
Two backends
Section titled “Two backends”The extension backend drives the iframe in place through the FKN extension’s content script, so the user’s session can travel with it. The cloud backend loads the site through the render proxy, needs nothing installed and copies none of the user’s cookies.
The root attachFrame() takes the extension backend when the extension is exposed, when lockdown is asked for, or when the realm, the JavaScript execution context the call runs in, has no window. Otherwise it takes the render proxy after a short wait for the extension, see backends. The Frame carries no tag saying which backend it landed on, so ask isExtensionExposed() right after the attach:
const const frame: Frame
frame = await function attachFrame(options: AttachFrameOptions): Promise<Frame>
attachFrame({ iframe: HTMLIFrameElement
iframe: var document: Document
window.document returns a reference to the document contained in the window.
document.ParentNode.querySelector<"iframe">(selectors: "iframe"): HTMLIFrameElement | null (+4 overloads)
Returns the first element that is a descendant of node that matches selectors.
querySelector('iframe')!, domains?: string[] | undefined
domains: ['example.org'] })const const onExtension: boolean
onExtension = function isExtensionExposed(): boolean
Unchanged on purpose, and still exported: a page half built before versioning calls exactly this,
and an extension that announces an ABI must keep answering it the same way. Backwards
compatibility here runs in BOTH directions, which is the only property that matters when neither
end updates on demand.
isExtensionExposed() // true when the attach landed on the extension, false when it went to the cloud
if (const onExtension: boolean
onExtension) { // the user's example.org cookies came in with the attach await const frame: Frame
frame.function goto(url: string, options?: GotoOptions): Promise<void>
goto('https://example.org/account') await const frame: Frame
frame.locator: (selector: string) => Locator$1<Extended<{ readonly element: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly getByRole: { readonly to: "element"; readonly resolve: (context: LocatorContext, role: string) => Element[]; readonly render: (role: unknown) => { fragment: string; }; }; readonly getByText: { readonly to: "element"; readonly resolve: (context: LocatorContext, text: string) => Element[]; readonly render: (text: unknown) => { fragment: string; }; }; readonly getByTestId: { readonly to: "element"; readonly resolve: (context: LocatorContext, testId: string) => Element[]; readonly render: (testId: unknown) => { fragment: string; }; }; readonly first: { readonly to: "element"; readonly resolve: (context: LocatorContext) => Element[]; readonly render: () => { fragment: string; }; }; readonly nth: { readonly to: "element"; readonly resolve: (context: LocatorContext, index: number) => Element[]; readonly render: (index: unknown) => { fragment: string; }; }; }; readonly operations: { readonly click: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly fill: { readonly resolve: (context: LocatorContext, value: string, _options?: OperationOptions) => void; }; readonly hover: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly textContent: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => string; }; readonly getAttribute: { readonly resolve: (context: LocatorContext, name: string, _options?: OperationOptions) => string | null; }; readonly isVisible: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; readonly count: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => number; }; readonly exists: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; }; }; readonly frame: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly owner: { readonly to: "frame"; readonly barrier: "up"; readonly resolve: (_context: LocatorContext) => Element[]; readonly render: () => { fragment: string; separator: "up"; }; }; }; readonly operations: { readonly addStyleTag: { readonly resolve: (context: LocatorContext, options: AddStyleTagOptions$1) => void; }; readonly fetch: { readonly kind: ChainKind; readonly ...
locator('.username').textContent: (_options?: LocatorOptions | undefined) => Promise<string>
textContent({ reason?: string | undefined
reason: 'Show who is signed in' }) // the signed-in name, after one sheet for read.text} else { await const frame: Frame
frame.function goto(url: string, options?: GotoOptions): Promise<void>
goto('https://example.org/catalog') // the render proxy's session, none of the user's cookies await const frame: Frame
frame.locator: (selector: string) => Locator$1<Extended<{ readonly element: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly getByRole: { readonly to: "element"; readonly resolve: (context: LocatorContext, role: string) => Element[]; readonly render: (role: unknown) => { fragment: string; }; }; readonly getByText: { readonly to: "element"; readonly resolve: (context: LocatorContext, text: string) => Element[]; readonly render: (text: unknown) => { fragment: string; }; }; readonly getByTestId: { readonly to: "element"; readonly resolve: (context: LocatorContext, testId: string) => Element[]; readonly render: (testId: unknown) => { fragment: string; }; }; readonly first: { readonly to: "element"; readonly resolve: (context: LocatorContext) => Element[]; readonly render: () => { fragment: string; }; }; readonly nth: { readonly to: "element"; readonly resolve: (context: LocatorContext, index: number) => Element[]; readonly render: (index: unknown) => { fragment: string; }; }; }; readonly operations: { readonly click: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly fill: { readonly resolve: (context: LocatorContext, value: string, _options?: OperationOptions) => void; }; readonly hover: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly textContent: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => string; }; readonly getAttribute: { readonly resolve: (context: LocatorContext, name: string, _options?: OperationOptions) => string | null; }; readonly isVisible: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; readonly count: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => number; }; readonly exists: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; }; }; readonly frame: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly owner: { readonly to: "frame"; readonly barrier: "up"; readonly resolve: (_context: LocatorContext) => Element[]; readonly render: () => { fragment: string; separator: "up"; }; }; }; readonly operations: { readonly addStyleTag: { readonly resolve: (context: LocatorContext, options: AddStyleTagOptions$1) => void; }; readonly fetch: { readonly kind: ChainKind; readonly ...
locator('h1').textContent: (_options?: LocatorOptions | undefined) => Promise<string>
textContent() // 'Catalog', no sheet on the cloud backend}The frame holds the user’s example.org cookies on the extension and none on the cloud. The root call never opens the install card: a page without the extension goes to the cloud, and only extension.attachFrame() asks the user to install. attachFrame() needs a window realm on either backend, so drive frames from the page and not from workers.
Options
Section titled “Options”attachFrame() takes one object, and both backends apply the same defaults:
| Option | Default | What it does |
|---|---|---|
iframe | required | The <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. |
syncCookies | true | Extension: copy the user’s cookies for domains into the frame. Cloud: the shared cookie jar, or a session of its own when false. |
lockdown | false | Extension only, and asking for it picks that backend. Serves the declared domains under default-src 'none'. |
domains on the extension installs one session rule for those hosts, in this tab only. The rule removes X-Frame-Options, Content-Security-Policy and Content-Security-Policy-Report-Only from the framed document’s responses and its subresources. With lockdown it still removes X-Frame-Options, sets Content-Security-Policy to default-src 'none' instead of removing it, and leaves Content-Security-Policy-Report-Only alone.
Without domains a site that forbids framing stays blank until a goto(), which adds its target host to the rule. On the cloud the list is normalised to bare lowercase hostnames of at most 253 characters, and anything else is dropped rather than repaired.
syncCookies on the extension copies a cookie whose domain equals a declared host, or is a subdomain of one that itself contains a dot. The copy runs only for the attach domains and each goto() target, so an attach without domains copies nothing and the frame starts logged out.
The two together are the shape the guided demo above uses:
const const frame: Frame
frame = await function attachFrame(options: AttachFrameOptions): Promise<Frame>
attachFrame({ iframe: HTMLIFrameElement
iframe: var document: Document
window.document returns a reference to the document contained in the window.
document.ParentNode.querySelector<"iframe">(selectors: "iframe"): HTMLIFrameElement | null (+4 overloads)
Returns the first element that is a descendant of node that matches selectors.
querySelector('iframe')!, domains?: string[] | undefined
domains: ['example.org', 'widget.example.org'], syncCookies?: boolean | undefined
syncCookies: false,})await const frame: Frame
frame.function goto(url: string, options?: GotoOptions): Promise<void>
goto('https://example.org/catalog') // resolves on the frame's load event
await const frame: Frame
frame.locator: (selector: string) => Locator$1<Extended<{ readonly element: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly getByRole: { readonly to: "element"; readonly resolve: (context: LocatorContext, role: string) => Element[]; readonly render: (role: unknown) => { fragment: string; }; }; readonly getByText: { readonly to: "element"; readonly resolve: (context: LocatorContext, text: string) => Element[]; readonly render: (text: unknown) => { fragment: string; }; }; readonly getByTestId: { readonly to: "element"; readonly resolve: (context: LocatorContext, testId: string) => Element[]; readonly render: (testId: unknown) => { fragment: string; }; }; readonly first: { readonly to: "element"; readonly resolve: (context: LocatorContext) => Element[]; readonly render: () => { fragment: string; }; }; readonly nth: { readonly to: "element"; readonly resolve: (context: LocatorContext, index: number) => Element[]; readonly render: (index: unknown) => { fragment: string; }; }; }; readonly operations: { readonly click: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly fill: { readonly resolve: (context: LocatorContext, value: string, _options?: OperationOptions) => void; }; readonly hover: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly textContent: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => string; }; readonly getAttribute: { readonly resolve: (context: LocatorContext, name: string, _options?: OperationOptions) => string | null; }; readonly isVisible: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; readonly count: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => number; }; readonly exists: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; }; }; readonly frame: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly owner: { readonly to: "frame"; readonly barrier: "up"; readonly resolve: (_context: LocatorContext) => Element[]; readonly render: () => { fragment: string; separator: "up"; }; }; }; readonly operations: { readonly addStyleTag: { readonly resolve: (context: LocatorContext, options: AddStyleTagOptions$1) => void; }; readonly fetch: { readonly kind: ChainKind; readonly ...
locator('h1').textContent: (_options?: LocatorOptions | undefined) => Promise<string>
textContent() // 'Catalog', after one sheet for read.text, from a document holding none of the user's cookiesNothing of the user’s is in that frame on either backend, and the two hosts are what the extension lifts framing headers for and a later cloud frame.fetch() may reach.
lockdown also stops the site’s own scripts and styles, since default-src 'none' covers them too. A header policy cannot cover a document that is already loading, so it needs domains or a later goto(): attachFrame({ iframe, domains: ['example.org'], lockdown: true }) works, and an iframe with a src and no domains is refused.
goto() and url()
Section titled “goto() and url()”goto(url, options?) navigates the frame and resolves once the new document is there. Both backends check the target against the platform rule first, so a URL on fkn.app, fkn.dev, sdbx.app or a subdomain of them is refused before anything moves, with attachFrame: refusing to target the extension's own pages or an FKN platform origin.
A navigation that passes resolves on the new document, and url() reports what you asked for:
await const frame: Frame
frame.function goto(url: string, options?: GotoOptions): Promise<void>
goto('https://example.org/catalog') // resolves on the frame's load eventconst frame: Frame
frame.function url(): string
url() // 'https://example.org/catalog', the url you asked for
await const frame: Frame
frame.function goto(url: string, options?: GotoOptions): Promise<void>
goto('https://example.org/player', { waitUntil?: "documentstart" | "load" | undefined
waitUntil: 'documentstart' }) // back before the page finished loadingwaitUntil defaults to 'load': the call resolves on the iframe’s load event. On the extension it rejects after 30,000 ms with frame load timed out after 30000ms for <href>. On the cloud the render proxy raises its own frame load timed out after 30000ms for <url>. When the render proxy never answers at all, the library gives up 5,000 ms after that deadline with cloud.attachFrame: the render proxy did not answer goto; the frame may have been detached or its page reloaded.
'documentstart' resolves as soon as the new document exists. On the extension that is when its content script announces itself, or frame goto: documentstart timed out after the same deadline. On the cloud it is at once, and a locator call made before the document commits gets navigation pending; the target document has not committed yet and is retried until it does.
On the extension every goto() is itself a consent, embed.open, at severity 0: no sheet, one auto row in the activity log, the on-device record of what an app did, scoped to the URL you asked for. A relative url resolves against your page, not the frame, so pass absolute URLs. options.domains extends the extension’s header rule and cookie copy for that navigation, and the cloud ignores it.
url() is the URL the app last asked for, on both backends: the attach target, or the target of your last goto(). A navigation the framed document performs on its own is invisible to it, and on the extension a frame that leaves the origins you declared refuses reads with frame: this frame no longer holds the document the app attached it to.
Pinning a backend
Section titled “Pinning a backend”extension.attachFrame() and cloud.attachFrame() skip the choice and take the same options, except that cloud.attachFrame() refuses lockdown: true with cloud.attachFrame does not support lockdown; use the extension backend for a sealed frame.
The extension one waits up to 1,000 ms for an ok handshake (or 150 ms after load). That is stricter than the root call’s marker read, and an extension announcing an ABI below the floor rejects with ExtensionOutdatedError before any card is shown. With no handshake it opens the install card drawn by the broker, the connection your app holds into FKN, and stays pending while the card is open. Pass null to setMissingExtensionHandler() to draw that state yourself, and the call rejects quietly instead:
function setMissingExtensionHandler(handler: MissingExtensionHandler | null): void
setMissingExtensionHandler(null) // a missing extension rejects instead of opening the install card
try { // syncCookies defaults to true, so the user's example.org cookies travel in const const frame: extension.Frame
frame = await (alias) namespace extensionimport extension
extension.extension_d_exports.attachFrame({ iframe, domains, syncCookies, lockdown }: extension.AttachFrameOptions): Promise<extension.Frame>export extension_d_exports.attachFrame
attachFrame({ iframe: HTMLIFrameElement
iframe: var document: Document
window.document returns a reference to the document contained in the window.
document.ParentNode.querySelector<"iframe">(selectors: "iframe"): HTMLIFrameElement | null (+4 overloads)
Returns the first element that is a descendant of node that matches selectors.
querySelector('iframe')!, domains?: string[] | undefined
domains: ['example.org'] }) await const frame: extension.Frame
frame.function goto(url: string, options?: extension.GotoOptions): Promise<void>
goto('https://example.org/account') await const frame: extension.Frame
frame.locator: (selector: string) => Locator$1<Extended<{ readonly element: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly getByRole: { readonly to: "element"; readonly resolve: (context: LocatorContext, role: string) => Element[]; readonly render: (role: unknown) => { fragment: string; }; }; readonly getByText: { readonly to: "element"; readonly resolve: (context: LocatorContext, text: string) => Element[]; readonly render: (text: unknown) => { fragment: string; }; }; readonly getByTestId: { readonly to: "element"; readonly resolve: (context: LocatorContext, testId: string) => Element[]; readonly render: (testId: unknown) => { fragment: string; }; }; readonly first: { readonly to: "element"; readonly resolve: (context: LocatorContext) => Element[]; readonly render: () => { fragment: string; }; }; readonly nth: { readonly to: "element"; readonly resolve: (context: LocatorContext, index: number) => Element[]; readonly render: (index: unknown) => { fragment: string; }; }; }; readonly operations: { readonly click: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly fill: { readonly resolve: (context: LocatorContext, value: string, _options?: OperationOptions) => void; }; readonly hover: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly textContent: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => string; }; readonly getAttribute: { readonly resolve: (context: LocatorContext, name: string, _options?: OperationOptions) => string | null; }; readonly isVisible: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; readonly count: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => number; }; readonly exists: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; }; }; readonly frame: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly owner: { readonly to: "frame"; readonly barrier: "up"; readonly resolve: (_context: LocatorContext) => Element[]; readonly render: () => { fragment: string; separator: "up"; }; }; }; readonly operations: { readonly addStyleTag: { readonly resolve: (context: LocatorContext, options: AddStyleTagOptions$1) => void; }; readonly fetch: { readonly kind: ChainKind; readonly ...
locator('.username').textContent: (_options?: LocatorOptions | undefined) => Promise<string>
textContent({ reason?: string | undefined
reason: 'Show who is signed in' }) // the signed-in name, after one sheet for read.text} catch (var error: unknown
error) { if (var error: unknown
error instanceof class ExtensionOutdatedError
Thrown instead of the generic "not installed" message when the extension IS there and merely too
old. Carries both numbers so an app can say which, and link its listing.
A named class rather than a message match: only the name survives a structured-clone hop, and the
codebase has been bitten before by an error whose identity was its text.
ExtensionOutdatedError) var error: extension.ExtensionOutdatedError
error.ExtensionOutdatedError.required: number
required // the ABI this page needs else (var error: unknown
error as interface Error
Error).Error.message: string
message // 'The FKN WebExtension is not installed, enabled or not exposed on this page.'}The ExtensionOutdatedError branch cannot run today, because the floor, REQUIRED_EXTENSION_ABI, is 0. The rejection you will see is The FKN WebExtension is not installed, enabled or not exposed on this page. ExtensionOutdatedError is still the one refusal here you can test with instanceof, since it is thrown in your own realm. Everything raised across the hop is matched by name, see handling errors.
The cloud one always takes the render proxy, even with the extension installed:
// a sandbox attribute, if present, must include allow-scripts and allow-same-originconst const frame: Frame
frame = await (alias) namespace cloudimport cloud
cloud.cloud_d_exports.attachFrame({ iframe, domains, syncCookies, lockdown }: AttachFrameOptions): Promise<Frame>export cloud_d_exports.attachFrame
attachFrame({ iframe: HTMLIFrameElement
iframe: var document: Document
window.document returns a reference to the document contained in the window.
document.ParentNode.querySelector<"iframe">(selectors: "iframe"): HTMLIFrameElement | null (+4 overloads)
Returns the first element that is a descendant of node that matches selectors.
querySelector('iframe')!, syncCookies?: boolean | undefined
syncCookies: false })await const frame: Frame
frame.function goto(url: string, options?: GotoOptions): Promise<void>
goto('https://example.org/catalog')
await const frame: Frame
frame.locator: (selector: string) => Locator$1<Extended<{ readonly element: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly getByRole: { readonly to: "element"; readonly resolve: (context: LocatorContext, role: string) => Element[]; readonly render: (role: unknown) => { fragment: string; }; }; readonly getByText: { readonly to: "element"; readonly resolve: (context: LocatorContext, text: string) => Element[]; readonly render: (text: unknown) => { fragment: string; }; }; readonly getByTestId: { readonly to: "element"; readonly resolve: (context: LocatorContext, testId: string) => Element[]; readonly render: (testId: unknown) => { fragment: string; }; }; readonly first: { readonly to: "element"; readonly resolve: (context: LocatorContext) => Element[]; readonly render: () => { fragment: string; }; }; readonly nth: { readonly to: "element"; readonly resolve: (context: LocatorContext, index: number) => Element[]; readonly render: (index: unknown) => { fragment: string; }; }; }; readonly operations: { readonly click: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly fill: { readonly resolve: (context: LocatorContext, value: string, _options?: OperationOptions) => void; }; readonly hover: { readonly resolve: (context: LocatorContext, options?: PositionOptions$1) => void; }; readonly textContent: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => string; }; readonly getAttribute: { readonly resolve: (context: LocatorContext, name: string, _options?: OperationOptions) => string | null; }; readonly isVisible: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; readonly count: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => number; }; readonly exists: { readonly resolve: (context: LocatorContext, _options?: OperationOptions) => boolean; }; }; }; readonly frame: { readonly selectors: { readonly locator: { readonly to: "element"; readonly css: true; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "descend"; }; }; readonly frameLocator: { readonly to: "frame"; readonly css: true; readonly barrier: "down"; readonly resolve: (context: LocatorContext, selector: string) => Element[]; readonly render: (selector: unknown) => { fragment: string; separator: "down"; }; }; readonly owner: { readonly to: "frame"; readonly barrier: "up"; readonly resolve: (_context: LocatorContext) => Element[]; readonly render: () => { fragment: string; separator: "up"; }; }; }; readonly operations: { readonly addStyleTag: { readonly resolve: (context: LocatorContext, options: AddStyleTagOptions$1) => void; }; readonly fetch: { readonly kind: ChainKind; readonly ...
locator('h1').textContent: (_options?: LocatorOptions | undefined) => Promise<string>
textContent() // 'Catalog', no sheet and no activity log on the cloud backendIt 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.
Fetching as the frame
Section titled “Fetching as the frame”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:
// on the extension the user's example.org session travels in, syncCookies defaults to trueconst const frame: Frame
frame = await function attachFrame(options: AttachFrameOptions): Promise<Frame>
attachFrame({ iframe: HTMLIFrameElement
iframe: var document: Document
window.document returns a reference to the document contained in the window.
document.ParentNode.querySelector<"iframe">(selectors: "iframe"): HTMLIFrameElement | null (+4 overloads)
Returns the first element that is a descendant of node that matches selectors.
querySelector('iframe')!, domains?: string[] | undefined
domains: ['example.org'] })// on the cloud a fetch needs its own session, so attach with syncCookies: false thereawait const frame: Frame
frame.function goto(url: string, options?: GotoOptions): Promise<void>
goto('https://example.org/')
try { // GET, HEAD and OPTIONS ask for frame.fetchRead, every other method for frame.fetchWrite const const result: FrameFetchResult
result = await const frame: Frame
frame.fetch: (url: string, init?: FrameFetchOptions | undefined) => Promise<FrameFetchResult>
fetch('https://example.org/api/catalog.json', { reason?: string | undefined
reason: 'Load your catalog' }) const result: FrameFetchResult
result.status: number
status // 200 when the catalog answered, and on the extension one receipt row in the activity log var JSON: JSON
An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.
JSON.JSON.parse(text: string, reviver?: (this: any, key: string, value: any) => any): any
Converts a JavaScript Object Notation (JSON) string into an object.
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.
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.
decode(const result: FrameFetchResult
result.body: ArrayBuffer
body)) // the catalog, from the ArrayBuffer body} catch (var error: unknown
error) { const { const name: string
name, const message: string
message } = var error: unknown
error as interface Error
Error if (const name: string
name === 'PermissionDeniedError') const message: string
message // 'Permission denied: frame.fetchRead (this frame)', the extension's sheet said no else if (function isLocatorDenied(error: unknown): boolean
isLocatorDenied(var error: unknown
error)) const message: string
message // refused by the cloud gate, here for the shared cookie jar else if (function isTerminalError(error: unknown): boolean
isTerminalError(var error: unknown
error)) const message: string
message // cannot run on this path, not retried else throw var error: unknown
error}Neither exported guard matches the first branch. A refusal on the extension’s sheet arrives under the name PermissionDeniedError with Permission denied: frame.fetchRead (this frame), so test error.name first, as a refusal neither guard matches shows. The second is the cloud’s, a terminal LocatorDeniedError the library raises itself. This attach meets it there at once, because the default syncCookies means the shared cookie jar.
The result is { status, statusText, ok, url, redirected, type, headers, body }, with headers as [name, value] pairs and body an ArrayBuffer. Its type is not exported by name (TypeScript). method defaults to GET, redirect to 'follow' and credentials to 'include'. There is no signal, and the body arrives whole.
On the extension the two keys, frame.fetchRead and frame.fetchWrite, sit at severity 3, see permission keys, and the reason you pass is shown on the sheet. Every call writes one receipt row to the activity log. A failing call is written once per identical call per 60,000 ms window, so the retries do not multiply rows.
On the cloud the call needs all four of these, and each miss is a terminal LocatorDeniedError the library raises before anything crosses:
- its own session, or
frame.fetch on the cloud backend needs its own session: attach with syncCookies: false - an absolute
url, orframe.fetch on the cloud backend needs an absolute url - an
httporhttpsscheme, orframe.fetch: only http(s) urls are supported - a host in
domains, or an origin the attach or agoto()targeted, orframe.fetch: the target is outside the origins this attachment declared
What passes raises the broker’s card, described under consent on the cloud backend. The card never shows your reason, and a refusal there is frame.fetch: the user did not grant this. Past the card, the render proxy’s shell today refuses the request under its default addressing, with the terminal fetch is not available while the proxied document is on its own origin, so read that error rather than building on the path, see limitations.
What is refused
Section titled “What is refused”Most refusals come before the iframe is touched. These are the six you are most likely to meet:
| Message | What happened |
|---|---|
cloud.attachFrame needs a window realm | cloud.attachFrame() called with no window, in a worker for example. |
attachFrame: the iframe must be connected to the document before attaching | The 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 iframe | The src already points at the fkn.app page from an earlier attach. |
cloud.attachFrame: timed out connecting to the render proxy page | No 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 goto | lockdown: true on an iframe that has a src and no domains. |
frame: this frame no longer holds the document the app attached it to | The framed document navigated off the origins the attach declared. Terminal. |
The platform rule runs on the src at attach and on every goto() target, page-side and again inside the extension’s content script and the fkn.app page that hosts the render proxy. The full list is on every error.
Timeouts
Section titled “Timeouts”Every machine wait on the way to a working frame is bounded, and the waits on a person are not:
| Call | Wait | How long |
|---|---|---|
attachFrame() | the backend decision | 150 ms after the document is complete without the extension, 10,000 ms at most |
extension.attachFrame() | the extension’s handshake | 1,000 ms, or 150 ms after load, then the install card |
cloud.attachFrame() | the render proxy handshake | 20,000 ms, then cloud.attachFrame: timed out connecting to the render proxy page |
cloud.attachFrame() | the render proxy ready | 65,000 ms, then cloud.attachFrame: the render proxy never became ready |
goto() | the new document | 30,000 ms on the extension, plus 5,000 ms in the library on the cloud, not adjustable through GotoOptions |
The install card that extension.attachFrame() opens stays up until the user dismisses it, and the call waits with it unless setMissingExtensionHandler(null) removed it. The broker’s card for frame.fetch() on the cloud backend waits the same way, and a dismissal there starts a 10,000 ms cooldown during which the call fails closed. The consent sheet is raised before the timer starts, so it never counts against a deadline. A locator action’s own deadline is under options, and the other knobs are in limits and timeouts.
Locators and consent, live
Section titled “Locators and consent, live”The demo below runs on the extension backend only. It waits for the extension, shows an install hint until it is exposed, then attaches a same-origin mock player with no domains, so no header rule and no cookie copy. Six buttons drive it, and every rejection is caught and shown, since a denial or a timeout is a normal outcome:
- a click on play,
.controlsthen#play - a read of the title
- a fill of the search box
- an
ensure('click', { subtree: true })on the whole.controlsbox, an area grant covering everything inside it - two clicks covered by that area grant
- a click outside the box, which prompts again
Revoke a grant from the extension’s popup or dashboard to see the sheet come back. The same six buttons are walked from the locator side in locators and actions.