@onebun/core 0.1.15 → 0.1.17
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.ts +68 -2
- package/src/decorators/decorators.ts +76 -0
- package/src/docs-examples.test.ts +407 -54
- package/src/index.ts +4 -0
- package/src/module/controller.ts +199 -0
- package/src/module/module.ts +5 -4
- package/src/types.ts +76 -0
package/package.json
CHANGED
|
@@ -24,13 +24,18 @@ import {
|
|
|
24
24
|
} from '@onebun/requests';
|
|
25
25
|
import { makeTraceService, TraceService } from '@onebun/trace';
|
|
26
26
|
|
|
27
|
-
import {
|
|
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
|
-
|
|
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.)
|
package/src/module/controller.ts
CHANGED
|
@@ -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
|
}
|
package/src/module/module.ts
CHANGED
|
@@ -326,11 +326,12 @@ export class OneBunModule implements ModuleInstance {
|
|
|
326
326
|
}
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
-
if
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
329
|
+
// Only report circular dependency if there are still unresolved services
|
|
330
|
+
const unresolvedServices = pendingProviders
|
|
331
|
+
.filter((p) => typeof p === 'function')
|
|
332
|
+
.map((p) => p.name);
|
|
333
333
|
|
|
334
|
+
if (iterations >= maxIterations && unresolvedServices.length > 0) {
|
|
334
335
|
const details = unresolvedServices
|
|
335
336
|
.map((serviceName) => {
|
|
336
337
|
const deps = unresolvedDeps.get(serviceName) || [];
|
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>;
|