@rsc-kit/mcp 0.27.0 → 0.28.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/recipes.js +108 -11
- package/dist/recipes.js.map +1 -1
- package/guides/api-routes.md +4 -2
- package/guides/deployment.md +69 -0
- package/guides/go.md +3 -2
- package/guides/laravel.md +26 -13
- package/guides/sections.md +342 -40
- package/guides/your-own-backend.md +1 -1
- package/package.json +1 -1
package/guides/sections.md
CHANGED
|
@@ -98,8 +98,9 @@ function of the section's props - the page's `params` and `searchParams` -
|
|
|
98
98
|
that makes them. A page can declare its own with `export const refreshOn`,
|
|
99
99
|
and a change refreshes the page as `refresh('page')` does.
|
|
100
100
|
|
|
101
|
-
The function
|
|
102
|
-
|
|
101
|
+
The function is given the page's `params` and `searchParams` already awaited,
|
|
102
|
+
and runs per request, so it can read the request too - a name for the person
|
|
103
|
+
signed in is the usual one on a dashboard:
|
|
103
104
|
|
|
104
105
|
```tsx title="src/app/dashboard/page.tsx"
|
|
105
106
|
import { cookies } from '@rsc-kit/core/request'
|
|
@@ -112,6 +113,56 @@ Laravel backend the backend says it instead -
|
|
|
112
113
|
[`reg.Changed(ctx, …)`](/hosts/go#saying-data-changed) or
|
|
113
114
|
[`Rsc::changed(…)`](/hosts/laravel#saying-data-changed) - and the page is the same.
|
|
114
115
|
|
|
116
|
+
### Whose data a name shows
|
|
117
|
+
|
|
118
|
+
A name is a signal, not data and not a permission. Hearing that
|
|
119
|
+
`conversation:42` changed tells a tab only to ask for its section again; what
|
|
120
|
+
comes back is rendered for that tab's visitor, through their own session, by
|
|
121
|
+
the same queries and guards as the first render. So two people never see each
|
|
122
|
+
other's data through a name - they see whatever their own render shows.
|
|
123
|
+
|
|
124
|
+
A chat between Ada and Ben:
|
|
125
|
+
|
|
126
|
+
```tsx title="src/app/chats/[id]/messages.section.tsx"
|
|
127
|
+
export default section('messages', async function Messages({ params }) {
|
|
128
|
+
const { id } = await params
|
|
129
|
+
const messages = await rpc('Chats.messages', id) // the backend checks the viewer is in it
|
|
130
|
+
|
|
131
|
+
return <Thread messages={messages} />
|
|
132
|
+
}, { refreshOn: ({ params }) => [`conversation:${params.id}`] })
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```tsx title="src/app/inbox/page.tsx"
|
|
136
|
+
// Each person's own list of conversations, with unread counts.
|
|
137
|
+
export const refreshOn = async () => [`inbox:${await currentUserId()}`]
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```go
|
|
141
|
+
// Ada sends a message.
|
|
142
|
+
reg.Changed(ctx, "conversation:42", "inbox:"+ben.ID, "inbox:"+ada.ID)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- Both have conversation 42 open, so both tabs refresh it, and each render
|
|
146
|
+
asks `Chats.messages` as its own visitor. If the thread shows "you" and
|
|
147
|
+
"them", each sees it from their side.
|
|
148
|
+
- Ben's inbox refreshes; so does Ada's. Nobody else's does: their pages were
|
|
149
|
+
rendered with their own `inbox:` names.
|
|
150
|
+
- Someone who is not in the conversation cannot open the page - its guards
|
|
151
|
+
refuse - so no render ever hands them `conversation:42` to listen for. A
|
|
152
|
+
tab can only listen for names its page was rendered with: the renderer
|
|
153
|
+
signs them, and a name a browser makes up is refused.
|
|
154
|
+
|
|
155
|
+
**Name the data, as narrowly as the data.** One name per thing that changes
|
|
156
|
+
together: `conversation:{id}`, `inbox:{userId}`, `team:{id}:repos`. Two
|
|
157
|
+
different things sharing one name only refresh too often; one thing split
|
|
158
|
+
across names refreshes too little. Use ids, not emails or titles - a name
|
|
159
|
+
sits in the page and in the stream's url, and its version says when it last
|
|
160
|
+
changed, so a name must not be something its visitor should not see.
|
|
161
|
+
|
|
162
|
+
A section that shows anything per visitor - "you", their unread count, what
|
|
163
|
+
they may do - must never be `shared`: that is the one place a render is
|
|
164
|
+
handed from one visitor to another.
|
|
165
|
+
|
|
115
166
|
### How a tab finds out
|
|
116
167
|
|
|
117
168
|
Each name has a version, a number that moves when something says the name
|
|
@@ -126,43 +177,109 @@ changed. Nothing else travels - not what changed, not the data.
|
|
|
126
177
|
4. When a version has moved, the stream says so in one line, and the tab asks
|
|
127
178
|
for exactly the sections showing that name, the way `refresh('repos')` does.
|
|
128
179
|
|
|
180
|
+
A name belongs to the data, not to a section or a page. Any number of
|
|
181
|
+
sections, on any number of pages, can refresh on the same name: one
|
|
182
|
+
`changed('team:1:repos')` moves its version once, and every open tab showing
|
|
183
|
+
any of them refreshes the ones it has. Section *names* are separate - two
|
|
184
|
+
pages may both have a section called `stats`; each is resolved through its own
|
|
185
|
+
page, and a shared render is never handed from one page to the other.
|
|
186
|
+
|
|
129
187
|
The data is never pushed: the section is rendered fresh, through its own
|
|
130
188
|
queries and its own guards, as any refresh is. That is also why this runs
|
|
131
189
|
on a Worker - the stream holds no state, and a version is a read.
|
|
132
190
|
|
|
133
191
|
### Where the versions live
|
|
134
192
|
|
|
135
|
-
**One server, no backend:** in that server's memory. Nothing to set up
|
|
193
|
+
**One server, no backend:** in that server's memory. Nothing to set up - but
|
|
194
|
+
in production rsc-kit says so in its log, once, because the same default
|
|
195
|
+
fails silently the moment there are two instances. If the deploy really is
|
|
196
|
+
one instance, say so and the warning goes:
|
|
197
|
+
|
|
198
|
+
```ts title="src/instrumentation.ts"
|
|
199
|
+
import { installVersionSource, memoryVersions } from '@rsc-kit/core/changed'
|
|
200
|
+
|
|
201
|
+
export function register() {
|
|
202
|
+
installVersionSource(memoryVersions()) // one instance, on purpose
|
|
203
|
+
}
|
|
204
|
+
```
|
|
136
205
|
|
|
137
206
|
**More than one instance, or a worker process:** this is the case to
|
|
138
207
|
plan for. In-memory versions fail silently the moment a load balancer adds a
|
|
139
208
|
second instance - a change made on one never reaches the tabs held by the
|
|
140
209
|
other - and a job finishes in a process that serves no pages at all. Keep
|
|
141
|
-
the versions
|
|
142
|
-
the client you already
|
|
210
|
+
the versions somewhere they all share - any database you already have, through
|
|
211
|
+
the client you already use. rsc-kit ships no driver; you hand it yours:
|
|
212
|
+
|
|
213
|
+
| Your database | The store | Another instance's change arrives |
|
|
214
|
+
| --- | --- | --- |
|
|
215
|
+
| Postgres, with postgres.js or `Bun.sql` (Bun 1.4+) - or the one under Drizzle | `postgresVersions(sql)`, or `postgresVersions(db.$client)` | at once, through LISTEN/NOTIFY; one statement per change |
|
|
216
|
+
| Postgres, with another client | `sqlVersions({ query, placeholder: (n) => '$' + n })` | within a second |
|
|
217
|
+
| MySQL, MariaDB | `sqlVersions({ query, dialect: 'mysql' })` | within a second; one statement per change |
|
|
218
|
+
| SQLite, libSQL, Turso, D1 | `sqlVersions({ query, dialect: 'sqlite' })` | within a second; one statement per change |
|
|
219
|
+
| Redis | `createVersions({ read, bump, listen, notify })` | at once, through pub/sub |
|
|
220
|
+
| An ORM: Drizzle, Prisma, Kysely, Knex | `createVersions` over its query builder, or `sqlVersions` over its raw query | as its database |
|
|
221
|
+
|
|
222
|
+
`sqlVersions` takes your driver as one function of SQL text and parameters.
|
|
223
|
+
Say which database with `dialect`, and a change is one upsert of every name it
|
|
224
|
+
moves; leave it out and it uses only SQL every database has, a read and a
|
|
225
|
+
write per name:
|
|
226
|
+
|
|
227
|
+
```ts title="src/versions.ts"
|
|
228
|
+
import { sqlVersions } from '@rsc-kit/core/changed'
|
|
229
|
+
|
|
230
|
+
// MySQL, mysql2
|
|
231
|
+
export const versions = sqlVersions({ dialect: 'mysql', query: async (text, params) => (await pool.query(text, params))[0] })
|
|
232
|
+
|
|
233
|
+
// SQLite, bun:sqlite or better-sqlite3
|
|
234
|
+
export const versions = sqlVersions({ dialect: 'sqlite', query: async (text, params) => db.query(text).all(...params) })
|
|
235
|
+
|
|
236
|
+
// libSQL / Turso
|
|
237
|
+
export const versions = sqlVersions({ dialect: 'sqlite', query: async (sql, args) => (await client.execute({ sql, args })).rows })
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
and with postgres.js it is one line:
|
|
143
241
|
|
|
144
242
|
```ts title="src/versions.ts"
|
|
145
243
|
import postgres from 'postgres'
|
|
146
244
|
import { postgresVersions } from '@rsc-kit/core/changed'
|
|
147
245
|
|
|
148
246
|
export const sql = postgres(process.env.DATABASE_URL!)
|
|
149
|
-
|
|
150
|
-
// CREATE TABLE rsc_versions (name TEXT PRIMARY KEY, version BIGINT NOT NULL)
|
|
151
247
|
export const versions = postgresVersions(sql)
|
|
152
248
|
```
|
|
153
249
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
250
|
+
A SQLite file is shared by the processes on one machine - a web server and a
|
|
251
|
+
worker on the same box. Instances on several machines need a database they
|
|
252
|
+
all reach over the network. The table every one of these reads is described
|
|
253
|
+
[below](#the-table).
|
|
254
|
+
|
|
255
|
+
postgres.js and `Bun.sql` both LISTEN, and `postgresVersions` uses it when the
|
|
256
|
+
client has it: a change on one instance - or in a worker - reaches the tabs on
|
|
257
|
+
every other at once. A listening server still reads the table now and then, in
|
|
258
|
+
case its listening connection dropped without a word; it is not how changes
|
|
259
|
+
normally arrive.
|
|
157
260
|
|
|
158
261
|
**Any database, any ORM, or no SQL at all.** rsc-kit needs three things from
|
|
159
262
|
your storage - read some versions, move some versions, and, if it can,
|
|
160
263
|
say the moment one moved - and does the comparing, waiting and waking itself.
|
|
161
264
|
`createVersions` takes exactly those:
|
|
162
265
|
|
|
163
|
-
|
|
266
|
+
With Drizzle, use its query builder rather than raw SQL - the table is one
|
|
267
|
+
more table in your schema:
|
|
268
|
+
|
|
269
|
+
```ts title="src/db/schema.ts"
|
|
270
|
+
import { bigint, pgTable, text } from 'drizzle-orm/pg-core'
|
|
271
|
+
|
|
272
|
+
export const rscVersions = pgTable('rsc_versions', {
|
|
273
|
+
name: text('name').primaryKey(),
|
|
274
|
+
version: bigint('version', { mode: 'number' }).notNull(),
|
|
275
|
+
})
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
```ts title="src/versions.ts"
|
|
164
279
|
import { inArray, sql } from 'drizzle-orm'
|
|
165
280
|
import { createVersions } from '@rsc-kit/core/changed'
|
|
281
|
+
import { db } from './db'
|
|
282
|
+
import { rscVersions } from './db/schema'
|
|
166
283
|
|
|
167
284
|
export const versions = createVersions({
|
|
168
285
|
read: async (names) =>
|
|
@@ -172,8 +289,11 @@ export const versions = createVersions({
|
|
|
172
289
|
),
|
|
173
290
|
bump: async (names) => {
|
|
174
291
|
await db.insert(rscVersions)
|
|
175
|
-
.values(names.map((name) => ({ name, version:
|
|
176
|
-
.onConflictDoUpdate({
|
|
292
|
+
.values(names.map((name) => ({ name, version: Date.now() })))
|
|
293
|
+
.onConflictDoUpdate({
|
|
294
|
+
target: rscVersions.name,
|
|
295
|
+
set: { version: sql`GREATEST(${rscVersions.version} + 1, excluded.version)` },
|
|
296
|
+
})
|
|
177
297
|
},
|
|
178
298
|
})
|
|
179
299
|
```
|
|
@@ -181,7 +301,7 @@ export const versions = createVersions({
|
|
|
181
301
|
```ts title="Prisma"
|
|
182
302
|
import { sqlVersions } from '@rsc-kit/core/changed'
|
|
183
303
|
|
|
184
|
-
// Raw SQL through Prisma; the table is
|
|
304
|
+
// Raw SQL through Prisma; the table is a model in its schema - see "The table" below.
|
|
185
305
|
export const versions = sqlVersions({
|
|
186
306
|
query: (text, params) => prisma.$queryRawUnsafe(text, ...params),
|
|
187
307
|
placeholder: (n) => '$' + n, // Postgres; leave out for MySQL and SQLite
|
|
@@ -192,13 +312,19 @@ export const versions = sqlVersions({
|
|
|
192
312
|
import { createVersions } from '@rsc-kit/core/changed'
|
|
193
313
|
|
|
194
314
|
const key = (name: string) => 'rsc:' + name
|
|
315
|
+
const BUMP = `local v = math.max(tonumber(redis.call('GET', KEYS[1]) or '0') + 1, tonumber(ARGV[1]))
|
|
316
|
+
redis.call('SET', KEYS[1], v, 'EX', ARGV[2])
|
|
317
|
+
return v`
|
|
195
318
|
|
|
196
319
|
export const versions = createVersions({
|
|
197
320
|
read: async (names) => {
|
|
198
321
|
const values = await redis.mget(names.map(key))
|
|
199
322
|
return Object.fromEntries(names.map((name, i) => [name, Number(values[i] ?? 0)]))
|
|
200
323
|
},
|
|
201
|
-
|
|
324
|
+
// One past where it was, or now if that is larger - see "Cleaning up".
|
|
325
|
+
bump: async (names) => {
|
|
326
|
+
await Promise.all(names.map((name) => redis.eval(BUMP, 1, key(name), Date.now(), 30 * 86400)))
|
|
327
|
+
},
|
|
202
328
|
// Instant across instances: a subscriber connection hears every bump.
|
|
203
329
|
listen: (wake) => subscriber.subscribe('rsc:versions', wake),
|
|
204
330
|
notify: () => redis.publish('rsc:versions', ''),
|
|
@@ -209,7 +335,12 @@ export const versions = createVersions({
|
|
|
209
335
|
parameters - node-postgres, mysql2, Kysely, Knex, D1 - and `createVersions`
|
|
210
336
|
takes anything else.
|
|
211
337
|
|
|
212
|
-
Install it in every process - the web server once, before the first request
|
|
338
|
+
Install it in every process - the web server once, before the first request.
|
|
339
|
+
`register()` also runs during the build, and that is fine: a store opens no
|
|
340
|
+
connection until a tab is actually waiting on it, and postgres.js connects
|
|
341
|
+
lazily, so a build never holds one open. Keep anything that connects eagerly
|
|
342
|
+
out of `register()`, or the build waits on it and never exits.
|
|
343
|
+
|
|
213
344
|
|
|
214
345
|
```ts title="src/instrumentation.ts"
|
|
215
346
|
import { installVersionSource } from '@rsc-kit/core/changed'
|
|
@@ -242,42 +373,195 @@ to it can say a name changed:
|
|
|
242
373
|
|
|
243
374
|
```sql
|
|
244
375
|
-- Postgres
|
|
245
|
-
INSERT INTO rsc_versions (name, version)
|
|
246
|
-
|
|
376
|
+
INSERT INTO rsc_versions (name, version)
|
|
377
|
+
VALUES ('restoration:42', (EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint)
|
|
378
|
+
ON CONFLICT (name) DO UPDATE SET version = GREATEST(rsc_versions.version + 1, EXCLUDED.version);
|
|
247
379
|
NOTIFY rsc_versions; -- optional: wakes the listening servers at once
|
|
248
380
|
|
|
249
|
-
-- MySQL
|
|
250
|
-
INSERT INTO rsc_versions (name, version)
|
|
251
|
-
|
|
381
|
+
-- MySQL, MariaDB
|
|
382
|
+
INSERT INTO rsc_versions (name, version)
|
|
383
|
+
VALUES ('restoration:42', FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000))
|
|
384
|
+
ON DUPLICATE KEY UPDATE version = GREATEST(version + 1, VALUES(version));
|
|
252
385
|
|
|
253
386
|
-- SQLite
|
|
254
|
-
INSERT INTO rsc_versions (name, version)
|
|
255
|
-
|
|
387
|
+
INSERT INTO rsc_versions (name, version)
|
|
388
|
+
VALUES ('restoration:42', CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))
|
|
389
|
+
ON CONFLICT (name) DO UPDATE SET version = MAX(version + 1, excluded.version);
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
The new version is one past the old, or the current time in milliseconds if
|
|
393
|
+
that is larger - so a version never repeats, which is what makes it safe to
|
|
394
|
+
delete old rows ([Cleaning up](#cleaning-up)). A writer that still does
|
|
395
|
+
`version + 1` keeps working, since servers only compare; it is only cleanup
|
|
396
|
+
that needs the time. Go's `SQLVersions` uses the same table, so a Go service
|
|
397
|
+
and a JavaScript app can share one.
|
|
398
|
+
|
|
399
|
+
**LISTEN through an ORM, or another driver.** An ORM builds queries; listening
|
|
400
|
+
is its driver's job, so pass `listen` and `notify` from the client underneath -
|
|
401
|
+
the one you handed the ORM:
|
|
402
|
+
|
|
403
|
+
```ts title="src/versions.ts"
|
|
404
|
+
import { SQL } from 'bun'
|
|
405
|
+
import { drizzle } from 'drizzle-orm/bun-sql'
|
|
406
|
+
|
|
407
|
+
const client = new SQL(process.env.DATABASE_URL!)
|
|
408
|
+
export const db = drizzle({ client })
|
|
409
|
+
|
|
410
|
+
export const versions = createVersions({
|
|
411
|
+
read, // the Drizzle read and bump above
|
|
412
|
+
bump,
|
|
413
|
+
listen: (wake) => client.listen('rsc_versions', wake),
|
|
414
|
+
notify: () => client.notify('rsc_versions', ''),
|
|
415
|
+
})
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
On node-postgres (`pg`), keep one client for listening alone:
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
const listener = new pg.Client(process.env.DATABASE_URL)
|
|
422
|
+
await listener.connect()
|
|
423
|
+
|
|
424
|
+
listen: async (wake) => {
|
|
425
|
+
listener.on('notification', wake)
|
|
426
|
+
await listener.query('LISTEN rsc_versions')
|
|
427
|
+
},
|
|
428
|
+
notify: () => pool.query('NOTIFY rsc_versions'),
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
A `NOTIFY` sent inside a transaction is only delivered when it commits, so a
|
|
432
|
+
listening tab is never woken before the data it will read is there - the
|
|
433
|
+
[after-commit](#say-it-after-the-write-commits) rule, kept by the database.
|
|
434
|
+
Redis has the same shape with SUBSCRIBE and PUBLISH (the example above).
|
|
435
|
+
|
|
436
|
+
### The table
|
|
437
|
+
|
|
438
|
+
A shared store keeps one row per name. Your app owns the table: rsc-kit never
|
|
439
|
+
creates, alters or migrates it at runtime - make it with your own migrations,
|
|
440
|
+
in your own schema, with whatever your ORM calls these types. (Laravel ships
|
|
441
|
+
one you publish into your own migrations, `--tag=rsc-migrations`; after that
|
|
442
|
+
it is yours too.)
|
|
443
|
+
|
|
444
|
+
| Column | Type | |
|
|
445
|
+
| --- | --- | --- |
|
|
446
|
+
| `name` | text, `PRIMARY KEY` (or a unique index) | the name a section refreshes on |
|
|
447
|
+
| `version` | `BIGINT NOT NULL` | when it last changed, in ms - or one past the last value, if that is larger |
|
|
448
|
+
|
|
449
|
+
```sql
|
|
450
|
+
-- Postgres, SQLite
|
|
451
|
+
CREATE TABLE rsc_versions (name TEXT PRIMARY KEY, version BIGINT NOT NULL);
|
|
452
|
+
-- MySQL: an indexed TEXT needs a length
|
|
453
|
+
CREATE TABLE rsc_versions (name VARCHAR(255) PRIMARY KEY, version BIGINT NOT NULL);
|
|
256
454
|
```
|
|
257
455
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
456
|
+
- **Extra columns are fine** - an `updated_at`, say - as long as each has a
|
|
457
|
+
default: rsc-kit and the documented upserts write only `name` and `version`.
|
|
458
|
+
- **Another name is fine:** `sqlVersions({ table })`, `postgresVersions(sql,
|
|
459
|
+
{ table })`, Go's `SQLVersions{Table}`, Laravel's `RSC_VERSIONS_TABLE`.
|
|
460
|
+
- **The NOTIFY channel** is `rsc_versions` whatever the table is called;
|
|
461
|
+
`postgresVersions(sql, { channel })` changes it.
|
|
462
|
+
- The same table works for Go's `SQLVersions` and Laravel's
|
|
463
|
+
`RSC_VERSIONS=database`, so a JavaScript app, a Go service and a PHP job
|
|
464
|
+
can share one.
|
|
465
|
+
|
|
466
|
+
### Cleaning up
|
|
467
|
+
|
|
468
|
+
A row is kept for every name that ever changed. For a fixed set of names -
|
|
469
|
+
`team:1:repos` - that is nothing; for one per order, job or restoration it
|
|
470
|
+
grows for ever. Delete the old ones on a schedule:
|
|
471
|
+
|
|
472
|
+
```ts
|
|
473
|
+
await versions.prune() // sqlVersions, postgresVersions: not changed in 30 days
|
|
474
|
+
await versions.prune(7 * 86400_000) // or any age, in ms
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
or from anything that can run SQL - the version is when the name last changed:
|
|
478
|
+
|
|
479
|
+
```sql
|
|
480
|
+
DELETE FROM rsc_versions WHERE version < (EXTRACT(EPOCH FROM now() - interval '30 days') * 1000)::bigint;
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
**Deleting is always safe.** A deleted name reads as 0, so a tab still
|
|
484
|
+
holding it sees a different version and refreshes once; changed again, the
|
|
485
|
+
name comes back at the current time - never at a value a tab is holding.
|
|
486
|
+
That is why a bump moves to the time rather than counting: a counter
|
|
487
|
+
restarted at 0 climbs back to values tabs still hold, and they would miss
|
|
488
|
+
the change.
|
|
489
|
+
|
|
490
|
+
The rest clean up on their own: `memoryVersions()` forgets a name not changed
|
|
491
|
+
in 30 days (`{ forgetAfter }`), the Redis example's keys expire, and the
|
|
492
|
+
server forgets every name the moment no open tab is watching it. Laravel and
|
|
493
|
+
Go have the same: `php artisan rsc:prune-versions` and `SQLVersions.Prune`.
|
|
494
|
+
|
|
495
|
+
If a store of your own counts with `+ 1`, use `nextVersion(current)` from
|
|
496
|
+
`@rsc-kit/core/changed` instead before you start deleting.
|
|
497
|
+
|
|
498
|
+
### Say it after the write commits
|
|
499
|
+
|
|
500
|
+
A tab that hears a name moved asks for its section at once. If the write
|
|
501
|
+
that changed the data has not committed yet, that render reads the old data -
|
|
502
|
+
and the tab is now at the new version, so it will not ask again until the
|
|
503
|
+
next change. Inside a transaction, say it changed **after** the transaction,
|
|
504
|
+
not inside it:
|
|
505
|
+
|
|
506
|
+
```ts
|
|
507
|
+
await db.transaction(async (tx) => {
|
|
508
|
+
await tx.insert(conversations).values(row)
|
|
509
|
+
// not here: this transaction may still roll back, and has not committed
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
await changed(`agent:${row.agentId}:conversations`) // here
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
The same goes for a worker writing the table directly: in the same
|
|
516
|
+
transaction as the data, or after it - never before. In Laravel,
|
|
517
|
+
`DB::afterCommit(fn () => Rsc::changed(...))` does this inside a transaction.
|
|
261
518
|
|
|
262
519
|
**With a Go or Laravel backend:** the backend keeps them - a table in Go, the
|
|
263
|
-
cache in Laravel - and you install nothing here. The
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
520
|
+
cache in Laravel - and you install nothing here. The backend owns the data,
|
|
521
|
+
so it answers for its versions too: the renderer asks it which moved, once
|
|
522
|
+
for every tab it is serving. Go holds that question until a version moves,
|
|
523
|
+
so tabs hear at once. PHP cannot hold a request open, so Laravel answers at
|
|
524
|
+
once and is asked again about every two seconds while any tab is watching:
|
|
525
|
+
one small request - a cache read - per renderer process, however many tabs.
|
|
526
|
+
For almost any app that is nothing, and a change reaches the tabs within
|
|
527
|
+
about two seconds - or at once, with Laravel's [broadcasting](/hosts/laravel#saying-data-changed).
|
|
528
|
+
|
|
529
|
+
### Waking the renderer
|
|
530
|
+
|
|
531
|
+
Whatever keeps the versions, the renderer has to learn *when* to ask which
|
|
532
|
+
moved. Go holds the question until one does; a store that listens -
|
|
533
|
+
Postgres's LISTEN, Redis's pub/sub - wakes it; otherwise it asks on a timer.
|
|
534
|
+
Anything else that can say "something changed" can wake it too:
|
|
269
535
|
|
|
270
536
|
```ts title="src/instrumentation.ts"
|
|
271
|
-
import {
|
|
272
|
-
import postgres from 'postgres'
|
|
537
|
+
import { listenToBroadcast, wakeOn } from '@rsc-kit/core/changed'
|
|
273
538
|
|
|
274
539
|
export function register() {
|
|
275
|
-
|
|
540
|
+
// A broadcast server - anything speaking the Pusher protocol.
|
|
541
|
+
wakeOn(listenToBroadcast({ url: process.env.RSC_BROADCAST_URL!, key: process.env.RSC_BROADCAST_KEY! }))
|
|
542
|
+
|
|
543
|
+
// Or a Redis channel your backend publishes to.
|
|
544
|
+
wakeOn((wake) => subscriber.subscribe('rsc:changed', wake))
|
|
276
545
|
}
|
|
277
546
|
```
|
|
278
547
|
|
|
279
|
-
|
|
280
|
-
|
|
548
|
+
`RSC_BROADCAST_URL` and `RSC_BROADCAST_KEY` in the renderer's environment do
|
|
549
|
+
the first with no code. A wake carries nothing - no names, no data - so it
|
|
550
|
+
can travel over a public channel: on a wake the renderer asks, as it always
|
|
551
|
+
does, and the versions say which names moved for whom. With something to wake
|
|
552
|
+
it, the renderer asks on its timer only every thirty seconds, as a safety net
|
|
553
|
+
for a dropped connection. Listeners start with the first watching tab, never
|
|
554
|
+
in a build.
|
|
555
|
+
|
|
556
|
+
**Private data stays private.** A wake decides nothing about who sees what.
|
|
557
|
+
On a wake the renderer asks which of *its tabs'* names moved - names each tab
|
|
558
|
+
got, signed, from its own render - and a tab that has one refreshes its
|
|
559
|
+
section through its own visitor's session and guards. Ada's `inbox:7` is never
|
|
560
|
+
a name Ben's tab holds, so a change to it never refreshes his page, broadcast
|
|
561
|
+
or not. All a public channel shows a subscriber is that the app changed and
|
|
562
|
+
when; if even that timing is sensitive, keep the wake on a channel only the
|
|
563
|
+
renderer can reach - a Redis channel, through `wakeOn` - rather than a
|
|
564
|
+
broadcast.
|
|
281
565
|
|
|
282
566
|
### The signing secret
|
|
283
567
|
|
|
@@ -287,15 +571,32 @@ instance has to sign with the same key, or a tab served by one is refused by
|
|
|
287
571
|
the next and never refreshes:
|
|
288
572
|
|
|
289
573
|
```sh
|
|
290
|
-
RSC_SIGNING_SECRET=… # the same on every instance
|
|
574
|
+
RSC_SIGNING_SECRET=… # the same on every instance: openssl rand -base64 32
|
|
291
575
|
```
|
|
292
576
|
|
|
293
577
|
It is its own secret, with or without a backend - not the backend's
|
|
294
578
|
`RSC_HOST_CALL_SECRET`, which authorises a different thing. In production, an
|
|
295
579
|
app that uses `refreshOn` without it refuses to serve, saying what to set: a
|
|
296
|
-
random key per instance would fail silently.
|
|
580
|
+
random key per instance would fail silently.
|
|
581
|
+
|
|
582
|
+
That refusal comes on the first request, because on some platforms - Workers
|
|
583
|
+
- the environment is only there once a request arrives. Under Kubernetes or
|
|
584
|
+
any orchestrator with a readiness check, that is too late: the pod is already
|
|
585
|
+
taking traffic. Make the deploy itself fail instead, from `register()`, which
|
|
586
|
+
runs before the server takes a request:
|
|
587
|
+
|
|
588
|
+
```ts title="src/instrumentation.ts"
|
|
589
|
+
import { assertSigningSecret } from '@rsc-kit/core/changed'
|
|
590
|
+
|
|
591
|
+
export function register() {
|
|
592
|
+
assertSigningSecret() // throws in production without RSC_SIGNING_SECRET
|
|
593
|
+
}
|
|
594
|
+
``` In development a random key is
|
|
297
595
|
fine, and is what you get.
|
|
298
596
|
|
|
597
|
+
How to generate it, where to keep it, and what rotating it does:
|
|
598
|
+
[Secrets](/hosts/deployment#secrets).
|
|
599
|
+
|
|
299
600
|
Names are signed when the page is served, never at build: a page stored at
|
|
300
601
|
build keeps everything else stored, and the part that carries the names is
|
|
301
602
|
filled per request. So the build does not need the secret.
|
|
@@ -342,7 +643,8 @@ is shown, `rpc()` calls included: they run with the first visitor's session.
|
|
|
342
643
|
|
|
343
644
|
- **Per tab:** one open connection while the page is visible, closed when the
|
|
344
645
|
tab is hidden and reopened when it is seen again. Idle, it carries a
|
|
345
|
-
keepalive line every
|
|
646
|
+
keepalive line every eight seconds ([set it lower](/hosts/deployment#streams-and-idle-timeouts)
|
|
647
|
+
under a shorter idle timeout); a change is one line of about forty
|
|
346
648
|
bytes.
|
|
347
649
|
- **Per change:** each tab showing the name asks for the sections that show
|
|
348
650
|
it, once, however many names moved together. That request is the size of
|
|
@@ -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