SDKsWeb SDK

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

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

FieldTypeDefaultDescription
persistence.enabledbooleanTurn persistence on. Required — there is no boolean shorthand. When on, mountContainer is ignored (the avatar must live in the top document).
persistence.iframeOnNavigatebooleanfalseDefer 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.navigationProgressboolean | { color?, height? }falseShow 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.iframeAllowAttributestring"autoplay; clipboard-write"Value for the site iframe's allow attribute (Permissions Policy).
persistence.syncHistorybooleantrueMirror 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 defaultiframeOnNavigate: true
When the iframe appearsStraight away, as soon as the session connectsOnly when the visitor first clicks a link
The page the visitor landed onInside the iframe from the startNever inside the iframe — it renders exactly as it would without persistence
…and while they're still on itEverything is safe. The iframe is already in place, so navigation happens inside itOnly 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 navigatesOn the landing page — no iframe yetAfter 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()Endsuse the event✅ Only the frame navigates
Your code: form.submit()Endsuse 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.

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 domainInside 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.

Next steps

On this page