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/react-native

v0.11.0

Published

Klyfa analytics, feature flags, kill-switch, and push registration for React Native.

Readme

@klyfa/react-native

Analytics, feature flags, kill-switch, and push registration for React Native apps on the Klyfa platform. One SDK, one init, one identity.

Install

npm install @klyfa/react-native react-native-device-info
# a store — AsyncStorage is the common choice, but see "Storage" below:
npm install @react-native-async-storage/async-storage
# optional, recommended — offline-aware event flushing:
npm install @react-native-community/netinfo

Setup

// lib/klyfa.ts
import {Klyfa} from '@klyfa/react-native';
import {asyncStorageAdapter} from '@klyfa/react-native/async-storage';
import NetInfo from '@react-native-community/netinfo';

export async function initKlyfa() {
  await Klyfa.init({
    apiUrl: 'https://api.klyfa.com',
    clientId: 'YOUR_CLIENT_ID',
    storage: asyncStorageAdapter(),
    networkInfo: NetInfo, // optional
  });
}

Call initKlyfa() once at app boot, before rendering.

Storage

storage is required, and says where the SDK keeps the install id, the offline queue, the push token, the flag cache, and the attribution result.

It used to be an implicit AsyncStorage import, which meant every app got that native module whether it wanted it or not — including apps that standardise on something else, and apps that have to justify each native dependency in a regulatory submission. Naming the store makes it your choice, makes the peer dependency genuinely optional, and makes 'none' sayable.

The interface is three methods, and they may be synchronous:

interface KlyfaStorage {
  get(key: string): string | null | Promise<string | null>;
  set(key: string, value: string): void | Promise<void>;
  remove(key: string): void | Promise<void>;
}

So MMKV — which is synchronous, returns undefined for a miss (also accepted), and can take an encryption key — is a shim:

import {MMKV} from 'react-native-mmkv';
const mmkv = new MMKV({id: 'klyfa', encryptionKey: await getKeyFromKeychain()});

storage: {
  get: (k) => mmkv.getString(k) ?? null,
  set: (k, v) => mmkv.set(k, v),
  remove: (k) => mmkv.delete(k),
}

The SDK never encrypts anything itself — if you need encryption at rest, it belongs in the adapter, and the key is yours to manage. Note that MMKV is plaintext unless you pass encryptionKey; a bare new MMKV() is no more encrypted than AsyncStorage, so switching stores is not by itself a confidentiality improvement. Be deliberate about that: if the key rotates or fails to load, every value below is unreadable at once, which looks exactly like a device that skipped its migration.

A store that throws is fine. Every call is best-effort — a full disk or a locked keystore degrades the SDK, it never fails your app.

storage: 'none'

Persists nothing:

await Klyfa.init({apiUrl, clientId, storage: 'none'});

No install id is minted, so identity falls back to the server-derived (IP+UA) device id; the offline queue lives in memory and dies with the process; and Klyfa.clear() cannot detach a push token it never remembered, so pass one explicitly. Flags and the kill-switch still work — they just re-fetch on every cold start instead of serving a cache.

This is the strictest posture available and mirrors the default of @klyfa/web. It is a real trade, not a free switch: without an install id the anonymous→identified stitch has only the rotating IP+UA hash to work with, so pre-login journeys get materially less reliable.

Changing stores

Switching stores strands whatever the old one held. Two of those keys fail silently — a lost install id quietly becomes a new anonymous identity, and a lost push token leaves a shared device bound to the previous user — so the SDK will carry them across for you:

import {asyncStorageAdapter} from '@klyfa/react-native/async-storage';

await Klyfa.init({
  apiUrl, clientId,
  storage: mmkvAdapter,
  // One-time. Runs before anything reads, fills gaps only (never overwrites),
  // then writes a sentinel and never reads this again.
  migrateStorageFrom: asyncStorageAdapter(),
});

Ship that in the release that changes storage, and delete the line a release later. If one binary inits with more than one client id — a dev / staging / production selector, say — keep it until every one of them has booted once on every device, since each client id migrates its own project-scoped keys.

One binary, several projects

If your app picks a project at runtime and passes a different clientId per build channel, note which keys are shared and which are not (the Scope column above).

Per client id, because the value belongs to a project: the offline queue and the flag cache. This matters more than it sounds — a queued event carries a profileId minted under one project, and flushing it under a different klyfa-client-id attaches that profile to the wrong project. An in-place update that flips the selector (same bundle id, so the data container survives) is enough to trigger it. The SDK namespaces these for you as of 0.11.0; do not also prefix them in your adapter, or the keys get namespaced twice and your existing data is stranded.

Shared across every client id, because the value belongs to the device: the install id, the push token, and the attribution result. Splitting those would be actively harmful rather than merely wasteful — three install ids means three anonymous identities for one phone, a per-project push token slot breaks detach() after a channel flip, and attribution is worst of all, because POST /match consumes a click from an org-wide pool, so one install would burn one click per project and could take a different user's. Leaving it in longer is inert rather than harmful, but it keeps the old native module in your build, which is usually the thing you were removing.

These are the keys involved, worst-to-lose first:

| Key | Scope | Holds | If it is lost | | --- | --- | --- | --- | | @klyfa/install_id | device | The durable anonymous identity | A new anonymous actor; the pre-login stitch breaks. Unrecoverable. | | @klyfa/push_token | device | The token, so clear() can detach | A shared device keeps the previous user's binding. | | @klyfa/attribution | device | Resolved deep-link match | Re-resolves, consuming a second click from your org's pool. | | @klyfa/queue | per client id | Up to 500 buffered events | Silent event loss, bounded. | | klyfa.flags.cache | per client id | Last known flag values | Defaults until the first refresh; the kill-switch is fail-open until then. | | @klyfa/offline_queue | device | A pre-0.11 backlog | Adopted into @klyfa/queue on first launch, then zeroed. Read-only. | | @klyfa/attribution | Resolved deep-link match | Re-resolves; risks a duplicate attribution. | | @klyfa/push_opens | Open-dedup memo | A few duplicate notification_opened events. | | klyfa.appstore.appId.<bundleId> | iTunes id memo | One extra API call. |

Analytics

Klyfa.screenView('Home');
Klyfa.track('questionnaire_completed', {score: 42});
Klyfa.revenue(999, {product: 'premium_monthly'}); // amount in cents

Events are queued while offline (persisted through your storage), flushed on reconnect and app foreground, and drained with bounded concurrency so a large backlog can't trip the ingest rate limit. Delivery is durable but not infinite: a transient failure (offline, rate limit) re-queues for free, but an event the server keeps rejecting with a 5xx is dropped after a few attempts, and the offline queue is capped at 500 events — beyond that the oldest are dropped. This bounds memory and storage on a long-offline device; it is not an at-least-once delivery guarantee.

Both drops always warn to the console — neither hides behind debug — and both are reportable, so silent loss is visible in production rather than only in a debug build:

await Klyfa.init({
  apiUrl, clientId, storage,
  onDropped: (payload, reason) => {
    // reason: 'queue_overflow' | 'delivery_failed'
    Sentry.captureMessage(`klyfa dropped an event: ${reason}`);
  },
});

Time on screen

Each screenView() starts a timer. When the user moves to another screen or backgrounds the app, the SDK emits a screen_time event carrying the departed screen's route and how long it was actually in front of the user, in milliseconds. Time while the app is backgrounded is not counted, and the transient inactive state (app switcher, incoming call) pauses the clock without ending the visit.

This only works if you call screenView() on navigation — there is no auto-tracking on native. The cost is one extra event per screen visit, and a visit interrupted by backgrounding reports one segment per stretch of attention, so total time on a screen is the sum of its durations. Stretches under a second are folded into the next one rather than dropped. Turn it off with init({trackScreenTime: false}).

screen_time is instrumentation rather than something the user did, so the server leaves it out of bounce rate, session event counts, and screen-view counts. It still shows up in the raw event stream.

Consent (GDPR)

Analytics can be gated on a consent decision while feature flags and the kill-switch stay live (they're strictly-necessary):

await Klyfa.init({apiUrl, clientId, consent: 'pending'}); // boot gated
// ...after the user decides:
Klyfa.setConsent('granted'); // deliver buffered + future events
Klyfa.setConsent('denied');  // drop buffered events and future ones

While consent is pending, analytics events buffer in memory only — nothing is sent and nothing is written to disk, so an app killed before the decision retains no pre-consent analytics data. That includes the install id: the durable device identifier is minted on setConsent('granted'), not at init.

Flags are the exception, and it is deliberate. Feature flags and the kill-switch are strictly-necessary, so FlagsAPI refreshes on init() regardless of consent — meaning that even under pending or denied the SDK makes one GET /api/flags and writes one key, the flag cache, to your store. That request carries no distinct_id and the cache holds flag keys and evaluated values only — no personal data, no identifier — which is the basis for treating it as strictly-necessary. It is still a network call and a disk write, so list it in your ROPA rather than meeting it for the first time in an audit.

There is deliberately no build flag to switch this off. A build that can disable its own flag fetch is a build whose kill-switch can no longer reach it, which defeats the one control that exists for when you have lost control of the fleet.

denied drops the in-memory buffer, removes any persisted queue, and erases the install id — so refusing after a previous session had consented takes the identifier off the device rather than only stopping its use. The user's distinct-id is withheld from flag evaluation until consent is granted.

Global Privacy Control is web-only — there is no navigator on native and no equivalent OS signal — so on React Native setConsent() is the only gate. @klyfa/web honors GPC where it exists.

Privacy note for your DPO: with consent granted, up to 500 events — including any identify() traits like email/name — may sit buffered on the device while it is offline. They are written through whatever storage you passed, in plaintext, so if that buffer needs encryption at rest, pass an encrypted adapter (see Storage); AsyncStorage does not encrypt, MMKV takes a key. Call Klyfa.clear() on logout (and when servicing an Art-17 erasure) to drop the buffer regardless.

Identity — one call for everything

// After login: analytics profile + flag targeting + push ownership.
Klyfa.identify(user.id, {email: user.email});

// On logout: clears analytics identity, flag targeting, cached flags.
await Klyfa.clear();

What identify() does to events from before the login

Calling identify() back-fills this user's earlier anonymous events onto the profile, so the pre-login journey — first open, browsing, sign-up — lands on the same person as everything after it. This is what keeps one human from being counted as two actors in funnels and retention, and it is why you should call identify() at login even with no traits to pass. A bare Klyfa.identify(user.id) is meaningful on its own.

The stitch is server-side and happens on the identify call itself:

| Project identity mode | Scope of the back-fill | | --- | --- | | standard (the default; mobile) | the install id or the derived device id | | authenticated | the install id alone, or — cookieless — the current session only |

In every mode it reaches 30 days back, and only touches events that are still unattributed. Two consequences worth designing around:

  • A user who first opened the app more than 30 days before signing up keeps only the last 30 days of their pre-login journey. If your acquisition funnel spans longer than that, measure it from the install id rather than from the profile.
  • The stitch is not reconstructable later. It runs once, at identify(). Skip the call — or defer it past the window — and those events stay anonymous permanently; there is no backfill job to run afterwards.

Events buffered under consent: 'pending' are stitched correctly: they are stamped with the install id when consent is granted and they flush, not when they were queued.

On a shared device, Klyfa.clear() rotates the install id, so the next person to log in cannot be linked to the previous user's install. Events the previous user was already identified against are never re-attributed either — the back-fill only touches events that are still unattributed.

What can still cross users is the window in between: in standard mode the stitch also matches on the derived device id, which is a hash of IP + user agent, so genuinely-anonymous activity on that device (or on another device behind the same NAT) in the last 30 days can be claimed by whoever identifies next. If that matters for your deployment — a clinic tablet, a shared workstation — set the project to authenticated identity mode (Project → Privacy → Identity mode), which drops the device-id leg and confines the stitch to one install or one session.

Push

After obtaining a device token from your push library:

Klyfa.push.register({token});          // binds to the identified profile, if any

Register as soon as you have a token — you do not need to wait for login. A subscription starts anonymous and is bound to a profile when one is known, so the OS permission prompt does not have to sit inside your sign-up flow, and a returning user is reachable the moment they identify. An anonymous device is never reached by a profile-addressed send.

Klyfa.clear() detaches the device on logout for you — the registration and the granted permission survive, only the identity is dropped, so the next person to log in on a shared device is reachable without another prompt and does not receive the previous user's notifications.

Klyfa.push.detach();                   // drop the identity, keep the registration
Klyfa.push.unregister(token);          // drop the registration entirely (rare)

Identity verification (optional)

Your client ID ships inside your published app, so by default anyone who extracts it can register their own device against one of your user IDs and receive that person's notifications. To close that, have your backend sign a short-lived JWT whose sub is the user ID, register the public key in Klyfa under Project → Push identity, and pass the token at login:

Klyfa.identify(user.id, {email: user.email}, jwtFromYourBackend);

// Or set it independently — e.g. after a refresh:
Klyfa.setIdentityToken(jwtFromYourBackend);

Tokens are short-lived by design, so handle rejection and fetch a fresh one:

Klyfa.init({
  apiUrl, clientId,
  onIdentityRejected: async ({profileId}) => {
    // That's all. Supplying a fresh token re-attempts the registration that
    // was rejected — you don't need to call register() again yourself.
    Klyfa.setIdentityToken(await fetchKlyfaToken(profileId));
  },
});

The JWT needs sub (your user ID) and exp. Sign it with RS256 or ES256 — the private key stays on your server, so Klyfa cannot mint a token for one of your users. Klyfa.clear() drops the token along with the rest of the identity.

Anonymous registration never needs a token, so logged-out devices stay reachable for broadcast sends even with enforcement on.

Open tracking

Without this call your Open rate is not zero — it is unmeasured, and the dashboard says so rather than showing 0%. Call trackOpen from wherever your notification library reports a tap, passing whatever it hands you:

import messaging from '@react-native-firebase/messaging';

// Tapped while the app was running or backgrounded.
messaging().onNotificationOpenedApp((message) => Klyfa.push.trackOpen(message));

// Tapped from cold start.
messaging().getInitialNotification().then((m) => m && Klyfa.push.trackOpen(m));

It accepts the raw payload, the remote message, an expo-notifications response, or a bare id string — the notification id lives in a different place on each platform (APNs puts it beside aps, FCM inside data, and each library nests that differently), so the SDK digs it out rather than making you fork on Platform.OS. Repeat calls for the same notification report once, which matters because a cold start commonly delivers one tap through both callbacks above.

This has to be an explicit call: the open callback belongs to whichever library registered the platform delegate — expo-notifications, @react-native-firebase/messaging, notifee — they are mutually exclusive, and this package deliberately depends on none of them.

Two cases it cannot see, by construction:

  • Foreground taps on Android. Sending a notification block means the system does not post a tray notification while your app is in the foreground, so there is nothing to open. Display it yourself (e.g. notifee) if you need those counted.
  • iOS through Firebase messaging. Pushes go via APNs directly, so RNFB's onNotificationOpenedApp never fires for them on iOS. Use expo-notifications or @react-native-community/push-notification-ios there.

There is no "delivered" state anywhere in the product: neither APNs nor FCM reports a delivery receipt, so sent means the provider accepted it.

Feature flags

const newOnboarding = Klyfa.flags.get('new_onboarding', false);
const variant = Klyfa.flags.get('checkout_variant', 'control');

Reads are synchronous from an in-memory cache backed by your storage. First boot returns the supplied default until the first refresh completes (fire-and-forget on init). The SDK auto-refreshes on AppState 'active'.

Scoping the kill switch by country

The kill switch is an ordinary flaginstallKillSwitch() reads its payload through flags.get() — so anything the rules engine can target, the kill switch can be scoped by. A single global min_version_ios is what one unconditional rule gives you, not what the mechanism imposes.

country is targetable, so one flag can carry different minimums:

| Rule | Condition | Value | | --- | --- | --- | | 0 | country in ['DK','DE','SE'] | {min_version_ios: '2.4.0'} | | 1 | country eq 'CA' | {min_version_ios: '2.1.0'} | | default | — | {} — inert, no forced update |

An app on 2.2.0 is blocked in Denmark, untouched in Canada, and untouched everywhere else.

One trap, and it is why this can look like it doesn't work: a country-scoped rule only matches if you actually pass country (there is no device fallback — see the country option above). A build that never sets it reaches no country rule at all, silently.

Experiment exposures

Reading a flag automatically reports an exposure (which value the user was shown) so you can tie a variant to downstream outcomes for A/B analysis. Exposures are dedup'd per flag per user, batched, and consent-gated (nothing is sent until consent is granted and a user is identified). The kill-switch flag is never reported. Disable entirely with trackExposures: false in init().

Deferred deep-link attribution

Ask which tracking-link an install came from (e.g. a paid-social campaign), and route the user accordingly:

const match = await Klyfa.attribution.resolve();
if (match.matched) {
  Klyfa.track('campaign_matched', {campaign: match.campaign, source: match.source});
  // ...route to the deep-linked destination
}

resolve() is opt-in, app-driven, and consent-gated — it makes no network call until consent is granted, so call it on first launch after the user consents. Only a definitive server answer is persisted; a transient failure (offline, no consent yet, install id not ready) is not cached, so the next launch retries. It runs at most once per install thereafter, never throws, and times out after 8s. Matching uses the Play Store install referrer on Android and an IP + user-agent fingerprint on iOS (30-minute window), so time it close to first launch.

Kill switch

Forces an app upgrade when the installed version falls below the minimum configured in the dashboard (Project → Flags → Kill switch).

// Anywhere after init — typically in App.tsx after navigation mounts.
Klyfa.flags.killSwitch();

The SDK reads the kill-switch flag from cache, compares the installed version (via DeviceInfo.getVersion()) against the platform-specific minimum, and shows a non-cancelable alert with an "Update" button if the user is out of date. The button opens the App Store / Play Store; if the user dismisses without updating, the alert re-shows on app foreground.

On iOS, when no appId is supplied the SDK resolves the App Store track id via a one-time request to Apple's public iTunes lookup API (itunes.apple.com). That's a third-party egress from the device — disclose it in your privacy documentation, or pass appId to avoid it.

Safety model (read before configuring a minimum version)

The kill-switch is fail-open: if the flag hasn't loaded (offline first launch), the platform minimum isn't set, or the installed version can't be read, it does nothing. It only blocks when it can positively determine the app is below the configured minimum. An offline patient is never locked out of a working app.

Operational caution — a wrong minimum blocks the whole fleet. The minimum version is free-form in the dashboard flag. A typo (e.g. 10 instead of 1.0) will hard-block every install below it. Treat raising the minimum like a deploy: double-check the value. Recovery: lower the minimum in the dashboard; apps re-evaluate on the next foreground/flag refresh and stop blocking. Set the minimum only as high as the oldest version you truly must retire.

Migration from a hand-rolled config-URL pattern

- import * as killSwitch from './utils/kill-switch';
- killSwitch.init({
-   configUrl: 'https://your-cdn.example/config.json',
-   title: 'Update required',
-   message: 'Please update to continue.',
-   button: 'Update',
- });
+ Klyfa.flags.killSwitch({
+   title: 'Update required',
+   message: 'Please update to continue.',
+   button: 'Update',
+ });

The dashboard now owns title/message/button (server-side overrides win when no client option is provided).

Migrating from 0.9

storage is now required — that is the whole breaking change, and it is one line:

  import {Klyfa} from '@klyfa/react-native';
+ import {asyncStorageAdapter} from '@klyfa/react-native/async-storage';

  await Klyfa.init({
    apiUrl, clientId,
+   storage: asyncStorageAdapter(),
  });

That keeps the exact behaviour 0.9 had, reading the same keys in the same place, so nothing on the device moves and no migration is needed. Only reach for migrateStorageFrom if you are switching to a different store — see Changing stores.

Two behaviour changes come with it, neither requiring code:

  • Analytics calls made before init() no longer throw. They return the null the types always advertised, and warn once. If you wrapped track() in a guard for this, you can drop it.
  • Events buffered under consent: 'pending' now carry the install id when they flush, so pre-consent activity stitches to the profile on login instead of falling back to the rotating IP+UA hash.

Migrating from 0.1 (analytics via a second SDK)

0.2 absorbs analytics — remove the second analytics package entirely:

- npm uninstall @openpanel/react-native
- import {OpenPanel} from '@openpanel/react-native';
- export const op = new OpenPanel({apiUrl: 'https://api.klyfa.com', clientId: '...'});
+ // gone — Klyfa.init() covers analytics now

- op.screenView('Home');
+ Klyfa.screenView('Home');

- op.identify({profileId: user.id});
- Klyfa.flags.setDistinctId(user.id);
+ Klyfa.identify(user.id);

- op.clear();
- await Klyfa.flags.reset();
+ await Klyfa.clear();

Buffered offline events persisted by the previous SDK are picked up and delivered after the upgrade (the queue is migrated on first launch).

App Store / Play privacy submission

Klyfa is first-party analytics and does not require App Tracking Transparency (no IDFA, no cross-app tracking, no third-party sharing). For the nutrition label, Play Data Safety, and a ready App Review answer, see the privacy disclosure reference (docs/sdk-privacy-disclosure.md in the platform repo).

License

MIT — see LICENSE. Includes MIT-licensed third-party code from the OpenPanel SDK; see THIRD-PARTY-LICENSES.