@onebun/core 0.2.1 → 0.2.2

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.1",
3
+ "version": "0.2.2",
4
4
  "description": "Core package for OneBun framework - decorators, DI, modules, controllers",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -906,6 +906,42 @@ describe('OneBunApplication', () => {
906
906
  expect(Bun.serve).toHaveBeenCalled();
907
907
  });
908
908
 
909
+ test('should pass default idleTimeout (120s) to Bun.serve', async () => {
910
+ @Module({})
911
+ class TestModule {}
912
+
913
+ const app = createTestApp(TestModule);
914
+ await app.start();
915
+
916
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
917
+ const serveCall = (Bun.serve as any).mock.calls[0];
918
+ expect(serveCall[0].idleTimeout).toBe(120);
919
+ });
920
+
921
+ test('should pass custom idleTimeout to Bun.serve', async () => {
922
+ @Module({})
923
+ class TestModule {}
924
+
925
+ const app = createTestApp(TestModule, { idleTimeout: 60 });
926
+ await app.start();
927
+
928
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
929
+ const serveCall = (Bun.serve as any).mock.calls[0];
930
+ expect(serveCall[0].idleTimeout).toBe(60);
931
+ });
932
+
933
+ test('should pass idleTimeout: 0 to disable timeout', async () => {
934
+ @Module({})
935
+ class TestModule {}
936
+
937
+ const app = createTestApp(TestModule, { idleTimeout: 0 });
938
+ await app.start();
939
+
940
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
941
+ const serveCall = (Bun.serve as any).mock.calls[0];
942
+ expect(serveCall[0].idleTimeout).toBe(0);
943
+ });
944
+
909
945
  test('should start application with config initialization', async () => {
910
946
  @Module({})
911
947
  class TestModule {}
@@ -27,6 +27,7 @@ import { makeTraceService, TraceService } from '@onebun/trace';
27
27
 
28
28
  import {
29
29
  getControllerMetadata,
30
+ getControllerMiddleware,
30
31
  getSseMetadata,
31
32
  type SseDecoratorOptions,
32
33
  } from '../decorators/decorators';
@@ -37,7 +38,12 @@ import {
37
38
  type OneBunAppConfig,
38
39
  } from '../module/config.interface';
39
40
  import { ConfigServiceImpl } from '../module/config.service';
40
- import { createSseStream } from '../module/controller';
41
+ import {
42
+ createSseStream,
43
+ DEFAULT_IDLE_TIMEOUT,
44
+ DEFAULT_SSE_HEARTBEAT_MS,
45
+ DEFAULT_SSE_TIMEOUT,
46
+ } from '../module/controller';
41
47
  import { OneBunModule } from '../module/module';
42
48
  import { QueueService, type QueueAdapter } from '../queue';
43
49
  import { InMemoryQueueAdapter } from '../queue/adapters/memory.adapter';
@@ -485,7 +491,7 @@ export class OneBunApplication {
485
491
 
486
492
  /**
487
493
  * Create a route handler with the full OneBun request lifecycle:
488
- * tracing setup → middleware chain → executeHandler → metrics → tracing end
494
+ * tracing setup → per-request timeout → middleware chain → executeHandler → metrics → tracing end
489
495
  */
490
496
  function createRouteHandler(
491
497
  routeMeta: RouteMetadata,
@@ -493,10 +499,28 @@ export class OneBunApplication {
493
499
  controller: Controller,
494
500
  fullPath: string,
495
501
  method: string,
496
- ): (req: OneBunRequest) => Promise<Response> {
497
- return async (req) => {
502
+ ): (req: OneBunRequest, server: ReturnType<typeof Bun.serve>) => Promise<Response> {
503
+ // Determine the effective timeout for this route:
504
+ // SSE endpoints check @Sse({ timeout }) first, then route-level, then DEFAULT_SSE_TIMEOUT
505
+ // Normal endpoints use route-level timeout only (undefined = use global idleTimeout)
506
+ const isSse = routeMeta.handler
507
+ ? getSseMetadata(Object.getPrototypeOf(controller), routeMeta.handler) !== undefined
508
+ : false;
509
+ const sseDecoratorOptions = routeMeta.handler
510
+ ? getSseMetadata(Object.getPrototypeOf(controller), routeMeta.handler)
511
+ : undefined;
512
+ const effectiveTimeout: number | undefined = isSse
513
+ ? (sseDecoratorOptions?.timeout ?? routeMeta.timeout ?? DEFAULT_SSE_TIMEOUT)
514
+ : routeMeta.timeout;
515
+
516
+ return async (req, server) => {
498
517
  const startTime = Date.now();
499
518
 
519
+ // Apply per-request idle timeout if configured
520
+ if (effectiveTimeout !== undefined) {
521
+ server.timeout(req, effectiveTimeout);
522
+ }
523
+
500
524
  // Setup tracing context if available and enabled
501
525
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
502
526
  let traceSpan: any = null;
@@ -701,6 +725,12 @@ export class OneBunApplication {
701
725
  };
702
726
  }
703
727
 
728
+ // Application-wide middleware — resolve class constructors via root module DI
729
+ const globalMiddlewareClasses = (this.options.middleware as Function[] | undefined) ?? [];
730
+ const globalMiddleware: Function[] = globalMiddlewareClasses.length > 0
731
+ ? (this.ensureModule().resolveMiddleware?.(globalMiddlewareClasses) ?? [])
732
+ : [];
733
+
704
734
  // Add routes from controllers
705
735
  for (const controllerClass of controllers) {
706
736
  const controllerMetadata = getControllerMetadata(controllerClass);
@@ -725,6 +755,15 @@ export class OneBunApplication {
725
755
 
726
756
  const controllerPath = controllerMetadata.path;
727
757
 
758
+ // Module-level middleware (already resolved bound functions)
759
+ const moduleMiddleware = this.ensureModule().getModuleMiddleware?.(controllerClass) ?? [];
760
+
761
+ // Controller-level middleware — resolve class constructors via root module DI
762
+ const ctrlMiddlewareClasses = getControllerMiddleware(controllerClass);
763
+ const ctrlMiddleware: Function[] = ctrlMiddlewareClasses.length > 0
764
+ ? (this.ensureModule().resolveMiddleware?.(ctrlMiddlewareClasses) ?? [])
765
+ : [];
766
+
728
767
  for (const route of controllerMetadata.routes) {
729
768
  // Combine: appPrefix + controllerPath + routePath
730
769
  // Normalize to ensure consistent matching (e.g., '/api/users/' -> '/api/users')
@@ -734,8 +773,26 @@ export class OneBunApplication {
734
773
  controller,
735
774
  );
736
775
 
776
+ // Route-level middleware — resolve class constructors via root module DI
777
+ const routeMiddlewareClasses = route.middleware ?? [];
778
+ const routeMiddleware: Function[] = routeMiddlewareClasses.length > 0
779
+ ? (this.ensureModule().resolveMiddleware?.(routeMiddlewareClasses) ?? [])
780
+ : [];
781
+
782
+ // Merge middleware: global → module → controller → route
783
+ const mergedMiddleware = [
784
+ ...globalMiddleware,
785
+ ...moduleMiddleware,
786
+ ...ctrlMiddleware,
787
+ ...routeMiddleware,
788
+ ];
789
+ const routeWithMergedMiddleware: RouteMetadata = {
790
+ ...route,
791
+ middleware: mergedMiddleware.length > 0 ? mergedMiddleware : undefined,
792
+ };
793
+
737
794
  // Create wrapped handler with full OneBun lifecycle (tracing, metrics, middleware)
738
- const wrappedHandler = createRouteHandler(route, handler, controller, fullPath, method);
795
+ const wrappedHandler = createRouteHandler(routeWithMergedMiddleware, handler, controller, fullPath, method);
739
796
 
740
797
  // Add to bunRoutes grouped by path and method
741
798
  if (!bunRoutes[fullPath]) {
@@ -844,6 +901,8 @@ export class OneBunApplication {
844
901
  this.server = Bun.serve<WsClientData>({
845
902
  port: this.options.port,
846
903
  hostname: this.options.host,
904
+ // Idle timeout (seconds) — default 120s to support SSE and long-running requests
905
+ idleTimeout: this.options.idleTimeout ?? DEFAULT_IDLE_TIMEOUT,
847
906
  // WebSocket handlers
848
907
  websocket: wsHandlers,
849
908
  // Bun routes API: all endpoints are handled here
@@ -1358,9 +1417,14 @@ export class OneBunApplication {
1358
1417
 
1359
1418
  // Check if result is an async iterable (generator)
1360
1419
  if (result && typeof result === 'object' && Symbol.asyncIterator in result) {
1420
+ // Apply default heartbeat if none specified to keep the connection alive
1421
+ const effectiveOptions = {
1422
+ ...options,
1423
+ heartbeat: options.heartbeat ?? DEFAULT_SSE_HEARTBEAT_MS,
1424
+ };
1361
1425
  const stream = createSseStream(
1362
1426
  result as AsyncIterable<unknown>,
1363
- options,
1427
+ effectiveOptions,
1364
1428
  );
1365
1429
 
1366
1430
  return new Response(stream, {
@@ -143,6 +143,7 @@ export class MultiServiceApplication<TServices extends ServicesMap = ServicesMap
143
143
  envOverrides: { ...appOptions.envOverrides, ...serviceOptions.envOverrides },
144
144
  envSchemaExtend: serviceOptions.envSchemaExtend,
145
145
  logger: { ...appOptions.logger, ...serviceOptions.logger },
146
+ middleware: serviceOptions.middleware ?? appOptions.middleware,
146
147
  metrics: { ...appOptions.metrics, ...serviceOptions.metrics },
147
148
  tracing: { ...appOptions.tracing, ...serviceOptions.tracing },
148
149
  };
@@ -197,6 +198,7 @@ export class MultiServiceApplication<TServices extends ServicesMap = ServicesMap
197
198
  basePath: mergedOptions.basePath,
198
199
  // When routePrefix is true, use service name as prefix
199
200
  routePrefix: mergedOptions.routePrefix ? name : undefined,
201
+ middleware: mergedOptions.middleware,
200
202
  envSchema: mergedEnvSchema,
201
203
  envOptions: {
202
204
  ...this.options.envOptions,
@@ -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'> {
33
+ extends Pick<ApplicationOptions, 'host' | 'basePath' | 'metrics' | 'tracing' | 'middleware'> {
34
34
  /**
35
35
  * Add service name as prefix to all routes.
36
36
  * When true, the service name will be used as routePrefix.
@@ -20,8 +20,14 @@ import {
20
20
  BaseService,
21
21
  Service,
22
22
  } from '../module';
23
+ import { BaseMiddleware } from '../module/middleware';
23
24
  import { makeMockLoggerLayer } from '../testing';
24
- import { HttpMethod, ParamType } from '../types';
25
+ import {
26
+ HttpMethod,
27
+ ParamType,
28
+ type OneBunRequest,
29
+ type OneBunResponse,
30
+ } from '../types';
25
31
 
26
32
  import {
27
33
  injectable,
@@ -52,6 +58,7 @@ import {
52
58
  UploadedFile,
53
59
  UploadedFiles,
54
60
  FormField,
61
+ getControllerMiddleware,
55
62
  } from './decorators';
56
63
 
57
64
  describe('decorators', () => {
@@ -678,42 +685,51 @@ describe('decorators', () => {
678
685
  });
679
686
 
680
687
  describe('UseMiddleware decorator', () => {
681
- const middleware1 = () => {};
682
- const middleware2 = () => {};
688
+ class Middleware1 extends BaseMiddleware {
689
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
690
+ return await next();
691
+ }
692
+ }
683
693
 
684
- test('should register middleware for method', () => {
694
+ class Middleware2 extends BaseMiddleware {
695
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
696
+ return await next();
697
+ }
698
+ }
699
+
700
+ test('should register middleware class for method', () => {
685
701
  @Controller()
686
702
  class TestController {
687
703
  @Get()
688
- @UseMiddleware(middleware1)
704
+ @UseMiddleware(Middleware1)
689
705
  test() {}
690
706
  }
691
707
 
692
708
  const metadata = getControllerMetadata(TestController);
693
709
  const route = metadata?.routes[0];
694
- expect(route?.middleware).toContain(middleware1);
710
+ expect(route?.middleware).toContain(Middleware1);
695
711
  });
696
712
 
697
- test('should register multiple middleware functions', () => {
713
+ test('should register multiple middleware classes', () => {
698
714
  @Controller()
699
715
  class TestController {
700
716
  @Get()
701
- @UseMiddleware(middleware1, middleware2)
717
+ @UseMiddleware(Middleware1, Middleware2)
702
718
  test() {}
703
719
  }
704
720
 
705
721
  const metadata = getControllerMetadata(TestController);
706
722
  const route = metadata?.routes[0];
707
- expect(route?.middleware).toContain(middleware1);
708
- expect(route?.middleware).toContain(middleware2);
723
+ expect(route?.middleware).toContain(Middleware1);
724
+ expect(route?.middleware).toContain(Middleware2);
709
725
  });
710
726
 
711
727
  test('should append to existing middleware', () => {
712
728
  @Controller()
713
729
  class TestController {
714
730
  @Get()
715
- @UseMiddleware(middleware1)
716
- @UseMiddleware(middleware2)
731
+ @UseMiddleware(Middleware1)
732
+ @UseMiddleware(Middleware2)
717
733
  test() {}
718
734
  }
719
735
 
@@ -721,6 +737,41 @@ describe('decorators', () => {
721
737
  const route = metadata?.routes[0];
722
738
  expect(route?.middleware).toHaveLength(2);
723
739
  });
740
+
741
+ test('should register middleware as class decorator', () => {
742
+ @Controller()
743
+ @UseMiddleware(Middleware1, Middleware2)
744
+ class TestController {
745
+ @Get()
746
+ test() {}
747
+ }
748
+
749
+ const controllerMw = getControllerMiddleware(TestController);
750
+ expect(controllerMw).toHaveLength(2);
751
+ expect(controllerMw).toContain(Middleware1);
752
+ expect(controllerMw).toContain(Middleware2);
753
+ });
754
+
755
+ test('should keep class and method middleware separate', () => {
756
+ @Controller()
757
+ @UseMiddleware(Middleware1)
758
+ class TestController {
759
+ @Get()
760
+ @UseMiddleware(Middleware2)
761
+ test() {}
762
+ }
763
+
764
+ // Class-level
765
+ const controllerMw = getControllerMiddleware(TestController);
766
+ expect(controllerMw).toHaveLength(1);
767
+ expect(controllerMw[0]).toBe(Middleware1);
768
+
769
+ // Route-level
770
+ const metadata = getControllerMetadata(TestController);
771
+ const route = metadata?.routes[0];
772
+ expect(route?.middleware).toHaveLength(1);
773
+ expect(route?.middleware?.[0]).toBe(Middleware2);
774
+ });
724
775
  });
725
776
 
726
777
  describe('Module decorator', () => {
@@ -9,6 +9,7 @@ import {
9
9
  type ParamDecoratorOptions,
10
10
  type ParamMetadata,
11
11
  ParamType,
12
+ type RouteOptions,
12
13
  } from '../types';
13
14
 
14
15
  import { getConstructorParamTypes as getDesignParamTypes, Reflect } from './metadata';
@@ -200,6 +201,18 @@ export function controllerDecorator(basePath: string = '') {
200
201
  META_CONSTRUCTOR_PARAMS.set(WrappedController, existingDeps);
201
202
  }
202
203
 
204
+ // Copy controller-level middleware from original class to wrapped class
205
+ // This ensures @UseMiddleware works regardless of decorator order
206
+ const existingControllerMiddleware: Function[] | undefined =
207
+ Reflect.getMetadata(CONTROLLER_MIDDLEWARE_METADATA, target);
208
+ if (existingControllerMiddleware) {
209
+ Reflect.defineMetadata(
210
+ CONTROLLER_MIDDLEWARE_METADATA,
211
+ existingControllerMiddleware,
212
+ WrappedController,
213
+ );
214
+ }
215
+
203
216
  return WrappedController as T;
204
217
  };
205
218
  }
@@ -266,7 +279,7 @@ const RESPONSE_SCHEMAS_METADATA = 'onebun:responseSchemas';
266
279
  * Base route decorator factory
267
280
  */
268
281
  function createRouteDecorator(method: HttpMethod) {
269
- return (path: string = '') =>
282
+ return (path: string = '', options?: RouteOptions) =>
270
283
  (target: object, propertyKey: string, descriptor: PropertyDescriptor) => {
271
284
  const controllerClass = target.constructor as Function;
272
285
 
@@ -309,6 +322,7 @@ function createRouteDecorator(method: HttpMethod) {
309
322
  schema: rs.schema,
310
323
  description: rs.description,
311
324
  })),
325
+ ...(options?.timeout !== undefined ? { timeout: options.timeout } : {}),
312
326
  });
313
327
 
314
328
  META_CONTROLLERS.set(controllerClass, metadata);
@@ -635,12 +649,72 @@ export function FormField(fieldName: string, options?: ParamDecoratorOptions): P
635
649
  }
636
650
 
637
651
  /**
638
- * Middleware decorator
639
- * @example \@UseMiddleware(authMiddleware)
652
+ * Metadata key for controller-level middleware
640
653
  */
654
+ const CONTROLLER_MIDDLEWARE_METADATA = 'onebun:controller_middleware';
641
655
 
642
- export function UseMiddleware(...middleware: Function[]): MethodDecorator {
643
- return (target: object, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
656
+ /**
657
+ * Middleware decorator can be applied to both controllers (class) and individual routes (method).
658
+ *
659
+ * Pass middleware **class constructors** (extending `BaseMiddleware`), not instances.
660
+ * The framework instantiates them once at startup with full DI support.
661
+ *
662
+ * When applied to a class, the middleware is added to **every** route in that controller
663
+ * and runs after global and module-level middleware but before route-level middleware.
664
+ *
665
+ * When applied to a method, the middleware runs after controller-level middleware.
666
+ *
667
+ * Execution order: global → controller → route → handler
668
+ *
669
+ * @example Class-level (all routes)
670
+ * ```typescript
671
+ * \@Controller('/admin')
672
+ * \@UseMiddleware(AuthMiddleware)
673
+ * class AdminController extends BaseController { ... }
674
+ * ```
675
+ *
676
+ * @example Method-level (single route)
677
+ * ```typescript
678
+ * \@Post('/action')
679
+ * \@UseMiddleware(LogMiddleware)
680
+ * action() { ... }
681
+ * ```
682
+ *
683
+ * @example Combined
684
+ * ```typescript
685
+ * \@Controller('/admin')
686
+ * \@UseMiddleware(AuthMiddleware) // runs on every route
687
+ * class AdminController extends BaseController {
688
+ * \@Get('/dashboard')
689
+ * \@UseMiddleware(CacheMiddleware) // runs only on this route, after auth
690
+ * getDashboard() { ... }
691
+ * }
692
+ * ```
693
+ */
694
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
695
+ export function UseMiddleware(...middleware: Function[]): any {
696
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
697
+ return function useMiddlewareDecorator(...args: any[]): any {
698
+ // ---- Class decorator: target is a constructor function ----
699
+ if (args.length === 1 && typeof args[0] === 'function') {
700
+ const target = args[0] as Function;
701
+ const existing: Function[] =
702
+ Reflect.getMetadata(CONTROLLER_MIDDLEWARE_METADATA, target) || [];
703
+ Reflect.defineMetadata(
704
+ CONTROLLER_MIDDLEWARE_METADATA,
705
+ [...existing, ...middleware],
706
+ target,
707
+ );
708
+
709
+ return target;
710
+ }
711
+
712
+ // ---- Method decorator: (target, propertyKey, descriptor) ----
713
+ const [target, propertyKey, descriptor] = args as [
714
+ object,
715
+ string | symbol,
716
+ PropertyDescriptor,
717
+ ];
644
718
  const existingMiddleware: Function[] =
645
719
  Reflect.getMetadata(MIDDLEWARE_METADATA, target, propertyKey) || [];
646
720
  Reflect.defineMetadata(
@@ -654,6 +728,17 @@ export function UseMiddleware(...middleware: Function[]): MethodDecorator {
654
728
  };
655
729
  }
656
730
 
731
+ /**
732
+ * Get controller-level middleware class constructors for a controller class.
733
+ * Returns middleware registered via @UseMiddleware() applied to the class.
734
+ *
735
+ * @param target - Controller class (constructor)
736
+ * @returns Array of middleware class constructors
737
+ */
738
+ export function getControllerMiddleware(target: Function): Function[] {
739
+ return Reflect.getMetadata(CONTROLLER_MIDDLEWARE_METADATA, target) || [];
740
+ }
741
+
657
742
  /**
658
743
  * HTTP GET decorator
659
744
  */
@@ -719,8 +804,17 @@ export interface SseDecoratorOptions {
719
804
  * Heartbeat interval in milliseconds.
720
805
  * When set, the server will send a comment (": heartbeat\n\n")
721
806
  * at this interval to keep the connection alive.
807
+ * @defaultValue 30000 (30 seconds) when using @Sse() decorator
722
808
  */
723
809
  heartbeat?: number;
810
+
811
+ /**
812
+ * Per-request idle timeout in seconds for this SSE connection.
813
+ * Overrides the global `idleTimeout` from `ApplicationOptions`.
814
+ * Set to 0 to disable the timeout entirely.
815
+ * @defaultValue 600 (10 minutes) for SSE endpoints
816
+ */
817
+ timeout?: number;
724
818
  }
725
819
 
726
820
  /**
@@ -766,7 +860,10 @@ export function Sse(options?: SseDecoratorOptions): MethodDecorator {
766
860
  }
767
861
 
768
862
  /**
769
- * Check if a method is marked as SSE endpoint
863
+ * Check if a method is marked as SSE endpoint.
864
+ * Traverses the prototype chain so that metadata stored on the original class
865
+ * prototype is found even when `@Controller` wraps the class.
866
+ *
770
867
  * @param target - Controller instance or prototype
771
868
  * @param methodName - Method name
772
869
  * @returns SSE options if method is SSE endpoint, undefined otherwise
@@ -775,7 +872,16 @@ export function getSseMetadata(
775
872
  target: object,
776
873
  methodName: string,
777
874
  ): SseDecoratorOptions | undefined {
778
- return Reflect.getMetadata(SSE_METADATA, target, methodName);
875
+ let proto: object | null = target;
876
+ while (proto) {
877
+ const metadata = Reflect.getMetadata(SSE_METADATA, proto, methodName);
878
+ if (metadata !== undefined) {
879
+ return metadata;
880
+ }
881
+ proto = Object.getPrototypeOf(proto);
882
+ }
883
+
884
+ return undefined;
779
885
  }
780
886
 
781
887
  /**