Push notifications

A push notification reaches the user's phone while the app is closed: your server sends it, le.codes relays it to Apple's and Google's services, the OS shows it. The app's backend sends one with a push channel — an export of a *.server.ts file that types both sides, like a channel: the server calls send(user, …), the app hears onTap. A reminder at a set time with no server (a local notification) does not exist (Not available), a short message inside the running app is toast(), and the number on the app icon's badge comes only with a push (badge).

  • send a notification to a user from your server: pushChannel() + send
  • open the right screen when the user taps it: onTap
  • show one that arrives while the app is open: onMessage
  • a project without a backend: Push, the relay's send route and lecodes pn send

Platforms: iOS and Android; not on desktop, the web, the web player or in headless tests — there a handler is recorded and never called, and Push.isSupported is false.

At a glance

TypeScript
// pushes.server.ts — one export, both sides
export const orderShippedPushChannel = pushChannel<{ orderId: number }>()

// orders.server.ts
export async function ship(orderId: number) {
  const order = await db.order.where({ id: orderId }).select({ id: true, owner: { id: true } }).first()
  await db.order.update(orderId, { status: "shipped" })
  await orderShippedPushChannel.send(order.owner, { title: "Order shipped", body: "Arrives Friday", data: { orderId } })
}

// main.ts — the app
import { orderShippedPushChannel } from "./pushes.server"

orderShippedPushChannel.onTap(({ data }) => Router.push(OrderScreen(data.orderId)))   // tapped — the tap that started the app too
orderShippedPushChannel.onMessage(({ title }) => toast(title))                        // arrived while the app is open

That is all the code: the permission is asked and the device registered when the server asks for it (after the user signs in), and a sign-out stops the notifications. The project needs sign-in — a user's devices are the devices they are signed in on — and "plugins": { "push": "*" } in app.json (below).

A push channel: pushChannel<Data>() {#push-channel}

TypeScript
export const newsPushChannel = pushChannel()                                   // title and body alone
export const orderShippedPushChannel = pushChannel<{ orderId: number }>()      // with data for the app

A push channel is an export of a *.server.ts file, named somethingPushChannel by convention. Its type parameter is the shape of data, what the notification carries for the app: a JSON object, up to 2 KB. In the app the import is the same name with onTap / onMessage; send there is a compile error (a push channel's send … the app listens), like publish on a channel.

TypeScript
interface PushChannel<Data> {
  send(user: PushTarget | PushTarget[], message: PushMessage<Data>): Promise<{ sent: number }>   // server
  onTap(handler: (notification: PushNotification<Data>) => void): ChannelSubscription              // app
  onMessage(handler: (notification: PushNotification<Data>) => void): ChannelSubscription          // app
}
type PushTarget = number | { id: number }                                   // a user id, or a row with one
type PushMessage<Data> = { title: string, body?: string, url?: string, badge?: number, data: Data }   // `data?` when Data is undefined
type PushNotification<Data> = PushMessage<Data> & { channel: string }

Send a notification from the server: send(user, message)

TypeScript
const user = await db.auth.requireUser().select({ id: true })
await welcomePushChannel.send(user, { title: "Welcome!" })                  // one user
await digestPushChannel.send([1, 2, 3], { title: "3 new posts", badge: 3 })   // several, by id
const { sent } = await orderShippedPushChannel.send(order.owner, { title: "Order shipped", data: { orderId } })

user is a user id or a row that has one — what db.auth.requireUser().select({ id: true }) answers, a relation of a row — or a list of either. send reaches every device the user is signed in on and answers how many: a user with no device is 0, not an error. It needs no request: a timer or a channel hook may send too.

field what it is
title required, up to 200 characters
body the second line, up to 1000 characters
data your JSON object, up to 2048 characters serialized — the channel's type
url a link the app opens itself in onTap (nothing opens by itself), up to 1000 characters
badge the number on the app icon (badge)

Sending needs a Pro or Business plan of the project's owner (the server console says so when the platform refused). Under lecodes dev the backend sends through your own login when the folder is a linked project; a project standing alone, or no login, sends nothing and says so once in the console.

How a device becomes a user's: nothing to write

A device registers for notifications once and gets an opaque address at the le.codes relay (never Apple's or Google's token). The app's transport tells your backend that address with every call, and the backend keeps it on the device's session — the same session that a sign-in binds to a user. So:

  • the OS permission is asked after the user signs in — the first response to a signed-in device without an address asks it to register (login() resolves, the dialog appears beside the next screen; the app waits for nothing). The dialog shows once per installation: a device that allowed registers silently from then on, one that refused is never asked again (the app's page in the system settings turns it back on — permissions.openSettings());
  • a user signed in on three devices has three sessions with three addresses: send reaches all;
  • a sign-out leaves the device on a guest session, which no send reaches; the next sign-in on the device, as anyone, makes it theirs;
  • a user who was signed in before the project got push channels is asked at their next call.

Nothing of this is in your code, and the user id is never asserted by the device: the session token is the proof. Push.register() remains for an app that wants to pick the moment of the dialog itself — called before a sign-in, it leaves nothing for the server to ask.

Note

the address is the session's secret, like its token: a project never reads it (db.auth.sessions leaves it out), and an app should not show or send it anywhere.

The user taps a notification: onTap

TypeScript
orderShippedPushChannel.onTap(({ data, url }) => {
  Router.push(OrderScreen(data.orderId))      // or route by url, if the app has deep-link routes: open it yourself
})

One handler covers every tap: while the app ran (in the foreground or the background), and the tap that started the app — that notification is replayed into the first handler of its channel, after the top-level code ran (Router.init(...) below the handler is fine). Write it at the top level of main.ts; it lives for the run (close() ends it). A notification's url is for the app to open: it does not arrive as the app's "url" event and does not change app.launchUrl.

A notification is heard once, by the handlers of its channel. One sent outside a channel (a push key, lecodes pn send) goes to Push.addEventListener instead.

A notification arrives while the app is open: onMessage

TypeScript
const inbox = signal<string[]>([])
newsPushChannel.onMessage(({ title, body }) => {
  inbox.value = [title, ...inbox.value]
  toast(body ? `${title}: ${body}` : title)
})

While the app is in the foreground, a notification of a channel with an onMessage handler is the app's to show: the OS shows no banner, the handler gets it. A channel without one gets the system banner, as if the app were closed, and a tap on it is onTap. So an app that only routes taps loses nothing.

What a notification carries

TypeScript
interface PushNotification<Data> {
  channel: string           // the channel's id — what the handlers were matched by
  title: string
  body?: string
  url?: string              // a deep link for the app to open in onTap
  data: Data                // the channel's type (absent for pushChannel() without one)
  badge?: number            // the number it set on the app icon
}

The number on the app icon badge

The app cannot set or clear its icon's badge itself; a notification sets it: send badge. iOS shows the number (badge: 0 clears it); on Android it is the notification's number, shown where the launcher supports badges. A notification that arrives while the app is open sets nothing: its badge comes in the onMessage notification.

Add push to the app: app.json

Push comes from the push plugin: list it in the project's app.json — "plugins": { "push": "*" }. The list is one for every platform (the bundle's // plugins: header): a plugin it does not name is not there in a shell built from the project, and the compile warns when the app has push channels and the list lacks push. The le.codes viewer app registers every first-party plugin whatever the list says.

Where push works: a project with its identity

A notification is addressed to a project, so the phone must know which project it runs:

  • the le.codes viewer app (iOS, Android): a published project opened from its QR code or link, and the project link of lecodes dev (le.codes/qr/dev/<uuid>/…). A bundle fetched from a local url of lecodes dev carries no project identity: the device cannot register there (the backend's send finds no device). In the iOS App Clip (a project opened from a QR code without the app installed) registering ends "unavailable" after about 15 s — it has no push entitlement.
  • an app built with lecodes app that lists the plugin: the same code, for a project that exists on le.codes. Delivery is scoped to apps Letary publishes — the relay signs for com.letary.* iOS bundle ids with Letary's APNs key and for Android packages of the le.codes Firebase project (the shell's google-services.json); any other app's registration rejects "unavailable".

Desktop, the web, the web player and headless tests have no push: handlers are recorded and never called; send on the server finds no web device.

Without a backend: Push, a push key, lecodes pn send

A project whose server is not on le.codes — or has none — uses the plugin's own object and the relay's send route. The app registers and tags the device with its own user id; a server sends with a project push key; the notifications arrive as events on Push.

TypeScript
Push.isSupported: boolean                  // did this host register the push service?
Push.getStatus(): Promise<PushStatus>      // never prompts
Push.register(options?: { user?: string }): Promise<{ address: string }>   // the OS dialog the first time
Push.unregister(): Promise<void>           // this device stops receiving
Push.addEventListener("tap" | "message", (payload: PushPayload) => void)   // notifications sent outside a channel
Push.removeEventListener(event, callback)

interface PushStatus { permission: "granted" | "denied" | "prompt", registered: boolean, address?: string }
interface PushPayload { channel?: string, title: string, body?: string, url?: string, data?: Record<string, any>, badge?: number }
TypeScript
if (Push.isSupported) {
  Push.register({ user: "u42" }).catch(e => console.log("no push:", e.message))   // "denied" | "unavailable"
  Push.addEventListener("tap", p => console.log("tapped", p.title, p.data))        // the one that started the app too
  Push.addEventListener("message", p => toast(p.title))                            // arrived while the app is open
}

register() rejects "denied" (the user said no) or "unavailable" (no project identity, no device token). register({ user }) tags the device with the app's own user id for the relay's users: [...]; the id is client-asserted — any device could claim any id — fine for "your order shipped", never for a secret. addEventListener("tap") hears the notification that started the app as well.

The send is one HTTP request to the relay, authorized by a project push key (lecodes_pk_…, minted once by lecodes pn keys new "prod server" --env or the project's settings → Push notifications, owner only, shown once); users, to (addresses) or both, up to 500 each; the answer is { sent, failed: [{ address, reason }] }, and an unknown or foreign address rejects the whole request (404, nothing sent):

bash
curl -X POST https://le.codes/api/projects/<projectUuid>/push/send \
  -H "Authorization: Bearer $LECODES_PUSH_KEY" -H "Content-Type: application/json" \
  -d '{"title":"Order shipped","body":"Arrives Friday","users":["u42"],"data":{"orderId":"42"}}'

While developing, the CLI sends on your login, with no key, from the project folder:

bash
lecodes pn devices                                        # did the phone register? (address, user, platform)
lecodes pn send --title "Order shipped" --user u42        # to every device of user u42
lecodes pn send --title "Hi" --to pa_abc --badge 3        # to one address

A notification sent this way has no channel: it reaches Push.addEventListener, not a push channel's handlers.

See also