@onebun/nats 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,588 @@
1
+ /**
2
+ * JetStream Queue Adapter
3
+ *
4
+ * Queue adapter using NATS JetStream for persistent messaging.
5
+ * Provides reliable message delivery with persistence and acknowledgments.
6
+ */
7
+
8
+ import type { JetStreamAdapterOptions } from './types';
9
+
10
+ import type {
11
+ QueueAdapter,
12
+ QueueAdapterType,
13
+ QueueFeature,
14
+ QueueEvents,
15
+ Message,
16
+ MessageMetadata,
17
+ PublishOptions,
18
+ SubscribeOptions,
19
+ Subscription,
20
+ ScheduledJobOptions,
21
+ ScheduledJobInfo,
22
+ MessageHandler,
23
+ QueueScheduler,
24
+ } from '@onebun/core';
25
+ import {
26
+ createQueuePatternMatcher,
27
+ createQueueScheduler,
28
+ type QueuePatternMatch,
29
+ } from '@onebun/core';
30
+
31
+ import { NatsClient } from './nats-client';
32
+
33
+ // Import JetStream types dynamically
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ let jetstreamModule: any = null;
36
+
37
+ async function getJetStreamModule() {
38
+ if (!jetstreamModule) {
39
+ jetstreamModule = await import('@nats-io/jetstream');
40
+ }
41
+
42
+ return jetstreamModule;
43
+ }
44
+
45
+ // ============================================================================
46
+ // JetStream Message Implementation
47
+ // ============================================================================
48
+
49
+ class JetStreamMessage<T> implements Message<T> {
50
+ id: string;
51
+ pattern: string;
52
+ data: T;
53
+ timestamp: number;
54
+ redelivered: boolean;
55
+ metadata: MessageMetadata;
56
+ attempt?: number;
57
+ maxAttempts?: number;
58
+
59
+ private acked = false;
60
+ private nacked = false;
61
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
62
+ private jsMsg: any;
63
+
64
+ constructor(
65
+ id: string,
66
+ pattern: string,
67
+ data: T,
68
+ timestamp: number,
69
+ metadata: MessageMetadata,
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ jsMsg: any,
72
+ ) {
73
+ this.id = id;
74
+ this.pattern = pattern;
75
+ this.data = data;
76
+ this.timestamp = timestamp;
77
+ this.metadata = metadata;
78
+ this.jsMsg = jsMsg;
79
+ this.redelivered = jsMsg?.info?.redelivered ?? false;
80
+ }
81
+
82
+ async ack(): Promise<void> {
83
+ if (this.acked || this.nacked) {
84
+ return;
85
+ }
86
+ this.acked = true;
87
+ if (this.jsMsg?.ack) {
88
+ this.jsMsg.ack();
89
+ }
90
+ }
91
+
92
+ async nack(requeue = false): Promise<void> {
93
+ if (this.acked || this.nacked) {
94
+ return;
95
+ }
96
+ this.nacked = true;
97
+ if (this.jsMsg?.nak) {
98
+ // JetStream will requeue automatically based on consumer config
99
+ this.jsMsg.nak(requeue ? undefined : { delay: -1 });
100
+ }
101
+ }
102
+ }
103
+
104
+ // ============================================================================
105
+ // JetStream Subscription Implementation
106
+ // ============================================================================
107
+
108
+ interface JetStreamSubscriptionEntry {
109
+ pattern: string;
110
+ handler: MessageHandler;
111
+ options?: SubscribeOptions;
112
+ matcher: (topic: string) => QueuePatternMatch;
113
+ paused: boolean;
114
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
115
+ consumer?: any;
116
+ running: boolean;
117
+ }
118
+
119
+ class JetStreamSubscription implements Subscription {
120
+ private active = true;
121
+
122
+ constructor(
123
+ private readonly entry: JetStreamSubscriptionEntry,
124
+ private readonly onUnsubscribe: () => Promise<void>,
125
+ ) {}
126
+
127
+ async unsubscribe(): Promise<void> {
128
+ this.active = false;
129
+ this.entry.running = false;
130
+ await this.onUnsubscribe();
131
+ }
132
+
133
+ pause(): void {
134
+ this.entry.paused = true;
135
+ }
136
+
137
+ resume(): void {
138
+ this.entry.paused = false;
139
+ }
140
+
141
+ get pattern(): string {
142
+ return this.entry.pattern;
143
+ }
144
+
145
+ get isActive(): boolean {
146
+ return this.active && !this.entry.paused;
147
+ }
148
+ }
149
+
150
+ // ============================================================================
151
+ // JetStream Queue Adapter
152
+ // ============================================================================
153
+
154
+ /**
155
+ * JetStream Queue Adapter
156
+ *
157
+ * Uses NATS JetStream for persistent, reliable message delivery.
158
+ *
159
+ * Features:
160
+ * - Pattern subscriptions
161
+ * - Consumer groups (durable consumers)
162
+ * - Scheduled jobs (via in-process scheduler)
163
+ * - Dead letter queue support
164
+ * - Retry with acknowledgment
165
+ * - Message persistence
166
+ *
167
+ * Not supported:
168
+ * - Priority (JetStream doesn't support priority)
169
+ * - Delayed messages (can be simulated with headers)
170
+ *
171
+ * @example
172
+ * ```typescript
173
+ * const adapter = new JetStreamQueueAdapter({
174
+ * servers: 'nats://localhost:4222',
175
+ * stream: 'EVENTS',
176
+ * createStream: true,
177
+ * streamConfig: {
178
+ * subjects: ['events.>'],
179
+ * retention: 'limits',
180
+ * maxMsgs: 1000000,
181
+ * },
182
+ * });
183
+ * await adapter.connect();
184
+ *
185
+ * await adapter.subscribe('events.*', async (message) => {
186
+ * console.log('Received:', message.data);
187
+ * await message.ack();
188
+ * }, { ackMode: 'manual', group: 'event-processor' });
189
+ *
190
+ * await adapter.publish('events.created', { id: 123 });
191
+ * ```
192
+ */
193
+ export class JetStreamQueueAdapter implements QueueAdapter {
194
+ readonly name = 'jetstream';
195
+ readonly type: QueueAdapterType = 'jetstream';
196
+
197
+ private client: NatsClient;
198
+ private connected = false;
199
+ private scheduler: QueueScheduler | null = null;
200
+ private subscriptions: JetStreamSubscriptionEntry[] = [];
201
+ private messageIdCounter = 0;
202
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
203
+ private js: any = null;
204
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
205
+ private jsm: any = null;
206
+
207
+ // Event handlers
208
+ private eventHandlers: Map<keyof QueueEvents, Set<(...args: unknown[]) => void>> = new Map();
209
+
210
+ constructor(private readonly options: JetStreamAdapterOptions) {
211
+ this.client = new NatsClient(options);
212
+ }
213
+
214
+ // ============================================================================
215
+ // Lifecycle
216
+ // ============================================================================
217
+
218
+ async connect(): Promise<void> {
219
+ if (this.connected) {
220
+ return;
221
+ }
222
+
223
+ try {
224
+ await this.client.connect();
225
+
226
+ const jsModule = await getJetStreamModule();
227
+ const nc = this.client.getConnection();
228
+
229
+ // Get JetStream context
230
+ this.js = jsModule.jetstream(nc);
231
+ this.jsm = await jsModule.jetstreamManager(nc);
232
+
233
+ // Create stream if needed
234
+ if (this.options.createStream) {
235
+ await this.ensureStream();
236
+ }
237
+
238
+ this.connected = true;
239
+ this.scheduler = createQueueScheduler(this);
240
+
241
+ this.emit('onReady');
242
+ } catch (error) {
243
+ this.emit('onError', error as Error);
244
+ throw error;
245
+ }
246
+ }
247
+
248
+ async disconnect(): Promise<void> {
249
+ if (!this.connected) {
250
+ return;
251
+ }
252
+
253
+ // Stop scheduler
254
+ if (this.scheduler) {
255
+ this.scheduler.stop();
256
+ this.scheduler = null;
257
+ }
258
+
259
+ // Stop all consumers
260
+ for (const entry of this.subscriptions) {
261
+ entry.running = false;
262
+ }
263
+ this.subscriptions = [];
264
+
265
+ await this.client.disconnect();
266
+ this.connected = false;
267
+ this.js = null;
268
+ this.jsm = null;
269
+ }
270
+
271
+ isConnected(): boolean {
272
+ return this.connected && this.client.isConnected();
273
+ }
274
+
275
+ // ============================================================================
276
+ // Publishing
277
+ // ============================================================================
278
+
279
+ async publish<T>(pattern: string, data: T, options?: PublishOptions): Promise<string> {
280
+ this.ensureConnected();
281
+
282
+ const messageId = options?.messageId ?? this.generateMessageId();
283
+ const timestamp = Date.now();
284
+
285
+ const messageData = {
286
+ id: messageId,
287
+ pattern,
288
+ data,
289
+ timestamp,
290
+ metadata: options?.metadata ?? {},
291
+ };
292
+
293
+ const encoder = new TextEncoder();
294
+
295
+ // Convert OneBun subject to NATS subject (replace # with >)
296
+ const natsSubject = pattern.replace(/#/g, '>');
297
+
298
+ await this.js.publish(natsSubject, encoder.encode(JSON.stringify(messageData)));
299
+
300
+ return messageId;
301
+ }
302
+
303
+ async publishBatch<T>(
304
+ messages: Array<{ pattern: string; data: T; options?: PublishOptions }>,
305
+ ): Promise<string[]> {
306
+ const ids: string[] = [];
307
+
308
+ for (const msg of messages) {
309
+ const id = await this.publish(msg.pattern, msg.data, msg.options);
310
+ ids.push(id);
311
+ }
312
+
313
+ return ids;
314
+ }
315
+
316
+ // ============================================================================
317
+ // Subscribing
318
+ // ============================================================================
319
+
320
+ async subscribe<T>(
321
+ pattern: string,
322
+ handler: MessageHandler<T>,
323
+ options?: SubscribeOptions,
324
+ ): Promise<Subscription> {
325
+ this.ensureConnected();
326
+
327
+ const jsModule = await getJetStreamModule();
328
+
329
+ // Create consumer name from group or generate one
330
+ const consumerName = options?.group ?? `consumer-${Date.now()}`;
331
+
332
+ // Convert pattern to filter subject (replace # with >)
333
+ const filterSubject = pattern.replace(/#/g, '>');
334
+
335
+ // Determine ack policy
336
+ const ackPolicy = options?.ackMode === 'manual'
337
+ ? jsModule.AckPolicy.Explicit
338
+ : jsModule.AckPolicy.None;
339
+
340
+ // Create or get consumer
341
+ try {
342
+ await this.jsm.consumers.add(this.options.stream, {
343
+ durable_name: options?.group ? consumerName : undefined,
344
+ name: consumerName,
345
+ ack_policy: ackPolicy,
346
+ filter_subject: filterSubject,
347
+ max_ack_pending: options?.prefetch ?? 100,
348
+ // eslint-disable-next-line no-magic-numbers
349
+ ack_wait: this.options.consumerConfig?.ackWait ?? 30000000000, // 30s in nanoseconds
350
+ max_deliver: options?.retry?.attempts ?? this.options.consumerConfig?.maxDeliver ?? 3,
351
+ });
352
+ } catch {
353
+ // Consumer might already exist, try to get it
354
+ }
355
+
356
+ const consumer = await this.js.consumers.get(this.options.stream, consumerName);
357
+
358
+ const entry: JetStreamSubscriptionEntry = {
359
+ pattern,
360
+ handler: handler as MessageHandler,
361
+ options,
362
+ matcher: createQueuePatternMatcher(pattern),
363
+ paused: false,
364
+ consumer,
365
+ running: true,
366
+ };
367
+
368
+ this.subscriptions.push(entry);
369
+
370
+ // Start consuming messages
371
+ this.consumeMessages(entry);
372
+
373
+ const subscription = new JetStreamSubscription(entry, async () => {
374
+ entry.running = false;
375
+ const index = this.subscriptions.indexOf(entry);
376
+ if (index !== -1) {
377
+ this.subscriptions.splice(index, 1);
378
+ }
379
+ });
380
+
381
+ return subscription;
382
+ }
383
+
384
+ // ============================================================================
385
+ // Scheduled Jobs
386
+ // ============================================================================
387
+
388
+ async addScheduledJob(name: string, options: ScheduledJobOptions): Promise<void> {
389
+ this.ensureConnected();
390
+
391
+ if (!this.scheduler) {
392
+ throw new Error('Scheduler not initialized');
393
+ }
394
+
395
+ if (options.schedule.cron) {
396
+ this.scheduler.addCronJob(name, options.schedule.cron, options.pattern, () => options.data, {
397
+ metadata: options.metadata,
398
+ overlapStrategy: options.overlapStrategy,
399
+ });
400
+ } else if (options.schedule.every) {
401
+ this.scheduler.addIntervalJob(name, options.schedule.every, options.pattern, () => options.data, {
402
+ metadata: options.metadata,
403
+ });
404
+ }
405
+ }
406
+
407
+ async removeScheduledJob(name: string): Promise<boolean> {
408
+ if (!this.scheduler) {
409
+ return false;
410
+ }
411
+
412
+ return this.scheduler.removeJob(name);
413
+ }
414
+
415
+ async getScheduledJobs(): Promise<ScheduledJobInfo[]> {
416
+ if (!this.scheduler) {
417
+ return [];
418
+ }
419
+
420
+ return this.scheduler.getJobs();
421
+ }
422
+
423
+ // ============================================================================
424
+ // Features
425
+ // ============================================================================
426
+
427
+ supports(feature: QueueFeature): boolean {
428
+ switch (feature) {
429
+ case 'pattern-subscriptions':
430
+ case 'consumer-groups':
431
+ case 'scheduled-jobs':
432
+ case 'dead-letter-queue':
433
+ case 'retry':
434
+ return true;
435
+ case 'delayed-messages':
436
+ case 'priority':
437
+ return false;
438
+ default:
439
+ return false;
440
+ }
441
+ }
442
+
443
+ // ============================================================================
444
+ // Events
445
+ // ============================================================================
446
+
447
+ on<E extends keyof QueueEvents>(event: E, handler: NonNullable<QueueEvents[E]>): void {
448
+ if (!this.eventHandlers.has(event)) {
449
+ this.eventHandlers.set(event, new Set());
450
+ }
451
+ this.eventHandlers.get(event)!.add(handler as (...args: unknown[]) => void);
452
+ }
453
+
454
+ off<E extends keyof QueueEvents>(event: E, handler: NonNullable<QueueEvents[E]>): void {
455
+ const handlers = this.eventHandlers.get(event);
456
+ if (handlers) {
457
+ handlers.delete(handler as (...args: unknown[]) => void);
458
+ }
459
+ }
460
+
461
+ // ============================================================================
462
+ // Private Methods
463
+ // ============================================================================
464
+
465
+ private ensureConnected(): void {
466
+ if (!this.connected) {
467
+ throw new Error('JetStreamQueueAdapter not connected. Call connect() first.');
468
+ }
469
+ }
470
+
471
+ private generateMessageId(): string {
472
+ // eslint-disable-next-line no-magic-numbers
473
+ return `js-${++this.messageIdCounter}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
474
+ }
475
+
476
+ private emit<E extends keyof QueueEvents>(event: E, ...args: unknown[]): void {
477
+ const handlers = this.eventHandlers.get(event);
478
+ if (handlers) {
479
+ for (const handler of handlers) {
480
+ try {
481
+ handler(...args);
482
+ } catch {
483
+ // Silently ignore event handler errors
484
+ }
485
+ }
486
+ }
487
+ }
488
+
489
+ private async ensureStream(): Promise<void> {
490
+ const config = this.options.streamConfig ?? {};
491
+
492
+ try {
493
+ // Try to get existing stream
494
+ await this.jsm.streams.info(this.options.stream);
495
+ } catch {
496
+ // Stream doesn't exist, create it
497
+ await this.jsm.streams.add({
498
+ name: this.options.stream,
499
+ subjects: config.subjects ?? [`${this.options.stream}.>`],
500
+ retention: config.retention ?? 'limits',
501
+ max_msgs: config.maxMsgs,
502
+ max_bytes: config.maxBytes,
503
+ max_age: config.maxAge,
504
+ storage: config.storage ?? 'file',
505
+ num_replicas: config.replicas ?? 1,
506
+ });
507
+ }
508
+ }
509
+
510
+ private async consumeMessages(entry: JetStreamSubscriptionEntry): Promise<void> {
511
+ const decoder = new TextDecoder();
512
+
513
+ try {
514
+ const messages = await entry.consumer.consume({
515
+ max_messages: entry.options?.prefetch ?? 10,
516
+ });
517
+
518
+ for await (const msg of messages) {
519
+ if (!entry.running || entry.paused) {
520
+ break;
521
+ }
522
+
523
+ try {
524
+ const messageData = JSON.parse(decoder.decode(msg.data));
525
+
526
+ // Check if pattern matches
527
+ const match = entry.matcher(messageData.pattern || msg.subject);
528
+ if (!match.matched) {
529
+ // Ack and skip non-matching messages
530
+ if (entry.options?.ackMode !== 'manual') {
531
+ msg.ack();
532
+ }
533
+ continue;
534
+ }
535
+
536
+ const message = new JetStreamMessage(
537
+ messageData.id || this.generateMessageId(),
538
+ messageData.pattern || msg.subject,
539
+ messageData.data,
540
+ messageData.timestamp || Date.now(),
541
+ messageData.metadata || {},
542
+ msg,
543
+ );
544
+
545
+ // Emit received event
546
+ this.emit('onMessageReceived', message);
547
+
548
+ try {
549
+ await entry.handler(message);
550
+
551
+ // Auto-ack if not manual mode
552
+ if (entry.options?.ackMode !== 'manual') {
553
+ msg.ack();
554
+ }
555
+
556
+ // Emit processed event
557
+ this.emit('onMessageProcessed', message);
558
+ } catch (error) {
559
+ // Emit failed event
560
+ this.emit('onMessageFailed', message, error as Error);
561
+
562
+ // Auto-nack if not manual mode
563
+ if (entry.options?.ackMode !== 'manual') {
564
+ msg.nak();
565
+ }
566
+ }
567
+ } catch {
568
+ // Message parsing error - ack to prevent redelivery
569
+ msg.ack();
570
+ }
571
+ }
572
+ } catch {
573
+ // Consumer error - will be handled by NATS reconnection
574
+ }
575
+
576
+ // Restart consumption if still running
577
+ if (entry.running) {
578
+ setTimeout(() => this.consumeMessages(entry), 100);
579
+ }
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Create a JetStream queue adapter
585
+ */
586
+ export function createJetStreamQueueAdapter(options: JetStreamAdapterOptions): JetStreamQueueAdapter {
587
+ return new JetStreamQueueAdapter(options);
588
+ }