@rsc-kit/mcp 0.28.1 → 0.29.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/index.js +31 -0
- package/dist/index.js.map +1 -1
- package/dist/recipes.js +30 -7
- package/dist/recipes.js.map +1 -1
- package/guides/go.md +35 -4
- package/guides/live-data.md +159 -12
- package/guides/mcp.md +38 -0
- package/guides/testing.md +24 -0
- package/guides/your-own-backend.md +36 -10
- package/package.json +2 -1
package/guides/go.md
CHANGED
|
@@ -272,18 +272,49 @@ reg.Changed(ctx, "team:"+teamID+":repos")
|
|
|
272
272
|
Every open tab showing the section refreshes it, the moment the webhook
|
|
273
273
|
lands, with nothing polling. The registry answers `__rsc.changed` by holding
|
|
274
274
|
the renderer's ask until a version moves - woken at once by a `Changed` in
|
|
275
|
-
this process, checking the store every `rsckit.
|
|
275
|
+
this process, checking the store every `rsckit.ChangedPoll` (1s) for one made
|
|
276
276
|
by another instance.
|
|
277
277
|
|
|
278
|
-
Versions live in a `
|
|
278
|
+
Versions live in a `VersionStore`. The default is in memory, which is one
|
|
279
279
|
instance; with more than one, a webhook lands on whichever instance the
|
|
280
280
|
balancer picked, so give them all the same store:
|
|
281
281
|
|
|
282
282
|
```go
|
|
283
|
-
// CREATE TABLE
|
|
284
|
-
reg.
|
|
283
|
+
// CREATE TABLE rsc_versions (name TEXT PRIMARY KEY, version BIGINT NOT NULL)
|
|
284
|
+
reg.Versions(&rsckit.SQLVersions{DB: db, Placeholder: rsckit.Dollar}) // Postgres; nil Placeholder is "?"
|
|
285
285
|
```
|
|
286
286
|
|
|
287
|
+
To hear another instance's change at once instead of within the second - and
|
|
288
|
+
stop reading the store while nothing changes - set `Notify` so each bump
|
|
289
|
+
sends a `pg_notify`, and run a listener with `WakeOn`. The adapter depends on
|
|
290
|
+
no driver, so the listener is yours; with pgx:
|
|
291
|
+
|
|
292
|
+
```go
|
|
293
|
+
reg.Versions(&rsckit.SQLVersions{DB: db, Placeholder: rsckit.Dollar, Notify: "rsc_versions"})
|
|
294
|
+
|
|
295
|
+
reg.WakeOn(ctx, func(ctx context.Context, connected, wake func()) error {
|
|
296
|
+
conn, err := pool.Acquire(ctx)
|
|
297
|
+
if err != nil {
|
|
298
|
+
return err
|
|
299
|
+
}
|
|
300
|
+
defer conn.Release()
|
|
301
|
+
if _, err := conn.Exec(ctx, "LISTEN rsc_versions"); err != nil {
|
|
302
|
+
return err
|
|
303
|
+
}
|
|
304
|
+
connected()
|
|
305
|
+
for {
|
|
306
|
+
if _, err := conn.Conn().WaitForNotification(ctx); err != nil {
|
|
307
|
+
return err
|
|
308
|
+
}
|
|
309
|
+
wake()
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
A listener that drops runs again with backoff, and the poll covers the gap.
|
|
315
|
+
Through PgBouncer in transaction mode, connect the listener straight to
|
|
316
|
+
Postgres. One instance needs none of this.
|
|
317
|
+
|
|
287
318
|
Anything with a `Bump` and a `Versions` fits. Set `RSC_SIGNING_SECRET` for the renderer, the same on every instance:
|
|
288
319
|
it signs the names a page hands the tab, and is not the host-call secret.
|
|
289
320
|
Generating and rotating both: [Secrets](/hosts/deployment#secrets). [Sections](/guides/live-data)
|
package/guides/live-data.md
CHANGED
|
@@ -59,6 +59,21 @@ import { cookies } from '@rsc-kit/core/request'
|
|
|
59
59
|
export const refreshOn = async () => [`inbox:${(await cookies()).get('agent')?.value}`]
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
A page with url schemas types its `refreshOn` from them, so `params` arrives
|
|
63
|
+
parsed and typed rather than typed by hand:
|
|
64
|
+
|
|
65
|
+
```tsx title="src/app/teams/[team]/page.tsx"
|
|
66
|
+
import type { PageRefreshOn } from '@rsc-kit/core/section'
|
|
67
|
+
|
|
68
|
+
export const params = z.object({ team: z.string() })
|
|
69
|
+
export const refreshOn: PageRefreshOn<typeof params> = ({ params }) => [`team:${params.team}`]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`export const refreshOn`, `export function refreshOn` and a list -
|
|
73
|
+
`export { refreshOn } from './names'` - all count. `export * from` does not:
|
|
74
|
+
the build reads the page, not what it re-exports wholesale, so name it in a
|
|
75
|
+
list.
|
|
76
|
+
|
|
62
77
|
That is the whole feature for an app that is only JavaScript. With a Go or
|
|
63
78
|
Laravel backend the backend says it instead -
|
|
64
79
|
[`reg.Changed(ctx, …)`](/hosts/go#saying-data-changed) or
|
|
@@ -114,6 +129,55 @@ A section that shows anything per visitor - "you", their unread count, what
|
|
|
114
129
|
they may do - must never be `shared`: that is the one place a render is
|
|
115
130
|
handed from one visitor to another.
|
|
116
131
|
|
|
132
|
+
**A change to a child is a change to its parent's list.** Renaming an app
|
|
133
|
+
changes the app's own page and the team's list of apps. Say both where the
|
|
134
|
+
write happens, rather than making either region watch the other's names:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
await renameApp(app.id, name)
|
|
138
|
+
await changed(`app:${app.id}`, `team:${app.teamId}:apps`)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```tsx
|
|
142
|
+
// The app's page: refreshOn = ({ params }) => [`app:${params.app}`]
|
|
143
|
+
// The team's list: refreshOn = ({ team }) => [`team:${team}:apps`]
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Each region names exactly what it shows, and the write - the one place that
|
|
147
|
+
knows what it touched - names everything that did. A region that watched
|
|
148
|
+
every child's name would hand the tab a name per row, and miss a child added
|
|
149
|
+
after it rendered.
|
|
150
|
+
|
|
151
|
+
**Not everything can announce itself.** A host's runtime logs, a third-party
|
|
152
|
+
status page, anything nothing tells you about: there is no write to call
|
|
153
|
+
`changed()` from, so poll it - `usePolling` - and keep `refreshOn` for data
|
|
154
|
+
your own code changes.
|
|
155
|
+
|
|
156
|
+
## Testing what a page watches
|
|
157
|
+
|
|
158
|
+
`createTestApp` renders the page as it is deployed, and `watched()` says what
|
|
159
|
+
each region refreshes on for that url - `page` for the page's own, a
|
|
160
|
+
section's name for each section's:
|
|
161
|
+
|
|
162
|
+
```ts title="tests/live.test.ts"
|
|
163
|
+
import { createTestApp } from '@rsc-kit/core/testing'
|
|
164
|
+
|
|
165
|
+
const app = await createTestApp()
|
|
166
|
+
|
|
167
|
+
test('the team page watches the team and its apps', async () => {
|
|
168
|
+
expect(await app.watched('/teams/acme')).toEqual({
|
|
169
|
+
page: ['team:acme'],
|
|
170
|
+
apps: ['team:acme:apps'],
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
A region whose `refreshOn` gave no names is there with `[]` - and said in the
|
|
176
|
+
server's log in development, since that is usually a param read under the
|
|
177
|
+
wrong name. To check what a change does, say it - `changed()` in an app
|
|
178
|
+
that keeps its own versions, `testChanges()` with a test backend - and fetch
|
|
179
|
+
the section again: [What a page watches](/guides/testing#what-a-page-watches).
|
|
180
|
+
|
|
117
181
|
## How a tab finds out
|
|
118
182
|
|
|
119
183
|
Each name has a version, a number that moves when something says the name
|
|
@@ -436,7 +500,8 @@ there is no single long-lived process:
|
|
|
436
500
|
- **Every isolate or instance watches for its own tabs.** Versions in memory
|
|
437
501
|
belong to one isolate, so a `changed()` handled by one never reaches tabs
|
|
438
502
|
held by another. Use a shared store - D1, Postgres, your backend - which the
|
|
439
|
-
production warning asks for if there is none.
|
|
503
|
+
production warning asks for if there is none. On Workers, [one hub](#one-hub-on-workers)
|
|
504
|
+
can hold every tab instead.
|
|
440
505
|
- **Connections are cut.** Platforms bound how long a request may run; a
|
|
441
506
|
stream that is cut reconnects on its own, and the tab catches up from its
|
|
442
507
|
versions. On a platform that bills a function for every second it is open,
|
|
@@ -492,6 +557,78 @@ The Cloudflare example's `/live` page is all of this, end to end, and was run
|
|
|
492
557
|
on `wrangler dev`: a change from its webhook reaches an open tab, and the
|
|
493
558
|
section refreshes with the new value.
|
|
494
559
|
|
|
560
|
+
### One hub on Workers
|
|
561
|
+
|
|
562
|
+
Each isolate watches only the tabs it happens to hold, and on Workers that is
|
|
563
|
+
often one. So by default every open tab costs its own ask of the store, every
|
|
564
|
+
two seconds, and a `wakeOn` listener runs once per isolate. A Durable Object
|
|
565
|
+
is one place every isolate can reach. Name it, and every tab's stream goes to
|
|
566
|
+
one instance, which watches all of them:
|
|
567
|
+
|
|
568
|
+
```ts title="vite.config.ts"
|
|
569
|
+
// cloudflare_module, plus the Durable Object class the hub runs in.
|
|
570
|
+
nitro({ preset: 'cloudflare_durable', serveStatic: 'inline' })
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
```jsonc title="wrangler.jsonc"
|
|
574
|
+
{
|
|
575
|
+
"durable_objects": {
|
|
576
|
+
"bindings": [{ "name": "$DurableObject", "class_name": "$DurableObject" }]
|
|
577
|
+
},
|
|
578
|
+
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["$DurableObject"] }],
|
|
579
|
+
"vars": { "RSC_CHANGES_HUB": "$DurableObject" }
|
|
580
|
+
}
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
Nothing changes in the app. `RSC_CHANGES_HUB` names the binding, and rsc-kit
|
|
584
|
+
does not look for one on its own: without it, each isolate watches its own
|
|
585
|
+
tabs as before. A name with no binding behind it is said once in the log, and
|
|
586
|
+
the isolate falls back to that.
|
|
587
|
+
|
|
588
|
+
What changes, against each isolate watching its own tabs:
|
|
589
|
+
|
|
590
|
+
| | Each isolate (default) | One hub |
|
|
591
|
+
| --- | --- | --- |
|
|
592
|
+
| Asks of the store or backend | one per open tab, every 2s | one for every tab, every 2s |
|
|
593
|
+
| A `changed()` reaches the tabs | at each isolate's next read, within a second or two | at once: `changed()` wakes the hub |
|
|
594
|
+
| A `wakeOn` listener or broadcast | one connection per isolate | one connection |
|
|
595
|
+
| Cost of a store with no LISTEN (D1) | grows with tabs | flat |
|
|
596
|
+
| A Laravel backend | one PHP request per tab, every 2s | one, for every tab |
|
|
597
|
+
|
|
598
|
+
- **Asks stop growing with tabs.** A thousand open tabs cost the store, or
|
|
599
|
+
your backend, one ask every two seconds instead of a thousand. On a backend
|
|
600
|
+
that answers each ask with a PHP request, this is the difference that
|
|
601
|
+
matters.
|
|
602
|
+
- **Changes arrive at once without LISTEN.** D1 cannot announce a change.
|
|
603
|
+
With the hub, `changed()` in any isolate - a webhook, an action - pokes the
|
|
604
|
+
hub after the write, and every tab hears it immediately instead of at the
|
|
605
|
+
next read.
|
|
606
|
+
- **One listener.** A broadcast server or `wakeOn` listener runs in the hub,
|
|
607
|
+
one connection for the deployment, rather than one per isolate that
|
|
608
|
+
happens to hold a tab.
|
|
609
|
+
|
|
610
|
+
What it costs, and its limits:
|
|
611
|
+
|
|
612
|
+
- **Durable Object time.** The hub stays active while any tab watches, and a
|
|
613
|
+
Durable Object is billed for the time it is active - for one instance open
|
|
614
|
+
all month, a few dollars on Cloudflare's paid plan. With no tab open it
|
|
615
|
+
costs nothing.
|
|
616
|
+
- **One location.** Every tab's stream goes to the one instance, wherever
|
|
617
|
+
Cloudflare placed it. An event crosses one more hop, tens of milliseconds;
|
|
618
|
+
the pages themselves are still rendered by the nearest isolate.
|
|
619
|
+
- **One instance's limits.** A Durable Object runs on one thread, and
|
|
620
|
+
Cloudflare documents a soft limit of about 1,000 requests a second for
|
|
621
|
+
one. A site whose tabs connect faster than that should stay on the
|
|
622
|
+
default flow, which spreads them across isolates.
|
|
623
|
+
- **Versions still live in a shared store.** The hub holds the tabs, not the
|
|
624
|
+
versions: D1, your database or your backend still keeps them, so a hub that
|
|
625
|
+
restarts loses nothing.
|
|
626
|
+
|
|
627
|
+
The forwarded requests carry a proof signed with `RSC_SIGNING_SECRET`, so a
|
|
628
|
+
browser cannot reach the hub's side of the endpoint directly. The Cloudflare
|
|
629
|
+
example runs this way, and was checked on `wrangler dev`: three tabs' streams
|
|
630
|
+
held by the hub, one webhook, and all three hearing the change.
|
|
631
|
+
|
|
495
632
|
Where a response cannot stream at all, use `usePolling` instead.
|
|
496
633
|
|
|
497
634
|
## The table
|
|
@@ -643,17 +780,26 @@ random key per instance would fail silently.
|
|
|
643
780
|
That refusal comes on the first request, because on some platforms - Workers
|
|
644
781
|
- the environment is only there once a request arrives. Under Kubernetes or
|
|
645
782
|
any orchestrator with a readiness check, that is too late: the pod is already
|
|
646
|
-
taking traffic. Make the deploy itself fail instead,
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
783
|
+
taking traffic. Make the deploy itself fail instead, by declaring the secret
|
|
784
|
+
where every other variable is - in `env.ts`, which refuses a missing variable
|
|
785
|
+
before the server starts:
|
|
786
|
+
|
|
787
|
+
```ts title="src/env.ts"
|
|
788
|
+
export const env = createEnv({
|
|
789
|
+
server: {
|
|
790
|
+
// Signs refreshOn's names. Required in production; in development a
|
|
791
|
+
// random key is fine, and what rsc-kit uses without one.
|
|
792
|
+
RSC_SIGNING_SECRET:
|
|
793
|
+
process.env.NODE_ENV === 'production' ? z.string().min(32) : z.string().optional(),
|
|
794
|
+
// ...
|
|
795
|
+
},
|
|
796
|
+
// ...
|
|
797
|
+
})
|
|
798
|
+
```
|
|
651
799
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
``` In development a random key is
|
|
656
|
-
fine, and is what you get.
|
|
800
|
+
An app without an `env.ts` gets the same from `assertSigningSecret()`, from
|
|
801
|
+
`@rsc-kit/core/changed`, called in `register()`. In development a random key
|
|
802
|
+
is fine, and is what you get.
|
|
657
803
|
|
|
658
804
|
How to generate it, where to keep it, and what rotating it does:
|
|
659
805
|
[Secrets](/hosts/deployment#secrets).
|
|
@@ -674,7 +820,8 @@ by region, each time that changes:
|
|
|
674
820
|
```
|
|
675
821
|
|
|
676
822
|
A region that declared `refreshOn` but is missing from that list rendered no
|
|
677
|
-
names, and the server's log says why, once
|
|
823
|
+
names, and the server's log says why, once - including when its function
|
|
824
|
+
returned none at all. And watching **stops while the
|
|
678
825
|
tab is hidden** - a background tab holds no connection - which the console
|
|
679
826
|
also says. Nothing is missed: shown again, the tab reconnects at the versions
|
|
680
827
|
it holds and refreshes whatever moved meanwhile.
|
package/guides/mcp.md
CHANGED
|
@@ -76,6 +76,7 @@ it is on the screen.
|
|
|
76
76
|
|
|
77
77
|
| tool | answers |
|
|
78
78
|
| --- | --- |
|
|
79
|
+
| `rules` | the rules for this app, as the installed version writes them - read first |
|
|
79
80
|
| `list_topics` | every topic it can explain |
|
|
80
81
|
| `how_to` | the short answer: forms, prefetch, validation, the action client, data with TanStack Query or SWR, Suspense, offline, pwa, no-javascript, api routes, authorization, why a page is dynamic |
|
|
81
82
|
| `list_guides` | every guide on this site, one line each |
|
|
@@ -92,6 +93,43 @@ ways that compile either way** — a check written in the component instead of t
|
|
|
92
93
|
action, a hand-parsed query string, `"use server"` at the top of a page. An
|
|
93
94
|
agent that has not been told writes those confidently.
|
|
94
95
|
|
|
96
|
+
## The rules an agent follows
|
|
97
|
+
|
|
98
|
+
`bun create rsc-kit` writes an `AGENTS.md`, and `rsc-kit init` adds a section
|
|
99
|
+
to the one you have. It opens with **Rules** - the things an agent gets wrong
|
|
100
|
+
in ways that compile:
|
|
101
|
+
|
|
102
|
+
- a change is not done without its test, and not until `check` passes;
|
|
103
|
+
- every environment variable is declared in `env.ts` (and `.env.example`) -
|
|
104
|
+
rsc-kit's own included;
|
|
105
|
+
- pages paint at once: each read is an async component under its own
|
|
106
|
+
`<Suspense>`, not a `loading.tsx`;
|
|
107
|
+
- server components call the data function directly, never the app's own api;
|
|
108
|
+
- live data is `refreshOn` with `changed()`, and polling only for what nothing
|
|
109
|
+
can announce;
|
|
110
|
+
- every action and query is built from the action client the scaffold writes
|
|
111
|
+
(`src/server/client.ts`), with authorisation as its `.use()` middleware - or,
|
|
112
|
+
with a Go or Laravel backend, in the backend's own guards;
|
|
113
|
+
- links and redirects are typed template literals; metadata is
|
|
114
|
+
`export const metadata`;
|
|
115
|
+
- generated files are never edited; secrets are generated, never committed;
|
|
116
|
+
- ask `how_to` before reaching for a pattern from another framework.
|
|
117
|
+
|
|
118
|
+
The section is rsc-kit's - between `<!-- rsc-kit:start -->` and
|
|
119
|
+
`<!-- rsc-kit:end -->`, stamped with the version that wrote it - and
|
|
120
|
+
everything around it is yours. When rsc-kit changes, bring it up to date:
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
bunx rsc-kit agents # rewrites the rsc-kit section only; review it in git
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The dev server says when the section is older than the rsc-kit installed. An
|
|
127
|
+
`AGENTS.md` from before the stamp needs `--host` and `--source-dir` once; one
|
|
128
|
+
with no section at all is only rewritten with `--replace`. The `rules` tool
|
|
129
|
+
answers the same rules for the installed version, so an agent that reads it
|
|
130
|
+
first is never working from an outdated file. A Laravel app gets the same
|
|
131
|
+
rules from its Boost skill, in Laravel's terms.
|
|
132
|
+
|
|
95
133
|
## Two things worth knowing
|
|
96
134
|
|
|
97
135
|
**Every answer says how old it is.** It reports the last build, and your files
|
package/guides/testing.md
CHANGED
|
@@ -177,6 +177,29 @@ above. For the round trip itself, and for anything that depends on hydration —
|
|
|
177
177
|
`useField` re-rendering, a navigation, an optimistic update reverting — use
|
|
178
178
|
Playwright against `vite preview`, which serves the real build.
|
|
179
179
|
|
|
180
|
+
## What a page watches
|
|
181
|
+
|
|
182
|
+
`watched()` renders a page and says what each region refreshes on - `page`
|
|
183
|
+
for the page's own `refreshOn`, a section's name for each section's - without
|
|
184
|
+
reading the signed names out of the payload:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
expect(await app.watched('/teams/acme')).toEqual({ page: ['team:acme'], apps: ['team:acme:apps'] })
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
With a Go or Laravel backend stood in by `host`, `testChanges()` answers the
|
|
191
|
+
version questions the way the backend would, and its `changed` says a name
|
|
192
|
+
moved - what the backend's webhook would do:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import { createTestApp, testChanges } from '@rsc-kit/core/testing'
|
|
196
|
+
|
|
197
|
+
const names = testChanges()
|
|
198
|
+
const app = await createTestApp({ host: { ...names.host, 'Repos.list': () => repos } })
|
|
199
|
+
|
|
200
|
+
names.changed('team:1:repos')
|
|
201
|
+
```
|
|
202
|
+
|
|
180
203
|
## What to test where
|
|
181
204
|
|
|
182
205
|
| what | how |
|
|
@@ -186,5 +209,6 @@ Playwright against `vite preview`, which serves the real build.
|
|
|
186
209
|
| an api route | call `GET`/`POST` with a `Request` |
|
|
187
210
|
| anything that reads cookies or headers | `withRequest()` |
|
|
188
211
|
| routing, guards, stored pages, status codes | `createTestApp().fetch()` |
|
|
212
|
+
| what a page refreshes on | `createTestApp().watched()` |
|
|
189
213
|
| a client component | React + a DOM |
|
|
190
214
|
| an action over the wire, hydration, navigation | Playwright |
|
|
@@ -66,6 +66,15 @@ run *as the visitor*: your session middleware reads the cookie and finds the
|
|
|
66
66
|
same person the page is being rendered for. During a build there is no
|
|
67
67
|
visitor and the headers are absent.
|
|
68
68
|
|
|
69
|
+
**Arguments that do not fit the function are a failure, not a refusal.**
|
|
70
|
+
Too few, or a value your language cannot decode into the parameter's type:
|
|
71
|
+
answer 500 with `error`, as Go and Laravel do. The renderer's calls are typed
|
|
72
|
+
from your own manifest, so a mismatch is a bug in a caller - most often a
|
|
73
|
+
manifest older than the code - and a 500 is what makes it show up in your
|
|
74
|
+
logs. Extra arguments may be ignored. Values that decode but break your
|
|
75
|
+
rules - an email that is not one - are the input being invalid: a 422 with
|
|
76
|
+
`validationErrors`, below.
|
|
77
|
+
|
|
69
78
|
**Check the secret first, in constant time, and refuse before dispatch.** This
|
|
70
79
|
endpoint runs functions by name with none of your routing in front of it;
|
|
71
80
|
`hash_equals('', '')` is true in PHP and its equivalents elsewhere, so an
|
|
@@ -89,7 +98,7 @@ message to tell an invalid form from a broken server:
|
|
|
89
98
|
| the input is invalid | 422 | `validationErrors: { "email": ["…"], "address.city": ["…"] }`, `error` |
|
|
90
99
|
| there is no session | 401 | `unauthenticated: true`, `error` |
|
|
91
100
|
| there is one, and still no | 403 | `unauthorized: true`, `error` |
|
|
92
|
-
| go somewhere else | **200** | `redirect: "/login"`,
|
|
101
|
+
| go somewhere else | **200** | `redirect: "/login"`, and `redirectStatus` when you mean something other than 307 |
|
|
93
102
|
| refused, with its own status | that status | `error`, `refusalStatus: 429` (any 4xx or 5xx: a 503 while a dependency is down) |
|
|
94
103
|
| the function failed | 500 | `error`, and in development `debug` |
|
|
95
104
|
|
|
@@ -100,7 +109,7 @@ and under `""` for a message about the form itself. `unauthenticated` and
|
|
|
100
109
|
`ServerAuthorizationError`, so a page answers 401 or 403 the way it would
|
|
101
110
|
have if a JavaScript guard had thrown them. `redirect` travels the path every
|
|
102
111
|
other redirect travels — a real 3xx above a Suspense boundary, a digest below
|
|
103
|
-
one. `revalidate` names [sections](/guides/
|
|
112
|
+
one. `revalidate` names [sections](/guides/live-data) or `page`, and the answer
|
|
104
113
|
to the action carries the re-rendered region with it rather than the browser
|
|
105
114
|
being told to ask again.
|
|
106
115
|
|
|
@@ -108,7 +117,10 @@ Two things that are easy to get wrong:
|
|
|
108
117
|
|
|
109
118
|
**A redirect is a 200.** An HTTP client follows a 3xx transparently, so a real
|
|
110
119
|
one here would send the host call itself to the destination and hand whatever
|
|
111
|
-
it found back to the render as the function's result.
|
|
120
|
+
it found back to the render as the function's result. The redirect's own
|
|
121
|
+
status goes in `redirectStatus`; without it the renderer uses 307, which keeps
|
|
122
|
+
the method. Send it whenever your framework says something else - Laravel's
|
|
123
|
+
redirects are 302, so the Laravel package always sends it.
|
|
112
124
|
|
|
113
125
|
**Refusing is not failing.** A form filled in wrongly is the ordinary case, and
|
|
114
126
|
`validationErrors` is checked before `error` — a reply carrying both is read as
|
|
@@ -169,11 +181,19 @@ whole-batch form every call in it waits for the slowest.
|
|
|
169
181
|
|
|
170
182
|
Either way, run every call and answer every one — a refusal in the second is
|
|
171
183
|
that call's answer, not a reason to leave the third out. Each call's
|
|
172
|
-
`revalidate` stays with that call.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
184
|
+
`revalidate` stays with that call. Batches never mix visitors; every call in
|
|
185
|
+
one carried the same forwarded headers.
|
|
186
|
+
|
|
187
|
+
**At most 50 calls.** The renderer never sends more in one batch; a page with
|
|
188
|
+
more is split. Refusing a larger one - 413, before running anything - is a
|
|
189
|
+
safe limit, and Laravel's.
|
|
190
|
+
|
|
191
|
+
**Batches are not optional.** The renderer always sends calls from the same
|
|
192
|
+
tick together, and an answer that is neither form - whatever its status -
|
|
193
|
+
fails every call in the batch with what your backend said. None is sent
|
|
194
|
+
again one by one: they may have run, and an action run twice is worse than
|
|
195
|
+
one reported as failed. The conformance suite checks batches, so a backend
|
|
196
|
+
that passes it takes them.
|
|
177
197
|
|
|
178
198
|
## Route middleware
|
|
179
199
|
|
|
@@ -222,7 +242,9 @@ an action. Every open tab showing it refreshes, with nothing polling. Two
|
|
|
222
242
|
things to provide:
|
|
223
243
|
|
|
224
244
|
**A way to say a name changed**, callable from anywhere in your backend. It
|
|
225
|
-
moves the name's version
|
|
245
|
+
moves the name's version to the larger of one past where it was and the
|
|
246
|
+
current time in milliseconds - so a version never comes round again, and an
|
|
247
|
+
old name can be deleted at any time - kept somewhere every instance of your
|
|
226
248
|
backend shares - a cache, a table - since a webhook lands on whichever
|
|
227
249
|
instance the balancer picked. A name never changed is at 0.
|
|
228
250
|
|
|
@@ -236,7 +258,11 @@ how long it would wait:
|
|
|
236
258
|
Answer `{ "result": { "versions": { ... } } }` with every name in `since`
|
|
237
259
|
whose version differs now, and nothing else - an empty object when none
|
|
238
260
|
does. If you can, hold the call up to `wait` milliseconds for one to move
|
|
239
|
-
and answer the moment it does: that is what makes the refresh immediate.
|
|
261
|
+
and answer the moment it does: that is what makes the refresh immediate.
|
|
262
|
+
The renderer asks with 5 seconds; hold no longer than `wait`, and never past
|
|
263
|
+
30 seconds, where the renderer gives up on any call. A name you have never
|
|
264
|
+
seen is at 0, compared like any other: held while the asker also holds 0,
|
|
265
|
+
answered the moment it is changed. If
|
|
240
266
|
you cannot - PHP-FPM, a worker that must answer and go - answer at once; the
|
|
241
267
|
renderer asks again on its interval, a couple of seconds apart. Both are
|
|
242
268
|
conformant, and the page is the same.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rsc-kit/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
37
|
+
"create-rsc-kit": "^0.29.0",
|
|
37
38
|
"zod": "^3.25"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|