import { CoreRequest, CoreResponse } from '../core/index.js'; import type { ICoreResponse } from '../core_base/types.js'; import * as plugins from './plugins.js'; import type { ICoreRequestOptions } from '../core_base/types.js'; import type { HttpMethod, ResponseType, FormField, RateLimitConfig, RawStreamFunction, } from './types/common.js'; import { type TPaginationConfig, PaginationStrategy, type OffsetPaginationConfig, type CursorPaginationConfig, type CustomPaginationConfig, type TPaginatedResponse, } from './types/pagination.js'; import { createPaginatedResponse } from './features/pagination.js'; /** * Parse Retry-After header value to milliseconds * @param retryAfter - The Retry-After header value (seconds or HTTP date) * @returns Delay in milliseconds */ function parseRetryAfter(retryAfter: string | string[]): number { // Handle array of values (take first) const value = Array.isArray(retryAfter) ? retryAfter[0] : retryAfter; if (!value) return 0; // Try to parse as seconds (number) const seconds = parseInt(value, 10); if (!isNaN(seconds)) { return seconds * 1000; } // Try to parse as HTTP date const retryDate = new Date(value); if (!isNaN(retryDate.getTime())) { return Math.max(0, retryDate.getTime() - Date.now()); } return 0; } /** * Modern fluent client for making HTTP requests */ export class SmartRequest { private _url: string; private _options: ICoreRequestOptions = {}; private _retries: number = 0; private _queryParams: Record = {}; private _paginationConfig?: TPaginationConfig; private _rateLimitConfig?: RateLimitConfig; /** * Create a new SmartRequest instance */ static create(): SmartRequest { return new SmartRequest(); } /** * Set the URL for the request */ url(url: string): this { this._url = url; return this; } /** * Set the HTTP method */ method(method: HttpMethod): this { this._options.method = method; return this; } /** * Set JSON body for the request */ json(data: any): this { if (!this._options.headers) { this._options.headers = {}; } this._options.headers['Content-Type'] = 'application/json'; this._options.requestBody = data; return this; } /** * Set form data for the request */ formData(data: FormField[]): this { const form = new plugins.formData(); for (const item of data) { if (Buffer.isBuffer(item.value)) { form.append(item.name, item.value, { filename: item.filename || 'file', contentType: item.contentType || 'application/octet-stream', }); } else { form.append(item.name, item.value); } } if (!this._options.headers) { this._options.headers = {}; } this._options.headers = { ...this._options.headers, ...form.getHeaders(), }; this._options.requestBody = form; return this; } /** * Set raw buffer data for the request */ buffer(data: Buffer | Uint8Array, contentType?: string): this { if (!this._options.headers) { this._options.headers = {}; } this._options.headers['Content-Type'] = contentType || 'application/octet-stream'; this._options.requestBody = data; return this; } /** * Stream data for the request * Accepts Node.js Readable streams or web ReadableStream */ stream(stream: NodeJS.ReadableStream | ReadableStream, contentType?: string): this { if (!this._options.headers) { this._options.headers = {}; } // Set content type if provided if (contentType) { this._options.headers['Content-Type'] = contentType; } // Check if it's a Node.js stream (has pipe method) if ('pipe' in stream && typeof (stream as any).pipe === 'function') { // For Node.js streams, we need to use a custom approach // Store the stream to be used later (this._options as any).__nodeStream = stream; } else { // For web ReadableStream, pass directly this._options.requestBody = stream; } return this; } /** * Provide a custom function to handle raw request streaming * This gives full control over the request body streaming * Note: Only works in Node.js environment, not supported in browsers */ raw(streamFunc: RawStreamFunction): this { // Store the raw streaming function to be used later (this._options as any).__rawStreamFunc = streamFunc; return this; } /** * Set request timeout in milliseconds */ timeout(ms: number): this { this._options.timeout = ms; this._options.hardDataCuttingTimeout = ms; return this; } /** * Set number of retry attempts */ retry(count: number): this { this._retries = count; return this; } /** * Enable automatic 429 (Too Many Requests) handling with configurable backoff */ handle429Backoff(config?: RateLimitConfig): this { this._rateLimitConfig = { maxRetries: config?.maxRetries ?? 3, respectRetryAfter: config?.respectRetryAfter ?? true, maxWaitTime: config?.maxWaitTime ?? 60000, fallbackDelay: config?.fallbackDelay ?? 1000, backoffFactor: config?.backoffFactor ?? 2, onRateLimit: config?.onRateLimit, }; return this; } /** * Set HTTP headers */ headers(headers: Record): this { if (!this._options.headers) { this._options.headers = {}; } this._options.headers = { ...this._options.headers, ...headers, }; return this; } /** * Set a single HTTP header */ header(name: string, value: string): this { if (!this._options.headers) { this._options.headers = {}; } this._options.headers[name] = value; return this; } /** * Set query parameters */ query(params: Record): this { this._queryParams = { ...this._queryParams, ...params, }; return this; } /** * Set additional request options */ options(options: Partial): this { this._options = { ...this._options, ...options, }; return this; } /** * Enable or disable auto-drain for unconsumed response bodies (Node.js only) * Default is true to prevent socket hanging */ autoDrain(enabled: boolean): this { this._options.autoDrain = enabled; return this; } /** * Set the Accept header to indicate what content type is expected */ accept(type: ResponseType): this { // Map response types to Accept header values const acceptHeaders: Record = { json: 'application/json', text: 'text/plain', binary: 'application/octet-stream', stream: '*/*', }; return this.header('Accept', acceptHeaders[type]); } /** * Configure pagination for requests */ pagination(config: TPaginationConfig): this { this._paginationConfig = config; return this; } /** * Configure offset-based pagination (page & limit) */ withOffsetPagination( config: Omit = {}, ): this { this._paginationConfig = { strategy: PaginationStrategy.OFFSET, pageParam: config.pageParam || 'page', limitParam: config.limitParam || 'limit', startPage: config.startPage || 1, pageSize: config.pageSize || 20, totalPath: config.totalPath || 'total', }; // Add initial pagination parameters this.query({ [this._paginationConfig.pageParam]: String( this._paginationConfig.startPage, ), [this._paginationConfig.limitParam]: String( this._paginationConfig.pageSize, ), }); return this; } /** * Configure cursor-based pagination */ withCursorPagination( config: Omit = {}, ): this { this._paginationConfig = { strategy: PaginationStrategy.CURSOR, cursorParam: config.cursorParam || 'cursor', cursorPath: config.cursorPath || 'nextCursor', hasMorePath: config.hasMorePath || 'hasMore', }; return this; } /** * Configure Link header-based pagination */ withLinkPagination(): this { this._paginationConfig = { strategy: PaginationStrategy.LINK_HEADER, }; return this; } /** * Configure custom pagination */ withCustomPagination(config: Omit): this { this._paginationConfig = { strategy: PaginationStrategy.CUSTOM, hasNextPage: config.hasNextPage, getNextPageParams: config.getNextPageParams, }; return this; } /** * Make a GET request */ async get(): Promise> { return this.execute('GET'); } /** * Make a POST request */ async post(): Promise> { return this.execute('POST'); } /** * Make a PUT request */ async put(): Promise> { return this.execute('PUT'); } /** * Make a DELETE request */ async delete(): Promise> { return this.execute('DELETE'); } /** * Make a PATCH request */ async patch(): Promise> { return this.execute('PATCH'); } /** * Get paginated response */ async getPaginated(): Promise> { if (!this._paginationConfig) { throw new Error( 'Pagination not configured. Call one of the pagination methods first.', ); } // Default to GET if no method specified if (!this._options.method) { this._options.method = 'GET'; } const response = await this.execute(); return await createPaginatedResponse( response, this._paginationConfig, this._queryParams, (nextPageParams) => { // Create a new client with the same configuration but updated query params const nextClient = new SmartRequest(); Object.assign(nextClient, this); nextClient._queryParams = nextPageParams; return nextClient.getPaginated(); }, ); } /** * Get all pages at once (use with caution for large datasets) */ async getAllPages(): Promise { const firstPage = await this.getPaginated(); return firstPage.getAllPages(); } /** * Execute the HTTP request */ private async execute(method?: HttpMethod): Promise> { if (method) { this._options.method = method; } this._options.queryParams = this._queryParams; // Track rate limit attempts separately let rateLimitAttempt = 0; let lastError: Error; // Main retry loop for (let attempt = 0; attempt <= this._retries; attempt++) { try { // Check if we have a Node.js stream or raw function that needs special handling let requestDataFunc = null; if ((this._options as any).__nodeStream) { const nodeStream = (this._options as any).__nodeStream; requestDataFunc = (req: any) => { nodeStream.pipe(req); }; // Remove the temporary stream reference delete (this._options as any).__nodeStream; } else if ((this._options as any).__rawStreamFunc) { requestDataFunc = (this._options as any).__rawStreamFunc; // Remove the temporary function reference delete (this._options as any).__rawStreamFunc; } const request = new CoreRequest(this._url, this._options as any, requestDataFunc); const response = (await request.fire()) as ICoreResponse; // Check for 429 status if rate limit handling is enabled if (this._rateLimitConfig && response.status === 429) { if (rateLimitAttempt >= this._rateLimitConfig.maxRetries) { // Max rate limit retries reached, return the 429 response return response; } let waitTime: number; if ( this._rateLimitConfig.respectRetryAfter && response.headers['retry-after'] ) { // Parse Retry-After header waitTime = parseRetryAfter(response.headers['retry-after']); // Cap wait time to maxWaitTime waitTime = Math.min(waitTime, this._rateLimitConfig.maxWaitTime); } else { // Use exponential backoff waitTime = Math.min( this._rateLimitConfig.fallbackDelay * Math.pow(this._rateLimitConfig.backoffFactor, rateLimitAttempt), this._rateLimitConfig.maxWaitTime, ); } // Call rate limit callback if provided if (this._rateLimitConfig.onRateLimit) { this._rateLimitConfig.onRateLimit(rateLimitAttempt + 1, waitTime); } // Wait before retrying await new Promise((resolve) => setTimeout(resolve, waitTime)); rateLimitAttempt++; // Decrement attempt to retry this attempt attempt--; continue; } // Success or non-429 error response return response; } catch (error) { lastError = error as Error; // If this is the last attempt, throw the error if (attempt === this._retries) { throw lastError; } // Otherwise, wait before retrying await new Promise((resolve) => setTimeout(resolve, 1000)); } } // This should never be reached due to the throw in the loop above throw lastError; } }