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

@tiledev/sdk-tile-notification

v0.4.1

Published

OneSignal push, iOS Live Activities and Android Live Notifications for TilePacket apps — real on iOS/Android, no-op on web. Powers audience targeting for the Tile notification center.

Readme

@tiledev/sdk-tile-notification

OneSignal push notifications for TilePacket apps — real on iOS/Android, a no-op on web.

This package is the device half of Tile's notification center. The dashboard stores a per-app OneSignal App ID + REST API Key and sends through OneSignal's REST API; this package is what makes the app a subscriber of that OneSignal app, and what sets the user tags the dashboard's automations select on.

npm install @tiledev/sdk-tile-notification

react-native-onesignal is an optional peer — install it (and run a prebuild so the native module links) only for the platforms that need real push:

npm install react-native-onesignal@^5
npx expo install onesignal-expo-plugin

Without it the package still installs and imports cleanly; every call is simply a no-op.

Usage

import AsyncStorage from '@react-native-async-storage/async-storage';
import { onesignal } from '@tiledev/sdk-tile-notification';

// The App ID is the published app's OWN OneSignal app — not the tile platform's.
// Fetch it at boot: GET /api/runtime/onesignal-app-id?appId=<tileAppId>
await onesignal.init({
  appId,
  storage: AsyncStorage,        // persists the re-prompt throttle
  reOptInPeriodDays: 7,         // don't re-ask a user who declined, for 7 days
  onDeeplink: (url) => navigate(url),
  logger: console,
  logLevel: 'none',
});

Then feed it commerce events — this is what makes audience targeting work at all, since OneSignal segments are built only from tags and outcomes:

onesignal.trackEvent('updateCartQuantity', { totalQuantity: 2 });
onesignal.trackEvent('purchase', { totalValue: 49.99 });
onesignal.trackEvent('pageView', { pageId: 'Home', storeName: 'acme' });

await onesignal.identify({ customerId, email, phone, firstName, lastName });
await onesignal.clearIdentity();   // on logout

What each event does

| Event | Effect | Dashboard automation it enables | | --- | --- | --- | | updateCartQuantity | sets cart_update to a unix timestamp, or removes it when the cart empties | Abandoned Cart | | purchase | Purchase outcome with revenue, and clears cart_update | Order Success | | login / signup | external_id + email/SMS subscriptions + first_name/last_name tags | New User Welcome | | logout | detaches the device from that customer | — | | pageView (Home only) | sets the store tag | audience segmentation | | pageView (every page) | sets the screen In-App Message trigger to its pageId | "show on the cart screen" | | updateCartQuantity | sets the cart_items trigger to the count ("0" when empty) | "show while the cart has items" | | every event, named or not | a OneSignal custom event with its properties (User.trackEvent) | Journeys and segments on any event the app tracks |

purchase clearing cart_update is deliberate — without it a customer who converted would stay in the abandoned-cart segment.

Notifications opened and received, and the unread badge

await onesignal.init({
  appId, storage: AsyncStorage, onDeeplink: openUrl,
  onNotification: (e) => analytics.track(e.type === 'opened' ? 'apptile_notification_open' : 'apptile_notification_foreground', e),
});

const unread = useUnreadNotificationCount({ historyUrl, storage: AsyncStorage }); // the bell badge
await markNotificationsSeen(AsyncStorage);                                   // history screen opened
  • onNotification gets every notification opened and every one that arrives while the app is open (foreground): { type, notificationId, title, body, launchUrl, data, at }. Notifications without a link included; a link still goes to onDeeplink.
  • Foreground notifications are shown by the SDK. Once anything listens, react-native-onesignal holds every foreground notification back (preventDefault() before JS sees it), so the SDK calls display() first. showForegroundNotifications: false keeps them silent. The SDK's own Live Notification cards are never shown twice. With neither option set, no listener is added and the native SDK shows them as always.
  • The badge: rows of the store's history (below) sent after the stored seen time (key notifications.seenAt.v1, the production apps' key). It counts the same rows the history screen shows. Refreshes on mount and when the app returns to the foreground; markNotificationsSeen resets every badge at once. A store with no history file (404) has 0.
  • A fresh install shows no badge (0.4.1). With no seen time stored, the first successful read stores the newest row's sentAt (or now, when the store has sent nothing) and counts 0, so only pushes sent after the first launch count. Offline on the first launch, nothing is stored until a read succeeds. Before 0.4.1 every row counted, so a store with a long history showed "99+" on a new phone (as the apps before the SDK did, on purpose; changed 2026-10-06 by the Head of Engineering).

The notification history screen

const { notifications, loading, error, refresh, markSeen } =
  useNotificationHistory({ historyUrl, storage: AsyncStorage });
// historyUrl: https://cdn.apptile.io/assets/notification-history/<store>.myshopify.com.json

Each row is a SentNotification:

| Field | From the history file's notificationBody | | --- | --- | | id, sentAt | the entry's own (sentAt is ISO 8601) | | title: string \| null | headings.en, else the first language that has one, else name | | body: string \| null | contents.en, else the first language that has one | | imageUrl: string \| null | big_picture, else large_icon, else ios_attachments.id1 | | link: string \| null | app_url exactly as sent (e.g. shop.68843864220.app://live-selling/uhlw2vrkxm); the app decides what it opens. An empty one is null |

  • Rows are newest first. Entries with no title and no body are dropped (nothing to show), as are entries without a sentAt. fetchNotificationHistory(historyUrl) returns the same rows.
  • loading is true only until the first answer. A failed refresh() keeps the last list and sets error; the next good one clears it. A store that has never sent a push has no file (403/404): an empty list, not an error.
  • It fetches once on mount (and again if historyUrl changes). Nothing refetches on its own, so re-renders, focus changes and new { historyUrl, storage } objects never cause a loop. Call refresh() for pull-to-refresh.
  • markSeen() marks the list read through markNotificationsSeen, so every bell badge drops to 0 at once. It stores the newest shown row's sentAt, so a push sent after the list loaded stays unread.
  • The request asks for a fresh copy. The CDN sends no Cache-Control, so iOS would otherwise answer from its URL cache and keep showing a deleted push. On device it sends Cache-Control: no-cache and Pragma: no-cache. On web it uses cache: 'no-cache' instead: those headers make the browser send a CORS preflight, which the CDN answers with 403.

Push on and off: the Settings switch

const push = usePushNotifications();
<Switch value={push.enabled} disabled={push.permission === null} onValueChange={async (on) => {
  if (!on) return push.turnOff();
  if ((await push.turnOn()) === 'denied') offerSettings(); // e.g. an Alert with Linking.openSettings()
}} />

The switch really turns pushes on and off. enabled is the OS permission AND this device's OneSignal push subscription. turnOff() calls OneSignal's User.pushSubscription.optOut(), so pushes stop arriving. turnOn() opts the device back in, asking the OS first if needed. OneSignal remembers the choice across launches, so the app stores nothing. (Production amber's switch only stored a local preference: pushes kept arriving when it was off, and turning it on never registered the device.)

| | Returns | | --- | --- | | permission | 'granted' \| 'denied' \| 'undetermined' \| null. null until the first check, and wherever push isn't available (web, or before init): keep the switch disabled then | | optedIn | boolean \| null: OneSignal's push subscription | | enabled | permission === 'granted' && optedIn: what the switch shows | | turnOn() | undetermined: asks the OS, then opts in if allowed → 'on'. Granted: opts in → 'on'. Denied, or refused just now: changes nothing → 'denied', and the app offers Settings. Never opens Settings itself | | turnOff() | opts out. The permission is left alone | | refresh() | checks both again |

  • It re-checks on mount and whenever the app comes back to the foreground, because the shopper may have changed the permission in Settings.
  • 'undetermined' means "the OS can still show its dialog", the same rule as production's canAskAgain. Android has no native "not determined" state the way iOS does. On iOS it is undetermined only until the first answer. On Android it can stay undetermined after a "Don't allow", because OneSignal records a refusal as final only when it was asked to fall back to Settings. turnOn() takes its answer from the request itself, so it returns 'denied' correctly on both.
  • On web it returns permission: null, enabled: false, and functions that do nothing (turnOn() resolves 'denied').
  • The provider methods underneath are public too: requestPermission(), isOptedIn(), optIn(), optOut() (see the API table).

All events to OneSignal: the analytics adapter

The app's whole event stream reaches OneSignal through one line, with no OneSignal code in the app:

import { createOneSignalAnalyticsAdapter } from '@tiledev/sdk-tile-notification';

createAnalytics([createApptileAnalyticsAdapter({ ... }), createOneSignalAnalyticsAdapter()]);
  • track → onesignal.trackEvent: every event becomes a custom event, and the named events above also drive their tags, outcomes and triggers.
  • identify → external_id = the email, else the customer id (the same rule as the login event, so a customer is never split). A bare user id, such as an install id, is ignored.
  • reset → logout. There is no screen: page views arrive as pageView, and handling both would count each screen twice.
  • Properties are made JSON-safe (dates as ISO strings, no functions or undefined), trimmed (strings 1,000 chars, 50 keys, 50 items, 4 levels), and keys that look like credentials are dropped (password, token, secret, authorization, cvv, card numbers).
  • init({ customEvents: false }) keeps only the mapping; { exclude: ['scroll'] } skips events. Needs react-native-onesignal 5.2+ (User.trackEvent); on older builds it is skipped quietly.
  • Custom events have to be available on the OneSignal app's plan to show up in the dashboard.

API

| Method | Notes | | --- | --- | | init(config) | Idempotent; safe on every boot. Initializes the SDK, requests permission (subject to reOptInPeriodDays) and wires the tap handler. | | trackEvent(name, data?) | Applies the tag/outcome map above. | | identify(identity) | customerId becomes OneSignal's external_id — what per-user sends target. | | clearIdentity() | OneSignal.logout(). | | getPermissionStatus() | 'granted' \| 'denied' \| 'undetermined'. Does not prompt. 'undetermined' = the OS can still show its dialog (see "Push on and off"). | | requestPermission() | Asks the OS only while undetermined and returns 'granted' or 'denied'; otherwise returns the current answer without asking. Never opens Settings (fallbackToSettings: false). Feeds the reOptInPeriodDays throttle. | | isOptedIn() | OneSignal's push subscription is on (User.pushSubscription.getOptedInAsync()). | | optIn() / optOut() | User.pushSubscription.optIn() / optOut(). Really starts or stops pushes to this device; OneSignal remembers it. Call optIn once permission is granted (OneSignal prompts on its own otherwise). | | getSubscriptionId() | This device's OneSignal id, or null. | | isReady() | True once init() completed against a real SDK. Always false on web. | | liveActivitiesEnabled() | True once init() set up Live Activities on iOS. Always false on Android and web. See below. | | prepareLiveActivityImage(url) | iOS: downloads a thumbnail into the App Group so the widget can show it (widgets can't use the network). Android: nothing to do, resolves true. See "Live thumbnails". | | startLiveActivity(id, attributes, content) | Start a Live Activity from the app. false if not enabled, no id, or over 4 KB. true means OneSignal accepted it; iOS can still refuse (see below). | | setInAppTriggers(triggers) | Set In-App Message triggers; null removes one. See "In-App Messages". | | setInAppMessagesPaused(paused) | Pause or resume In-App Messages. | | isNoop | True in the web build. |

The provider's methods are safe before init() has run and where push is unavailable: they never throw, and answer 'undetermined' / false / nothing.

Hooks: usePushNotifications(), useNotificationHistory({ historyUrl, storage }) and useUnreadNotificationCount({ historyUrl, storage }), described above.

./eventMap is also exported on its own — it's pure (event → tag/outcome mutations, no SDK calls), so the mapping can be tested without a device.

In-App Messages

The popups, banners and full-screen cards a marketer designs in the OneSignal dashboard (Messages → In-App), shown while the app is open. The native SDK already shows them with no code, on both platforms, as soon as init runs. This package adds the control around them:

await onesignal.init({
  appId,
  inAppMessages: {
    paused: true, // optional: nothing shows until you resume
    onEvent: (e) => analytics.track(`in_app_message_${e.type}`, { id: e.messageId, ...('click' in e ? e.click : {}) }),
  },
});
onesignal.setInAppMessagesPaused(false);            // e.g. once onboarding is done
onesignal.setInAppTriggers({ viewed_collection: 'sale' });
  • Who sees a message is set in the dashboard: a segment (built from the tags trackEvent sets) and/or triggers. trackEvent sets two triggers for free: screen (the pageId of every pageView) and cart_items (the count, "0" when empty). So "screen is Cart" or "cart_items greater than 0" works with no app code. Triggers are strings; numbers are converted, and OneSignal compares a numeric rule numerically.
  • Buttons with a URL need nothing from the app: OneSignal opens the URL itself. A link in the app's own scheme (e.g. amorefashion://…, target "browser") comes back through the OS like any deep link. A button with only an action id is for the app to handle in onEvent.
  • onEvent gets { type: 'display' | 'dismiss', messageId } or { type: 'click', messageId, click: { actionId?, url?, urlTarget?, closesMessage } }. urlTarget is iOS only: react-native-onesignal's Android bridge drops it.
  • OneSignal fetches messages when a session starts (a cold start, or back from 30 s+ in the background). A message added in the dashboard shows from the next session, not the current one. Test on a device: a headless iOS simulator never starts a session.
  • To test, give the dashboard message the trigger tile_test is iam and set it from a test build. A message with no trigger goes to its whole audience, real customers included.
  • No native changes: it works on any build that already has react-native-onesignal.

Live Activities (iOS)

A lock-screen and Dynamic Island card that the backend keeps up to date, such as a live show with its viewer count and current product. It uses OneSignal's built-in DefaultLiveActivityAttributes, so the app needs no Swift bridge of its own.

1. The app's app.json (native, so a new binary):

["onesignal-expo-plugin", {
  "mode": "production",
  "liveActivities": { "widgetFilePath": "./node_modules/@tiledev/sdk-tile-notification/ios/TileLiveActivity.swift" }
}]

ios/TileLiveActivity.swift is this package's widget. It has two layouts, upcoming and live, driven by the data keys below, and covers the lock screen, the Dynamic Island (compact, expanded, minimal) and the brand colours from attributes.primary / accent. Point widgetFilePath at your own Swift file only if you need a different design.

The plugin adds the widget extension (OneSignalWidget, iOS 16.2+, bundle id <bundleId>.OneSignalWidget) and NSSupportsLiveActivities. Without widgetFilePath it uses OneSignal's placeholder widget, which isn't fit to ship. The widget's bundle id needs its own provisioning profile.

2. Opt in at init:

await onesignal.init({ appId, storage: AsyncStorage, liveActivities: true });
// or { pushToStart: true, pushToUpdate: true }, which is what `true` means

This registers the device's push-to-start token. It doesn't need notification permission.

3. Start one, from the backend (push-to-start, iOS 17.2+) or from the app (iOS 16.1+, app in the foreground):

onesignal.startLiveActivity(streamId, { title: 'Live now' }, { viewers: 120 });

In the widget, attributes arrive as context.attributes.data (fixed for the activity's life) and content as context.state.data (replaced by each update).

4. Update and end it from the backend. The REST API key stays server-side:

| | Endpoint | Body | | --- | --- | --- | | Start | POST /apps/{app_id}/activities/activity/DefaultLiveActivityAttributes | event: "start", activity_id, event_attributes, event_updates, name, contents, headings, a target (included_segments, include_aliases, …) | | Update / end | POST /apps/{app_id}/live_activities/{activity_id}/notifications | event: "update" or "end", event_updates, name, contents |

Limits:

  • The OneSignal app needs a .p8 APNs key. Apple doesn't allow p12 certificates for Live Activities.
  • Attributes and content together must be 4 KB or less. startLiveActivity refuses anything larger rather than letting iOS drop it silently.
  • The widget can't use the network, so remote images won't load. Use text and bundled art.
  • Give every Text in the widget an explicit colour. The lock screen's default foreground follows the system appearance, so in dark mode it is white. On a white activityBackgroundTint, any line left on the default colour disappears; the first Amore test card lost its message and product name this way.
  • The first activity asks the user. iOS shows "Allow Live Activities from ?" under it on the lock screen, and the card is dimmed until they answer.
  • An activity stays live for up to 8 hours, then remains on the lock screen for up to 4 more.
  • Priority-10 updates count against a budget Apple doesn't publish. Send frequent changes (viewer counts) at priority 5.
  • iOS only. On Android and web nothing is set up, liveActivitiesEnabled() is false and startLiveActivity returns false.
  • The app needs the aps-environment entitlement, even for an app-started activity, because it is push-updatable. onesignal-expo-plugin adds it, but a simulator build made with CODE_SIGNING_ALLOWED=NO drops every entitlement. iOS then refuses with does not specify an APS environment name (in the liveactivitiesd log) while startLiveActivity has already returned true. Sign simulator builds ad hoc (CODE_SIGN_IDENTITY=-).
  • The simulator can't test server updates. It gets no APNs push token ("Push token is not available"), and simctl push doesn't accept Live Activity payloads. Test push-to-start and updates on a real iPhone.

Live thumbnails and the card design

Pass the show's image as attributes.image / event_attributes.image (an https URL).

| | iOS widget (ios/TileLiveActivity.swift) | Android renderer | | --- | --- | --- | | Lock screen / shade | Dark brand-gradient card; a 76 pt rounded thumbnail with a red ring and LIVE badge; host in small caps; bold title; viewers pill; frosted product chip with the price. Upcoming: UPCOMING pill and a big "4 hrs, 25 min · 7:30 PM" countdown | Thumbnail as the large icon. Expanded: the full picture (below Android 16 QPR1; promotion allows only text and progress styles), with Watch now and Hide buttons. Upcoming: "Starts in · 4:25:10" | | Dynamic Island / status bar | Compact: a round thumbnail plus viewers or the countdown. Expanded: thumbnail, badge, title, chip | Promoted chip on Android 16 QPR1+ | | Where the image comes from | The App Group only. A widget can't use the network: call prepareLiveActivityImage(url) before startLiveActivity, and whenever the app learns about upcoming shows, so an activity the server starts later finds its image. No file yet: a brand-gradient tile stands in | Downloaded by the renderer (off the main thread, 5 s timeouts, 4 MB cap), cached in the app's cache |

iOS setup: expo-file-system (SDK 54, 19.x) in the app, plus the plugin option:

["@tiledev/sdk-tile-notification", { "liveNotifications": true, "liveActivityImages": true }]

List this plugin after onesignal-expo-plugin. liveActivityImages also adds the App Group to the widget's entry in extra.eas.build.experimental.ios.appExtensions, which cloud builds (Tile, EAS) use to provision each extension; onesignal-expo-plugin leaves it {}. liveActivityImages gives OneSignal's widget target (OneSignalWidget) an entitlements file with the App Group, because onesignal-expo-plugin gives it none. It also writes TileAppGroup into the widget's Info.plist. The group is appGroup, defaulting to group.<bundleId>.onesignal, the one OneSignal already gives the app. The widget's App ID needs that App Group in its provisioning profile.

Register the widget's App ID by hand before the first cloud build. In the Apple Developer portal (Certificates, Identifiers & Profiles → Identifiers → +), add <bundleId>.OneSignalWidget with App Groups on and the app's group ticked. Cloud builds sign with an App Store Connect API key. The key can make profiles for IDs that already exist, but it couldn't set up this new one with its group. Amore's first TestFlight build failed on the widget target alone with Authentication failed: Make sure a bearer token was provided… and No profiles for 'com.amorefashion.app.OneSignalWidget' were found. The app and its notification extension, whose IDs already had the group, signed fine. One local Xcode build signed with an Apple ID on the team (automatic signing) also registers it, group included.

Cached file names follow one rule in all three languages (JS, Swift, Kotlin): FNV-1a 32-bit of the URL's UTF-8 bytes, as 8 hex digits, plus .img (liveActivityImageFileName, which is exported). The gate checks the JS against the canonical vector ("a" → e40c292c); the Swift and Kotlin were checked end to end on Amore, where the widget found the file the JS had written.

Upcoming shows: "Starting in 4 hrs, 25 min"

The same activity or notification can count down to a start time, and then flip to live. The countdown is drawn by the OS: Text(date, style: .relative) on iOS and a count-down chronometer on Android. It keeps ticking with no pushes.

| When | Send (iOS and Android) | Shows | | --- | --- | --- | | Any time before the show | start with event_updates: { status: "UPCOMING", startsAt: <unix seconds>, message } | UPCOMING, the title, "Starting in 4 hrs, 25 min · 7:30 PM" (iOS) / "Starts in · 4:25:10" and "Starts at 7:30 PM · Thu 24 Sep" (Android) | | At start time | update with status: "LIVE" plus viewers, product, price | The live layout, the same card updated in place | | After the show | end | Removed |

startsAt is unix seconds. Send the LIVE update at start time. Without it, iOS's countdown stops at 0:00 and, once stale, shows "Starting now". Android's chronometer would keep running past zero. On iOS, also set the push's stale_date to startsAt. On Android the notification's timeout stretches to the wait plus 8 hours, so a show more than 8 hours away doesn't expire before it starts.

Live Notifications (Android)

Android's counterpart to Live Activities: one ongoing notification per key, updated in place. On Android 16 QPR1+ it is promoted to a Live Update (a status-bar chip); older versions still update in place. It is push-driven only: the app can't start one itself, and startLiveActivity returns false on Android.

1. The app's app.json: add this package as a config plugin (native, so a new binary):

["@tiledev/sdk-tile-notification", { "liveNotifications": true }]

At prebuild, the plugin writes Kotlin into the app's own package (<package>.tilelive) and registers it in AndroidManifest:

  • TileLiveNotificationExtension as OneSignal's com.onesignal.NotificationServiceExtension. Pushes without live_notification go to OneSignal untouched.
  • TileLiveUpdateDismissReceiver, so a swipe-away or "Hide" sticks until the next start.
  • POST_PROMOTED_NOTIFICATIONS (install-time; promotion only).
  • Debug builds only: TileLiveDebugReceiver, which renders a payload sent over adb, with no server. Only its manifest entry is debug-only; the class sits in the main source set, because a debug source set holding just this file gets moved by Expo's package rename on the next prebuild and breaks the compile.

If the app already registers its own OneSignal extension, prebuild fails: OneSignal loads exactly one. Call TileLiveNotifications.handle(context, payload) from yours instead. Needs compileSdk 36 (Expo SDK 54's default) and OneSignal Android 5.1.14+.

2. The backend sends normal pushes with the same collapse_id for every event of one instance, and data.live_notification:

POST https://api.onesignal.com/notifications
{
  "app_id": "…", "include_aliases": { "external_id": ["…"] }, "target_channel": "push",
  "isAndroid": true, "collapse_id": "show-42", "ttl": 120,
  "headings": { "en": "Amore Live" }, "contents": { "en": "We're live" },
  "data": { "live_notification": {
    "key": "show-42", "event": "start",
    "event_attributes": { "title": "Amore Live: Summer Drop", "url": "amorefashion://", "accent": "#A94D62" },
    "event_updates": { "status": "LIVE", "message": "30% off tonight", "viewers": 128, "product": "Linen Midi Dress", "price": "$58" }
  } }
}

event is start, update (an upsert) or end. The data keys are the iOS widget's, so one backend event can drive both platforms. Resend event_attributes on every event: the extension runs per push and keeps no state apart from dismissals. progress (0–100) switches to a progress bar (ProgressStyle on Android 16).

3. Test without a server (debug build):

adb shell am broadcast -n <package>/.tilelive.TileLiveDebugReceiver \
  --es payload '{"key":"show-42","event":"start","event_attributes":{"title":"Live"},"event_updates":{"viewers":12}}'
# → Broadcast completed: result=0, data="handled=true"

Design notes

Platform split, not a mock build. provider.native.ts holds the real implementation and provider.ts a no-op; Metro resolves ./provider per platform. react-native-onesignal has no web build, so it must never enter a web bundle — the split is on a file, which is unambiguous in Metro, rather than a directory index.

One build, all real. There is no mock/stub variant. A OneSignal App ID is public by design (it ships inside every client binary) and the REST API Key stays server-side, so there is nothing to withhold from a published artifact.

Zero dependencies. The SDK is required lazily inside init() and typed against a narrow local interface, so tsc passes with nothing installed and importing the package where the SDK is absent cannot throw. Only init() can fail, and it reports why.

No import-time side effects. sideEffects: false is honest — initialize, log level and the tap listener all happen inside init().

Development

npm run build   # clean + tsc (fails loudly; noEmitOnError)
npm test        # publish gate — see below
npm run lint    # tsc --noEmit

npm test enforces five invariants that a reviewer can't eyeball, and runs again from prepublishOnly:

  1. The web closure never imports react-native-onesignal. Checked at the level of require/import specifiers, not raw text — the doc comments legitimately name the package, so a plain grep gives false positives.
  2. The tag map still produces what the dashboard selects on. A silent change here crashes nothing; it just stops campaigns matching anyone, which stays invisible until a send reaches zero devices.
  3. Live Activities are set up the way the RN bridge needs. It runs the native provider against a fake SDK. The bridge reads a missing setupDefault option as false, not the native default true, so both options must always be passed. It also checks the 4 KB refusal, the off-by-default opt-in, that Android reports them off, and that an SDK without Live Activities still initialises.
  4. The Android config plugin writes what OneSignal expects. Every template renders into the app's package. The manifest edits (extension, dismiss receiver, permission) are idempotent. An existing OneSignal extension is refused, not overwritten. The debug receiver lands only in the debug manifest (Expo's self-closing <application/> included). A disabled plugin never loads Expo.
  5. The push switch and the history behave as the app relies on. The provider runs against a fake SDK with a fake device (permission, the shopper's answer, opt-out): permission states, asking only while undetermined and never with the Settings fallback, opt-in and opt-out, and every call before init. History rows are mapped from entries shaped like the real file (other languages, a missing title or body, image precedence), dropped and sorted; 403/404 is empty; the web request carries no headers. The hooks are rendered (React 18 + react-dom under jsdom, the renderHook helper in scripts/verify.js): turnOn's three paths, the foreground re-check, no refetch loop, a failed refresh keeping the list, and markSeen resetting the badge. The hooks get the native provider the way Metro would give it (loadWith swaps ./provider), so their public signatures carry no test-only options.

See docs/ARCHITECTURE.md for how this fits the notification center end to end.