@orpc/publisher 2.0.0-beta.40 → 2.0.0-beta.41

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/README.md CHANGED
@@ -85,7 +85,7 @@ Like what we build over at [middleapi](https://github.com/middleapi)? You can he
85
85
 
86
86
  <table>
87
87
  <tr>
88
- <td width="2000"><a href="https://screenshotone.com/?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener" title="The screenshot API for developers"><img src="https://avatars.githubusercontent.com/u/97035603?v=4" width="64" align="left" hspace="12" alt="ScreenshotOne.com"/><b>ScreenshotOne.com</b></a><br /><sub>The screenshot API for developers</sub></td>
88
+ <td width="2000"><a href="https://screenshotone.com/?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="The screenshot API for developers"><img src="https://avatars.githubusercontent.com/u/97035603?v=4" width="64" align="left" hspace="12" alt="ScreenshotOne.com"/><b>ScreenshotOne.com</b></a><br /><sub>The screenshot API for developers</sub></td>
89
89
  </tr>
90
90
  <tr>
91
91
  <td width="2000"><a href="https://yuzu.health/careers?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="We&#39;re hiring NYC based engineers"><img src="https://avatars.githubusercontent.com/u/102488956?v=4" width="64" align="left" hspace="12" alt="Yuzu"/><b>Yuzu</b></a><br /><sub>We&#39;re hiring NYC based engineers</sub></td>
@@ -127,19 +127,18 @@ Like what we build over at [middleapi](https://github.com/middleapi)? You can he
127
127
  <td align="center"><a href="https://soymilk.party/?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="あわわわとーにゅ"><img src="https://avatars.githubusercontent.com/u/17376330?u=de3353804be889f009f7e0a1582daf04d0ab292d&amp;v=4" width="139" alt="あわわわとーにゅ"/><br />あわわわとーにゅ</a></td>
128
128
  <td align="center"><a href="https://github.com/nicognaW?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="nk"><img src="https://avatars.githubusercontent.com/u/66731869?u=4699bda3a9092d3ec34fbd959450767bcc8b8b6d&amp;v=4" width="139" alt="nk"/><br />nk</a></td>
129
129
  <td align="center"><a href="https://supastarter.dev/?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="supastarter"><img src="https://avatars.githubusercontent.com/u/110960143?v=4" width="139" alt="supastarter"/><br />supastarter</a></td>
130
- <td align="center"><a href="https://github.com/divmgl?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="Dexter Miguel"><img src="https://avatars.githubusercontent.com/u/5452298?u=645993204be8696c085ecf0d228c3062efe2ed65&amp;v=4" width="139" alt="Dexter Miguel"/><br />Dexter Miguel</a></td>
131
130
  <td align="center"><a href="https://github.com/herrfugbaum?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="herrfugbaum"><img src="https://avatars.githubusercontent.com/u/12859776?u=644dc1666d0220bc0468eb0de3c56b919f635b16&amp;v=4" width="139" alt="herrfugbaum"/><br />herrfugbaum</a></td>
131
+ <td align="center"><a href="https://laststance.io/?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="Ryota Murakami"><img src="https://avatars.githubusercontent.com/u/5501268?u=599389e03340734325726ca3f8f423c021d47d7f&amp;v=4" width="139" alt="Ryota Murakami"/><br />Ryota Murakami</a></td>
132
132
  </tr>
133
133
  <tr>
134
- <td align="center"><a href="https://laststance.io/?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="Ryota Murakami"><img src="https://avatars.githubusercontent.com/u/5501268?u=599389e03340734325726ca3f8f423c021d47d7f&amp;v=4" width="139" alt="Ryota Murakami"/><br />Ryota Murakami</a></td>
135
134
  <td align="center"><a href="https://cra.mr/?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="David Cramer"><img src="https://avatars.githubusercontent.com/u/23610?v=4" width="139" alt="David Cramer"/><br />David Cramer</a></td>
136
135
  <td align="center"><a href="https://valerii15298.github.io/?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="Valerii Petryniak"><img src="https://avatars.githubusercontent.com/u/44531564?u=88ac74d9bacd20401518441907acad21063cd397&amp;v=4" width="139" alt="Valerii Petryniak"/><br />Valerii Petryniak</a></td>
137
136
  <td align="center"><a href="https://letstri.dev/?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="Valerii Strilets"><img src="https://avatars.githubusercontent.com/u/13253748?u=c7b10399ccc8f8081e24db94ec32cd9858e86ac3&amp;v=4" width="139" alt="Valerii Strilets"/><br />Valerii Strilets</a></td>
138
137
  <td align="center"><a href="https://blacklight.sh/?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="Kyle Mistele"><img src="https://avatars.githubusercontent.com/u/18430555?u=3afebeb81de666e35aaac3ed46f14159d7603ffb&amp;v=4" width="139" alt="Kyle Mistele"/><br />Kyle Mistele</a></td>
139
138
  <td align="center"><a href="https://github.com/christ12938?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="christ12938"><img src="https://avatars.githubusercontent.com/u/25758598?v=4" width="139" alt="christ12938"/><br />christ12938</a></td>
139
+ <td align="center"><a href="https://github.com/Ryanjso?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="Ryan Soderberg"><img src="https://avatars.githubusercontent.com/u/39172778?u=5ed913c31d57e7221b75784abcad48c7ebddde27&amp;v=4" width="139" alt="Ryan Soderberg"/><br />Ryan Soderberg</a></td>
140
140
  </tr>
141
141
  <tr>
142
- <td align="center"><a href="https://github.com/Ryanjso?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="Ryan Soderberg"><img src="https://avatars.githubusercontent.com/u/39172778?u=5ed913c31d57e7221b75784abcad48c7ebddde27&amp;v=4" width="139" alt="Ryan Soderberg"/><br />Ryan Soderberg</a></td>
143
142
  <td align="center"><a href="https://github.com/itigoore01?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="shota"><img src="https://avatars.githubusercontent.com/u/11831107?u=c976a6dc7e055eb026304c46c99100ed22b0c8e0&amp;v=4" width="139" alt="shota"/><br />shota</a></td>
144
143
  <td align="center"><a href="https://github.com/ellis-driscoll?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="Ellis Driscoll"><img src="https://avatars.githubusercontent.com/u/70685966?u=c5f95bc33b5991d9744abe00052542e4a2ed3cb9&amp;v=4" width="139" alt="Ellis Driscoll"/><br />Ellis Driscoll</a></td>
145
144
  <td align="center"><a href="https://github.com/hoangbn?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="Hoang Nguyen"><img src="https://avatars.githubusercontent.com/u/38968280?u=c90084c6de65c56facabab7ba13a72a49ddbc3e4&amp;v=4" width="139" alt="Hoang Nguyen"/><br />Hoang Nguyen</a></td>
@@ -173,12 +172,13 @@ Like what we build over at [middleapi](https://github.com/middleapi)? You can he
173
172
  <td align="center"><a href="https://kellychan.im/?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="Kelly Peilin Chan"><img src="https://avatars.githubusercontent.com/u/520852?u=6b0f7105f694e7b5cacf410a3f04c7044b469dc8&amp;v=4" width="119" alt="Kelly Peilin Chan"/><br />Kelly Peilin Chan</a></td>
174
173
  <td align="center"><a href="https://guyariely.com/?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="Guy Ariely"><img src="https://avatars.githubusercontent.com/u/42813496?u=edb6b7f563bf28e160a290832e7da57c0506f8ca&amp;v=4" width="119" alt="Guy Ariely"/><br />Guy Ariely</a></td>
175
174
  <td align="center"><a href="https://paulsenon.com/?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="PaulSenon"><img src="https://avatars.githubusercontent.com/u/19531087?u=6385741eb91d200b8c513ec6045482e301767ca6&amp;v=4" width="119" alt="PaulSenon"/><br />PaulSenon</a></td>
175
+ <td align="center"><a href="https://nakasyou.how/?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="Shotaro Nakamura"><img src="https://avatars.githubusercontent.com/u/79000684?u=f644df3f29f0e8677a90967115774564f1d9d6ab&amp;v=4" width="119" alt="Shotaro Nakamura"/><br />Shotaro Nakamura</a></td>
176
176
  <td align="center"><a href="https://piscis.dev/?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="Alex"><img src="https://avatars.githubusercontent.com/u/326163?u=b245f368bd940cf51d08c0b6bf55f8257f359437&amp;v=4" width="119" alt="Alex"/><br />Alex</a></td>
177
177
  <td align="center"><a href="https://opensource.gubanov.eu/?ref=middleapi&amp;utm_source=middleapi&amp;utm_medium=sponsor" target="_blank" rel="noopener sponsored" title="Andrey Gubanov"><img src="https://avatars.githubusercontent.com/u/1082083?u=c5f2daf7ebece498e85c83367bb37b4e10e2649d&amp;v=4" width="119" alt="Andrey Gubanov"/><br />Andrey Gubanov</a></td>
178
178
  </tr>
179
179
  </table>
180
180
 
181
- With thanks to [36 past sponsors](https://htmlpreview.github.io/?https://github.com/middleapi/static/blob/main/sponsors.svg) who helped get oRPC here.
181
+ With thanks to [38 past sponsors](https://orpc.dev/sponsors#past-sponsors) who helped get oRPC here.
182
182
 
183
183
  ## References
184
184
 
@@ -9,7 +9,8 @@ import { RPCJsonSerializer } from '@orpc/client';
9
9
  */
10
10
  interface BaseRedisPublisherOptions extends PublisherOptions {
11
11
  /**
12
- * The prefix to use for Redis keys.
12
+ * The prefix to use for Redis keys and Pub/Sub channels.
13
+ * Set it to isolate events when several apps share one Redis instance.
13
14
  *
14
15
  * @default ''
15
16
  */
@@ -64,25 +65,6 @@ interface RedisStreamEntry {
64
65
  */
65
66
  data: unknown;
66
67
  }
67
- /**
68
- * Trimming to apply to a Redis Stream while adding an entry.
69
- *
70
- * @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters}
71
- */
72
- interface RedisStreamTrimOptions {
73
- /**
74
- * Entries with an ID lower than this one are removed (`XTRIM key MINID minId`).
75
- */
76
- minId: string;
77
- /**
78
- * Whether trimming is exact (`=`) or approximate (`~`).
79
- */
80
- exactness: '~' | '=';
81
- /**
82
- * Time to live to set on the stream key, in seconds (`EXPIRE key seconds`).
83
- */
84
- expireSeconds: number;
85
- }
86
68
  /**
87
69
  * Base class for Redis-backed publisher adapters. It owns the key naming, the
88
70
  * message format, and the resume logic, so every adapter built on it can exchange
@@ -119,11 +101,11 @@ declare abstract class BaseRedisPublisher<T extends Record<string, object>> exte
119
101
  */
120
102
  protected abstract subscribeChannel(channel: string, listener: (message: unknown) => void, onError?: (error: ThrowableError) => void): Promise<() => Promise<void>>;
121
103
  /**
122
- * Appends an entry to a stream (`XADD key * data <data>`) and resolves with its ID.
123
- * When `trim` is provided, also trims the stream and refreshes its TTL,
124
- * preferably within the same round trip.
104
+ * Runs a Lua script (`EVAL script numkeys key [key ...] arg [arg ...]`) and resolves with its reply.
105
+ * The publish script also uses its key as the Pub/Sub channel, so a client that prefixes
106
+ * keys must prefix channels the same way.
125
107
  */
126
- protected abstract addStreamEntry(key: string, data: string, trim?: RedisStreamTrimOptions): Promise<string>;
108
+ protected abstract evalScript(script: string, keys: string[], args: string[]): Promise<unknown>;
127
109
  /**
128
110
  * Reads the entries with an ID greater than `lastId` (`XREAD STREAMS key lastId`), oldest first.
129
111
  */
@@ -135,4 +117,4 @@ declare abstract class BaseRedisPublisher<T extends Record<string, object>> exte
135
117
  }
136
118
 
137
119
  export { BaseRedisPublisher };
138
- export type { BaseRedisPublisherOptions, RedisStreamEntry, RedisStreamTrimOptions };
120
+ export type { BaseRedisPublisherOptions, RedisStreamEntry };
@@ -9,7 +9,8 @@ import { RPCJsonSerializer } from '@orpc/client';
9
9
  */
10
10
  interface BaseRedisPublisherOptions extends PublisherOptions {
11
11
  /**
12
- * The prefix to use for Redis keys.
12
+ * The prefix to use for Redis keys and Pub/Sub channels.
13
+ * Set it to isolate events when several apps share one Redis instance.
13
14
  *
14
15
  * @default ''
15
16
  */
@@ -64,25 +65,6 @@ interface RedisStreamEntry {
64
65
  */
65
66
  data: unknown;
66
67
  }
67
- /**
68
- * Trimming to apply to a Redis Stream while adding an entry.
69
- *
70
- * @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters}
71
- */
72
- interface RedisStreamTrimOptions {
73
- /**
74
- * Entries with an ID lower than this one are removed (`XTRIM key MINID minId`).
75
- */
76
- minId: string;
77
- /**
78
- * Whether trimming is exact (`=`) or approximate (`~`).
79
- */
80
- exactness: '~' | '=';
81
- /**
82
- * Time to live to set on the stream key, in seconds (`EXPIRE key seconds`).
83
- */
84
- expireSeconds: number;
85
- }
86
68
  /**
87
69
  * Base class for Redis-backed publisher adapters. It owns the key naming, the
88
70
  * message format, and the resume logic, so every adapter built on it can exchange
@@ -119,11 +101,11 @@ declare abstract class BaseRedisPublisher<T extends Record<string, object>> exte
119
101
  */
120
102
  protected abstract subscribeChannel(channel: string, listener: (message: unknown) => void, onError?: (error: ThrowableError) => void): Promise<() => Promise<void>>;
121
103
  /**
122
- * Appends an entry to a stream (`XADD key * data <data>`) and resolves with its ID.
123
- * When `trim` is provided, also trims the stream and refreshes its TTL,
124
- * preferably within the same round trip.
104
+ * Runs a Lua script (`EVAL script numkeys key [key ...] arg [arg ...]`) and resolves with its reply.
105
+ * The publish script also uses its key as the Pub/Sub channel, so a client that prefixes
106
+ * keys must prefix channels the same way.
125
107
  */
126
- protected abstract addStreamEntry(key: string, data: string, trim?: RedisStreamTrimOptions): Promise<string>;
108
+ protected abstract evalScript(script: string, keys: string[], args: string[]): Promise<unknown>;
127
109
  /**
128
110
  * Reads the entries with an ID greater than `lastId` (`XREAD STREAMS key lastId`), oldest first.
129
111
  */
@@ -135,4 +117,4 @@ declare abstract class BaseRedisPublisher<T extends Record<string, object>> exte
135
117
  }
136
118
 
137
119
  export { BaseRedisPublisher };
138
- export type { BaseRedisPublisherOptions, RedisStreamEntry, RedisStreamTrimOptions };
120
+ export type { BaseRedisPublisherOptions, RedisStreamEntry };
@@ -1,8 +1,9 @@
1
1
  import { RPCJsonSerializer } from '@orpc/client';
2
2
  import { stringifyJSON, once, parseEmptyableJSON } from '@orpc/shared';
3
3
  import { unwrapEvent, withEventMeta, getEventMeta } from '@standard-server/core';
4
- import { P as Publisher } from '../shared/publisher.BSQf8ybR.mjs';
4
+ import { P as Publisher } from '../shared/publisher.DcPG7zl6.mjs';
5
5
 
6
+ const PUBLISH_SCRIPT = `local id=redis.call('XADD',KEYS[1],'*','data',ARGV[1]) if ARGV[2] then redis.call('XTRIM',KEYS[1],'MINID',ARGV[2],ARGV[3]) redis.call('EXPIRE',KEYS[1],ARGV[4]) end redis.call('PUBLISH',KEYS[1],'{"data":'..ARGV[1]..',"id":"'..id..'"}')`;
6
7
  class BaseRedisPublisher extends Publisher {
7
8
  prefix;
8
9
  serializer;
@@ -23,28 +24,29 @@ class BaseRedisPublisher extends Publisher {
23
24
  async publish(event, payload) {
24
25
  const channel = `${this.prefix}${event}`;
25
26
  const data = this.serializePayload(payload);
26
- let id;
27
- if (this.resumeEnabled) {
28
- const now = Date.now();
29
- const windowMs = this.resumeSeconds * 1e3;
30
- for (const [trimmedChannel, trimTime] of this.lastTrimTimes) {
31
- if (trimTime + windowMs < now) {
32
- this.lastTrimTimes.delete(trimmedChannel);
33
- }
34
- }
35
- if (this.lastTrimTimes.has(channel)) {
36
- id = await this.addStreamEntry(channel, stringifyJSON(data));
37
- } else {
38
- this.lastTrimTimes.set(channel, now);
39
- id = await this.addStreamEntry(channel, stringifyJSON(data), {
40
- minId: `${now - windowMs}-0`,
41
- exactness: this.xTrimExactness,
42
- // 2x so entries added late in a window outlive it until the next trim refreshes the TTL.
43
- expireSeconds: this.resumeSeconds * 2
44
- });
27
+ if (!this.resumeEnabled) {
28
+ await this.publishMessage(channel, stringifyJSON({ data }));
29
+ return;
30
+ }
31
+ const now = Date.now();
32
+ const windowMs = this.resumeSeconds * 1e3;
33
+ for (const [trimmedChannel, trimTime] of this.lastTrimTimes) {
34
+ if (trimTime + windowMs >= now) {
35
+ break;
45
36
  }
37
+ this.lastTrimTimes.delete(trimmedChannel);
38
+ }
39
+ const args = [stringifyJSON(data)];
40
+ if (!this.lastTrimTimes.has(channel)) {
41
+ this.lastTrimTimes.set(channel, now);
42
+ args.push(
43
+ this.xTrimExactness,
44
+ `${now - windowMs}-0`,
45
+ // 2x so entries added late in a window outlive it until the next trim refreshes the TTL.
46
+ String(this.resumeSeconds * 2)
47
+ );
46
48
  }
47
- await this.publishMessage(channel, stringifyJSON({ data, id }));
49
+ await this.evalScript(PUBLISH_SCRIPT, [channel], args);
48
50
  }
49
51
  async subscribeListener(event, listener, { lastEventId, onError } = {}) {
50
52
  const channel = `${this.prefix}${event}`;
@@ -1,6 +1,6 @@
1
1
  import { SequentialIdGenerator, once, compareSequentialIds } from '@orpc/shared';
2
2
  import { unwrapEvent, withEventMeta, getEventMeta } from '@standard-server/core';
3
- import { P as Publisher } from '../shared/publisher.BSQf8ybR.mjs';
3
+ import { P as Publisher } from '../shared/publisher.DcPG7zl6.mjs';
4
4
 
5
5
  class MemoryPublisher extends Publisher {
6
6
  listenersMap = /* @__PURE__ */ new Map();
@@ -25,8 +25,7 @@ class MemoryPublisher extends Publisher {
25
25
  payload = withEventMeta(original, { ...meta, id: this.idGenerator.generate() });
26
26
  bucket.push({ expiresAt, payload });
27
27
  }
28
- const listeners = this.listenersMap.get(event);
29
- listeners?.forEach((listener) => listener(payload));
28
+ this.listenersMap.get(event)?.forEach((listener) => listener(payload));
30
29
  }
31
30
  async subscribeListener(event, listener, options) {
32
31
  this.cleanup();
@@ -45,9 +44,13 @@ class MemoryPublisher extends Publisher {
45
44
  }
46
45
  listeners.push(listener);
47
46
  return once(async () => {
48
- listeners.splice(listeners.indexOf(listener), 1);
49
- if (listeners.length === 0) {
47
+ const current = this.listenersMap.get(event);
48
+ const index = current.indexOf(listener);
49
+ const remaining = current.filter((_, i) => i !== index);
50
+ if (remaining.length === 0) {
50
51
  this.listenersMap.delete(event);
52
+ } else {
53
+ this.listenersMap.set(event, remaining);
51
54
  }
52
55
  });
53
56
  }
@@ -1,5 +1,5 @@
1
1
  import { RedisClientType } from 'redis';
2
- import { BaseRedisPublisher, BaseRedisPublisherOptions, RedisStreamTrimOptions, RedisStreamEntry } from './base-redis.mjs';
2
+ import { BaseRedisPublisher, BaseRedisPublisherOptions, RedisStreamEntry } from './base-redis.mjs';
3
3
  import '@orpc/shared';
4
4
  import '../index.mjs';
5
5
  import '@orpc/client';
@@ -23,10 +23,15 @@ interface RedisPublisherOptions extends BaseRedisPublisherOptions {
23
23
  declare class RedisPublisher<T extends Record<string, object>> extends BaseRedisPublisher<T> {
24
24
  private readonly redis;
25
25
  private readonly subscriber;
26
+ /**
27
+ * node-redis applies `keyPrefix` to keys but not channels, while the publish script
28
+ * publishes on its key, so channels need the same prefix.
29
+ */
30
+ private readonly channelPrefix;
26
31
  constructor(redis: RedisClientType<any, any, any, any, any>, { subscriber, ...options }?: RedisPublisherOptions);
27
32
  protected publishMessage(channel: string, message: string): Promise<void>;
28
33
  protected subscribeChannel(channel: string, listener: (message: unknown) => void): Promise<() => Promise<void>>;
29
- protected addStreamEntry(key: string, data: string, trim?: RedisStreamTrimOptions): Promise<string>;
34
+ protected evalScript(script: string, keys: string[], args: string[]): Promise<unknown>;
30
35
  protected readStreamEntries(key: string, lastId: string): Promise<RedisStreamEntry[]>;
31
36
  }
32
37
 
@@ -1,5 +1,5 @@
1
1
  import { RedisClientType } from 'redis';
2
- import { BaseRedisPublisher, BaseRedisPublisherOptions, RedisStreamTrimOptions, RedisStreamEntry } from './base-redis.js';
2
+ import { BaseRedisPublisher, BaseRedisPublisherOptions, RedisStreamEntry } from './base-redis.js';
3
3
  import '@orpc/shared';
4
4
  import '../index.js';
5
5
  import '@orpc/client';
@@ -23,10 +23,15 @@ interface RedisPublisherOptions extends BaseRedisPublisherOptions {
23
23
  declare class RedisPublisher<T extends Record<string, object>> extends BaseRedisPublisher<T> {
24
24
  private readonly redis;
25
25
  private readonly subscriber;
26
+ /**
27
+ * node-redis applies `keyPrefix` to keys but not channels, while the publish script
28
+ * publishes on its key, so channels need the same prefix.
29
+ */
30
+ private readonly channelPrefix;
26
31
  constructor(redis: RedisClientType<any, any, any, any, any>, { subscriber, ...options }?: RedisPublisherOptions);
27
32
  protected publishMessage(channel: string, message: string): Promise<void>;
28
33
  protected subscribeChannel(channel: string, listener: (message: unknown) => void): Promise<() => Promise<void>>;
29
- protected addStreamEntry(key: string, data: string, trim?: RedisStreamTrimOptions): Promise<string>;
34
+ protected evalScript(script: string, keys: string[], args: string[]): Promise<unknown>;
30
35
  protected readStreamEntries(key: string, lastId: string): Promise<RedisStreamEntry[]>;
31
36
  }
32
37
 
@@ -2,33 +2,40 @@ import { BaseRedisPublisher } from './base-redis.mjs';
2
2
  import '@orpc/client';
3
3
  import '@orpc/shared';
4
4
  import '@standard-server/core';
5
- import '../shared/publisher.BSQf8ybR.mjs';
5
+ import '../shared/publisher.DcPG7zl6.mjs';
6
6
 
7
7
  class RedisPublisher extends BaseRedisPublisher {
8
8
  constructor(redis, { subscriber, ...options } = {}) {
9
9
  super(options);
10
10
  this.redis = redis;
11
+ const keyPrefix = redis.options?.keyPrefix;
12
+ if (keyPrefix !== void 0 && typeof keyPrefix !== "string") {
13
+ throw new TypeError("RedisPublisher only supports a string keyPrefix on the Redis client.");
14
+ }
11
15
  this.subscriber = subscriber ?? redis.duplicate();
16
+ this.channelPrefix = keyPrefix ?? "";
12
17
  }
13
18
  subscriber;
19
+ /**
20
+ * node-redis applies `keyPrefix` to keys but not channels, while the publish script
21
+ * publishes on its key, so channels need the same prefix.
22
+ */
23
+ channelPrefix;
14
24
  async publishMessage(channel, message) {
15
25
  await connectIfNeeded(this.redis);
16
- await this.redis.publish(channel, message);
26
+ await this.redis.publish(`${this.channelPrefix}${channel}`, message);
17
27
  }
18
28
  async subscribeChannel(channel, listener) {
29
+ const prefixedChannel = `${this.channelPrefix}${channel}`;
19
30
  await connectIfNeeded(this.subscriber);
20
- await this.subscriber.subscribe(channel, listener);
31
+ await this.subscriber.subscribe(prefixedChannel, listener);
21
32
  return async () => {
22
- await this.subscriber.unsubscribe(channel, listener);
33
+ await this.subscriber.unsubscribe(prefixedChannel, listener);
23
34
  };
24
35
  }
25
- async addStreamEntry(key, data, trim) {
36
+ async evalScript(script, keys, args) {
26
37
  await connectIfNeeded(this.redis);
27
- if (!trim) {
28
- return await this.redis.xAdd(key, "*", { data });
29
- }
30
- const [id] = await this.redis.multi().xAdd(key, "*", { data }).xTrim(key, "MINID", trim.minId, { strategyModifier: trim.exactness }).expire(key, trim.expireSeconds).exec();
31
- return id;
38
+ return await this.redis.eval(script, { keys, arguments: args });
32
39
  }
33
40
  async readStreamEntries(key, lastId) {
34
41
  await connectIfNeeded(this.redis);
@@ -1,6 +1,6 @@
1
1
  import { ThrowableError } from '@orpc/shared';
2
2
  import { Redis } from '@upstash/redis';
3
- import { BaseRedisPublisher, BaseRedisPublisherOptions, RedisStreamTrimOptions, RedisStreamEntry } from './base-redis.mjs';
3
+ import { BaseRedisPublisher, BaseRedisPublisherOptions, RedisStreamEntry } from './base-redis.mjs';
4
4
  import '../index.mjs';
5
5
  import '@orpc/client';
6
6
 
@@ -21,7 +21,7 @@ declare class UpstashPublisher<T extends Record<string, object>> extends BaseRed
21
21
  constructor(redis: Redis, options?: UpstashPublisherOptions);
22
22
  protected publishMessage(channel: string, message: string): Promise<void>;
23
23
  protected subscribeChannel(channel: string, listener: (message: unknown) => void, onError?: (error: ThrowableError) => void): Promise<() => Promise<void>>;
24
- protected addStreamEntry(key: string, data: string, trim?: RedisStreamTrimOptions): Promise<string>;
24
+ protected evalScript(script: string, keys: string[], args: string[]): Promise<unknown>;
25
25
  protected readStreamEntries(key: string, lastId: string): Promise<RedisStreamEntry[]>;
26
26
  private subscribeIfNeeded;
27
27
  private unsubscribeIfUnused;
@@ -1,6 +1,6 @@
1
1
  import { ThrowableError } from '@orpc/shared';
2
2
  import { Redis } from '@upstash/redis';
3
- import { BaseRedisPublisher, BaseRedisPublisherOptions, RedisStreamTrimOptions, RedisStreamEntry } from './base-redis.js';
3
+ import { BaseRedisPublisher, BaseRedisPublisherOptions, RedisStreamEntry } from './base-redis.js';
4
4
  import '../index.js';
5
5
  import '@orpc/client';
6
6
 
@@ -21,7 +21,7 @@ declare class UpstashPublisher<T extends Record<string, object>> extends BaseRed
21
21
  constructor(redis: Redis, options?: UpstashPublisherOptions);
22
22
  protected publishMessage(channel: string, message: string): Promise<void>;
23
23
  protected subscribeChannel(channel: string, listener: (message: unknown) => void, onError?: (error: ThrowableError) => void): Promise<() => Promise<void>>;
24
- protected addStreamEntry(key: string, data: string, trim?: RedisStreamTrimOptions): Promise<string>;
24
+ protected evalScript(script: string, keys: string[], args: string[]): Promise<unknown>;
25
25
  protected readStreamEntries(key: string, lastId: string): Promise<RedisStreamEntry[]>;
26
26
  private subscribeIfNeeded;
27
27
  private unsubscribeIfUnused;
@@ -2,7 +2,7 @@ import { promiseWithResolvers } from '@orpc/shared';
2
2
  import { BaseRedisPublisher } from './base-redis.mjs';
3
3
  import '@orpc/client';
4
4
  import '@standard-server/core';
5
- import '../shared/publisher.BSQf8ybR.mjs';
5
+ import '../shared/publisher.DcPG7zl6.mjs';
6
6
 
7
7
  class UpstashPublisher extends BaseRedisPublisher {
8
8
  constructor(redis, options = {}) {
@@ -36,12 +36,8 @@ class UpstashPublisher extends BaseRedisPublisher {
36
36
  await this.unsubscribeIfUnused(channel);
37
37
  };
38
38
  }
39
- async addStreamEntry(key, data, trim) {
40
- if (!trim) {
41
- return this.redis.xadd(key, "*", { data });
42
- }
43
- const [id] = await this.redis.multi().xadd(key, "*", { data }).xtrim(key, { strategy: "MINID", exactness: trim.exactness, threshold: trim.minId }).expire(key, trim.expireSeconds).exec();
44
- return id;
39
+ async evalScript(script, keys, args) {
40
+ return await this.redis.eval(script, keys, args);
45
41
  }
46
42
  async readStreamEntries(key, lastId) {
47
43
  const results = await this.redis.xread(key, lastId);
package/dist/index.d.mts CHANGED
@@ -6,6 +6,8 @@ interface PublisherOptions {
6
6
  *
7
7
  * If the buffer exceeds this limit, the oldest event is dropped.
8
8
  * This prevents unbounded memory growth if consumers process events slowly.
9
+ * Missed events the adapter replays for `lastEventId` while subscribing are all kept,
10
+ * and the limit then applies to how much further behind the consumer falls.
9
11
  *
10
12
  * Set to:
11
13
  * - `0`: Disable buffering. Events must be consumed before the next one arrives.
@@ -42,6 +44,8 @@ declare abstract class Publisher<T extends Record<string, object>> {
42
44
  /**
43
45
  * Subscribes to a specific event using a callback function.
44
46
  * Returns an unsubscribe function to remove the listener.
47
+ * Events missed since `lastEventId` should reach the listener before the returned promise
48
+ * resolves, so iterator subscribers keep them all regardless of `maxBufferedEvents`.
45
49
  *
46
50
  * @remarks
47
51
  * This method should be protected to avoid conflicts with `subscribe` method
package/dist/index.d.ts CHANGED
@@ -6,6 +6,8 @@ interface PublisherOptions {
6
6
  *
7
7
  * If the buffer exceeds this limit, the oldest event is dropped.
8
8
  * This prevents unbounded memory growth if consumers process events slowly.
9
+ * Missed events the adapter replays for `lastEventId` while subscribing are all kept,
10
+ * and the limit then applies to how much further behind the consumer falls.
9
11
  *
10
12
  * Set to:
11
13
  * - `0`: Disable buffering. Events must be consumed before the next one arrives.
@@ -42,6 +44,8 @@ declare abstract class Publisher<T extends Record<string, object>> {
42
44
  /**
43
45
  * Subscribes to a specific event using a callback function.
44
46
  * Returns an unsubscribe function to remove the listener.
47
+ * Events missed since `lastEventId` should reach the listener before the returned promise
48
+ * resolves, so iterator subscribers keep them all regardless of `maxBufferedEvents`.
45
49
  *
46
50
  * @remarks
47
51
  * This method should be protected to avoid conflicts with `subscribe` method
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- export { P as Publisher } from './shared/publisher.BSQf8ybR.mjs';
1
+ export { P as Publisher } from './shared/publisher.DcPG7zl6.mjs';
2
2
  import '@orpc/shared';
@@ -1,4 +1,4 @@
1
- import { AsyncIteratorClass } from '@orpc/shared';
1
+ import { throwIfAborted, AsyncIteratorClass } from '@orpc/shared';
2
2
 
3
3
  class Publisher {
4
4
  maxBufferedEvents;
@@ -11,10 +11,13 @@ class Publisher {
11
11
  }
12
12
  const signal = listenerOrOptions?.signal;
13
13
  const maxBufferedEvents = listenerOrOptions?.maxBufferedEvents ?? this.maxBufferedEvents;
14
- signal?.throwIfAborted();
15
- const bufferedEvents = [];
14
+ throwIfAborted(signal);
15
+ const resuming = listenerOrOptions?.lastEventId !== void 0;
16
+ const bufferedEvents = new Queue();
16
17
  const pullResolvers = [];
17
- const unsubscribePromise = this.subscribeListener(event, (payload) => {
18
+ let bufferFloor = 0;
19
+ let bufferLimit = resuming ? Infinity : maxBufferedEvents;
20
+ const subscription = this.subscribeListener(event, (payload) => {
18
21
  if (signal?.aborted) {
19
22
  return;
20
23
  }
@@ -23,14 +26,18 @@ class Publisher {
23
26
  resolver.resolve({ done: false, value: payload });
24
27
  } else {
25
28
  bufferedEvents.push(payload);
26
- if (bufferedEvents.length > maxBufferedEvents) {
29
+ if (bufferedEvents.length > bufferLimit) {
27
30
  bufferedEvents.shift();
28
31
  }
29
32
  }
30
33
  }, {
31
34
  lastEventId: listenerOrOptions?.lastEventId,
32
35
  onError: (error) => terminate({ kind: "error", error })
33
- }).catch((error) => terminate({ kind: "error", error }));
36
+ });
37
+ const unsubscribePromise = (resuming ? subscription.finally(() => {
38
+ bufferFloor = bufferedEvents.length;
39
+ bufferLimit = bufferFloor + maxBufferedEvents;
40
+ }) : subscription).catch((error) => terminate({ kind: "error", error }));
34
41
  const abortListener = () => terminate({ kind: "error", error: signal.reason });
35
42
  signal?.addEventListener("abort", abortListener);
36
43
  let terminalState;
@@ -47,7 +54,7 @@ class Publisher {
47
54
  }
48
55
  if (signal?.aborted) {
49
56
  pullResolvers.length = 0;
50
- bufferedEvents.length = 0;
57
+ bufferedEvents.clear();
51
58
  }
52
59
  await unsubscribePromise.then((unsubscribe) => unsubscribe?.());
53
60
  }
@@ -56,8 +63,13 @@ class Publisher {
56
63
  if (terminalState?.kind === "cancelled") {
57
64
  return { done: true, value: void 0 };
58
65
  }
59
- if (bufferedEvents.length > 0) {
60
- return { done: false, value: bufferedEvents.shift() };
66
+ const value = bufferedEvents.shift();
67
+ if (value !== void 0) {
68
+ if (bufferedEvents.length < bufferFloor) {
69
+ bufferFloor = bufferedEvents.length;
70
+ bufferLimit = bufferFloor + maxBufferedEvents;
71
+ }
72
+ return { done: false, value };
61
73
  }
62
74
  if (terminalState?.kind === "error") {
63
75
  throw terminalState.error;
@@ -70,5 +82,31 @@ class Publisher {
70
82
  );
71
83
  }
72
84
  }
85
+ class Queue {
86
+ items = [];
87
+ head = 0;
88
+ get length() {
89
+ return this.items.length - this.head;
90
+ }
91
+ push(item) {
92
+ this.items.push(item);
93
+ }
94
+ shift() {
95
+ if (this.head === this.items.length) {
96
+ return void 0;
97
+ }
98
+ const item = this.items[this.head];
99
+ this.items[this.head++] = void 0;
100
+ if (this.head >= 32 && this.head * 2 >= this.items.length) {
101
+ this.items.splice(0, this.head);
102
+ this.head = 0;
103
+ }
104
+ return item;
105
+ }
106
+ clear() {
107
+ this.items.length = 0;
108
+ this.head = 0;
109
+ }
110
+ }
73
111
 
74
112
  export { Publisher as P };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@orpc/publisher",
3
3
  "type": "module",
4
- "version": "2.0.0-beta.40",
4
+ "version": "2.0.0-beta.41",
5
5
  "description": "Typesafe pub/sub for oRPC event streaming, with memory, Redis, and Upstash adapters",
6
6
  "license": "MIT",
7
7
  "funding": [
@@ -70,9 +70,9 @@
70
70
  }
71
71
  },
72
72
  "dependencies": {
73
- "@orpc/client": "2.0.0-beta.40",
74
- "@orpc/shared": "2.0.0-beta.40",
75
- "@standard-server/core": "~0.9.2"
73
+ "@orpc/client": "2.0.0-beta.41",
74
+ "@orpc/shared": "2.0.0-beta.41",
75
+ "@standard-server/core": "~0.10.0"
76
76
  },
77
77
  "devDependencies": {
78
78
  "@upstash/redis": "^1.38.2",