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

@klyfa/web

v0.6.0

Published

First-party, cookieless-by-default web analytics for the Klyfa platform. Built on @klyfa/core.

Readme

@klyfa/web

First-party, cookieless-by-default web analytics for sites and web apps on the Klyfa platform. One SDK, one init, one identity. Built on @klyfa/core.

Install

npm install @klyfa/web

@klyfa/core comes along as a dependency. The package ships compiled ESM and CommonJS with TypeScript declarations, so it works without asking your bundler to transpile node_modules — Vite, Astro, webpack (even with exclude: /node_modules/), Next.js without transpilePackages, Jest, and plain Node all consume it as-is.

Setup

import {Klyfa} from '@klyfa/web';

Klyfa.init({
  apiUrl: 'https://api.klyfa.com',
  clientId: 'YOUR_WEB_CLIENT_ID', // the project's web SDK client
});

Call init() once, as early as possible. By default a screen_view fires on load and on every client-side navigation (History API + Astro View Transitions), so single-page and multi-page apps both work with no extra code.

Tracking

Klyfa.track('cta_click', {cta: 'get_started'});
Klyfa.screenView('/pricing');            // manual page view (optional)
Klyfa.setGlobalProperties({plan: 'pro'}); // attached to every later event

Referrer attribution

The referring URL for the page load rides on the session's first event, so organic and social traffic is attributed rather than counted as direct. Same-origin referrers are dropped — in a multi-page site every internal navigation would otherwise look like a self-referral. UTM parameters are read from the landing path and, failing that, the referrer's query string.

Time on screen

The SDK measures how long each page is actually in front of the visitor and reports it as a screen_time event when they navigate away or hide the tab. That event carries the departed page's path and its duration in milliseconds, so the Pages report's Avg duration column reflects the page it measured. Time while the tab is hidden is not counted, so an hour in a background tab adds nothing.

Without it, "time spent" can only be inferred from the gap between events — which reads a three-minute read with no clicks as zero seconds. The cost is one extra event per page visit. Turn it off with init({trackScreenTime: false}).

A visit interrupted by tab-switching reports one segment per stretch of attention rather than one row per visit, so total time on a page is the sum of its screen_time durations. Stretches under a second aren't reported on their own — they're carried and folded into the next one, so nothing is lost.

screen_time is instrumentation rather than something the visitor did, so the server excludes it from bounce rate, session event counts, and page-view counts. It does appear in the raw event stream.

Storage — cookieless by default

init({storage}) decides whether anything is written to the visitor's device:

| Mode | Stores on device | Identity | Consent banner | Funnels & retention | |------|------------------|----------|----------------|---------------------| | 'none' (default) | nothing | server-derived (IP + user-agent) | not required | counted, but the identity rotates monthly | | 'local' | a stable device id + the offline queue in localStorage | the stored device id | required | counted by a stable id | | 'cookie' | a stable device id in a first-party cookie; the offline queue in localStorage | the cookie's device id | required | counted by a stable id, shared across subdomains |

'none' is the strict, GDPR-clean default: no cookie, no localStorage, so a consent banner isn't needed. Choose 'local' when you want a stable per-browser identity (see identity stitching below) — and pair it with a consent flow.

What the mode costs you in the dashboard. Funnels and retention count anonymous visitors, so they work without identify() in any mode. But under 'none' a visitor's identity is a hash of their IP + user-agent, and the server rotates the salt monthly: the same person looks like a new visitor after a rotation or a network change, which flattens multi-week retention and splits long funnels. 'local' and 'cookie' send a stable install id that doesn't rotate. Calling identify() is better still — it's the only thing that follows one person across devices.

When to choose 'cookie'

'local' is origin-scoped, so www.example.com and app.example.com get different ids, and nothing on your server can read it. Those are the two — and the only two — reasons to pick 'cookie':

Klyfa.init({
  apiUrl, clientId,
  storage: {mode: 'cookie', domain: '.example.com'}, // one jar for every subdomain
  consent: 'granted',
});

| Option | Default | Notes | |--------|---------|-------| | domain | none (host-only) | Must be stated to share the id across subdomains. Never inferred from the hostname — stripping labels breaks on foo.co.uk and on preview hosts like *.vercel.app, where a parent-domain cookie either fails silently or leaks identity between unrelated sites. | | maxAge | 400 days | Seconds. 400 days is the ceiling Chrome clamps cookie lifetimes to. | | sameSite | 'Lax' | 'None' is opt-in only (and forces Secure) — pass it only if your site runs in a cross-site iframe. | | secure | true on https | |

Only the device id goes in the cookie. The offline queue stays in localStorage: it is JSON that can reach 500 events against a ~4KB cookie budget, and every cookie is sent on every request to the domain, so a queue in a cookie would truncate and tax the whole site. The bare string 'cookie' also works and gives a host-only cookie (server-readable, not shared across subdomains).

The funnel below needs neither mode: it carries identity across the browser→server boundary as an opaque id you mint yourself.

The consent default follows the storage posture. With 'none' there is nothing stored and nothing to ask, so consent defaults to 'granted'. With 'local' or 'cookie' it defaults to 'pending' — events buffer in memory and nothing touches the device until you call setConsent('granted'). Storing an analytics identifier isn't "strictly necessary" under ePrivacy Art 5(3), so the SDK won't do it before you say the visitor agreed. If you already have a lawful basis, say so at init:

Klyfa.init({apiUrl, clientId, storage: 'local', consent: 'granted'});

Upgrading from 0.1.x? This is the one behavior change that can lose data. If you use storage: 'local' or 'cookie' and never call setConsent(), events will now buffer instead of send. Pass consent explicitly. The SDK logs a warning at init when it applies this default.

The SDK is SSR-safe: it touches no browser globals at import, and on the server it degrades to no storage and no DOM listeners.

Global Privacy Control

If the visitor's browser asserts Global Privacy Control — Brave and DuckDuckGo send it by default, Firefox in private windows, plus extensions like Privacy Badger — the SDK downgrades to cookieless, unlinked tracking, whatever storage you configured:

  • nothing is written to the device — no cookie is set — and anything an earlier visit stored (the client device id in either backing store, the offline queue) is wiped
  • no client id is sent, so identity is the server-derived one and only
  • identify() is ignored — the visitor is never linked across visits
  • page views and events still count, anonymously
Klyfa.getPrivacySignals();
// → {gpc: true, dnt: false, cookielessEnforced: true}

This is stricter than the law requires. GPC is a do-not-sell-or-share signal, and CCPA § 7025(c)(1) says honoring it "is not required for a business that does not sell or share personal information" — which first-party, single-tenant analytics does not. Klyfa honors it anyway, because a visitor asking not to be profiled should not have to know how your vendor is contracted.

Set init({respectGpc: false}) only if you have established the signal doesn't apply to you: it is legally binding in twelve US states, and enforcement is active (Sephora, Todd Snyder, Healthline, Tractor Supply).

Don't pop up a notice because GPC is set — § 7025(f) forbids responding to the signal with a notification or interstitial. getPrivacySignals() is for suppressing a banner that has nothing left to ask, or rendering the "opt-out request honored" status § 7025(c)(6) requires you to display.

Do Not Track, GPC's dead predecessor, is detected but not acted on — no browser or regulator gives it weight, and several turned it on by default. Opt in with init({respectDnt: true}) if you want the belt-and-braces posture.

The Klyfa server honors GPC independently, by reading the Sec-GPC: 1 header browsers attach to the ingest request (per-project toggle, default on). The two are complements, not duplicates: Brave on iOS sets the JS property but sends no header, so the SDK catches those visitors; an SDK too old to know about GPC still sends the header, so the server catches those. respectGpc: false only turns off the client half — the project setting governs the server half.

Identity & consent

Klyfa.identify(profileId);            // opaque id recommended — NOT an email
Klyfa.setConsent('granted');          // 'granted' | 'pending' | 'denied'
Klyfa.clear();                        // logout / erasure: reset identity, drop buffered events

identify(profileId) ties this visitor's anonymous page views to profileId (server-side anon→identified stitch). Consent gates delivery: 'pending' buffers events in memory (nothing sent, nothing persisted) until 'granted'; 'denied' drops them.

Two properties of that stitch are worth designing around, because both are easy to discover a year later as a data anomaly rather than up front:

  • It reaches 30 days back, and no further. A visitor whose first session was more than 30 days before they identified keeps only the last 30 days of their pre-identify journey. If your acquisition funnel spans longer, measure it from the device id rather than from the profile.
  • It runs once, at identify(), and cannot be reconstructed. There is no backfill job. Skip the call — or defer it past the window — and those page views stay anonymous permanently. This is why calling identify() with no traits at all is still worth doing.

What it matches on depends on the project's identity mode (Project → Privacy → Identity mode): standard claims the visitor's device id, authenticated confines the stitch to a single session so a shared IP+UA can never retroactively reassign someone else's page views.

Only 'granted' writes to the device. That matters for the usual way to boot a returning visitor — read your own consent cookie and pass what it says:

Klyfa.init({apiUrl, clientId, storage: 'local', consent: readMyConsentCookie()});

With 'denied' this stores nothing at all (no device id, no queue), and calling setConsent('denied') later removes what an earlier 'granted' session wrote.

identify() is also a no-op for a visitor asserting Global Privacy Control (see above), so a funnel built on it will under-count rather than mis-count — those visits stay anonymous page views. Check getPrivacySignals().cookielessEnforced if your funnel needs to know.

Groups — per-tenant reporting

Klyfa.setGroup('vessel:1');        // every later event is tagged with this tenant
Klyfa.upsertGroup({id: 'vessel:1', type: 'vessel', name: 'MV Nordlys'});

A group is an account, not a person — your customer's customer. Tagging events with one is what makes per-tenant reporting work (filter the dashboard by group, export one tenant's data). Because it identifies an organisation rather than an individual, group tagging keeps working for a visitor asserting Global Privacy Control, where identify() does not.

clear() drops the group along with the identity, so on a shared dashboard the next person to log in never inherits the previous tenant.

Funnel pattern (browser → server-verified conversion)

Connect anonymous browsing to a server-verified conversion without sending an email to Klyfa — mint an opaque id, identify with it in the browser, and hand the same id to your backend so it can emit the trusted conversion:

const pid = crypto.randomUUID();
Klyfa.identify(pid);                                  // stitches this visitor's page views to pid
await fetch('/api/signup', {                          // your backend maps pid <-> email privately
  method: 'POST',
  body: JSON.stringify({pid, email}),
});
// your backend then POSTs /track with a server client secret:
//   track 'lead_submitted' { profileId: pid }  (source='server', spoof-proof)

Because Klyfa never sees the email, only the opaque pid, the funnel (visit → form → verified lead) is one identity with no PII in analytics.

For a B2B dashboard where colleagues share one office IP, set the project's identity mode to authenticated and use storage:'local' so each browser sends a stable id — otherwise IP+user-agent collisions merge colleagues.

API

| Method | Description | |--------|-------------| | init({origin}) | Label which surface this build is ('web', 'pro-web', …) — stamped on every event so the dashboard can separate products sharing one project | | init(config) | Start the SDK. {apiUrl, clientId, clientSecret?, consent?, storage?, trackScreenViews?, trackScreenTime?, respectGpc?, respectDnt?, debug?} | | track(name, props?) | Record a custom event | | screenView(path?, props?) | Record a page view (auto-fired on navigation by default) | | identify(profileId, traits?) | Identify the current visitor | | setConsent(state) / getConsent() | GDPR consent posture | | getPrivacySignals() | What the browser asserted (gpc, dnt) and whether the SDK acted on it | | setGroup(id) / setGroups(ids) | Tag subsequent events with a tenant/account (a clinic, a vessel) | | upsertGroup({id, type, name, properties?}) | Create or update a group's own attributes | | revenue(amount, props?) | Record revenue in minor units; pass currency | | increment(prop, value?) / decrement(...) | Add to a numeric property on the identified visitor's profile | | setGlobalProperties(props) | Properties attached to every subsequent event | | getDeviceId() / getSessionId() | Server-assigned ids (empty until the first event) | | flush() | Force delivery of buffered events now | | clear() | Reset identity and drop buffered events | | destroy() | Tear down listeners (for re-init / teardown) |

License

MIT — see LICENSE.