Skip to content

Use the extension or the cloud per request

Your app sends each request through the user’s own browser session when the FKN extension is on the page, and through the FKN cloud when it is not. The install offer appears inside your own interface rather than as the card the library opens on its own. This page covers the read that tells the two cases apart, the header probe, the credentialed fetch, the redirect that answers status 0, the install offer, and the fallback.

What the path costs, before the first step:

Every step runs in the page, so every badge below reads [Page]. The badges are defined on recipes.

The extension is the FKN browser extension. It announces itself by marking the page’s <html> element, and isExtensionExposed() reads that marker at the moment you call. The content script lands a tick after the document starts, so the read answers false both when there is no extension and when nobody has answered yet. Your app keeps the third state itself, undefined until the wait settles:

app.ts
import {
const 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
,
const setMissingExtensionHandler: (handler: MissingExtensionHandler | null) => void
setMissingExtensionHandler
,
const waitForExtensionExposure: (timeout?: number) => Promise<void>
waitForExtensionExposure
} from '@fkn/lib'
function setMissingExtensionHandler(handler: MissingExtensionHandler | null): void
setMissingExtensionHandler
(null) // before the first extension call, so absence rejects instead of opening the install card
let
let exposed: boolean | undefined
exposed
: boolean | undefined // undefined until the wait below settles
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
() // false, the same answer for absent and for not answered yet
try {
await
function waitForExtensionExposure(timeout?: number): Promise<void>
waitForExtensionExposure
()
let exposed: boolean | undefined
exposed
= true
} catch {
let exposed: boolean | undefined
exposed
= false // no usable extension on this page
}
let exposed: boolean
exposed
// true or false from here on

The wait resolves at once when the marker is already there. Otherwise it gives the content script 1,000 ms, or 150 ms once the page has finished loading, then rejects with The FKN WebExtension is not installed, enabled or not exposed on this page. and draws nothing, because the handler is gone. Until then exposed is undefined, and a render that reads it shows neither an install offer nor a cloud notice. what available() means explains the marker.

extension.fetch builds a new Request(input, init) first, and the platform drops the forbidden request headers there, cookie among them. A cookie you set on the init is gone from the request the extension sees. @fkn/lib reads the names in FORGEABLE_HEADERS, the forbidden headers it can put back, off your init.headers separately and restores them on the first hop. Whether cookie is in that set is what to probe, rather than which version you installed:

app.ts
import {
const FORGEABLE_HEADERS: string[]
FORGEABLE_HEADERS
} from '@fkn/lib'
const FORGEABLE_HEADERS: string[]
FORGEABLE_HEADERS
// ['origin', 'referer', 'cookie']
const
const canCarryCookie: boolean
canCarryCookie
=
const FORGEABLE_HEADERS: string[]
FORGEABLE_HEADERS
.
Array<string>.includes(searchElement: string, fromIndex?: number): boolean

Determines whether an array includes a certain element, returning true or false as appropriate.

@paramsearchElement The element to search for.

@paramfromIndex The position in this array at which to begin searching for searchElement.

includes
('cookie') // true, so the path is available
const
const jar: "session=abc123; theme=dark"
jar
= 'session=abc123; theme=dark' // one string, never a repeated cookie header
const
const headers: {
cookie: string;
} | {
cookie?: undefined;
}
headers
=
const canCarryCookie: boolean
canCarryCookie
? {
cookie: string
cookie
:
const jar: "session=abc123; theme=dark"
jar
} : {} // a name outside the set stays dropped, with no error

A build whose set lacks cookie drops the header silently and sends the request as nobody, and no version number would have told you. A cookie you supply and credentials: 'include' are two identities, so the extension refuses the pair with fetch: a forged Cookie header and credentials:'include' are different identities - send one or the other. headers the page cannot set owns the rule.

credentials: 'include' attaches the cookies the browser holds for the URL, httpOnly ones included, and asks the user first through the consent sheet, the prompt the extension shows before an action above severity 0. Pass a reason the user will read there. A refusal arrives as a plain Error named PermissionDeniedError, and only its name and message survive the hop out of the extension, so test the name:

app.ts
import {
(alias) namespace extension
import extension
extension
} from '@fkn/lib'
const
const loadAsUser: () => Promise<Response | null>
loadAsUser
= async () => {
try {
const
const response: Response
response
= await
(alias) namespace extension
import extension
extension
.
extension_d_exports.fetch(input: RequestInfo | URL, init?: extension.FetchInit): Promise<Response>
export extension_d_exports.fetch
fetch
('https://example.org/api/catalog.json', {
RequestInit.credentials?: RequestCredentials | undefined

A string indicating whether credentials will be sent with the request always, never, or only when sent to a same-origin URL. Sets request's credentials.

credentials
: 'include',
reason?: string | undefined
reason
: 'Load the catalog from your own example.org account',
})
const response: Response
response
.
Response.status: number

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

MDN Reference

status
// 200, the request carried the cookies the browser holds for example.org
return
const response: Response
response
} catch (
function (local var) error: unknown
error
) {
if (
function (local var) error: unknown
error
instanceof
var Error: ErrorConstructor
Error
&&
function (local var) error: Error
error
.
Error.name: string
name
=== 'PermissionDeniedError') return null // the user declined this time
throw
function (local var) error: unknown
error
}
}

The refusal reads Permission denied: network.fetchCredentialed (<scope>), and the null is the signal the fallback step reads. Every extension.* call needs a window realm, a realm being one JavaScript execution context. The root fetch with credentials: 'include' in a worker rejects with fetch with credentials needs the FKN extension, which only exists in window realms and never falls back to the cloud. the sheet covers what the user sees.

With redirect at its default, 'follow', the extension follows the redirect and hands you the final response. Ask for redirect: 'manual' and a redirect answers with status 0: the opaque redirect the extension’s service worker saw, revived on your side as Response.error(). It is not a failure. It says the upstream redirected and this call did not follow. The browser refuses to rebuild such a response, since new Response(body, { status: 0 }) throws a RangeError, so branch on the status before you wrap or forward anything:

app.ts
import {
(alias) namespace extension
import extension
extension
} from '@fkn/lib'
const
const response: Response
response
= await
(alias) namespace extension
import extension
extension
.
extension_d_exports.fetch(input: RequestInfo | URL, init?: extension.FetchInit): Promise<Response>
export extension_d_exports.fetch
fetch
('https://example.org/api/catalog.json', {
RequestInit.redirect?: RequestRedirect | undefined

A string indicating whether request follows redirects, results in an error upon encountering a redirect, or returns the redirect (in an opaque fashion). Sets request's redirect.

redirect
: 'manual' })
const response: Response
response
.
Response.status: number

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

MDN Reference

status
// 0 when example.org redirected, an opaque redirect rather than a failure
const response: Response
response
.
Response.type: ResponseType

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

MDN Reference

type
// 'error', how a status 0 is revived across the hop
const
const redirected: boolean
redirected
=
const response: Response
response
.
Response.status: number

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

MDN Reference

status
=== 0 // true when the upstream answered a redirect this call did not follow

When redirected is true, re-issue the call with redirect: 'follow' or take the cloud path. cloud.fetch never follows a redirect, so a 301 or 302 arrives there as an ordinary Response with a location header, see cloud.fetch().

Importing @fkn/lib registers a missing-extension handler. By default an extension.* call that finds no extension runs it, and it opens the install card the broker draws. The broker is the connection your page holds into FKN. Step 1 removed that handler, so promptInstall(reason) is now the only thing that opens the card, from your own button, with your reason and a store link on it:

app.ts
function setMissingExtensionHandler(handler: MissingExtensionHandler | null): void
setMissingExtensionHandler
(null) // the library opens nothing on its own from here on
(alias) namespace extension
import extension
extension
.
const extension_d_exports.events: TypedEventTarget<ExtensionEventMap>
export extension_d_exports.events
events
.
TypedEventTarget<ExtensionEventMap>.addEventListener<"statuschange">(type: "statuschange", listener: ((event: CustomEvent<{
enabled: boolean;
}>) => void) | null, 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

addEventListener
('statuschange', (
event: CustomEvent<{
enabled: boolean;
}>
event
) => {
event: CustomEvent<{
enabled: boolean;
}>
event
.
CustomEvent<{ enabled: boolean; }>.detail: {
enabled: boolean;
}

The read-only detail property of the CustomEvent interface returns any data passed when initializing the event.

MDN Reference

detail
.
enabled: boolean
enabled
// true the moment the extension marks the page, false when it goes away
})
const offer: HTMLButtonElement
offer
.
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 () => {
const
const installed: boolean
installed
= await
function promptInstall(reason?: string): Promise<boolean>
promptInstall
('Load the catalog from your own account, with no cloud in between')
const installed: boolean
installed
// true when the extension showed up while the card was open, false when it was dismissed
})

promptInstall resolves true at once when the extension is already there, and false without asking in a worker. It draws the card through the broker frame, the hidden fkn.app iframe the library mounts, and waits for the broker with no deadline. Show the offer only while exposed is false, and let the statuschange event flip your interface when the install lands. when the extension is missing owns the default handler.

The root fetch reads the marker at the moment of the call. With the extension on the page it takes the extension with credentials forced to 'omit', and otherwise the cloud, through the proxy, the FKN server that makes the request on your behalf. Neither carries a session of the user’s, so a refusal at the sheet and a dismissed card end on the same line:

app.ts
const
const loadCatalog: () => Promise<Response | null>
loadCatalog
= async () => {
if (
const exposed: boolean | undefined
exposed
===
var undefined
undefined
) return null // nobody has answered yet, so draw nothing and ask again later
const
const own: Response | null
own
=
const exposed: boolean
exposed
? await
const loadAsUser: () => Promise<Response | null>
loadAsUser
() : null // the session path, null when the user declined
if (
const own: Response | null
own
) return
const own: Response
own
return
function fetch(input: RequestInfo | URL, init?: RequestInit & {
reason?: string;
render?: boolean;
}): Promise<Response>
fetch
('https://example.org/api/catalog.json') // no credentials on the init, so nothing here asks the user
}
const
const response: Response | null
response
= await
const loadCatalog: () => Promise<Response | null>
loadCatalog
()
const response: Response | null
response
?.
Response.url: string | undefined

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

MDN Reference

url
// '' when the cloud answered, the final URL when the extension answered without a session
const
const catalog: unknown
catalog
: unknown = await
const response: Response | null
response
?.
Body.json(): Promise<any>
json
() // the same catalog, from the public side of example.org

The branch comes from the three states of step 1 and loadAsUser from step 3. The root fetch closes the path. Only response.url tells you which backend answered, a backend being where a call runs. Pin cloud.fetch instead when the request must leave from the cloud whatever the page has, see how the root fetch decides.

With the extension on the page, loadCatalog() resolves a Response whose url is the final URL and whose body is the catalog of the signed-in account. With it absent, the same call resolves a Response whose url is '', through the cloud. When neither happens, find the point of failure here:

  1. exposed stays undefined, or isExtensionExposed() answers false with the extension installed. The wait has not settled, and it takes up to 1,000 ms. Nobody has answered yet, which is not absence, so draw nothing and read exposed after step 1.
  2. The call throws fetch: refusing to forge request header(s): cookie. The extension enforces the set with a copy of its own, and the two copies can disagree when one is older. Leave the cookie off that call.
  3. The call throws fetch: a forged Cookie header and credentials:'include' are different identities - send one or the other. One call carried both. Send one.
  4. loadAsUser() resolves null every time. The user declined, or a stored denial covers https://example.org. The user lifts a stored denial from the extension’s own popup, and the fallback is the answer, see a refusal neither guard matches.
  5. An extension.* call rejects with The FKN WebExtension is not installed, enabled or not exposed on this page.. You called it with exposed at false. Route that request through the root fetch.
  6. promptInstall never settles. It waits for the broker with no deadline, and a page whose broker frame never connects holds it forever, see connecting.

A jar your app already holds travels on either backend, as one cookie string on init.headers, see headers the page cannot set.

When the data sits behind a page rather than an endpoint, attach the site in a frame and read it there, see drive a real site and play its video.