@onebun/core 0.2.13 → 0.2.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onebun/core",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "Core package for OneBun framework - decorators, DI, modules, controllers",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -46,7 +46,7 @@
46
46
  "effect": "^3.13.10",
47
47
  "arktype": "^2.0.0",
48
48
  "@onebun/logger": "^0.2.1",
49
- "@onebun/envs": "^0.2.1",
49
+ "@onebun/envs": "^0.2.2",
50
50
  "@onebun/metrics": "^0.2.2",
51
51
  "@onebun/requests": "^0.2.1",
52
52
  "@onebun/trace": "^0.2.1"
@@ -246,7 +246,8 @@ function resolvePathUnderRoot(rootDir: string, relativePath: string): string | n
246
246
  /**
247
247
  * OneBun Application
248
248
  */
249
- export class OneBunApplication {
249
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
250
+ export class OneBunApplication<QA extends import('../queue/types').QueueAdapterConstructor<any> = import('../queue/types').QueueAdapterConstructor> {
250
251
  private rootModule: ModuleInstance | null = null;
251
252
  private server: ReturnType<typeof Bun.serve> | null = null;
252
253
  private options: ApplicationOptions;
@@ -272,7 +273,7 @@ export class OneBunApplication {
272
273
  */
273
274
  constructor(
274
275
  moduleClass: new (...args: unknown[]) => object,
275
- options?: Partial<ApplicationOptions>,
276
+ options?: Partial<ApplicationOptions<QA>>,
276
277
  ) {
277
278
  this.moduleClass = moduleClass;
278
279
 
@@ -37,6 +37,7 @@ import type {
37
37
  OnApplicationDestroy,
38
38
  } from './';
39
39
  import type { ExceptionFilter } from './exception-filters/exception-filters';
40
+ import type { QueueAdapter, QueueAdapterConstructor } from './queue/types';
40
41
  import type {
41
42
  SseEvent,
42
43
  SseGenerator,
@@ -44,6 +45,7 @@ import type {
44
45
  OneBunResponse,
45
46
  MiddlewareClass,
46
47
  OnModuleConfigure,
48
+ QueueApplicationOptions,
47
49
  } from './types';
48
50
  import type { HttpExecutionContext } from './types';
49
51
  import type { ServerWebSocket } from 'bun';
@@ -5706,3 +5708,96 @@ describe('docs/api/security.md', () => {
5706
5708
  expect(res.headers.get('Strict-Transport-Security')).toBeNull();
5707
5709
  });
5708
5710
  });
5711
+
5712
+ // ============================================================================
5713
+ // docs/api/queue.md — Type-safe queue adapter configuration
5714
+ // ============================================================================
5715
+ describe('docs/api/queue.md — type-safe adapter options', () => {
5716
+ // Minimal custom adapter for type-checking purposes
5717
+ interface CustomAdapterOptions {
5718
+ servers: string;
5719
+ streams?: Array<{ name: string; subjects: string[] }>;
5720
+ }
5721
+
5722
+ class CustomAdapter implements QueueAdapter {
5723
+ readonly name = 'custom';
5724
+ readonly type = 'jetstream' as const;
5725
+ constructor(private opts: CustomAdapterOptions) {}
5726
+ async connect() { /* noop */ }
5727
+ async disconnect() { /* noop */ }
5728
+ isConnected() {
5729
+ return true;
5730
+ }
5731
+ async publish() {
5732
+ return '';
5733
+ }
5734
+ async publishBatch() {
5735
+ return [];
5736
+ }
5737
+ async subscribe() {
5738
+ return {
5739
+ async unsubscribe() { /* noop */ },
5740
+ pause() { /* noop */ },
5741
+ resume() { /* noop */ },
5742
+ pattern: '',
5743
+ isActive: true,
5744
+ };
5745
+ }
5746
+ supports() {
5747
+ return false;
5748
+ }
5749
+ on() { /* noop */ }
5750
+ off() { /* noop */ }
5751
+ }
5752
+
5753
+ it('QueueApplicationOptions infers options type from adapter constructor', () => {
5754
+ // When adapter is a specific class, options should be typed as its constructor argument
5755
+ const queueOpts: QueueApplicationOptions<typeof CustomAdapter> = {
5756
+ adapter: CustomAdapter,
5757
+ options: {
5758
+ servers: 'nats://localhost:4222',
5759
+ streams: [{ name: 'EVENTS', subjects: ['events.>'] }],
5760
+ },
5761
+ };
5762
+
5763
+ expect(queueOpts.adapter).toBe(CustomAdapter);
5764
+ expect(queueOpts.options?.servers).toBe('nats://localhost:4222');
5765
+ });
5766
+
5767
+ it('QueueApplicationOptions works with string adapter type (backward compatibility)', () => {
5768
+ const queueOpts: QueueApplicationOptions = {
5769
+ adapter: 'memory',
5770
+ enabled: true,
5771
+ };
5772
+
5773
+ expect(queueOpts.adapter).toBe('memory');
5774
+ });
5775
+
5776
+ it('QueueAdapterConstructor is compatible with custom adapter classes', () => {
5777
+ // Verify that a custom adapter class satisfies QueueAdapterConstructor
5778
+ const ctor: QueueAdapterConstructor<CustomAdapterOptions> = CustomAdapter;
5779
+ const instance = new ctor({ servers: 'nats://localhost:4222' });
5780
+
5781
+ expect(instance.name).toBe('custom');
5782
+ expect(instance.type).toBe('jetstream');
5783
+ });
5784
+
5785
+ it('OneBunApplication accepts typed adapter options without type assertion', () => {
5786
+ // This is the main desired usage — no `as SomeOptions` needed
5787
+ @Module({ controllers: [] })
5788
+ class TestModule {}
5789
+
5790
+ const app = new OneBunApplication(TestModule, {
5791
+ loggerLayer: makeMockLoggerLayer(),
5792
+ queue: {
5793
+ adapter: CustomAdapter,
5794
+ options: {
5795
+ servers: 'nats://localhost:4222',
5796
+ streams: [{ name: 'EVENTS', subjects: ['events.>'] }],
5797
+ },
5798
+ },
5799
+ });
5800
+
5801
+ expect(app).toBeDefined();
5802
+ });
5803
+ });
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export {
3
3
  Env,
4
4
  type EnvSchema,
5
5
  EnvValidationError,
6
+ getConfig,
6
7
  type InferConfigType,
7
8
  type EnvVariableConfig,
8
9
  } from '@onebun/envs';
@@ -189,6 +189,48 @@ describe('queue-decorators', () => {
189
189
  expect(handlers[0].propertyKey).toBe('handleReady');
190
190
  });
191
191
 
192
+ it('should support multiple @OnQueueReady handlers in one class', () => {
193
+ class TestService {
194
+ @OnQueueReady()
195
+ handleReady1() {}
196
+
197
+ @OnQueueReady()
198
+ handleReady2() {}
199
+ }
200
+
201
+ const handlers = getLifecycleHandlers(TestService, 'ON_READY');
202
+ expect(handlers.length).toBe(2);
203
+ expect(handlers[0].propertyKey).toBe('handleReady1');
204
+ expect(handlers[1].propertyKey).toBe('handleReady2');
205
+ });
206
+
207
+ it('should return empty array when no @OnQueueReady handlers', () => {
208
+ class TestService {
209
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
210
+ handle() {}
211
+ }
212
+
213
+ const handlers = getLifecycleHandlers(TestService, 'ON_READY');
214
+ expect(handlers.length).toBe(0);
215
+ });
216
+
217
+ it('should not mix @OnQueueReady with other lifecycle metadata', () => {
218
+ class TestService {
219
+ @OnQueueReady()
220
+ handleReady() {}
221
+
222
+ @OnQueueError()
223
+ handleError(_error: Error) {}
224
+ }
225
+
226
+ const readyHandlers = getLifecycleHandlers(TestService, 'ON_READY');
227
+ const errorHandlers = getLifecycleHandlers(TestService, 'ON_ERROR');
228
+ expect(readyHandlers.length).toBe(1);
229
+ expect(readyHandlers[0].propertyKey).toBe('handleReady');
230
+ expect(errorHandlers.length).toBe(1);
231
+ expect(errorHandlers[0].propertyKey).toBe('handleError');
232
+ });
233
+
192
234
  it('should register @OnQueueError handler', () => {
193
235
  class TestService {
194
236
  @OnQueueError()
@@ -13,6 +13,7 @@ import {
13
13
  import type { Message } from './types';
14
14
 
15
15
  import { InMemoryQueueAdapter } from './adapters/memory.adapter';
16
+ import { OnQueueReady, Subscribe } from './decorators';
16
17
  import { QueueService } from './queue.service';
17
18
 
18
19
  describe('QueueService', () => {
@@ -341,6 +342,161 @@ describe('QueueService', () => {
341
342
  });
342
343
  });
343
344
 
345
+ describe('registerService lifecycle', () => {
346
+ test('should call @OnQueueReady handler when queue starts', async () => {
347
+ let readyCalled = false;
348
+
349
+ class TestService {
350
+ @OnQueueReady()
351
+ handleReady() {
352
+ readyCalled = true;
353
+ }
354
+ }
355
+
356
+ const instance = new TestService();
357
+ await service.registerService(instance, TestService);
358
+ expect(readyCalled).toBe(false);
359
+
360
+ await service.start();
361
+ expect(readyCalled).toBe(true);
362
+ });
363
+
364
+ test('should allow publishing from @OnQueueReady handler', async () => {
365
+ const received: Message[] = [];
366
+
367
+ class TestService {
368
+ @OnQueueReady()
369
+ handleReady() {
370
+ service.publish('init.ready', { status: 'ok' });
371
+ }
372
+
373
+ @Subscribe('init.ready')
374
+ handleInit(_message: Message) {}
375
+ }
376
+
377
+ const instance = new TestService();
378
+
379
+ // Subscribe before start so the handler is in place
380
+ await service.subscribe('init.ready', async (message) => {
381
+ received.push(message);
382
+ });
383
+
384
+ await service.registerService(instance, TestService);
385
+ await service.start();
386
+
387
+ expect(received.length).toBe(1);
388
+ expect(received[0].data).toEqual({ status: 'ok' });
389
+ });
390
+
391
+ test('should call multiple @OnQueueReady handlers in order', async () => {
392
+ const callOrder: string[] = [];
393
+
394
+ class TestService {
395
+ @OnQueueReady()
396
+ handleReady1() {
397
+ callOrder.push('first');
398
+ }
399
+
400
+ @OnQueueReady()
401
+ handleReady2() {
402
+ callOrder.push('second');
403
+ }
404
+ }
405
+
406
+ const instance = new TestService();
407
+ await service.registerService(instance, TestService);
408
+ await service.start();
409
+
410
+ expect(callOrder).toEqual(['first', 'second']);
411
+ });
412
+
413
+ test('should call @OnQueueReady with correct this binding', async () => {
414
+ let capturedValue: string | null = null as string | null;
415
+
416
+ class TestService {
417
+ readonly name = 'test-service';
418
+
419
+ @OnQueueReady()
420
+ handleReady() {
421
+ capturedValue = this.name;
422
+ }
423
+ }
424
+
425
+ const instance = new TestService();
426
+ await service.registerService(instance, TestService);
427
+ await service.start();
428
+
429
+ expect(capturedValue).toBe('test-service');
430
+ });
431
+
432
+ test('should call @OnQueueReady again after stop() and start()', async () => {
433
+ let readyCount = 0;
434
+
435
+ class TestService {
436
+ @OnQueueReady()
437
+ handleReady() {
438
+ readyCount++;
439
+ }
440
+ }
441
+
442
+ const instance = new TestService();
443
+ await service.registerService(instance, TestService);
444
+
445
+ await service.start();
446
+ expect(readyCount).toBe(1);
447
+
448
+ await service.stop();
449
+ await service.start();
450
+ expect(readyCount).toBe(2);
451
+ });
452
+
453
+ test('should call @OnQueueReady on adapter reconnect', async () => {
454
+ let readyCount = 0;
455
+
456
+ class TestService {
457
+ @OnQueueReady()
458
+ handleReady() {
459
+ readyCount++;
460
+ }
461
+ }
462
+
463
+ const instance = new TestService();
464
+ await service.registerService(instance, TestService);
465
+
466
+ await service.start();
467
+ expect(readyCount).toBe(1);
468
+
469
+ // Simulate reconnect without stopping the service
470
+ await adapter.disconnect();
471
+ await adapter.connect();
472
+ expect(readyCount).toBe(2);
473
+ });
474
+
475
+ test('should not call @OnQueueReady on adapter reconnect when service is stopped', async () => {
476
+ let readyCount = 0;
477
+
478
+ class TestService {
479
+ @OnQueueReady()
480
+ handleReady() {
481
+ readyCount++;
482
+ }
483
+ }
484
+
485
+ const instance = new TestService();
486
+ await service.registerService(instance, TestService);
487
+
488
+ await service.start();
489
+ expect(readyCount).toBe(1);
490
+
491
+ await service.stop();
492
+
493
+ // Reconnect while service is stopped — handler should NOT fire
494
+ await adapter.connect();
495
+ expect(readyCount).toBe(1);
496
+ });
497
+
498
+ });
499
+
344
500
  describe('error handling', () => {
345
501
  test('should handle subscription errors gracefully', async () => {
346
502
  await service.start();
@@ -56,6 +56,8 @@ export class QueueService {
56
56
  private subscriptions: Subscription[] = [];
57
57
  private started = false;
58
58
  private config: QueueConfig;
59
+ private onReadyHandlers: Array<() => void> = [];
60
+ private adapterOnReadyRegistered = false;
59
61
 
60
62
  constructor(config: QueueConfig) {
61
63
  this.config = config;
@@ -86,11 +88,28 @@ export class QueueService {
86
88
  await this.adapter.connect();
87
89
  }
88
90
 
91
+ // Register adapter-level onReady listener once for reconnection scenarios
92
+ if (!this.adapterOnReadyRegistered) {
93
+ this.adapter.on('onReady', () => {
94
+ if (this.started) {
95
+ for (const handler of this.onReadyHandlers) {
96
+ handler();
97
+ }
98
+ }
99
+ });
100
+ this.adapterOnReadyRegistered = true;
101
+ }
102
+
89
103
  if (this.scheduler) {
90
104
  this.scheduler.start();
91
105
  }
92
106
 
93
107
  this.started = true;
108
+
109
+ // Call @OnQueueReady handlers — queue is fully ready at this point
110
+ for (const handler of this.onReadyHandlers) {
111
+ handler();
112
+ }
94
113
  }
95
114
 
96
115
  /**
@@ -359,7 +378,7 @@ export class QueueService {
359
378
  const onReadyHandlers = getMetadata(QUEUE_METADATA.ON_READY, serviceClass) || [];
360
379
  for (const handler of onReadyHandlers) {
361
380
  const method = serviceInstance[handler.propertyKey].bind(serviceInstance);
362
- this.on('onReady', method);
381
+ this.onReadyHandlers.push(method);
363
382
  }
364
383
 
365
384
  const onErrorHandlers = getMetadata(QUEUE_METADATA.ON_ERROR, serviceClass) || [];
@@ -433,11 +433,12 @@ export interface QueueAdapter {
433
433
  export type BuiltInAdapterType = 'memory' | 'redis';
434
434
 
435
435
  /**
436
- * Queue adapter constructor
436
+ * Queue adapter constructor — generic over the options type.
437
+ * When used as a generic constraint (e.g. `QueueApplicationOptions<typeof MyAdapter>`),
438
+ * TypeScript infers the correct options type automatically.
437
439
  */
438
- export interface QueueAdapterConstructor {
439
- new (options?: unknown): QueueAdapter;
440
- }
440
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
441
+ export type QueueAdapterConstructor<TOptions = any> = new (options: TOptions) => QueueAdapter;
441
442
 
442
443
  /**
443
444
  * Queue configuration options
package/src/types.ts CHANGED
@@ -198,7 +198,8 @@ export interface TypedEnvSchema {
198
198
  /**
199
199
  * Application options
200
200
  */
201
- export interface ApplicationOptions {
201
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
202
+ export interface ApplicationOptions<QA extends QueueAdapterConstructor<any> = QueueAdapterConstructor> {
202
203
  /**
203
204
  * Application name (used for metrics and tracing labels)
204
205
  */
@@ -431,7 +432,7 @@ export interface ApplicationOptions {
431
432
  /**
432
433
  * Queue configuration
433
434
  */
434
- queue?: QueueApplicationOptions;
435
+ queue?: QueueApplicationOptions<QA>;
435
436
 
436
437
  /**
437
438
  * Static file serving: serve files from a directory for requests not matched by API routes.
@@ -544,15 +545,18 @@ export interface ApplicationOptions {
544
545
  export type QueueAdapterType = 'memory' | 'redis';
545
546
 
546
547
  /**
547
- * Queue configuration for OneBunApplication
548
+ * Queue configuration for OneBunApplication.
549
+ * Generic parameter `A` infers the adapter constructor type, so `options`
550
+ * is automatically typed to match the adapter's constructor argument.
548
551
  */
549
- export interface QueueApplicationOptions {
552
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
553
+ export interface QueueApplicationOptions<A extends QueueAdapterConstructor<any> = QueueAdapterConstructor> {
550
554
  /** Enable/disable queue (default: auto - enabled if handlers exist) */
551
555
  enabled?: boolean;
552
556
  /** Adapter type, or custom adapter constructor (e.g. for NATS JetStream) */
553
- adapter?: QueueAdapterType | QueueAdapterConstructor;
557
+ adapter?: QueueAdapterType | A;
554
558
  /** Options passed to the custom adapter constructor when adapter is a class */
555
- options?: unknown;
559
+ options?: A extends QueueAdapterConstructor<infer O> ? O : never;
556
560
  /** Redis-specific options (only used when adapter is 'redis') */
557
561
  redis?: {
558
562
  /** Use shared Redis provider instead of dedicated connection */