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

@userintuition-ai/recorder

v0.1.1

Published

Standalone rrweb-based session recording script for UserIntuition interviews. Inert until activated by a session token; no screen share.

Readme

@userintuition-ai/recorder

A standalone, customer-installable session-recording script. Customers drop a single <script> tag on their own website; during a UserIntuition interview that shares a link to that site, the script records the participant's session via rrweb and streams curated activity to the AI moderator. There is no screen share -- this script is the alternative to it.

The script is inert on every other page load. It does nothing -- no network, no rrweb, no DOM changes beyond a marker object -- unless it finds an active session (see "Activation" below).

Install

<script async src="https://cdn.jsdelivr.net/npm/@userintuition-ai/recorder@0/dist/recorder.umd.min.js"></script>

Put it once, near the end of <body> (or in <head>, it doesn't matter -- async means it won't block rendering), on every page the interview might touch. It is safe to leave installed permanently; it is inert without an active session.

Optional masking configuration

Add these attributes to the same <script> tag. Each takes a comma-separated list of CSS selectors.

<script
  async
  src="https://cdn.jsdelivr.net/npm/@userintuition-ai/recorder@0/dist/recorder.umd.min.js"
  data-ui-mask=".card-number, .ssn-input"
  data-ui-block=".internal-admin-panel"
  data-ui-ignore=".search-as-you-type"
></script>
  • data-ui-mask -- elements (and their descendants) are masked: rrweb records a redacted placeholder instead of real text/values, and the curated click feed (see below) never includes their label.
  • data-ui-block -- elements (and descendants) are fully excluded from the rrweb recording (rendered as a blocked placeholder), and likewise never contribute a label to the curated click feed.
  • data-ui-ignore -- input elements matching these selectors have their keystrokes/values excluded from rrweb's input tracking entirely (distinct from masking: the element is still visible in the recording, its typed content just isn't captured at all). Useful for noisy, non-sensitive fields you'd rather not track (e.g. a live search box).

maskAllInputs is always on regardless of data-ui-mask/data-ui-block: every <input>/<textarea> value is masked by default.

A note on Subresource Integrity (SRI)

The snippet above intentionally does not include an integrity="sha384-..." attribute. @1 is an auto-updating CDN range that always serves the latest compatible release; a pinned SRI hash would break on every new recorder release (the hash wouldn't match the new file, and the browser would refuse to load the script). If your security policy requires SRI, pin an exact version instead of @1 and generate the hash yourself for that specific build -- but then you own re-pinning it on every upgrade.

How it works

  1. On load, index.ts sets window.UserIntuitionRecorder = { version, active, stop } immediately -- a truthy marker object, even when the script stays inert. This is the detection signal other UserIntuition tooling looks for (alongside the src containing "userintuition" and "recorder").
  2. It looks for a session in the URL fragment (#uir=..., see below) or a previously-persisted session in sessionStorage.
  3. With neither, it does nothing else. No network call, no rrweb.record(), no DOM changes.
  4. With a session, it boots: strips the fragment from the URL immediately, starts rrweb recording, shows a small "Recording for UserIntuition" indicator + Stop control (shadow DOM, won't clash with page styles), and starts talking to the backend (see "Wire contract").

Wire contract

This is the shared contract with the interview call page (which produces the fragment and speaks the other end of the channel) and the backend ingest API (/api/concept-link-recordings/{id}/...). Keep this section and src/wire.ts / src/session.ts / src/channel.ts in sync if either side changes.

Ingest origin

Compiled into the bundle at build time as a constant (INGEST_ORIGIN), never read from the fragment or any runtime source. Default: https://api.userintuition.ai. Override at build time:

INGEST_ORIGIN=https://ingest.example.com npm run build

All ingest calls go to ${INGEST_ORIGIN}/api/concept-link-recordings/{recording_id}/....

Session fragment

#uir=<recording_id>.<token>
  • The whole hash must start with #uir=.
  • The script splits the remainder on the first . only: recording_id = everything before it, session_token = everything after (the token is an opaque string and may itself contain dots).
  • The fragment is stripped from the URL via history.replaceState(null, '', pathname + search) immediately upon parsing -- before anything else runs -- so the token never lingers in the address bar, browser history, or an SPA router's state.
  • The parsed session ({recording_id, token, startedAt, lastSeq, signalSeq}) is persisted to sessionStorage['__uirec'] so it survives full-page navigation on the same origin. lastSeq (the /chunks counter) and signalSeq (the /signal counter) are separate fields, updated independently, so a resumed recorder continues BOTH sequences instead of restarting either one. On a later page load with no fragment, the script resumes from sessionStorage instead. With neither a fragment nor a resumable session, the script is inert.

The call page (Task 8/9) is responsible for producing this fragment when it opens/embeds the customer page; the script never calls /start, and never parses the token for anything beyond splitting it from recording_id -- it is echoed back opaquely for auth.

Endpoints

All POST, base ${INGEST_ORIGIN}/api/concept-link-recordings/${recording_id}:

| Endpoint | Body | Notes | |---|---|---| | /chunks | canonical payload (see below) | Upload a chunk of rrweb events. | | /signal | {type: "hello"\|"heartbeat"\|"activity", data?, seq} | Sent in both modes now (see "Channels" below). data is a curated activity object for type:"activity"; must serialize under 8KB. seq is a monotonically increasing counter shared across all three types, persisted (see below) so the backend can dedupe a retried send. | | /poll | {cursor?} | Tab mode only. Returns {status, is_partial, activity, next_cursor, heartbeat_at}. Safe to call repeatedly (non-destructive read). | | /finalize | {final_seq} | Sent by the script once it observes the interview closing (see "Channels"), after draining the outbox. | | /abort | {reason} | The script only ever sends reason: "participant_stopped" (the Stop control). Other reasons (handshake_timeout, heartbeat_lost, screen_share_fallback, call_cancelled) are sent by the call page, not this script. |

The script never calls /start (the call page does, to mint the fragment) and never calls /close (parent-initiated, not a script action).

A 409 from any endpoint means the recording is already finalized/aborted server-side. The script stops immediately and clears its session -- it never retries after a 409.

Canonical /chunks payload and dual transport

The canonical payload is:

JSON.stringify({ seq, events }) // serialized exactly once

This exact string is reused, byte-identical, across both transports:

  • Normal upload (fetch): Authorization: Bearer <session_token>, Content-Type: application/json, body = the canonical payload string itself (not wrapped in anything).
  • Unload drain (sendBeacon): sendBeacon cannot set headers, so the token travels inside the body instead: JSON.stringify({ session_token, payload }), where payload is the canonical payload string (nested as a string, not a re-parsed object). The script checks sendBeacon's boolean return -- false means the browser didn't accept the send, and the chunk stays queued for a later retry rather than being dropped.

seq is monotonically increasing starting at 1. final_seq (sent to /finalize) is the last seq produced.

Channels: two modes, chosen once at boot

  • iframe mode -- window.parent !== window (the recorder page is embedded in an iframe on the call page). Hello goes only to the parent via postMessage. Heartbeat goes to both the parent (via postMessage, since the parent still needs it directly for its own screen-share-fallback detection) and POST /signal (the backend batches activity server-side and pushes it into the live call, which needs to keep working even when the parent tab is backgrounded). Curated activity is POST /signal-only -- it no longer goes via postMessage at all:

    parent.postMessage(
      { source: 'ui-recorder', token, recordingId, type, payload },
      parentOrigin // resolved from location.ancestorOrigins[0] or document.referrer
    );

    Incoming messages are only accepted if event.origin matches the resolved parent origin (or is in location.ancestorOrigins) and event.data.token matches this session's token. The parent sends {type: 'close', token} (drain the outbox, then POST /finalize {final_seq}) or {type: 'ack', token} (informational, no action).

  • tab mode -- top-level window (opened via window.open, no reachable parent). Hello/heartbeat/curated-activity all go to POST /signal (there's nothing to postMessage). The script polls POST /poll every ~3s to read status:

    • 'closing' → drain the outbox, then POST /finalize {final_seq}.
    • 'aborted' or 'complete' → stop immediately and clear the session (no finalize call).

In both modes, /chunks uploads are always used (durable, regardless of channel mode) and a heartbeat is sent every ~5s.

/signal sends are serialized through a single promise chain per Channel instance (never overlapping in-flight POSTs), so a retry can't be reordered against a later send. An activity send that gets back {ok:false} (network failure, non-2xx) is retried a bounded number of times with the same seq -- a genuinely-lost send is re-delivered, and a response-lost retry is deduped server-side by that unchanged seq. hello/heartbeat are best-effort/single-attempt on purpose: a dropped heartbeat is superseded by the next one ~5s later, and retrying it would sit in front of later sends in the serialized chain and delay them.

pagehide behavior (important)

On pagehide, the script only drains whatever's queued in the outbox via sendBeacon to /chunks (best-effort). It never calls /finalize, /close, or /abort from a pagehide-driven path -- a page unload is not a signal that the interview is over (the participant might navigate to another page on the same site and the session will resume from sessionStorage there). Finalization only ever happens via the explicit channel signals above (parent close message, or poll status: 'closing').

Self-limits

The script stops itself (and clears the session) if:

  • 10 consecutive /chunks upload failures occur (network down, endpoint unreachable, CORS misconfigured, etc.).
  • Any /chunks call returns 409 (finalized/aborted server-side).
  • 2 hours elapse since boot.
  • The participant clicks Stop (sends POST /abort {reason: 'participant_stopped'} first, then clears).

window.UserIntuitionRecorder.stop() triggers the same participant-stop flow programmatically.

Module structure (src/)

| Module | Responsibility | |---|---| | index.ts | Sets the marker immediately; auto-init: parse fragment/resume session, boot or stay inert. | | session.ts | Fragment parsing, immediate stripping, sessionStorage persistence/resume. | | outbox.ts | Bounded IndexedDB (or in-memory fallback) queue of unacked /chunks; replay-on-boot; pagehide drain via sendBeacon. | | recorder.ts | Wraps rrweb.record(); batches events into chunks; owns the seq counter. | | curated.ts | Derives click / rage-click / navigation activity from the DOM + history, respecting mask/block selectors. | | channel.ts | iframe-postMessage vs tab-/signal+/poll mode; auth + origin validation. | | consent.ts | Shadow-DOM recording indicator + Stop control. | | wire.ts | Shared canonical payload builder, endpoint URLs, fetch/beacon transport helpers. | | masking.ts | Shared mask/block selector matching used by both recorder.ts and curated.ts. | | config.ts | Build-time constants and tuning knobs (intervals, caps, self-limits). |

Build

npm install
npm run build     # tsup -> dist/recorder.umd.min.js (IIFE, minified) + dist/recorder.esm.js
npm run size       # reports the gzipped size of the IIFE bundle against the 35KB budget

rrweb is pinned to exactly 2.0.0-alpha.4 to byte-match the version the main frontend's rrweb-player uses for replay -- do not bump this independently of the frontend.

Test

npm test          # jest + jsdom
npm run typecheck # tsc --noEmit

Demo

A tiny multi-page static site under demo/ exercises the script locally (no real backend required -- network calls will fail, which is expected and demonstrates the self-limit behavior).

npm run demo       # builds, copies the bundle into demo/vendor/, and serves demo/ on :4444

Open http://localhost:4444/, then use the "Start a demo recording session" link (carries a #uir=... fragment) to activate the recorder, and navigate between pages to see the session resume from sessionStorage.

License

UNLICENSED / proprietary -- internal UserIntuition package.