diff --git a/README.md b/README.md index d7ebd616..65d97cc0 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ They provide common runtime utilities needed by SDKs to make API calls and handl | [@apimatic/pagination](packages/pagination) | [![npm shield](https://img.shields.io/npm/v/@apimatic/pagination)](https://www.npmjs.com/package/@apimatic/pagination) | Provides utilities to handle paginated API responses, including support for asynchronous iteration over pages or items. | | [@apimatic/proxy](packages/proxy) | [![npm shield](https://img.shields.io/npm/v/@apimatic/proxy)](https://www.npmjs.com/package/@apimatic/proxy) | Provides proxy configuration utilities for HTTP clients. | | [@apimatic/hmac-signature-verifier](packages/hmac-signature-verifier) | [![npm shield](https://img.shields.io/npm/v/@apimatic/hmac-signature-verifier)](https://www.npmjs.com/package/@apimatic/hmac-signature-verifier) | Provides HMAC signature verification utilities to secure HTTP requests. | +| [@apimatic/sse](packages/sse) | [![npm shield](https://img.shields.io/npm/v/@apimatic/sse)](https://www.npmjs.com/package/@apimatic/sse) | Provides parsing and typed consumption for Server-Sent Events (SSE) API responses. | ## Builds and Usage diff --git a/packages/sse/README.md b/packages/sse/README.md new file mode 100644 index 00000000..1e52169e --- /dev/null +++ b/packages/sse/README.md @@ -0,0 +1,48 @@ +# @apimatic/sse + +Server-Sent Events (SSE) utilities for APIMatic-generated TypeScript SDKs: + +- **Spec-compliant incremental parser** (`SseParser`) — chunk-boundary + buffering (including split multi-byte UTF-8 and split CRLF), multi-line + `data:` folding, comment/heartbeat skipping, BOM handling, and the + WHATWG `id:`/`retry:` field rules. +- **`SseStream`** — a single-use async-iterable stream with + `withMetadata()`, abort-on-break cancellation, and single-use enforcement. +- **Read timeout** — bounds the wait for the server between frames (never + consumer processing time); a stall surfaces as a typed `SseTimeoutError`. +- **Typed errors** — `SseError` base, `SseTimeoutError` (idle stall), + `SseDecodeError` (a frame that couldn't be decoded). A mid-stream transport + drop surfaces the raw underlying error to the consumer. +- **`createEventStream`** — the factory that core's + `RequestBuilder.callAsEventStream(...)` hook delegates to (mirroring how + `paginate()` delegates to `@apimatic/pagination`'s `createPagedData`): + executes the request as an SSE stream and buffers non-2xx error bodies back + to text. + +## Usage + +Generated SDK code (via `@apimatic/core`'s request builder): + +```ts +import { createEventStream } from '@apimatic/sse'; + +const req = this.createRequest('GET', '/paragraphs'); +// core's callAsEventStream hands the factory the request builder plus the +// client-wide stream read timeout (ms). +const { result: stream } = await req.callAsEventStream((req, streamReadTimeout) => + createEventStream( + req, + requestOptions, + streamReadTimeout, + (raw) => ({ done: false, value: raw.data }) // a decoder, e.g. schemaSseDecoder(schema) + ) +); + +for await (const event of stream) { + console.log(event); +} +``` + +The client-wide stream read timeout is a plain `number` (milliseconds), +configured on the SDK like `timeout` — a `0` or unset value means no idle +timeout. diff --git a/packages/sse/jest.config.js b/packages/sse/jest.config.js new file mode 100644 index 00000000..91adb311 --- /dev/null +++ b/packages/sse/jest.config.js @@ -0,0 +1,9 @@ +const { jest: lernaAliases } = require('lerna-alias'); + +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + setupFiles: ['/test/setup.js'], + moduleNameMapper: lernaAliases(), + coverageReporters: [['lcov', { projectRoot: '../../' }]], +}; diff --git a/packages/sse/package.json b/packages/sse/package.json new file mode 100644 index 00000000..7e1e4bcb --- /dev/null +++ b/packages/sse/package.json @@ -0,0 +1,61 @@ +{ + "name": "@apimatic/sse", + "version": "0.0.0", + "description": "Provides parsing and typed consumption for Server-Sent Events (SSE) API responses.", + "main": "lib/index.js", + "module": "es/index.js", + "types": "lib/index.d.ts", + "sideEffects": false, + "files": [ + "umd", + "lib/**/*.js", + "lib/**/*.d.ts", + "es/**/*.js", + "es/**/*.d.ts", + "src", + "LICENSE.md" + ], + "engines": { + "node": ">=14.15.0 || >=16.0.0" + }, + "scripts": { + "clean": "rimraf lib es umd tsconfig.tsbuildinfo", + "test": "jest --ci --coverage --maxWorkers=2 --passWithNoTests", + "build": "npm run clean && tsc && rollup -c --bundleConfigAsCjs && npm run annotate:es", + "annotate:es": "babel es --out-dir es --no-babelrc --plugins annotate-pure-calls", + "preversion": "npm run test", + "prepublishOnly": "npm run build", + "size": "size-limit", + "analyze": "size-limit --why", + "lint": "tslint --project .", + "lint:fix": "tslint --project . --fix", + "check-style": "prettier --check \"{src,test}/**/*.ts\"", + "check-style:fix": "prettier --write \"{src,test}/**/*.ts\"" + }, + "author": "APIMatic Ltd.", + "license": "MIT", + "size-limit": [ + { + "path": "es/index.js", + "limit": "8 KB" + } + ], + "dependencies": { + "@apimatic/core-interfaces": "^0.2.14", + "@apimatic/json-bigint": "^1.2.0", + "@apimatic/schema": "^0.7.21", + "abort-controller": "^3.0.0", + "tslib": "2.8.1" + }, + "devDependencies": { + "@types/node": "^17.0.29" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git@github.com:apimatic/apimatic-js-runtime.git", + "directory": "packages/sse" + } +} diff --git a/packages/sse/rollup.config.js b/packages/sse/rollup.config.js new file mode 100644 index 00000000..e6229b5f --- /dev/null +++ b/packages/sse/rollup.config.js @@ -0,0 +1,34 @@ +import typescript from 'rollup-plugin-typescript2'; +const packageJson = require('./package.json'); + +const getTsPlugin = ({ declaration = true, target } = {}) => + typescript({ + clean: true, + tsconfigOverride: { + compilerOptions: { + declaration, + ...(target && { target }), + }, + }, + }); + +const getNpmConfig = ({ input, output, external }) => ({ + input, + output, + preserveModules: true, + plugins: [getTsPlugin({ declaration: true })], + external, +}); + +export default [ + getNpmConfig({ + input: 'src/index.ts', + output: [ + { + dir: 'es', + format: 'esm', + }, + ], + external: Object.keys(packageJson.dependencies), + }), +]; diff --git a/packages/sse/src/coreInterfaces.ts b/packages/sse/src/coreInterfaces.ts new file mode 100644 index 00000000..e1824f90 --- /dev/null +++ b/packages/sse/src/coreInterfaces.ts @@ -0,0 +1,6 @@ +export { + ApiResponse, + HttpContext, + HttpInterceptorInterface, + RequestOptions, +} from '@apimatic/core-interfaces'; diff --git a/packages/sse/src/createEventStream.ts b/packages/sse/src/createEventStream.ts new file mode 100644 index 00000000..9d650a99 --- /dev/null +++ b/packages/sse/src/createEventStream.ts @@ -0,0 +1,150 @@ +import { AbortController } from 'abort-controller'; +import JSONBig from '@apimatic/json-bigint'; +import { Schema, validateAndMap } from '@apimatic/schema'; +import { + ApiResponse, + HttpContext, + HttpInterceptorInterface, + RequestOptions, +} from './coreInterfaces'; +import { drainBodyToText, SseStream } from './sseStream'; +import { RawSseEvent, SseDecodeResult, SseDecoder } from './sseTypes'; + +const SSE_CONTENT_TYPE = 'text/event-stream'; + +/** + * Parses JSON with the same big-integer-safe reader the request builder's + * `callAsJson` uses, so streamed frames decode identically to non-streaming + * responses (integers beyond `Number.MAX_SAFE_INTEGER` keep their precision). + */ +const jsonBig = JSONBig(); + +/** Cap on how much of a non-2xx error body is buffered into text. */ +const ERROR_BODY_MAX_BYTES = 64 * 1024; + +/** The subset of the request builder the SSE machinery needs. */ +export interface StreamCapableRequestBuilder { + accept(acceptHeaderValue: string): void; + intercept( + interceptor: HttpInterceptorInterface + ): void; + callAsStream( + requestOptions?: RequestOptions + ): Promise>; +} + +/** + * A decoder that validates and maps each event's `data` payload against + * `schema` (the same mechanism as the request builder's `callAsJson`), so a + * single decoder works for every response type — `string()`, `number()`, + * objects, arrays, etc. A frame that fails validation is surfaced as an + * `SseDecodeError`. + */ +export function schemaSseDecoder(schema: Schema): SseDecoder { + return (raw: RawSseEvent): SseDecodeResult => { + const parsed = parseFrameData(raw.data); + let mapped = validateAndMap(parsed, schema); + if (mapped.errors && typeof parsed !== 'string') { + // A plain-text frame can coincidentally be valid JSON (`123`, `true`, + // `null`); when the schema rejects the parsed form, try the raw text + // before failing so string schemas keep accepting such frames. + const rawMapped = validateAndMap(raw.data, schema); + if (!rawMapped.errors) { + mapped = rawMapped; + } + } + if (mapped.errors) { + throw new Error( + 'SSE frame failed schema validation:\n' + + mapped.errors.map((e) => e.message).join('\n') + ); + } + return { type: 'event', value: mapped.result }; + }; +} + +/** + * Prepares a frame's `data` for schema validation. Structured payloads + * (objects, arrays, numbers) arrive as JSON text and are parsed with the + * big-integer-safe reader; a payload that isn't JSON — e.g. a plain-text + * `string()` frame — is validated as the raw string as-is. + */ +function parseFrameData(data: string): unknown { + try { + return jsonBig.parse(data); + } catch { + return data; + } +} + +/** + * Executes a request as a server-sent event stream. Core's + * `callAsEventStream` hook delegates here. + */ +export async function createEventStream( + requestBuilder: StreamCapableRequestBuilder, + requestOptions: RequestOptions | undefined, + streamReadTimeout: number | undefined, + decoder: SseDecoder, + terminator?: string +): Promise>> { + const controller = new AbortController(); + const userSignal = requestOptions?.abortSignal; + if (userSignal !== undefined) { + if (userSignal.aborted) { + controller.abort(); + } else { + const forwardAbort = () => controller.abort(); + userSignal.addEventListener('abort', forwardAbort); + controller.signal.addEventListener('abort', () => + userSignal.removeEventListener('abort', forwardAbort) + ); + } + } + const streamRequestOptions: RequestOptions = { + ...requestOptions, + abortSignal: controller.signal as unknown as RequestOptions['abortSignal'], + }; + + requestBuilder.accept(SSE_CONTENT_TYPE); + requestBuilder.intercept(bufferErrorBodies); + + const wrappedDecoder = wrapDecoder(decoder, terminator); + const response = await requestBuilder + .callAsStream(streamRequestOptions) + .catch((error) => { + // Abort so the user-signal forwarding listener above is detached even + // when the request itself fails (e.g. a non-2xx ApiError). + controller.abort(); + throw error; + }); + const stream = new SseStream(response.result, { + decoder: wrappedDecoder, + controller, + readTimeout: streamReadTimeout, + }); + return { ...response, result: stream }; +} + +/** Drains a non-2xx stream body back into text before error handling. */ +const bufferErrorBodies: HttpInterceptorInterface< + RequestOptions | undefined +> = async (request, options, next) => { + const context: HttpContext = await next(request, options); + const { response } = context; + if (response.statusCode >= 200 && response.statusCode < 300) { + return context; + } + const body = await drainBodyToText(response.body, ERROR_BODY_MAX_BYTES); + return { ...context, response: { ...response, body } }; +}; + +function wrapDecoder( + decoder: SseDecoder, + terminator: string | undefined +): SseDecoder { + if (terminator === undefined) { + return decoder; + } + return (raw) => (raw.data === terminator ? { type: 'done' } : decoder(raw)); +} diff --git a/packages/sse/src/errors/sseDecodeError.ts b/packages/sse/src/errors/sseDecodeError.ts new file mode 100644 index 00000000..a3384eb7 --- /dev/null +++ b/packages/sse/src/errors/sseDecodeError.ts @@ -0,0 +1,18 @@ +import { SseError } from './sseError'; + +/** A frame's payload could not be decoded into the expected type. */ +export class SseDecodeError extends SseError { + /** The raw frame payload that failed to decode. */ + public readonly rawFrame: string; + /** The underlying decode failure. */ + public readonly cause: unknown; + + constructor(rawFrame: string, cause: unknown) { + super( + 'Failed to decode a Server-Sent Events frame. ' + + 'See rawFrame for the offending payload.' + ); + this.rawFrame = rawFrame; + this.cause = cause; + } +} diff --git a/packages/sse/src/errors/sseError.ts b/packages/sse/src/errors/sseError.ts new file mode 100644 index 00000000..7e4b0850 --- /dev/null +++ b/packages/sse/src/errors/sseError.ts @@ -0,0 +1,8 @@ +/** Base class for errors thrown while iterating a server-sent event stream. */ +export abstract class SseError extends Error { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + this.name = new.target.name; + } +} diff --git a/packages/sse/src/errors/sseTimeoutError.ts b/packages/sse/src/errors/sseTimeoutError.ts new file mode 100644 index 00000000..2db2a75f --- /dev/null +++ b/packages/sse/src/errors/sseTimeoutError.ts @@ -0,0 +1,15 @@ +import { SseError } from './sseError'; + +/** The server stalled between frames longer than the read timeout. */ +export class SseTimeoutError extends SseError { + /** The idle window (ms) that elapsed without a frame arriving. */ + public readonly idleTimeoutMs: number; + + constructor(idleTimeoutMs: number) { + super( + `SSE stream idle timeout exceeded (${idleTimeoutMs}ms without a frame). ` + + 'This bounds only the wait for the server, not consumer processing time.' + ); + this.idleTimeoutMs = idleTimeoutMs; + } +} diff --git a/packages/sse/src/index.ts b/packages/sse/src/index.ts new file mode 100644 index 00000000..cdc0a221 --- /dev/null +++ b/packages/sse/src/index.ts @@ -0,0 +1,19 @@ +/** + * @apimatic/sse + * + * Server-Sent Events utilities for APIMatic-generated TypeScript SDKs. + */ +export { SseParser } from './sseParser'; +export { SseStream } from './sseStream'; +export type { SseStreamOptions } from './sseStream'; +export { SseError } from './errors/sseError'; +export { SseDecodeError } from './errors/sseDecodeError'; +export { SseTimeoutError } from './errors/sseTimeoutError'; +export { createEventStream, schemaSseDecoder } from './createEventStream'; +export type { StreamCapableRequestBuilder } from './createEventStream'; +export type { + RawSseEvent, + SseDecodeResult, + SseDecoder, + SseEvent, +} from './sseTypes'; diff --git a/packages/sse/src/sseParser.ts b/packages/sse/src/sseParser.ts new file mode 100644 index 00000000..78a9b53a --- /dev/null +++ b/packages/sse/src/sseParser.ts @@ -0,0 +1,166 @@ +import { RawSseEvent } from './sseTypes'; + +/** Incremental parser for `text/event-stream` (WHATWG server-sent events). */ +export class SseParser { + private textBuffer = ''; + private dataBuffer: string | undefined; + private eventTypeBuffer = ''; + private lastEventIdBuffer: string | undefined; + private retryMs: number | undefined; + private bomChecked = false; + private eof = false; + private scanFrom = 0; + private decoder = new TextDecoder('utf-8'); + + /** The most recent `id:` value seen. */ + public get lastEventId(): string | undefined { + return this.lastEventIdBuffer; + } + + /** The most recent `retry:` value seen, in milliseconds. */ + public get retry(): number | undefined { + return this.retryMs; + } + + /** Feeds one chunk from the wire and returns any events it completes. */ + public feed(chunk: Uint8Array | string): RawSseEvent[] { + if (typeof chunk === 'string') { + this.textBuffer += chunk; + } else { + this.textBuffer += this.decoder.decode(chunk, { stream: true }); + } + if (!this.bomChecked && this.textBuffer.length > 0) { + if (this.textBuffer.charCodeAt(0) === 0xfeff) { + this.textBuffer = this.textBuffer.slice(1); + } + this.bomChecked = true; + } + return this.processBuffer(); + } + + /** Signals end of stream and returns any final event it completes. */ + public end(): RawSseEvent[] { + this.textBuffer += this.decoder.decode(); + this.eof = true; + const events = this.processBuffer(); + this.textBuffer = ''; + this.dataBuffer = undefined; + this.eventTypeBuffer = ''; + this.scanFrom = 0; + return events; + } + + private processBuffer(): RawSseEvent[] { + const events: RawSseEvent[] = []; + const buffer = this.textBuffer; + let start = 0; + + while (start < buffer.length) { + // The prefix before `scanFrom` was already searched on a previous feed + // and holds no unprocessed line terminator, so resume the search there + // instead of rescanning the whole buffer (which is O(n^2) for a single + // large frame split across many chunks). + const from = start > this.scanFrom ? start : this.scanFrom; + const cr = buffer.indexOf('\r', from); + const lf = buffer.indexOf('\n', from); + let lineEnd: number; + let nextStart: number; + + if (cr === -1 && lf === -1) { + this.scanFrom = buffer.length; + break; + } else if (cr !== -1 && (lf === -1 || cr < lf)) { + // A trailing CR may be the first half of a CRLF split across chunks. + if (cr === buffer.length - 1 && !this.eof) { + this.scanFrom = cr; + break; + } + lineEnd = cr; + nextStart = buffer.charAt(cr + 1) === '\n' ? cr + 2 : cr + 1; + } else { + lineEnd = lf; + nextStart = lf + 1; + } + + const line = buffer.slice(start, lineEnd); + start = nextStart; + this.scanFrom = start; + + const event = this.processLine(line); + if (event !== undefined) { + events.push(event); + } + } + + this.textBuffer = buffer.slice(start); + // `scanFrom` was tracked against the pre-slice buffer; rebase it onto the + // retained tail so the next feed resumes at the right place. + this.scanFrom = this.scanFrom > start ? this.scanFrom - start : 0; + return events; + } + + private processLine(line: string): RawSseEvent | undefined { + if (line === '') { + return this.dispatchEvent(); + } + if (line.charAt(0) === ':') { + return undefined; + } + + let field: string; + let value: string; + const colon = line.indexOf(':'); + if (colon === -1) { + field = line; + value = ''; + } else { + field = line.slice(0, colon); + value = line.slice(colon + 1); + if (value.charAt(0) === ' ') { + value = value.slice(1); + } + } + + switch (field) { + case 'data': + this.dataBuffer = + this.dataBuffer === undefined + ? value + : `${this.dataBuffer}\n${value}`; + break; + case 'event': + this.eventTypeBuffer = value; + break; + case 'id': + if (value.indexOf('\u0000') === -1) { + this.lastEventIdBuffer = value; + } + break; + case 'retry': + if (/^\d+$/.test(value)) { + this.retryMs = parseInt(value, 10); + } + break; + default: + break; + } + return undefined; + } + + private dispatchEvent(): RawSseEvent | undefined { + const data = this.dataBuffer; + const eventType = this.eventTypeBuffer; + this.dataBuffer = undefined; + this.eventTypeBuffer = ''; + + if (data === undefined) { + return undefined; + } + return { + data, + event: eventType === '' ? undefined : eventType, + id: this.lastEventIdBuffer, + retry: this.retryMs, + }; + } +} diff --git a/packages/sse/src/sseStream.ts b/packages/sse/src/sseStream.ts new file mode 100644 index 00000000..e63014e3 --- /dev/null +++ b/packages/sse/src/sseStream.ts @@ -0,0 +1,375 @@ +import { AbortController } from 'abort-controller'; +import { SseDecodeError } from './errors/sseDecodeError'; +import { SseTimeoutError } from './errors/sseTimeoutError'; +import { SseParser } from './sseParser'; +import { + RawSseEvent, + SseAbortSignal, + SseByteReader, + SseDecoder, + SseEvent, +} from './sseTypes'; + +/** Construction options for {@link SseStream}. */ +export interface SseStreamOptions { + /** Decodes each raw event into a typed value. */ + decoder: SseDecoder; + /** Abort controller to adopt; a new one is created when omitted. */ + controller?: AbortController; + /** Max ms to await the next frame before `SseTimeoutError`; 0 = no limit. */ + readTimeout?: number; +} + +const ERROR_STASH = '__sseErrorStash__'; + +function armErrorStash(body: unknown): void { + const emitter = body as { + on?: (event: string, listener: (e: unknown) => void) => void; + [ERROR_STASH]?: { error?: unknown }; + }; + if ( + emitter !== null && + typeof emitter === 'object' && + typeof emitter.on === 'function' && + emitter[ERROR_STASH] === undefined + ) { + const stash: { error?: unknown } = {}; + emitter[ERROR_STASH] = stash; + emitter.on('error', (e: unknown) => { + if (stash.error === undefined) { + stash.error = e !== undefined ? e : new Error('SSE stream errored.'); + } + }); + } +} + +function stashedError(body: unknown): unknown { + return (body as { [ERROR_STASH]?: { error?: unknown } })?.[ERROR_STASH] + ?.error; +} + +/** + * A single-use, async-iterable stream of server-sent events. Iterate for + * decoded values, or `withMetadata()` for full frames; breaking the loop (or + * `close()`) aborts the request. + */ +export class SseStream implements AsyncIterable { + /** Aborts the live request when triggered. */ + public readonly controller: AbortController; + + private readonly initialBody: unknown; + private readonly decoder: SseDecoder; + private readonly readTimeout: number; + private consumed = false; + + constructor(initialBody: unknown, options: SseStreamOptions) { + this.initialBody = initialBody; + this.decoder = options.decoder; + this.controller = options.controller ?? new AbortController(); + armErrorStash(initialBody); + // 0 (or unset) means no idle timeout; the SDK configuration is where a + // default stream read timeout is applied. + this.readTimeout = options.readTimeout ?? 0; + const asyncDispose: symbol | undefined = (Symbol as any).asyncDispose; + if (asyncDispose !== undefined) { + (this as any)[asyncDispose] = async () => this.close(); + } + } + + /** Iterates decoded event values. */ + public [Symbol.asyncIterator](): AsyncIterator { + const events = this.withMetadata(); + return (async function* () { + for await (const event of events) { + yield event.data; + } + })(); + } + + /** Iterates full events, including `event`, `id` and `retry` metadata. */ + public withMetadata(): AsyncIterable> { + if (this.consumed) { + throw new Error( + 'This SSE stream has already been consumed. ' + + 'Streams are single-use; make a new API call to re-read.' + ); + } + this.consumed = true; + return this.run(); + } + + /** Aborts the connection and stops iteration. */ + public close(): void { + this.controller.abort(); + } + + private async *run(): AsyncGenerator, void, undefined> { + const signal = this.controller.signal; + const body = this.initialBody; + const parser = new SseParser(); + + try { + for await (const raw of iterateBody( + body, + parser, + signal, + this.readTimeout + )) { + let decoded; + try { + decoded = this.decoder(raw); + } catch (cause) { + throw new SseDecodeError(raw.data, cause); + } + switch (decoded.type) { + case 'done': + return; + case 'skip': + continue; + case 'event': + yield { + data: decoded.value, + event: raw.event, + id: raw.id, + retry: raw.retry, + }; + } + } + } catch (error) { + // A user abort is a clean stop; anything else (a decode failure, an + // idle SseTimeoutError, or a raw transport drop) surfaces to the caller. + if (signal.aborted) { + return; + } + throw error; + } finally { + this.controller.abort(); + destroyBody(body); + } + } +} + +async function readWithTimeout( + read: Promise, + readTimeout: number, + teardown: () => void +): Promise { + if (readTimeout <= 0) { + return read; + } + read.catch(() => undefined); + let timer: ReturnType | undefined; + try { + return await Promise.race([ + read, + new Promise((_, reject) => { + timer = setTimeout(() => { + teardown(); + reject(new SseTimeoutError(readTimeout)); + }, readTimeout); + }), + ]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } +} + +async function* iterateBody( + body: unknown, + parser: SseParser, + signal: SseAbortSignal, + readTimeout: number +): AsyncGenerator { + if (typeof body === 'string') { + yield* parser.feed(body); + yield* parser.end(); + return; + } + + if (isAsyncIterable(body)) { + const preError = stashedError(body); + if (preError !== undefined) { + throw preError; + } + const abort = () => destroyBody(body); + signal.addEventListener('abort', abort); + const iterator = body[Symbol.asyncIterator](); + try { + for (;;) { + const { done, value } = await readWithTimeout( + iterator.next(), + readTimeout, + () => destroyBody(body) + ); + if (done) { + yield* parser.end(); + return; + } + throwIfAborted(signal); + yield* parser.feed(coerceChunk(value)); + } + } finally { + signal.removeEventListener('abort', abort); + if (typeof iterator.return === 'function') { + Promise.resolve(iterator.return()).catch(() => undefined); + } + } + } + + if (isWebReadableStream(body)) { + const reader = body.getReader(); + const abort = () => { + reader.cancel().catch(() => undefined); + }; + signal.addEventListener('abort', abort); + try { + for (;;) { + const { done, value } = await readWithTimeout( + reader.read(), + readTimeout, + () => { + reader.cancel().catch(() => undefined); + } + ); + if (done) { + yield* parser.end(); + return; + } + throwIfAborted(signal); + yield* parser.feed(coerceChunk(value)); + } + } finally { + signal.removeEventListener('abort', abort); + reader.releaseLock(); + } + } + + if (isBlobLike(body)) { + const inner = typeof body.stream === 'function' ? body.stream() : undefined; + if ( + inner !== undefined && + (isAsyncIterable(inner) || isWebReadableStream(inner)) + ) { + yield* iterateBody(inner, parser, signal, readTimeout); + return; + } + yield* parser.feed(await body.text()); + yield* parser.end(); + return; + } + + throw new Error( + 'Unsupported SSE response body; expected a stream, Blob or string.' + ); +} + +/** Reads a response body into text, up to `maxBytes`. */ +export async function drainBodyToText( + body: unknown, + maxBytes: number +): Promise { + if (typeof body === 'string') { + return body; + } + if (isBlobLike(body)) { + const text = await body.text(); + return text.length > maxBytes ? text.slice(0, maxBytes) : text; + } + + const decoder = new TextDecoder('utf-8'); + let out = ''; + let read = 0; + const push = (chunk: unknown): boolean => { + const piece = + typeof chunk === 'string' + ? chunk + : decoder.decode(coerceChunk(chunk) as Uint8Array, { stream: true }); + out += piece; + read += piece.length; + return read < maxBytes; + }; + + if (isAsyncIterable(body)) { + try { + for await (const chunk of body) { + if (!push(chunk)) { + destroyBody(body); + break; + } + } + } catch { + // return whatever was read before the body failed + } + return out + decoder.decode(); + } + if (isWebReadableStream(body)) { + const reader = body.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done || !push(value)) { + break; + } + } + } finally { + reader.cancel().catch(() => undefined); + reader.releaseLock(); + } + return out + decoder.decode(); + } + return String(body); +} + +function coerceChunk(chunk: unknown): Uint8Array | string { + if (typeof chunk === 'string' || chunk instanceof Uint8Array) { + return chunk; + } + throw new Error('Unsupported chunk type in SSE response body.'); +} + +function throwIfAborted(signal: SseAbortSignal): void { + if (signal.aborted) { + const error = new Error('The SSE stream was aborted.'); + error.name = 'AbortError'; + throw error; + } +} + +function destroyBody(body: unknown): void { + const destroy = (body as { destroy?: () => void })?.destroy; + if (typeof destroy === 'function') { + destroy.call(body); + } +} + +function isAsyncIterable( + value: unknown +): value is AsyncIterable { + return ( + value !== null && + typeof value === 'object' && + typeof (value as any)[Symbol.asyncIterator] === 'function' + ); +} + +function isWebReadableStream( + value: unknown +): value is { getReader(): SseByteReader } { + return ( + value !== null && + typeof value === 'object' && + typeof (value as any).getReader === 'function' + ); +} + +function isBlobLike( + value: unknown +): value is { stream?: () => unknown; text(): Promise } { + return ( + value !== null && + typeof value === 'object' && + typeof (value as any).text === 'function' + ); +} diff --git a/packages/sse/src/sseTypes.ts b/packages/sse/src/sseTypes.ts new file mode 100644 index 00000000..61e91ec7 --- /dev/null +++ b/packages/sse/src/sseTypes.ts @@ -0,0 +1,52 @@ +/** A raw server-sent event as parsed off the wire. */ +export interface RawSseEvent { + /** The `data:` payload (multi-line data joined with `\n`). */ + data: string; + /** The `event:` field value, if any. */ + event?: string; + /** The `id:` field value in effect for this event, if any. */ + id?: string; + /** The `retry:` reconnection delay, in milliseconds, if any. */ + retry?: number; +} + +/** A typed server-sent event, produced by an {@link SseDecoder}. */ +export interface SseEvent { + data: T; + event?: string; + id?: string; + retry?: number; +} + +/** The outcome of decoding one raw SSE frame. */ +export type SseDecodeResult = + /** Surface this decoded value. */ + | { type: 'event'; value: T } + /** Ignore this frame and keep streaming. */ + | { type: 'skip' } + /** End the stream without surfacing anything. */ + | { type: 'done' }; + +/** Decodes a raw SSE event into a typed value. */ +export type SseDecoder = (raw: RawSseEvent) => SseDecodeResult; + +/** Response body shapes accepted by the SSE machinery. */ +export type SseBody = + | AsyncIterable + | { getReader(): SseByteReader; cancel?(reason?: unknown): Promise } + | { stream(): unknown; text(): Promise } + | string; + +/** Minimal structural type for a WHATWG ReadableStreamDefaultReader. */ +export interface SseByteReader { + read(): Promise<{ done: boolean; value?: Uint8Array | string }>; + cancel(reason?: unknown): Promise; + releaseLock(): void; +} + +/** Structural abort signal, satisfied by the DOM and polyfill signals. */ +export interface SseAbortSignal { + readonly aborted: boolean; + addEventListener(type: 'abort', listener: () => void): void; + removeEventListener(type: 'abort', listener: () => void): void; +} diff --git a/packages/sse/test/createEventStream.test.ts b/packages/sse/test/createEventStream.test.ts new file mode 100644 index 00000000..29e1e55f --- /dev/null +++ b/packages/sse/test/createEventStream.test.ts @@ -0,0 +1,246 @@ +import { AbortController } from 'abort-controller'; +import { number, object, string } from '@apimatic/schema'; +import { + createEventStream, + schemaSseDecoder, + StreamCapableRequestBuilder, +} from '../src/createEventStream'; +import { + HttpInterceptorInterface, + RequestOptions, +} from '../src/coreInterfaces'; +import { SseDecodeError } from '../src/errors/sseDecodeError'; +import { SseDecoder } from '../src/sseTypes'; +import { collectStream, mockSseBody } from './sseTestKit'; + +interface Point { + index: number; + text: string; +} + +const pointSchema = object({ + index: ['index', number()], + text: ['text', string()], +}); + +/** A decoder that yields each frame's `data` payload as-is. */ +const passthrough: SseDecoder = (raw) => ({ + type: 'event', + value: raw.data, +}); + +/** + * A fake request builder. A single mutable header bag is snapshotted on the + * `callAsStream` call, so `connection` records the headers the request was + * made with (e.g. the SSE accept header). + */ +function fakeRequest(makeBody: () => unknown) { + const headers: Record = {}; + let connection: Record = {}; + const req: StreamCapableRequestBuilder = { + accept: (value: string) => { + headers.accept = value; + }, + intercept: () => undefined, + callAsStream: async () => { + connection = { ...headers }; + return { + request: {}, + statusCode: 200, + headers: { 'content-type': 'text/event-stream' }, + body: '', + result: makeBody(), + } as any; + }, + }; + return { req, connection: () => connection }; +} + +describe('createEventStream', () => { + it('yields decoded data and returns response metadata', async () => { + const { req, connection } = fakeRequest(() => + mockSseBody(['data: a\n\n', 'data: b\n\n']) + ); + const response = await createEventStream(req, {}, undefined, passthrough); + expect(response.statusCode).toBe(200); + expect(connection().accept).toBe('text/event-stream'); + expect(await collectStream(response.result)).toEqual(['a', 'b']); + }); + + it('stops at an exact string terminator without surfacing it', async () => { + const { req } = fakeRequest(() => + mockSseBody(['data: a\n\n', 'data: [DONE]\n\n', 'data: never\n\n']) + ); + const { result } = await createEventStream( + req, + {}, + undefined, + passthrough, + '[DONE]' + ); + expect(await collectStream(result)).toEqual(['a']); + }); + + it('surfaces a mid-stream drop as an error (no reconnection)', async () => { + const { req } = fakeRequest(() => + mockSseBody(['data: a\n\n'], { failWith: new Error('drop') }) + ); + const { result } = await createEventStream(req, {}, undefined, passthrough); + await expect(collectStream(result)).rejects.toThrow('drop'); + }); + + it('schemaSseDecoder validates and maps each frame against a schema', async () => { + const { req } = fakeRequest(() => + mockSseBody([ + 'data: {"index":1,"text":"a"}\n\n', + 'data: {"index":2,"text":"b"}\n\n', + ]) + ); + const { result } = await createEventStream( + req, + {}, + undefined, + schemaSseDecoder(pointSchema) + ); + expect(await collectStream(result)).toEqual([ + { index: 1, text: 'a' }, + { index: 2, text: 'b' }, + ]); + }); + + it('schemaSseDecoder(string()) validates plain-text frames without JSON parsing', async () => { + const { req } = fakeRequest(() => + mockSseBody(['data: the quick brown fox\n\n', 'data: lazy dog\n\n']) + ); + const { result } = await createEventStream( + req, + {}, + undefined, + schemaSseDecoder(string()) + ); + expect(await collectStream(result)).toEqual([ + 'the quick brown fox', + 'lazy dog', + ]); + }); + + it('schemaSseDecoder(string()) accepts plain-text frames that happen to be valid JSON', async () => { + const { req } = fakeRequest(() => + mockSseBody(['data: 123\n\n', 'data: true\n\n', 'data: null\n\n']) + ); + const { result } = await createEventStream( + req, + {}, + undefined, + schemaSseDecoder(string()) + ); + expect(await collectStream(result)).toEqual(['123', 'true', 'null']); + }); + + it('schemaSseDecoder surfaces a schema mismatch as SseDecodeError', async () => { + const { req } = fakeRequest(() => + mockSseBody(['data: {"index":"not-a-number","text":"a"}\n\n']) + ); + const { result } = await createEventStream( + req, + {}, + undefined, + schemaSseDecoder(pointSchema) + ); + await expect(collectStream(result)).rejects.toThrow(SseDecodeError); + }); + + it('propagates request options (abort signal) to the stream controller', async () => { + const aborted = new AbortController(); + aborted.abort(); + const { req } = fakeRequest(() => mockSseBody(['data: a\n\n'])); + const { result } = await createEventStream( + req, + { + // polyfill signal: structurally fine, lacks newer DOM-only sugar + abortSignal: aborted.signal as unknown as AbortSignal, + }, + undefined, + passthrough + ); + expect(result.controller.signal.aborted).toBe(true); + }); + + it('forwards a later user abort to the stream controller', async () => { + const userController = new AbortController(); + const { req } = fakeRequest(() => mockSseBody(['data: a\n\n'])); + const { result } = await createEventStream( + req, + { abortSignal: userController.signal as unknown as AbortSignal }, + undefined, + passthrough + ); + expect(result.controller.signal.aborted).toBe(false); + userController.abort(); + expect(result.controller.signal.aborted).toBe(true); + }); + + it('detaches the user-signal listener when the request itself fails', async () => { + const userController = new AbortController(); + const removeSpy = jest.spyOn(userController.signal, 'removeEventListener'); + const req: StreamCapableRequestBuilder = { + accept: () => undefined, + intercept: () => undefined, + callAsStream: async () => { + throw new Error('connect failed'); + }, + }; + await expect( + createEventStream( + req, + { abortSignal: userController.signal as unknown as AbortSignal }, + undefined, + passthrough + ) + ).rejects.toThrow('connect failed'); + expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function)); + }); + + it('buffers only non-2xx error bodies via the interceptor', async () => { + let interceptor: + | HttpInterceptorInterface + | undefined; + const req: StreamCapableRequestBuilder = { + accept: () => undefined, + intercept: (i) => { + interceptor = i; + }, + callAsStream: async () => + ({ statusCode: 200, headers: {}, result: mockSseBody([]) } as any), + }; + await createEventStream(req, {}, undefined, passthrough); + expect(interceptor).toBeDefined(); + + // A 2xx response body is passed through untouched. + const okBody = mockSseBody(['data: ok\n\n']); + const okContext = await interceptor!( + {} as unknown as any, + undefined, + (async () => ({ + request: {}, + response: { statusCode: 200, headers: {}, body: okBody }, + })) as unknown as any + ); + expect((okContext as any).response.body).toBe(okBody); + + // A non-2xx response body is drained into text. + const errContext = await interceptor!( + {} as unknown as any, + undefined, + (async () => ({ + request: {}, + response: { + statusCode: 500, + headers: {}, + body: mockSseBody(['{"error":', '"boom"}']), + }, + })) as unknown as any + ); + expect((errContext as any).response.body).toBe('{"error":"boom"}'); + }); +}); diff --git a/packages/sse/test/setup.js b/packages/sse/test/setup.js new file mode 100644 index 00000000..00b9dad7 --- /dev/null +++ b/packages/sse/test/setup.js @@ -0,0 +1,10 @@ +// Jest's sandbox does not expose Node's TextDecoder/TextEncoder globals +// (available at runtime since Node 11) — bridge them in for tests. +const { TextDecoder, TextEncoder } = require('util'); + +if (typeof global.TextDecoder === 'undefined') { + global.TextDecoder = TextDecoder; +} +if (typeof global.TextEncoder === 'undefined') { + global.TextEncoder = TextEncoder; +} diff --git a/packages/sse/test/sseParser.test.ts b/packages/sse/test/sseParser.test.ts new file mode 100644 index 00000000..8104174f Binary files /dev/null and b/packages/sse/test/sseParser.test.ts differ diff --git a/packages/sse/test/sseStream.test.ts b/packages/sse/test/sseStream.test.ts new file mode 100644 index 00000000..de971694 --- /dev/null +++ b/packages/sse/test/sseStream.test.ts @@ -0,0 +1,287 @@ +import { PassThrough } from 'stream'; +import { SseDecodeError, SseTimeoutError } from '../src'; +import { drainBodyToText, SseStream } from '../src/sseStream'; +import { SseDecoder } from '../src/sseTypes'; +import { collectStream, mockSseBody } from './sseTestKit'; + +const passthrough: SseDecoder = (raw) => ({ + type: 'event', + value: raw.data, +}); + +function makeStream( + body: unknown, + decoder: SseDecoder, + readTimeout?: number +): SseStream { + return new SseStream(body, { decoder, readTimeout }); +} + +describe('SseStream', () => { + it('yields decoded values via for-await', async () => { + const stream = makeStream( + mockSseBody(['data: one\n\n', 'data: two\n\n', ': ping\n\n']), + passthrough + ); + expect(await collectStream(stream)).toEqual(['one', 'two']); + }); + + it('yields metadata via withMetadata()', async () => { + const stream = makeStream( + mockSseBody(['event: line\nid: 1\ndata: hello\n\n']), + passthrough + ); + const events = await collectStream(stream.withMetadata()); + expect(events).toEqual([ + { data: 'hello', event: 'line', id: '1', retry: undefined }, + ]); + }); + + it('parses across pathological chunk boundaries', async () => { + const stream = makeStream( + mockSseBody(['data: café ☕\n\n', 'data: done\n\n'], { chunkSize: 1 }), + passthrough + ); + expect(await collectStream(stream)).toEqual(['café ☕', 'done']); + }); + + it('ends without surfacing the event when the decoder reports done', async () => { + const decoder: SseDecoder = (raw) => + raw.data === '[DONE]' + ? { type: 'done' } + : { type: 'event', value: raw.data }; + const stream = makeStream( + mockSseBody(['data: a\n\n', 'data: [DONE]\n\n', 'data: never\n\n']), + decoder + ); + expect(await collectStream(stream)).toEqual(['a']); + }); + + it('skips a frame without ending the stream when the decoder reports skip', async () => { + const decoder: SseDecoder = (raw) => + raw.data === 'ignore' + ? { type: 'skip' } + : { type: 'event', value: raw.data }; + const stream = makeStream( + mockSseBody(['data: a\n\n', 'data: ignore\n\n', 'data: b\n\n']), + decoder + ); + expect(await collectStream(stream)).toEqual(['a', 'b']); + }); + + it('is single-use', async () => { + const stream = makeStream(mockSseBody(['data: a\n\n']), passthrough); + await collectStream(stream); + expect(() => stream.withMetadata()).toThrow(/already been consumed/); + }); + + it('stops quietly when aborted mid-stream', async () => { + const stream = makeStream( + mockSseBody(['data: 1\n\n', 'data: 2\n\n', 'data: 3\n\n'], { + chunkSize: 8, + delayMs: 5, + }), + passthrough + ); + const received: string[] = []; + for await (const item of stream) { + received.push(item); + stream.controller.abort(); + } + expect(received).toEqual(['1']); + }); + + it('does not crash when the body errors before consumption begins', async () => { + const body = new PassThrough(); + const stream = makeStream(body, passthrough); + // The connection resets BEFORE the consumer starts iterating. Without + // an armed error listener this is an unhandled 'error' → process crash. + body.destroy(new Error('reset before consumption')); + await new Promise((resolve) => setImmediate(resolve)); + await expect(collectStream(stream)).rejects.toThrow( + 'reset before consumption' + ); + }); + + it('surfaces a mid-stream transport drop to the consumer', async () => { + const stream = makeStream( + mockSseBody(['data: a\n\n'], { failWith: new Error('boom') }), + passthrough + ); + await expect(collectStream(stream)).rejects.toThrow('boom'); + }); + + describe('read timeout (streaming options)', () => { + it('throws SseTimeoutError when the server stalls between frames', async () => { + const stream = makeStream( + mockSseBody(['data: a\n\n'], { stallMs: 5000 }), + passthrough, + 50 // readTimeout + ); + expect.assertions(2); + try { + await collectStream(stream); + } catch (error) { + expect(error).toBeInstanceOf(SseTimeoutError); + expect((error as SseTimeoutError).idleTimeoutMs).toBe(50); + } + }); + + it('a steadily-sending server never times out', async () => { + const stream = makeStream( + mockSseBody(['data: a\n\n', ': ping\n\n', 'data: b\n\n'], { + chunkSize: 4, + delayMs: 20, + }), + passthrough, + 100 // readTimeout far above the 20ms inter-chunk gap + ); + expect(await collectStream(stream)).toEqual(['a', 'b']); + }); + }); + + describe('decode errors', () => { + it('wraps decoder failures in SseDecodeError with the raw frame', async () => { + const stream = makeStream(mockSseBody(['data: not json\n\n']), (raw) => ({ + type: 'event', + value: JSON.parse(raw.data), + })); + expect.assertions(3); + try { + await collectStream(stream); + } catch (error) { + expect(error).toBeInstanceOf(SseDecodeError); + expect((error as SseDecodeError).rawFrame).toBe('not json'); + expect((error as SseDecodeError).cause).toBeInstanceOf(SyntaxError); + } + }); + + it('a JSON decoder parses frames into typed values', async () => { + const jsonDecoder: SseDecoder<{ n: number }> = (raw) => ({ + type: 'event', + value: JSON.parse(raw.data), + }); + const stream = new SseStream<{ n: number }>( + mockSseBody(['data: {"n":1}\n\n', 'data: {"n":2}\n\n']), + { decoder: jsonDecoder } + ); + expect(await collectStream(stream)).toEqual([{ n: 1 }, { n: 2 }]); + }); + }); + + describe('body sources', () => { + it('reads a plain string body', async () => { + const stream = makeStream('data: hi\n\ndata: bye\n\n', passthrough); + expect(await collectStream(stream)).toEqual(['hi', 'bye']); + }); + + it('reads a web ReadableStream (getReader) body', async () => { + const stream = makeStream( + webReadableStreamOf(['data: a\n\n', 'data: b\n\n']), + passthrough + ); + expect(await collectStream(stream)).toEqual(['a', 'b']); + }); + + it('reads a Blob-like body via text()', async () => { + const stream = makeStream(blobOf('data: x\n\ndata: y\n\n'), passthrough); + expect(await collectStream(stream)).toEqual(['x', 'y']); + }); + + it('throws on an unsupported chunk type', async () => { + async function* numbers() { + yield 123 as unknown as Uint8Array; + } + const stream = makeStream(numbers(), passthrough); + await expect(collectStream(stream)).rejects.toThrow( + 'Unsupported chunk type' + ); + }); + + it('throws on an unsupported body type', async () => { + const stream = makeStream(42, passthrough); + await expect(collectStream(stream)).rejects.toThrow( + /Unsupported SSE response body/ + ); + }); + }); + + describe('close()', () => { + it('aborts the underlying controller', () => { + const stream = makeStream(mockSseBody(['data: a\n\n']), passthrough); + expect(stream.controller.signal.aborted).toBe(false); + stream.close(); + expect(stream.controller.signal.aborted).toBe(true); + }); + }); +}); + +describe('drainBodyToText', () => { + it('returns a string body unchanged', async () => { + expect(await drainBodyToText('hello', 1000)).toBe('hello'); + }); + + it('reads a Blob-like body and truncates to maxBytes', async () => { + expect(await drainBodyToText(blobOf('abcdef'), 1000)).toBe('abcdef'); + expect(await drainBodyToText(blobOf('abcdef'), 3)).toBe('abc'); + }); + + it('drains an async-iterable body of mixed chunk types', async () => { + async function* body() { + yield new TextEncoder().encode('hello '); + yield 'world'; + } + expect(await drainBodyToText(body(), 1000)).toBe('hello world'); + }); + + it('stops draining an async-iterable body once maxBytes is reached', async () => { + async function* body() { + yield 'aaaa'; + yield 'bbbb'; + yield 'cccc'; + } + const out = await drainBodyToText(body(), 5); + expect(out.startsWith('aaaa')).toBe(true); + expect(out.length).toBeLessThan('aaaabbbbcccc'.length); + }); + + it('returns whatever was read before an async-iterable body errors', async () => { + async function* body() { + yield 'partial'; + throw new Error('mid-stream failure'); + } + expect(await drainBodyToText(body(), 1000)).toBe('partial'); + }); + + it('drains a web ReadableStream body', async () => { + const body = webReadableStreamOf(['chunk1 ', 'chunk2']); + expect(await drainBodyToText(body, 1000)).toBe('chunk1 chunk2'); + }); + + it('falls back to String() for an unknown body', async () => { + expect(await drainBodyToText(42, 1000)).toBe('42'); + }); +}); + +/** A minimal WHATWG-style readable stream exposing only `getReader()`. */ +function webReadableStreamOf(frames: string[]) { + const chunks = frames.map((f) => new TextEncoder().encode(f)); + let i = 0; + return { + getReader() { + return { + read: async () => + i < chunks.length + ? { done: false, value: chunks[i++] } + : { done: true, value: undefined }, + cancel: async () => undefined, + releaseLock: () => undefined, + }; + }, + }; +} + +/** A minimal Blob-like body exposing only `text()`. */ +function blobOf(text: string) { + return { text: async () => text }; +} diff --git a/packages/sse/test/sseTestKit.ts b/packages/sse/test/sseTestKit.ts new file mode 100644 index 00000000..ef8393f0 --- /dev/null +++ b/packages/sse/test/sseTestKit.ts @@ -0,0 +1,58 @@ +import { SseEvent } from '../src/sseTypes'; + +/** Options for {@link mockSseBody}. */ +export interface MockSseBodyOptions { + /** Re-chunk the wire text into pieces of this many bytes before yielding. */ + chunkSize?: number; + /** Milliseconds to wait between chunks. */ + delayMs?: number; + /** Throw this error after all frames are sent. */ + failWith?: Error; + /** Keep the connection open silently for this long after all frames. */ + stallMs?: number; +} + +/** Builds an async-iterable byte stream from SSE wire-format frames. */ +export function mockSseBody( + frames: string[], + options: MockSseBodyOptions = {} +): AsyncIterable { + const { chunkSize, delayMs, failWith, stallMs } = options; + return (async function* () { + const encoder = new TextEncoder(); + const wire = encoder.encode(frames.join('')); + const size = + chunkSize !== undefined && chunkSize > 0 ? chunkSize : wire.length; + for (let i = 0; i < wire.length; i += size) { + if (delayMs !== undefined && delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + yield wire.slice(i, i + size); + } + if (stallMs !== undefined && stallMs > 0) { + await new Promise((resolve) => { + const timer = setTimeout(resolve, stallMs); + (timer as { unref?: () => void }).unref?.(); + }); + } + if (failWith !== undefined) { + throw failWith; + } + })(); +} + +/** Collects every value of an async iterable into an array. */ +export async function collectStream(stream: AsyncIterable): Promise { + const items: T[] = []; + for await (const item of stream) { + items.push(item); + } + return items; +} + +/** Collects full events (with metadata) from a stream-like source. */ +export async function collectEvents( + stream: AsyncIterable> +): Promise>> { + return collectStream(stream); +} diff --git a/packages/sse/tsconfig.json b/packages/sse/tsconfig.json new file mode 100644 index 00000000..47ff9287 --- /dev/null +++ b/packages/sse/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*"], + "compilerOptions": { + "outDir": "./lib", + "rootDir": "./src", + "skipLibCheck": true, + "resolveJsonModule": true + } +} diff --git a/sonar-project.properties b/sonar-project.properties index 7ef1e735..b1f12a0c 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -8,5 +8,5 @@ sonar.sources=packages sonar.exclusions=**/*config.js sonar.tests=packages -sonar.test.inclusions=**/test/**/*.test.ts +sonar.test.inclusions=**/test/**/* sonar.javascript.lcov.reportPaths=packages/**/coverage/lcov.info \ No newline at end of file diff --git a/tsconfig.monorepo.json b/tsconfig.monorepo.json index 5dc5e2cd..af1a3205 100644 --- a/tsconfig.monorepo.json +++ b/tsconfig.monorepo.json @@ -42,6 +42,9 @@ }, { "path": "./packages/proxy" + }, + { + "path": "./packages/sse" } ] }