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

@amritk/lynx-notifications

v0.2.2

Published

Local and remote push notifications for Lynx: an Android and iOS native module, and a promise-shaped facade that reaches it from the main thread.

Readme

@amritk/lynx-notifications

Local and remote push notifications for Lynx — an Android module, an iOS module, and a promise-shaped facade that reaches them from the main thread.

  • iOS — UNUserNotificationCenter for scheduling and delivery, APNs for remote push.
  • Android — NotificationManager + AlarmManager for scheduling, Firebase Cloud Messaging for remote push.

Transport-agnostic on the server side: forward the device token to your backend and send through APNs and FCM directly, or through anything that fronts them.

Why this exists

Lynx ships no notifications module. Sparkling's sparkling-notifications is a reserved npm name with no implementation behind it (0.0.1, "implementation follows"). The official answer in lynx-family/lynx#67 is still "write native code and send it into your Lynx code".

Install

bun add @amritk/lynx-notifications

@amritk/mini-lynx-native comes with it — it is the thread hop this package's calls travel over, because NativeModules is background-thread-only and a @amritk/mini-lynx tree renders on the main thread.

JavaScript setup

One line in the background chunk, then use it from anywhere:

// background chunk
import { installNativeBridge } from '@amritk/mini-lynx-native/background'

installNativeBridge()
// main-thread chunk
import {
  createNotificationChannel,
  getPermissionStatus,
  onNotificationResponse,
  requestPermission,
  scheduleNotification,
} from '@amritk/lynx-notifications'

// At the app root, during the first render — a cold-start tap is replayed to
// whoever subscribes first, and only once.
onCleanup(
  onNotificationResponse(({ notification }) => {
    const screen = notification.data['screen']
    if (typeof screen === 'string') router.navigate(screen)
  }),
)

// Android needs a channel or notifications are dropped in silence. No-op on iOS.
await createNotificationChannel({ id: 'reminders', name: 'Reminders', importance: 'high' })

if ((await getPermissionStatus()) === 'undetermined') await requestPermission()

await scheduleNotification({
  title: 'Standup',
  body: 'In five minutes',
  channelId: 'reminders',
  data: { screen: 'calendar' },
  trigger: { type: 'timeInterval', seconds: 300 },
})

Host-app setup

The native sources are declared in lynx.lib.json, so Lynx's autolinking picks them up. What autolinking cannot do for you is credentials and entitlements.

Android

Local notifications work with no further setup. The library's manifest contributes POST_NOTIFICATIONS, RECEIVE_BOOT_COMPLETED, the alarm and tap receivers, and the permission-prompt activity through manifest merging.

The small icon is your app's launcher icon. If you want a dedicated one, add a drawable named for it and override in your own manifest.

For remote push, add Firebase to the host app — the library depends on firebase-messaging as compileOnly, so an app that only schedules local notifications does not inherit it:

// app/build.gradle.kts
plugins { id("com.google.gms.google-services") }
dependencies { implementation("com.google.firebase:firebase-messaging:24.0.0") }

and drop your google-services.json into app/.

If your build does not run Lynx's annotation processor, register the module by hand:

LynxEnv.inst().registerModule(
  "MiniLynxNotificationsModule",
  MiniLynxNotificationsModule::class.java,
)

iOS

Register the module where you configure Lynx:

[config registerModule:MiniLynxNotificationsModule.class];

For remote push, add the Push Notifications capability and the aps-environment entitlement, then forward APNs' two app-delegate callbacks — iOS delivers these to the app delegate and there is no supported way for a library to intercept them without swizzling:

#import <MiniLynxNotifications/MiniLynxNotificationsCenter.h>

- (void)application:(UIApplication *)application
    didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
  [MiniLynxNotificationsCenter.shared didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}

- (void)application:(UIApplication *)application
    didFailToRegisterForRemoteNotificationsWithError:(NSError *)error {
  [MiniLynxNotificationsCenter.shared didFailToRegisterForRemoteNotificationsWithError:error];
}

Without this, local notifications work and getDeviceToken always resolves null.

API

| Function | Returns | | --- | --- | | getPermissionStatus() | 'undetermined' \| 'denied' \| 'granted' \| 'provisional' | | requestPermission(request?) | the status the user chose | | getDeviceToken() | DeviceToken \| null | | onDeviceToken(listener) | unsubscribe | | scheduleNotification(request) | the id it was filed under | | cancelNotification(id) / cancelAllNotifications() | — | | getScheduledNotifications() | what has not fired yet | | onNotificationReceived(listener) | unsubscribe (foreground arrivals) | | onNotificationResponse(listener) | unsubscribe (taps, including cold start) | | getBadgeCount() / setBadgeCount(n) | — | | createNotificationChannel(channel) | — (Android; no-op on iOS) | | isNotificationsAvailable() | whether the host app linked the module |

The things that actually bite

  • You get one permission prompt, ever. iOS shows it once per install; Android 13+ stops after two dismissals. A request made after a refusal shows nothing and reports the refusal. Check getPermissionStatus() first, and spend the prompt at a moment the user already understands.
  • Android drops a notification with no valid channel, silently. Call createNotificationChannel at startup. The module creates a default channel on demand so an app that forgets still delivers, but every notification lands in one bucket the user can only turn off wholesale.
  • A channel is immutable after creation. Android ignores every field but the name and description on a channel that already exists. Get the importance right first time, or use a new id.
  • A foreground notification shows no banner, by design. It arrives at onNotificationReceived and the in-app treatment is yours. A system banner over the screen the user is looking at is worse.
  • The device token is not an identifier. iOS reissues it on reinstall and restore; FCM rotates it on its own schedule. Send every arrival to your backend — storing the first and assuming it holds is why "push stopped working for some users" takes weeks to find.
  • Scheduled notifications are not exact on Android. They use inexact alarms deliberately: exact alarms need SCHEDULE_EXACT_ALARM, which Google Play restricts to alarm clocks and calendars. Doze can delay one. Fine for a reminder, wrong for a countdown.
  • Subscribe to taps at the app root, during the first render. A cold-start tap is held by the native side and replayed once, to whoever subscribes first.

Testing

@amritk/lynx-notifications/testing ships the native module in memory, so your own screens can be tested with no device:

import { installNativeBridge } from '@amritk/mini-lynx-native/background'
import { createFakeContexts, createFakeEmitter } from '@amritk/mini-lynx-native/testing'
import { MODULE } from '@amritk/lynx-notifications'
import { createFakeNotifications } from '@amritk/lynx-notifications/testing'

const contexts = createFakeContexts()
const emitter = createFakeEmitter()
const notifications = createFakeNotifications(emitter)
setPeerContext(contexts.mainThread)
installNativeBridge({ peer: contexts.background, emitter, modules: { [MODULE]: notifications.module } })

notifications.deliver({ title: 'Order shipped' })

Status, and what is actually verified

| | Checked by | Runs where | | --- | --- | --- | | TypeScript facade | bun run test | everywhere | | JS ⇄ native contract (names, arities, event names) | src/native-contract.test.ts | everywhere | | Kotlin compiles and packages to an AAR | bun run check:android | needs an Android SDK; CI | | Objective-C compiles against the iOS SDK | pod lib lint | macOS only; not in CI, run by hand | | Any of it working on a device | — | nothing |

The last row is the one to keep in mind. Both native halves compile against the real Lynx SDK, and the contract between the three implementations is pinned by a test that fails when any of them drifts. None of that is the same as a notification appearing on a phone: permission flows, AlarmManager behaviour under Doze, APNs registration and FCM delivery are all things only a device can answer.

bun run check:android skips with an explanation when there is no SDK, so a contributor without one is told what is being skipped rather than handed a failure they cannot act on. CI passes --require-sdk and fails instead.

Licence

MIT