@rsc-kit/core 0.27.1 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/changed.js CHANGED
@@ -28,12 +28,50 @@
28
28
  */
29
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,25 +257,30 @@ 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
+ let listening = Boolean(store.listen);
265
+ const poll = () => options.poll ?? (listening ? LISTENING_POLL_MS : 1_000);
173
266
  const waiters = new Set();
174
267
  const wakeAll = () => {
175
268
  for (const wake of [...waiters])
176
269
  wake();
177
270
  };
178
- // Listening opens a connection, and an open connection keeps a process
179
- // alive: started here, a store installed in register() - which a build
180
- // runs too - held the build open for ever. So it starts with the first
181
- // ask that waits, which only a server with a tab watching ever makes.
182
- let listening = false;
183
- const listen = () => {
184
- if (listening || !store.listen)
185
- return;
186
- listening = true;
187
- void Promise.resolve(store.listen(wakeAll)).catch(() => {
188
- listening = false;
189
- });
190
- };
271
+ // A store that can listen wakes the renderer the way anything else does -
272
+ // through wakeOn, which starts it with the first watching tab and never in
273
+ // a build. A wake also ends an ask this store is holding.
274
+ if (store.listen) {
275
+ const listen = store.listen.bind(store);
276
+ wakeOn((wake, health) => listen(wake, {
277
+ deaf: (reason) => {
278
+ listening = false;
279
+ health.deaf(reason);
280
+ },
281
+ }));
282
+ }
283
+ wakers().sleepers.add(wakeAll);
191
284
  const read = async (names) => {
192
285
  if (names.length === 0)
193
286
  return {};
@@ -196,8 +289,10 @@ export function createVersions(store, options = {}) {
196
289
  };
197
290
  return {
198
291
  async changed(since, wait) {
292
+ // Only a server with a tab watching asks with a wait - never a build -
293
+ // so this is a safe moment to start listening, as is the first tab.
199
294
  if (wait > 0)
200
- listen();
295
+ startWakers();
201
296
  const names = Object.keys(since);
202
297
  const deadline = Date.now() + Math.max(0, wait);
203
298
  for (;;) {
@@ -215,7 +310,7 @@ export function createVersions(store, options = {}) {
215
310
  waiters.delete(done);
216
311
  resolve();
217
312
  };
218
- const timer = setTimeout(done, Math.min(poll, remaining));
313
+ const timer = setTimeout(done, Math.min(poll(), remaining));
219
314
  timer.unref?.();
220
315
  waiters.add(done);
221
316
  });
@@ -238,25 +333,58 @@ export function createVersions(store, options = {}) {
238
333
  * installVersionSource(postgresVersions(sql))
239
334
  *
240
335
  * `sqlVersions` with Postgres's placeholders, and - when the client can
241
- * LISTEN, as postgres.js can - a change on one instance wakes the waiting asks
242
- * on every other at once, rather than on their next read of the table.
243
- * Without it (`Bun.sql`), the table is read once a second while an ask waits.
336
+ * LISTEN, as postgres.js and `Bun.sql` (Bun 1.4+) can - a change on one
337
+ * instance wakes the waiting asks on every other at once, rather than on
338
+ * their next read of the table. Without it, the table is read once a second
339
+ * while an ask waits.
244
340
  *
245
341
  * CREATE TABLE rsc_versions (name TEXT PRIMARY KEY, version BIGINT NOT NULL)
246
342
  */
247
343
  export function postgresVersions(sql, options = {}) {
248
344
  const channel = options.channel ?? "rsc_versions";
249
- const listen = sql.listen?.bind(sql);
250
- const notify = sql.notify?.bind(sql);
345
+ const listener = options.listenWith ?? sql;
346
+ const listenOn = options.listen === false ? undefined : listener.listen?.bind(listener);
347
+ // One statement per change: the upsert of every name, and the NOTIFY that
348
+ // wakes every listening server - this one included - when it commits.
251
349
  return sqlVersions({
252
350
  query: async (text, params) => await sql.unsafe(text, params),
253
351
  placeholder: (n) => "$" + n,
352
+ dialect: "postgres",
353
+ channel,
254
354
  table: options.table,
255
355
  poll: options.poll,
256
- listen: listen ? (wake) => listen(channel, wake) : undefined,
257
- notify: listen && notify ? () => notify(channel, "") : undefined,
356
+ listen: listenOn
357
+ ? async (wake, health) => {
358
+ // A probe, sent the way a change is - through `sql` - and listened
359
+ // for on the listening connection. Behind a transaction-mode pooler
360
+ // the LISTEN lands on a connection the pooler takes back, sending
361
+ // still works, and nothing says the listener is deaf: changes from
362
+ // other instances would wait for the safety-net read. The probe
363
+ // not arriving is how that is found out - once, as listening starts.
364
+ const probe = "rsc-kit:probe:" + crypto.randomUUID();
365
+ let heard = false;
366
+ await listenOn(channel, (payload) => {
367
+ if (payload === probe)
368
+ heard = true;
369
+ else
370
+ wake();
371
+ });
372
+ await sql.unsafe("SELECT pg_notify($1, $2)", [channel, probe]);
373
+ const timer = setTimeout(() => {
374
+ if (heard)
375
+ return;
376
+ health.deaf("listening on Postgres receives no notifications - is the connection going through a pooler " +
377
+ "in transaction mode (PgBouncer; DigitalOcean, Supabase or Neon pooled connections)? " +
378
+ "Pass a direct or session-mode connection: postgresVersions(sql, { listenWith }). " +
379
+ "Reading every second meanwhile.");
380
+ }, LISTEN_PROBE_MS);
381
+ timer.unref?.();
382
+ }
383
+ : undefined,
258
384
  });
259
385
  }
386
+ /** How long a listener's probe may take to come back before it counts as deaf. */
387
+ export const LISTEN_PROBE_MS = 5_000;
260
388
  const SOURCE = Symbol.for("rsc-kit.version-source");
261
389
  const BACKEND_SOURCE = Symbol.for("rsc-kit.backend-version-source");
262
390
  const LOCAL_SOURCE = Symbol.for("rsc-kit.local-version-source");
@@ -278,6 +406,11 @@ export function installBackendVersionSource(source) {
278
406
  }
279
407
  /** The source in use: the app's, else the backend's, else this process's own. */
280
408
  export function versionSource() {
409
+ // A factory, made into the store on first use: a store made in register()
410
+ // would make its database client in the build too, which runs register()
411
+ // without the database's settings.
412
+ if (typeof globals[SOURCE] === "function")
413
+ globals[SOURCE] = globals[SOURCE]();
281
414
  const chosen = globals[SOURCE] ??
282
415
  globals[BACKEND_SOURCE];
283
416
  if (chosen)
@@ -295,7 +428,7 @@ export function versionSource() {
295
428
  "instance, or in a worker, will not reach the tabs this one serves. Install a store every " +
296
429
  "process shares with installVersionSource() in instrumentation.ts - sqlVersions for MySQL " +
297
430
  "or SQLite, postgresVersions for Postgres, createVersions for anything else: " +
298
- "https://docs.rsc-kit.dev/guides/sections#where-the-versions-live. For a deploy that is one " +
431
+ "https://docs.rsc-kit.dev/guides/live-data#where-the-versions-live. For a deploy that is one " +
299
432
  "instance, installVersionSource(memoryVersions()) says so.");
300
433
  }
301
434
  }
@@ -459,6 +592,26 @@ function observe(name, version) {
459
592
  if (stream.names.has(name))
460
593
  stream.send(name, version);
461
594
  }
595
+ /**
596
+ * A stream gone: and with it every name no other stream holds, which this
597
+ * process has no reason to remember. Kept, the latest version of every name
598
+ * any tab ever watched piled up for as long as the server ran.
599
+ */
600
+ function close(stream) {
601
+ if (!streams.delete(stream))
602
+ return;
603
+ for (const name of stream.names) {
604
+ let held = false;
605
+ for (const other of streams) {
606
+ if (other.names.has(name)) {
607
+ held = true;
608
+ break;
609
+ }
610
+ }
611
+ if (!held)
612
+ known.delete(name);
613
+ }
614
+ }
462
615
  /** Mapped to this process's set of streams: what to ask about, relative to what is known. */
463
616
  function sinceAll() {
464
617
  const since = {};
@@ -467,11 +620,84 @@ function sinceAll() {
467
620
  since[name] = known.get(name) ?? 0;
468
621
  return since;
469
622
  }
623
+ // ── Waking ───────────────────────────────────────────────────────────────────
624
+ //
625
+ // A backend that cannot hold a question open - PHP - is asked again on an
626
+ // interval. Something that can say "a version moved" the moment it does -
627
+ // a broadcast, a Redis channel - makes that interval a safety net: the
628
+ // renderer asks the moment it is woken, and otherwise rarely.
629
+ const WAKERS = Symbol.for("rsc-kit.wakers");
630
+ function wakers() {
631
+ return (globals[WAKERS] ??= {
632
+ listens: [],
633
+ started: false,
634
+ pending: false,
635
+ sleepers: new Set(),
636
+ });
637
+ }
638
+ export function wakeOn(listen) {
639
+ const state = wakers();
640
+ state.listens.push(listen);
641
+ if (state.started)
642
+ start(listen);
643
+ }
644
+ /**
645
+ * Start one listener; one that fails - a database not up yet - is tried
646
+ * again, backing off to thirty seconds. Until it is up the timer stands in.
647
+ */
648
+ function start(listen, failures = 0) {
649
+ const health = {
650
+ deaf: (reason) => {
651
+ const state = wakers();
652
+ const at = state.listens.indexOf(listen);
653
+ // No longer counted: the renderer goes back to asking on the shorter
654
+ // interval, as it does with nothing to wake it.
655
+ if (at === -1)
656
+ return;
657
+ state.listens.splice(at, 1);
658
+ console.warn("[rsc-kit] " + reason);
659
+ },
660
+ };
661
+ void Promise.resolve()
662
+ .then(() => listen(signal, health))
663
+ .catch((error) => {
664
+ if (failures === 0) {
665
+ console.warn("[rsc-kit] a wakeOn listener failed to start, trying again: " +
666
+ (error instanceof Error ? error.message : String(error)));
667
+ }
668
+ const retry = setTimeout(() => start(listen, failures + 1), Math.min(30_000, 1000 * 2 ** failures));
669
+ retry.unref?.();
670
+ });
671
+ }
672
+ /** Something said a version may have moved: ask now, and end any ask a store is holding. */
673
+ function signal() {
674
+ const state = wakers();
675
+ state.pending = true;
676
+ wake?.();
677
+ for (const sleeper of [...state.sleepers])
678
+ sleeper();
679
+ }
680
+ function startWakers() {
681
+ const state = wakers();
682
+ if (state.started)
683
+ return;
684
+ state.started = true;
685
+ // Set in the environment, not guessed: a broadcast server to listen to.
686
+ const url = env("RSC_BROADCAST_URL");
687
+ const key = env("RSC_BROADCAST_KEY");
688
+ if (url && key)
689
+ state.listens.push(listenToBroadcast({ url, key }));
690
+ for (const listen of state.listens)
691
+ start(listen);
692
+ }
470
693
  async function loop() {
471
694
  running = true;
695
+ startWakers();
472
696
  try {
473
697
  while (streams.size > 0) {
474
698
  const started = Date.now();
699
+ const state = wakers();
700
+ state.pending = false;
475
701
  try {
476
702
  const moved = await versionSource().changed(sinceAll(), CHANGES_WAIT_MS);
477
703
  for (const [name, version] of Object.entries(moved))
@@ -486,8 +712,11 @@ async function loop() {
486
712
  }
487
713
  }
488
714
  // A source that waited used the interval; one that answered at once is
489
- // asked again after it. A stream opening meanwhile wakes this early.
490
- const remaining = CHANGES_INTERVAL_MS - (Date.now() - started);
715
+ // asked again after it - or, with something to wake it, only as a
716
+ // safety net. A stream opening, or a wake, ends the wait early; a wake
717
+ // that came during the ask is answered at once.
718
+ const interval = state.listens.length > 0 ? LISTENING_POLL_MS : CHANGES_INTERVAL_MS;
719
+ const remaining = state.pending ? 0 : interval - (Date.now() - started);
491
720
  if (remaining > 0 && streams.size > 0) {
492
721
  await new Promise((resolve) => {
493
722
  const timer = setTimeout(resolve, remaining);
@@ -579,7 +808,7 @@ export async function changes(request) {
579
808
  streams.add(me);
580
809
  const end = () => {
581
810
  clearInterval(keepalive);
582
- streams.delete(me);
811
+ close(me);
583
812
  try {
584
813
  sink.close();
585
814
  }
@@ -614,7 +843,7 @@ export async function changes(request) {
614
843
  },
615
844
  cancel() {
616
845
  if (stream)
617
- streams.delete(stream);
846
+ close(stream);
618
847
  },
619
848
  });
620
849
  return new Response(body, {
@@ -628,7 +857,12 @@ export async function changes(request) {
628
857
  });
629
858
  }
630
859
  /** For tests: forget every stream and version this process has seen. */
860
+ /** For tests: how many names this process remembers for its open streams. */
861
+ export function rememberedNames() {
862
+ return known.size;
863
+ }
631
864
  export function resetChanges() {
865
+ delete globals[WAKERS];
632
866
  streams.clear();
633
867
  known.clear();
634
868
  warned = false;