@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 +1 -1
- package/src/application/application.test.ts +36 -0
- package/src/application/application.ts +70 -6
- package/src/application/multi-service-application.ts +2 -0
- package/src/application/multi-service.types.ts +1 -1
- package/src/decorators/decorators.test.ts +63 -12
- package/src/decorators/decorators.ts +113 -7
- package/src/docs-examples.test.ts +793 -8
- package/src/index.ts +9 -0
- package/src/module/controller.ts +96 -10
- package/src/module/index.ts +2 -1
- package/src/module/lifecycle.ts +13 -0
- package/src/module/middleware.ts +76 -0
- package/src/module/module.test.ts +138 -1
- package/src/module/module.ts +127 -2
- package/src/types.ts +142 -0
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
682
|
-
|
|
688
|
+
class Middleware1 extends BaseMiddleware {
|
|
689
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
690
|
+
return await next();
|
|
691
|
+
}
|
|
692
|
+
}
|
|
683
693
|
|
|
684
|
-
|
|
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(
|
|
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(
|
|
710
|
+
expect(route?.middleware).toContain(Middleware1);
|
|
695
711
|
});
|
|
696
712
|
|
|
697
|
-
test('should register multiple middleware
|
|
713
|
+
test('should register multiple middleware classes', () => {
|
|
698
714
|
@Controller()
|
|
699
715
|
class TestController {
|
|
700
716
|
@Get()
|
|
701
|
-
@UseMiddleware(
|
|
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(
|
|
708
|
-
expect(route?.middleware).toContain(
|
|
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(
|
|
716
|
-
@UseMiddleware(
|
|
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
|
-
*
|
|
639
|
-
* @example \@UseMiddleware(authMiddleware)
|
|
652
|
+
* Metadata key for controller-level middleware
|
|
640
653
|
*/
|
|
654
|
+
const CONTROLLER_MIDDLEWARE_METADATA = 'onebun:controller_middleware';
|
|
641
655
|
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
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
|
/**
|