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 andlecodes 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
// 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 openThat 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}
export const newsPushChannel = pushChannel() // title and body alone
export const orderShippedPushChannel = pushChannel<{ orderId: number }>() // with data for the appA 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.
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)
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:
sendreaches all; - a sign-out leaves the device on a guest session, which no
sendreaches; 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.
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
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
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
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 oflecodes devcarries no project identity: the device cannot register there (the backend'ssendfinds 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 appthat lists the plugin: the same code, for a project that exists on le.codes. Delivery is scoped to apps Letary publishes — the relay signs forcom.letary.*iOS bundle ids with Letary's APNs key and for Android packages of the le.codes Firebase project (the shell'sgoogle-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.
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 }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):
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:
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 addressA notification sent this way has no channel: it reaches Push.addEventListener, not a push
channel's handlers.
See also
- Server functions — endpoints, channels, where the backend runs
- Sign-in — sessions and users: what a device's notifications hang on
- app —
app.launchUrl+ the"url"event (deep links, which a notification'surlis not) - Toast — a short message inside the running app
- Permissions —
permissions.openSettings() - Conventions — host gating