@rsc-kit/mcp 0.28.0 → 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.
@@ -0,0 +1,885 @@
1
+ # Live data
2
+
3
+ > Keep what a page shows current as the data changes - refreshOn, events, polling.
4
+
5
+ A page rendered on the server shows the data as it was. When it changes
6
+ while someone is looking - a teammate edits it, a webhook lands, a job
7
+ finishes - there are three ways to show the change, and which to use depends
8
+ on where the change comes from.
9
+
10
+ | | The change comes from | What travels | Use it for |
11
+ | --- | --- | --- | --- |
12
+ | [`refreshOn`](#refresh-a-section-when-its-data-changes) | your backend: a webhook, a job, another visitor's action | a one-line signal; the section re-renders through its own queries and guards | almost everything - dashboards, inboxes, build status, anything a backend can say changed |
13
+ | [`events()` + `useEvents`](/guides/api-routes#a-route-that-streams) | a stream of values: prices, progress, logs | each value, as it is produced | data that *is* a stream, rendered on the client |
14
+ | [`usePolling`](/guides/queries#live-data) | nowhere you can hook: a third-party API | the whole answer, every interval | values with no change signal at all |
15
+
16
+ Inside the tab that made a change, none of these is needed: an action says
17
+ what it changed, and its answer carries those sections back -
18
+ [revalidate](/guides/sections#refreshing-it).
19
+
20
+ ## Refresh a section when its data changes
21
+
22
+ An action can say what it changed because it is the thing changing it. A
23
+ webhook, a job finishing in a worker, another visitor's action - none of
24
+ those is in the tab that is showing the data. For those a section says what
25
+ it depends on, by name:
26
+
27
+ ```tsx title="src/app/t/[team]/repos.section.tsx"
28
+ export default section('repos', Repos, {
29
+ refreshOn: ({ params }) => [`team:${params.team}:repos`],
30
+ })
31
+ ```
32
+
33
+ and whatever changes the data says so, from wherever it is:
34
+
35
+ ```ts title="src/app/api/github/webhook/route.ts"
36
+ import { changed } from '@rsc-kit/core/changed'
37
+
38
+ export async function POST(request: Request) {
39
+ const { team, repos } = await request.json()
40
+ await saveRepos(team, repos)
41
+ await changed(`team:${team}:repos`)
42
+ return new Response(null, { status: 204 })
43
+ }
44
+ ```
45
+
46
+ Every open tab showing the section refreshes it - the same refresh as above,
47
+ with nothing polling and no client code. `refreshOn` is a list of names, or a
48
+ function of the section's props - the page's `params` and `searchParams` -
49
+ that makes them. A page can declare its own with `export const refreshOn`,
50
+ and a change refreshes the page as `refresh('page')` does.
51
+
52
+ The function is given the page's `params` and `searchParams` already awaited,
53
+ and runs per request, so it can read the request too - a name for the person
54
+ signed in is the usual one on a dashboard:
55
+
56
+ ```tsx title="src/app/dashboard/page.tsx"
57
+ import { cookies } from '@rsc-kit/core/request'
58
+
59
+ export const refreshOn = async () => [`inbox:${(await cookies()).get('agent')?.value}`]
60
+ ```
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
+
77
+ That is the whole feature for an app that is only JavaScript. With a Go or
78
+ Laravel backend the backend says it instead -
79
+ [`reg.Changed(ctx, …)`](/hosts/go#saying-data-changed) or
80
+ [`Rsc::changed(…)`](/hosts/laravel#saying-data-changed) - and the page is the same.
81
+
82
+ ## Whose data a name shows
83
+
84
+ A name is a signal, not data and not a permission. Hearing that
85
+ `conversation:42` changed tells a tab only to ask for its section again; what
86
+ comes back is rendered for that tab's visitor, through their own session, by
87
+ the same queries and guards as the first render. So two people never see each
88
+ other's data through a name - they see whatever their own render shows.
89
+
90
+ A chat between Ada and Ben:
91
+
92
+ ```tsx title="src/app/chats/[id]/messages.section.tsx"
93
+ export default section('messages', async function Messages({ params }) {
94
+ const { id } = await params
95
+ const messages = await rpc('Chats.messages', id) // the backend checks the viewer is in it
96
+
97
+ return <Thread messages={messages} />
98
+ }, { refreshOn: ({ params }) => [`conversation:${params.id}`] })
99
+ ```
100
+
101
+ ```tsx title="src/app/inbox/page.tsx"
102
+ // Each person's own list of conversations, with unread counts.
103
+ export const refreshOn = async () => [`inbox:${await currentUserId()}`]
104
+ ```
105
+
106
+ ```go
107
+ // Ada sends a message.
108
+ reg.Changed(ctx, "conversation:42", "inbox:"+ben.ID, "inbox:"+ada.ID)
109
+ ```
110
+
111
+ - Both have conversation 42 open, so both tabs refresh it, and each render
112
+ asks `Chats.messages` as its own visitor. If the thread shows "you" and
113
+ "them", each sees it from their side.
114
+ - Ben's inbox refreshes; so does Ada's. Nobody else's does: their pages were
115
+ rendered with their own `inbox:` names.
116
+ - Someone who is not in the conversation cannot open the page - its guards
117
+ refuse - so no render ever hands them `conversation:42` to listen for. A
118
+ tab can only listen for names its page was rendered with: the renderer
119
+ signs them, and a name a browser makes up is refused.
120
+
121
+ **Name the data, as narrowly as the data.** One name per thing that changes
122
+ together: `conversation:{id}`, `inbox:{userId}`, `team:{id}:repos`. Two
123
+ different things sharing one name only refresh too often; one thing split
124
+ across names refreshes too little. Use ids, not emails or titles - a name
125
+ sits in the page and in the stream's url, and its version says when it last
126
+ changed, so a name must not be something its visitor should not see.
127
+
128
+ A section that shows anything per visitor - "you", their unread count, what
129
+ they may do - must never be `shared`: that is the one place a render is
130
+ handed from one visitor to another.
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
+
181
+ ## How a tab finds out
182
+
183
+ Each name has a version, a number that moves when something says the name
184
+ changed. Nothing else travels - not what changed, not the data.
185
+
186
+ 1. The page renders, reads the version of each name it shows, and hands them
187
+ to the tab, signed.
188
+ 2. The tab opens one stream, `/_rsc/changes`, for every name on the page. Only
189
+ a page with `refreshOn` opens one, and only while the tab is visible.
190
+ 3. The server asks where the versions live - "which of these differ from what
191
+ I hold?" - once for every open tab it is serving, and again on an interval.
192
+ 4. When a version has moved, the stream says so in one line, and the tab asks
193
+ for exactly the sections showing that name, the way `refresh('repos')` does.
194
+
195
+ A name belongs to the data, not to a section or a page. Any number of
196
+ sections, on any number of pages, can refresh on the same name: one
197
+ `changed('team:1:repos')` moves its version once, and every open tab showing
198
+ any of them refreshes the ones it has. Section *names* are separate - two
199
+ pages may both have a section called `stats`; each is resolved through its own
200
+ page, and a shared render is never handed from one page to the other.
201
+
202
+ The data is never pushed: the section is rendered fresh, through its own
203
+ queries and its own guards, as any refresh is. That is also why this runs
204
+ on a Worker - the stream holds no state, and a version is a read.
205
+
206
+ ## Where the versions live
207
+
208
+ **One server, no backend:** in that server's memory. Nothing to set up - but
209
+ in production rsc-kit says so in its log, once, because the same default
210
+ fails silently the moment there are two instances. If the deploy really is
211
+ one instance, say so and the warning goes:
212
+
213
+ ```ts title="src/instrumentation.ts"
214
+ import { installVersionSource, memoryVersions } from '@rsc-kit/core/changed'
215
+
216
+ export function register() {
217
+ installVersionSource(memoryVersions()) // one instance, on purpose
218
+ }
219
+ ```
220
+
221
+ **More than one instance, or a worker process:** this is the case to
222
+ plan for. In-memory versions fail silently the moment a load balancer adds a
223
+ second instance - a change made on one never reaches the tabs held by the
224
+ other - and a job finishes in a process that serves no pages at all. Keep
225
+ the versions somewhere they all share - any database you already have, through
226
+ the client you already use. rsc-kit ships no driver; you hand it yours:
227
+
228
+ | Your database | The store | Another instance's change arrives |
229
+ | --- | --- | --- |
230
+ | 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 |
231
+ | Postgres, with another client | `sqlVersions({ query, placeholder: (n) => '$' + n })` | within a second |
232
+ | MySQL, MariaDB | `sqlVersions({ query, dialect: 'mysql' })` | within a second; one statement per change |
233
+ | SQLite, libSQL, Turso, D1 | `sqlVersions({ query, dialect: 'sqlite' })` | within a second; one statement per change |
234
+ | Redis | `createVersions({ read, bump, listen, notify })` | at once, through pub/sub |
235
+ | An ORM: Drizzle, Prisma, Kysely, Knex | `createVersions` over its query builder, or `sqlVersions` over its raw query | as its database |
236
+
237
+ `sqlVersions` takes your driver as one function of SQL text and parameters.
238
+ Say which database with `dialect`, and a change is one upsert of every name it
239
+ moves; leave it out and it uses only SQL every database has, a read and a
240
+ write per name:
241
+
242
+ ```ts title="src/versions.ts"
243
+ import { sqlVersions } from '@rsc-kit/core/changed'
244
+
245
+ // MySQL, mysql2
246
+ export const versions = sqlVersions({ dialect: 'mysql', query: async (text, params) => (await pool.query(text, params))[0] })
247
+
248
+ // SQLite, bun:sqlite or better-sqlite3
249
+ export const versions = sqlVersions({ dialect: 'sqlite', query: async (text, params) => db.query(text).all(...params) })
250
+
251
+ // libSQL / Turso
252
+ export const versions = sqlVersions({ dialect: 'sqlite', query: async (sql, args) => (await client.execute({ sql, args })).rows })
253
+ ```
254
+
255
+ and with postgres.js it is one line:
256
+
257
+ ```ts title="src/versions.ts"
258
+ import postgres from 'postgres'
259
+ import { postgresVersions } from '@rsc-kit/core/changed'
260
+
261
+ // A function, so the store - and its database client - is made on first use.
262
+ export const versions = () => postgresVersions(postgres(process.env.DATABASE_URL!))
263
+ ```
264
+
265
+ A SQLite file is shared by the processes on one machine - a web server and a
266
+ worker on the same box. Instances on several machines need a database they
267
+ all reach over the network. The table every one of these reads is described
268
+ [below](#the-table).
269
+
270
+ postgres.js and `Bun.sql` both LISTEN, and `postgresVersions` uses it when the
271
+ client has it: a change on one instance - or in a worker - reaches the tabs on
272
+ every other at once. A listening server still reads the table now and then, in
273
+ case its listening connection dropped without a word; it is not how changes
274
+ normally arrive.
275
+
276
+ **Any database, any ORM, or no SQL at all.** rsc-kit needs three things from
277
+ your storage - read some versions, move some versions, and, if it can,
278
+ say the moment one moved - and does the comparing, waiting and waking itself.
279
+ `createVersions` takes exactly those:
280
+
281
+ With Drizzle, use its query builder rather than raw SQL - the table is one
282
+ more table in your schema:
283
+
284
+ ```ts title="src/db/schema.ts"
285
+ import { bigint, pgTable, text } from 'drizzle-orm/pg-core'
286
+
287
+ export const rscVersions = pgTable('rsc_versions', {
288
+ name: text('name').primaryKey(),
289
+ version: bigint('version', { mode: 'number' }).notNull(),
290
+ })
291
+ ```
292
+
293
+ ```ts title="src/versions.ts"
294
+ import { inArray, sql } from 'drizzle-orm'
295
+ import { createVersions } from '@rsc-kit/core/changed'
296
+ import { db } from './db'
297
+ import { rscVersions } from './db/schema'
298
+
299
+ export const versions = createVersions({
300
+ read: async (names) =>
301
+ Object.fromEntries(
302
+ (await db.select().from(rscVersions).where(inArray(rscVersions.name, names)))
303
+ .map((row) => [row.name, Number(row.version)]),
304
+ ),
305
+ bump: async (names) => {
306
+ await db.insert(rscVersions)
307
+ .values(names.map((name) => ({ name, version: Date.now() })))
308
+ .onConflictDoUpdate({
309
+ target: rscVersions.name,
310
+ set: { version: sql`GREATEST(${rscVersions.version} + 1, excluded.version)` },
311
+ })
312
+ },
313
+ })
314
+ ```
315
+
316
+ ```ts title="Prisma"
317
+ import { sqlVersions } from '@rsc-kit/core/changed'
318
+
319
+ // Raw SQL through Prisma; the table is a model in its schema - see "The table" below.
320
+ export const versions = sqlVersions({
321
+ query: (text, params) => prisma.$queryRawUnsafe(text, ...params),
322
+ placeholder: (n) => '$' + n, // Postgres; leave out for MySQL and SQLite
323
+ })
324
+ ```
325
+
326
+ ```ts title="Redis"
327
+ import { createVersions } from '@rsc-kit/core/changed'
328
+
329
+ const key = (name: string) => 'rsc:' + name
330
+ const BUMP = `local v = math.max(tonumber(redis.call('GET', KEYS[1]) or '0') + 1, tonumber(ARGV[1]))
331
+ redis.call('SET', KEYS[1], v, 'EX', ARGV[2])
332
+ return v`
333
+
334
+ export const versions = createVersions({
335
+ read: async (names) => {
336
+ const values = await redis.mget(names.map(key))
337
+ return Object.fromEntries(names.map((name, i) => [name, Number(values[i] ?? 0)]))
338
+ },
339
+ // One past where it was, or now if that is larger - see "Cleaning up".
340
+ bump: async (names) => {
341
+ await Promise.all(names.map((name) => redis.eval(BUMP, 1, key(name), Date.now(), 30 * 86400)))
342
+ },
343
+ // Instant across instances: a subscriber connection hears every bump.
344
+ listen: (wake) => subscriber.subscribe('rsc:versions', wake),
345
+ notify: () => redis.publish('rsc:versions', ''),
346
+ })
347
+ ```
348
+
349
+ `sqlVersions({ query })` takes any SQL driver as a function of text and
350
+ parameters - node-postgres, mysql2, Kysely, Knex, D1 - and `createVersions`
351
+ takes anything else.
352
+
353
+ Install it in every process - the web server once, before the first request.
354
+ Pass the function, not a store: `register()` also runs during the build,
355
+ usually without the database's settings, and a store made there makes its
356
+ client there too. Given a function, rsc-kit makes the store the first time
357
+ versions are read - which a build never does.
358
+
359
+
360
+ ```ts title="src/instrumentation.ts"
361
+ import { installVersionSource } from '@rsc-kit/core/changed'
362
+ import { versions } from './versions'
363
+
364
+ export function register() {
365
+ installVersionSource(versions)
366
+ }
367
+ ```
368
+
369
+ and the worker, which then says what it finished:
370
+
371
+ ```ts title="worker.ts"
372
+ import { changed, installVersionSource } from '@rsc-kit/core/changed'
373
+ import { versions } from './src/versions'
374
+
375
+ installVersionSource(versions)
376
+
377
+ queue.on('restoration.done', async (job) => {
378
+ await changed(`restoration:${job.id}`)
379
+ })
380
+ ```
381
+
382
+ Without `listen` and `notify` - MySQL, SQLite, or a driver that cannot listen -
383
+ a waiting server reads the table every second instead (`poll` changes it).
384
+
385
+ **From any process, without rsc-kit:** the table is the contract. A cron job,
386
+ a service in another language, a database trigger - anything that can write
387
+ to it can say a name changed:
388
+
389
+ ```sql
390
+ -- Postgres
391
+ INSERT INTO rsc_versions (name, version)
392
+ VALUES ('restoration:42', (EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint)
393
+ ON CONFLICT (name) DO UPDATE SET version = GREATEST(rsc_versions.version + 1, EXCLUDED.version);
394
+ NOTIFY rsc_versions; -- optional: wakes the listening servers at once
395
+
396
+ -- MySQL, MariaDB
397
+ INSERT INTO rsc_versions (name, version)
398
+ VALUES ('restoration:42', FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000))
399
+ ON DUPLICATE KEY UPDATE version = GREATEST(version + 1, VALUES(version));
400
+
401
+ -- SQLite
402
+ INSERT INTO rsc_versions (name, version)
403
+ VALUES ('restoration:42', CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))
404
+ ON CONFLICT (name) DO UPDATE SET version = MAX(version + 1, excluded.version);
405
+ ```
406
+
407
+ The new version is one past the old, or the current time in milliseconds if
408
+ that is larger - so a version never repeats, which is what makes it safe to
409
+ delete old rows ([Cleaning up](#cleaning-up)). A writer that still does
410
+ `version + 1` keeps working, since servers only compare; it is only cleanup
411
+ that needs the time. Go's `SQLVersions` uses the same table, so a Go service
412
+ and a JavaScript app can share one.
413
+
414
+ **LISTEN through an ORM, or another driver.** An ORM builds queries; listening
415
+ is its driver's job, so pass `listen` and `notify` from the client underneath -
416
+ the one you handed the ORM:
417
+
418
+ ```ts title="src/versions.ts"
419
+ import { SQL } from 'bun'
420
+ import { drizzle } from 'drizzle-orm/bun-sql'
421
+
422
+ const client = new SQL(process.env.DATABASE_URL!)
423
+ export const db = drizzle({ client })
424
+
425
+ export const versions = createVersions({
426
+ read, // the Drizzle read and bump above
427
+ bump,
428
+ listen: (wake) => client.listen('rsc_versions', wake),
429
+ notify: () => client.notify('rsc_versions', ''),
430
+ })
431
+ ```
432
+
433
+ On node-postgres (`pg`), keep one client for listening alone:
434
+
435
+ ```ts
436
+ const listener = new pg.Client(process.env.DATABASE_URL)
437
+ await listener.connect()
438
+
439
+ listen: async (wake) => {
440
+ listener.on('notification', wake)
441
+ await listener.query('LISTEN rsc_versions')
442
+ },
443
+ notify: () => pool.query('NOTIFY rsc_versions'),
444
+ ```
445
+
446
+ A `NOTIFY` sent inside a transaction is only delivered when it commits, so a
447
+ listening tab is never woken before the data it will read is there - the
448
+ [after-commit](#say-it-after-the-write-commits) rule, kept by the database.
449
+ Redis has the same shape with SUBSCRIBE and PUBLISH (the example above).
450
+
451
+ ## Connection poolers
452
+
453
+ A pooler in **transaction mode** - PgBouncer, and the pooled connection
454
+ DigitalOcean, Supabase and Neon offer - hands the database connection back
455
+ after every statement. Reads and writes are fine; even sending a NOTIFY is.
456
+ But a LISTEN lands on a connection the pooler takes back, and the listener
457
+ hears nothing - silently. Changes from other instances would then wait for the
458
+ thirty-second safety net.
459
+
460
+ rsc-kit checks: as `postgresVersions` starts listening it sends itself a probe,
461
+ and if the probe does not come back it says so in the log - naming the cause
462
+ and the fix - and goes back to reading every second. The fix is to listen
463
+ through a connection that keeps its session - a direct one, or a pool in
464
+ session mode - and leave everything else on the pool:
465
+
466
+ ```ts title="src/versions.ts"
467
+ import { SQL } from 'bun'
468
+ import { postgresVersions } from '@rsc-kit/core/changed'
469
+
470
+ export const versions = () =>
471
+ postgresVersions(new SQL(process.env.DATABASE_URL!), { // the pool: reads and writes
472
+ listenWith: new SQL(process.env.DATABASE_LISTEN_URL!), // direct: listening only
473
+ })
474
+ ```
475
+
476
+ That is one extra connection per process. On DigitalOcean the direct port is
477
+ 25060 and the pool's 25061; on Supabase, 5432 and 6543. Use the app's own
478
+ database user for it, not the one migrations run as.
479
+
480
+ If direct connections are too scarce to spend one per process, don't listen -
481
+ say so, and rsc-kit reads every second instead, with no probe and no warning:
482
+
483
+ ```ts
484
+ postgresVersions(pool, { listen: false })
485
+ ```
486
+
487
+ That is one small indexed read per second per process, however many tabs it
488
+ holds. A change made in a process still reaches that process's tabs at once;
489
+ one made elsewhere - another instance, the worker - within about a second.
490
+
491
+ `await connection()` in a page is unrelated - despite the name, it is about
492
+ rendering per request, not database connections.
493
+
494
+ ## On Workers and serverless
495
+
496
+ The stream needs nothing kept between requests - names are signed, not
497
+ looked up - so it runs anywhere a response can stream. What differs is that
498
+ there is no single long-lived process:
499
+
500
+ - **Every isolate or instance watches for its own tabs.** Versions in memory
501
+ belong to one isolate, so a `changed()` handled by one never reaches tabs
502
+ held by another. Use a shared store - D1, Postgres, your backend - which the
503
+ production warning asks for if there is none. On Workers, [one hub](#one-hub-on-workers)
504
+ can hold every tab instead.
505
+ - **Connections are cut.** Platforms bound how long a request may run; a
506
+ stream that is cut reconnects on its own, and the tab catches up from its
507
+ versions. On a platform that bills a function for every second it is open,
508
+ each watching tab is billed for as long as it watches.
509
+ - **Listening is unlikely.** Hyperdrive and serverless Postgres drivers go
510
+ through poolers; the probe finds this and falls back to reading every second,
511
+ or say `listen: false` up front.
512
+
513
+ On Workers, bindings come from `cloudflare:workers`, imported inside a
514
+ request - when they exist, and never in the build:
515
+
516
+ ```ts title="src/versions.ts"
517
+ import { sqlVersions } from '@rsc-kit/core/changed'
518
+
519
+ // The binding, imported inside the request: the build prerenders in Node,
520
+ // which runs register() and cannot load cloudflare:workers.
521
+ const db = async () => (await import('cloudflare:workers')).env.DB
522
+
523
+ // D1: the table is the same as anywhere else.
524
+ export const versions = () =>
525
+ sqlVersions({
526
+ dialect: 'sqlite',
527
+ query: async (text, params) => (await (await db()).prepare(text).bind(...params).all()).results,
528
+ })
529
+ ```
530
+
531
+ Then the binding, the table and the secret:
532
+
533
+ ```jsonc title="wrangler.jsonc"
534
+ {
535
+ // Nitro merges this into the Worker's config.
536
+ "d1_databases": [
537
+ { "binding": "DB", "database_name": "my-app", "database_id": "<from wrangler d1 create>", "migrations_dir": "migrations" }
538
+ ]
539
+ }
540
+ ```
541
+
542
+ ```sql title="migrations/0001_rsc_versions.sql"
543
+ CREATE TABLE IF NOT EXISTS rsc_versions (name TEXT PRIMARY KEY, version BIGINT NOT NULL);
544
+ ```
545
+
546
+ ```sh
547
+ npx wrangler d1 create my-app # once; put its id in wrangler.jsonc
548
+ npx wrangler d1 migrations apply DB --remote # --local for wrangler dev
549
+ npx wrangler secret put RSC_SIGNING_SECRET # production; .dev.vars for wrangler dev
550
+ ```
551
+
552
+ and install it as anywhere else - `installVersionSource(versions)` in
553
+ `instrumentation.ts`. A section the store serves needs `await connection()`
554
+ when it reads D1 itself: the data is only there per request.
555
+
556
+ The Cloudflare example's `/live` page is all of this, end to end, and was run
557
+ on `wrangler dev`: a change from its webhook reaches an open tab, and the
558
+ section refreshes with the new value.
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
+
632
+ Where a response cannot stream at all, use `usePolling` instead.
633
+
634
+ ## The table
635
+
636
+ A shared store keeps one row per name. Your app owns the table: rsc-kit never
637
+ creates, alters or migrates it at runtime - make it with your own migrations,
638
+ in your own schema, with whatever your ORM calls these types. (Laravel ships
639
+ one you publish into your own migrations, `--tag=rsc-migrations`; after that
640
+ it is yours too.)
641
+
642
+ | Column | Type | |
643
+ | --- | --- | --- |
644
+ | `name` | text, `PRIMARY KEY` (or a unique index) | the name a section refreshes on |
645
+ | `version` | `BIGINT NOT NULL` | when it last changed, in ms - or one past the last value, if that is larger |
646
+
647
+ ```sql
648
+ -- Postgres, SQLite
649
+ CREATE TABLE rsc_versions (name TEXT PRIMARY KEY, version BIGINT NOT NULL);
650
+ -- MySQL: an indexed TEXT needs a length
651
+ CREATE TABLE rsc_versions (name VARCHAR(255) PRIMARY KEY, version BIGINT NOT NULL);
652
+ ```
653
+
654
+ - **Extra columns are fine** - an `updated_at`, say - as long as each has a
655
+ default: rsc-kit and the documented upserts write only `name` and `version`.
656
+ - **Another name is fine:** `sqlVersions({ table })`, `postgresVersions(sql,
657
+ { table })`, Go's `SQLVersions{Table}`, Laravel's `RSC_VERSIONS_TABLE`.
658
+ - **The NOTIFY channel** is `rsc_versions` whatever the table is called;
659
+ `postgresVersions(sql, { channel })` changes it.
660
+ - The same table works for Go's `SQLVersions` and Laravel's
661
+ `RSC_VERSIONS=database`, so a JavaScript app, a Go service and a PHP job
662
+ can share one.
663
+
664
+ ## Cleaning up
665
+
666
+ A row is kept for every name that ever changed. For a fixed set of names -
667
+ `team:1:repos` - that is nothing; for one per order, job or restoration it
668
+ grows for ever. Delete the old ones on a schedule:
669
+
670
+ ```ts
671
+ await versions.prune() // sqlVersions, postgresVersions: not changed in 30 days
672
+ await versions.prune(7 * 86400_000) // or any age, in ms
673
+ ```
674
+
675
+ or from anything that can run SQL - the version is when the name last changed:
676
+
677
+ ```sql
678
+ DELETE FROM rsc_versions WHERE version < (EXTRACT(EPOCH FROM now() - interval '30 days') * 1000)::bigint;
679
+ ```
680
+
681
+ **Deleting is always safe.** A deleted name reads as 0, so a tab still
682
+ holding it sees a different version and refreshes once; changed again, the
683
+ name comes back at the current time - never at a value a tab is holding.
684
+ That is why a bump moves to the time rather than counting: a counter
685
+ restarted at 0 climbs back to values tabs still hold, and they would miss
686
+ the change.
687
+
688
+ The rest clean up on their own: `memoryVersions()` forgets a name not changed
689
+ in 30 days (`{ forgetAfter }`), the Redis example's keys expire, and the
690
+ server forgets every name the moment no open tab is watching it. Laravel and
691
+ Go have the same: `php artisan rsc:prune-versions` and `SQLVersions.Prune`.
692
+
693
+ If a store of your own counts with `+ 1`, use `nextVersion(current)` from
694
+ `@rsc-kit/core/changed` instead before you start deleting.
695
+
696
+ ## Say it after the write commits
697
+
698
+ A tab that hears a name moved asks for its section at once. If the write
699
+ that changed the data has not committed yet, that render reads the old data -
700
+ and the tab is now at the new version, so it will not ask again until the
701
+ next change. Inside a transaction, say it changed **after** the transaction,
702
+ not inside it:
703
+
704
+ ```ts
705
+ await db.transaction(async (tx) => {
706
+ await tx.insert(conversations).values(row)
707
+ // not here: this transaction may still roll back, and has not committed
708
+ })
709
+
710
+ await changed(`agent:${row.agentId}:conversations`) // here
711
+ ```
712
+
713
+ The same goes for a worker writing the table directly: in the same
714
+ transaction as the data, or after it - never before. In Laravel,
715
+ `DB::afterCommit(fn () => Rsc::changed(...))` does this inside a transaction.
716
+
717
+ **With a Go or Laravel backend:** the backend keeps them - a table in Go, the
718
+ cache in Laravel - and you install nothing here. The backend owns the data,
719
+ so it answers for its versions too: the renderer asks it which moved, once
720
+ for every tab it is serving. Go holds that question until a version moves,
721
+ so tabs hear at once. PHP cannot hold a request open, so Laravel answers at
722
+ once and is asked again about every two seconds while any tab is watching:
723
+ one small request - a cache read - per renderer process, however many tabs.
724
+ For almost any app that is nothing, and a change reaches the tabs within
725
+ about two seconds - or at once, with Laravel's [broadcasting](/hosts/laravel#saying-data-changed).
726
+
727
+ ## Waking the renderer
728
+
729
+ Whatever keeps the versions, the renderer has to learn *when* to ask which
730
+ moved. Go holds the question until one does; a store that listens -
731
+ Postgres's LISTEN, Redis's pub/sub - wakes it; otherwise it asks on a timer.
732
+ Anything else that can say "something changed" can wake it too:
733
+
734
+ ```ts title="src/instrumentation.ts"
735
+ import { listenToBroadcast, wakeOn } from '@rsc-kit/core/changed'
736
+
737
+ export function register() {
738
+ // A broadcast server - anything speaking the Pusher protocol.
739
+ wakeOn(listenToBroadcast({ url: process.env.RSC_BROADCAST_URL!, key: process.env.RSC_BROADCAST_KEY! }))
740
+
741
+ // Or a Redis channel your backend publishes to.
742
+ wakeOn((wake) => subscriber.subscribe('rsc:changed', wake))
743
+ }
744
+ ```
745
+
746
+ `RSC_BROADCAST_URL` and `RSC_BROADCAST_KEY` in the renderer's environment do
747
+ the first with no code. A wake carries nothing - no names, no data - so it
748
+ can travel over a public channel: on a wake the renderer asks, as it always
749
+ does, and the versions say which names moved for whom. With something to wake
750
+ it, the renderer asks on its timer only every thirty seconds, as a safety net
751
+ for a dropped connection. Listeners start with the first watching tab, never
752
+ in a build.
753
+
754
+ **Private data stays private.** A wake decides nothing about who sees what.
755
+ On a wake the renderer asks which of *its tabs'* names moved - names each tab
756
+ got, signed, from its own render - and a tab that has one refreshes its
757
+ section through its own visitor's session and guards. Ada's `inbox:7` is never
758
+ a name Ben's tab holds, so a change to it never refreshes his page, broadcast
759
+ or not. All a public channel shows a subscriber is that the app changed and
760
+ when; if even that timing is sensitive, keep the wake on a channel only the
761
+ renderer can reach - a Redis channel, through `wakeOn` - rather than a
762
+ broadcast.
763
+
764
+ ## The signing secret
765
+
766
+ The names a page hands the tab are signed, so a tab can only listen for the
767
+ names its page was rendered with - a browser cannot make one up. Every
768
+ instance has to sign with the same key, or a tab served by one is refused by
769
+ the next and never refreshes:
770
+
771
+ ```sh
772
+ RSC_SIGNING_SECRET=… # the same on every instance: openssl rand -base64 32
773
+ ```
774
+
775
+ It is its own secret, with or without a backend - not the backend's
776
+ `RSC_HOST_CALL_SECRET`, which authorises a different thing. In production, an
777
+ app that uses `refreshOn` without it refuses to serve, saying what to set: a
778
+ random key per instance would fail silently.
779
+
780
+ That refusal comes on the first request, because on some platforms - Workers
781
+ - the environment is only there once a request arrives. Under Kubernetes or
782
+ any orchestrator with a readiness check, that is too late: the pod is already
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
+ ```
799
+
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.
803
+
804
+ How to generate it, where to keep it, and what rotating it does:
805
+ [Secrets](/hosts/deployment#secrets).
806
+
807
+ Names are signed when the page is served, never at build: a page stored at
808
+ build keeps everything else stored, and the part that carries the names is
809
+ filled per request. So the build does not need the secret.
810
+
811
+ ## When nothing refreshes
812
+
813
+ In development the browser console says what the page is watching, region
814
+ by region, each time that changes:
815
+
816
+ ```
817
+ [rsc-kit] refreshOn watching, by region:
818
+ repos: team:1:repos @3
819
+ page: inbox:ada @0
820
+ ```
821
+
822
+ A region that declared `refreshOn` but is missing from that list rendered no
823
+ names, and the server's log says why, once - including when its function
824
+ returned none at all. And watching **stops while the
825
+ tab is hidden** - a background tab holds no connection - which the console
826
+ also says. Nothing is missed: shown again, the tab reconnects at the versions
827
+ it holds and refreshes whatever moved meanwhile.
828
+
829
+ ## When many tabs show the same thing
830
+
831
+ When a name moves, every tab showing it asks for its sections again - 500 tabs
832
+ on one team's board are 500 renders. Each tab's render can differ by who is
833
+ looking, so by default each is its own. A section that is the same for
834
+ everyone allowed to see the page can say so:
835
+
836
+ ```tsx title="src/app/status/board.section.tsx"
837
+ export default section('board', Board, { refreshOn: ['board'], shared: true })
838
+ ```
839
+
840
+ Then the tabs asking because of the same change get one render between them:
841
+ the first tab's request renders it, and every other gets the same bytes.
842
+ Each tab's guards still run on its own request - a visitor who may not see
843
+ the page is still refused - and only a refresh a change triggered is shared,
844
+ so `refresh()` by hand, or after an action, always renders afresh.
845
+
846
+ Never set `shared` on a section that shows anything per visitor - their name,
847
+ their data, what they may do. The first tab's render is what every other tab
848
+ is shown, `rpc()` calls included: they run with the first visitor's session.
849
+
850
+ ## What it costs
851
+
852
+ - **Per tab:** one open connection while the page is visible, closed when the
853
+ tab is hidden and reopened when it is seen again. Idle, it carries a
854
+ keepalive line every eight seconds ([set it lower](/hosts/deployment#streams-and-idle-timeouts)
855
+ under a shorter idle timeout); a change is one line of about forty
856
+ bytes.
857
+ - **Per change:** each tab showing the name asks for the sections that show
858
+ it, once, however many names moved together. That request is the size of
859
+ the section, not the page. A `shared` section renders once per change for
860
+ every tab; any other renders once per tab.
861
+ - **On the server:** one ask of wherever the versions live, covering every tab
862
+ that server holds - a thousand open tabs are one query, not a thousand. The
863
+ ask may wait up to five seconds for a change: a database with `listen`, or a
864
+ Go backend, answers the moment a version moves; a table without it is read
865
+ once a second while the ask waits; Laravel answers at once and is asked again
866
+ two seconds later. Plus one small read when each tab opens its stream.
867
+
868
+ Polling a section every two seconds instead costs a full render of it, per
869
+ tab, every two seconds, whether anything changed or not. The trade is the open
870
+ connection: cheap on Bun, Node and Workers; on a host that bills a function
871
+ for every second it is open, it is a second billed for as long as the tab is.
872
+
873
+ ## Coming from Next.js
874
+
875
+ This is where `revalidateTag` was, and it is not the same thing. Next's tags
876
+ label entries in its Data Cache and `revalidateTag()` throws them away, so the
877
+ *next request* refetches; an open tab learns nothing until it navigates.
878
+ There is no data cache here - every render is fresh - so `refreshOn` names
879
+ what is *on screen*, and `changed()` refreshes every open tab showing it, now.
880
+ A port keeps its names: `tags: ['posts']` on the fetch becomes
881
+ `refreshOn: ['posts']` on the section, and `revalidateTag('posts')` becomes
882
+ `changed('posts')`.
883
+
884
+ A name's version reveals only that something with that name changed. Do not
885
+ put a secret in a name.