@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,288 @@
|
|
|
1
|
+
# Authorization
|
|
2
|
+
|
|
3
|
+
> Protecting pages, server actions and API routes.
|
|
4
|
+
|
|
5
|
+
There are three ways into your app — a page, a server action, an API route —
|
|
6
|
+
and each needs its own check. Guarding one does not guard the others.
|
|
7
|
+
|
|
8
|
+
## Protect a page
|
|
9
|
+
|
|
10
|
+
Put a `middleware.ts` in the directory you want to protect:
|
|
11
|
+
|
|
12
|
+
```ts title="src/app/admin/middleware.ts"
|
|
13
|
+
import { redirect } from '@rsc-kit/core/redirect';
|
|
14
|
+
import { currentUser } from '../../auth';
|
|
15
|
+
|
|
16
|
+
export default async function middleware() {
|
|
17
|
+
const user = await currentUser();
|
|
18
|
+
|
|
19
|
+
if (!user?.isAdmin) redirect('/login');
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
It runs before anything in that directory or below it renders. Return nothing
|
|
24
|
+
to allow; redirect or throw to refuse.
|
|
25
|
+
|
|
26
|
+
Middleware compose up the tree like layouts — outermost first — and **every**
|
|
27
|
+
request runs the whole chain: a full page load, a navigation, a prefetch, a
|
|
28
|
+
revalidation. There is no flag to remember; the file is the declaration.
|
|
29
|
+
|
|
30
|
+
<Aside type="caution" title="Do not put the check in a layout">
|
|
31
|
+
It looks like it works, and it does on a full page load. But a navigation
|
|
32
|
+
tells the server which layouts the browser already has, and the server skips
|
|
33
|
+
them — that is what makes navigation fast. A request can claim to have your
|
|
34
|
+
layout, and then the check never runs.
|
|
35
|
+
|
|
36
|
+
`middleware.ts` is never skipped.
|
|
37
|
+
</Aside>
|
|
38
|
+
|
|
39
|
+
## Protect a server action
|
|
40
|
+
|
|
41
|
+
An action is a public endpoint. Anyone can call it directly:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
curl -X POST /_rsc/action \
|
|
45
|
+
-H 'X-RSC-Action: 0339292364be#placeOrder' \
|
|
46
|
+
-H 'X-RSC-Content-Type: text/plain;charset=UTF-8' \
|
|
47
|
+
--data-binary '["a rubber duck"]'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
That is not a hole to plug — an action *is* an RPC endpoint and its id is not a
|
|
51
|
+
secret. It means the check goes **inside the action**, not in the component that
|
|
52
|
+
renders the button:
|
|
53
|
+
|
|
54
|
+
```ts title="src/actions.ts"
|
|
55
|
+
'use server'
|
|
56
|
+
|
|
57
|
+
import { currentUser } from './auth';
|
|
58
|
+
|
|
59
|
+
export async function placeOrder(item: string) {
|
|
60
|
+
const user = await currentUser();
|
|
61
|
+
|
|
62
|
+
if (!user) throw new Error('Not signed in');
|
|
63
|
+
|
|
64
|
+
// …
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
<Aside type="caution" title="Middleware does not cover actions">
|
|
69
|
+
Middleware runs when a route renders. An action renders no route, so none of
|
|
70
|
+
them run. Each entry point defends itself.
|
|
71
|
+
</Aside>
|
|
72
|
+
|
|
73
|
+
### Write the check once
|
|
74
|
+
|
|
75
|
+
One action is fine. Twenty is twenty chances to forget, and the one you forget
|
|
76
|
+
is the one that matters. Put the check on a client and build every action from
|
|
77
|
+
it:
|
|
78
|
+
|
|
79
|
+
```ts title="src/server/client.ts"
|
|
80
|
+
'use server'
|
|
81
|
+
|
|
82
|
+
import { createActionClient } from '@rsc-kit/core/action'
|
|
83
|
+
|
|
84
|
+
export const client = createActionClient()
|
|
85
|
+
.use(async ({ next }) => {
|
|
86
|
+
const user = await currentUser()
|
|
87
|
+
|
|
88
|
+
if (!user) throw new ServerAuthenticationError()
|
|
89
|
+
|
|
90
|
+
return next({ ctx: { user } })
|
|
91
|
+
})
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```ts title="src/server/orders.ts"
|
|
95
|
+
'use server'
|
|
96
|
+
|
|
97
|
+
import { client } from './client'
|
|
98
|
+
|
|
99
|
+
export const placeOrder = client.input(schema).handler(async ({ input, ctx }) =>
|
|
100
|
+
orders.create(ctx.user.id, input),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
export const listOrders = client.query(async ({ ctx }) => orders.forUser(ctx.user.id))
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`ctx.user` is typed and non-null inside the handler, because the only way to get
|
|
107
|
+
there was through the middleware that put it in. **An action cannot be added
|
|
108
|
+
without the check** — not because a rule says so, but because there is no other
|
|
109
|
+
constructor to reach for.
|
|
110
|
+
|
|
111
|
+
`.handler()` makes an action, `.query()` makes a [read](/guides/queries/), and
|
|
112
|
+
both run the same chain. Add a second `.use()` for a role check and it applies
|
|
113
|
+
to everything built from that client:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
export const admin = client.use(async ({ ctx, next }) => {
|
|
117
|
+
if (!ctx.user.isAdmin) throw new ServerAuthorizationError()
|
|
118
|
+
|
|
119
|
+
return next({ ctx })
|
|
120
|
+
})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Authorise on **identity, not arguments**. `cancelOrder(id)` that trusts the id
|
|
124
|
+
is the whole of an IDOR — the caller chooses the id, so the handler has to check
|
|
125
|
+
the row belongs to `ctx.user`.
|
|
126
|
+
|
|
127
|
+
## Protect an API route
|
|
128
|
+
|
|
129
|
+
A [route](/guides/api-routes/) runs the same middleware a page in that directory
|
|
130
|
+
would. Put it under a guarded path and it is guarded:
|
|
131
|
+
|
|
132
|
+
```text
|
|
133
|
+
src/app/admin/
|
|
134
|
+
middleware.ts ← guards everything below
|
|
135
|
+
page.tsx ← guarded
|
|
136
|
+
api/export/route.ts ← guarded too
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
A refused route answers **401** or **403** rather than redirecting, and names
|
|
140
|
+
the destination in `X-RSC-Redirect` if the middleware wanted one. A `fetch`
|
|
141
|
+
would follow a redirect and hand back a login page as though it were your data.
|
|
142
|
+
|
|
143
|
+
For a route with no middleware above it, check inside the handler:
|
|
144
|
+
|
|
145
|
+
```ts title="src/app/api/orders/route.ts"
|
|
146
|
+
import { currentUser } from '../../../auth';
|
|
147
|
+
|
|
148
|
+
export async function GET(): Promise<Response> {
|
|
149
|
+
const user = await currentUser();
|
|
150
|
+
|
|
151
|
+
if (!user) return new Response('Unauthorized', { status: 401 });
|
|
152
|
+
|
|
153
|
+
return Response.json(await orders(user.id));
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Read the session
|
|
158
|
+
|
|
159
|
+
There is no request object. `headers()` and `cookies()` read the one in flight:
|
|
160
|
+
|
|
161
|
+
```ts title="src/app/[locale]/middleware.ts"
|
|
162
|
+
import { redirect } from '@rsc-kit/core/redirect';
|
|
163
|
+
import { cookies, headers } from '@rsc-kit/core/request';
|
|
164
|
+
|
|
165
|
+
export default async function middleware() {
|
|
166
|
+
const jar = await cookies();
|
|
167
|
+
const locale = jar.get('locale') ?? negotiate((await headers()).get('accept-language'));
|
|
168
|
+
|
|
169
|
+
if (!locale) redirect('/en');
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
They work anywhere a request is in flight — middleware, server components,
|
|
174
|
+
actions, API routes — so whatever you already use for locale, feature flags or
|
|
175
|
+
tenants works inside a plain async function. `request()` gives you the whole
|
|
176
|
+
`Request` for anything the two do not cover.
|
|
177
|
+
|
|
178
|
+
They are async for a reason worth knowing: at build time there is no request, so
|
|
179
|
+
a read *suspends*. React freezes the shell above it and only the part that
|
|
180
|
+
wanted a header renders per visitor. A synchronous read would force the whole
|
|
181
|
+
page to re-render for everyone.
|
|
182
|
+
|
|
183
|
+
### Ask once
|
|
184
|
+
|
|
185
|
+
Middleware wants to know who you are; the layout wants their name; the page
|
|
186
|
+
wants their permissions. Wrap the lookup in `cache()` and that is one query:
|
|
187
|
+
|
|
188
|
+
```ts title="src/session.ts"
|
|
189
|
+
import { cache } from '@rsc-kit/core/cache';
|
|
190
|
+
|
|
191
|
+
export const currentUser = cache(async () => {
|
|
192
|
+
const id = await sessionId();
|
|
193
|
+
|
|
194
|
+
return db.user(id);
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The scope is one request. Two requests never see each other's answers, and
|
|
199
|
+
nothing survives between them. Outside a request it just calls through, so
|
|
200
|
+
shared code does not need to know where it is running.
|
|
201
|
+
|
|
202
|
+
## Set a cookie
|
|
203
|
+
|
|
204
|
+
Middleware runs before the response exists, which makes it the place to put a
|
|
205
|
+
header or a cookie on it:
|
|
206
|
+
|
|
207
|
+
```ts title="src/app/account/middleware.ts"
|
|
208
|
+
import { cookies, responseHeaders } from '@rsc-kit/core/request'
|
|
209
|
+
|
|
210
|
+
// Middleware runs before anything below it renders, which is also before the
|
|
211
|
+
// host has built a response — so this is the one place left where a header or a
|
|
212
|
+
// cookie can still be put on it. A component runs after, while the response is
|
|
213
|
+
// already streaming, and writing from there throws rather than being dropped.
|
|
214
|
+
//
|
|
215
|
+
// The page below is frozen at build time and stays frozen: what is written here
|
|
216
|
+
// is per request, so neither costs the other anything.
|
|
217
|
+
export default async function middleware() {
|
|
218
|
+
responseHeaders().set('X-Account-Section', 'yes')
|
|
219
|
+
|
|
220
|
+
const jar = await cookies()
|
|
221
|
+
|
|
222
|
+
if (!jar.get('seen-account')) {
|
|
223
|
+
jar.set('seen-account', new Date().toISOString(), { httpOnly: true, sameSite: 'lax' })
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Actions can write too, which is the case that matters — signing someone in is a
|
|
229
|
+
mutation that has to leave a cookie behind:
|
|
230
|
+
|
|
231
|
+
```ts title="src/app/login/actions.ts"
|
|
232
|
+
'use server';
|
|
233
|
+
|
|
234
|
+
import { action } from '@rsc-kit/core/action';
|
|
235
|
+
import { cookies } from '@rsc-kit/core/request';
|
|
236
|
+
|
|
237
|
+
export const login = action.input(credentials).handler(async ({ input }) => {
|
|
238
|
+
const session = await authenticate(input);
|
|
239
|
+
|
|
240
|
+
(await cookies()).set('session', session.token, {
|
|
241
|
+
httpOnly: true,
|
|
242
|
+
secure: true,
|
|
243
|
+
sameSite: 'lax',
|
|
244
|
+
maxAge: 60 * 60 * 24 * 7,
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`get`, `set` and `delete` over the request in flight is the whole surface an
|
|
250
|
+
auth library needs, so you can wire in whichever one you use.
|
|
251
|
+
|
|
252
|
+
<Aside type="caution" title="A component is too late">
|
|
253
|
+
By the time a page renders, the status and headers are already on the wire.
|
|
254
|
+
Writing from a component throws and tells you to move it to middleware —
|
|
255
|
+
rather than accepting the call and quietly dropping it.
|
|
256
|
+
</Aside>
|
|
257
|
+
|
|
258
|
+
## A guarded page can still be frozen
|
|
259
|
+
|
|
260
|
+
Whether the content is the same for everyone, and whether *you* may see it, are
|
|
261
|
+
different questions. The build answers the first; middleware answers the second,
|
|
262
|
+
per request. So an internal page whose bytes never vary is frozen at build time
|
|
263
|
+
and the middleware decides who gets the file:
|
|
264
|
+
|
|
265
|
+
| Route | Navigation |
|
|
266
|
+
| --- | --- |
|
|
267
|
+
| Guarded and frozen — check, then serve from disk | 6.7 ms |
|
|
268
|
+
| Guarded, rendered on demand | 2626.7 ms |
|
|
269
|
+
| Unguarded, frozen | 0.6 ms |
|
|
270
|
+
|
|
271
|
+
The check runs before the file is read, so a refusal never touches it.
|
|
272
|
+
|
|
273
|
+
<Aside type="caution" title="Two places this does not hold">
|
|
274
|
+
**A static export cannot guard anything.** A static host serves files without
|
|
275
|
+
running code, so exporting a guarded route publishes it to whoever asks.
|
|
276
|
+
|
|
277
|
+
**Do not put a guarded page in a shared cache.** Your origin runs the check; a
|
|
278
|
+
CDN holding the response serves it to the next caller without asking.
|
|
279
|
+
</Aside>
|
|
280
|
+
|
|
281
|
+
## Where each check goes
|
|
282
|
+
|
|
283
|
+
| Question | Where it goes |
|
|
284
|
+
| --- | --- |
|
|
285
|
+
| May this person see this section? | `middleware.ts` in its directory |
|
|
286
|
+
| May this caller run this action? | Inside the action |
|
|
287
|
+
| May this caller use this endpoint? | Inside the route handler |
|
|
288
|
+
| Should the page show different things to different people? | The page — it renders per request anyway |
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Asking once per request
|
|
2
|
+
|
|
3
|
+
> cache() — one lookup, however many places need it.
|
|
4
|
+
|
|
5
|
+
Middleware checks who you are. The layout wants their name. The page wants their
|
|
6
|
+
permissions. That is three calls and one answer.
|
|
7
|
+
|
|
8
|
+
Wrap the lookup:
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { cache } from '@rsc-kit/core/cache'
|
|
12
|
+
|
|
13
|
+
export const currentUser = cache(async () => db.user(await sessionId()))
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Now call it wherever you need it. The first call runs; the rest get the same
|
|
17
|
+
answer:
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
export async function middleware() {
|
|
21
|
+
if (!(await currentUser())) redirect('/login')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default async function Page() {
|
|
25
|
+
const user = await currentUser() // already resolved
|
|
26
|
+
|
|
27
|
+
return <h1>Hello {user.name}</h1>
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## One request, and no further
|
|
32
|
+
|
|
33
|
+
Two requests in flight never see each other's answers, and nothing survives into
|
|
34
|
+
the next one. The scope opens when the request arrives and is torn down with it.
|
|
35
|
+
|
|
36
|
+
That is the whole safety story: a table that outlived its request would not be a
|
|
37
|
+
stale cache, it would be one visitor seeing another's data.
|
|
38
|
+
|
|
39
|
+
## Arguments
|
|
40
|
+
|
|
41
|
+
Compared the way React compares them — primitives by value, objects by identity:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
const post = cache(async (id: string) => db.post(id))
|
|
45
|
+
|
|
46
|
+
post('a') // runs
|
|
47
|
+
post('a') // reuses the first
|
|
48
|
+
post('b') // runs
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Two objects that look the same are two different calls, so pass an id rather
|
|
52
|
+
than an object when you want the reuse.
|
|
53
|
+
|
|
54
|
+
<Aside type="note" title="Not React's cache()">
|
|
55
|
+
Same idea, wider scope. Middleware runs before any component does, which is
|
|
56
|
+
exactly where the duplicate lookups start — and React has no scope open yet.
|
|
57
|
+
</Aside>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Rendering per request
|
|
2
|
+
|
|
3
|
+
> Marking work that belongs to the visitor, not to the build.
|
|
4
|
+
|
|
5
|
+
Most pages can be rendered once, at build time, and served as files. Some work
|
|
6
|
+
cannot: a query whose database the build machine cannot reach, or a value that
|
|
7
|
+
has to differ per visitor.
|
|
8
|
+
|
|
9
|
+
`connection()` marks that work.
|
|
10
|
+
|
|
11
|
+
```tsx title="src/app/orders/page.tsx"
|
|
12
|
+
import { connection } from '@rsc-kit/core/request'
|
|
13
|
+
|
|
14
|
+
export default async function OrdersPage() {
|
|
15
|
+
await connection()
|
|
16
|
+
|
|
17
|
+
const rows = await db.query('select * from orders')
|
|
18
|
+
|
|
19
|
+
return <ul>{rows.map((r) => <li key={r.id}>{r.reference}</li>)}</ul>
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
At build time it never resolves, so **nothing after it runs** — the query is not
|
|
24
|
+
made, and the build needs no database. At request time it resolves immediately
|
|
25
|
+
and the component runs normally.
|
|
26
|
+
|
|
27
|
+
## Call it once
|
|
28
|
+
|
|
29
|
+
This is the mistake worth avoiding:
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
await connection()
|
|
33
|
+
|
|
34
|
+
const orders = await db.orders()
|
|
35
|
+
await connection() // ← does nothing
|
|
36
|
+
const customers = await db.customers()
|
|
37
|
+
await connection() // ← does nothing
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
It is a **barrier, not a wrapper**. Everything after it in that component
|
|
41
|
+
belongs to the request, however many calls that turns out to be. The second and
|
|
42
|
+
third calls are already covered by the first.
|
|
43
|
+
|
|
44
|
+
One call, as early as the work begins. That is the whole API.
|
|
45
|
+
|
|
46
|
+
## It needs a boundary above it
|
|
47
|
+
|
|
48
|
+
The build stores what it *can* paint. If `connection()` is reached before
|
|
49
|
+
anything has painted, there is nothing to store:
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
export default async function Page() {
|
|
53
|
+
await connection() // nothing above it has rendered
|
|
54
|
+
|
|
55
|
+
return <h1>{await something()}</h1>
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Give it a boundary and the page becomes a shell — the layout and headings are
|
|
60
|
+
stored, and the marked part arrives per request:
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
export default function Page() {
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
<h1>Orders</h1>
|
|
67
|
+
<Suspense fallback={<p>Loading orders…</p>}>
|
|
68
|
+
<Orders /> {/* calls connection() inside */}
|
|
69
|
+
</Suspense>
|
|
70
|
+
</>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
A `loading.tsx` beside the page does the same thing for everything below it.
|
|
76
|
+
Without either, the build refuses the route rather than storing a blank page,
|
|
77
|
+
and tells you which one to add.
|
|
78
|
+
|
|
79
|
+
## When you do not need it
|
|
80
|
+
|
|
81
|
+
A query the build **can** run, whose answer is the same for every visitor, wants
|
|
82
|
+
none of this. Let it freeze — that is the whole benefit of prerendering, and
|
|
83
|
+
marking it per-request gives every visitor a render they did not need.
|
|
84
|
+
|
|
85
|
+
Use it when the build genuinely should not do the work:
|
|
86
|
+
|
|
87
|
+
- the database or API is not reachable from the build machine
|
|
88
|
+
- the value must differ per visitor
|
|
89
|
+
- the data changes faster than you deploy
|
|
90
|
+
|
|
91
|
+
## Same as Next.js
|
|
92
|
+
|
|
93
|
+
Same name and same behaviour as Next's `connection()`, so there is nothing new
|
|
94
|
+
to learn if you have used it.
|
|
95
|
+
|
|
96
|
+
You will reach for it less often here, though. The build classifies routes by
|
|
97
|
+
rendering them rather than asking you to declare, so most dynamic pages are
|
|
98
|
+
already understood without a marker.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Serving shells from a CDN
|
|
2
|
+
|
|
3
|
+
> Putting build-time shells on the edge, and what rsc-kit does not do.
|
|
4
|
+
|
|
5
|
+
A prerendered page is a file. So is a PPR shell. Both can sit on a CDN and be
|
|
6
|
+
served without touching your origin.
|
|
7
|
+
|
|
8
|
+
## What the build gives you
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
build/static/
|
|
12
|
+
about.html a whole page, frozen
|
|
13
|
+
posts/hello.html likewise
|
|
14
|
+
posts/[slug].ppr.html a shell — static parts frozen, holes still empty
|
|
15
|
+
about.flight the payload for a client-side navigation
|
|
16
|
+
about.seg1.flight the same, for a client already holding one layout
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`.html` is a finished page. `.ppr.html` is a shell: the layout, nav and
|
|
20
|
+
everything outside a `<Suspense>` boundary, with the fallbacks still in place.
|
|
21
|
+
Neither contains per-visitor data — they were rendered at build time, in no
|
|
22
|
+
request's context, which is what makes them safe for a shared cache.
|
|
23
|
+
|
|
24
|
+
## Caching them
|
|
25
|
+
|
|
26
|
+
The host already sends the right header:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
Cache-Control: public, max-age=0, must-revalidate
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`public` says a shared cache may store it. `max-age=0, must-revalidate` says
|
|
33
|
+
**check with the origin first**. So a CDN in front of this holds the bytes but
|
|
34
|
+
still asks every time — which saves bandwidth and nothing else.
|
|
35
|
+
|
|
36
|
+
To actually serve from the edge, add a cache rule for the paths you want held,
|
|
37
|
+
and give the CDN an edge TTL. On Cloudflare that is a Cache Rule with *Edge TTL
|
|
38
|
+
→ Override origin*.
|
|
39
|
+
|
|
40
|
+
**Do not blanket-cache the whole site.** A route with middleware is deliberately
|
|
41
|
+
sent as:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
Cache-Control: private, no-store
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
because middleware runs per visitor — that is a page whose content depends on
|
|
48
|
+
who asked. A zone-wide "cache everything" rule overrides that and serves one
|
|
49
|
+
person's gated page to everyone. Scope the rule to the paths you know are
|
|
50
|
+
static.
|
|
51
|
+
|
|
52
|
+
Deployments are handled for you: cached responses carry a build version, so a
|
|
53
|
+
new deploy does not leave old shells being served against a new payload.
|
|
54
|
+
|
|
55
|
+
## Finishing a shell at the edge
|
|
56
|
+
|
|
57
|
+
A shell has holes in it, and something has to fill them. That happens at your
|
|
58
|
+
origin, into the same response — the shell is written first, then the
|
|
59
|
+
boundaries it could not finish.
|
|
60
|
+
|
|
61
|
+
You get this with no configuration: request a PPR route and the document you
|
|
62
|
+
receive already contains its dynamic content.
|
|
63
|
+
|
|
64
|
+
A small inline script from React moves each hole into place as the HTML parses.
|
|
65
|
+
So the content appears without waiting for the app bundle or for hydration — on
|
|
66
|
+
a slow connection, the difference between a spinner and a page. It also means
|
|
67
|
+
the content is in the HTML a crawler reads.
|
|
68
|
+
|
|
69
|
+
This is not the same as working without JavaScript: with scripting off, the
|
|
70
|
+
fallbacks stay.
|
|
71
|
+
|
|
72
|
+
To serve the shell itself from a CDN, two endpoints exist for an edge worker:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
GET /_rsc/ppr-shell?url=/dashboard the build-time shell, cacheable
|
|
76
|
+
POST /_rsc/ppr-resume?url=/dashboard the holes, for this visitor
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
There is a complete Cloudflare implementation in
|
|
80
|
+
[`examples/cloudflare-ppr-worker`](https://github.com/rsc-kit/rsc-kit/tree/main/examples/cloudflare-ppr-worker),
|
|
81
|
+
with no KV and no build step. The cache fills itself from the shell endpoint: a
|
|
82
|
+
miss goes to the origin while the shell warms behind it, and a hit streams the
|
|
83
|
+
shell then pipes the resumed holes onto the same response.
|
|
84
|
+
|
|
85
|
+
### What the response looks like on the wire
|
|
86
|
+
|
|
87
|
+
Measured on a deployed worker, for a page whose hole takes 2.5 s:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
headers 55 ms
|
|
91
|
+
first body byte 56 ms
|
|
92
|
+
shell heading 56 ms ← the page is on screen here
|
|
93
|
+
fallback markup 56 ms
|
|
94
|
+
hole content 2549 ms ← same response, no second request
|
|
95
|
+
stream complete 2549 ms
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Before this, the same document finished in 2 ms and contained no hole at all —
|
|
99
|
+
the content arrived later, on a separate payload fetch, after React had
|
|
100
|
+
hydrated.
|
|
101
|
+
|
|
102
|
+
**The trade is that the response stays open until the holes finish.** Previously
|
|
103
|
+
the document closed immediately and `load` fired early; now it fires when the
|
|
104
|
+
slowest boundary resolves. Nothing a visitor sees is slower — the shell paints
|
|
105
|
+
at the same moment either way — but page-level metrics that key on `load` will
|
|
106
|
+
read differently, and a proxy with a short response timeout needs to allow for
|
|
107
|
+
the whole render rather than just the shell.
|
|
108
|
+
|
|
109
|
+
## Guarded routes are never cached
|
|
110
|
+
|
|
111
|
+
The shell endpoint answers `404` for any route that declares middleware. Such a
|
|
112
|
+
page is not cacheable by a shared cache at all, so it never becomes a cache
|
|
113
|
+
entry — refused at the source rather than checked at the edge.
|
|
114
|
+
|
|
115
|
+
The resume endpoint runs that route's middleware against **the caller's own
|
|
116
|
+
cookies**, and refuses before rendering anything. An edge worker must therefore
|
|
117
|
+
forward the visitor's request rather than making one of its own; a resume asked
|
|
118
|
+
for with no cookies is an anonymous visitor and gets an anonymous answer.
|
|
119
|
+
|
|
120
|
+
## What stays on your origin
|
|
121
|
+
|
|
122
|
+
Next's PPR protocol hands the postponed blob to the CDN and takes it back on the
|
|
123
|
+
resume, which means the resume endpoint parses something an attacker can write.
|
|
124
|
+
That is the shape of a known denial-of-service against it.
|
|
125
|
+
|
|
126
|
+
Here the endpoint takes a **url**. The origin reads its own state from disk, and
|
|
127
|
+
a body posted to it is ignored. This is only possible because — unlike a generic
|
|
128
|
+
CDN — the origin already has the artifact, so there is nothing to hand out and
|
|
129
|
+
take back.
|
|
130
|
+
|
|
131
|
+
## When a CDN owns the response
|
|
132
|
+
|
|
133
|
+
Everything above rests on one fact: on this host, a per-visitor response head
|
|
134
|
+
can only come from middleware. `responseHeaders()` and `cookies().set()` throw
|
|
135
|
+
outside it, so a route that declares no middleware has no way to acquire one —
|
|
136
|
+
which is why "declares middleware" is a safe answer to "is this cacheable".
|
|
137
|
+
|
|
138
|
+
An auth proxy in front of the app breaks that, and quietly. One with sliding
|
|
139
|
+
expiry re-issues the session on an ordinary `200`, so the response leaving your
|
|
140
|
+
CDN carries a `Set-Cookie` this host never sent. Marked `public`, that is one
|
|
141
|
+
visitor's session handed to the next.
|
|
142
|
+
|
|
143
|
+
**Give any path your proxy covers a `middleware.ts`**, even an empty one. That
|
|
144
|
+
is what makes a route covered here, and covered routes are excluded from every
|
|
145
|
+
cache decision: `private, no-store` on the page, and refused outright by the
|
|
146
|
+
shell endpoint.
|
|
147
|
+
|
|
148
|
+
Two things worth knowing about this failure if you go looking for it.
|
|
149
|
+
|
|
150
|
+
You cannot detect this by inspecting the response. Nothing added a
|
|
151
|
+
`Vary: Cookie`, and looking for `Set-Cookie` only catches the cookie version —
|
|
152
|
+
an `x-user-id`, a CSRF token or a locale header leaks the same way.
|
|
153
|
+
|
|
154
|
+
The reliable question is structural: who owns the response head on this route?
|
|
155
|
+
|
|
156
|
+
It fails both ways. The loud one is a session leaking to the next visitor. The
|
|
157
|
+
quiet one is the reverse — an anonymous response cached first, then served to
|
|
158
|
+
someone who should have had a session, silently signing them out. The first gets
|
|
159
|
+
reported; the second looks like a flaky login.
|