opinionated-machine
Version:
Very opinionated DI framework for fastify, built on top of awilix
262 lines • 9.71 kB
JavaScript
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