@rsc-kit/mcp 0.26.0 → 0.27.1
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/recipes.js +92 -0
- package/dist/recipes.js.map +1 -1
- package/guides/api-routes.md +4 -2
- package/guides/deployment.md +69 -0
- package/guides/go.md +4 -2
- package/guides/laravel.md +25 -1
- package/guides/sections.md +444 -40
- package/guides/your-own-backend.md +1 -1
- package/package.json +1 -1
package/guides/api-routes.md
CHANGED
|
@@ -215,8 +215,10 @@ export const GET = events(async function* ({ params, signal }) {
|
|
|
215
215
|
|
|
216
216
|
An ordinary `route.ts`, so everything above applies: it lives beside its
|
|
217
217
|
pages, runs the `middleware.ts` above it, takes the same params. `events()`
|
|
218
|
-
frames each yield as JSON, sends a keepalive
|
|
219
|
-
stream
|
|
218
|
+
frames each yield as JSON, sends a keepalive every eight seconds so nothing in
|
|
219
|
+
front drops an idle stream ([`RSC_STREAM_KEEPALIVE_MS`](/hosts/deployment#streams-and-idle-timeouts)
|
|
220
|
+
to change it), sets `text/event-stream` and `no-store, no-transform`, is never
|
|
221
|
+
compressed, and ends the generator when
|
|
220
222
|
the browser goes away — that is what `signal` is for. `named(event, data, {
|
|
221
223
|
id })` names a message or gives it an id, for a client that listens by name or
|
|
222
224
|
resumes after a reconnect.
|
package/guides/deployment.md
CHANGED
|
@@ -160,6 +160,75 @@ names the stylesheet and the entry. Nothing to configure, and nothing it
|
|
|
160
160
|
replaces: the stylesheet still blocks paint, and inlining critical CSS
|
|
161
161
|
stacks on top of it.
|
|
162
162
|
|
|
163
|
+
## Streams and idle timeouts
|
|
164
|
+
|
|
165
|
+
An `events()` route and the [`refreshOn`](/guides/sections#refreshing-on-a-change-from-outside)
|
|
166
|
+
stream hold a connection open and are quiet most of the time. Anything in
|
|
167
|
+
front that closes a quiet connection - the server's idle timeout, a proxy's
|
|
168
|
+
read timeout - would cut them, so each sends a keepalive line every **eight
|
|
169
|
+
seconds**: under Bun's default ten, and well under nginx's sixty and
|
|
170
|
+
Cloudflare's hundred.
|
|
171
|
+
|
|
172
|
+
If something in front closes idle connections sooner, set the keepalive
|
|
173
|
+
below it:
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
RSC_STREAM_KEEPALIVE_MS=4000 # under a 5-second idle timeout
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Too long a keepalive does not lose anything - the browser reconnects, and a
|
|
180
|
+
tab catches up on whatever moved - but every open tab is cut and reopens each
|
|
181
|
+
time the timeout passes. Both streams are sent with `Cache-Control: no-store,
|
|
182
|
+
no-transform` and are never compressed, so nothing in front buffers them.
|
|
183
|
+
|
|
184
|
+
## Secrets
|
|
185
|
+
|
|
186
|
+
rsc-kit reads up to three. Each is its own key for its own job - never reuse
|
|
187
|
+
one as another, so one leaking does not hand out the rest.
|
|
188
|
+
|
|
189
|
+
| Variable | What it protects | Who needs it | Read by |
|
|
190
|
+
| --- | --- | --- | --- |
|
|
191
|
+
| `RSC_HOST_CALL_SECRET` | calls between the renderer and a Go or Laravel backend | an app with a backend - required | the renderer **and** the backend |
|
|
192
|
+
| `RSC_SIGNING_SECRET` | the names a page hands a tab for [`refreshOn`](/guides/sections#refreshing-on-a-change-from-outside) | an app that uses `refreshOn` - required in production | the renderer |
|
|
193
|
+
| `RSC_ACTION_ENCRYPTION_KEY` | values a server action sends through the browser, [across a deploy](#server-actions-across-a-deploy) | optional | the renderer, from the build |
|
|
194
|
+
|
|
195
|
+
`bun create rsc-kit` and `rsc-kit init` generate the first two into `.env` for
|
|
196
|
+
an app with a backend, and `php artisan rsc:install` does the same for
|
|
197
|
+
Laravel. That is development. For production, generate each once and keep it
|
|
198
|
+
where you keep secrets - your platform's environment settings, not the repo:
|
|
199
|
+
|
|
200
|
+
```sh
|
|
201
|
+
openssl rand -base64 32
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
or, without openssl:
|
|
205
|
+
|
|
206
|
+
```sh
|
|
207
|
+
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
|
|
208
|
+
php -r "echo base64_encode(random_bytes(32)), PHP_EOL;"
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Three rules apply to all of them:
|
|
212
|
+
|
|
213
|
+
- **The same value on every instance.** Each is checked by a process that may
|
|
214
|
+
not be the one that issued it: a tab served by one renderer, a call landing
|
|
215
|
+
on another backend replica. A value per instance fails for some requests and
|
|
216
|
+
not others.
|
|
217
|
+
- **At runtime, from the environment.** The built server reads them when it
|
|
218
|
+
starts, so one build deploys anywhere. `RSC_ACTION_ENCRYPTION_KEY` is the
|
|
219
|
+
exception: set it at build time too.
|
|
220
|
+
- **Never in git.** The scaffold's `.gitignore` already leaves out `.env`.
|
|
221
|
+
|
|
222
|
+
**Rotating one** is a deploy, and each says so differently:
|
|
223
|
+
|
|
224
|
+
- `RSC_HOST_CALL_SECRET` - the renderer and the backend must change together.
|
|
225
|
+
Between the two, every `rpc()` is refused, so deploy both at once.
|
|
226
|
+
- `RSC_SIGNING_SECRET` - pages already open hold names signed with the old
|
|
227
|
+
key; their watching is refused and they stop refreshing until reloaded.
|
|
228
|
+
Nothing else breaks.
|
|
229
|
+
- `RSC_ACTION_ENCRYPTION_KEY` - an action called from a page loaded before the
|
|
230
|
+
change fails, the same window as a deploy without the key pinned.
|
|
231
|
+
|
|
163
232
|
## Pages across a deploy
|
|
164
233
|
|
|
165
234
|
A page from the previous build asks for a payload from the new one, whose
|
package/guides/go.md
CHANGED
|
@@ -34,7 +34,7 @@ server in production:
|
|
|
34
34
|
|
|
35
35
|
```ini title=".env"
|
|
36
36
|
RSC_BACKEND=http://127.0.0.1:8080
|
|
37
|
-
RSC_HOST_CALL_SECRET
|
|
37
|
+
RSC_HOST_CALL_SECRET=… # openssl rand -base64 32 - see Secrets under Deployment
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
And the endpoint, on whatever mux you already have:
|
|
@@ -284,7 +284,9 @@ balancer picked, so give them all the same store:
|
|
|
284
284
|
reg.Names(&rsckit.SQLTags{DB: db, Placeholder: rsckit.Dollar}) // Postgres; nil Placeholder is "?"
|
|
285
285
|
```
|
|
286
286
|
|
|
287
|
-
Anything with a `Bump` and a `Versions` fits.
|
|
287
|
+
Anything with a `Bump` and a `Versions` fits. Set `RSC_SIGNING_SECRET` for the renderer, the same on every instance:
|
|
288
|
+
it signs the names a page hands the tab, and is not the host-call secret.
|
|
289
|
+
Generating and rotating both: [Secrets](/hosts/deployment#secrets). [Sections](/guides/sections#refreshing-on-a-change-from-outside)
|
|
288
290
|
has the whole picture.
|
|
289
291
|
|
|
290
292
|
## Production
|
package/guides/laravel.md
CHANGED
|
@@ -354,7 +354,31 @@ versions live in the cache - the store every server already shares, or the
|
|
|
354
354
|
one `rsc.tags_store` names; a store that is per server, like `file`, is one
|
|
355
355
|
server's. PHP answers the renderer's ask at once rather than holding it, so
|
|
356
356
|
a change reaches the tab on the renderer's next ask, a couple of seconds
|
|
357
|
-
later.
|
|
357
|
+
later. Set `RSC_SIGNING_SECRET` for the renderer, the same on every instance:
|
|
358
|
+
it signs the names a page hands the tab, and is not the host-call secret.
|
|
359
|
+
Generating and rotating both: [Secrets](/hosts/deployment#secrets).
|
|
360
|
+
|
|
361
|
+
**Watching without PHP.** By default the renderer asks PHP which versions
|
|
362
|
+
moved, every couple of seconds while any tab is watching. Keep the versions in
|
|
363
|
+
the `rsc_versions` table instead and the renderer reads them itself:
|
|
364
|
+
|
|
365
|
+
```sh
|
|
366
|
+
php artisan vendor:publish --tag=rsc-migrations && php artisan migrate
|
|
367
|
+
RSC_VERSIONS=database
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
```ts title="src/instrumentation.ts"
|
|
371
|
+
import { installVersionSource, postgresVersions } from '@rsc-kit/core/changed'
|
|
372
|
+
import postgres from 'postgres'
|
|
373
|
+
|
|
374
|
+
export function register() {
|
|
375
|
+
installVersionSource(postgresVersions(postgres(process.env.DATABASE_URL!)))
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Watching then costs PHP no requests, and on Postgres `Rsc::changed()` sends a
|
|
380
|
+
NOTIFY, so tabs hear it at once. On MySQL use `sqlVersions` with your driver;
|
|
381
|
+
the renderer reads the table once a second. [Sections](/guides/sections#refreshing-on-a-change-from-outside) has the
|
|
358
382
|
whole picture.
|
|
359
383
|
|
|
360
384
|
## Route middleware
|
package/guides/sections.md
CHANGED
|
@@ -69,9 +69,9 @@ slots the calling page does have.
|
|
|
69
69
|
## Refreshing on a change from outside
|
|
70
70
|
|
|
71
71
|
An action can say what it changed because it is the thing changing it. A
|
|
72
|
-
webhook
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
webhook, a job finishing in a worker, another visitor's action - none of
|
|
73
|
+
those is in the tab that is showing the data. For those a section says what
|
|
74
|
+
it depends on, by name:
|
|
75
75
|
|
|
76
76
|
```tsx title="src/app/t/[team]/repos.section.tsx"
|
|
77
77
|
export default section('repos', Repos, {
|
|
@@ -79,51 +79,455 @@ export default section('repos', Repos, {
|
|
|
79
79
|
})
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
and whatever changes
|
|
82
|
+
and whatever changes the data says so, from wherever it is:
|
|
83
83
|
|
|
84
|
-
```
|
|
85
|
-
|
|
84
|
+
```ts title="src/app/api/github/webhook/route.ts"
|
|
85
|
+
import { changed } from '@rsc-kit/core/changed'
|
|
86
|
+
|
|
87
|
+
export async function POST(request: Request) {
|
|
88
|
+
const { team, repos } = await request.json()
|
|
89
|
+
await saveRepos(team, repos)
|
|
90
|
+
await changed(`team:${team}:repos`)
|
|
91
|
+
return new Response(null, { status: 204 })
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Every open tab showing the section refreshes it - the same refresh as above,
|
|
96
|
+
with nothing polling and no client code. `refreshOn` is a list of names, or a
|
|
97
|
+
function of the section's props - the page's `params` and `searchParams` -
|
|
98
|
+
that makes them. A page can declare its own with `export const refreshOn`,
|
|
99
|
+
and a change refreshes the page as `refresh('page')` does.
|
|
100
|
+
|
|
101
|
+
The function runs per request, so it can read the request - a name for the
|
|
102
|
+
person signed in is the usual one on a dashboard:
|
|
103
|
+
|
|
104
|
+
```tsx title="src/app/dashboard/page.tsx"
|
|
105
|
+
import { cookies } from '@rsc-kit/core/request'
|
|
106
|
+
|
|
107
|
+
export const refreshOn = async () => [`inbox:${(await cookies()).get('agent')?.value}`]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
That is the whole feature for an app that is only JavaScript. With a Go or
|
|
111
|
+
Laravel backend the backend says it instead -
|
|
112
|
+
[`reg.Changed(ctx, …)`](/hosts/go#saying-data-changed) or
|
|
113
|
+
[`Rsc::changed(…)`](/hosts/laravel#saying-data-changed) - and the page is the same.
|
|
114
|
+
|
|
115
|
+
### How a tab finds out
|
|
116
|
+
|
|
117
|
+
Each name has a version, a number that moves when something says the name
|
|
118
|
+
changed. Nothing else travels - not what changed, not the data.
|
|
119
|
+
|
|
120
|
+
1. The page renders, reads the version of each name it shows, and hands them
|
|
121
|
+
to the tab, signed.
|
|
122
|
+
2. The tab opens one stream, `/_rsc/changes`, for every name on the page. Only
|
|
123
|
+
a page with `refreshOn` opens one, and only while the tab is visible.
|
|
124
|
+
3. The server asks where the versions live - "which of these differ from what
|
|
125
|
+
I hold?" - once for every open tab it is serving, and again on an interval.
|
|
126
|
+
4. When a version has moved, the stream says so in one line, and the tab asks
|
|
127
|
+
for exactly the sections showing that name, the way `refresh('repos')` does.
|
|
128
|
+
|
|
129
|
+
A name belongs to the data, not to a section or a page. Any number of
|
|
130
|
+
sections, on any number of pages, can refresh on the same name: one
|
|
131
|
+
`changed('team:1:repos')` moves its version once, and every open tab showing
|
|
132
|
+
any of them refreshes the ones it has. Section *names* are separate - two
|
|
133
|
+
pages may both have a section called `stats`; each is resolved through its own
|
|
134
|
+
page, and a shared render is never handed from one page to the other.
|
|
135
|
+
|
|
136
|
+
The data is never pushed: the section is rendered fresh, through its own
|
|
137
|
+
queries and its own guards, as any refresh is. That is also why this runs
|
|
138
|
+
on a Worker - the stream holds no state, and a version is a read.
|
|
139
|
+
|
|
140
|
+
### Where the versions live
|
|
141
|
+
|
|
142
|
+
**One server, no backend:** in that server's memory. Nothing to set up - but
|
|
143
|
+
in production rsc-kit says so in its log, once, because the same default
|
|
144
|
+
fails silently the moment there are two instances. If the deploy really is
|
|
145
|
+
one instance, say so and the warning goes:
|
|
146
|
+
|
|
147
|
+
```ts title="src/instrumentation.ts"
|
|
148
|
+
import { installVersionSource, memoryVersions } from '@rsc-kit/core/changed'
|
|
149
|
+
|
|
150
|
+
export function register() {
|
|
151
|
+
installVersionSource(memoryVersions()) // one instance, on purpose
|
|
152
|
+
}
|
|
86
153
|
```
|
|
87
154
|
|
|
88
|
-
|
|
89
|
-
|
|
155
|
+
**More than one instance, or a worker process:** this is the case to
|
|
156
|
+
plan for. In-memory versions fail silently the moment a load balancer adds a
|
|
157
|
+
second instance - a change made on one never reaches the tabs held by the
|
|
158
|
+
other - and a job finishes in a process that serves no pages at all. Keep
|
|
159
|
+
the versions somewhere they all share - any database you already have, through
|
|
160
|
+
the client you already use. rsc-kit ships no driver; you hand it yours:
|
|
161
|
+
|
|
162
|
+
| Your database | The store | Another instance's change arrives |
|
|
163
|
+
| --- | --- | --- |
|
|
164
|
+
| Postgres, with postgres.js or `Bun.sql` | `postgresVersions(sql)` | at once with postgres.js; within a second with `Bun.sql` |
|
|
165
|
+
| Postgres, with another client | `sqlVersions({ query, placeholder: (n) => '$' + n })` | within a second |
|
|
166
|
+
| MySQL, MariaDB | `sqlVersions({ query })` | within a second |
|
|
167
|
+
| SQLite, libSQL, Turso, D1 | `sqlVersions({ query })` | within a second |
|
|
168
|
+
| Redis | `createVersions({ read, bump, listen, notify })` | at once, through pub/sub |
|
|
169
|
+
| An ORM: Drizzle, Prisma, Kysely, Knex | `createVersions` over its query builder, or `sqlVersions` over its raw query | as its database |
|
|
170
|
+
|
|
171
|
+
`sqlVersions` takes your driver as one function of SQL text and parameters,
|
|
172
|
+
and writes plain SQL every dialect has - a select, an update, an insert:
|
|
173
|
+
|
|
174
|
+
```ts title="src/versions.ts"
|
|
175
|
+
import { sqlVersions } from '@rsc-kit/core/changed'
|
|
176
|
+
|
|
177
|
+
// MySQL, mysql2
|
|
178
|
+
export const versions = sqlVersions({ query: async (text, params) => (await pool.query(text, params))[0] })
|
|
179
|
+
|
|
180
|
+
// SQLite, bun:sqlite or better-sqlite3
|
|
181
|
+
export const versions = sqlVersions({ query: async (text, params) => db.query(text).all(...params) })
|
|
182
|
+
|
|
183
|
+
// libSQL / Turso
|
|
184
|
+
export const versions = sqlVersions({ query: async (sql, args) => (await client.execute({ sql, args })).rows })
|
|
90
185
|
```
|
|
91
186
|
|
|
187
|
+
and with postgres.js it is one line:
|
|
188
|
+
|
|
189
|
+
```ts title="src/versions.ts"
|
|
190
|
+
import postgres from 'postgres'
|
|
191
|
+
import { postgresVersions } from '@rsc-kit/core/changed'
|
|
192
|
+
|
|
193
|
+
export const sql = postgres(process.env.DATABASE_URL!)
|
|
194
|
+
export const versions = postgresVersions(sql)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
A SQLite file is shared by the processes on one machine - a web server and a
|
|
198
|
+
worker on the same box. Instances on several machines need a database they
|
|
199
|
+
all reach over the network. The table every one of these reads is described
|
|
200
|
+
[below](#the-table).
|
|
201
|
+
|
|
202
|
+
postgres.js can LISTEN, so a change on one instance reaches the tabs on every
|
|
203
|
+
other at once. `Bun.sql` works too, without LISTEN: a waiting server reads the
|
|
204
|
+
table once a second, so a change from another instance takes up to a second -
|
|
205
|
+
unless a second, listening connection wakes it (below).
|
|
206
|
+
|
|
207
|
+
**Any database, any ORM, or no SQL at all.** rsc-kit needs three things from
|
|
208
|
+
your storage - read some versions, move some versions, and, if it can,
|
|
209
|
+
say the moment one moved - and does the comparing, waiting and waking itself.
|
|
210
|
+
`createVersions` takes exactly those:
|
|
211
|
+
|
|
212
|
+
With Drizzle, use its query builder rather than raw SQL - the table is one
|
|
213
|
+
more table in your schema:
|
|
214
|
+
|
|
215
|
+
```ts title="src/db/schema.ts"
|
|
216
|
+
import { bigint, pgTable, text } from 'drizzle-orm/pg-core'
|
|
217
|
+
|
|
218
|
+
export const rscVersions = pgTable('rsc_versions', {
|
|
219
|
+
name: text('name').primaryKey(),
|
|
220
|
+
version: bigint('version', { mode: 'number' }).notNull(),
|
|
221
|
+
})
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
```ts title="src/versions.ts"
|
|
225
|
+
import { inArray, sql } from 'drizzle-orm'
|
|
226
|
+
import { createVersions } from '@rsc-kit/core/changed'
|
|
227
|
+
import { db } from './db'
|
|
228
|
+
import { rscVersions } from './db/schema'
|
|
229
|
+
|
|
230
|
+
export const versions = createVersions({
|
|
231
|
+
read: async (names) =>
|
|
232
|
+
Object.fromEntries(
|
|
233
|
+
(await db.select().from(rscVersions).where(inArray(rscVersions.name, names)))
|
|
234
|
+
.map((row) => [row.name, Number(row.version)]),
|
|
235
|
+
),
|
|
236
|
+
bump: async (names) => {
|
|
237
|
+
await db.insert(rscVersions)
|
|
238
|
+
.values(names.map((name) => ({ name, version: 1 })))
|
|
239
|
+
.onConflictDoUpdate({ target: rscVersions.name, set: { version: sql`${rscVersions.version} + 1` } })
|
|
240
|
+
},
|
|
241
|
+
})
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
```ts title="Prisma"
|
|
245
|
+
import { sqlVersions } from '@rsc-kit/core/changed'
|
|
246
|
+
|
|
247
|
+
// Raw SQL through Prisma; the table is a model in its schema - see "The table" below.
|
|
248
|
+
export const versions = sqlVersions({
|
|
249
|
+
query: (text, params) => prisma.$queryRawUnsafe(text, ...params),
|
|
250
|
+
placeholder: (n) => '$' + n, // Postgres; leave out for MySQL and SQLite
|
|
251
|
+
})
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
```ts title="Redis"
|
|
255
|
+
import { createVersions } from '@rsc-kit/core/changed'
|
|
256
|
+
|
|
257
|
+
const key = (name: string) => 'rsc:' + name
|
|
258
|
+
|
|
259
|
+
export const versions = createVersions({
|
|
260
|
+
read: async (names) => {
|
|
261
|
+
const values = await redis.mget(names.map(key))
|
|
262
|
+
return Object.fromEntries(names.map((name, i) => [name, Number(values[i] ?? 0)]))
|
|
263
|
+
},
|
|
264
|
+
bump: async (names) => { await Promise.all(names.map((name) => redis.incr(key(name)))) },
|
|
265
|
+
// Instant across instances: a subscriber connection hears every bump.
|
|
266
|
+
listen: (wake) => subscriber.subscribe('rsc:versions', wake),
|
|
267
|
+
notify: () => redis.publish('rsc:versions', ''),
|
|
268
|
+
})
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`sqlVersions({ query })` takes any SQL driver as a function of text and
|
|
272
|
+
parameters - node-postgres, mysql2, Kysely, Knex, D1 - and `createVersions`
|
|
273
|
+
takes anything else.
|
|
274
|
+
|
|
275
|
+
Install it in every process - the web server once, before the first request.
|
|
276
|
+
`register()` also runs during the build, and that is fine: a store opens no
|
|
277
|
+
connection until a tab is actually waiting on it, and postgres.js connects
|
|
278
|
+
lazily, so a build never holds one open. Keep anything that connects eagerly
|
|
279
|
+
out of `register()`, or the build waits on it and never exits.
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
```ts title="src/instrumentation.ts"
|
|
283
|
+
import { installVersionSource } from '@rsc-kit/core/changed'
|
|
284
|
+
import { versions } from './versions'
|
|
285
|
+
|
|
286
|
+
export function register() {
|
|
287
|
+
installVersionSource(versions)
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
and the worker, which then says what it finished:
|
|
292
|
+
|
|
293
|
+
```ts title="worker.ts"
|
|
294
|
+
import { changed, installVersionSource } from '@rsc-kit/core/changed'
|
|
295
|
+
import { versions } from './src/versions'
|
|
296
|
+
|
|
297
|
+
installVersionSource(versions)
|
|
298
|
+
|
|
299
|
+
queue.on('restoration.done', async (job) => {
|
|
300
|
+
await changed(`restoration:${job.id}`)
|
|
301
|
+
})
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Without `listen` and `notify` - MySQL, SQLite, or a driver that cannot listen -
|
|
305
|
+
a waiting server reads the table every second instead (`poll` changes it).
|
|
306
|
+
|
|
307
|
+
**From any process, without rsc-kit:** the table is the contract. A cron job,
|
|
308
|
+
a service in another language, a database trigger - anything that can write
|
|
309
|
+
to it can say a name changed:
|
|
310
|
+
|
|
311
|
+
```sql
|
|
312
|
+
-- Postgres
|
|
313
|
+
INSERT INTO rsc_versions (name, version) VALUES ('restoration:42', 1)
|
|
314
|
+
ON CONFLICT (name) DO UPDATE SET version = rsc_versions.version + 1;
|
|
315
|
+
NOTIFY rsc_versions; -- optional: wakes the listening servers at once
|
|
316
|
+
|
|
317
|
+
-- MySQL
|
|
318
|
+
INSERT INTO rsc_versions (name, version) VALUES ('restoration:42', 1)
|
|
319
|
+
ON DUPLICATE KEY UPDATE version = version + 1;
|
|
320
|
+
|
|
321
|
+
-- SQLite
|
|
322
|
+
INSERT INTO rsc_versions (name, version) VALUES ('restoration:42', 1)
|
|
323
|
+
ON CONFLICT (name) DO UPDATE SET version = version + 1;
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
A version only has to *change*; the servers compare, they do not count. Go's
|
|
327
|
+
`SQLVersions` uses the same table, so a Go service and a JavaScript app can
|
|
328
|
+
share one.
|
|
329
|
+
|
|
330
|
+
**Instant across instances on a client that cannot LISTEN** - `Bun.sql`, or
|
|
331
|
+
a Drizzle or Prisma setup over one: keep it for reading and writing, and open
|
|
332
|
+
one postgres.js connection only to listen. It costs one idle connection per
|
|
333
|
+
process:
|
|
334
|
+
|
|
335
|
+
```ts title="src/versions.ts"
|
|
336
|
+
import postgres from 'postgres'
|
|
337
|
+
|
|
338
|
+
const listener = postgres(process.env.DATABASE_URL!, { max: 1 })
|
|
339
|
+
|
|
340
|
+
export const versions = createVersions({
|
|
341
|
+
read, // as above, on your own client
|
|
342
|
+
bump,
|
|
343
|
+
listen: (wake) => listener.listen('rsc_versions', wake),
|
|
344
|
+
notify: () => listener.notify('rsc_versions', ''),
|
|
345
|
+
})
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### The table
|
|
349
|
+
|
|
350
|
+
A shared store keeps one row per name. Your app owns the table: rsc-kit never
|
|
351
|
+
creates, alters or migrates it at runtime - make it with your own migrations,
|
|
352
|
+
in your own schema, with whatever your ORM calls these types. (Laravel ships
|
|
353
|
+
one you publish into your own migrations, `--tag=rsc-migrations`; after that
|
|
354
|
+
it is yours too.)
|
|
355
|
+
|
|
356
|
+
| Column | Type | |
|
|
357
|
+
| --- | --- | --- |
|
|
358
|
+
| `name` | text, `PRIMARY KEY` (or a unique index) | the name a section refreshes on |
|
|
359
|
+
| `version` | `BIGINT NOT NULL` | any integer; it only has to change |
|
|
360
|
+
|
|
361
|
+
```sql
|
|
362
|
+
-- Postgres, SQLite
|
|
363
|
+
CREATE TABLE rsc_versions (name TEXT PRIMARY KEY, version BIGINT NOT NULL);
|
|
364
|
+
-- MySQL: an indexed TEXT needs a length
|
|
365
|
+
CREATE TABLE rsc_versions (name VARCHAR(255) PRIMARY KEY, version BIGINT NOT NULL);
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
- **Extra columns are fine** - an `updated_at`, say - as long as each has a
|
|
369
|
+
default: rsc-kit and the documented upserts write only `name` and `version`.
|
|
370
|
+
- **Another name is fine:** `sqlVersions({ table })`, `postgresVersions(sql,
|
|
371
|
+
{ table })`, Go's `SQLVersions{Table}`, Laravel's `RSC_VERSIONS_TABLE`.
|
|
372
|
+
- **The NOTIFY channel** is `rsc_versions` whatever the table is called;
|
|
373
|
+
`postgresVersions(sql, { channel })` changes it.
|
|
374
|
+
- The same table works for Go's `SQLVersions` and Laravel's
|
|
375
|
+
`RSC_VERSIONS=database`, so a JavaScript app, a Go service and a PHP job
|
|
376
|
+
can share one.
|
|
377
|
+
|
|
378
|
+
### Say it after the write commits
|
|
379
|
+
|
|
380
|
+
A tab that hears a name moved asks for its section at once. If the write
|
|
381
|
+
that changed the data has not committed yet, that render reads the old data -
|
|
382
|
+
and the tab is now at the new version, so it will not ask again until the
|
|
383
|
+
next change. Inside a transaction, say it changed **after** the transaction,
|
|
384
|
+
not inside it:
|
|
385
|
+
|
|
92
386
|
```ts
|
|
93
|
-
|
|
94
|
-
await
|
|
387
|
+
await db.transaction(async (tx) => {
|
|
388
|
+
await tx.insert(conversations).values(row)
|
|
389
|
+
// not here: this transaction may still roll back, and has not committed
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
await changed(`agent:${row.agentId}:conversations`) // here
|
|
95
393
|
```
|
|
96
394
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
the
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
was rendered with
|
|
125
|
-
|
|
126
|
-
|
|
395
|
+
The same goes for a worker writing the table directly: in the same
|
|
396
|
+
transaction as the data, or after it - never before. In Laravel,
|
|
397
|
+
`DB::afterCommit(fn () => Rsc::changed(...))` does this inside a transaction.
|
|
398
|
+
|
|
399
|
+
**With a Go or Laravel backend:** the backend keeps them - a table in Go, the
|
|
400
|
+
cache in Laravel - and you install nothing here. The renderer asks the
|
|
401
|
+
backend which moved: Go holds the question until one does; PHP cannot hold a
|
|
402
|
+
request open, so Laravel is asked again every couple of seconds while any tab
|
|
403
|
+
is watching. To take PHP out of watching altogether, keep Laravel's versions
|
|
404
|
+
in the table (`RSC_VERSIONS=database`) and have the renderer read it itself -
|
|
405
|
+
an app's own store always wins over the backend's:
|
|
406
|
+
|
|
407
|
+
```ts title="src/instrumentation.ts"
|
|
408
|
+
import { installVersionSource, postgresVersions } from '@rsc-kit/core/changed'
|
|
409
|
+
import postgres from 'postgres'
|
|
410
|
+
|
|
411
|
+
export function register() {
|
|
412
|
+
installVersionSource(postgresVersions(postgres(process.env.DATABASE_URL!)))
|
|
413
|
+
}
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
Watching then costs PHP nothing, and `Rsc::changed()`'s NOTIFY reaches the
|
|
417
|
+
tabs at once.
|
|
418
|
+
|
|
419
|
+
### The signing secret
|
|
420
|
+
|
|
421
|
+
The names a page hands the tab are signed, so a tab can only listen for the
|
|
422
|
+
names its page was rendered with - a browser cannot make one up. Every
|
|
423
|
+
instance has to sign with the same key, or a tab served by one is refused by
|
|
424
|
+
the next and never refreshes:
|
|
425
|
+
|
|
426
|
+
```sh
|
|
427
|
+
RSC_SIGNING_SECRET=… # the same on every instance: openssl rand -base64 32
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
It is its own secret, with or without a backend - not the backend's
|
|
431
|
+
`RSC_HOST_CALL_SECRET`, which authorises a different thing. In production, an
|
|
432
|
+
app that uses `refreshOn` without it refuses to serve, saying what to set: a
|
|
433
|
+
random key per instance would fail silently.
|
|
434
|
+
|
|
435
|
+
That refusal comes on the first request, because on some platforms - Workers
|
|
436
|
+
- the environment is only there once a request arrives. Under Kubernetes or
|
|
437
|
+
any orchestrator with a readiness check, that is too late: the pod is already
|
|
438
|
+
taking traffic. Make the deploy itself fail instead, from `register()`, which
|
|
439
|
+
runs before the server takes a request:
|
|
440
|
+
|
|
441
|
+
```ts title="src/instrumentation.ts"
|
|
442
|
+
import { assertSigningSecret } from '@rsc-kit/core/changed'
|
|
443
|
+
|
|
444
|
+
export function register() {
|
|
445
|
+
assertSigningSecret() // throws in production without RSC_SIGNING_SECRET
|
|
446
|
+
}
|
|
447
|
+
``` In development a random key is
|
|
448
|
+
fine, and is what you get.
|
|
449
|
+
|
|
450
|
+
How to generate it, where to keep it, and what rotating it does:
|
|
451
|
+
[Secrets](/hosts/deployment#secrets).
|
|
452
|
+
|
|
453
|
+
Names are signed when the page is served, never at build: a page stored at
|
|
454
|
+
build keeps everything else stored, and the part that carries the names is
|
|
455
|
+
filled per request. So the build does not need the secret.
|
|
456
|
+
|
|
457
|
+
### When nothing refreshes
|
|
458
|
+
|
|
459
|
+
In development the browser console says what the page is watching, region
|
|
460
|
+
by region, each time that changes:
|
|
461
|
+
|
|
462
|
+
```
|
|
463
|
+
[rsc-kit] refreshOn watching, by region:
|
|
464
|
+
repos: team:1:repos @3
|
|
465
|
+
page: inbox:ada @0
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
A region that declared `refreshOn` but is missing from that list rendered no
|
|
469
|
+
names, and the server's log says why, once. And watching **stops while the
|
|
470
|
+
tab is hidden** - a background tab holds no connection - which the console
|
|
471
|
+
also says. Nothing is missed: shown again, the tab reconnects at the versions
|
|
472
|
+
it holds and refreshes whatever moved meanwhile.
|
|
473
|
+
|
|
474
|
+
### When many tabs show the same thing
|
|
475
|
+
|
|
476
|
+
When a name moves, every tab showing it asks for its sections again - 500 tabs
|
|
477
|
+
on one team's board are 500 renders. Each tab's render can differ by who is
|
|
478
|
+
looking, so by default each is its own. A section that is the same for
|
|
479
|
+
everyone allowed to see the page can say so:
|
|
480
|
+
|
|
481
|
+
```tsx title="src/app/status/board.section.tsx"
|
|
482
|
+
export default section('board', Board, { refreshOn: ['board'], shared: true })
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Then the tabs asking because of the same change get one render between them:
|
|
486
|
+
the first tab's request renders it, and every other gets the same bytes.
|
|
487
|
+
Each tab's guards still run on its own request - a visitor who may not see
|
|
488
|
+
the page is still refused - and only a refresh a change triggered is shared,
|
|
489
|
+
so `refresh()` by hand, or after an action, always renders afresh.
|
|
490
|
+
|
|
491
|
+
Never set `shared` on a section that shows anything per visitor - their name,
|
|
492
|
+
their data, what they may do. The first tab's render is what every other tab
|
|
493
|
+
is shown, `rpc()` calls included: they run with the first visitor's session.
|
|
494
|
+
|
|
495
|
+
### What it costs
|
|
496
|
+
|
|
497
|
+
- **Per tab:** one open connection while the page is visible, closed when the
|
|
498
|
+
tab is hidden and reopened when it is seen again. Idle, it carries a
|
|
499
|
+
keepalive line every eight seconds ([set it lower](/hosts/deployment#streams-and-idle-timeouts)
|
|
500
|
+
under a shorter idle timeout); a change is one line of about forty
|
|
501
|
+
bytes.
|
|
502
|
+
- **Per change:** each tab showing the name asks for the sections that show
|
|
503
|
+
it, once, however many names moved together. That request is the size of
|
|
504
|
+
the section, not the page. A `shared` section renders once per change for
|
|
505
|
+
every tab; any other renders once per tab.
|
|
506
|
+
- **On the server:** one ask of wherever the versions live, covering every tab
|
|
507
|
+
that server holds - a thousand open tabs are one query, not a thousand. The
|
|
508
|
+
ask may wait up to five seconds for a change: a database with `listen`, or a
|
|
509
|
+
Go backend, answers the moment a version moves; a table without it is read
|
|
510
|
+
once a second while the ask waits; Laravel answers at once and is asked again
|
|
511
|
+
two seconds later. Plus one small read when each tab opens its stream.
|
|
512
|
+
|
|
513
|
+
Polling a section every two seconds instead costs a full render of it, per
|
|
514
|
+
tab, every two seconds, whether anything changed or not. The trade is the open
|
|
515
|
+
connection: cheap on Bun, Node and Workers; on a host that bills a function
|
|
516
|
+
for every second it is open, it is a second billed for as long as the tab is.
|
|
517
|
+
|
|
518
|
+
### Coming from Next.js
|
|
519
|
+
|
|
520
|
+
This is where `revalidateTag` was, and it is not the same thing. Next's tags
|
|
521
|
+
label entries in its Data Cache and `revalidateTag()` throws them away, so the
|
|
522
|
+
*next request* refetches; an open tab learns nothing until it navigates.
|
|
523
|
+
There is no data cache here - every render is fresh - so `refreshOn` names
|
|
524
|
+
what is *on screen*, and `changed()` refreshes every open tab showing it, now.
|
|
525
|
+
A port keeps its names: `tags: ['posts']` on the fetch becomes
|
|
526
|
+
`refreshOn: ['posts']` on the section, and `revalidateTag('posts')` becomes
|
|
527
|
+
`changed('posts')`.
|
|
528
|
+
|
|
529
|
+
A name's version reveals only that something with that name changed. Do not
|
|
530
|
+
put a secret in a name.
|
|
127
531
|
|
|
128
532
|
## The name is scoped to the module, not the app
|
|
129
533
|
|
|
@@ -22,7 +22,7 @@ its process environment:
|
|
|
22
22
|
|
|
23
23
|
```ini title=".env"
|
|
24
24
|
RSC_BACKEND=http://127.0.0.1:8080
|
|
25
|
-
RSC_HOST_CALL_SECRET
|
|
25
|
+
RSC_HOST_CALL_SECRET=… # openssl rand -base64 32 - see Secrets under Deployment
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
`APP_URL` is read where `RSC_BACKEND` is absent, which is what makes a Laravel
|
package/package.json
CHANGED