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

@supa-media/notifications

v1.0.2

Published

Push notification infrastructure for Supa apps — provider, hooks, and handlers for Expo + Convex

Readme

@supa-media/notifications

Expo push-notification lifecycle for React Native apps — one provider that handles permission, token acquisition, foreground display, Android channels, and turning a notification tap (including a cold start) into a router path. It degrades to a no-op on web, on simulators, and when the expo-* native modules aren't installed.

It does not send notifications and does not store tokens. The server half is @supa-media/convex/notifications plus a pushTokens table; you wire those up yourself.

Install

pnpm add @supa-media/notifications

Peer dependencies you install yourself:

| Peer | Required? | Why | | --- | --- | --- | | react >=19.0.0 | required | provider + hooks | | react-native >=0.81.0 | required | Platform checks | | expo-notifications >=0.32.0 | optional | the whole native surface. Without it the provider mounts, reports isReady, and does nothing | | expo-device >=8.0.0 | optional | physical-device check. Without it, permission is never requested | | expo-constants >=18.0.0 | optional | projectId fallback from expoConfig.extra.eas.projectId | | expo-router >=6.0.0 | declared, never imported | the package hands you a path string; you call router.push |

The three expo-* modules are loaded with a guarded require() at module scope, so a missing one is caught and the feature it powers is skipped silently. In practice you want all three.

Mounting

Wrap your root layout once:

// app/_layout.tsx
import { router } from "expo-router";
import { NotificationProvider } from "@supa-media/notifications";
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";

export default function RootLayout() {
  const registerToken = useMutation(api.notifications.registerPushToken);

  return (
    <NotificationProvider
      requestPermissionDelay={5000}
      onTokenRegistered={(token, platform) => registerToken({ token, platform })}
      onNotificationTap={(event) => router.push(event.deepLink ?? "/")}
      shouldShowForegroundNotification={(data) => data.channelId !== activeChannelId}
    >
      <Stack />
    </NotificationProvider>
  );
}

NotificationProviderProps

| Prop | Default | Behavior | | --- | --- | --- | | requestPermissionDelay | -1 | ms to wait after mount before auto-requesting permission. 0 = immediately, -1 = never | | onTokenRegistered(token, platform) | — | fired once a push token is obtained. This is your hook into the backend | | onNotificationTap(event) | — | fired on tap. event is { deepLink?, data, id } | | projectId | Constants.expoConfig.extra.eas.projectId | EAS project id for getExpoPushTokenAsync | | showForegroundNotifications | true | master switch for alert/sound while the app is foregrounded | | shouldShowForegroundNotification(data) | — | per-notification filter. Returning false suppresses alert and sound; the notification still lands in the notification center |

⚠️ The default requestPermissionDelay is -1, which means the provider never asks for permission and therefore never obtains a token. Out of the box a fresh install registers nothing. Either set a delay (a few seconds after mount, so the OS prompt lands in context rather than on first paint) or drive it yourself — but see the next warning before you choose the manual route.

⚠️ Calling requestPermission() from useNotificationPermission() grants permission but does NOT fetch or register a push token. Token registration happens in exactly two places: the provider's init path, when permission is already granted, and the requestPermissionDelay timer after it grants. So a manual-prompt flow gets a token only on the next app launch. If you want a custom prompt moment, use requestPermissionDelay and control when the provider mounts instead.

API

@supa-media/notifications/providers

NotificationProvider, and the NotificationProviderProps type.

@supa-media/notifications/hooks

All three must be called inside the provider; each throws a descriptive error otherwise.

| Hook | Returns | | --- | --- | | useNotifications() | { lastNotification, isReady }lastNotification is the most recent foreground notification as { id, title, body, data } | | useNotificationPermission() | { status, requestPermission } — status is "granted" \| "denied" \| "undetermined" | | usePushToken() | { token, isRegistered }isRegistered means onTokenRegistered was invoked, not that your backend acknowledged it |

@supa-media/notifications/handlers

registerBackgroundHandler(handler?: (notification: unknown) => void): void
resolveDeepLink(deepLink: string): string

registerBackgroundHandler() goes at the top level of your entry file, outside any component — it registers the BACKGROUND_NOTIFICATION_TASK and installs a show-everything notification handler.

resolveDeepLink reduces any of these to a router-ready path:

| Input | Output | | --- | --- | | /groups/123 | /groups/123 (passthrough) | | https://app.example.com/groups/123?tab=x | /groups/123?tab=x | | myapp://groups/123 | /groups/123 | | unparseable | the input, prefixed with / |

The provider applies it automatically before calling onNotificationTap, reading the raw link from data.url ?? data.deepLink. Export it directly only if you also resolve links from somewhere else (a share sheet, a universal link).

⚠️ registerBackgroundHandler calls setNotificationHandler with "always show". The provider's own effect calls setNotificationHandler too — there is one global handler, and last writer wins. Calling registerBackgroundHandler at module scope (as documented) is fine: the provider's effect runs later and takes over. Calling it after mount silently disables showForegroundNotifications and shouldShowForegroundNotification.

@supa-media/notifications/config

DEFAULT_ANDROID_CHANNELS: AndroidNotificationChannel[]
setupAndroidChannels(channels?: AndroidNotificationChannel[]): Promise<void>

The defaults are default (General, importance 3), messages (importance 4), reminders (importance 3, no badge), and updates (importance 2, no vibration). setupAndroidChannels is a no-op off Android and safe to call anywhere.

⚠️ The provider registers DEFAULT_ANDROID_CHANNELS during init and takes no channels prop. To ship your own set, call setupAndroidChannels(mine) yourself — but the four defaults will already exist on the device, and Android channels cannot be renamed or re-configured once created. Use your own channel ids if you don't want the defaults' behavior.

@supa-media/notifications/types

NotificationPayload, NotificationData, NotificationTapEvent, NotificationProviderConfig, PermissionStatus, UseNotificationsResult, UseNotificationPermissionResult, UsePushTokenResult, AndroidNotificationChannel. All type-only, all re-exported from the root entry.

What you must build on the backend

A token that nothing stores is useless. onTokenRegistered is the seam; here is the other side of it, from @supa-media/convex:

1. TablessupaNotificationTables supplies pushTokens (userId, token, platform, indexed by_userId and by_token) and notificationQueue:

import { defineSchema } from "convex/server";
import { supaAuthTables, supaNotificationTables } from "@supa-media/convex/schema";

export default defineSchema({ ...supaAuthTables, ...supaNotificationTables });

2. A registerPushToken mutation wrapping the helper of the same name from @supa-media/convex/notifications — it upserts by token and reassigns the row when a device changes hands, so it is safe to call on every launch. Note the naming collision if your Convex functions authenticate by token argument: the auth JWT and the push token are both "token". Togather's equivalent mutation names them authToken and token.

3. Sending. Also plain async functions you wrap yourself:

| Helper | Use | | --- | --- | | sendNotificationToUser(ctx, payload) | fans out to every token for a user | | sendPushNotification(messages) | raw Expo Push API call (action context — needs network) | | enqueueNotification(ctx, payload) | writes a pending row for later | | processNotificationQueue(ctx, batchSize?) | drains the queue; run it from a cron | | cleanupExpiredTokens(ctx, invalidTokens) | delete tokens after a DeviceNotRegistered receipt |

sendNotificationToUser puts your deepLink inside the push payload's data, which is exactly where the provider looks for it — server deepLink in, event.deepLink out.

⚠️ Nothing prunes dead tokens automatically. sendPushNotification returns Expo's tickets and the framework does not inspect them. Read the tickets, collect the tokens that came back DeviceNotRegistered, and pass them to cleanupExpiredTokens — otherwise every send fans out to an ever-growing set of dead tokens.

Platform behavior

  • WebisNativeAvailable() is false; the provider sets isReady: true, leaves permission "undetermined" until asked (then "denied"), and registers no listeners.
  • Simulators / emulatorsrequestPermission() warns that a physical device is required and sets "denied". There is no way to test the real token path in a simulator.
  • Cold start from a tap — read via getLastNotificationResponseAsync(), then onNotificationTap fires after a 500 ms delay so navigation is mounted. Handled ids are deduplicated against the live response listener, so one tap never navigates twice.

Test coverage

One test: __tests__/esm-resolution.test.js, which walks the built dist/ output and asserts every relative ESM specifier resolves under Node's strict rules (the 1.0.1 CHANGELOG explains why that bug class is invisible to tsc). There are no behavioural tests — nothing covers the permission state machine, token registration, cold-start dedup, or channel setup, and none of it runs in CI without a device. Verify push end-to-end on real hardware.


Part of the Supa Media framework — https://github.com/Supa-Media/supa-framework. MIT.