Aviary
Channels

Push

Send push notifications through Web Push, Firebase Cloud Messaging, or Expo. Pick one transport, build a PushMessage, and route to one device token or many.

The push channel delivers a notification through a single push transport — Web Push (VAPID), Firebase Cloud Messaging, or Expo. You pick one transport at registration, build a fluent PushMessage, and route to one device token (or subscription) or an array of them.

Install

Each transport pulls a different SDK as an optional peer — install only the one you use:

pnpm add @dudousxd/nestjs-notifications-push web-push
pnpm add @dudousxd/nestjs-notifications-push firebase-admin
pnpm add @dudousxd/nestjs-notifications-push expo-server-sdk

Register the channel

Pick exactly one transport and supply its matching options:

app.module.ts
import { PushChannelModule, WebPushTransport } from '@dudousxd/nestjs-notifications-push';

@Module({
  imports: [
    PushChannelModule.forRoot({
      transport: WebPushTransport,
      webPush: {
        publicKey: process.env.VAPID_PUBLIC_KEY!,
        privateKey: process.env.VAPID_PRIVATE_KEY!,
        subject: 'mailto:ops@example.com',
      },
    }),
  ],
})
export class AppModule {}

The target is a browser PushSubscription object (from PushManager.subscribe(), persisted server-side).

app.module.ts
import * as admin from 'firebase-admin';
import { FcmTransport, PushChannelModule } from '@dudousxd/nestjs-notifications-push';

@Module({
  imports: [
    PushChannelModule.forRoot({
      transport: FcmTransport,
      fcm: { credential: admin.credential.cert(serviceAccount) },
    }),
  ],
})
export class AppModule {}

The target is an FCM registration (device) token string. fcm options are forwarded to admin.initializeApp; the transport reuses an already-initialized app if there is one.

app.module.ts
import { ExpoTransport, PushChannelModule } from '@dudousxd/nestjs-notifications-push';

@Module({
  imports: [
    PushChannelModule.forRoot({
      transport: ExpoTransport,
      expo: { accessToken: process.env.EXPO_ACCESS_TOKEN },
    }),
  ],
})
export class AppModule {}

The target is an Expo push token string (e.g. ExponentPushToken[...]).

PushChannelModule.forRoot() takes:

OptionTypeDefaultDescription
transportType<PushTransport>The transport class. Required — pick one.
webPushWebPushOptionsVAPID options. Supply with WebPushTransport.
fcmFcmOptionsFirebase Admin app options. Supply with FcmTransport.
expoExpoOptionsExpo client options. Supply with ExpoTransport.
apnsApnsOptionsApple credentials. Supply with ApnsTransport.
resolveTransport(tenant: string) => PushTransportPer-tenant transport resolver. See Per-tenant config.
onInvalidTokensInvalidTokenCallbackCalled with the dead device tokens a multicast send rejected, so you can prune them. See Multicast & invalid tokens.
globalbooleantrueRegister globally so the channel is discoverable app-wide.

APNs (native iOS)

ApnsTransport delivers straight to Apple Push Notification service using token-based (.p8) auth. The target is the device token string (or { deviceToken, topic }):

app.module.ts
import { ApnsTransport, PushChannelModule } from '@dudousxd/nestjs-notifications-push';

PushChannelModule.forRoot({
  transport: ApnsTransport,
  apns: {
    token: { key: process.env.APNS_KEY!, keyId: process.env.APNS_KEY_ID!, teamId: process.env.APPLE_TEAM_ID! },
    topic: 'com.acme.app', // your bundle id
    production: true,
  },
});

It pulls @parse/node-apn as an optional peer — install it only when you use APNs.

The notification side

Annotate the payload method with the @Push() handle and return a PushMessage:

order-shipped.notification.ts
import { type Notifiable, Notification } from '@dudousxd/nestjs-notifications-core';
import { Push, PushMessage } from '@dudousxd/nestjs-notifications-push';

@Notification()
export class OrderShipped {
  constructor(private orderId: number) {}

  @Push()
  toPush({ notifiable }: ChannelContext): PushMessage {
    return new PushMessage()
      .title('Order shipped')
      .body('Your order is on its way.')
      .icon('https://app.example.com/icon.png')
      .url(`https://app.example.com/orders/${this.orderId}`)
      .data({ orderId: this.orderId });
  }
}

The Push handle also works as a via() token (via() { return [Push]; }) for explicit routing; implement PushNotification alongside the decorator for compile-time checks on toPush().

PushMessage builder

Each method returns this:

MethodEffect
.title(s)Set the notification title.
.body(s)Set the body text.
.icon(s)Set the notification icon URL.
.url(s)Set the URL opened when the notification is tapped.
.data(obj)Attach an arbitrary data payload delivered alongside the notification.

Transports map these fields to each provider's shape. FCM stringifies every data value (the FCM API requires string data), and Expo delivers only title, body, and data.

Routing the target

The target comes from routeNotificationFor('push'). Return a single token/subscription, or an array — the channel sends the message to each one:

user.ts
export class User implements Notifiable {
  constructor(public deviceTokens: string[]) {}

  routeNotificationFor(channel: string) {
    if (channel !== 'push') return undefined;
    return this.deviceTokens; // one string, or an array of them
  }
}

The concrete shape of the target depends on the transport — a Web Push subscription object, an FCM device token, or an Expo push token.

Per-tenant config

In a multi-tenant app each tenant can push through its own project/credentials. Pass resolveTransport — when a send is scoped to a tenant (via forTenant(id) or a @Tenant() property), the channel uses the PushTransport you return instead of the default:

app.module.ts
PushChannelModule.forRoot({
  transport: FcmTransport,
  resolveTransport: (tenant) => tenantPushTransports.get(tenant) ?? defaultTransport,
});

Sends with no tenant scope keep the default transport. See Multi-tenancy for the full picture.

Multicast & invalid tokens

When a recipient routes many device tokens (a string[]), transports that support multicast — FCM and Expo — deliver them in a single batch via sendMany() instead of one call per token. Transports without it (web-push, APNs) fall back to per-token send(). It's transparent to the notification; you just get fewer provider round-trips.

Push providers also report tokens that are no longer valid (the app was uninstalled, the token rotated). Hand the channel an onInvalidTokens callback to learn about them and prune your store:

app.module.ts
PushChannelModule.forRoot({
  transport: FcmTransport,
  fcm: { /* … */ },
  onInvalidTokens: async ({ notifiable, invalidTargets, tenant }) => {
    await deviceTokens.removeAll(notifiable, invalidTargets); // stop pushing to dead tokens
  },
});

The callback receives an InvalidTokenReport ({ notifiable, invalidTargets, tenant? }). It fires after a batch send when the provider flags rejected tokens — keeping your token list clean so deliverability doesn't rot over time.

On this page