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

@reopt-ai/data-sdk-client

v0.6.1

Published

reopt-data browser SDK — vanilla, React and Next.js App Router client components

Readme

@reopt-ai/data-sdk-client

Browser SDK for reopt-data. Three entry points, one client:

| Entry | Use it from | | --------------------------------- | --------------------------------------------------------------- | | @reopt-ai/data-sdk-client | a <script> tag, a plain SPA, any framework — no directive | | @reopt-ai/data-sdk-client/react | React client components ("use client") | | @reopt-ai/data-sdk-client/next | Next.js App Router — importing it from a server component is OK |

Server-side tracking (route handlers, server components, workers) lives in @reopt-ai/data-sdk-server. Importing that package from a client component fails at build time on purpose.

Install

pnpm add @reopt-ai/data-sdk-client

Next.js App Router

// app/layout.tsx — a server component
import { ReoptProvider, ReoptPageView, ReoptWebVitals } from "@reopt-ai/data-sdk-client/next";
import { getBootstrap } from "@/lib/reopt"; // @reopt-ai/data-sdk-server — optional

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const bootstrap = await getBootstrap(); // null when the server knows nothing about this visitor
  return (
    <html>
      <body>
        <ReoptProvider
          config={{ writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!, baseUrl: "/ingest" }}
          bootstrap={bootstrap}
        >
          <ReoptPageView />
          <ReoptWebVitals />
          {children}
        </ReoptProvider>
      </body>
    </html>
  );
}
  • ReoptPageView sends $pageview on every navigation — pathname and query string — and a $pageleave with time-on-page for the page it replaces. It is a sibling of your content, not a wrapper, so a navigation does not rerender your tree. It carries its own <Suspense>, so it will not knock a prerendered route into client rendering.
  • ReoptWebVitals forwards Next's Core Web Vitals as $web_vitals.
  • baseUrl: "/ingest" assumes the reoptProxy from @reopt-ai/data-sdk-server/proxy rewrites /ingest/* to your reopt-data deployment (the SDK sends /ingest/api/track, which lands on <reopt-data>/api/track). Without the proxy, pass the deployment origin.
  • bootstrap is optional. With it, the first render already agrees with the server about who the visitor is and what they consented to.

Then, anywhere in a client component:

"use client";
import { useTrack, useIdentify } from "@reopt-ai/data-sdk-client/next";

export function BuyButton() {
  const track = useTrack();
  return <button onClick={() => track("checkout_started", { plan: "pro" })}>Buy</button>;
}

React (any router)

import { ReoptProvider } from "@reopt-ai/data-sdk-client/react";

<ReoptProvider config={{ writeKey, baseUrl: "https://data.example.com" }}>
  <App />
</ReoptProvider>;

The provider sends the first $pageview itself (initialPageView={false} to opt out). Call usePageView() on route changes; there is no history.pushState patching.

Hooks: useReopt, useReoptClient, useTrack, useIdentify, usePageView, useConsent, useTrackOnMount, usePageViewOnMount.

The client is created during render and kept on window per write key, so a child's effect can use it on first mount, StrictMode's double render shares one instance, and two copies of this package on one page do not create two devices. It is not closed on unmount; call client.close() if you need to.

Vanilla

import { init, track, identify, pageView } from "@reopt-ai/data-sdk-client";

init({ writeKey: "wk_…", baseUrl: "https://data.example.com" });
track("signup_completed", { plan: "pro" });
identify("cus_123", { email: "[email protected]" });
pageView(); // sent automatically on init(); call this on client-side navigation

Configuration

init({
  writeKey: "wk_…",
  baseUrl: "https://data.example.com", // or "/ingest" behind the proxy
  bootstrap: null, // from @reopt-ai/data-sdk-server getBootstrap()
  identity: {
    storage: "auto", // cookie → localStorage → memory. Cookies survive Safari ITP; localStorage does not
    cookieDomain: undefined, // ".example.com" to share across subdomains
    cookieMaxAgeSeconds: 400 * 24 * 3600,
  },
  capture: {
    pageview: true, // first $pageview on init (the Next entry sets false — <ReoptPageView /> owns it)
    pageleave: true, // $pageleave with `duration` (seconds) and `scroll_depth`
    scrollDepth: true,
    exceptions: false, // $exception for uncaught errors and unhandled rejections; loads as its own chunk when on
  },
  release: undefined, // build id stamped on every event as $release_id; see below
  tracingHeaders: false, // opt-in: add reopt-device-id to same-origin fetch/XHR (true) or to listed hosts; loads as its own chunk
  normalizePath: (pathname) => pathname, // see below; applied to every path the SDK stamps itself
  fetch: undefined, // transport override for tests
  observe: undefined, // local lifecycle observer for devtools; never sent over the network
  consent: { categories: ["analytics", "marketing"], defaultConsent: true },
  batch: { size: 100, intervalMs: 1000, maxBytes: 400_000 },
  retry: { maxRetries: 3, baseDelay: 1000, maxDelay: 30000, jitter: 0.1 },
  circuitBreaker: { failureThreshold: 5, recoveryTimeout: 60000 },
  debug: false,
});

Release tracking

release stamps every event with $release_id. Error tracking reads it to say which build an issue first and last appeared in, and — once you resolve an issue "in" a release — to tell a plain reopen from a regression, where the fix you declared did not hold.

init({ writeKey: "wk_…", release: "1.4.0" });

Most apps do not know their version at the call site. The SDK falls back to globalThis.__REOPT_RELEASE__, which is where a build step writes it:

// vite.config.js
export default { define: { __REOPT_RELEASE__: JSON.stringify(process.env.GIT_SHA) } };

// next.config.js
module.exports = {
  webpack: (config, { webpack }) => {
    config.plugins.push(
      new webpack.DefinePlugin({ __REOPT_RELEASE__: JSON.stringify(process.env.VERCEL_GIT_COMMIT_SHA) })
    );
    return config;
  },
};

An explicit release wins over the global. With neither, the property is omitted entirely — not sent as an empty string, which would create a release named "" and label every issue with it.

The label is yours: a version, a commit sha, a build id. The server never issues one and never rejects an event for carrying an unfamiliar release.

Runtime-resolved keys, manual page views, external consent

The three things a multi-tenant host needs, in the shape it needs them:

// Server component: the write key comes from your database, per tenant, per request.
const writeKey = await resolveBrandWriteKey(brandId);
<AnalyticsMount config={{ writeKey, baseUrl: "/ingest", capture: { pageview: false }, consent: { persist: false } }} />;
  • capture.pageview: false and call pageView({ path, properties }) yourself — for example only when the page carries the markers that make it a real published page. There is no automatic page view you have to suppress, and init() is idempotent per write key, so a remount or a soft navigation never creates a second client.
  • Opt-out removes identity. Refusing analytics deletes the device cookie (the proxy does the same on its side) and the SDK writes none while refused; granting again writes the current device through. Note the bridge you own: with consent.persist: false the SDK keeps no consent cookie, but reoptProxy decides seeding from exactly that cookie — so a banner that manages consent itself must also write reopt_<writeKey>_consent (via serializeConsentCookie, single encoding) for the proxy to see it.
  • consent.persist: false when a consent banner already owns the decision: sync it with setConsent("analytics", allowed) before tracking, and the SDK keeps no cookie of its own that could disagree with the banner's. An undecided visitor is allowed (opt-out model).
  • normalizePath rewrites high-cardinality paths (/workspace/8f3…/customers/2a1…/workspace/:id/customers/:id) and can lift the removed ids into properties for breakdowns. It runs wherever the SDK stamps a path itself — the default $pageview path, $pageleave, $web_vitals, $exception — and not on a path you pass to pageView() explicitly, so use the same function in both places. Synchronous, pure; if it throws, the raw path is used.
  • getDeviceId() returns the id this page is tracked under, so your own server call can carry it explicitly (submitForm(body, { deviceId })) and the server can record the conversion with deviceId on the event — the browser session and the server-side conversion then land on the same device without any header magic. null before init() or when analytics is disabled.
  • properties in init() sets those same global properties from the very first event — web vitals can fire the instant the client exists, before an effect gets to call register(). Give it the page context the server already knows.
  • register(properties) attaches properties to every event from then on — including the automatic ones. This is how a host puts its own breakdown axis (page_id, a tenant) on $web_vitals and $pageleave, which the SDK otherwise stamps with only a path. An event's own properties win; reset() clears them.
  • Fail-open. A missing writeKey or baseUrl logs a warning and yields a disabled client whose every call is a no-op; nothing throws. Analytics must never be why a page fails.
  • fetch injects the transport. A test can hand in a recording function and assert on the exact payload the SDK built, instead of intercepting the network.
  • observe receives local configuration, enqueue/drop, identity, consent, and pause/resume facts in lifecycle order. Its implementation loads as an opt-in chunk, buffers facts while that chunk arrives, ignores callback errors, and never receives the write key or event properties. Use it for diagnostics such as @reopt-ai/data-sdk-devtool, not application behavior.

Bootstrap and caching (Next.js)

bootstrap comes from getBootstrap() in @reopt-ai/data-sdk-server, which reads cookies(). Three ways that goes wrong:

  1. With cacheComponents: true, calling it inside a "use cache" function (or a helper that function calls) passes next build and fails at next start with next-request-in-use-cache — on dynamic routes only when the route runs, so it gets past your gate. Read it outside the cache, after await connection(), and pass the value down.
  2. Passing the cookies() promise as a prop into a cached component does not error; the build hangs or the prerender times out.
  3. Without cacheComponents (older apps, unstable_cache, fetch caching) the value really does get cached — every visitor gets the same device id. In those apps do not pass bootstrap; rely on the proxy's cookie seeding and the client's own identity.

Exceptions

$exception_list — the structured form

Alongside the flat keys, an exception carries the parsed cause chain. The flat keys still describe the outermost error and are unchanged, so anything written against them keeps working; $exception_list is what the issue page renders and what fingerprinting groups on.

| Field | Meaning | | --------------------- | ------------------------------------------------------------------------------------- | | type / value | TypeError / the message. For a non-Error throw the SDK derives both. | | mechanism.handled | false when nothing caught it. Causes are true — they were caught and re-thrown. | | mechanism.type | onerror | unhandledrejection | manual | console | nextjs_onRequestError | | mechanism.synthetic | The SDK invented the type because what was thrown was not an Error. | | stacktrace.frames[] | filename, function, lineno, colno, in_app — innermost first. |

{
  "$exception_type": "TypeError",
  "$exception_message": "cart.items is not a function",
  "$exception_list": [
    {
      "type": "TypeError",
      "value": "cart.items is not a function",
      "mechanism": { "handled": false, "type": "onerror" },
      "stacktrace": {
        "type": "raw",
        "frames": [
          {
            "filename": "https://shop.example.com/main.a1b2c3d4.js",
            "function": "handleCheckout",
            "lineno": 412,
            "colno": 19,
            "in_app": true
          }
        ]
      }
    }
  ]
}

Throttling

Automatic capture is throttled per exception type — a token bucket of 10 with one token back every 10s. One component stuck in a rejection loop can fire thousands of identical events a second; unthrottled that fills the visitor's network and the customer's quota with a single bug, and buries every other error in the issue list.

capture: {
  exceptions: true,
  exceptionRateLimit: { bucketSize: 20, refillSeconds: 5 }, // or `false` for no limit
}

Per type, so a runaway TypeError cannot silence a RangeError that starts later. Never applied to captureException() — a report you asked for explicitly is not noise the SDK gets to drop. When throttling kicks in, debug: true logs it once per type.

Breadcrumbs

$exception_steps carries what happened just before the error — up to 20 steps, each with a 200-character message and at most 1 KB of serialized data.

client.addExceptionStep({ category: "fetch", message: "POST /orders", data: { status: 500 } });
// …later, any exception carries the trail

capture.exceptionSteps: true also records a navigation step on every page view. Off by default: navigation history on every error is a privacy decision the host makes, not the SDK. addExceptionStep() works either way.

The trail is copied onto an exception, not moved — a second error in the same session sees the same history rather than an empty one. One buffer per page, not per client.

A cause chain is carried outermost first, up to four links. A stack made entirely of chrome-extension:// frames is not reported — it is a bug in a visitor's extension, which the site cannot reproduce or fix.

captureException(error, options?) takes { fingerprint?, level?, properties? }. fingerprint is a suggestion — the server bounds it and decides — and is how you say "group these two apart" or "group these together". The pre-options shape captureException(error, { myProperty: 1 }) still works: an object carrying none of the three option keys is read as properties.

captureException() is the one place the list can be absent. The parsers live in the exception chunk, and a manual capture made before that chunk has loaded reports the flat keys only; the server falls back to fingerprinting those, so the event still groups. Turning on capture.exceptions loads the chunk at init, which is the usual case.

Size

The production bundle is checked on every build (scripts/check-size.mjs): each entry is bundled as a consumer would (minified, NODE_ENV=production, framework externals) and must stay under the per-entry gzip budget that script declares (BUDGET_GZIP_BYTES — tightest for the vanilla entry, looser for ./react and ./next, which add the provider, hooks and page-view component on top) and carry no zod. Opt-in features (observer formatting, tracing headers, exception capture) are separate chunks. Event validation in production is a hand-written mirror of the contract; the zod schema runs in development only and is dropped by the consumer's bundler.

What it does that you would otherwise have to remember

  • Identity in a cookie. reopt_<writeKey>_device, SameSite=Lax, Secure on https, 400 days. The server SDK and the proxy read the same cookie, so server-side events land on the same device. An id from the previous SDK generation (localStorage reopt_device_id) is migrated on first load.
  • Consent in a cookie (reopt_<writeKey>_consent), so the server stops sending when the browser does. Refusing analytics stops everything; a decision made in the browser beats the server's bootstrap.
  • reset() becomes a new device. Log out on a shared computer and the next person is not attributed to the previous one.
  • Byte-aware batches. Requests stay under the server's 512 KB cap; unload flushes stay under fetch's 64 KB keepalive limit; an event that could never fit is refused at track() with reason: "payload_too_large" instead of being dropped by the server later.
  • Offline queue in localStorage, restored once by the next page.
  • Corrected clock. With a bootstrap, a device clock more than 30 s off the server's is corrected before events are stamped.
  • Event ids are UUIDv7, so they sort in creation order.

Events it sends

Names and property keys are exported from @reopt-ai/data-contract/events (AUTO_EVENT_NAMES, AUTO_EVENT_PROPERTIES); the low-cardinality subset suitable as rollup dimensions is AUTO_EVENT_ROLLUP_KEYS. Derive catalogue entries from those rather than retyping them.

| Event | Properties | | ------------- | --------------------------------------------------------------------------------------------------------------------------------- | | $pageview | path, origin, title, referrer, utm_*, search (Next, when present), plus anything normalizePath lifts | | $pageleave | path, origin, duration (s), scroll_depth (0–100) | | $web_vitals | metric_name, metric_id, value, delta, rating, navigation_type, path | | $exception | $exception_type, $exception_message, $exception_stack, $exception_source, $exception_handled, $exception_list, path |

Session replay (workspace; npm release pending)

Replay is opt-in and loads @rrweb/record only after project sampling and consent. Enable replay for the project in the Data console, then configure the browser client:

const client = init({
  writeKey: publicWriteKey,
  baseUrl: "/ingest",
  sessionReplay: { enabled: true },
});
// Call only after your consent UI receives the visitor's recording decision.
client.setConsent("replay", true);
await client.flushReplay();
client.setConsent("replay", false);

Both analytics and replay consent must be granted. Keep replay out of default-granted consent categories. consentCategory can select an existing category only when that category represents recording consent. flushReplay() attempts buffered delivery; it does not override sampling, consent, retry delay, or prove that a recording exists. Use the collector response/request ID or console to verify acceptance.

Chunks are gzipped in the browser with CompressionStream when available (Content-Encoding: gzip), which cuts the visitor's upload roughly tenfold; browsers without it send JSON. A session the server does not sample is remembered until the analytics session changes, so an unsampled visitor sends one start request per session rather than one per minute. A stream that fails three times in a row on the same page (for example a page whose DOM never fits the event limit) stops recording until the next page load instead of retrying forever.

blockSelector adds private subtrees to the built-in blocks. data-reopt-replay-block, data-reopt-private, password fields, iframes, objects and embeds are always blocked. Inputs and visible text are masked, including CSS-generated text in snapshots and incremental style updates; DOM attributes are allowlisted. URLs lose queries/fragments and image/font resources are excluded by default. Console/network bodies, canvas, custom recorder plugins and persistent offline DOM queues are unsupported. Never add an unmasking workaround to pass sensitive content into replay.

Explicit public images and fonts

sessionReplay.publicAssets optionally maps public static URLs to build-time data URLs:

sessionReplay: {
  enabled: true,
  publicAssets: [
    { url: "/brand/logo.webp", dataUrl: publicLogoDataUrl },
    { url: "/fonts/public.woff2", dataUrl: publicFontDataUrl },
  ],
}

Generate these values from reviewed application-owned files during your build. The example's scripts/build-replay-assets.mjs shows this pattern. Do not fetch arbitrary page URLs, rasterize the visitor's page, or include uploads, avatars, authenticated/personalized files, or images containing private text. Pixels and font metadata are not masked. Only deliberately public assets belong here.

The recorder matches exact resolved HTTP(S) URLs; configured URLs with credentials, queries or fragments are rejected. Signed variants and unlisted inline images remain excluded. PNG, JPEG, WebP and WOFF2 are supported with MIME/signature checks. At most 16 entries are inspected, each file is at most 128 KiB decoded, and accepted data URL strings total at most 256 KiB. Invalid or over-budget entries are ignored. Font loading uses captured @font-face CSS; the FontFace API, SVG, srcset selection, canvas and automatic asset discovery are not supported.

Approved files replace image src and CSS url() references in full snapshots and incremental updates. The server revalidates embedded formats. Playback needs no request to the source site: the existing sandbox/CSP permits only embedded images/fonts. Assets share the recording's transport, private storage, quotas, retention and deletion; they are not uploaded to a separate public bucket. Repeated CSS references and snapshots repeat bytes, so subset fonts and small images are preferable. Existing recordings cannot regain previously removed resources.

Each page load/visibility restart has a distinct stream linked to the server's current analytics session. Tabs never share DOM mirrors. One eligible recorder owns each document when several SDK clients coexist. Pause, reset, consent withdrawal and close stop observers and cancel pending replay work. Idle pages suspend after 60 seconds; activity starts a fresh snapshot. idleTimeoutMs accepts 10–300 seconds and flushIntervalMs accepts 1–30 seconds (default 20; each flush is one upload and one stored object, and page hide/unload flushes regardless). Full DOM snapshots are retaken every five minutes.

Uploads use /api/replay/start and /api/replay/chunk under baseUrl; the server SDK's existing /ingest proxy forwards them. Replay requests omit cookies and use the write-key/device headers plus a short-lived project/device/session/stream-bound grant. A 1 MiB request, 900 KiB single event, 4 MiB queue and 32 MiB / 30-minute stream bound resource use. Oversized snapshots stop the stream; mutations never continue with a broken snapshot. The next eligible attempt starts a fresh stream.