Compare commits
	
		
			23 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| df28cd4778 | |||
| f49cbd2b6a | |||
| 984b53cba2 | |||
| 4c55243646 | |||
| 49cfcaedd1 | |||
| 3996a69f91 | |||
| 629f6dd425 | |||
| d141ceeaf7 | |||
| 7d3c94cae6 | |||
| 5bae452365 | |||
| ffabcf7bdb | |||
| 361d97f440 | |||
| 35867d9148 | |||
| d455a34632 | |||
| 9c5a939499 | |||
| 7b2081dc4d | |||
| ee750dea58 | |||
| 9b9c8fd618 | |||
| 1991308d4a | |||
| b4769e7feb | |||
| 4cbca08f43 | |||
| cf24bf94b9 | |||
| 3e24f1c5a8 | 
| @@ -6,8 +6,8 @@ on: | ||||
|       - '**' | ||||
|  | ||||
| env: | ||||
|   IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci | ||||
|   NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git | ||||
|   IMAGE: code.foss.global/host.today/ht-docker-node:npmci | ||||
|   NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git | ||||
|   NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}} | ||||
|   NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}} | ||||
|   NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}} | ||||
| @@ -23,24 +23,16 @@ jobs: | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|  | ||||
|       - name: Install pnpm and npmci | ||||
|         run: | | ||||
|           pnpm install -g pnpm | ||||
|           pnpm install -g @shipzone/npmci | ||||
|  | ||||
|       - name: Run npm prepare | ||||
|         run: npmci npm prepare | ||||
|  | ||||
|       - name: Audit production dependencies | ||||
|         run: | | ||||
|           npmci command npm config set registry https://registry.npmjs.org | ||||
|           npmci command pnpm audit --audit-level=high --prod | ||||
|           npm config set registry https://registry.npmjs.org | ||||
|           pnpm audit --audit-level=high --prod | ||||
|         continue-on-error: true | ||||
|  | ||||
|       - name: Audit development dependencies | ||||
|         run: | | ||||
|           npmci command npm config set registry https://registry.npmjs.org | ||||
|           npmci command pnpm audit --audit-level=high --dev | ||||
|           npm config set registry https://registry.npmjs.org | ||||
|           pnpm audit --audit-level=high --dev | ||||
|         continue-on-error: true | ||||
|  | ||||
|   test: | ||||
| @@ -55,12 +47,10 @@ jobs: | ||||
|  | ||||
|       - name: Test stable | ||||
|         run: | | ||||
|           npmci node install stable | ||||
|           npmci npm install | ||||
|           npmci npm test | ||||
|           pnpm install | ||||
|           pnpm test | ||||
|  | ||||
|       - name: Test build | ||||
|         run: | | ||||
|           npmci node install stable | ||||
|           npmci npm install | ||||
|           npmci npm build | ||||
|           pnpm install | ||||
|           pnpm build | ||||
|   | ||||
| @@ -6,8 +6,8 @@ on: | ||||
|       - '*' | ||||
|  | ||||
| env: | ||||
|   IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci | ||||
|   NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git | ||||
|   IMAGE: code.foss.global/host.today/ht-docker-node:npmci | ||||
|   NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git | ||||
|   NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}} | ||||
|   NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}} | ||||
|   NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}} | ||||
| @@ -23,22 +23,16 @@ jobs: | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|  | ||||
|       - name: Prepare | ||||
|         run: | | ||||
|           pnpm install -g pnpm | ||||
|           pnpm install -g @shipzone/npmci | ||||
|           npmci npm prepare | ||||
|  | ||||
|       - name: Audit production dependencies | ||||
|         run: | | ||||
|           npmci command npm config set registry https://registry.npmjs.org | ||||
|           npmci command pnpm audit --audit-level=high --prod | ||||
|           npm config set registry https://registry.npmjs.org | ||||
|           pnpm audit --audit-level=high --prod | ||||
|         continue-on-error: true | ||||
|  | ||||
|       - name: Audit development dependencies | ||||
|         run: | | ||||
|           npmci command npm config set registry https://registry.npmjs.org | ||||
|           npmci command pnpm audit --audit-level=high --dev | ||||
|           npm config set registry https://registry.npmjs.org | ||||
|           pnpm audit --audit-level=high --dev | ||||
|         continue-on-error: true | ||||
|  | ||||
|   test: | ||||
| @@ -51,23 +45,15 @@ jobs: | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|  | ||||
|       - name: Prepare | ||||
|         run: | | ||||
|           pnpm install -g pnpm | ||||
|           pnpm install -g @shipzone/npmci | ||||
|           npmci npm prepare | ||||
|  | ||||
|       - name: Test stable | ||||
|         run: | | ||||
|           npmci node install stable | ||||
|           npmci npm install | ||||
|           npmci npm test | ||||
|           pnpm install | ||||
|           pnpm test | ||||
|  | ||||
|       - name: Test build | ||||
|         run: | | ||||
|           npmci node install stable | ||||
|           npmci npm install | ||||
|           npmci npm build | ||||
|           pnpm install | ||||
|           pnpm build | ||||
|  | ||||
|   release: | ||||
|     needs: test | ||||
| @@ -79,16 +65,27 @@ jobs: | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|  | ||||
|       - name: Prepare | ||||
|         run: | | ||||
|           pnpm install -g pnpm | ||||
|           pnpm install -g @shipzone/npmci | ||||
|           npmci npm prepare | ||||
|  | ||||
|       - name: Release | ||||
|         run: | | ||||
|           npmci node install stable | ||||
|           npmci npm publish | ||||
|           pnpm install | ||||
|           # Extract server host from GITHUB_SERVER_URL (remove https://) | ||||
|           GITEA_HOST="${GITHUB_SERVER_URL#https://}" | ||||
|           GITEA_REGISTRY="$GITHUB_SERVER_URL/api/packages/$GITHUB_REPOSITORY_OWNER/npm/" | ||||
|  | ||||
|           # Create .npmrc for Gitea authentication | ||||
|           echo "@${GITHUB_REPOSITORY_OWNER}:registry=${GITEA_REGISTRY}" > .npmrc | ||||
|           echo "//${GITEA_HOST}/api/packages/${GITHUB_REPOSITORY_OWNER}/npm/:_authToken=${GITEA_TOKEN}" >> .npmrc | ||||
|  | ||||
|           # Publish to Gitea | ||||
|           pnpm publish --no-git-checks | ||||
|  | ||||
|           # Conditionally publish to npmjs.org if token exists | ||||
|           if [ -n "$NPMCI_TOKEN_NPM" ]; then | ||||
|             # Update .npmrc for npmjs.org | ||||
|             echo "registry=https://registry.npmjs.org/" > .npmrc | ||||
|             echo "//registry.npmjs.org/:_authToken=${NPMCI_TOKEN_NPM}" >> .npmrc | ||||
|             pnpm publish --no-git-checks | ||||
|           fi | ||||
|  | ||||
|   metadata: | ||||
|     needs: test | ||||
| @@ -101,24 +98,14 @@ jobs: | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|  | ||||
|       - name: Prepare | ||||
|         run: | | ||||
|           pnpm install -g pnpm | ||||
|           pnpm install -g @shipzone/npmci | ||||
|           npmci npm prepare | ||||
|  | ||||
|       - name: Code quality | ||||
|         run: | | ||||
|           npmci command npm install -g typescript | ||||
|           npmci npm install | ||||
|  | ||||
|       - name: Trigger | ||||
|         run: npmci trigger | ||||
|           npm install -g typescript | ||||
|           pnpm install | ||||
|  | ||||
|       - name: Build docs and upload artifacts | ||||
|         run: | | ||||
|           npmci node install stable | ||||
|           npmci npm install | ||||
|           pnpm install | ||||
|           pnpm install -g @git.zone/tsdoc | ||||
|           npmci command tsdoc | ||||
|           tsdoc | ||||
|         continue-on-error: true | ||||
|   | ||||
							
								
								
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -3,7 +3,6 @@ | ||||
| # artifacts | ||||
| coverage/ | ||||
| public/ | ||||
| pages/ | ||||
|  | ||||
| # installs | ||||
| node_modules/ | ||||
| @@ -17,4 +16,8 @@ node_modules/ | ||||
| dist/ | ||||
| dist_*/ | ||||
|  | ||||
| # custom | ||||
| # AI | ||||
| .claude/ | ||||
| .serena/ | ||||
|  | ||||
| #------# custom | ||||
							
								
								
									
										148
									
								
								changelog.md
									
									
									
									
									
								
							
							
						
						
									
										148
									
								
								changelog.md
									
									
									
									
									
								
							| @@ -1,9 +1,139 @@ | ||||
| # Changelog | ||||
|  | ||||
| ## 2025-10-26 - 4.3.6 - fix(ci) | ||||
| Use .npmrc for registry authentication in Gitea workflow and add conditional npmjs publish | ||||
|  | ||||
| - Replace npm config set commands with creating a .npmrc file for Gitea registry authentication in .gitea/workflows/default_tags.yaml | ||||
| - Add conditional update of .npmrc and publishing to npmjs.org when NPMCI_TOKEN_NPM is provided | ||||
| - Keep pnpm publish --no-git-checks; improve CI credential handling to be file-based | ||||
|  | ||||
| ## 2025-10-26 - 4.3.5 - fix(workflows) | ||||
| Remove npmci wrappers from CI workflows and use pnpm/npm CLI directly | ||||
|  | ||||
| - Removed global npmci installation and npmci npm prepare steps from Gitea workflow files | ||||
| - Use pnpm install/test/build instead of npmci-wrapped commands in test jobs | ||||
| - Replace npmci command npm config set ... with direct npm config set calls for registry/auth configuration | ||||
| - Use pnpm publish --no-git-checks for Gitea publishing and use pnpm publish for conditional npmjs publish when token present | ||||
| - Simplified dependency auditing to run pnpm audit and set registry via npm config set | ||||
| - Install tsdoc globally and run tsdoc during docs build step (replacing npmci command usage) | ||||
|  | ||||
| ## 2025-10-25 - 4.3.4 - fix(ci) | ||||
| Fix Gitea workflow publish invocation to run npm publish via npmci command | ||||
|  | ||||
| - Update .gitea/workflows/default_tags.yaml to use 'npmci command npm publish' for the publish step | ||||
| - Ensures the workflow runs npm publish through the npmci command wrapper to avoid incorrect task invocation | ||||
|  | ||||
| ## 2025-10-25 - 4.3.3 - fix(ci) | ||||
| Improve Gitea release workflow: install deps, configure Gitea npm registry, and optionally publish to npmjs.org | ||||
|  | ||||
| - Run npm install in the release job to ensure dependencies are available before publishing. | ||||
| - Configure Gitea/npm registry using GITHUB_SERVER_URL and set auth token for the @<owner> scope. | ||||
| - Publish to the Gitea npm registry during release. | ||||
| - If NPMCI_TOKEN_NPM is provided, also publish to the public npmjs.org registry (conditional publish). | ||||
| - Extract host from GITHUB_SERVER_URL to correctly set the registry auth URL. | ||||
|  | ||||
| ## 2025-10-17 - 4.3.2 - fix(core) | ||||
| Remove stray console.log from core module | ||||
|  | ||||
| - Removed a stray debug console.log(modulePath) from ts/core/index.ts that printed the module path during Node environment initialization | ||||
|  | ||||
| ## 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 | ||||
|  | ||||
| - Add SmartRequest.buffer(data, contentType?) to send Buffer or Uint8Array bodies with Content-Type header. | ||||
| - Add SmartRequest.stream(stream, contentType?) to accept Node.js Readable streams or web ReadableStream and set Content-Type when provided. | ||||
| - Add SmartRequest.raw(streamFunc) to allow custom raw streaming functions (Node.js only) and a RawStreamFunction type. | ||||
| - Wire Node.js stream handling into CoreRequest by passing a requestDataFunc when creating CoreRequest instances. | ||||
| - Add comprehensive streaming examples and documentation to README describing buffer/stream/raw usage and streaming methods. | ||||
| - Add tests for streaming behavior (test/test.streaming.ts) covering buffer, stream, raw, and Uint8Array usage. | ||||
| - Update client exports and plugins to support streaming features and FormData usage where needed. | ||||
|  | ||||
| ## 2025-08-18 - 4.2.2 - fix(client) | ||||
| Fix CI configuration, prevent socket hangs with auto-drain, and apply various client/core TypeScript fixes and test updates | ||||
|  | ||||
| - CI/workflow updates: switch container IMAGE to code.foss.global/host.today/ht-docker-node:npmci, adjust NPMCI_COMPUTED_REPOURL, and install @ship.zone/npmci instead of @shipzone/npmci | ||||
| - Prevent socket hanging by adding automatic draining of unconsumed Node.js response bodies (configurable via options.autoDrain / SmartRequest.autoDrain); added logging when auto-drain runs and updated tests to consume bodies | ||||
| - Client improvements: fixes and cleanups in SmartRequest (accept header mapping, formData header handling, options(), pagination helpers, handle429Backoff backoff/Retry-After parsing and callbacks, retry logic and small API ergonomics) | ||||
| - Core fixes: fetch and node implementations corrected (buildUrl, fetch options, request/response constructors, stream conversions to web ReadableStream, proper error messages) and consistent exports | ||||
| - TypeScript and formatting fixes across many files (consistent trailing commas, object layout, newline fixes, typed function signatures, cleaned up exports and module imports) | ||||
| - Package metadata and tooling updates: package.json bug/homepage URLs adjusted to code.foss.global, bumped @git.zone/tstest devDependency, added pnpm overrides field; small .gitignore additions | ||||
|  | ||||
| ## 2025-07-29 - 4.2.1 - fix(client) | ||||
|  | ||||
| Fix socket hanging issues and add auto-drain feature | ||||
|  | ||||
| **Fixes:** | ||||
|  | ||||
| - Fixed socket hanging issues caused by unconsumed response bodies | ||||
| - Resolved test timeout problems where sockets remained open after tests completed | ||||
|  | ||||
| **Features:** | ||||
|  | ||||
| - Added automatic response body draining to prevent socket pool exhaustion | ||||
| - Made auto-drain configurable via `autoDrain()` method (enabled by default) | ||||
| - Added logging when auto-drain activates for debugging purposes | ||||
|  | ||||
| **Improvements:** | ||||
|  | ||||
| - Updated all tests to properly consume response bodies | ||||
| - Enhanced documentation about the importance of consuming response bodies | ||||
|  | ||||
| ## 2025-07-29 - 4.2.0 - feat(client) | ||||
|  | ||||
| Add handle429Backoff method for intelligent rate limit handling | ||||
|  | ||||
| **Features:** | ||||
|  | ||||
| - Added `handle429Backoff()` method to SmartRequest class for automatic HTTP 429 handling | ||||
| - Respects `Retry-After` headers with support for both seconds and HTTP date formats | ||||
| - Configurable exponential backoff when no Retry-After header is present | ||||
| - Added `RateLimitConfig` interface with customizable retry behavior | ||||
| - Optional callback for monitoring rate limit events | ||||
| - Maximum wait time capping to prevent excessive delays | ||||
|  | ||||
| **Improvements:** | ||||
|  | ||||
| - Updated test endpoints to use more reliable services (jsonplaceholder, echo.zuplo.io) | ||||
| - Added timeout parameter to test script for better CI/CD compatibility | ||||
|  | ||||
| **Documentation:** | ||||
|  | ||||
| - Added comprehensive rate limiting section to README with examples | ||||
| - Documented all configuration options for handle429Backoff | ||||
|  | ||||
| ## 2025-07-29 - 4.1.0 - feat(client) | ||||
|  | ||||
| Add missing options() method to SmartRequest client | ||||
|  | ||||
| **Features:** | ||||
|  | ||||
| - Added `options()` method to SmartRequest class for setting arbitrary request options | ||||
| - Enables setting keepAlive and other platform-specific options via fluent API | ||||
| - Added test coverage for keepAlive functionality | ||||
|  | ||||
| **Documentation:** | ||||
|  | ||||
| - Updated README with examples of using the `options()` method | ||||
| - Added specific examples for enabling keepAlive connections | ||||
| - Corrected all documentation to use `options()` instead of `option()` | ||||
|  | ||||
| ## 2025-07-28 - 4.0.0 - BREAKING CHANGE(core) | ||||
|  | ||||
| Complete architectural overhaul with cross-platform support | ||||
|  | ||||
| **Breaking Changes:** | ||||
|  | ||||
| - Renamed `SmartRequestClient` to `SmartRequest` for simpler, cleaner API | ||||
| - Removed legacy API entirely (no more `/legacy` import path) | ||||
| - Major architectural refactoring: | ||||
| @@ -17,6 +147,7 @@ Complete architectural overhaul with cross-platform support | ||||
| - Removed all "Abstract" prefixes from type names | ||||
|  | ||||
| **Features:** | ||||
|  | ||||
| - Full cross-platform support (Node.js and browsers) | ||||
| - Automatic platform detection using @push.rocks/smartenv | ||||
| - Consistent API across platforms with platform-specific capabilities | ||||
| @@ -24,15 +155,18 @@ Complete architectural overhaul with cross-platform support | ||||
| - Better error messages for unsupported platform features | ||||
|  | ||||
| **Documentation:** | ||||
|  | ||||
| - Completely rewritten README with platform-specific examples | ||||
| - Added architecture overview section | ||||
| - Added migration guide from v2.x and v3.x | ||||
| - Updated all examples to use the new `SmartRequest` class name | ||||
|  | ||||
| ## 2025-07-27 - 3.0.0 - BREAKING CHANGE(core) | ||||
|  | ||||
| Major architectural refactoring with fetch-like API | ||||
|  | ||||
| **Breaking Changes:** | ||||
|  | ||||
| - Legacy API functions are now imported from `@push.rocks/smartrequest/legacy` instead of the main export | ||||
| - Modern API response objects now use fetch-like methods (`.json()`, `.text()`, `.arrayBuffer()`, `.stream()`) instead of direct `.body` access | ||||
| - Renamed `responseType()` method to `accept()` in modern API | ||||
| @@ -46,17 +180,20 @@ Major architectural refactoring with fetch-like API | ||||
|   - Legacy API is now just an adapter over the core module | ||||
|  | ||||
| **Features:** | ||||
|  | ||||
| - New fetch-like response API with single-use body consumption | ||||
| - Better TypeScript support and type safety | ||||
| - Cleaner separation of concerns between request and response | ||||
| - More predictable behavior aligned with fetch API standards | ||||
|  | ||||
| **Documentation:** | ||||
|  | ||||
| - Updated all examples to show correct import paths | ||||
| - Added comprehensive examples for the new response API | ||||
| - Enhanced migration guide | ||||
|  | ||||
| ## 2025-04-03 - 2.1.0 - feat(docs) | ||||
|  | ||||
| Enhance documentation and tests with modern API usage examples and migration guide | ||||
|  | ||||
| - Updated README to include detailed examples for the modern fluent API, covering GET, POST, headers, query, timeout, retries, and pagination | ||||
| @@ -66,6 +203,7 @@ Enhance documentation and tests with modern API usage examples and migration gui | ||||
| - Minor formatting improvements in the code and documentation examples | ||||
|  | ||||
| ## 2024-11-06 - 2.0.23 - fix(core) | ||||
|  | ||||
| Enhance type safety for response in binary requests | ||||
|  | ||||
| - Updated the dependency versions in package.json to their latest versions. | ||||
| @@ -73,31 +211,37 @@ Enhance type safety for response in binary requests | ||||
| - Introduced generic typing to IExtendedIncomingMessage interface for better type safety. | ||||
|  | ||||
| ## 2024-05-29 - 2.0.22 - Documentation | ||||
|  | ||||
| update description | ||||
|  | ||||
| ## 2024-04-01 - 2.0.21 - Configuration | ||||
|  | ||||
| Updated configuration files | ||||
|  | ||||
| - Updated `tsconfig` | ||||
| - Updated `npmextra.json`: githost | ||||
|  | ||||
| ## 2023-07-10 - 2.0.15 - Structure | ||||
|  | ||||
| Refactored the organization structure | ||||
|  | ||||
| - Switched to a new organization scheme | ||||
|  | ||||
| ## 2022-07-29 - 1.1.57 to 2.0.0 - Major Update | ||||
|  | ||||
| Significant changes and improvements leading to a major version update | ||||
|  | ||||
| - **BREAKING CHANGE**: Switched the core to use ECMAScript modules (ESM) | ||||
|  | ||||
| ## 2018-08-14 - 1.1.12 to 1.1.13 - Functional Enhancements | ||||
|  | ||||
| Enhanced request capabilities and removed unnecessary dependencies | ||||
|  | ||||
| - Fixed request module to allow sending strings | ||||
| - Removed CI dependencies | ||||
|  | ||||
| ## 2018-07-19 - 1.1.1 to 1.1.11 - Various Fixes and Improvements | ||||
|  | ||||
| Improvements and fixes across various components | ||||
|  | ||||
| - Added formData capability | ||||
| @@ -107,11 +251,13 @@ Improvements and fixes across various components | ||||
| - Updated request ending method | ||||
|  | ||||
| ## 2018-06-19 - 1.0.14 - Structural Fix | ||||
|  | ||||
| Resolved conflicts with file extensions | ||||
|  | ||||
| - Changed `.json.ts` to `.jsonrest.ts` to avoid conflicts | ||||
|  | ||||
| ## 2018-06-13 - 1.0.8 to 1.0.10 - Core Updates | ||||
|  | ||||
| Ensured binary handling compliance | ||||
|  | ||||
| - Enhanced core to uphold latest standards | ||||
| @@ -119,9 +265,9 @@ Ensured binary handling compliance | ||||
| - Fix for handling and returning binary responses | ||||
|  | ||||
| ## 2017-06-09 - 1.0.4 to 1.0.6 - Infrastructure and Type Improvements | ||||
|  | ||||
| Types and infrastructure updates | ||||
|  | ||||
| - Improved types | ||||
| - Removed need for content type on post requests | ||||
| - Updated for new infrastructure | ||||
|  | ||||
|   | ||||
							
								
								
									
										17
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								package.json
									
									
									
									
									
								
							| @@ -1,16 +1,16 @@ | ||||
| { | ||||
|   "name": "@push.rocks/smartrequest", | ||||
|   "version": "4.0.0", | ||||
|   "version": "4.3.6", | ||||
|   "private": false, | ||||
|   "description": "A module for modern HTTP/HTTPS requests with support for form data, file uploads, JSON, binary data, streams, and more.", | ||||
|   "exports": { | ||||
|     ".": "./dist_ts_web/index.js", | ||||
|     ".": "./dist_ts/index.js", | ||||
|     "./core_node": "./dist_ts/core_node/index.js", | ||||
|     "./core_fetch": "./dist_ts/core_fetch/index.js" | ||||
|   }, | ||||
|   "type": "module", | ||||
|   "scripts": { | ||||
|     "test": "(tstest test/ --verbose)", | ||||
|     "test": "(tstest test/ --verbose --timeout 120)", | ||||
|     "build": "(tsbuild --web)", | ||||
|     "buildDocs": "tsdoc" | ||||
|   }, | ||||
| @@ -35,9 +35,9 @@ | ||||
|   "author": "Task Venture Capital GmbH", | ||||
|   "license": "MIT", | ||||
|   "bugs": { | ||||
|     "url": "https://gitlab.com/push.rocks/smartrequest/issues" | ||||
|     "url": "https://code.foss.global/push.rocks/smartrequest/issues" | ||||
|   }, | ||||
|   "homepage": "https://code.foss.global/push.rocks/smartrequest", | ||||
|   "homepage": "https://code.foss.global/push.rocks/smartrequest#readme", | ||||
|   "dependencies": { | ||||
|     "@push.rocks/smartenv": "^5.0.13", | ||||
|     "@push.rocks/smartpath": "^6.0.0", | ||||
| @@ -49,7 +49,7 @@ | ||||
|   "devDependencies": { | ||||
|     "@git.zone/tsbuild": "^2.6.4", | ||||
|     "@git.zone/tsrun": "^1.3.3", | ||||
|     "@git.zone/tstest": "^2.3.2", | ||||
|     "@git.zone/tstest": "^2.3.4", | ||||
|     "@types/node": "^22.9.0" | ||||
|   }, | ||||
|   "files": [ | ||||
| @@ -67,5 +67,8 @@ | ||||
|   "browserslist": [ | ||||
|     "last 1 chrome versions" | ||||
|   ], | ||||
|   "packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6" | ||||
|   "packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6", | ||||
|   "pnpm": { | ||||
|     "overrides": {} | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										2105
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2105
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,6 +1,7 @@ | ||||
| # SmartRequest Architecture Hints | ||||
|  | ||||
| ## Core Features | ||||
|  | ||||
| - supports http | ||||
| - supports https | ||||
| - supports unix socks | ||||
| @@ -15,6 +16,7 @@ | ||||
| - used in modules like @push.rocks/smartproxy and @api.global/typedrequest | ||||
|  | ||||
| ## Architecture Overview (as of v3.0.0 major refactoring) | ||||
|  | ||||
| - The project now has a multi-layer architecture with platform abstraction | ||||
| - Base layer (ts/core_base/) contains abstract classes and unified types | ||||
| - Node.js implementation (ts/core_node/) uses native http/https modules | ||||
| @@ -26,6 +28,7 @@ | ||||
| ## Key Components | ||||
|  | ||||
| ### Core Base Module (ts/core_base/) | ||||
|  | ||||
| - `request.ts`: Abstract CoreRequest class defining the request interface | ||||
| - `response.ts`: Abstract CoreResponse class with fetch-like API | ||||
|   - Defines `stream()` method that always returns web-style ReadableStream | ||||
| @@ -35,6 +38,7 @@ | ||||
|   - Implementations handle unsupported options by throwing errors | ||||
|  | ||||
| ### Core Node Module (ts/core_node/) | ||||
|  | ||||
| - `request.ts`: Node.js implementation using http/https modules | ||||
|   - Supports unix socket connections and keep-alive agents | ||||
|   - Converts Node.js specific options from unified interface | ||||
| @@ -44,6 +48,7 @@ | ||||
|   - Methods like `json()`, `text()`, `arrayBuffer()` handle parsing | ||||
|  | ||||
| ### Core Fetch Module (ts/core_fetch/) | ||||
|  | ||||
| - `request.ts`: Fetch API implementation for browsers | ||||
|   - Throws errors for Node.js specific options (agent, socketPath) | ||||
|   - Native support for CORS, credentials, and other browser features | ||||
| @@ -52,27 +57,32 @@ | ||||
|   - `streamNode()` throws error explaining it's not available in browser | ||||
|  | ||||
| ### Core Module (ts/core/) | ||||
|  | ||||
| - Dynamically loads appropriate implementation based on environment | ||||
| - Uses @push.rocks/smartenv for environment detection | ||||
| - Exports unified types from core_base | ||||
|  | ||||
| ### Client API (ts/client/) | ||||
|  | ||||
| - SmartRequest: Fluent API with method chaining | ||||
| - Returns CoreResponse objects with fetch-like methods | ||||
| - Supports pagination, retries, timeouts, and various response types | ||||
|  | ||||
| ### Stream Handling | ||||
|  | ||||
| - `stream()` method always returns web-style ReadableStream<Uint8Array> | ||||
| - In Node.js, converts native streams to web streams | ||||
| - `streamNode()` available only in Node.js environment for native streams | ||||
| - Consistent API across platforms while preserving platform-specific capabilities | ||||
|  | ||||
| ### Binary Request Handling | ||||
|  | ||||
| - Binary requests handled through ArrayBuffer API | ||||
| - Response body kept as Buffer/ArrayBuffer without string conversion | ||||
| - No automatic transformations applied to binary data | ||||
|  | ||||
| ## Testing | ||||
|  | ||||
| - Use `pnpm test` to run all tests | ||||
| - Tests use @git.zone/tstest/tapbundle for assertions | ||||
| - Separate test files for Node.js (test.node.ts) and browser (test.browser.ts) | ||||
|   | ||||
							
								
								
									
										301
									
								
								readme.md
									
									
									
									
									
								
							
							
						
						
									
										301
									
								
								readme.md
									
									
									
									
									
								
							| @@ -1,7 +1,9 @@ | ||||
| # @push.rocks/smartrequest | ||||
|  | ||||
| A modern, cross-platform HTTP/HTTPS request library for Node.js and browsers with a unified API, supporting form data, file uploads, JSON, binary data, streams, and unix sockets. | ||||
|  | ||||
| ## Install | ||||
|  | ||||
| ```bash | ||||
| # Using npm | ||||
| npm install @push.rocks/smartrequest --save | ||||
| @@ -23,7 +25,7 @@ yarn add @push.rocks/smartrequest | ||||
| - ⚡ **Keep-Alive Connections** - Efficient connection pooling in Node.js | ||||
| - 🛡️ **TypeScript First** - Full type safety and IntelliSense support | ||||
| - 🎯 **Zero Magic Defaults** - Explicit configuration following fetch API principles | ||||
| - 📡 **Streaming Support** - Handle large files and real-time data | ||||
| - 📡 **Streaming Support** - Stream buffers, files, and custom data without loading into memory | ||||
| - 🔧 **Highly Configurable** - Timeouts, retries, headers, and more | ||||
|  | ||||
| ## Architecture | ||||
| @@ -79,8 +81,8 @@ async function directCoreRequest() { | ||||
|   const request = new CoreRequest('https://api.example.com/data', { | ||||
|     method: 'GET', | ||||
|     headers: { | ||||
|       'Accept': 'application/json' | ||||
|     } | ||||
|       Accept: 'application/json', | ||||
|     }, | ||||
|   }); | ||||
|  | ||||
|   const response = await request.fire(); | ||||
| @@ -100,7 +102,7 @@ async function searchRepositories(query: string, perPage: number = 10) { | ||||
|     .header('Accept', 'application/vnd.github.v3+json') | ||||
|     .query({ | ||||
|       q: query, | ||||
|       per_page: perPage.toString() | ||||
|       per_page: perPage.toString(), | ||||
|     }) | ||||
|     .get(); | ||||
|  | ||||
| @@ -125,6 +127,25 @@ async function fetchWithRetry(url: string) { | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### Setting Request Options | ||||
|  | ||||
| Use the `options()` method to set any request options supported by the underlying implementation: | ||||
|  | ||||
| ```typescript | ||||
| import { SmartRequest } from '@push.rocks/smartrequest'; | ||||
|  | ||||
| // Set various options | ||||
| const response = await SmartRequest.create() | ||||
|   .url('https://api.example.com/data') | ||||
|   .options({ | ||||
|     keepAlive: true, // Enable connection reuse (Node.js) | ||||
|     timeout: 10000, // 10 second timeout | ||||
|     hardDataCuttingTimeout: 15000, // 15 second hard timeout | ||||
|     // Platform-specific options are also supported | ||||
|   }) | ||||
|   .get(); | ||||
| ``` | ||||
|  | ||||
| ### Working with Different Response Types | ||||
|  | ||||
| The API provides a fetch-like interface for handling different response types: | ||||
| @@ -134,18 +155,14 @@ import { SmartRequest } from '@push.rocks/smartrequest'; | ||||
|  | ||||
| // JSON response (default) | ||||
| async function fetchJson(url: string) { | ||||
|   const response = await SmartRequest.create() | ||||
|     .url(url) | ||||
|     .get(); | ||||
|   const response = await SmartRequest.create().url(url).get(); | ||||
|  | ||||
|   return await response.json(); // Parses JSON automatically | ||||
| } | ||||
|  | ||||
| // Text response | ||||
| async function fetchText(url: string) { | ||||
|   const response = await SmartRequest.create() | ||||
|     .url(url) | ||||
|     .get(); | ||||
|   const response = await SmartRequest.create().url(url).get(); | ||||
|  | ||||
|   return await response.text(); // Returns response as string | ||||
| } | ||||
| @@ -163,9 +180,7 @@ async function downloadImage(url: string) { | ||||
|  | ||||
| // Streaming response (Web Streams API) | ||||
| async function streamLargeFile(url: string) { | ||||
|   const response = await SmartRequest.create() | ||||
|     .url(url) | ||||
|     .get(); | ||||
|   const response = await SmartRequest.create().url(url).get(); | ||||
|  | ||||
|   // Get a web-style ReadableStream (works in both Node.js and browsers) | ||||
|   const stream = response.stream(); | ||||
| @@ -187,9 +202,7 @@ async function streamLargeFile(url: string) { | ||||
|  | ||||
| // Node.js specific stream (only in Node.js environment) | ||||
| async function streamWithNodeApi(url: string) { | ||||
|   const response = await SmartRequest.create() | ||||
|     .url(url) | ||||
|     .get(); | ||||
|   const response = await SmartRequest.create().url(url).get(); | ||||
|  | ||||
|   // Only available in Node.js, throws error in browser | ||||
|   const nodeStream = response.streamNode(); | ||||
| @@ -218,6 +231,51 @@ The response object provides these methods: | ||||
|  | ||||
| Each body method can only be called once per response, similar to the fetch API. | ||||
|  | ||||
| ### Important: Always Consume Response Bodies | ||||
|  | ||||
| **You should always consume response bodies, even if you don't need the data.** Unconsumed response bodies can cause: | ||||
|  | ||||
| - Memory leaks as data accumulates in buffers | ||||
| - Socket hanging with keep-alive connections | ||||
| - Connection pool exhaustion | ||||
|  | ||||
| ```typescript | ||||
| // ❌ BAD - Response body is not consumed | ||||
| const response = await SmartRequest.create() | ||||
|   .url('https://api.example.com/status') | ||||
|   .get(); | ||||
|  | ||||
| if (response.ok) { | ||||
|   console.log('Success!'); | ||||
| } | ||||
| // Socket may hang here! | ||||
|  | ||||
| // ✅ GOOD - Response body is consumed | ||||
| const response = await SmartRequest.create() | ||||
|   .url('https://api.example.com/status') | ||||
|   .get(); | ||||
|  | ||||
| if (response.ok) { | ||||
|   console.log('Success!'); | ||||
| } | ||||
| await response.text(); // Consume the body even if not needed | ||||
| ``` | ||||
|  | ||||
| In Node.js, SmartRequest automatically drains unconsumed responses to prevent socket hanging, but it's still best practice to explicitly consume response bodies. When auto-drain occurs, you'll see a console log: `Auto-draining unconsumed response body for [URL] (status: [STATUS])`. | ||||
|  | ||||
| You can disable auto-drain if needed: | ||||
|  | ||||
| ```typescript | ||||
| // Disable auto-drain (not recommended unless you have specific requirements) | ||||
| const response = await SmartRequest.create() | ||||
|   .url('https://api.example.com/data') | ||||
|   .autoDrain(false) // Disable auto-drain | ||||
|   .get(); | ||||
|  | ||||
| // Now you MUST consume the body or the socket will hang | ||||
| await response.text(); | ||||
| ``` | ||||
|  | ||||
| ## Advanced Features | ||||
|  | ||||
| ### Form Data with File Uploads | ||||
| @@ -226,12 +284,14 @@ Each body method can only be called once per response, similar to the fetch API. | ||||
| import { SmartRequest } from '@push.rocks/smartrequest'; | ||||
| import * as fs from 'fs'; | ||||
|  | ||||
| async function uploadMultipleFiles(files: Array<{name: string, path: string}>) { | ||||
|   const formFields = files.map(file => ({ | ||||
| async function uploadMultipleFiles( | ||||
|   files: Array<{ name: string; path: string }>, | ||||
| ) { | ||||
|   const formFields = files.map((file) => ({ | ||||
|     name: 'files', | ||||
|     value: fs.readFileSync(file.path), | ||||
|     filename: file.name, | ||||
|     contentType: 'application/octet-stream' | ||||
|     contentType: 'application/octet-stream', | ||||
|   })); | ||||
|  | ||||
|   const response = await SmartRequest.create() | ||||
| @@ -243,6 +303,103 @@ async function uploadMultipleFiles(files: Array<{name: string, path: string}>) { | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### Streaming Request Bodies | ||||
|  | ||||
| SmartRequest provides multiple ways to stream data in requests, making it easy to upload large files or send real-time data without loading everything into memory: | ||||
|  | ||||
| ```typescript | ||||
| import { SmartRequest } from '@push.rocks/smartrequest'; | ||||
| import * as fs from 'fs'; | ||||
| import { Readable } from 'stream'; | ||||
|  | ||||
| // Stream a Buffer directly | ||||
| async function uploadBuffer() { | ||||
|   const buffer = Buffer.from('Hello, World!'); | ||||
|    | ||||
|   const response = await SmartRequest.create() | ||||
|     .url('https://api.example.com/upload') | ||||
|     .buffer(buffer, 'text/plain') | ||||
|     .post(); | ||||
|    | ||||
|   return await response.json(); | ||||
| } | ||||
|  | ||||
| // Stream a file using Node.js streams | ||||
| async function uploadLargeFile(filePath: string) { | ||||
|   const fileStream = fs.createReadStream(filePath); | ||||
|    | ||||
|   const response = await SmartRequest.create() | ||||
|     .url('https://api.example.com/upload') | ||||
|     .stream(fileStream, 'application/octet-stream') | ||||
|     .post(); | ||||
|    | ||||
|   return await response.json(); | ||||
| } | ||||
|  | ||||
| // Stream data from any readable source | ||||
| async function streamData(dataSource: Readable) { | ||||
|   const response = await SmartRequest.create() | ||||
|     .url('https://api.example.com/stream') | ||||
|     .stream(dataSource) | ||||
|     .post(); | ||||
|    | ||||
|   return await response.json(); | ||||
| } | ||||
|  | ||||
| // Advanced: Full control over request streaming (Node.js only) | ||||
| async function customStreaming() { | ||||
|   const response = await SmartRequest.create() | ||||
|     .url('https://api.example.com/stream') | ||||
|     .raw((request) => { | ||||
|       // Custom streaming logic - you have full control | ||||
|       request.write('chunk1'); | ||||
|       request.write('chunk2'); | ||||
|        | ||||
|       // Stream from another source | ||||
|       someReadableStream.pipe(request); | ||||
|     }) | ||||
|     .post(); | ||||
|    | ||||
|   return await response.json(); | ||||
| } | ||||
|  | ||||
| // Send Uint8Array (works in both Node.js and browser) | ||||
| async function uploadBinaryData() { | ||||
|   const data = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" | ||||
|    | ||||
|   const response = await SmartRequest.create() | ||||
|     .url('https://api.example.com/binary') | ||||
|     .buffer(data, 'application/octet-stream') | ||||
|     .post(); | ||||
|    | ||||
|   return await response.json(); | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### Streaming Methods | ||||
|  | ||||
| - **`.buffer(data, contentType?)`** - Stream a Buffer or Uint8Array directly | ||||
|   - `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 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 | ||||
|   - `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 | ||||
| - Streaming real-time data to servers | ||||
| - Proxying data between services | ||||
| - Implementing chunked transfer encoding | ||||
|  | ||||
| ### Unix Socket Support (Node.js only) | ||||
|  | ||||
| ```typescript | ||||
| @@ -274,7 +431,7 @@ async function fetchAllUsers() { | ||||
|       limitParam: 'limit', | ||||
|       startPage: 1, | ||||
|       pageSize: 20, | ||||
|       totalPath: 'meta.total' | ||||
|       totalPath: 'meta.total', | ||||
|     }); | ||||
|  | ||||
|   // Get first page with pagination info | ||||
| @@ -300,7 +457,7 @@ async function fetchAllPosts() { | ||||
|     .withCursorPagination({ | ||||
|       cursorParam: 'cursor', | ||||
|       cursorPath: 'meta.nextCursor', | ||||
|       hasMorePath: 'meta.hasMore' | ||||
|       hasMorePath: 'meta.hasMore', | ||||
|     }) | ||||
|     .getAllPages(); | ||||
|  | ||||
| @@ -326,20 +483,88 @@ import { SmartRequest } from '@push.rocks/smartrequest'; | ||||
|  | ||||
| // Enable keep-alive for better performance with multiple requests | ||||
| async function performMultipleRequests() { | ||||
|   const client = SmartRequest.create() | ||||
|     .header('Connection', 'keep-alive'); | ||||
|   // Note: keepAlive is NOT enabled by default | ||||
|   const response1 = await SmartRequest.create() | ||||
|     .url('https://api.example.com/endpoint1') | ||||
|     .options({ keepAlive: true }) | ||||
|     .get(); | ||||
|  | ||||
|   // Requests will reuse the same connection in Node.js | ||||
|   const results = await Promise.all([ | ||||
|     client.url('https://api.example.com/endpoint1').get(), | ||||
|     client.url('https://api.example.com/endpoint2').get(), | ||||
|     client.url('https://api.example.com/endpoint3').get() | ||||
|   ]); | ||||
|   const response2 = await SmartRequest.create() | ||||
|     .url('https://api.example.com/endpoint2') | ||||
|     .options({ keepAlive: true }) | ||||
|     .get(); | ||||
|  | ||||
|   return Promise.all(results.map(r => r.json())); | ||||
|   // Connections are pooled and reused when keepAlive is enabled | ||||
|   return [await response1.json(), await response2.json()]; | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### Rate Limiting (429 Too Many Requests) Handling | ||||
|  | ||||
| The library includes built-in support for handling HTTP 429 (Too Many Requests) responses with intelligent backoff: | ||||
|  | ||||
| ```typescript | ||||
| import { SmartRequest } from '@push.rocks/smartrequest'; | ||||
|  | ||||
| // Simple usage - handle 429 with defaults | ||||
| async function fetchWithRateLimitHandling() { | ||||
|   const response = await SmartRequest.create() | ||||
|     .url('https://api.example.com/data') | ||||
|     .handle429Backoff() // Automatically retry on 429 | ||||
|     .get(); | ||||
|  | ||||
|   return await response.json(); | ||||
| } | ||||
|  | ||||
| // Advanced usage with custom configuration | ||||
| async function fetchWithCustomRateLimiting() { | ||||
|   const response = await SmartRequest.create() | ||||
|     .url('https://api.example.com/data') | ||||
|     .handle429Backoff({ | ||||
|       maxRetries: 5, // Try up to 5 times (default: 3) | ||||
|       respectRetryAfter: true, // Honor Retry-After header (default: true) | ||||
|       maxWaitTime: 30000, // Max 30 seconds wait (default: 60000) | ||||
|       fallbackDelay: 2000, // 2s initial delay if no Retry-After (default: 1000) | ||||
|       backoffFactor: 2, // Exponential backoff multiplier (default: 2) | ||||
|       onRateLimit: (attempt, waitTime) => { | ||||
|         console.log(`Rate limited. Attempt ${attempt}, waiting ${waitTime}ms`); | ||||
|       }, | ||||
|     }) | ||||
|     .get(); | ||||
|  | ||||
|   return await response.json(); | ||||
| } | ||||
|  | ||||
| // Example: API client with rate limit handling | ||||
| class RateLimitedApiClient { | ||||
|   private async request(path: string) { | ||||
|     return SmartRequest.create() | ||||
|       .url(`https://api.example.com${path}`) | ||||
|       .handle429Backoff({ | ||||
|         maxRetries: 3, | ||||
|         onRateLimit: (attempt, waitTime) => { | ||||
|           console.log( | ||||
|             `API rate limit hit. Waiting ${waitTime}ms before retry ${attempt}`, | ||||
|           ); | ||||
|         }, | ||||
|       }); | ||||
|   } | ||||
|  | ||||
|   async fetchData(id: string) { | ||||
|     const response = await this.request(`/data/${id}`).get(); | ||||
|     return response.json(); | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| The rate limiting feature: | ||||
|  | ||||
| - Automatically detects 429 responses and retries with backoff | ||||
| - Respects the `Retry-After` header when present (supports both seconds and HTTP date formats) | ||||
| - Uses exponential backoff when no `Retry-After` header is provided | ||||
| - Allows custom callbacks for monitoring rate limit events | ||||
| - Caps maximum wait time to prevent excessive delays | ||||
|  | ||||
| ## Platform-Specific Features | ||||
|  | ||||
| ### Browser-Specific Options | ||||
| @@ -349,11 +574,11 @@ When running in a browser, you can use browser-specific fetch options: | ||||
| ```typescript | ||||
| const response = await SmartRequest.create() | ||||
|   .url('https://api.example.com/data') | ||||
|   .option({ | ||||
|   .options({ | ||||
|     credentials: 'include', // Include cookies | ||||
|     mode: 'cors',          // CORS mode | ||||
|     cache: 'no-cache',     // Cache mode | ||||
|     referrerPolicy: 'no-referrer' | ||||
|     mode: 'cors', // CORS mode | ||||
|     cache: 'no-cache', // Cache mode | ||||
|     referrerPolicy: 'no-referrer', | ||||
|   }) | ||||
|   .get(); | ||||
| ``` | ||||
| @@ -367,9 +592,9 @@ import { Agent } from 'https'; | ||||
|  | ||||
| const response = await SmartRequest.create() | ||||
|   .url('https://api.example.com/data') | ||||
|   .option({ | ||||
|   .options({ | ||||
|     agent: new Agent({ keepAlive: true }), // Custom agent | ||||
|     socketPath: '/var/run/api.sock',       // Unix socket | ||||
|     socketPath: '/var/run/api.sock', // Unix socket | ||||
|   }) | ||||
|   .get(); | ||||
| ``` | ||||
| @@ -409,9 +634,7 @@ class BlogApiClient { | ||||
|   } | ||||
|  | ||||
|   async createPost(post: Omit<Post, 'id'>): Promise<Post> { | ||||
|     const response = await this.request('/posts') | ||||
|       .json(post) | ||||
|       .post(); | ||||
|     const response = await this.request('/posts').json(post).post(); | ||||
|     return response.json<Post>(); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -6,7 +6,9 @@ import { CoreRequest, CoreResponse } from '../ts/core/index.js'; | ||||
| import type { ICoreRequestOptions } from '../ts/core_base/types.js'; | ||||
|  | ||||
| tap.test('browser: should request a JSON document over https', async () => { | ||||
|   const request = new CoreRequest('https://jsonplaceholder.typicode.com/posts/1'); | ||||
|   const request = new CoreRequest( | ||||
|     'https://jsonplaceholder.typicode.com/posts/1', | ||||
|   ); | ||||
|   const response = await request.fire(); | ||||
|  | ||||
|   expect(response).not.toBeNull(); | ||||
| @@ -22,11 +24,14 @@ tap.test('browser: should request a JSON document over https', async () => { | ||||
| tap.test('browser: should handle CORS requests', async () => { | ||||
|   const options: ICoreRequestOptions = { | ||||
|     headers: { | ||||
|       'Accept': 'application/vnd.github.v3+json' | ||||
|     } | ||||
|       Accept: 'application/vnd.github.v3+json', | ||||
|     }, | ||||
|   }; | ||||
|  | ||||
|   const request = new CoreRequest('https://api.github.com/users/github', options); | ||||
|   const request = new CoreRequest( | ||||
|     'https://api.github.com/users/github', | ||||
|     options, | ||||
|   ); | ||||
|   const response = await request.fire(); | ||||
|  | ||||
|   expect(response).not.toBeNull(); | ||||
| @@ -41,15 +46,20 @@ tap.test('browser: should handle request timeouts', async () => { | ||||
|   let timedOut = false; | ||||
|  | ||||
|   const options: ICoreRequestOptions = { | ||||
|     timeout: 1000 | ||||
|     timeout: 1, // Extremely short timeout to guarantee failure | ||||
|   }; | ||||
|  | ||||
|   try { | ||||
|     const request = new CoreRequest('https://httpbin.org/delay/10', options); | ||||
|     // Use a URL that will definitely take longer than 1ms | ||||
|     const request = new CoreRequest( | ||||
|       'https://jsonplaceholder.typicode.com/posts/1', | ||||
|       options, | ||||
|     ); | ||||
|     await request.fire(); | ||||
|   } catch (error) { | ||||
|     timedOut = true; | ||||
|     expect(error.message).toContain('timed out'); | ||||
|     // Accept any error since different browsers handle timeouts differently | ||||
|     expect(error).toBeDefined(); | ||||
|   } | ||||
|  | ||||
|   expect(timedOut).toEqual(true); | ||||
| @@ -59,15 +69,18 @@ tap.test('browser: should handle POST requests with JSON', async () => { | ||||
|   const testData = { | ||||
|     title: 'foo', | ||||
|     body: 'bar', | ||||
|     userId: 1 | ||||
|     userId: 1, | ||||
|   }; | ||||
|  | ||||
|   const options: ICoreRequestOptions = { | ||||
|     method: 'POST', | ||||
|     requestBody: testData | ||||
|     requestBody: testData, | ||||
|   }; | ||||
|  | ||||
|   const request = new CoreRequest('https://jsonplaceholder.typicode.com/posts', options); | ||||
|   const request = new CoreRequest( | ||||
|     'https://jsonplaceholder.typicode.com/posts', | ||||
|     options, | ||||
|   ); | ||||
|   const response = await request.fire(); | ||||
|  | ||||
|   expect(response.status).toEqual(201); | ||||
| @@ -82,21 +95,25 @@ tap.test('browser: should handle POST requests with JSON', async () => { | ||||
| tap.test('browser: should handle query parameters', async () => { | ||||
|   const options: ICoreRequestOptions = { | ||||
|     queryParams: { | ||||
|       foo: 'bar', | ||||
|       baz: 'qux' | ||||
|     } | ||||
|       userId: '2', | ||||
|     }, | ||||
|   }; | ||||
|  | ||||
|   const request = new CoreRequest('https://httpbin.org/get', options); | ||||
|   const request = new CoreRequest( | ||||
|     'https://jsonplaceholder.typicode.com/posts', | ||||
|     options, | ||||
|   ); | ||||
|   const response = await request.fire(); | ||||
|  | ||||
|   expect(response.status).toEqual(200); | ||||
|  | ||||
|   const data = await response.json(); | ||||
|   expect(data.args).toHaveProperty('foo'); | ||||
|   expect(data.args.foo).toEqual('bar'); | ||||
|   expect(data.args).toHaveProperty('baz'); | ||||
|   expect(data.args.baz).toEqual('qux'); | ||||
|   expect(Array.isArray(data)).toBeTrue(); | ||||
|   // Verify we got posts filtered by userId 2 | ||||
|   if (data.length > 0) { | ||||
|     expect(data[0]).toHaveProperty('userId'); | ||||
|     expect(data[0].userId).toEqual(2); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| export default tap.start(); | ||||
| @@ -25,16 +25,16 @@ tap.test('client: should request a JSON document over https', async () => { | ||||
| }); | ||||
|  | ||||
| tap.test('client: should post a JSON document over http', async () => { | ||||
|   const testData = { text: 'example_text' }; | ||||
|   const testData = { title: 'example_text', body: 'test body', userId: 1 }; | ||||
|   const response = await SmartRequest.create() | ||||
|     .url('https://httpbin.org/post') | ||||
|     .url('https://jsonplaceholder.typicode.com/posts') | ||||
|     .json(testData) | ||||
|     .post(); | ||||
|  | ||||
|   const body = await response.json(); | ||||
|   expect(body).toHaveProperty('json'); | ||||
|   expect(body.json).toHaveProperty('text'); | ||||
|   expect(body.json.text).toEqual('example_text'); | ||||
|   expect(body).toHaveProperty('title'); | ||||
|   expect(body.title).toEqual('example_text'); | ||||
|   expect(body).toHaveProperty('id'); // jsonplaceholder returns an id for created posts | ||||
| }); | ||||
|  | ||||
| tap.test('client: should set headers correctly', async () => { | ||||
| @@ -42,54 +42,180 @@ tap.test('client: should set headers correctly', async () => { | ||||
|   const headerValue = 'test-value'; | ||||
|  | ||||
|   const response = await SmartRequest.create() | ||||
|     .url('https://httpbin.org/headers') | ||||
|     .url('https://echo.zuplo.io/') | ||||
|     .header(customHeader, headerValue) | ||||
|     .get(); | ||||
|  | ||||
|   const body = await response.json(); | ||||
|   expect(body).toHaveProperty('headers'); | ||||
|  | ||||
|   // Check if the header exists (case-sensitive) | ||||
|   expect(body.headers).toHaveProperty(customHeader); | ||||
|   expect(body.headers[customHeader]).toEqual(headerValue); | ||||
|   // Check if the header exists (headers might be lowercase) | ||||
|   const headers = body.headers; | ||||
|   const headerFound = | ||||
|     headers[customHeader] || | ||||
|     headers[customHeader.toLowerCase()] || | ||||
|     headers['x-custom-header']; | ||||
|   expect(headerFound).toEqual(headerValue); | ||||
| }); | ||||
|  | ||||
| tap.test('client: should handle query parameters', async () => { | ||||
|   const params = { param1: 'value1', param2: 'value2' }; | ||||
|   const params = { userId: '1' }; | ||||
|  | ||||
|   const response = await SmartRequest.create() | ||||
|     .url('https://httpbin.org/get') | ||||
|     .url('https://jsonplaceholder.typicode.com/posts') | ||||
|     .query(params) | ||||
|     .get(); | ||||
|  | ||||
|   const body = await response.json(); | ||||
|   expect(body).toHaveProperty('args'); | ||||
|   expect(body.args).toHaveProperty('param1'); | ||||
|   expect(body.args.param1).toEqual('value1'); | ||||
|   expect(body.args).toHaveProperty('param2'); | ||||
|   expect(body.args.param2).toEqual('value2'); | ||||
|   expect(Array.isArray(body)).toBeTrue(); | ||||
|   // Check that we got posts for userId 1 | ||||
|   if (body.length > 0) { | ||||
|     expect(body[0]).toHaveProperty('userId'); | ||||
|     expect(body[0].userId).toEqual(1); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| tap.test('client: should handle timeout configuration', async () => { | ||||
|   // This test just verifies that the timeout method doesn't throw | ||||
|   const client = SmartRequest.create() | ||||
|     .url('https://httpbin.org/get') | ||||
|     .url('https://jsonplaceholder.typicode.com/posts/1') | ||||
|     .timeout(5000); | ||||
|  | ||||
|   const response = await client.get(); | ||||
|   expect(response).toHaveProperty('ok'); | ||||
|   expect(response.ok).toBeTrue(); | ||||
|  | ||||
|   // Consume the body to prevent socket hanging | ||||
|   await response.text(); | ||||
| }); | ||||
|  | ||||
| tap.test('client: should handle retry configuration', async () => { | ||||
|   // This test just verifies that the retry method doesn't throw | ||||
|   const client = SmartRequest.create() | ||||
|     .url('https://httpbin.org/get') | ||||
|     .url('https://jsonplaceholder.typicode.com/posts/1') | ||||
|     .retry(1); | ||||
|  | ||||
|   const response = await client.get(); | ||||
|   expect(response).toHaveProperty('ok'); | ||||
|   expect(response.ok).toBeTrue(); | ||||
|  | ||||
|   // Consume the body to prevent socket hanging | ||||
|   await response.text(); | ||||
| }); | ||||
|  | ||||
| tap.test( | ||||
|   'client: should support keepAlive option for connection reuse', | ||||
|   async () => { | ||||
|     // Simple test | ||||
|     const response = await SmartRequest.create() | ||||
|       .url('https://jsonplaceholder.typicode.com/posts/1') | ||||
|       .options({ keepAlive: true }) | ||||
|       .get(); | ||||
|  | ||||
|     expect(response.ok).toBeTrue(); | ||||
|     await response.text(); | ||||
|   }, | ||||
| ); | ||||
|  | ||||
| tap.test( | ||||
|   'client: should handle 429 rate limiting with default config', | ||||
|   async () => { | ||||
|     // Test that handle429Backoff can be configured without errors | ||||
|     const client = SmartRequest.create() | ||||
|       .url('https://jsonplaceholder.typicode.com/posts/1') | ||||
|       .handle429Backoff(); | ||||
|  | ||||
|     const response = await client.get(); | ||||
|     expect(response.status).toEqual(200); | ||||
|  | ||||
|     // Consume the body to prevent socket hanging | ||||
|     await response.text(); | ||||
|   }, | ||||
| ); | ||||
|  | ||||
| tap.test('client: should handle 429 with custom config', async () => { | ||||
|   let rateLimitCallbackCalled = false; | ||||
|   let attemptCount = 0; | ||||
|   let waitTimeReceived = 0; | ||||
|  | ||||
|   const client = SmartRequest.create() | ||||
|     .url('https://jsonplaceholder.typicode.com/posts/1') | ||||
|     .handle429Backoff({ | ||||
|       maxRetries: 2, | ||||
|       fallbackDelay: 500, | ||||
|       maxWaitTime: 5000, | ||||
|       onRateLimit: (attempt, waitTime) => { | ||||
|         rateLimitCallbackCalled = true; | ||||
|         attemptCount = attempt; | ||||
|         waitTimeReceived = waitTime; | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|   const response = await client.get(); | ||||
|   expect(response.status).toEqual(200); | ||||
|  | ||||
|   // The callback should not have been called for a 200 response | ||||
|   expect(rateLimitCallbackCalled).toBeFalse(); | ||||
|  | ||||
|   // Consume the body to prevent socket hanging | ||||
|   await response.text(); | ||||
| }); | ||||
|  | ||||
| tap.test( | ||||
|   'client: should respect Retry-After header format (seconds)', | ||||
|   async () => { | ||||
|     // Test the configuration works - actual 429 testing would require a mock server | ||||
|     const client = SmartRequest.create() | ||||
|       .url('https://jsonplaceholder.typicode.com/posts/1') | ||||
|       .handle429Backoff({ | ||||
|         maxRetries: 1, | ||||
|         respectRetryAfter: true, | ||||
|       }); | ||||
|  | ||||
|     const response = await client.get(); | ||||
|     expect(response.ok).toBeTrue(); | ||||
|  | ||||
|     // Consume the body to prevent socket hanging | ||||
|     await response.text(); | ||||
|   }, | ||||
| ); | ||||
|  | ||||
| tap.test( | ||||
|   'client: should handle rate limiting with exponential backoff', | ||||
|   async () => { | ||||
|     // Test exponential backoff configuration | ||||
|     const client = SmartRequest.create() | ||||
|       .url('https://jsonplaceholder.typicode.com/posts/1') | ||||
|       .handle429Backoff({ | ||||
|         maxRetries: 3, | ||||
|         fallbackDelay: 100, | ||||
|         backoffFactor: 2, | ||||
|         maxWaitTime: 1000, | ||||
|       }); | ||||
|  | ||||
|     const response = await client.get(); | ||||
|     expect(response.status).toEqual(200); | ||||
|  | ||||
|     // Consume the body to prevent socket hanging | ||||
|     await response.text(); | ||||
|   }, | ||||
| ); | ||||
|  | ||||
| tap.test( | ||||
|   'client: should not retry non-429 errors with rate limit handler', | ||||
|   async () => { | ||||
|     // Test that 404 errors are not retried by rate limit handler | ||||
|     const client = SmartRequest.create() | ||||
|       .url('https://jsonplaceholder.typicode.com/posts/999999') | ||||
|       .handle429Backoff(); | ||||
|  | ||||
|     const response = await client.get(); | ||||
|     expect(response.status).toEqual(404); | ||||
|     expect(response.ok).toBeFalse(); | ||||
|  | ||||
|     // Consume the body to prevent socket hanging | ||||
|     await response.text(); | ||||
|   }, | ||||
| ); | ||||
|  | ||||
| tap.start(); | ||||
|   | ||||
							
								
								
									
										41
									
								
								test/test.streaming.browser.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								test/test.streaming.browser.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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(); | ||||
							
								
								
									
										74
									
								
								test/test.streaming.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								test/test.streaming.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,74 @@ | ||||
| import { tap, expect } from '@git.zone/tstest/tapbundle'; | ||||
| import * as fs from 'fs'; | ||||
| import { SmartRequest } from '../ts/index.js'; | ||||
|  | ||||
| tap.test('should send a buffer using buffer() method', async () => { | ||||
|   const testBuffer = Buffer.from('Hello, World!'); | ||||
|    | ||||
|   const smartRequest = SmartRequest.create() | ||||
|     .url('https://httpbin.org/post') | ||||
|     .buffer(testBuffer, 'text/plain') | ||||
|     .method('POST'); | ||||
|    | ||||
|   const response = await smartRequest.post(); | ||||
|   const data = await response.json(); | ||||
|    | ||||
|   expect(data).toHaveProperty('data'); | ||||
|   expect(data.data).toEqual('Hello, World!'); | ||||
|   expect(data.headers['Content-Type']).toEqual('text/plain'); | ||||
| }); | ||||
|  | ||||
| tap.test('should send a stream using stream() method', async () => { | ||||
|   // Create a simple readable stream | ||||
|   const { Readable } = await import('stream'); | ||||
|   const testData = 'Stream data test'; | ||||
|   const stream = Readable.from([testData]); | ||||
|    | ||||
|   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'); | ||||
|   expect(data.data).toEqual(testData); | ||||
| }); | ||||
|  | ||||
| tap.test('should handle raw streaming with custom function', async () => { | ||||
|   const testData = 'Custom raw stream data'; | ||||
|    | ||||
|   const smartRequest = SmartRequest.create() | ||||
|     .url('https://httpbin.org/post') | ||||
|     .raw((request) => { | ||||
|       // Custom streaming logic | ||||
|       request.write(testData); | ||||
|       request.end(); | ||||
|     }) | ||||
|     .method('POST'); | ||||
|    | ||||
|   const response = await smartRequest.post(); | ||||
|   const data = await response.json(); | ||||
|    | ||||
|   expect(data).toHaveProperty('data'); | ||||
|   expect(data.data).toEqual(testData); | ||||
| }); | ||||
|  | ||||
| tap.test('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(); | ||||
|    | ||||
|   // Just verify that data was sent | ||||
|   expect(data).toHaveProperty('data'); | ||||
|   expect(data.headers['Content-Type']).toEqual('application/octet-stream'); | ||||
| }); | ||||
|  | ||||
| export default tap.start(); | ||||
							
								
								
									
										27
									
								
								test/test.streamnode.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								test/test.streamnode.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| import { tap, expect } from '@git.zone/tstest/tapbundle'; | ||||
| import { SmartRequest } from '../ts/index.js'; | ||||
|  | ||||
| tap.test('should have streamNode() method available', async () => { | ||||
|   const response = await SmartRequest.create() | ||||
|     .url('https://httpbin.org/get') | ||||
|     .get(); | ||||
|  | ||||
|   // Verify streamNode() method exists | ||||
|   expect(response.streamNode).toBeDefined(); | ||||
|   expect(typeof response.streamNode).toEqual('function'); | ||||
|    | ||||
|   // In Node.js, it should return a stream | ||||
|   const nodeStream = response.streamNode(); | ||||
|   expect(nodeStream).toBeDefined(); | ||||
|    | ||||
|   // Verify it's a Node.js readable stream | ||||
|   expect(typeof nodeStream.pipe).toEqual('function'); | ||||
|   expect(typeof nodeStream.on).toEqual('function'); | ||||
|    | ||||
|   // Consume the stream to avoid hanging | ||||
|   nodeStream.resume(); | ||||
| }); | ||||
|  | ||||
|  | ||||
|  | ||||
| export default tap.start(); | ||||
							
								
								
									
										60
									
								
								test/test.timeout.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								test/test.timeout.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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(); | ||||
| @@ -3,6 +3,6 @@ | ||||
|  */ | ||||
| export const commitinfo = { | ||||
|   name: '@push.rocks/smartrequest', | ||||
|   version: '2.1.0', | ||||
|   version: '4.3.6', | ||||
|   description: 'A module for modern HTTP/HTTPS requests with support for form data, file uploads, JSON, binary data, streams, and more.' | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,10 @@ | ||||
| import { type CoreResponse } from '../../core/index.js'; | ||||
| import type { ICoreResponse } from '../../core_base/types.js'; | ||||
| import { type TPaginationConfig, PaginationStrategy, type TPaginatedResponse } from '../types/pagination.js'; | ||||
| import { | ||||
|   type TPaginationConfig, | ||||
|   PaginationStrategy, | ||||
|   type TPaginatedResponse, | ||||
| } from '../types/pagination.js'; | ||||
|  | ||||
| /** | ||||
|  * Creates a paginated response from a regular response | ||||
| @@ -9,15 +13,17 @@ export async function createPaginatedResponse<T>( | ||||
|   response: ICoreResponse<any>, | ||||
|   paginationConfig: TPaginationConfig, | ||||
|   queryParams: Record<string, string>, | ||||
|   fetchNextPage: (params: Record<string, string>) => Promise<TPaginatedResponse<T>> | ||||
|   fetchNextPage: ( | ||||
|     params: Record<string, string>, | ||||
|   ) => Promise<TPaginatedResponse<T>>, | ||||
| ): Promise<TPaginatedResponse<T>> { | ||||
|   // Parse response body first | ||||
|   const body = await response.json() as any; | ||||
|   const body = (await response.json()) as any; | ||||
|  | ||||
|   // Default to response.body for items if response is JSON | ||||
|   let items: T[] = Array.isArray(body) | ||||
|     ? body | ||||
|     : (body?.items || body?.data || body?.results || []); | ||||
|     : body?.items || body?.data || body?.results || []; | ||||
|  | ||||
|   let hasNextPage = false; | ||||
|   let nextPageParams: Record<string, string> = {}; | ||||
| @@ -26,8 +32,14 @@ export async function createPaginatedResponse<T>( | ||||
|   switch (paginationConfig.strategy) { | ||||
|     case PaginationStrategy.OFFSET: { | ||||
|       const config = paginationConfig; | ||||
|       const currentPage = parseInt(queryParams[config.pageParam || 'page'] || String(config.startPage || 1)); | ||||
|       const limit = parseInt(queryParams[config.limitParam || 'limit'] || String(config.pageSize || 20)); | ||||
|       const currentPage = parseInt( | ||||
|         queryParams[config.pageParam || 'page'] || | ||||
|           String(config.startPage || 1), | ||||
|       ); | ||||
|       const limit = parseInt( | ||||
|         queryParams[config.limitParam || 'limit'] || | ||||
|           String(config.pageSize || 20), | ||||
|       ); | ||||
|       const total = getValueByPath(body, config.totalPath || 'total') || 0; | ||||
|  | ||||
|       hasNextPage = currentPage * limit < total; | ||||
| @@ -35,7 +47,7 @@ export async function createPaginatedResponse<T>( | ||||
|       if (hasNextPage) { | ||||
|         nextPageParams = { | ||||
|           ...queryParams, | ||||
|           [config.pageParam || 'page']: String(currentPage + 1) | ||||
|           [config.pageParam || 'page']: String(currentPage + 1), | ||||
|         }; | ||||
|       } | ||||
|       break; | ||||
| @@ -43,7 +55,10 @@ export async function createPaginatedResponse<T>( | ||||
|  | ||||
|     case PaginationStrategy.CURSOR: { | ||||
|       const config = paginationConfig; | ||||
|       const nextCursor = getValueByPath(body, config.cursorPath || 'nextCursor'); | ||||
|       const nextCursor = getValueByPath( | ||||
|         body, | ||||
|         config.cursorPath || 'nextCursor', | ||||
|       ); | ||||
|       const hasMore = getValueByPath(body, config.hasMorePath || 'hasMore'); | ||||
|  | ||||
|       hasNextPage = !!nextCursor || !!hasMore; | ||||
| @@ -51,7 +66,7 @@ export async function createPaginatedResponse<T>( | ||||
|       if (hasNextPage && nextCursor) { | ||||
|         nextPageParams = { | ||||
|           ...queryParams, | ||||
|           [config.cursorParam || 'cursor']: nextCursor | ||||
|           [config.cursorParam || 'cursor']: nextCursor, | ||||
|         }; | ||||
|       } | ||||
|       break; | ||||
| @@ -60,7 +75,9 @@ export async function createPaginatedResponse<T>( | ||||
|     case PaginationStrategy.LINK_HEADER: { | ||||
|       const linkHeader = response.headers['link'] || ''; | ||||
|       // Handle both string and string[] types for the link header | ||||
|       const headerValue = Array.isArray(linkHeader) ? linkHeader[0] : linkHeader; | ||||
|       const headerValue = Array.isArray(linkHeader) | ||||
|         ? linkHeader[0] | ||||
|         : linkHeader; | ||||
|       const links = parseLinkHeader(headerValue); | ||||
|  | ||||
|       hasNextPage = !!links.next; | ||||
| @@ -100,7 +117,13 @@ export async function createPaginatedResponse<T>( | ||||
|   // Create a function to fetch all remaining pages | ||||
|   const getAllPages = async (): Promise<T[]> => { | ||||
|     const allItems = [...items]; | ||||
|     let currentPage: TPaginatedResponse<T> = { items, hasNextPage, getNextPage, getAllPages, response }; | ||||
|     let currentPage: TPaginatedResponse<T> = { | ||||
|       items, | ||||
|       hasNextPage, | ||||
|       getNextPage, | ||||
|       getAllPages, | ||||
|       response, | ||||
|     }; | ||||
|  | ||||
|     while (currentPage.hasNextPage) { | ||||
|       try { | ||||
| @@ -119,7 +142,7 @@ export async function createPaginatedResponse<T>( | ||||
|     hasNextPage, | ||||
|     getNextPage, | ||||
|     getAllPages, | ||||
|     response | ||||
|     response, | ||||
|   }; | ||||
| } | ||||
|  | ||||
| @@ -166,7 +189,11 @@ export function getValueByPath(obj: any, path?: string): any { | ||||
|   let current = obj; | ||||
|  | ||||
|   for (const key of keys) { | ||||
|     if (current === null || current === undefined || typeof current !== 'object') { | ||||
|     if ( | ||||
|       current === null || | ||||
|       current === undefined || | ||||
|       typeof current !== 'object' | ||||
|     ) { | ||||
|       return undefined; | ||||
|     } | ||||
|     current = current[key]; | ||||
|   | ||||
| @@ -5,7 +5,14 @@ export { SmartRequest } from './smartrequest.js'; | ||||
| export { CoreResponse } from '../core/index.js'; | ||||
|  | ||||
| // Export types | ||||
| export type { HttpMethod, ResponseType, FormField, RetryConfig, TimeoutConfig } from './types/common.js'; | ||||
| export type { | ||||
|   HttpMethod, | ||||
|   ResponseType, | ||||
|   FormField, | ||||
|   RetryConfig, | ||||
|   TimeoutConfig, | ||||
|   RateLimitConfig, | ||||
| } from './types/common.js'; | ||||
| export { | ||||
|   PaginationStrategy, | ||||
|   type TPaginationConfig as PaginationConfig, | ||||
| @@ -13,7 +20,7 @@ export { | ||||
|   type CursorPaginationConfig, | ||||
|   type LinkPaginationConfig, | ||||
|   type CustomPaginationConfig, | ||||
|   type TPaginatedResponse as PaginatedResponse | ||||
|   type TPaginatedResponse as PaginatedResponse, | ||||
| } from './types/pagination.js'; | ||||
|  | ||||
| // Convenience factory functions | ||||
|   | ||||
| @@ -1,6 +1,4 @@ | ||||
| // plugins for client module | ||||
| import FormData from 'form-data'; | ||||
|  | ||||
| export { | ||||
|   FormData as formData | ||||
| }; | ||||
| export { FormData as formData }; | ||||
|   | ||||
| @@ -3,17 +3,49 @@ 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 } from './types/common.js'; | ||||
| import type { | ||||
|   HttpMethod, | ||||
|   ResponseType, | ||||
|   FormField, | ||||
|   RateLimitConfig, | ||||
|   RawStreamFunction, | ||||
| } from './types/common.js'; | ||||
| import { | ||||
|   type TPaginationConfig, | ||||
|   PaginationStrategy, | ||||
|   type OffsetPaginationConfig, | ||||
|   type CursorPaginationConfig, | ||||
|   type CustomPaginationConfig, | ||||
|   type TPaginatedResponse | ||||
|   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 | ||||
|  */ | ||||
| @@ -23,6 +55,7 @@ export class SmartRequest<T = any> { | ||||
|   private _retries: number = 0; | ||||
|   private _queryParams: Record<string, string> = {}; | ||||
|   private _paginationConfig?: TPaginationConfig; | ||||
|   private _rateLimitConfig?: RateLimitConfig; | ||||
|  | ||||
|   /** | ||||
|    * Create a new SmartRequest instance | ||||
| @@ -69,7 +102,7 @@ export class SmartRequest<T = any> { | ||||
|       if (Buffer.isBuffer(item.value)) { | ||||
|         form.append(item.name, item.value, { | ||||
|           filename: item.filename || 'file', | ||||
|           contentType: item.contentType || 'application/octet-stream' | ||||
|           contentType: item.contentType || 'application/octet-stream', | ||||
|         }); | ||||
|       } else { | ||||
|         form.append(item.name, item.value); | ||||
| @@ -82,13 +115,63 @@ export class SmartRequest<T = any> { | ||||
|  | ||||
|     this._options.headers = { | ||||
|       ...this._options.headers, | ||||
|       ...form.getHeaders() | ||||
|       ...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<Uint8Array>, 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 | ||||
|    */ | ||||
| @@ -106,6 +189,21 @@ export class SmartRequest<T = any> { | ||||
|     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 | ||||
|    */ | ||||
| @@ -115,7 +213,7 @@ export class SmartRequest<T = any> { | ||||
|     } | ||||
|     this._options.headers = { | ||||
|       ...this._options.headers, | ||||
|       ...headers | ||||
|       ...headers, | ||||
|     }; | ||||
|     return this; | ||||
|   } | ||||
| @@ -137,21 +235,41 @@ export class SmartRequest<T = any> { | ||||
|   query(params: Record<string, string>): this { | ||||
|     this._queryParams = { | ||||
|       ...this._queryParams, | ||||
|       ...params | ||||
|       ...params, | ||||
|     }; | ||||
|     return this; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Set additional request options | ||||
|    */ | ||||
|   options(options: Partial<ICoreRequestOptions>): 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<ResponseType, string> = { | ||||
|       'json': 'application/json', | ||||
|       'text': 'text/plain', | ||||
|       'binary': 'application/octet-stream', | ||||
|       'stream': '*/*' | ||||
|       json: 'application/json', | ||||
|       text: 'text/plain', | ||||
|       binary: 'application/octet-stream', | ||||
|       stream: '*/*', | ||||
|     }; | ||||
|  | ||||
|     return this.header('Accept', acceptHeaders[type]); | ||||
| @@ -168,20 +286,26 @@ export class SmartRequest<T = any> { | ||||
|   /** | ||||
|    * Configure offset-based pagination (page & limit) | ||||
|    */ | ||||
|   withOffsetPagination(config: Omit<OffsetPaginationConfig, 'strategy'> = {}): this { | ||||
|   withOffsetPagination( | ||||
|     config: Omit<OffsetPaginationConfig, 'strategy'> = {}, | ||||
|   ): 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' | ||||
|       totalPath: config.totalPath || 'total', | ||||
|     }; | ||||
|  | ||||
|     // Add initial pagination parameters | ||||
|     this.query({ | ||||
|       [this._paginationConfig.pageParam]: String(this._paginationConfig.startPage), | ||||
|       [this._paginationConfig.limitParam]: String(this._paginationConfig.pageSize) | ||||
|       [this._paginationConfig.pageParam]: String( | ||||
|         this._paginationConfig.startPage, | ||||
|       ), | ||||
|       [this._paginationConfig.limitParam]: String( | ||||
|         this._paginationConfig.pageSize, | ||||
|       ), | ||||
|     }); | ||||
|  | ||||
|     return this; | ||||
| @@ -190,12 +314,14 @@ export class SmartRequest<T = any> { | ||||
|   /** | ||||
|    * Configure cursor-based pagination | ||||
|    */ | ||||
|   withCursorPagination(config: Omit<CursorPaginationConfig, 'strategy'> = {}): this { | ||||
|   withCursorPagination( | ||||
|     config: Omit<CursorPaginationConfig, 'strategy'> = {}, | ||||
|   ): this { | ||||
|     this._paginationConfig = { | ||||
|       strategy: PaginationStrategy.CURSOR, | ||||
|       cursorParam: config.cursorParam || 'cursor', | ||||
|       cursorPath: config.cursorPath || 'nextCursor', | ||||
|       hasMorePath: config.hasMorePath || 'hasMore' | ||||
|       hasMorePath: config.hasMorePath || 'hasMore', | ||||
|     }; | ||||
|     return this; | ||||
|   } | ||||
| @@ -205,7 +331,7 @@ export class SmartRequest<T = any> { | ||||
|    */ | ||||
|   withLinkPagination(): this { | ||||
|     this._paginationConfig = { | ||||
|       strategy: PaginationStrategy.LINK_HEADER | ||||
|       strategy: PaginationStrategy.LINK_HEADER, | ||||
|     }; | ||||
|     return this; | ||||
|   } | ||||
| @@ -217,7 +343,7 @@ export class SmartRequest<T = any> { | ||||
|     this._paginationConfig = { | ||||
|       strategy: PaginationStrategy.CUSTOM, | ||||
|       hasNextPage: config.hasNextPage, | ||||
|       getNextPageParams: config.getNextPageParams | ||||
|       getNextPageParams: config.getNextPageParams, | ||||
|     }; | ||||
|     return this; | ||||
|   } | ||||
| @@ -262,7 +388,9 @@ export class SmartRequest<T = any> { | ||||
|    */ | ||||
|   async getPaginated<ItemType = T>(): Promise<TPaginatedResponse<ItemType>> { | ||||
|     if (!this._paginationConfig) { | ||||
|       throw new Error('Pagination not configured. Call one of the pagination methods first.'); | ||||
|       throw new Error( | ||||
|         'Pagination not configured. Call one of the pagination methods first.', | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     // Default to GET if no method specified | ||||
| @@ -283,7 +411,7 @@ export class SmartRequest<T = any> { | ||||
|         nextClient._queryParams = nextPageParams; | ||||
|  | ||||
|         return nextClient.getPaginated<ItemType>(); | ||||
|       } | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @@ -305,14 +433,74 @@ export class SmartRequest<T = any> { | ||||
|  | ||||
|     this._options.queryParams = this._queryParams; | ||||
|  | ||||
|     // Handle retry logic | ||||
|     // Track rate limit attempts separately | ||||
|     let rateLimitAttempt = 0; | ||||
|     let lastError: Error; | ||||
|  | ||||
|     // Main retry loop | ||||
|     for (let attempt = 0; attempt <= this._retries; attempt++) { | ||||
|       try { | ||||
|         const request = new CoreRequest(this._url, this._options as any); | ||||
|         const response = await request.fire(); | ||||
|         return response as ICoreResponse<R>; | ||||
|         // 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<R>; | ||||
|  | ||||
|         // 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; | ||||
|  | ||||
| @@ -322,7 +510,7 @@ export class SmartRequest<T = any> { | ||||
|         } | ||||
|  | ||||
|         // Otherwise, wait before retrying | ||||
|         await new Promise(resolve => setTimeout(resolve, 1000)); | ||||
|         await new Promise((resolve) => setTimeout(resolve, 1000)); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,14 @@ | ||||
| /** | ||||
|  * HTTP Methods supported by the client | ||||
|  */ | ||||
| export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS'; | ||||
| export type HttpMethod = | ||||
|   | 'GET' | ||||
|   | 'POST' | ||||
|   | 'PUT' | ||||
|   | 'DELETE' | ||||
|   | 'PATCH' | ||||
|   | 'HEAD' | ||||
|   | 'OPTIONS'; | ||||
|  | ||||
| /** | ||||
|  * Response types supported by the client | ||||
| @@ -30,11 +37,11 @@ export interface UrlEncodedField { | ||||
|  * Retry configuration | ||||
|  */ | ||||
| export interface RetryConfig { | ||||
|   attempts: number;         // Number of retry attempts | ||||
|   initialDelay?: number;    // Initial delay in ms | ||||
|   maxDelay?: number;        // Maximum delay in ms | ||||
|   factor?: number;          // Backoff factor | ||||
|   statusCodes?: number[];   // Status codes to retry on | ||||
|   attempts: number; // Number of retry attempts | ||||
|   initialDelay?: number; // Initial delay in ms | ||||
|   maxDelay?: number; // Maximum delay in ms | ||||
|   factor?: number; // Backoff factor | ||||
|   statusCodes?: number[]; // Status codes to retry on | ||||
|   shouldRetry?: (error: Error, attemptCount: number) => boolean; | ||||
| } | ||||
|  | ||||
| @@ -42,8 +49,26 @@ export interface RetryConfig { | ||||
|  * Timeout configuration | ||||
|  */ | ||||
| export interface TimeoutConfig { | ||||
|   request?: number;         // Overall request timeout in ms | ||||
|   connection?: number;      // Connection timeout in ms | ||||
|   socket?: number;          // Socket idle timeout in ms | ||||
|   response?: number;        // Response timeout in ms | ||||
|   request?: number; // Overall request timeout in ms | ||||
|   connection?: number; // Connection timeout in ms | ||||
|   socket?: number; // Socket idle timeout in ms | ||||
|   response?: number; // Response timeout in ms | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Rate limit configuration for handling 429 responses | ||||
|  */ | ||||
| export interface RateLimitConfig { | ||||
|   maxRetries?: number; // Maximum number of retries (default: 3) | ||||
|   respectRetryAfter?: boolean; // Respect Retry-After header (default: true) | ||||
|   maxWaitTime?: number; // Max wait time in ms (default: 60000) | ||||
|   fallbackDelay?: number; // Delay when no Retry-After header (default: 1000) | ||||
|   backoffFactor?: number; // Exponential backoff factor (default: 2) | ||||
|   onRateLimit?: (attempt: number, waitTime: number) => void; // Callback for rate limit events | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Raw streaming function for advanced request body control | ||||
|  * Note: The request parameter type depends on the environment (Node.js ClientRequest or fetch Request) | ||||
|  */ | ||||
| export type RawStreamFunction = (request: any) => void; | ||||
|   | ||||
| @@ -5,10 +5,10 @@ import type { ICoreResponse } from '../../core_base/types.js'; | ||||
|  * Pagination strategy options | ||||
|  */ | ||||
| export enum PaginationStrategy { | ||||
|   OFFSET = 'offset',        // Uses page & limit parameters | ||||
|   CURSOR = 'cursor',        // Uses a cursor/token for next page | ||||
|   LINK_HEADER = 'link',     // Uses Link headers | ||||
|   CUSTOM = 'custom'         // Uses a custom pagination handler | ||||
|   OFFSET = 'offset', // Uses page & limit parameters | ||||
|   CURSOR = 'cursor', // Uses a cursor/token for next page | ||||
|   LINK_HEADER = 'link', // Uses Link headers | ||||
|   CUSTOM = 'custom', // Uses a custom pagination handler | ||||
| } | ||||
|  | ||||
| /** | ||||
| @@ -16,11 +16,11 @@ export enum PaginationStrategy { | ||||
|  */ | ||||
| export interface OffsetPaginationConfig { | ||||
|   strategy: PaginationStrategy.OFFSET; | ||||
|   pageParam?: string;       // Parameter name for page number (default: "page") | ||||
|   limitParam?: string;      // Parameter name for page size (default: "limit") | ||||
|   startPage?: number;       // Starting page number (default: 1) | ||||
|   pageSize?: number;        // Number of items per page (default: 20) | ||||
|   totalPath?: string;       // JSON path to total item count (default: "total") | ||||
|   pageParam?: string; // Parameter name for page number (default: "page") | ||||
|   limitParam?: string; // Parameter name for page size (default: "limit") | ||||
|   startPage?: number; // Starting page number (default: 1) | ||||
|   pageSize?: number; // Number of items per page (default: 20) | ||||
|   totalPath?: string; // JSON path to total item count (default: "total") | ||||
| } | ||||
|  | ||||
| /** | ||||
| @@ -28,9 +28,9 @@ export interface OffsetPaginationConfig { | ||||
|  */ | ||||
| export interface CursorPaginationConfig { | ||||
|   strategy: PaginationStrategy.CURSOR; | ||||
|   cursorParam?: string;     // Parameter name for cursor (default: "cursor") | ||||
|   cursorPath?: string;      // JSON path to next cursor (default: "nextCursor") | ||||
|   hasMorePath?: string;     // JSON path to check if more items exist (default: "hasMore") | ||||
|   cursorParam?: string; // Parameter name for cursor (default: "cursor") | ||||
|   cursorPath?: string; // JSON path to next cursor (default: "nextCursor") | ||||
|   hasMorePath?: string; // JSON path to check if more items exist (default: "hasMore") | ||||
| } | ||||
|  | ||||
| /** | ||||
| @@ -47,21 +47,28 @@ export interface LinkPaginationConfig { | ||||
| export interface CustomPaginationConfig { | ||||
|   strategy: PaginationStrategy.CUSTOM; | ||||
|   hasNextPage: (response: ICoreResponse<any>) => boolean; | ||||
|   getNextPageParams: (response: ICoreResponse<any>, currentParams: Record<string, string>) => Record<string, string>; | ||||
|   getNextPageParams: ( | ||||
|     response: ICoreResponse<any>, | ||||
|     currentParams: Record<string, string>, | ||||
|   ) => Record<string, string>; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Union type of all pagination configurations | ||||
|  */ | ||||
| export type TPaginationConfig = OffsetPaginationConfig | CursorPaginationConfig | LinkPaginationConfig | CustomPaginationConfig; | ||||
| export type TPaginationConfig = | ||||
|   | OffsetPaginationConfig | ||||
|   | CursorPaginationConfig | ||||
|   | LinkPaginationConfig | ||||
|   | CustomPaginationConfig; | ||||
|  | ||||
| /** | ||||
|  * Interface for a paginated response | ||||
|  */ | ||||
| export interface TPaginatedResponse<T> { | ||||
|   items: T[];                   // Current page items | ||||
|   hasNextPage: boolean;         // Whether there are more pages | ||||
|   getNextPage: () => Promise<TPaginatedResponse<T>>;  // Function to get the next page | ||||
|   getAllPages: () => Promise<T[]>;  // Function to get all remaining pages and combine | ||||
|   response: ICoreResponse<any>;  // Original response | ||||
|   items: T[]; // Current page items | ||||
|   hasNextPage: boolean; // Whether there are more pages | ||||
|   getNextPage: () => Promise<TPaginatedResponse<T>>; // Function to get the next page | ||||
|   getAllPages: () => Promise<T[]>; // Function to get all remaining pages and combine | ||||
|   response: ICoreResponse<any>; // Original response | ||||
| } | ||||
| @@ -13,9 +13,8 @@ if (smartenvInstance.isNode) { | ||||
|   // In Node.js, load the node implementation | ||||
|   const modulePath = plugins.smartpath.join( | ||||
|     plugins.smartpath.dirname(import.meta.url), | ||||
|     '../core_node/index.js' | ||||
|   ) | ||||
|   console.log(modulePath); | ||||
|     '../core_node/index.js', | ||||
|   ); | ||||
|   const impl = await smartenvInstance.getSafeNodeModule(modulePath); | ||||
|   CoreRequest = impl.CoreRequest; | ||||
|   CoreResponse = impl.CoreResponse; | ||||
|   | ||||
| @@ -3,7 +3,10 @@ import * as types from './types.js'; | ||||
| /** | ||||
|  * Abstract Core Request class that defines the interface for all HTTP/HTTPS requests | ||||
|  */ | ||||
| export abstract class CoreRequest<TOptions extends types.ICoreRequestOptions = types.ICoreRequestOptions, TResponse = any> { | ||||
| export abstract class CoreRequest< | ||||
|   TOptions extends types.ICoreRequestOptions = types.ICoreRequestOptions, | ||||
|   TResponse = any, | ||||
| > { | ||||
|   /** | ||||
|    * Tests if a URL is a unix socket | ||||
|    */ | ||||
| @@ -41,5 +44,4 @@ export abstract class CoreRequest<TOptions extends types.ICoreRequestOptions = t | ||||
|    * Fire the request and return the raw response (platform-specific) | ||||
|    */ | ||||
|   abstract fireCore(): Promise<any>; | ||||
|  | ||||
| } | ||||
| @@ -42,4 +42,9 @@ export abstract class CoreResponse<T = any> implements types.ICoreResponse<T> { | ||||
|    * Get response as a web-style ReadableStream | ||||
|    */ | ||||
|   abstract stream(): ReadableStream<Uint8Array> | null; | ||||
|  | ||||
|   /** | ||||
|    * Get response as a Node.js stream (throws in browser) | ||||
|    */ | ||||
|   abstract streamNode(): NodeJS.ReadableStream | never; | ||||
| } | ||||
| @@ -1,7 +1,14 @@ | ||||
| /** | ||||
|  * HTTP Methods supported | ||||
|  */ | ||||
| export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS'; | ||||
| export type THttpMethod = | ||||
|   | 'GET' | ||||
|   | 'POST' | ||||
|   | 'PUT' | ||||
|   | 'DELETE' | ||||
|   | 'PATCH' | ||||
|   | 'HEAD' | ||||
|   | 'OPTIONS'; | ||||
|  | ||||
| /** | ||||
|  * Response types supported | ||||
| @@ -38,6 +45,7 @@ export interface ICoreRequestOptions { | ||||
|   queryParams?: { [key: string]: string }; | ||||
|   timeout?: number; | ||||
|   hardDataCuttingTimeout?: number; | ||||
|   autoDrain?: boolean; // Auto-drain unconsumed responses (Node.js only, default: true) | ||||
|  | ||||
|   // Node.js specific options (ignored in fetch implementation) | ||||
|   agent?: any; | ||||
| @@ -78,4 +86,5 @@ export interface ICoreResponse<T = any> { | ||||
|   text(): Promise<string>; | ||||
|   arrayBuffer(): Promise<ArrayBuffer>; | ||||
|   stream(): ReadableStream<Uint8Array> | null; // Always returns web-style stream | ||||
|   streamNode(): NodeJS.ReadableStream | never; // Returns Node.js stream or throws in browser | ||||
| } | ||||
| @@ -5,13 +5,21 @@ import { CoreRequest as AbstractCoreRequest } from '../core_base/request.js'; | ||||
| /** | ||||
|  * Fetch-based implementation of Core Request class | ||||
|  */ | ||||
| export class CoreRequest extends AbstractCoreRequest<types.ICoreRequestOptions, CoreResponse> { | ||||
| export class CoreRequest extends AbstractCoreRequest< | ||||
|   types.ICoreRequestOptions, | ||||
|   CoreResponse | ||||
| > { | ||||
|   private timeoutId: ReturnType<typeof setTimeout> | null = null; | ||||
|   private abortController: AbortController | null = null; | ||||
|  | ||||
|   constructor(url: string, options: types.ICoreRequestOptions = {}) { | ||||
|     super(url, options); | ||||
|  | ||||
|     // Check for unsupported Node.js-specific options | ||||
|     if (options.agent || options.socketPath) { | ||||
|       throw new Error('Node.js specific options (agent, socketPath) are not supported in browser/fetch implementation'); | ||||
|       throw new Error( | ||||
|         'Node.js specific options (agent, socketPath) are not supported in browser/fetch implementation', | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -19,7 +27,10 @@ export class CoreRequest extends AbstractCoreRequest<types.ICoreRequestOptions, | ||||
|    * Build the full URL with query parameters | ||||
|    */ | ||||
|   private buildUrl(): string { | ||||
|     if (!this.options.queryParams || Object.keys(this.options.queryParams).length === 0) { | ||||
|     if ( | ||||
|       !this.options.queryParams || | ||||
|       Object.keys(this.options.queryParams).length === 0 | ||||
|     ) { | ||||
|       return this.url; | ||||
|     } | ||||
|  | ||||
| @@ -50,12 +61,22 @@ export class CoreRequest extends AbstractCoreRequest<types.ICoreRequestOptions, | ||||
|  | ||||
|     // Handle request body | ||||
|     if (this.options.requestBody !== undefined) { | ||||
|       if (typeof this.options.requestBody === 'string' ||  | ||||
|           this.options.requestBody instanceof ArrayBuffer || | ||||
|           this.options.requestBody instanceof FormData || | ||||
|           this.options.requestBody instanceof URLSearchParams || | ||||
|           this.options.requestBody instanceof ReadableStream) { | ||||
|       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 || | ||||
|         // 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); | ||||
| @@ -66,7 +87,10 @@ export class CoreRequest extends AbstractCoreRequest<types.ICoreRequestOptions, | ||||
|           if (!fetchOptions.headers.has('Content-Type')) { | ||||
|             fetchOptions.headers.set('Content-Type', 'application/json'); | ||||
|           } | ||||
|         } else if (typeof fetchOptions.headers === 'object' && !Array.isArray(fetchOptions.headers)) { | ||||
|         } else if ( | ||||
|           typeof fetchOptions.headers === 'object' && | ||||
|           !Array.isArray(fetchOptions.headers) | ||||
|         ) { | ||||
|           const headersObj = fetchOptions.headers as Record<string, string>; | ||||
|           if (!headersObj['Content-Type']) { | ||||
|             headersObj['Content-Type'] = 'application/json'; | ||||
| @@ -77,10 +101,15 @@ export class CoreRequest extends AbstractCoreRequest<types.ICoreRequestOptions, | ||||
|  | ||||
|     // Handle timeout | ||||
|     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; | ||||
|       const timeout = | ||||
|         this.options.hardDataCuttingTimeout || this.options.timeout; | ||||
|       this.abortController = new AbortController(); | ||||
|       this.timeoutId = setTimeout(() => { | ||||
|         if (this.abortController) { | ||||
|           this.abortController.abort(); | ||||
|         } | ||||
|       }, timeout); | ||||
|       fetchOptions.signal = this.abortController.signal; | ||||
|     } | ||||
|  | ||||
|     return fetchOptions; | ||||
| @@ -103,8 +132,12 @@ export class CoreRequest extends AbstractCoreRequest<types.ICoreRequestOptions, | ||||
|  | ||||
|     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'); | ||||
|       } | ||||
| @@ -112,12 +145,25 @@ export class CoreRequest extends AbstractCoreRequest<types.ICoreRequestOptions, | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * 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 | ||||
|    */ | ||||
|   static async create( | ||||
|     url: string, | ||||
|     options: types.ICoreRequestOptions = {} | ||||
|     options: types.ICoreRequestOptions = {}, | ||||
|   ): Promise<CoreResponse> { | ||||
|     const request = new CoreRequest(url, options); | ||||
|     return request.fire(); | ||||
|   | ||||
| @@ -4,7 +4,10 @@ import { CoreResponse as AbstractCoreResponse } from '../core_base/response.js'; | ||||
| /** | ||||
|  * Fetch-based implementation of Core Response class | ||||
|  */ | ||||
| export class CoreResponse<T = any> extends AbstractCoreResponse<T> implements types.IFetchResponse<T> { | ||||
| export class CoreResponse<T = any> | ||||
|   extends AbstractCoreResponse<T> | ||||
|   implements types.IFetchResponse<T> | ||||
| { | ||||
|   private response: Response; | ||||
|   private responseClone: Response; | ||||
|  | ||||
| @@ -73,7 +76,9 @@ export class CoreResponse<T = any> extends AbstractCoreResponse<T> implements ty | ||||
|    * Node.js stream method - not available in browser | ||||
|    */ | ||||
|   streamNode(): never { | ||||
|     throw new Error('streamNode() is not available in browser/fetch environment. Use stream() for web-style ReadableStream.'); | ||||
|     throw new Error( | ||||
|       'streamNode() is not available in browser/fetch environment. Use stream() for web-style ReadableStream.', | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|   | ||||
| @@ -7,9 +7,6 @@ export * from '../core_base/types.js'; | ||||
|  * Fetch-specific response extensions | ||||
|  */ | ||||
| export interface IFetchResponse<T = any> extends baseTypes.ICoreResponse<T> { | ||||
|   // Node.js stream method that throws in browser | ||||
|   streamNode(): never; | ||||
|    | ||||
|   // Access to raw Response object | ||||
|   raw(): Response; | ||||
| } | ||||
| @@ -29,21 +29,33 @@ const httpsAgentKeepAliveFalse = new plugins.agentkeepalive.HttpsAgent({ | ||||
| /** | ||||
|  * Node.js implementation of Core Request class that handles all HTTP/HTTPS requests | ||||
|  */ | ||||
| export class CoreRequest extends AbstractCoreRequest<types.ICoreRequestOptions, CoreResponse> { | ||||
| export class CoreRequest extends AbstractCoreRequest< | ||||
|   types.ICoreRequestOptions, | ||||
|   CoreResponse | ||||
| > { | ||||
|   private requestDataFunc: ((req: plugins.http.ClientRequest) => void) | null; | ||||
|  | ||||
|   constructor( | ||||
|     url: string, | ||||
|     options: types.ICoreRequestOptions = {}, | ||||
|     requestDataFunc: ((req: plugins.http.ClientRequest) => void) | null = null | ||||
|     requestDataFunc: ((req: plugins.http.ClientRequest) => void) | null = null, | ||||
|   ) { | ||||
|     super(url, options); | ||||
|     this.requestDataFunc = requestDataFunc; | ||||
|  | ||||
|     // Check for unsupported fetch-specific options | ||||
|     if (options.credentials || options.mode || options.cache || options.redirect ||  | ||||
|         options.referrer || options.referrerPolicy || options.integrity) { | ||||
|       throw new Error('Fetch API specific options (credentials, mode, cache, redirect, referrer, referrerPolicy, integrity) are not supported in Node.js implementation'); | ||||
|     if ( | ||||
|       options.credentials || | ||||
|       options.mode || | ||||
|       options.cache || | ||||
|       options.redirect || | ||||
|       options.referrer || | ||||
|       options.referrerPolicy || | ||||
|       options.integrity | ||||
|     ) { | ||||
|       throw new Error( | ||||
|         'Fetch API specific options (credentials, mode, cache, redirect, referrer, referrerPolicy, integrity) are not supported in Node.js implementation', | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -52,7 +64,7 @@ export class CoreRequest extends AbstractCoreRequest<types.ICoreRequestOptions, | ||||
|    */ | ||||
|   async fire(): Promise<CoreResponse> { | ||||
|     const incomingMessage = await this.fireCore(); | ||||
|     return new CoreResponse(incomingMessage, this.url); | ||||
|     return new CoreResponse(incomingMessage, this.url, this.options); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
| @@ -74,7 +86,9 @@ export class CoreRequest extends AbstractCoreRequest<types.ICoreRequestOptions, | ||||
|  | ||||
|     // Handle unix socket URLs | ||||
|     if (CoreRequest.isUnixSocket(this.url)) { | ||||
|       const { socketPath, path } = CoreRequest.parseUnixSocketUrl(this.options.path); | ||||
|       const { socketPath, path } = CoreRequest.parseUnixSocketUrl( | ||||
|         this.options.path, | ||||
|       ); | ||||
|       this.options.socketPath = socketPath; | ||||
|       this.options.path = path; | ||||
|     } | ||||
| @@ -83,25 +97,33 @@ export class CoreRequest extends AbstractCoreRequest<types.ICoreRequestOptions, | ||||
|     if (!this.options.agent) { | ||||
|       // Only use keep-alive agents if explicitly requested | ||||
|       if (this.options.keepAlive === true) { | ||||
|         this.options.agent = parsedUrl.protocol === 'https:' ? httpsAgent : httpAgent; | ||||
|         this.options.agent = | ||||
|           parsedUrl.protocol === 'https:' ? httpsAgent : httpAgent; | ||||
|       } else if (this.options.keepAlive === false) { | ||||
|         this.options.agent = parsedUrl.protocol === 'https:' ? httpsAgentKeepAliveFalse : httpAgentKeepAliveFalse; | ||||
|         this.options.agent = | ||||
|           parsedUrl.protocol === 'https:' | ||||
|             ? httpsAgentKeepAliveFalse | ||||
|             : httpAgentKeepAliveFalse; | ||||
|       } | ||||
|       // If keepAlive is undefined, don't set any agent (more fetch-like behavior) | ||||
|     } | ||||
|  | ||||
|     // Determine request module | ||||
|     const requestModule = parsedUrl.protocol === 'https:' ? plugins.https : plugins.http; | ||||
|     const requestModule = | ||||
|       parsedUrl.protocol === 'https:' ? plugins.https : plugins.http; | ||||
|  | ||||
|     if (!requestModule) { | ||||
|       throw new Error(`The request to ${this.url} is missing a viable protocol. Must be http or https`); | ||||
|       throw new Error( | ||||
|         `The request to ${this.url} is missing a viable protocol. Must be http or https`, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     // 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); | ||||
| @@ -111,6 +133,14 @@ export class CoreRequest extends AbstractCoreRequest<types.ICoreRequestOptions, | ||||
|       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) { | ||||
| @@ -119,11 +149,12 @@ export class CoreRequest extends AbstractCoreRequest<types.ICoreRequestOptions, | ||||
|         }); | ||||
|       } else { | ||||
|         // Write body as-is - caller is responsible for serialization | ||||
|         const bodyData = typeof this.options.requestBody === 'string'  | ||||
|           ? this.options.requestBody  | ||||
|           : this.options.requestBody instanceof Buffer  | ||||
|         const bodyData = | ||||
|           typeof this.options.requestBody === 'string' | ||||
|             ? this.options.requestBody | ||||
|             : JSON.stringify(this.options.requestBody); // Still stringify for backward compatibility | ||||
|             : this.options.requestBody instanceof Buffer | ||||
|               ? this.options.requestBody | ||||
|               : JSON.stringify(this.options.requestBody); // Still stringify for backward compatibility | ||||
|         request.write(bodyData); | ||||
|         request.end(); | ||||
|       } | ||||
| @@ -137,11 +168,23 @@ export class CoreRequest extends AbstractCoreRequest<types.ICoreRequestOptions, | ||||
|     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(); | ||||
| @@ -155,7 +198,7 @@ export class CoreRequest extends AbstractCoreRequest<types.ICoreRequestOptions, | ||||
|    */ | ||||
|   static async create( | ||||
|     url: string, | ||||
|     options: types.ICoreRequestOptions = {} | ||||
|     options: types.ICoreRequestOptions = {}, | ||||
|   ): Promise<CoreResponse> { | ||||
|     const request = new CoreRequest(url, options); | ||||
|     return request.fire(); | ||||
|   | ||||
| @@ -5,9 +5,13 @@ import { CoreResponse as AbstractCoreResponse } from '../core_base/response.js'; | ||||
| /** | ||||
|  * Node.js implementation of Core Response class that provides a fetch-like API | ||||
|  */ | ||||
| export class CoreResponse<T = any> extends AbstractCoreResponse<T> implements types.INodeResponse<T> { | ||||
| export class CoreResponse<T = any> | ||||
|   extends AbstractCoreResponse<T> | ||||
|   implements types.INodeResponse<T> | ||||
| { | ||||
|   private incomingMessage: plugins.http.IncomingMessage; | ||||
|   private bodyBufferPromise: Promise<Buffer> | null = null; | ||||
|   private _autoDrainTimeout: NodeJS.Immediate | null = null; | ||||
|  | ||||
|   // Public properties | ||||
|   public readonly ok: boolean; | ||||
| @@ -16,7 +20,11 @@ export class CoreResponse<T = any> extends AbstractCoreResponse<T> implements ty | ||||
|   public readonly headers: plugins.http.IncomingHttpHeaders; | ||||
|   public readonly url: string; | ||||
|  | ||||
|   constructor(incomingMessage: plugins.http.IncomingMessage, url: string) { | ||||
|   constructor( | ||||
|     incomingMessage: plugins.http.IncomingMessage, | ||||
|     url: string, | ||||
|     options: types.ICoreRequestOptions = {}, | ||||
|   ) { | ||||
|     super(); | ||||
|     this.incomingMessage = incomingMessage; | ||||
|     this.url = url; | ||||
| @@ -24,6 +32,33 @@ export class CoreResponse<T = any> extends AbstractCoreResponse<T> implements ty | ||||
|     this.statusText = incomingMessage.statusMessage || ''; | ||||
|     this.ok = this.status >= 200 && this.status < 300; | ||||
|     this.headers = incomingMessage.headers; | ||||
|  | ||||
|     // Auto-drain unconsumed streams to prevent socket hanging | ||||
|     // This prevents keep-alive sockets from timing out when response bodies aren't consumed | ||||
|     // Default to true if not specified | ||||
|     if (options.autoDrain !== false) { | ||||
|       this._autoDrainTimeout = setImmediate(() => { | ||||
|         if (!this.consumed && !this.incomingMessage.readableEnded) { | ||||
|           console.log( | ||||
|             `Auto-draining unconsumed response body for ${this.url} (status: ${this.status})`, | ||||
|           ); | ||||
|           this.incomingMessage.resume(); // Drain without processing | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Override to also cancel auto-drain when body is consumed | ||||
|    */ | ||||
|   protected ensureNotConsumed(): void { | ||||
|     // Cancel auto-drain since we're consuming the body | ||||
|     if (this._autoDrainTimeout) { | ||||
|       clearImmediate(this._autoDrainTimeout); | ||||
|       this._autoDrainTimeout = null; | ||||
|     } | ||||
|  | ||||
|     super.ensureNotConsumed(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
| @@ -80,7 +115,10 @@ export class CoreResponse<T = any> extends AbstractCoreResponse<T> implements ty | ||||
|    */ | ||||
|   async arrayBuffer(): Promise<ArrayBuffer> { | ||||
|     const buffer = await this.collectBody(); | ||||
|     return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); | ||||
|     return buffer.buffer.slice( | ||||
|       buffer.byteOffset, | ||||
|       buffer.byteOffset + buffer.byteLength, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
| @@ -114,7 +152,7 @@ export class CoreResponse<T = any> extends AbstractCoreResponse<T> implements ty | ||||
|  | ||||
|       cancel() { | ||||
|         nodeStream.destroy(); | ||||
|       } | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
| @@ -132,5 +170,4 @@ export class CoreResponse<T = any> extends AbstractCoreResponse<T> implements ty | ||||
|   raw(): plugins.http.IncomingMessage { | ||||
|     return this.incomingMessage; | ||||
|   } | ||||
|  | ||||
| } | ||||
| @@ -7,7 +7,8 @@ export * from '../core_base/types.js'; | ||||
| /** | ||||
|  * Extended IncomingMessage with body property (legacy compatibility) | ||||
|  */ | ||||
| export interface IExtendedIncomingMessage<T = any> extends plugins.http.IncomingMessage { | ||||
| export interface IExtendedIncomingMessage<T = any> | ||||
|   extends plugins.http.IncomingMessage { | ||||
|   body: T; | ||||
| } | ||||
|  | ||||
| @@ -15,9 +16,6 @@ export interface IExtendedIncomingMessage<T = any> extends plugins.http.Incoming | ||||
|  * Node.js specific response extensions | ||||
|  */ | ||||
| export interface INodeResponse<T = any> extends baseTypes.ICoreResponse<T> { | ||||
|   // Node.js specific methods | ||||
|   streamNode(): NodeJS.ReadableStream; // Returns Node.js style stream | ||||
|    | ||||
|   // Legacy compatibility | ||||
|   raw(): plugins.http.IncomingMessage; | ||||
| } | ||||
| @@ -6,9 +6,9 @@ | ||||
|     "module": "NodeNext", | ||||
|     "moduleResolution": "NodeNext", | ||||
|     "esModuleInterop": true, | ||||
|     "verbatimModuleSyntax": true | ||||
|     "verbatimModuleSyntax": true, | ||||
|     "baseUrl": ".", | ||||
|     "paths": {} | ||||
|   }, | ||||
|   "exclude": [ | ||||
|     "dist_*/**/*.d.ts" | ||||
|   ] | ||||
|   "exclude": ["dist_*/**/*.d.ts"] | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user