@onebun/core 0.2.5 → 0.2.7

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.
@@ -24,7 +24,11 @@ import type {
24
24
  } from '../types';
25
25
 
26
26
 
27
- import { Controller as CtrlDeco, Module } from '../decorators/decorators';
27
+ import {
28
+ Controller as CtrlDeco,
29
+ Middleware,
30
+ Module,
31
+ } from '../decorators/decorators';
28
32
  import { makeMockLoggerLayer } from '../testing/test-utils';
29
33
  import { BaseWebSocketGateway } from '../websocket/ws-base-gateway';
30
34
  import { WebSocketGateway } from '../websocket/ws-decorators';
@@ -685,7 +689,7 @@ describe('OneBunModule', () => {
685
689
 
686
690
  describe('Controller DI with @Inject decorator', () => {
687
691
  const {
688
- Inject, getConstructorParamTypes, Controller: ControllerDecorator, clearGlobalModules,
692
+ Inject: InjectDecorator, getConstructorParamTypes, Controller: ControllerDecorator, clearGlobalModules,
689
693
  } = require('../decorators/decorators');
690
694
  const { Controller: BaseController } = require('./controller');
691
695
  const { clearGlobalServicesRegistry: clearRegistry } = require('./module');
@@ -716,7 +720,7 @@ describe('OneBunModule', () => {
716
720
 
717
721
  // Define controller with @Inject BEFORE @Controller
718
722
  class OriginalController extends BaseController {
719
- constructor(@Inject(SimpleService) private svc: SimpleService) {
723
+ constructor(@InjectDecorator(SimpleService) private svc: SimpleService) {
720
724
  super();
721
725
  }
722
726
  }
@@ -1189,6 +1193,55 @@ describe('OneBunModule', () => {
1189
1193
  });
1190
1194
  });
1191
1195
 
1196
+ describe('Middleware with service injection', () => {
1197
+ test('should resolve middleware with injected service and use it in use()', async () => {
1198
+ @Service()
1199
+ class HelperService {
1200
+ getValue(): string {
1201
+ return 'injected';
1202
+ }
1203
+ }
1204
+
1205
+ @Middleware()
1206
+ class MiddlewareWithService extends BaseMiddleware {
1207
+ constructor(private readonly helper: HelperService) {
1208
+ super();
1209
+ }
1210
+
1211
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>): Promise<OneBunResponse> {
1212
+ const res = await next();
1213
+ res.headers.set('X-Injected-Value', this.helper.getValue());
1214
+
1215
+ return res;
1216
+ }
1217
+ }
1218
+
1219
+ @CtrlDeco('/test')
1220
+ class TestCtrl extends CtrlBase {}
1221
+
1222
+ @Module({
1223
+ controllers: [TestCtrl],
1224
+ providers: [HelperService],
1225
+ })
1226
+ class TestModule {}
1227
+
1228
+ const module = new OneBunModule(TestModule, mockLoggerLayer);
1229
+ await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
1230
+
1231
+ const resolved = module.resolveMiddleware([MiddlewareWithService]);
1232
+ expect(resolved).toHaveLength(1);
1233
+
1234
+ const mockReq = Object.assign(new Request('http://localhost/'), {
1235
+ params: {},
1236
+ cookies: new Map(),
1237
+ }) as unknown as OneBunRequest;
1238
+ const next = async (): Promise<OneBunResponse> => new Response('ok');
1239
+ const response = await resolved[0](mockReq, next);
1240
+
1241
+ expect(response.headers.get('X-Injected-Value')).toBe('injected');
1242
+ });
1243
+ });
1244
+
1192
1245
  describe('Lifecycle hooks', () => {
1193
1246
  const { clearGlobalModules } = require('../decorators/decorators');
1194
1247
  const { clearGlobalServicesRegistry: clearRegistry, OneBunModule: ModuleClass } = require('./module');
@@ -1375,7 +1428,7 @@ describe('OneBunModule', () => {
1375
1428
  const {
1376
1429
  Controller: ControllerDecorator,
1377
1430
  Get,
1378
- Inject,
1431
+ Inject: InjectDecorator,
1379
1432
  clearGlobalModules,
1380
1433
  } = require('../decorators/decorators');
1381
1434
  const { Controller: BaseController } = require('./controller');
@@ -1404,7 +1457,7 @@ describe('OneBunModule', () => {
1404
1457
  }
1405
1458
 
1406
1459
  class CounterController extends BaseController {
1407
- constructor(@Inject(CounterService) private readonly counterService: CounterService) {
1460
+ constructor(@InjectDecorator(CounterService) private readonly counterService: CounterService) {
1408
1461
  super();
1409
1462
  }
1410
1463
  getCount() {
@@ -1439,7 +1492,7 @@ describe('OneBunModule', () => {
1439
1492
  }
1440
1493
 
1441
1494
  class ChildController extends BaseController {
1442
- constructor(@Inject(ChildService) private readonly childService: ChildService) {
1495
+ constructor(@InjectDecorator(ChildService) private readonly childService: ChildService) {
1443
1496
  super();
1444
1497
  }
1445
1498
  getValue() {
@@ -1486,7 +1539,7 @@ describe('OneBunModule', () => {
1486
1539
  class SharedModule {}
1487
1540
 
1488
1541
  class AppController extends BaseController {
1489
- constructor(@Inject(SharedService) private readonly sharedService: SharedService) {
1542
+ constructor(@InjectDecorator(SharedService) private readonly sharedService: SharedService) {
1490
1543
  super();
1491
1544
  }
1492
1545
  getLabel() {
@@ -22,6 +22,7 @@ import {
22
22
  isGlobalModule,
23
23
  registerControllerDependencies,
24
24
  } from '../decorators/decorators';
25
+ import { QueueService, QueueServiceTag } from '../queue';
25
26
  import { BaseWebSocketGateway } from '../websocket/ws-base-gateway';
26
27
  import { isWebSocketGateway } from '../websocket/ws-decorators';
27
28
 
@@ -616,6 +617,16 @@ export class OneBunModule implements ModuleInstance {
616
617
  * Resolve dependency by type (constructor function)
617
618
  */
618
619
  private resolveDependencyByType(type: Function): unknown {
620
+ // QueueService is registered by tag (QueueServiceTag) before setup(); resolve by tag
621
+ if (type === QueueService) {
622
+ const byTag = this.serviceInstances.get(
623
+ QueueServiceTag as Context.Tag<unknown, unknown>,
624
+ );
625
+ if (byTag !== undefined) {
626
+ return byTag;
627
+ }
628
+ }
629
+
619
630
  // Find service instance that matches the type
620
631
  const serviceInstance = Array.from(this.serviceInstances.values()).find((instance) => {
621
632
  if (!instance) {
@@ -974,6 +985,24 @@ export class OneBunModule implements ModuleInstance {
974
985
  return [];
975
986
  }
976
987
 
988
+ /**
989
+ * Get the module instance that owns the given controller.
990
+ * Used to resolve controller-level and route-level middleware with the owner module's DI.
991
+ */
992
+ getOwnerModuleForController(controllerClass: Function): ModuleInstance | undefined {
993
+ if (this.controllers.includes(controllerClass)) {
994
+ return this;
995
+ }
996
+ for (const childModule of this.childModules) {
997
+ const owner = childModule.getOwnerModuleForController(controllerClass);
998
+ if (owner) {
999
+ return owner;
1000
+ }
1001
+ }
1002
+
1003
+ return undefined;
1004
+ }
1005
+
977
1006
  /**
978
1007
  * Get all controller instances from this module and child modules (recursive).
979
1008
  */
@@ -1023,6 +1052,13 @@ export class OneBunModule implements ModuleInstance {
1023
1052
  return this.rootLayer;
1024
1053
  }
1025
1054
 
1055
+ /**
1056
+ * Register a service instance by tag (e.g. before setup() for application-provided services like QueueService proxy).
1057
+ */
1058
+ registerService<T>(tag: Context.Tag<unknown, T>, instance: T): void {
1059
+ this.serviceInstances.set(tag as Context.Tag<unknown, unknown>, instance);
1060
+ }
1061
+
1026
1062
  /**
1027
1063
  * Create a module from class
1028
1064
  * @param moduleClass - The module class
@@ -35,6 +35,7 @@ import {
35
35
  MessageAnyGuard,
36
36
  createMessageGuard,
37
37
  type Message,
38
+ type QueueAdapter,
38
39
  CronExpression,
39
40
  parseCronExpression,
40
41
  getNextRun,
@@ -113,9 +114,9 @@ describe('Setup Section Examples (docs/api/queue.md)', () => {
113
114
  * @source docs/api/queue.md#quick-start
114
115
  */
115
116
  describe('Quick Start Example (docs/api/queue.md)', () => {
116
- it('should define service with queue decorators', () => {
117
- // From docs/api/queue.md: Quick Start
118
- class OrderService {
117
+ it('should define controller with queue decorators', () => {
118
+ // From docs/api/queue.md: Quick Start (queue handlers must be in controllers)
119
+ class EventProcessor {
119
120
  @OnQueueReady()
120
121
  onReady() {
121
122
  // console.log('Queue connected');
@@ -134,19 +135,19 @@ describe('Quick Start Example (docs/api/queue.md)', () => {
134
135
  }
135
136
 
136
137
  // Verify decorators are registered
137
- expect(hasQueueDecorators(OrderService)).toBe(true);
138
+ expect(hasQueueDecorators(EventProcessor)).toBe(true);
138
139
 
139
- const subscriptions = getSubscribeMetadata(OrderService);
140
+ const subscriptions = getSubscribeMetadata(EventProcessor);
140
141
  expect(subscriptions.length).toBe(1);
141
142
  expect(subscriptions[0].pattern).toBe('orders.created');
142
143
 
143
- const cronJobs = getCronMetadata(OrderService);
144
+ const cronJobs = getCronMetadata(EventProcessor);
144
145
  expect(cronJobs.length).toBe(1);
145
146
  expect(cronJobs[0].expression).toBe(CronExpression.EVERY_HOUR);
146
147
  expect(cronJobs[0].options.pattern).toBe('cleanup.expired');
147
148
  });
148
149
 
149
- it('should define service with interval decorator', () => {
150
+ it('should define controller with interval decorator', () => {
150
151
  // From docs/api/queue.md: Quick Start - Interval example
151
152
  class EventProcessor {
152
153
  @Subscribe('orders.created')
@@ -389,9 +390,9 @@ describe('Message Guards Examples (docs/api/queue.md)', () => {
389
390
  * @source docs/api/queue.md#lifecycle-decorators
390
391
  */
391
392
  describe('Lifecycle Decorators Examples (docs/api/queue.md)', () => {
392
- it('should define lifecycle handlers', () => {
393
- // From docs/api/queue.md: Lifecycle Decorators section
394
- class EventService {
393
+ it('should define lifecycle handlers on controller', () => {
394
+ // From docs/api/queue.md: Lifecycle Decorators (handlers must be in controllers)
395
+ class EventProcessor {
395
396
  @OnQueueReady()
396
397
  handleReady() {
397
398
  // console.log('Queue connected');
@@ -418,8 +419,67 @@ describe('Lifecycle Decorators Examples (docs/api/queue.md)', () => {
418
419
  }
419
420
  }
420
421
 
421
- // Class should be defined without errors
422
- expect(EventService).toBeDefined();
422
+ // Class with lifecycle handlers only; hasQueueDecorators checks Subscribe/Cron/Interval/Timeout only
423
+ expect(EventProcessor).toBeDefined();
424
+ });
425
+ });
426
+
427
+ /**
428
+ * @source docs/api/queue.md#custom-adapter-nats-jetstream
429
+ */
430
+ describe('Custom adapter NATS JetStream (docs/api/queue.md)', () => {
431
+ it('should use custom adapter constructor with options', async () => {
432
+ // From docs/api/queue.md: Custom adapter: NATS JetStream
433
+ // Minimal adapter class that implements QueueAdapter for use with queue: { adapter, options }
434
+ /* eslint-disable @typescript-eslint/no-empty-function */
435
+ class NatsJetStreamAdapter implements QueueAdapter {
436
+ readonly name = 'nats-jetstream';
437
+ readonly type = 'jetstream';
438
+ constructor(private opts: { servers: string; stream?: string }) {}
439
+ async connect(): Promise<void> {}
440
+ async disconnect(): Promise<void> {}
441
+ isConnected(): boolean {
442
+ return true;
443
+ }
444
+ async publish(): Promise<string> {
445
+ return '';
446
+ }
447
+ async publishBatch(): Promise<string[]> {
448
+ return [];
449
+ }
450
+ async subscribe(): Promise<import('./types').Subscription> {
451
+ return {
452
+ async unsubscribe() {},
453
+ pause() {},
454
+ resume() {},
455
+ pattern: '',
456
+ isActive: true,
457
+ };
458
+ }
459
+ async addScheduledJob(): Promise<void> {}
460
+ async removeScheduledJob(): Promise<boolean> {
461
+ return false;
462
+ }
463
+ async getScheduledJobs(): Promise<import('./types').ScheduledJobInfo[]> {
464
+ return [];
465
+ }
466
+ supports(): boolean {
467
+ return false;
468
+ }
469
+ on(): void {}
470
+ off(): void {}
471
+ }
472
+ /* eslint-enable @typescript-eslint/no-empty-function */
473
+
474
+ const adapter = new NatsJetStreamAdapter({
475
+ servers: 'nats://localhost:4222',
476
+ stream: 'EVENTS',
477
+ });
478
+ await adapter.connect();
479
+ expect(adapter.name).toBe('nats-jetstream');
480
+ expect(adapter.type).toBe('jetstream');
481
+ expect(adapter.isConnected()).toBe(true);
482
+ await adapter.disconnect();
423
483
  });
424
484
  });
425
485
 
@@ -113,6 +113,10 @@ export {
113
113
  createQueueService,
114
114
  resolveAdapterType,
115
115
  } from './queue.service';
116
+ export {
117
+ QueueServiceProxy,
118
+ QUEUE_NOT_ENABLED_ERROR_MESSAGE,
119
+ } from './queue-service-proxy';
116
120
 
117
121
  // Adapters (re-export from adapters folder)
118
122
  export * from './adapters';
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Tests for QueueServiceProxy
3
+ */
4
+
5
+ import {
6
+ describe,
7
+ test,
8
+ expect,
9
+ } from 'bun:test';
10
+
11
+
12
+ import { InMemoryQueueAdapter } from './adapters/memory.adapter';
13
+ import { QueueServiceProxy, QUEUE_NOT_ENABLED_ERROR_MESSAGE } from './queue-service-proxy';
14
+ import { QueueService } from './queue.service';
15
+
16
+ describe('QueueServiceProxy', () => {
17
+ test('throws with expected message when delegate is null', async () => {
18
+ const proxy = new QueueServiceProxy();
19
+ expect(() => proxy.getAdapter()).toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
20
+ expect(() => proxy.getScheduler()).toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
21
+ await expect(proxy.publish('e', {})).rejects.toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
22
+ await expect(proxy.publishBatch([{ pattern: 'e', data: {} }])).rejects.toThrow(
23
+ QUEUE_NOT_ENABLED_ERROR_MESSAGE,
24
+ );
25
+ await expect(
26
+ proxy.subscribe('e', async () => {
27
+ /* no-op for throw test */
28
+ }),
29
+ ).rejects.toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
30
+ await expect(proxy.addScheduledJob('j', { pattern: 'e', schedule: { every: 1000 } })).rejects.toThrow(
31
+ QUEUE_NOT_ENABLED_ERROR_MESSAGE,
32
+ );
33
+ await expect(proxy.removeScheduledJob('j')).rejects.toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
34
+ await expect(proxy.getScheduledJobs()).rejects.toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
35
+ expect(() => proxy.supports('pattern-subscriptions')).toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
36
+ expect(() =>
37
+ proxy.on('onReady', () => {
38
+ /* no-op */
39
+ }),
40
+ ).toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
41
+ expect(() =>
42
+ proxy.off('onReady', () => {
43
+ /* no-op */
44
+ }),
45
+ ).toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
46
+ });
47
+
48
+ test('delegates to real QueueService after setDelegate', async () => {
49
+ const adapter = new InMemoryQueueAdapter();
50
+ const real = new QueueService({ adapter: 'memory' as const });
51
+ await real.initialize(adapter);
52
+ await real.start();
53
+
54
+ const proxy = new QueueServiceProxy();
55
+ proxy.setDelegate(real);
56
+
57
+ expect(proxy.getAdapter()).toBe(adapter);
58
+ expect(proxy.getScheduler()).toBe(real.getScheduler());
59
+ expect(proxy.supports('pattern-subscriptions')).toBe(true);
60
+
61
+ const ids = await proxy.publish('test.event', { x: 1 });
62
+ expect(ids).toBeDefined();
63
+ expect(typeof ids).toBe('string');
64
+
65
+ const received: unknown[] = [];
66
+ const sub = await proxy.subscribe('test.event', async (msg) => {
67
+ received.push(msg.data);
68
+ });
69
+ expect(sub.isActive).toBe(true);
70
+
71
+ await proxy.publish('test.event', { y: 2 });
72
+ // Allow microtask for handler
73
+ await Promise.resolve();
74
+ await new Promise((r) => setTimeout(r, 10));
75
+ expect(received.length).toBe(1);
76
+ expect(received[0]).toEqual({ y: 2 });
77
+
78
+ await real.stop();
79
+ proxy.setDelegate(null);
80
+ expect(() => proxy.getAdapter()).toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
81
+ });
82
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * QueueServiceProxy — placeholder for DI when the real QueueService is created after setup().
3
+ * When the queue is enabled, the application sets the real QueueService via setDelegate().
4
+ * When the queue is not enabled, any method call throws a clear error.
5
+ */
6
+
7
+ import type { QueueService } from './queue.service';
8
+ import type { QueueScheduler } from './scheduler';
9
+ import type { QueueAdapter } from './types';
10
+ import type {
11
+ MessageHandler,
12
+ PublishOptions,
13
+ QueueEvents,
14
+ ScheduledJobInfo,
15
+ ScheduledJobOptions,
16
+ SubscribeOptions,
17
+ Subscription,
18
+ QueueFeature,
19
+ } from './types';
20
+
21
+ const QUEUE_NOT_ENABLED_MESSAGE =
22
+ 'Queue is not enabled. Enable it via `queue.enabled: true` in application options or register at least one controller with queue decorators (@Subscribe, @Cron, @Interval, @Timeout).';
23
+
24
+ function throwIfNoDelegate(delegate: QueueService | null): asserts delegate is QueueService {
25
+ if (delegate === null) {
26
+ throw new Error(QUEUE_NOT_ENABLED_MESSAGE);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Proxy for QueueService used in DI before the real service is created.
32
+ * After initializeQueue(), the application calls setDelegate(realQueueService) when the queue is enabled.
33
+ */
34
+ export class QueueServiceProxy {
35
+ private delegate: QueueService | null = null;
36
+
37
+ setDelegate(service: QueueService | null): void {
38
+ this.delegate = service;
39
+ }
40
+
41
+ getAdapter(): QueueAdapter {
42
+ throwIfNoDelegate(this.delegate);
43
+
44
+ return this.delegate.getAdapter();
45
+ }
46
+
47
+ getScheduler(): QueueScheduler {
48
+ throwIfNoDelegate(this.delegate);
49
+
50
+ return this.delegate.getScheduler();
51
+ }
52
+
53
+ async publish<T>(pattern: string, data: T, options?: PublishOptions): Promise<string> {
54
+ throwIfNoDelegate(this.delegate);
55
+
56
+ return await this.delegate.publish(pattern, data, options);
57
+ }
58
+
59
+ async publishBatch<T>(
60
+ messages: Array<{ pattern: string; data: T; options?: PublishOptions }>,
61
+ ): Promise<string[]> {
62
+ throwIfNoDelegate(this.delegate);
63
+
64
+ return await this.delegate.publishBatch(messages);
65
+ }
66
+
67
+ async subscribe<T>(
68
+ pattern: string,
69
+ handler: MessageHandler<T>,
70
+ options?: SubscribeOptions,
71
+ ): Promise<Subscription> {
72
+ throwIfNoDelegate(this.delegate);
73
+
74
+ return await this.delegate.subscribe(pattern, handler, options);
75
+ }
76
+
77
+ async addScheduledJob(name: string, options: ScheduledJobOptions): Promise<void> {
78
+ throwIfNoDelegate(this.delegate);
79
+
80
+ return await this.delegate.addScheduledJob(name, options);
81
+ }
82
+
83
+ async removeScheduledJob(name: string): Promise<boolean> {
84
+ throwIfNoDelegate(this.delegate);
85
+
86
+ return await this.delegate.removeScheduledJob(name);
87
+ }
88
+
89
+ async getScheduledJobs(): Promise<ScheduledJobInfo[]> {
90
+ throwIfNoDelegate(this.delegate);
91
+
92
+ return await this.delegate.getScheduledJobs();
93
+ }
94
+
95
+ supports(feature: QueueFeature): boolean {
96
+ throwIfNoDelegate(this.delegate);
97
+
98
+ return this.delegate.supports(feature);
99
+ }
100
+
101
+ on<E extends keyof QueueEvents>(event: E, handler: NonNullable<QueueEvents[E]>): void {
102
+ throwIfNoDelegate(this.delegate);
103
+
104
+ return this.delegate.on(event, handler);
105
+ }
106
+
107
+ off<E extends keyof QueueEvents>(event: E, handler: NonNullable<QueueEvents[E]>): void {
108
+ throwIfNoDelegate(this.delegate);
109
+
110
+ return this.delegate.off(event, handler);
111
+ }
112
+ }
113
+
114
+ export const QUEUE_NOT_ENABLED_ERROR_MESSAGE = QUEUE_NOT_ENABLED_MESSAGE;
@@ -395,8 +395,8 @@ export interface QueueAdapterConstructor {
395
395
  * Queue configuration options
396
396
  */
397
397
  export interface QueueConfig {
398
- /** Adapter type or custom adapter class */
399
- adapter: BuiltInAdapterType | QueueAdapterConstructor;
398
+ /** Adapter type (built-in or custom, e.g. 'jetstream') or custom adapter class */
399
+ adapter: QueueAdapterType | QueueAdapterConstructor;
400
400
 
401
401
  /** Adapter-specific options */
402
402
  options?: Record<string, unknown>;
package/src/types.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import type { BaseMiddleware } from './module/middleware';
2
+ import type { QueueAdapterConstructor } from './queue/types';
2
3
  import type { Type } from 'arktype';
3
- import type { Effect, Layer } from 'effect';
4
+ import type {
5
+ Context,
6
+ Effect,
7
+ Layer,
8
+ } from 'effect';
4
9
 
5
10
  import type { Logger, LoggerOptions } from '@onebun/logger';
6
11
 
12
+
7
13
  /**
8
14
  * HTTP Request type used in OneBun controllers.
9
15
  * Extends standard Web API Request with:
@@ -163,11 +169,23 @@ export interface ModuleInstance {
163
169
  */
164
170
  getModuleMiddleware?(controllerClass: Function): Function[];
165
171
 
172
+ /**
173
+ * Get the module instance that owns the given controller (the module in whose
174
+ * `controllers` array the controller is declared). Returns this module or a
175
+ * child module, or undefined if the controller is not in this module tree.
176
+ */
177
+ getOwnerModuleForController?(controllerClass: Function): ModuleInstance | undefined;
178
+
166
179
  /**
167
180
  * Resolve middleware class constructors into bound `use()` functions
168
181
  * using this module's DI scope (services + logger + config).
169
182
  */
170
183
  resolveMiddleware?(classes: Function[]): Function[];
184
+
185
+ /**
186
+ * Register a service instance by tag (e.g. before setup() for application-provided services like QueueService proxy).
187
+ */
188
+ registerService?<T>(tag: Context.Tag<unknown, T>, instance: T): void;
171
189
  }
172
190
 
173
191
  /**
@@ -415,6 +433,12 @@ export interface ApplicationOptions {
415
433
  */
416
434
  queue?: QueueApplicationOptions;
417
435
 
436
+ /**
437
+ * Static file serving: serve files from a directory for requests not matched by API routes.
438
+ * Optional path prefix and fallback file (e.g. index.html) for SPA-style routing.
439
+ */
440
+ static?: StaticApplicationOptions;
441
+
418
442
  /**
419
443
  * Documentation configuration (OpenAPI/Swagger)
420
444
  */
@@ -460,8 +484,10 @@ export type QueueAdapterType = 'memory' | 'redis';
460
484
  export interface QueueApplicationOptions {
461
485
  /** Enable/disable queue (default: auto - enabled if handlers exist) */
462
486
  enabled?: boolean;
463
- /** Adapter type or custom adapter instance */
464
- adapter?: QueueAdapterType;
487
+ /** Adapter type, or custom adapter constructor (e.g. for NATS JetStream) */
488
+ adapter?: QueueAdapterType | QueueAdapterConstructor;
489
+ /** Options passed to the custom adapter constructor when adapter is a class */
490
+ options?: unknown;
465
491
  /** Redis-specific options (only used when adapter is 'redis') */
466
492
  redis?: {
467
493
  /** Use shared Redis provider instead of dedicated connection */
@@ -521,6 +547,39 @@ export interface WebSocketApplicationOptions {
521
547
  maxPayload?: number;
522
548
  }
523
549
 
550
+ /**
551
+ * Static file serving configuration for OneBunApplication.
552
+ * Serves files from a directory for requests not matched by API/ws/docs/metrics routes.
553
+ */
554
+ export interface StaticApplicationOptions {
555
+ /**
556
+ * Filesystem path to the directory to serve (static root).
557
+ * Can be absolute or relative to the process cwd.
558
+ */
559
+ root: string;
560
+
561
+ /**
562
+ * URL path prefix under which static files are served.
563
+ * Empty or '/' means "everything not matched by API routes".
564
+ * Example: '/app' — only paths starting with /app are served from static root
565
+ * (the prefix is stripped when resolving the file path).
566
+ */
567
+ pathPrefix?: string;
568
+
569
+ /**
570
+ * Fallback file name (e.g. 'index.html') for SPA-style client-side routing.
571
+ * When the requested file is not found, this file under static root is returned instead.
572
+ */
573
+ fallbackFile?: string;
574
+
575
+ /**
576
+ * TTL in milliseconds for caching file existence checks.
577
+ * Reduces disk I/O for repeated requests. Use 0 to disable caching.
578
+ * @defaultValue 60000
579
+ */
580
+ fileExistenceCacheTtlMs?: number;
581
+ }
582
+
524
583
  /**
525
584
  * Documentation configuration for OneBunApplication
526
585
  * Enables automatic OpenAPI spec generation and Swagger UI