opinionated-machine 5.0.1 → 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.
Files changed (55) hide show
  1. package/README.md +1237 -164
  2. package/dist/index.d.ts +3 -2
  3. package/dist/index.js +5 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/AbstractController.d.ts +3 -3
  6. package/dist/lib/AbstractController.js.map +1 -1
  7. package/dist/lib/AbstractModule.d.ts +14 -0
  8. package/dist/lib/AbstractModule.js +16 -0
  9. package/dist/lib/AbstractModule.js.map +1 -1
  10. package/dist/lib/DIContext.d.ts +35 -0
  11. package/dist/lib/DIContext.js +99 -0
  12. package/dist/lib/DIContext.js.map +1 -1
  13. package/dist/lib/resolverFunctions.d.ts +49 -0
  14. package/dist/lib/resolverFunctions.js +70 -0
  15. package/dist/lib/resolverFunctions.js.map +1 -1
  16. package/dist/lib/sse/AbstractSSEController.d.ts +163 -0
  17. package/dist/lib/sse/AbstractSSEController.js +228 -0
  18. package/dist/lib/sse/AbstractSSEController.js.map +1 -0
  19. package/dist/lib/sse/SSEConnectionSpy.d.ts +55 -0
  20. package/dist/lib/sse/SSEConnectionSpy.js +136 -0
  21. package/dist/lib/sse/SSEConnectionSpy.js.map +1 -0
  22. package/dist/lib/sse/index.d.ts +5 -0
  23. package/dist/lib/sse/index.js +6 -0
  24. package/dist/lib/sse/index.js.map +1 -0
  25. package/dist/lib/sse/sseContracts.d.ts +132 -0
  26. package/dist/lib/sse/sseContracts.js +102 -0
  27. package/dist/lib/sse/sseContracts.js.map +1 -0
  28. package/dist/lib/sse/sseParser.d.ts +167 -0
  29. package/dist/lib/sse/sseParser.js +225 -0
  30. package/dist/lib/sse/sseParser.js.map +1 -0
  31. package/dist/lib/sse/sseRouteBuilder.d.ts +47 -0
  32. package/dist/lib/sse/sseRouteBuilder.js +114 -0
  33. package/dist/lib/sse/sseRouteBuilder.js.map +1 -0
  34. package/dist/lib/sse/sseTypes.d.ts +164 -0
  35. package/dist/lib/sse/sseTypes.js +2 -0
  36. package/dist/lib/sse/sseTypes.js.map +1 -0
  37. package/dist/lib/testing/index.d.ts +5 -0
  38. package/dist/lib/testing/index.js +5 -0
  39. package/dist/lib/testing/index.js.map +1 -0
  40. package/dist/lib/testing/sseHttpClient.d.ts +203 -0
  41. package/dist/lib/testing/sseHttpClient.js +262 -0
  42. package/dist/lib/testing/sseHttpClient.js.map +1 -0
  43. package/dist/lib/testing/sseInjectClient.d.ts +173 -0
  44. package/dist/lib/testing/sseInjectClient.js +234 -0
  45. package/dist/lib/testing/sseInjectClient.js.map +1 -0
  46. package/dist/lib/testing/sseInjectHelpers.d.ts +59 -0
  47. package/dist/lib/testing/sseInjectHelpers.js +117 -0
  48. package/dist/lib/testing/sseInjectHelpers.js.map +1 -0
  49. package/dist/lib/testing/sseTestServer.d.ts +93 -0
  50. package/dist/lib/testing/sseTestServer.js +108 -0
  51. package/dist/lib/testing/sseTestServer.js.map +1 -0
  52. package/dist/lib/testing/sseTestTypes.d.ts +106 -0
  53. package/dist/lib/testing/sseTestTypes.js +2 -0
  54. package/dist/lib/testing/sseTestTypes.js.map +1 -0
  55. 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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=sseTypes.js.map
@@ -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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"}