@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,198 @@
1
+ /**
2
+ * NATS Client Wrapper
3
+ *
4
+ * Wrapper around @nats-io/transport-node for easier usage.
5
+ */
6
+
7
+ import type { NatsConnectionOptions } from './types';
8
+
9
+ // Import NATS types dynamically to handle potential import issues
10
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
+ let natsModule: any = null;
12
+
13
+ async function getNatsModule() {
14
+ if (!natsModule) {
15
+ natsModule = await import('@nats-io/transport-node');
16
+ }
17
+
18
+ return natsModule;
19
+ }
20
+
21
+ /**
22
+ * NATS subscription wrapper
23
+ */
24
+ export interface NatsSubscriptionHandle {
25
+ /** Unsubscribe from the subject */
26
+ unsubscribe(): void;
27
+ /** Drain the subscription */
28
+ drain(): Promise<void>;
29
+ }
30
+
31
+ /**
32
+ * NATS message wrapper
33
+ */
34
+ export interface NatsMessage {
35
+ /** Subject the message was received on */
36
+ subject: string;
37
+ /** Message data as string */
38
+ data: string;
39
+ /** Reply subject if request-reply pattern */
40
+ reply?: string;
41
+ /** Message headers */
42
+ headers?: Map<string, string[]>;
43
+ }
44
+
45
+ /**
46
+ * NATS Client
47
+ *
48
+ * Simplified wrapper around the NATS.js client.
49
+ */
50
+ export class NatsClient {
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
+ private nc: any = null;
53
+ private readonly options: NatsConnectionOptions;
54
+
55
+ constructor(options: NatsConnectionOptions) {
56
+ this.options = options;
57
+ }
58
+
59
+ /**
60
+ * Connect to NATS
61
+ */
62
+ async connect(): Promise<void> {
63
+ if (this.nc) {
64
+ return;
65
+ }
66
+
67
+ const nats = await getNatsModule();
68
+
69
+ this.nc = await nats.connect({
70
+ servers: this.options.servers,
71
+ name: this.options.name,
72
+ token: this.options.token,
73
+ user: this.options.user,
74
+ pass: this.options.pass,
75
+ maxReconnectAttempts: this.options.maxReconnectAttempts,
76
+ reconnectTimeWait: this.options.reconnectTimeWait,
77
+ timeout: this.options.timeout,
78
+ tls: this.options.tls ? {} : undefined,
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Disconnect from NATS
84
+ */
85
+ async disconnect(): Promise<void> {
86
+ if (this.nc) {
87
+ await this.nc.drain();
88
+ await this.nc.close();
89
+ this.nc = null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Check if connected
95
+ */
96
+ isConnected(): boolean {
97
+ return this.nc !== null && !this.nc.isClosed();
98
+ }
99
+
100
+ /**
101
+ * Publish a message
102
+ */
103
+ async publish(subject: string, data: string, headers?: Record<string, string>): Promise<void> {
104
+ if (!this.nc) {
105
+ throw new Error('Not connected to NATS');
106
+ }
107
+
108
+ const nats = await getNatsModule();
109
+ const encoder = new TextEncoder();
110
+
111
+ let natsHeaders;
112
+ if (headers) {
113
+ natsHeaders = nats.headers();
114
+ for (const [key, value] of Object.entries(headers)) {
115
+ natsHeaders.set(key, value);
116
+ }
117
+ }
118
+
119
+ this.nc.publish(subject, encoder.encode(data), { headers: natsHeaders });
120
+ }
121
+
122
+ /**
123
+ * Subscribe to a subject
124
+ */
125
+ async subscribe(
126
+ subject: string,
127
+ callback: (msg: NatsMessage) => void | Promise<void>,
128
+ options?: { queue?: string },
129
+ ): Promise<NatsSubscriptionHandle> {
130
+ if (!this.nc) {
131
+ throw new Error('Not connected to NATS');
132
+ }
133
+
134
+ const decoder = new TextDecoder();
135
+ const sub = this.nc.subscribe(subject, { queue: options?.queue });
136
+
137
+ // Start consuming messages
138
+ (async () => {
139
+ for await (const msg of sub) {
140
+ const natsMsg: NatsMessage = {
141
+ subject: msg.subject,
142
+ data: decoder.decode(msg.data),
143
+ reply: msg.reply,
144
+ headers: msg.headers ? new Map(msg.headers.entries()) : undefined,
145
+ };
146
+ await callback(natsMsg);
147
+ }
148
+ })();
149
+
150
+ return {
151
+ unsubscribe: () => sub.unsubscribe(),
152
+ drain: () => sub.drain(),
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Request-reply pattern
158
+ */
159
+ async request(
160
+ subject: string,
161
+ data: string,
162
+ options?: { timeout?: number },
163
+ ): Promise<NatsMessage> {
164
+ if (!this.nc) {
165
+ throw new Error('Not connected to NATS');
166
+ }
167
+
168
+ const encoder = new TextEncoder();
169
+ const decoder = new TextDecoder();
170
+
171
+
172
+ const response = await this.nc.request(subject, encoder.encode(data), {
173
+ timeout: options?.timeout ?? 5000,
174
+ });
175
+
176
+ return {
177
+ subject: response.subject,
178
+ data: decoder.decode(response.data),
179
+ reply: response.reply,
180
+ headers: response.headers ? new Map(response.headers.entries()) : undefined,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Get the underlying NATS connection
186
+ */
187
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
188
+ getConnection(): any {
189
+ return this.nc;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Create a NATS client
195
+ */
196
+ export function createNatsClient(options: NatsConnectionOptions): NatsClient {
197
+ return new NatsClient(options);
198
+ }
@@ -0,0 +1,467 @@
1
+ /**
2
+ * NATS Queue Adapter
3
+ *
4
+ * Queue adapter using NATS pub/sub for message delivery.
5
+ * This adapter provides basic pub/sub functionality without persistence.
6
+ * For persistent messaging, use JetStreamQueueAdapter.
7
+ */
8
+
9
+ import type { NatsAdapterOptions } from './types';
10
+
11
+ import type {
12
+ QueueAdapter,
13
+ QueueAdapterType,
14
+ QueueFeature,
15
+ QueueEvents,
16
+ Message,
17
+ MessageMetadata,
18
+ PublishOptions,
19
+ SubscribeOptions,
20
+ Subscription,
21
+ ScheduledJobOptions,
22
+ ScheduledJobInfo,
23
+ MessageHandler,
24
+ QueueScheduler,
25
+ } from '@onebun/core';
26
+ import {
27
+ createQueuePatternMatcher,
28
+ createQueueScheduler,
29
+ type QueuePatternMatch,
30
+ } from '@onebun/core';
31
+
32
+ import {
33
+ NatsClient,
34
+ type NatsMessage,
35
+ type NatsSubscriptionHandle,
36
+ } from './nats-client';
37
+
38
+ // ============================================================================
39
+ // NATS Message Implementation
40
+ // ============================================================================
41
+
42
+ class NatsQueueMessage<T> implements Message<T> {
43
+ id: string;
44
+ pattern: string;
45
+ data: T;
46
+ timestamp: number;
47
+ redelivered: boolean;
48
+ metadata: MessageMetadata;
49
+ attempt?: number;
50
+ maxAttempts?: number;
51
+
52
+ private acked = false;
53
+ private nacked = false;
54
+
55
+ constructor(
56
+ id: string,
57
+ pattern: string,
58
+ data: T,
59
+ timestamp: number,
60
+ metadata: MessageMetadata,
61
+ ) {
62
+ this.id = id;
63
+ this.pattern = pattern;
64
+ this.data = data;
65
+ this.timestamp = timestamp;
66
+ this.metadata = metadata;
67
+ this.redelivered = false;
68
+ }
69
+
70
+ async ack(): Promise<void> {
71
+ // NATS pub/sub doesn't require explicit ack
72
+ this.acked = true;
73
+ }
74
+
75
+ async nack(_requeue = false): Promise<void> {
76
+ // NATS pub/sub doesn't support nack/requeue
77
+ this.nacked = true;
78
+ }
79
+ }
80
+
81
+ // ============================================================================
82
+ // NATS Subscription Implementation
83
+ // ============================================================================
84
+
85
+ interface NatsSubscriptionEntry {
86
+ pattern: string;
87
+ handler: MessageHandler;
88
+ options?: SubscribeOptions;
89
+ matcher: (topic: string) => QueuePatternMatch;
90
+ paused: boolean;
91
+ handle?: NatsSubscriptionHandle;
92
+ }
93
+
94
+ class NatsSubscription implements Subscription {
95
+ private active = true;
96
+
97
+ constructor(
98
+ private readonly entry: NatsSubscriptionEntry,
99
+ private readonly onUnsubscribe: () => Promise<void>,
100
+ ) {}
101
+
102
+ async unsubscribe(): Promise<void> {
103
+ this.active = false;
104
+ await this.onUnsubscribe();
105
+ }
106
+
107
+ pause(): void {
108
+ this.entry.paused = true;
109
+ }
110
+
111
+ resume(): void {
112
+ this.entry.paused = false;
113
+ }
114
+
115
+ get pattern(): string {
116
+ return this.entry.pattern;
117
+ }
118
+
119
+ get isActive(): boolean {
120
+ return this.active && !this.entry.paused;
121
+ }
122
+ }
123
+
124
+ // ============================================================================
125
+ // NATS Queue Adapter
126
+ // ============================================================================
127
+
128
+ /**
129
+ * NATS Queue Adapter
130
+ *
131
+ * Uses NATS pub/sub for message delivery. This is suitable for
132
+ * scenarios where messages don't need to be persisted and can be
133
+ * lost if no subscribers are available.
134
+ *
135
+ * For persistent messaging, use JetStreamQueueAdapter.
136
+ *
137
+ * Features:
138
+ * - Pattern subscriptions (using NATS wildcards)
139
+ * - Consumer groups (using NATS queue groups)
140
+ * - Scheduled jobs (via in-process scheduler)
141
+ *
142
+ * Not supported:
143
+ * - Delayed messages
144
+ * - Priority
145
+ * - Dead letter queues
146
+ * - Retry (message is lost if handler fails)
147
+ *
148
+ * @example
149
+ * ```typescript
150
+ * const adapter = new NatsQueueAdapter({
151
+ * servers: 'nats://localhost:4222',
152
+ * });
153
+ * await adapter.connect();
154
+ *
155
+ * await adapter.subscribe('orders.*', async (message) => {
156
+ * console.log('Received:', message.data);
157
+ * });
158
+ *
159
+ * await adapter.publish('orders.created', { orderId: 123 });
160
+ * ```
161
+ */
162
+ export class NatsQueueAdapter implements QueueAdapter {
163
+ readonly name = 'nats';
164
+ readonly type: QueueAdapterType = 'nats';
165
+
166
+ private client: NatsClient;
167
+ private connected = false;
168
+ private scheduler: QueueScheduler | null = null;
169
+ private subscriptions: NatsSubscriptionEntry[] = [];
170
+ private messageIdCounter = 0;
171
+
172
+ // Event handlers
173
+ private eventHandlers: Map<keyof QueueEvents, Set<(...args: unknown[]) => void>> = new Map();
174
+
175
+ constructor(private readonly options: NatsAdapterOptions) {
176
+ this.client = new NatsClient(options);
177
+ }
178
+
179
+ // ============================================================================
180
+ // Lifecycle
181
+ // ============================================================================
182
+
183
+ async connect(): Promise<void> {
184
+ if (this.connected) {
185
+ return;
186
+ }
187
+
188
+ try {
189
+ await this.client.connect();
190
+ this.connected = true;
191
+ this.scheduler = createQueueScheduler(this);
192
+
193
+ this.emit('onReady');
194
+ } catch (error) {
195
+ this.emit('onError', error as Error);
196
+ throw error;
197
+ }
198
+ }
199
+
200
+ async disconnect(): Promise<void> {
201
+ if (!this.connected) {
202
+ return;
203
+ }
204
+
205
+ // Stop scheduler
206
+ if (this.scheduler) {
207
+ this.scheduler.stop();
208
+ this.scheduler = null;
209
+ }
210
+
211
+ // Unsubscribe all
212
+ for (const entry of this.subscriptions) {
213
+ if (entry.handle) {
214
+ entry.handle.unsubscribe();
215
+ }
216
+ }
217
+ this.subscriptions = [];
218
+
219
+ await this.client.disconnect();
220
+ this.connected = false;
221
+ }
222
+
223
+ isConnected(): boolean {
224
+ return this.connected && this.client.isConnected();
225
+ }
226
+
227
+ // ============================================================================
228
+ // Publishing
229
+ // ============================================================================
230
+
231
+ async publish<T>(pattern: string, data: T, options?: PublishOptions): Promise<string> {
232
+ this.ensureConnected();
233
+
234
+ const messageId = options?.messageId ?? this.generateMessageId();
235
+ const timestamp = Date.now();
236
+
237
+ const messageData = {
238
+ id: messageId,
239
+ pattern,
240
+ data,
241
+ timestamp,
242
+ metadata: options?.metadata ?? {},
243
+ };
244
+
245
+ // Convert headers to string map
246
+ const headers: Record<string, string> = {};
247
+ if (options?.metadata?.headers) {
248
+ Object.assign(headers, options.metadata.headers);
249
+ }
250
+
251
+ await this.client.publish(pattern, JSON.stringify(messageData), headers);
252
+
253
+ return messageId;
254
+ }
255
+
256
+ async publishBatch<T>(
257
+ messages: Array<{ pattern: string; data: T; options?: PublishOptions }>,
258
+ ): Promise<string[]> {
259
+ const ids: string[] = [];
260
+
261
+ for (const msg of messages) {
262
+ const id = await this.publish(msg.pattern, msg.data, msg.options);
263
+ ids.push(id);
264
+ }
265
+
266
+ return ids;
267
+ }
268
+
269
+ // ============================================================================
270
+ // Subscribing
271
+ // ============================================================================
272
+
273
+ async subscribe<T>(
274
+ pattern: string,
275
+ handler: MessageHandler<T>,
276
+ options?: SubscribeOptions,
277
+ ): Promise<Subscription> {
278
+ this.ensureConnected();
279
+
280
+ // Convert OneBun pattern to NATS pattern
281
+ // OneBun uses '.' as separator and '*' for single-level, '#' for multi-level
282
+ // NATS uses '.' as separator and '*' for single-level, '>' for multi-level
283
+ const natsPattern = pattern.replace(/#/g, '>');
284
+
285
+ const entry: NatsSubscriptionEntry = {
286
+ pattern,
287
+ handler: handler as MessageHandler,
288
+ options,
289
+ matcher: createQueuePatternMatcher(pattern),
290
+ paused: false,
291
+ };
292
+
293
+ // Subscribe to NATS
294
+ const handle = await this.client.subscribe(
295
+ natsPattern,
296
+ async (msg) => {
297
+ if (entry.paused) {
298
+ return;
299
+ }
300
+ await this.processMessage(entry, msg);
301
+ },
302
+ { queue: options?.group },
303
+ );
304
+
305
+ entry.handle = handle;
306
+ this.subscriptions.push(entry);
307
+
308
+ const subscription = new NatsSubscription(entry, async () => {
309
+ const index = this.subscriptions.indexOf(entry);
310
+ if (index !== -1) {
311
+ this.subscriptions.splice(index, 1);
312
+ }
313
+ if (entry.handle) {
314
+ entry.handle.unsubscribe();
315
+ }
316
+ });
317
+
318
+ return subscription;
319
+ }
320
+
321
+ // ============================================================================
322
+ // Scheduled Jobs
323
+ // ============================================================================
324
+
325
+ async addScheduledJob(name: string, options: ScheduledJobOptions): Promise<void> {
326
+ this.ensureConnected();
327
+
328
+ if (!this.scheduler) {
329
+ throw new Error('Scheduler not initialized');
330
+ }
331
+
332
+ if (options.schedule.cron) {
333
+ this.scheduler.addCronJob(name, options.schedule.cron, options.pattern, () => options.data, {
334
+ metadata: options.metadata,
335
+ overlapStrategy: options.overlapStrategy,
336
+ });
337
+ } else if (options.schedule.every) {
338
+ this.scheduler.addIntervalJob(name, options.schedule.every, options.pattern, () => options.data, {
339
+ metadata: options.metadata,
340
+ });
341
+ }
342
+ }
343
+
344
+ async removeScheduledJob(name: string): Promise<boolean> {
345
+ if (!this.scheduler) {
346
+ return false;
347
+ }
348
+
349
+ return this.scheduler.removeJob(name);
350
+ }
351
+
352
+ async getScheduledJobs(): Promise<ScheduledJobInfo[]> {
353
+ if (!this.scheduler) {
354
+ return [];
355
+ }
356
+
357
+ return this.scheduler.getJobs();
358
+ }
359
+
360
+ // ============================================================================
361
+ // Features
362
+ // ============================================================================
363
+
364
+ supports(feature: QueueFeature): boolean {
365
+ switch (feature) {
366
+ case 'pattern-subscriptions':
367
+ case 'consumer-groups':
368
+ case 'scheduled-jobs':
369
+ return true;
370
+ case 'delayed-messages':
371
+ case 'priority':
372
+ case 'dead-letter-queue':
373
+ case 'retry':
374
+ return false;
375
+ default:
376
+ return false;
377
+ }
378
+ }
379
+
380
+ // ============================================================================
381
+ // Events
382
+ // ============================================================================
383
+
384
+ on<E extends keyof QueueEvents>(event: E, handler: NonNullable<QueueEvents[E]>): void {
385
+ if (!this.eventHandlers.has(event)) {
386
+ this.eventHandlers.set(event, new Set());
387
+ }
388
+ this.eventHandlers.get(event)!.add(handler as (...args: unknown[]) => void);
389
+ }
390
+
391
+ off<E extends keyof QueueEvents>(event: E, handler: NonNullable<QueueEvents[E]>): void {
392
+ const handlers = this.eventHandlers.get(event);
393
+ if (handlers) {
394
+ handlers.delete(handler as (...args: unknown[]) => void);
395
+ }
396
+ }
397
+
398
+ // ============================================================================
399
+ // Private Methods
400
+ // ============================================================================
401
+
402
+ private ensureConnected(): void {
403
+ if (!this.connected) {
404
+ throw new Error('NatsQueueAdapter not connected. Call connect() first.');
405
+ }
406
+ }
407
+
408
+ private generateMessageId(): string {
409
+ // eslint-disable-next-line no-magic-numbers
410
+ return `nats-${++this.messageIdCounter}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
411
+ }
412
+
413
+ private emit<E extends keyof QueueEvents>(event: E, ...args: unknown[]): void {
414
+ const handlers = this.eventHandlers.get(event);
415
+ if (handlers) {
416
+ for (const handler of handlers) {
417
+ try {
418
+ handler(...args);
419
+ } catch {
420
+ // Silently ignore event handler errors
421
+ }
422
+ }
423
+ }
424
+ }
425
+
426
+ private async processMessage(entry: NatsSubscriptionEntry, natsMsg: NatsMessage): Promise<void> {
427
+ try {
428
+ const messageData = JSON.parse(natsMsg.data);
429
+
430
+ // Check if pattern matches
431
+ const match = entry.matcher(messageData.pattern || natsMsg.subject);
432
+ if (!match.matched) {
433
+ return;
434
+ }
435
+
436
+ const message = new NatsQueueMessage(
437
+ messageData.id || this.generateMessageId(),
438
+ messageData.pattern || natsMsg.subject,
439
+ messageData.data,
440
+ messageData.timestamp || Date.now(),
441
+ messageData.metadata || {},
442
+ );
443
+
444
+ // Emit received event
445
+ this.emit('onMessageReceived', message);
446
+
447
+ try {
448
+ await entry.handler(message);
449
+
450
+ // Emit processed event
451
+ this.emit('onMessageProcessed', message);
452
+ } catch (error) {
453
+ // Emit failed event
454
+ this.emit('onMessageFailed', message, error as Error);
455
+ }
456
+ } catch {
457
+ // Silently ignore message parsing errors
458
+ }
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Create a NATS queue adapter
464
+ */
465
+ export function createNatsQueueAdapter(options: NatsAdapterOptions): NatsQueueAdapter {
466
+ return new NatsQueueAdapter(options);
467
+ }