@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
|
@@ -38,6 +38,8 @@ 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
|
|
|
@@ -59,6 +61,7 @@ import {
|
|
|
59
61
|
BaseService,
|
|
60
62
|
BaseController,
|
|
61
63
|
UseMiddleware,
|
|
64
|
+
getControllerMiddleware,
|
|
62
65
|
getServiceTag,
|
|
63
66
|
getControllerMetadata,
|
|
64
67
|
HttpStatusCode,
|
|
@@ -118,6 +121,10 @@ import {
|
|
|
118
121
|
OneBunFile,
|
|
119
122
|
MimeType,
|
|
120
123
|
matchMimeType,
|
|
124
|
+
BaseMiddleware,
|
|
125
|
+
DEFAULT_IDLE_TIMEOUT,
|
|
126
|
+
DEFAULT_SSE_HEARTBEAT_MS,
|
|
127
|
+
DEFAULT_SSE_TIMEOUT,
|
|
121
128
|
} from './';
|
|
122
129
|
|
|
123
130
|
|
|
@@ -320,8 +327,8 @@ describe('Core README Examples', () => {
|
|
|
320
327
|
it('should use middleware decorator', () => {
|
|
321
328
|
// From README: Middleware example
|
|
322
329
|
function loggerMiddleware(
|
|
323
|
-
_req:
|
|
324
|
-
next: () => Promise<
|
|
330
|
+
_req: OneBunRequest,
|
|
331
|
+
next: () => Promise<OneBunResponse>,
|
|
325
332
|
): Promise<Response> {
|
|
326
333
|
// eslint-disable-next-line no-console
|
|
327
334
|
console.log('Request received');
|
|
@@ -330,8 +337,8 @@ describe('Core README Examples', () => {
|
|
|
330
337
|
}
|
|
331
338
|
|
|
332
339
|
function authMiddleware(
|
|
333
|
-
req:
|
|
334
|
-
next: () => Promise<
|
|
340
|
+
req: OneBunRequest,
|
|
341
|
+
next: () => Promise<OneBunResponse>,
|
|
335
342
|
): Promise<Response> {
|
|
336
343
|
const token = req.headers.get('Authorization');
|
|
337
344
|
if (!token) {
|
|
@@ -564,8 +571,8 @@ describe('Decorators API Documentation Examples', () => {
|
|
|
564
571
|
it('should apply middleware to route handler', () => {
|
|
565
572
|
// From docs: @UseMiddleware() example
|
|
566
573
|
const authMiddleware = async (
|
|
567
|
-
req:
|
|
568
|
-
next: () => Promise<
|
|
574
|
+
req: OneBunRequest,
|
|
575
|
+
next: () => Promise<OneBunResponse>,
|
|
569
576
|
) => {
|
|
570
577
|
const token = req.headers.get('Authorization');
|
|
571
578
|
if (!token) {
|
|
@@ -576,8 +583,8 @@ describe('Decorators API Documentation Examples', () => {
|
|
|
576
583
|
};
|
|
577
584
|
|
|
578
585
|
const logMiddleware = async (
|
|
579
|
-
_req:
|
|
580
|
-
next: () => Promise<
|
|
586
|
+
_req: OneBunRequest,
|
|
587
|
+
next: () => Promise<OneBunResponse>,
|
|
581
588
|
) => {
|
|
582
589
|
// eslint-disable-next-line no-console
|
|
583
590
|
console.log('Request logged');
|
|
@@ -602,6 +609,60 @@ describe('Decorators API Documentation Examples', () => {
|
|
|
602
609
|
|
|
603
610
|
expect(UserController).toBeDefined();
|
|
604
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
|
+
});
|
|
605
666
|
});
|
|
606
667
|
});
|
|
607
668
|
|
|
@@ -863,6 +924,430 @@ describe('Controllers API Documentation Examples', () => {
|
|
|
863
924
|
});
|
|
864
925
|
});
|
|
865
926
|
|
|
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()));
|
|
936
|
+
|
|
937
|
+
return response;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
expect(RequestLogMiddleware.prototype).toBeInstanceOf(BaseMiddleware);
|
|
942
|
+
// eslint-disable-next-line jest/unbound-method
|
|
943
|
+
expect(RequestLogMiddleware.prototype.use).toBeInstanceOf(Function);
|
|
944
|
+
});
|
|
945
|
+
});
|
|
946
|
+
|
|
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
|
+
}
|
|
956
|
+
|
|
957
|
+
return await next();
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
@Controller('/api')
|
|
962
|
+
class ApiController extends BaseController {
|
|
963
|
+
@Get('/public')
|
|
964
|
+
publicEndpoint() {
|
|
965
|
+
return { message: 'Anyone can see this' };
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
@Post('/protected')
|
|
969
|
+
@UseMiddleware(AuthMiddleware)
|
|
970
|
+
protectedEndpoint() {
|
|
971
|
+
return { message: 'Auth required' };
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const metadata = getControllerMetadata(ApiController);
|
|
976
|
+
expect(metadata).toBeDefined();
|
|
977
|
+
|
|
978
|
+
const publicRoute = metadata!.routes.find((r) => r.path === '/public');
|
|
979
|
+
expect(publicRoute?.middleware?.length ?? 0).toBe(0);
|
|
980
|
+
|
|
981
|
+
const protectedRoute = metadata!.routes.find((r) => r.path === '/protected');
|
|
982
|
+
expect(protectedRoute?.middleware).toHaveLength(1);
|
|
983
|
+
expect(protectedRoute!.middleware![0]).toBe(AuthMiddleware);
|
|
984
|
+
});
|
|
985
|
+
});
|
|
986
|
+
|
|
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
|
+
}
|
|
996
|
+
|
|
997
|
+
return await next();
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
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 };
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Controller-level middleware is stored separately
|
|
1016
|
+
const ctrlMiddleware = getControllerMiddleware(AdminController);
|
|
1017
|
+
expect(ctrlMiddleware).toHaveLength(1);
|
|
1018
|
+
expect(ctrlMiddleware[0]).toBe(AuthMiddleware);
|
|
1019
|
+
});
|
|
1020
|
+
|
|
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();
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
class AuditLogMiddleware extends BaseMiddleware {
|
|
1030
|
+
async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
|
|
1031
|
+
return await next();
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
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
|
+
}
|
|
1042
|
+
|
|
1043
|
+
@Put('/settings')
|
|
1044
|
+
@UseMiddleware(AuditLogMiddleware) // Runs second, only on this route
|
|
1045
|
+
updateSettings() {
|
|
1046
|
+
return { updated: true };
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
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);
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
|
|
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;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
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
|
+
|
|
866
1351
|
describe('Services API Documentation Examples', () => {
|
|
867
1352
|
describe('BaseService (docs/api/services.md)', () => {
|
|
868
1353
|
it('should create basic service', () => {
|
|
@@ -3734,6 +4219,91 @@ describe('SSE (Server-Sent Events) API Documentation (docs/api/controllers.md)',
|
|
|
3734
4219
|
// Should have the actual event (string data is not wrapped in extra quotes)
|
|
3735
4220
|
expect(output).toContain('data: done');
|
|
3736
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
|
+
});
|
|
3737
4307
|
});
|
|
3738
4308
|
|
|
3739
4309
|
describe('Controller.sse() Method', () => {
|
|
@@ -3765,6 +4335,79 @@ describe('SSE (Server-Sent Events) API Documentation (docs/api/controllers.md)',
|
|
|
3765
4335
|
|
|
3766
4336
|
expect(EventsController).toBeDefined();
|
|
3767
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
|
+
});
|
|
3768
4411
|
});
|
|
3769
4412
|
|
|
3770
4413
|
describe('Complete SSE Controller Example (docs/api/controllers.md)', () => {
|
|
@@ -3844,6 +4487,148 @@ describe('SSE (Server-Sent Events) API Documentation (docs/api/controllers.md)',
|
|
|
3844
4487
|
});
|
|
3845
4488
|
});
|
|
3846
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
|
+
|
|
3847
4632
|
// ============================================================================
|
|
3848
4633
|
// Cookie, Headers, @Req with OneBunRequest
|
|
3849
4634
|
// ============================================================================
|