UNPKG

opinionated-machine

Version:

Very opinionated DI framework for fastify, built on top of awilix

262 lines 9.71 kB
import { stringify } from 'fast-querystring'; import { parseSSEBuffer } from "../sse/sseParser.js"; /** * SSE client for testing long-lived connections using real HTTP. * * This client uses the native `fetch()` API to establish a real HTTP connection * to an SSE endpoint. Events are streamed incrementally as the server sends them, * making it suitable for testing: * * - **Long-lived connections** that stay open indefinitely * - **Real-time notifications** where events arrive over time * - **Push-based streaming** where the client waits for server-initiated events * * **When to use SSEHttpClient vs SSEInjectClient:** * * | SSEHttpClient (this class) | SSEInjectClient | * |-------------------------------------|--------------------------------------| * | Real HTTP connection via fetch() | Fastify's inject() (no network) | * | Events arrive incrementally | All events returned at once | * | Connection can stay open | Response must complete | * | Requires running server (listen()) | Works without starting server | * | Use for: notifications, chat, feeds | Use for: OpenAI-style streaming | * * @example * ```typescript * // 1. Start a real HTTP server * await app.listen({ port: 0 }) * const address = app.server.address() as { port: number } * const baseUrl = `http://localhost:${address.port}` * * // 2. Connect to SSE endpoint (returns when headers are received) * const client = await SSEHttpClient.connect(baseUrl, '/api/notifications', { * headers: { authorization: 'Bearer token' }, * }) * * // 3. Server can now send events at any time * controller.sendEvent(connectionId, { event: 'notification', data: { msg: 'Hello' } }) * * // 4. Collect events as they arrive * const events = await client.collectEvents(3) // wait for 3 events * // or: collect until a specific event * const events = await client.collectEvents(e => e.event === 'done') * * // 5. Alternative: use async iterator for manual control * for await (const event of client.events()) { * console.log('Received:', event.event, event.data) * if (event.event === 'done') break * } * * // 6. Cleanup * client.close() * await app.close() * ``` */ export class SSEHttpClient { /** The fetch Response object. Available immediately after connect() returns. */ response; abortController; reader; decoder = new TextDecoder(); buffer = ''; closed = false; constructor(response, abortController) { this.response = response; this.abortController = abortController; if (!response.body) { throw new Error('SSE response has no body'); } this.reader = response.body.getReader(); } static async connect(baseUrl, path, options) { // Build path with query string let pathWithQuery = path; if (options?.query) { const queryString = stringify(options.query); if (queryString) { pathWithQuery = `${path}?${queryString}`; } } // Connect - fetch() returns when headers are received const abortController = new AbortController(); const response = await fetch(`${baseUrl}${pathWithQuery}`, { headers: { Accept: 'text/event-stream', ...options?.headers, }, signal: abortController.signal, }); const client = new SSEHttpClient(response, abortController); // If awaitServerConnection is specified, wait for server-side registration if (options && 'awaitServerConnection' in options && options.awaitServerConnection) { const { controller, timeout } = options.awaitServerConnection; const serverConnection = await controller.connectionSpy.waitForConnection({ timeout: timeout ?? 5000, predicate: (conn) => conn.request.url === pathWithQuery, }); return { client, serverConnection }; } return client; } /** * Async generator that yields parsed SSE events as they arrive. * * Use this for full control over event processing. The generator * completes when the server closes the connection or the abort signal fires. * * @param signal - Optional AbortSignal to stop the generator early * * @example * ```typescript * for await (const event of client.events()) { * const data = JSON.parse(event.data) * console.log(`[${event.event}]`, data) * * if (event.event === 'done') { * break // Stop consuming, connection stays open until close() * } * } * ``` * * @example * ```typescript * // With abort signal for timeout control * const controller = new AbortController() * setTimeout(() => controller.abort(), 5000) * * for await (const event of client.events(controller.signal)) { * console.log(event) * } * ``` */ async *events(signal) { while (!this.closed) { if (signal?.aborted) { return; } const readResult = await this.readWithAbort(signal); if (readResult === 'aborted') { return; } if (readResult.done) { this.closed = true; break; } this.buffer += this.decoder.decode(readResult.value, { stream: true }); const parseResult = parseSSEBuffer(this.buffer); this.buffer = parseResult.remaining; for (const event of parseResult.events) { if (signal?.aborted) { return; } yield event; } } } /** * Read from the stream with abort signal support. * Returns 'aborted' if the signal fires before read completes. */ async readWithAbort(signal) { const readPromise = this.reader.read(); if (!signal) { return readPromise; } let raceSettled = false; const abortPromise = new Promise((resolve) => { const onAbort = () => { if (!raceSettled) { resolve('aborted'); } }; if (signal.aborted) { onAbort(); } else { signal.addEventListener('abort', onAbort, { once: true }); } }); const result = await Promise.race([readPromise, abortPromise]); raceSettled = true; if (result === 'aborted') { // Prevent unhandled rejection when connection closes readPromise.catch(() => { }); } return result; } /** * Collect events until a count is reached or predicate returns true. * * @param countOrPredicate - Either a number of events to collect, * or a predicate function that returns true when collection should stop. * The event that matches the predicate IS included in the result. * @param timeout - Maximum time to wait in milliseconds (default: 5000) * @returns Array of collected events * @throws Error if timeout is reached before condition is met * * @example * ```typescript * // Collect exactly 5 events * const events = await client.collectEvents(5) * * // Collect until 'done' event is received * const events = await client.collectEvents(e => e.event === 'done') * * // Collect with custom timeout * const events = await client.collectEvents(10, 30000) // 30s timeout * ``` */ async collectEvents(countOrPredicate, timeout = 5000) { const collected = []; const isCount = typeof countOrPredicate === 'number'; const abortController = new AbortController(); const iterator = this.events(abortController.signal); let timedOut = false; const timeoutId = setTimeout(() => { timedOut = true; abortController.abort(new Error(`Timeout collecting events (got ${collected.length})`)); }, timeout); try { for await (const event of iterator) { collected.push(event); if (isCount && collected.length >= countOrPredicate) { break; } if (!isCount && countOrPredicate(event)) { break; } } // Check if loop exited due to timeout (generator returns cleanly on abort) if (timedOut) { throw abortController.signal.reason; } } catch (err) { // Re-throw abort errors with our timeout message if (timedOut && abortController.signal.aborted) { throw abortController.signal.reason; } throw err; } finally { clearTimeout(timeoutId); abortController.abort(); // Signal generator to stop on early break } return collected; } /** * Close the connection from the client side. * * This aborts the underlying fetch request. Call this when done * consuming events to clean up resources. */ close() { this.closed = true; // Cancel the reader first to prevent unhandled rejections from pending reads this.reader.cancel().catch(() => { // Expected: may already be closed or errored }); this.abortController.abort(); } } //# sourceMappingURL=sseHttpClient.js.map