opinionated-machine 5.1.0 → 5.2.0
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/README.md +1237 -278
- package/dist/index.d.ts +3 -2
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/AbstractController.d.ts +3 -3
- package/dist/lib/AbstractController.js.map +1 -1
- package/dist/lib/AbstractModule.d.ts +14 -0
- package/dist/lib/AbstractModule.js +16 -0
- package/dist/lib/AbstractModule.js.map +1 -1
- package/dist/lib/DIContext.d.ts +35 -0
- package/dist/lib/DIContext.js +99 -0
- package/dist/lib/DIContext.js.map +1 -1
- package/dist/lib/resolverFunctions.d.ts +33 -0
- package/dist/lib/resolverFunctions.js +46 -0
- package/dist/lib/resolverFunctions.js.map +1 -1
- package/dist/lib/sse/AbstractSSEController.d.ts +163 -0
- package/dist/lib/sse/AbstractSSEController.js +228 -0
- package/dist/lib/sse/AbstractSSEController.js.map +1 -0
- package/dist/lib/sse/SSEConnectionSpy.d.ts +55 -0
- package/dist/lib/sse/SSEConnectionSpy.js +136 -0
- package/dist/lib/sse/SSEConnectionSpy.js.map +1 -0
- package/dist/lib/sse/index.d.ts +5 -0
- package/dist/lib/sse/index.js +6 -0
- package/dist/lib/sse/index.js.map +1 -0
- package/dist/lib/sse/sseContracts.d.ts +132 -0
- package/dist/lib/sse/sseContracts.js +102 -0
- package/dist/lib/sse/sseContracts.js.map +1 -0
- package/dist/lib/sse/sseParser.d.ts +167 -0
- package/dist/lib/sse/sseParser.js +225 -0
- package/dist/lib/sse/sseParser.js.map +1 -0
- package/dist/lib/sse/sseRouteBuilder.d.ts +47 -0
- package/dist/lib/sse/sseRouteBuilder.js +114 -0
- package/dist/lib/sse/sseRouteBuilder.js.map +1 -0
- package/dist/lib/sse/sseTypes.d.ts +164 -0
- package/dist/lib/sse/sseTypes.js +2 -0
- package/dist/lib/sse/sseTypes.js.map +1 -0
- package/dist/lib/testing/index.d.ts +5 -0
- package/dist/lib/testing/index.js +5 -0
- package/dist/lib/testing/index.js.map +1 -0
- package/dist/lib/testing/sseHttpClient.d.ts +203 -0
- package/dist/lib/testing/sseHttpClient.js +262 -0
- package/dist/lib/testing/sseHttpClient.js.map +1 -0
- package/dist/lib/testing/sseInjectClient.d.ts +173 -0
- package/dist/lib/testing/sseInjectClient.js +234 -0
- package/dist/lib/testing/sseInjectClient.js.map +1 -0
- package/dist/lib/testing/sseInjectHelpers.d.ts +59 -0
- package/dist/lib/testing/sseInjectHelpers.js +117 -0
- package/dist/lib/testing/sseInjectHelpers.js.map +1 -0
- package/dist/lib/testing/sseTestServer.d.ts +93 -0
- package/dist/lib/testing/sseTestServer.js +108 -0
- package/dist/lib/testing/sseTestServer.js.map +1 -0
- package/dist/lib/testing/sseTestTypes.d.ts +106 -0
- package/dist/lib/testing/sseTestTypes.js +2 -0
- package/dist/lib/testing/sseTestTypes.js.map +1 -0
- package/package.json +80 -78
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
2
|
+
import type { z } from 'zod';
|
|
3
|
+
import type { AnySSERouteDefinition } from './sseContracts.ts';
|
|
4
|
+
/**
|
|
5
|
+
* Minimal logger interface for SSE route error handling.
|
|
6
|
+
* Compatible with CommonLogger from @lokalise/node-core and pino loggers.
|
|
7
|
+
*/
|
|
8
|
+
export type SSELogger = {
|
|
9
|
+
error: (obj: Record<string, unknown>, msg: string) => void;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Async preHandler hook for SSE routes.
|
|
13
|
+
*
|
|
14
|
+
* IMPORTANT: SSE route preHandlers MUST return a Promise. This is required
|
|
15
|
+
* for proper integration with @fastify/sse. Synchronous handlers will cause
|
|
16
|
+
* connection issues.
|
|
17
|
+
*
|
|
18
|
+
* For rejection (auth failure), return the reply after sending:
|
|
19
|
+
* ```typescript
|
|
20
|
+
* preHandler: (request, reply) => {
|
|
21
|
+
* if (!validAuth) {
|
|
22
|
+
* return reply.code(401).send({ error: 'Unauthorized' })
|
|
23
|
+
* }
|
|
24
|
+
* return Promise.resolve()
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export type SSEPreHandler = (request: FastifyRequest, reply: FastifyReply) => Promise<unknown>;
|
|
29
|
+
/**
|
|
30
|
+
* Represents an active SSE connection with typed context.
|
|
31
|
+
*
|
|
32
|
+
* @template Context - Custom context data stored per connection
|
|
33
|
+
*/
|
|
34
|
+
export type SSEConnection<Context = unknown> = {
|
|
35
|
+
/** Unique identifier for this connection */
|
|
36
|
+
id: string;
|
|
37
|
+
/** The original Fastify request */
|
|
38
|
+
request: FastifyRequest;
|
|
39
|
+
/** The Fastify reply with SSE capabilities from @fastify/sse */
|
|
40
|
+
reply: FastifyReply;
|
|
41
|
+
/** Custom context data for this connection */
|
|
42
|
+
context: Context;
|
|
43
|
+
/** Timestamp when the connection was established */
|
|
44
|
+
connectedAt: Date;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* SSE message format compatible with @fastify/sse.
|
|
48
|
+
*
|
|
49
|
+
* @template T - Type of the event data
|
|
50
|
+
*/
|
|
51
|
+
export type SSEMessage<T = unknown> = {
|
|
52
|
+
/** Event name (maps to EventSource 'event' field) */
|
|
53
|
+
event?: string;
|
|
54
|
+
/** Event data (will be JSON serialized) */
|
|
55
|
+
data: T;
|
|
56
|
+
/** Event ID for client reconnection via Last-Event-ID */
|
|
57
|
+
id?: string;
|
|
58
|
+
/** Reconnection delay hint in milliseconds */
|
|
59
|
+
retry?: number;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Handler called when an SSE connection is established.
|
|
63
|
+
*
|
|
64
|
+
* @template Params - Path parameters type
|
|
65
|
+
* @template Query - Query string parameters type
|
|
66
|
+
* @template Headers - Request headers type
|
|
67
|
+
* @template Body - Request body type (for POST/PUT/PATCH)
|
|
68
|
+
* @template Context - Connection context type
|
|
69
|
+
*/
|
|
70
|
+
export type SSERouteHandler<Params = unknown, Query = unknown, Headers = unknown, Body = unknown, Context = unknown> = (request: FastifyRequest<{
|
|
71
|
+
Params: Params;
|
|
72
|
+
Querystring: Query;
|
|
73
|
+
Headers: Headers;
|
|
74
|
+
Body: Body;
|
|
75
|
+
}>, connection: SSEConnection<Context>) => void | Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Options for configuring an SSE route.
|
|
78
|
+
*/
|
|
79
|
+
export type SSERouteOptions = {
|
|
80
|
+
/**
|
|
81
|
+
* Async preHandler hook for authentication/authorization.
|
|
82
|
+
* Runs BEFORE the SSE connection is established.
|
|
83
|
+
*
|
|
84
|
+
* MUST return a Promise - synchronous handlers will cause connection issues.
|
|
85
|
+
* Return `reply.code(401).send(...)` for rejection, or `Promise.resolve()` for success.
|
|
86
|
+
*
|
|
87
|
+
* @see SSEPreHandler for usage examples
|
|
88
|
+
*/
|
|
89
|
+
preHandler?: SSEPreHandler;
|
|
90
|
+
/**
|
|
91
|
+
* Called when client connects (after SSE handshake).
|
|
92
|
+
*/
|
|
93
|
+
onConnect?: (connection: SSEConnection) => void | Promise<void>;
|
|
94
|
+
/**
|
|
95
|
+
* Called when client disconnects.
|
|
96
|
+
*/
|
|
97
|
+
onDisconnect?: (connection: SSEConnection) => void | Promise<void>;
|
|
98
|
+
/**
|
|
99
|
+
* Handler for Last-Event-ID reconnection.
|
|
100
|
+
* Return an iterable of events to replay, or handle replay manually.
|
|
101
|
+
* Supports both sync iterables (arrays, generators) and async iterables.
|
|
102
|
+
*/
|
|
103
|
+
onReconnect?: (connection: SSEConnection, lastEventId: string) => Iterable<SSEMessage> | AsyncIterable<SSEMessage> | void | Promise<void>;
|
|
104
|
+
/**
|
|
105
|
+
* Optional logger for SSE route errors.
|
|
106
|
+
* If not provided, errors will be logged to console.error.
|
|
107
|
+
* Compatible with CommonLogger from @lokalise/node-core and pino loggers.
|
|
108
|
+
*/
|
|
109
|
+
logger?: SSELogger;
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Route configuration returned by buildSSERoutes().
|
|
113
|
+
*
|
|
114
|
+
* @template Contract - The SSE route definition
|
|
115
|
+
*/
|
|
116
|
+
export type SSEHandlerConfig<Contract extends AnySSERouteDefinition> = {
|
|
117
|
+
/** The SSE route contract */
|
|
118
|
+
contract: Contract;
|
|
119
|
+
/** Handler called when connection is established */
|
|
120
|
+
handler: SSERouteHandler<z.infer<Contract['params']>, z.infer<Contract['query']>, z.infer<Contract['requestHeaders']>, Contract['body'] extends z.ZodTypeAny ? z.infer<Contract['body']> : undefined, unknown>;
|
|
121
|
+
/** Optional route configuration */
|
|
122
|
+
options?: SSERouteOptions;
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Maps SSE contracts to handler configurations for type checking.
|
|
126
|
+
*/
|
|
127
|
+
export type BuildSSERoutesReturnType<APIContracts extends Record<string, AnySSERouteDefinition>> = {
|
|
128
|
+
[K in keyof APIContracts]: SSEHandlerConfig<APIContracts[K]>;
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Infer the FastifyRequest type from an SSE contract.
|
|
132
|
+
*
|
|
133
|
+
* Use this to get properly typed request parameters in handlers without
|
|
134
|
+
* manually spelling out the types.
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```typescript
|
|
138
|
+
* const handler = async (
|
|
139
|
+
* request: InferSSERequest<typeof chatCompletionContract>,
|
|
140
|
+
* connection: SSEConnection,
|
|
141
|
+
* ) => {
|
|
142
|
+
* // request.body is typed as { message: string; stream: true }
|
|
143
|
+
* const { message } = request.body
|
|
144
|
+
* }
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
export type InferSSERequest<Contract extends AnySSERouteDefinition> = FastifyRequest<{
|
|
148
|
+
Params: z.infer<Contract['params']>;
|
|
149
|
+
Querystring: z.infer<Contract['query']>;
|
|
150
|
+
Headers: z.infer<Contract['requestHeaders']>;
|
|
151
|
+
Body: Contract['body'] extends z.ZodTypeAny ? z.infer<Contract['body']> : undefined;
|
|
152
|
+
}>;
|
|
153
|
+
/**
|
|
154
|
+
* Configuration options for SSE controllers.
|
|
155
|
+
*/
|
|
156
|
+
export type SSEControllerConfig = {
|
|
157
|
+
/**
|
|
158
|
+
* Enable connection spying for testing.
|
|
159
|
+
* When enabled, the controller tracks connections and allows waiting for them.
|
|
160
|
+
* Only enable this in test environments.
|
|
161
|
+
* @default false
|
|
162
|
+
*/
|
|
163
|
+
enableConnectionSpy?: boolean;
|
|
164
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sseTypes.js","sourceRoot":"","sources":["../../../lib/sse/sseTypes.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { type HasConnectionSpy, SSEHttpClient, type SSEHttpConnectOptions, type SSEHttpConnectResult, type SSEHttpConnectWithSpyOptions, } from './sseHttpClient.js';
|
|
2
|
+
export { SSEInjectClient, SSEInjectConnection } from './sseInjectClient.js';
|
|
3
|
+
export { buildUrl, injectPayloadSSE, injectSSE } from './sseInjectHelpers.js';
|
|
4
|
+
export { SSETestServer } from './sseTestServer.js';
|
|
5
|
+
export type { CreateSSETestServerOptions, InjectPayloadSSEOptions, InjectSSEOptions, InjectSSEResult, SSEConnectOptions, SSEResponse, SSETestConnection, } from './sseTestTypes.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { SSEHttpClient, } from './sseHttpClient.js';
|
|
2
|
+
export { SSEInjectClient, SSEInjectConnection } from './sseInjectClient.js';
|
|
3
|
+
export { buildUrl, injectPayloadSSE, injectSSE } from './sseInjectHelpers.js';
|
|
4
|
+
export { SSETestServer } from './sseTestServer.js';
|
|
5
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../lib/testing/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,aAAa,GAId,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,eAAe,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAA;AAC3E,OAAO,EAAE,QAAQ,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAA;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA"}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { SSEConnectionSpy } from '../sse/SSEConnectionSpy.ts';
|
|
2
|
+
import { type ParsedSSEEvent } from '../sse/sseParser.ts';
|
|
3
|
+
import type { SSEConnection } from '../sse/sseTypes.ts';
|
|
4
|
+
/**
|
|
5
|
+
* Interface for objects that have a connectionSpy (e.g., SSE controllers in test mode).
|
|
6
|
+
*/
|
|
7
|
+
export type HasConnectionSpy = {
|
|
8
|
+
connectionSpy: SSEConnectionSpy;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Options for connecting to an SSE endpoint via HTTP.
|
|
12
|
+
*/
|
|
13
|
+
export type SSEHttpConnectOptions = {
|
|
14
|
+
/** Query parameters to add to the URL */
|
|
15
|
+
query?: Record<string, string | undefined>;
|
|
16
|
+
/** Additional headers to send with the request */
|
|
17
|
+
headers?: Record<string, string>;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Options for connecting with automatic server-side connection waiting.
|
|
21
|
+
*/
|
|
22
|
+
export type SSEHttpConnectWithSpyOptions = SSEHttpConnectOptions & {
|
|
23
|
+
/**
|
|
24
|
+
* Wait for server-side connection registration after HTTP headers are received.
|
|
25
|
+
* This eliminates the race condition between `connect()` returning and the
|
|
26
|
+
* server-side handler completing connection registration.
|
|
27
|
+
*/
|
|
28
|
+
awaitServerConnection: {
|
|
29
|
+
/** The SSE controller (must have connectionSpy enabled via isTestMode) */
|
|
30
|
+
controller: HasConnectionSpy;
|
|
31
|
+
/** Timeout in milliseconds (default: 5000) */
|
|
32
|
+
timeout?: number;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Result when connecting with awaitServerConnection option.
|
|
37
|
+
*/
|
|
38
|
+
export type SSEHttpConnectResult = {
|
|
39
|
+
client: SSEHttpClient;
|
|
40
|
+
serverConnection: SSEConnection;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* SSE client for testing long-lived connections using real HTTP.
|
|
44
|
+
*
|
|
45
|
+
* This client uses the native `fetch()` API to establish a real HTTP connection
|
|
46
|
+
* to an SSE endpoint. Events are streamed incrementally as the server sends them,
|
|
47
|
+
* making it suitable for testing:
|
|
48
|
+
*
|
|
49
|
+
* - **Long-lived connections** that stay open indefinitely
|
|
50
|
+
* - **Real-time notifications** where events arrive over time
|
|
51
|
+
* - **Push-based streaming** where the client waits for server-initiated events
|
|
52
|
+
*
|
|
53
|
+
* **When to use SSEHttpClient vs SSEInjectClient:**
|
|
54
|
+
*
|
|
55
|
+
* | SSEHttpClient (this class) | SSEInjectClient |
|
|
56
|
+
* |-------------------------------------|--------------------------------------|
|
|
57
|
+
* | Real HTTP connection via fetch() | Fastify's inject() (no network) |
|
|
58
|
+
* | Events arrive incrementally | All events returned at once |
|
|
59
|
+
* | Connection can stay open | Response must complete |
|
|
60
|
+
* | Requires running server (listen()) | Works without starting server |
|
|
61
|
+
* | Use for: notifications, chat, feeds | Use for: OpenAI-style streaming |
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```typescript
|
|
65
|
+
* // 1. Start a real HTTP server
|
|
66
|
+
* await app.listen({ port: 0 })
|
|
67
|
+
* const address = app.server.address() as { port: number }
|
|
68
|
+
* const baseUrl = `http://localhost:${address.port}`
|
|
69
|
+
*
|
|
70
|
+
* // 2. Connect to SSE endpoint (returns when headers are received)
|
|
71
|
+
* const client = await SSEHttpClient.connect(baseUrl, '/api/notifications', {
|
|
72
|
+
* headers: { authorization: 'Bearer token' },
|
|
73
|
+
* })
|
|
74
|
+
*
|
|
75
|
+
* // 3. Server can now send events at any time
|
|
76
|
+
* controller.sendEvent(connectionId, { event: 'notification', data: { msg: 'Hello' } })
|
|
77
|
+
*
|
|
78
|
+
* // 4. Collect events as they arrive
|
|
79
|
+
* const events = await client.collectEvents(3) // wait for 3 events
|
|
80
|
+
* // or: collect until a specific event
|
|
81
|
+
* const events = await client.collectEvents(e => e.event === 'done')
|
|
82
|
+
*
|
|
83
|
+
* // 5. Alternative: use async iterator for manual control
|
|
84
|
+
* for await (const event of client.events()) {
|
|
85
|
+
* console.log('Received:', event.event, event.data)
|
|
86
|
+
* if (event.event === 'done') break
|
|
87
|
+
* }
|
|
88
|
+
*
|
|
89
|
+
* // 6. Cleanup
|
|
90
|
+
* client.close()
|
|
91
|
+
* await app.close()
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export declare class SSEHttpClient {
|
|
95
|
+
/** The fetch Response object. Available immediately after connect() returns. */
|
|
96
|
+
readonly response: Response;
|
|
97
|
+
private readonly abortController;
|
|
98
|
+
private readonly reader;
|
|
99
|
+
private readonly decoder;
|
|
100
|
+
private buffer;
|
|
101
|
+
private closed;
|
|
102
|
+
private constructor();
|
|
103
|
+
/**
|
|
104
|
+
* Connect to an SSE endpoint.
|
|
105
|
+
*
|
|
106
|
+
* The returned promise resolves as soon as HTTP headers are received,
|
|
107
|
+
* indicating the connection is established. Events can then be consumed
|
|
108
|
+
* via `events()` or `collectEvents()`.
|
|
109
|
+
*
|
|
110
|
+
* @param baseUrl - Base URL of the server (e.g., 'http://localhost:3000')
|
|
111
|
+
* @param path - SSE endpoint path (e.g., '/api/notifications')
|
|
112
|
+
* @param options - Connection options (query params, headers)
|
|
113
|
+
* @returns Connected SSE client ready to receive events
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```typescript
|
|
117
|
+
* // Basic connection (returns when HTTP headers received)
|
|
118
|
+
* const client = await SSEHttpClient.connect(
|
|
119
|
+
* 'http://localhost:3000',
|
|
120
|
+
* '/api/stream',
|
|
121
|
+
* { query: { userId: '123' }, headers: { authorization: 'Bearer token' } }
|
|
122
|
+
* )
|
|
123
|
+
*
|
|
124
|
+
* // With awaitServerConnection (waits for server-side registration)
|
|
125
|
+
* const { client, serverConnection } = await SSEHttpClient.connect(
|
|
126
|
+
* 'http://localhost:3000',
|
|
127
|
+
* '/api/stream',
|
|
128
|
+
* { awaitServerConnection: { controller } }
|
|
129
|
+
* )
|
|
130
|
+
* // serverConnection is ready to use immediately
|
|
131
|
+
* await controller.sendEvent(serverConnection.id, { event: 'test', data: {} })
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
static connect(baseUrl: string, path: string, options: SSEHttpConnectWithSpyOptions): Promise<SSEHttpConnectResult>;
|
|
135
|
+
static connect(baseUrl: string, path: string, options?: SSEHttpConnectOptions): Promise<SSEHttpClient>;
|
|
136
|
+
/**
|
|
137
|
+
* Async generator that yields parsed SSE events as they arrive.
|
|
138
|
+
*
|
|
139
|
+
* Use this for full control over event processing. The generator
|
|
140
|
+
* completes when the server closes the connection or the abort signal fires.
|
|
141
|
+
*
|
|
142
|
+
* @param signal - Optional AbortSignal to stop the generator early
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* ```typescript
|
|
146
|
+
* for await (const event of client.events()) {
|
|
147
|
+
* const data = JSON.parse(event.data)
|
|
148
|
+
* console.log(`[${event.event}]`, data)
|
|
149
|
+
*
|
|
150
|
+
* if (event.event === 'done') {
|
|
151
|
+
* break // Stop consuming, connection stays open until close()
|
|
152
|
+
* }
|
|
153
|
+
* }
|
|
154
|
+
* ```
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* ```typescript
|
|
158
|
+
* // With abort signal for timeout control
|
|
159
|
+
* const controller = new AbortController()
|
|
160
|
+
* setTimeout(() => controller.abort(), 5000)
|
|
161
|
+
*
|
|
162
|
+
* for await (const event of client.events(controller.signal)) {
|
|
163
|
+
* console.log(event)
|
|
164
|
+
* }
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
events(signal?: AbortSignal): AsyncGenerator<ParsedSSEEvent, void, unknown>;
|
|
168
|
+
/**
|
|
169
|
+
* Read from the stream with abort signal support.
|
|
170
|
+
* Returns 'aborted' if the signal fires before read completes.
|
|
171
|
+
*/
|
|
172
|
+
private readWithAbort;
|
|
173
|
+
/**
|
|
174
|
+
* Collect events until a count is reached or predicate returns true.
|
|
175
|
+
*
|
|
176
|
+
* @param countOrPredicate - Either a number of events to collect,
|
|
177
|
+
* or a predicate function that returns true when collection should stop.
|
|
178
|
+
* The event that matches the predicate IS included in the result.
|
|
179
|
+
* @param timeout - Maximum time to wait in milliseconds (default: 5000)
|
|
180
|
+
* @returns Array of collected events
|
|
181
|
+
* @throws Error if timeout is reached before condition is met
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* ```typescript
|
|
185
|
+
* // Collect exactly 5 events
|
|
186
|
+
* const events = await client.collectEvents(5)
|
|
187
|
+
*
|
|
188
|
+
* // Collect until 'done' event is received
|
|
189
|
+
* const events = await client.collectEvents(e => e.event === 'done')
|
|
190
|
+
*
|
|
191
|
+
* // Collect with custom timeout
|
|
192
|
+
* const events = await client.collectEvents(10, 30000) // 30s timeout
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
collectEvents(countOrPredicate: number | ((event: ParsedSSEEvent) => boolean), timeout?: number): Promise<ParsedSSEEvent[]>;
|
|
196
|
+
/**
|
|
197
|
+
* Close the connection from the client side.
|
|
198
|
+
*
|
|
199
|
+
* This aborts the underlying fetch request. Call this when done
|
|
200
|
+
* consuming events to clean up resources.
|
|
201
|
+
*/
|
|
202
|
+
close(): void;
|
|
203
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { stringify } from 'fast-querystring';
|
|
2
|
+
import { parseSSEBuffer } from "../sse/sseParser.js";
|
|
3
|
+
/**
|
|
4
|
+
* SSE client for testing long-lived connections using real HTTP.
|
|
5
|
+
*
|
|
6
|
+
* This client uses the native `fetch()` API to establish a real HTTP connection
|
|
7
|
+
* to an SSE endpoint. Events are streamed incrementally as the server sends them,
|
|
8
|
+
* making it suitable for testing:
|
|
9
|
+
*
|
|
10
|
+
* - **Long-lived connections** that stay open indefinitely
|
|
11
|
+
* - **Real-time notifications** where events arrive over time
|
|
12
|
+
* - **Push-based streaming** where the client waits for server-initiated events
|
|
13
|
+
*
|
|
14
|
+
* **When to use SSEHttpClient vs SSEInjectClient:**
|
|
15
|
+
*
|
|
16
|
+
* | SSEHttpClient (this class) | SSEInjectClient |
|
|
17
|
+
* |-------------------------------------|--------------------------------------|
|
|
18
|
+
* | Real HTTP connection via fetch() | Fastify's inject() (no network) |
|
|
19
|
+
* | Events arrive incrementally | All events returned at once |
|
|
20
|
+
* | Connection can stay open | Response must complete |
|
|
21
|
+
* | Requires running server (listen()) | Works without starting server |
|
|
22
|
+
* | Use for: notifications, chat, feeds | Use for: OpenAI-style streaming |
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* // 1. Start a real HTTP server
|
|
27
|
+
* await app.listen({ port: 0 })
|
|
28
|
+
* const address = app.server.address() as { port: number }
|
|
29
|
+
* const baseUrl = `http://localhost:${address.port}`
|
|
30
|
+
*
|
|
31
|
+
* // 2. Connect to SSE endpoint (returns when headers are received)
|
|
32
|
+
* const client = await SSEHttpClient.connect(baseUrl, '/api/notifications', {
|
|
33
|
+
* headers: { authorization: 'Bearer token' },
|
|
34
|
+
* })
|
|
35
|
+
*
|
|
36
|
+
* // 3. Server can now send events at any time
|
|
37
|
+
* controller.sendEvent(connectionId, { event: 'notification', data: { msg: 'Hello' } })
|
|
38
|
+
*
|
|
39
|
+
* // 4. Collect events as they arrive
|
|
40
|
+
* const events = await client.collectEvents(3) // wait for 3 events
|
|
41
|
+
* // or: collect until a specific event
|
|
42
|
+
* const events = await client.collectEvents(e => e.event === 'done')
|
|
43
|
+
*
|
|
44
|
+
* // 5. Alternative: use async iterator for manual control
|
|
45
|
+
* for await (const event of client.events()) {
|
|
46
|
+
* console.log('Received:', event.event, event.data)
|
|
47
|
+
* if (event.event === 'done') break
|
|
48
|
+
* }
|
|
49
|
+
*
|
|
50
|
+
* // 6. Cleanup
|
|
51
|
+
* client.close()
|
|
52
|
+
* await app.close()
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export class SSEHttpClient {
|
|
56
|
+
/** The fetch Response object. Available immediately after connect() returns. */
|
|
57
|
+
response;
|
|
58
|
+
abortController;
|
|
59
|
+
reader;
|
|
60
|
+
decoder = new TextDecoder();
|
|
61
|
+
buffer = '';
|
|
62
|
+
closed = false;
|
|
63
|
+
constructor(response, abortController) {
|
|
64
|
+
this.response = response;
|
|
65
|
+
this.abortController = abortController;
|
|
66
|
+
if (!response.body) {
|
|
67
|
+
throw new Error('SSE response has no body');
|
|
68
|
+
}
|
|
69
|
+
this.reader = response.body.getReader();
|
|
70
|
+
}
|
|
71
|
+
static async connect(baseUrl, path, options) {
|
|
72
|
+
// Build path with query string
|
|
73
|
+
let pathWithQuery = path;
|
|
74
|
+
if (options?.query) {
|
|
75
|
+
const queryString = stringify(options.query);
|
|
76
|
+
if (queryString) {
|
|
77
|
+
pathWithQuery = `${path}?${queryString}`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Connect - fetch() returns when headers are received
|
|
81
|
+
const abortController = new AbortController();
|
|
82
|
+
const response = await fetch(`${baseUrl}${pathWithQuery}`, {
|
|
83
|
+
headers: {
|
|
84
|
+
Accept: 'text/event-stream',
|
|
85
|
+
...options?.headers,
|
|
86
|
+
},
|
|
87
|
+
signal: abortController.signal,
|
|
88
|
+
});
|
|
89
|
+
const client = new SSEHttpClient(response, abortController);
|
|
90
|
+
// If awaitServerConnection is specified, wait for server-side registration
|
|
91
|
+
if (options && 'awaitServerConnection' in options && options.awaitServerConnection) {
|
|
92
|
+
const { controller, timeout } = options.awaitServerConnection;
|
|
93
|
+
const serverConnection = await controller.connectionSpy.waitForConnection({
|
|
94
|
+
timeout: timeout ?? 5000,
|
|
95
|
+
predicate: (conn) => conn.request.url === pathWithQuery,
|
|
96
|
+
});
|
|
97
|
+
return { client, serverConnection };
|
|
98
|
+
}
|
|
99
|
+
return client;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Async generator that yields parsed SSE events as they arrive.
|
|
103
|
+
*
|
|
104
|
+
* Use this for full control over event processing. The generator
|
|
105
|
+
* completes when the server closes the connection or the abort signal fires.
|
|
106
|
+
*
|
|
107
|
+
* @param signal - Optional AbortSignal to stop the generator early
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```typescript
|
|
111
|
+
* for await (const event of client.events()) {
|
|
112
|
+
* const data = JSON.parse(event.data)
|
|
113
|
+
* console.log(`[${event.event}]`, data)
|
|
114
|
+
*
|
|
115
|
+
* if (event.event === 'done') {
|
|
116
|
+
* break // Stop consuming, connection stays open until close()
|
|
117
|
+
* }
|
|
118
|
+
* }
|
|
119
|
+
* ```
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```typescript
|
|
123
|
+
* // With abort signal for timeout control
|
|
124
|
+
* const controller = new AbortController()
|
|
125
|
+
* setTimeout(() => controller.abort(), 5000)
|
|
126
|
+
*
|
|
127
|
+
* for await (const event of client.events(controller.signal)) {
|
|
128
|
+
* console.log(event)
|
|
129
|
+
* }
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
async *events(signal) {
|
|
133
|
+
while (!this.closed) {
|
|
134
|
+
if (signal?.aborted) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const readResult = await this.readWithAbort(signal);
|
|
138
|
+
if (readResult === 'aborted') {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (readResult.done) {
|
|
142
|
+
this.closed = true;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
this.buffer += this.decoder.decode(readResult.value, { stream: true });
|
|
146
|
+
const parseResult = parseSSEBuffer(this.buffer);
|
|
147
|
+
this.buffer = parseResult.remaining;
|
|
148
|
+
for (const event of parseResult.events) {
|
|
149
|
+
if (signal?.aborted) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
yield event;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Read from the stream with abort signal support.
|
|
158
|
+
* Returns 'aborted' if the signal fires before read completes.
|
|
159
|
+
*/
|
|
160
|
+
async readWithAbort(signal) {
|
|
161
|
+
const readPromise = this.reader.read();
|
|
162
|
+
if (!signal) {
|
|
163
|
+
return readPromise;
|
|
164
|
+
}
|
|
165
|
+
let raceSettled = false;
|
|
166
|
+
const abortPromise = new Promise((resolve) => {
|
|
167
|
+
const onAbort = () => {
|
|
168
|
+
if (!raceSettled) {
|
|
169
|
+
resolve('aborted');
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
if (signal.aborted) {
|
|
173
|
+
onAbort();
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
const result = await Promise.race([readPromise, abortPromise]);
|
|
180
|
+
raceSettled = true;
|
|
181
|
+
if (result === 'aborted') {
|
|
182
|
+
// Prevent unhandled rejection when connection closes
|
|
183
|
+
readPromise.catch(() => { });
|
|
184
|
+
}
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Collect events until a count is reached or predicate returns true.
|
|
189
|
+
*
|
|
190
|
+
* @param countOrPredicate - Either a number of events to collect,
|
|
191
|
+
* or a predicate function that returns true when collection should stop.
|
|
192
|
+
* The event that matches the predicate IS included in the result.
|
|
193
|
+
* @param timeout - Maximum time to wait in milliseconds (default: 5000)
|
|
194
|
+
* @returns Array of collected events
|
|
195
|
+
* @throws Error if timeout is reached before condition is met
|
|
196
|
+
*
|
|
197
|
+
* @example
|
|
198
|
+
* ```typescript
|
|
199
|
+
* // Collect exactly 5 events
|
|
200
|
+
* const events = await client.collectEvents(5)
|
|
201
|
+
*
|
|
202
|
+
* // Collect until 'done' event is received
|
|
203
|
+
* const events = await client.collectEvents(e => e.event === 'done')
|
|
204
|
+
*
|
|
205
|
+
* // Collect with custom timeout
|
|
206
|
+
* const events = await client.collectEvents(10, 30000) // 30s timeout
|
|
207
|
+
* ```
|
|
208
|
+
*/
|
|
209
|
+
async collectEvents(countOrPredicate, timeout = 5000) {
|
|
210
|
+
const collected = [];
|
|
211
|
+
const isCount = typeof countOrPredicate === 'number';
|
|
212
|
+
const abortController = new AbortController();
|
|
213
|
+
const iterator = this.events(abortController.signal);
|
|
214
|
+
let timedOut = false;
|
|
215
|
+
const timeoutId = setTimeout(() => {
|
|
216
|
+
timedOut = true;
|
|
217
|
+
abortController.abort(new Error(`Timeout collecting events (got ${collected.length})`));
|
|
218
|
+
}, timeout);
|
|
219
|
+
try {
|
|
220
|
+
for await (const event of iterator) {
|
|
221
|
+
collected.push(event);
|
|
222
|
+
if (isCount && collected.length >= countOrPredicate) {
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
if (!isCount && countOrPredicate(event)) {
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Check if loop exited due to timeout (generator returns cleanly on abort)
|
|
230
|
+
if (timedOut) {
|
|
231
|
+
throw abortController.signal.reason;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
// Re-throw abort errors with our timeout message
|
|
236
|
+
if (timedOut && abortController.signal.aborted) {
|
|
237
|
+
throw abortController.signal.reason;
|
|
238
|
+
}
|
|
239
|
+
throw err;
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
clearTimeout(timeoutId);
|
|
243
|
+
abortController.abort(); // Signal generator to stop on early break
|
|
244
|
+
}
|
|
245
|
+
return collected;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Close the connection from the client side.
|
|
249
|
+
*
|
|
250
|
+
* This aborts the underlying fetch request. Call this when done
|
|
251
|
+
* consuming events to clean up resources.
|
|
252
|
+
*/
|
|
253
|
+
close() {
|
|
254
|
+
this.closed = true;
|
|
255
|
+
// Cancel the reader first to prevent unhandled rejections from pending reads
|
|
256
|
+
this.reader.cancel().catch(() => {
|
|
257
|
+
// Expected: may already be closed or errored
|
|
258
|
+
});
|
|
259
|
+
this.abortController.abort();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
//# sourceMappingURL=sseHttpClient.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sseHttpClient.js","sourceRoot":"","sources":["../../../lib/testing/sseHttpClient.ts"],"names":[],"mappings":"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"}
|