diff --git a/changelog.md b/changelog.md index 15adcab..3f263a0 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## 2025-08-19 - 4.3.1 - fix(core) +Improve streaming support and timeout handling; add browser streaming & timeout tests and README clarifications + +- core_fetch: accept Uint8Array and Buffer-like bodies; set fetch duplex for ReadableStream bodies so streaming requests work in environments that require duplex +- core_fetch: implement AbortController-based timeouts and ensure timeouts are cleared on success/error to avoid hanging timers +- core_node: add explicit request timeout handling (request.setTimeout) and hard-data-cutting timeout tracking with proper timeoutId clear on success/error +- client: document that raw(streamFunc) is Node-only (not supported in browsers) +- tests: add browser streaming tests (test/test.streaming.browser.ts) that exercise buffer() and web ReadableStream via stream() +- tests: add timeout tests (test/test.timeout.ts) to validate clearing timers, enforcing timeouts, and preventing timer leaks across multiple requests +- docs: update README streaming section to clarify cross-platform behavior of buffer(), stream(), and raw() methods + ## 2025-08-18 - 4.3.0 - feat(client/smartrequest) Add streaming and raw buffer support to SmartRequest (buffer, stream, raw); update docs and tests diff --git a/readme.md b/readme.md index 6815172..e7ecd4c 100644 --- a/readme.md +++ b/readme.md @@ -379,15 +379,20 @@ async function uploadBinaryData() { #### Streaming Methods - **`.buffer(data, contentType?)`** - Stream a Buffer or Uint8Array directly - - `data`: Buffer or Uint8Array to send + - `data`: Buffer (Node.js) or Uint8Array (both platforms) to send - `contentType`: Optional content type (defaults to 'application/octet-stream') + - ✅ Works in both Node.js and browsers -- **`.stream(stream, contentType?)`** - Stream from Node.js ReadableStream or web ReadableStream - - `stream`: The stream to pipe to the request +- **`.stream(stream, contentType?)`** - Stream from ReadableStream + - `stream`: Web ReadableStream (both platforms) or Node.js stream (Node.js only) - `contentType`: Optional content type + - ✅ Web ReadableStream works in both Node.js and browsers + - ⚠️ Node.js streams only work in Node.js environment -- **`.raw(streamFunc)`** - Advanced control over request streaming (Node.js only) +- **`.raw(streamFunc)`** - Advanced control over request streaming - `streamFunc`: Function that receives the raw request object for custom streaming + - ❌ **Node.js only** - not supported in browsers + - Use for advanced scenarios like chunked transfer encoding These methods are particularly useful for: - Uploading large files without loading them into memory diff --git a/test/test.streaming.browser.ts b/test/test.streaming.browser.ts new file mode 100644 index 0000000..f92e84e --- /dev/null +++ b/test/test.streaming.browser.ts @@ -0,0 +1,41 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; +import { SmartRequest } from '../ts/index.js'; + +tap.test('browser: should send Uint8Array using buffer() method', async () => { + const testData = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" in ASCII + + const smartRequest = SmartRequest.create() + .url('https://httpbin.org/post') + .buffer(testData, 'application/octet-stream') + .method('POST'); + + const response = await smartRequest.post(); + const data = await response.json(); + + expect(data).toHaveProperty('data'); + expect(data.headers['Content-Type']).toEqual('application/octet-stream'); +}); + +tap.test('browser: should send web ReadableStream using stream() method', async () => { + // Create a web ReadableStream + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('Test stream data')); + controller.close(); + } + }); + + const smartRequest = SmartRequest.create() + .url('https://httpbin.org/post') + .stream(stream, 'text/plain') + .method('POST'); + + const response = await smartRequest.post(); + const data = await response.json(); + + expect(data).toHaveProperty('data'); + // httpbin should receive the streamed data +}); + +export default tap.start(); \ No newline at end of file diff --git a/test/test.timeout.ts b/test/test.timeout.ts new file mode 100644 index 0000000..e492c99 --- /dev/null +++ b/test/test.timeout.ts @@ -0,0 +1,60 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; +import { SmartRequest } from '../ts/index.js'; + +tap.test('should clear timeout when request completes before timeout', async () => { + // Set a long timeout that would keep the process alive if not cleared + const response = await SmartRequest.create() + .url('https://httpbin.org/delay/1') // 1 second delay + .timeout(10000) // 10 second timeout (much longer than needed) + .get(); + + const data = await response.json(); + expect(data).toBeDefined(); + + // The test should complete quickly, not wait for the 10 second timeout + // If the timeout isn't cleared, the process would hang for 10 seconds +}); + +tap.test('should timeout when request takes longer than timeout', async () => { + let errorThrown = false; + + try { + // Try to fetch with a very short timeout + await SmartRequest.create() + .url('https://httpbin.org/delay/3') // 3 second delay + .timeout(100) // 100ms timeout (will fail) + .get(); + } catch (error) { + errorThrown = true; + expect(error.message).toContain('Request timed out'); + } + + expect(errorThrown).toBeTrue(); +}); + +tap.test('should not leak timers with multiple successful requests', async () => { + // Make multiple requests with timeouts to ensure no timer leaks + const promises = []; + + for (let i = 0; i < 5; i++) { + promises.push( + SmartRequest.create() + .url('https://httpbin.org/get') + .timeout(5000) // 5 second timeout + .get() + .then(response => response.json()) + ); + } + + const results = await Promise.all(promises); + + // All requests should complete successfully + expect(results).toHaveLength(5); + results.forEach(result => { + expect(result).toBeDefined(); + }); + + // Process should exit cleanly after this test without hanging +}); + +export default tap.start(); \ No newline at end of file diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index d89a553..f111254 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartrequest', - version: '4.3.0', + version: '4.3.1', description: 'A module for modern HTTP/HTTPS requests with support for form data, file uploads, JSON, binary data, streams, and more.' } diff --git a/ts/client/smartrequest.ts b/ts/client/smartrequest.ts index 64ce276..0c430d2 100644 --- a/ts/client/smartrequest.ts +++ b/ts/client/smartrequest.ts @@ -164,7 +164,7 @@ export class SmartRequest { /** * 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 + * Note: Only works in Node.js environment, not supported in browsers */ raw(streamFunc: RawStreamFunction): this { // Store the raw streaming function to be used later diff --git a/ts/core_fetch/request.ts b/ts/core_fetch/request.ts index 932ca5d..c9606ef 100644 --- a/ts/core_fetch/request.ts +++ b/ts/core_fetch/request.ts @@ -9,6 +9,9 @@ export class CoreRequest extends AbstractCoreRequest< types.ICoreRequestOptions, CoreResponse > { + private timeoutId: ReturnType | null = null; + private abortController: AbortController | null = null; + constructor(url: string, options: types.ICoreRequestOptions = {}) { super(url, options); @@ -61,11 +64,19 @@ export class CoreRequest extends AbstractCoreRequest< if ( typeof this.options.requestBody === 'string' || this.options.requestBody instanceof ArrayBuffer || + this.options.requestBody instanceof Uint8Array || this.options.requestBody instanceof FormData || this.options.requestBody instanceof URLSearchParams || - this.options.requestBody instanceof ReadableStream + this.options.requestBody instanceof ReadableStream || + // Check for Buffer (Node.js polyfills in browser may provide this) + (typeof Buffer !== 'undefined' && this.options.requestBody instanceof Buffer) ) { fetchOptions.body = this.options.requestBody; + + // If streaming, we need to set duplex mode + if (this.options.requestBody instanceof ReadableStream) { + (fetchOptions as any).duplex = 'half'; + } } else { // Convert objects to JSON fetchOptions.body = JSON.stringify(this.options.requestBody); @@ -92,9 +103,13 @@ export class CoreRequest extends AbstractCoreRequest< if (this.options.timeout || this.options.hardDataCuttingTimeout) { const timeout = this.options.hardDataCuttingTimeout || this.options.timeout; - const controller = new AbortController(); - setTimeout(() => controller.abort(), timeout); - fetchOptions.signal = controller.signal; + this.abortController = new AbortController(); + this.timeoutId = setTimeout(() => { + if (this.abortController) { + this.abortController.abort(); + } + }, timeout); + fetchOptions.signal = this.abortController.signal; } return fetchOptions; @@ -117,8 +132,12 @@ export class CoreRequest extends AbstractCoreRequest< try { const response = await fetch(url, options); + // Clear timeout on successful response + this.clearTimeout(); return response; } catch (error) { + // Clear timeout on error + this.clearTimeout(); if (error.name === 'AbortError') { throw new Error('Request timed out'); } @@ -126,6 +145,19 @@ export class CoreRequest extends AbstractCoreRequest< } } + /** + * Clear the timeout and abort controller + */ + private clearTimeout(): void { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + if (this.abortController) { + this.abortController = null; + } + } + /** * Static factory method to create and fire a request */ diff --git a/ts/core_node/request.ts b/ts/core_node/request.ts index 593a51c..2d1f45a 100644 --- a/ts/core_node/request.ts +++ b/ts/core_node/request.ts @@ -119,10 +119,11 @@ export class CoreRequest extends AbstractCoreRequest< } // Perform the request + let timeoutId: NodeJS.Timeout | null = null; const request = requestModule.request(this.options, async (response) => { // Handle hard timeout if (this.options.hardDataCuttingTimeout) { - setTimeout(() => { + timeoutId = setTimeout(() => { response.destroy(); done.reject(new Error('Request timed out')); }, this.options.hardDataCuttingTimeout); @@ -132,6 +133,14 @@ export class CoreRequest extends AbstractCoreRequest< done.resolve(response); }); + // Set request timeout (Node.js built-in timeout) + if (this.options.timeout) { + request.setTimeout(this.options.timeout, () => { + request.destroy(); + done.reject(new Error('Request timed out')); + }); + } + // Write request body if (this.options.requestBody) { if (this.options.requestBody instanceof plugins.formData) { @@ -159,11 +168,23 @@ export class CoreRequest extends AbstractCoreRequest< request.on('error', (e) => { console.error(e); request.destroy(); + // Clear timeout on error + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } done.reject(e); }); // Get response and handle response errors const response = await done.promise; + + // Clear timeout on successful response + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + response.on('error', (err) => { console.error(err); response.destroy();