@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.
@@ -9,6 +9,27 @@ import type { Context } from 'effect';
9
9
  import type { SyncLogger } from '@onebun/logger';
10
10
  import { HttpStatusCode } from '@onebun/requests';
11
11
 
12
+ /**
13
+ * Default idle timeout for HTTP connections (in seconds).
14
+ * Bun.serve closes idle connections after this period.
15
+ * @defaultValue 120
16
+ */
17
+ export const DEFAULT_IDLE_TIMEOUT = 120;
18
+
19
+ /**
20
+ * Default heartbeat interval for SSE connections (in milliseconds).
21
+ * Sends a comment (`: heartbeat\n\n`) to keep the connection alive.
22
+ * @defaultValue 30000 (30 seconds)
23
+ */
24
+ export const DEFAULT_SSE_HEARTBEAT_MS = 30_000;
25
+
26
+ /**
27
+ * Default per-request idle timeout for SSE connections (in seconds).
28
+ * SSE connections are long-lived by nature, so they get a longer timeout.
29
+ * @defaultValue 600 (10 minutes)
30
+ */
31
+ export const DEFAULT_SSE_TIMEOUT = 600;
32
+
12
33
  /**
13
34
  * Base controller class that can be extended to add common functionality
14
35
  */
@@ -189,8 +210,11 @@ export class Controller {
189
210
  * This method provides an alternative to the @Sse() decorator for creating
190
211
  * SSE responses programmatically.
191
212
  *
192
- * @param source - Async iterable that yields SseEvent objects or raw data
193
- * @param options - SSE options (heartbeat interval, etc.)
213
+ * @param source - Async iterable that yields SseEvent objects or raw data,
214
+ * or a factory function that receives an AbortSignal and returns an async iterable.
215
+ * The factory pattern is useful for SSE proxying -- pass the signal to `fetch()`
216
+ * to automatically abort upstream connections when the client disconnects.
217
+ * @param options - SSE options (heartbeat interval, onAbort callback, etc.)
194
218
  * @returns A Response object with SSE content type
195
219
  *
196
220
  * @example Basic usage
@@ -206,20 +230,51 @@ export class Controller {
206
230
  * }
207
231
  * ```
208
232
  *
209
- * @example With heartbeat
233
+ * @example With heartbeat and onAbort callback
210
234
  * ```typescript
211
235
  * @Get('/live')
212
236
  * live(): Response {
213
237
  * const updates = this.dataService.getUpdateStream();
214
- * return this.sse(updates, { heartbeat: 15000 });
238
+ * return this.sse(updates, {
239
+ * heartbeat: 15000,
240
+ * onAbort: () => this.dataService.unsubscribe(),
241
+ * });
242
+ * }
243
+ * ```
244
+ *
245
+ * @example Factory function with AbortSignal (SSE proxy)
246
+ * ```typescript
247
+ * @Get('/proxy')
248
+ * proxy(): Response {
249
+ * return this.sse((signal) => this.proxyUpstream(signal));
250
+ * }
251
+ *
252
+ * private async *proxyUpstream(signal: AbortSignal): SseGenerator {
253
+ * const response = await fetch('https://api.example.com/events', { signal });
254
+ * // parse and yield SSE events from upstream...
255
+ * // When client disconnects -> signal aborted -> fetch aborted automatically
215
256
  * }
216
257
  * ```
217
258
  */
218
259
  protected sse(
219
- source: AsyncIterable<SseEvent | unknown>,
260
+ source:
261
+ | AsyncIterable<SseEvent | unknown>
262
+ | ((signal: AbortSignal) => AsyncIterable<SseEvent | unknown>),
220
263
  options: SseOptions = {},
221
264
  ): Response {
222
- const stream = createSseStream(source, options);
265
+ let iterable: AsyncIterable<SseEvent | unknown>;
266
+ let onCancel: (() => void) | undefined;
267
+
268
+ if (typeof source === 'function') {
269
+ // Factory pattern: create an AbortController and pass its signal to the factory
270
+ const ac = new AbortController();
271
+ iterable = source(ac.signal);
272
+ onCancel = () => ac.abort();
273
+ } else {
274
+ iterable = source;
275
+ }
276
+
277
+ const stream = createSseStream(iterable, { ...options, _onCancel: onCancel });
223
278
 
224
279
  return new Response(stream, {
225
280
  status: HttpStatusCode.OK,
@@ -309,21 +364,41 @@ export function formatSseEvent(event: SseEvent | unknown): string {
309
364
  return result;
310
365
  }
311
366
 
367
+ /**
368
+ * Internal options for createSseStream, extending public SseOptions
369
+ * with private hooks used by controller.sse()
370
+ */
371
+ interface CreateSseStreamOptions extends SseOptions {
372
+ /**
373
+ * Internal cancel hook used by controller.sse() to abort
374
+ * the AbortController for factory-pattern SSE sources.
375
+ * @internal
376
+ */
377
+ _onCancel?: () => void;
378
+ }
379
+
312
380
  /**
313
381
  * Create a ReadableStream for SSE from an async iterable
314
382
  *
383
+ * Uses a manual async iterator so that `iterator.return()` can be called
384
+ * on stream cancellation, triggering the generator's `finally` blocks
385
+ * for proper cleanup (e.g., aborting upstream SSE connections).
386
+ *
315
387
  * @param source - Async iterable that yields events
316
- * @param options - SSE options
388
+ * @param options - SSE options (heartbeat, onAbort, etc.)
317
389
  * @returns ReadableStream for Response body
318
390
  */
319
391
  export function createSseStream(
320
392
  source: AsyncIterable<SseEvent | unknown>,
321
- options: SseOptions = {},
393
+ options: CreateSseStreamOptions = {},
322
394
  ): ReadableStream<Uint8Array> {
323
395
  const encoder = new TextEncoder();
324
396
  let heartbeatTimer: Timer | null = null;
325
397
  let isCancelled = false;
326
398
 
399
+ // Obtain manual iterator handle so we can call return() on cancel
400
+ const iterator = source[Symbol.asyncIterator]();
401
+
327
402
  return new ReadableStream<Uint8Array>({
328
403
  async start(controller) {
329
404
  // Set up heartbeat timer if specified
@@ -340,11 +415,15 @@ export function createSseStream(
340
415
  }
341
416
 
342
417
  try {
343
- for await (const event of source) {
418
+ while (true) {
344
419
  if (isCancelled) {
345
420
  break;
346
421
  }
347
- const formatted = formatSseEvent(event);
422
+ const { value, done } = await iterator.next();
423
+ if (done || isCancelled) {
424
+ break;
425
+ }
426
+ const formatted = formatSseEvent(value);
348
427
  controller.enqueue(encoder.encode(formatted));
349
428
  }
350
429
  } catch (error) {
@@ -374,6 +453,13 @@ export function createSseStream(
374
453
  clearInterval(heartbeatTimer);
375
454
  heartbeatTimer = null;
376
455
  }
456
+ // Force-terminate the generator, triggering its finally blocks
457
+ // This enables cleanup on client disconnect (e.g., aborting upstream SSE)
458
+ iterator.return?.(undefined);
459
+ // Fire user-provided onAbort callback
460
+ options.onAbort?.();
461
+ // Fire internal cancel hook (used by controller.sse() factory pattern)
462
+ options._onCancel?.();
377
463
  },
378
464
  });
379
465
  }
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Module System
3
3
  *
4
- * Core module, controller, and service abstractions.
4
+ * Core module, controller, service, and middleware abstractions.
5
5
  */
6
6
 
7
7
  export * from './module';
8
8
  export * from './controller';
9
9
  // Re-export Controller as BaseController for backward compatibility
10
10
  export { Controller as BaseController } from './controller';
11
+ export * from './middleware';
11
12
  export * from './service';
12
13
  export * from './config.service';
13
14
  export * from './config.interface';
@@ -83,6 +83,11 @@ export interface OnApplicationDestroy {
83
83
  onApplicationDestroy(signal?: string): Promise<void> | void;
84
84
  }
85
85
 
86
+ /**
87
+ * Interface for modules that configure middleware.
88
+ * Re-exported from types for convenience. Use the canonical import from types.ts.
89
+ */
90
+
86
91
  // =============================================================================
87
92
  // Helper functions for checking if an object implements lifecycle hooks
88
93
  // =============================================================================
@@ -147,6 +152,14 @@ export function hasOnApplicationDestroy(obj: unknown): obj is OnApplicationDestr
147
152
  );
148
153
  }
149
154
 
155
+ /**
156
+ * Check if a class (constructor) has a configureMiddleware method on its prototype.
157
+ * Used to detect modules that implement OnModuleConfigure.
158
+ */
159
+ export function hasConfigureMiddleware(cls: Function): boolean {
160
+ return typeof cls.prototype?.configureMiddleware === 'function';
161
+ }
162
+
150
163
  // =============================================================================
151
164
  // Helper functions to call lifecycle hooks safely
152
165
  // =============================================================================
@@ -0,0 +1,76 @@
1
+ import type { IConfig, OneBunAppConfig } from './config.interface';
2
+ import type { OneBunRequest, OneBunResponse } from '../types';
3
+
4
+ import type { SyncLogger } from '@onebun/logger';
5
+
6
+ /**
7
+ * Base class for all OneBun middleware.
8
+ *
9
+ * Extend this class to create middleware with access to the framework's
10
+ * logger (scoped to the middleware class name) and configuration.
11
+ * Constructor-based DI is fully supported — inject any service from
12
+ * the module's DI scope just like you would in a controller.
13
+ *
14
+ * Middleware is instantiated **once** at application startup and reused
15
+ * for every matching request.
16
+ *
17
+ * @example Simple middleware (no DI)
18
+ * ```typescript
19
+ * class LoggingMiddleware extends BaseMiddleware {
20
+ * async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
21
+ * this.logger.info(`${req.method} ${new URL(req.url).pathname}`);
22
+ * return next();
23
+ * }
24
+ * }
25
+ * ```
26
+ *
27
+ * @example Middleware with DI
28
+ * ```typescript
29
+ * class AuthMiddleware extends BaseMiddleware {
30
+ * constructor(private authService: AuthService) {
31
+ * super();
32
+ * }
33
+ *
34
+ * async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
35
+ * const secret = this.config.get('auth.jwtSecret');
36
+ * const token = req.headers.get('Authorization');
37
+ * if (!this.authService.verify(token, secret)) {
38
+ * this.logger.warn('Authentication failed');
39
+ * return new Response('Unauthorized', { status: 401 });
40
+ * }
41
+ * return next();
42
+ * }
43
+ * }
44
+ * ```
45
+ */
46
+ export abstract class BaseMiddleware {
47
+ /** Logger instance scoped to the middleware class name */
48
+ protected logger!: SyncLogger;
49
+
50
+ /** Configuration instance for accessing environment variables */
51
+ protected config!: IConfig<OneBunAppConfig>;
52
+
53
+ /**
54
+ * Initialize middleware with logger and config.
55
+ * Called by the framework after DI construction — do NOT call manually.
56
+ * @internal
57
+ */
58
+ initializeMiddleware(logger: SyncLogger, config: IConfig<OneBunAppConfig>): void {
59
+ const className = this.constructor.name;
60
+ this.logger = logger.child({ className });
61
+ this.config = config;
62
+ this.logger.debug(`Middleware ${className} initialized`);
63
+ }
64
+
65
+ /**
66
+ * The middleware handler.
67
+ *
68
+ * @param req - The incoming request (OneBunRequest with `.cookies`, `.params`, etc.)
69
+ * @param next - Call to invoke the next middleware or the route handler
70
+ * @returns A response — either from `next()` or a short-circuit response
71
+ */
72
+ abstract use(
73
+ req: OneBunRequest,
74
+ next: () => Promise<OneBunResponse>,
75
+ ): Promise<OneBunResponse> | OneBunResponse;
76
+ }
@@ -16,9 +16,19 @@ import {
16
16
  Layer,
17
17
  } from 'effect';
18
18
 
19
- import { Module } from '../decorators/decorators';
19
+ import type {
20
+ MiddlewareClass,
21
+ OneBunRequest,
22
+ OneBunResponse,
23
+ OnModuleConfigure,
24
+ } from '../types';
25
+
26
+
27
+ import { Controller as CtrlDeco, Module } from '../decorators/decorators';
20
28
  import { makeMockLoggerLayer } from '../testing/test-utils';
21
29
 
30
+ import { Controller as CtrlBase } from './controller';
31
+ import { BaseMiddleware } from './middleware';
22
32
  import { OneBunModule } from './module';
23
33
  import { Service } from './service';
24
34
 
@@ -1275,4 +1285,131 @@ describe('OneBunModule', () => {
1275
1285
  expect(controller.getLabel()).toBe('shared');
1276
1286
  });
1277
1287
  });
1288
+
1289
+ describe('Module-level middleware (OnModuleConfigure)', () => {
1290
+ class Mw1 extends BaseMiddleware {
1291
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1292
+ return await next();
1293
+ }
1294
+ }
1295
+
1296
+ class Mw2 extends BaseMiddleware {
1297
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1298
+ return await next();
1299
+ }
1300
+ }
1301
+
1302
+ class Mw3 extends BaseMiddleware {
1303
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1304
+ return await next();
1305
+ }
1306
+ }
1307
+
1308
+ test('should collect middleware from module implementing OnModuleConfigure', async () => {
1309
+ @CtrlDeco('/test')
1310
+ class TestController extends CtrlBase {}
1311
+
1312
+ @Module({ controllers: [TestController] })
1313
+ class TestModule implements OnModuleConfigure {
1314
+ configureMiddleware(): MiddlewareClass[] {
1315
+ return [Mw1, Mw2];
1316
+ }
1317
+ }
1318
+
1319
+ const module = new OneBunModule(TestModule, mockLoggerLayer);
1320
+ await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
1321
+
1322
+ const middleware = module.getModuleMiddleware(TestController);
1323
+ expect(middleware).toHaveLength(2);
1324
+ // Resolved middleware are bound use() functions
1325
+ expect(typeof middleware[0]).toBe('function');
1326
+ expect(typeof middleware[1]).toBe('function');
1327
+ });
1328
+
1329
+ test('should return empty middleware for modules without OnModuleConfigure', async () => {
1330
+ @CtrlDeco('/test')
1331
+ class TestController extends CtrlBase {}
1332
+
1333
+ @Module({ controllers: [TestController] })
1334
+ class PlainModule {}
1335
+
1336
+ const module = new OneBunModule(PlainModule, mockLoggerLayer);
1337
+ await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
1338
+
1339
+ const middleware = module.getModuleMiddleware(TestController);
1340
+ expect(middleware).toHaveLength(0);
1341
+ });
1342
+
1343
+ test('should accumulate middleware from parent to child modules', async () => {
1344
+ @CtrlDeco('/child')
1345
+ class ChildController extends CtrlBase {}
1346
+
1347
+ @Module({ controllers: [ChildController] })
1348
+ class ChildModule implements OnModuleConfigure {
1349
+ configureMiddleware(): MiddlewareClass[] {
1350
+ return [Mw2];
1351
+ }
1352
+ }
1353
+
1354
+ @CtrlDeco('/root')
1355
+ class RootController extends CtrlBase {}
1356
+
1357
+ @Module({ imports: [ChildModule], controllers: [RootController] })
1358
+ class RootModule implements OnModuleConfigure {
1359
+ configureMiddleware(): MiddlewareClass[] {
1360
+ return [Mw1];
1361
+ }
1362
+ }
1363
+
1364
+ const module = new OneBunModule(RootModule, mockLoggerLayer);
1365
+ await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
1366
+
1367
+ // ChildController should have accumulated: [Mw1 (root), Mw2 (child)]
1368
+ const childMw = module.getModuleMiddleware(ChildController);
1369
+ expect(childMw).toHaveLength(2);
1370
+ expect(typeof childMw[0]).toBe('function');
1371
+ expect(typeof childMw[1]).toBe('function');
1372
+
1373
+ // RootController should have only root middleware: [Mw1]
1374
+ const rootMw = module.getModuleMiddleware(RootController);
1375
+ expect(rootMw).toHaveLength(1);
1376
+ expect(typeof rootMw[0]).toBe('function');
1377
+ });
1378
+
1379
+ test('should handle deeply nested module middleware', async () => {
1380
+ @CtrlDeco('/deep')
1381
+ class DeepController extends CtrlBase {}
1382
+
1383
+ @Module({ controllers: [DeepController] })
1384
+ class DeepModule implements OnModuleConfigure {
1385
+ configureMiddleware(): MiddlewareClass[] {
1386
+ return [Mw3];
1387
+ }
1388
+ }
1389
+
1390
+ @Module({ imports: [DeepModule] })
1391
+ class MiddleModule implements OnModuleConfigure {
1392
+ configureMiddleware(): MiddlewareClass[] {
1393
+ return [Mw2];
1394
+ }
1395
+ }
1396
+
1397
+ @Module({ imports: [MiddleModule] })
1398
+ class TopModule implements OnModuleConfigure {
1399
+ configureMiddleware(): MiddlewareClass[] {
1400
+ return [Mw1];
1401
+ }
1402
+ }
1403
+
1404
+ const module = new OneBunModule(TopModule, mockLoggerLayer);
1405
+ await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
1406
+
1407
+ // DeepController should get: [Mw1 (top), Mw2 (middle), Mw3 (deep)]
1408
+ const middleware = module.getModuleMiddleware(DeepController);
1409
+ expect(middleware).toHaveLength(3);
1410
+ expect(typeof middleware[0]).toBe('function');
1411
+ expect(typeof middleware[1]).toBe('function');
1412
+ expect(typeof middleware[2]).toBe('function');
1413
+ });
1414
+ });
1278
1415
  });
@@ -35,7 +35,9 @@ import {
35
35
  hasOnModuleDestroy,
36
36
  hasBeforeApplicationDestroy,
37
37
  hasOnApplicationDestroy,
38
+ hasConfigureMiddleware,
38
39
  } from './lifecycle';
40
+ import { BaseMiddleware } from './middleware';
39
41
  import {
40
42
  BaseService,
41
43
  getServiceMetadata,
@@ -83,10 +85,34 @@ export class OneBunModule implements ModuleInstance {
83
85
  private logger: SyncLogger;
84
86
  private config: IConfig<OneBunAppConfig>;
85
87
 
88
+ /**
89
+ * Middleware class constructors defined by this module via OnModuleConfigure.configureMiddleware()
90
+ */
91
+ private ownMiddlewareClasses: Function[] = [];
92
+
93
+ /**
94
+ * Accumulated middleware class constructors from ancestor modules (parent → child).
95
+ * Does NOT include this module's own middleware.
96
+ */
97
+ private ancestorMiddlewareClasses: Function[] = [];
98
+
99
+ /**
100
+ * Resolved middleware functions (bound use() methods) for this module's own middleware.
101
+ * Populated during setup() after services are created.
102
+ */
103
+ private resolvedOwnMiddleware: Function[] = [];
104
+
105
+ /**
106
+ * Resolved middleware functions from ancestor modules.
107
+ * Populated during setup() after services are created.
108
+ */
109
+ private resolvedAncestorMiddleware: Function[] = [];
110
+
86
111
  constructor(
87
112
  private moduleClass: Function,
88
113
  private loggerLayer?: Layer.Layer<never, never, unknown>,
89
114
  config?: IConfig<OneBunAppConfig>,
115
+ ancestorMiddleware?: Function[],
90
116
  ) {
91
117
  // Initialize logger with module class name as context
92
118
  const effectLogger = Effect.runSync(
@@ -99,6 +125,23 @@ export class OneBunModule implements ModuleInstance {
99
125
  ) as Logger;
100
126
  this.logger = createSyncLogger(effectLogger);
101
127
  this.config = config ?? new NotInitializedConfig();
128
+ this.ancestorMiddlewareClasses = ancestorMiddleware ?? [];
129
+
130
+ // Read module-level middleware from OnModuleConfigure interface
131
+ if (hasConfigureMiddleware(moduleClass)) {
132
+ try {
133
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
134
+ const moduleInstance = new (moduleClass as new () => any)();
135
+ this.ownMiddlewareClasses = moduleInstance.configureMiddleware();
136
+ this.logger.debug(
137
+ `Module ${moduleClass.name} configured ${this.ownMiddlewareClasses.length} middleware class(es)`,
138
+ );
139
+ } catch (error) {
140
+ this.logger.error(
141
+ `Failed to call configureMiddleware() on module ${moduleClass.name}: ${error}`,
142
+ );
143
+ }
144
+ }
102
145
 
103
146
  this.logger.debug(`Initializing OneBunModule for ${moduleClass.name}`);
104
147
  const { layer, controllers } = this.initModule();
@@ -166,8 +209,9 @@ export class OneBunModule implements ModuleInstance {
166
209
  continue;
167
210
  }
168
211
 
169
- // Pass the logger layer and config to child modules
170
- const childModule = new OneBunModule(importModule, this.loggerLayer, this.config);
212
+ // Pass the logger layer, config, and accumulated middleware class refs to child modules
213
+ const accumulatedMiddleware = [...this.ancestorMiddlewareClasses, ...this.ownMiddlewareClasses];
214
+ const childModule = new OneBunModule(importModule, this.loggerLayer, this.config, accumulatedMiddleware);
171
215
  this.childModules.push(childModule);
172
216
 
173
217
  // Merge layers
@@ -392,6 +436,57 @@ export class OneBunModule implements ModuleInstance {
392
436
  return exported;
393
437
  }
394
438
 
439
+ /**
440
+ * Instantiate middleware classes with DI from this module's service scope.
441
+ * Resolves constructor dependencies, calls initializeMiddleware(), and
442
+ * returns bound use() functions ready for the execution pipeline.
443
+ */
444
+ resolveMiddleware(classes: Function[]): Function[] {
445
+ return classes.map((cls) => {
446
+ // Resolve constructor dependencies (same logic as for controllers)
447
+ const paramTypes = getConstructorParamTypes(cls);
448
+ const deps: unknown[] = [];
449
+
450
+ if (paramTypes && paramTypes.length > 0) {
451
+ for (const paramType of paramTypes) {
452
+ const dep = this.resolveDependencyByType(paramType);
453
+ if (dep) {
454
+ deps.push(dep);
455
+ } else {
456
+ this.logger.warn(
457
+ `Could not resolve dependency ${paramType.name} for middleware ${cls.name}`,
458
+ );
459
+ }
460
+ }
461
+ }
462
+
463
+ const middlewareConstructor = cls as new (...args: unknown[]) => BaseMiddleware;
464
+ const instance = new middlewareConstructor(...deps);
465
+ instance.initializeMiddleware(this.logger, this.config);
466
+
467
+ return instance.use.bind(instance);
468
+ });
469
+ }
470
+
471
+ /**
472
+ * Resolve own and ancestor middleware classes into bound functions.
473
+ * Recursively resolves middleware for all child modules too.
474
+ * Must be called after services are created (DI scope is complete).
475
+ * @internal
476
+ */
477
+ private resolveModuleMiddleware(): void {
478
+ if (this.ancestorMiddlewareClasses.length > 0) {
479
+ this.resolvedAncestorMiddleware = this.resolveMiddleware(this.ancestorMiddlewareClasses);
480
+ }
481
+ if (this.ownMiddlewareClasses.length > 0) {
482
+ this.resolvedOwnMiddleware = this.resolveMiddleware(this.ownMiddlewareClasses);
483
+ }
484
+ // Recursively resolve for all descendant modules
485
+ for (const childModule of this.childModules) {
486
+ childModule.resolveModuleMiddleware();
487
+ }
488
+ }
489
+
395
490
  /**
396
491
  * Create controller instances and inject services
397
492
  */
@@ -550,6 +645,13 @@ export class OneBunModule implements ModuleInstance {
550
645
  discard: true,
551
646
  }),
552
647
  ),
648
+ // Resolve module-level middleware with DI (services are now available)
649
+ // resolveModuleMiddleware is recursive and handles all descendants
650
+ Effect.flatMap(() =>
651
+ Effect.sync(() => {
652
+ this.resolveModuleMiddleware();
653
+ }),
654
+ ),
553
655
  // Create controller instances in child modules first, then this module (each uses its own DI scope)
554
656
  Effect.flatMap(() =>
555
657
  Effect.forEach(this.childModules, (childModule) => childModule.createControllerInstances(), {
@@ -808,6 +910,29 @@ export class OneBunModule implements ModuleInstance {
808
910
  return instance;
809
911
  }
810
912
 
913
+ /**
914
+ * Get accumulated module-level middleware (resolved bound functions)
915
+ * for a controller class. Returns [...ancestorResolved, ...ownResolved]
916
+ * for controllers that belong to this module, or delegates to child modules.
917
+ */
918
+ getModuleMiddleware(controllerClass: Function): Function[] {
919
+ // Check if this module directly owns the controller
920
+ if (this.controllers.includes(controllerClass)) {
921
+ return [...this.resolvedAncestorMiddleware, ...this.resolvedOwnMiddleware];
922
+ }
923
+
924
+ // Delegate to child modules
925
+ for (const childModule of this.childModules) {
926
+ const middleware = childModule.getModuleMiddleware(controllerClass);
927
+ if (middleware.length > 0) {
928
+ return middleware;
929
+ }
930
+ }
931
+
932
+ // Controller not found in this module tree (return empty — no module middleware)
933
+ return [];
934
+ }
935
+
811
936
  /**
812
937
  * Get all controller instances from this module and child modules (recursive).
813
938
  */