@onebun/core 0.2.0 → 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.
@@ -38,9 +38,95 @@ import type {
38
38
  SseGenerator,
39
39
  OneBunRequest,
40
40
  OneBunResponse,
41
+ MiddlewareClass,
42
+ OnModuleConfigure,
41
43
  } from './types';
42
44
  import type { ServerWebSocket } from 'bun';
43
45
 
46
+ import {
47
+ Controller,
48
+ Get,
49
+ Post,
50
+ Put,
51
+ Delete,
52
+ Patch,
53
+ Param,
54
+ Query,
55
+ Body,
56
+ Header,
57
+ Req,
58
+ Cookie,
59
+ Module,
60
+ Service,
61
+ BaseService,
62
+ BaseController,
63
+ UseMiddleware,
64
+ getControllerMiddleware,
65
+ getServiceTag,
66
+ getControllerMetadata,
67
+ HttpStatusCode,
68
+ ParamType,
69
+ NotFoundError,
70
+ InternalServerError,
71
+ OneBunBaseError,
72
+ Env,
73
+ validate,
74
+ validateOrThrow,
75
+ MultiServiceApplication,
76
+ OneBunApplication,
77
+ createServiceDefinition,
78
+ createServiceClient,
79
+ WebSocketGateway,
80
+ BaseWebSocketGateway,
81
+ OnConnect,
82
+ OnDisconnect,
83
+ OnJoinRoom,
84
+ OnLeaveRoom,
85
+ OnMessage,
86
+ Client,
87
+ Socket,
88
+ MessageData,
89
+ RoomName,
90
+ PatternParams,
91
+ WsServer,
92
+ UseWsGuards,
93
+ WsAuthGuard,
94
+ WsPermissionGuard,
95
+ WsAnyPermissionGuard,
96
+ createGuard,
97
+ createInMemoryWsStorage,
98
+ SharedRedisProvider,
99
+ Sse,
100
+ getSseMetadata,
101
+ formatSseEvent,
102
+ createSseStream,
103
+ createWsServiceDefinition,
104
+ createWsClient,
105
+ createNativeWsClient,
106
+ matchPattern,
107
+ makeMockLoggerLayer,
108
+ hasOnModuleInit,
109
+ hasOnApplicationInit,
110
+ hasOnModuleDestroy,
111
+ hasBeforeApplicationDestroy,
112
+ hasOnApplicationDestroy,
113
+ callOnModuleInit,
114
+ callOnApplicationInit,
115
+ callOnModuleDestroy,
116
+ callBeforeApplicationDestroy,
117
+ callOnApplicationDestroy,
118
+ UploadedFile,
119
+ UploadedFiles,
120
+ FormField,
121
+ OneBunFile,
122
+ MimeType,
123
+ matchMimeType,
124
+ BaseMiddleware,
125
+ DEFAULT_IDLE_TIMEOUT,
126
+ DEFAULT_SSE_HEARTBEAT_MS,
127
+ DEFAULT_SSE_TIMEOUT,
128
+ } from './';
129
+
44
130
 
45
131
  /**
46
132
  * @source docs/index.md#minimal-working-example
@@ -241,8 +327,8 @@ describe('Core README Examples', () => {
241
327
  it('should use middleware decorator', () => {
242
328
  // From README: Middleware example
243
329
  function loggerMiddleware(
244
- _req: Request,
245
- next: () => Promise<Response>,
330
+ _req: OneBunRequest,
331
+ next: () => Promise<OneBunResponse>,
246
332
  ): Promise<Response> {
247
333
  // eslint-disable-next-line no-console
248
334
  console.log('Request received');
@@ -251,8 +337,8 @@ describe('Core README Examples', () => {
251
337
  }
252
338
 
253
339
  function authMiddleware(
254
- req: Request,
255
- next: () => Promise<Response>,
340
+ req: OneBunRequest,
341
+ next: () => Promise<OneBunResponse>,
256
342
  ): Promise<Response> {
257
343
  const token = req.headers.get('Authorization');
258
344
  if (!token) {
@@ -485,8 +571,8 @@ describe('Decorators API Documentation Examples', () => {
485
571
  it('should apply middleware to route handler', () => {
486
572
  // From docs: @UseMiddleware() example
487
573
  const authMiddleware = async (
488
- req: Request,
489
- next: () => Promise<Response>,
574
+ req: OneBunRequest,
575
+ next: () => Promise<OneBunResponse>,
490
576
  ) => {
491
577
  const token = req.headers.get('Authorization');
492
578
  if (!token) {
@@ -497,8 +583,8 @@ describe('Decorators API Documentation Examples', () => {
497
583
  };
498
584
 
499
585
  const logMiddleware = async (
500
- _req: Request,
501
- next: () => Promise<Response>,
586
+ _req: OneBunRequest,
587
+ next: () => Promise<OneBunResponse>,
502
588
  ) => {
503
589
  // eslint-disable-next-line no-console
504
590
  console.log('Request logged');
@@ -523,6 +609,60 @@ describe('Decorators API Documentation Examples', () => {
523
609
 
524
610
  expect(UserController).toBeDefined();
525
611
  });
612
+
613
+ it('should apply middleware to all routes when used as class decorator (docs/api/decorators.md)', () => {
614
+ // From docs: @UseMiddleware() class-level example
615
+ const authMiddleware = async (
616
+ req: OneBunRequest,
617
+ next: () => Promise<OneBunResponse>,
618
+ ) => {
619
+ const token = req.headers.get('Authorization');
620
+ if (!token) {
621
+ return new Response('Unauthorized', { status: 401 });
622
+ }
623
+
624
+ return await next();
625
+ };
626
+
627
+ const auditLogMiddleware = async (
628
+ _req: OneBunRequest,
629
+ next: () => Promise<OneBunResponse>,
630
+ ) => await next();
631
+
632
+ @Controller('/admin')
633
+ @UseMiddleware(authMiddleware) // Applied to ALL routes in this controller
634
+ class AdminController extends BaseController {
635
+ @Get('/dashboard')
636
+ getDashboard() {
637
+ return this.success({ stats: {} });
638
+ }
639
+
640
+ @Put('/settings')
641
+ @UseMiddleware(auditLogMiddleware) // Additional middleware for this route
642
+ updateSettings() {
643
+ return this.success({ updated: true });
644
+ }
645
+ }
646
+
647
+ expect(AdminController).toBeDefined();
648
+
649
+ // Verify controller-level middleware is stored
650
+ const ctrlMiddleware = getControllerMiddleware(AdminController);
651
+ expect(ctrlMiddleware).toHaveLength(1);
652
+ expect(ctrlMiddleware[0]).toBe(authMiddleware);
653
+
654
+ // Verify route-level middleware is stored on the method
655
+ const metadata = getControllerMetadata(AdminController);
656
+ expect(metadata).toBeDefined();
657
+
658
+ const settingsRoute = metadata!.routes.find((r) => r.path === '/settings');
659
+ expect(settingsRoute?.middleware).toHaveLength(1);
660
+ expect(settingsRoute!.middleware![0]).toBe(auditLogMiddleware);
661
+
662
+ // The dashboard route should have no route-level middleware (only controller-level)
663
+ const dashboardRoute = metadata!.routes.find((r) => r.path === '/dashboard');
664
+ expect(dashboardRoute?.middleware?.length ?? 0).toBe(0);
665
+ });
526
666
  });
527
667
  });
528
668
 
@@ -784,175 +924,599 @@ describe('Controllers API Documentation Examples', () => {
784
924
  });
785
925
  });
786
926
 
787
- describe('Services API Documentation Examples', () => {
788
- describe('BaseService (docs/api/services.md)', () => {
789
- it('should create basic service', () => {
790
- // From docs: Basic Service example
791
- @Service()
792
- class CounterService extends BaseService {
793
- private count = 0;
794
-
795
- increment(): number {
796
- this.count++;
797
- this.logger.debug('Counter incremented', { count: this.count });
927
+ describe('Middleware API Documentation Examples (docs/api/controllers.md)', () => {
928
+ describe('BaseMiddleware (docs/api/controllers.md#basemiddleware)', () => {
929
+ it('should define a middleware class extending BaseMiddleware', () => {
930
+ // From docs: BaseMiddleware example
931
+ class RequestLogMiddleware extends BaseMiddleware {
932
+ async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
933
+ this.logger.info(`${req.method} ${new URL(req.url).pathname}`);
934
+ const response = await next();
935
+ response.headers.set('X-Request-Duration', String(Date.now()));
798
936
 
799
- return this.count;
937
+ return response;
800
938
  }
939
+ }
801
940
 
802
- decrement(): number {
803
- this.count--;
941
+ expect(RequestLogMiddleware.prototype).toBeInstanceOf(BaseMiddleware);
942
+ // eslint-disable-next-line jest/unbound-method
943
+ expect(RequestLogMiddleware.prototype.use).toBeInstanceOf(Function);
944
+ });
945
+ });
804
946
 
805
- return this.count;
806
- }
947
+ describe('Route-Level Middleware (docs/api/controllers.md#route-level-middleware)', () => {
948
+ it('should apply middleware to individual routes', () => {
949
+ // From docs: Route-Level Middleware example
950
+ class AuthMiddleware extends BaseMiddleware {
951
+ async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
952
+ const token = req.headers.get('Authorization');
953
+ if (!token) {
954
+ return new Response('Unauthorized', { status: 401 });
955
+ }
807
956
 
808
- getValue(): number {
809
- return this.count;
957
+ return await next();
810
958
  }
811
959
  }
812
960
 
813
- expect(CounterService).toBeDefined();
814
- });
815
-
816
- it('should create service with dependencies', () => {
817
- // From docs: Service with Dependencies example
818
- @Service()
819
- class UserRepository extends BaseService {}
961
+ @Controller('/api')
962
+ class ApiController extends BaseController {
963
+ @Get('/public')
964
+ publicEndpoint() {
965
+ return { message: 'Anyone can see this' };
966
+ }
820
967
 
821
- @Service()
822
- class UserService extends BaseService {
823
- // Dependencies are auto-injected via constructor
824
- // Logger and config are available immediately after super()
825
- constructor(private repository: UserRepository) {
826
- super();
968
+ @Post('/protected')
969
+ @UseMiddleware(AuthMiddleware)
970
+ protectedEndpoint() {
971
+ return { message: 'Auth required' };
827
972
  }
828
973
  }
829
974
 
830
- expect(UserService).toBeDefined();
831
- });
832
- });
975
+ const metadata = getControllerMetadata(ApiController);
976
+ expect(metadata).toBeDefined();
833
977
 
834
- describe('getServiceTag (docs/api/services.md)', () => {
835
- /**
836
- * @source docs/api/services.md#service-tags-advanced
837
- */
838
- it('should get service tag from class', () => {
839
- @Service()
840
- class MyService extends BaseService {}
978
+ const publicRoute = metadata!.routes.find((r) => r.path === '/public');
979
+ expect(publicRoute?.middleware?.length ?? 0).toBe(0);
841
980
 
842
- const tag = getServiceTag(MyService);
843
- expect(tag).toBeDefined();
981
+ const protectedRoute = metadata!.routes.find((r) => r.path === '/protected');
982
+ expect(protectedRoute?.middleware).toHaveLength(1);
983
+ expect(protectedRoute!.middleware![0]).toBe(AuthMiddleware);
844
984
  });
845
985
  });
846
986
 
847
- describe('BaseService Methods (docs/api/services.md)', () => {
848
- /**
849
- * @source docs/api/services.md#class-definition
850
- */
851
- it('should have runEffect method', () => {
852
- // From docs: BaseService has runEffect method for Effect.js integration
853
- // Note: Cannot instantiate service without OneBunApplication context
854
- // Check prototype instead
855
- expect(typeof BaseService.prototype['runEffect']).toBe('function');
856
- });
987
+ describe('Controller-Level Middleware (docs/api/controllers.md#controller-level-middleware)', () => {
988
+ it('should apply middleware to all routes via class decorator', () => {
989
+ // From docs: Controller-Level Middleware example
990
+ class AuthMiddleware extends BaseMiddleware {
991
+ async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
992
+ const token = req.headers.get('Authorization');
993
+ if (!token) {
994
+ return new Response('Unauthorized', { status: 401 });
995
+ }
857
996
 
858
- /**
859
- * @source docs/api/services.md#class-definition
860
- */
861
- it('should have formatError method', () => {
862
- // From docs: BaseService has formatError method
863
- // Note: Cannot instantiate service without OneBunApplication context
864
- // Check prototype instead
865
- expect(typeof BaseService.prototype['formatError']).toBe('function');
866
- });
867
- });
997
+ return await next();
998
+ }
999
+ }
868
1000
 
869
- describe('Service Logger (docs/api/services.md)', () => {
870
- /**
871
- * @source docs/api/services.md#log-levels
872
- */
873
- it('should support all log levels', () => {
874
- @Service()
875
- class EmailService extends BaseService {
876
- async send() {
877
- // From docs: Log Levels
878
- this.logger.trace('Very detailed info'); // Level 0
879
- this.logger.debug('Debug information'); // Level 1
880
- this.logger.info('General information'); // Level 2
881
- this.logger.warn('Warning message'); // Level 3
882
- this.logger.error('Error occurred'); // Level 4
883
- this.logger.fatal('Fatal error'); // Level 5
1001
+ @Controller('/admin')
1002
+ @UseMiddleware(AuthMiddleware)
1003
+ class AdminController extends BaseController {
1004
+ @Get('/dashboard')
1005
+ getDashboard() {
1006
+ return { stats: { users: 100 } };
1007
+ }
1008
+
1009
+ @Put('/settings')
1010
+ updateSettings() {
1011
+ return { updated: true };
884
1012
  }
885
1013
  }
886
1014
 
887
- expect(EmailService).toBeDefined();
1015
+ // Controller-level middleware is stored separately
1016
+ const ctrlMiddleware = getControllerMiddleware(AdminController);
1017
+ expect(ctrlMiddleware).toHaveLength(1);
1018
+ expect(ctrlMiddleware[0]).toBe(AuthMiddleware);
888
1019
  });
889
- });
890
- });
891
-
892
- describe('Lifecycle Hooks API Documentation Examples (docs/api/services.md)', () => {
893
- describe('OnModuleInit Interface', () => {
894
- /**
895
- * @source docs/api/services.md#lifecycle-hooks
896
- */
897
- it('should implement OnModuleInit interface', () => {
898
- // From docs: OnModuleInit example
899
- @Service()
900
- class DatabaseService extends BaseService implements OnModuleInit {
901
- private connection: unknown = null;
902
1020
 
903
- async onModuleInit(): Promise<void> {
904
- // Called after service instantiation and DI
905
- this.connection = { connected: true };
906
- this.logger.info('Database connected');
1021
+ it('should combine controller-level and route-level middleware', () => {
1022
+ // From docs: Combined controller + route middleware example
1023
+ class AuthMiddleware extends BaseMiddleware {
1024
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1025
+ return await next();
907
1026
  }
1027
+ }
908
1028
 
909
- isConnected(): boolean {
910
- return this.connection !== null;
1029
+ class AuditLogMiddleware extends BaseMiddleware {
1030
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1031
+ return await next();
911
1032
  }
912
1033
  }
913
1034
 
914
- expect(DatabaseService).toBeDefined();
915
- // Verify method exists
916
- const service = new DatabaseService();
917
- expect(typeof service.onModuleInit).toBe('function');
918
- });
919
- });
1035
+ @Controller('/admin')
1036
+ @UseMiddleware(AuthMiddleware) // Runs first on all routes
1037
+ class AdminController extends BaseController {
1038
+ @Get('/dashboard')
1039
+ getDashboard() {
1040
+ return { stats: {} };
1041
+ }
920
1042
 
921
- describe('OnApplicationInit Interface', () => {
922
- /**
923
- * @source docs/api/services.md#lifecycle-hooks
924
- */
925
- it('should implement OnApplicationInit interface', () => {
926
- // From docs: OnApplicationInit example
927
- @Service()
928
- class CacheService extends BaseService implements OnApplicationInit {
929
- async onApplicationInit(): Promise<void> {
930
- // Called after all modules initialized, before HTTP server starts
931
- this.logger.info('Warming up cache');
1043
+ @Put('/settings')
1044
+ @UseMiddleware(AuditLogMiddleware) // Runs second, only on this route
1045
+ updateSettings() {
1046
+ return { updated: true };
932
1047
  }
933
1048
  }
934
1049
 
935
- expect(CacheService).toBeDefined();
936
- const service = new CacheService();
937
- expect(typeof service.onApplicationInit).toBe('function');
1050
+ // Controller-level middleware
1051
+ const ctrlMiddleware = getControllerMiddleware(AdminController);
1052
+ expect(ctrlMiddleware).toHaveLength(1);
1053
+ expect(ctrlMiddleware[0]).toBe(AuthMiddleware);
1054
+
1055
+ // Route-level middleware only on /settings
1056
+ const metadata = getControllerMetadata(AdminController);
1057
+ const settingsRoute = metadata!.routes.find((r) => r.path === '/settings');
1058
+ expect(settingsRoute?.middleware).toHaveLength(1);
1059
+ expect(settingsRoute!.middleware![0]).toBe(AuditLogMiddleware);
1060
+
1061
+ const dashboardRoute = metadata!.routes.find((r) => r.path === '/dashboard');
1062
+ expect(dashboardRoute?.middleware?.length ?? 0).toBe(0);
938
1063
  });
939
1064
  });
940
1065
 
941
- describe('OnModuleDestroy Interface', () => {
942
- /**
943
- * @source docs/api/services.md#lifecycle-hooks
944
- */
945
- it('should implement OnModuleDestroy interface', () => {
946
- // From docs: OnModuleDestroy example
947
- @Service()
948
- class ConnectionService extends BaseService implements OnModuleDestroy {
949
- async onModuleDestroy(): Promise<void> {
950
- // Called during shutdown, after HTTP server stops
951
- this.logger.info('Closing connections');
1066
+ describe('Application-Wide Middleware (docs/api/controllers.md#application-wide-middleware)', () => {
1067
+ it('should accept middleware classes in ApplicationOptions', () => {
1068
+ // From docs: Application-Wide Middleware example
1069
+ class RequestIdMiddleware extends BaseMiddleware {
1070
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1071
+ const response = await next();
1072
+ response.headers.set('X-Request-ID', crypto.randomUUID());
1073
+
1074
+ return response;
952
1075
  }
953
1076
  }
954
1077
 
955
- expect(ConnectionService).toBeDefined();
1078
+ class CorsMiddleware extends BaseMiddleware {
1079
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1080
+ const response = await next();
1081
+ response.headers.set('Access-Control-Allow-Origin', '*');
1082
+
1083
+ return response;
1084
+ }
1085
+ }
1086
+
1087
+ // Verify that middleware option is accepted by OneBunApplication
1088
+ // (We don't start the app, just verify the type/constructor accepts it)
1089
+ @Module({
1090
+ controllers: [],
1091
+ providers: [],
1092
+ })
1093
+ class AppModule {}
1094
+
1095
+ const app = new OneBunApplication(AppModule, {
1096
+ port: 0,
1097
+ middleware: [RequestIdMiddleware, CorsMiddleware],
1098
+ });
1099
+
1100
+ expect(app).toBeDefined();
1101
+ });
1102
+ });
1103
+
1104
+ describe('Middleware Execution Order (docs/api/controllers.md#middleware-execution-order)', () => {
1105
+ it('should support all four middleware levels together', () => {
1106
+ // From docs: Combining All Levels example
1107
+ class RequestIdMiddleware extends BaseMiddleware {
1108
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1109
+ return await next();
1110
+ }
1111
+ }
1112
+
1113
+ class TimingMiddleware extends BaseMiddleware {
1114
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1115
+ return await next();
1116
+ }
1117
+ }
1118
+
1119
+ class JwtAuthMiddleware extends BaseMiddleware {
1120
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1121
+ return await next();
1122
+ }
1123
+ }
1124
+
1125
+ class JsonOnlyMiddleware extends BaseMiddleware {
1126
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1127
+ return await next();
1128
+ }
1129
+ }
1130
+
1131
+ @Controller('/admin')
1132
+ @UseMiddleware(JwtAuthMiddleware)
1133
+ class AdminController extends BaseController {
1134
+ @Get('/stats')
1135
+ getStats() {
1136
+ return { stats: {} };
1137
+ }
1138
+
1139
+ @Post('/users')
1140
+ @UseMiddleware(JsonOnlyMiddleware)
1141
+ createUser() {
1142
+ return { created: true };
1143
+ }
1144
+ }
1145
+
1146
+ // Verify controller middleware
1147
+ const ctrlMiddleware = getControllerMiddleware(AdminController);
1148
+ expect(ctrlMiddleware).toHaveLength(1);
1149
+ expect(ctrlMiddleware[0]).toBe(JwtAuthMiddleware);
1150
+
1151
+ // Verify route-level middleware on /users only
1152
+ const metadata = getControllerMetadata(AdminController);
1153
+ const statsRoute = metadata!.routes.find((r) => r.path === '/stats');
1154
+ expect(statsRoute?.middleware?.length ?? 0).toBe(0);
1155
+
1156
+ const usersRoute = metadata!.routes.find((r) => r.path === '/users');
1157
+ expect(usersRoute?.middleware).toHaveLength(1);
1158
+ expect(usersRoute!.middleware![0]).toBe(JsonOnlyMiddleware);
1159
+
1160
+ // Global middleware would be set via ApplicationOptions.middleware
1161
+ @Module({
1162
+ controllers: [AdminController],
1163
+ providers: [],
1164
+ })
1165
+ class AppModule {}
1166
+
1167
+ const app = new OneBunApplication(AppModule, {
1168
+ port: 0,
1169
+ middleware: [RequestIdMiddleware, TimingMiddleware],
1170
+ });
1171
+
1172
+ expect(app).toBeDefined();
1173
+ });
1174
+ });
1175
+
1176
+ describe('Real-World Middleware Examples (docs/api/controllers.md#real-world-examples)', () => {
1177
+ it('should define custom authentication middleware', () => {
1178
+ // From docs: Custom Authentication Middleware example
1179
+ class JwtAuthMiddleware extends BaseMiddleware {
1180
+ async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1181
+ const authHeader = req.headers.get('Authorization');
1182
+ if (!authHeader?.startsWith('Bearer ')) {
1183
+ this.logger.warn('Missing or invalid Authorization header');
1184
+
1185
+ return new Response(JSON.stringify({
1186
+ success: false,
1187
+ code: 401,
1188
+ message: 'Missing or invalid Authorization header',
1189
+ }), {
1190
+ status: 401,
1191
+ // eslint-disable-next-line @typescript-eslint/naming-convention
1192
+ headers: { 'Content-Type': 'application/json' },
1193
+ });
1194
+ }
1195
+
1196
+ // Validate the token (your logic here)
1197
+ this.logger.debug(`Validating JWT token: ${authHeader.slice(7).substring(0, 8)}...`);
1198
+
1199
+ return await next();
1200
+ }
1201
+ }
1202
+
1203
+ expect(JwtAuthMiddleware.prototype).toBeInstanceOf(BaseMiddleware);
1204
+ });
1205
+
1206
+ it('should define request validation middleware', () => {
1207
+ // From docs: Request Validation Middleware example
1208
+ class JsonOnlyMiddleware extends BaseMiddleware {
1209
+ async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1210
+ if (req.method !== 'GET' && req.method !== 'DELETE') {
1211
+ const contentType = req.headers.get('Content-Type');
1212
+ if (!contentType?.includes('application/json')) {
1213
+ this.logger.warn(`Invalid Content-Type: ${contentType}`);
1214
+
1215
+ return new Response(JSON.stringify({
1216
+ success: false,
1217
+ code: 415,
1218
+ message: 'Content-Type must be application/json',
1219
+ }), {
1220
+ status: 415,
1221
+ // eslint-disable-next-line @typescript-eslint/naming-convention
1222
+ headers: { 'Content-Type': 'application/json' },
1223
+ });
1224
+ }
1225
+ }
1226
+
1227
+ return await next();
1228
+ }
1229
+ }
1230
+
1231
+ expect(JsonOnlyMiddleware.prototype).toBeInstanceOf(BaseMiddleware);
1232
+ });
1233
+
1234
+ it('should define timing/logging middleware', () => {
1235
+ // From docs: Timing / Logging Middleware example
1236
+ class TimingMiddleware extends BaseMiddleware {
1237
+ async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1238
+ const start = performance.now();
1239
+ const response = await next();
1240
+ const duration = (performance.now() - start).toFixed(2);
1241
+ response.headers.set('X-Response-Time', `${duration}ms`);
1242
+ this.logger.info(`${req.method} ${new URL(req.url).pathname} — ${duration}ms`);
1243
+
1244
+ return response;
1245
+ }
1246
+ }
1247
+
1248
+ expect(TimingMiddleware.prototype).toBeInstanceOf(BaseMiddleware);
1249
+ });
1250
+ });
1251
+
1252
+ describe('Module-Level Middleware (docs/api/controllers.md#module-level-middleware)', () => {
1253
+ it('should define module-level middleware via OnModuleConfigure interface', () => {
1254
+ // From docs: Module-Level Middleware example
1255
+ class TenantMiddleware extends BaseMiddleware {
1256
+ async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1257
+ const tenantId = req.headers.get('X-Tenant-ID');
1258
+ if (!tenantId) {
1259
+ return new Response('Missing X-Tenant-ID', { status: 400 });
1260
+ }
1261
+
1262
+ return await next();
1263
+ }
1264
+ }
1265
+
1266
+ @Controller('/users')
1267
+ class UserController extends BaseController {
1268
+ @Get('/')
1269
+ getUsers() {
1270
+ return [];
1271
+ }
1272
+ }
1273
+
1274
+ @Module({
1275
+ controllers: [UserController],
1276
+ })
1277
+ class UserModule implements OnModuleConfigure {
1278
+ configureMiddleware(): MiddlewareClass[] {
1279
+ return [TenantMiddleware];
1280
+ }
1281
+ }
1282
+
1283
+ // Verify the module class has configureMiddleware
1284
+ const instance = new UserModule();
1285
+ const middleware = instance.configureMiddleware();
1286
+ expect(middleware).toHaveLength(1);
1287
+ expect(middleware[0]).toBe(TenantMiddleware);
1288
+ });
1289
+
1290
+ it('should inherit module middleware in child modules', () => {
1291
+ // From docs: Module middleware inheritance example
1292
+ class RequestIdMiddleware extends BaseMiddleware {
1293
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1294
+ return await next();
1295
+ }
1296
+ }
1297
+
1298
+ class TenantMiddleware extends BaseMiddleware {
1299
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1300
+ return await next();
1301
+ }
1302
+ }
1303
+
1304
+ @Controller('/users')
1305
+ class UserController extends BaseController {
1306
+ @Get('/')
1307
+ getUsers() {
1308
+ return [];
1309
+ }
1310
+ }
1311
+
1312
+ @Module({
1313
+ controllers: [UserController],
1314
+ })
1315
+ class UserModule implements OnModuleConfigure {
1316
+ configureMiddleware(): MiddlewareClass[] {
1317
+ return [TenantMiddleware];
1318
+ }
1319
+ }
1320
+
1321
+ @Controller('/health')
1322
+ class HealthController extends BaseController {
1323
+ @Get('/')
1324
+ health() {
1325
+ return { ok: true };
1326
+ }
1327
+ }
1328
+
1329
+ @Module({
1330
+ imports: [UserModule],
1331
+ controllers: [HealthController],
1332
+ })
1333
+ class AppModule implements OnModuleConfigure {
1334
+ configureMiddleware(): MiddlewareClass[] {
1335
+ return [RequestIdMiddleware];
1336
+ }
1337
+ }
1338
+
1339
+ // Verify both modules have configureMiddleware
1340
+ const appInstance = new AppModule();
1341
+ expect(appInstance.configureMiddleware()).toHaveLength(1);
1342
+ expect(appInstance.configureMiddleware()[0]).toBe(RequestIdMiddleware);
1343
+
1344
+ const userInstance = new UserModule();
1345
+ expect(userInstance.configureMiddleware()).toHaveLength(1);
1346
+ expect(userInstance.configureMiddleware()[0]).toBe(TenantMiddleware);
1347
+ });
1348
+ });
1349
+ });
1350
+
1351
+ describe('Services API Documentation Examples', () => {
1352
+ describe('BaseService (docs/api/services.md)', () => {
1353
+ it('should create basic service', () => {
1354
+ // From docs: Basic Service example
1355
+ @Service()
1356
+ class CounterService extends BaseService {
1357
+ private count = 0;
1358
+
1359
+ increment(): number {
1360
+ this.count++;
1361
+ this.logger.debug('Counter incremented', { count: this.count });
1362
+
1363
+ return this.count;
1364
+ }
1365
+
1366
+ decrement(): number {
1367
+ this.count--;
1368
+
1369
+ return this.count;
1370
+ }
1371
+
1372
+ getValue(): number {
1373
+ return this.count;
1374
+ }
1375
+ }
1376
+
1377
+ expect(CounterService).toBeDefined();
1378
+ });
1379
+
1380
+ it('should create service with dependencies', () => {
1381
+ // From docs: Service with Dependencies example
1382
+ @Service()
1383
+ class UserRepository extends BaseService {}
1384
+
1385
+ @Service()
1386
+ class UserService extends BaseService {
1387
+ // Dependencies are auto-injected via constructor
1388
+ // Logger and config are available immediately after super()
1389
+ constructor(private repository: UserRepository) {
1390
+ super();
1391
+ }
1392
+ }
1393
+
1394
+ expect(UserService).toBeDefined();
1395
+ });
1396
+ });
1397
+
1398
+ describe('getServiceTag (docs/api/services.md)', () => {
1399
+ /**
1400
+ * @source docs/api/services.md#service-tags-advanced
1401
+ */
1402
+ it('should get service tag from class', () => {
1403
+ @Service()
1404
+ class MyService extends BaseService {}
1405
+
1406
+ const tag = getServiceTag(MyService);
1407
+ expect(tag).toBeDefined();
1408
+ });
1409
+ });
1410
+
1411
+ describe('BaseService Methods (docs/api/services.md)', () => {
1412
+ /**
1413
+ * @source docs/api/services.md#class-definition
1414
+ */
1415
+ it('should have runEffect method', () => {
1416
+ // From docs: BaseService has runEffect method for Effect.js integration
1417
+ // Note: Cannot instantiate service without OneBunApplication context
1418
+ // Check prototype instead
1419
+ expect(typeof BaseService.prototype['runEffect']).toBe('function');
1420
+ });
1421
+
1422
+ /**
1423
+ * @source docs/api/services.md#class-definition
1424
+ */
1425
+ it('should have formatError method', () => {
1426
+ // From docs: BaseService has formatError method
1427
+ // Note: Cannot instantiate service without OneBunApplication context
1428
+ // Check prototype instead
1429
+ expect(typeof BaseService.prototype['formatError']).toBe('function');
1430
+ });
1431
+ });
1432
+
1433
+ describe('Service Logger (docs/api/services.md)', () => {
1434
+ /**
1435
+ * @source docs/api/services.md#log-levels
1436
+ */
1437
+ it('should support all log levels', () => {
1438
+ @Service()
1439
+ class EmailService extends BaseService {
1440
+ async send() {
1441
+ // From docs: Log Levels
1442
+ this.logger.trace('Very detailed info'); // Level 0
1443
+ this.logger.debug('Debug information'); // Level 1
1444
+ this.logger.info('General information'); // Level 2
1445
+ this.logger.warn('Warning message'); // Level 3
1446
+ this.logger.error('Error occurred'); // Level 4
1447
+ this.logger.fatal('Fatal error'); // Level 5
1448
+ }
1449
+ }
1450
+
1451
+ expect(EmailService).toBeDefined();
1452
+ });
1453
+ });
1454
+ });
1455
+
1456
+ describe('Lifecycle Hooks API Documentation Examples (docs/api/services.md)', () => {
1457
+ describe('OnModuleInit Interface', () => {
1458
+ /**
1459
+ * @source docs/api/services.md#lifecycle-hooks
1460
+ */
1461
+ it('should implement OnModuleInit interface', () => {
1462
+ // From docs: OnModuleInit example
1463
+ @Service()
1464
+ class DatabaseService extends BaseService implements OnModuleInit {
1465
+ private connection: unknown = null;
1466
+
1467
+ async onModuleInit(): Promise<void> {
1468
+ // Called after service instantiation and DI
1469
+ this.connection = { connected: true };
1470
+ this.logger.info('Database connected');
1471
+ }
1472
+
1473
+ isConnected(): boolean {
1474
+ return this.connection !== null;
1475
+ }
1476
+ }
1477
+
1478
+ expect(DatabaseService).toBeDefined();
1479
+ // Verify method exists
1480
+ const service = new DatabaseService();
1481
+ expect(typeof service.onModuleInit).toBe('function');
1482
+ });
1483
+ });
1484
+
1485
+ describe('OnApplicationInit Interface', () => {
1486
+ /**
1487
+ * @source docs/api/services.md#lifecycle-hooks
1488
+ */
1489
+ it('should implement OnApplicationInit interface', () => {
1490
+ // From docs: OnApplicationInit example
1491
+ @Service()
1492
+ class CacheService extends BaseService implements OnApplicationInit {
1493
+ async onApplicationInit(): Promise<void> {
1494
+ // Called after all modules initialized, before HTTP server starts
1495
+ this.logger.info('Warming up cache');
1496
+ }
1497
+ }
1498
+
1499
+ expect(CacheService).toBeDefined();
1500
+ const service = new CacheService();
1501
+ expect(typeof service.onApplicationInit).toBe('function');
1502
+ });
1503
+ });
1504
+
1505
+ describe('OnModuleDestroy Interface', () => {
1506
+ /**
1507
+ * @source docs/api/services.md#lifecycle-hooks
1508
+ */
1509
+ it('should implement OnModuleDestroy interface', () => {
1510
+ // From docs: OnModuleDestroy example
1511
+ @Service()
1512
+ class ConnectionService extends BaseService implements OnModuleDestroy {
1513
+ async onModuleDestroy(): Promise<void> {
1514
+ // Called during shutdown, after HTTP server stops
1515
+ this.logger.info('Closing connections');
1516
+ }
1517
+ }
1518
+
1519
+ expect(ConnectionService).toBeDefined();
956
1520
  const service = new ConnectionService();
957
1521
  expect(typeof service.onModuleDestroy).toBe('function');
958
1522
  });
@@ -3376,123 +3940,50 @@ describe('WebSocket Chat Example (docs/examples/websocket-chat.md)', () => {
3376
3940
  @WebSocketGateway({ path: '/chat' })
3377
3941
  class ChatGateway extends BaseWebSocketGateway {
3378
3942
  constructor(private chatService: ChatService) {
3379
- super();
3380
- }
3381
-
3382
- @OnMessage('chat:message')
3383
- handleMessage(@MessageData() data: { text: string }) {
3384
- return { event: 'received', data };
3385
- }
3386
- }
3387
-
3388
- @Module({
3389
- controllers: [ChatGateway],
3390
- providers: [ChatService],
3391
- })
3392
- class ChatModule {}
3393
-
3394
- // From docs: Typed Client (native) example
3395
- const definition = createWsServiceDefinition(ChatModule);
3396
- const client = createWsClient(definition, {
3397
- url: 'ws://localhost:3000/chat',
3398
- protocol: 'native',
3399
- auth: {
3400
- token: 'user-jwt-token',
3401
- },
3402
- reconnect: true,
3403
- reconnectInterval: 2000,
3404
- maxReconnectAttempts: 5,
3405
- });
3406
-
3407
- // Lifecycle events
3408
- expect(typeof client.on).toBe('function');
3409
-
3410
- // Connect/disconnect
3411
- expect(typeof client.connect).toBe('function');
3412
- expect(typeof client.disconnect).toBe('function');
3413
-
3414
- // Gateway access
3415
- expect(client.ChatGateway).toBeDefined();
3416
- });
3417
- });
3418
- });
3419
-
3420
- // ============================================================================
3421
- // SSE (Server-Sent Events) Documentation Tests
3422
- // ============================================================================
3423
-
3424
- import { Sse, getSseMetadata } from './decorators/decorators';
3425
- import { formatSseEvent, createSseStream } from './module/controller';
3426
-
3427
- import {
3428
- Controller,
3429
- Get,
3430
- Post,
3431
- Put,
3432
- Delete,
3433
- Patch,
3434
- Param,
3435
- Query,
3436
- Body,
3437
- Header,
3438
- Req,
3439
- Cookie,
3440
- Module,
3441
- Service,
3442
- BaseService,
3443
- BaseController,
3444
- UseMiddleware,
3445
- getServiceTag,
3446
- getControllerMetadata,
3447
- HttpStatusCode,
3448
- ParamType,
3449
- NotFoundError,
3450
- InternalServerError,
3451
- OneBunBaseError,
3452
- Env,
3453
- validate,
3454
- validateOrThrow,
3455
- MultiServiceApplication,
3456
- OneBunApplication,
3457
- createServiceDefinition,
3458
- createServiceClient,
3459
- WebSocketGateway,
3460
- BaseWebSocketGateway,
3461
- OnConnect,
3462
- OnDisconnect,
3463
- OnJoinRoom,
3464
- OnLeaveRoom,
3465
- OnMessage,
3466
- Client,
3467
- Socket,
3468
- MessageData,
3469
- RoomName,
3470
- PatternParams,
3471
- WsServer,
3472
- UseWsGuards,
3473
- WsAuthGuard,
3474
- WsPermissionGuard,
3475
- WsAnyPermissionGuard,
3476
- createGuard,
3477
- createInMemoryWsStorage,
3478
- SharedRedisProvider,
3479
- createWsServiceDefinition,
3480
- createWsClient,
3481
- createNativeWsClient,
3482
- matchPattern,
3483
- makeMockLoggerLayer,
3484
- hasOnModuleInit,
3485
- hasOnApplicationInit,
3486
- hasOnModuleDestroy,
3487
- hasBeforeApplicationDestroy,
3488
- hasOnApplicationDestroy,
3489
- callOnModuleInit,
3490
- callOnApplicationInit,
3491
- callOnModuleDestroy,
3492
- callBeforeApplicationDestroy,
3493
- callOnApplicationDestroy,
3494
- } from './';
3943
+ super();
3944
+ }
3945
+
3946
+ @OnMessage('chat:message')
3947
+ handleMessage(@MessageData() data: { text: string }) {
3948
+ return { event: 'received', data };
3949
+ }
3950
+ }
3951
+
3952
+ @Module({
3953
+ controllers: [ChatGateway],
3954
+ providers: [ChatService],
3955
+ })
3956
+ class ChatModule {}
3957
+
3958
+ // From docs: Typed Client (native) example
3959
+ const definition = createWsServiceDefinition(ChatModule);
3960
+ const client = createWsClient(definition, {
3961
+ url: 'ws://localhost:3000/chat',
3962
+ protocol: 'native',
3963
+ auth: {
3964
+ token: 'user-jwt-token',
3965
+ },
3966
+ reconnect: true,
3967
+ reconnectInterval: 2000,
3968
+ maxReconnectAttempts: 5,
3969
+ });
3970
+
3971
+ // Lifecycle events
3972
+ expect(typeof client.on).toBe('function');
3973
+
3974
+ // Connect/disconnect
3975
+ expect(typeof client.connect).toBe('function');
3976
+ expect(typeof client.disconnect).toBe('function');
3977
+
3978
+ // Gateway access
3979
+ expect(client.ChatGateway).toBeDefined();
3980
+ });
3981
+ });
3982
+ });
3495
3983
 
3984
+ // ============================================================================
3985
+ // SSE (Server-Sent Events) Documentation Tests
3986
+ // ============================================================================
3496
3987
 
3497
3988
  describe('SSE (Server-Sent Events) API Documentation (docs/api/controllers.md)', () => {
3498
3989
  describe('SseEvent Type (docs/api/controllers.md)', () => {
@@ -3728,6 +4219,91 @@ describe('SSE (Server-Sent Events) API Documentation (docs/api/controllers.md)',
3728
4219
  // Should have the actual event (string data is not wrapped in extra quotes)
3729
4220
  expect(output).toContain('data: done');
3730
4221
  });
4222
+
4223
+ it('should call iterator.return() on cancel, triggering generator finally block', async () => {
4224
+ let finallyCalled = false;
4225
+
4226
+ async function* testGenerator(): SseGenerator {
4227
+ try {
4228
+ yield { event: 'start', data: { count: 0 } };
4229
+ // Simulate a long-running generator
4230
+ yield { event: 'tick', data: { count: 1 } };
4231
+ yield { event: 'tick', data: { count: 2 } };
4232
+ } finally {
4233
+ finallyCalled = true;
4234
+ }
4235
+ }
4236
+
4237
+ const stream = createSseStream(testGenerator());
4238
+ const reader = stream.getReader();
4239
+
4240
+ // Read first chunk
4241
+ const first = await reader.read();
4242
+ expect(first.done).toBe(false);
4243
+
4244
+ // Cancel the stream (simulates client disconnect)
4245
+ await reader.cancel();
4246
+
4247
+ // The generator's finally block should have been triggered
4248
+ // Give a tick for the async return to settle
4249
+ await Bun.sleep(10);
4250
+ expect(finallyCalled).toBe(true);
4251
+ });
4252
+
4253
+ it('should fire onAbort callback on cancel', async () => {
4254
+ let abortCalled = false;
4255
+
4256
+ async function* testGenerator(): SseGenerator {
4257
+ yield { event: 'start', data: { count: 0 } };
4258
+ yield { event: 'tick', data: { count: 1 } };
4259
+ }
4260
+
4261
+ const stream = createSseStream(testGenerator(), {
4262
+ onAbort() {
4263
+ abortCalled = true;
4264
+ },
4265
+ });
4266
+ const reader = stream.getReader();
4267
+
4268
+ // Read first chunk
4269
+ await reader.read();
4270
+
4271
+ // Cancel the stream
4272
+ await reader.cancel();
4273
+
4274
+ expect(abortCalled).toBe(true);
4275
+ });
4276
+
4277
+ it('should abort upstream fetch via try/finally when client disconnects (SSE proxy pattern)', async () => {
4278
+ let upstreamAborted = false;
4279
+ const ac = new AbortController();
4280
+
4281
+ async function* proxyGenerator(): SseGenerator {
4282
+ try {
4283
+ ac.signal.addEventListener('abort', () => {
4284
+ upstreamAborted = true;
4285
+ });
4286
+ yield { event: 'proxied', data: { from: 'upstream' } };
4287
+ // Simulate waiting for more upstream data
4288
+ yield { event: 'proxied', data: { from: 'upstream-2' } };
4289
+ } finally {
4290
+ // This is the idiomatic pattern: abort the upstream connection in finally
4291
+ ac.abort();
4292
+ }
4293
+ }
4294
+
4295
+ const stream = createSseStream(proxyGenerator());
4296
+ const reader = stream.getReader();
4297
+
4298
+ // Read first event
4299
+ await reader.read();
4300
+
4301
+ // Client disconnects
4302
+ await reader.cancel();
4303
+
4304
+ await Bun.sleep(10);
4305
+ expect(upstreamAborted).toBe(true);
4306
+ });
3731
4307
  });
3732
4308
 
3733
4309
  describe('Controller.sse() Method', () => {
@@ -3759,6 +4335,79 @@ describe('SSE (Server-Sent Events) API Documentation (docs/api/controllers.md)',
3759
4335
 
3760
4336
  expect(EventsController).toBeDefined();
3761
4337
  });
4338
+
4339
+ it('should accept factory function with AbortSignal for SSE proxy pattern', async () => {
4340
+ let signalAborted = false;
4341
+
4342
+ @Controller('/events')
4343
+ class ProxyController extends BaseController {
4344
+ @Get('/proxy')
4345
+ proxy(): Response {
4346
+ return this.sse((signal) => this.proxyUpstream(signal));
4347
+ }
4348
+
4349
+ private async *proxyUpstream(signal: AbortSignal): SseGenerator {
4350
+ signal.addEventListener('abort', () => {
4351
+ signalAborted = true;
4352
+ });
4353
+ yield { event: 'proxied', data: { from: 'upstream' } };
4354
+ yield { event: 'proxied', data: { from: 'upstream-2' } };
4355
+ }
4356
+ }
4357
+
4358
+ const controller = new ProxyController();
4359
+ // Access protected method via type assertion
4360
+ const sseMethod = (controller as unknown as {
4361
+ sse: (source: (signal: AbortSignal) => AsyncIterable<unknown>, options?: unknown) => Response;
4362
+ }).sse.bind(controller);
4363
+
4364
+ const response = sseMethod((signal) => (async function* () {
4365
+ signal.addEventListener('abort', () => {
4366
+ signalAborted = true;
4367
+ });
4368
+ yield { event: 'proxied', data: { from: 'upstream' } };
4369
+ })());
4370
+
4371
+ expect(response).toBeInstanceOf(Response);
4372
+ expect(response.headers.get('Content-Type')).toBe('text/event-stream');
4373
+
4374
+ const reader = response.body!.getReader();
4375
+
4376
+ // Read first event
4377
+ await reader.read();
4378
+
4379
+ // Client disconnects
4380
+ await reader.cancel();
4381
+
4382
+ await Bun.sleep(10);
4383
+ expect(signalAborted).toBe(true);
4384
+ });
4385
+
4386
+ it('should support onAbort callback with sse() helper', async () => {
4387
+ let abortCalled = false;
4388
+
4389
+ const controller = new BaseController();
4390
+ const sseMethod = (controller as unknown as {
4391
+ sse: (source: AsyncIterable<unknown>, options?: { onAbort?: () => void }) => Response;
4392
+ }).sse.bind(controller);
4393
+
4394
+ const response = sseMethod(
4395
+ (async function* () {
4396
+ yield { event: 'tick', data: { count: 0 } };
4397
+ })(),
4398
+ {
4399
+ onAbort() {
4400
+ abortCalled = true;
4401
+ },
4402
+ },
4403
+ );
4404
+
4405
+ const reader = response.body!.getReader();
4406
+ await reader.read();
4407
+ await reader.cancel();
4408
+
4409
+ expect(abortCalled).toBe(true);
4410
+ });
3762
4411
  });
3763
4412
 
3764
4413
  describe('Complete SSE Controller Example (docs/api/controllers.md)', () => {
@@ -3838,6 +4487,148 @@ describe('SSE (Server-Sent Events) API Documentation (docs/api/controllers.md)',
3838
4487
  });
3839
4488
  });
3840
4489
 
4490
+ // ============================================================================
4491
+ // Server & SSE Default Constants
4492
+ // ============================================================================
4493
+
4494
+ describe('Server & SSE Default Constants (docs/api/core.md, docs/api/controllers.md)', () => {
4495
+ it('should export DEFAULT_IDLE_TIMEOUT as 120 seconds', () => {
4496
+ expect(DEFAULT_IDLE_TIMEOUT).toBe(120);
4497
+ });
4498
+
4499
+ it('should export DEFAULT_SSE_HEARTBEAT_MS as 30000 milliseconds', () => {
4500
+ expect(DEFAULT_SSE_HEARTBEAT_MS).toBe(30_000);
4501
+ });
4502
+
4503
+ it('should export DEFAULT_SSE_TIMEOUT as 600 seconds (10 minutes)', () => {
4504
+ expect(DEFAULT_SSE_TIMEOUT).toBe(600);
4505
+ });
4506
+ });
4507
+
4508
+ // ============================================================================
4509
+ // Per-request timeout via route decorators (docs/api/decorators.md)
4510
+ // ============================================================================
4511
+
4512
+ describe('Per-request timeout via route decorators (docs/api/decorators.md)', () => {
4513
+ it('should store timeout in route metadata when specified via @Get options', () => {
4514
+ @Controller('/tasks')
4515
+ class TaskController extends BaseController {
4516
+ @Get('/process', { timeout: 300 })
4517
+ async process() {
4518
+ return new Response('OK');
4519
+ }
4520
+ }
4521
+
4522
+ const metadata = getControllerMetadata(TaskController);
4523
+ expect(metadata).toBeDefined();
4524
+ const route = metadata!.routes.find((r) => r.handler === 'process');
4525
+ expect(route).toBeDefined();
4526
+ expect(route!.timeout).toBe(300);
4527
+ });
4528
+
4529
+ it('should store timeout: 0 (disable) in route metadata', () => {
4530
+ @Controller('/export')
4531
+ class ExportController extends BaseController {
4532
+ @Get('/all', { timeout: 0 })
4533
+ async exportAll() {
4534
+ return new Response('OK');
4535
+ }
4536
+ }
4537
+
4538
+ const metadata = getControllerMetadata(ExportController);
4539
+ expect(metadata).toBeDefined();
4540
+ const route = metadata!.routes.find((r) => r.handler === 'exportAll');
4541
+ expect(route).toBeDefined();
4542
+ expect(route!.timeout).toBe(0);
4543
+ });
4544
+
4545
+ it('should not include timeout in metadata when not specified', () => {
4546
+ @Controller('/simple')
4547
+ class SimpleController extends BaseController {
4548
+ @Get('/hello')
4549
+ async hello() {
4550
+ return new Response('OK');
4551
+ }
4552
+ }
4553
+
4554
+ const metadata = getControllerMetadata(SimpleController);
4555
+ expect(metadata).toBeDefined();
4556
+ const route = metadata!.routes.find((r) => r.handler === 'hello');
4557
+ expect(route).toBeDefined();
4558
+ expect(route!.timeout).toBeUndefined();
4559
+ });
4560
+
4561
+ it('should support timeout on @Post, @Put, @Delete, @Patch', () => {
4562
+ @Controller('/api')
4563
+ class CrudController extends BaseController {
4564
+ @Post('/create', { timeout: 60 })
4565
+ async create() {
4566
+ return new Response('OK');
4567
+ }
4568
+
4569
+ @Put('/update', { timeout: 120 })
4570
+ async update() {
4571
+ return new Response('OK');
4572
+ }
4573
+
4574
+ @Delete('/remove', { timeout: 30 })
4575
+ async remove() {
4576
+ return new Response('OK');
4577
+ }
4578
+
4579
+ @Patch('/patch', { timeout: 45 })
4580
+ async patch() {
4581
+ return new Response('OK');
4582
+ }
4583
+ }
4584
+
4585
+ const metadata = getControllerMetadata(CrudController);
4586
+ expect(metadata).toBeDefined();
4587
+
4588
+ expect(metadata!.routes.find((r) => r.handler === 'create')!.timeout).toBe(60);
4589
+ expect(metadata!.routes.find((r) => r.handler === 'update')!.timeout).toBe(120);
4590
+ expect(metadata!.routes.find((r) => r.handler === 'remove')!.timeout).toBe(30);
4591
+ expect(metadata!.routes.find((r) => r.handler === 'patch')!.timeout).toBe(45);
4592
+ });
4593
+
4594
+ it('should store timeout in SSE decorator options', () => {
4595
+ @Controller('/events')
4596
+ class SseTimeoutController extends BaseController {
4597
+ @Get('/stream')
4598
+ @Sse({ timeout: 3600, heartbeat: 10000 })
4599
+ async *stream(): SseGenerator {
4600
+ yield { event: 'tick', data: { count: 0 } };
4601
+ }
4602
+ }
4603
+
4604
+ const sseOptions = getSseMetadata(
4605
+ SseTimeoutController.prototype,
4606
+ 'stream',
4607
+ );
4608
+ expect(sseOptions).toBeDefined();
4609
+ expect(sseOptions!.timeout).toBe(3600);
4610
+ expect(sseOptions!.heartbeat).toBe(10000);
4611
+ });
4612
+
4613
+ it('should support timeout: 0 in @Sse to disable timeout', () => {
4614
+ @Controller('/events')
4615
+ class InfiniteSseController extends BaseController {
4616
+ @Get('/infinite')
4617
+ @Sse({ timeout: 0 })
4618
+ async *infinite(): SseGenerator {
4619
+ yield { event: 'start', data: {} };
4620
+ }
4621
+ }
4622
+
4623
+ const sseOptions = getSseMetadata(
4624
+ InfiniteSseController.prototype,
4625
+ 'infinite',
4626
+ );
4627
+ expect(sseOptions).toBeDefined();
4628
+ expect(sseOptions!.timeout).toBe(0);
4629
+ });
4630
+ });
4631
+
3841
4632
  // ============================================================================
3842
4633
  // Cookie, Headers, @Req with OneBunRequest
3843
4634
  // ============================================================================
@@ -4128,3 +4919,297 @@ describe('Working with Cookies (docs/api/controllers.md)', () => {
4128
4919
  expect(AuthController).toBeDefined();
4129
4920
  });
4130
4921
  });
4922
+
4923
+ // ============================================================================
4924
+ // File Upload Documentation Tests
4925
+ // ============================================================================
4926
+
4927
+ describe('File Upload API Documentation (docs/api/decorators.md)', () => {
4928
+ /**
4929
+ * @source docs/api/decorators.md#uploadedfile
4930
+ */
4931
+ describe('Single File Upload (docs/api/decorators.md#uploadedfile)', () => {
4932
+ it('should define controller with @UploadedFile decorator', () => {
4933
+ @Controller('/api/files')
4934
+ class FileController extends BaseController {
4935
+ @Post('/avatar')
4936
+ async uploadAvatar(
4937
+ @UploadedFile('avatar', {
4938
+ maxSize: 5 * 1024 * 1024,
4939
+ mimeTypes: [MimeType.ANY_IMAGE],
4940
+ }) file: OneBunFile,
4941
+ ): Promise<Response> {
4942
+ await file.writeTo(`./uploads/${file.name}`);
4943
+
4944
+ return this.success({ filename: file.name, size: file.size });
4945
+ }
4946
+ }
4947
+
4948
+ expect(FileController).toBeDefined();
4949
+ const metadata = getControllerMetadata(FileController);
4950
+ expect(metadata).toBeDefined();
4951
+ expect(metadata!.routes).toHaveLength(1);
4952
+
4953
+ const route = metadata!.routes[0];
4954
+ expect(route.params).toBeDefined();
4955
+ expect(route.params!.length).toBe(1);
4956
+ expect(route.params![0].type).toBe(ParamType.FILE);
4957
+ expect(route.params![0].name).toBe('avatar');
4958
+ expect(route.params![0].isRequired).toBe(true);
4959
+ expect(route.params![0].fileOptions).toBeDefined();
4960
+ expect(route.params![0].fileOptions!.maxSize).toBe(5 * 1024 * 1024);
4961
+ expect(route.params![0].fileOptions!.mimeTypes).toEqual([MimeType.ANY_IMAGE]);
4962
+ });
4963
+ });
4964
+
4965
+ /**
4966
+ * @source docs/api/decorators.md#uploadedfiles
4967
+ */
4968
+ describe('Multiple File Upload (docs/api/decorators.md#uploadedfiles)', () => {
4969
+ it('should define controller with @UploadedFiles decorator', () => {
4970
+ @Controller('/api/files')
4971
+ class FileController extends BaseController {
4972
+ @Post('/documents')
4973
+ async uploadDocs(
4974
+ @UploadedFiles('docs', { maxCount: 10 }) files: OneBunFile[],
4975
+ ): Promise<Response> {
4976
+ for (const file of files) {
4977
+ await file.writeTo(`./uploads/${file.name}`);
4978
+ }
4979
+
4980
+ return this.success({ count: files.length });
4981
+ }
4982
+ }
4983
+
4984
+ expect(FileController).toBeDefined();
4985
+ const metadata = getControllerMetadata(FileController);
4986
+ expect(metadata).toBeDefined();
4987
+
4988
+ const route = metadata!.routes[0];
4989
+ expect(route.params).toBeDefined();
4990
+ expect(route.params![0].type).toBe(ParamType.FILES);
4991
+ expect(route.params![0].name).toBe('docs');
4992
+ expect(route.params![0].fileOptions!.maxCount).toBe(10);
4993
+ });
4994
+
4995
+ it('should support @UploadedFiles without field name (all files)', () => {
4996
+ @Controller('/api/files')
4997
+ class FileController extends BaseController {
4998
+ @Post('/batch')
4999
+ async uploadBatch(
5000
+ @UploadedFiles(undefined, { maxCount: 20 }) files: OneBunFile[],
5001
+ ): Promise<Response> {
5002
+ return this.success({ count: files.length });
5003
+ }
5004
+ }
5005
+
5006
+ expect(FileController).toBeDefined();
5007
+ const metadata = getControllerMetadata(FileController);
5008
+ const route = metadata!.routes[0];
5009
+ expect(route.params![0].type).toBe(ParamType.FILES);
5010
+ expect(route.params![0].name).toBe('');
5011
+ });
5012
+ });
5013
+
5014
+ /**
5015
+ * @source docs/api/decorators.md#formfield
5016
+ */
5017
+ describe('Form Field (docs/api/decorators.md#formfield)', () => {
5018
+ it('should define controller with @FormField decorator', () => {
5019
+ @Controller('/api/files')
5020
+ class FileController extends BaseController {
5021
+ @Post('/profile')
5022
+ async createProfile(
5023
+ @UploadedFile('avatar', { mimeTypes: [MimeType.ANY_IMAGE] }) avatar: OneBunFile,
5024
+ @FormField('name', { required: true }) name: string,
5025
+ @FormField('email') email: string,
5026
+ ): Promise<Response> {
5027
+ await avatar.writeTo(`./uploads/${avatar.name}`);
5028
+
5029
+ return this.success({ name, email, avatar: avatar.name });
5030
+ }
5031
+ }
5032
+
5033
+ expect(FileController).toBeDefined();
5034
+ const metadata = getControllerMetadata(FileController);
5035
+ const route = metadata!.routes[0];
5036
+ expect(route.params).toBeDefined();
5037
+ expect(route.params!.length).toBe(3);
5038
+
5039
+ // @UploadedFile
5040
+ const fileParam = route.params!.find((p) => p.type === ParamType.FILE);
5041
+ expect(fileParam).toBeDefined();
5042
+ expect(fileParam!.name).toBe('avatar');
5043
+
5044
+ // @FormField (required)
5045
+ const nameParam = route.params!.find((p) => p.name === 'name');
5046
+ expect(nameParam).toBeDefined();
5047
+ expect(nameParam!.type).toBe(ParamType.FORM_FIELD);
5048
+ expect(nameParam!.isRequired).toBe(true);
5049
+
5050
+ // @FormField (optional)
5051
+ const emailParam = route.params!.find((p) => p.name === 'email');
5052
+ expect(emailParam).toBeDefined();
5053
+ expect(emailParam!.type).toBe(ParamType.FORM_FIELD);
5054
+ expect(emailParam!.isRequired).toBe(false);
5055
+ });
5056
+ });
5057
+
5058
+ /**
5059
+ * @source docs/api/decorators.md#onebunfile
5060
+ */
5061
+ describe('OneBunFile Class (docs/api/decorators.md#onebunfile)', () => {
5062
+ it('should create OneBunFile from File and support all methods', async () => {
5063
+ const content = 'test file content';
5064
+ const file = new File([content], 'test.txt', { type: 'text/plain' });
5065
+ const oneBunFile = new OneBunFile(file);
5066
+
5067
+ expect(oneBunFile.name).toBe('test.txt');
5068
+ expect(oneBunFile.size).toBe(content.length);
5069
+ expect(oneBunFile.type).toStartWith('text/plain');
5070
+
5071
+ const base64 = await oneBunFile.toBase64();
5072
+ expect(base64).toBe(btoa(content));
5073
+
5074
+ const buffer = await oneBunFile.toBuffer();
5075
+ expect(buffer.toString()).toBe(content);
5076
+
5077
+ const blob = oneBunFile.toBlob();
5078
+ expect(blob.size).toBe(content.length);
5079
+ });
5080
+
5081
+ it('should create OneBunFile from base64', async () => {
5082
+ const content = 'base64 content';
5083
+ const base64 = btoa(content);
5084
+ const file = OneBunFile.fromBase64(base64, 'decoded.txt', 'text/plain');
5085
+
5086
+ expect(file.name).toBe('decoded.txt');
5087
+ expect(file.type).toStartWith('text/plain');
5088
+
5089
+ const roundTripped = await file.toBase64();
5090
+ expect(roundTripped).toBe(base64);
5091
+ });
5092
+ });
5093
+
5094
+ /**
5095
+ * @source docs/api/decorators.md#mimetype-enum
5096
+ */
5097
+ describe('MimeType Enum (docs/api/decorators.md#mimetype-enum)', () => {
5098
+ it('should provide common MIME type constants', () => {
5099
+ // Wildcards
5100
+ expect(String(MimeType.ANY)).toBe('*/*');
5101
+ expect(String(MimeType.ANY_IMAGE)).toBe('image/*');
5102
+ expect(String(MimeType.ANY_VIDEO)).toBe('video/*');
5103
+ expect(String(MimeType.ANY_AUDIO)).toBe('audio/*');
5104
+
5105
+ // Specific types
5106
+ expect(String(MimeType.PNG)).toBe('image/png');
5107
+ expect(String(MimeType.PDF)).toBe('application/pdf');
5108
+ expect(String(MimeType.MP4)).toBe('video/mp4');
5109
+
5110
+ // Wildcard matching
5111
+ expect(matchMimeType('image/png', MimeType.ANY_IMAGE)).toBe(true);
5112
+ expect(matchMimeType('video/mp4', MimeType.ANY_IMAGE)).toBe(false);
5113
+ });
5114
+ });
5115
+
5116
+ /**
5117
+ * @source docs/api/decorators.md#json-base64-upload-format
5118
+ */
5119
+ describe('JSON Base64 Upload (docs/api/decorators.md#json-base64-upload-format)', () => {
5120
+ it('should parse full JSON base64 format', () => {
5121
+ const base64 = btoa('png image data');
5122
+ const file = OneBunFile.fromBase64(base64, 'photo.png', 'image/png');
5123
+
5124
+ expect(file.name).toBe('photo.png');
5125
+ expect(file.type).toBe('image/png');
5126
+ });
5127
+
5128
+ it('should parse data URI format', () => {
5129
+ const base64 = btoa('svg data');
5130
+ const dataUri = `data:image/svg+xml;base64,${base64}`;
5131
+ const file = OneBunFile.fromBase64(dataUri, 'icon.svg');
5132
+
5133
+ expect(file.type).toBe('image/svg+xml');
5134
+ expect(file.name).toBe('icon.svg');
5135
+ });
5136
+ });
5137
+ });
5138
+
5139
+ describe('File Upload API Documentation (docs/api/controllers.md)', () => {
5140
+ /**
5141
+ * @source docs/api/controllers.md#single-file-upload
5142
+ */
5143
+ it('should define single file upload controller', () => {
5144
+ @Controller('/api/files')
5145
+ class FileController extends BaseController {
5146
+ @Post('/avatar')
5147
+ async uploadAvatar(
5148
+ @UploadedFile('avatar', {
5149
+ maxSize: 5 * 1024 * 1024,
5150
+ mimeTypes: [MimeType.ANY_IMAGE],
5151
+ }) file: OneBunFile,
5152
+ ): Promise<Response> {
5153
+ await file.writeTo(`./uploads/${file.name}`);
5154
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
5155
+ const _base64 = await file.toBase64();
5156
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
5157
+ const _buffer = await file.toBuffer();
5158
+
5159
+ return this.success({
5160
+ filename: file.name,
5161
+ size: file.size,
5162
+ type: file.type,
5163
+ });
5164
+ }
5165
+ }
5166
+
5167
+ expect(FileController).toBeDefined();
5168
+ });
5169
+
5170
+ /**
5171
+ * @source docs/api/controllers.md#multiple-file-upload
5172
+ */
5173
+ it('should define multiple file upload controller', () => {
5174
+ @Controller('/api/files')
5175
+ class FileController extends BaseController {
5176
+ @Post('/documents')
5177
+ async uploadDocuments(
5178
+ @UploadedFiles('docs', {
5179
+ maxCount: 10,
5180
+ maxSize: 10 * 1024 * 1024,
5181
+ mimeTypes: [MimeType.PDF, MimeType.DOCX],
5182
+ }) files: OneBunFile[],
5183
+ ): Promise<Response> {
5184
+ for (const file of files) {
5185
+ await file.writeTo(`./uploads/${file.name}`);
5186
+ }
5187
+
5188
+ return this.success({ uploaded: files.length });
5189
+ }
5190
+ }
5191
+
5192
+ expect(FileController).toBeDefined();
5193
+ });
5194
+
5195
+ /**
5196
+ * @source docs/api/controllers.md#file-with-form-fields
5197
+ */
5198
+ it('should define file with form fields controller', () => {
5199
+ @Controller('/api/files')
5200
+ class FileController extends BaseController {
5201
+ @Post('/profile')
5202
+ async createProfile(
5203
+ @UploadedFile('avatar', { mimeTypes: [MimeType.ANY_IMAGE] }) avatar: OneBunFile,
5204
+ @FormField('name', { required: true }) name: string,
5205
+ @FormField('email') email: string,
5206
+ ): Promise<Response> {
5207
+ await avatar.writeTo(`./uploads/${avatar.name}`);
5208
+
5209
+ return this.success({ name, email, avatar: avatar.name });
5210
+ }
5211
+ }
5212
+
5213
+ expect(FileController).toBeDefined();
5214
+ });
5215
+ });