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

pulse-updates

v1.4.0

Published

Pulse app-experience SDK for React Native: updates, config, experiments, events, decisions and first-party links

Downloads

4,175

Readme


Everything wired, in one call

import { initPulse } from 'pulse-updates';
import { AppState } from 'react-native';

export const pulse = initPulse({
  apiUrl: 'https://pulse.example.com',
  appSlug: 'my-app',
  storage: mmkv,         // { getString, set }; app version/locale are auto-detected
  defaults: { newPaywall: false },
  appState: AppState,
  signingKeyId: 'production-1',
  signingPublicKey: PULSE_PUBLIC_KEY,
  requireConfigSignature: true,
});

// remote config, with the default when the server has never been reached
if (pulse.config.boolean('newPaywall')) showNewPaywall();

// your own events — declare `purchase` in the registry first
pulse.track('purchase', { revenue: 4.99, currency: 'EUR' });

That is the whole integration for config, experiments and metrics: the two urls are built from the slug (so the halves cannot address different apps), the install gets a stable id — minted and kept if the app has none of its own — and events are queued and flushed from a persistent outbox in batches. pulse.health() exposes config freshness, signature state, and event backlog. Retention needs nothing further: Pulse counts the install from the config request it already makes.

Event identity is explicit. The default eventIdentityMode: 'context' preserves the existing experiment-metric contract by using the config context's deviceId/userId. Set eventIdentityMode: 'anonymous_installation' only for an event family that carries its own experiment/variant dimensions. In that mode Pulse ignores context deviceId and userId, persists a separate random UUID per app event outbox, and omits userId. The server's legacy request field is still named deviceId, but its value is only that random analytics-installation UUID — never IDFA, IDFV, a hardware id, an account id, or the app's config identity.

Identity and consent stay explicit and reversible:

pulse.setUser(account.id, { plan: account.plan });
pulse.setAnalyticsConsent(true);
// on logout
pulse.clearUser();

Expo

Add one plugin entry; it writes both native manifests during prebuild:

{
  "expo": {
    "plugins": [["pulse-updates", {
      "apiUrl": "https://pulse.example.com",
      "signingKeyId": "production-1",
      "signingPublicKey": "BASE64_PUBLIC_KEY"
    }]]
  }
}

Typed contracts

Keep a reviewable pulse.schema.json with config, events, and actions, then run:

npx pulse-updates generate

This writes src/pulse.generated.ts; it never declares server events automatically. Unknown events remain visible and must be approved in the PULSE dashboard.

OTA updates are the other half of the package and set up separately — see below.

Pulse Links

Pulse owns the reusable installed-app transport; Encore remains the phase-one Smart Link resolver and campaign ledger. The client persists only the opaque handoff state, retries transient failures, applies each resolved action once and accepts only app-owned deep-link prefixes. Resolver outcomes use a bounded durable outbox: each logical transition is queued once with a stable UUIDv4 and timestamp before delivery, then retried with exponential backoff and jitter across app restarts. The opaque exposure token remains only in local state and Encore's URL path; neither the POST body nor the optional Pulse analytics callback receives it:

import {
  createPulseLinkClient,
  DeferredLinkPasteButton,
  recoverAndroidInstallReferrer,
} from 'pulse-updates';

const links = createPulseLinkClient({
  appSlug: 'my-app',
  resolverBaseUrl: 'https://encore.example.com/t/d',
  storage: mmkv,
  allowedDeepLinkPrefixes: ['myapp://', 'https://myapp.example/app/'],
  isAccountReady: () => auth.isSignedIn && !auth.isGuest,
  subscribeAccountState: auth.subscribe,
  accountBridge: {
    claim: (token) => billing.claimDeferredLink(token),
    pending: () => billing.getPendingDeferredLink(),
  },
  onDeepLink: (url) => navigation.open(url),
  onAction: (link) => applyClosedAppAction(link.action),
  // Safe marketing dimensions only. The opaque token/link id is deliberately absent.
  onOutcome: ({ name, matchBasis, confidence, campaignId }) =>
    pulse.track(name, {
      matchBasis,
      confidenceBucket: confidence >= 0.95 ? 'very_high' : confidence >= 0.8 ? 'high' : 'lower',
      campaignId,
    }),
});

// Universal/App Link while the app is installed.
links.captureUrl(incomingUrl);

// Android: reads the optional PulseAttribution bridge. The host keeps
// playStoreImplementation 'com.android.installreferrer:installreferrer:2.2'; non-Play builds
// contain no Google dependency and return FEATURE_NOT_SUPPORTED.
void recoverAndroidInstallReferrer(links);

On a recent iOS first open, the host may call links.matchFirstOpen with its native install time, bundle id and locale. It may also provide bounded ephemeral release/model dimensions (app/OS version, hardware model code, device type, distribution, timezone offset and emulator flag). Pulse validates and omits malformed values. The request intentionally contains no unique device id, IDFV/IDFA, clipboard value or client-observed IP. It carries the signed-in address only when you give links a getAccountEmail reader: an install that names its account is bound to the message sent there instead of being inferred, which is the only deterministic signal left where no store referrer survives. No reader, nothing sent — absent is off, so this cannot be switched on by accident, and the address is read at the moment of the request and never stored. Ambiguous matches produce no action. Sensitive actions are rejected for every probabilistic result even if a malformed server response claims otherwise. A random install-attempt nonce may accompany later deterministic resolver outcomes so the server can calibrate its earlier probabilistic decision against deterministic ground truth; it is never exposed to the analytics callback.

onFirstOpenResult is an opt-in durable terminal-delivery boundary for FOUND, NOT_FOUND and FAILURE. shouldQueueFirstOpenResult is evaluated synchronously before Pulse creates an event ID or persists any terminal metadata; false or throw completes first-open matching without an outbox. With no handler, Pulse likewise creates no record and purges an older queued envelope rather than silently retaining it for a future app version. When enabled, Pulse persists a sanitized record before invoking the callback and replays the same UUIDv4 eventId and occurredAt after a negative acknowledgement, throw, rejection, timeout or restart. A FOUND result also includes the already-normalized matchBasis, clamped confidence and optional 128-character-bounded campaignId, experimentId and variantId; no token, install-attempt ID, device signal or raw resolver field is included. NOT_FOUND and FAILURE omit all attribution dimensions. The callback returns accepted (or legacy true) only after its receiver has durably accepted the event, retry/false to replay it, or drop to durably discard it after a policy or consent revocation. drop first replaces the envelope with a minimal sticky tombstone; if local deletion fails, Pulse retries deletion without ever invoking the receiver again, including after restart or a later consent grant. Delivery is at-least-once, not exactly-once: a crash after downstream acceptance but before Pulse persists the acknowledgement can replay it, so the receiver must deduplicate by eventId. For Pulse analytics, use trackIdempotent: it returns true only after the caller-owned event is durably enqueued, keeps that event in the persisted Track outbox while HTTP is in flight, and removes it only after the server ACK plus a successful local outbox update. Network failure, restart, queue pressure and the normal best-effort max-attempt limit do not discard such an event; replays keep the same wire ID. Cross-restart acknowledgement requires an app-scoped synchronous storage adapter such as MMKV. initPulseAsync may still mirror best-effort events through AsyncStorage, but trackIdempotent deliberately returns false because a pending setItem is not a durable synchronous ACK; it creates no Track row and Links therefore retains its own outbox. Custom adapters that unexpectedly return a Promise are detected at runtime, their rejection is consumed, and durable ACKs fail closed.

Consent is sampled at collection/enqueue. A result denied at first-open completion is never retained and cannot appear after a later grant; a revocation before Links hands the result to Track must return drop. Once trackIdempotent has returned true, that already-consented event remains in Track's durable outbox until server ACK and is not retroactively purged by a later revocation.

The Core integration sends only app_open_confirmed, deferred_link_resolved, and action_applied in anonymous_installation identity mode. These campaign events must be segmented by their explicit experimentId and variantId properties; they must not be implicitly re-bucketed from the legacy request field, because that field contains the separate anonymous analytics-installation UUID in this mode.

For the explicit iOS recovery fallback, render DeferredLinkPasteButton and pass its validated token to links.capture(token, 'ios_user_paste'). It uses the system UIPasteControl on iOS 16+ and never reads the clipboard passively. Adding Pulse Links native capabilities requires a new App Store/Play binary; Pulse's OTA capability preflight must not be bypassed.

Features

  • One-call setup for config, experiments and events (initPulse)
  • Remote config with typed getters and a default that is never false by accident
  • A/B experiments assigned server-side, with the arm carried in the payload
  • Your own events (track) — no warehouse required
  • First-party deferred-link transport and privacy-safe install recovery (createPulseLinkClient)
  • Full support for React Native New Architecture (Bridgeless mode)
  • Automatic asset resolution with embedded fallback
  • Hermes bytecode compilation for faster startup
  • Incremental updates (only changed assets are downloaded)
  • Rollback protection with health checks
  • Channel-based deployments (production, staging, etc.)
  • Compatible with expo-asset resolution

Installation

npm install pulse-updates
# or
yarn add pulse-updates

iOS Setup

Add to your Podfile:

pod 'pulse-updates', :path => '../node_modules/pulse-updates'

Then run:

cd ios && pod install

Add these keys to your Info.plist:

<key>PulseUpdatesEnabled</key>
<true/>
<key>PulseUpdatesURL</key>
<string>https://your-update-server.com</string>
<key>PulseUpdatesRuntimeVersion</key>
<string>$(MARKETING_VERSION)</string>
<key>PulseUpdatesCheckOnLaunch</key>
<string>ALWAYS</string>
<!-- Code signing (REQUIRED in release builds) -->
<key>PulseUpdatesRequireSignature</key>
<true/>
<key>PulseUpdatesSigningKeyId</key>
<string>your-key-id</string>
<key>PulseUpdatesSigningPublicKey</key>
<string>base64-ed25519-public-key</string>

Security: In release builds PulseUpdatesRequireSignature defaults to true. When it is on, both PulseUpdatesSigningKeyId and PulseUpdatesSigningPublicKey are required — if the public key is missing or a manifest's Ed25519 signature does not verify, the update is rejected (fail-closed) and the embedded bundle is used. See SECURITY.md.

Android Setup

Add to your android/app/build.gradle:

dependencies {
    implementation project(':pulse-updates')
}

Add metadata to AndroidManifest.xml inside <application>:

<meta-data android:name="PulseUpdatesEnabled" android:value="true" />
<meta-data android:name="PulseUpdatesURL" android:value="https://your-update-server.com" />
<meta-data android:name="PulseUpdatesRuntimeVersion" android:value="@string/app_version" />
<meta-data android:name="PulseUpdatesCheckOnLaunch" android:value="ALWAYS" />
<!-- Code signing (REQUIRED in release builds) -->
<meta-data android:name="PulseUpdatesRequireSignature" android:value="true" />
<meta-data android:name="PulseUpdatesSigningKeyId" android:value="your-key-id" />
<meta-data android:name="PulseUpdatesSigningPublicKey" android:value="base64-ed25519-public-key" />

Security: In release builds PulseUpdatesRequireSignature defaults to true. When it is on, both PulseUpdatesSigningKeyId and PulseUpdatesSigningPublicKey are required — if the public key is missing or a manifest's Ed25519 signature does not verify, the update is rejected (fail-closed) and the embedded bundle is used. See SECURITY.md.

New Architecture (Bridgeless) Setup

For React Native 0.76+ with New Architecture enabled, update your MainApplication.kt:

import app.pulse.updates.PulseUpdatesModule
import app.pulse.updates.PulseReactHostFactory

class MainApplication : Application(), ReactApplication {

    // Extract packages into a reusable method
    private fun buildPackages(): List<ReactPackage> {
        val packages = PackageList(this).packages.toMutableList()
        // Add your custom packages here
        return packages
    }

    override val reactNativeHost: ReactNativeHost =
        object : DefaultReactNativeHost(this) {
            override fun getPackages(): List<ReactPackage> = buildPackages()
            override fun getJSMainModuleName(): String = "index"

            // Use Pulse Updates bundle
            override fun getJSBundleFile(): String? {
                return PulseUpdatesModule.getBundleFile(applicationContext)?.absolutePath
                    ?: super.getJSBundleFile()
            }

            override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
            override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
        }

    // For New Architecture: use PulseReactHostFactory
    override val reactHost: ReactHost
        get() = PulseReactHostFactory.createReactHost(
            applicationContext,
            packages = buildPackages(),
            jsMainModuleName = "index",
            useDevSupport = BuildConfig.DEBUG
        )
}

Configuration Options

| Option | Type | Default | Description | |--------|------|---------|-------------| | PulseUpdatesEnabled | boolean | true | Enable/disable OTA updates | | PulseUpdatesURL | string | required | Your update server URL | | PulseUpdatesRuntimeVersion | string | app version | Version for update compatibility | | PulseUpdatesCheckOnLaunch | string | ALWAYS | When to check: ALWAYS, WIFI_ONLY, NEVER | | PulseUpdatesChannel | string | production | Update channel | | PulseUpdatesLaunchWaitMs | number | 0 | Wait time for update check on launch | | PulseUpdatesRequireSignature | boolean | true in release, false in debug | Reject manifests without a valid Ed25519 signature (fail-closed). When true, the signing keys below are required | | PulseUpdatesSigningKeyId | string | required in release | Key id that must match the manifest's signature.keyId | | PulseUpdatesSigningPublicKey | string | required in release | Base64 Ed25519 public key used to verify the manifest signature |

JavaScript API

Basic Usage

import * as PulseUpdates from 'pulse-updates';
import { initializeAssetResolver } from 'pulse-updates';

// Initialize at app startup
async function initUpdates() {
  // Refresh state from native module
  await PulseUpdates.refreshStateAsync();

  // Initialize asset resolver for images
  initializeAssetResolver(PulseUpdates.localAssets);

  // Check current state
  console.log('Update ID:', PulseUpdates.updateId);
  console.log('Is embedded:', PulseUpdates.isEmbeddedLaunch);
}

// Check for updates
async function checkUpdates() {
  const result = await PulseUpdates.checkForUpdateAsync();

  if (result.isAvailable) {
    console.log('Update available:', result.manifest?.updateId);

    // Download the update
    const fetchResult = await PulseUpdates.fetchUpdateAsync();

    if (fetchResult.isNew) {
      // Reload to apply
      await PulseUpdates.reloadAsync();
    }
  }
}

React Hook

import { usePulseUpdates } from 'pulse-updates';

function UpdateBanner() {
  const {
    isChecking,
    isDownloading,
    availableUpdate,
    downloadedUpdate,
    checkForUpdate,
    downloadUpdate,
    reload
  } = usePulseUpdates();

  if (downloadedUpdate) {
    return (
      <View>
        <Text>Update ready!</Text>
        <Button title="Restart" onPress={reload} />
      </View>
    );
  }

  if (availableUpdate) {
    return (
      <View>
        <Text>Update available</Text>
        <Button
          title={isDownloading ? "Downloading..." : "Download"}
          onPress={downloadUpdate}
          disabled={isDownloading}
        />
      </View>
    );
  }

  return null;
}

API Reference

State Properties

PulseUpdates.isEnabled        // boolean - Updates enabled
PulseUpdates.updateId         // string | null - Current update ID
PulseUpdates.runtimeVersion   // string | null - Runtime version
PulseUpdates.channel          // string | null - Update channel
PulseUpdates.isEmbeddedLaunch // boolean - Running embedded bundle
PulseUpdates.manifest         // PulseManifest | null - Current manifest
PulseUpdates.localAssets      // Record<string, string> | null - Local asset map

Methods

// Refresh state from native
await PulseUpdates.refreshStateAsync()

// Check for available updates
const result = await PulseUpdates.checkForUpdateAsync()
// Returns: { isAvailable: boolean, manifest?: PulseManifest }

// Download available update
const result = await PulseUpdates.fetchUpdateAsync()
// Returns: { isNew: boolean, manifest?: PulseManifest }

// Reload app with new update
await PulseUpdates.reloadAsync()

// Mark app as successfully launched (for rollback protection)
await PulseUpdates.markAppReady()

// Report launch failure (triggers rollback)
await PulseUpdates.reportLaunchFailure(reason: string)

CLI Commands

Publishing Updates

# Publish an update
npx pulse-updates publish --platform <ios|android> --build <number>

# With all options
npx pulse-updates publish \
  --platform ios \
  --build 42 \
  --channel production \
  --api-key your-api-key \
  --api-url https://your-server.com \
  --runtime-version 1.0.0 \
  --message "Bug fixes and improvements"

CLI Options

| Option | Description | |--------|-------------| | --platform | Target platform: ios or android (required) | | --build | Build number for this update (optional metadata) | | --dry-run | Build + validate locally without creating a release on the server | | --channel | Update channel (default: production) | | --api-key | Server API key (or set in pulse.config.json) | | --api-url | Server URL (auto-detected from native config) | | --runtime-version | Runtime version (auto-detected from native config) | | --message | Release notes | | --skip-bundle | Skip bundle creation (use existing) | | --strict | Abort the publish if new native modules are detected (default: warn only) |

Generating Signing Keys

Manifest signing uses Ed25519. Generate a matched keypair and paste each half into the right place:

npx pulse-updates keygen [--key-id <id>]

It prints the private seed for the server (Pulse:SigningKey / SIGNING_KEY) and the public key for the app (PulseUpdatesSigningPublicKey in Info.plist / AndroidManifest). The server's per-app Setup panel and GET /api/signing expose the public key too.

Registering Native Capabilities

For capability-based crash-prediction, record what native modules the shipped binary actually provides:

npx pulse-updates register-capabilities --platform <ios|android> [--api-key <key>]

Run it at build time so the server can warn when a future JS-only update references native modules the installed binary doesn't have. When the release pipeline already has the exact embedded bundle, pass it with --bundle <path>. On Android the command automatically reuses the newest Gradle packager source map (the exact pre-Hermes graph retained under android/app/build/intermediates/sourcemaps), so a normal Gradle release build needs no extra path. If no embedded or generated source exists, it builds an unminified, scan-only Metro graph; that fallback is never uploaded or used as the app's launch bundle.

Configuration File

Create pulse.config.json in your project root:

{
  "apiKey": "your-api-key",
  "apiUrl": "https://your-server.com",
  "channel": "production"
}

Server Requirements

Pulse Updates requires a compatible server. The server must implement:

  • POST /api/releases — create a release (CLI, X-API-Key)
  • POST /api/releases/:id/assets/check — check which assets already exist (dedup)
  • POST /api/releases/:id/assets — upload an asset
  • POST /api/releases/:id/finalize — finalize the release
  • GET /pulse/manifest/:appSlug — public signed manifest the on-device SDK fetches

Embedded Manifest

For offline-first support, generate an embedded manifest during your build:

node node_modules/pulse-updates/scripts/generate-embedded-manifest.mjs \
  --bundle path/to/index.bundle \
  --assets path/to/assets \
  --out path/to/output \
  --platform ios \
  --runtime-version 1.0.0

Troubleshooting

Images not loading after update

Ensure you initialize the asset resolver at app startup:

import { initializeAssetResolver } from 'pulse-updates';
import * as PulseUpdates from 'pulse-updates';

await PulseUpdates.refreshStateAsync();
initializeAssetResolver(PulseUpdates.localAssets);

Update not applying on reload

For New Architecture apps, ensure you're using PulseReactHostFactory in your MainApplication.kt.

Debug Logging

Set PULSE_DEBUG=true environment variable to enable verbose logging in native code.

License

MIT