@onebun/core 0.2.6 → 0.2.8

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.
Files changed (33) hide show
  1. package/package.json +6 -6
  2. package/src/application/application.test.ts +350 -7
  3. package/src/application/application.ts +537 -254
  4. package/src/application/multi-service-application.test.ts +15 -0
  5. package/src/application/multi-service-application.ts +2 -0
  6. package/src/application/multi-service.types.ts +7 -1
  7. package/src/decorators/decorators.ts +213 -0
  8. package/src/docs-examples.test.ts +386 -3
  9. package/src/exception-filters/exception-filters.test.ts +172 -0
  10. package/src/exception-filters/exception-filters.ts +129 -0
  11. package/src/exception-filters/http-exception.ts +22 -0
  12. package/src/exception-filters/index.ts +2 -0
  13. package/src/file/onebun-file.ts +8 -2
  14. package/src/http-guards/http-guards.test.ts +230 -0
  15. package/src/http-guards/http-guards.ts +173 -0
  16. package/src/http-guards/index.ts +1 -0
  17. package/src/index.ts +10 -0
  18. package/src/module/module.test.ts +78 -0
  19. package/src/module/module.ts +55 -7
  20. package/src/queue/docs-examples.test.ts +72 -12
  21. package/src/queue/index.ts +4 -0
  22. package/src/queue/queue-service-proxy.test.ts +82 -0
  23. package/src/queue/queue-service-proxy.ts +114 -0
  24. package/src/queue/types.ts +2 -2
  25. package/src/security/cors-middleware.ts +212 -0
  26. package/src/security/index.ts +19 -0
  27. package/src/security/rate-limit-middleware.ts +276 -0
  28. package/src/security/security-headers-middleware.ts +188 -0
  29. package/src/security/security.test.ts +285 -0
  30. package/src/testing/index.ts +1 -0
  31. package/src/testing/testing-module.test.ts +199 -0
  32. package/src/testing/testing-module.ts +252 -0
  33. package/src/types.ts +153 -3
@@ -0,0 +1,252 @@
1
+ /**
2
+ * TestingModule — lightweight harness for testing OneBun controllers and services
3
+ * without the full application startup overhead.
4
+ *
5
+ * Usage:
6
+ * ```typescript
7
+ * const module = await TestingModule
8
+ * .create({ controllers: [UserController], providers: [UserService] })
9
+ * .overrideProvider(UserService).useValue(mockUserService)
10
+ * .compile();
11
+ *
12
+ * const response = await module.inject('GET', '/users/1');
13
+ * expect(response.ok).toBe(true);
14
+ *
15
+ * await module.close();
16
+ * ```
17
+ */
18
+
19
+ import type { OneBunApplication } from '../application/application';
20
+ import type { HttpMethod, OneBunResponse } from '../types';
21
+ import type { Context } from 'effect';
22
+
23
+ import { Module } from '../decorators/decorators';
24
+ import { getServiceTag } from '../module/service';
25
+
26
+ import { makeMockLoggerLayer } from './test-utils';
27
+
28
+ // Re-export for convenience
29
+ export { makeMockLoggerLayer };
30
+
31
+ // ============================================================================
32
+ // Types
33
+ // ============================================================================
34
+
35
+ interface TestingModuleCreateOptions {
36
+ /** Module classes to import (already decorated with @Module) */
37
+ imports?: Function[];
38
+ /** Controller classes to include */
39
+ controllers?: Function[];
40
+ /** Service/provider classes to include */
41
+ providers?: Function[];
42
+ }
43
+
44
+ interface InjectOptions {
45
+ /** Request body (will be JSON-serialised) */
46
+ body?: unknown;
47
+ /** Extra headers */
48
+ headers?: Record<string, string>;
49
+ /** Query parameters */
50
+ query?: Record<string, string>;
51
+ }
52
+
53
+ type OverrideBuilder = {
54
+ /** Replace the service with a plain value / mock instance */
55
+ useValue(val: unknown): TestingModule;
56
+ /** Replace the service with an instance of the given class */
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
+ useClass(cls: new (...args: any[]) => unknown): TestingModule;
59
+ };
60
+
61
+ // ============================================================================
62
+ // CompiledTestingModule
63
+ // ============================================================================
64
+
65
+ /**
66
+ * Result of `TestingModule.compile()`.
67
+ * Provides `inject()` for HTTP calls and `get()` for service access.
68
+ * Call `close()` when done to release the underlying server.
69
+ */
70
+ export class CompiledTestingModule {
71
+ constructor(
72
+ private readonly app: OneBunApplication,
73
+ private readonly port: number,
74
+ ) {}
75
+
76
+ /**
77
+ * Get a service instance by its class constructor.
78
+ * Useful for asserting service state after a request.
79
+ *
80
+ * @param serviceClass - The `@Service()`-decorated class
81
+ * @returns The service instance registered in the module
82
+ * @throws If the service is not found
83
+ */
84
+ get<T>(serviceClass: new (...args: unknown[]) => T): T {
85
+ return this.app.getService(serviceClass) as T;
86
+ }
87
+
88
+ /**
89
+ * Send a fake HTTP request to the testing application.
90
+ * No real network call is made — the request goes through the full
91
+ * middleware → guards → filters → handler pipeline.
92
+ *
93
+ * @param method - HTTP method (GET, POST, PUT, DELETE, etc.)
94
+ * @param path - URL path (e.g. '/users/1')
95
+ * @param options - Optional body, headers, query params
96
+ */
97
+ async inject(
98
+ method: HttpMethod | string,
99
+ path: string,
100
+ options?: InjectOptions,
101
+ ): Promise<OneBunResponse> {
102
+ let url = `http://localhost:${this.port}${path}`;
103
+
104
+ if (options?.query && Object.keys(options.query).length > 0) {
105
+ const qs = new URLSearchParams(options.query).toString();
106
+ url = `${url}?${qs}`;
107
+ }
108
+
109
+ const headers = new Headers(options?.headers);
110
+ if (!headers.has('content-type')) {
111
+ headers.set('content-type', 'application/json');
112
+ }
113
+
114
+ // Use the native undici fetch to bypass any global `fetch` mock that may be set by
115
+ // other concurrent test files. This ensures real network calls reach the test server.
116
+
117
+ const nativeFetch = (require('undici') as { fetch: typeof fetch }).fetch ?? globalThis.fetch;
118
+
119
+ return await nativeFetch(url, {
120
+ method,
121
+ headers,
122
+ body: options?.body !== undefined ? JSON.stringify(options.body) : undefined,
123
+ }) as OneBunResponse;
124
+ }
125
+
126
+ /**
127
+ * Stop the test server and release resources.
128
+ * Call this in `afterEach` / `afterAll` to prevent port leaks.
129
+ */
130
+ async close(): Promise<void> {
131
+ await this.app.stop?.({ closeSharedRedis: false });
132
+ }
133
+ }
134
+
135
+ // ============================================================================
136
+ // TestingModule
137
+ // ============================================================================
138
+
139
+ /**
140
+ * Fluent builder for creating isolated test environments for OneBun modules.
141
+ *
142
+ * @example Basic usage
143
+ * ```typescript
144
+ * describe('UserController', () => {
145
+ * let module: CompiledTestingModule;
146
+ *
147
+ * beforeEach(async () => {
148
+ * module = await TestingModule
149
+ * .create({ controllers: [UserController], providers: [UserService] })
150
+ * .compile();
151
+ * });
152
+ *
153
+ * afterEach(() => module.close());
154
+ *
155
+ * it('returns 200 for GET /users', async () => {
156
+ * const res = await module.inject('GET', '/users');
157
+ * expect(res.ok).toBe(true);
158
+ * });
159
+ * });
160
+ * ```
161
+ *
162
+ * @example With provider overrides
163
+ * ```typescript
164
+ * const mockService = { getUser: () => ({ id: 1, name: 'Test' }) };
165
+ *
166
+ * module = await TestingModule
167
+ * .create({ controllers: [UserController], providers: [UserService] })
168
+ * .overrideProvider(UserService).useValue(mockService)
169
+ * .compile();
170
+ * ```
171
+ */
172
+ export class TestingModule {
173
+ private readonly options: TestingModuleCreateOptions;
174
+ private readonly overrides: Array<{
175
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
176
+ tag: Context.Tag<any, any>;
177
+ value: unknown;
178
+ }> = [];
179
+
180
+ private constructor(options: TestingModuleCreateOptions) {
181
+ this.options = options;
182
+ }
183
+
184
+ /**
185
+ * Create a new TestingModule builder.
186
+ *
187
+ * @param options - Module options (controllers, providers, imports)
188
+ */
189
+ static create(options: TestingModuleCreateOptions): TestingModule {
190
+ return new TestingModule(options);
191
+ }
192
+
193
+ /**
194
+ * Override a provider with a mock value or class.
195
+ * Overrides are applied before `setup()` so controllers receive mocks at construction time.
196
+ *
197
+ * @param serviceClass - The `@Service()`-decorated class to override
198
+ */
199
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
200
+ overrideProvider<T>(serviceClass: new (...args: any[]) => T): OverrideBuilder {
201
+ return {
202
+ useValue: (val: unknown): TestingModule => {
203
+ this.overrides.push({ tag: getServiceTag(serviceClass), value: val });
204
+
205
+ return this;
206
+ },
207
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
208
+ useClass: (cls: new (...args: any[]) => unknown): TestingModule => {
209
+ this.overrides.push({ tag: getServiceTag(serviceClass), value: new cls() });
210
+
211
+ return this;
212
+ },
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Compile the testing module and start the test server.
218
+ * Returns a `CompiledTestingModule` with `inject()` and `get()` methods.
219
+ *
220
+ * @returns Compiled module ready for testing
221
+ */
222
+ async compile(): Promise<CompiledTestingModule> {
223
+ // Lazily import to avoid circular dependencies at module parse time
224
+ // eslint-disable-next-line @typescript-eslint/naming-convention
225
+ const { OneBunApplication } = await import('../application/application');
226
+
227
+ // Build a synthetic module class from the provided options
228
+ const { controllers = [], providers = [], imports = [] } = this.options;
229
+
230
+ class _TestingAppModule {}
231
+ Module({
232
+ controllers,
233
+ providers: providers as unknown[],
234
+ imports,
235
+ })(_TestingAppModule);
236
+
237
+ // Create the application with:
238
+ // - port 0 → OS picks a free port
239
+ // - silent logger
240
+ // - test provider overrides injected before setup()
241
+ const app = new OneBunApplication(_TestingAppModule, {
242
+ port: 0,
243
+ loggerLayer: makeMockLoggerLayer() as import('effect').Layer.Layer<import('@onebun/logger').Logger>,
244
+ gracefulShutdown: false,
245
+ _testProviders: this.overrides,
246
+ });
247
+
248
+ await app.start();
249
+
250
+ return new CompiledTestingModule(app, app.getPort());
251
+ }
252
+ }
package/src/types.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import type { BaseMiddleware } from './module/middleware';
2
+ import type { QueueAdapterConstructor } from './queue/types';
2
3
  import type { Type } from 'arktype';
3
- import type { Effect, Layer } from 'effect';
4
+ import type {
5
+ Context,
6
+ Effect,
7
+ Layer,
8
+ } from 'effect';
4
9
 
5
10
  import type { Logger, LoggerOptions } from '@onebun/logger';
6
11
 
12
+
7
13
  /**
8
14
  * HTTP Request type used in OneBun controllers.
9
15
  * Extends standard Web API Request with:
@@ -175,6 +181,11 @@ export interface ModuleInstance {
175
181
  * using this module's DI scope (services + logger + config).
176
182
  */
177
183
  resolveMiddleware?(classes: Function[]): Function[];
184
+
185
+ /**
186
+ * Register a service instance by tag (e.g. before setup() for application-provided services like QueueService proxy).
187
+ */
188
+ registerService?<T>(tag: Context.Tag<unknown, T>, instance: T): void;
178
189
  }
179
190
 
180
191
  /**
@@ -422,6 +433,12 @@ export interface ApplicationOptions {
422
433
  */
423
434
  queue?: QueueApplicationOptions;
424
435
 
436
+ /**
437
+ * Static file serving: serve files from a directory for requests not matched by API routes.
438
+ * Optional path prefix and fallback file (e.g. index.html) for SPA-style routing.
439
+ */
440
+ static?: StaticApplicationOptions;
441
+
425
442
  /**
426
443
  * Documentation configuration (OpenAPI/Swagger)
427
444
  */
@@ -454,6 +471,71 @@ export interface ApplicationOptions {
454
471
  * @defaultValue true
455
472
  */
456
473
  gracefulShutdown?: boolean;
474
+
475
+ /**
476
+ * Global exception filters applied to all routes.
477
+ * Route-level and controller-level filters take priority over global ones.
478
+ * If no filters match, the built-in default filter is used.
479
+ *
480
+ * @example
481
+ * ```typescript
482
+ * const app = new OneBunApplication(AppModule, {
483
+ * filters: [new GlobalExceptionFilter()],
484
+ * });
485
+ * ```
486
+ */
487
+ filters?: import('./exception-filters/exception-filters').ExceptionFilter[];
488
+
489
+ /**
490
+ * CORS configuration. When provided, `CorsMiddleware` is automatically prepended
491
+ * to the global middleware chain.
492
+ *
493
+ * @example
494
+ * ```typescript
495
+ * const app = new OneBunApplication(AppModule, {
496
+ * cors: { origin: 'https://my-frontend.example.com', credentials: true },
497
+ * });
498
+ * ```
499
+ */
500
+ cors?: import('./security/cors-middleware').CorsOptions | true;
501
+
502
+ /**
503
+ * Rate limiting configuration. When provided, `RateLimitMiddleware` is automatically
504
+ * prepended to the global middleware chain (after CORS, before other middleware).
505
+ *
506
+ * @example
507
+ * ```typescript
508
+ * const app = new OneBunApplication(AppModule, {
509
+ * rateLimit: { windowMs: 60_000, max: 100 },
510
+ * });
511
+ * ```
512
+ */
513
+ rateLimit?: import('./security/rate-limit-middleware').RateLimitOptions | true;
514
+
515
+ /**
516
+ * Security headers configuration. When provided, `SecurityHeadersMiddleware` is
517
+ * automatically appended to the global middleware chain.
518
+ * Pass `true` to use all defaults (equivalent to no options).
519
+ *
520
+ * @example
521
+ * ```typescript
522
+ * const app = new OneBunApplication(AppModule, {
523
+ * security: { strictTransportSecurity: false }, // disable HSTS for local dev
524
+ * });
525
+ * ```
526
+ */
527
+ security?: import('./security/security-headers-middleware').SecurityHeadersOptions | true;
528
+
529
+ /**
530
+ * Pre-register service instances for testing.
531
+ * These are injected before `setup()` so controllers receive mocks at construction time.
532
+ * @internal Use `TestingModule` instead of setting this directly.
533
+ */
534
+ _testProviders?: Array<{
535
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
536
+ tag: Context.Tag<any, any>;
537
+ value: unknown;
538
+ }>;
457
539
  }
458
540
 
459
541
  /**
@@ -467,8 +549,10 @@ export type QueueAdapterType = 'memory' | 'redis';
467
549
  export interface QueueApplicationOptions {
468
550
  /** Enable/disable queue (default: auto - enabled if handlers exist) */
469
551
  enabled?: boolean;
470
- /** Adapter type or custom adapter instance */
471
- adapter?: QueueAdapterType;
552
+ /** Adapter type, or custom adapter constructor (e.g. for NATS JetStream) */
553
+ adapter?: QueueAdapterType | QueueAdapterConstructor;
554
+ /** Options passed to the custom adapter constructor when adapter is a class */
555
+ options?: unknown;
472
556
  /** Redis-specific options (only used when adapter is 'redis') */
473
557
  redis?: {
474
558
  /** Use shared Redis provider instead of dedicated connection */
@@ -528,6 +612,39 @@ export interface WebSocketApplicationOptions {
528
612
  maxPayload?: number;
529
613
  }
530
614
 
615
+ /**
616
+ * Static file serving configuration for OneBunApplication.
617
+ * Serves files from a directory for requests not matched by API/ws/docs/metrics routes.
618
+ */
619
+ export interface StaticApplicationOptions {
620
+ /**
621
+ * Filesystem path to the directory to serve (static root).
622
+ * Can be absolute or relative to the process cwd.
623
+ */
624
+ root: string;
625
+
626
+ /**
627
+ * URL path prefix under which static files are served.
628
+ * Empty or '/' means "everything not matched by API routes".
629
+ * Example: '/app' — only paths starting with /app are served from static root
630
+ * (the prefix is stripped when resolving the file path).
631
+ */
632
+ pathPrefix?: string;
633
+
634
+ /**
635
+ * Fallback file name (e.g. 'index.html') for SPA-style client-side routing.
636
+ * When the requested file is not found, this file under static root is returned instead.
637
+ */
638
+ fallbackFile?: string;
639
+
640
+ /**
641
+ * TTL in milliseconds for caching file existence checks.
642
+ * Reduces disk I/O for repeated requests. Use 0 to disable caching.
643
+ * @defaultValue 60000
644
+ */
645
+ fileExistenceCacheTtlMs?: number;
646
+ }
647
+
531
648
  /**
532
649
  * Documentation configuration for OneBunApplication
533
650
  * Enables automatic OpenAPI spec generation and Swagger UI
@@ -712,6 +829,35 @@ export interface RouteOptions {
712
829
  timeout?: number;
713
830
  }
714
831
 
832
+ /**
833
+ * HTTP Execution Context provided to HTTP guards.
834
+ * Gives read-only access to the incoming request and routing information.
835
+ */
836
+ export interface HttpExecutionContext {
837
+ /** Returns the incoming request object */
838
+ getRequest(): OneBunRequest;
839
+ /** Returns the handler method name (e.g. 'getUser') */
840
+ getHandler(): string;
841
+ /** Returns the controller class name (e.g. 'UserController') */
842
+ getController(): string;
843
+ }
844
+
845
+ /**
846
+ * HTTP Guard interface — implement to protect routes via `@UseGuards()`.
847
+ *
848
+ * @example
849
+ * ```typescript
850
+ * class MyGuard implements HttpGuard {
851
+ * canActivate(ctx: HttpExecutionContext): boolean {
852
+ * return ctx.getRequest().headers.get('x-api-key') === 'secret';
853
+ * }
854
+ * }
855
+ * ```
856
+ */
857
+ export interface HttpGuard {
858
+ canActivate(context: HttpExecutionContext): boolean | Promise<boolean>;
859
+ }
860
+
715
861
  /**
716
862
  * Route metadata
717
863
  */
@@ -721,6 +867,10 @@ export interface RouteMetadata {
721
867
  handler: string;
722
868
  params?: ParamMetadata[];
723
869
  middleware?: Function[];
870
+ /** Guards to execute before the route handler. Supports class constructors and instances. */
871
+ guards?: (Function | HttpGuard)[];
872
+ /** Exception filters to apply when the route handler throws. */
873
+ filters?: import('./exception-filters/exception-filters').ExceptionFilter[];
724
874
  /**
725
875
  * Response schemas for validation
726
876
  * Key is HTTP status code (e.g., 200, 201, 404)