npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ravenkeep/browser

v0.4.0

Published

Ravenkeep SDK for browsers: first-touch UTM attribution, error capture, pageviews and the support widget (chat + feature requests).

Downloads

1,177

Readme

@ravenkeep/browser

The Ravenkeep SDK for browsers. Keep each visitor's first-touch attribution (UTMs, click ids, referrer, landing page) so your backend can report signups with it, capture front-end errors, send pageviews and public events, and give signed-in customers the support widget: live chat with your team and feature requests they can vote on.

  • Uses the publishable key (rk_pub_…) only. The secret key belongs to @ravenkeep/node.
  • Never throws. Request/response methods resolve to { data, error }; the others are fire-and-forget.
  • Safe to import during SSR. Browser globals are only touched in a browser.
  • The core is about 8 KB gzip. Error processing (≤ 9 KB since 0.4.0, ~7 KB in 0.3.0) and the widget (≤ 45 KB, Preact + UI + realtime) are separate chunks, loaded only when needed.
  • ESM, CommonJS and a <script> build (window.Ravenkeep). No runtime dependencies.

Installation

The package is public on npm:

npm install @ravenkeep/browser
# pnpm add @ravenkeep/browser · yarn add @ravenkeep/browser · bun add @ravenkeep/browser

Or with a script tag. jsDelivr serves the npm package; pin the version you test with. The lazy chunks (ravenkeep-extras.global.js, ravenkeep-widget.global.js and, since 0.3.0 with autoPageviews, ravenkeep-engage.global.js) are loaded from the same directory as the main script (ravenkeep.min.global.js; ravenkeep.global.js is the same file under its older name):

<script src="https://cdn.jsdelivr.net/npm/@ravenkeep/[email protected]/dist/ravenkeep.min.global.js"></script>
<script>
  const ravenkeep = Ravenkeep.init({ publishableKey: 'rk_pub_acme_…' })
</script>

To serve the script tag from your own site instead (for example under a Content Security Policy without a third-party host), copy the files from the installed package into your site's static files, side by side, load /ravenkeep.min.global.js from there, and copy them again after each upgrade:

cp node_modules/@ravenkeep/browser/dist/*.global.js public/

Quickstart

import { Ravenkeep } from '@ravenkeep/browser'

export const ravenkeep = Ravenkeep.init({
  publishableKey: 'rk_pub_acme_…', // Project settings → Keys
  attribution: { cookieDomain: '.acme.com' }, // share the first touch between www. and app.
  autoPageviews: true,
  release: import.meta.env.VITE_GIT_SHA,
})

// Once the user is signed in (userHash comes from your backend, see "Identity verification"):
await ravenkeep.identify({ userId: user.id, userHash: user.ravenkeepHash, email: user.email, name: user.name })

// On logout (always: it forgets the user's widget session in this tab):
ravenkeep.reset()

Create one client per page and keep it. Ravenkeep.init and new Ravenkeep(options) are equivalent.

Configuration

| Option | Default | Description | |---|---|---| | publishableKey | — | The project's publishable key (rk_pub_…). If it is empty, nothing is sent and onError gets missing_publishable_key. A secret key is refused locally with publishable_key_required and is never sent. | | host | https://api.ravenkeep.io | API origin, for self-hosted installs. | | captureErrors | true | Capture window.onerror and unhandledrejection, plus breadcrumbs. | | sampleRate | 1 | Share of errors that are sent (0–1). | | maxErrorsPerMinute | 10 | Rate limit on sent errors, counting both captured and manual ones. | | beforeSend | — | (crash) => crash \| null. Change or drop each crash before it is sent. | | release, environment | — | Attached to crashes. The API defaults environment to production. | | autoPageviews | false | Send $pageview on load and after every SPA navigation (History API), and, since 0.3.0, each view's engaged time ($pageleave, see Engaged time). | | attribution | { storage: 'cookie' } | { cookieDomain?, storage?: 'cookie' \| 'localStorage' }, or false when the visitor has not consented: no first touch or visit is captured or kept, and init forgets the ones the site kept (see Consent). The anonymous id (rk_aid) is still stored (localStorage) and sent. | | widget | {} | { position?: 'bottom-right' \| 'bottom-left', accentColor?, locale?: 'en' \| 'pt-BR', launcher?: boolean, scriptUrl? }, or false to turn the widget off. | | fetch | global fetch | A custom fetch, for tests or proxies. | | onError | warns once per code | Receives every failure of the background pipeline (RavenkeepError). |

API

| Method | Returns | What it does | |---|---|---| | identify({ userId, userHash?, email?, name?, account? }) | Promise<{ data, error }> | Opens the widget session (POST /v1/widget/session) and tags later crashes and events with userId; while the session exists their batches carry its token, so the API counts them as this customer's. | | reset() | void | Logout. Forgets the session (memory and the tab's stored copy) and the user, closes the widget, and starts a new anonymous id and a new visit. Call it on every sign-out. | | getAttribution() | Attribution \| null | The first touch of this browser. | | clearAttribution() | void | Forgets the first touch and the visit, without stopping them: the next event starts a new visit. To stop, see Consent. | | getSessionId() | string \| null | The current visit's id, sent as sessionId on every event (see Sessions). Reading it never extends the visit, but starts one when none is current. null during SSR and with attribution: false. | | captureException(error, { tags?, context?, level?, fingerprint? }) | void | Reports an error. | | trackPageview(properties?) | void | Sends $pageview with url, path, referrer, title and the address's utm_source, utm_medium, utm_campaign, utm_term, utm_content. | | track(name, properties?) | void | A custom event. With the publishable key, only names in the project's public event names are accepted. | | open('chat' \| 'features'), close() | void | Opens or closes the widget panel. open() before identify() waits for the session. | | on('unread' \| 'open' \| 'close' \| 'message', listener) | unsubscribe function | unread: unread operator messages (a new listener gets the current count right away). message: a new operator message. | | flush() | Promise<void> | Sends queued crashes and events now (unless a retry is already waiting for its backoff). Never rejects. | | shutdown() | Promise<void> | Removes every handler and patch, unmounts the widget, and makes one last attempt to send what is queued (no backoff wait; each request bounded by the 10 s timeout). What the API did not take goes to sendBeacon and is reported once as flush_timeout (details.pending). Nothing keeps running afterwards. Never rejects. |

The package also exports parseAttribution(url, referrer?, { siteHost? }), isEmptyAttribution, RavenkeepError, RAVENKEEP_API_URL, SDK_VERSION and the wire types.

error is a RavenkeepError with a stable code. That is either the API's code (identity_verification_failed, rate_limited, origin_not_allowed…) or one of the SDK's own codes: network_error and timeout (status 0; a response body cut by the timeout is a timeout too, never an empty success), missing_publishable_key, publishable_key_required, validation_failed, not_identified, invalid_response (a 2xx without a JSON body, such as a proxy page), items_rejected, queue_overflow, flush_timeout, client_closed or widget_load_failed. On failure data is null.

Attribution: marketing site → app → signup

The first page a visitor lands on is kept forever: UTM source, medium, campaign, term and content, gclid/fbclid/msclkid, the referrer and the landing page. Later visits never overwrite it. It is stored in a first-party cookie, mirrored in localStorage, and the localStorage copy is used when cookies are refused. A referrer from your own site (or from cookieDomain) counts as internal navigation and is dropped.

When the marketing site and the app run on different subdomains, initialize the SDK on both with the same cookieDomain. The first touch is then recorded on www.acme.com and read on app.acme.com:

// www.acme.com (marketing): attribution only
Ravenkeep.init({ publishableKey, attribution: { cookieDomain: '.acme.com' }, widget: false, captureErrors: false })

// app.acme.com (product)
const ravenkeep = Ravenkeep.init({ publishableKey, attribution: { cookieDomain: '.acme.com' } })

Send the attribution along with your signup form. Your backend then records the signup with @ravenkeep/node. Attribution is written once per contact and never overwritten:

// Browser
await fetch('/api/signup', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ email, password, attribution: ravenkeep.getAttribution() }),
})

// Backend (Node)
import { Ravenkeep } from '@ravenkeep/node'
const ravenkeep = new Ravenkeep({ secretKey: process.env.RAVENKEEP_SECRET_KEY! })

app.post('/api/signup', async (req, res) => {
  const user = await users.create(req.body)
  await ravenkeep.signup({ userId: user.id, email: user.email, attribution: req.body.attribution })
  res.status(201).json({ id: user.id })
})

clearAttribution() forgets the stored value, without stopping the next ones (to stop, see Consent). attribution: false captures nothing.

Consent

  • attribution: false means this visitor has not consented (before consent, or after it was withdrawn): it captures and stores nothing for attribution or the visit, sends no sessionId, and at every init forgets what the site kept: the first touch (rk_attr) and the visit (rk_sid). Removal is per origin: their cookie on this host and on each of its parent domains, and this origin's localStorage copy — not a host-only cookie of a sibling host (www without cookieDomain) or another origin's localStorage, which that app forgets when it inits with attribution: false.
  • It is not a per-app "attribution off": on a shared cookieDomain, every init (each page load; not an SPA's navigations) of an app with attribution: false clears the site-wide first touch and visit the other apps keep there (your marketing site's first touch is then lost). Give every app of the site the visitor's consent, the same way.
  • clearAttribution() forgets the same two now, but does not stop them: this client's next event starts a new visit (stored again), and the next init with attribution on captures a new first touch.
  • Full withdrawal: await ravenkeep.shutdown(), then Ravenkeep.init({ …, attribution: false }) on this page and every later one until consent returns.
  • The anonymous id (rk_aid) is not attribution's: with attribution: false too, it is stored (in localStorage; with attribution on, in the cookie as well) and sent with every event and crash, as events, crashes and the widget's conversations are tied to it; reset() replaces it. Whether your consent rules allow that before consent is yours to check.

Sessions

Every event carries sessionId, the visit it belongs to (0.2.0 and later; Metrics → Sessions reports them). A visit is a random UUID kept in the same first-party store as the anonymous id (rk_sid: a first-party cookie, on cookieDomain when set and else the host's, mirrored in localStorage), so tabs share it, and so do subdomains when every app of the site initializes the SDK with the same cookieDomain. A new visit starts after 30 minutes without an event, 24 hours after the visit began, when the clock moved back more than 30 minutes, after reset(), after an identify() of another user than the page's, and when a page view lands with a campaign (utm_source, utm_medium, utm_campaign, each trimmed as every UTM is read, then compared by position and exactly) other than the visit's. The visit is the browser's: an identify() of another user in one tab starts a new visit for every tab. Without storage (a privacy mode refusing both), the visit lasts for the page's life. With attribution: false nothing is stored for the visit either: events carry no sessionId and getSessionId() returns null (the API groups the person's events by the 30-minute gap).

Since 0.4.0, a tab that comes back to its page in a visit its view was not sent in sends the page's $pageview again into that visit as the tab becomes visible, starting it when none is current. That happens after the visit ended while the tab was away (hidden over 30 minutes after the last event, or past the visit's 24 hours), and also when something else started the current visit meanwhile (an event of the page or of another tab, or getSessionId()). The view keeps its url as the address is (a landing's utm_* and click ids stay in it), its title and the properties you named it with (as they were then), but has no utm_* properties and no referrer: the campaign and the referrer brought the earlier visit, so a visit the view starts has no campaign. An event or an SPA navigation after it joins that visit. It needs attribution on (the default), as the visit does: with attribution: false nothing is sent again. Only the page the SDK last sent a view of is sent again, and each tab sends its own: two tabs of one page that come back each send the view. After reset() nothing is sent again until a view is named again (the next navigation with autoPageviews, or your own trackPageview()): a sign-out never sends the signed-out user's page again under the new anonymous id. A visit that ends while the tab stays visible starts at the next event without a view (the view joins it at the tab's next return), and an SPA navigation then sends the earlier page's $pageleave (engaged time) into it before the new $pageview. In 0.3.0 and earlier nothing is sent at the return: the visit the next event starts has no view of the page the tab came back to, and its first view is the next navigation's.

Events sent with the publishable key are timed when the API receives them, not by the browser's clock: a batch held back over 30 minutes (a device asleep or offline) can split a visit the browser kept as one, as Ravenkeep applies the 30-minute gap within a visit's events too.

To count a server event in the same visit, send ravenkeep.getSessionId() to your backend and pass it to the Node SDK: ravenkeep.track('report.exported', { userId, sessionId }).

Engaged time

Since 0.3.0, autoPageviews: true also measures how long each page view held the visitor (Metrics → Pages & screens and Features report it). A lazily loaded chunk (engage, under 1 KB gzip) counts the time the page is visible and the visitor is there: input (pointer, keys, wheel, touch) or a video or audio playing with sound keeps it going, and it stops 60 seconds after the last one (reading without a click still counts; a tab left open over lunch does not, nor a page that scrolls itself or a muted video playing on its own). Hidden time never counts.

The time is sent as a $pageleave event (path, engaged_ms, and resumed: true on a view's later segments) when the view ends (an SPA navigation to another address; a hash change is the same view), when the page is hidden or left (in the same sendBeacon as the queue), and every 15 minutes while the view holds engaged time; a segment never exceeds 30 minutes. A view that was never visible or never held engaged time sends nothing. Since 0.4.0, when a return sends the view again into another visit (see Sessions), its next segment counts as the view's first there (no resumed). Nothing is stored for it: the measurement lives in the page, so attribution: false changes only what every event carries (no sessionId). Leave events do not count toward your plan's events. There is no separate switch: autoPageviews: false stops the views and their time together, and pages that call trackPageview() themselves get no time.

On an SPA navigation the ending view's $pageleave is queued just before the next $pageview, so it stays in its visit even when the next view starts another (a campaign). The chunk sees navigations through trackPageview, which it wraps while it runs (shutdown() restores it).

Identity verification

Projects require verified identities by default, the same way Intercom does. The widget session is refused unless userHash = hex(HMAC-SHA256(identitySecret, userId)) is sent. Compute the hash on your server, because the identity secret must never reach the browser, then hand it to the page:

// Backend: new Ravenkeep({ secretKey, identitySecret: process.env.RAVENKEEP_IDENTITY_SECRET })
res.json({ user, ravenkeepHash: ravenkeep.widgetUserHash(user.id) })

// Browser
const { error } = await ravenkeep.identify({ userId: user.id, userHash: me.ravenkeepHash, email: user.email })
if (error?.code === 'identity_verification_failed') console.warn('Check the identity secret')

The session token lasts 12 hours. It is kept in memory and in sessionStorage for reloads within the same tab, and is renewed 5 minutes before it expires. Besides the widget routes, it proves who the customer is to /v1/events and /v1/crashes: the API stores publishable-key events and crashes as anonymous unless they come with a valid token of the same contact (X-Ravenkeep-Widget-Token on fetches, a widgetToken body field on beacons, never in a URL). Before identify() succeeds, and after reset(), batches carry no token.

A renewal presents the current token (X-Ravenkeep-Widget-Token on POST /v1/widget/session) so the new session continues the same lineage: in a project without identity verification, an unverified session (data.verified === false) only sees the conversations of its own lineage, and keeps them across renewals. The last token of the tab's user is kept in memory past its expiry for that purpose (a tab that slept overnight), is only ever presented for the same userId, and is forgotten by reset(). An unverified session's contact carries only what identify sent, never the stored profile. Call identify() on every page load once the user is known: when the identity has not changed and the token is still fresh, no request is made.

Which values win

identify() sends only the fields you pass: a missing, null or blank email, name or account name is left out of the request, never sent as an empty value, and nothing is carried over from an earlier call. The API only fills a name or an email the contact does not have yet: values your backend set with the secret key (signup, identify in @ravenkeep/node) are never overwritten from the browser. An email sent without identity verification is stored as unverified: your team can answer in the widget, but it never receives campaign emails (flows skip the contact with the reason untrusted_email). The language of campaign emails comes from the contact trait locale, which only your backend sets.

Stored sessions

A stored session is never used on its own. After a reload nothing is restored (no widget, no token) until identify() runs, and the stored session is picked up only when the same userId identifies with the same input (compared through a SHA-256 digest; the user hash itself is never stored). Another user of the same tab always gets a new session, even if the app forgot reset() on logout. Without WebCrypto (pages served over plain http: other than localhost) the session is not stored for reuse and each page asks the API again.

The support widget

Once identify() succeeds, the widget loads and shows a launcher button in a corner. It runs inside a Shadow DOM, so your CSS and its CSS never mix. The Chat tab lists the customer's conversations, lets them write to you with attachments (up to 10 MB each), and shows replies live, with typing indicators and "Seen" receipts (live, and from the conversation's operatorReadAt after a reload). The Feature requests tab lets them search, sort, vote, and suggest new features; it refreshes each time it is shown again (a suggestion merged into a request moves the customer's vote there). Requests they voted for show an "Updated" badge when their status changes.

Attachments:

  • Uploads the API refuses are explained in the composer: larger than 10 MB (413 payload_too_large, also checked before uploading), a type it does not accept such as SVG or HTML (415 unsupported_media_type), too many uploads (429). An attachment the API refuses when the message is sent (already used, older than 24 hours: 422 on attachments) asks the customer to attach it again.
  • Images reserve their space from the width / height the API returns (scaled into 220 × 160), so the thread does not jump while they load.
  • Attachment URLs are signed and expire after 1 hour. A thread on screen with attachments is reloaded (all the pages it shows) once its URLs are 45 minutes old, when it is reopened or the page becomes visible again after that, and when an image fails to load (at most once every 30 s).
Ravenkeep.init({
  publishableKey,
  widget: { position: 'bottom-left', accentColor: '#4f46e5', locale: 'pt-BR' },
})

To use your own button, set launcher: false:

const ravenkeep = Ravenkeep.init({ publishableKey, widget: { launcher: false } })
helpButton.onclick = () => ravenkeep.open('chat')
ravenkeep.on('unread', count => (badge.textContent = count ? String(count) : ''))

The widget follows prefers-color-scheme and prefers-reduced-motion. It behaves as a dialog: focus is kept inside it, Escape closes it, new messages are announced to screen readers, and the tabs work with the arrow keys. It is available in English and Brazilian Portuguese; the language comes from locale, or otherwise from navigator.language. Messages arrive in real time over a WebSocket (the iii rbac-proxy, chat-subscribe with scope: 'contact'; typing goes through chat::typing). While that connection is down, the widget shows "Reconnecting…", checks for new messages every 10 seconds, retries the connection at least every 10 seconds, and reloads the conversations once it is back. Each connection is followed by one more refresh 2 seconds later (the subscription reaches the hub a moment after the socket opens), and a renewed token reopens the socket at once, without going offline (the hub stops delivering to a socket whose token expired). The iii client's [iii] WebSocket error log on each failed attempt is silenced (the browser still logs its own network error line).

Errors

Uncaught errors and unhandled rejections are captured. Existing window.onerror handlers keep running, and their return value is respected. Each crash includes:

  • the stack, parsed into frames marked in-app or not;
  • context.url, the user agent and userId;
  • the last 50 breadcrumbs: SPA navigations, console.error calls and failed fetch requests (query strings are removed and the SDK's own requests are excluded).

console.error and the History API are wrapped at init; a logged value is previewed with a bounded serializer (the first 10 arguments, about 1 KB each, arrays cut to 20 items), so logging a large object costs no more than a small one. fetch is wrapped only once the extras chunk has loaded (after identify(), the first error or open()): a page that never errors and never identifies keeps its own fetch, and failed requests before that point are not in the trail. captureErrors: false installs none of these wrappers (the History API is still observed with autoPageviews: true).

The same bug is sent once per tab session: grouping uses the API's normalized fingerprint, so a different line number or bundle hash still counts as the same bug. At most 10 errors are sent per minute.

An error the browser reports without an error object and without a script position, such as ResizeObserver loop completed with undelivered notifications., has no stack: the source the browser names is the page itself, so it is grouped by its message, whatever page (and ids in its address) it happened on.

Ravenkeep.init({
  publishableKey,
  sampleRate: 0.5,
  beforeSend: crash => (crash.exception.message.includes('ResizeObserver') ? null : crash),
})

try {
  await checkout()
} catch (error) {
  ravenkeep.captureException(error, { tags: { step: 'payment' }, context: { cartId } })
}

Crashes and events are sent in batches about a second after they occur. Failed batches (network errors, timeouts, 408, 429, 5xx) are retried 3 times with backoff and Retry-After; new events during a backoff join the waiting retry instead of forcing another request. An event or crash that cannot be serialized to JSON (a BigInt or a cycle in properties or context) is dropped and reported as validation_failed; the rest of the queue is still sent. When the page is hidden or unloaded, whatever is still queued goes out with navigator.sendBeacon. Beacons cannot carry headers, so the publishable key travels in ?apikey= for those requests. A crash too large for a beacon (64 KiB: a very long stack or large breadcrumbs) is trimmed to fit (the last 10 breadcrumbs, a 16 KB stack, 50 frames); anything still too large is sent with a normal request and reported if that fails.

Frameworks

Nuxt (plugins/ravenkeep.client.ts, which runs in the browser only):

import { Ravenkeep } from '@ravenkeep/browser'

export default defineNuxtPlugin(() => {
  const ravenkeep = Ravenkeep.init({
    publishableKey: useRuntimeConfig().public.ravenkeepKey,
    attribution: { cookieDomain: '.acme.com' },
    autoPageviews: true,
  })
  return { provide: { ravenkeep } }
})

React:

// src/lib/ravenkeep.ts
export const ravenkeep = Ravenkeep.init({ publishableKey: import.meta.env.VITE_RAVENKEEP_KEY, autoPageviews: true })

// In your auth provider
useEffect(() => {
  if (user) void ravenkeep.identify({ userId: user.id, userHash: user.ravenkeepHash, email: user.email })
  else ravenkeep.reset()
}, [user])

// An unread badge
const [unread, setUnread] = useState(0)
useEffect(() => ravenkeep.on('unread', setUnread), [])

Any SPA: autoPageviews: true tracks pushState/replaceState/popstate navigations, once per page. To track pageviews manually, call ravenkeep.trackPageview() after each route change.

Content Security Policy

| Directive | Add | |---|---| | connect-src | Your API host (https://api.ravenkeep.io) and the realtime WebSocket host from the widget session (for example wss://api.ravenkeep.io). | | script-src | Only for the script tag: the host that serves ravenkeep*.global.js. The lazy chunks are injected with the main script's nonce. | | img-src | The attachment host, so image attachments can show previews: https://<account id>.r2.cloudflarestorage.com on an API that stores attachments in Cloudflare R2 (Ravenkeep's hosted API), the API host itself on a stack with local storage. It is the host of the url the widget receives for an attachment. | | style-src | Nothing on current browsers: the widget uses constructable stylesheets. Very old browsers fall back to a <style> element inside the Shadow DOM, which needs 'unsafe-inline'. |

The API host must list your site in the project's allowed origins. Otherwise publishable-key requests get 403 origin_not_allowed.

Bundle size

| Part | gzip | Loaded | |---|---|---| | Core (attribution, visits, capture hooks, queue, events, widget loader) | ≤ 8.125 KB since 0.4.0 (≤ 8 KB in 0.3.0) | with the page | | Extras (stack parsing, fingerprinting, per-session dedupe, fetch breadcrumbs, widget session, widget events) | ≤ 9 KB (~7 KB in 0.3.0) | on the first error, on identify() or on open() | | Engage (engaged time, since 0.3.0) | ≤ 1 KB | with autoPageviews, after the first page view | | Widget (Preact, UI, iii realtime client) | ≤ 45 KB | once a widget session exists |

pnpm --dir packages/sdk-browser build && pnpm --dir packages/sdk-browser size checks the budgets (it also runs before every publish, through prepublishOnly).

Demo

demo/index.html is a stand-in for a customer's site. It loads the script-tag build (dist/ravenkeep.min.global.js) and reads its setup from the query string: ?key=rk_pub_…&host=http://localhost:5401&userId=…&userHash=…. Other parameters are email, name, locale, position, accent, launcher=false, autoPageviews=true, sdk= for a different script URL, and campaign parameters (utm_source=…) for the first touch. Its Pages links move to another page of the site keeping the setup but not the campaign parameters; its buttons open the widget, throw an error, reject a promise, capture an exception, track a page view and flush; its State panel shows the identify result, the unread count and getAttribution().

pnpm --dir packages/sdk-browser build
pnpm --dir packages/sdk-browser demo        # http://localhost:5410/demo/ (--port to change it)

The demo server (scripts/demo-server.mjs) serves only demo/ and dist/, without caching. http://localhost:* is an allowed origin of every new project; compute userHash with the project's identity secret (hex(HMAC-SHA256(identitySecret, userId))). The end-to-end suite runs the widget there against the live API and the console: pnpm --dir app test:e2e:widget (app/playwright.widget.config.ts builds the SDK and starts the demo server on port 5410).