@rsc-kit/mcp
Version:
An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.
476 lines (368 loc) • 18 kB
Markdown
# Progressive web apps
> Making the app installable, and adding push notifications and background sync.
An installable app is a manifest plus a service worker. The
[offline guide](/guides/offline/) covers the worker; this covers everything
that makes the app feel like one the operating system knows about.
## Starting one installable
```sh
bun create rsc-kit@latest my-app --pwa
```
Or answer yes to *Installable app* when it asks. It turns on `offline`, and
writes a manifest, a static `/offline` page and four starter icons — `icon-192`,
`icon-512`, `icon-maskable-512` and `apple-icon`. They are files, found by name,
so the app is installable the moment it exists: Chrome's own installability
check finds nothing missing, and with the network cut the home page still loads
and a page never visited lands on `/offline`. Replace the icons with your own,
keeping the names. Nothing in rsc-kit draws, resizes or encodes an image — not
at build, not in dev, not at runtime.
It is off by default, and that is deliberate: a service worker outlives the
code that installed it, so it is a decision to take on purpose rather than one
a template takes for you.
## The manifest
A service worker makes an app survive a dead network. It does not make a
browser offer to put it on a home screen — that needs a web app manifest, which
is a file beside your routes:
```ts title="src/app/manifest.ts"
import type { WebManifest } from '@rsc-kit/core/manifest-file'
export default {
id: '/',
name: 'Orders',
shortName: 'Orders',
themeColor: '#0b0b0c',
backgroundColor: '#ffffff',
icons: ['icon-192.png', 'icon-512.png'],
} satisfies WebManifest
```
**Set `id` once and never change it.** It is the app's identity. Without it a
browser derives one from `startUrl` — change that later and everyone who
installed is left with an app their device no longer recognises as yours. `'/'`
is right for almost everyone; the build warns when it is missing.
The rest of the spec is typed as well, in its own names: `scope`, `orientation`,
`categories`, and `shortcuts` — the jumps a long press on the icon offers:
```ts
shortcuts: [{ name: 'New order', shortName: 'New', url: '/orders/new', icons: ['icon-96.png'] }],
```
Anything else the spec allows goes through `other`.
A file rather than a `vite.config.ts` key, and rather than `metadata` in a
layout. It is not build configuration — it is one more thing the app declares
about itself, so it lives where the app is, next to `layout.tsx` and
`not-found.tsx`.
Not `metadata` either, and for a reason worth stating: `metadata` is resolved
per route and can be computed per request. A manifest is one file for the whole
app and has to exist before anything renders. Putting it in an inheritable,
dynamic mechanism would invite a question — *can I override it for this route?*
— whose only honest answer is no.
The build reads it, writes `manifest.webmanifest`, and links it from every
page. **There is no layout to edit** — React hoists the `<link>` and the
`theme-color` meta into `<head>` from wherever they are rendered, so this works
on an app that already has its own root layout.
It is read at build time, before there is a module graph to evaluate it in, so
it must be an object literal — not computed, not imported from elsewhere. A
file that is not gets a build error rather than an app that is quietly not
installable.
Icons are paths in your `public/` directory, and **their sizes are read from
the filename** — `icon-192.png` and `icon-192x192.png` both mean 192. Declaring
the size in the config as well would be the same number written twice, and the
one that drifts is the one nobody looks at.
Each icon has one purpose, read from its name. An ordinary icon is `any`. One
named `icon-maskable-512.png` is `maskable`: drawn with a safe zone — a
launcher only promises to show the middle 80% — so Android can mask it to its
own shape without cutting anything off. It goes in the manifest and never in
`<head>`, where the padding would make it the small icon in the tab. One file
marked both, which is what this used to do, is either shrunk or cropped, and
the Android splash screen drew the shrunk one.
### The build tells you if it will not work
This is the failure worth guarding: a manifest with no icon is *valid*. It
parses, it links, it sets the theme colour, and no browser ever offers to
install it. Nothing is wrong and nothing works.
```text
[rsc-kit] manifest: no icons, so no browser will offer to install this. Add a 192px and a 512px png.
[rsc-kit] manifest: no id, so the app's identity is its startUrl - change that and everyone who installed has a different app.
[rsc-kit] manifest: Orders is installable
```
A warning rather than a failed build — the manifest still does its other job —
but it is said out loud, because someone who wrote `manifest: {…}` meant
installable and would otherwise find out months later.
## An install button
Chrome, Edge and Android make an offer — the `beforeinstallprompt` event — once,
whenever the page qualifies. Nothing guarantees that is after your component's
effect has run, and an app listening in `useEffect` could miss it for good,
with no way to offer *Install* until a reload. So the page catches it in its
inline bootstrap, ahead of the runtime, and `useInstall()` hands it over:
```tsx
'use client'
import { useInstall } from '@rsc-kit/core/install'
export function InstallButton() {
const { canInstall, install, installed, ios } = useInstall()
if (installed) return null
if (canInstall) return <button onClick={install}>Install</button>
if (ios) return <p>Tap Share, then Add to Home Screen.</p>
return null
}
```
- **`canInstall`** — the browser has offered; `install()` shows its dialog and
resolves with `accepted` or `dismissed`. An offer can be shown once, so after
that it is `unavailable` until the browser makes another.
- **`ios`** — Safari on an iPhone or iPad. iOS makes no offer and exposes no
way to install from a page: the visitor does it from the Share menu, and the
most the app can do is say so.
- **`installed`** — running as the installed app, or installed while this page
was open. For hiding the button, or anything that should differ in the app's
own window.
The browser's own install UI is left alone, so an app with no button of its own
loses nothing. The capture is only in apps that declare a manifest — nothing
else can be installed, so nothing else pays for it.
## The richer install sheet
Chrome shows a plain install dialog by default, and a much fuller one — with
screenshots, like an app store listing — when the manifest has them. The same
bargain as the icons: name the files by their size, and the build reads the
rest.
```text
src/app/
screenshot-wide-1280x720.png desktop
screenshot-narrow-750x1334.png a phone
```
Each becomes a manifest `screenshots` entry with its `sizes` and a `form_factor`
of `wide` or `narrow`. Nothing opens the file. Chrome wants at least one wide
screenshot for the desktop sheet and one narrow one for a phone; the build
mentions them when an otherwise installable app has none.
## Splash screens
What shows while an installed app starts. The two platforms do it differently,
and neither needs anything run over your images — you make them, the build
links them.
### Android, from the manifest
Chrome and Edge draw the splash themselves from three things the manifest
already has: the `name`, the largest icon, and `backgroundColor`. Give it a
512px icon and a background colour and it is done. The build warns about
either missing:
```text
[rsc-kit] manifest: no 512px icon, so a splash screen will be upscaled from a smaller one.
[rsc-kit] manifest: no backgroundColor, so the Android splash screen is white.
```
### iOS, from files named by their size
Safari ignores the manifest for this. It wants one image per device and
orientation, each behind a media query naming the device's size and pixel
ratio — and with no match it shows a blank screen. A query written by hand
with the ratio off by one matches nothing, silently.
So the image carries its size in its name, and the query comes from a table:
```text
src/app/
apple-splash-1179x2556.png iPhone 14 Pro, 15, 15 Pro, 16 — portrait
apple-splash-2556x1179.png the same phones — landscape
apple-splash-1290x2796.png 14 Pro Max, 15 Plus / Pro Max, 16 Plus
```
Each becomes `<link rel="apple-touch-startup-image" media="…">` with the right
query, and the page says it launches as an app, which is when Safari shows one.
Nothing opens the file — no decoding, no resizing, at build, in dev or at
runtime. Make the images however you like, for the devices you care about;
every other device gets the background colour.
| Size (portrait) | Devices |
| --- | --- |
| 1320×2868 | iPhone 16 Pro Max |
| 1206×2622 | iPhone 16 Pro |
| 1290×2796 | iPhone 14 Pro Max, 15 Plus, 15 Pro Max, 16 Plus |
| 1179×2556 | iPhone 14 Pro, 15, 15 Pro, 16 |
| 1284×2778 | iPhone 12 Pro Max, 13 Pro Max, 14 Plus |
| 1170×2532 | iPhone 12, 12 Pro, 13, 13 Pro, 14 |
| 1242×2688 | iPhone XS Max, 11 Pro Max |
| 1125×2436 | iPhone X, XS, 11 Pro, 12 mini, 13 mini |
| 828×1792 | iPhone XR, 11 |
| 1242×2208 | iPhone 6, 7, 8 Plus |
| 750×1334 | iPhone 6, 7, 8, SE (2nd and 3rd) |
| 640×1136 | iPhone SE (1st) |
| 2064×2752 | iPad Pro 13" (M4) |
| 2048×2732 | iPad Pro 12.9" |
| 1668×2420 | iPad Pro 11" (M4) |
| 1668×2388 | iPad Pro 11" |
| 1640×2360 | iPad Air 10.9", iPad (10th) |
| 1668×2224 | iPad Pro 10.5", iPad Air (3rd) |
| 1620×2160 | iPad 10.2" |
| 1488×2266 | iPad mini (6th) |
| 1536×2048 | iPad mini, iPad Air, iPad 9.7" |
Swap the numbers for landscape. A size no device has is served but linked from
no page, and the build names it rather than dropping it:
```text
[rsc-kit] apple-splash: apple-splash-1000x2000.png matches no iPhone or iPad screen, so no page links to it.
```
What it costs: a page gets one `<link>` per image — a full set is about 4 KB
before compression and well under 1 KB after, since the tags differ only in
numbers. Other browsers ignore them, and iOS fetches at most the one that
matches. An app with no `apple-splash-*` files gets no tags at all.
### One grey frame on a dark launch
In dark mode an installed app on iOS can show a single frame of grey (about
`#2C2C2E`) between the splash and the page. It is iOS's own: it starts
dismissing the splash one frame before the page has painted, and what shows
through for that frame is the empty web view's background — iOS's grey in
dark mode, white in light mode, which is why light mode looks clean.
Nothing in the page reaches it. `color-scheme`, an `html` background, the
cards and the placeholders are all drawn by the page, and this frame comes
before the page has drawn anything. It was chased through all of those on a
real phone before a frame-by-frame recording showed the whole screen lifting
evenly — the splash's pure-black icon plate included — which only a layer
behind the web view can do. It is 1/60 of a second; leave it.
### Or in metadata, the way Next writes it
A port from Next keeps its `appleWebApp` unchanged — the same shape, rendered
as the same tags:
```ts title="src/app/layout.tsx"
export const metadata: Metadata = {
appleWebApp: {
capable: true,
title: 'Orders',
statusBarStyle: 'black-translucent',
startupImage: [{ url: '/splash-1179x2556.png', media: '(device-width: 393px) and (-webkit-device-pixel-ratio: 3)' }],
},
}
```
`appleWebApp: true` is `{ capable: true }`. The files above are the easier way
to supply launch screens — this is for an app that already has its queries
written.
## Your own worker code
Push, notification clicks and background sync are events the generated worker
does not handle, and there is nowhere to put them in a file that says *do not
edit*. So it will import yours:
```js title="src/app/sw.js"
// Plain javascript. The browser evaluates this in a worker scope with no build
// step in front of it, so what you write is what runs.
self.addEventListener('push', (event) => {
const payload = event.data ? event.data.json() : {}
event.waitUntil(
self.registration.showNotification(payload.title ?? 'Update', {
body: payload.body,
data: { url: payload.url ?? '/' },
}),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
event.waitUntil(self.clients.openWindow(event.notification.data.url))
})
```
The build copies it beside the generated worker and imports it first, so your
listeners are registered before anything of ours can answer an event. It is
precached like everything else — a worker whose import fails does not start, and
then nothing is cached at all.
```text
[rsc-kit] offline: 32 files precached as rsc-kit-3d9785e4, falling back to /offline, with app/sw.js
```
There is no option for this. The file is there or it is not.
:::caution[`.js`, not `.ts`]
It is evaluated by the browser, not built. TypeScript would not run.
:::
## Push notifications
Four pieces, and only one of them is ours.
### 1. Keys
Push needs a VAPID key pair — the public half identifies your server to the
browser, the private half signs what you send.
```sh
bunx web-push generate-vapid-keys # or: npx web-push generate-vapid-keys
```
If either answers `web-push: command not found`, the runner found a copy of
`web-push` in the project and no command inside it — a half-finished install,
usually. Ask the library itself instead, which needs no command at all:
```sh
node -e "console.log(require('web-push').generateVAPIDKeys())"
```
Keep the private key wherever you keep secrets. The public one reaches the
browser, so it goes in a `VITE_` variable — `VITE_VAPID_PUBLIC_KEY`, as the
code below reads it.
### 2. Ask, and subscribe
```tsx title="src/components/EnableNotifications.tsx"
'use client'
export function EnableNotifications() {
async function enable() {
if (await Notification.requestPermission() !== 'granted') return
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
})
await saveSubscription(subscription.toJSON())
}
return <button onClick={enable}>Enable notifications</button>
}
```
**Ask when they have a reason to say yes.** A permission prompt on first load is
how an app gets denied permanently — the browser remembers a refusal, and there
is no second chance.
### 3. Store the subscription
An ordinary [server action](/guides/server-actions/), so it is one function and
no endpoint:
```ts title="src/server/push.ts"
'use server'
import { client } from './client'
export const saveSubscription = client.input(subscriptionSchema).handler(
async ({ input, ctx }) => db.pushSubscriptions.upsert(ctx.user.id, input),
)
```
Store it against a **user**, not a session. A subscription outlives the session
that created it, and that is the point of one.
### 4. Send
From wherever the event happens — a queue, a cron, a webhook:
```ts
import webpush from 'web-push'
webpush.setVapidDetails('mailto:you@example.com', PUBLIC_KEY, PRIVATE_KEY)
for (const subscription of await db.pushSubscriptions.forUser(userId)) {
try {
await webpush.sendNotification(subscription, JSON.stringify({
title: 'Your order shipped',
url: '/orders/42',
}))
} catch (error) {
// 404 and 410 mean the subscription is dead — the app was uninstalled, or
// the browser rotated it. Delete it rather than retrying forever.
if (error.statusCode === 404 || error.statusCode === 410) {
await db.pushSubscriptions.remove(subscription.endpoint)
}
}
}
```
That last branch is the one people skip, and it is why push senders accumulate
dead endpoints until they are mostly dead endpoints.
:::note[Not ours, deliberately]
This package ships no push helper. Everything above is the web API and
`web-push`, and the only part we could own — a place to put the listener — is
`app/sw.js`. A wrapper would be a thin layer over a stable standard, and one
more thing to keep current.
:::
## Background sync
For work that must happen even if the person closes the tab — a queued message,
an offline edit.
```js title="src/app/sw.js"
self.addEventListener('sync', (event) => {
if (event.tag === 'outbox') event.waitUntil(flushOutbox())
})
async function flushOutbox() {
const db = await openDB()
for (const item of await db.getAll('outbox')) {
const response = await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.body),
})
// Left in the queue on failure, so the browser retries the whole sync
// later. Removed only once the server has it.
if (response.ok) await db.delete('outbox', item.id)
}
}
```
Register it from the page after queueing the work:
```ts
const registration = await navigator.serviceWorker.ready
await registration.sync.register('outbox')
```
:::danger[Make the endpoint idempotent]
The browser decides when a sync runs and may run it more than once — a request
that succeeded on the server but whose response never arrived gets retried. If
that posts a message twice, the person sent it twice.
Give each queued item an id the client generates, and have the endpoint ignore
one it has already seen. Nothing else about background sync is hard; this is,
and no framework can do it for you because only your data model knows what
"already seen" means.
:::
Support is narrower than the rest of this page — Chromium has it, Safari and
Firefox do not. Treat it as an optimisation over an ordinary retry, never as the
only path:
```ts
if ('sync' in registration) await registration.sync.register('outbox')
else await flushNow()
```