UNPKG

opinionated-machine

Version:

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

232 lines 8.04 kB
import { parseSSEEvents } from "../sse/sseParser.js"; /** * SSE connection object returned by SSEInjectClient. * * Represents a completed SSE response from Fastify's inject(). * Since inject() waits for the complete response, all events * are available immediately after construction. */ export class SSEInjectConnection { receivedEvents = []; response; constructor(response) { this.response = response; // Parse all events from response body (inject waits for complete response) if (response.body) { const events = parseSSEEvents(response.body); this.receivedEvents.push(...events); } } /** * Wait for a specific event by name. * Since inject() returns the complete response, this searches * the already-received events. */ async waitForEvent(eventName, timeout = 5000) { const startTime = Date.now(); while (Date.now() - startTime < timeout) { const event = this.receivedEvents.find((e) => e.event === eventName); if (event) { return event; } await new Promise((resolve) => setTimeout(resolve, 10)); } throw new Error(`Timeout waiting for event: ${eventName}`); } /** * Wait for a specific number of events. * Since inject() returns the complete response, this checks * the already-received events. */ async waitForEvents(count, timeout = 5000) { const startTime = Date.now(); while (Date.now() - startTime < timeout) { if (this.receivedEvents.length >= count) { return this.receivedEvents.slice(0, count); } await new Promise((resolve) => setTimeout(resolve, 10)); } throw new Error(`Timeout waiting for ${count} events, received ${this.receivedEvents.length}`); } /** * Get all events received in the response. */ getReceivedEvents() { return [...this.receivedEvents]; } /** * Close the connection. No-op for inject connections since * the response is already complete. */ close() { // No-op - inject() responses are already complete } /** * Check if the connection has been closed. * Always returns true for inject connections since response is complete. */ isClosed() { return true; } /** * Get the HTTP status code from the response. */ getStatusCode() { return this.response.statusCode; } /** * Get the response headers. */ getHeaders() { return this.response.headers; } } /** * SSE client using Fastify's inject() for testing SSE endpoints. * * This client uses Fastify's `inject()` method which simulates HTTP requests * without network overhead. The key characteristic is that `inject()` waits * for the **complete response** before returning, meaning: * * - All events are available immediately after connect() returns * - The SSE handler must close the connection for connect() to complete * - Best suited for SSE streams that have a defined end * * **Ideal for testing:** * - OpenAI-style streaming (POST with body, streams tokens, then closes) * - Short-lived streams that complete after sending all events * - Endpoints where you want to test the full response at once * * **When to use SSEInjectClient vs SSEHttpClient:** * * | SSEInjectClient (this class) | SSEHttpClient | * |-------------------------------------|--------------------------------------| * | Fastify's inject() (no network) | Real HTTP connection via fetch() | * | All events returned at once | Events arrive incrementally | * | Handler must close the connection | Connection can stay open | * | Works without starting server | Requires running server (listen()) | * | Use for: OpenAI-style, completions | Use for: notifications, chat, feeds | * * @example * ```typescript * // Testing OpenAI-style chat completion streaming * const client = new SSEInjectClient(app) * * // POST request that streams response and closes * const conn = await client.connectWithBody( * '/api/chat/completions', * { model: 'gpt-4', messages: [{ role: 'user', content: 'Hello' }], stream: true } * ) * * // connect() returns after handler closes - all events are available * expect(conn.getStatusCode()).toBe(200) * * // Get all events that were streamed * const events = conn.getReceivedEvents() * expect(events[events.length - 1].event).toBe('done') * * // Parse the streamed content * const chunks = events * .filter(e => e.event === 'chunk') * .map(e => JSON.parse(e.data).content) * const fullResponse = chunks.join('') * ``` * * @example * ```typescript * // Testing GET SSE endpoint * const client = new SSEInjectClient(app) * const conn = await client.connect('/api/export/progress', { * headers: { authorization: 'Bearer token' } * }) * * // Wait for specific event type * const completeEvent = await conn.waitForEvent('complete') * expect(JSON.parse(completeEvent.data)).toMatchObject({ status: 'success' }) * ``` */ export class SSEInjectClient { app; /** * Create a new SSE inject client. * @param app - Fastify instance (does not need to be listening) */ constructor(app) { this.app = app; } /** * Send a GET request to an SSE endpoint. * * Returns when the SSE handler closes the connection. * All events are then available via getReceivedEvents(). * * @param url - The endpoint URL (e.g., '/api/stream') * @param options - Optional headers * @returns Connection object with all received events * * @example * ```typescript * const conn = await client.connect('/api/notifications/stream', { * headers: { authorization: 'Bearer token' } * }) * const events = conn.getReceivedEvents() * ``` */ async connect(url, options) { const response = await this.app.inject({ method: 'GET', url, headers: { accept: 'text/event-stream', ...options?.headers, }, }); return new SSEInjectConnection({ statusCode: response.statusCode, headers: response.headers, body: response.body, }); } /** * Send a POST/PUT/PATCH request to an SSE endpoint with a body. * * This is the typical pattern for OpenAI-style streaming APIs where * you send a request body and receive a streamed response. * * Returns when the SSE handler closes the connection. * All events are then available via getReceivedEvents(). * * @param url - The endpoint URL (e.g., '/api/chat/completions') * @param body - Request body (will be JSON stringified) * @param options - Optional method (defaults to POST) and headers * @returns Connection object with all received events * * @example * ```typescript * const conn = await client.connectWithBody( * '/api/chat/completions', * { model: 'gpt-4', messages: [...], stream: true }, * { headers: { authorization: 'Bearer sk-...' } } * ) * const chunks = conn.getReceivedEvents().filter(e => e.event === 'chunk') * ``` */ async connectWithBody(url, body, options) { const response = await this.app.inject({ method: options?.method ?? 'POST', url, headers: { accept: 'text/event-stream', 'content-type': 'application/json', ...options?.headers, }, payload: JSON.stringify(body), }); return new SSEInjectConnection({ statusCode: response.statusCode, headers: response.headers, body: response.body, }); } } //# sourceMappingURL=sseInjectClient.js.map