@onebun/core 0.2.11 → 0.2.12

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.11",
3
+ "version": "0.2.12",
4
4
  "description": "Core package for OneBun framework - decorators, DI, modules, controllers",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -32,6 +32,10 @@
32
32
  "src",
33
33
  "README.md"
34
34
  ],
35
+ "exports": {
36
+ ".": "./src/index.ts",
37
+ "./testing": "./src/testing/index.ts"
38
+ },
35
39
  "main": "src/index.ts",
36
40
  "module": "src/index.ts",
37
41
  "types": "src/index.ts",
@@ -43,7 +47,7 @@
43
47
  "arktype": "^2.0.0",
44
48
  "@onebun/logger": "^0.2.1",
45
49
  "@onebun/envs": "^0.2.1",
46
- "@onebun/metrics": "^0.2.1",
50
+ "@onebun/metrics": "^0.2.2",
47
51
  "@onebun/requests": "^0.2.1",
48
52
  "@onebun/trace": "^0.2.1"
49
53
  },
@@ -39,7 +39,7 @@ import {
39
39
  import { Controller as BaseController } from '../module/controller';
40
40
  import { BaseMiddleware } from '../module/middleware';
41
41
  import { clearGlobalServicesRegistry } from '../module/module';
42
- import { Service } from '../module/service';
42
+ import { BaseService, Service } from '../module/service';
43
43
  import { QueueService, QUEUE_NOT_ENABLED_ERROR_MESSAGE } from '../queue';
44
44
  import { CronExpression } from '../queue/cron-expression';
45
45
  import {
@@ -2706,6 +2706,43 @@ describe('OneBunApplication', () => {
2706
2706
  expect(bodyListWithSlash.result).toEqual({ tasks: ['t1', 't2'], status: 'all' });
2707
2707
  });
2708
2708
 
2709
+ test('should not produce double slash when @Controller() has no path prefix', async () => {
2710
+ @Controller()
2711
+ class NoPrefixController extends BaseController {
2712
+ @Get('/users')
2713
+ async listUsers(): Promise<OneBunResponse> {
2714
+ return this.success({ users: ['u1'] });
2715
+ }
2716
+
2717
+ @Get('/')
2718
+ async root(): Promise<OneBunResponse> {
2719
+ return this.success({ root: true });
2720
+ }
2721
+ }
2722
+
2723
+ @Module({ controllers: [NoPrefixController] })
2724
+ class TestModule {}
2725
+
2726
+ const app = createTestApp(TestModule);
2727
+ await app.start();
2728
+
2729
+ // Route should be /users, not //users
2730
+ const responseUsers = await (mockServer as /* eslint-disable-line @typescript-eslint/no-explicit-any */ any).fetchHandler(
2731
+ new Request('http://localhost:3000/users', { method: 'GET' }),
2732
+ );
2733
+ expect(responseUsers.status).toBe(200);
2734
+ const bodyUsers = await responseUsers.json();
2735
+ expect(bodyUsers.result).toEqual({ users: ['u1'] });
2736
+
2737
+ // Route should be /, not empty
2738
+ const responseRoot = await (mockServer as /* eslint-disable-line @typescript-eslint/no-explicit-any */ any).fetchHandler(
2739
+ new Request('http://localhost:3000/', { method: 'GET' }),
2740
+ );
2741
+ expect(responseRoot.status).toBe(200);
2742
+ const bodyRoot = await responseRoot.json();
2743
+ expect(bodyRoot.result).toEqual({ root: true });
2744
+ });
2745
+
2709
2746
  test('should normalize metrics route labels - trailing slash requests use same label as non-trailing', async () => {
2710
2747
  @Controller('/api')
2711
2748
  class ApiController extends BaseController {
@@ -4311,4 +4348,291 @@ describe('OneBunApplication', () => {
4311
4348
  await app.stop();
4312
4349
  });
4313
4350
  });
4351
+
4352
+ describe('queue handler execution', () => {
4353
+ afterEach(async () => {
4354
+ clearGlobalServicesRegistry();
4355
+ register.clear();
4356
+ });
4357
+
4358
+ test('@Interval handler fires immediately on start (auto-detected, no explicit queue config)', async () => {
4359
+ let callCount = 0;
4360
+ const intervalMs = 60000;
4361
+
4362
+ @Controller('/interval-fire')
4363
+ class IntervalFireController extends BaseController {
4364
+ @Interval(intervalMs, { pattern: 'test.interval' })
4365
+ getData() {
4366
+ callCount++;
4367
+
4368
+ return { ts: Date.now() };
4369
+ }
4370
+ }
4371
+
4372
+ @Module({ controllers: [IntervalFireController] })
4373
+ class IntervalFireModule {}
4374
+
4375
+ const app = createTestApp(IntervalFireModule, { port: 0 });
4376
+ await app.start();
4377
+
4378
+ // scheduler.startIntervalJob calls executeJob immediately (fire-and-forget async)
4379
+ await new Promise(r => setTimeout(r, 50));
4380
+
4381
+ expect(app.getQueueService()).not.toBeNull();
4382
+ expect(callCount).toBe(1);
4383
+
4384
+ await app.stop();
4385
+ });
4386
+
4387
+ test('@Interval handler fires with explicit queue: { enabled: true }', async () => {
4388
+ let callCount = 0;
4389
+ const intervalMs = 60000;
4390
+
4391
+ @Controller('/interval-explicit')
4392
+ class IntervalExplicitController extends BaseController {
4393
+ @Interval(intervalMs, { pattern: 'test.explicit' })
4394
+ getData() {
4395
+ callCount++;
4396
+
4397
+ return { ts: Date.now() };
4398
+ }
4399
+ }
4400
+
4401
+ @Module({ controllers: [IntervalExplicitController] })
4402
+ class IntervalExplicitModule {}
4403
+
4404
+ const app = createTestApp(IntervalExplicitModule, {
4405
+ port: 0,
4406
+ queue: { enabled: true },
4407
+ });
4408
+ await app.start();
4409
+
4410
+ await new Promise(r => setTimeout(r, 50));
4411
+
4412
+ expect(callCount).toBe(1);
4413
+
4414
+ await app.stop();
4415
+ });
4416
+
4417
+ test('@Interval handler fires with injected service dependency', async () => {
4418
+ let callCount = 0;
4419
+ const intervalMs = 60000;
4420
+
4421
+ @Service()
4422
+ class WorkerService extends BaseService {
4423
+ async doWork(): Promise<string> {
4424
+ return 'done';
4425
+ }
4426
+ }
4427
+
4428
+ @Controller('/interval-di')
4429
+ class IntervalDIController extends BaseController {
4430
+ constructor(private readonly worker: WorkerService) {
4431
+ super();
4432
+ }
4433
+
4434
+ @Interval(intervalMs, { pattern: 'test.di' })
4435
+ async getData(): Promise<Record<string, unknown>> {
4436
+ callCount++;
4437
+
4438
+ return { result: await this.worker.doWork() };
4439
+ }
4440
+ }
4441
+
4442
+ @Module({ controllers: [IntervalDIController], providers: [WorkerService] })
4443
+ class IntervalDIModule {}
4444
+
4445
+ const app = createTestApp(IntervalDIModule, { port: 0 });
4446
+ await app.start();
4447
+
4448
+ await new Promise(r => setTimeout(r, 50));
4449
+
4450
+ expect(callCount).toBe(1);
4451
+
4452
+ await app.stop();
4453
+ });
4454
+
4455
+ test('@Cron handler registers job in scheduler (auto-detected)', async () => {
4456
+ @Controller('/cron-register')
4457
+ class CronRegisterController extends BaseController {
4458
+ @Cron(CronExpression.EVERY_HOUR, { pattern: 'cron.hourly' })
4459
+ getData() {
4460
+ return { ts: Date.now() };
4461
+ }
4462
+ }
4463
+
4464
+ @Module({ controllers: [CronRegisterController] })
4465
+ class CronRegisterModule {}
4466
+
4467
+ const app = createTestApp(CronRegisterModule, { port: 0 });
4468
+ await app.start();
4469
+
4470
+ const scheduler = app.getQueueService()!.getScheduler();
4471
+ const jobs = scheduler.getJobs();
4472
+ expect(jobs.length).toBe(1);
4473
+ expect(jobs[0].pattern).toBe('cron.hourly');
4474
+
4475
+ await app.stop();
4476
+ });
4477
+
4478
+ test('@Timeout handler registers and fires once (auto-detected)', async () => {
4479
+ let callCount = 0;
4480
+ const timeoutMs = 10;
4481
+
4482
+ @Controller('/timeout-fire')
4483
+ class TimeoutFireController extends BaseController {
4484
+ @Timeout(timeoutMs, { pattern: 'test.timeout' })
4485
+ getData() {
4486
+ callCount++;
4487
+
4488
+ return { ts: Date.now() };
4489
+ }
4490
+ }
4491
+
4492
+ @Module({ controllers: [TimeoutFireController] })
4493
+ class TimeoutFireModule {}
4494
+
4495
+ const app = createTestApp(TimeoutFireModule, { port: 0 });
4496
+ await app.start();
4497
+
4498
+ // Wait for timeout to fire
4499
+ await new Promise(r => setTimeout(r, 100));
4500
+
4501
+ expect(callCount).toBe(1);
4502
+
4503
+ await app.stop();
4504
+ });
4505
+
4506
+ test('scheduled decorators in nested child module auto-init queue and fire', async () => {
4507
+ let callCount = 0;
4508
+ const intervalMs = 60000;
4509
+
4510
+ @Controller('/nested-interval')
4511
+ class NestedIntervalController extends BaseController {
4512
+ @Interval(intervalMs, { pattern: 'nested.interval' })
4513
+ getData() {
4514
+ callCount++;
4515
+
4516
+ return { ts: Date.now() };
4517
+ }
4518
+ }
4519
+
4520
+ @Module({ controllers: [NestedIntervalController] })
4521
+ class ChildModule {}
4522
+
4523
+ @Module({ imports: [ChildModule] })
4524
+ class ParentModule {}
4525
+
4526
+ const app = createTestApp(ParentModule, { port: 0 });
4527
+ await app.start();
4528
+
4529
+ await new Promise(r => setTimeout(r, 50));
4530
+
4531
+ expect(app.getQueueService()).not.toBeNull();
4532
+ expect(callCount).toBe(1);
4533
+
4534
+ await app.stop();
4535
+ });
4536
+
4537
+ test('scheduled decorators in deeply nested module auto-init queue and fire', async () => {
4538
+ let callCount = 0;
4539
+ const intervalMs = 60000;
4540
+
4541
+ @Controller('/deep-interval')
4542
+ class DeepIntervalController extends BaseController {
4543
+ @Interval(intervalMs, { pattern: 'deep.interval' })
4544
+ getData() {
4545
+ callCount++;
4546
+
4547
+ return { ts: Date.now() };
4548
+ }
4549
+ }
4550
+
4551
+ @Module({ controllers: [DeepIntervalController] })
4552
+ class GrandchildModule {}
4553
+
4554
+ @Module({ imports: [GrandchildModule] })
4555
+ class MiddleModule {}
4556
+
4557
+ @Module({ imports: [MiddleModule] })
4558
+ class TopModule {}
4559
+
4560
+ const app = createTestApp(TopModule, { port: 0 });
4561
+ await app.start();
4562
+
4563
+ await new Promise(r => setTimeout(r, 50));
4564
+
4565
+ expect(app.getQueueService()).not.toBeNull();
4566
+ expect(callCount).toBe(1);
4567
+
4568
+ await app.stop();
4569
+ });
4570
+
4571
+ test('handler error does not break scheduler', async () => {
4572
+ let errorCallCount = 0;
4573
+ let successCallCount = 0;
4574
+ const intervalMs = 60000;
4575
+
4576
+ @Controller('/error-handler')
4577
+ class ErrorHandlerController extends BaseController {
4578
+ @Interval(intervalMs, { pattern: 'error.handler' })
4579
+ getErrorData() {
4580
+ errorCallCount++;
4581
+ throw new Error('Handler failed');
4582
+ }
4583
+
4584
+ @Interval(intervalMs, { pattern: 'success.handler' })
4585
+ getSuccessData() {
4586
+ successCallCount++;
4587
+
4588
+ return { ok: true };
4589
+ }
4590
+ }
4591
+
4592
+ @Module({ controllers: [ErrorHandlerController] })
4593
+ class ErrorHandlerModule {}
4594
+
4595
+ const app = createTestApp(ErrorHandlerModule, { port: 0 });
4596
+ await app.start();
4597
+
4598
+ await new Promise(r => setTimeout(r, 50));
4599
+
4600
+ // Both handlers should have been called despite one throwing
4601
+ expect(errorCallCount).toBe(1);
4602
+ expect(successCallCount).toBe(1);
4603
+
4604
+ await app.stop();
4605
+ });
4606
+
4607
+ test('only scheduled decorators (no @Subscribe) implicitly use in-memory adapter', async () => {
4608
+ @Controller('/scheduled-only')
4609
+ class ScheduledOnlyController extends BaseController {
4610
+ @Interval(60000, { pattern: 'scheduled.only' })
4611
+ getData() {
4612
+ return { ts: Date.now() };
4613
+ }
4614
+
4615
+ @Cron(CronExpression.EVERY_HOUR, { pattern: 'scheduled.cron' })
4616
+ getCronData() {
4617
+ return { ts: Date.now() };
4618
+ }
4619
+ }
4620
+
4621
+ @Module({ controllers: [ScheduledOnlyController] })
4622
+ class ScheduledOnlyModule {}
4623
+
4624
+ // No queue config at all — should auto-detect and use in-memory
4625
+ const app = createTestApp(ScheduledOnlyModule, { port: 0 });
4626
+ await app.start();
4627
+
4628
+ const qs = app.getQueueService();
4629
+ expect(qs).not.toBeNull();
4630
+
4631
+ const scheduler = qs!.getScheduler();
4632
+ const jobs = scheduler.getJobs();
4633
+ expect(jobs.length).toBe(2);
4634
+
4635
+ await app.stop();
4636
+ });
4637
+ });
4314
4638
  });
@@ -1777,7 +1777,7 @@ export class OneBunApplication {
1777
1777
  return false;
1778
1778
  }
1779
1779
 
1780
- return hasQueueDecorators(instance.constructor);
1780
+ return hasQueueDecorators(controller) || hasQueueDecorators(instance.constructor);
1781
1781
  });
1782
1782
 
1783
1783
  // Determine if queue should be enabled
@@ -1840,24 +1840,41 @@ export class OneBunApplication {
1840
1840
  : queueOptions?.redis,
1841
1841
  };
1842
1842
  this.queueService = new QueueService(queueServiceConfig);
1843
-
1843
+
1844
1844
  // Initialize with the adapter
1845
1845
  await this.queueService.initialize(this.queueAdapter);
1846
1846
 
1847
+ // Wire scheduler error handler so failed jobs are logged
1848
+ this.queueService.getScheduler().setErrorHandler((jobName, error) => {
1849
+ this.logger.warn(`Scheduled job "${jobName}" failed: ${error instanceof Error ? error.message : String(error)}`);
1850
+ });
1851
+
1847
1852
  // Register handlers from controllers using registerService
1848
1853
  for (const controllerClass of controllers) {
1849
1854
  const instance = this.ensureModule().getControllerInstance?.(controllerClass);
1850
1855
  if (!instance) {
1856
+ this.logger.debug(`Queue: skipping controller ${controllerClass.name} (no instance found)`);
1851
1857
  continue;
1852
1858
  }
1853
-
1854
- // Only register if the controller has queue decorators
1855
- if (hasQueueDecorators(controllerClass)) {
1859
+
1860
+ // Check both controllerClass and instance.constructor for queue decorators
1861
+ // These should be identical, but if @Controller wrapping produces a different reference,
1862
+ // we check both to be safe
1863
+ const hasDecorators =
1864
+ hasQueueDecorators(controllerClass) || hasQueueDecorators(instance.constructor);
1865
+
1866
+ if (hasDecorators) {
1867
+ // Use whichever reference has the metadata for registration
1868
+ const registrationClass = hasQueueDecorators(controllerClass)
1869
+ ? controllerClass
1870
+ : instance.constructor;
1856
1871
  await this.queueService.registerService(
1857
1872
  instance,
1858
- controllerClass as new (...args: unknown[]) => unknown,
1873
+ registrationClass as new (...args: unknown[]) => unknown,
1859
1874
  );
1860
1875
  this.logger.debug(`Registered queue handlers for controller: ${controllerClass.name}`);
1876
+ } else {
1877
+ this.logger.debug(`Queue: controller ${controllerClass.name} has no queue decorators`);
1861
1878
  }
1862
1879
  }
1863
1880
 
@@ -104,7 +104,7 @@ describe('decorators', () => {
104
104
 
105
105
  const metadata = getControllerMetadata(TestController);
106
106
  expect(metadata).toBeDefined();
107
- expect(metadata?.path).toBe('/');
107
+ expect(metadata?.path).toBe('');
108
108
  expect(metadata?.routes).toEqual([]);
109
109
  });
110
110
 
@@ -156,7 +156,7 @@ export function controllerDecorator(basePath: string = '') {
156
156
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
157
157
  return <T extends new (...args: any[]) => any>(target: T): T => {
158
158
  const metadata: ControllerMetadata = {
159
- path: basePath.startsWith('/') ? basePath : `/${basePath}`,
159
+ path: basePath === '' ? '' : (basePath.startsWith('/') ? basePath : `/${basePath}`),
160
160
  routes: [],
161
161
  };
162
162
 
@@ -48,6 +48,8 @@ import type {
48
48
  import type { HttpExecutionContext } from './types';
49
49
  import type { ServerWebSocket } from 'bun';
50
50
 
51
+ import { makeMockLoggerLayer } from './testing';
52
+
51
53
  import {
52
54
  Controller,
53
55
  Get,
@@ -109,7 +111,6 @@ import {
109
111
  createWsClient,
110
112
  createNativeWsClient,
111
113
  matchPattern,
112
- makeMockLoggerLayer,
113
114
  hasOnModuleInit,
114
115
  hasOnApplicationInit,
115
116
  hasOnModuleDestroy,
package/src/index.ts CHANGED
@@ -130,8 +130,8 @@ export * from './queue';
130
130
  // Validation
131
131
  export * from './validation';
132
132
 
133
- // Testing Utilities
134
- export * from './testing';
133
+ // Testing Utilities are available via '@onebun/core/testing' subpath import
134
+ // to avoid requiring testcontainers as a mandatory dependency
135
135
 
136
136
  // HTTP Guards
137
137
  export * from './http-guards';
@@ -49,6 +49,7 @@ import {
49
49
  getIntervalMetadata,
50
50
  getTimeoutMetadata,
51
51
  hasQueueDecorators,
52
+ QueueScheduler,
52
53
  } from './index';
53
54
 
54
55
  /**
@@ -593,3 +594,92 @@ describe('Feature Support Matrix (docs/api/queue.md)', () => {
593
594
  expect(adapter.supports('retry')).toBe(false);
594
595
  });
595
596
  });
597
+
598
+ /**
599
+ * @source docs/api/queue.md#setup (scheduled-only tip)
600
+ */
601
+ describe('Scheduled-only Controllers (docs/api/queue.md)', () => {
602
+ it('should auto-detect queue decorators on controller with only @Interval', () => {
603
+ // From docs/api/queue.md: Scheduled-only Controllers tip
604
+ class ScheduledOnlyController {
605
+ @Interval(60000, { pattern: 'metrics.collect' })
606
+ getMetrics() {
607
+ return { cpu: process.cpuUsage() };
608
+ }
609
+ }
610
+
611
+ // No @Subscribe — only scheduling decorators
612
+ expect(hasQueueDecorators(ScheduledOnlyController)).toBe(true);
613
+ expect(getSubscribeMetadata(ScheduledOnlyController).length).toBe(0);
614
+ expect(getIntervalMetadata(ScheduledOnlyController).length).toBe(1);
615
+ });
616
+
617
+ it('should auto-detect queue decorators on controller with only @Cron', () => {
618
+ class CronOnlyController {
619
+ @Cron(CronExpression.EVERY_HOUR, { pattern: 'cleanup.expired' })
620
+ getCleanupData() {
621
+ return { timestamp: Date.now() };
622
+ }
623
+ }
624
+
625
+ expect(hasQueueDecorators(CronOnlyController)).toBe(true);
626
+ expect(getSubscribeMetadata(CronOnlyController).length).toBe(0);
627
+ expect(getCronMetadata(CronOnlyController).length).toBe(1);
628
+ });
629
+
630
+ it('should auto-detect queue decorators on controller with only @Timeout', () => {
631
+ class TimeoutOnlyController {
632
+ @Timeout(5000, { pattern: 'startup.warmup' })
633
+ getWarmupData() {
634
+ return { type: 'warmup' };
635
+ }
636
+ }
637
+
638
+ expect(hasQueueDecorators(TimeoutOnlyController)).toBe(true);
639
+ expect(getSubscribeMetadata(TimeoutOnlyController).length).toBe(0);
640
+ expect(getTimeoutMetadata(TimeoutOnlyController).length).toBe(1);
641
+ });
642
+ });
643
+
644
+ /**
645
+ * @source docs/api/queue.md#setup (error handling info)
646
+ */
647
+ describe('Scheduled Job Error Handling (docs/api/queue.md)', () => {
648
+ let adapter: InMemoryQueueAdapter;
649
+
650
+ beforeEach(async () => {
651
+ adapter = new InMemoryQueueAdapter();
652
+ await adapter.connect();
653
+ });
654
+
655
+ afterEach(async () => {
656
+ await adapter.disconnect();
657
+ });
658
+
659
+ it('should continue scheduler after handler error via setErrorHandler', async () => {
660
+ // From docs/api/queue.md: Scheduled Job Error Handling info
661
+ const scheduler = new QueueScheduler(adapter);
662
+
663
+ const errors: Array<{ name: string; error: unknown }> = [];
664
+ scheduler.setErrorHandler((name: string, error: unknown) => {
665
+ errors.push({ name, error });
666
+ });
667
+
668
+ // Add a job that will fail
669
+ scheduler.addIntervalJob('failing-job', 60000, 'test.fail', () => {
670
+ throw new Error('Job failed');
671
+ });
672
+
673
+ scheduler.start();
674
+
675
+ // executeJob is async fire-and-forget, wait for it
676
+ await new Promise(r => setTimeout(r, 50));
677
+
678
+ // Error handler should have been called (immediate execution)
679
+ expect(errors.length).toBe(1);
680
+ expect(errors[0].name).toBe('failing-job');
681
+ expect((errors[0].error as Error).message).toBe('Job failed');
682
+
683
+ scheduler.stop();
684
+ });
685
+ });
@@ -64,9 +64,17 @@ export class QueueScheduler {
64
64
  private running = false;
65
65
  private cronCheckInterval?: ReturnType<typeof setInterval>;
66
66
  private readonly cronCheckIntervalMs = 1000; // Check cron jobs every second
67
+ private onJobError?: (jobName: string, error: unknown) => void;
67
68
 
68
69
  constructor(private readonly adapter: QueueAdapter) {}
69
70
 
71
+ /**
72
+ * Set error handler for scheduled job failures
73
+ */
74
+ setErrorHandler(handler: (jobName: string, error: unknown) => void): void {
75
+ this.onJobError = handler;
76
+ }
77
+
70
78
  /**
71
79
  * Start the scheduler
72
80
  */
@@ -363,8 +371,11 @@ export class QueueScheduler {
363
371
  await this.adapter.publish(job.pattern, data, {
364
372
  metadata: job.metadata,
365
373
  });
366
- } catch {
367
- // Error executing job - silently continue (error handling should be done via events)
374
+ } catch (error) {
375
+ // Report error via handler if set, otherwise silently continue
376
+ if (this.onJobError) {
377
+ this.onJobError(job.name, error);
378
+ }
368
379
  } finally {
369
380
  job.isRunning = false;
370
381
  }
@@ -232,7 +232,7 @@ export const fakeTimers = new FakeTimers();
232
232
  *
233
233
  * @example
234
234
  * ```typescript
235
- * import { useFakeTimers } from '@onebun/core';
235
+ * import { useFakeTimers } from '@onebun/core/testing';
236
236
  *
237
237
  * describe('My tests', () => {
238
238
  * const { advanceTime, restore } = useFakeTimers();