Compare commits

...

29 Commits

Author SHA1 Message Date
ffabcf7bdb 4.3.1
Some checks failed
Default (tags) / security (push) Successful in 42s
Default (tags) / test (push) Successful in 1m2s
Default (tags) / release (push) Failing after 43s
Default (tags) / metadata (push) Successful in 53s
2025-08-19 01:36:44 +00:00
361d97f440 fix(core): Improve streaming support and timeout handling; add browser streaming & timeout tests and README clarifications 2025-08-19 01:36:44 +00:00
35867d9148 feat(response): Add streamNode() method for Node.js stream support; update tests 2025-08-19 01:20:19 +00:00
d455a34632 4.3.0
Some checks failed
Default (tags) / security (push) Successful in 41s
Default (tags) / test (push) Successful in 59s
Default (tags) / release (push) Failing after 44s
Default (tags) / metadata (push) Successful in 54s
2025-08-18 22:29:24 +00:00
9c5a939499 feat(client/smartrequest): Add streaming and raw buffer support to SmartRequest (buffer, stream, raw); update docs and tests 2025-08-18 22:29:24 +00:00
7b2081dc4d 4.2.2
Some checks failed
Default (tags) / security (push) Successful in 43s
Default (tags) / test (push) Successful in 57s
Default (tags) / release (push) Failing after 43s
Default (tags) / metadata (push) Successful in 56s
2025-08-18 00:21:15 +00:00
ee750dea58 fix(client): Fix CI configuration, prevent socket hangs with auto-drain, and apply various client/core TypeScript fixes and test updates 2025-08-18 00:21:14 +00:00
9b9c8fd618 fix(client): Fix socket hanging issues and add auto-drain feature
Some checks failed
Default (tags) / security (push) Failing after 26s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
- Fixed socket hanging issues caused by unconsumed response bodies
- Added automatic response body draining to prevent socket pool exhaustion
- Made auto-drain configurable via autoDrain() method (enabled by default)
- Updated all tests to properly consume response bodies
- Enhanced documentation about response body consumption
2025-07-29 15:49:35 +00:00
1991308d4a update 2025-07-29 15:44:04 +00:00
b4769e7feb feat(client): add handle429Backoff method for intelligent rate limit handling
Some checks failed
Default (tags) / security (push) Failing after 23s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-29 13:49:50 +00:00
4cbca08f43 feat(429 handling): now handles 429 correctly 2025-07-29 13:19:43 +00:00
cf24bf94b9 4.0.1
Some checks failed
Default (tags) / security (push) Failing after 25s
Default (tags) / test (push) Failing after 15s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-29 00:19:30 +00:00
3e24f1c5a8 fix:(exports) 2025-07-29 00:19:19 +00:00
2dc82bd730 BREAKING CHANGE(core): major architectural refactoring with cross-platform support and SmartRequest rename
Some checks failed
Default (tags) / security (push) Failing after 24s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-28 23:20:52 +00:00
8e75047d1f update 2025-07-28 22:50:12 +00:00
eb2ccd8d9f update 2025-07-28 22:37:36 +00:00
bc99aa3569 update 2025-07-28 17:23:48 +00:00
94bf23ad55 update 2025-07-28 17:15:35 +00:00
ea54a8aeda update 2025-07-28 17:07:24 +00:00
18d8ab0278 update 2025-07-28 17:01:34 +00:00
b8d707b363 update 2025-07-28 16:51:30 +00:00
7dcc5f3fe2 update 2025-07-28 15:12:11 +00:00
8f5c88b47e update 2025-07-28 15:12:04 +00:00
28a56b87bc update 2025-07-28 15:00:42 +00:00
d627bc870e update 2025-07-28 14:45:47 +00:00
2cded974a8 update 2025-07-28 14:38:09 +00:00
31c25c8333 update 2025-07-28 14:30:27 +00:00
01bbfa4a06 fix tests 2025-07-28 14:21:42 +00:00
0ebd47d1b2 update readme.md 2025-07-28 07:45:37 +00:00
50 changed files with 5846 additions and 4155 deletions

View File

@@ -6,8 +6,8 @@ on:
- '**' - '**'
env: env:
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci IMAGE: code.foss.global/host.today/ht-docker-node:npmci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}} NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}} NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}} NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
@@ -26,7 +26,7 @@ jobs:
- name: Install pnpm and npmci - name: Install pnpm and npmci
run: | run: |
pnpm install -g pnpm pnpm install -g pnpm
pnpm install -g @shipzone/npmci pnpm install -g @ship.zone/npmci
- name: Run npm prepare - name: Run npm prepare
run: npmci npm prepare run: npmci npm prepare

View File

@@ -6,8 +6,8 @@ on:
- '*' - '*'
env: env:
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci IMAGE: code.foss.global/host.today/ht-docker-node:npmci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}} NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}} NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}} NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
@@ -26,7 +26,7 @@ jobs:
- name: Prepare - name: Prepare
run: | run: |
pnpm install -g pnpm pnpm install -g pnpm
pnpm install -g @shipzone/npmci pnpm install -g @ship.zone/npmci
npmci npm prepare npmci npm prepare
- name: Audit production dependencies - name: Audit production dependencies
@@ -54,7 +54,7 @@ jobs:
- name: Prepare - name: Prepare
run: | run: |
pnpm install -g pnpm pnpm install -g pnpm
pnpm install -g @shipzone/npmci pnpm install -g @ship.zone/npmci
npmci npm prepare npmci npm prepare
- name: Test stable - name: Test stable
@@ -82,7 +82,7 @@ jobs:
- name: Prepare - name: Prepare
run: | run: |
pnpm install -g pnpm pnpm install -g pnpm
pnpm install -g @shipzone/npmci pnpm install -g @ship.zone/npmci
npmci npm prepare npmci npm prepare
- name: Release - name: Release
@@ -104,7 +104,7 @@ jobs:
- name: Prepare - name: Prepare
run: | run: |
pnpm install -g pnpm pnpm install -g pnpm
pnpm install -g @shipzone/npmci pnpm install -g @ship.zone/npmci
npmci npm prepare npmci npm prepare
- name: Code quality - name: Code quality

7
.gitignore vendored
View File

@@ -3,7 +3,6 @@
# artifacts # artifacts
coverage/ coverage/
public/ public/
pages/
# installs # installs
node_modules/ node_modules/
@@ -17,4 +16,8 @@ node_modules/
dist/ dist/
dist_*/ dist_*/
# custom # AI
.claude/
.serena/
#------# custom

View File

@@ -1,9 +1,135 @@
# Changelog # Changelog
## 2025-08-19 - 4.3.1 - fix(core)
Improve streaming support and timeout handling; add browser streaming & timeout tests and README clarifications
- core_fetch: accept Uint8Array and Buffer-like bodies; set fetch duplex for ReadableStream bodies so streaming requests work in environments that require duplex
- core_fetch: implement AbortController-based timeouts and ensure timeouts are cleared on success/error to avoid hanging timers
- core_node: add explicit request timeout handling (request.setTimeout) and hard-data-cutting timeout tracking with proper timeoutId clear on success/error
- client: document that raw(streamFunc) is Node-only (not supported in browsers)
- tests: add browser streaming tests (test/test.streaming.browser.ts) that exercise buffer() and web ReadableStream via stream()
- tests: add timeout tests (test/test.timeout.ts) to validate clearing timers, enforcing timeouts, and preventing timer leaks across multiple requests
- docs: update README streaming section to clarify cross-platform behavior of buffer(), stream(), and raw() methods
## 2025-08-18 - 4.3.0 - feat(client/smartrequest)
Add streaming and raw buffer support to SmartRequest (buffer, stream, raw); update docs and tests
- 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:
- Added abstraction layer with `core_base` containing abstract classes
- Split implementations into `core_node` (Node.js) and `core_fetch` (browser)
- Dynamic implementation selection based on environment
- Response streaming API changes:
- `stream()` now always returns web-style `ReadableStream<Uint8Array>`
- Added `streamNode()` for Node.js streams (throws error in browser)
- Unified type system with single `ICoreRequestOptions` interface
- 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
- Web Streams API support in both environments
- 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) ## 2025-07-27 - 3.0.0 - BREAKING CHANGE(core)
Major architectural refactoring with fetch-like API Major architectural refactoring with fetch-like API
**Breaking Changes:** **Breaking Changes:**
- Legacy API functions are now imported from `@push.rocks/smartrequest/legacy` instead of the main export - 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 - 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 - Renamed `responseType()` method to `accept()` in modern API
@@ -17,17 +143,20 @@ Major architectural refactoring with fetch-like API
- Legacy API is now just an adapter over the core module - Legacy API is now just an adapter over the core module
**Features:** **Features:**
- New fetch-like response API with single-use body consumption - New fetch-like response API with single-use body consumption
- Better TypeScript support and type safety - Better TypeScript support and type safety
- Cleaner separation of concerns between request and response - Cleaner separation of concerns between request and response
- More predictable behavior aligned with fetch API standards - More predictable behavior aligned with fetch API standards
**Documentation:** **Documentation:**
- Updated all examples to show correct import paths - Updated all examples to show correct import paths
- Added comprehensive examples for the new response API - Added comprehensive examples for the new response API
- Enhanced migration guide - Enhanced migration guide
## 2025-04-03 - 2.1.0 - feat(docs) ## 2025-04-03 - 2.1.0 - feat(docs)
Enhance documentation and tests with modern API usage examples and migration guide 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 - Updated README to include detailed examples for the modern fluent API, covering GET, POST, headers, query, timeout, retries, and pagination
@@ -37,6 +166,7 @@ Enhance documentation and tests with modern API usage examples and migration gui
- Minor formatting improvements in the code and documentation examples - Minor formatting improvements in the code and documentation examples
## 2024-11-06 - 2.0.23 - fix(core) ## 2024-11-06 - 2.0.23 - fix(core)
Enhance type safety for response in binary requests Enhance type safety for response in binary requests
- Updated the dependency versions in package.json to their latest versions. - Updated the dependency versions in package.json to their latest versions.
@@ -44,31 +174,37 @@ Enhance type safety for response in binary requests
- Introduced generic typing to IExtendedIncomingMessage interface for better type safety. - Introduced generic typing to IExtendedIncomingMessage interface for better type safety.
## 2024-05-29 - 2.0.22 - Documentation ## 2024-05-29 - 2.0.22 - Documentation
update description update description
## 2024-04-01 - 2.0.21 - Configuration ## 2024-04-01 - 2.0.21 - Configuration
Updated configuration files Updated configuration files
- Updated `tsconfig` - Updated `tsconfig`
- Updated `npmextra.json`: githost - Updated `npmextra.json`: githost
## 2023-07-10 - 2.0.15 - Structure ## 2023-07-10 - 2.0.15 - Structure
Refactored the organization structure Refactored the organization structure
- Switched to a new organization scheme - Switched to a new organization scheme
## 2022-07-29 - 1.1.57 to 2.0.0 - Major Update ## 2022-07-29 - 1.1.57 to 2.0.0 - Major Update
Significant changes and improvements leading to a major version update Significant changes and improvements leading to a major version update
- **BREAKING CHANGE**: Switched the core to use ECMAScript modules (ESM) - **BREAKING CHANGE**: Switched the core to use ECMAScript modules (ESM)
## 2018-08-14 - 1.1.12 to 1.1.13 - Functional Enhancements ## 2018-08-14 - 1.1.12 to 1.1.13 - Functional Enhancements
Enhanced request capabilities and removed unnecessary dependencies Enhanced request capabilities and removed unnecessary dependencies
- Fixed request module to allow sending strings - Fixed request module to allow sending strings
- Removed CI dependencies - Removed CI dependencies
## 2018-07-19 - 1.1.1 to 1.1.11 - Various Fixes and Improvements ## 2018-07-19 - 1.1.1 to 1.1.11 - Various Fixes and Improvements
Improvements and fixes across various components Improvements and fixes across various components
- Added formData capability - Added formData capability
@@ -78,11 +214,13 @@ Improvements and fixes across various components
- Updated request ending method - Updated request ending method
## 2018-06-19 - 1.0.14 - Structural Fix ## 2018-06-19 - 1.0.14 - Structural Fix
Resolved conflicts with file extensions Resolved conflicts with file extensions
- Changed `.json.ts` to `.jsonrest.ts` to avoid conflicts - Changed `.json.ts` to `.jsonrest.ts` to avoid conflicts
## 2018-06-13 - 1.0.8 to 1.0.10 - Core Updates ## 2018-06-13 - 1.0.8 to 1.0.10 - Core Updates
Ensured binary handling compliance Ensured binary handling compliance
- Enhanced core to uphold latest standards - Enhanced core to uphold latest standards
@@ -90,9 +228,9 @@ Ensured binary handling compliance
- Fix for handling and returning binary responses - Fix for handling and returning binary responses
## 2017-06-09 - 1.0.4 to 1.0.6 - Infrastructure and Type Improvements ## 2017-06-09 - 1.0.4 to 1.0.6 - Infrastructure and Type Improvements
Types and infrastructure updates Types and infrastructure updates
- Improved types - Improved types
- Removed need for content type on post requests - Removed need for content type on post requests
- Updated for new infrastructure - Updated for new infrastructure

View File

@@ -1,15 +1,16 @@
{ {
"name": "@push.rocks/smartrequest", "name": "@push.rocks/smartrequest",
"version": "3.0.0", "version": "4.3.1",
"private": false, "private": false,
"description": "A module for modern HTTP/HTTPS requests with support for form data, file uploads, JSON, binary data, streams, and more.", "description": "A module for modern HTTP/HTTPS requests with support for form data, file uploads, JSON, binary data, streams, and more.",
"exports": { "exports": {
".": "./dist_ts_web/index.js", ".": "./dist_ts/index.js",
"./legacy": "./dist_ts/legacy/index.js" "./core_node": "./dist_ts/core_node/index.js",
"./core_fetch": "./dist_ts/core_fetch/index.js"
}, },
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "(tstest test/ --web)", "test": "(tstest test/ --verbose --timeout 120)",
"build": "(tsbuild --web)", "build": "(tsbuild --web)",
"buildDocs": "tsdoc" "buildDocs": "tsdoc"
}, },
@@ -34,20 +35,21 @@
"author": "Task Venture Capital GmbH", "author": "Task Venture Capital GmbH",
"license": "MIT", "license": "MIT",
"bugs": { "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": { "dependencies": {
"@push.rocks/smartenv": "^5.0.13",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.0.4", "@push.rocks/smartpromise": "^4.0.4",
"@push.rocks/smarturl": "^3.1.0", "@push.rocks/smarturl": "^3.1.0",
"agentkeepalive": "^4.5.0", "agentkeepalive": "^4.5.0",
"form-data": "^4.0.1" "form-data": "^4.0.4"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.2.0", "@git.zone/tsbuild": "^2.6.4",
"@git.zone/tsrun": "^1.3.3", "@git.zone/tsrun": "^1.3.3",
"@git.zone/tstest": "^1.0.90", "@git.zone/tstest": "^2.3.4",
"@pushrocks/tapbundle": "^5.0.8",
"@types/node": "^22.9.0" "@types/node": "^22.9.0"
}, },
"files": [ "files": [
@@ -65,5 +67,8 @@
"browserslist": [ "browserslist": [
"last 1 chrome versions" "last 1 chrome versions"
], ],
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6" "packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6",
"pnpm": {
"overrides": {}
}
} }

5495
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
# SmartRequest Architecture Hints # SmartRequest Architecture Hints
## Core Features ## Core Features
- supports http - supports http
- supports https - supports https
- supports unix socks - supports unix socks
@@ -11,44 +12,78 @@
- written in TypeScript - written in TypeScript
- continuously updated - continuously updated
- uses node native http and https modules - uses node native http and https modules
- supports both Node.js and browser environments
- used in modules like @push.rocks/smartproxy and @api.global/typedrequest - used in modules like @push.rocks/smartproxy and @api.global/typedrequest
## Architecture Overview (as of latest refactoring) ## Architecture Overview (as of v3.0.0 major refactoring)
- The project is now structured with a clean separation between core functionality and API layers
- Core module (ts/core/) contains the essential HTTP request logic using Node.js http/https modules - The project now has a multi-layer architecture with platform abstraction
- **Core always returns raw streams** - no parsing or body collection happens in the core request function - Base layer (ts/core_base/) contains abstract classes and unified types
- Modern API (ts/modern/) provides a fluent, chainable interface with fetch-like Response objects - Node.js implementation (ts/core_node/) uses native http/https modules
- Legacy API is maintained through a thin adapter layer for backward compatibility - Fetch implementation (ts/core_fetch/) uses Fetch API for browser compatibility
- Core module (ts/core/) dynamically selects the appropriate implementation based on environment
- Client API (ts/client/) provides a fluent, chainable interface
- Legacy API has been completely removed in v3.0.0
## Key Components ## Key Components
### Core Module (ts/core/) ### Core Base Module (ts/core_base/)
- `request.ts`: Core HTTP/HTTPS request logic with unix socket support and keep-alive agents
- `coreRequest()` always returns a raw Node.js IncomingMessage stream
- No response parsing or body collection happens here
- `response.ts`: SmartResponse class providing fetch-like API
- Methods like `json()`, `text()`, `arrayBuffer()` handle all parsing and body collection
- Response body is streamed and collected only when these methods are called
- Body can only be consumed once (throws error on second attempt)
- `types.ts`: Core TypeScript interfaces and types
- `plugins.ts`: Centralized dependencies
### Modern API - `request.ts`: Abstract CoreRequest class defining the request interface
- SmartRequestClient: Fluent API with method chaining - `response.ts`: Abstract CoreResponse class with fetch-like API
- Returns SmartResponse objects with fetch-like methods - Defines `stream()` method that always returns web-style ReadableStream
- Body can only be consumed once (throws error on second attempt)
- `types.ts`: Unified TypeScript interfaces and types
- Single `ICoreRequestOptions` interface for all implementations
- 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
- `response.ts`: Node.js CoreResponse implementation
- `stream()` method converts Node.js stream to web ReadableStream
- `streamNode()` method returns native Node.js stream
- 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
- `response.ts`: Fetch-based CoreResponse implementation
- `stream()` returns native web ReadableStream from response.body
- `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 - 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 Request Handling
- Binary requests are handled correctly when `responseType: 'binary'` is set
- Response body is kept as Buffer without string conversion - Binary requests handled through ArrayBuffer API
- Response body kept as Buffer/ArrayBuffer without string conversion
- No automatic transformations applied to binary data - No automatic transformations applied to binary data
### Legacy Compatibility
- All legacy functions (getJson, postJson, etc.) are maintained through adapter.ts
- Legacy API returns IExtendedIncomingMessage for backward compatibility
- Modern API can be accessed alongside legacy API
## Testing ## Testing
- Use `pnpm test` to run all tests - Use `pnpm test` to run all tests
- Modern API tests use the new SmartResponse methods (response.json(), response.text()) - Tests use @git.zone/tstest/tapbundle for assertions
- Legacy API tests continue to use the body property directly - Separate test files for Node.js (test.node.ts) and browser (test.browser.ts)
- Browser tests run in headless Chromium via puppeteer

676
readme.md
View File

@@ -1,8 +1,8 @@
# @push.rocks/smartrequest # @push.rocks/smartrequest
A modern HTTP/HTTPS request library for Node.js with support for form data, file uploads, JSON, binary data, streams, and unix sockets. Features both a legacy API for backward compatibility and a modern fetch-like API for new projects.
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 ## Install
To install `@push.rocks/smartrequest`, use one of the following commands:
```bash ```bash
# Using npm # Using npm
@@ -15,156 +15,41 @@ pnpm add @push.rocks/smartrequest
yarn add @push.rocks/smartrequest yarn add @push.rocks/smartrequest
``` ```
This will add `@push.rocks/smartrequest` to your project's dependencies.
## Key Features ## Key Features
- 🚀 **Modern Fetch-like API** - Familiar response methods (`.json()`, `.text()`, `.arrayBuffer()`, `.stream()`) - 🚀 **Modern Fetch-like API** - Familiar response methods (`.json()`, `.text()`, `.arrayBuffer()`, `.stream()`)
- 🔄 **Two API Styles** - Legacy function-based API and modern fluent chainable API - 🌐 **Cross-Platform** - Works in both Node.js and browsers with a unified API
- 🌐 **Unix Socket Support** - Connect to local services like Docker - 🔌 **Unix Socket Support** - Connect to local services like Docker (Node.js only)
- 📦 **Form Data & File Uploads** - Built-in support for multipart/form-data - 📦 **Form Data & File Uploads** - Built-in support for multipart/form-data
- 🔁 **Pagination Support** - Multiple strategies (offset, cursor, Link headers) - 🔁 **Pagination Support** - Multiple strategies (offset, cursor, Link headers)
-**Keep-Alive Connections** - Efficient connection pooling -**Keep-Alive Connections** - Efficient connection pooling in Node.js
- 🛡️ **TypeScript First** - Full type safety and IntelliSense support - 🛡️ **TypeScript First** - Full type safety and IntelliSense support
- 🎯 **Zero Magic Defaults** - Explicit configuration following fetch API principles - 🎯 **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 - 🔧 **Highly Configurable** - Timeouts, retries, headers, and more
## Architecture
SmartRequest v3.0 features a multi-layer architecture that provides consistent behavior across platforms:
- **Core Base** - Abstract classes and unified types shared across implementations
- **Core Node** - Node.js implementation using native http/https modules
- **Core Fetch** - Browser implementation using the Fetch API
- **Core** - Dynamic implementation selection based on environment
- **Client** - High-level fluent API for everyday use
## Usage ## Usage
`@push.rocks/smartrequest` is designed as a versatile, modern HTTP client library for making HTTP/HTTPS requests in Node.js environments. It provides a clean, type-safe API inspired by the native fetch API but with additional features needed for server-side applications. `@push.rocks/smartrequest` provides a clean, type-safe API inspired by the native fetch API but with additional features needed for modern applications.
The library provides two distinct APIs: ### Basic Usage
1. **Legacy API** - Simple function-based API for quick requests and backward compatibility
2. **Modern Fluent API** - A chainable, fetch-like API for more complex scenarios
Below we will cover key usage scenarios of `@push.rocks/smartrequest`, showcasing its capabilities and providing you with a solid starting point to integrate it into your projects.
### Import Guide
```typescript ```typescript
// Modern API (recommended for new projects) import { SmartRequest } from '@push.rocks/smartrequest';
import { SmartRequestClient } from '@push.rocks/smartrequest';
// Legacy API (for backward compatibility)
import { getJson, postJson, request } from '@push.rocks/smartrequest/legacy';
```
### Simple GET Request
For fetching data from a REST API or any web service that returns JSON:
```typescript
import { getJson } from '@push.rocks/smartrequest/legacy';
async function fetchGitHubUserInfo(username: string) {
const response = await getJson(`https://api.github.com/users/${username}`);
console.log(response.body); // The body contains the JSON response
}
fetchGitHubUserInfo('octocat');
```
The `getJson` function simplifies the process of sending a GET request and parsing the JSON response.
### POST Requests with JSON
When you need to send JSON data to a server, for example, creating a new resource:
```typescript
import { postJson } from '@push.rocks/smartrequest/legacy';
async function createTodoItem(todoDetails: { title: string; completed: boolean }) {
const response = await postJson('https://jsonplaceholder.typicode.com/todos', {
requestBody: todoDetails
});
console.log(response.body); // Log the created todo item
}
createTodoItem({ title: 'Implement smartrequest', completed: false });
```
`postJson` handles setting the appropriate content-type header and stringifies the JSON body.
### Handling Form Data and File Uploads
`@push.rocks/smartrequest` simplifies the process of uploading files and submitting form data to a server:
```typescript
import { postFormData, IFormField } from '@push.rocks/smartrequest/legacy';
async function uploadProfilePicture(formDataFields: IFormField[]) {
await postFormData('https://api.example.com/upload', {}, formDataFields);
}
uploadProfilePicture([
{ name: 'avatar', type: 'filePath', payload: './path/to/avatar.jpg', fileName: 'avatar.jpg', contentType: 'image/jpeg' },
{ name: 'user_id', type: 'string', payload: '12345' }
]);
```
### Streaming Support
For cases when dealing with large datasets or streaming APIs, `@push.rocks/smartrequest` provides streaming capabilities:
```typescript
import { getStream } from '@push.rocks/smartrequest/legacy';
async function streamLargeFile(url: string) {
const stream = await getStream(url);
stream.on('data', (chunk) => {
console.log('Received chunk of data.');
});
stream.on('end', () => {
console.log('Stream ended.');
});
}
streamLargeFile('https://example.com/largefile');
```
`getStream` allows you to handle data as it's received, which can be beneficial for performance and scalability.
### Advanced Options and Customization
`@push.rocks/smartrequest` is built to be flexible, allowing you to specify additional options to tailor requests to your needs:
```typescript
import { request, ISmartRequestOptions } from '@push.rocks/smartrequest/legacy';
async function customRequestExample() {
const options: ISmartRequestOptions = {
method: 'GET',
headers: {
'Custom-Header': 'Value'
},
keepAlive: true // Enables connection keep-alive
};
const response = await request('https://example.com/data', options);
console.log(response.body);
}
customRequestExample();
```
`request` is the underlying function that powers the simpler `getJson`, `postJson`, etc., and provides you with full control over the HTTP request.
## Modern Fluent API
In addition to the legacy API shown above, `@push.rocks/smartrequest` provides a modern, fluent API with a fetch-like response interface that offers a more chainable and TypeScript-friendly approach to making HTTP requests.
### Basic Usage with the Modern API
```typescript
import { SmartRequestClient } from '@push.rocks/smartrequest';
// Simple GET request // Simple GET request
async function fetchUserData(userId: number) { async function fetchUserData(userId: number) {
const response = await SmartRequestClient.create() const response = await SmartRequest.create()
.url(`https://jsonplaceholder.typicode.com/users/${userId}`) .url(`https://jsonplaceholder.typicode.com/users/${userId}`)
.get(); .get();
@@ -175,7 +60,7 @@ async function fetchUserData(userId: number) {
// POST request with JSON body // POST request with JSON body
async function createPost(title: string, body: string, userId: number) { async function createPost(title: string, body: string, userId: number) {
const response = await SmartRequestClient.create() const response = await SmartRequest.create()
.url('https://jsonplaceholder.typicode.com/posts') .url('https://jsonplaceholder.typicode.com/posts')
.json({ title, body, userId }) .json({ title, body, userId })
.post(); .post();
@@ -185,18 +70,39 @@ async function createPost(title: string, body: string, userId: number) {
} }
``` ```
### Direct Core API Usage
For advanced use cases, you can use the Core API directly:
```typescript
import { CoreRequest } from '@push.rocks/smartrequest';
async function directCoreRequest() {
const request = new CoreRequest('https://api.example.com/data', {
method: 'GET',
headers: {
Accept: 'application/json',
},
});
const response = await request.fire();
const data = await response.json();
return data;
}
```
### Setting Headers and Query Parameters ### Setting Headers and Query Parameters
```typescript ```typescript
import { SmartRequestClient } from '@push.rocks/smartrequest'; import { SmartRequest } from '@push.rocks/smartrequest';
async function searchRepositories(query: string, perPage: number = 10) { async function searchRepositories(query: string, perPage: number = 10) {
const response = await SmartRequestClient.create() const response = await SmartRequest.create()
.url('https://api.github.com/search/repositories') .url('https://api.github.com/search/repositories')
.header('Accept', 'application/vnd.github.v3+json') .header('Accept', 'application/vnd.github.v3+json')
.query({ .query({
q: query, q: query,
per_page: perPage.toString() per_page: perPage.toString(),
}) })
.get(); .get();
@@ -208,10 +114,10 @@ async function searchRepositories(query: string, perPage: number = 10) {
### Handling Timeouts and Retries ### Handling Timeouts and Retries
```typescript ```typescript
import { SmartRequestClient } from '@push.rocks/smartrequest'; import { SmartRequest } from '@push.rocks/smartrequest';
async function fetchWithRetry(url: string) { async function fetchWithRetry(url: string) {
const response = await SmartRequestClient.create() const response = await SmartRequest.create()
.url(url) .url(url)
.timeout(5000) // 5 seconds timeout .timeout(5000) // 5 seconds timeout
.retry(3) // Retry up to 3 times on failure .retry(3) // Retry up to 3 times on failure
@@ -221,34 +127,49 @@ async function fetchWithRetry(url: string) {
} }
``` ```
### Working with Different Response Types ### Setting Request Options
The modern API provides a fetch-like interface for handling different response types: Use the `options()` method to set any request options supported by the underlying implementation:
```typescript ```typescript
import { SmartRequestClient } from '@push.rocks/smartrequest'; 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:
```typescript
import { SmartRequest } from '@push.rocks/smartrequest';
// JSON response (default) // JSON response (default)
async function fetchJson(url: string) { async function fetchJson(url: string) {
const response = await SmartRequestClient.create() const response = await SmartRequest.create().url(url).get();
.url(url)
.get();
return await response.json(); // Parses JSON automatically return await response.json(); // Parses JSON automatically
} }
// Text response // Text response
async function fetchText(url: string) { async function fetchText(url: string) {
const response = await SmartRequestClient.create() const response = await SmartRequest.create().url(url).get();
.url(url)
.get();
return await response.text(); // Returns response as string return await response.text(); // Returns response as string
} }
// Binary data // Binary data
async function downloadImage(url: string) { async function downloadImage(url: string) {
const response = await SmartRequestClient.create() const response = await SmartRequest.create()
.url(url) .url(url)
.accept('binary') // Optional: hints to server we want binary .accept('binary') // Optional: hints to server we want binary
.get(); .get();
@@ -257,43 +178,260 @@ async function downloadImage(url: string) {
return Buffer.from(buffer); // Convert ArrayBuffer to Buffer if needed return Buffer.from(buffer); // Convert ArrayBuffer to Buffer if needed
} }
// Streaming response // Streaming response (Web Streams API)
async function streamLargeFile(url: string) { async function streamLargeFile(url: string) {
const response = await SmartRequestClient.create() const response = await SmartRequest.create().url(url).get();
.url(url)
.get();
// Get the underlying Node.js stream // Get a web-style ReadableStream (works in both Node.js and browsers)
const stream = response.stream(); const stream = response.stream();
stream.on('data', (chunk) => { if (stream) {
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(`Received ${value.length} bytes of data`);
}
} finally {
reader.releaseLock();
}
}
}
// Node.js specific stream (only in Node.js environment)
async function streamWithNodeApi(url: string) {
const response = await SmartRequest.create().url(url).get();
// Only available in Node.js, throws error in browser
const nodeStream = response.streamNode();
nodeStream.on('data', (chunk) => {
console.log(`Received ${chunk.length} bytes of data`); console.log(`Received ${chunk.length} bytes of data`);
}); });
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
stream.on('end', resolve); nodeStream.on('end', resolve);
stream.on('error', reject); nodeStream.on('error', reject);
}); });
} }
``` ```
### Response Object Methods
The response object provides these methods:
- `json<T>(): Promise<T>` - Parse response as JSON
- `text(): Promise<string>` - Get response as text
- `arrayBuffer(): Promise<ArrayBuffer>` - Get response as ArrayBuffer
- `stream(): ReadableStream<Uint8Array> | null` - Get web-style ReadableStream (cross-platform)
- `streamNode(): NodeJS.ReadableStream` - Get Node.js stream (Node.js only, throws in browser)
- `raw(): Response | http.IncomingMessage` - Get the underlying platform response
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
```typescript
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) => ({
name: 'files',
value: fs.readFileSync(file.path),
filename: file.name,
contentType: 'application/octet-stream',
}));
const response = await SmartRequest.create()
.url('https://api.example.com/upload')
.formData(formFields)
.post();
return await response.json();
}
```
### 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
import { SmartRequest } from '@push.rocks/smartrequest';
// Connect to a service via Unix socket
async function queryViaUnixSocket() {
const response = await SmartRequest.create()
.url('http://unix:/var/run/docker.sock:/v1.24/containers/json')
.get();
return await response.json();
}
```
### Pagination Support ### Pagination Support
The modern API includes built-in support for various pagination strategies: The library includes built-in support for various pagination strategies:
```typescript ```typescript
import { SmartRequestClient, PaginationStrategy } from '@push.rocks/smartrequest'; import { SmartRequest } from '@push.rocks/smartrequest';
// Offset-based pagination (page & limit) // Offset-based pagination (page & limit)
async function fetchAllUsers() { async function fetchAllUsers() {
const client = SmartRequestClient.create() const client = SmartRequest.create()
.url('https://api.example.com/users') .url('https://api.example.com/users')
.withOffsetPagination({ .withOffsetPagination({
pageParam: 'page', pageParam: 'page',
limitParam: 'limit', limitParam: 'limit',
startPage: 1, startPage: 1,
pageSize: 20, pageSize: 20,
totalPath: 'meta.total' totalPath: 'meta.total',
}); });
// Get first page with pagination info // Get first page with pagination info
@@ -314,12 +452,12 @@ async function fetchAllUsers() {
// Cursor-based pagination // Cursor-based pagination
async function fetchAllPosts() { async function fetchAllPosts() {
const allPosts = await SmartRequestClient.create() const allPosts = await SmartRequest.create()
.url('https://api.example.com/posts') .url('https://api.example.com/posts')
.withCursorPagination({ .withCursorPagination({
cursorParam: 'cursor', cursorParam: 'cursor',
cursorPath: 'meta.nextCursor', cursorPath: 'meta.nextCursor',
hasMorePath: 'meta.hasMore' hasMorePath: 'meta.hasMore',
}) })
.getAllPages(); .getAllPages();
@@ -328,7 +466,7 @@ async function fetchAllPosts() {
// Link header-based pagination (GitHub API style) // Link header-based pagination (GitHub API style)
async function fetchAllIssues(repo: string) { async function fetchAllIssues(repo: string) {
const paginatedResponse = await SmartRequestClient.create() const paginatedResponse = await SmartRequest.create()
.url(`https://api.github.com/repos/${repo}/issues`) .url(`https://api.github.com/repos/${repo}/issues`)
.header('Accept', 'application/vnd.github.v3+json') .header('Accept', 'application/vnd.github.v3+json')
.withLinkPagination() .withLinkPagination()
@@ -338,104 +476,135 @@ async function fetchAllIssues(repo: string) {
} }
``` ```
### Advanced Features ### Keep-Alive Connections (Node.js)
#### Unix Socket Support
```typescript ```typescript
import { SmartRequestClient } from '@push.rocks/smartrequest'; import { SmartRequest } from '@push.rocks/smartrequest';
// Connect to a service via Unix socket // Enable keep-alive for better performance with multiple requests
async function queryViaUnixSocket() { async function performMultipleRequests() {
const response = await SmartRequestClient.create() // Note: keepAlive is NOT enabled by default
.url('http://unix:/var/run/docker.sock:/v1.24/containers/json') const response1 = await SmartRequest.create()
.url('https://api.example.com/endpoint1')
.options({ keepAlive: true })
.get();
const response2 = await SmartRequest.create()
.url('https://api.example.com/endpoint2')
.options({ keepAlive: true })
.get();
// 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(); .get();
return await response.json(); return await response.json();
} }
```
#### Form Data with File Uploads // Advanced usage with custom configuration
async function fetchWithCustomRateLimiting() {
```typescript const response = await SmartRequest.create()
import { SmartRequestClient } from '@push.rocks/smartrequest'; .url('https://api.example.com/data')
.handle429Backoff({
async function uploadMultipleFiles(files: Array<{name: string, path: string}>) { maxRetries: 5, // Try up to 5 times (default: 3)
const formFields = files.map(file => ({ respectRetryAfter: true, // Honor Retry-After header (default: true)
name: 'files', maxWaitTime: 30000, // Max 30 seconds wait (default: 60000)
value: fs.readFileSync(file.path), fallbackDelay: 2000, // 2s initial delay if no Retry-After (default: 1000)
filename: file.name, backoffFactor: 2, // Exponential backoff multiplier (default: 2)
contentType: 'application/octet-stream' onRateLimit: (attempt, waitTime) => {
})); console.log(`Rate limited. Attempt ${attempt}, waiting ${waitTime}ms`);
},
const response = await SmartRequestClient.create() })
.url('https://api.example.com/upload') .get();
.formData(formFields)
.post();
return await response.json(); return await response.json();
} }
```
#### Keep-Alive Connections // 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}`,
);
},
});
}
```typescript async fetchData(id: string) {
import { SmartRequestClient } from '@push.rocks/smartrequest'; const response = await this.request(`/data/${id}`).get();
return response.json();
// Enable keep-alive for better performance with multiple requests }
async function performMultipleRequests() {
const client = SmartRequestClient.create()
.header('Connection', 'keep-alive');
// Requests will reuse the same connection
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()
]);
return Promise.all(results.map(r => r.json()));
} }
``` ```
### Response Object Methods The rate limiting feature:
The modern API returns a `SmartResponse` object with the following methods: - 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
- `json<T>(): Promise<T>` - Parse response as JSON ## Platform-Specific Features
- `text(): Promise<string>` - Get response as text
- `arrayBuffer(): Promise<ArrayBuffer>` - Get response as ArrayBuffer
- `stream(): NodeJS.ReadableStream` - Get the underlying Node.js stream
- `raw(): http.IncomingMessage` - Get the raw http.IncomingMessage
Each body method can only be called once per response, similar to the fetch API. ### Browser-Specific Options
Through its comprehensive set of features tailored for modern web development, `@push.rocks/smartrequest` aims to provide developers with a powerful tool for handling HTTP/HTTPS requests efficiently. Whether it's a simple API call, handling form data, processing streams, or working with paginated APIs, `@push.rocks/smartrequest` delivers a robust, type-safe solution to fit your project's requirements. When running in a browser, you can use browser-specific fetch options:
## Migration Guide: Legacy API to Modern API
If you're currently using the legacy API and want to migrate to the modern fluent API, here's a quick reference guide:
| Legacy API | Modern API |
|------------|------------|
| `getJson(url)` | `SmartRequestClient.create().url(url).get()` |
| `postJson(url, { requestBody: data })` | `SmartRequestClient.create().url(url).json(data).post()` |
| `putJson(url, { requestBody: data })` | `SmartRequestClient.create().url(url).json(data).put()` |
| `delJson(url)` | `SmartRequestClient.create().url(url).delete()` |
| `postFormData(url, {}, fields)` | `SmartRequestClient.create().url(url).formData(fields).post()` |
| `getStream(url)` | `SmartRequestClient.create().url(url).accept('stream').get()` |
| `request(url, options)` | `SmartRequestClient.create().url(url).[...configure options...].get()` |
The modern API provides more flexibility and better TypeScript integration, making it the recommended approach for new projects.
## Complete Examples
### Building a REST API Client
Here's a complete example of building a typed API client using smartrequest:
```typescript ```typescript
import { SmartRequestClient, type SmartResponse } from '@push.rocks/smartrequest'; const response = await SmartRequest.create()
.url('https://api.example.com/data')
.options({
credentials: 'include', // Include cookies
mode: 'cors', // CORS mode
cache: 'no-cache', // Cache mode
referrerPolicy: 'no-referrer',
})
.get();
```
### Node.js-Specific Options
When running in Node.js, you can use Node-specific options:
```typescript
import { Agent } from 'https';
const response = await SmartRequest.create()
.url('https://api.example.com/data')
.options({
agent: new Agent({ keepAlive: true }), // Custom agent
socketPath: '/var/run/api.sock', // Unix socket
})
.get();
```
## Complete Example: Building a REST API Client
Here's a complete example of building a typed API client:
```typescript
import { SmartRequest, type CoreResponse } from '@push.rocks/smartrequest';
interface User { interface User {
id: number; id: number;
@@ -454,7 +623,7 @@ class BlogApiClient {
private baseUrl = 'https://jsonplaceholder.typicode.com'; private baseUrl = 'https://jsonplaceholder.typicode.com';
private async request(path: string) { private async request(path: string) {
return SmartRequestClient.create() return SmartRequest.create()
.url(`${this.baseUrl}${path}`) .url(`${this.baseUrl}${path}`)
.header('Accept', 'application/json'); .header('Accept', 'application/json');
} }
@@ -465,9 +634,7 @@ class BlogApiClient {
} }
async createPost(post: Omit<Post, 'id'>): Promise<Post> { async createPost(post: Omit<Post, 'id'>): Promise<Post> {
const response = await this.request('/posts') const response = await this.request('/posts').json(post).post();
.json(post)
.post();
return response.json<Post>(); return response.json<Post>();
} }
@@ -497,14 +664,14 @@ const user = await api.getUser(1);
const posts = await api.getAllPosts(user.id); const posts = await api.getAllPosts(user.id);
``` ```
### Error Handling ## Error Handling
```typescript ```typescript
import { SmartRequestClient } from '@push.rocks/smartrequest'; import { SmartRequest } from '@push.rocks/smartrequest';
async function fetchWithErrorHandling(url: string) { async function fetchWithErrorHandling(url: string) {
try { try {
const response = await SmartRequestClient.create() const response = await SmartRequest.create()
.url(url) .url(url)
.timeout(5000) .timeout(5000)
.retry(2) .retry(2)
@@ -530,6 +697,8 @@ async function fetchWithErrorHandling(url: string) {
console.error('Connection refused - is the server running?'); console.error('Connection refused - is the server running?');
} else if (error.code === 'ETIMEDOUT') { } else if (error.code === 'ETIMEDOUT') {
console.error('Request timed out'); console.error('Request timed out');
} else if (error.name === 'AbortError') {
console.error('Request was aborted');
} else { } else {
console.error('Request failed:', error.message); console.error('Request failed:', error.message);
} }
@@ -538,6 +707,15 @@ async function fetchWithErrorHandling(url: string) {
} }
``` ```
## Migrating from v2.x to v3.x
Version 3.0 brings significant architectural improvements and a more consistent API:
1. **Legacy API Removed**: The function-based API (getJson, postJson, etc.) has been removed. Use SmartRequest instead.
2. **Unified Response API**: All responses now use the same fetch-like interface regardless of platform.
3. **Stream Changes**: The `stream()` method now returns a web-style ReadableStream on all platforms. Use `streamNode()` for Node.js streams.
4. **Cross-Platform by Default**: The library now works in browsers out of the box with automatic platform detection.
## License and Legal Information ## License and Legal Information
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.

View File

@@ -1,53 +0,0 @@
# Smartrequest Refactoring Plan
Command to reread CLAUDE.md: `cat /home/philkunz/.claude/CLAUDE.md`
## Objective
Refactor smartrequest to use native fetch-like API with a streamlined core that supports unix sockets and keep-alive.
## Architecture Overview
- Rename `legacy/` to `core/` and remove "smartrequest." prefix from filenames
- Create a modern Response class similar to fetch API
- Use core as foundation for modern API, not as legacy adapter
- Maintain unix socket and keep-alive support
## Task Checklist
- [x] Reread /home/philkunz/.claude/CLAUDE.md
- [x] Create ts/core directory structure with request.ts, types.ts, and plugins.ts
- [x] Migrate core request logic from legacy to core/request.ts
- [x] Create modern Response class with fetch-like API
- [x] Update modern API to use new core module
- [x] Create legacy adapter for backward compatibility
- [x] Update exports in ts/index.ts
- [x] Run tests and fix any issues
- [x] Clean up old legacy files
## Implementation Details
### Core Module Structure
```
ts/core/
├── request.ts # Core HTTP/HTTPS request logic with unix socket support
├── types.ts # Core interfaces and types
├── plugins.ts # Dependencies (http, https, agentkeepalive, etc.)
└── response.ts # Modern Response class
```
### Response Class API
The new Response class will provide fetch-like methods:
- `json()`: Promise<T> - Parse response as JSON
- `text()`: Promise<string> - Get response as text
- `arrayBuffer()`: Promise<ArrayBuffer> - Get response as ArrayBuffer
- `stream()`: ReadableStream - Get response as stream
- `ok`: boolean - Status is 2xx
- `status`: number - HTTP status code
- `statusText`: string - HTTP status text
- `headers`: Headers - Response headers
### Migration Strategy
1. Move core request logic without breaking changes
2. Create Response wrapper that provides modern API
3. Update SmartRequestClient to use new core
4. Add legacy adapter for backward compatibility
5. Ensure all tests pass throughout migration

119
test/test.browser.ts Normal file
View File

@@ -0,0 +1,119 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
// For browser tests, we need to import from a browser-safe path
// that doesn't trigger Node.js module imports
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 response = await request.fire();
expect(response).not.toBeNull();
expect(response).toHaveProperty('status');
expect(response.status).toEqual(200);
const data = await response.json();
expect(data).toHaveProperty('id');
expect(data.id).toEqual(1);
expect(data).toHaveProperty('title');
});
tap.test('browser: should handle CORS requests', async () => {
const options: ICoreRequestOptions = {
headers: {
Accept: 'application/vnd.github.v3+json',
},
};
const request = new CoreRequest(
'https://api.github.com/users/github',
options,
);
const response = await request.fire();
expect(response).not.toBeNull();
expect(response.status).toEqual(200);
const data = await response.json();
expect(data).toHaveProperty('login');
expect(data.login).toEqual('github');
});
tap.test('browser: should handle request timeouts', async () => {
let timedOut = false;
const options: ICoreRequestOptions = {
timeout: 1, // Extremely short timeout to guarantee failure
};
try {
// 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;
// Accept any error since different browsers handle timeouts differently
expect(error).toBeDefined();
}
expect(timedOut).toEqual(true);
});
tap.test('browser: should handle POST requests with JSON', async () => {
const testData = {
title: 'foo',
body: 'bar',
userId: 1,
};
const options: ICoreRequestOptions = {
method: 'POST',
requestBody: testData,
};
const request = new CoreRequest(
'https://jsonplaceholder.typicode.com/posts',
options,
);
const response = await request.fire();
expect(response.status).toEqual(201);
const responseData = await response.json();
expect(responseData).toHaveProperty('id');
expect(responseData.title).toEqual(testData.title);
expect(responseData.body).toEqual(testData.body);
expect(responseData.userId).toEqual(testData.userId);
});
tap.test('browser: should handle query parameters', async () => {
const options: ICoreRequestOptions = {
queryParams: {
userId: '2',
},
};
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(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();

View File

@@ -1,45 +0,0 @@
import { tap, expect, expectAsync } from '@pushrocks/tapbundle';
import * as smartrequest from '../ts/legacy/index.js';
tap.test('should request a html document over https', async () => {
await expectAsync(smartrequest.getJson('https://encrypted.google.com/')).toHaveProperty('body');
});
tap.test('should request a JSON document over https', async () => {
await expectAsync(smartrequest.getJson('https://jsonplaceholder.typicode.com/posts/1'))
.property('body')
.property('id')
.toEqual(1);
});
tap.test('should post a JSON document over http', async () => {
const testData = { text: 'example_text' };
await expectAsync(smartrequest.postJson('https://httpbin.org/post', { requestBody: testData }))
.property('body')
.property('json')
.property('text')
.toEqual('example_text');
});
tap.test('should safe get stuff', async () => {
smartrequest.safeGet('http://coffee.link/');
smartrequest.safeGet('https://coffee.link/');
});
tap.skip.test('should deal with unix socks', async () => {
const socketResponse = await smartrequest.request(
'http://unix:/var/run/docker.sock:/containers/json',
{
headers: {
'Content-Type': 'application/json',
Host: 'docker.sock',
},
}
);
console.log(socketResponse.body);
});
tap.skip.test('should correctly upload a file using formData', async () => {});
tap.start();

View File

@@ -1,95 +0,0 @@
import { tap, expect } from '@pushrocks/tapbundle';
import { SmartRequestClient } from '../ts/modern/index.js';
tap.test('modern: should request a html document over https', async () => {
const response = await SmartRequestClient.create()
.url('https://encrypted.google.com/')
.get();
expect(response).not.toBeNull();
expect(response).toHaveProperty('status');
expect(response.status).toBeGreaterThan(0);
const text = await response.text();
expect(text.length).toBeGreaterThan(0);
});
tap.test('modern: should request a JSON document over https', async () => {
const response = await SmartRequestClient.create()
.url('https://jsonplaceholder.typicode.com/posts/1')
.get();
const body = await response.json();
expect(body).toHaveProperty('id');
expect(body.id).toEqual(1);
});
tap.test('modern: should post a JSON document over http', async () => {
const testData = { text: 'example_text' };
const response = await SmartRequestClient.create()
.url('https://httpbin.org/post')
.json(testData)
.post();
const body = await response.json();
expect(body).toHaveProperty('json');
expect(body.json).toHaveProperty('text');
expect(body.json.text).toEqual('example_text');
});
tap.test('modern: should set headers correctly', async () => {
const customHeader = 'X-Custom-Header';
const headerValue = 'test-value';
const response = await SmartRequestClient.create()
.url('https://httpbin.org/headers')
.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);
});
tap.test('modern: should handle query parameters', async () => {
const params = { param1: 'value1', param2: 'value2' };
const response = await SmartRequestClient.create()
.url('https://httpbin.org/get')
.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');
});
tap.test('modern: should handle timeout configuration', async () => {
// This test just verifies that the timeout method doesn't throw
const client = SmartRequestClient.create()
.url('https://httpbin.org/get')
.timeout(5000);
const response = await client.get();
expect(response).toHaveProperty('ok');
expect(response.ok).toBeTrue();
});
tap.test('modern: should handle retry configuration', async () => {
// This test just verifies that the retry method doesn't throw
const client = SmartRequestClient.create()
.url('https://httpbin.org/get')
.retry(1);
const response = await client.get();
expect(response).toHaveProperty('ok');
expect(response.ok).toBeTrue();
});
tap.start();

221
test/test.node.ts Normal file
View File

@@ -0,0 +1,221 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { SmartRequest } from '../ts/client/index.js';
tap.test('client: should request a html document over https', async () => {
const response = await SmartRequest.create()
.url('https://encrypted.google.com/')
.get();
expect(response).not.toBeNull();
expect(response).toHaveProperty('status');
expect(response.status).toBeGreaterThan(0);
const text = await response.text();
expect(text.length).toBeGreaterThan(0);
});
tap.test('client: should request a JSON document over https', async () => {
const response = await SmartRequest.create()
.url('https://jsonplaceholder.typicode.com/posts/1')
.get();
const body = await response.json();
expect(body).toHaveProperty('id');
expect(body.id).toEqual(1);
});
tap.test('client: should post a JSON document over http', async () => {
const testData = { title: 'example_text', body: 'test body', userId: 1 };
const response = await SmartRequest.create()
.url('https://jsonplaceholder.typicode.com/posts')
.json(testData)
.post();
const body = await response.json();
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 () => {
const customHeader = 'X-Custom-Header';
const headerValue = 'test-value';
const response = await SmartRequest.create()
.url('https://echo.zuplo.io/')
.header(customHeader, headerValue)
.get();
const body = await response.json();
expect(body).toHaveProperty('headers');
// 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 = { userId: '1' };
const response = await SmartRequest.create()
.url('https://jsonplaceholder.typicode.com/posts')
.query(params)
.get();
const body = await response.json();
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://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://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();

View 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
View 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
View 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
View 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();

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartrequest', name: '@push.rocks/smartrequest',
version: '2.1.0', version: '4.3.1',
description: 'A module for modern HTTP/HTTPS requests with support for form data, file uploads, JSON, binary data, streams, and more.' description: 'A module for modern HTTP/HTTPS requests with support for form data, file uploads, JSON, binary data, streams, and more.'
} }

View File

@@ -1,22 +1,29 @@
import { type SmartResponse } from '../../core/index.js'; import { type CoreResponse } from '../../core/index.js';
import { type TPaginationConfig, PaginationStrategy, type TPaginatedResponse } from '../types/pagination.js'; import type { ICoreResponse } from '../../core_base/types.js';
import {
type TPaginationConfig,
PaginationStrategy,
type TPaginatedResponse,
} from '../types/pagination.js';
/** /**
* Creates a paginated response from a regular response * Creates a paginated response from a regular response
*/ */
export async function createPaginatedResponse<T>( export async function createPaginatedResponse<T>(
response: SmartResponse<any>, response: ICoreResponse<any>,
paginationConfig: TPaginationConfig, paginationConfig: TPaginationConfig,
queryParams: Record<string, string>, queryParams: Record<string, string>,
fetchNextPage: (params: Record<string, string>) => Promise<TPaginatedResponse<T>> fetchNextPage: (
params: Record<string, string>,
) => Promise<TPaginatedResponse<T>>,
): Promise<TPaginatedResponse<T>> { ): Promise<TPaginatedResponse<T>> {
// Parse response body first // Parse response body first
const body = await response.json(); const body = (await response.json()) as any;
// Default to response.body for items if response is JSON // Default to response.body for items if response is JSON
let items: T[] = Array.isArray(body) let items: T[] = Array.isArray(body)
? body ? body
: (body?.items || body?.data || body?.results || []); : body?.items || body?.data || body?.results || [];
let hasNextPage = false; let hasNextPage = false;
let nextPageParams: Record<string, string> = {}; let nextPageParams: Record<string, string> = {};
@@ -25,8 +32,14 @@ export async function createPaginatedResponse<T>(
switch (paginationConfig.strategy) { switch (paginationConfig.strategy) {
case PaginationStrategy.OFFSET: { case PaginationStrategy.OFFSET: {
const config = paginationConfig; const config = paginationConfig;
const currentPage = parseInt(queryParams[config.pageParam || 'page'] || String(config.startPage || 1)); const currentPage = parseInt(
const limit = parseInt(queryParams[config.limitParam || 'limit'] || String(config.pageSize || 20)); 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; const total = getValueByPath(body, config.totalPath || 'total') || 0;
hasNextPage = currentPage * limit < total; hasNextPage = currentPage * limit < total;
@@ -34,7 +47,7 @@ export async function createPaginatedResponse<T>(
if (hasNextPage) { if (hasNextPage) {
nextPageParams = { nextPageParams = {
...queryParams, ...queryParams,
[config.pageParam || 'page']: String(currentPage + 1) [config.pageParam || 'page']: String(currentPage + 1),
}; };
} }
break; break;
@@ -42,7 +55,10 @@ export async function createPaginatedResponse<T>(
case PaginationStrategy.CURSOR: { case PaginationStrategy.CURSOR: {
const config = paginationConfig; const config = paginationConfig;
const nextCursor = getValueByPath(body, config.cursorPath || 'nextCursor'); const nextCursor = getValueByPath(
body,
config.cursorPath || 'nextCursor',
);
const hasMore = getValueByPath(body, config.hasMorePath || 'hasMore'); const hasMore = getValueByPath(body, config.hasMorePath || 'hasMore');
hasNextPage = !!nextCursor || !!hasMore; hasNextPage = !!nextCursor || !!hasMore;
@@ -50,7 +66,7 @@ export async function createPaginatedResponse<T>(
if (hasNextPage && nextCursor) { if (hasNextPage && nextCursor) {
nextPageParams = { nextPageParams = {
...queryParams, ...queryParams,
[config.cursorParam || 'cursor']: nextCursor [config.cursorParam || 'cursor']: nextCursor,
}; };
} }
break; break;
@@ -59,7 +75,9 @@ export async function createPaginatedResponse<T>(
case PaginationStrategy.LINK_HEADER: { case PaginationStrategy.LINK_HEADER: {
const linkHeader = response.headers['link'] || ''; const linkHeader = response.headers['link'] || '';
// Handle both string and string[] types for the link header // 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); const links = parseLinkHeader(headerValue);
hasNextPage = !!links.next; hasNextPage = !!links.next;
@@ -99,7 +117,13 @@ export async function createPaginatedResponse<T>(
// Create a function to fetch all remaining pages // Create a function to fetch all remaining pages
const getAllPages = async (): Promise<T[]> => { const getAllPages = async (): Promise<T[]> => {
const allItems = [...items]; const allItems = [...items];
let currentPage: TPaginatedResponse<T> = { items, hasNextPage, getNextPage, getAllPages, response }; let currentPage: TPaginatedResponse<T> = {
items,
hasNextPage,
getNextPage,
getAllPages,
response,
};
while (currentPage.hasNextPage) { while (currentPage.hasNextPage) {
try { try {
@@ -118,7 +142,7 @@ export async function createPaginatedResponse<T>(
hasNextPage, hasNextPage,
getNextPage, getNextPage,
getAllPages, getAllPages,
response response,
}; };
} }
@@ -165,7 +189,11 @@ export function getValueByPath(obj: any, path?: string): any {
let current = obj; let current = obj;
for (const key of keys) { for (const key of keys) {
if (current === null || current === undefined || typeof current !== 'object') { if (
current === null ||
current === undefined ||
typeof current !== 'object'
) {
return undefined; return undefined;
} }
current = current[key]; current = current[key];

View File

@@ -1,11 +1,18 @@
// Export the main client // Export the main client
export { SmartRequestClient } from './smartrequestclient.js'; export { SmartRequest } from './smartrequest.js';
// Export response type from core // Export response type from core
export { SmartResponse } from '../core/index.js'; export { CoreResponse } from '../core/index.js';
// Export types // 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 { export {
PaginationStrategy, PaginationStrategy,
type TPaginationConfig as PaginationConfig, type TPaginationConfig as PaginationConfig,
@@ -13,36 +20,36 @@ export {
type CursorPaginationConfig, type CursorPaginationConfig,
type LinkPaginationConfig, type LinkPaginationConfig,
type CustomPaginationConfig, type CustomPaginationConfig,
type TPaginatedResponse as PaginatedResponse type TPaginatedResponse as PaginatedResponse,
} from './types/pagination.js'; } from './types/pagination.js';
// Convenience factory functions // Convenience factory functions
import { SmartRequestClient } from './smartrequestclient.js'; import { SmartRequest } from './smartrequest.js';
/** /**
* Create a client pre-configured for JSON requests * Create a client pre-configured for JSON requests
*/ */
export function createJsonClient<T = any>() { export function createJsonClient<T = any>() {
return SmartRequestClient.create<T>(); return SmartRequest.create<T>();
} }
/** /**
* Create a client pre-configured for form data requests * Create a client pre-configured for form data requests
*/ */
export function createFormClient<T = any>() { export function createFormClient<T = any>() {
return SmartRequestClient.create<T>(); return SmartRequest.create<T>();
} }
/** /**
* Create a client pre-configured for binary data * Create a client pre-configured for binary data
*/ */
export function createBinaryClient<T = any>() { export function createBinaryClient<T = any>() {
return SmartRequestClient.create<T>().accept('binary'); return SmartRequest.create<T>().accept('binary');
} }
/** /**
* Create a client pre-configured for streaming * Create a client pre-configured for streaming
*/ */
export function createStreamClient() { export function createStreamClient() {
return SmartRequestClient.create().accept('stream'); return SmartRequest.create().accept('stream');
} }

4
ts/client/plugins.ts Normal file
View File

@@ -0,0 +1,4 @@
// plugins for client module
import FormData from 'form-data';
export { FormData as formData };

520
ts/client/smartrequest.ts Normal file
View File

@@ -0,0 +1,520 @@
import { CoreRequest, CoreResponse } from '../core/index.js';
import type { ICoreResponse } from '../core_base/types.js';
import * as plugins from './plugins.js';
import type { ICoreRequestOptions } from '../core_base/types.js';
import type {
HttpMethod,
ResponseType,
FormField,
RateLimitConfig,
RawStreamFunction,
} from './types/common.js';
import {
type TPaginationConfig,
PaginationStrategy,
type OffsetPaginationConfig,
type CursorPaginationConfig,
type CustomPaginationConfig,
type TPaginatedResponse,
} from './types/pagination.js';
import { createPaginatedResponse } from './features/pagination.js';
/**
* Parse Retry-After header value to milliseconds
* @param retryAfter - The Retry-After header value (seconds or HTTP date)
* @returns Delay in milliseconds
*/
function parseRetryAfter(retryAfter: string | string[]): number {
// Handle array of values (take first)
const value = Array.isArray(retryAfter) ? retryAfter[0] : retryAfter;
if (!value) return 0;
// Try to parse as seconds (number)
const seconds = parseInt(value, 10);
if (!isNaN(seconds)) {
return seconds * 1000;
}
// Try to parse as HTTP date
const retryDate = new Date(value);
if (!isNaN(retryDate.getTime())) {
return Math.max(0, retryDate.getTime() - Date.now());
}
return 0;
}
/**
* Modern fluent client for making HTTP requests
*/
export class SmartRequest<T = any> {
private _url: string;
private _options: ICoreRequestOptions = {};
private _retries: number = 0;
private _queryParams: Record<string, string> = {};
private _paginationConfig?: TPaginationConfig;
private _rateLimitConfig?: RateLimitConfig;
/**
* Create a new SmartRequest instance
*/
static create<T = any>(): SmartRequest<T> {
return new SmartRequest<T>();
}
/**
* Set the URL for the request
*/
url(url: string): this {
this._url = url;
return this;
}
/**
* Set the HTTP method
*/
method(method: HttpMethod): this {
this._options.method = method;
return this;
}
/**
* Set JSON body for the request
*/
json(data: any): this {
if (!this._options.headers) {
this._options.headers = {};
}
this._options.headers['Content-Type'] = 'application/json';
this._options.requestBody = data;
return this;
}
/**
* Set form data for the request
*/
formData(data: FormField[]): this {
const form = new plugins.formData();
for (const item of data) {
if (Buffer.isBuffer(item.value)) {
form.append(item.name, item.value, {
filename: item.filename || 'file',
contentType: item.contentType || 'application/octet-stream',
});
} else {
form.append(item.name, item.value);
}
}
if (!this._options.headers) {
this._options.headers = {};
}
this._options.headers = {
...this._options.headers,
...form.getHeaders(),
};
this._options.requestBody = form;
return this;
}
/**
* Set raw buffer data for the request
*/
buffer(data: Buffer | Uint8Array, contentType?: string): this {
if (!this._options.headers) {
this._options.headers = {};
}
this._options.headers['Content-Type'] = contentType || 'application/octet-stream';
this._options.requestBody = data;
return this;
}
/**
* Stream data for the request
* Accepts Node.js Readable streams or web ReadableStream
*/
stream(stream: NodeJS.ReadableStream | ReadableStream<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
*/
timeout(ms: number): this {
this._options.timeout = ms;
this._options.hardDataCuttingTimeout = ms;
return this;
}
/**
* Set number of retry attempts
*/
retry(count: number): this {
this._retries = count;
return this;
}
/**
* Enable automatic 429 (Too Many Requests) handling with configurable backoff
*/
handle429Backoff(config?: RateLimitConfig): this {
this._rateLimitConfig = {
maxRetries: config?.maxRetries ?? 3,
respectRetryAfter: config?.respectRetryAfter ?? true,
maxWaitTime: config?.maxWaitTime ?? 60000,
fallbackDelay: config?.fallbackDelay ?? 1000,
backoffFactor: config?.backoffFactor ?? 2,
onRateLimit: config?.onRateLimit,
};
return this;
}
/**
* Set HTTP headers
*/
headers(headers: Record<string, string>): this {
if (!this._options.headers) {
this._options.headers = {};
}
this._options.headers = {
...this._options.headers,
...headers,
};
return this;
}
/**
* Set a single HTTP header
*/
header(name: string, value: string): this {
if (!this._options.headers) {
this._options.headers = {};
}
this._options.headers[name] = value;
return this;
}
/**
* Set query parameters
*/
query(params: Record<string, string>): this {
this._queryParams = {
...this._queryParams,
...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: '*/*',
};
return this.header('Accept', acceptHeaders[type]);
}
/**
* Configure pagination for requests
*/
pagination(config: TPaginationConfig): this {
this._paginationConfig = config;
return this;
}
/**
* Configure offset-based pagination (page & limit)
*/
withOffsetPagination(
config: Omit<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',
};
// Add initial pagination parameters
this.query({
[this._paginationConfig.pageParam]: String(
this._paginationConfig.startPage,
),
[this._paginationConfig.limitParam]: String(
this._paginationConfig.pageSize,
),
});
return this;
}
/**
* Configure cursor-based pagination
*/
withCursorPagination(
config: Omit<CursorPaginationConfig, 'strategy'> = {},
): this {
this._paginationConfig = {
strategy: PaginationStrategy.CURSOR,
cursorParam: config.cursorParam || 'cursor',
cursorPath: config.cursorPath || 'nextCursor',
hasMorePath: config.hasMorePath || 'hasMore',
};
return this;
}
/**
* Configure Link header-based pagination
*/
withLinkPagination(): this {
this._paginationConfig = {
strategy: PaginationStrategy.LINK_HEADER,
};
return this;
}
/**
* Configure custom pagination
*/
withCustomPagination(config: Omit<CustomPaginationConfig, 'strategy'>): this {
this._paginationConfig = {
strategy: PaginationStrategy.CUSTOM,
hasNextPage: config.hasNextPage,
getNextPageParams: config.getNextPageParams,
};
return this;
}
/**
* Make a GET request
*/
async get<R = T>(): Promise<ICoreResponse<R>> {
return this.execute<R>('GET');
}
/**
* Make a POST request
*/
async post<R = T>(): Promise<ICoreResponse<R>> {
return this.execute<R>('POST');
}
/**
* Make a PUT request
*/
async put<R = T>(): Promise<ICoreResponse<R>> {
return this.execute<R>('PUT');
}
/**
* Make a DELETE request
*/
async delete<R = T>(): Promise<ICoreResponse<R>> {
return this.execute<R>('DELETE');
}
/**
* Make a PATCH request
*/
async patch<R = T>(): Promise<ICoreResponse<R>> {
return this.execute<R>('PATCH');
}
/**
* Get paginated response
*/
async getPaginated<ItemType = T>(): Promise<TPaginatedResponse<ItemType>> {
if (!this._paginationConfig) {
throw new Error(
'Pagination not configured. Call one of the pagination methods first.',
);
}
// Default to GET if no method specified
if (!this._options.method) {
this._options.method = 'GET';
}
const response = await this.execute();
return await createPaginatedResponse<ItemType>(
response,
this._paginationConfig,
this._queryParams,
(nextPageParams) => {
// Create a new client with the same configuration but updated query params
const nextClient = new SmartRequest<ItemType>();
Object.assign(nextClient, this);
nextClient._queryParams = nextPageParams;
return nextClient.getPaginated<ItemType>();
},
);
}
/**
* Get all pages at once (use with caution for large datasets)
*/
async getAllPages<ItemType = T>(): Promise<ItemType[]> {
const firstPage = await this.getPaginated<ItemType>();
return firstPage.getAllPages();
}
/**
* Execute the HTTP request
*/
private async execute<R = T>(method?: HttpMethod): Promise<ICoreResponse<R>> {
if (method) {
this._options.method = method;
}
this._options.queryParams = this._queryParams;
// Track rate limit attempts separately
let rateLimitAttempt = 0;
let lastError: Error;
// Main retry loop
for (let attempt = 0; attempt <= this._retries; attempt++) {
try {
// Check if we have a Node.js stream or raw function that needs special handling
let requestDataFunc = null;
if ((this._options as any).__nodeStream) {
const nodeStream = (this._options as any).__nodeStream;
requestDataFunc = (req: any) => {
nodeStream.pipe(req);
};
// Remove the temporary stream reference
delete (this._options as any).__nodeStream;
} else if ((this._options as any).__rawStreamFunc) {
requestDataFunc = (this._options as any).__rawStreamFunc;
// Remove the temporary function reference
delete (this._options as any).__rawStreamFunc;
}
const request = new CoreRequest(this._url, this._options as any, requestDataFunc);
const response = (await request.fire()) as ICoreResponse<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;
// If this is the last attempt, throw the error
if (attempt === this._retries) {
throw lastError;
}
// Otherwise, wait before retrying
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
// This should never be reached due to the throw in the loop above
throw lastError;
}
}

74
ts/client/types/common.ts Normal file
View File

@@ -0,0 +1,74 @@
/**
* HTTP Methods supported by the client
*/
export type HttpMethod =
| 'GET'
| 'POST'
| 'PUT'
| 'DELETE'
| 'PATCH'
| 'HEAD'
| 'OPTIONS';
/**
* Response types supported by the client
*/
export type ResponseType = 'json' | 'text' | 'binary' | 'stream';
/**
* Form field data for multipart/form-data requests
*/
export interface FormField {
name: string;
value: string | Buffer;
filename?: string;
contentType?: string;
}
/**
* URL encoded form field
*/
export interface UrlEncodedField {
key: string;
value: string;
}
/**
* 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
shouldRetry?: (error: Error, attemptCount: number) => boolean;
}
/**
* 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
}
/**
* 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;

View File

@@ -0,0 +1,74 @@
import { type CoreResponse } from '../../core/index.js';
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
}
/**
* Configuration for offset-based pagination
*/
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")
}
/**
* Configuration for cursor-based pagination
*/
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")
}
/**
* Configuration for Link header-based pagination
*/
export interface LinkPaginationConfig {
strategy: PaginationStrategy.LINK_HEADER;
// No additional config needed, uses standard Link header format
}
/**
* Configuration for custom pagination
*/
export interface CustomPaginationConfig {
strategy: PaginationStrategy.CUSTOM;
hasNextPage: (response: ICoreResponse<any>) => boolean;
getNextPageParams: (
response: ICoreResponse<any>,
currentParams: Record<string, string>,
) => Record<string, string>;
}
/**
* Union type of all pagination configurations
*/
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
}

View File

@@ -1,4 +1,30 @@
// Core exports import * as plugins from './plugins.js';
export * from './types.js';
export * from './response.js'; // Export all base types - these are the public API
export { request, coreRequest, isUnixSocket, parseUnixSocketUrl } from './request.js'; export * from '../core_base/types.js';
const smartenvInstance = new plugins.smartenv.Smartenv();
// Dynamically load the appropriate implementation
let CoreRequest: any;
let CoreResponse: any;
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);
const impl = await smartenvInstance.getSafeNodeModule(modulePath);
CoreRequest = impl.CoreRequest;
CoreResponse = impl.CoreResponse;
} else {
// In browser, load the fetch implementation
const impl = await import('../core_fetch/index.js');
CoreRequest = impl.CoreRequest;
CoreResponse = impl.CoreResponse;
}
// Export the loaded implementations
export { CoreRequest, CoreResponse };

View File

@@ -1,19 +1,4 @@
// node native scope import * as smartenv from '@push.rocks/smartenv';
import * as fs from 'fs'; import * as smartpath from '@push.rocks/smartpath/iso';
import * as http from 'http';
import * as https from 'https';
import * as path from 'path';
export { http, https, fs, path }; export { smartenv, smartpath };
// pushrocks scope
import * as smartpromise from '@push.rocks/smartpromise';
import * as smarturl from '@push.rocks/smarturl';
export { smartpromise, smarturl };
// third party scope
import agentkeepalive from 'agentkeepalive';
import formData from 'form-data';
export { agentkeepalive, formData };

View File

@@ -1,159 +0,0 @@
import * as plugins from './plugins.js';
import * as types from './types.js';
import { SmartResponse } from './response.js';
// Keep-alive agents for connection pooling
const httpAgent = new plugins.agentkeepalive({
keepAlive: true,
maxFreeSockets: 10,
maxSockets: 100,
maxTotalSockets: 1000,
});
const httpAgentKeepAliveFalse = new plugins.agentkeepalive({
keepAlive: false,
});
const httpsAgent = new plugins.agentkeepalive.HttpsAgent({
keepAlive: true,
maxFreeSockets: 10,
maxSockets: 100,
maxTotalSockets: 1000,
});
const httpsAgentKeepAliveFalse = new plugins.agentkeepalive.HttpsAgent({
keepAlive: false,
});
/**
* Tests if a URL is a unix socket
*/
export const isUnixSocket = (url: string): boolean => {
const unixRegex = /^(http:\/\/|https:\/\/|)unix:/;
return unixRegex.test(url);
};
/**
* Parses socket path and route from unix socket URL
*/
export const parseUnixSocketUrl = (url: string): { socketPath: string; path: string } => {
const parseRegex = /(.*):(.*)/;
const result = parseRegex.exec(url);
return {
socketPath: result[1],
path: result[2],
};
};
/**
* Core request function that handles all HTTP/HTTPS requests
*/
export async function coreRequest(
urlArg: string,
optionsArg: types.ICoreRequestOptions = {},
requestDataFunc: ((req: plugins.http.ClientRequest) => void) | null = null
): Promise<plugins.http.IncomingMessage> {
const done = plugins.smartpromise.defer<plugins.http.IncomingMessage>();
// No defaults - let users explicitly set options to match fetch behavior
// Parse URL
const parsedUrl = plugins.smarturl.Smarturl.createFromUrl(urlArg, {
searchParams: optionsArg.queryParams || {},
});
optionsArg.hostname = parsedUrl.hostname;
if (parsedUrl.port) {
optionsArg.port = parseInt(parsedUrl.port, 10);
}
optionsArg.path = parsedUrl.path;
// Handle unix socket URLs
if (isUnixSocket(urlArg)) {
const { socketPath, path } = parseUnixSocketUrl(optionsArg.path);
optionsArg.socketPath = socketPath;
optionsArg.path = path;
}
// Determine agent based on protocol and keep-alive setting
if (!optionsArg.agent) {
// Only use keep-alive agents if explicitly requested
if (optionsArg.keepAlive === true) {
optionsArg.agent = parsedUrl.protocol === 'https:' ? httpsAgent : httpAgent;
} else if (optionsArg.keepAlive === false) {
optionsArg.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;
if (!requestModule) {
throw new Error(`The request to ${urlArg} is missing a viable protocol. Must be http or https`);
}
// Perform the request
const request = requestModule.request(optionsArg, async (response) => {
// Handle hard timeout
if (optionsArg.hardDataCuttingTimeout) {
setTimeout(() => {
response.destroy();
done.reject(new Error('Request timed out'));
}, optionsArg.hardDataCuttingTimeout);
}
// Always return the raw stream
done.resolve(response);
});
// Write request body
if (optionsArg.requestBody) {
if (optionsArg.requestBody instanceof plugins.formData) {
optionsArg.requestBody.pipe(request).on('finish', () => {
request.end();
});
} else {
// Write body as-is - caller is responsible for serialization
const bodyData = typeof optionsArg.requestBody === 'string'
? optionsArg.requestBody
: optionsArg.requestBody instanceof Buffer
? optionsArg.requestBody
: JSON.stringify(optionsArg.requestBody); // Still stringify for backward compatibility
request.write(bodyData);
request.end();
}
} else if (requestDataFunc) {
requestDataFunc(request);
} else {
request.end();
}
// Handle request errors
request.on('error', (e) => {
console.error(e);
request.destroy();
done.reject(e);
});
// Get response and handle response errors
const response = await done.promise;
response.on('error', (err) => {
console.error(err);
response.destroy();
});
return response;
}
/**
* Modern request function that returns a SmartResponse
*/
export async function request(
urlArg: string,
optionsArg: types.ICoreRequestOptions = {}
): Promise<SmartResponse> {
const response = await coreRequest(urlArg, optionsArg);
return new SmartResponse(response, urlArg);
}

View File

@@ -1,110 +0,0 @@
import * as plugins from './plugins.js';
import * as types from './types.js';
/**
* Modern Response class that provides a fetch-like API
*/
export class SmartResponse<T = any> implements types.ICoreResponse<T> {
private incomingMessage: plugins.http.IncomingMessage;
private bodyBufferPromise: Promise<Buffer> | null = null;
private consumed = false;
// Public properties
public readonly ok: boolean;
public readonly status: number;
public readonly statusText: string;
public readonly headers: plugins.http.IncomingHttpHeaders;
public readonly url: string;
constructor(incomingMessage: plugins.http.IncomingMessage, url: string) {
this.incomingMessage = incomingMessage;
this.url = url;
this.status = incomingMessage.statusCode || 0;
this.statusText = incomingMessage.statusMessage || '';
this.ok = this.status >= 200 && this.status < 300;
this.headers = incomingMessage.headers;
}
/**
* Ensures the body can only be consumed once
*/
private ensureNotConsumed(): void {
if (this.consumed) {
throw new Error('Body has already been consumed');
}
this.consumed = true;
}
/**
* Collects the body as a buffer
*/
private async collectBody(): Promise<Buffer> {
this.ensureNotConsumed();
if (this.bodyBufferPromise) {
return this.bodyBufferPromise;
}
this.bodyBufferPromise = new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
this.incomingMessage.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
this.incomingMessage.on('end', () => {
resolve(Buffer.concat(chunks));
});
this.incomingMessage.on('error', reject);
});
return this.bodyBufferPromise;
}
/**
* Parse response as JSON
*/
async json(): Promise<T> {
const buffer = await this.collectBody();
const text = buffer.toString('utf-8');
try {
return JSON.parse(text);
} catch (error) {
throw new Error(`Failed to parse JSON: ${error.message}`);
}
}
/**
* Get response as text
*/
async text(): Promise<string> {
const buffer = await this.collectBody();
return buffer.toString('utf-8');
}
/**
* Get response as ArrayBuffer
*/
async arrayBuffer(): Promise<ArrayBuffer> {
const buffer = await this.collectBody();
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
}
/**
* Get response as a readable stream
*/
stream(): NodeJS.ReadableStream {
this.ensureNotConsumed();
return this.incomingMessage;
}
/**
* Get the raw IncomingMessage (for legacy compatibility)
*/
raw(): plugins.http.IncomingMessage {
return this.incomingMessage;
}
}

View File

@@ -1,67 +0,0 @@
import * as plugins from './plugins.js';
/**
* Core request options extending Node.js RequestOptions
*/
export interface ICoreRequestOptions extends plugins.https.RequestOptions {
keepAlive?: boolean;
requestBody?: any;
queryParams?: { [key: string]: string };
hardDataCuttingTimeout?: number;
}
/**
* HTTP Methods supported
*/
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
/**
* Response types supported
*/
export type ResponseType = 'json' | 'text' | 'binary' | 'stream';
/**
* Extended IncomingMessage with body property (legacy compatibility)
*/
export interface IExtendedIncomingMessage<T = any> extends plugins.http.IncomingMessage {
body: T;
}
/**
* Form field data for multipart/form-data requests
*/
export interface IFormField {
name: string;
value: string | Buffer;
filename?: string;
contentType?: string;
}
/**
* URL encoded form field
*/
export interface IUrlEncodedField {
key: string;
value: string;
}
/**
* Core response object that provides fetch-like API
*/
export interface ICoreResponse<T = any> {
// Properties
ok: boolean;
status: number;
statusText: string;
headers: plugins.http.IncomingHttpHeaders;
url: string;
// Methods
json(): Promise<T>;
text(): Promise<string>;
arrayBuffer(): Promise<ArrayBuffer>;
stream(): NodeJS.ReadableStream;
// Legacy compatibility
raw(): plugins.http.IncomingMessage;
}

4
ts/core_base/index.ts Normal file
View File

@@ -0,0 +1,4 @@
// Core base exports - abstract classes and platform-agnostic types
export * from './types.js';
export * from './request.js';
export * from './response.js';

47
ts/core_base/request.ts Normal file
View File

@@ -0,0 +1,47 @@
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,
> {
/**
* Tests if a URL is a unix socket
*/
static isUnixSocket(url: string): boolean {
const unixRegex = /^(http:\/\/|https:\/\/|)unix:/;
return unixRegex.test(url);
}
/**
* Parses socket path and route from unix socket URL
*/
static parseUnixSocketUrl(url: string): { socketPath: string; path: string } {
const parseRegex = /(.*):(.*)/;
const result = parseRegex.exec(url);
return {
socketPath: result[1],
path: result[2],
};
}
protected url: string;
protected options: TOptions;
constructor(url: string, options?: TOptions) {
this.url = url;
this.options = options || ({} as TOptions);
}
/**
* Fire the request and return a response
*/
abstract fire(): Promise<TResponse>;
/**
* Fire the request and return the raw response (platform-specific)
*/
abstract fireCore(): Promise<any>;
}

50
ts/core_base/response.ts Normal file
View File

@@ -0,0 +1,50 @@
import * as types from './types.js';
/**
* Abstract Core Response class that provides a fetch-like API
*/
export abstract class CoreResponse<T = any> implements types.ICoreResponse<T> {
protected consumed = false;
// Public properties
public abstract readonly ok: boolean;
public abstract readonly status: number;
public abstract readonly statusText: string;
public abstract readonly headers: types.Headers;
public abstract readonly url: string;
/**
* Ensures the body can only be consumed once
*/
protected ensureNotConsumed(): void {
if (this.consumed) {
throw new Error('Body has already been consumed');
}
this.consumed = true;
}
/**
* Parse response as JSON
*/
abstract json(): Promise<T>;
/**
* Get response as text
*/
abstract text(): Promise<string>;
/**
* Get response as ArrayBuffer
*/
abstract arrayBuffer(): Promise<ArrayBuffer>;
/**
* 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;
}

90
ts/core_base/types.ts Normal file
View File

@@ -0,0 +1,90 @@
/**
* HTTP Methods supported
*/
export type THttpMethod =
| 'GET'
| 'POST'
| 'PUT'
| 'DELETE'
| 'PATCH'
| 'HEAD'
| 'OPTIONS';
/**
* Response types supported
*/
export type ResponseType = 'json' | 'text' | 'binary' | 'stream';
/**
* Form field data for multipart/form-data requests
*/
export interface IFormField {
name: string;
value: string | Buffer;
filename?: string;
contentType?: string;
}
/**
* URL encoded form field
*/
export interface IUrlEncodedField {
key: string;
value: string;
}
/**
* Core request options - unified interface for all implementations
*/
export interface ICoreRequestOptions {
// Common options
method?: THttpMethod | string; // Allow string for compatibility
headers?: any; // Allow any for platform compatibility
keepAlive?: boolean;
requestBody?: any;
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;
socketPath?: string;
hostname?: string;
port?: number;
path?: string;
// Fetch API specific options (ignored in Node.js implementation)
credentials?: RequestCredentials;
mode?: RequestMode;
cache?: RequestCache;
redirect?: RequestRedirect;
referrer?: string;
referrerPolicy?: ReferrerPolicy;
integrity?: string;
signal?: AbortSignal;
}
/**
* Response headers - platform agnostic
*/
export type Headers = Record<string, string | string[]>;
/**
* Core response interface - platform agnostic
*/
export interface ICoreResponse<T = any> {
// Properties
ok: boolean;
status: number;
statusText: string;
headers: Headers;
url: string;
// Methods
json(): Promise<T>;
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
}

3
ts/core_fetch/index.ts Normal file
View File

@@ -0,0 +1,3 @@
// Core fetch exports - native fetch implementation
export * from './response.js';
export { CoreRequest } from './request.js';

177
ts/core_fetch/request.ts Normal file
View File

@@ -0,0 +1,177 @@
import * as types from './types.js';
import { CoreResponse } from './response.js';
import { CoreRequest as AbstractCoreRequest } from '../core_base/request.js';
/**
* Fetch-based implementation of Core Request class
*/
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',
);
}
}
/**
* Build the full URL with query parameters
*/
private buildUrl(): string {
if (
!this.options.queryParams ||
Object.keys(this.options.queryParams).length === 0
) {
return this.url;
}
const url = new URL(this.url);
Object.entries(this.options.queryParams).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
return url.toString();
}
/**
* Convert our options to fetch RequestInit
*/
private buildFetchOptions(): RequestInit {
const fetchOptions: RequestInit = {
method: this.options.method,
headers: this.options.headers,
credentials: this.options.credentials,
mode: this.options.mode,
cache: this.options.cache,
redirect: this.options.redirect,
referrer: this.options.referrer,
referrerPolicy: this.options.referrerPolicy,
integrity: this.options.integrity,
keepalive: this.options.keepAlive,
signal: this.options.signal,
};
// Handle request body
if (this.options.requestBody !== undefined) {
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);
// Set content-type if not already set
if (!fetchOptions.headers) {
fetchOptions.headers = { 'Content-Type': 'application/json' };
} else if (fetchOptions.headers instanceof Headers) {
if (!fetchOptions.headers.has('Content-Type')) {
fetchOptions.headers.set('Content-Type', 'application/json');
}
} 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';
}
}
}
}
// Handle timeout
if (this.options.timeout || this.options.hardDataCuttingTimeout) {
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;
}
/**
* Fire the request and return a CoreResponse
*/
async fire(): Promise<CoreResponse> {
const response = await this.fireCore();
return new CoreResponse(response);
}
/**
* Fire the request and return the raw Response
*/
async fireCore(): Promise<Response> {
const url = this.buildUrl();
const options = this.buildFetchOptions();
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');
}
throw error;
}
}
/**
* 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 = {},
): Promise<CoreResponse> {
const request = new CoreRequest(url, options);
return request.fire();
}
}
/**
* Convenience exports for backward compatibility
*/
export const isUnixSocket = CoreRequest.isUnixSocket;
export const parseUnixSocketUrl = CoreRequest.parseUnixSocketUrl;

90
ts/core_fetch/response.ts Normal file
View File

@@ -0,0 +1,90 @@
import * as types from './types.js';
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>
{
private response: Response;
private responseClone: Response;
// Public properties
public readonly ok: boolean;
public readonly status: number;
public readonly statusText: string;
public readonly headers: types.Headers;
public readonly url: string;
constructor(response: Response) {
super();
// Clone the response so we can read the body multiple times if needed
this.response = response;
this.responseClone = response.clone();
this.ok = response.ok;
this.status = response.status;
this.statusText = response.statusText;
this.url = response.url;
// Convert Headers to plain object
this.headers = {};
response.headers.forEach((value, key) => {
this.headers[key] = value;
});
}
/**
* Parse response as JSON
*/
async json(): Promise<T> {
this.ensureNotConsumed();
try {
return await this.response.json();
} catch (error) {
throw new Error(`Failed to parse JSON: ${error.message}`);
}
}
/**
* Get response as text
*/
async text(): Promise<string> {
this.ensureNotConsumed();
return await this.response.text();
}
/**
* Get response as ArrayBuffer
*/
async arrayBuffer(): Promise<ArrayBuffer> {
this.ensureNotConsumed();
return await this.response.arrayBuffer();
}
/**
* Get response as a readable stream (Web Streams API)
*/
stream(): ReadableStream<Uint8Array> | null {
this.ensureNotConsumed();
return this.response.body;
}
/**
* 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.',
);
}
/**
* Get the raw Response object
*/
raw(): Response {
return this.responseClone;
}
}

12
ts/core_fetch/types.ts Normal file
View File

@@ -0,0 +1,12 @@
import * as baseTypes from '../core_base/types.js';
// Re-export base types
export * from '../core_base/types.js';
/**
* Fetch-specific response extensions
*/
export interface IFetchResponse<T = any> extends baseTypes.ICoreResponse<T> {
// Access to raw Response object
raw(): Response;
}

3
ts/core_node/index.ts Normal file
View File

@@ -0,0 +1,3 @@
// Core exports
export * from './response.js';
export { CoreRequest } from './request.js';

20
ts/core_node/plugins.ts Normal file
View File

@@ -0,0 +1,20 @@
// node native scope
import * as fs from 'fs';
import * as http from 'http';
import * as https from 'https';
import * as path from 'path';
export { http, https, fs, path };
// pushrocks scope
import * as smartpromise from '@push.rocks/smartpromise';
import * as smarturl from '@push.rocks/smarturl';
export { smartpromise, smarturl };
// third party scope
import { HttpAgent, HttpsAgent } from 'agentkeepalive';
const agentkeepalive = { HttpAgent, HttpsAgent };
import formData from 'form-data';
export { agentkeepalive, formData };

206
ts/core_node/request.ts Normal file
View File

@@ -0,0 +1,206 @@
import * as plugins from './plugins.js';
import * as types from './types.js';
import { CoreResponse } from './response.js';
import { CoreRequest as AbstractCoreRequest } from '../core_base/request.js';
// Keep-alive agents for connection pooling
const httpAgent = new plugins.agentkeepalive.HttpAgent({
keepAlive: true,
maxFreeSockets: 10,
maxSockets: 100,
maxTotalSockets: 1000,
});
const httpAgentKeepAliveFalse = new plugins.agentkeepalive.HttpAgent({
keepAlive: false,
});
const httpsAgent = new plugins.agentkeepalive.HttpsAgent({
keepAlive: true,
maxFreeSockets: 10,
maxSockets: 100,
maxTotalSockets: 1000,
});
const httpsAgentKeepAliveFalse = new plugins.agentkeepalive.HttpsAgent({
keepAlive: false,
});
/**
* Node.js implementation of Core Request class that handles all HTTP/HTTPS requests
*/
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,
) {
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',
);
}
}
/**
* Fire the request and return a CoreResponse
*/
async fire(): Promise<CoreResponse> {
const incomingMessage = await this.fireCore();
return new CoreResponse(incomingMessage, this.url, this.options);
}
/**
* Fire the request and return the raw IncomingMessage
*/
async fireCore(): Promise<plugins.http.IncomingMessage> {
const done = plugins.smartpromise.defer<plugins.http.IncomingMessage>();
// Parse URL
const parsedUrl = plugins.smarturl.Smarturl.createFromUrl(this.url, {
searchParams: this.options.queryParams || {},
});
this.options.hostname = parsedUrl.hostname;
if (parsedUrl.port) {
this.options.port = parseInt(parsedUrl.port, 10);
}
this.options.path = parsedUrl.path;
// Handle unix socket URLs
if (CoreRequest.isUnixSocket(this.url)) {
const { socketPath, path } = CoreRequest.parseUnixSocketUrl(
this.options.path,
);
this.options.socketPath = socketPath;
this.options.path = path;
}
// Determine agent based on protocol and keep-alive setting
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;
} else if (this.options.keepAlive === false) {
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;
if (!requestModule) {
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) {
timeoutId = setTimeout(() => {
response.destroy();
done.reject(new Error('Request timed out'));
}, this.options.hardDataCuttingTimeout);
}
// Always return the raw stream
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) {
this.options.requestBody.pipe(request).on('finish', () => {
request.end();
});
} 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
? this.options.requestBody
: JSON.stringify(this.options.requestBody); // Still stringify for backward compatibility
request.write(bodyData);
request.end();
}
} else if (this.requestDataFunc) {
this.requestDataFunc(request);
} else {
request.end();
}
// Handle request errors
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();
});
return response;
}
/**
* Static factory method to create and fire a request
*/
static async create(
url: string,
options: types.ICoreRequestOptions = {},
): Promise<CoreResponse> {
const request = new CoreRequest(url, options);
return request.fire();
}
}

173
ts/core_node/response.ts Normal file
View File

@@ -0,0 +1,173 @@
import * as plugins from './plugins.js';
import * as types from './types.js';
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>
{
private incomingMessage: plugins.http.IncomingMessage;
private bodyBufferPromise: Promise<Buffer> | null = null;
private _autoDrainTimeout: NodeJS.Immediate | null = null;
// Public properties
public readonly ok: boolean;
public readonly status: number;
public readonly statusText: string;
public readonly headers: plugins.http.IncomingHttpHeaders;
public readonly url: string;
constructor(
incomingMessage: plugins.http.IncomingMessage,
url: string,
options: types.ICoreRequestOptions = {},
) {
super();
this.incomingMessage = incomingMessage;
this.url = url;
this.status = incomingMessage.statusCode || 0;
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();
}
/**
* Collects the body as a buffer
*/
private async collectBody(): Promise<Buffer> {
this.ensureNotConsumed();
if (this.bodyBufferPromise) {
return this.bodyBufferPromise;
}
this.bodyBufferPromise = new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
this.incomingMessage.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
this.incomingMessage.on('end', () => {
resolve(Buffer.concat(chunks));
});
this.incomingMessage.on('error', reject);
});
return this.bodyBufferPromise;
}
/**
* Parse response as JSON
*/
async json(): Promise<T> {
const buffer = await this.collectBody();
const text = buffer.toString('utf-8');
try {
return JSON.parse(text);
} catch (error) {
throw new Error(`Failed to parse JSON: ${error.message}`);
}
}
/**
* Get response as text
*/
async text(): Promise<string> {
const buffer = await this.collectBody();
return buffer.toString('utf-8');
}
/**
* Get response as ArrayBuffer
*/
async arrayBuffer(): Promise<ArrayBuffer> {
const buffer = await this.collectBody();
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
);
}
/**
* Get response as a web-style ReadableStream
*/
stream(): ReadableStream<Uint8Array> | null {
this.ensureNotConsumed();
// Convert Node.js stream to web stream
// In Node.js 16.5+ we can use Readable.toWeb()
if (this.incomingMessage.readableEnded || this.incomingMessage.destroyed) {
return null;
}
// Create a web ReadableStream from the Node.js stream
const nodeStream = this.incomingMessage;
return new ReadableStream<Uint8Array>({
start(controller) {
nodeStream.on('data', (chunk) => {
controller.enqueue(new Uint8Array(chunk));
});
nodeStream.on('end', () => {
controller.close();
});
nodeStream.on('error', (err) => {
controller.error(err);
});
},
cancel() {
nodeStream.destroy();
},
});
}
/**
* Get response as a Node.js readable stream
*/
streamNode(): NodeJS.ReadableStream {
this.ensureNotConsumed();
return this.incomingMessage;
}
/**
* Get the raw IncomingMessage (for legacy compatibility)
*/
raw(): plugins.http.IncomingMessage {
return this.incomingMessage;
}
}

21
ts/core_node/types.ts Normal file
View File

@@ -0,0 +1,21 @@
import * as plugins from './plugins.js';
import * as baseTypes from '../core_base/types.js';
// Re-export base types
export * from '../core_base/types.js';
/**
* Extended IncomingMessage with body property (legacy compatibility)
*/
export interface IExtendedIncomingMessage<T = any>
extends plugins.http.IncomingMessage {
body: T;
}
/**
* Node.js specific response extensions
*/
export interface INodeResponse<T = any> extends baseTypes.ICoreResponse<T> {
// Legacy compatibility
raw(): plugins.http.IncomingMessage;
}

View File

@@ -1,12 +1,10 @@
// Legacy API exports (for backward compatibility) // Client API exports
export * from './legacy/index.js'; export * from './client/index.js';
// Modern API exports
export * from './modern/index.js';
// Core exports for advanced usage // Core exports for advanced usage
export { SmartResponse, type ICoreRequestOptions, type ICoreResponse } from './core/index.js'; export { CoreResponse } from './core/index.js';
export type { ICoreRequestOptions, ICoreResponse } from './core_base/types.js';
// Default export for easier importing // Default export for easier importing
import { SmartRequestClient } from './modern/smartrequestclient.js'; import { SmartRequest } from './client/smartrequest.js';
export default SmartRequestClient; export default SmartRequest;

View File

@@ -1,242 +0,0 @@
/**
* Legacy adapter that provides backward compatibility
* Maps legacy API to the new core module
*/
import * as core from '../core/index.js';
import * as plugins from '../core/plugins.js';
const smartpromise = plugins.smartpromise;
// Re-export types for backward compatibility
export { type IExtendedIncomingMessage } from '../core/types.js';
export interface ISmartRequestOptions extends core.ICoreRequestOptions {
autoJsonParse?: boolean;
responseType?: 'json' | 'text' | 'binary' | 'stream';
}
// Re-export interface for form fields
export interface IFormField {
name: string;
type: 'string' | 'filePath' | 'Buffer';
payload: string | Buffer;
fileName?: string;
contentType?: string;
}
/**
* Helper function to convert stream to IExtendedIncomingMessage for legacy compatibility
*/
async function streamToExtendedMessage(
stream: plugins.http.IncomingMessage,
autoJsonParse = true
): Promise<core.IExtendedIncomingMessage> {
const done = smartpromise.defer<core.IExtendedIncomingMessage>();
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
stream.on('end', () => {
const buffer = Buffer.concat(chunks);
const extendedMessage = stream as core.IExtendedIncomingMessage;
if (autoJsonParse) {
const text = buffer.toString('utf-8');
try {
extendedMessage.body = JSON.parse(text);
} catch (err) {
extendedMessage.body = text;
}
} else {
extendedMessage.body = buffer;
}
done.resolve(extendedMessage);
});
stream.on('error', (err) => {
done.reject(err);
});
return done.promise;
}
/**
* Legacy request function that returns IExtendedIncomingMessage
*/
export async function request(
urlArg: string,
optionsArg: ISmartRequestOptions = {},
responseStreamArg = false,
requestDataFunc?: (req: plugins.http.ClientRequest) => void
): Promise<core.IExtendedIncomingMessage> {
const stream = await core.coreRequest(urlArg, optionsArg, requestDataFunc);
if (responseStreamArg) {
// For stream responses, just cast and return
return stream as core.IExtendedIncomingMessage;
}
// Convert stream to IExtendedIncomingMessage
const autoJsonParse = optionsArg.autoJsonParse !== false;
return streamToExtendedMessage(stream, autoJsonParse);
}
/**
* Safe GET request
*/
export async function safeGet(urlArg: string): Promise<core.IExtendedIncomingMessage | null> {
const agentToUse = urlArg.startsWith('http://')
? new plugins.http.Agent()
: new plugins.https.Agent();
try {
const response = await request(urlArg, {
method: 'GET',
agent: agentToUse,
timeout: 5000,
hardDataCuttingTimeout: 5000,
autoJsonParse: false,
});
return response;
} catch (err) {
console.error(err);
return null;
}
}
/**
* GET JSON request
*/
export async function getJson(urlArg: string, optionsArg: ISmartRequestOptions = {}) {
optionsArg.method = 'GET';
return request(urlArg, optionsArg);
}
/**
* POST JSON request
*/
export async function postJson(urlArg: string, optionsArg: ISmartRequestOptions = {}) {
optionsArg.method = 'POST';
if (
typeof optionsArg.requestBody === 'object' &&
(!optionsArg.headers || !optionsArg.headers['Content-Type'])
) {
// make sure headers exist
if (!optionsArg.headers) {
optionsArg.headers = {};
}
// assign the right Content-Type, leaving all other headers in place
optionsArg.headers = {
...optionsArg.headers,
'Content-Type': 'application/json',
};
}
return request(urlArg, optionsArg);
}
/**
* PUT JSON request
*/
export async function putJson(urlArg: string, optionsArg: ISmartRequestOptions = {}) {
optionsArg.method = 'PUT';
return request(urlArg, optionsArg);
}
/**
* DELETE JSON request
*/
export async function delJson(urlArg: string, optionsArg: ISmartRequestOptions = {}) {
optionsArg.method = 'DELETE';
return request(urlArg, optionsArg);
}
/**
* GET binary data
*/
export async function getBinary(urlArg: string, optionsArg: ISmartRequestOptions = {}) {
optionsArg = {
...optionsArg,
autoJsonParse: false,
responseType: 'binary'
};
return request(urlArg, optionsArg);
}
/**
* POST form data
*/
export async function postFormData(urlArg: string, formFields: IFormField[], optionsArg: ISmartRequestOptions = {}) {
const form = new plugins.formData();
for (const formField of formFields) {
if (formField.type === 'filePath') {
const fileData = plugins.fs.readFileSync(
plugins.path.isAbsolute(formField.payload as string)
? formField.payload as string
: plugins.path.join(process.cwd(), formField.payload as string)
);
form.append(formField.name, fileData, {
filename: formField.fileName || plugins.path.basename(formField.payload as string),
contentType: formField.contentType
});
} else if (formField.type === 'Buffer') {
form.append(formField.name, formField.payload, {
filename: formField.fileName,
contentType: formField.contentType
});
} else {
form.append(formField.name, formField.payload);
}
}
optionsArg.method = 'POST';
optionsArg.requestBody = form;
if (!optionsArg.headers) {
optionsArg.headers = {};
}
optionsArg.headers = {
...optionsArg.headers,
...form.getHeaders()
};
return request(urlArg, optionsArg);
}
/**
* POST URL encoded form data
*/
export async function postFormDataUrlEncoded(
urlArg: string,
formFields: { key: string; content: string }[],
optionsArg: ISmartRequestOptions = {}
) {
optionsArg.method = 'POST';
if (!optionsArg.headers) {
optionsArg.headers = {};
}
optionsArg.headers['Content-Type'] = 'application/x-www-form-urlencoded';
const urlEncodedBody = formFields
.map(field => `${encodeURIComponent(field.key)}=${encodeURIComponent(field.content)}`)
.join('&');
optionsArg.requestBody = urlEncodedBody;
return request(urlArg, optionsArg);
}
/**
* GET stream
*/
export async function getStream(
urlArg: string,
optionsArg: ISmartRequestOptions = {}
): Promise<plugins.http.IncomingMessage> {
optionsArg.method = 'GET';
const response = await request(urlArg, optionsArg, true);
return response;
}

View File

@@ -1,2 +0,0 @@
// Export everything from the legacy adapter
export * from './adapter.js';

View File

@@ -1,329 +0,0 @@
import { request, SmartResponse, type ICoreRequestOptions } from '../core/index.js';
import * as plugins from '../core/plugins.js';
import type { HttpMethod, ResponseType, FormField } from './types/common.js';
import {
type TPaginationConfig,
PaginationStrategy,
type OffsetPaginationConfig,
type CursorPaginationConfig,
type CustomPaginationConfig,
type TPaginatedResponse
} from './types/pagination.js';
import { createPaginatedResponse } from './features/pagination.js';
/**
* Modern fluent client for making HTTP requests
*/
export class SmartRequestClient<T = any> {
private _url: string;
private _options: ICoreRequestOptions = {};
private _retries: number = 0;
private _queryParams: Record<string, string> = {};
private _paginationConfig?: TPaginationConfig;
/**
* Create a new SmartRequestClient instance
*/
static create<T = any>(): SmartRequestClient<T> {
return new SmartRequestClient<T>();
}
/**
* Set the URL for the request
*/
url(url: string): this {
this._url = url;
return this;
}
/**
* Set the HTTP method
*/
method(method: HttpMethod): this {
this._options.method = method;
return this;
}
/**
* Set JSON body for the request
*/
json(data: any): this {
if (!this._options.headers) {
this._options.headers = {};
}
this._options.headers['Content-Type'] = 'application/json';
this._options.requestBody = data;
return this;
}
/**
* Set form data for the request
*/
formData(data: FormField[]): this {
const form = new plugins.formData();
for (const item of data) {
if (Buffer.isBuffer(item.value)) {
form.append(item.name, item.value, {
filename: item.filename || 'file',
contentType: item.contentType || 'application/octet-stream'
});
} else {
form.append(item.name, item.value);
}
}
if (!this._options.headers) {
this._options.headers = {};
}
this._options.headers = {
...this._options.headers,
...form.getHeaders()
};
this._options.requestBody = form;
return this;
}
/**
* Set request timeout in milliseconds
*/
timeout(ms: number): this {
this._options.timeout = ms;
this._options.hardDataCuttingTimeout = ms;
return this;
}
/**
* Set number of retry attempts
*/
retry(count: number): this {
this._retries = count;
return this;
}
/**
* Set HTTP headers
*/
headers(headers: Record<string, string>): this {
if (!this._options.headers) {
this._options.headers = {};
}
this._options.headers = {
...this._options.headers,
...headers
};
return this;
}
/**
* Set a single HTTP header
*/
header(name: string, value: string): this {
if (!this._options.headers) {
this._options.headers = {};
}
this._options.headers[name] = value;
return this;
}
/**
* Set query parameters
*/
query(params: Record<string, string>): this {
this._queryParams = {
...this._queryParams,
...params
};
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': '*/*'
};
return this.header('Accept', acceptHeaders[type]);
}
/**
* Configure pagination for requests
*/
pagination(config: TPaginationConfig): this {
this._paginationConfig = config;
return this;
}
/**
* Configure offset-based pagination (page & limit)
*/
withOffsetPagination(config: Omit<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'
};
// Add initial pagination parameters
this.query({
[this._paginationConfig.pageParam]: String(this._paginationConfig.startPage),
[this._paginationConfig.limitParam]: String(this._paginationConfig.pageSize)
});
return this;
}
/**
* Configure cursor-based pagination
*/
withCursorPagination(config: Omit<CursorPaginationConfig, 'strategy'> = {}): this {
this._paginationConfig = {
strategy: PaginationStrategy.CURSOR,
cursorParam: config.cursorParam || 'cursor',
cursorPath: config.cursorPath || 'nextCursor',
hasMorePath: config.hasMorePath || 'hasMore'
};
return this;
}
/**
* Configure Link header-based pagination
*/
withLinkPagination(): this {
this._paginationConfig = {
strategy: PaginationStrategy.LINK_HEADER
};
return this;
}
/**
* Configure custom pagination
*/
withCustomPagination(config: Omit<CustomPaginationConfig, 'strategy'>): this {
this._paginationConfig = {
strategy: PaginationStrategy.CUSTOM,
hasNextPage: config.hasNextPage,
getNextPageParams: config.getNextPageParams
};
return this;
}
/**
* Make a GET request
*/
async get<R = T>(): Promise<SmartResponse<R>> {
return this.execute<R>('GET');
}
/**
* Make a POST request
*/
async post<R = T>(): Promise<SmartResponse<R>> {
return this.execute<R>('POST');
}
/**
* Make a PUT request
*/
async put<R = T>(): Promise<SmartResponse<R>> {
return this.execute<R>('PUT');
}
/**
* Make a DELETE request
*/
async delete<R = T>(): Promise<SmartResponse<R>> {
return this.execute<R>('DELETE');
}
/**
* Make a PATCH request
*/
async patch<R = T>(): Promise<SmartResponse<R>> {
return this.execute<R>('PATCH');
}
/**
* Get paginated response
*/
async getPaginated<ItemType = T>(): Promise<TPaginatedResponse<ItemType>> {
if (!this._paginationConfig) {
throw new Error('Pagination not configured. Call one of the pagination methods first.');
}
// Default to GET if no method specified
if (!this._options.method) {
this._options.method = 'GET';
}
const response = await this.execute();
return await createPaginatedResponse<ItemType>(
response,
this._paginationConfig,
this._queryParams,
(nextPageParams) => {
// Create a new client with the same configuration but updated query params
const nextClient = new SmartRequestClient<ItemType>();
Object.assign(nextClient, this);
nextClient._queryParams = nextPageParams;
return nextClient.getPaginated<ItemType>();
}
);
}
/**
* Get all pages at once (use with caution for large datasets)
*/
async getAllPages<ItemType = T>(): Promise<ItemType[]> {
const firstPage = await this.getPaginated<ItemType>();
return firstPage.getAllPages();
}
/**
* Execute the HTTP request
*/
private async execute<R = T>(method?: HttpMethod): Promise<SmartResponse<R>> {
if (method) {
this._options.method = method;
}
this._options.queryParams = this._queryParams;
// Handle retry logic
let lastError: Error;
for (let attempt = 0; attempt <= this._retries; attempt++) {
try {
const response = await request(this._url, this._options);
return response as SmartResponse<R>;
} catch (error) {
lastError = error as Error;
// If this is the last attempt, throw the error
if (attempt === this._retries) {
throw lastError;
}
// Otherwise, wait before retrying
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// This should never be reached due to the throw in the loop above
throw lastError;
}
}

View File

@@ -1,49 +0,0 @@
/**
* HTTP Methods supported by the client
*/
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
/**
* Response types supported by the client
*/
export type ResponseType = 'json' | 'text' | 'binary' | 'stream';
/**
* Form field data for multipart/form-data requests
*/
export interface FormField {
name: string;
value: string | Buffer;
filename?: string;
contentType?: string;
}
/**
* URL encoded form field
*/
export interface UrlEncodedField {
key: string;
value: string;
}
/**
* 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
shouldRetry?: (error: Error, attemptCount: number) => boolean;
}
/**
* 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
}

View File

@@ -1,66 +0,0 @@
import { type SmartResponse } from '../../core/index.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
}
/**
* Configuration for offset-based pagination
*/
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")
}
/**
* Configuration for cursor-based pagination
*/
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")
}
/**
* Configuration for Link header-based pagination
*/
export interface LinkPaginationConfig {
strategy: PaginationStrategy.LINK_HEADER;
// No additional config needed, uses standard Link header format
}
/**
* Configuration for custom pagination
*/
export interface CustomPaginationConfig {
strategy: PaginationStrategy.CUSTOM;
hasNextPage: (response: SmartResponse<any>) => boolean;
getNextPageParams: (response: SmartResponse<any>, currentParams: Record<string, string>) => Record<string, string>;
}
/**
* Union type of all pagination configurations
*/
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: SmartResponse<any>; // Original response
}

View File

@@ -6,9 +6,9 @@
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"esModuleInterop": true, "esModuleInterop": true,
"verbatimModuleSyntax": true "verbatimModuleSyntax": true,
"baseUrl": ".",
"paths": {}
}, },
"exclude": [ "exclude": ["dist_*/**/*.d.ts"]
"dist_*/**/*.d.ts"
]
} }