@blokjs/trigger-pubsub 0.6.18 → 0.6.20
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/PubSubTrigger.js +20 -1
- package/package.json +5 -4
- package/CHANGELOG.md +0 -22
- package/__tests__/integration/gcp-pubsub.real-emulator.test.ts +0 -235
- package/__tests__/integration/kafka-pubsub.real-kafka.test.ts +0 -269
- package/__tests__/integration/nats-pubsub.real-nats.test.ts +0 -138
- package/src/PubSubTrigger.test.ts +0 -151
- package/src/PubSubTrigger.ts +0 -402
- package/src/adapters/AWSSNSAdapter.ts +0 -322
- package/src/adapters/AzureServiceBusAdapter.ts +0 -263
- package/src/adapters/GCPPubSubAdapter.ts +0 -236
- package/src/adapters/KafkaPubSubAdapter.ts +0 -194
- package/src/adapters/NATSPubSubAdapter.ts +0 -326
- package/src/adapters/RedisStreamsPubSubAdapter.ts +0 -225
- package/src/adapters/factory.test.ts +0 -87
- package/src/adapters/factory.ts +0 -88
- package/src/adapters/new-adapters.test.ts +0 -108
- package/src/index.ts +0 -67
- package/template/.env.example +0 -8
- package/template/package.json +0 -44
- package/template/src/Nodes.ts +0 -10
- package/template/src/Workflows.ts +0 -8
- package/template/src/index.ts +0 -41
- package/template/src/runner/PubSubServer.ts +0 -39
- package/template/src/runner/types/Workflows.ts +0 -7
- package/template/src/workflows/messages/on-message.ts +0 -48
- package/template/tsconfig.json +0 -31
- package/template/vitest.config.ts +0 -39
- package/tsconfig.json +0 -32
|
@@ -1,326 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* NATSPubSubAdapter — v0.7 PR 6 — Pub/Sub adapter backed by NATS.
|
|
3
|
-
*
|
|
4
|
-
* Two modes:
|
|
5
|
-
* - **Fan-out** (default, when `consumerGroup` is absent): every
|
|
6
|
-
* subscriber receives every message on the subject. NATS Core
|
|
7
|
-
* publish/subscribe semantics — cheapest path, no persistence.
|
|
8
|
-
* - **Competing-consumer** (when `consumerGroup` is set): NATS
|
|
9
|
-
* Queue Group — exactly one subscriber in the named group
|
|
10
|
-
* receives each message. Pure NATS Core.
|
|
11
|
-
* - **Durable** (when `durable: true`): subscribe via NATS
|
|
12
|
-
* JetStream consumer so the subscription survives restarts and
|
|
13
|
-
* replays missed messages from `startFrom`. Required for the
|
|
14
|
-
* `{seq}` / `{timestamp}` replay cursors.
|
|
15
|
-
*
|
|
16
|
-
* Subject wildcards (`orders.*.created`, `orders.>`) are honored by
|
|
17
|
-
* NATS natively in both modes.
|
|
18
|
-
*
|
|
19
|
-
* Requires `nats` as a peer dependency:
|
|
20
|
-
*
|
|
21
|
-
* bun add nats
|
|
22
|
-
*
|
|
23
|
-
* Environment variables:
|
|
24
|
-
* - `NATS_SERVERS` — comma-separated URLs (default `localhost:4222`).
|
|
25
|
-
* - `NATS_TOKEN` — bearer token authentication.
|
|
26
|
-
* - `NATS_USER` / `NATS_PASS` — userpass authentication.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import type { PubSubTriggerOpts } from "@blokjs/helper";
|
|
30
|
-
import { v4 as uuid } from "uuid";
|
|
31
|
-
import type { PubSubAdapter, PubSubMessage } from "../PubSubTrigger";
|
|
32
|
-
|
|
33
|
-
export interface NATSPubSubConfig {
|
|
34
|
-
servers: string[];
|
|
35
|
-
token?: string;
|
|
36
|
-
user?: string;
|
|
37
|
-
pass?: string;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
interface NatsSubscription {
|
|
41
|
-
unsubscribe: () => void | Promise<void>;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
interface NatsConnection {
|
|
45
|
-
close: () => Promise<void>;
|
|
46
|
-
drain: () => Promise<void>;
|
|
47
|
-
subscribe: (
|
|
48
|
-
subject: string,
|
|
49
|
-
opts?: { queue?: string; callback?: (err: Error | null, msg: NatsMsg) => void },
|
|
50
|
-
) => NatsSubscription;
|
|
51
|
-
publish: (subject: string, payload: Uint8Array) => void;
|
|
52
|
-
jetstream?: () => NatsJetStream;
|
|
53
|
-
jetstreamManager?: () => Promise<NatsJetStreamManager>;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
interface NatsJetStream {
|
|
57
|
-
subscribe: (
|
|
58
|
-
subject: string,
|
|
59
|
-
opts?: unknown,
|
|
60
|
-
) => Promise<{
|
|
61
|
-
[Symbol.asyncIterator]: () => AsyncIterator<NatsJsMsg>;
|
|
62
|
-
unsubscribe: () => Promise<void> | void;
|
|
63
|
-
}>;
|
|
64
|
-
publish: (subject: string, payload: Uint8Array) => Promise<unknown>;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
interface NatsJetStreamManager {
|
|
68
|
-
streams: { add: (config: unknown) => Promise<unknown>; info: (name: string) => Promise<unknown> };
|
|
69
|
-
consumers: { add: (stream: string, config: unknown) => Promise<unknown> };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
interface NatsMsg {
|
|
73
|
-
subject: string;
|
|
74
|
-
data: Uint8Array;
|
|
75
|
-
sid: number;
|
|
76
|
-
respond?: (data?: Uint8Array) => boolean;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
interface NatsJsMsg {
|
|
80
|
-
subject: string;
|
|
81
|
-
data: Uint8Array;
|
|
82
|
-
seq: number;
|
|
83
|
-
ack: () => void;
|
|
84
|
-
nak: (millis?: number) => void;
|
|
85
|
-
info: { stream: string; consumer: string; redeliveryCount: number; timestampNanos?: number };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const TEXT_DECODER = new TextDecoder();
|
|
89
|
-
const TEXT_ENCODER = new TextEncoder();
|
|
90
|
-
|
|
91
|
-
export class NATSPubSubAdapter implements PubSubAdapter {
|
|
92
|
-
readonly provider = "nats" as const;
|
|
93
|
-
private readonly config: NATSPubSubConfig;
|
|
94
|
-
private conn: NatsConnection | null = null;
|
|
95
|
-
private subscriptions: Map<string, NatsSubscription | { unsubscribe: () => Promise<void> | void }> = new Map();
|
|
96
|
-
private connected = false;
|
|
97
|
-
|
|
98
|
-
constructor(config?: Partial<NATSPubSubConfig>) {
|
|
99
|
-
this.config = {
|
|
100
|
-
servers: config?.servers ?? (process.env.NATS_SERVERS ?? "localhost:4222").split(",").map((s) => s.trim()),
|
|
101
|
-
token: config?.token ?? process.env.NATS_TOKEN,
|
|
102
|
-
user: config?.user ?? process.env.NATS_USER,
|
|
103
|
-
pass: config?.pass ?? process.env.NATS_PASS,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async connect(): Promise<void> {
|
|
108
|
-
if (this.connected) return;
|
|
109
|
-
try {
|
|
110
|
-
// biome-ignore lint/suspicious/noExplicitAny: nats is a runtime peer dep.
|
|
111
|
-
const nats: any = await import("nats");
|
|
112
|
-
this.conn = (await nats.connect({
|
|
113
|
-
servers: this.config.servers,
|
|
114
|
-
token: this.config.token,
|
|
115
|
-
user: this.config.user,
|
|
116
|
-
pass: this.config.pass,
|
|
117
|
-
})) as NatsConnection;
|
|
118
|
-
this.connected = true;
|
|
119
|
-
} catch (err) {
|
|
120
|
-
throw new Error(
|
|
121
|
-
`[blok][pubsub-nats] connect failed: ${(err as Error).message}. Install nats as a peer dependency: bun add nats`,
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async disconnect(): Promise<void> {
|
|
127
|
-
if (!this.connected) return;
|
|
128
|
-
for (const sub of this.subscriptions.values()) {
|
|
129
|
-
try {
|
|
130
|
-
const result = sub.unsubscribe();
|
|
131
|
-
if (result instanceof Promise) await result;
|
|
132
|
-
} catch {
|
|
133
|
-
/* ignore */
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
this.subscriptions.clear();
|
|
137
|
-
try {
|
|
138
|
-
await this.conn?.drain();
|
|
139
|
-
} catch {
|
|
140
|
-
/* ignore */
|
|
141
|
-
}
|
|
142
|
-
this.conn = null;
|
|
143
|
-
this.connected = false;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async subscribe(config: PubSubTriggerOpts, handler: (message: PubSubMessage) => Promise<void>): Promise<void> {
|
|
147
|
-
if (!this.connected || !this.conn) throw new Error("[blok][pubsub-nats] not connected. Call connect() first.");
|
|
148
|
-
const subKey = `${config.topic}#${config.consumerGroup ?? ""}`;
|
|
149
|
-
|
|
150
|
-
if (config.durable === true) {
|
|
151
|
-
// JetStream durable subscription — survives restarts.
|
|
152
|
-
if (!this.conn.jetstream) {
|
|
153
|
-
throw new Error("[blok][pubsub-nats] durable subscriptions require JetStream support in the nats client");
|
|
154
|
-
}
|
|
155
|
-
const jsm = await this.conn.jetstreamManager?.();
|
|
156
|
-
if (jsm) {
|
|
157
|
-
// Auto-create a stream covering the subject if one doesn't
|
|
158
|
-
// exist. Production deployments should pre-provision via
|
|
159
|
-
// `nats stream add` to control retention.
|
|
160
|
-
try {
|
|
161
|
-
await jsm.streams.add({
|
|
162
|
-
name: `blok-${(config.topic ?? "").replace(/[^a-zA-Z0-9_]/g, "_")}`,
|
|
163
|
-
subjects: [config.topic],
|
|
164
|
-
});
|
|
165
|
-
} catch {
|
|
166
|
-
/* stream already exists — ignore */
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
const js = this.conn.jetstream();
|
|
170
|
-
const startSeq =
|
|
171
|
-
typeof config.startFrom === "object" && config.startFrom && "seq" in config.startFrom
|
|
172
|
-
? config.startFrom.seq
|
|
173
|
-
: undefined;
|
|
174
|
-
const deliverPolicy =
|
|
175
|
-
config.startFrom === "earliest"
|
|
176
|
-
? "all"
|
|
177
|
-
: config.startFrom === "latest"
|
|
178
|
-
? "new"
|
|
179
|
-
: startSeq !== undefined
|
|
180
|
-
? "by_start_sequence"
|
|
181
|
-
: "all";
|
|
182
|
-
const sub = await js.subscribe(config.topic, {
|
|
183
|
-
config: {
|
|
184
|
-
durable_name:
|
|
185
|
-
config.consumerGroup ??
|
|
186
|
-
`blok-${(config.subscription ?? config.topic ?? "default").replace(/[^a-zA-Z0-9_]/g, "_")}`,
|
|
187
|
-
deliver_policy: deliverPolicy,
|
|
188
|
-
opt_start_seq: startSeq,
|
|
189
|
-
ack_policy: config.ack === false ? "none" : "explicit",
|
|
190
|
-
},
|
|
191
|
-
});
|
|
192
|
-
this.subscriptions.set(subKey, { unsubscribe: () => sub.unsubscribe() });
|
|
193
|
-
// Drive the async iterator in a background loop.
|
|
194
|
-
void (async () => {
|
|
195
|
-
try {
|
|
196
|
-
for await (const msg of sub as unknown as AsyncIterable<NatsJsMsg>) {
|
|
197
|
-
await this.dispatchJsMessage(msg, config, handler);
|
|
198
|
-
}
|
|
199
|
-
} catch (err) {
|
|
200
|
-
// Subscription closed or connection lost — let the trigger
|
|
201
|
-
// re-listen via HMR/reconnect logic. Log for visibility.
|
|
202
|
-
console.error(`[blok][pubsub-nats] subscription error: ${(err as Error).message}`);
|
|
203
|
-
}
|
|
204
|
-
})();
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Core NATS subscription — fire-and-forget, with optional queue
|
|
209
|
-
// group for competing-consumer semantics.
|
|
210
|
-
const sub = this.conn.subscribe(config.topic, {
|
|
211
|
-
queue: config.consumerGroup,
|
|
212
|
-
callback: (err, msg) => {
|
|
213
|
-
if (err) {
|
|
214
|
-
console.error(`[blok][pubsub-nats] subscribe error: ${err.message}`);
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
void this.dispatchCoreMessage(msg, config, handler);
|
|
218
|
-
},
|
|
219
|
-
});
|
|
220
|
-
this.subscriptions.set(subKey, sub);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
private async dispatchJsMessage(
|
|
224
|
-
msg: NatsJsMsg,
|
|
225
|
-
config: PubSubTriggerOpts,
|
|
226
|
-
handler: (message: PubSubMessage) => Promise<void>,
|
|
227
|
-
): Promise<void> {
|
|
228
|
-
const text = TEXT_DECODER.decode(msg.data);
|
|
229
|
-
let body: unknown = text;
|
|
230
|
-
try {
|
|
231
|
-
body = text.length > 0 ? JSON.parse(text) : null;
|
|
232
|
-
} catch {
|
|
233
|
-
/* leave as text */
|
|
234
|
-
}
|
|
235
|
-
const message: PubSubMessage = {
|
|
236
|
-
id: `${msg.info.stream}:${msg.seq}`,
|
|
237
|
-
body,
|
|
238
|
-
attributes: { subject: msg.subject },
|
|
239
|
-
raw: msg,
|
|
240
|
-
topic: msg.subject,
|
|
241
|
-
subscription: msg.info.consumer,
|
|
242
|
-
publishTime: msg.info.timestampNanos ? new Date(msg.info.timestampNanos / 1e6) : undefined,
|
|
243
|
-
ack: async () => {
|
|
244
|
-
msg.ack();
|
|
245
|
-
},
|
|
246
|
-
nack: async () => {
|
|
247
|
-
msg.nak();
|
|
248
|
-
},
|
|
249
|
-
};
|
|
250
|
-
try {
|
|
251
|
-
await handler(message);
|
|
252
|
-
if (config.ack !== false) msg.ack();
|
|
253
|
-
} catch {
|
|
254
|
-
msg.nak();
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
private async dispatchCoreMessage(
|
|
259
|
-
msg: NatsMsg,
|
|
260
|
-
_config: PubSubTriggerOpts,
|
|
261
|
-
handler: (message: PubSubMessage) => Promise<void>,
|
|
262
|
-
): Promise<void> {
|
|
263
|
-
const text = TEXT_DECODER.decode(msg.data);
|
|
264
|
-
let body: unknown = text;
|
|
265
|
-
try {
|
|
266
|
-
body = text.length > 0 ? JSON.parse(text) : null;
|
|
267
|
-
} catch {
|
|
268
|
-
/* leave as text */
|
|
269
|
-
}
|
|
270
|
-
const message: PubSubMessage = {
|
|
271
|
-
id: `${msg.subject}:${msg.sid}:${uuid()}`,
|
|
272
|
-
body,
|
|
273
|
-
attributes: { subject: msg.subject },
|
|
274
|
-
raw: msg,
|
|
275
|
-
topic: msg.subject,
|
|
276
|
-
ack: async () => {
|
|
277
|
-
/* core NATS has no explicit ack */
|
|
278
|
-
},
|
|
279
|
-
nack: async () => {
|
|
280
|
-
/* core NATS has no explicit nack */
|
|
281
|
-
},
|
|
282
|
-
};
|
|
283
|
-
try {
|
|
284
|
-
await handler(message);
|
|
285
|
-
} catch (err) {
|
|
286
|
-
console.error(`[blok][pubsub-nats] handler error: ${(err as Error).message}`);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
async unsubscribe(subscription: string): Promise<void> {
|
|
291
|
-
const sub = this.subscriptions.get(subscription);
|
|
292
|
-
if (!sub) return;
|
|
293
|
-
try {
|
|
294
|
-
const result = sub.unsubscribe();
|
|
295
|
-
if (result instanceof Promise) await result;
|
|
296
|
-
} catch {
|
|
297
|
-
/* ignore */
|
|
298
|
-
}
|
|
299
|
-
this.subscriptions.delete(subscription);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
async publish(topic: string, payload: unknown): Promise<void> {
|
|
303
|
-
if (!this.connected || !this.conn) throw new Error("[blok][pubsub-nats] not connected. Call connect() first.");
|
|
304
|
-
const body = typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
305
|
-
const data = TEXT_ENCODER.encode(body);
|
|
306
|
-
// Use core NATS publish. When a durable subscriber has been
|
|
307
|
-
// installed for this subject, the subscribe() path created a
|
|
308
|
-
// JetStream stream with a subject filter that captures any
|
|
309
|
-
// publish to `topic` — so durable consumers see the message via
|
|
310
|
-
// the stream while core subscribers see it directly. The earlier
|
|
311
|
-
// "try js.publish first, fall back to core" logic caused
|
|
312
|
-
// **double-delivery**: js.publish to a subject covered by a
|
|
313
|
-
// stream goes to BOTH the stream and core subscribers, then the
|
|
314
|
-
// fallback would publish AGAIN if js.publish timed out (vs
|
|
315
|
-
// returning 503 fast). Sticking to core publish avoids the race.
|
|
316
|
-
this.conn.publish(topic, data);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
isConnected(): boolean {
|
|
320
|
-
return this.connected;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
async healthCheck(): Promise<boolean> {
|
|
324
|
-
return this.connected;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* RedisStreamsPubSubAdapter — v0.7 PR 6 — Pub/Sub adapter backed by
|
|
3
|
-
* Redis Streams via `ioredis`.
|
|
4
|
-
*
|
|
5
|
-
* Pub/Sub vs Worker semantics: this adapter uses **distinct consumer
|
|
6
|
-
* groups per subscriber** so multiple subscribers each receive every
|
|
7
|
-
* message (fan-out). When `consumerGroup` is explicitly set on the
|
|
8
|
-
* workflow, all subscribers in the group compete (1 of N gets each).
|
|
9
|
-
*
|
|
10
|
-
* Replay cursors:
|
|
11
|
-
* - `"earliest"` / unset → `$` (only new messages by default).
|
|
12
|
-
* - `"earliest"` with explicit intent → `0` (replay full stream).
|
|
13
|
-
* - `{seq: N}` → resume from stream id `N-0`.
|
|
14
|
-
*
|
|
15
|
-
* Requires `ioredis` as a peer dependency.
|
|
16
|
-
*
|
|
17
|
-
* Environment variables:
|
|
18
|
-
* - `REDIS_HOST` (default `localhost`).
|
|
19
|
-
* - `REDIS_PORT` (default `6379`).
|
|
20
|
-
* - `REDIS_PASSWORD`.
|
|
21
|
-
* - `REDIS_DB`.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import type { PubSubTriggerOpts } from "@blokjs/helper";
|
|
25
|
-
import { v4 as uuid } from "uuid";
|
|
26
|
-
import type { PubSubAdapter, PubSubMessage } from "../PubSubTrigger";
|
|
27
|
-
|
|
28
|
-
export interface RedisStreamsPubSubConfig {
|
|
29
|
-
host: string;
|
|
30
|
-
port: number;
|
|
31
|
-
password?: string;
|
|
32
|
-
db?: number;
|
|
33
|
-
blockMs: number;
|
|
34
|
-
count: number;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
interface RedisClient {
|
|
38
|
-
xadd(stream: string, ...args: string[]): Promise<string>;
|
|
39
|
-
xreadgroup(...args: string[]): Promise<Array<[string, Array<[string, string[]]>]> | null>;
|
|
40
|
-
xgroup(...args: string[]): Promise<string>;
|
|
41
|
-
xack(stream: string, group: string, ...ids: string[]): Promise<number>;
|
|
42
|
-
ping(): Promise<string>;
|
|
43
|
-
quit(): Promise<string>;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
interface ActiveSubscription {
|
|
47
|
-
stop: () => void;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export class RedisStreamsPubSubAdapter implements PubSubAdapter {
|
|
51
|
-
readonly provider = "redis-streams" as const;
|
|
52
|
-
private readonly config: RedisStreamsPubSubConfig;
|
|
53
|
-
private client: RedisClient | null = null;
|
|
54
|
-
private subscriptions: Map<string, ActiveSubscription> = new Map();
|
|
55
|
-
private connected = false;
|
|
56
|
-
private consumerName = `blok-pubsub-${uuid().slice(0, 8)}`;
|
|
57
|
-
|
|
58
|
-
constructor(config?: Partial<RedisStreamsPubSubConfig>) {
|
|
59
|
-
this.config = {
|
|
60
|
-
host: config?.host ?? process.env.REDIS_HOST ?? "localhost",
|
|
61
|
-
port: config?.port ?? Number.parseInt(process.env.REDIS_PORT ?? "6379", 10),
|
|
62
|
-
password: config?.password ?? process.env.REDIS_PASSWORD,
|
|
63
|
-
db: config?.db ?? Number.parseInt(process.env.REDIS_DB ?? "0", 10),
|
|
64
|
-
blockMs: config?.blockMs ?? 5000,
|
|
65
|
-
count: config?.count ?? 10,
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async connect(): Promise<void> {
|
|
70
|
-
if (this.connected) return;
|
|
71
|
-
try {
|
|
72
|
-
// biome-ignore lint/suspicious/noExplicitAny: ioredis is a runtime peer dep.
|
|
73
|
-
const ioredis: any = await import("ioredis");
|
|
74
|
-
const IORedis = ioredis.default ?? ioredis.Redis ?? ioredis;
|
|
75
|
-
this.client = new IORedis({
|
|
76
|
-
host: this.config.host,
|
|
77
|
-
port: this.config.port,
|
|
78
|
-
password: this.config.password,
|
|
79
|
-
db: this.config.db,
|
|
80
|
-
}) as RedisClient;
|
|
81
|
-
await this.client.ping();
|
|
82
|
-
this.connected = true;
|
|
83
|
-
} catch (err) {
|
|
84
|
-
throw new Error(
|
|
85
|
-
`[blok][pubsub-redis] connect failed: ${(err as Error).message}. Install ioredis as a peer dependency: bun add ioredis`,
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async disconnect(): Promise<void> {
|
|
91
|
-
if (!this.connected) return;
|
|
92
|
-
for (const sub of this.subscriptions.values()) sub.stop();
|
|
93
|
-
this.subscriptions.clear();
|
|
94
|
-
try {
|
|
95
|
-
await this.client?.quit();
|
|
96
|
-
} catch {
|
|
97
|
-
/* ignore */
|
|
98
|
-
}
|
|
99
|
-
this.client = null;
|
|
100
|
-
this.connected = false;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async subscribe(config: PubSubTriggerOpts, handler: (message: PubSubMessage) => Promise<void>): Promise<void> {
|
|
104
|
-
if (!this.connected || !this.client) throw new Error("[blok][pubsub-redis] not connected. Call connect() first.");
|
|
105
|
-
const client = this.client;
|
|
106
|
-
const stream = config.topic;
|
|
107
|
-
// Fan-out: each subscriber gets its own group (unique per instance).
|
|
108
|
-
// Competing-consumer: explicit `consumerGroup` makes all subscribers
|
|
109
|
-
// share work.
|
|
110
|
-
const group = config.consumerGroup ?? `blok-fanout-${this.consumerName}-${stream.replace(/[^a-zA-Z0-9_]/g, "_")}`;
|
|
111
|
-
const startId =
|
|
112
|
-
config.startFrom === "earliest"
|
|
113
|
-
? "0"
|
|
114
|
-
: config.startFrom === "latest" || config.startFrom === undefined
|
|
115
|
-
? "$"
|
|
116
|
-
: typeof config.startFrom === "object" && "seq" in config.startFrom
|
|
117
|
-
? `${config.startFrom.seq}-0`
|
|
118
|
-
: "$";
|
|
119
|
-
|
|
120
|
-
try {
|
|
121
|
-
await client.xgroup("CREATE", stream, group, startId, "MKSTREAM");
|
|
122
|
-
} catch (err) {
|
|
123
|
-
if (!/BUSYGROUP/i.test((err as Error).message)) throw err;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
let stopped = false;
|
|
127
|
-
const sub: ActiveSubscription = {
|
|
128
|
-
stop: () => {
|
|
129
|
-
stopped = true;
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
this.subscriptions.set(`${stream}#${group}`, sub);
|
|
133
|
-
|
|
134
|
-
void (async () => {
|
|
135
|
-
while (!stopped) {
|
|
136
|
-
let entries: Array<[string, Array<[string, string[]]>]> | null = null;
|
|
137
|
-
try {
|
|
138
|
-
entries = await client.xreadgroup(
|
|
139
|
-
"GROUP",
|
|
140
|
-
group,
|
|
141
|
-
this.consumerName,
|
|
142
|
-
"COUNT",
|
|
143
|
-
String(this.config.count),
|
|
144
|
-
"BLOCK",
|
|
145
|
-
String(this.config.blockMs),
|
|
146
|
-
"STREAMS",
|
|
147
|
-
stream,
|
|
148
|
-
">",
|
|
149
|
-
);
|
|
150
|
-
} catch {
|
|
151
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
if (!entries) continue;
|
|
155
|
-
for (const [, msgs] of entries) {
|
|
156
|
-
for (const [id, fields] of msgs) {
|
|
157
|
-
if (stopped) break;
|
|
158
|
-
const payload = this.fieldsToObject(fields);
|
|
159
|
-
const dataString = typeof payload.data === "string" ? payload.data : "";
|
|
160
|
-
let body: unknown;
|
|
161
|
-
try {
|
|
162
|
-
body = dataString.length > 0 ? JSON.parse(dataString) : null;
|
|
163
|
-
} catch {
|
|
164
|
-
body = dataString;
|
|
165
|
-
}
|
|
166
|
-
const message: PubSubMessage = {
|
|
167
|
-
id,
|
|
168
|
-
body,
|
|
169
|
-
attributes: payload,
|
|
170
|
-
raw: { id, fields },
|
|
171
|
-
topic: stream,
|
|
172
|
-
subscription: group,
|
|
173
|
-
publishTime: new Date(Number.parseInt(id.split("-")[0] ?? String(Date.now()), 10)),
|
|
174
|
-
ack: async () => {
|
|
175
|
-
await client.xack(stream, group, id);
|
|
176
|
-
},
|
|
177
|
-
nack: async () => {
|
|
178
|
-
/* leave unacked — picked up by XAUTOCLAIM */
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
try {
|
|
182
|
-
await handler(message);
|
|
183
|
-
if (config.ack !== false) await client.xack(stream, group, id);
|
|
184
|
-
} catch {
|
|
185
|
-
// Leave unacked — pending entries are visible in XPENDING.
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
})();
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
private fieldsToObject(fields: string[]): Record<string, string> {
|
|
194
|
-
const out: Record<string, string> = {};
|
|
195
|
-
for (let i = 0; i < fields.length; i += 2) out[fields[i]] = fields[i + 1];
|
|
196
|
-
return out;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
async unsubscribe(subscription: string): Promise<void> {
|
|
200
|
-
const sub = this.subscriptions.get(subscription);
|
|
201
|
-
if (!sub) return;
|
|
202
|
-
sub.stop();
|
|
203
|
-
this.subscriptions.delete(subscription);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
async publish(topic: string, payload: unknown): Promise<void> {
|
|
207
|
-
if (!this.connected || !this.client) throw new Error("[blok][pubsub-redis] not connected. Call connect() first.");
|
|
208
|
-
const body = typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
209
|
-
await this.client.xadd(topic, "*", "data", body);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
isConnected(): boolean {
|
|
213
|
-
return this.connected;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
async healthCheck(): Promise<boolean> {
|
|
217
|
-
if (!this.connected || !this.client) return false;
|
|
218
|
-
try {
|
|
219
|
-
const pong = await this.client.ping();
|
|
220
|
-
return pong === "PONG";
|
|
221
|
-
} catch {
|
|
222
|
-
return false;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pub/Sub adapter factory unit tests — v0.7 PR 6.
|
|
3
|
-
*
|
|
4
|
-
* Mirrors the worker factory tests (PR 5) — provider resolution
|
|
5
|
-
* order, constructor lookup, pool sharing, reset utility.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
9
|
-
|
|
10
|
-
import { _resetAdapterPoolForTests, createPubSubAdapter, getOrCreateAdapter, resolveProvider } from "./factory";
|
|
11
|
-
|
|
12
|
-
describe("pubsub adapter factory — v0.7 PR 6", () => {
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
_resetAdapterPoolForTests();
|
|
15
|
-
process.env.BLOK_PUBSUB_ADAPTER = undefined;
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
afterEach(() => {
|
|
19
|
-
_resetAdapterPoolForTests();
|
|
20
|
-
process.env.BLOK_PUBSUB_ADAPTER = undefined;
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
describe("resolveProvider()", () => {
|
|
24
|
-
it("returns the explicit provider when set", () => {
|
|
25
|
-
expect(resolveProvider("kafka")).toBe("kafka");
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("falls back to BLOK_PUBSUB_ADAPTER env var", () => {
|
|
29
|
-
process.env.BLOK_PUBSUB_ADAPTER = "redis-streams";
|
|
30
|
-
expect(resolveProvider(undefined)).toBe("redis-streams");
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("falls back to nats when neither is set", () => {
|
|
34
|
-
expect(resolveProvider(undefined)).toBe("nats");
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("ignores invalid env values and falls back to nats", () => {
|
|
38
|
-
process.env.BLOK_PUBSUB_ADAPTER = "not-a-provider";
|
|
39
|
-
expect(resolveProvider(undefined)).toBe("nats");
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("explicit provider wins over the env var", () => {
|
|
43
|
-
process.env.BLOK_PUBSUB_ADAPTER = "gcp";
|
|
44
|
-
expect(resolveProvider("aws")).toBe("aws");
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
describe("createPubSubAdapter()", () => {
|
|
49
|
-
it("returns the correct provider name for each built-in", () => {
|
|
50
|
-
expect(createPubSubAdapter("nats").provider).toBe("nats");
|
|
51
|
-
expect(createPubSubAdapter("redis-streams").provider).toBe("redis-streams");
|
|
52
|
-
expect(createPubSubAdapter("kafka").provider).toBe("kafka");
|
|
53
|
-
expect(createPubSubAdapter("gcp").provider).toBe("gcp");
|
|
54
|
-
expect(createPubSubAdapter("aws").provider).toBe("aws");
|
|
55
|
-
expect(createPubSubAdapter("azure").provider).toBe("azure");
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("each call returns a fresh instance", () => {
|
|
59
|
-
const a = createPubSubAdapter("nats");
|
|
60
|
-
const b = createPubSubAdapter("nats");
|
|
61
|
-
expect(a).not.toBe(b);
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
describe("getOrCreateAdapter()", () => {
|
|
66
|
-
it("returns the same instance on repeated calls for the same provider", () => {
|
|
67
|
-
const a = getOrCreateAdapter("nats");
|
|
68
|
-
const b = getOrCreateAdapter("nats");
|
|
69
|
-
expect(a).toBe(b);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("returns different instances for different providers", () => {
|
|
73
|
-
const nats = getOrCreateAdapter("nats");
|
|
74
|
-
const kafka = getOrCreateAdapter("kafka");
|
|
75
|
-
expect(nats).not.toBe(kafka);
|
|
76
|
-
expect(nats.provider).toBe("nats");
|
|
77
|
-
expect(kafka.provider).toBe("kafka");
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("_resetAdapterPoolForTests() drops cached instances", () => {
|
|
81
|
-
const first = getOrCreateAdapter("nats");
|
|
82
|
-
_resetAdapterPoolForTests();
|
|
83
|
-
const second = getOrCreateAdapter("nats");
|
|
84
|
-
expect(first).not.toBe(second);
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
});
|