@onebun/core 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/application/application.test.ts +36 -0
- package/src/application/application.ts +70 -6
- package/src/application/multi-service-application.ts +2 -0
- package/src/application/multi-service.types.ts +1 -1
- package/src/decorators/decorators.test.ts +63 -12
- package/src/decorators/decorators.ts +113 -7
- package/src/docs-examples.test.ts +793 -8
- package/src/index.ts +9 -0
- package/src/module/controller.ts +96 -10
- package/src/module/index.ts +2 -1
- package/src/module/lifecycle.ts +13 -0
- package/src/module/middleware.ts +76 -0
- package/src/module/module.test.ts +138 -1
- package/src/module/module.ts +127 -2
- package/src/types.ts +142 -0
package/src/index.ts
CHANGED
|
@@ -36,8 +36,11 @@ export {
|
|
|
36
36
|
type ParamDecoratorOptions,
|
|
37
37
|
type ParamMetadata,
|
|
38
38
|
type ResponseSchemaMetadata,
|
|
39
|
+
type RouteOptions,
|
|
39
40
|
type RouteMetadata,
|
|
40
41
|
type ControllerMetadata,
|
|
42
|
+
type MiddlewareClass,
|
|
43
|
+
type OnModuleConfigure,
|
|
41
44
|
// File upload types
|
|
42
45
|
type FileUploadOptions,
|
|
43
46
|
type FilesUploadOptions,
|
|
@@ -86,6 +89,8 @@ export {
|
|
|
86
89
|
type OnModuleDestroy,
|
|
87
90
|
type BeforeApplicationDestroy,
|
|
88
91
|
type OnApplicationDestroy,
|
|
92
|
+
// Middleware
|
|
93
|
+
BaseMiddleware,
|
|
89
94
|
// Lifecycle hooks helper functions
|
|
90
95
|
hasOnModuleInit,
|
|
91
96
|
hasOnApplicationInit,
|
|
@@ -100,6 +105,10 @@ export {
|
|
|
100
105
|
// SSE helpers
|
|
101
106
|
formatSseEvent,
|
|
102
107
|
createSseStream,
|
|
108
|
+
// Server & SSE default constants
|
|
109
|
+
DEFAULT_IDLE_TIMEOUT,
|
|
110
|
+
DEFAULT_SSE_HEARTBEAT_MS,
|
|
111
|
+
DEFAULT_SSE_TIMEOUT,
|
|
103
112
|
} from './module';
|
|
104
113
|
|
|
105
114
|
// Application
|
package/src/module/controller.ts
CHANGED
|
@@ -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
|
-
*
|
|
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, {
|
|
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:
|
|
260
|
+
source:
|
|
261
|
+
| AsyncIterable<SseEvent | unknown>
|
|
262
|
+
| ((signal: AbortSignal) => AsyncIterable<SseEvent | unknown>),
|
|
220
263
|
options: SseOptions = {},
|
|
221
264
|
): Response {
|
|
222
|
-
|
|
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:
|
|
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
|
-
|
|
418
|
+
while (true) {
|
|
344
419
|
if (isCancelled) {
|
|
345
420
|
break;
|
|
346
421
|
}
|
|
347
|
-
const
|
|
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
|
}
|
package/src/module/index.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Module System
|
|
3
3
|
*
|
|
4
|
-
* Core module, controller, and
|
|
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';
|
package/src/module/lifecycle.ts
CHANGED
|
@@ -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 {
|
|
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
|
});
|
package/src/module/module.ts
CHANGED
|
@@ -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
|
|
170
|
-
const
|
|
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
|
*/
|