@orpc/publisher 2.0.0-beta.4 → 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 +97 -94
- package/dist/adapters/base-redis.d.mts +120 -0
- package/dist/adapters/base-redis.d.ts +120 -0
- package/dist/adapters/base-redis.mjs +106 -0
- package/dist/adapters/memory.d.mts +13 -7
- package/dist/adapters/memory.d.ts +13 -7
- package/dist/adapters/memory.mjs +21 -18
- package/dist/adapters/redis.d.mts +19 -55
- package/dist/adapters/redis.d.ts +19 -55
- package/dist/adapters/redis.mjs +38 -110
- package/dist/adapters/upstash.d.mts +19 -60
- package/dist/adapters/upstash.d.ts +19 -60
- package/dist/adapters/upstash.mjs +65 -164
- package/dist/index.d.mts +6 -2
- package/dist/index.d.ts +6 -2
- package/dist/index.mjs +1 -1
- package/dist/shared/{publisher.BSQf8ybR.mjs → publisher.DcPG7zl6.mjs} +47 -9
- package/package.json +25 -8
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { RPCJsonSerializer } from '@orpc/client';
|
|
2
|
+
import { stringifyJSON, once, parseEmptyableJSON } from '@orpc/shared';
|
|
3
|
+
import { unwrapEvent, withEventMeta, getEventMeta } from '@standard-server/core';
|
|
4
|
+
import { P as Publisher } from '../shared/publisher.DcPG7zl6.mjs';
|
|
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..'"}')`;
|
|
7
|
+
class BaseRedisPublisher extends Publisher {
|
|
8
|
+
prefix;
|
|
9
|
+
serializer;
|
|
10
|
+
resumeEnabled;
|
|
11
|
+
resumeSeconds;
|
|
12
|
+
/**
|
|
13
|
+
* Tests switch this to `=` for deterministic trimming.
|
|
14
|
+
*/
|
|
15
|
+
xTrimExactness = "~";
|
|
16
|
+
lastTrimTimes = /* @__PURE__ */ new Map();
|
|
17
|
+
constructor({ prefix, serializer, resume, ...options } = {}) {
|
|
18
|
+
super(options);
|
|
19
|
+
this.prefix = prefix ?? "";
|
|
20
|
+
this.serializer = serializer ?? new RPCJsonSerializer();
|
|
21
|
+
this.resumeEnabled = resume?.enabled ?? false;
|
|
22
|
+
this.resumeSeconds = resume?.seconds ?? 300;
|
|
23
|
+
}
|
|
24
|
+
async publish(event, payload) {
|
|
25
|
+
const channel = `${this.prefix}${event}`;
|
|
26
|
+
const data = this.serializePayload(payload);
|
|
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;
|
|
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
|
+
);
|
|
48
|
+
}
|
|
49
|
+
await this.evalScript(PUBLISH_SCRIPT, [channel], args);
|
|
50
|
+
}
|
|
51
|
+
async subscribeListener(event, listener, { lastEventId, onError } = {}) {
|
|
52
|
+
const channel = `${this.prefix}${event}`;
|
|
53
|
+
let pendingPayloads = [];
|
|
54
|
+
const resumedIds = /* @__PURE__ */ new Set();
|
|
55
|
+
const deduplicatingListener = (payload) => {
|
|
56
|
+
if (pendingPayloads) {
|
|
57
|
+
pendingPayloads.push(payload);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const id = getEventMeta(payload)?.id;
|
|
61
|
+
if (id === void 0 || !resumedIds.has(id)) {
|
|
62
|
+
listener(payload);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const messageListener = (message) => {
|
|
66
|
+
try {
|
|
67
|
+
const { id, data } = parseIfText(message);
|
|
68
|
+
deduplicatingListener(this.deserializePayload(id, data));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
onError?.(error);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const unsubscribe = await this.subscribeChannel(channel, messageListener, onError);
|
|
74
|
+
try {
|
|
75
|
+
if (this.resumeEnabled && lastEventId !== void 0) {
|
|
76
|
+
for (const { id, data } of await this.readStreamEntries(channel, lastEventId)) {
|
|
77
|
+
resumedIds.add(id);
|
|
78
|
+
listener(this.deserializePayload(id, parseIfText(data)));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const pending = pendingPayloads;
|
|
82
|
+
pendingPayloads = void 0;
|
|
83
|
+
pending.forEach(deduplicatingListener);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
await unsubscribe();
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
return once(unsubscribe);
|
|
89
|
+
}
|
|
90
|
+
serializePayload(payload) {
|
|
91
|
+
const [original, meta] = unwrapEvent(payload);
|
|
92
|
+
const { json, meta: jsonMeta } = this.serializer.serialize(original);
|
|
93
|
+
return { payload: { json, meta: jsonMeta }, meta };
|
|
94
|
+
}
|
|
95
|
+
deserializePayload(id, { payload, meta }) {
|
|
96
|
+
return withEventMeta(
|
|
97
|
+
this.serializer.deserialize(payload),
|
|
98
|
+
id === void 0 ? { ...meta } : { ...meta, id }
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function parseIfText(value) {
|
|
103
|
+
return typeof value === "string" ? parseEmptyableJSON(value) : value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export { BaseRedisPublisher };
|
|
@@ -3,16 +3,16 @@ import '@orpc/shared';
|
|
|
3
3
|
|
|
4
4
|
interface MemoryPublisherOptions extends PublisherOptions {
|
|
5
5
|
/**
|
|
6
|
-
* Configuration for event
|
|
6
|
+
* Configuration for event resume support.
|
|
7
7
|
*
|
|
8
8
|
* When enabled, published events are temporarily stored so new
|
|
9
9
|
* subscribers can resume from a previous position using `lastEventId`.
|
|
10
10
|
*
|
|
11
11
|
* @default { enabled: false }
|
|
12
12
|
*/
|
|
13
|
-
|
|
13
|
+
resume?: {
|
|
14
14
|
/**
|
|
15
|
-
* Whether event
|
|
15
|
+
* Whether event resume support is enabled.
|
|
16
16
|
*
|
|
17
17
|
* When enabled, published events are temporarily stored so new
|
|
18
18
|
* subscribers can resume from a previous position using `lastEventId`.
|
|
@@ -21,7 +21,7 @@ interface MemoryPublisherOptions extends PublisherOptions {
|
|
|
21
21
|
*/
|
|
22
22
|
enabled: boolean;
|
|
23
23
|
/**
|
|
24
|
-
* How long (in seconds) to retain events for
|
|
24
|
+
* How long (in seconds) to retain events for resume.
|
|
25
25
|
*
|
|
26
26
|
* Expired events are cleaned up lazily for performance reasons, so
|
|
27
27
|
* some events may remain available slightly longer than this period.
|
|
@@ -31,13 +31,19 @@ interface MemoryPublisherOptions extends PublisherOptions {
|
|
|
31
31
|
seconds?: number;
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Publisher adapter backed by in-memory storage, with optional resume support.
|
|
36
|
+
* Events are delivered to subscribers within the same process only.
|
|
37
|
+
*
|
|
38
|
+
* @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters}
|
|
39
|
+
*/
|
|
34
40
|
declare class MemoryPublisher<T extends Record<string, object>> extends Publisher<T> {
|
|
35
41
|
private readonly listenersMap;
|
|
36
42
|
private readonly idGenerator;
|
|
37
43
|
private readonly eventsMap;
|
|
38
|
-
private readonly
|
|
39
|
-
private readonly
|
|
40
|
-
constructor({
|
|
44
|
+
private readonly resumeEnabled;
|
|
45
|
+
private readonly resumeSeconds;
|
|
46
|
+
constructor({ resume, ...options }?: MemoryPublisherOptions);
|
|
41
47
|
publish<K extends keyof T & string>(event: K, payload: T[K]): Promise<void>;
|
|
42
48
|
protected subscribeListener<K extends keyof T & string>(event: K, listener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise<void>>;
|
|
43
49
|
private lastCleanupTime;
|
|
@@ -3,16 +3,16 @@ import '@orpc/shared';
|
|
|
3
3
|
|
|
4
4
|
interface MemoryPublisherOptions extends PublisherOptions {
|
|
5
5
|
/**
|
|
6
|
-
* Configuration for event
|
|
6
|
+
* Configuration for event resume support.
|
|
7
7
|
*
|
|
8
8
|
* When enabled, published events are temporarily stored so new
|
|
9
9
|
* subscribers can resume from a previous position using `lastEventId`.
|
|
10
10
|
*
|
|
11
11
|
* @default { enabled: false }
|
|
12
12
|
*/
|
|
13
|
-
|
|
13
|
+
resume?: {
|
|
14
14
|
/**
|
|
15
|
-
* Whether event
|
|
15
|
+
* Whether event resume support is enabled.
|
|
16
16
|
*
|
|
17
17
|
* When enabled, published events are temporarily stored so new
|
|
18
18
|
* subscribers can resume from a previous position using `lastEventId`.
|
|
@@ -21,7 +21,7 @@ interface MemoryPublisherOptions extends PublisherOptions {
|
|
|
21
21
|
*/
|
|
22
22
|
enabled: boolean;
|
|
23
23
|
/**
|
|
24
|
-
* How long (in seconds) to retain events for
|
|
24
|
+
* How long (in seconds) to retain events for resume.
|
|
25
25
|
*
|
|
26
26
|
* Expired events are cleaned up lazily for performance reasons, so
|
|
27
27
|
* some events may remain available slightly longer than this period.
|
|
@@ -31,13 +31,19 @@ interface MemoryPublisherOptions extends PublisherOptions {
|
|
|
31
31
|
seconds?: number;
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Publisher adapter backed by in-memory storage, with optional resume support.
|
|
36
|
+
* Events are delivered to subscribers within the same process only.
|
|
37
|
+
*
|
|
38
|
+
* @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters}
|
|
39
|
+
*/
|
|
34
40
|
declare class MemoryPublisher<T extends Record<string, object>> extends Publisher<T> {
|
|
35
41
|
private readonly listenersMap;
|
|
36
42
|
private readonly idGenerator;
|
|
37
43
|
private readonly eventsMap;
|
|
38
|
-
private readonly
|
|
39
|
-
private readonly
|
|
40
|
-
constructor({
|
|
44
|
+
private readonly resumeEnabled;
|
|
45
|
+
private readonly resumeSeconds;
|
|
46
|
+
constructor({ resume, ...options }?: MemoryPublisherOptions);
|
|
41
47
|
publish<K extends keyof T & string>(event: K, payload: T[K]): Promise<void>;
|
|
42
48
|
protected subscribeListener<K extends keyof T & string>(event: K, listener: (payload: T[K]) => void, options?: PublisherSubscribeListenerOptions): Promise<() => Promise<void>>;
|
|
43
49
|
private lastCleanupTime;
|
package/dist/adapters/memory.mjs
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
import { SequentialIdGenerator, once, compareSequentialIds } from '@orpc/shared';
|
|
2
|
-
import { unwrapEvent, withEventMeta, getEventMeta } from '@
|
|
3
|
-
import { P as Publisher } from '../shared/publisher.
|
|
2
|
+
import { unwrapEvent, withEventMeta, getEventMeta } from '@standard-server/core';
|
|
3
|
+
import { P as Publisher } from '../shared/publisher.DcPG7zl6.mjs';
|
|
4
4
|
|
|
5
5
|
class MemoryPublisher extends Publisher {
|
|
6
6
|
listenersMap = /* @__PURE__ */ new Map();
|
|
7
7
|
idGenerator = new SequentialIdGenerator();
|
|
8
8
|
eventsMap = /* @__PURE__ */ new Map();
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
constructor({
|
|
9
|
+
resumeEnabled;
|
|
10
|
+
resumeSeconds;
|
|
11
|
+
constructor({ resume, ...options } = {}) {
|
|
12
12
|
super(options);
|
|
13
|
-
this.
|
|
14
|
-
this.
|
|
13
|
+
this.resumeEnabled = resume?.enabled ?? false;
|
|
14
|
+
this.resumeSeconds = resume?.seconds ?? 300;
|
|
15
15
|
}
|
|
16
16
|
async publish(event, payload) {
|
|
17
17
|
this.cleanup();
|
|
18
|
-
if (this.
|
|
19
|
-
const expiresAt = Date.now() + this.
|
|
18
|
+
if (this.resumeEnabled) {
|
|
19
|
+
const expiresAt = Date.now() + this.resumeSeconds * 1e3;
|
|
20
20
|
let bucket = this.eventsMap.get(event);
|
|
21
21
|
if (!bucket) {
|
|
22
22
|
this.eventsMap.set(event, bucket = []);
|
|
@@ -25,15 +25,14 @@ class MemoryPublisher extends Publisher {
|
|
|
25
25
|
payload = withEventMeta(original, { ...meta, id: this.idGenerator.generate() });
|
|
26
26
|
bucket.push({ expiresAt, payload });
|
|
27
27
|
}
|
|
28
|
-
|
|
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();
|
|
33
|
-
if (this.
|
|
32
|
+
if (this.resumeEnabled && options?.lastEventId !== void 0) {
|
|
34
33
|
const bucket = this.eventsMap.get(event);
|
|
35
34
|
if (bucket?.length) {
|
|
36
|
-
const startIdx =
|
|
35
|
+
const startIdx = findResumeStartIndex(bucket, options.lastEventId);
|
|
37
36
|
for (let i = startIdx; i < bucket.length; i++) {
|
|
38
37
|
listener(bucket[i].payload);
|
|
39
38
|
}
|
|
@@ -45,19 +44,23 @@ class MemoryPublisher extends Publisher {
|
|
|
45
44
|
}
|
|
46
45
|
listeners.push(listener);
|
|
47
46
|
return once(async () => {
|
|
48
|
-
|
|
49
|
-
|
|
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
|
}
|
|
54
57
|
lastCleanupTime = 0;
|
|
55
58
|
cleanup() {
|
|
56
|
-
if (!this.
|
|
59
|
+
if (!this.resumeEnabled) {
|
|
57
60
|
return;
|
|
58
61
|
}
|
|
59
62
|
const now = Date.now();
|
|
60
|
-
if (now - this.lastCleanupTime < this.
|
|
63
|
+
if (now - this.lastCleanupTime < this.resumeSeconds * 1e3) {
|
|
61
64
|
return;
|
|
62
65
|
}
|
|
63
66
|
this.lastCleanupTime = now;
|
|
@@ -69,7 +72,7 @@ class MemoryPublisher extends Publisher {
|
|
|
69
72
|
}
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
|
-
function
|
|
75
|
+
function findResumeStartIndex(bucket, lastEventId) {
|
|
73
76
|
let lo = 0;
|
|
74
77
|
let hi = bucket.length;
|
|
75
78
|
while (lo < hi) {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { RedisClientType } from 'redis';
|
|
2
|
-
import {
|
|
3
|
-
import { RPCSerializer } from '@orpc/client';
|
|
2
|
+
import { BaseRedisPublisher, BaseRedisPublisherOptions, RedisStreamEntry } from './base-redis.mjs';
|
|
4
3
|
import '@orpc/shared';
|
|
4
|
+
import '../index.mjs';
|
|
5
|
+
import '@orpc/client';
|
|
5
6
|
|
|
6
|
-
interface RedisPublisherOptions extends
|
|
7
|
+
interface RedisPublisherOptions extends BaseRedisPublisherOptions {
|
|
7
8
|
/**
|
|
8
9
|
* Redis subscriber instance.
|
|
9
10
|
* Pub/Sub takes over the connection, so a client with subscriptions
|
|
@@ -12,63 +13,26 @@ interface RedisPublisherOptions extends PublisherOptions {
|
|
|
12
13
|
* @default redis.duplicate()
|
|
13
14
|
*/
|
|
14
15
|
subscriber?: undefined | RedisClientType<any, any, any, any, any>;
|
|
15
|
-
/**
|
|
16
|
-
* The prefix to use for Redis keys.
|
|
17
|
-
*/
|
|
18
|
-
prefix?: string;
|
|
19
|
-
/**
|
|
20
|
-
* Serializer for serialize and deserialize payloads.
|
|
21
|
-
*
|
|
22
|
-
* @default RPCSerializer
|
|
23
|
-
*/
|
|
24
|
-
serializer?: undefined | Pick<RPCSerializer, keyof RPCSerializer>;
|
|
25
|
-
/**
|
|
26
|
-
* Configuration for event replay support.
|
|
27
|
-
*
|
|
28
|
-
* When enabled, published events are temporarily stored so new
|
|
29
|
-
* subscribers can resume from a previous position using `lastEventId`.
|
|
30
|
-
*
|
|
31
|
-
* @default { enabled: false }
|
|
32
|
-
*/
|
|
33
|
-
replay?: {
|
|
34
|
-
/**
|
|
35
|
-
* Whether event replay support is enabled.
|
|
36
|
-
*
|
|
37
|
-
* When enabled, published events are temporarily stored so new
|
|
38
|
-
* subscribers can resume from a previous position using `lastEventId`.
|
|
39
|
-
*
|
|
40
|
-
* @default false
|
|
41
|
-
*/
|
|
42
|
-
enabled: boolean;
|
|
43
|
-
/**
|
|
44
|
-
* How long (in seconds) to retain events for replay.
|
|
45
|
-
*
|
|
46
|
-
* Expired events are cleaned up lazily for performance reasons, so
|
|
47
|
-
* some events may remain available slightly longer than this period.
|
|
48
|
-
*
|
|
49
|
-
* @default 300 (5 min)
|
|
50
|
-
*/
|
|
51
|
-
seconds?: number;
|
|
52
|
-
};
|
|
53
16
|
}
|
|
54
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Publisher adapter for Redis. Distributes events across processes via
|
|
19
|
+
* Redis Pub/Sub, with optional resume support backed by Redis Streams.
|
|
20
|
+
*
|
|
21
|
+
* @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters}
|
|
22
|
+
*/
|
|
23
|
+
declare class RedisPublisher<T extends Record<string, object>> extends BaseRedisPublisher<T> {
|
|
55
24
|
private readonly redis;
|
|
56
25
|
private readonly subscriber;
|
|
57
|
-
private readonly prefix;
|
|
58
|
-
private readonly serializer;
|
|
59
|
-
private readonly replayEnabled;
|
|
60
|
-
private readonly replaySeconds;
|
|
61
26
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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.
|
|
64
29
|
*/
|
|
65
|
-
private readonly
|
|
66
|
-
constructor(redis: RedisClientType<any, any, any, any, any>, options?: RedisPublisherOptions);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
protected
|
|
70
|
-
|
|
71
|
-
private deserializePayload;
|
|
30
|
+
private readonly channelPrefix;
|
|
31
|
+
constructor(redis: RedisClientType<any, any, any, any, any>, { subscriber, ...options }?: RedisPublisherOptions);
|
|
32
|
+
protected publishMessage(channel: string, message: string): Promise<void>;
|
|
33
|
+
protected subscribeChannel(channel: string, listener: (message: unknown) => void): Promise<() => Promise<void>>;
|
|
34
|
+
protected evalScript(script: string, keys: string[], args: string[]): Promise<unknown>;
|
|
35
|
+
protected readStreamEntries(key: string, lastId: string): Promise<RedisStreamEntry[]>;
|
|
72
36
|
}
|
|
73
37
|
|
|
74
38
|
export { RedisPublisher };
|
package/dist/adapters/redis.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { RedisClientType } from 'redis';
|
|
2
|
-
import {
|
|
3
|
-
import { RPCSerializer } from '@orpc/client';
|
|
2
|
+
import { BaseRedisPublisher, BaseRedisPublisherOptions, RedisStreamEntry } from './base-redis.js';
|
|
4
3
|
import '@orpc/shared';
|
|
4
|
+
import '../index.js';
|
|
5
|
+
import '@orpc/client';
|
|
5
6
|
|
|
6
|
-
interface RedisPublisherOptions extends
|
|
7
|
+
interface RedisPublisherOptions extends BaseRedisPublisherOptions {
|
|
7
8
|
/**
|
|
8
9
|
* Redis subscriber instance.
|
|
9
10
|
* Pub/Sub takes over the connection, so a client with subscriptions
|
|
@@ -12,63 +13,26 @@ interface RedisPublisherOptions extends PublisherOptions {
|
|
|
12
13
|
* @default redis.duplicate()
|
|
13
14
|
*/
|
|
14
15
|
subscriber?: undefined | RedisClientType<any, any, any, any, any>;
|
|
15
|
-
/**
|
|
16
|
-
* The prefix to use for Redis keys.
|
|
17
|
-
*/
|
|
18
|
-
prefix?: string;
|
|
19
|
-
/**
|
|
20
|
-
* Serializer for serialize and deserialize payloads.
|
|
21
|
-
*
|
|
22
|
-
* @default RPCSerializer
|
|
23
|
-
*/
|
|
24
|
-
serializer?: undefined | Pick<RPCSerializer, keyof RPCSerializer>;
|
|
25
|
-
/**
|
|
26
|
-
* Configuration for event replay support.
|
|
27
|
-
*
|
|
28
|
-
* When enabled, published events are temporarily stored so new
|
|
29
|
-
* subscribers can resume from a previous position using `lastEventId`.
|
|
30
|
-
*
|
|
31
|
-
* @default { enabled: false }
|
|
32
|
-
*/
|
|
33
|
-
replay?: {
|
|
34
|
-
/**
|
|
35
|
-
* Whether event replay support is enabled.
|
|
36
|
-
*
|
|
37
|
-
* When enabled, published events are temporarily stored so new
|
|
38
|
-
* subscribers can resume from a previous position using `lastEventId`.
|
|
39
|
-
*
|
|
40
|
-
* @default false
|
|
41
|
-
*/
|
|
42
|
-
enabled: boolean;
|
|
43
|
-
/**
|
|
44
|
-
* How long (in seconds) to retain events for replay.
|
|
45
|
-
*
|
|
46
|
-
* Expired events are cleaned up lazily for performance reasons, so
|
|
47
|
-
* some events may remain available slightly longer than this period.
|
|
48
|
-
*
|
|
49
|
-
* @default 300 (5 min)
|
|
50
|
-
*/
|
|
51
|
-
seconds?: number;
|
|
52
|
-
};
|
|
53
16
|
}
|
|
54
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Publisher adapter for Redis. Distributes events across processes via
|
|
19
|
+
* Redis Pub/Sub, with optional resume support backed by Redis Streams.
|
|
20
|
+
*
|
|
21
|
+
* @see {@link https://orpc.dev/docs/helpers/publisher#adapters | Publisher Helpers - Adapters}
|
|
22
|
+
*/
|
|
23
|
+
declare class RedisPublisher<T extends Record<string, object>> extends BaseRedisPublisher<T> {
|
|
55
24
|
private readonly redis;
|
|
56
25
|
private readonly subscriber;
|
|
57
|
-
private readonly prefix;
|
|
58
|
-
private readonly serializer;
|
|
59
|
-
private readonly replayEnabled;
|
|
60
|
-
private readonly replaySeconds;
|
|
61
26
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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.
|
|
64
29
|
*/
|
|
65
|
-
private readonly
|
|
66
|
-
constructor(redis: RedisClientType<any, any, any, any, any>, options?: RedisPublisherOptions);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
protected
|
|
70
|
-
|
|
71
|
-
private deserializePayload;
|
|
30
|
+
private readonly channelPrefix;
|
|
31
|
+
constructor(redis: RedisClientType<any, any, any, any, any>, { subscriber, ...options }?: RedisPublisherOptions);
|
|
32
|
+
protected publishMessage(channel: string, message: string): Promise<void>;
|
|
33
|
+
protected subscribeChannel(channel: string, listener: (message: unknown) => void): Promise<() => Promise<void>>;
|
|
34
|
+
protected evalScript(script: string, keys: string[], args: string[]): Promise<unknown>;
|
|
35
|
+
protected readStreamEntries(key: string, lastId: string): Promise<RedisStreamEntry[]>;
|
|
72
36
|
}
|
|
73
37
|
|
|
74
38
|
export { RedisPublisher };
|
package/dist/adapters/redis.mjs
CHANGED
|
@@ -1,124 +1,52 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
1
|
+
import { BaseRedisPublisher } from './base-redis.mjs';
|
|
2
|
+
import '@orpc/client';
|
|
3
|
+
import '@orpc/shared';
|
|
4
|
+
import '@standard-server/core';
|
|
5
|
+
import '../shared/publisher.DcPG7zl6.mjs';
|
|
5
6
|
|
|
6
|
-
class RedisPublisher extends
|
|
7
|
-
constructor(redis, options = {}) {
|
|
7
|
+
class RedisPublisher extends BaseRedisPublisher {
|
|
8
|
+
constructor(redis, { subscriber, ...options } = {}) {
|
|
8
9
|
super(options);
|
|
9
10
|
this.redis = redis;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
this.subscriber =
|
|
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
|
+
}
|
|
15
|
+
this.subscriber = subscriber ?? redis.duplicate();
|
|
16
|
+
this.channelPrefix = keyPrefix ?? "";
|
|
15
17
|
}
|
|
16
18
|
subscriber;
|
|
17
|
-
prefix;
|
|
18
|
-
serializer;
|
|
19
|
-
replayEnabled;
|
|
20
|
-
replaySeconds;
|
|
21
19
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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.
|
|
24
22
|
*/
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const data = this.serializePayload(payload);
|
|
30
|
-
let id;
|
|
31
|
-
if (!this.redis.isOpen) {
|
|
32
|
-
await this.redis.connect();
|
|
33
|
-
}
|
|
34
|
-
if (this.replayEnabled) {
|
|
35
|
-
const now = Date.now();
|
|
36
|
-
for (const [event2, firstPublishTime] of this.firstPublishTimeMap) {
|
|
37
|
-
if (firstPublishTime + this.replaySeconds * 1e3 < now) {
|
|
38
|
-
this.firstPublishTimeMap.delete(event2);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
if (!this.firstPublishTimeMap.has(event)) {
|
|
42
|
-
this.firstPublishTimeMap.set(event, now);
|
|
43
|
-
const result = await this.redis.multi().xAdd(redisKey, "*", { data: stringifyJSON(data) }).xTrim(redisKey, "MINID", `${now - this.replaySeconds * 1e3}-0`, { strategyModifier: this.xTrimExactness }).expire(redisKey, this.replaySeconds * 2).exec();
|
|
44
|
-
id = result[0];
|
|
45
|
-
} else {
|
|
46
|
-
id = await this.redis.xAdd(redisKey, "*", { data: stringifyJSON(data) });
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
await this.redis.publish(redisKey, stringifyJSON({ data, id }));
|
|
23
|
+
channelPrefix;
|
|
24
|
+
async publishMessage(channel, message) {
|
|
25
|
+
await connectIfNeeded(this.redis);
|
|
26
|
+
await this.redis.publish(`${this.channelPrefix}${channel}`, message);
|
|
50
27
|
}
|
|
51
|
-
async
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const replayedIds = /* @__PURE__ */ new Set();
|
|
58
|
-
const deduplicatingListener = (payload) => {
|
|
59
|
-
if (pendingPayloads) {
|
|
60
|
-
pendingPayloads.push(payload);
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
const id = getEventMeta(payload)?.id;
|
|
64
|
-
if (id !== void 0 && replayedIds.has(id)) {
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
originalListener(payload);
|
|
28
|
+
async subscribeChannel(channel, listener) {
|
|
29
|
+
const prefixedChannel = `${this.channelPrefix}${channel}`;
|
|
30
|
+
await connectIfNeeded(this.subscriber);
|
|
31
|
+
await this.subscriber.subscribe(prefixedChannel, listener);
|
|
32
|
+
return async () => {
|
|
33
|
+
await this.subscriber.unsubscribe(prefixedChannel, listener);
|
|
68
34
|
};
|
|
69
|
-
const redisListener = (message) => {
|
|
70
|
-
try {
|
|
71
|
-
const { id, data } = parseEmptyableJSON(message);
|
|
72
|
-
const payload = this.deserializePayload(id, data);
|
|
73
|
-
deduplicatingListener(payload);
|
|
74
|
-
} catch (error) {
|
|
75
|
-
onError?.(error);
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
try {
|
|
79
|
-
const subscribePromise = this.subscriber.subscribe(redisKey, redisListener);
|
|
80
|
-
try {
|
|
81
|
-
if (this.replayEnabled && lastEventId !== void 0) {
|
|
82
|
-
if (!this.redis.isOpen) {
|
|
83
|
-
await this.redis.connect();
|
|
84
|
-
}
|
|
85
|
-
const results = await this.redis.xRead({ key: redisKey, id: lastEventId });
|
|
86
|
-
if (results && results[0]) {
|
|
87
|
-
const { messages } = results[0];
|
|
88
|
-
for (const { id, message } of messages) {
|
|
89
|
-
const rawData = message.data;
|
|
90
|
-
const data = parseEmptyableJSON(rawData);
|
|
91
|
-
const payload = this.deserializePayload(id, data);
|
|
92
|
-
replayedIds.add(id);
|
|
93
|
-
originalListener(payload);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
} finally {
|
|
98
|
-
const pending = pendingPayloads;
|
|
99
|
-
pendingPayloads = void 0;
|
|
100
|
-
for (const payload of pending) {
|
|
101
|
-
deduplicatingListener(payload);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
await subscribePromise;
|
|
105
|
-
} catch (error) {
|
|
106
|
-
await this.subscriber.unsubscribe(redisKey, redisListener);
|
|
107
|
-
throw error;
|
|
108
|
-
}
|
|
109
|
-
return once(async () => {
|
|
110
|
-
await this.subscriber.unsubscribe(redisKey, redisListener);
|
|
111
|
-
});
|
|
112
35
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return
|
|
36
|
+
async evalScript(script, keys, args) {
|
|
37
|
+
await connectIfNeeded(this.redis);
|
|
38
|
+
return await this.redis.eval(script, { keys, arguments: args });
|
|
116
39
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
);
|
|
40
|
+
async readStreamEntries(key, lastId) {
|
|
41
|
+
await connectIfNeeded(this.redis);
|
|
42
|
+
const results = await this.redis.xRead({ key, id: lastId });
|
|
43
|
+
const entries = results?.[0]?.messages ?? [];
|
|
44
|
+
return entries.map(({ id, message }) => ({ id, data: message.data }));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function connectIfNeeded(client) {
|
|
48
|
+
if (!client.isOpen) {
|
|
49
|
+
await client.connect();
|
|
122
50
|
}
|
|
123
51
|
}
|
|
124
52
|
|