Persistence
Keep one session alive across page navigation on a multi-page site
On a traditional multi-page site, clicking a link triggers a full page reload — which tears down the running session, so the agent would restart and reconnect on every page. Persistence keeps one session alive as the user moves around the site.
When enabled, the SDK wraps the page in an iframe of your own site on connect. The avatar stays in the top document — which never reloads — while navigation happens inside the iframe, so the session survives page changes.
Enable it
One flag on the init config
Options
The persistence config object
When the wrap happens
On connect by default, or deferred to the first navigation
What survives a navigation
Every way a page can change, and what it costs
Navigating from code
Redirect without ending the session
Break-out rules
Links that must leave the iframe
Allowing another domain
Keep a domain that isn't yours inside the frame
Enable it
Turn persistence on with the persistence object when you initialize the SDK:
NapsterCompanionApiSdk.init(token, {
persistence: { enabled: true },
});Include the same snippet on every page of the site. When persistence wraps a page in the iframe, the copy of the snippet running inside that frame detects it and exits without doing anything — it never starts a second session — so it's safe everywhere.
By default a session persists only across pages on the same domain — the domain it started on, exactly as it appears in the address bar. Those are the pages the SDK loads in the iframe. Click a link to anywhere else — another site, or one of your own subdomains like auth.example.com — and the SDK doesn't put it in the iframe: it ends the session and navigates the whole tab there. You can allow specific domains inside the frame with persistence.include, at a cost. See Break-out rules.
Options
| Field | Type | Default | Description |
|---|---|---|---|
persistence.enabled | boolean | — | Turn persistence on. Required — there is no boolean shorthand. When on, mountContainer is ignored (the avatar must live in the top document). |
persistence.iframeOnNavigate | boolean | false | Defer the iframe wrap to the first allowed link click, keeping the landing page fully native. Off by default — the page is wrapped as soon as the session connects. See When the wrap happens. |
persistence.exclude | { urls?: string[] } | — | Pages of your own site excluded from persistence — they open as a normal top-level navigation, which ends the session. See Break-out rules. |
persistence.include | { domains?: string[] } | — | Other domains allowed to load inside the frame, so a link to them keeps the session alive. Everything off your domain breaks out by default; this is the exception list. See Allowing another domain inside the frame. |
persistence.navigationProgress | boolean | { color?, height? } | false | Show a thin progress bar at the top of the page while a page loads into the site frame. Navigations inside the frame drive no browser chrome (no tab spinner), so without an indicator a click the engine took over looks dead for the whole load. true for default styling, or restyle with { color, height } (defaults #be369d, 3px — also overridable via the --np-persist-progress-color / --np-persist-progress-height CSS custom properties). Leave off if your site shows its own navigation indicator. |
persistence.iframeAllowAttribute | string | "autoplay; clipboard-write" | Value for the site iframe's allow attribute (Permissions Policy). |
persistence.syncHistory | boolean | true | Mirror the iframe's path into the address bar and follow back/forward. Same domain only. |
When the wrap happens
Persistence works by putting your site inside an iframe. iframeOnNavigate controls when that happens — which gives a session two possible states, and they behave differently:
iframeOnNavigate: false — the default | iframeOnNavigate: true | |
|---|---|---|
| When the iframe appears | Straight away, as soon as the session connects | Only when the visitor first clicks a link |
| The page the visitor landed on | Inside the iframe from the start | Never inside the iframe — it renders exactly as it would without persistence |
| …and while they're still on it | Everything is safe. The iframe is already in place, so navigation happens inside it | Only link clicks are safe. Anything else that navigates — your code, a redirect — ends the session |
Wrapping on connect is the default because it protects everything from the first moment: there is no window where a redirect or a script can end the session.
Set iframeOnNavigate: true to keep the landing page fully native until the visitor actually navigates — nothing about its initial render changes. The cost is that the page is unprotected until the first link click: anything else that navigates there must go through the persist-navigate event, or the session ends.
NapsterCompanionApiSdk.init(token, {
persistence: {
enabled: true,
iframeOnNavigate: true, // keep the landing page unframed until the first link click
},
});What survives a navigation
The rule behind this whole table in one line: the SDK sees what the visitor does — clicks and form submissions. It cannot see a navigation your code performs. Once the page is inside the iframe that stops mattering, because the frame absorbs the navigation and the top document (where the avatar lives) never reloads.
| What navigates | On the landing page — no iframe yet | After the first link click — inside the iframe |
|---|---|---|
| Visitor clicks a link on your domain | ✅ Wraps into the frame | ✅ Loads in the frame |
| Visitor clicks a link to another domain | ❌ Ends — by design, unless the domain is in include | ❌ Ends — by design, unless the domain is in include |
| Visitor submits a form to your domain | ✅ Routed into the frame | ✅ Submits inside the frame |
Your code: location.assign(), location.href = …, location.replace() | ❌ Ends — use the event | ✅ Only the frame navigates |
Your code: form.submit() | ❌ Ends — use requestSubmit() | ✅ Submits inside the frame |
A server redirect, or <meta http-equiv="refresh"> | ❌ Ends | ✅ The frame follows it |
SPA router (history.pushState) | ✅ No page load at all | ✅ No page load at all |
target="_blank" or window.open() | ✅ Opens a new tab; this one stays put | ✅ Opens a new tab; this one stays put |
Under the default (iframeOnNavigate: false), only the second column applies — the iframe exists from the moment the session connects, so there is no unprotected landing page. The first column describes the opt-in deferred mode (iframeOnNavigate: true).
Back and forward are handled while wrapped: the iframe follows them, and the address bar stays in step as long as syncHistory is on.
Navigating from code
This section fixes the ❌ rows in the table above — they apply while a page is unframed (the deferred mode's landing page), and they're all one problem. A click produces an event the SDK can intercept; location.assign(), location.href = … and location.replace() produce nothing. They're instructions straight to the browser, with no event to listen for, so on a page that isn't wrapped yet the tab simply leaves and the session goes with it.
The fix is to announce the navigation instead of performing it. Dispatch the exported PERSIST_NAVIGATE_EVENT on window:
import { PERSIST_NAVIGATE_EVENT } from "@touchcastllc/napster-companion-api";
function navigate(url) {
const e = new CustomEvent(PERSIST_NAVIGATE_EVENT, {
detail: { url },
cancelable: true,
});
window.dispatchEvent(e);
// If nothing handled it (no active persisted session, or an excluded URL),
// fall back to a normal navigation.
if (!e.defaultPrevented) location.assign(url);
}The SDK listens while native, wraps into the iframe on the event, loads detail.url, and calls preventDefault() so you know it was handled. Excluded URLs (persistence.exclude) are ignored so they still navigate normally.
Forms submitted from code
The form.submit() row fails for a different reason than the rest. A form the visitor submits is safe — the browser fires a submit event first, and the SDK routes the submission into the frame. But form.submit() called from code fires no event at all, by design, so there is nothing for the SDK to hear.
Call form.requestSubmit() instead. It performs the same submission but fires the event first, exactly as if the visitor had pressed the button — which is all the SDK needs:
form.requestSubmit(); // not form.submit()requestSubmit() also runs the browser's own validation, which submit() skips.
Break-out rules
A link that leaves your domain breaks out by default, with no configuration: the SDK ends the session and navigates the whole tab to it. That covers any other site, and any subdomain of your own — auth.example.com is a different domain from www.example.com as far as the browser is concerned, so it breaks out on its own. To keep a specific domain inside the frame instead, see Allowing another domain inside the frame.
What you configure are your own pages that should end the session instead of running inside the frame — a checkout flow, a sign-out route. List them in persistence.exclude:
NapsterCompanionApiSdk.init(token, {
persistence: {
enabled: true,
exclude: {
urls: ["/checkout", "/signout"], // paths (and everything under them), or absolute https URLs
},
},
});When a link matches, the SDK ends the session and navigates the whole tab to that URL.
A urls entry matches its exact path and everything under it — at path boundaries, not by spelling. "/check" matches /check, /check/out, and /check?step=1, but not /checkout — a different page that merely shares the prefix. So excluding one route never accidentally excludes look-alike siblings. End an entry with / to say "everything under this directory" explicitly.
These rules are applied to the URL of the link the visitor clicks, and nothing after that. If a page on your domain redirects somewhere else, the SDK never saw it coming — a /login route that redirects to auth.example.com passes the check, because /login is on your domain. The other domain then loads inside the frame, where many login pages refuse to render at all. Exclude the starting path so the click leaves the frame up front: exclude: { urls: ["/login"] }.
Allowing another domain inside the frame
Sometimes a domain that isn't yours belongs in the session — a docs site you also run, a partner's help centre. List it in persistence.include and links to it load in the frame instead of ending the session:
NapsterCompanionApiSdk.init(token, {
persistence: {
enabled: true,
include: {
domains: ["docs.example.com", "partner.io"],
},
},
});An entry matches the exact host and any subdomain of it. "example.com" covers docs.example.com and shop.example.com, but not notexample.com — a different site that merely ends with the same letters.
What you give up
Everything the SDK does inside the frame depends on being able to read the framed page, and the browser only allows that on your own domain. On someone else's domain the session stays alive, but the SDK is blind:
| Inside your domain | Inside an included domain | |
|---|---|---|
| Links are checked against your break-out rules | ✅ | ❌ The visitor can navigate anywhere from there |
| The address bar follows the frame | ✅ | ❌ It stops updating |
| Back and forward stay in step with the frame | ✅ | ❌ |
| Ending the session reloads the page the visitor is on | ✅ | ❌ It unwraps to the last page of your own site |
The other site has to permit framing at all. A site that sends X-Frame-Options: DENY or a restrictive frame-ancestors renders nothing, and the SDK cannot detect that — the visitor sees a blank frame. Test every domain you add before shipping it, and only add domains you control or have agreed with.