@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.
@@ -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();
@@ -758,10 +815,14 @@ export class OneBunApplication {
758
815
  // Module-level middleware (already resolved bound functions)
759
816
  const moduleMiddleware = this.ensureModule().getModuleMiddleware?.(controllerClass) ?? [];
760
817
 
761
- // Controller-level middleware resolve class constructors via root module DI
818
+ // Resolve controller-level and route-level middleware with the owner module's DI
819
+ const ownerModule =
820
+ this.ensureModule().getOwnerModuleForController?.(controllerClass) ?? this.ensureModule();
821
+
822
+ // Controller-level middleware — resolve via owner module DI
762
823
  const ctrlMiddlewareClasses = getControllerMiddleware(controllerClass);
763
824
  const ctrlMiddleware: Function[] = ctrlMiddlewareClasses.length > 0
764
- ? (this.ensureModule().resolveMiddleware?.(ctrlMiddlewareClasses) ?? [])
825
+ ? (ownerModule.resolveMiddleware?.(ctrlMiddlewareClasses) ?? [])
765
826
  : [];
766
827
 
767
828
  for (const route of controllerMetadata.routes) {
@@ -773,10 +834,10 @@ export class OneBunApplication {
773
834
  controller,
774
835
  );
775
836
 
776
- // Route-level middleware — resolve class constructors via root module DI
837
+ // Route-level middleware — resolve via owner module DI
777
838
  const routeMiddlewareClasses = route.middleware ?? [];
778
839
  const routeMiddleware: Function[] = routeMiddlewareClasses.length > 0
779
- ? (this.ensureModule().resolveMiddleware?.(routeMiddlewareClasses) ?? [])
840
+ ? (ownerModule.resolveMiddleware?.(routeMiddlewareClasses) ?? [])
780
841
  : [];
781
842
 
782
843
  // Merge middleware: global → module → controller → route
@@ -897,6 +958,69 @@ export class OneBunApplication {
897
958
 
898
959
  drain() { /* no-op */ },
899
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
+ }
900
1024
 
901
1025
  this.server = Bun.serve<WsClientData>({
902
1026
  port: this.options.port,
@@ -916,8 +1040,8 @@ export class OneBunApplication {
916
1040
  const socketioPath = app.options.websocket?.socketio?.path ?? '/socket.io';
917
1041
 
918
1042
  const url = new URL(req.url);
919
- const path = normalizePath(url.pathname);
920
- const isSocketIoPath = socketioEnabled && path.startsWith(socketioPath);
1043
+ const requestPath = normalizePath(url.pathname);
1044
+ const isSocketIoPath = socketioEnabled && requestPath.startsWith(socketioPath);
921
1045
  if (upgradeHeader === 'websocket' || isSocketIoPath) {
922
1046
  const response = await app.wsHandler.handleUpgrade(req, server);
923
1047
  if (response === undefined) {
@@ -928,6 +1052,60 @@ export class OneBunApplication {
928
1052
  }
929
1053
  }
930
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
+
931
1109
  // 404 for everything not matched by routes
932
1110
  return new Response('Not Found', { status: HttpStatusCode.NOT_FOUND });
933
1111
  },
@@ -949,6 +1127,9 @@ export class OneBunApplication {
949
1127
  }
950
1128
 
951
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
+ }
952
1133
  if (this.metricsService) {
953
1134
  this.logger.info(
954
1135
  `Metrics available at http://${this.options.host}:${this.options.port}${metricsPath}`,
@@ -1477,6 +1658,7 @@ export class OneBunApplication {
1477
1658
  await this.queueService.stop();
1478
1659
  this.queueService = null;
1479
1660
  }
1661
+ this.queueServiceProxy?.setDelegate(null);
1480
1662
 
1481
1663
  // Disconnect queue adapter
1482
1664
  if (this.queueAdapter) {
@@ -1538,43 +1720,57 @@ export class OneBunApplication {
1538
1720
  }
1539
1721
 
1540
1722
  // Create the appropriate adapter
1541
- const adapterType = queueOptions?.adapter ?? 'memory';
1542
-
1543
- if (adapterType === 'memory') {
1544
- this.queueAdapter = new InMemoryQueueAdapter();
1545
- this.logger.info('Queue system initialized with in-memory adapter');
1546
- } else if (adapterType === 'redis') {
1547
- const redisOptions = queueOptions?.redis ?? {};
1548
- if (redisOptions.useSharedProvider !== false) {
1549
- // Use shared Redis provider
1550
- this.queueAdapter = new RedisQueueAdapter({
1551
- useSharedClient: true,
1552
- keyPrefix: redisOptions.prefix ?? 'onebun:queue:',
1553
- });
1554
- this.logger.info('Queue system initialized with Redis adapter (shared provider)');
1555
- } else if (redisOptions.url) {
1556
- // Create dedicated Redis connection
1557
- this.queueAdapter = new RedisQueueAdapter({
1558
- useSharedClient: false,
1559
- url: redisOptions.url,
1560
- keyPrefix: redisOptions.prefix ?? 'onebun:queue:',
1561
- });
1562
- 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
+ }
1563
1756
  } else {
1564
- throw new Error('Redis queue adapter requires either useSharedProvider: true or a url');
1757
+ throw new Error(`Unknown queue adapter type: ${adapterType}`);
1565
1758
  }
1566
- } else {
1567
- throw new Error(`Unknown queue adapter type: ${adapterType}`);
1568
- }
1569
1759
 
1570
- // Connect the adapter
1571
- await this.queueAdapter.connect();
1760
+ // Connect the adapter
1761
+ await this.queueAdapter.connect();
1762
+ }
1572
1763
 
1573
1764
  // Create queue service with config
1574
- this.queueService = new QueueService({
1575
- adapter: adapterType,
1576
- options: queueOptions?.redis,
1577
- });
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);
1578
1774
 
1579
1775
  // Initialize with the adapter
1580
1776
  await this.queueService.initialize(this.queueAdapter);
@@ -1599,6 +1795,9 @@ export class OneBunApplication {
1599
1795
  // Start the queue service
1600
1796
  await this.queueService.start();
1601
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);
1602
1801
  }
1603
1802
 
1604
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
  }
@@ -52,6 +52,7 @@ import {
52
52
  Req,
53
53
  Res,
54
54
  UseMiddleware,
55
+ Middleware,
55
56
  Module,
56
57
  getModuleMetadata,
57
58
  ApiResponse,
@@ -301,6 +302,33 @@ describe('decorators', () => {
301
302
  });
302
303
  });
303
304
 
305
+ describe('Middleware decorator', () => {
306
+ test('should emit design:paramtypes for automatic DI', () => {
307
+ @Service()
308
+ class HelperService {
309
+ getValue() {
310
+ return 'ok';
311
+ }
312
+ }
313
+
314
+ @Middleware()
315
+ class TestMiddleware extends BaseMiddleware {
316
+ constructor(private readonly helper: HelperService) {
317
+ super();
318
+ }
319
+
320
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
321
+ return await next();
322
+ }
323
+ }
324
+
325
+ const deps = getConstructorParamTypes(TestMiddleware);
326
+ expect(deps).toBeDefined();
327
+ expect(deps).toHaveLength(1);
328
+ expect(deps?.[0]).toBe(HelperService);
329
+ });
330
+ });
331
+
304
332
  describe('HTTP method decorators', () => {
305
333
  test('should register GET route', () => {
306
334
  @Controller('test')
@@ -240,6 +240,32 @@ export function Inject<T>(serviceType: new (...args: any[]) => T) {
240
240
  };
241
241
  }
242
242
 
243
+ /**
244
+ * Class decorator for middleware. Apply to classes that extend BaseMiddleware so that
245
+ * TypeScript emits design:paramtypes and constructor dependencies are resolved automatically
246
+ * by the framework (no need for @Inject on each parameter). You can still use @Inject when needed.
247
+ *
248
+ * @example
249
+ * ```ts
250
+ * @Middleware()
251
+ * class AuthMiddleware extends BaseMiddleware {
252
+ * constructor(private authService: AuthService) {
253
+ * super();
254
+ * }
255
+ * async use(req, next) { ... }
256
+ * }
257
+ * ```
258
+ */
259
+ // eslint-disable-next-line @typescript-eslint/naming-convention
260
+ export function Middleware(): ClassDecorator {
261
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
262
+ return (target: any): any => {
263
+ injectable()(target);
264
+
265
+ return target;
266
+ };
267
+ }
268
+
243
269
  /**
244
270
  * Register dependencies manually (fallback method)
245
271
  */
@@ -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