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

@loxel.dev/pharos-browser

v0.12.0

Published

Browser client for Pharos — server-evaluated feature flags, error capture, and session replay.

Readme

@loxel.dev/pharos-browser

A thin browser client for Pharos: server-evaluated feature flags, error reporting, and session replay.

Bootstraps a context against the Pharos client API, then keeps flag values current over a Server-Sent Events stream. Uncaught errors and unhandled rejections are reported automatically. Session replay is opt-in, and its one dependency (rrweb) is loaded on demand so you do not pay for it unless you record — see Bundle size.

Built and tested with Bun.

Internals — the wire format, the local rest, the degradation ladder, and the full export reference — are in TECHNICAL-DETAILS.md, shipped alongside this file.

The server-evaluated model

This client never evaluates flags itself. Every value flag() returns was computed server-side — rules, percentage rollouts, targeting attributes, and bucketing all happen behind POST /api/v1/client/bootstrap, which returns a flat {key: value} map for the given context.

The client's only job is to hold that map, keep it fresh via GET /api/v1/client/stream, and hand values back synchronously. This keeps rule logic and segment membership off the client (nothing to reverse-engineer from a bundle) and means a targeting change takes effect for every open tab without a redeploy.

Install

bun add @loxel.dev/pharos-browser        # or: npm install @loxel.dev/pharos-browser

The published tarball ships built output only (dist/, plus .d.ts files), not TypeScript source: the exports map is what lets a bundler resolve the bare specifier.

Before you start

Pharos is a hosted service, and access is by invitation. There is one instance, at https://pharos-ops.app.

init() needs three values, and they all come from your console there:

| value | where it comes from | |---|---| | baseUrl | always https://pharos-ops.app | | clientKey | ORGANIZATION → API keys → new key, kind client. Starts phc_. | | context.application | the application that client key was minted for |

Register your browser origins on that application (ORGANIZATION → Applications) before the first request. An origin that is not on the list is refused by CORS, which surfaces as a network error rather than a permissions one — the most common first-run failure.

Use the phc_ client key, never the phs_ server key. The client key is meant to ship in your bundle: it only authorises bootstrap, error ingest and replay upload, for one application. The server key does much more and must stay on a backend.

Bundle size

Importing this package does not cost you rrweb. The session recorder is the only thing that needs it, and the recorder is not on the root entry:

| you import | you get | |---|---| | @loxel.dev/pharos-browser | flags, error reporting, the privacy policy, the replay buffer/encoder/store — no rrweb | | @loxel.dev/pharos-browser/replay | the recorder, and therefore rrweb | | await client.startReplay() | the recorder, fetched on demand as a separate chunk |

Measured on a real application (a React + Vite app, replay switched off): importing the client used to add 204 kB raw / 63 kB gzip to its main chunk, essentially all of it rrweb, paid whether or not anything ever recorded. It now adds 8.5 kB raw / 2.9 kB gzip.

startReplay() reaches the recorder through a dynamic import(). The chunk still ships with your deployment, so turning replay on stays a configuration change — it just stops being downloaded by visitors who never record.

rrweb (2.1.1) is an ordinary runtime dependency — not a CDN script. Installing this package installs rrweb into your own node_modules; your bundler compiles it into your own output, served from your own origin.

This package's build never inlines it. The published tarball contains no rrweb files at all — only a single unresolved import { record } from "rrweb" for your bundler to resolve. That is what lets a project already using rrweb end up with one shared copy rather than two.

Usage

Set up

import { PharosBrowserClient } from "@loxel.dev/pharos-browser";

const client = await PharosBrowserClient.init({
  baseUrl: "https://pharos-ops.app",   // required
  clientKey: "phc_...",                // required
  context: {
    contextKey: "user-42",             // required — who is asking; buckets rollouts
    application: "tax-graph",          // recommended — see below
    release: "1.4.0",                  // recommended — see below
    sessionId: "sess-abc",             // optional — joins errors to replay windows
    attributes: { plan: "enterprise", country: "US" },  // optional — flag targeting
  },
});

contextKey is the only context field the server enforces — a bootstrap without one is a 400.

application and release are optional to the type, and you want both anyway. application is hashed into the error fingerprint, so omitting it collapses every browser application in your organization into one fingerprint namespace and stops the console's application= filter from returning your rows. release drives release attribution and the release= filter — and, from 0.9.0, the Session Replays console's Release filter too: client.startReplay() sends it with every replay window.

sessionId matters only if you want an error and the replay window captured around it to be joinable; client.startReplay() gives you one id across both without passing anything. attributes is free-form data for your flag targeting rules — send nothing if no rule reads it.

Read a flag

// Synchronous, no network call — the value was computed server-side and is
// already here. Your default is returned until the bootstrap lands, for any
// key Pharos does not know, and for a value whose type disagrees with your
// default — a flag sent as the string "false" is not a boolean, and reading
// it through a boolean gate would otherwise turn the gate ON.
if (client.flag("new-nav", false)) {
  renderNewNav();
}

// Values stay current over the stream, so a targeting change takes effect in
// every open tab without a redeploy. Re-read on change; do not trust the
// payload's shape.
const off = client.on("change", () => rerender());

// Stream and bootstrap failures surface here rather than throwing.
client.on("error", (err) => console.warn("pharos stream error", err));

Identify a user

// After login. This is a full HTTP round trip, not a local re-key — call it
// on real identity changes, not per render.
await client.identify({
  contextKey: user.id,
  application: "tax-graph",  // identify REPLACES the stored context, so
  release: "1.4.0",          // resend these or errors lose them
});

The new user's flags arrive with it, so identify() fires change the same way a server push does — a component that mounted while the user was anonymous re-renders against the identified bucket without the app arranging anything. If the re-bootstrap fails it still rejects (catch it), and the client schedules a reconnect rather than leaving live updates dead until a page reload.

Shut down

off();          // unsubscribe the change listener
client.close(); // close the stream, unregister the error listeners

Error reporting

init() reports errors to POST /api/v1/errors two ways:

  • Auto-capture (on by default) — registers window.addEventListener("error", ...) and window.addEventListener("unhandledrejection", ...) and reports every uncaught exception / rejected promise automatically. Opt out with init({ ..., errors: false }) to only report via explicit captureException() calls. close() unregisters both listeners.
  • captureException(err, opts?) — call this yourself from a catch block for errors your own code already handled. err can be an Error, a string, or anything else (String(err) is used as a fallback message); opts.site labels where it was caught (e.g. a route name) and opts.attributes attaches extra scalar (or array-of-scalar) context to this occurrence only.
// Uncaught errors and unhandled rejections are reported for you. Report a
// handled one yourself, with whatever context makes it diagnosable:
try {
  await checkout(cart);
} catch (err) {
  client.captureException(err, {
    site: "checkout",
    attributes: { step: "payment", itemCount: cart.length },
  });
}

If you use an error boundary, report from it explicitly. React hands a render error to the nearest boundary instead of letting it reach the window, so auto-capture never sees the one class of error that takes your whole UI down:

componentDidCatch(error, info) {
  client.captureException(error, {
    site: "ErrorBoundary",
    attributes: { componentStack: info.componentStack ?? "" },
  });
}

Every report is a single-entry batch: message (from err.message, falling back to String(err), never sent empty — "(no message)" is used instead), kind (from err.name), stack (parsed from err.stack when it's a string — V8 and Firefox stack-trace shapes are both recognized, via the exported parseStack), the client's current flag map, and the same reserved context fields init()'s bootstrap sends (contextKey, application, release, sessionId — whichever are set on the current context).

application/release matter here beyond bookkeeping: the server hashes them into the error fingerprint and release-tracking, so omitting them would silently merge every browser app's errors into one fingerprint namespace and break the admin issues API's release= filter.

Every field is sanitized/truncated client-side to the server's validation caps before sending, because the server rejects the whole batch if any single field violates them (an empty flag map or attrs map is simply omitted rather than sent empty) — this matters most for auto-capture, which forwards whatever an uncaught exception happened to contain.

Sending is fire-and-forget from the caller's side: captureException never throws and returns nothing to await. A failed POST emits on("error"), like the SSE stream's own failures do, and since 0.11.0 is kept and retried rather than lost.

When Pharos is down

A report is attempted at once, exactly as before. If that attempt fails for a reason a retry could fix — the fetch rejected (offline, refused, DNS) or the server answered 5xx — the report enters a local queue and is delivered, in order, once Pharos can take it:

  • One request per report, FIFO. The drain stops at the first report the server still cannot take and backs off from 1 s to a 30 s ceiling; reports that arrive during an outage join the back of the queue instead of being attempted, so ordering holds and a down server is not hammered.
  • What wakes it: the backoff timer; the browser's online event (which resets the ramp — a tab that just got its network back tries at once); a delivered flag push on the live stream (proof the server is up); and the next page load.
  • Persisted across loads. Queued reports are written to IndexedDB (a pharos-errors database, separate from the replay store) so a tab closed during an outage still delivers when your site next runs. They expire locally after 24 hours, the same rule as replay windows. Without IndexedDB the queue is memory-only and dies with the tab.
  • Bounded. At most 100 reports are held; past that the oldest is evicted, because late in an outage the newest errors describe the state the user is in now.
  • A 4xx is not retried. Pharos answers 400 for a body that fails validation and 403 for a key it refuses; neither changes with time, so such a report is dropped and on("error") receives { type: "error-report-dropped", reason: "rejected" }. Every failed attempt, retryable or not, also emits { type: "errors-post-failed", status } (or the thrown error), so an app watching that event sees each try.
const client = await PharosBrowserClient.init({
  baseUrl, clientKey, context,
  // The default. `false` restores the 0.10 behaviour (a failed report is
  // gone); an object tunes the bound or the IndexedDB factory.
  errorQueue: { maxEntries: 100 },
});

client.pendingErrors();   // reports held for retry right now
await client.flushErrors(); // try to deliver them all now, e.g. before teardown

Two tabs of the same origin opening together after an outage can each send the same persisted report before either deletes it — a duplicate occurrence, which is the cheaper failure; the replay store has the same property.

captureException after close() is a no-op (no fetch), matching identify()'s documented behavior. close() also stops the queue's timer; reports it had already persisted stay in IndexedDB for the next page load to deliver.

Session replay

Recording is opt-in. Importing this package records nothing.

One call starts a recorder, wires it to this client's errors, and uploads windows under the same session id your errors already carry:

const recorder = await client.startReplay();

// Consent withdrawn: stops recording, discards the buffer, and deletes
// anything already persisted locally.
client.detachReplay();
recorder.stop();

It is async because it loads the recorder on demand — that is what keeps rrweb out of your bundle until you actually record.

How it records. The recorder runs as a dashcam: it is always capturing into a bounded local buffer and sends nothing until something goes wrong. A trigger — anything reaching captureException, including window.onerror and unhandled promise rejections — flushes a window covering preRollMs before the error and postRollMs after it. A burst of errors from one broken render produces one window, not fifty: triggers inside an existing post-roll extend that window.

When to call it. Call startRecording() after the document is parsed if you can. rrweb defers its first snapshot while document.readyState is "loading"; the recorder pins recordAfter: "DOMContentLoaded" rather than rrweb's "load" default, which shrinks that gap from "until every subresource has loaded" to "until the document is parsed" — but it does not close it. A recorder started from a <head> script captures nothing (and stats() reports snapshots: 0) until DOMContentLoaded fires.

What it captures. DOM structure and mutations, clicks, focus, input events and scroll (via rrweb), plus one Pharos-specific addition: an activation mode (pointer, keyboard or programmatic) emitted as an rrweb custom event tagged pharos-activation.

What it never captures: key identity. The keyboard listener records a single enum — that the last input was a key — and never reads, stores or transmits which key was pressed. A keydown listener sees passwords, and this one cannot leak one.

Privacy. Every node is routed through this package's privacy policy (decide, masksValue, maskText, scrubAttribute); the recorder re-implements no rule of its own. Rendered text is recorded by default and markers hide (pharos-exclude) or obfuscate (pharos-mask) it — a deliberate posture, not an omission. See the Pharos README for what is and is not masked — including what it does not catch.

Retention is not yours to choose. Stored windows expire under a per-application TTL, and each application also has a session cap — at most 50 sessions per environment (Pharos 0.47): when it is exceeded the environment's oldest sessions are deleted along with their windows and their stored bytes. Both are per-application configuration delivered over /api/v1/client/bootstrap; a value in an upload body is read by nothing. The TTL is the user-facing promise, and the cap is what protects the disk — one busy application would otherwise consume the whole retention window.

Idle stretches are not uploaded (0.12.0). A closed checkpoint segment (~15 s at the defaults) that holds no user input — no pointer or mouse interaction, mousemove, touch, drag, scroll, input, keyboard/pointer activation or viewport resize — is not sent. A page changing its own DOM (a clock, a ticker) is not activity. The skipped [start, end] spans ride on the next window that is sent, as idleSpans, and the Pharos console shows them as idle rather than as missing footage. The session's final open segment is still sent on unload, and an error window is sent whether or not anyone touched the page. stats().idleSegmentsSkipped counts what was skipped.

A session recording holds at most 300 MB (and 10 000 windows) on the server. Once it is full Pharos answers 409 (SESSION_LIMIT_STATUS), and 0.12.0 stops recording for that session: the recorder is torn down (no more DOM observers, snapshots or uploads), and a reload of the same tab does not start it again. A new session (a new tab) records normally. Earlier versions keep recording and park the refused window in IndexedDB to retry it for 24 hours.

Wiring your own transport (startRecording, startReplayUpload), the wire format, the local IndexedDB rest and its 24-hour expiry, what droppedWindows counts, and the degradation ladder are all in TECHNICAL-DETAILS.md, shipped in this package.

API at a glance

Full reference — every export, every option — is in TECHNICAL-DETAILS.md.

| | | |---|---| | PharosBrowserClient.init(config) | bootstrap and open the live stream | | flag(key, default) | synchronous lookup; no network call, no evaluation | | on("change" \| "error", cb) | subscribe; returns an unsubscribe function | | identify(context) | re-bootstrap as a new context (a full round trip — call it on login, not per render) | | captureException(err, opts?) | report a caught error; never throws; queued and retried if Pharos is down | | pendingErrors() / flushErrors() | reports held for retry; deliver them now | | startReplay(options?) | start a recorder wired to this client, and attach it | | attachReplay / detachReplay | route this client's errors into a recorder you started yourself | | close() | stop the stream and unregister the error listeners | | reportDisplay (config) | send screen + viewport size with the bootstrap (default true; see below) | | errorQueue (config) | queue failed error reports and deliver them once Pharos is back (default true; see "When Pharos is down") |

The privacy policy (decide, maskText, scrubAttribute, collectRecordedStrings, the marker constants) is exported from the root so you can verify what a recorder would send before turning one on.

What the bootstrap tells Pharos about the browser

Pharos's Users dashboard (Pharos 0.41.0) aggregates browsers, operating systems, device classes, languages and countries. Pharos derives all of those on the server, from the bootstrap request's own User-Agent and Accept-Language headers and, behind a configured front proxy, a country header. The SDK sends nothing extra for them, and page script cannot set those headers. The one thing the server cannot see is the display, so the bootstrap carries pharos.display: { screen: [w, h], viewport: [w, h] } in CSS pixels: two pairs of numbers, with no pixel ratio, colour depth or orientation. Pass reportDisplay: false to init to send neither.

Version history

  • 0.12.0 — Idle stretches are not uploaded, and a full session stops recording (Pharos 0.47 security hardening). A closed checkpoint segment with no user input (pointer, mouse interaction, mousemove, touch, drag, scroll, input, keyboard/pointer activation, viewport resize) is no longer sent; the skipped spans are reported on the next window as idleSpans so the console shows them as idle, not as a gap. A 409 from Pharos (the session reached its 300 MB / 10 000-window limit) is treated as final: the window is not parked for retry and nothing more is sent for that session. New: ReplayWindow.idleSpans, UploadEnvelopeMeta.idleSpans, RecorderStats.idleSegmentsSkipped, SESSION_LIMIT_STATUS, and an optional onSessionLimit callback on postEnvelope.

  • 0.11.1 — A dead stream token no longer strands the tab (#1847). The client used to wait for the browser to settle the stream into CLOSED before re-bootstrapping for a new token; one Chrome tab never did, and retried the same dead token every 3 s for a day after a Pharos restart. Now three stream errors in a row with no flag push between them also stop the stream and re-bootstrap, on the usual 1 s to 30 s backoff. (Pharos 0.45.1 also keeps stream sessions across restarts, so a restart no longer kills tokens in the first place.)

  • 0.11.0 — Error reports survive a Pharos outage. A captureException (or auto-captured error) that Pharos cannot take — the fetch rejected, or a 5xx — is no longer lost. It enters a FIFO queue drained one report per request with a 1 s to 30 s backoff, woken by the browser's online event, by a delivered flag push, and on the next page load; queued reports rest in IndexedDB (pharos-errors) with a 24 h local TTL and a 100-report bound that evicts the oldest. A 4xx is dropped, not retried. The healthy path is unchanged: the first attempt is still issued synchronously, with no storage write. New: errorQueue config (false restores the old fire-and-forget), pendingErrors(), flushErrors(), the errors-post-failed / error-report-dropped shapes on on("error"), and the ErrorQueue class on the root for an app with its own transport. Before this the replay window an error triggered outlived the error that triggered it.

  • 0.10.0 — The bootstrap reports screen and viewport size (loxel#1534).

    pharos.display: { screen: [w, h], viewport: [w, h] } (CSS pixels, read from screen and innerWidth/innerHeight) rides the bootstrap for Pharos 0.41.0's Users dashboard. It is omitted when either size is unavailable, and reportDisplay: false turns it off. Browser, OS, device and language need no SDK change because Pharos derives them from request headers. An older Pharos ignores the field. Also exports readDisplaySize().

  • 0.9.0 — Replay windows carry the page's release (loxel#1517).

    startReplayUpload takes a release option and sends it as pharos.release — the same slot the errors envelope uses — on every window, live or persisted for a later drain. client.startReplay() passes PharosContext.release automatically (an explicit release option still wins). Pharos 0.37.0 stamps it on the session so the replay console's top-bar Release filter can find it; an older Pharos ignores the field. Omitted, not sent empty, when the host declares no release.

  • 0.8.1 — A failed replay start no longer leaves a dead recorder hooked into the page.

    When rrweb's record() fails internally it returns undefined, and startRecording already hands back an inert handle. But rrweb has patched Element.prototype.attachShadow by then, and only the stop function it never returned undoes that. The dead session kept observing every shadow root the page attached afterwards and emitted into the NEXT recording, as duplicate shadow-text events. startRecording now restores the native attachShadow when record() fails. It surfaced as leak.test.ts's shadow-DOM cases failing only when they ran after recorder.test.ts.

  • 0.8.0 — identify() tells you the flags changed, and a failed one no longer kills live updates (#788).

    identify() swaps the whole flags map and emitted nothing, so a component that rendered against the anonymous bootstrap kept that value for the life of its mount while the server evaluated the real user's bucket on every request. It now emits change with the new payload. If its bootstrap rejects it still rejects — an app awaiting it on login has to see that — but it now also schedules a reconnect, where before the stream was left dead with nothing pending and nothing reported. Reconnects back off exponentially (1 s doubling to a 30 s ceiling, reset by a delivered flags push) instead of re-bootstrapping every second for the length of an outage.

    flag(key, default) now returns your default when the stored value's type disagrees with it. It was a cast (as T), so a flag delivered as the string "false" read as ON through a boolean gate. null/undefined defaults opt out of the check, since typeof describes the absence of a value for both.

    The replay sensitive-field word list is no longer English-only (#823). It now carries the common Spanish, French, German, Portuguese, Japanese and Chinese spellings of the same fields, and normalization folds accents instead of deleting them (contraseña → contrasena, Straße → strasse) — without which every accented entry would have missed by a character. A localised app previously got essentially nothing from this layer. Annotating autocomplete is still the recommended integration step: it is language-independent and is checked first.

    Why MINOR: change now fires on a path where it did not, a type-mismatched flag stops being returned, and a localised app's fields are masked where they were recorded — so a pinned collectRecordedStrings snapshot will differ. Every move is in the safe direction, but a consumer sees different behaviour either way.

  • 0.7.0 — a marker on a shadow host now protects its shadow root (#821), and the verification helper sees what the recorder sees (#822).

    hasMarkerAncestor walked parentElement, which is null at a shadow boundary, so pharos-mask/pharos-exclude on a web component's host protected nothing inside it — while rrweb recorded that content regardless. Established empirically before the fix: rrweb captures shadow-root text unconditionally (no recorder option gates it), and maskTextHook is invoked for those nodes, so the decision function was the sole failure point. The walk now re-roots through getRootNode()/.host, bounded against a malformed tree.

    collectRecordedStrings did not traverse shadow DOM either. That was not a compensating margin — the auditor's blind spot lined up exactly with the recorder's leak surface, so an app could run the helper, get a clean report, and be shipping shadow-root content. It now traverses, and pathOf() renders a boundary crossing as >>> rather than truncating the path to '' (which was indistinguishable from a root-level row).

    Why MINOR, not PATCH: two consumer-visible outputs change. Content inside a marked shadow host is now masked where it previously shipped in the clear, and collectRecordedStrings reports strings it previously omitted — so a pinned audit snapshot will differ. Both changes are in the safe direction, but a consumer sees different output either way.

    STRUCTURAL_ATTRS is unchanged in membership and is now derived from a justification map: each entry declares a kind — closed-enumeration (which must name its finite vocabulary) or cost-tradeoff (which must carry meetsImpossibilityBar: false). class is recorded honestly as the second kind. The gate cannot verify a claim's truth; that stays human review.

    The first round of that fix only reached the FULL SNAPSHOT. rrweb decides whether to consult maskTextFn in needMaskingText, which returns "nothing to mask" for any node that is not an element and has no parent element — and a text node whose parent is a ShadowRoot is exactly that. So on the incremental streams (characterData, childList adds) the decision function was never called at all and a marked host still shipped shadowRoot.textContent = user.name in the clear once recording had started. An element-wrapped <p> inside the same shadow root did not reproduce it (rrweb takes its element branch there), which is how it shipped. The recorder now post-processes both streams through the same policy, and applies it over rrweb's answer in the snapshot too — in the masking direction only, never unmasking.

    pharos-exclude across a shadow boundary shipped attributes. rrweb's isBlocked uses closest(), which does not cross a boundary, so an add into an excluded host's shadow root was not dropped — and the scrub pass judges a serialized node through a detached surrogate that by construction has no host and no ancestry. Measured: <p title="SECRET">TEXT</p> appended into an excluded host's shadow root shipped its title verbatim while its text was masked. Such adds are now dropped, judged against the live parent the mutation names by id.

    Snapshot noise to expect on your first re-run: shadow roots almost always contain a <style>, and collectRecordedStrings now reaches it and reports its CSS text as an ordinary text row. That is not a leak — author CSS is not user content — but a pinned audit snapshot will grow by more than the app's own strings.

    Still not covered:

    • Closed shadow roots, which rrweb cannot reach either (n.shadowRoot is null), so neither the recorder nor the helper sees them.
    • The full snapshot's own answer for a bare shadow text node is rrweb's, not the policy's, and it flips on the host's shape: needMaskingText returns false for an element with no child nodes, so whether a shadow subtree inherits masking depends on whether the host happens to have light-DOM children. The policy is now applied over the top, but only ever to mask MORE — so an unmarked host's bare shadow text can be masked in the snapshot while collectRecordedStrings reports it. The report is the truthful one: the recorder ships that string verbatim on the incremental stream.
    • An excluded host's shadow subtree can still leave structural traces. Content is dropped, but rrweb's mirror holds nodes this SDK removed from the adds stream, so a later removes entry can carry their ids. Ids and tree shape, never text or attributes.
    • Light-DOM content projected through a native <slot> is no longer on this list: it was measured and is covered. A slotted node is an ordinary light-DOM child of the host — rrweb serializes it under the host and the helper reaches it via el.children — so the two agree with no slot-specific handling, and a marker on the host governs it through ordinary light-DOM ancestry. There is a test for it so it does not have to be re-derived.
  • 0.6.0 — first npm release, as @loxel.dev/pharos-browser. Ships built output with an exports map, so it resolves as an ordinary package.

    Breaking: the recorder moved to the @loxel.dev/pharos-browser/replay subpath (startRecording, startReplayUpload, resolveSessionId, SESSION_STORAGE_KEY, SESSION_DROPS_KEY), and client.startReplay() now returns a promise. Everything else is unchanged on the root.

    Why: rrweb was reachable from the root entry, so it landed in every bundle whether or not anything recorded — +204 kB raw / +63 kB gzip on a real application with replay switched off. It is now loaded on demand, costing 8.5 kB raw / 2.9 kB gzip.

  • 0.5.2 — stops shipping most checkpoint segments twice. A segment flushed by the unload path while still open was shipped again when it closed, roughly doubling ingest for always-on recording. No API change.

  • 0.5.1 — replay-upload correctness. droppedWindows is now cumulative across page loads. A window persisted by a tab switch is uploaded when the tab becomes visible again rather than waiting for a full page load. Adds startReplay(), and a warning when replay and errors would be reported under different session ids.

  • 0.5.0 — session-replay upload: startReplayUpload(), gzip + JSON envelope to POST /api/v1/client/replay, IndexedDB rest for windows triggered during unload, a durable per-tab session id, and droppedWindows.

  • 0.4.0 — session-replay recorder: startRecording() with a pluggable sink, a checkpoint-segmented ring buffer, activation-mode capture that never reads key identity, error-driven windows, and self-degradation under load.

  • 0.3.0 — session-replay privacy contract: pharos-exclude / pharos-mask markers, field-sensitivity detection, attribute/URL/style scrubbing, and collectRecordedStrings for verifying what a recorder would send.

  • 0.2.0 — error reporting: captureException, auto-capture of uncaught errors and unhandled rejections, and parseStack.

  • 0.1.0 — initial release: bootstrap + SSE stream, flag / on / identify / close.