NukeBase

Push Notifications: Client API

The browser SDK gains a client.push object.

MethodReturnsDescription
client.push.enable(path)Promise<status>Must be called from a tap or click. It registers /sw.js, asks permission if needed, subscribes, and saves this device at [...path, auth] with a normal set, so your rules apply. Call it again with another path to save the same device somewhere else too. Device problems never throw; they come back as a status. Only a missing or non-array path throws, as a programming error.
client.push.status()Promise<status>Tells you what to show, without asking the user anything.
client.push.setBadge(n)Promise<void>Sets the number on this device's app icon. 0 clears it. Does nothing where badges aren't supported.
client.push.disable(path?)Promise<"off">With a path, removes this device from that one place and keeps notifications on elsewhere. Without one, removes it from every place this browser saved it and turns push off on this device.
StatusMeaningWhat to show
"on"This device will receive notifications."Notifications on", plus a Turn off option.
"off"Supported, but not enabled yet.A "Turn on notifications" button.
"blocked"The user said no, or blocked it in settings. Browsers won't ask again.How to allow it in the phone's or browser's settings.
"install-first"iPhone or iPad Safari, not installed yet."Tap Share, then Add to Home Screen, then open the app from your Home Screen."
"not-saved"Permission was given, but your rules refused the write to path.Usually the user isn't signed in, or the path is wrong. Check your rules.
"unsupported"An in-app browser, or a very old browser."Open this page in Safari or Chrome."

Users never have to turn it on twice. The SDK remembers every path this browser saved the device to. Each time the app opens, if the browser or phone renewed the subscription behind the scenes, the SDK quietly saves the new one to those same paths. Updates to sw.js and to your app keep both the permission and the subscription.

App icon badge

The badge is the red number on an installed app's icon. Set it in any push with badge, and from the page with client.push.setBadge(). It's a plain number you choose, such as unread messages or open orders, and 0 clears it.

WhereBadge?Notes
iPhone / iPad, installed app (iOS 16.4+)YesShown once the user has allowed notifications.
Windows / Mac, installed Chrome or Edge appYesOn the taskbar or Dock icon.
Mac, Safari web app in the DockYes
AndroidDot onlyAndroid shows its own dot while a notification is unread. The number is ignored.
A normal browser tabNoBadges need the installed app.

Where badges aren't supported, badge is silently ignored and the notification still shows. Your code never has to check.

Set it with a push

The number is the same for every device in one send. For a count that differs per person, like each user's unread messages, send to each user separately:

New message: notify and update the unread count
addDbTrigger("set", ["chats", "$chatId", "messages", "$msgId"], (context) => {
  if (context.dataBefore !== undefined) return;
  const msg = context.dataAfter;
  const unread = increment(["users", msg.to, "unread"], 1).data;   // the recipient's new total
  sendPush(["users", msg.to, "pushTokens"], {
    title: msg.fromName,
    body: msg.text,
    url: `/chats/${context.path[1]}`,
    badge: unread
  });
});

Clear it when they've caught up

Opening the app doesn't clear the badge on its own. Set it when the user has seen things, and keep your stored count in step:

public/index.html
async function openInbox() {
  showInbox();
  await client.set(["users", myUid, "unread"], 0);
  client.push.setBadge(0);          // this device, right now
}

setBadge only changes the device it runs on. If the user also has a laptop, its badge updates with the next notification you send them, which carries the new count. There are no silent, badge-only pushes: every push shows a notification, because Apple cancels apps that receive pushes without one.

Make the app installable

iPhones only deliver push to apps added to the Home Screen, and an installed app also looks and feels like a real app on Android. Two things make that possible: a manifest file and an icon. New projects include both. For an existing project, add them like this:

public/manifest.webmanifest
{
  "name": "Pawsbook",
  "short_name": "Pawsbook",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4361ee",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}
In the <head> of every page
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/icon-192.png">
<meta name="theme-color" content="#4361ee">

Changing the icon needs a new file name. The engine tells browsers to cache images for a year, so replacing icon-192.png in place leaves phones showing the old one. Save the new icon as icon-192-v2.png and update the manifest.

The service worker

public/sw.js is an engine file, like sdkmod.js. The SDK registers it on every page, whether or not the app uses push. It does five things:

  • Keeps a page cache. See below: the app opens instantly from the last copy seen, and opens at all with no signal.
  • Sets the badge. When a push carries badge, it updates the app icon number, even if the app is closed.
  • Shows every push. It always displays a notification, even for a damaged payload (falling back to "New update"). Apple cancels subscriptions for apps that receive pushes without showing one.
  • Handles taps. It focuses an open window of your app or opens the payload's url, and only ever within your own site.
  • Updates itself immediately. A new version takes over the next time the app is opened.

Never move, rename or edit /sw.js. A service worker at a different address counts as a new one with no subscriptions, and every user would have to turn notifications on again. nukebase update replaces this file, so edits are lost. If you need your own worker logic, add sw.js to updateExclude and keep the push handlers intact.

The page cache

On by default. Every same-origin GET the page makes — the HTML, sdkmod.js, your scripts, styles, images and manifest — is answered from the worker's cache when a copy exists, and the current copy is fetched in the background and stored for next time. This is stale-while-revalidate: a page the user has seen before opens at once, even on a slow connection, and with no network at all it still opens. A page never opened on that device gets a small built-in "You're offline" page instead of the browser's error.

Nobody is left on an old build. Every load re-fetches in the background (serveStatic answers 304 cheaply when nothing changed), and when the fetched copy differs from the stored one the worker tells every open window. The SDK reloads the page by itself only when that happens within a few seconds of load and before the user has typed, tapped or scrolled — on a fast connection that is an invisible refresh. After that it is your app's call through client.onUpdate, which is the place for a "new version available" notice:

public/index.html
const client = await createClient();

client.onUpdate(() => {
  showBanner("A new version is ready", { action: "Reload", onClick: () => location.reload() });
});

// Or turn the page cache off for this app. Push keeps working.
const client = await createClient({ pageCache: false });

What is never cached: anything cross-origin; anything but GET; the WebSocket, which the worker never sees; the file store under /_file/, which is rule-governed; any response carrying Cache-Control: private or no-store, which is how a route of your own stays out; any non-200; anything over 8 MB. The cache keeps at most 500 entries, oldest dropped first. Files served to a signed-in person by an auth-gated serveStatic mount are marked private by the engine and never stored — see Serving Static Files.

The first visit is never offline-ready. The worker stores what passes through, so a page is available offline from the second visit on. It stores no list of files in advance, which is what keeps it from ever serving a build you did not just push.

The page cache is not the database. It holds files. Data read over the WebSocket is not cached by the worker; an app opened offline renders, connects when it can, and shows client.online === false until then. For data that survives being offline — cached reads and queued writes — opt in to offline mode.

Page cache: troubleshooting

SymptomLikely causeFix
After a push, a user still sees the old pageStale-while-revalidate serves the last copy first. The page reloads itself only within a few seconds of load and before any input; after that the new build waits for the next load.Expected. Register client.onUpdate and show a "new version, reload" notice so a user mid-task can refresh when they choose.
A members-only page opens offline for someone else on the same deviceThe serveStatic auth callback for that mount did not read any identity field, so the engine treated its files as public and the worker stored them.Gate on req.uid or req.claims in the callback. Files served that way are answered private, no-store and never cached. For a route of your own, send that header yourself.
A page you changed on the server never updates in one browserThat browser is offline, or the file is an image: images carry a one-year cache header and the browser keeps the old one.Rename a changed image (icon-192-v2.png) and update what references it. Everything else refreshes on the next online load.
The app opens offline but shows no dataThe page cache holds files only. Database reads go over the WebSocket, and the socket is down.Expected today. Use client.onConnectionChange to show an offline state until the socket is back.
A user who has opened the app before gets the "You're offline" pageThe copy aged out: the cache keeps 500 entries and drops the oldest, or they opened the app only before this engine version, when nothing was cached.One online visit fills the cache again. Nothing to change.
You want the old behaviour, no cache at all—createClient({ pageCache: false }). The worker drops its cache and only handles push. Nothing else changes.
↑