@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.
@@ -0,0 +1,22 @@
1
+ /** Where the broadcast server is, and which channel and event mean "a version moved". */
2
+ export interface BroadcastOptions {
3
+ /** The server: `wss://ws.example.com` or `http://localhost:8080` (http is read as ws). */
4
+ url: string;
5
+ /** The app key the server knows this app by (Laravel: REVERB_APP_KEY, or PUSHER_APP_KEY). */
6
+ key: string;
7
+ /** Default `rsc-versions`, the channel Laravel's Rsc::changed() broadcasts on. */
8
+ channel?: string;
9
+ /** Default `rsc.changed`, the event it broadcasts. */
10
+ event?: string;
11
+ }
12
+ /**
13
+ * A `wakeOn` listener for a broadcast server:
14
+ *
15
+ * wakeOn(listenToBroadcast({ url: 'wss://ws.example.com', key: process.env.REVERB_APP_KEY! }))
16
+ *
17
+ * or, with no code, `RSC_BROADCAST_URL` and `RSC_BROADCAST_KEY` in the
18
+ * renderer's environment. Uses the runtime's own WebSocket - Bun, Node 22,
19
+ * Deno - and reconnects with backoff when the connection drops, waking once
20
+ * it is back, since anything announced while it was gone was lost.
21
+ */
22
+ export declare function listenToBroadcast(options: BroadcastOptions): (wake: () => void) => () => void;
@@ -0,0 +1,109 @@
1
+ // Waking on a broadcast: the renderer subscribes to one channel on a
2
+ // broadcast server - one speaking the Pusher protocol, which Laravel's
3
+ // Reverb, Pusher and Soketi all do - and is woken whenever a change is
4
+ // announced on it.
5
+ //
6
+ // The announcement carries nothing: no names, no data. Anyone may subscribe
7
+ // to a public channel, so what goes over it must be safe for anyone to see;
8
+ // "something changed" is. The renderer then asks wherever the versions live
9
+ // - the backend, through __rsc.changed - which answers for its visitor's
10
+ // names alone, as it always has.
11
+ const PING_MS = 60_000;
12
+ /**
13
+ * A `wakeOn` listener for a broadcast server:
14
+ *
15
+ * wakeOn(listenToBroadcast({ url: 'wss://ws.example.com', key: process.env.REVERB_APP_KEY! }))
16
+ *
17
+ * or, with no code, `RSC_BROADCAST_URL` and `RSC_BROADCAST_KEY` in the
18
+ * renderer's environment. Uses the runtime's own WebSocket - Bun, Node 22,
19
+ * Deno - and reconnects with backoff when the connection drops, waking once
20
+ * it is back, since anything announced while it was gone was lost.
21
+ */
22
+ export function listenToBroadcast(options) {
23
+ const channel = options.channel ?? "rsc-versions";
24
+ const event = options.event ?? "rsc.changed";
25
+ const base = options.url.replace(/^http/, "ws").replace(/\/+$/, "");
26
+ const url = `${base}/app/${encodeURIComponent(options.key)}?protocol=7&client=rsc-kit&version=1&flash=false`;
27
+ return (wake) => {
28
+ let socket = null;
29
+ let stopped = false;
30
+ let failures = 0;
31
+ let retry;
32
+ let ping;
33
+ let warned = false;
34
+ const send = (message) => {
35
+ try {
36
+ socket?.send(JSON.stringify(message));
37
+ }
38
+ catch {
39
+ // Closing; the close handler reconnects.
40
+ }
41
+ };
42
+ const connect = () => {
43
+ if (stopped)
44
+ return;
45
+ const WebSocketImpl = globalThis
46
+ .WebSocket;
47
+ if (!WebSocketImpl) {
48
+ console.warn("[rsc-kit] listenToBroadcast needs a runtime with WebSocket (Bun, Node 22+): not listening.");
49
+ return;
50
+ }
51
+ const ws = new WebSocketImpl(url);
52
+ socket = ws;
53
+ ws.onmessage = (message) => {
54
+ let frame;
55
+ try {
56
+ frame = JSON.parse(String(message.data));
57
+ }
58
+ catch {
59
+ return;
60
+ }
61
+ if (frame.event === "pusher:connection_established") {
62
+ send({ event: "pusher:subscribe", data: { channel } });
63
+ }
64
+ else if (frame.event === "pusher_internal:subscription_succeeded") {
65
+ failures = 0;
66
+ // Connected again after a drop: whatever was announced meanwhile is
67
+ // gone, so ask now.
68
+ wake();
69
+ }
70
+ else if (frame.event === "pusher:ping") {
71
+ send({ event: "pusher:pong", data: {} });
72
+ }
73
+ else if (frame.event === "pusher:error") {
74
+ if (!warned) {
75
+ warned = true;
76
+ console.warn("[rsc-kit] the broadcast server refused: " +
77
+ JSON.stringify(frame.data));
78
+ }
79
+ }
80
+ else if (frame.event === event && frame.channel === channel) {
81
+ wake();
82
+ }
83
+ };
84
+ ws.onclose = () => {
85
+ clearInterval(ping);
86
+ if (socket === ws)
87
+ socket = null;
88
+ if (stopped)
89
+ return;
90
+ retry = setTimeout(connect, Math.min(30_000, 1000 * 2 ** failures++));
91
+ retry.unref?.();
92
+ };
93
+ ws.onerror = () => {
94
+ // The close that follows reconnects.
95
+ };
96
+ // Some proxies drop a connection that only listens.
97
+ ping = setInterval(() => send({ event: "pusher:ping", data: {} }), PING_MS);
98
+ ping.unref?.();
99
+ };
100
+ connect();
101
+ return () => {
102
+ stopped = true;
103
+ clearTimeout(retry);
104
+ clearInterval(ping);
105
+ socket?.close();
106
+ };
107
+ };
108
+ }
109
+ //# sourceMappingURL=broadcast.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"broadcast.js","sourceRoot":"","sources":["../src/broadcast.ts"],"names":[],"mappings":"AAAA,qEAAqE;AACrE,uEAAuE;AACvE,uEAAuE;AACvE,mBAAmB;AACnB,EAAE;AACF,4EAA4E;AAC5E,4EAA4E;AAC5E,4EAA4E;AAC5E,yEAAyE;AACzE,iCAAiC;AAcjC,MAAM,OAAO,GAAG,MAAM,CAAC;AAEvB;;;;;;;;;GASG;AACH,MAAM,UAAU,iBAAiB,CAC/B,OAAyB;IAEzB,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,cAAc,CAAC;IAClD,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,aAAa,CAAC;IAC7C,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IACpE,MAAM,GAAG,GAAG,GAAG,IAAI,QAAQ,kBAAkB,CAAC,OAAO,CAAC,GAAG,CAAC,kDAAkD,CAAC;IAE7G,OAAO,CAAC,IAAI,EAAE,EAAE;QACd,IAAI,MAAM,GAAqB,IAAI,CAAC;QACpC,IAAI,OAAO,GAAG,KAAK,CAAC;QACpB,IAAI,QAAQ,GAAG,CAAC,CAAC;QACjB,IAAI,KAAgD,CAAC;QACrD,IAAI,IAAgD,CAAC;QACrD,IAAI,MAAM,GAAG,KAAK,CAAC;QAEnB,MAAM,IAAI,GAAG,CAAC,OAAgB,EAAE,EAAE;YAChC,IAAI,CAAC;gBACH,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC;YACxC,CAAC;YAAC,MAAM,CAAC;gBACP,yCAAyC;YAC3C,CAAC;QACH,CAAC,CAAC;QAEF,MAAM,OAAO,GAAG,GAAG,EAAE;YACnB,IAAI,OAAO;gBAAE,OAAO;YAEpB,MAAM,aAAa,GAAI,UAA+C;iBACnE,SAAS,CAAC;YAEb,IAAI,CAAC,aAAa,EAAE,CAAC;gBACnB,OAAO,CAAC,IAAI,CACV,4FAA4F,CAC7F,CAAC;gBACF,OAAO;YACT,CAAC;YAED,MAAM,EAAE,GAAG,IAAI,aAAa,CAAC,GAAG,CAAC,CAAC;YAElC,MAAM,GAAG,EAAE,CAAC;YAEZ,EAAE,CAAC,SAAS,GAAG,CAAC,OAAqB,EAAE,EAAE;gBACvC,IAAI,KAA2D,CAAC;gBAEhE,IAAI,CAAC;oBACH,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAiB,CAAC;gBAC3D,CAAC;gBAAC,MAAM,CAAC;oBACP,OAAO;gBACT,CAAC;gBAED,IAAI,KAAK,CAAC,KAAK,KAAK,+BAA+B,EAAE,CAAC;oBACpD,IAAI,CAAC,EAAE,KAAK,EAAE,kBAAkB,EAAE,IAAI,EAAE,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC;gBACzD,CAAC;qBAAM,IAAI,KAAK,CAAC,KAAK,KAAK,wCAAwC,EAAE,CAAC;oBACpE,QAAQ,GAAG,CAAC,CAAC;oBACb,oEAAoE;oBACpE,oBAAoB;oBACpB,IAAI,EAAE,CAAC;gBACT,CAAC;qBAAM,IAAI,KAAK,CAAC,KAAK,KAAK,aAAa,EAAE,CAAC;oBACzC,IAAI,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;gBAC3C,CAAC;qBAAM,IAAI,KAAK,CAAC,KAAK,KAAK,cAAc,EAAE,CAAC;oBAC1C,IAAI,CAAC,MAAM,EAAE,CAAC;wBACZ,MAAM,GAAG,IAAI,CAAC;wBACd,OAAO,CAAC,IAAI,CACV,0CAA0C;4BACxC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,CAC7B,CAAC;oBACJ,CAAC;gBACH,CAAC;qBAAM,IAAI,KAAK,CAAC,KAAK,KAAK,KAAK,IAAI,KAAK,CAAC,OAAO,KAAK,OAAO,EAAE,CAAC;oBAC9D,IAAI,EAAE,CAAC;gBACT,CAAC;YACH,CAAC,CAAC;YAEF,EAAE,CAAC,OAAO,GAAG,GAAG,EAAE;gBAChB,aAAa,CAAC,IAAI,CAAC,CAAC;gBACpB,IAAI,MAAM,KAAK,EAAE;oBAAE,MAAM,GAAG,IAAI,CAAC;gBACjC,IAAI,OAAO;oBAAE,OAAO;gBAEpB,KAAK,GAAG,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,GAAG,CAAC,IAAI,QAAQ,EAAE,CAAC,CAAC,CAAC;gBACrE,KAAgC,CAAC,KAAK,EAAE,EAAE,CAAC;YAC9C,CAAC,CAAC;YAEF,EAAE,CAAC,OAAO,GAAG,GAAG,EAAE;gBAChB,qCAAqC;YACvC,CAAC,CAAC;YAEF,oDAAoD;YACpD,IAAI,GAAG,WAAW,CAChB,GAAG,EAAE,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,EAC9C,OAAO,CACR,CAAC;YACD,IAA+B,CAAC,KAAK,EAAE,EAAE,CAAC;QAC7C,CAAC,CAAC;QAEF,OAAO,EAAE,CAAC;QAEV,OAAO,GAAG,EAAE;YACV,OAAO,GAAG,IAAI,CAAC;YACf,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,aAAa,CAAC,IAAI,CAAC,CAAC;YACpB,MAAM,EAAE,KAAK,EAAE,CAAC;QAClB,CAAC,CAAC;IACJ,CAAC,CAAC;AACJ,CAAC","sourcesContent":["// Waking on a broadcast: the renderer subscribes to one channel on a\n// broadcast server - one speaking the Pusher protocol, which Laravel's\n// Reverb, Pusher and Soketi all do - and is woken whenever a change is\n// announced on it.\n//\n// The announcement carries nothing: no names, no data. Anyone may subscribe\n// to a public channel, so what goes over it must be safe for anyone to see;\n// \"something changed\" is. The renderer then asks wherever the versions live\n// - the backend, through __rsc.changed - which answers for its visitor's\n// names alone, as it always has.\n\n/** Where the broadcast server is, and which channel and event mean \"a version moved\". */\nexport interface BroadcastOptions {\n /** The server: `wss://ws.example.com` or `http://localhost:8080` (http is read as ws). */\n url: string;\n /** The app key the server knows this app by (Laravel: REVERB_APP_KEY, or PUSHER_APP_KEY). */\n key: string;\n /** Default `rsc-versions`, the channel Laravel's Rsc::changed() broadcasts on. */\n channel?: string;\n /** Default `rsc.changed`, the event it broadcasts. */\n event?: string;\n}\n\nconst PING_MS = 60_000;\n\n/**\n * A `wakeOn` listener for a broadcast server:\n *\n * wakeOn(listenToBroadcast({ url: 'wss://ws.example.com', key: process.env.REVERB_APP_KEY! }))\n *\n * or, with no code, `RSC_BROADCAST_URL` and `RSC_BROADCAST_KEY` in the\n * renderer's environment. Uses the runtime's own WebSocket - Bun, Node 22,\n * Deno - and reconnects with backoff when the connection drops, waking once\n * it is back, since anything announced while it was gone was lost.\n */\nexport function listenToBroadcast(\n options: BroadcastOptions,\n): (wake: () => void) => () => void {\n const channel = options.channel ?? \"rsc-versions\";\n const event = options.event ?? \"rsc.changed\";\n const base = options.url.replace(/^http/, \"ws\").replace(/\\/+$/, \"\");\n const url = `${base}/app/${encodeURIComponent(options.key)}?protocol=7&client=rsc-kit&version=1&flash=false`;\n\n return (wake) => {\n let socket: WebSocket | null = null;\n let stopped = false;\n let failures = 0;\n let retry: ReturnType<typeof setTimeout> | undefined;\n let ping: ReturnType<typeof setInterval> | undefined;\n let warned = false;\n\n const send = (message: unknown) => {\n try {\n socket?.send(JSON.stringify(message));\n } catch {\n // Closing; the close handler reconnects.\n }\n };\n\n const connect = () => {\n if (stopped) return;\n\n const WebSocketImpl = (globalThis as { WebSocket?: typeof WebSocket })\n .WebSocket;\n\n if (!WebSocketImpl) {\n console.warn(\n \"[rsc-kit] listenToBroadcast needs a runtime with WebSocket (Bun, Node 22+): not listening.\",\n );\n return;\n }\n\n const ws = new WebSocketImpl(url);\n\n socket = ws;\n\n ws.onmessage = (message: MessageEvent) => {\n let frame: { event?: string; channel?: string; data?: unknown };\n\n try {\n frame = JSON.parse(String(message.data)) as typeof frame;\n } catch {\n return;\n }\n\n if (frame.event === \"pusher:connection_established\") {\n send({ event: \"pusher:subscribe\", data: { channel } });\n } else if (frame.event === \"pusher_internal:subscription_succeeded\") {\n failures = 0;\n // Connected again after a drop: whatever was announced meanwhile is\n // gone, so ask now.\n wake();\n } else if (frame.event === \"pusher:ping\") {\n send({ event: \"pusher:pong\", data: {} });\n } else if (frame.event === \"pusher:error\") {\n if (!warned) {\n warned = true;\n console.warn(\n \"[rsc-kit] the broadcast server refused: \" +\n JSON.stringify(frame.data),\n );\n }\n } else if (frame.event === event && frame.channel === channel) {\n wake();\n }\n };\n\n ws.onclose = () => {\n clearInterval(ping);\n if (socket === ws) socket = null;\n if (stopped) return;\n\n retry = setTimeout(connect, Math.min(30_000, 1000 * 2 ** failures++));\n (retry as { unref?: () => void }).unref?.();\n };\n\n ws.onerror = () => {\n // The close that follows reconnects.\n };\n\n // Some proxies drop a connection that only listens.\n ping = setInterval(\n () => send({ event: \"pusher:ping\", data: {} }),\n PING_MS,\n );\n (ping as { unref?: () => void }).unref?.();\n };\n\n connect();\n\n return () => {\n stopped = true;\n clearTimeout(retry);\n clearInterval(ping);\n socket?.close();\n };\n };\n}\n"]}
package/dist/changed.d.ts CHANGED
@@ -26,6 +26,7 @@
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
+ export { listenToBroadcast, type BroadcastOptions } from "./broadcast.js";
29
30
  /** The reserved name a backend answers name versions on. */
30
31
  export declare const CHANGED_FUNCTION = "__rsc.changed";
31
32
  /** What `__rsc.changed` is asked. */
@@ -52,8 +53,33 @@ export interface VersionSource {
52
53
  /** Say these names changed. Only a source that holds its own versions can. */
53
54
  bump(names: string[]): void | Promise<void>;
54
55
  }
55
- /** Versions kept in this process: the source without a backend, and the one tests use. */
56
- export declare function memoryVersions(): VersionSource;
56
+ /**
57
+ * The version a name moves to: the larger of one past where it was and the
58
+ * current time in milliseconds.
59
+ *
60
+ * A counter would do for "it moved", but a counter repeats once its row is
61
+ * gone - deleted to keep the store small, a name starts again from 0 and
62
+ * climbs back to a value some tab is still holding, and that tab misses the
63
+ * change. A time never comes round again, so a name may be forgotten at any
64
+ * moment: a tab holding the old value sees a different one, and at worst
65
+ * refreshes once for nothing. It is also when the name last moved, which is
66
+ * all cleanup needs. Every store bumps with it; one that writes `+ 1` still
67
+ * works, it is just not safe to prune.
68
+ */
69
+ export declare function nextVersion(current?: number): number;
70
+ /** The safety-net read of a store that listens: longer than any ask waits, so once per ask. */
71
+ export declare const LISTENING_POLL_MS = 30000;
72
+ /** How long a name nobody changes is kept, by the stores that forget on their own. */
73
+ export declare const VERSIONS_KEPT_MS: number;
74
+ /**
75
+ * Versions kept in this process: the source without a backend, and the one
76
+ * tests use. A name not changed in `forgetAfter` ms (30 days) is forgotten,
77
+ * swept at most once an hour, so a process up for months does not hold every
78
+ * name it ever saw.
79
+ */
80
+ export declare function memoryVersions(options?: {
81
+ forgetAfter?: number;
82
+ }): VersionSource;
57
83
  type HostFn = (name: string, ...args: unknown[]) => Promise<unknown>;
58
84
  /**
59
85
  * Versions kept by the backend, read through `__rsc.changed`.
@@ -67,6 +93,17 @@ export declare function backendVersions(call: HostFn): VersionSource;
67
93
  /** Runs one statement with positional parameters and resolves to its rows. */
68
94
  export type SqlQuery = (text: string, params: unknown[]) => Promise<readonly Record<string, unknown>[]>;
69
95
  export interface SqlVersionsOptions {
96
+ /**
97
+ * Which database, so a change is one statement - an upsert of every name
98
+ * it moves - rather than a read and a write per name. Left out, the store
99
+ * uses only SQL every database has, at the cost of those extra queries.
100
+ */
101
+ dialect?: "postgres" | "mysql" | "sqlite";
102
+ /**
103
+ * Postgres only: NOTIFY this channel in the same statement as the upsert,
104
+ * so a listening server wakes - and only once the write commits.
105
+ */
106
+ channel?: string;
70
107
  /**
71
108
  * Your driver, as a function of SQL text and parameters:
72
109
  *
@@ -86,33 +123,30 @@ export interface SqlVersionsOptions {
86
123
  *
87
124
  * Without it, a waiting ask reads the table every `poll` ms.
88
125
  */
89
- listen?: (wake: () => void) => unknown;
126
+ listen?: (wake: () => void, health: ListenerHealth) => unknown;
90
127
  /**
91
128
  * Run after every bump, to tell the other instances - Postgres's NOTIFY:
92
129
  *
93
130
  * notify: () => sql.notify('rsc_versions', '')
94
131
  */
95
132
  notify?: () => unknown;
96
- /** How often a waiting ask reads the table. Default 1000ms. */
133
+ /**
134
+ * How often a waiting ask reads the table: every second by default, or,
135
+ * with `listen`, only as a safety net - about once per ask.
136
+ */
97
137
  poll?: number;
98
138
  }
99
- /**
100
- * Versions in a table every instance shares: the web servers, and the worker
101
- * that finishes a job and says so. The same table and columns Go's
102
- * `SQLVersions` uses, so a Go service and a JavaScript worker can share one.
103
- *
104
- * installVersionSource(sqlVersions({
105
- * query: (text, params) => sql.unsafe(text, params),
106
- * placeholder: (n) => '$' + n,
107
- * listen: (wake) => sql.listen('rsc_versions', wake),
108
- * notify: () => sql.notify('rsc_versions', ''),
109
- * }))
110
- *
111
- * Written with an UPDATE and, the first time, an INSERT - two statements any
112
- * SQL dialect has - rather than an upsert each spells differently. Takes the
113
- * app's own driver, so the engine has no database dependency of its own.
114
- */
115
- export declare function sqlVersions(options: SqlVersionsOptions): VersionSource;
139
+ /** A store whose old names can be cleared out: the SQL ones. */
140
+ export interface PrunableVersions extends VersionSource {
141
+ /**
142
+ * Delete every name not changed in `olderThan` ms (30 days by default).
143
+ * Always safe: a version is a time and never comes round again, so a tab
144
+ * still holding a pruned name sees it differ and refreshes once. Run it
145
+ * from a scheduled job - a daily one is plenty.
146
+ */
147
+ prune(olderThan?: number): Promise<void>;
148
+ }
149
+ export declare function sqlVersions(options: SqlVersionsOptions): PrunableVersions;
116
150
  /**
117
151
  * Where versions are kept, as the app writes it for its own database: the
118
152
  * three things rsc-kit cannot know about your storage, and nothing else.
@@ -134,14 +168,24 @@ export declare function sqlVersions(options: SqlVersionsOptions): VersionSource;
134
168
  export interface VersionStore {
135
169
  /** The current version of each name asked for. A name it has never seen may be left out: it is 0. */
136
170
  read(names: string[]): Promise<Record<string, number>>;
137
- /** Move each name's version - by one, or to anything new: watchers compare, they do not count. */
171
+ /**
172
+ * Move each name's version to `nextVersion(current)` - one past where it
173
+ * was, or the current time in ms if that is larger. Watchers only compare,
174
+ * so any new value works; this one never repeats, which is what makes it
175
+ * safe to delete a name later. A plain `+ 1` works too, until a row is
176
+ * deleted.
177
+ */
138
178
  bump(names: string[]): Promise<void>;
139
179
  /**
140
180
  * Called once with a function that wakes every waiting ask, when the store
141
181
  * can say the moment a version moved - Postgres LISTEN, Redis SUBSCRIBE.
142
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.
143
187
  */
144
- listen?(wake: () => void): unknown;
188
+ listen?(wake: () => void, health: ListenerHealth): unknown;
145
189
  /** Run after a bump, to tell the other instances listening: Postgres NOTIFY, Redis PUBLISH. */
146
190
  notify?(): unknown;
147
191
  }
@@ -162,8 +206,8 @@ export declare function createVersions(store: VersionStore, options?: {
162
206
  /** The parts of a Postgres client `postgresVersions` uses: postgres.js and `Bun.sql` both have them. */
163
207
  export interface PostgresClient {
164
208
  unsafe(text: string, params?: unknown[]): PromiseLike<readonly Record<string, unknown>[]>;
165
- /** postgres.js has it; with it, a waiting ask hears another instance at once. */
166
- 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;
167
211
  notify?(channel: string, payload: string): unknown;
168
212
  }
169
213
  /**
@@ -172,9 +216,10 @@ export interface PostgresClient {
172
216
  * installVersionSource(postgresVersions(sql))
173
217
  *
174
218
  * `sqlVersions` with Postgres's placeholders, and - when the client can
175
- * LISTEN, as postgres.js can - a change on one instance wakes the waiting asks
176
- * on every other at once, rather than on their next read of the table.
177
- * Without it (`Bun.sql`), the table is read once a second while an ask waits.
219
+ * LISTEN, as postgres.js and `Bun.sql` (Bun 1.4+) can - a change on one
220
+ * instance wakes the waiting asks on every other at once, rather than on
221
+ * their next read of the table. Without it, the table is read once a second
222
+ * while an ask waits.
178
223
  *
179
224
  * CREATE TABLE rsc_versions (name TEXT PRIMARY KEY, version BIGINT NOT NULL)
180
225
  */
@@ -182,7 +227,25 @@ export declare function postgresVersions(sql: PostgresClient, options?: {
182
227
  table?: string;
183
228
  channel?: string;
184
229
  poll?: number;
185
- }): VersionSource;
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;
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;
186
249
  /**
187
250
  * Install where versions are read from - a table, Redis, any store - for this
188
251
  * process; `null` takes it out again.
@@ -191,7 +254,7 @@ export declare function postgresVersions(sql: PostgresClient, options?: {
191
254
  * writes its versions to a store this process can read too, reading it here
192
255
  * means watching costs the backend nothing.
193
256
  */
194
- export declare function installVersionSource(source: VersionSource | null): void;
257
+ export declare function installVersionSource(source: VersionSource | (() => VersionSource) | null): void;
195
258
  /** The engine's, when a backend is installed: its versions, read through it. Not for apps. */
196
259
  export declare function installBackendVersionSource(source: VersionSource | null): void;
197
260
  /** The source in use: the app's, else the backend's, else this process's own. */
@@ -244,6 +307,25 @@ export type SignedName = [version: number, signature: string];
244
307
  export declare const CHANGES_INTERVAL_MS = 2000;
245
308
  /** How long the source may hold one ask. Bounds how long a new tab waits to join the shared ask. */
246
309
  export declare const CHANGES_WAIT_MS = 5000;
310
+ /**
311
+ * Wake the renderer when a version may have moved, so it asks then rather
312
+ * than on its next interval.
313
+ *
314
+ * wakeOn(listenToBroadcast({ url, key })) // a broadcast server
315
+ * wakeOn((wake) => subscriber.subscribe('rsc', wake)) // a Redis channel
316
+ *
317
+ * `listen` is given a function to call - it carries no names and no data;
318
+ * the renderer asks wherever the versions live, as always. It is started
319
+ * when the first tab starts watching, never in a build. With one installed,
320
+ * a backend that answers at once is asked again every 30 seconds as a safety
321
+ * net instead of every two.
322
+ */
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;
247
329
  /**
248
330
  * The stream a tab watches its names on: `GET /_rsc/changes?w=[[name, version, signature], …]`.
249
331
  *
@@ -254,7 +336,8 @@ export declare const CHANGES_WAIT_MS = 5000;
254
336
  */
255
337
  export declare function changes(request: Request): Promise<Response>;
256
338
  /** For tests: forget every stream and version this process has seen. */
339
+ /** For tests: how many names this process remembers for its open streams. */
340
+ export declare function rememberedNames(): number;
257
341
  export declare function resetChanges(): void;
258
342
  /** The url a tab opens to watch, for the client. */
259
343
  export declare const CHANGES_PATH: "/_rsc/changes";
260
- export {};