@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.
@@ -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: Request,
324
- next: () => Promise<Response>,
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: Request,
334
- next: () => Promise<Response>,
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: Request,
568
- next: () => Promise<Response>,
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: Request,
580
- next: () => Promise<Response>,
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
  // ============================================================================