@rsc-kit/core 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.
package/dist/changed.d.ts CHANGED
@@ -123,7 +123,7 @@ export interface SqlVersionsOptions {
123
123
  *
124
124
  * Without it, a waiting ask reads the table every `poll` ms.
125
125
  */
126
- listen?: (wake: () => void) => unknown;
126
+ listen?: (wake: () => void, health: ListenerHealth) => unknown;
127
127
  /**
128
128
  * Run after every bump, to tell the other instances - Postgres's NOTIFY:
129
129
  *
@@ -180,8 +180,12 @@ export interface VersionStore {
180
180
  * Called once with a function that wakes every waiting ask, when the store
181
181
  * can say the moment a version moved - Postgres LISTEN, Redis SUBSCRIBE.
182
182
  * Without it a waiting ask reads again every `poll` ms.
183
+ *
184
+ * Given `health` too: a store that finds it hears nothing - a Postgres
185
+ * listener behind a pooler in transaction mode - calls `health.deaf(why)`,
186
+ * and rsc-kit says so and goes back to reading every second.
183
187
  */
184
- listen?(wake: () => void): unknown;
188
+ listen?(wake: () => void, health: ListenerHealth): unknown;
185
189
  /** Run after a bump, to tell the other instances listening: Postgres NOTIFY, Redis PUBLISH. */
186
190
  notify?(): unknown;
187
191
  }
@@ -202,8 +206,8 @@ export declare function createVersions(store: VersionStore, options?: {
202
206
  /** The parts of a Postgres client `postgresVersions` uses: postgres.js and `Bun.sql` both have them. */
203
207
  export interface PostgresClient {
204
208
  unsafe(text: string, params?: unknown[]): PromiseLike<readonly Record<string, unknown>[]>;
205
- /** postgres.js has it; with it, a waiting ask hears another instance at once. */
206
- listen?(channel: string, onNotify: () => void): unknown;
209
+ /** postgres.js and Bun.sql have it; with it, a waiting ask hears another instance at once. */
210
+ listen?(channel: string, onNotify: (payload?: string) => void): unknown;
207
211
  notify?(channel: string, payload: string): unknown;
208
212
  }
209
213
  /**
@@ -223,7 +227,25 @@ export declare function postgresVersions(sql: PostgresClient, options?: {
223
227
  table?: string;
224
228
  channel?: string;
225
229
  poll?: number;
230
+ /**
231
+ * A client to listen on, when `sql` goes through a connection pooler in
232
+ * transaction mode - PgBouncer, or DigitalOcean's, Supabase's or Neon's
233
+ * pooled connection - which hands the server connection back after each
234
+ * statement, so a LISTEN on it hears nothing. A direct or session-mode
235
+ * connection; reads and writes stay on `sql`. One connection per process.
236
+ */
237
+ listenWith?: PostgresClient;
238
+ /**
239
+ * `false`: do not listen - read every second instead, on purpose. For a
240
+ * database whose direct connections are too few to spend one per process
241
+ * on, behind a pooler that cannot listen. No listener, so no probe and no
242
+ * warning about one. A change made in this process still reaches its own
243
+ * tabs at once; one made elsewhere within about a second.
244
+ */
245
+ listen?: boolean;
226
246
  }): PrunableVersions;
247
+ /** How long a listener's probe may take to come back before it counts as deaf. */
248
+ export declare const LISTEN_PROBE_MS = 5000;
227
249
  /**
228
250
  * Install where versions are read from - a table, Redis, any store - for this
229
251
  * process; `null` takes it out again.
@@ -232,7 +254,7 @@ export declare function postgresVersions(sql: PostgresClient, options?: {
232
254
  * writes its versions to a store this process can read too, reading it here
233
255
  * means watching costs the backend nothing.
234
256
  */
235
- export declare function installVersionSource(source: VersionSource | null): void;
257
+ export declare function installVersionSource(source: VersionSource | (() => VersionSource) | null): void;
236
258
  /** The engine's, when a backend is installed: its versions, read through it. Not for apps. */
237
259
  export declare function installBackendVersionSource(source: VersionSource | null): void;
238
260
  /** The source in use: the app's, else the backend's, else this process's own. */
@@ -298,7 +320,12 @@ export declare const CHANGES_WAIT_MS = 5000;
298
320
  * a backend that answers at once is asked again every 30 seconds as a safety
299
321
  * net instead of every two.
300
322
  */
301
- export declare function wakeOn(listen: (wake: () => void) => unknown): void;
323
+ /** What a listener is told besides how to wake: how to say it hears nothing. */
324
+ export interface ListenerHealth {
325
+ /** This listener will never wake anything: say why, once, and stop counting on it. */
326
+ deaf(reason: string): void;
327
+ }
328
+ export declare function wakeOn(listen: (wake: () => void, health: ListenerHealth) => unknown): void;
302
329
  /**
303
330
  * The stream a tab watches its names on: `GET /_rsc/changes?w=[[name, version, signature], …]`.
304
331
  *
@@ -314,3 +341,9 @@ export declare function rememberedNames(): number;
314
341
  export declare function resetChanges(): void;
315
342
  /** The url a tab opens to watch, for the client. */
316
343
  export declare const CHANGES_PATH: "/_rsc/changes";
344
+ /**
345
+ * The changes endpoint: a tab's stream here, or - with a hub - in the hub,
346
+ * where every tab's is. The hub itself answers what isolates forward to it:
347
+ * a stream to hold, or a poke to ask now.
348
+ */
349
+ export declare function serveChanges(request: Request): Promise<Response>;
package/dist/changed.js CHANGED
@@ -261,7 +261,8 @@ export function createVersions(store, options = {}) {
261
261
  // table on a timer is only a safety net - for a listening connection that
262
262
  // dropped without a word. Once an ask is enough: about every five seconds
263
263
  // per server, where one that cannot listen reads every second.
264
- const poll = options.poll ?? (store.listen ? LISTENING_POLL_MS : 1_000);
264
+ let listening = Boolean(store.listen);
265
+ const poll = () => options.poll ?? (listening ? LISTENING_POLL_MS : 1_000);
265
266
  const waiters = new Set();
266
267
  const wakeAll = () => {
267
268
  for (const wake of [...waiters])
@@ -270,8 +271,15 @@ export function createVersions(store, options = {}) {
270
271
  // A store that can listen wakes the renderer the way anything else does -
271
272
  // through wakeOn, which starts it with the first watching tab and never in
272
273
  // a build. A wake also ends an ask this store is holding.
273
- if (store.listen)
274
- wakeOn(store.listen);
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
+ }
275
283
  wakers().sleepers.add(wakeAll);
276
284
  const read = async (names) => {
277
285
  if (names.length === 0)
@@ -302,7 +310,7 @@ export function createVersions(store, options = {}) {
302
310
  waiters.delete(done);
303
311
  resolve();
304
312
  };
305
- const timer = setTimeout(done, Math.min(poll, remaining));
313
+ const timer = setTimeout(done, Math.min(poll(), remaining));
306
314
  timer.unref?.();
307
315
  waiters.add(done);
308
316
  });
@@ -334,7 +342,8 @@ export function createVersions(store, options = {}) {
334
342
  */
335
343
  export function postgresVersions(sql, options = {}) {
336
344
  const channel = options.channel ?? "rsc_versions";
337
- const listen = sql.listen?.bind(sql);
345
+ const listener = options.listenWith ?? sql;
346
+ const listenOn = options.listen === false ? undefined : listener.listen?.bind(listener);
338
347
  // One statement per change: the upsert of every name, and the NOTIFY that
339
348
  // wakes every listening server - this one included - when it commits.
340
349
  return sqlVersions({
@@ -344,9 +353,38 @@ export function postgresVersions(sql, options = {}) {
344
353
  channel,
345
354
  table: options.table,
346
355
  poll: options.poll,
347
- listen: listen ? (wake) => listen(channel, wake) : 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,
348
384
  });
349
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;
350
388
  const SOURCE = Symbol.for("rsc-kit.version-source");
351
389
  const BACKEND_SOURCE = Symbol.for("rsc-kit.backend-version-source");
352
390
  const LOCAL_SOURCE = Symbol.for("rsc-kit.local-version-source");
@@ -368,6 +406,11 @@ export function installBackendVersionSource(source) {
368
406
  }
369
407
  /** The source in use: the app's, else the backend's, else this process's own. */
370
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]();
371
414
  const chosen = globals[SOURCE] ??
372
415
  globals[BACKEND_SOURCE];
373
416
  if (chosen)
@@ -385,7 +428,7 @@ export function versionSource() {
385
428
  "instance, or in a worker, will not reach the tabs this one serves. Install a store every " +
386
429
  "process shares with installVersionSource() in instrumentation.ts - sqlVersions for MySQL " +
387
430
  "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 " +
431
+ "https://docs.rsc-kit.dev/guides/live-data#where-the-versions-live. For a deploy that is one " +
389
432
  "instance, installVersionSource(memoryVersions()) says so.");
390
433
  }
391
434
  }
@@ -405,6 +448,9 @@ export function versionSource() {
405
448
  */
406
449
  export async function changed(...names) {
407
450
  await versionSource().bump(names);
451
+ // On Workers with a hub, the tabs are held by the hub, not this isolate:
452
+ // tell it, so they hear now rather than at the hub's next read.
453
+ await pokeHub();
408
454
  }
409
455
  // ── Tokens ───────────────────────────────────────────────────────────────────
410
456
  //
@@ -592,19 +638,6 @@ function wakers() {
592
638
  sleepers: new Set(),
593
639
  });
594
640
  }
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
641
  export function wakeOn(listen) {
609
642
  const state = wakers();
610
643
  state.listens.push(listen);
@@ -616,8 +649,20 @@ export function wakeOn(listen) {
616
649
  * again, backing off to thirty seconds. Until it is up the timer stands in.
617
650
  */
618
651
  function start(listen, failures = 0) {
652
+ const health = {
653
+ deaf: (reason) => {
654
+ const state = wakers();
655
+ const at = state.listens.indexOf(listen);
656
+ // No longer counted: the renderer goes back to asking on the shorter
657
+ // interval, as it does with nothing to wake it.
658
+ if (at === -1)
659
+ return;
660
+ state.listens.splice(at, 1);
661
+ console.warn("[rsc-kit] " + reason);
662
+ },
663
+ };
619
664
  void Promise.resolve()
620
- .then(() => listen(signal))
665
+ .then(() => listen(signal, health))
621
666
  .catch((error) => {
622
667
  if (failures === 0) {
623
668
  console.warn("[rsc-kit] a wakeOn listener failed to start, trying again: " +
@@ -820,6 +865,7 @@ export function rememberedNames() {
820
865
  return known.size;
821
866
  }
822
867
  export function resetChanges() {
868
+ warnedHub = false;
823
869
  delete globals[WAKERS];
824
870
  streams.clear();
825
871
  known.clear();
@@ -828,4 +874,115 @@ export function resetChanges() {
828
874
  }
829
875
  /** The url a tab opens to watch, for the client. */
830
876
  export const CHANGES_PATH = HEADER.changesPath;
877
+ // ── The hub ──────────────────────────────────────────────────────────────────
878
+ //
879
+ // On Workers each isolate watches for the tabs it happens to hold, which is
880
+ // often one: an ask per tab per interval, a listener per isolate. A Durable
881
+ // Object is one place every isolate can reach. With RSC_CHANGES_HUB naming
882
+ // its binding, every tab's stream is forwarded to one instance of it, which
883
+ // runs the shared ask above for all of them - one ask an interval for the
884
+ // whole deployment, one listener, and a changed() anywhere wakes it at once.
885
+ //
886
+ // Opt-in, by name: the binding is the app's, declared in its wrangler config,
887
+ // and nothing here guesses that a Durable Object is there to use.
888
+ /** Marks a request the hub sent itself; its value is signed, so a browser cannot. */
889
+ const HUB_HEADER = "x-rsc-changes-hub";
890
+ /** The one instance every isolate's streams go to. */
891
+ const HUB_INSTANCE = "rsc-kit-changes";
892
+ /** What the header's value is the signature of. */
893
+ const HUB_PROOF = "rsc-kit:changes-hub";
894
+ let warnedHub = false;
895
+ /** The hub's stub, when RSC_CHANGES_HUB names a Durable Object binding; null otherwise. */
896
+ async function hubStub(request) {
897
+ const binding = env("RSC_CHANGES_HUB");
898
+ if (!binding)
899
+ return null;
900
+ let bindings = request?.runtime?.cloudflare?.env;
901
+ // Nitro's Workers entry keeps the bindings here for code with no request -
902
+ // changed() from a webhook's handler.
903
+ bindings ??= globalThis.__env__;
904
+ if (!bindings) {
905
+ try {
906
+ // Computed, so neither the bundler nor tsc looks for a module only the
907
+ // Workers runtime has.
908
+ const specifier = "cloudflare:workers";
909
+ bindings = (await import(/* @vite-ignore */ specifier)).env;
910
+ }
911
+ catch {
912
+ bindings = undefined;
913
+ }
914
+ }
915
+ const namespace = bindings?.[binding];
916
+ if (!namespace || typeof namespace.idFromName !== "function") {
917
+ if (!warnedHub) {
918
+ warnedHub = true;
919
+ console.warn(`[rsc-kit] RSC_CHANGES_HUB names ${JSON.stringify(binding)}, which is not a Durable Object binding ` +
920
+ "here: each isolate watches for its own tabs. Declare the binding in wrangler config: " +
921
+ "https://docs.rsc-kit.dev/guides/live-data#one-hub-on-workers");
922
+ }
923
+ return null;
924
+ }
925
+ return namespace.get(namespace.idFromName(HUB_INSTANCE));
926
+ }
927
+ /** Whether this request came from an isolate forwarding to the hub. */
928
+ async function fromIsolate(request) {
929
+ const proof = request.headers.get(HUB_HEADER);
930
+ return proof !== null && (await verify(HUB_PROOF, proof));
931
+ }
932
+ /** Tell the hub something changed, so the tabs it holds hear at once. */
933
+ async function pokeHub() {
934
+ let stub;
935
+ try {
936
+ stub = await hubStub();
937
+ }
938
+ catch {
939
+ return;
940
+ }
941
+ if (!stub)
942
+ return;
943
+ try {
944
+ await stub.fetch(new Request("https://hub" + HEADER.changesPath, {
945
+ method: "POST",
946
+ headers: { [HUB_HEADER]: await sign(HUB_PROOF) },
947
+ }));
948
+ }
949
+ catch (error) {
950
+ // The change is in the store; the hub reads it at its next ask.
951
+ if (!warnedHub) {
952
+ warnedHub = true;
953
+ console.warn("[rsc-kit] telling the changes hub failed; its tabs hear at its next read: " +
954
+ (error instanceof Error ? error.message : String(error)));
955
+ }
956
+ }
957
+ }
958
+ /**
959
+ * The changes endpoint: a tab's stream here, or - with a hub - in the hub,
960
+ * where every tab's is. The hub itself answers what isolates forward to it:
961
+ * a stream to hold, or a poke to ask now.
962
+ */
963
+ export async function serveChanges(request) {
964
+ // Forwarded, but not with this key: never forwarded again, which would be
965
+ // a loop - the hub reaching itself - and never served as if from the hub.
966
+ if (request.headers.has(HUB_HEADER) && !(await fromIsolate(request))) {
967
+ return new Response("Not from this deployment's isolates.", { status: 403 });
968
+ }
969
+ if (request.headers.has(HUB_HEADER)) {
970
+ if (request.method === "POST") {
971
+ signal();
972
+ return new Response(null, { status: 204 });
973
+ }
974
+ return await changes(request);
975
+ }
976
+ if (request.method !== "GET")
977
+ return new Response("Method not allowed.", { status: 405 });
978
+ const stub = await hubStub(request);
979
+ if (!stub)
980
+ return await changes(request);
981
+ const headers = new Headers(request.headers);
982
+ headers.set(HUB_HEADER, await sign(HUB_PROOF));
983
+ const held = await stub.fetch(new Request(request.url, { method: "GET", headers, signal: request.signal }));
984
+ // A stub's response has immutable headers, and the response is still the
985
+ // app's to finish - the server adds its own on the way out.
986
+ return new Response(held.body, held);
987
+ }
831
988
  //# sourceMappingURL=changed.js.map