@onebun/core 0.2.6 → 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.
@@ -1,4 +1,10 @@
1
- import { Effect, type Layer } from 'effect';
1
+ import path from 'node:path';
2
+
3
+ import {
4
+ type Context,
5
+ Effect,
6
+ type Layer,
7
+ } from 'effect';
2
8
 
3
9
  import type { Controller } from '../module/controller';
4
10
  import type { WsClientData } from '../websocket/ws.types';
@@ -45,7 +51,13 @@ import {
45
51
  DEFAULT_SSE_TIMEOUT,
46
52
  } from '../module/controller';
47
53
  import { OneBunModule } from '../module/module';
48
- import { QueueService, type QueueAdapter } from '../queue';
54
+ import {
55
+ QueueService,
56
+ QueueServiceProxy,
57
+ QueueServiceTag,
58
+ type QueueAdapter,
59
+ type QueueConfig,
60
+ } from '../queue';
49
61
  import { InMemoryQueueAdapter } from '../queue/adapters/memory.adapter';
50
62
  import { RedisQueueAdapter } from '../queue/adapters/redis.adapter';
51
63
  import { hasQueueDecorators } from '../queue/decorators';
@@ -88,6 +100,17 @@ try {
88
100
  // Docs module not available - this is optional
89
101
  }
90
102
 
103
+ // Optional CacheService for static file existence caching
104
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
105
+ let cacheServiceClass: new (...args: unknown[]) => any = null as any;
106
+ try {
107
+ // eslint-disable-next-line import/no-extraneous-dependencies
108
+ const cacheModule = require('@onebun/cache');
109
+ cacheServiceClass = cacheModule.CacheService;
110
+ } catch {
111
+ // @onebun/cache not available - use in-memory Map fallback
112
+ }
113
+
91
114
  // Import tracing modules directly
92
115
 
93
116
  // Global trace context for current request
@@ -113,12 +136,12 @@ function clearGlobalTraceContext(): void {
113
136
  * normalizePath('/') // => '/'
114
137
  * normalizePath('/api/v1/') // => '/api/v1'
115
138
  */
116
- function normalizePath(path: string): string {
117
- if (path === '/' || path.length <= 1) {
118
- return path;
139
+ function normalizePath(pathStr: string): string {
140
+ if (pathStr === '/' || pathStr.length <= 1) {
141
+ return pathStr;
119
142
  }
120
143
 
121
- return path.endsWith('/') ? path.slice(0, -1) : path;
144
+ return pathStr.endsWith('/') ? pathStr.slice(0, -1) : pathStr;
122
145
  }
123
146
 
124
147
  /**
@@ -191,6 +214,29 @@ function resolveHost(explicitHost: string | undefined): string {
191
214
  return '0.0.0.0';
192
215
  }
193
216
 
217
+ /** Default TTL for static file existence cache (ms) when not specified */
218
+ const DEFAULT_STATIC_FILE_EXISTENCE_CACHE_TTL_MS = 60_000;
219
+
220
+ /** Cache key prefix for static file existence in CacheService */
221
+ const STATIC_EXISTS_CACHE_PREFIX = 'onebun:static:exists:';
222
+
223
+ /**
224
+ * Resolve a relative path under a root directory and ensure the result stays inside the root (path traversal protection).
225
+ * @param rootDir - Absolute path to the static root directory
226
+ * @param relativePath - URL path segment (e.g. from request path after prefix); must not contain '..' that escapes root
227
+ * @returns Absolute path if under root, otherwise null
228
+ */
229
+ function resolvePathUnderRoot(rootDir: string, relativePath: string): string | null {
230
+ const normalized = path.join(rootDir, relativePath.startsWith('/') ? relativePath.slice(1) : relativePath);
231
+ const resolved = path.resolve(normalized);
232
+ const rootResolved = path.resolve(rootDir);
233
+ if (resolved === rootResolved || resolved.startsWith(rootResolved + path.sep)) {
234
+ return resolved;
235
+ }
236
+
237
+ return null;
238
+ }
239
+
194
240
  /**
195
241
  * OneBun Application
196
242
  */
@@ -210,6 +256,7 @@ export class OneBunApplication {
210
256
  private wsHandler: WsHandler | null = null;
211
257
  private queueService: QueueService | null = null;
212
258
  private queueAdapter: QueueAdapter | null = null;
259
+ private queueServiceProxy: QueueServiceProxy | null = null;
213
260
  // Docs (OpenAPI/Swagger) - generated on start()
214
261
  private openApiSpec: Record<string, unknown> | null = null;
215
262
  private swaggerHtml: string | null = null;
@@ -364,11 +411,11 @@ export class OneBunApplication {
364
411
  * const port = app.getConfigValue('server.port'); // number
365
412
  * const host = app.getConfigValue('server.host'); // string
366
413
  */
367
- getConfigValue<P extends DeepPaths<OneBunAppConfig>>(path: P): DeepValue<OneBunAppConfig, P>;
414
+ getConfigValue<P extends DeepPaths<OneBunAppConfig>>(pathKey: P): DeepValue<OneBunAppConfig, P>;
368
415
  /** Fallback for dynamic paths */
369
- getConfigValue<T = unknown>(path: string): T;
370
- getConfigValue(path: string): unknown {
371
- return this.getConfig().get(path);
416
+ getConfigValue<T = unknown>(pathKey: string): T;
417
+ getConfigValue(pathKey: string): unknown {
418
+ return this.getConfig().get(pathKey);
372
419
  }
373
420
 
374
421
  /**
@@ -441,6 +488,16 @@ export class OneBunApplication {
441
488
  // so services can safely use this.config.get() in their constructors
442
489
  this.rootModule = OneBunModule.create(this.moduleClass, this.loggerLayer, this.config);
443
490
 
491
+ // Register QueueService proxy in DI so controllers/providers/middleware can inject QueueService.
492
+ // After initializeQueue(), setDelegate(real) is called when queue is enabled.
493
+ this.queueServiceProxy = new QueueServiceProxy();
494
+ if (this.ensureModule().registerService) {
495
+ this.ensureModule().registerService!(
496
+ QueueServiceTag as Context.Tag<unknown, QueueService>,
497
+ this.queueServiceProxy as unknown as QueueService,
498
+ );
499
+ }
500
+
444
501
  // Start metrics collection if enabled
445
502
  if (this.metricsService && this.metricsService.startSystemMetricsCollection) {
446
503
  this.metricsService.startSystemMetricsCollection();
@@ -901,6 +958,69 @@ export class OneBunApplication {
901
958
 
902
959
  drain() { /* no-op */ },
903
960
  };
961
+
962
+ // Static file serving: resolve root and setup existence cache (CacheService or in-memory Map)
963
+ const staticOpts = this.options.static;
964
+ let staticRootResolved: string | null = null;
965
+ let staticPathPrefix: string | undefined;
966
+ let staticFallbackFile: string | undefined;
967
+ let staticCacheTtlMs = 0;
968
+ // Cache interface: get(key) => value | undefined, set(key, value, ttlMs)
969
+ type StaticExistsCache = {
970
+ get(key: string): Promise<boolean | undefined>;
971
+ set(key: string, value: boolean, ttlMs: number): Promise<void>;
972
+ };
973
+ let staticExistsCache: StaticExistsCache | null = null;
974
+
975
+ if (staticOpts?.root) {
976
+ staticRootResolved = path.resolve(staticOpts.root);
977
+ staticPathPrefix = staticOpts.pathPrefix;
978
+ staticFallbackFile = staticOpts.fallbackFile;
979
+ staticCacheTtlMs = staticOpts.fileExistenceCacheTtlMs ?? DEFAULT_STATIC_FILE_EXISTENCE_CACHE_TTL_MS;
980
+
981
+ if (staticCacheTtlMs > 0) {
982
+ if (cacheServiceClass) {
983
+ try {
984
+ const cacheService = this.ensureModule().getServiceByClass?.(cacheServiceClass);
985
+ if (cacheService && typeof cacheService.get === 'function' && typeof cacheService.set === 'function') {
986
+ staticExistsCache = {
987
+ get: (key: string) => cacheService.get(STATIC_EXISTS_CACHE_PREFIX + key),
988
+ set: (key: string, value: boolean, ttlMs: number) =>
989
+ cacheService.set(STATIC_EXISTS_CACHE_PREFIX + key, value, { ttl: ttlMs }),
990
+ };
991
+ this.logger.debug('Static file existence cache using CacheService');
992
+ }
993
+ } catch {
994
+ // CacheService not in module, use fallback
995
+ }
996
+ }
997
+ if (!staticExistsCache) {
998
+ const map = new Map<string, { exists: boolean; expiresAt: number }>();
999
+ staticExistsCache = {
1000
+ async get(key: string): Promise<boolean | undefined> {
1001
+ const entry = map.get(key);
1002
+ if (!entry) {
1003
+ return undefined;
1004
+ }
1005
+ if (staticCacheTtlMs > 0 && Date.now() > entry.expiresAt) {
1006
+ map.delete(key);
1007
+
1008
+ return undefined;
1009
+ }
1010
+
1011
+ return entry.exists;
1012
+ },
1013
+ async set(key: string, value: boolean, ttlMs: number): Promise<void> {
1014
+ map.set(key, {
1015
+ exists: value,
1016
+ expiresAt: ttlMs > 0 ? Date.now() + ttlMs : Number.MAX_SAFE_INTEGER,
1017
+ });
1018
+ },
1019
+ };
1020
+ this.logger.debug('Static file existence cache using in-memory Map');
1021
+ }
1022
+ }
1023
+ }
904
1024
 
905
1025
  this.server = Bun.serve<WsClientData>({
906
1026
  port: this.options.port,
@@ -920,8 +1040,8 @@ export class OneBunApplication {
920
1040
  const socketioPath = app.options.websocket?.socketio?.path ?? '/socket.io';
921
1041
 
922
1042
  const url = new URL(req.url);
923
- const path = normalizePath(url.pathname);
924
- const isSocketIoPath = socketioEnabled && path.startsWith(socketioPath);
1043
+ const requestPath = normalizePath(url.pathname);
1044
+ const isSocketIoPath = socketioEnabled && requestPath.startsWith(socketioPath);
925
1045
  if (upgradeHeader === 'websocket' || isSocketIoPath) {
926
1046
  const response = await app.wsHandler.handleUpgrade(req, server);
927
1047
  if (response === undefined) {
@@ -932,6 +1052,60 @@ export class OneBunApplication {
932
1052
  }
933
1053
  }
934
1054
 
1055
+ // Static file serving (GET/HEAD only)
1056
+ if (staticRootResolved && (req.method === 'GET' || req.method === 'HEAD')) {
1057
+ const requestPath = normalizePath(new URL(req.url).pathname);
1058
+ const prefix = staticPathPrefix ?? '/';
1059
+ const hasPrefix = prefix !== '' && prefix !== '/';
1060
+ if (hasPrefix && !requestPath.startsWith(prefix)) {
1061
+ return new Response('Not Found', { status: HttpStatusCode.NOT_FOUND });
1062
+ }
1063
+ const relativePath = hasPrefix ? requestPath.slice(prefix.length) || '/' : requestPath;
1064
+ const resolvedPath = resolvePathUnderRoot(staticRootResolved, relativePath);
1065
+ if (resolvedPath === null) {
1066
+ return new Response('Not Found', { status: HttpStatusCode.NOT_FOUND });
1067
+ }
1068
+
1069
+ const cache = staticCacheTtlMs > 0 ? staticExistsCache : null;
1070
+ const cacheKey = resolvedPath;
1071
+ if (cache) {
1072
+ const cached = await cache.get(cacheKey);
1073
+ if (cached === true) {
1074
+ return new Response(Bun.file(resolvedPath));
1075
+ }
1076
+ if (cached === false) {
1077
+ if (staticFallbackFile) {
1078
+ const fallbackResolved = resolvePathUnderRoot(staticRootResolved, staticFallbackFile);
1079
+ if (fallbackResolved !== null) {
1080
+ return new Response(Bun.file(fallbackResolved));
1081
+ }
1082
+ }
1083
+
1084
+ return new Response('Not Found', { status: HttpStatusCode.NOT_FOUND });
1085
+ }
1086
+ }
1087
+
1088
+ const file = Bun.file(resolvedPath);
1089
+ const exists = await file.exists();
1090
+ if (cache && staticCacheTtlMs > 0) {
1091
+ await cache.set(cacheKey, exists, staticCacheTtlMs);
1092
+ }
1093
+ if (exists) {
1094
+ return new Response(file);
1095
+ }
1096
+ if (staticFallbackFile) {
1097
+ const fallbackResolved = resolvePathUnderRoot(staticRootResolved, staticFallbackFile);
1098
+ if (fallbackResolved !== null) {
1099
+ const fallbackFile = Bun.file(fallbackResolved);
1100
+ if (await fallbackFile.exists()) {
1101
+ return new Response(fallbackFile);
1102
+ }
1103
+ }
1104
+ }
1105
+
1106
+ return new Response('Not Found', { status: HttpStatusCode.NOT_FOUND });
1107
+ }
1108
+
935
1109
  // 404 for everything not matched by routes
936
1110
  return new Response('Not Found', { status: HttpStatusCode.NOT_FOUND });
937
1111
  },
@@ -953,6 +1127,9 @@ export class OneBunApplication {
953
1127
  }
954
1128
 
955
1129
  this.logger.info(`Server started on http://${this.options.host}:${this.options.port}`);
1130
+ if (staticRootResolved) {
1131
+ this.logger.info(`Static files served from ${staticRootResolved}`);
1132
+ }
956
1133
  if (this.metricsService) {
957
1134
  this.logger.info(
958
1135
  `Metrics available at http://${this.options.host}:${this.options.port}${metricsPath}`,
@@ -1481,6 +1658,7 @@ export class OneBunApplication {
1481
1658
  await this.queueService.stop();
1482
1659
  this.queueService = null;
1483
1660
  }
1661
+ this.queueServiceProxy?.setDelegate(null);
1484
1662
 
1485
1663
  // Disconnect queue adapter
1486
1664
  if (this.queueAdapter) {
@@ -1542,43 +1720,57 @@ export class OneBunApplication {
1542
1720
  }
1543
1721
 
1544
1722
  // Create the appropriate adapter
1545
- const adapterType = queueOptions?.adapter ?? 'memory';
1546
-
1547
- if (adapterType === 'memory') {
1548
- this.queueAdapter = new InMemoryQueueAdapter();
1549
- this.logger.info('Queue system initialized with in-memory adapter');
1550
- } else if (adapterType === 'redis') {
1551
- const redisOptions = queueOptions?.redis ?? {};
1552
- if (redisOptions.useSharedProvider !== false) {
1553
- // Use shared Redis provider
1554
- this.queueAdapter = new RedisQueueAdapter({
1555
- useSharedClient: true,
1556
- keyPrefix: redisOptions.prefix ?? 'onebun:queue:',
1557
- });
1558
- this.logger.info('Queue system initialized with Redis adapter (shared provider)');
1559
- } else if (redisOptions.url) {
1560
- // Create dedicated Redis connection
1561
- this.queueAdapter = new RedisQueueAdapter({
1562
- useSharedClient: false,
1563
- url: redisOptions.url,
1564
- keyPrefix: redisOptions.prefix ?? 'onebun:queue:',
1565
- });
1566
- this.logger.info('Queue system initialized with Redis adapter (dedicated connection)');
1723
+ const adapterOpt = queueOptions?.adapter ?? 'memory';
1724
+
1725
+ if (typeof adapterOpt === 'function') {
1726
+ // Custom adapter constructor (e.g. NATS JetStream)
1727
+ const adapterCtor = adapterOpt;
1728
+ this.queueAdapter = new adapterCtor(queueOptions?.options);
1729
+ await this.queueAdapter.connect();
1730
+ this.logger.info(`Queue system initialized with custom adapter: ${this.queueAdapter.name}`);
1731
+ } else {
1732
+ const adapterType = adapterOpt;
1733
+ if (adapterType === 'memory') {
1734
+ this.queueAdapter = new InMemoryQueueAdapter();
1735
+ this.logger.info('Queue system initialized with in-memory adapter');
1736
+ } else if (adapterType === 'redis') {
1737
+ const redisOptions = queueOptions?.redis ?? {};
1738
+ if (redisOptions.useSharedProvider !== false) {
1739
+ // Use shared Redis provider
1740
+ this.queueAdapter = new RedisQueueAdapter({
1741
+ useSharedClient: true,
1742
+ keyPrefix: redisOptions.prefix ?? 'onebun:queue:',
1743
+ });
1744
+ this.logger.info('Queue system initialized with Redis adapter (shared provider)');
1745
+ } else if (redisOptions.url) {
1746
+ // Create dedicated Redis connection
1747
+ this.queueAdapter = new RedisQueueAdapter({
1748
+ useSharedClient: false,
1749
+ url: redisOptions.url,
1750
+ keyPrefix: redisOptions.prefix ?? 'onebun:queue:',
1751
+ });
1752
+ this.logger.info('Queue system initialized with Redis adapter (dedicated connection)');
1753
+ } else {
1754
+ throw new Error('Redis queue adapter requires either useSharedProvider: true or a url');
1755
+ }
1567
1756
  } else {
1568
- throw new Error('Redis queue adapter requires either useSharedProvider: true or a url');
1757
+ throw new Error(`Unknown queue adapter type: ${adapterType}`);
1569
1758
  }
1570
- } else {
1571
- throw new Error(`Unknown queue adapter type: ${adapterType}`);
1572
- }
1573
1759
 
1574
- // Connect the adapter
1575
- await this.queueAdapter.connect();
1760
+ // Connect the adapter
1761
+ await this.queueAdapter.connect();
1762
+ }
1576
1763
 
1577
1764
  // Create queue service with config
1578
- this.queueService = new QueueService({
1579
- adapter: adapterType,
1580
- options: queueOptions?.redis,
1581
- });
1765
+ const queueServiceConfig: QueueConfig = {
1766
+ adapter:
1767
+ typeof adapterOpt === 'function' ? this.queueAdapter.type : adapterOpt,
1768
+ options:
1769
+ typeof adapterOpt === 'function'
1770
+ ? (queueOptions?.options as Record<string, unknown> | undefined)
1771
+ : queueOptions?.redis,
1772
+ };
1773
+ this.queueService = new QueueService(queueServiceConfig);
1582
1774
 
1583
1775
  // Initialize with the adapter
1584
1776
  await this.queueService.initialize(this.queueAdapter);
@@ -1603,6 +1795,9 @@ export class OneBunApplication {
1603
1795
  // Start the queue service
1604
1796
  await this.queueService.start();
1605
1797
  this.logger.info('Queue service started');
1798
+
1799
+ // Wire the real QueueService into the DI proxy so injected consumers use it
1800
+ this.queueServiceProxy?.setDelegate(this.queueService);
1606
1801
  }
1607
1802
 
1608
1803
  /**
@@ -181,6 +181,21 @@ describe('MultiServiceApplication', () => {
181
181
  });
182
182
  });
183
183
 
184
+ describe('queue option', () => {
185
+ test('should accept queue option and pass it to child applications', () => {
186
+ const app = new MultiServiceApplication({
187
+ services: {
188
+ serviceA: { module: TestModuleA, port: 3001 },
189
+ },
190
+ queue: {
191
+ enabled: true,
192
+ adapter: 'memory',
193
+ },
194
+ });
195
+ expect(app).toBeDefined();
196
+ });
197
+ });
198
+
184
199
  describe('filtering configuration', () => {
185
200
  test('should accept enabledServices option', () => {
186
201
  const app = new MultiServiceApplication({
@@ -219,6 +219,8 @@ export class MultiServiceApplication<TServices extends ServicesMap = ServicesMap
219
219
  ...mergedOptions.tracing,
220
220
  serviceName: name,
221
221
  },
222
+ queue: this.options.queue,
223
+ static: mergedOptions.static ?? serviceConfig.static,
222
224
  });
223
225
 
224
226
  this.applications.set(name, app);
@@ -30,7 +30,7 @@ export type TracingOptions = NonNullable<ApplicationOptions['tracing']>;
30
30
  * Any new shared options should be added to ApplicationOptions first.
31
31
  */
32
32
  export interface BaseServiceOptions
33
- extends Pick<ApplicationOptions, 'host' | 'basePath' | 'metrics' | 'tracing' | 'middleware'> {
33
+ extends Pick<ApplicationOptions, 'host' | 'basePath' | 'metrics' | 'tracing' | 'middleware' | 'static'> {
34
34
  /**
35
35
  * Add service name as prefix to all routes.
36
36
  * When true, the service name will be used as routePrefix.
@@ -132,4 +132,10 @@ export interface MultiServiceApplicationOptions<TServices extends ServicesMap =
132
132
  * ```
133
133
  */
134
134
  externalServiceUrls?: Partial<Record<keyof TServices, string>>;
135
+
136
+ /**
137
+ * Queue configuration applied to all services.
138
+ * When set, each service's OneBunApplication receives this queue config.
139
+ */
140
+ queue?: ApplicationOptions['queue'];
135
141
  }
@@ -1648,7 +1648,7 @@ describe('Lifecycle Hooks API Documentation Examples (docs/api/services.md)', ()
1648
1648
 
1649
1649
  describe('Controller Lifecycle Hooks', () => {
1650
1650
  /**
1651
- * @source docs/api/controllers.md#lifecycle-hooks
1651
+ * @source docs/api/services.md#lifecycle-hooks (controllers support the same hooks)
1652
1652
  */
1653
1653
  it('should implement lifecycle hooks in controllers', () => {
1654
1654
  // From docs: Controller lifecycle hooks example
@@ -2406,6 +2406,34 @@ describe('OneBunApplication (docs/api/core.md)', () => {
2406
2406
  const app = new OneBunApplication(AppModule, { tracing: tracingOptions, loggerLayer: makeMockLoggerLayer() });
2407
2407
  expect(app).toBeDefined();
2408
2408
  });
2409
+
2410
+ /**
2411
+ * @source docs/api/core.md#staticapplicationoptions
2412
+ */
2413
+ it('should accept static file serving configuration (SPA on same host)', async () => {
2414
+ const fs = await import('node:fs');
2415
+ const path = await import('node:path');
2416
+ const os = await import('node:os');
2417
+
2418
+ const tmpDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'onebun-docs-static-'));
2419
+ try {
2420
+ fs.writeFileSync(path.join(tmpDir, 'index.html'), '<!DOCTYPE html><html><body>SPA</body></html>', 'utf8');
2421
+
2422
+ @Module({ controllers: [] })
2423
+ class AppModule {}
2424
+
2425
+ // From docs: Static files (SPA on same host)
2426
+ const app = new OneBunApplication(AppModule, {
2427
+ loggerLayer: makeMockLoggerLayer(),
2428
+ static: { root: tmpDir, fallbackFile: 'index.html' },
2429
+ });
2430
+ expect(app).toBeDefined();
2431
+ await app.start();
2432
+ await app.stop();
2433
+ } finally {
2434
+ fs.rmSync(tmpDir, { recursive: true, force: true });
2435
+ }
2436
+ });
2409
2437
  });
2410
2438
 
2411
2439
  describe('MultiServiceApplication (docs/api/core.md)', () => {
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ export {
48
48
  type WsStorageType,
49
49
  type WsStorageOptions,
50
50
  type WebSocketApplicationOptions,
51
+ type StaticApplicationOptions,
51
52
  // Docs types
52
53
  type DocsApplicationOptions,
53
54
  // SSE types
@@ -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) {
@@ -1041,6 +1052,13 @@ export class OneBunModule implements ModuleInstance {
1041
1052
  return this.rootLayer;
1042
1053
  }
1043
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
+
1044
1062
  /**
1045
1063
  * Create a module from class
1046
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';