@rsc-kit/mcp 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/answers.d.ts +7 -0
  2. package/dist/answers.js +26 -0
  3. package/dist/answers.js.map +1 -1
  4. package/dist/bundleGuides.d.ts +22 -0
  5. package/dist/bundleGuides.js +130 -0
  6. package/dist/bundleGuides.js.map +1 -0
  7. package/dist/index.js +21 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/recipes.js +81 -6
  10. package/dist/recipes.js.map +1 -1
  11. package/dist/report.d.ts +11 -0
  12. package/dist/report.js +1 -1
  13. package/dist/report.js.map +1 -1
  14. package/guides/api-routes.md +168 -0
  15. package/guides/authorization.md +288 -0
  16. package/guides/caching.md +57 -0
  17. package/guides/connection.md +98 -0
  18. package/guides/edge-caching.md +159 -0
  19. package/guides/errors.md +109 -0
  20. package/guides/file-uploads.md +119 -0
  21. package/guides/fonts.md +117 -0
  22. package/guides/forms.md +528 -0
  23. package/guides/images.md +83 -0
  24. package/guides/index.json +162 -0
  25. package/guides/mcp.md +113 -0
  26. package/guides/metadata.md +289 -0
  27. package/guides/navigation.md +84 -0
  28. package/guides/no-javascript.md +39 -0
  29. package/guides/offline.md +215 -0
  30. package/guides/ppr.md +181 -0
  31. package/guides/pwa.md +260 -0
  32. package/guides/queries.md +340 -0
  33. package/guides/react-compiler.md +153 -0
  34. package/guides/redirects.md +143 -0
  35. package/guides/response-headers.md +66 -0
  36. package/guides/route-interception.md +206 -0
  37. package/guides/routing.md +458 -0
  38. package/guides/sections.md +74 -0
  39. package/guides/server-actions.md +444 -0
  40. package/guides/static-generation.md +347 -0
  41. package/guides/testing.md +158 -0
  42. package/guides/third-party-scripts.md +105 -0
  43. package/guides/typed-routes.md +139 -0
  44. package/guides/url-validation.md +143 -0
  45. package/guides/validation.md +175 -0
  46. package/guides/view-transitions.md +120 -0
  47. package/package.json +4 -3
@@ -0,0 +1,215 @@
1
+ # Offline
2
+
3
+ > Knowing when the server cannot be reached, and carrying on without it.
4
+
5
+ ```tsx
6
+ 'use client'
7
+
8
+ import { useOffline } from '@rsc-kit/core/useOffline'
9
+
10
+ export function ConnectionBanner() {
11
+ const offline = useOffline()
12
+
13
+ if (!offline) return null
14
+
15
+ return <p role="status">You are offline. Changes will not be saved.</p>
16
+ }
17
+ ```
18
+
19
+ ## What "online" means here
20
+
21
+ Whether the **router could reach the server**, as of its last attempt — not
22
+ `navigator.onLine`.
23
+
24
+ That distinction matters. `navigator.onLine` only tells you the network
25
+ interface is up — a laptop on café wifi that needs a login is "online" by that
26
+ measure and can reach nothing.
27
+
28
+ This reports what the router actually observed on its own requests, which is
29
+ what a person means by offline.
30
+
31
+ It follows that the value only changes when something is attempted. A tab
32
+ sitting idle with a dead connection reports online until the next navigation,
33
+ prefetch or action tries and fails.
34
+
35
+ ## On the server
36
+
37
+ `useOffline()` returns `false` during a server render, and deliberately: the
38
+ server can reach itself. Returning anything else would render an offline state
39
+ into the HTML and then mismatch when the browser hydrated and disagreed.
40
+
41
+ ## `useOnline`
42
+
43
+ The same reading, the other way round, for when that is what the markup wants:
44
+
45
+ ```tsx
46
+ 'use client';
47
+
48
+ import { useOnline } from '@rsc-kit/core/useOnline';
49
+
50
+ export function Status() {
51
+ return <span>{useOnline() ? 'connected' : 'reconnecting…'}</span>;
52
+ }
53
+ ```
54
+
55
+ It returns `true` on the server, for the same reason `useOffline()` returns
56
+ `false` there. Both read the one store, so they cannot disagree.
57
+
58
+ ## Working with no network at all
59
+
60
+ The hooks above report. A service worker is what lets the app keep working,
61
+ and it is off by default:
62
+
63
+ ```ts title="vite.config.ts"
64
+ rscKit({ offline: true })
65
+ ```
66
+
67
+ The build writes `sw.js` beside the assets and the generated entry registers
68
+ it. With it on, a page you have visited survives a full reload with no network
69
+ at all — not just a navigation, a reload — and comes back interactive.
70
+
71
+ Everything else lives in one page's memory — the pages a boundary keeps
72
+ mounted, the prefetch cache. Reload with no network and the browser shows its
73
+ own error page: no script runs, so nothing held in JavaScript is reachable.
74
+
75
+ A service worker is the only thing that survives that.
76
+
77
+ ### What is cached, and when
78
+
79
+ | | |
80
+ | --- | --- |
81
+ | hashed assets | at install, and answered from the cache forever after — the name changes when the bytes do |
82
+ | a page you loaded | its document, and the payload it boots from |
83
+ | a page you reached by link | its payload, and its document fetched once to go with it |
84
+ | a page you never visited | nothing |
85
+
86
+ The last two rows are the same mechanism from either end. A hard load fetches a
87
+ document and never the payload; a link fetches a payload and never the document.
88
+ Either alone is half a page.
89
+
90
+ So whichever arrives first fetches the other, **once per url**. A page costs one
91
+ extra request the first time you reach it, and none after.
92
+
93
+ A url you have never opened cannot be served, and is not faked. An earlier
94
+ version answered with the cached root, which put the home page's markup under
95
+ the address someone asked for and did not hydrate — a wrong page pretending to
96
+ be the right one. It fails now, which is true.
97
+
98
+ ### What is deliberately not cached
99
+
100
+ Anything the server sent with `Cache-Control: no-store`, whatever else it is.
101
+
102
+ The Cache API files responses by url and knows nothing about who asked. So a
103
+ guarded page, or a [query](/guides/queries/) that read the session, would be
104
+ handed to whoever opens the app next on that machine — signed in as someone
105
+ else. `no-store` is the server saying so, and the worker obeys it.
106
+
107
+ This is why a query does **not** work offline by default. Queries answer
108
+ `private, no-store` precisely because they may read the session, and widening
109
+ that is a decision about who may see the answer:
110
+
111
+ ```ts
112
+ // Cacheable, and offline, because the answer is the same for everyone.
113
+ export const getPricing = query(async () => tiers(), { cache: "private", maxAge: 300 })
114
+ ```
115
+
116
+ For a personal read you want across reloads, do not widen it — persist your
117
+ cache library instead. TanStack's `persistQueryClient` writes to storage that
118
+ belongs to that browser, which gets you the same result without putting one
119
+ visitor's data somewhere the next one can be served it.
120
+
121
+ ### Updating
122
+
123
+ The cache name is a hash of what it holds. A build that changed nothing keeps
124
+ the same name and leaves a visitor's cache alone; a build that changed anything
125
+ gets a new one, and the new worker sweeps the old caches away.
126
+
127
+ There is no version to set, and no way to strand someone on a stale worker.
128
+
129
+ A new worker takes over as soon as it installs rather than waiting for every
130
+ tab to close. That is safe here because assets are addressed by content: a page
131
+ already open goes on asking for the names it was built with.
132
+
133
+ <Aside type="note" title="The first visit still needs a reload">
134
+ A worker does not control the page that registered it. So the first visit
135
+ caches what it needs and the *second* is the one that survives being offline.
136
+ That is how service workers work everywhere, not something particular here.
137
+
138
+ Development is left alone entirely — a worker answering from a cache in front
139
+ of the dev server turns every edit into a question about which copy you are
140
+ looking at.
141
+ </Aside>
142
+
143
+ <Aside type="caution" title="It can hide a server that is down">
144
+ Serving from a cache asks nothing of the network, so an app can look healthy
145
+ while its backend is not. That is the same edge the reveal window has, and it
146
+ is what `useOffline()` above is for: with this on, saying so in the interface
147
+ stops being a nicety.
148
+ </Aside>
149
+
150
+ ## When nothing can answer
151
+
152
+ A reload with no network, on a page the worker has never cached, fails. It does
153
+ not fall back to the cached home page — that was tried, and it was worse than
154
+ failing: the document *is* the page here, so the visitor got the home page's
155
+ markup under the address they asked for, and it did not hydrate.
156
+
157
+ Add a route at `/offline` and that page is served instead:
158
+
159
+ ```tsx title="src/app/offline/page.tsx"
160
+ export default function Offline() {
161
+ return (
162
+ <main>
163
+ <h1>You are offline</h1>
164
+ <p>Try again once you are back.</p>
165
+ </main>
166
+ )
167
+ }
168
+ ```
169
+
170
+ Nothing in the file makes it special. It is an ordinary route, and what makes it
171
+ the fallback is that the build stored it and the worker precached it. It is also
172
+ the one page that can honestly stand in for another, because it is about being
173
+ offline rather than about the url it appears under.
174
+
175
+ **It has to be static.** A fallback that renders per request cannot be served
176
+ when there is no request to be made, so the build checks and says when it will
177
+ not work:
178
+
179
+ ```text
180
+ [rsc-kit] offline: /offline cannot be the fallback, because it called cookies(),
181
+ headers(). A fallback has to be servable with no network at all.
182
+ ```
183
+
184
+ It is served for navigations only — a payload request answered with a document
185
+ would be handed to the Flight decoder, which throws.
186
+
187
+ ## When a new version is live
188
+
189
+ The worker takes over as soon as it installs rather than waiting for every tab
190
+ to close, and taking over sweeps the previous build's cache. A page that has
191
+ been open across a deploy is therefore running javascript whose remaining chunks
192
+ are gone. It works until it navigates somewhere that needs one.
193
+
194
+ Only the worker knows this happened, so it says so:
195
+
196
+ ```tsx
197
+ 'use client'
198
+ import { useAppUpdate } from '@rsc-kit/core/useAppUpdate'
199
+
200
+ export function UpdateBanner() {
201
+ const { updated, reload } = useAppUpdate()
202
+
203
+ if (!updated) return null
204
+
205
+ return <button onClick={reload}>A new version is ready — reload</button>
206
+ }
207
+ ```
208
+
209
+ Reported rather than acted on. Reloading out from under someone mid-form is
210
+ worse than the staleness it fixes, so this package will not do it for you.
211
+
212
+ ## Going further
213
+
214
+ Installing, push notifications and background sync are in
215
+ [Progressive web apps](/guides/pwa/).
package/guides/ppr.md ADDED
@@ -0,0 +1,181 @@
1
+ # Partial prerendering
2
+
3
+ > Storing what every visitor sees the same, and rendering the rest per request.
4
+
5
+ A page usually has two halves: markup that is the same for everybody, and data
6
+ that is not. The build stores the first and renders the second per request, so
7
+ the browser paints immediately and fills in as the data arrives.
8
+
9
+ There is nothing to switch on, and no mode to pick. Every route goes through
10
+ the same probe, and this is one of the things it can find.
11
+
12
+ ## How the build decides
13
+
14
+ ```text
15
+ Build time
16
+ → the page is rendered with a budget
17
+ → anything still waiting when the budget expires is postponed
18
+ → React has already flushed what did not need the request: layouts, static
19
+ markup, and the Suspense fallbacks standing in for the rest
20
+ → that markup is stored as the route's shell, and beside it, where it stopped
21
+ Request time
22
+ → the shell is served straight from disk — fast, no render
23
+ → the render is picked up from where it stopped, against data that exists now
24
+ → only the unfinished boundaries are written, onto the same response
25
+ → a script React emits beside each one moves it into place as the HTML parses
26
+ ```
27
+
28
+ **Postponed, not merely abandoned.** The difference matters. Aborting a render
29
+ gives you the bytes that flushed and nothing else — React has no record of
30
+ where it got to, so the holes can only be filled later by the browser.
31
+ Postponing keeps that record, which is what lets the boundaries be finished
32
+ at the origin and arrive with the document.
33
+
34
+ So the content is in the first response. It appears without waiting for the app
35
+ bundle or for hydration — on a slow connection, the difference between a spinner
36
+ and a page — and it is in the HTML a crawler reads.
37
+
38
+ This is not the same as working with scripting off: with JavaScript disabled the
39
+ fallbacks stay.
40
+
41
+ If anything goes wrong — the engine cannot resume, or the render fails — the
42
+ shell is served on its own and the client fills the boundaries when it
43
+ hydrates, exactly as it did before any of this existed. A slower hole, never a
44
+ wrong page.
45
+
46
+ The shell is stored per route **pattern**, not per url. `/posts/[slug]` has one
47
+ shell serving every slug, because everything that varies by slug is behind a
48
+ boundary the request fills.
49
+
50
+ ## What makes a page dynamic
51
+
52
+ Anything the build cannot know:
53
+
54
+ - **`await params`** — the url, for a route that has not listed its urls
55
+ - **`await searchParams`** — the query string
56
+ - **`await headers()` / `await cookies()`** — the request
57
+ - **a host call**, on a host that has one
58
+ - **slow work of your own** that outlasts the budget
59
+
60
+ All of them behave the same way: the read suspends, the nearest fallback above
61
+ it goes into the shell, and the real value arrives per request.
62
+
63
+ ## Where to put the boundaries
64
+
65
+ Only content inside a `<Suspense>` boundary can stream. Content above every
66
+ boundary has to finish before anything paints — which is the difference between
67
+ a route the build can store and one it refuses.
68
+
69
+ ```tsx title="src/app/posts/[slug]/page.tsx"
70
+ import { Suspense } from 'react';
71
+ import { findPost } from '../../../data';
72
+
73
+ async function Body({ params }: { params: Promise<{ slug: string }> }) {
74
+ const { slug } = await params;
75
+ const post = await findPost(slug);
76
+
77
+ return <article><h1>{post.title}</h1><p>{post.body}</p></article>;
78
+ }
79
+
80
+ export default function PostPage({ params }: { params: Promise<{ slug: string }> }) {
81
+ return (
82
+ <Suspense fallback={<p>Loading…</p>}>
83
+ <Body params={params} />
84
+ </Suspense>
85
+ );
86
+ }
87
+ ```
88
+
89
+ `Body` is still waiting when the budget expires, so the shell holds the
90
+ fallback. At request time the slug resolves and React replaces it.
91
+
92
+ ## loading.tsx instead
93
+
94
+ Rather than wrapping every page by hand, put a `loading.tsx` beside it. The
95
+ build wraps the page in `<Suspense fallback={<Loading />}>` for you, and the
96
+ page can await at its top level:
97
+
98
+ ```tsx title="src/app/posts/[slug]/loading.tsx"
99
+ export default function Loading() {
100
+ return <div className="h-6 w-50 animate-pulse rounded-lg bg-zinc-800" />;
101
+ }
102
+ ```
103
+
104
+ ```tsx title="src/app/posts/[slug]/page.tsx"
105
+ export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
106
+ const { slug } = await params;
107
+
108
+ return <h1>{(await findPost(slug)).title}</h1>;
109
+ }
110
+ ```
111
+
112
+ ### It is hierarchical
113
+
114
+ Like `layout.tsx`, the nearest `loading.tsx` to the page wins, and several in
115
+ one chain stack as nested boundaries:
116
+
117
+ ```text
118
+ src/app/
119
+ layout.tsx
120
+ loading.tsx fallback for everything without a closer one
121
+ docs/
122
+ loading.tsx fallback for /docs/*
123
+ [slug]/
124
+ page.tsx uses docs/loading.tsx
125
+ dashboard/
126
+ page.tsx uses app/loading.tsx
127
+ ```
128
+
129
+ ### A root loading.tsx catches everything, including mistakes
130
+
131
+ It wraps every page, so a page that waits above any boundary of its own is
132
+ still caught and still stored — and the build has nothing to refuse. It looks
133
+ exactly like a page whose boundary is in the right place.
134
+
135
+ So the build says so:
136
+
137
+ ```text
138
+ ◐ /locale
139
+ ⚠ nothing painted without the root loading.tsx — the fallback the whole app
140
+ shares is standing in for this page. Put a boundary where the waiting is.
141
+ ```
142
+
143
+ Worked out by rendering the route a second time without the root fallback: a
144
+ page with a boundary of its own paints immediately, and a page leaning on the
145
+ root paints nothing. Only routes with no closer `loading.tsx` are asked, so it
146
+ costs nothing for the rest.
147
+
148
+ It is a warning rather than an error because the page does work. What it costs
149
+ is shared: every page in the app shows the same fallback while this one waits,
150
+ and the fallback cannot say anything about what is loading. Moving the boundary
151
+ to where the waiting is fixes both.
152
+
153
+ ## When nothing can be frozen
154
+
155
+ If nothing was flushed before the page blocked, there is no shell to store —
156
+ and the build says so rather than filing the route under a category:
157
+
158
+ ```text
159
+ Some routes could not be prerendered:
160
+
161
+ /dashboard — reaches for the host before anything can paint, so there is no shell to store
162
+
163
+ Each one reads request data — params, headers, cookies, or the host —
164
+ above every Suspense boundary, so nothing can paint without it.
165
+
166
+ Put the part that waits inside <Suspense>, or add a loading.tsx beside
167
+ the page, so there is something to store while the rest arrives.
168
+ ```
169
+
170
+ Fixed the same way every time, and there is no way to declare it away. A route
171
+ the build cannot store has its boundary in the wrong place; moving the boundary
172
+ is the fix.
173
+
174
+ ## If the build hangs
175
+
176
+ `RSC_PPR_TIMEOUT_MS` sets it; the default is 2 seconds. Raising it lets slow
177
+ pages be stored whole; lowering it pushes more of them to shells.
178
+
179
+ A page whose data resolves inside the budget is stored **whole**, even one that
180
+ called `fetch()`. If a page must reflect the world at request time, what makes
181
+ it so is reading the request — not a flag.
package/guides/pwa.md ADDED
@@ -0,0 +1,260 @@
1
+ # Progressive web apps
2
+
3
+ > Making the app installable, and adding push notifications and background sync.
4
+
5
+ An installable app is a manifest plus a service worker. The
6
+ [offline guide](/guides/offline/) covers the worker; this covers everything
7
+ that makes the app feel like one the operating system knows about.
8
+
9
+ ## The manifest
10
+
11
+ A service worker makes an app survive a dead network. It does not make a
12
+ browser offer to put it on a home screen — that needs a web app manifest, which
13
+ is a file beside your routes:
14
+
15
+ ```ts title="src/app/manifest.ts"
16
+ import type { WebManifest } from '@rsc-kit/core/manifest-file'
17
+
18
+ export default {
19
+ name: 'Orders',
20
+ shortName: 'Orders',
21
+ themeColor: '#0b0b0c',
22
+ backgroundColor: '#ffffff',
23
+ icons: ['icon-192.png', 'icon-512.png'],
24
+ } satisfies WebManifest
25
+ ```
26
+
27
+ A file rather than a `vite.config.ts` key, and rather than `metadata` in a
28
+ layout. It is not build configuration — it is one more thing the app declares
29
+ about itself, so it lives where the app is, next to `layout.tsx` and
30
+ `not-found.tsx`.
31
+
32
+ Not `metadata` either, and for a reason worth stating: `metadata` is resolved
33
+ per route and can be computed per request. A manifest is one file for the whole
34
+ app and has to exist before anything renders. Putting it in an inheritable,
35
+ dynamic mechanism would invite a question — *can I override it for this route?*
36
+ — whose only honest answer is no.
37
+
38
+ The build reads it, writes `manifest.webmanifest`, and links it from every
39
+ page. **There is no layout to edit** — React hoists the `<link>` and the
40
+ `theme-color` meta into `<head>` from wherever they are rendered, so this works
41
+ on an app that already has its own root layout.
42
+
43
+ It is read at build time, before there is a module graph to evaluate it in, so
44
+ it must be an object literal — not computed, not imported from elsewhere. A
45
+ file that is not gets a build error rather than an app that is quietly not
46
+ installable.
47
+
48
+ Icons are paths in your `public/` directory, and **their sizes are read from
49
+ the filename** — `icon-192.png` and `icon-192x192.png` both mean 192. Declaring
50
+ the size in the config as well would be the same number written twice, and the
51
+ one that drifts is the one nobody looks at.
52
+
53
+ Every icon is marked `any maskable`, because without it Android crops a square
54
+ icon into a circle and takes the corners off whatever was in them.
55
+
56
+ ### The build tells you if it will not work
57
+
58
+ This is the failure worth guarding: a manifest with no icon is *valid*. It
59
+ parses, it links, it sets the theme colour, and no browser ever offers to
60
+ install it. Nothing is wrong and nothing works.
61
+
62
+ ```text
63
+ [rsc-kit] manifest: no icons, so no browser will offer to install this. Add a 192px and a 512px png.
64
+ [rsc-kit] manifest: Orders is installable
65
+ ```
66
+
67
+ A warning rather than a failed build — the manifest still does its other job —
68
+ but it is said out loud, because someone who wrote `manifest: {…}` meant
69
+ installable and would otherwise find out months later.
70
+
71
+ ## Your own worker code
72
+
73
+ Push, notification clicks and background sync are events the generated worker
74
+ does not handle, and there is nowhere to put them in a file that says *do not
75
+ edit*. So it will import yours:
76
+
77
+ ```js title="src/app/sw.js"
78
+ // Plain javascript. The browser evaluates this in a worker scope with no build
79
+ // step in front of it, so what you write is what runs.
80
+
81
+ self.addEventListener('push', (event) => {
82
+ const payload = event.data ? event.data.json() : {}
83
+
84
+ event.waitUntil(
85
+ self.registration.showNotification(payload.title ?? 'Update', {
86
+ body: payload.body,
87
+ data: { url: payload.url ?? '/' },
88
+ }),
89
+ )
90
+ })
91
+
92
+ self.addEventListener('notificationclick', (event) => {
93
+ event.notification.close()
94
+ event.waitUntil(self.clients.openWindow(event.notification.data.url))
95
+ })
96
+ ```
97
+
98
+ The build copies it beside the generated worker and imports it first, so your
99
+ listeners are registered before anything of ours can answer an event. It is
100
+ precached like everything else — a worker whose import fails does not start, and
101
+ then nothing is cached at all.
102
+
103
+ ```text
104
+ [rsc-kit] offline: 32 files precached as rsc-kit-3d9785e4, falling back to /offline, with app/sw.js
105
+ ```
106
+
107
+ There is no option for this. The file is there or it is not.
108
+
109
+ :::caution[`.js`, not `.ts`]
110
+ It is evaluated by the browser, not built. TypeScript would not run.
111
+ :::
112
+
113
+ ## Push notifications
114
+
115
+ Four pieces, and only one of them is ours.
116
+
117
+ ### 1. Keys
118
+
119
+ Push needs a VAPID key pair — the public half identifies your server to the
120
+ browser, the private half signs what you send.
121
+
122
+ ```sh
123
+ npx web-push generate-vapid-keys
124
+ ```
125
+
126
+ Keep the private key wherever you keep secrets. The public one reaches the
127
+ browser, so it can be a plain environment variable.
128
+
129
+ ### 2. Ask, and subscribe
130
+
131
+ ```tsx title="src/components/EnableNotifications.tsx"
132
+ 'use client'
133
+
134
+ export function EnableNotifications() {
135
+ async function enable() {
136
+ if (await Notification.requestPermission() !== 'granted') return
137
+
138
+ const registration = await navigator.serviceWorker.ready
139
+ const subscription = await registration.pushManager.subscribe({
140
+ userVisibleOnly: true,
141
+ applicationServerKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
142
+ })
143
+
144
+ await saveSubscription(subscription.toJSON())
145
+ }
146
+
147
+ return <button onClick={enable}>Enable notifications</button>
148
+ }
149
+ ```
150
+
151
+ **Ask when they have a reason to say yes.** A permission prompt on first load is
152
+ how an app gets denied permanently — the browser remembers a refusal, and there
153
+ is no second chance.
154
+
155
+ ### 3. Store the subscription
156
+
157
+ An ordinary [server action](/guides/server-actions/), so it is one function and
158
+ no endpoint:
159
+
160
+ ```ts title="src/server/push.ts"
161
+ 'use server'
162
+
163
+ import { client } from './client'
164
+
165
+ export const saveSubscription = client.input(subscriptionSchema).handler(
166
+ async ({ input, ctx }) => db.pushSubscriptions.upsert(ctx.user.id, input),
167
+ )
168
+ ```
169
+
170
+ Store it against a **user**, not a session. A subscription outlives the session
171
+ that created it, and that is the point of one.
172
+
173
+ ### 4. Send
174
+
175
+ From wherever the event happens — a queue, a cron, a webhook:
176
+
177
+ ```ts
178
+ import webpush from 'web-push'
179
+
180
+ webpush.setVapidDetails('mailto:you@example.com', PUBLIC_KEY, PRIVATE_KEY)
181
+
182
+ for (const subscription of await db.pushSubscriptions.forUser(userId)) {
183
+ try {
184
+ await webpush.sendNotification(subscription, JSON.stringify({
185
+ title: 'Your order shipped',
186
+ url: '/orders/42',
187
+ }))
188
+ } catch (error) {
189
+ // 404 and 410 mean the subscription is dead — the app was uninstalled, or
190
+ // the browser rotated it. Delete it rather than retrying forever.
191
+ if (error.statusCode === 404 || error.statusCode === 410) {
192
+ await db.pushSubscriptions.remove(subscription.endpoint)
193
+ }
194
+ }
195
+ }
196
+ ```
197
+
198
+ That last branch is the one people skip, and it is why push senders accumulate
199
+ dead endpoints until they are mostly dead endpoints.
200
+
201
+ :::note[Not ours, deliberately]
202
+ This package ships no push helper. Everything above is the web API and
203
+ `web-push`, and the only part we could own — a place to put the listener — is
204
+ `app/sw.js`. A wrapper would be a thin layer over a stable standard, and one
205
+ more thing to keep current.
206
+ :::
207
+
208
+ ## Background sync
209
+
210
+ For work that must happen even if the person closes the tab — a queued message,
211
+ an offline edit.
212
+
213
+ ```js title="src/app/sw.js"
214
+ self.addEventListener('sync', (event) => {
215
+ if (event.tag === 'outbox') event.waitUntil(flushOutbox())
216
+ })
217
+
218
+ async function flushOutbox() {
219
+ const db = await openDB()
220
+
221
+ for (const item of await db.getAll('outbox')) {
222
+ const response = await fetch('/api/messages', {
223
+ method: 'POST',
224
+ headers: { 'Content-Type': 'application/json' },
225
+ body: JSON.stringify(item.body),
226
+ })
227
+
228
+ // Left in the queue on failure, so the browser retries the whole sync
229
+ // later. Removed only once the server has it.
230
+ if (response.ok) await db.delete('outbox', item.id)
231
+ }
232
+ }
233
+ ```
234
+
235
+ Register it from the page after queueing the work:
236
+
237
+ ```ts
238
+ const registration = await navigator.serviceWorker.ready
239
+ await registration.sync.register('outbox')
240
+ ```
241
+
242
+ :::danger[Make the endpoint idempotent]
243
+ The browser decides when a sync runs and may run it more than once — a request
244
+ that succeeded on the server but whose response never arrived gets retried. If
245
+ that posts a message twice, the person sent it twice.
246
+
247
+ Give each queued item an id the client generates, and have the endpoint ignore
248
+ one it has already seen. Nothing else about background sync is hard; this is,
249
+ and no framework can do it for you because only your data model knows what
250
+ "already seen" means.
251
+ :::
252
+
253
+ Support is narrower than the rest of this page — Chromium has it, Safari and
254
+ Firefox do not. Treat it as an optimisation over an ordinary retry, never as the
255
+ only path:
256
+
257
+ ```ts
258
+ if ('sync' in registration) await registration.sync.register('outbox')
259
+ else await flushNow()
260
+ ```