@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
@@ -22,6 +22,7 @@ import {
22
22
  isGlobalModule,
23
23
  registerControllerDependencies,
24
24
  } from '../decorators/decorators';
25
+ import { QueueService, QueueServiceTag } from '../queue';
25
26
  import { BaseWebSocketGateway } from '../websocket/ws-base-gateway';
26
27
  import { isWebSocketGateway } from '../websocket/ws-decorators';
27
28
 
@@ -616,7 +617,31 @@ export class OneBunModule implements ModuleInstance {
616
617
  * Resolve dependency by type (constructor function)
617
618
  */
618
619
  private resolveDependencyByType(type: Function): unknown {
619
- // Find service instance that matches the type
620
+ // QueueService is registered by tag (QueueServiceTag) before setup(); resolve by tag
621
+ if (type === QueueService) {
622
+ const byTag = this.serviceInstances.get(
623
+ QueueServiceTag as Context.Tag<unknown, unknown>,
624
+ );
625
+ if (byTag !== undefined) {
626
+ return byTag;
627
+ }
628
+ }
629
+
630
+ // Try to find by Effect Context.Tag first.
631
+ // This is the primary mechanism and also makes test overrides work:
632
+ // TestingModule.overrideProvider(MyService).useValue(mock) registers the mock
633
+ // under MyService's tag, so it is found here even if mock is not instanceof MyService.
634
+ try {
635
+ const tag = getServiceTag(type as new (...args: unknown[]) => unknown);
636
+ const byTag = this.serviceInstances.get(tag as Context.Tag<unknown, unknown>);
637
+ if (byTag !== undefined) {
638
+ return byTag;
639
+ }
640
+ } catch {
641
+ // Not a @Service()-decorated class — fall through to instanceof check below
642
+ }
643
+
644
+ // Fallback: find service instance that matches the type by reference equality or inheritance
620
645
  const serviceInstance = Array.from(this.serviceInstances.values()).find((instance) => {
621
646
  if (!instance) {
622
647
  return false;
@@ -678,11 +703,27 @@ export class OneBunModule implements ModuleInstance {
678
703
  /**
679
704
  * Setup the module and its dependencies
680
705
  */
706
+ /**
707
+ * Collect all descendant modules in depth-first order (leaves first).
708
+ * This ensures that deeply nested modules are initialized before their parents.
709
+ */
710
+ private collectDescendantModules(): OneBunModule[] {
711
+ const result: OneBunModule[] = [];
712
+ for (const child of this.childModules) {
713
+ result.push(...child.collectDescendantModules());
714
+ result.push(child);
715
+ }
716
+
717
+ return result;
718
+ }
719
+
681
720
  setup(): Effect.Effect<unknown, never, void> {
721
+ const allDescendants = this.collectDescendantModules();
722
+
682
723
  return this.callServicesOnModuleInit().pipe(
683
- // Also run onModuleInit for child modules' services
724
+ // Run onModuleInit for all descendant modules' services (depth-first)
684
725
  Effect.flatMap(() =>
685
- Effect.forEach(this.childModules, (childModule) => childModule.callServicesOnModuleInit(), {
726
+ Effect.forEach(allDescendants, (mod) => mod.callServicesOnModuleInit(), {
686
727
  discard: true,
687
728
  }),
688
729
  ),
@@ -693,18 +734,18 @@ export class OneBunModule implements ModuleInstance {
693
734
  this.resolveModuleMiddleware();
694
735
  }),
695
736
  ),
696
- // Create controller instances in child modules first, then this module (each uses its own DI scope)
737
+ // Create controller instances in all descendant modules first, then this module
697
738
  Effect.flatMap(() =>
698
- Effect.forEach(this.childModules, (childModule) => childModule.createControllerInstances(), {
739
+ Effect.forEach(allDescendants, (mod) => mod.createControllerInstances(), {
699
740
  discard: true,
700
741
  }),
701
742
  ),
702
743
  Effect.flatMap(() => this.createControllerInstances()),
703
744
  // Then call onModuleInit for controllers
704
745
  Effect.flatMap(() => this.callControllersOnModuleInit()),
705
- // Also run onModuleInit for child modules' controllers
746
+ // Run onModuleInit for all descendant modules' controllers
706
747
  Effect.flatMap(() =>
707
- Effect.forEach(this.childModules, (childModule) => childModule.callControllersOnModuleInit(), {
748
+ Effect.forEach(allDescendants, (mod) => mod.callControllersOnModuleInit(), {
708
749
  discard: true,
709
750
  }),
710
751
  ),
@@ -1041,6 +1082,13 @@ export class OneBunModule implements ModuleInstance {
1041
1082
  return this.rootLayer;
1042
1083
  }
1043
1084
 
1085
+ /**
1086
+ * Register a service instance by tag (e.g. before setup() for application-provided services like QueueService proxy).
1087
+ */
1088
+ registerService<T>(tag: Context.Tag<unknown, T>, instance: T): void {
1089
+ this.serviceInstances.set(tag as Context.Tag<unknown, unknown>, instance);
1090
+ }
1091
+
1044
1092
  /**
1045
1093
  * Create a module from class
1046
1094
  * @param moduleClass - The module class
@@ -35,6 +35,7 @@ import {
35
35
  MessageAnyGuard,
36
36
  createMessageGuard,
37
37
  type Message,
38
+ type QueueAdapter,
38
39
  CronExpression,
39
40
  parseCronExpression,
40
41
  getNextRun,
@@ -113,9 +114,9 @@ describe('Setup Section Examples (docs/api/queue.md)', () => {
113
114
  * @source docs/api/queue.md#quick-start
114
115
  */
115
116
  describe('Quick Start Example (docs/api/queue.md)', () => {
116
- it('should define service with queue decorators', () => {
117
- // From docs/api/queue.md: Quick Start
118
- class OrderService {
117
+ it('should define controller with queue decorators', () => {
118
+ // From docs/api/queue.md: Quick Start (queue handlers must be in controllers)
119
+ class EventProcessor {
119
120
  @OnQueueReady()
120
121
  onReady() {
121
122
  // console.log('Queue connected');
@@ -134,19 +135,19 @@ describe('Quick Start Example (docs/api/queue.md)', () => {
134
135
  }
135
136
 
136
137
  // Verify decorators are registered
137
- expect(hasQueueDecorators(OrderService)).toBe(true);
138
+ expect(hasQueueDecorators(EventProcessor)).toBe(true);
138
139
 
139
- const subscriptions = getSubscribeMetadata(OrderService);
140
+ const subscriptions = getSubscribeMetadata(EventProcessor);
140
141
  expect(subscriptions.length).toBe(1);
141
142
  expect(subscriptions[0].pattern).toBe('orders.created');
142
143
 
143
- const cronJobs = getCronMetadata(OrderService);
144
+ const cronJobs = getCronMetadata(EventProcessor);
144
145
  expect(cronJobs.length).toBe(1);
145
146
  expect(cronJobs[0].expression).toBe(CronExpression.EVERY_HOUR);
146
147
  expect(cronJobs[0].options.pattern).toBe('cleanup.expired');
147
148
  });
148
149
 
149
- it('should define service with interval decorator', () => {
150
+ it('should define controller with interval decorator', () => {
150
151
  // From docs/api/queue.md: Quick Start - Interval example
151
152
  class EventProcessor {
152
153
  @Subscribe('orders.created')
@@ -389,9 +390,9 @@ describe('Message Guards Examples (docs/api/queue.md)', () => {
389
390
  * @source docs/api/queue.md#lifecycle-decorators
390
391
  */
391
392
  describe('Lifecycle Decorators Examples (docs/api/queue.md)', () => {
392
- it('should define lifecycle handlers', () => {
393
- // From docs/api/queue.md: Lifecycle Decorators section
394
- class EventService {
393
+ it('should define lifecycle handlers on controller', () => {
394
+ // From docs/api/queue.md: Lifecycle Decorators (handlers must be in controllers)
395
+ class EventProcessor {
395
396
  @OnQueueReady()
396
397
  handleReady() {
397
398
  // console.log('Queue connected');
@@ -418,8 +419,67 @@ describe('Lifecycle Decorators Examples (docs/api/queue.md)', () => {
418
419
  }
419
420
  }
420
421
 
421
- // Class should be defined without errors
422
- expect(EventService).toBeDefined();
422
+ // Class with lifecycle handlers only; hasQueueDecorators checks Subscribe/Cron/Interval/Timeout only
423
+ expect(EventProcessor).toBeDefined();
424
+ });
425
+ });
426
+
427
+ /**
428
+ * @source docs/api/queue.md#custom-adapter-nats-jetstream
429
+ */
430
+ describe('Custom adapter NATS JetStream (docs/api/queue.md)', () => {
431
+ it('should use custom adapter constructor with options', async () => {
432
+ // From docs/api/queue.md: Custom adapter: NATS JetStream
433
+ // Minimal adapter class that implements QueueAdapter for use with queue: { adapter, options }
434
+ /* eslint-disable @typescript-eslint/no-empty-function */
435
+ class NatsJetStreamAdapter implements QueueAdapter {
436
+ readonly name = 'nats-jetstream';
437
+ readonly type = 'jetstream';
438
+ constructor(private opts: { servers: string; stream?: string }) {}
439
+ async connect(): Promise<void> {}
440
+ async disconnect(): Promise<void> {}
441
+ isConnected(): boolean {
442
+ return true;
443
+ }
444
+ async publish(): Promise<string> {
445
+ return '';
446
+ }
447
+ async publishBatch(): Promise<string[]> {
448
+ return [];
449
+ }
450
+ async subscribe(): Promise<import('./types').Subscription> {
451
+ return {
452
+ async unsubscribe() {},
453
+ pause() {},
454
+ resume() {},
455
+ pattern: '',
456
+ isActive: true,
457
+ };
458
+ }
459
+ async addScheduledJob(): Promise<void> {}
460
+ async removeScheduledJob(): Promise<boolean> {
461
+ return false;
462
+ }
463
+ async getScheduledJobs(): Promise<import('./types').ScheduledJobInfo[]> {
464
+ return [];
465
+ }
466
+ supports(): boolean {
467
+ return false;
468
+ }
469
+ on(): void {}
470
+ off(): void {}
471
+ }
472
+ /* eslint-enable @typescript-eslint/no-empty-function */
473
+
474
+ const adapter = new NatsJetStreamAdapter({
475
+ servers: 'nats://localhost:4222',
476
+ stream: 'EVENTS',
477
+ });
478
+ await adapter.connect();
479
+ expect(adapter.name).toBe('nats-jetstream');
480
+ expect(adapter.type).toBe('jetstream');
481
+ expect(adapter.isConnected()).toBe(true);
482
+ await adapter.disconnect();
423
483
  });
424
484
  });
425
485
 
@@ -113,6 +113,10 @@ export {
113
113
  createQueueService,
114
114
  resolveAdapterType,
115
115
  } from './queue.service';
116
+ export {
117
+ QueueServiceProxy,
118
+ QUEUE_NOT_ENABLED_ERROR_MESSAGE,
119
+ } from './queue-service-proxy';
116
120
 
117
121
  // Adapters (re-export from adapters folder)
118
122
  export * from './adapters';
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Tests for QueueServiceProxy
3
+ */
4
+
5
+ import {
6
+ describe,
7
+ test,
8
+ expect,
9
+ } from 'bun:test';
10
+
11
+
12
+ import { InMemoryQueueAdapter } from './adapters/memory.adapter';
13
+ import { QueueServiceProxy, QUEUE_NOT_ENABLED_ERROR_MESSAGE } from './queue-service-proxy';
14
+ import { QueueService } from './queue.service';
15
+
16
+ describe('QueueServiceProxy', () => {
17
+ test('throws with expected message when delegate is null', async () => {
18
+ const proxy = new QueueServiceProxy();
19
+ expect(() => proxy.getAdapter()).toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
20
+ expect(() => proxy.getScheduler()).toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
21
+ await expect(proxy.publish('e', {})).rejects.toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
22
+ await expect(proxy.publishBatch([{ pattern: 'e', data: {} }])).rejects.toThrow(
23
+ QUEUE_NOT_ENABLED_ERROR_MESSAGE,
24
+ );
25
+ await expect(
26
+ proxy.subscribe('e', async () => {
27
+ /* no-op for throw test */
28
+ }),
29
+ ).rejects.toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
30
+ await expect(proxy.addScheduledJob('j', { pattern: 'e', schedule: { every: 1000 } })).rejects.toThrow(
31
+ QUEUE_NOT_ENABLED_ERROR_MESSAGE,
32
+ );
33
+ await expect(proxy.removeScheduledJob('j')).rejects.toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
34
+ await expect(proxy.getScheduledJobs()).rejects.toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
35
+ expect(() => proxy.supports('pattern-subscriptions')).toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
36
+ expect(() =>
37
+ proxy.on('onReady', () => {
38
+ /* no-op */
39
+ }),
40
+ ).toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
41
+ expect(() =>
42
+ proxy.off('onReady', () => {
43
+ /* no-op */
44
+ }),
45
+ ).toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
46
+ });
47
+
48
+ test('delegates to real QueueService after setDelegate', async () => {
49
+ const adapter = new InMemoryQueueAdapter();
50
+ const real = new QueueService({ adapter: 'memory' as const });
51
+ await real.initialize(adapter);
52
+ await real.start();
53
+
54
+ const proxy = new QueueServiceProxy();
55
+ proxy.setDelegate(real);
56
+
57
+ expect(proxy.getAdapter()).toBe(adapter);
58
+ expect(proxy.getScheduler()).toBe(real.getScheduler());
59
+ expect(proxy.supports('pattern-subscriptions')).toBe(true);
60
+
61
+ const ids = await proxy.publish('test.event', { x: 1 });
62
+ expect(ids).toBeDefined();
63
+ expect(typeof ids).toBe('string');
64
+
65
+ const received: unknown[] = [];
66
+ const sub = await proxy.subscribe('test.event', async (msg) => {
67
+ received.push(msg.data);
68
+ });
69
+ expect(sub.isActive).toBe(true);
70
+
71
+ await proxy.publish('test.event', { y: 2 });
72
+ // Allow microtask for handler
73
+ await Promise.resolve();
74
+ await new Promise((r) => setTimeout(r, 10));
75
+ expect(received.length).toBe(1);
76
+ expect(received[0]).toEqual({ y: 2 });
77
+
78
+ await real.stop();
79
+ proxy.setDelegate(null);
80
+ expect(() => proxy.getAdapter()).toThrow(QUEUE_NOT_ENABLED_ERROR_MESSAGE);
81
+ });
82
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * QueueServiceProxy — placeholder for DI when the real QueueService is created after setup().
3
+ * When the queue is enabled, the application sets the real QueueService via setDelegate().
4
+ * When the queue is not enabled, any method call throws a clear error.
5
+ */
6
+
7
+ import type { QueueService } from './queue.service';
8
+ import type { QueueScheduler } from './scheduler';
9
+ import type { QueueAdapter } from './types';
10
+ import type {
11
+ MessageHandler,
12
+ PublishOptions,
13
+ QueueEvents,
14
+ ScheduledJobInfo,
15
+ ScheduledJobOptions,
16
+ SubscribeOptions,
17
+ Subscription,
18
+ QueueFeature,
19
+ } from './types';
20
+
21
+ const QUEUE_NOT_ENABLED_MESSAGE =
22
+ 'Queue is not enabled. Enable it via `queue.enabled: true` in application options or register at least one controller with queue decorators (@Subscribe, @Cron, @Interval, @Timeout).';
23
+
24
+ function throwIfNoDelegate(delegate: QueueService | null): asserts delegate is QueueService {
25
+ if (delegate === null) {
26
+ throw new Error(QUEUE_NOT_ENABLED_MESSAGE);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Proxy for QueueService used in DI before the real service is created.
32
+ * After initializeQueue(), the application calls setDelegate(realQueueService) when the queue is enabled.
33
+ */
34
+ export class QueueServiceProxy {
35
+ private delegate: QueueService | null = null;
36
+
37
+ setDelegate(service: QueueService | null): void {
38
+ this.delegate = service;
39
+ }
40
+
41
+ getAdapter(): QueueAdapter {
42
+ throwIfNoDelegate(this.delegate);
43
+
44
+ return this.delegate.getAdapter();
45
+ }
46
+
47
+ getScheduler(): QueueScheduler {
48
+ throwIfNoDelegate(this.delegate);
49
+
50
+ return this.delegate.getScheduler();
51
+ }
52
+
53
+ async publish<T>(pattern: string, data: T, options?: PublishOptions): Promise<string> {
54
+ throwIfNoDelegate(this.delegate);
55
+
56
+ return await this.delegate.publish(pattern, data, options);
57
+ }
58
+
59
+ async publishBatch<T>(
60
+ messages: Array<{ pattern: string; data: T; options?: PublishOptions }>,
61
+ ): Promise<string[]> {
62
+ throwIfNoDelegate(this.delegate);
63
+
64
+ return await this.delegate.publishBatch(messages);
65
+ }
66
+
67
+ async subscribe<T>(
68
+ pattern: string,
69
+ handler: MessageHandler<T>,
70
+ options?: SubscribeOptions,
71
+ ): Promise<Subscription> {
72
+ throwIfNoDelegate(this.delegate);
73
+
74
+ return await this.delegate.subscribe(pattern, handler, options);
75
+ }
76
+
77
+ async addScheduledJob(name: string, options: ScheduledJobOptions): Promise<void> {
78
+ throwIfNoDelegate(this.delegate);
79
+
80
+ return await this.delegate.addScheduledJob(name, options);
81
+ }
82
+
83
+ async removeScheduledJob(name: string): Promise<boolean> {
84
+ throwIfNoDelegate(this.delegate);
85
+
86
+ return await this.delegate.removeScheduledJob(name);
87
+ }
88
+
89
+ async getScheduledJobs(): Promise<ScheduledJobInfo[]> {
90
+ throwIfNoDelegate(this.delegate);
91
+
92
+ return await this.delegate.getScheduledJobs();
93
+ }
94
+
95
+ supports(feature: QueueFeature): boolean {
96
+ throwIfNoDelegate(this.delegate);
97
+
98
+ return this.delegate.supports(feature);
99
+ }
100
+
101
+ on<E extends keyof QueueEvents>(event: E, handler: NonNullable<QueueEvents[E]>): void {
102
+ throwIfNoDelegate(this.delegate);
103
+
104
+ return this.delegate.on(event, handler);
105
+ }
106
+
107
+ off<E extends keyof QueueEvents>(event: E, handler: NonNullable<QueueEvents[E]>): void {
108
+ throwIfNoDelegate(this.delegate);
109
+
110
+ return this.delegate.off(event, handler);
111
+ }
112
+ }
113
+
114
+ export const QUEUE_NOT_ENABLED_ERROR_MESSAGE = QUEUE_NOT_ENABLED_MESSAGE;
@@ -395,8 +395,8 @@ export interface QueueAdapterConstructor {
395
395
  * Queue configuration options
396
396
  */
397
397
  export interface QueueConfig {
398
- /** Adapter type or custom adapter class */
399
- adapter: BuiltInAdapterType | QueueAdapterConstructor;
398
+ /** Adapter type (built-in or custom, e.g. 'jetstream') or custom adapter class */
399
+ adapter: QueueAdapterType | QueueAdapterConstructor;
400
400
 
401
401
  /** Adapter-specific options */
402
402
  options?: Record<string, unknown>;
@@ -0,0 +1,212 @@
1
+ import type { OneBunRequest, OneBunResponse } from '../types';
2
+
3
+ import { BaseMiddleware } from '../module/middleware';
4
+
5
+ /**
6
+ * CORS (Cross-Origin Resource Sharing) configuration options.
7
+ * Passed via `ApplicationOptions.cors`.
8
+ */
9
+ export interface CorsOptions {
10
+ /**
11
+ * Allowed origin(s).
12
+ * - `'*'` — allow any origin
13
+ * - `string` — exact match
14
+ * - `RegExp` — regex match
15
+ * - `string[]` / `RegExp[]` — list of allowed origins
16
+ * - `(origin: string) => boolean` — custom predicate
17
+ * @defaultValue '*'
18
+ */
19
+ origin?: string | RegExp | Array<string | RegExp> | ((origin: string) => boolean);
20
+
21
+ /**
22
+ * Allowed HTTP methods.
23
+ * @defaultValue ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS']
24
+ */
25
+ methods?: string[];
26
+
27
+ /**
28
+ * Allowed request headers.
29
+ * @defaultValue ['Content-Type', 'Authorization']
30
+ */
31
+ allowedHeaders?: string[];
32
+
33
+ /**
34
+ * Headers to expose to the browser.
35
+ */
36
+ exposedHeaders?: string[];
37
+
38
+ /**
39
+ * Allow credentials (cookies, Authorization header).
40
+ * @defaultValue false
41
+ */
42
+ credentials?: boolean;
43
+
44
+ /**
45
+ * Preflight cache duration in seconds.
46
+ * @defaultValue 86400 (24 hours)
47
+ */
48
+ maxAge?: number;
49
+
50
+ /**
51
+ * Whether to pass the CORS preflight request to the next handler.
52
+ * When `false` (default) preflight requests are handled by the middleware
53
+ * and never reach route handlers.
54
+ * @defaultValue false
55
+ */
56
+ preflightContinue?: boolean;
57
+ }
58
+
59
+ const DEFAULT_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'];
60
+ const DEFAULT_ALLOWED_HEADERS = ['Content-Type', 'Authorization'];
61
+ const DEFAULT_MAX_AGE = 86400;
62
+ const NO_CONTENT = 204;
63
+
64
+ /**
65
+ * Resolves whether the given `origin` is allowed by the CORS configuration.
66
+ */
67
+ function isOriginAllowed(
68
+ origin: string,
69
+ allowed: NonNullable<CorsOptions['origin']>,
70
+ ): boolean {
71
+ if (allowed === '*') {
72
+ return true;
73
+ }
74
+
75
+ if (typeof allowed === 'string') {
76
+ return origin === allowed;
77
+ }
78
+
79
+ if (allowed instanceof RegExp) {
80
+ return allowed.test(origin);
81
+ }
82
+
83
+ if (typeof allowed === 'function') {
84
+ return allowed(origin);
85
+ }
86
+
87
+ return (allowed as Array<string | RegExp>).some((item) =>
88
+ typeof item === 'string' ? origin === item : item.test(origin),
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Built-in CORS middleware.
94
+ *
95
+ * Handles preflight `OPTIONS` requests and sets appropriate CORS response
96
+ * headers for all other requests. Configure once via `ApplicationOptions.cors`
97
+ * and the framework will instantiate this middleware automatically.
98
+ *
99
+ * @example
100
+ * ```typescript
101
+ * const app = new OneBunApplication(AppModule, {
102
+ * cors: {
103
+ * origin: 'https://my-frontend.example.com',
104
+ * credentials: true,
105
+ * },
106
+ * });
107
+ * ```
108
+ *
109
+ * When using manually (e.g. from `ApplicationOptions.middleware`):
110
+ * ```typescript
111
+ * const app = new OneBunApplication(AppModule, {
112
+ * middleware: [CorsMiddleware.configure({ origin: /example\.com$/ })],
113
+ * });
114
+ * ```
115
+ */
116
+ export class CorsMiddleware extends BaseMiddleware {
117
+ private readonly options: Required<
118
+ Pick<CorsOptions, 'methods' | 'allowedHeaders' | 'maxAge' | 'credentials' | 'preflightContinue'>
119
+ > &
120
+ Pick<CorsOptions, 'origin' | 'exposedHeaders'>;
121
+
122
+ /**
123
+ * Create a pre-configured CorsMiddleware class with the given options.
124
+ * Returns a constructor — pass the result directly to `ApplicationOptions.middleware`.
125
+ *
126
+ * @example
127
+ * ```typescript
128
+ * const app = new OneBunApplication(AppModule, {
129
+ * middleware: [CorsMiddleware.configure({ origin: 'https://example.com' })],
130
+ * });
131
+ * ```
132
+ */
133
+ static configure(options: CorsOptions = {}): typeof CorsMiddleware {
134
+ class ConfiguredCorsMiddleware extends CorsMiddleware {
135
+ constructor() {
136
+ super(options);
137
+ }
138
+ }
139
+
140
+ return ConfiguredCorsMiddleware;
141
+ }
142
+
143
+ constructor(options: CorsOptions = {}) {
144
+ super();
145
+
146
+ this.options = {
147
+ origin: options.origin ?? '*',
148
+ methods: options.methods ?? DEFAULT_METHODS,
149
+ allowedHeaders: options.allowedHeaders ?? DEFAULT_ALLOWED_HEADERS,
150
+ exposedHeaders: options.exposedHeaders,
151
+ credentials: options.credentials ?? false,
152
+ maxAge: options.maxAge ?? DEFAULT_MAX_AGE,
153
+ preflightContinue: options.preflightContinue ?? false,
154
+ };
155
+ }
156
+
157
+ async use(req: OneBunRequest, next: () => Promise<OneBunResponse>): Promise<OneBunResponse> {
158
+ const origin = req.headers.get('origin') ?? '';
159
+ const headers = new Headers();
160
+
161
+ // Determine the effective origin for the Access-Control-Allow-Origin header
162
+ const { origin: allowedOrigin } = this.options;
163
+ const allowAll = allowedOrigin === '*' && !this.options.credentials;
164
+
165
+ if (allowAll) {
166
+ headers.set('Access-Control-Allow-Origin', '*');
167
+ } else if (origin && isOriginAllowed(origin, allowedOrigin ?? '*')) {
168
+ headers.set('Access-Control-Allow-Origin', origin);
169
+ headers.append('Vary', 'Origin');
170
+ } else if (!origin) {
171
+ // Non-browser request (no Origin header) — set wildcard if configured
172
+ if (allowedOrigin === '*') {
173
+ headers.set('Access-Control-Allow-Origin', '*');
174
+ }
175
+ }
176
+
177
+ if (this.options.credentials) {
178
+ headers.set('Access-Control-Allow-Credentials', 'true');
179
+ }
180
+
181
+ if (this.options.exposedHeaders && this.options.exposedHeaders.length > 0) {
182
+ headers.set('Access-Control-Expose-Headers', this.options.exposedHeaders.join(', '));
183
+ }
184
+
185
+ // Handle preflight OPTIONS request
186
+ if (req.method === 'OPTIONS') {
187
+ headers.set('Access-Control-Allow-Methods', this.options.methods.join(', '));
188
+ headers.set('Access-Control-Allow-Headers', this.options.allowedHeaders.join(', '));
189
+ headers.set('Access-Control-Max-Age', String(this.options.maxAge));
190
+
191
+ if (this.options.preflightContinue) {
192
+ const response = await next();
193
+ // Copy CORS headers onto the response from next()
194
+ for (const [key, value] of headers.entries()) {
195
+ response.headers.set(key, value);
196
+ }
197
+
198
+ return response;
199
+ }
200
+
201
+ return new Response(null, { status: NO_CONTENT, headers });
202
+ }
203
+
204
+ // For non-OPTIONS requests — let the handler run, then attach CORS headers
205
+ const response = await next();
206
+ for (const [key, value] of headers.entries()) {
207
+ response.headers.set(key, value);
208
+ }
209
+
210
+ return response;
211
+ }
212
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Security Middleware
3
+ *
4
+ * Built-in middleware for common security concerns: CORS, rate limiting,
5
+ * and HTTP security headers.
6
+ */
7
+
8
+ export { CorsMiddleware, type CorsOptions } from './cors-middleware';
9
+ export {
10
+ RateLimitMiddleware,
11
+ MemoryRateLimitStore,
12
+ RedisRateLimitStore,
13
+ type RateLimitOptions,
14
+ type RateLimitStore,
15
+ } from './rate-limit-middleware';
16
+ export {
17
+ SecurityHeadersMiddleware,
18
+ type SecurityHeadersOptions,
19
+ } from './security-headers-middleware';