@rsc-kit/core 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/changed.js CHANGED
@@ -26,14 +26,52 @@
26
26
  * answers at once, and the watcher asks again on its interval. The protocol
27
27
  * is the same either way, and so is the page.
28
28
  */
29
- import { KEEPALIVE_MS, frame } from "./events.js";
29
+ import { frame, keepaliveMs } from "./events.js";
30
30
  import { HEADER } from "./headers.js";
31
+ import { listenToBroadcast } from "./broadcast.js";
32
+ export { listenToBroadcast } from "./broadcast.js";
31
33
  /** The reserved name a backend answers name versions on. */
32
34
  export const CHANGED_FUNCTION = "__rsc.changed";
33
- /** Versions kept in this process: the source without a backend, and the one tests use. */
34
- export function memoryVersions() {
35
+ /**
36
+ * The version a name moves to: the larger of one past where it was and the
37
+ * current time in milliseconds.
38
+ *
39
+ * A counter would do for "it moved", but a counter repeats once its row is
40
+ * gone - deleted to keep the store small, a name starts again from 0 and
41
+ * climbs back to a value some tab is still holding, and that tab misses the
42
+ * change. A time never comes round again, so a name may be forgotten at any
43
+ * moment: a tab holding the old value sees a different one, and at worst
44
+ * refreshes once for nothing. It is also when the name last moved, which is
45
+ * all cleanup needs. Every store bumps with it; one that writes `+ 1` still
46
+ * works, it is just not safe to prune.
47
+ */
48
+ export function nextVersion(current = 0) {
49
+ return Math.max(current + 1, Date.now());
50
+ }
51
+ /** The safety-net read of a store that listens: longer than any ask waits, so once per ask. */
52
+ export const LISTENING_POLL_MS = 30_000;
53
+ /** How long a name nobody changes is kept, by the stores that forget on their own. */
54
+ export const VERSIONS_KEPT_MS = 30 * 24 * 60 * 60 * 1000;
55
+ /**
56
+ * Versions kept in this process: the source without a backend, and the one
57
+ * tests use. A name not changed in `forgetAfter` ms (30 days) is forgotten,
58
+ * swept at most once an hour, so a process up for months does not hold every
59
+ * name it ever saw.
60
+ */
61
+ export function memoryVersions(options = {}) {
62
+ const forgetAfter = options.forgetAfter ?? VERSIONS_KEPT_MS;
35
63
  const versions = new Map();
36
64
  const waiters = new Set();
65
+ let swept = Date.now();
66
+ const sweep = () => {
67
+ const now = Date.now();
68
+ if (now - swept < Math.min(forgetAfter, 60 * 60 * 1000))
69
+ return;
70
+ swept = now;
71
+ for (const [name, version] of versions)
72
+ if (version < now - forgetAfter)
73
+ versions.delete(name);
74
+ };
37
75
  const differing = (since) => {
38
76
  const out = {};
39
77
  for (const [name, held] of Object.entries(since)) {
@@ -62,8 +100,9 @@ export function memoryVersions() {
62
100
  return differing(since);
63
101
  },
64
102
  bump(names) {
103
+ sweep();
65
104
  for (const name of names)
66
- versions.set(name, (versions.get(name) ?? 0) + 1);
105
+ versions.set(name, nextVersion(versions.get(name)));
67
106
  for (const wake of [...waiters])
68
107
  wake();
69
108
  },
@@ -123,39 +162,88 @@ export function backendVersions(call) {
123
162
  * SQL dialect has - rather than an upsert each spells differently. Takes the
124
163
  * app's own driver, so the engine has no database dependency of its own.
125
164
  */
165
+ /**
166
+ * One statement that moves every name to nextVersion: an upsert, in the
167
+ * dialect's own words, and on Postgres the NOTIFY with it. A name is either
168
+ * inserted at `now` or moved to the larger of one more and `now`.
169
+ */
170
+ function upsert(dialect, table, mark, names, now, channel) {
171
+ if (dialect === "postgres") {
172
+ // $1 is now, the names follow; the channel, if any, comes last.
173
+ const rows = names.map((_, i) => `(${mark(i + 2)}, ${mark(1)})`).join(", ");
174
+ const insert = `INSERT INTO ${table} (name, version) VALUES ${rows} ` +
175
+ `ON CONFLICT (name) DO UPDATE SET version = GREATEST(${table}.version + 1, EXCLUDED.version)`;
176
+ if (!channel)
177
+ return [insert, [now, ...names]];
178
+ // A NOTIFY is delivered when the transaction commits, so a listener is
179
+ // never woken before the change it is told about can be read.
180
+ return [
181
+ `WITH moved AS (${insert} RETURNING 1) SELECT pg_notify(${mark(names.length + 2)}, '') FROM (SELECT count(*) FROM moved) AS done`,
182
+ [now, ...names, channel],
183
+ ];
184
+ }
185
+ const rows = names.map(() => `(${mark(1)}, ${mark(1)})`).join(", ");
186
+ const params = names.flatMap((name) => [name, now]);
187
+ return dialect === "mysql"
188
+ ? [
189
+ `INSERT INTO ${table} (name, version) VALUES ${rows} ON DUPLICATE KEY UPDATE version = GREATEST(version + 1, VALUES(version))`,
190
+ params,
191
+ ]
192
+ : [
193
+ `INSERT INTO ${table} (name, version) VALUES ${rows} ON CONFLICT (name) DO UPDATE SET version = MAX(version + 1, excluded.version)`,
194
+ params,
195
+ ];
196
+ }
126
197
  export function sqlVersions(options) {
127
198
  const { query } = options;
128
199
  const table = options.table ?? "rsc_versions";
129
200
  const mark = options.placeholder ?? (() => "?");
130
- return createVersions({
201
+ const source = createVersions({
131
202
  async read(names) {
132
203
  const rows = await query(`SELECT name, version FROM ${table} WHERE name IN (${names.map((_, i) => mark(i + 1)).join(", ")})`, names);
133
204
  // A BIGINT comes back as a string from most drivers.
134
205
  return Object.fromEntries(rows.map((row) => [String(row.name), Number(row.version)]));
135
206
  },
136
207
  async bump(names) {
137
- const update = `UPDATE ${table} SET version = version + 1 WHERE name = ${mark(1)}`;
138
- const insert = `INSERT INTO ${table} (name, version) VALUES (${mark(1)}, 1)`;
208
+ if (options.dialect) {
209
+ await query(...upsert(options.dialect, table, mark, names, Date.now(), options.channel));
210
+ return;
211
+ }
212
+ // nextVersion, in SQL every dialect has: CASE rather than GREATEST,
213
+ // which SQLite spells MAX. The time is this process's, passed in, so
214
+ // the database's clock never has to agree with it.
215
+ const update = `UPDATE ${table} SET version = CASE WHEN version + 1 > ${mark(1)} THEN version + 1 ELSE ${mark(2)} END ` +
216
+ `WHERE name = ${mark(3)}`;
217
+ const insert = `INSERT INTO ${table} (name, version) VALUES (${mark(1)}, ${mark(2)})`;
139
218
  for (const name of names) {
219
+ const now = Date.now();
140
220
  // The row count is not something every driver reports the same way,
141
221
  // so ask: a name with no row yet gets one.
142
222
  const [exists] = await query(`SELECT 1 AS found FROM ${table} WHERE name = ${mark(1)}`, [name]);
143
223
  if (exists) {
144
- await query(update, [name]);
224
+ await query(update, [now, now, name]);
145
225
  continue;
146
226
  }
147
227
  try {
148
- await query(insert, [name]);
228
+ await query(insert, [name, now]);
149
229
  }
150
230
  catch {
151
231
  // Another instance inserted it first: bump that one.
152
- await query(update, [name]);
232
+ await query(update, [now, now, name]);
153
233
  }
154
234
  }
155
235
  },
156
236
  listen: options.listen,
157
237
  notify: options.notify,
158
238
  }, { poll: options.poll });
239
+ return {
240
+ ...source,
241
+ async prune(olderThan = VERSIONS_KEPT_MS) {
242
+ await query(`DELETE FROM ${table} WHERE version < ${mark(1)}`, [
243
+ Date.now() - olderThan,
244
+ ]);
245
+ },
246
+ };
159
247
  }
160
248
  /**
161
249
  * A version source over any store: SQL through any driver or ORM - Drizzle,
@@ -169,14 +257,22 @@ export function sqlVersions(options) {
169
257
  * once whatever the store.
170
258
  */
171
259
  export function createVersions(store, options = {}) {
172
- const poll = options.poll ?? 1_000;
260
+ // A store that listens is woken the moment a version moves, so reading the
261
+ // table on a timer is only a safety net - for a listening connection that
262
+ // dropped without a word. Once an ask is enough: about every five seconds
263
+ // per server, where one that cannot listen reads every second.
264
+ const poll = options.poll ?? (store.listen ? LISTENING_POLL_MS : 1_000);
173
265
  const waiters = new Set();
174
266
  const wakeAll = () => {
175
267
  for (const wake of [...waiters])
176
268
  wake();
177
269
  };
270
+ // A store that can listen wakes the renderer the way anything else does -
271
+ // through wakeOn, which starts it with the first watching tab and never in
272
+ // a build. A wake also ends an ask this store is holding.
178
273
  if (store.listen)
179
- void Promise.resolve(store.listen(wakeAll)).catch(() => { });
274
+ wakeOn(store.listen);
275
+ wakers().sleepers.add(wakeAll);
180
276
  const read = async (names) => {
181
277
  if (names.length === 0)
182
278
  return {};
@@ -185,6 +281,10 @@ export function createVersions(store, options = {}) {
185
281
  };
186
282
  return {
187
283
  async changed(since, wait) {
284
+ // Only a server with a tab watching asks with a wait - never a build -
285
+ // so this is a safe moment to start listening, as is the first tab.
286
+ if (wait > 0)
287
+ startWakers();
188
288
  const names = Object.keys(since);
189
289
  const deadline = Date.now() + Math.max(0, wait);
190
290
  for (;;) {
@@ -225,23 +325,26 @@ export function createVersions(store, options = {}) {
225
325
  * installVersionSource(postgresVersions(sql))
226
326
  *
227
327
  * `sqlVersions` with Postgres's placeholders, and - when the client can
228
- * LISTEN, as postgres.js can - a change on one instance wakes the waiting asks
229
- * on every other at once, rather than on their next read of the table.
230
- * Without it (`Bun.sql`), the table is read once a second while an ask waits.
328
+ * LISTEN, as postgres.js and `Bun.sql` (Bun 1.4+) can - a change on one
329
+ * instance wakes the waiting asks on every other at once, rather than on
330
+ * their next read of the table. Without it, the table is read once a second
331
+ * while an ask waits.
231
332
  *
232
333
  * CREATE TABLE rsc_versions (name TEXT PRIMARY KEY, version BIGINT NOT NULL)
233
334
  */
234
335
  export function postgresVersions(sql, options = {}) {
235
336
  const channel = options.channel ?? "rsc_versions";
236
337
  const listen = sql.listen?.bind(sql);
237
- const notify = sql.notify?.bind(sql);
338
+ // One statement per change: the upsert of every name, and the NOTIFY that
339
+ // wakes every listening server - this one included - when it commits.
238
340
  return sqlVersions({
239
341
  query: async (text, params) => await sql.unsafe(text, params),
240
342
  placeholder: (n) => "$" + n,
343
+ dialect: "postgres",
344
+ channel,
241
345
  table: options.table,
242
346
  poll: options.poll,
243
347
  listen: listen ? (wake) => listen(channel, wake) : undefined,
244
- notify: listen && notify ? () => notify(channel, "") : undefined,
245
348
  });
246
349
  }
247
350
  const SOURCE = Symbol.for("rsc-kit.version-source");
@@ -265,9 +368,28 @@ export function installBackendVersionSource(source) {
265
368
  }
266
369
  /** The source in use: the app's, else the backend's, else this process's own. */
267
370
  export function versionSource() {
268
- return (globals[SOURCE] ??
269
- globals[BACKEND_SOURCE] ??
270
- (globals[LOCAL_SOURCE] ??= memoryVersions()));
371
+ const chosen = globals[SOURCE] ??
372
+ globals[BACKEND_SOURCE];
373
+ if (chosen)
374
+ return chosen;
375
+ let local = globals[LOCAL_SOURCE];
376
+ if (!local) {
377
+ local = globals[LOCAL_SOURCE] = memoryVersions();
378
+ // The fallback works in development and in tests, which are one process,
379
+ // and fails silently in production with two: a change on one instance
380
+ // never reaches the tabs on another. Said once, at the first use. An app
381
+ // that really is one instance installs memoryVersions() itself, which is
382
+ // a choice rather than a fallback, and is not warned.
383
+ if (production()) {
384
+ console.warn("[rsc-kit] refreshOn versions are kept in this process only: a change made on another " +
385
+ "instance, or in a worker, will not reach the tabs this one serves. Install a store every " +
386
+ "process shares with installVersionSource() in instrumentation.ts - sqlVersions for MySQL " +
387
+ "or SQLite, postgresVersions for Postgres, createVersions for anything else: " +
388
+ "https://docs.rsc-kit.dev/guides/sections#where-the-versions-live. For a deploy that is one " +
389
+ "instance, installVersionSource(memoryVersions()) says so.");
390
+ }
391
+ }
392
+ return local;
271
393
  }
272
394
  /**
273
395
  * Say these names changed, so every open tab showing them refreshes.
@@ -427,6 +549,26 @@ function observe(name, version) {
427
549
  if (stream.names.has(name))
428
550
  stream.send(name, version);
429
551
  }
552
+ /**
553
+ * A stream gone: and with it every name no other stream holds, which this
554
+ * process has no reason to remember. Kept, the latest version of every name
555
+ * any tab ever watched piled up for as long as the server ran.
556
+ */
557
+ function close(stream) {
558
+ if (!streams.delete(stream))
559
+ return;
560
+ for (const name of stream.names) {
561
+ let held = false;
562
+ for (const other of streams) {
563
+ if (other.names.has(name)) {
564
+ held = true;
565
+ break;
566
+ }
567
+ }
568
+ if (!held)
569
+ known.delete(name);
570
+ }
571
+ }
430
572
  /** Mapped to this process's set of streams: what to ask about, relative to what is known. */
431
573
  function sinceAll() {
432
574
  const since = {};
@@ -435,11 +577,85 @@ function sinceAll() {
435
577
  since[name] = known.get(name) ?? 0;
436
578
  return since;
437
579
  }
580
+ // ── Waking ───────────────────────────────────────────────────────────────────
581
+ //
582
+ // A backend that cannot hold a question open - PHP - is asked again on an
583
+ // interval. Something that can say "a version moved" the moment it does -
584
+ // a broadcast, a Redis channel - makes that interval a safety net: the
585
+ // renderer asks the moment it is woken, and otherwise rarely.
586
+ const WAKERS = Symbol.for("rsc-kit.wakers");
587
+ function wakers() {
588
+ return (globals[WAKERS] ??= {
589
+ listens: [],
590
+ started: false,
591
+ pending: false,
592
+ sleepers: new Set(),
593
+ });
594
+ }
595
+ /**
596
+ * Wake the renderer when a version may have moved, so it asks then rather
597
+ * than on its next interval.
598
+ *
599
+ * wakeOn(listenToBroadcast({ url, key })) // a broadcast server
600
+ * wakeOn((wake) => subscriber.subscribe('rsc', wake)) // a Redis channel
601
+ *
602
+ * `listen` is given a function to call - it carries no names and no data;
603
+ * the renderer asks wherever the versions live, as always. It is started
604
+ * when the first tab starts watching, never in a build. With one installed,
605
+ * a backend that answers at once is asked again every 30 seconds as a safety
606
+ * net instead of every two.
607
+ */
608
+ export function wakeOn(listen) {
609
+ const state = wakers();
610
+ state.listens.push(listen);
611
+ if (state.started)
612
+ start(listen);
613
+ }
614
+ /**
615
+ * Start one listener; one that fails - a database not up yet - is tried
616
+ * again, backing off to thirty seconds. Until it is up the timer stands in.
617
+ */
618
+ function start(listen, failures = 0) {
619
+ void Promise.resolve()
620
+ .then(() => listen(signal))
621
+ .catch((error) => {
622
+ if (failures === 0) {
623
+ console.warn("[rsc-kit] a wakeOn listener failed to start, trying again: " +
624
+ (error instanceof Error ? error.message : String(error)));
625
+ }
626
+ const retry = setTimeout(() => start(listen, failures + 1), Math.min(30_000, 1000 * 2 ** failures));
627
+ retry.unref?.();
628
+ });
629
+ }
630
+ /** Something said a version may have moved: ask now, and end any ask a store is holding. */
631
+ function signal() {
632
+ const state = wakers();
633
+ state.pending = true;
634
+ wake?.();
635
+ for (const sleeper of [...state.sleepers])
636
+ sleeper();
637
+ }
638
+ function startWakers() {
639
+ const state = wakers();
640
+ if (state.started)
641
+ return;
642
+ state.started = true;
643
+ // Set in the environment, not guessed: a broadcast server to listen to.
644
+ const url = env("RSC_BROADCAST_URL");
645
+ const key = env("RSC_BROADCAST_KEY");
646
+ if (url && key)
647
+ state.listens.push(listenToBroadcast({ url, key }));
648
+ for (const listen of state.listens)
649
+ start(listen);
650
+ }
438
651
  async function loop() {
439
652
  running = true;
653
+ startWakers();
440
654
  try {
441
655
  while (streams.size > 0) {
442
656
  const started = Date.now();
657
+ const state = wakers();
658
+ state.pending = false;
443
659
  try {
444
660
  const moved = await versionSource().changed(sinceAll(), CHANGES_WAIT_MS);
445
661
  for (const [name, version] of Object.entries(moved))
@@ -454,8 +670,11 @@ async function loop() {
454
670
  }
455
671
  }
456
672
  // A source that waited used the interval; one that answered at once is
457
- // asked again after it. A stream opening meanwhile wakes this early.
458
- const remaining = CHANGES_INTERVAL_MS - (Date.now() - started);
673
+ // asked again after it - or, with something to wake it, only as a
674
+ // safety net. A stream opening, or a wake, ends the wait early; a wake
675
+ // that came during the ask is answered at once.
676
+ const interval = state.listens.length > 0 ? LISTENING_POLL_MS : CHANGES_INTERVAL_MS;
677
+ const remaining = state.pending ? 0 : interval - (Date.now() - started);
459
678
  if (remaining > 0 && streams.size > 0) {
460
679
  await new Promise((resolve) => {
461
680
  const timer = setTimeout(resolve, remaining);
@@ -537,7 +756,7 @@ export async function changes(request) {
537
756
  }
538
757
  };
539
758
  write("retry: 2000\n\n");
540
- const keepalive = setInterval(() => write(": keepalive\n\n"), KEEPALIVE_MS);
759
+ const keepalive = setInterval(() => write(": keepalive\n\n"), keepaliveMs());
541
760
  keepalive.unref?.();
542
761
  const me = {
543
762
  names: new Set(Object.keys(since)),
@@ -547,7 +766,7 @@ export async function changes(request) {
547
766
  streams.add(me);
548
767
  const end = () => {
549
768
  clearInterval(keepalive);
550
- streams.delete(me);
769
+ close(me);
551
770
  try {
552
771
  sink.close();
553
772
  }
@@ -582,19 +801,26 @@ export async function changes(request) {
582
801
  },
583
802
  cancel() {
584
803
  if (stream)
585
- streams.delete(stream);
804
+ close(stream);
586
805
  },
587
806
  });
588
807
  return new Response(body, {
589
808
  headers: {
590
809
  "Content-Type": "text/event-stream; charset=utf-8",
591
- "Cache-Control": "no-store",
810
+ // no-transform: a proxy or CDN in front must not compress or buffer
811
+ // it either - Cloudflare compresses text unless told not to.
812
+ "Cache-Control": "no-store, no-transform",
592
813
  "X-Accel-Buffering": "no",
593
814
  },
594
815
  });
595
816
  }
596
817
  /** For tests: forget every stream and version this process has seen. */
818
+ /** For tests: how many names this process remembers for its open streams. */
819
+ export function rememberedNames() {
820
+ return known.size;
821
+ }
597
822
  export function resetChanges() {
823
+ delete globals[WAKERS];
598
824
  streams.clear();
599
825
  known.clear();
600
826
  warned = false;