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
Maintainers
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 generateThis 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
falseby 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-updatesiOS Setup
Add to your Podfile:
pod 'pulse-updates', :path => '../node_modules/pulse-updates'Then run:
cd ios && pod installAdd 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
PulseUpdatesRequireSignaturedefaults totrue. When it is on, bothPulseUpdatesSigningKeyIdandPulseUpdatesSigningPublicKeyare 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
PulseUpdatesRequireSignaturedefaults totrue. When it is on, bothPulseUpdatesSigningKeyIdandPulseUpdatesSigningPublicKeyare 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 mapMethods
// 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 assetPOST /api/releases/:id/finalize— finalize the releaseGET /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.0Troubleshooting
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
