@onebun/core 0.1.14 → 0.1.16

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onebun/core",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Core package for OneBun framework - decorators, DI, modules, controllers",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -24,13 +24,18 @@ import {
24
24
  } from '@onebun/requests';
25
25
  import { makeTraceService, TraceService } from '@onebun/trace';
26
26
 
27
- import { getControllerMetadata } from '../decorators/decorators';
27
+ import {
28
+ getControllerMetadata,
29
+ getSseMetadata,
30
+ type SseDecoratorOptions,
31
+ } from '../decorators/decorators';
28
32
  import {
29
33
  NotInitializedConfig,
30
34
  type IConfig,
31
35
  type OneBunAppConfig,
32
36
  } from '../module/config.interface';
33
37
  import { ConfigServiceImpl } from '../module/config.service';
38
+ import { createSseStream } from '../module/controller';
34
39
  import { OneBunModule } from '../module/module';
35
40
  import { QueueService, type QueueAdapter } from '../queue';
36
41
  import { InMemoryQueueAdapter } from '../queue/adapters/memory.adapter';
@@ -371,6 +376,7 @@ export class OneBunApplication {
371
376
  {
372
377
  method: string;
373
378
  handler: Function;
379
+ handlerName: string;
374
380
  controller: Controller;
375
381
  params?: ParamMetadata[];
376
382
  middleware?: Function[];
@@ -436,6 +442,7 @@ export class OneBunApplication {
436
442
  routes.set(_routeKey, {
437
443
  method,
438
444
  handler,
445
+ handlerName: route.handler,
439
446
  controller,
440
447
  params: route.params,
441
448
  middleware: route.middleware,
@@ -899,6 +906,7 @@ export class OneBunApplication {
899
906
  async function executeHandler(
900
907
  route: {
901
908
  handler: Function;
909
+ handlerName?: string;
902
910
  controller: Controller;
903
911
  params?: ParamMetadata[];
904
912
  responseSchemas?: RouteMetadata['responseSchemas'];
@@ -906,9 +914,22 @@ export class OneBunApplication {
906
914
  req: Request,
907
915
  paramValues: Record<string, string | string[]>,
908
916
  ): Promise<Response> {
917
+ // Check if this is an SSE endpoint
918
+ let sseOptions: SseDecoratorOptions | undefined;
919
+ if (route.handlerName) {
920
+ sseOptions = getSseMetadata(Object.getPrototypeOf(route.controller), route.handlerName);
921
+ }
922
+
909
923
  // If no parameter metadata, just call the handler with the request
910
924
  if (!route.params || route.params.length === 0) {
911
- return await route.handler(req);
925
+ const result = await route.handler(req);
926
+
927
+ // Handle SSE response
928
+ if (sseOptions !== undefined) {
929
+ return createSseResponseFromResult(result, sseOptions);
930
+ }
931
+
932
+ return result;
912
933
  }
913
934
 
914
935
  // Prepare arguments array based on parameter metadata
@@ -975,6 +996,11 @@ export class OneBunApplication {
975
996
  // Call handler with injected parameters
976
997
  const result = await route.handler(...args);
977
998
 
999
+ // Handle SSE response - wrap async generator in SSE Response
1000
+ if (sseOptions !== undefined) {
1001
+ return createSseResponseFromResult(result, sseOptions);
1002
+ }
1003
+
978
1004
  // Initialize variables for response validation
979
1005
  let validatedResult = result;
980
1006
  let responseStatusCode = HttpStatusCode.OK;
@@ -1107,6 +1133,46 @@ export class OneBunApplication {
1107
1133
  });
1108
1134
  }
1109
1135
  }
1136
+
1137
+ /**
1138
+ * Create SSE Response from handler result
1139
+ * Handles both async generators and already-created Responses
1140
+ */
1141
+ function createSseResponseFromResult(
1142
+ result: unknown,
1143
+ options: SseDecoratorOptions,
1144
+ ): Response {
1145
+ // If result is already a Response (e.g., from controller.sse()), return it
1146
+ if (result instanceof Response) {
1147
+ return result;
1148
+ }
1149
+
1150
+ // Check if result is an async iterable (generator)
1151
+ if (result && typeof result === 'object' && Symbol.asyncIterator in result) {
1152
+ const stream = createSseStream(
1153
+ result as AsyncIterable<unknown>,
1154
+ options,
1155
+ );
1156
+
1157
+ return new Response(stream, {
1158
+ status: HttpStatusCode.OK,
1159
+ headers: {
1160
+ // eslint-disable-next-line @typescript-eslint/naming-convention
1161
+ 'Content-Type': 'text/event-stream',
1162
+ // eslint-disable-next-line @typescript-eslint/naming-convention
1163
+ 'Cache-Control': 'no-cache',
1164
+ // eslint-disable-next-line @typescript-eslint/naming-convention
1165
+ 'Connection': 'keep-alive',
1166
+ },
1167
+ });
1168
+ }
1169
+
1170
+ // Fallback: return error if result is not valid for SSE
1171
+ throw new Error(
1172
+ 'SSE endpoint must return an async generator or a Response object. ' +
1173
+ 'Use "async *methodName()" or return this.sse(generator).',
1174
+ );
1175
+ }
1110
1176
  }
1111
1177
 
1112
1178
  /**
@@ -575,6 +575,82 @@ export const Head = createRouteDecorator(HttpMethod.HEAD);
575
575
  // eslint-disable-next-line @typescript-eslint/naming-convention
576
576
  export const All = createRouteDecorator(HttpMethod.ALL);
577
577
 
578
+ // ============================================================================
579
+ // SSE (Server-Sent Events) Decorator
580
+ // ============================================================================
581
+
582
+ /**
583
+ * Metadata key for SSE handler configuration
584
+ */
585
+ export const SSE_METADATA = 'onebun:sse';
586
+
587
+ /**
588
+ * SSE decorator options
589
+ */
590
+ export interface SseDecoratorOptions {
591
+ /**
592
+ * Heartbeat interval in milliseconds.
593
+ * When set, the server will send a comment (": heartbeat\n\n")
594
+ * at this interval to keep the connection alive.
595
+ */
596
+ heartbeat?: number;
597
+ }
598
+
599
+ /**
600
+ * SSE (Server-Sent Events) method decorator
601
+ *
602
+ * Marks a controller method as an SSE endpoint. The method should be an async generator
603
+ * that yields SseEvent objects or raw data.
604
+ *
605
+ * @param options - SSE configuration options
606
+ * @returns Method decorator
607
+ *
608
+ * @example Simple SSE endpoint
609
+ * ```typescript
610
+ * @Get('/events')
611
+ * @Sse()
612
+ * async *events(): SseGenerator {
613
+ * for (let i = 0; i < 10; i++) {
614
+ * await Bun.sleep(1000);
615
+ * yield { event: 'tick', data: { count: i } };
616
+ * }
617
+ * }
618
+ * ```
619
+ *
620
+ * @example SSE with heartbeat for long-lived connections
621
+ * ```typescript
622
+ * @Get('/live')
623
+ * @Sse({ heartbeat: 15000 }) // Send heartbeat every 15 seconds
624
+ * async *liveUpdates(): SseGenerator {
625
+ * while (true) {
626
+ * const data = await this.dataService.waitForUpdate();
627
+ * yield { event: 'update', data };
628
+ * }
629
+ * }
630
+ * ```
631
+ */
632
+ // eslint-disable-next-line @typescript-eslint/naming-convention
633
+ export function Sse(options?: SseDecoratorOptions): MethodDecorator {
634
+ return (target: object, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
635
+ Reflect.defineMetadata(SSE_METADATA, options ?? {}, target, propertyKey as string);
636
+
637
+ return descriptor;
638
+ };
639
+ }
640
+
641
+ /**
642
+ * Check if a method is marked as SSE endpoint
643
+ * @param target - Controller instance or prototype
644
+ * @param methodName - Method name
645
+ * @returns SSE options if method is SSE endpoint, undefined otherwise
646
+ */
647
+ export function getSseMetadata(
648
+ target: object,
649
+ methodName: string,
650
+ ): SseDecoratorOptions | undefined {
651
+ return Reflect.getMetadata(SSE_METADATA, target, methodName);
652
+ }
653
+
578
654
  /**
579
655
  * Module decorator metadata
580
656
  */
@@ -26,62 +26,9 @@ import type {
26
26
  WsExecutionContext,
27
27
  WsServerType,
28
28
  } from './';
29
+ import type { SseEvent, SseGenerator } from './types';
29
30
  import type { ServerWebSocket } from 'bun';
30
31
 
31
- import {
32
- Controller,
33
- Get,
34
- Post,
35
- Put,
36
- Delete,
37
- Patch,
38
- Param,
39
- Query,
40
- Body,
41
- Header,
42
- Req,
43
- Module,
44
- Service,
45
- BaseService,
46
- BaseController,
47
- UseMiddleware,
48
- getServiceTag,
49
- HttpStatusCode,
50
- NotFoundError,
51
- InternalServerError,
52
- OneBunBaseError,
53
- Env,
54
- validate,
55
- validateOrThrow,
56
- MultiServiceApplication,
57
- OneBunApplication,
58
- createServiceDefinition,
59
- createServiceClient,
60
- WebSocketGateway,
61
- BaseWebSocketGateway,
62
- OnConnect,
63
- OnDisconnect,
64
- OnJoinRoom,
65
- OnLeaveRoom,
66
- OnMessage,
67
- Client,
68
- Socket,
69
- MessageData,
70
- RoomName,
71
- PatternParams,
72
- WsServer,
73
- UseWsGuards,
74
- WsAuthGuard,
75
- WsPermissionGuard,
76
- WsAnyPermissionGuard,
77
- createGuard,
78
- createInMemoryWsStorage,
79
- SharedRedisProvider,
80
- createWsServiceDefinition,
81
- createWsClient,
82
- matchPattern,
83
- makeMockLoggerLayer,
84
- } from './';
85
32
 
86
33
  /**
87
34
  * @source docs/index.md#minimal-working-example
@@ -2928,3 +2875,409 @@ describe('WebSocket Chat Example (docs/examples/websocket-chat.md)', () => {
2928
2875
  });
2929
2876
  });
2930
2877
  });
2878
+
2879
+ // ============================================================================
2880
+ // SSE (Server-Sent Events) Documentation Tests
2881
+ // ============================================================================
2882
+
2883
+ import { Sse, getSseMetadata } from './decorators/decorators';
2884
+ import { formatSseEvent, createSseStream } from './module/controller';
2885
+
2886
+ import {
2887
+ Controller,
2888
+ Get,
2889
+ Post,
2890
+ Put,
2891
+ Delete,
2892
+ Patch,
2893
+ Param,
2894
+ Query,
2895
+ Body,
2896
+ Header,
2897
+ Req,
2898
+ Module,
2899
+ Service,
2900
+ BaseService,
2901
+ BaseController,
2902
+ UseMiddleware,
2903
+ getServiceTag,
2904
+ HttpStatusCode,
2905
+ NotFoundError,
2906
+ InternalServerError,
2907
+ OneBunBaseError,
2908
+ Env,
2909
+ validate,
2910
+ validateOrThrow,
2911
+ MultiServiceApplication,
2912
+ OneBunApplication,
2913
+ createServiceDefinition,
2914
+ createServiceClient,
2915
+ WebSocketGateway,
2916
+ BaseWebSocketGateway,
2917
+ OnConnect,
2918
+ OnDisconnect,
2919
+ OnJoinRoom,
2920
+ OnLeaveRoom,
2921
+ OnMessage,
2922
+ Client,
2923
+ Socket,
2924
+ MessageData,
2925
+ RoomName,
2926
+ PatternParams,
2927
+ WsServer,
2928
+ UseWsGuards,
2929
+ WsAuthGuard,
2930
+ WsPermissionGuard,
2931
+ WsAnyPermissionGuard,
2932
+ createGuard,
2933
+ createInMemoryWsStorage,
2934
+ SharedRedisProvider,
2935
+ createWsServiceDefinition,
2936
+ createWsClient,
2937
+ matchPattern,
2938
+ makeMockLoggerLayer,
2939
+ } from './';
2940
+
2941
+ describe('SSE (Server-Sent Events) API Documentation (docs/api/controllers.md)', () => {
2942
+ describe('SseEvent Type (docs/api/controllers.md)', () => {
2943
+ /**
2944
+ * @source docs/api/controllers.md#sseevent-type
2945
+ */
2946
+ it('should define SseEvent interface', () => {
2947
+ // From docs: SseEvent interface
2948
+ const event: SseEvent = {
2949
+ event: 'update',
2950
+ data: { count: 1 },
2951
+ id: '123',
2952
+ retry: 5000,
2953
+ };
2954
+
2955
+ expect(event.event).toBe('update');
2956
+ expect(event.data).toEqual({ count: 1 });
2957
+ expect(event.id).toBe('123');
2958
+ expect(event.retry).toBe(5000);
2959
+ });
2960
+
2961
+ it('should allow minimal SseEvent with only data', () => {
2962
+ const event: SseEvent = {
2963
+ data: { message: 'Hello' },
2964
+ };
2965
+
2966
+ expect(event.data).toEqual({ message: 'Hello' });
2967
+ expect(event.event).toBeUndefined();
2968
+ });
2969
+ });
2970
+
2971
+ describe('@Sse() Decorator (docs/api/controllers.md)', () => {
2972
+ /**
2973
+ * @source docs/api/controllers.md#sse-decorator
2974
+ */
2975
+ it('should mark method as SSE endpoint', () => {
2976
+ // Test @Sse decorator independently (without @Controller wrapping)
2977
+ class TestClass {
2978
+ @Sse()
2979
+ async *stream(): SseGenerator {
2980
+ yield { event: 'start', data: { timestamp: Date.now() } };
2981
+ }
2982
+ }
2983
+
2984
+ expect(TestClass).toBeDefined();
2985
+
2986
+ // Verify SSE metadata is set on the prototype
2987
+ const metadata = getSseMetadata(TestClass.prototype, 'stream');
2988
+ expect(metadata).toBeDefined();
2989
+ });
2990
+
2991
+ /**
2992
+ * @source docs/api/controllers.md#sse-with-heartbeat
2993
+ */
2994
+ it('should support heartbeat option', () => {
2995
+ // Test @Sse decorator with options independently
2996
+ class TestClass {
2997
+ @Sse({ heartbeat: 15000 })
2998
+ async *live(): SseGenerator {
2999
+ yield { event: 'connected', data: { clientId: 'test' } };
3000
+ }
3001
+ }
3002
+
3003
+ expect(TestClass).toBeDefined();
3004
+
3005
+ // Verify heartbeat option is set
3006
+ const metadata = getSseMetadata(TestClass.prototype, 'live');
3007
+ expect(metadata).toBeDefined();
3008
+ expect(metadata?.heartbeat).toBe(15000);
3009
+ });
3010
+
3011
+ /**
3012
+ * @source docs/api/controllers.md#sse-decorator-with-controller
3013
+ */
3014
+ it('should work with @Controller decorator', () => {
3015
+ // From docs: @Sse() decorator example with full controller
3016
+ @Controller('/events')
3017
+ class EventsController extends BaseController {
3018
+ @Get('/stream')
3019
+ @Sse()
3020
+ async *stream(): SseGenerator {
3021
+ yield { event: 'start', data: { timestamp: Date.now() } };
3022
+ }
3023
+
3024
+ @Get('/live')
3025
+ @Sse({ heartbeat: 15000 })
3026
+ async *live(): SseGenerator {
3027
+ yield { event: 'connected', data: { clientId: 'test' } };
3028
+ }
3029
+ }
3030
+
3031
+ expect(EventsController).toBeDefined();
3032
+ });
3033
+ });
3034
+
3035
+ describe('formatSseEvent Function', () => {
3036
+ /**
3037
+ * @source docs/api/controllers.md#sse-wire-format
3038
+ */
3039
+ it('should format event with all fields', () => {
3040
+ const event: SseEvent = {
3041
+ event: 'update',
3042
+ data: { count: 1 },
3043
+ id: '123',
3044
+ retry: 5000,
3045
+ };
3046
+
3047
+ const formatted = formatSseEvent(event);
3048
+
3049
+ expect(formatted).toContain('event: update\n');
3050
+ expect(formatted).toContain('id: 123\n');
3051
+ expect(formatted).toContain('retry: 5000\n');
3052
+ expect(formatted).toContain('data: {"count":1}\n');
3053
+ expect(formatted).toEndWith('\n\n');
3054
+ });
3055
+
3056
+ it('should format event with only data', () => {
3057
+ const event: SseEvent = {
3058
+ data: { message: 'Hello' },
3059
+ };
3060
+
3061
+ const formatted = formatSseEvent(event);
3062
+
3063
+ expect(formatted).toBe('data: {"message":"Hello"}\n\n');
3064
+ expect(formatted).not.toContain('event:');
3065
+ expect(formatted).not.toContain('id:');
3066
+ });
3067
+
3068
+ it('should format raw data as default event', () => {
3069
+ const rawData = { count: 42 };
3070
+
3071
+ const formatted = formatSseEvent(rawData);
3072
+
3073
+ expect(formatted).toBe('data: {"count":42}\n\n');
3074
+ });
3075
+
3076
+ it('should handle multi-line data', () => {
3077
+ const event: SseEvent = {
3078
+ data: 'line1\nline2\nline3',
3079
+ };
3080
+
3081
+ const formatted = formatSseEvent(event);
3082
+
3083
+ expect(formatted).toContain('data: line1\n');
3084
+ expect(formatted).toContain('data: line2\n');
3085
+ expect(formatted).toContain('data: line3\n');
3086
+ });
3087
+
3088
+ it('should handle string data', () => {
3089
+ const event: SseEvent = {
3090
+ event: 'message',
3091
+ data: 'Simple string message',
3092
+ };
3093
+
3094
+ const formatted = formatSseEvent(event);
3095
+
3096
+ expect(formatted).toContain('event: message\n');
3097
+ expect(formatted).toContain('data: Simple string message\n');
3098
+ });
3099
+ });
3100
+
3101
+ describe('createSseStream Function', () => {
3102
+ /**
3103
+ * @source docs/api/controllers.md#sse-method
3104
+ */
3105
+ it('should create ReadableStream from async generator', async () => {
3106
+ async function* testGenerator(): SseGenerator {
3107
+ yield { event: 'start', data: { count: 0 } };
3108
+ yield { event: 'tick', data: { count: 1 } };
3109
+ yield { event: 'end', data: { count: 2 } };
3110
+ }
3111
+
3112
+ const stream = createSseStream(testGenerator());
3113
+
3114
+ expect(stream).toBeInstanceOf(ReadableStream);
3115
+
3116
+ // Read all chunks from stream
3117
+ const reader = stream.getReader();
3118
+ const decoder = new TextDecoder();
3119
+ const chunks: string[] = [];
3120
+
3121
+ while (true) {
3122
+ const { done, value } = await reader.read();
3123
+ if (done) {
3124
+ break;
3125
+ }
3126
+ chunks.push(decoder.decode(value));
3127
+ }
3128
+
3129
+ const output = chunks.join('');
3130
+ expect(output).toContain('event: start\n');
3131
+ expect(output).toContain('event: tick\n');
3132
+ expect(output).toContain('event: end\n');
3133
+ });
3134
+
3135
+ it('should handle heartbeat option', async () => {
3136
+ // Use a very short heartbeat for testing
3137
+ const heartbeatInterval = 50;
3138
+
3139
+ async function* slowGenerator(): SseGenerator {
3140
+ await Bun.sleep(150);
3141
+ yield { data: 'done' };
3142
+ }
3143
+
3144
+ const stream = createSseStream(slowGenerator(), { heartbeat: heartbeatInterval });
3145
+ const reader = stream.getReader();
3146
+ const decoder = new TextDecoder();
3147
+ const chunks: string[] = [];
3148
+
3149
+ // Read chunks with timeout
3150
+ const startTime = Date.now();
3151
+ while (Date.now() - startTime < 300) {
3152
+ const result = await Promise.race([
3153
+ reader.read(),
3154
+ Bun.sleep(50).then(() => ({ done: false, value: undefined, timeout: true })),
3155
+ ]);
3156
+
3157
+ if ('timeout' in result) {
3158
+ continue;
3159
+ }
3160
+ if (result.done) {
3161
+ break;
3162
+ }
3163
+ if (result.value) {
3164
+ chunks.push(decoder.decode(result.value));
3165
+ }
3166
+ }
3167
+
3168
+ const output = chunks.join('');
3169
+
3170
+ // Should have heartbeat comments
3171
+ expect(output).toContain(': heartbeat\n\n');
3172
+ // Should have the actual event (string data is not wrapped in extra quotes)
3173
+ expect(output).toContain('data: done');
3174
+ });
3175
+ });
3176
+
3177
+ describe('Controller.sse() Method', () => {
3178
+ /**
3179
+ * @source docs/api/controllers.md#sse-method
3180
+ */
3181
+ it('should have sse() method on BaseController', () => {
3182
+ const controller = new BaseController();
3183
+
3184
+ // Access protected method via type assertion
3185
+ expect(typeof (controller as unknown as { sse: Function }).sse).toBe('function');
3186
+ });
3187
+
3188
+ /**
3189
+ * @source docs/api/controllers.md#sse-method-example
3190
+ */
3191
+ it('should define controller using sse() method', () => {
3192
+ // From docs: Using sse() method example
3193
+ @Controller('/events')
3194
+ class EventsController extends BaseController {
3195
+ @Get('/manual')
3196
+ events(): Response {
3197
+ return this.sse(async function* () {
3198
+ yield { event: 'start', data: { timestamp: Date.now() } };
3199
+ yield { event: 'complete', data: { success: true } };
3200
+ }());
3201
+ }
3202
+ }
3203
+
3204
+ expect(EventsController).toBeDefined();
3205
+ });
3206
+ });
3207
+
3208
+ describe('Complete SSE Controller Example (docs/api/controllers.md)', () => {
3209
+ /**
3210
+ * @source docs/api/controllers.md#server-sent-events-sse
3211
+ */
3212
+ it('should define complete SSE controller', () => {
3213
+ // From docs: Complete SSE Controller example
3214
+ @Service()
3215
+ class DataService extends BaseService {
3216
+ async waitForUpdate(): Promise<unknown> {
3217
+ return { updated: true };
3218
+ }
3219
+ }
3220
+
3221
+ @Service()
3222
+ class NotificationService extends BaseService {
3223
+ async poll(): Promise<unknown> {
3224
+ return { type: 'notification', message: 'New message' };
3225
+ }
3226
+ }
3227
+
3228
+ @Controller('/events')
3229
+ class EventsController extends BaseController {
3230
+ constructor(
3231
+ private dataService: DataService,
3232
+ private notificationService: NotificationService,
3233
+ ) {
3234
+ super();
3235
+ }
3236
+
3237
+ // Simple SSE endpoint
3238
+ @Get('/stream')
3239
+ @Sse()
3240
+ async *stream(): SseGenerator {
3241
+ for (let i = 0; i < 10; i++) {
3242
+ yield { event: 'tick', data: { count: i, timestamp: Date.now() } };
3243
+ }
3244
+ }
3245
+
3246
+ // SSE with heartbeat
3247
+ @Get('/live')
3248
+ @Sse({ heartbeat: 15000 })
3249
+ async *live(): SseGenerator {
3250
+ yield { event: 'connected', data: { clientId: crypto.randomUUID() } };
3251
+ }
3252
+
3253
+ // SSE with event IDs for reconnection
3254
+ @Get('/notifications')
3255
+ @Sse({ heartbeat: 30000 })
3256
+ async *notifications(): SseGenerator {
3257
+ let eventId = 0;
3258
+ const notification = await this.notificationService.poll();
3259
+ eventId++;
3260
+ yield {
3261
+ event: 'notification',
3262
+ data: notification,
3263
+ id: String(eventId),
3264
+ retry: 5000,
3265
+ };
3266
+ }
3267
+
3268
+ // Using sse() method
3269
+ @Get('/manual')
3270
+ events(): Response {
3271
+ return this.sse(async function* () {
3272
+ yield { event: 'start', data: { timestamp: Date.now() } };
3273
+ yield { event: 'complete', data: { success: true } };
3274
+ }());
3275
+ }
3276
+ }
3277
+
3278
+ expect(EventsController).toBeDefined();
3279
+ expect(DataService).toBeDefined();
3280
+ expect(NotificationService).toBeDefined();
3281
+ });
3282
+ });
3283
+ });
package/src/index.ts CHANGED
@@ -42,6 +42,10 @@ export {
42
42
  type WebSocketApplicationOptions,
43
43
  // Docs types
44
44
  type DocsApplicationOptions,
45
+ // SSE types
46
+ type SseEvent,
47
+ type SseOptions,
48
+ type SseGenerator,
45
49
  } from './types';
46
50
 
47
51
  // Decorators and Metadata (exports Controller decorator, Module decorator, etc.)
@@ -1,4 +1,5 @@
1
1
  import type { IConfig, OneBunAppConfig } from './config.interface';
2
+ import type { SseEvent, SseOptions } from '../types';
2
3
  import type { Context } from 'effect';
3
4
 
4
5
  import type { SyncLogger } from '@onebun/logger';
@@ -173,4 +174,202 @@ export class Controller {
173
174
  },
174
175
  });
175
176
  }
177
+
178
+ // ===========================================================================
179
+ // SSE (Server-Sent Events) Methods
180
+ // ===========================================================================
181
+
182
+ /**
183
+ * Create an SSE (Server-Sent Events) response from an async iterable
184
+ *
185
+ * This method provides an alternative to the @Sse() decorator for creating
186
+ * SSE responses programmatically.
187
+ *
188
+ * @param source - Async iterable that yields SseEvent objects or raw data
189
+ * @param options - SSE options (heartbeat interval, etc.)
190
+ * @returns A Response object with SSE content type
191
+ *
192
+ * @example Basic usage
193
+ * ```typescript
194
+ * @Get('/events')
195
+ * events(): Response {
196
+ * return this.sse(async function* () {
197
+ * for (let i = 0; i < 10; i++) {
198
+ * await Bun.sleep(1000);
199
+ * yield { event: 'tick', data: { count: i } };
200
+ * }
201
+ * }());
202
+ * }
203
+ * ```
204
+ *
205
+ * @example With heartbeat
206
+ * ```typescript
207
+ * @Get('/live')
208
+ * live(): Response {
209
+ * const updates = this.dataService.getUpdateStream();
210
+ * return this.sse(updates, { heartbeat: 15000 });
211
+ * }
212
+ * ```
213
+ */
214
+ protected sse(
215
+ source: AsyncIterable<SseEvent | unknown>,
216
+ options: SseOptions = {},
217
+ ): Response {
218
+ const stream = createSseStream(source, options);
219
+
220
+ return new Response(stream, {
221
+ status: HttpStatusCode.OK,
222
+ headers: {
223
+ // eslint-disable-next-line @typescript-eslint/naming-convention
224
+ 'Content-Type': 'text/event-stream',
225
+ // eslint-disable-next-line @typescript-eslint/naming-convention
226
+ 'Cache-Control': 'no-cache',
227
+ // eslint-disable-next-line @typescript-eslint/naming-convention
228
+ 'Connection': 'keep-alive',
229
+ },
230
+ });
231
+ }
232
+ }
233
+
234
+ // =============================================================================
235
+ // SSE Helper Functions (used by both Controller.sse() and executeHandler)
236
+ // =============================================================================
237
+
238
+ /**
239
+ * Format an event object into SSE wire format
240
+ *
241
+ * @param event - Event object or raw data
242
+ * @returns Formatted SSE string
243
+ *
244
+ * @example
245
+ * ```typescript
246
+ * formatSseEvent({ event: 'update', data: { count: 1 }, id: '123' })
247
+ * // Returns: "event: update\nid: 123\ndata: {"count":1}\n\n"
248
+ *
249
+ * formatSseEvent({ count: 1 })
250
+ * // Returns: "data: {"count":1}\n\n"
251
+ * ```
252
+ */
253
+ export function formatSseEvent(event: SseEvent | unknown): string {
254
+ let result = '';
255
+
256
+ // Check if this is an SseEvent object or raw data
257
+ const isSseEvent =
258
+ typeof event === 'object' &&
259
+ event !== null &&
260
+ 'data' in event &&
261
+ (('event' in event && typeof (event as SseEvent).event === 'string') ||
262
+ ('id' in event && typeof (event as SseEvent).id === 'string') ||
263
+ ('retry' in event && typeof (event as SseEvent).retry === 'number') ||
264
+ Object.keys(event).every((k) => ['event', 'data', 'id', 'retry'].includes(k)));
265
+
266
+ if (isSseEvent) {
267
+ const sseEvent = event as SseEvent;
268
+
269
+ // Add event name if specified
270
+ if (sseEvent.event) {
271
+ result += `event: ${sseEvent.event}\n`;
272
+ }
273
+
274
+ // Add event ID if specified
275
+ if (sseEvent.id) {
276
+ result += `id: ${sseEvent.id}\n`;
277
+ }
278
+
279
+ // Add retry interval if specified
280
+ if (sseEvent.retry !== undefined) {
281
+ result += `retry: ${sseEvent.retry}\n`;
282
+ }
283
+
284
+ // Add data (JSON serialized)
285
+ const dataStr =
286
+ typeof sseEvent.data === 'string' ? sseEvent.data : JSON.stringify(sseEvent.data);
287
+
288
+ // Handle multi-line data by prefixing each line with "data: "
289
+ const dataLines = dataStr.split('\n');
290
+ for (const line of dataLines) {
291
+ result += `data: ${line}\n`;
292
+ }
293
+ } else {
294
+ // Raw data - serialize as JSON
295
+ const dataStr = typeof event === 'string' ? event : JSON.stringify(event);
296
+ const dataLines = dataStr.split('\n');
297
+ for (const line of dataLines) {
298
+ result += `data: ${line}\n`;
299
+ }
300
+ }
301
+
302
+ // Add final newline to complete the event
303
+ result += '\n';
304
+
305
+ return result;
306
+ }
307
+
308
+ /**
309
+ * Create a ReadableStream for SSE from an async iterable
310
+ *
311
+ * @param source - Async iterable that yields events
312
+ * @param options - SSE options
313
+ * @returns ReadableStream for Response body
314
+ */
315
+ export function createSseStream(
316
+ source: AsyncIterable<SseEvent | unknown>,
317
+ options: SseOptions = {},
318
+ ): ReadableStream<Uint8Array> {
319
+ const encoder = new TextEncoder();
320
+ let heartbeatTimer: Timer | null = null;
321
+ let isCancelled = false;
322
+
323
+ return new ReadableStream<Uint8Array>({
324
+ async start(controller) {
325
+ // Set up heartbeat timer if specified
326
+ if (options.heartbeat && options.heartbeat > 0) {
327
+ heartbeatTimer = setInterval(() => {
328
+ if (!isCancelled) {
329
+ try {
330
+ controller.enqueue(encoder.encode(': heartbeat\n\n'));
331
+ } catch {
332
+ // Controller might be closed, ignore
333
+ }
334
+ }
335
+ }, options.heartbeat);
336
+ }
337
+
338
+ try {
339
+ for await (const event of source) {
340
+ if (isCancelled) {
341
+ break;
342
+ }
343
+ const formatted = formatSseEvent(event);
344
+ controller.enqueue(encoder.encode(formatted));
345
+ }
346
+ } catch (error) {
347
+ // Log error but don't throw - stream should close gracefully
348
+ // eslint-disable-next-line no-console
349
+ console.error('[SSE] Stream error:', error);
350
+ } finally {
351
+ // Clean up heartbeat timer
352
+ if (heartbeatTimer) {
353
+ clearInterval(heartbeatTimer);
354
+ heartbeatTimer = null;
355
+ }
356
+ // Close the stream
357
+ if (!isCancelled) {
358
+ try {
359
+ controller.close();
360
+ } catch {
361
+ // Controller might already be closed
362
+ }
363
+ }
364
+ }
365
+ },
366
+
367
+ cancel() {
368
+ isCancelled = true;
369
+ if (heartbeatTimer) {
370
+ clearInterval(heartbeatTimer);
371
+ heartbeatTimer = null;
372
+ }
373
+ },
374
+ });
176
375
  }
@@ -154,6 +154,94 @@ describe('OneBunModule', () => {
154
154
 
155
155
  expect(() => new OneBunModule(TestClass, mockLoggerLayer)).toThrow();
156
156
  });
157
+
158
+ test('should detect circular dependencies and provide detailed error message', () => {
159
+ const { registerDependencies } = require('../decorators/decorators');
160
+ const { Effect, Layer } = require('effect');
161
+ const { LoggerService } = require('@onebun/logger');
162
+
163
+ // Collect error messages
164
+ const errorMessages: string[] = [];
165
+
166
+ // Create mock logger that captures error messages
167
+ // Using Effect.sync to ensure the message is captured synchronously when Effect.runSync is called
168
+ const captureLogger = {
169
+ trace: () => Effect.sync(() => undefined),
170
+ debug: () => Effect.sync(() => undefined),
171
+ info: () => Effect.sync(() => undefined),
172
+ warn: () => Effect.sync(() => undefined),
173
+ error: (msg: string) =>
174
+ Effect.sync(() => {
175
+ errorMessages.push(msg);
176
+ }),
177
+ fatal: () => Effect.sync(() => undefined),
178
+ child: () => captureLogger,
179
+ };
180
+ const captureLoggerLayer = Layer.succeed(LoggerService, captureLogger);
181
+
182
+ // Create services - define all classes first
183
+ @Service()
184
+ class CircularServiceA {
185
+ getValue() {
186
+ return 'A';
187
+ }
188
+ }
189
+
190
+ @Service()
191
+ class CircularServiceB {
192
+ getValue() {
193
+ return 'B';
194
+ }
195
+ }
196
+
197
+ @Service()
198
+ class CircularServiceC {
199
+ getValue() {
200
+ return 'C';
201
+ }
202
+ }
203
+
204
+ // Now register circular dependencies manually: A -> B -> C -> A
205
+ registerDependencies(CircularServiceA, [CircularServiceC]);
206
+ registerDependencies(CircularServiceB, [CircularServiceA]);
207
+ registerDependencies(CircularServiceC, [CircularServiceB]);
208
+
209
+ @Module({
210
+ providers: [CircularServiceA, CircularServiceB, CircularServiceC],
211
+ })
212
+ class CircularModule {}
213
+
214
+ // Initialize module - should detect circular dependency
215
+ new OneBunModule(CircularModule, captureLoggerLayer);
216
+
217
+ // Verify error message was logged
218
+ expect(errorMessages.length).toBeGreaterThan(0);
219
+
220
+ // Find the circular dependency error message
221
+ const circularError = errorMessages.find((msg) =>
222
+ msg.includes('Circular dependency detected'),
223
+ );
224
+ expect(circularError).toBeDefined();
225
+
226
+ // Should contain module name
227
+ expect(circularError).toContain('CircularModule');
228
+
229
+ // Should contain "Unresolved services" section
230
+ expect(circularError).toContain('Unresolved services');
231
+
232
+ // Should contain at least one of the service names with their dependencies
233
+ const hasServiceInfo =
234
+ circularError!.includes('CircularServiceA') ||
235
+ circularError!.includes('CircularServiceB') ||
236
+ circularError!.includes('CircularServiceC');
237
+ expect(hasServiceInfo).toBe(true);
238
+
239
+ // Should contain "needs:" showing what dependencies are required
240
+ expect(circularError).toContain('needs:');
241
+
242
+ // Should contain "Dependency chain" showing the cycle
243
+ expect(circularError).toContain('Dependency chain');
244
+ });
157
245
  });
158
246
 
159
247
  describe('Module instance methods', () => {
@@ -229,6 +229,7 @@ export class OneBunModule implements ModuleInstance {
229
229
  // Create services in dependency order
230
230
  const pendingProviders = [...metadata.providers.filter((p) => typeof p === 'function')];
231
231
  const createdServices = new Set<string>();
232
+ const unresolvedDeps = new Map<string, string[]>(); // Track unresolved dependencies for error reporting
232
233
  let iterations = 0;
233
234
  const maxIterations = pendingProviders.length * 2; // Prevent infinite loops
234
235
 
@@ -245,8 +246,11 @@ export class OneBunModule implements ModuleInstance {
245
246
  continue;
246
247
  }
247
248
 
248
- // Use autoDetectDependencies to find dependencies from constructor
249
- const detectedDeps = autoDetectDependencies(provider, availableServiceClasses);
249
+ // Use getConstructorParamTypes first (for @Inject and TypeScript metadata),
250
+ // then fallback to autoDetectDependencies (for constructor source analysis)
251
+ const detectedDeps =
252
+ getConstructorParamTypes(provider) ||
253
+ autoDetectDependencies(provider, availableServiceClasses);
250
254
  const dependencies: unknown[] = [];
251
255
  let allDependenciesResolved = true;
252
256
 
@@ -259,6 +263,12 @@ export class OneBunModule implements ModuleInstance {
259
263
  // Check if it's a service that hasn't been created yet
260
264
  const isServiceInModule = availableServiceClasses.has(depType.name);
261
265
  if (isServiceInModule && !createdServices.has(depType.name)) {
266
+ // Track unresolved dependency for error reporting
267
+ const deps = unresolvedDeps.get(provider.name) || [];
268
+ if (!deps.includes(depType.name)) {
269
+ deps.push(depType.name);
270
+ unresolvedDeps.set(provider.name, deps);
271
+ }
262
272
  // This dependency will be created later, defer this service
263
273
  allDependenciesResolved = false;
264
274
  pendingProviders.push(provider);
@@ -317,7 +327,25 @@ export class OneBunModule implements ModuleInstance {
317
327
  }
318
328
 
319
329
  if (iterations >= maxIterations) {
320
- this.logger.error('Possible circular dependency detected in services');
330
+ const unresolvedServices = pendingProviders
331
+ .filter((p) => typeof p === 'function')
332
+ .map((p) => p.name);
333
+
334
+ const details = unresolvedServices
335
+ .map((serviceName) => {
336
+ const deps = unresolvedDeps.get(serviceName) || [];
337
+
338
+ return ` - ${serviceName} -> needs: [${deps.join(', ')}]`;
339
+ })
340
+ .join('\n');
341
+
342
+ const dependencyChain = this.buildDependencyChain(unresolvedDeps, unresolvedServices);
343
+
344
+ this.logger.error(
345
+ `Circular dependency detected in module ${this.moduleClass.name}!\n` +
346
+ `Unresolved services:\n${details}\n` +
347
+ `Dependency chain: ${dependencyChain}`,
348
+ );
321
349
  }
322
350
  }
323
351
 
@@ -479,6 +507,52 @@ export class OneBunModule implements ModuleInstance {
479
507
  return serviceInstance;
480
508
  }
481
509
 
510
+ /**
511
+ * Build a human-readable dependency chain for circular dependency error reporting
512
+ * Traverses the dependency graph to find and display the cycle
513
+ */
514
+ private buildDependencyChain(
515
+ unresolvedDeps: Map<string, string[]>,
516
+ unresolvedServices: string[],
517
+ ): string {
518
+ // Find cycle by traversing dependencies
519
+ const visited = new Set<string>();
520
+ const chain: string[] = [];
521
+
522
+ const findCycle = (service: string): boolean => {
523
+ if (visited.has(service)) {
524
+ chain.push(service);
525
+
526
+ return true;
527
+ }
528
+ visited.add(service);
529
+ chain.push(service);
530
+
531
+ const deps = unresolvedDeps.get(service) || [];
532
+ for (const dep of deps) {
533
+ if (unresolvedServices.includes(dep)) {
534
+ if (findCycle(dep)) {
535
+ return true;
536
+ }
537
+ }
538
+ }
539
+ chain.pop();
540
+
541
+ return false;
542
+ };
543
+
544
+ for (const service of unresolvedServices) {
545
+ visited.clear();
546
+ chain.length = 0;
547
+ if (findCycle(service)) {
548
+ return chain.join(' -> ');
549
+ }
550
+ }
551
+
552
+ // If no cycle found, just show all unresolved services
553
+ return unresolvedServices.join(' <-> ');
554
+ }
555
+
482
556
  /**
483
557
  * Setup the module and its dependencies
484
558
  */
package/src/types.ts CHANGED
@@ -511,3 +511,79 @@ export interface ControllerMetadata {
511
511
  path: string;
512
512
  routes: RouteMetadata[];
513
513
  }
514
+
515
+ // ============================================================================
516
+ // SSE (Server-Sent Events) Types
517
+ // ============================================================================
518
+
519
+ /**
520
+ * SSE event structure
521
+ *
522
+ * Represents a single Server-Sent Event that can be sent to the client.
523
+ *
524
+ * @example
525
+ * ```typescript
526
+ * // Simple event with data
527
+ * yield { data: { message: 'Hello' } };
528
+ *
529
+ * // Named event with ID
530
+ * yield { event: 'update', data: { count: 42 }, id: '123' };
531
+ *
532
+ * // Event with retry interval
533
+ * yield { event: 'status', data: { online: true }, retry: 5000 };
534
+ * ```
535
+ */
536
+ export interface SseEvent {
537
+ /** Event name (optional, defaults to 'message') */
538
+ event?: string;
539
+ /** Event data (will be JSON serialized) */
540
+ data: unknown;
541
+ /** Event ID for reconnection (Last-Event-ID header) */
542
+ id?: string;
543
+ /** Reconnection interval in milliseconds */
544
+ retry?: number;
545
+ }
546
+
547
+ /**
548
+ * SSE decorator options
549
+ *
550
+ * @example
551
+ * ```typescript
552
+ * @Sse({ heartbeat: 15000 }) // Send heartbeat every 15 seconds
553
+ * async *events(): SseGenerator {
554
+ * // ...
555
+ * }
556
+ * ```
557
+ */
558
+ export interface SseOptions {
559
+ /**
560
+ * Heartbeat interval in milliseconds.
561
+ * When set, the server will send a comment (": heartbeat\n\n")
562
+ * at this interval to keep the connection alive.
563
+ */
564
+ heartbeat?: number;
565
+ }
566
+
567
+ /**
568
+ * SSE generator type
569
+ *
570
+ * An async generator that yields SSE events or raw data.
571
+ * When raw data is yielded, it will be wrapped in a default event.
572
+ *
573
+ * @example
574
+ * ```typescript
575
+ * @Get('/events')
576
+ * @Sse()
577
+ * async *events(): SseGenerator {
578
+ * yield { event: 'start', data: { timestamp: Date.now() } };
579
+ *
580
+ * for (let i = 0; i < 10; i++) {
581
+ * await Bun.sleep(1000);
582
+ * yield { event: 'tick', data: { count: i } };
583
+ * }
584
+ *
585
+ * yield { event: 'end', data: { total: 10 } };
586
+ * }
587
+ * ```
588
+ */
589
+ export type SseGenerator = AsyncGenerator<SseEvent | unknown, void, unknown>;