@rsc-kit/mcp 0.13.1 → 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.
- package/dist/answers.d.ts +7 -0
- package/dist/answers.js +26 -0
- package/dist/answers.js.map +1 -1
- package/dist/bundleGuides.d.ts +22 -0
- package/dist/bundleGuides.js +130 -0
- package/dist/bundleGuides.js.map +1 -0
- package/dist/index.js +21 -1
- package/dist/index.js.map +1 -1
- package/dist/recipes.js +239 -0
- package/dist/recipes.js.map +1 -1
- package/dist/report.d.ts +11 -0
- package/dist/report.js +1 -1
- package/dist/report.js.map +1 -1
- package/guides/api-routes.md +168 -0
- package/guides/authorization.md +288 -0
- package/guides/caching.md +57 -0
- package/guides/connection.md +98 -0
- package/guides/edge-caching.md +159 -0
- package/guides/errors.md +109 -0
- package/guides/file-uploads.md +119 -0
- package/guides/fonts.md +117 -0
- package/guides/forms.md +528 -0
- package/guides/images.md +83 -0
- package/guides/index.json +162 -0
- package/guides/mcp.md +113 -0
- package/guides/metadata.md +289 -0
- package/guides/navigation.md +84 -0
- package/guides/no-javascript.md +39 -0
- package/guides/offline.md +215 -0
- package/guides/ppr.md +181 -0
- package/guides/pwa.md +260 -0
- package/guides/queries.md +340 -0
- package/guides/react-compiler.md +153 -0
- package/guides/redirects.md +143 -0
- package/guides/response-headers.md +66 -0
- package/guides/route-interception.md +206 -0
- package/guides/routing.md +458 -0
- package/guides/sections.md +74 -0
- package/guides/server-actions.md +444 -0
- package/guides/static-generation.md +347 -0
- package/guides/testing.md +158 -0
- package/guides/third-party-scripts.md +105 -0
- package/guides/typed-routes.md +139 -0
- package/guides/url-validation.md +143 -0
- package/guides/validation.md +175 -0
- package/guides/view-transitions.md +120 -0
- 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
|
+
```
|