Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3ba77050a | |||
| 6211acd60b | |||
| 32332309dc | |||
| 9d29bd92da | |||
| 6d148bb59e | |||
| e0f586693c | |||
| df28cd4778 | |||
| f49cbd2b6a | |||
| 984b53cba2 | |||
| 4c55243646 | |||
| 49cfcaedd1 | |||
| 3996a69f91 | |||
| 629f6dd425 | |||
| d141ceeaf7 | |||
| 7d3c94cae6 | |||
| 5bae452365 | |||
| ffabcf7bdb | |||
| 361d97f440 | |||
| 35867d9148 | |||
| d455a34632 | |||
| 9c5a939499 | |||
| 7b2081dc4d | |||
| ee750dea58 | |||
| 9b9c8fd618 | |||
| 1991308d4a | |||
| b4769e7feb | |||
| 4cbca08f43 | |||
| cf24bf94b9 | |||
| 3e24f1c5a8 | |||
| 2dc82bd730 | |||
| 8e75047d1f | |||
| eb2ccd8d9f | |||
| bc99aa3569 | |||
| 94bf23ad55 | |||
| ea54a8aeda | |||
| 18d8ab0278 | |||
| b8d707b363 | |||
| 7dcc5f3fe2 | |||
| 8f5c88b47e | |||
| 28a56b87bc | |||
| d627bc870e | |||
| 2cded974a8 | |||
| 31c25c8333 | |||
| 01bbfa4a06 | |||
| 0ebd47d1b2 | |||
| bbb57004d9 | |||
| f7d2c6de4f | |||
| b8f545cdd5 | |||
| 96820090d4 | |||
| 6e2c63fe1b | |||
| 39d3bb4d24 | |||
| 62db3a9bc5 | |||
| a82d9eafe2 | |||
| f55ab55365 | |||
| 9cf8c0b0f3 | |||
| 49796f89dd | |||
| aaeaa23b79 | |||
| 85199b8839 | |||
| 33f7df28e2 | |||
| 29ee46b3a2 | |||
| 18dd110d4e | |||
| fa93f13306 | |||
| 81694cf58c | |||
| fdd1c7cdb3 | |||
| 40f330791f | |||
| 16b4d168db | |||
| cc017a9bbf | |||
| 2adf7e9ee2 | |||
| 2303b6da7e | |||
| 4487579bfd | |||
| 8d2bbcae2a | |||
| deb25a3068 | |||
| 0a83d8b476 | |||
| 8e7c730d86 | |||
| fe50adb1ff | |||
| cd75f7acd8 | |||
| bb0dd6426a | |||
| d471376681 | |||
| b882922f2b | |||
| 9a0d35c325 | |||
| 7b49bba0d2 | |||
| 6600a23a00 | |||
| e2845c9992 | |||
| 5e6f2c6fbf | |||
| d3d0649b73 | |||
| fba43df3c4 | |||
| c6fa540543 | |||
| 1891b54389 | |||
| fee8443af1 | |||
| c48f956ae3 | |||
| 4a4b64a2c4 | |||
| 43d4b47782 | |||
| 6d970cb925 | |||
| 43710c930e | |||
| 306dd7c366 | |||
| 3d69d97891 | |||
| a6d52702fd | |||
| de31ee6093 | |||
| cd2d7b2525 | |||
| 2d4a75c9cd |
56
.gitea/workflows/default_nottags.yaml
Normal file
56
.gitea/workflows/default_nottags.yaml
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Default (not tags)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags-ignore:
|
||||
- '**'
|
||||
|
||||
env:
|
||||
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
NPMCI_URL_CLOUDLY: ${{secrets.NPMCI_URL_CLOUDLY}}
|
||||
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: |
|
||||
npm config set registry https://registry.npmjs.org
|
||||
pnpm audit --audit-level=high --prod
|
||||
continue-on-error: true
|
||||
|
||||
- name: Audit development dependencies
|
||||
run: |
|
||||
npm config set registry https://registry.npmjs.org
|
||||
pnpm audit --audit-level=high --dev
|
||||
continue-on-error: true
|
||||
|
||||
test:
|
||||
if: ${{ always() }}
|
||||
needs: security
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Test stable
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm test
|
||||
|
||||
- name: Test build
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm build
|
||||
111
.gitea/workflows/default_tags.yaml
Normal file
111
.gitea/workflows/default_tags.yaml
Normal file
@@ -0,0 +1,111 @@
|
||||
name: Default (tags)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
env:
|
||||
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
|
||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
NPMCI_URL_CLOUDLY: ${{secrets.NPMCI_URL_CLOUDLY}}
|
||||
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: |
|
||||
npm config set registry https://registry.npmjs.org
|
||||
pnpm audit --audit-level=high --prod
|
||||
continue-on-error: true
|
||||
|
||||
- name: Audit development dependencies
|
||||
run: |
|
||||
npm config set registry https://registry.npmjs.org
|
||||
pnpm audit --audit-level=high --dev
|
||||
continue-on-error: true
|
||||
|
||||
test:
|
||||
if: ${{ always() }}
|
||||
needs: security
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Test stable
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm test
|
||||
|
||||
- name: Test build
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
release:
|
||||
needs: test
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Release
|
||||
run: |
|
||||
pnpm install
|
||||
# Extract server host from GITHUB_SERVER_URL (remove https://)
|
||||
GITEA_HOST="${GITHUB_SERVER_URL#https://}"
|
||||
GITEA_REGISTRY="$GITHUB_SERVER_URL/api/packages/$GITHUB_REPOSITORY_OWNER/npm/"
|
||||
|
||||
# Create .npmrc for Gitea authentication
|
||||
echo "@${GITHUB_REPOSITORY_OWNER}:registry=${GITEA_REGISTRY}" > .npmrc
|
||||
echo "//${GITEA_HOST}/api/packages/${GITHUB_REPOSITORY_OWNER}/npm/:_authToken=${GITEA_TOKEN}" >> .npmrc
|
||||
|
||||
# Publish to Gitea
|
||||
pnpm publish --no-git-checks
|
||||
|
||||
# Conditionally publish to npmjs.org if token exists
|
||||
if [ -n "$NPMCI_TOKEN_NPM" ]; then
|
||||
# Update .npmrc for npmjs.org
|
||||
echo "registry=https://registry.npmjs.org/" > .npmrc
|
||||
echo "//registry.npmjs.org/:_authToken=${NPMCI_TOKEN_NPM}" >> .npmrc
|
||||
pnpm publish --no-git-checks
|
||||
fi
|
||||
|
||||
metadata:
|
||||
needs: test
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Code quality
|
||||
run: |
|
||||
npm install -g typescript
|
||||
pnpm install
|
||||
|
||||
- name: Build docs and upload artifacts
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm install -g @git.zone/tsdoc
|
||||
tsdoc
|
||||
continue-on-error: true
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -3,7 +3,6 @@
|
||||
# artifacts
|
||||
coverage/
|
||||
public/
|
||||
pages/
|
||||
|
||||
# installs
|
||||
node_modules/
|
||||
@@ -17,4 +16,8 @@ node_modules/
|
||||
dist/
|
||||
dist_*/
|
||||
|
||||
# custom
|
||||
# AI
|
||||
.claude/
|
||||
.serena/
|
||||
|
||||
#------# custom
|
||||
128
.gitlab-ci.yml
128
.gitlab-ci.yml
@@ -1,128 +0,0 @@
|
||||
# gitzone ci_default
|
||||
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
|
||||
cache:
|
||||
paths:
|
||||
- .npmci_cache/
|
||||
key: '$CI_BUILD_STAGE'
|
||||
|
||||
stages:
|
||||
- security
|
||||
- test
|
||||
- release
|
||||
- metadata
|
||||
|
||||
before_script:
|
||||
- npm install -g @shipzone/npmci
|
||||
|
||||
# ====================
|
||||
# security stage
|
||||
# ====================
|
||||
auditProductionDependencies:
|
||||
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
stage: security
|
||||
script:
|
||||
- npmci npm prepare
|
||||
- npmci command npm install --production --ignore-scripts
|
||||
- npmci command npm config set registry https://registry.npmjs.org
|
||||
- npmci command npm audit --audit-level=high --only=prod --production
|
||||
tags:
|
||||
- docker
|
||||
allow_failure: true
|
||||
|
||||
auditDevDependencies:
|
||||
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
stage: security
|
||||
script:
|
||||
- npmci npm prepare
|
||||
- npmci command npm install --ignore-scripts
|
||||
- npmci command npm config set registry https://registry.npmjs.org
|
||||
- npmci command npm audit --audit-level=high --only=dev
|
||||
tags:
|
||||
- docker
|
||||
allow_failure: true
|
||||
|
||||
# ====================
|
||||
# test stage
|
||||
# ====================
|
||||
|
||||
testStable:
|
||||
stage: test
|
||||
script:
|
||||
- npmci npm prepare
|
||||
- npmci node install stable
|
||||
- npmci npm install
|
||||
- npmci npm test
|
||||
coverage: /\d+.?\d+?\%\s*coverage/
|
||||
tags:
|
||||
- docker
|
||||
|
||||
testBuild:
|
||||
stage: test
|
||||
script:
|
||||
- npmci npm prepare
|
||||
- npmci node install stable
|
||||
- npmci npm install
|
||||
- npmci command npm run build
|
||||
coverage: /\d+.?\d+?\%\s*coverage/
|
||||
tags:
|
||||
- docker
|
||||
|
||||
release:
|
||||
stage: release
|
||||
script:
|
||||
- npmci node install stable
|
||||
- npmci npm publish
|
||||
only:
|
||||
- tags
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
- notpriv
|
||||
|
||||
# ====================
|
||||
# metadata stage
|
||||
# ====================
|
||||
codequality:
|
||||
stage: metadata
|
||||
allow_failure: true
|
||||
only:
|
||||
- tags
|
||||
script:
|
||||
- npmci command npm install -g typescript
|
||||
- npmci npm prepare
|
||||
- npmci npm install
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
- priv
|
||||
|
||||
trigger:
|
||||
stage: metadata
|
||||
script:
|
||||
- npmci trigger
|
||||
only:
|
||||
- tags
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
- notpriv
|
||||
|
||||
pages:
|
||||
stage: metadata
|
||||
script:
|
||||
- npmci node install stable
|
||||
- npmci npm prepare
|
||||
- npmci npm install
|
||||
- npmci command npm run buildDocs
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
- notpriv
|
||||
only:
|
||||
- tags
|
||||
artifacts:
|
||||
expire_in: 1 week
|
||||
paths:
|
||||
- public
|
||||
allow_failure: true
|
||||
303
changelog.md
Normal file
303
changelog.md
Normal file
@@ -0,0 +1,303 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-11-16 - 4.4.0 - feat(core)
|
||||
Add Bun and Deno runtime support, unify core loader, unix-socket support and cross-runtime streaming/tests
|
||||
|
||||
- package.json: expose ./core_bun and ./core_deno in exports and add runtime-related keywords
|
||||
- Core dynamic loader (ts/core/index.ts): detect Bun and Deno at runtime and load corresponding implementations
|
||||
- New runtime modules: added ts/core_bun/* and ts/core_deno/* (response, types, index) to provide Bun and Deno CoreResponse/CoreRequest wrappers
|
||||
- Client streaming: SmartRequest no longer immediately deletes temporary __nodeStream and __rawStreamFunc props — CoreRequest implementations handle them; temporary properties are cleaned up after CoreRequest is created
|
||||
- Node.js request: core_node/request.ts converts web ReadableStream to Node.js Readable via stream.Readable.fromWeb and pipes it; also supports passing requestDataFunc for raw streaming
|
||||
- core_node/plugins: export stream helper and rework third-party exports (agentkeepalive, form-data) for Node implementation
|
||||
- CoreResponse for Bun/Deno: new implementations wrap native fetch Response and expose raw(), stream(), and streamNode() behavior (streamNode() throws in Bun/Deno with guidance to use web streams)
|
||||
- Tests: added unified cross-runtime streaming tests and separate unix-socket tests for Node/Bun/Deno with Docker-socket availability checks; removed old node-only streaming test
|
||||
- Docs/readme: updated to describe Node, Bun, Deno, and browser support, unix socket behavior per runtime, and new test conventions
|
||||
|
||||
## 2025-11-16 - 4.3.8 - fix(core)
|
||||
Ensure correct ArrayBuffer return, fix fetch body typing, reorganize node-only tests, and bump tsbuild devDependency
|
||||
|
||||
- core_node: Fix arrayBuffer() to ensure an ArrayBuffer is returned (avoid returning SharedArrayBuffer) to improve interoperability when consuming binary responses.
|
||||
- core_fetch: Cast request body to BodyInit when assigning to fetch options and preserve duplex = 'half' for ReadableStream bodies to satisfy typings and streaming behavior.
|
||||
- tests: Reorganize tests into Node-only variants (rename/remove multi-platform test files to test.*.node.ts) to separate platform-specific test coverage.
|
||||
- chore: Bump devDependency @git.zone/tsbuild from ^2.6.8 to ^2.7.1.
|
||||
|
||||
## 2025-11-01 - 4.3.7 - fix(ci)
|
||||
Update dependencies, add deno.lock, and reorganize tests for browser and Node environments
|
||||
|
||||
- Add deno.lock with resolved npm package versions for deterministic Deno/npm usage
|
||||
- Bump @push.rocks/smartenv dependency to ^6.0.0
|
||||
- Bump devDependencies: @git.zone/tsbuild -> ^2.6.8, @git.zone/tsrun -> ^1.6.2, @git.zone/tstest -> ^2.7.0
|
||||
- Reorganize tests: move browser tests to chromium variants and add environment-specific test files for node, bun, deno (streaming, timeout, streamNode, etc.)
|
||||
- Update package.json dependency ranges to match upgraded lockfile and test tooling
|
||||
|
||||
## 2025-10-26 - 4.3.6 - fix(ci)
|
||||
Use .npmrc for registry authentication in Gitea workflow and add conditional npmjs publish
|
||||
|
||||
- Replace npm config set commands with creating a .npmrc file for Gitea registry authentication in .gitea/workflows/default_tags.yaml
|
||||
- Add conditional update of .npmrc and publishing to npmjs.org when NPMCI_TOKEN_NPM is provided
|
||||
- Keep pnpm publish --no-git-checks; improve CI credential handling to be file-based
|
||||
|
||||
## 2025-10-26 - 4.3.5 - fix(workflows)
|
||||
Remove npmci wrappers from CI workflows and use pnpm/npm CLI directly
|
||||
|
||||
- Removed global npmci installation and npmci npm prepare steps from Gitea workflow files
|
||||
- Use pnpm install/test/build instead of npmci-wrapped commands in test jobs
|
||||
- Replace npmci command npm config set ... with direct npm config set calls for registry/auth configuration
|
||||
- Use pnpm publish --no-git-checks for Gitea publishing and use pnpm publish for conditional npmjs publish when token present
|
||||
- Simplified dependency auditing to run pnpm audit and set registry via npm config set
|
||||
- Install tsdoc globally and run tsdoc during docs build step (replacing npmci command usage)
|
||||
|
||||
## 2025-10-25 - 4.3.4 - fix(ci)
|
||||
Fix Gitea workflow publish invocation to run npm publish via npmci command
|
||||
|
||||
- Update .gitea/workflows/default_tags.yaml to use 'npmci command npm publish' for the publish step
|
||||
- Ensures the workflow runs npm publish through the npmci command wrapper to avoid incorrect task invocation
|
||||
|
||||
## 2025-10-25 - 4.3.3 - fix(ci)
|
||||
Improve Gitea release workflow: install deps, configure Gitea npm registry, and optionally publish to npmjs.org
|
||||
|
||||
- Run npm install in the release job to ensure dependencies are available before publishing.
|
||||
- Configure Gitea/npm registry using GITHUB_SERVER_URL and set auth token for the @<owner> scope.
|
||||
- Publish to the Gitea npm registry during release.
|
||||
- If NPMCI_TOKEN_NPM is provided, also publish to the public npmjs.org registry (conditional publish).
|
||||
- Extract host from GITHUB_SERVER_URL to correctly set the registry auth URL.
|
||||
|
||||
## 2025-10-17 - 4.3.2 - fix(core)
|
||||
Remove stray console.log from core module
|
||||
|
||||
- Removed a stray debug console.log(modulePath) from ts/core/index.ts that printed the module path during Node environment initialization
|
||||
|
||||
## 2025-08-19 - 4.3.1 - fix(core)
|
||||
Improve streaming support and timeout handling; add browser streaming & timeout tests and README clarifications
|
||||
|
||||
- core_fetch: accept Uint8Array and Buffer-like bodies; set fetch duplex for ReadableStream bodies so streaming requests work in environments that require duplex
|
||||
- core_fetch: implement AbortController-based timeouts and ensure timeouts are cleared on success/error to avoid hanging timers
|
||||
- core_node: add explicit request timeout handling (request.setTimeout) and hard-data-cutting timeout tracking with proper timeoutId clear on success/error
|
||||
- client: document that raw(streamFunc) is Node-only (not supported in browsers)
|
||||
- tests: add browser streaming tests (test/test.streaming.browser.ts) that exercise buffer() and web ReadableStream via stream()
|
||||
- tests: add timeout tests (test/test.timeout.ts) to validate clearing timers, enforcing timeouts, and preventing timer leaks across multiple requests
|
||||
- docs: update README streaming section to clarify cross-platform behavior of buffer(), stream(), and raw() methods
|
||||
|
||||
## 2025-08-18 - 4.3.0 - feat(client/smartrequest)
|
||||
Add streaming and raw buffer support to SmartRequest (buffer, stream, raw); update docs and tests
|
||||
|
||||
- Add SmartRequest.buffer(data, contentType?) to send Buffer or Uint8Array bodies with Content-Type header.
|
||||
- Add SmartRequest.stream(stream, contentType?) to accept Node.js Readable streams or web ReadableStream and set Content-Type when provided.
|
||||
- Add SmartRequest.raw(streamFunc) to allow custom raw streaming functions (Node.js only) and a RawStreamFunction type.
|
||||
- Wire Node.js stream handling into CoreRequest by passing a requestDataFunc when creating CoreRequest instances.
|
||||
- Add comprehensive streaming examples and documentation to README describing buffer/stream/raw usage and streaming methods.
|
||||
- Add tests for streaming behavior (test/test.streaming.ts) covering buffer, stream, raw, and Uint8Array usage.
|
||||
- Update client exports and plugins to support streaming features and FormData usage where needed.
|
||||
|
||||
## 2025-08-18 - 4.2.2 - fix(client)
|
||||
Fix CI configuration, prevent socket hangs with auto-drain, and apply various client/core TypeScript fixes and test updates
|
||||
|
||||
- CI/workflow updates: switch container IMAGE to code.foss.global/host.today/ht-docker-node:npmci, adjust NPMCI_COMPUTED_REPOURL, and install @ship.zone/npmci instead of @shipzone/npmci
|
||||
- Prevent socket hanging by adding automatic draining of unconsumed Node.js response bodies (configurable via options.autoDrain / SmartRequest.autoDrain); added logging when auto-drain runs and updated tests to consume bodies
|
||||
- Client improvements: fixes and cleanups in SmartRequest (accept header mapping, formData header handling, options(), pagination helpers, handle429Backoff backoff/Retry-After parsing and callbacks, retry logic and small API ergonomics)
|
||||
- Core fixes: fetch and node implementations corrected (buildUrl, fetch options, request/response constructors, stream conversions to web ReadableStream, proper error messages) and consistent exports
|
||||
- TypeScript and formatting fixes across many files (consistent trailing commas, object layout, newline fixes, typed function signatures, cleaned up exports and module imports)
|
||||
- Package metadata and tooling updates: package.json bug/homepage URLs adjusted to code.foss.global, bumped @git.zone/tstest devDependency, added pnpm overrides field; small .gitignore additions
|
||||
|
||||
## 2025-07-29 - 4.2.1 - fix(client)
|
||||
|
||||
Fix socket hanging issues and add auto-drain feature
|
||||
|
||||
**Fixes:**
|
||||
|
||||
- Fixed socket hanging issues caused by unconsumed response bodies
|
||||
- Resolved test timeout problems where sockets remained open after tests completed
|
||||
|
||||
**Features:**
|
||||
|
||||
- Added automatic response body draining to prevent socket pool exhaustion
|
||||
- Made auto-drain configurable via `autoDrain()` method (enabled by default)
|
||||
- Added logging when auto-drain activates for debugging purposes
|
||||
|
||||
**Improvements:**
|
||||
|
||||
- Updated all tests to properly consume response bodies
|
||||
- Enhanced documentation about the importance of consuming response bodies
|
||||
|
||||
## 2025-07-29 - 4.2.0 - feat(client)
|
||||
|
||||
Add handle429Backoff method for intelligent rate limit handling
|
||||
|
||||
**Features:**
|
||||
|
||||
- Added `handle429Backoff()` method to SmartRequest class for automatic HTTP 429 handling
|
||||
- Respects `Retry-After` headers with support for both seconds and HTTP date formats
|
||||
- Configurable exponential backoff when no Retry-After header is present
|
||||
- Added `RateLimitConfig` interface with customizable retry behavior
|
||||
- Optional callback for monitoring rate limit events
|
||||
- Maximum wait time capping to prevent excessive delays
|
||||
|
||||
**Improvements:**
|
||||
|
||||
- Updated test endpoints to use more reliable services (jsonplaceholder, echo.zuplo.io)
|
||||
- Added timeout parameter to test script for better CI/CD compatibility
|
||||
|
||||
**Documentation:**
|
||||
|
||||
- Added comprehensive rate limiting section to README with examples
|
||||
- Documented all configuration options for handle429Backoff
|
||||
|
||||
## 2025-07-29 - 4.1.0 - feat(client)
|
||||
|
||||
Add missing options() method to SmartRequest client
|
||||
|
||||
**Features:**
|
||||
|
||||
- Added `options()` method to SmartRequest class for setting arbitrary request options
|
||||
- Enables setting keepAlive and other platform-specific options via fluent API
|
||||
- Added test coverage for keepAlive functionality
|
||||
|
||||
**Documentation:**
|
||||
|
||||
- Updated README with examples of using the `options()` method
|
||||
- Added specific examples for enabling keepAlive connections
|
||||
- Corrected all documentation to use `options()` instead of `option()`
|
||||
|
||||
## 2025-07-28 - 4.0.0 - BREAKING CHANGE(core)
|
||||
|
||||
Complete architectural overhaul with cross-platform support
|
||||
|
||||
**Breaking Changes:**
|
||||
|
||||
- Renamed `SmartRequestClient` to `SmartRequest` for simpler, cleaner API
|
||||
- Removed legacy API entirely (no more `/legacy` import path)
|
||||
- Major architectural refactoring:
|
||||
- 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)
|
||||
|
||||
Major architectural refactoring with fetch-like API
|
||||
|
||||
**Breaking Changes:**
|
||||
|
||||
- Legacy API functions are now imported from `@push.rocks/smartrequest/legacy` instead of the main export
|
||||
- Modern API response objects now use fetch-like methods (`.json()`, `.text()`, `.arrayBuffer()`, `.stream()`) instead of direct `.body` access
|
||||
- Renamed `responseType()` method to `accept()` in modern API
|
||||
- Removed automatic defaults:
|
||||
- No default keepAlive (must be explicitly set)
|
||||
- No default timeouts
|
||||
- No automatic JSON parsing in core
|
||||
- Complete internal architecture refactoring:
|
||||
- Core module now always returns raw streams
|
||||
- Response parsing happens in SmartResponse methods
|
||||
- Legacy API is now just an adapter over the core module
|
||||
|
||||
**Features:**
|
||||
|
||||
- New fetch-like response API with single-use body consumption
|
||||
- Better TypeScript support and type safety
|
||||
- Cleaner separation of concerns between request and response
|
||||
- More predictable behavior aligned with fetch API standards
|
||||
|
||||
**Documentation:**
|
||||
|
||||
- Updated all examples to show correct import paths
|
||||
- Added comprehensive examples for the new response API
|
||||
- Enhanced migration guide
|
||||
|
||||
## 2025-04-03 - 2.1.0 - feat(docs)
|
||||
|
||||
Enhance documentation and tests with modern API usage examples and migration guide
|
||||
|
||||
- Updated README to include detailed examples for the modern fluent API, covering GET, POST, headers, query, timeout, retries, and pagination
|
||||
- Added a migration guide comparing the legacy API and modern API usage
|
||||
- Improved installation instructions with npm, pnpm, and yarn examples
|
||||
- Added and updated test files for both legacy and modern API functionalities
|
||||
- Minor formatting improvements in the code and documentation examples
|
||||
|
||||
## 2024-11-06 - 2.0.23 - fix(core)
|
||||
|
||||
Enhance type safety for response in binary requests
|
||||
|
||||
- Updated the dependency versions in package.json to their latest versions.
|
||||
- Improved type inference for the response body in getBinary method of smartrequest.binaryrest.ts.
|
||||
- Introduced generic typing to IExtendedIncomingMessage interface for better type safety.
|
||||
|
||||
## 2024-05-29 - 2.0.22 - Documentation
|
||||
|
||||
update description
|
||||
|
||||
## 2024-04-01 - 2.0.21 - Configuration
|
||||
|
||||
Updated configuration files
|
||||
|
||||
- Updated `tsconfig`
|
||||
- Updated `npmextra.json`: githost
|
||||
|
||||
## 2023-07-10 - 2.0.15 - Structure
|
||||
|
||||
Refactored the organization structure
|
||||
|
||||
- Switched to a new organization scheme
|
||||
|
||||
## 2022-07-29 - 1.1.57 to 2.0.0 - Major Update
|
||||
|
||||
Significant changes and improvements leading to a major version update
|
||||
|
||||
- **BREAKING CHANGE**: Switched the core to use ECMAScript modules (ESM)
|
||||
|
||||
## 2018-08-14 - 1.1.12 to 1.1.13 - Functional Enhancements
|
||||
|
||||
Enhanced request capabilities and removed unnecessary dependencies
|
||||
|
||||
- Fixed request module to allow sending strings
|
||||
- Removed CI dependencies
|
||||
|
||||
## 2018-07-19 - 1.1.1 to 1.1.11 - Various Fixes and Improvements
|
||||
|
||||
Improvements and fixes across various components
|
||||
|
||||
- Added formData capability
|
||||
- Corrected path resolution to use current working directory (CWD)
|
||||
- Improved formData handling
|
||||
- Included correct headers
|
||||
- Updated request ending method
|
||||
|
||||
## 2018-06-19 - 1.0.14 - Structural Fix
|
||||
|
||||
Resolved conflicts with file extensions
|
||||
|
||||
- Changed `.json.ts` to `.jsonrest.ts` to avoid conflicts
|
||||
|
||||
## 2018-06-13 - 1.0.8 to 1.0.10 - Core Updates
|
||||
|
||||
Ensured binary handling compliance
|
||||
|
||||
- Enhanced core to uphold latest standards
|
||||
- Correct binary file handling response
|
||||
- Fix for handling and returning binary responses
|
||||
|
||||
## 2017-06-09 - 1.0.4 to 1.0.6 - Infrastructure and Type Improvements
|
||||
|
||||
Types and infrastructure updates
|
||||
|
||||
- Improved types
|
||||
- Removed need for content type on post requests
|
||||
- Updated for new infrastructure
|
||||
@@ -9,12 +9,29 @@
|
||||
"gitzone": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "gitlab.com",
|
||||
"gitscope": "pushrocks",
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "push.rocks",
|
||||
"gitrepo": "smartrequest",
|
||||
"description": "dropin replacement for request",
|
||||
"npmPackagename": "@pushrocks/smartrequest",
|
||||
"license": "MIT"
|
||||
"description": "A module for modern HTTP/HTTPS requests with support for form data, file uploads, JSON, binary data, streams, and more.",
|
||||
"npmPackagename": "@push.rocks/smartrequest",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"HTTP",
|
||||
"HTTPS",
|
||||
"request library",
|
||||
"form data",
|
||||
"file uploads",
|
||||
"JSON",
|
||||
"binary data",
|
||||
"streams",
|
||||
"keepAlive",
|
||||
"TypeScript",
|
||||
"modern web requests",
|
||||
"drop-in replacement"
|
||||
]
|
||||
}
|
||||
},
|
||||
"tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis 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. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15161
package-lock.json
generated
15161
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
69
package.json
69
package.json
@@ -1,43 +1,62 @@
|
||||
{
|
||||
"name": "@pushrocks/smartrequest",
|
||||
"version": "2.0.0",
|
||||
"name": "@push.rocks/smartrequest",
|
||||
"version": "4.4.0",
|
||||
"private": false,
|
||||
"description": "dropin replacement for request",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"description": "A module for modern HTTP/HTTPS requests with support for form data, file uploads, JSON, binary data, streams, and more.",
|
||||
"exports": {
|
||||
".": "./dist_ts/index.js",
|
||||
"./core_node": "./dist_ts/core_node/index.js",
|
||||
"./core_fetch": "./dist_ts/core_fetch/index.js",
|
||||
"./core_bun": "./dist_ts/core_bun/index.js",
|
||||
"./core_deno": "./dist_ts/core_deno/index.js"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --web)",
|
||||
"test": "(tstest test/ --verbose --timeout 120)",
|
||||
"build": "(tsbuild --web)",
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+ssh://git@gitlab.com/pushrocks/smartrequest.git"
|
||||
"url": "https://code.foss.global/push.rocks/smartrequest.git"
|
||||
},
|
||||
"keywords": [
|
||||
"request"
|
||||
"HTTP",
|
||||
"HTTPS",
|
||||
"request library",
|
||||
"form data",
|
||||
"file uploads",
|
||||
"JSON",
|
||||
"binary data",
|
||||
"streams",
|
||||
"keepAlive",
|
||||
"TypeScript",
|
||||
"modern web requests",
|
||||
"drop-in replacement",
|
||||
"Bun",
|
||||
"Deno",
|
||||
"Node.js",
|
||||
"unix sockets"
|
||||
],
|
||||
"author": "Lossless GmbH",
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://gitlab.com/pushrocks/smartrequest/issues"
|
||||
"url": "https://code.foss.global/push.rocks/smartrequest/issues"
|
||||
},
|
||||
"homepage": "https://gitlab.com/pushrocks/smartrequest#README",
|
||||
"homepage": "https://code.foss.global/push.rocks/smartrequest#readme",
|
||||
"dependencies": {
|
||||
"@pushrocks/smartpromise": "^3.1.7",
|
||||
"@pushrocks/smarturl": "^3.0.2",
|
||||
"agentkeepalive": "^4.2.1",
|
||||
"form-data": "^4.0.0"
|
||||
"@push.rocks/smartenv": "^6.0.0",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.0.4",
|
||||
"@push.rocks/smarturl": "^3.1.0",
|
||||
"agentkeepalive": "^4.5.0",
|
||||
"form-data": "^4.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@gitzone/tsbuild": "^2.1.63",
|
||||
"@gitzone/tsrun": "^1.2.37",
|
||||
"@gitzone/tstest": "^1.0.72",
|
||||
"@pushrocks/tapbundle": "^5.0.4",
|
||||
"@types/node": "^18.6.2",
|
||||
"tslint": "^6.1.3",
|
||||
"tslint-config-prettier": "^1.18.0"
|
||||
"@git.zone/tsbuild": "^2.7.1",
|
||||
"@git.zone/tsrun": "^1.6.2",
|
||||
"@git.zone/tstest": "^2.7.0",
|
||||
"@types/node": "^22.9.0"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
@@ -53,5 +72,9 @@
|
||||
],
|
||||
"browserslist": [
|
||||
"last 1 chrome versions"
|
||||
]
|
||||
],
|
||||
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6",
|
||||
"pnpm": {
|
||||
"overrides": {}
|
||||
}
|
||||
}
|
||||
|
||||
9403
pnpm-lock.yaml
generated
Normal file
9403
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
127
readme.hints.md
Normal file
127
readme.hints.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# SmartRequest Architecture Hints
|
||||
|
||||
## Core Features
|
||||
|
||||
- supports http
|
||||
- supports https
|
||||
- supports unix sockets on Node.js, Bun, and Deno
|
||||
- supports formData
|
||||
- supports file uploads
|
||||
- supports best practice keepAlive
|
||||
- dedicated functions for working with JSON request/response cycles
|
||||
- written in TypeScript
|
||||
- continuously updated
|
||||
- supports Node.js, Bun, Deno, and browser environments with automatic runtime detection
|
||||
- runtime-specific implementations using native APIs (Node http/https, Bun fetch, Deno fetch with HttpClient, browser fetch)
|
||||
- used in modules like @push.rocks/smartproxy and @api.global/typedrequest
|
||||
|
||||
## Architecture Overview (as of v4.x with Bun and Deno support)
|
||||
|
||||
- The project has a multi-layer architecture with runtime abstraction
|
||||
- Base layer (ts/core_base/) contains abstract classes and unified types
|
||||
- Node.js implementation (ts/core_node/) uses native http/https modules with unix socket support
|
||||
- Bun implementation (ts/core_bun/) uses Bun's native fetch with unix socket support via `unix` option
|
||||
- Deno implementation (ts/core_deno/) uses Deno's fetch with unix socket support via HttpClient proxy
|
||||
- Browser implementation (ts/core_fetch/) uses standard Fetch API for browser compatibility
|
||||
- Core module (ts/core/) uses @push.rocks/smartenv to detect runtime and dynamically load appropriate implementation
|
||||
- Client API (ts/client/) provides a fluent, chainable interface that works across all runtimes
|
||||
- Runtime detection order: Deno → Bun → Node.js → Browser (following smartenv detection best practices)
|
||||
|
||||
## Key Components
|
||||
|
||||
### Core Base Module (ts/core_base/)
|
||||
|
||||
- `request.ts`: Abstract CoreRequest class defining the request interface
|
||||
- `response.ts`: Abstract CoreResponse class with fetch-like API
|
||||
- Defines `stream()` method that always returns web-style ReadableStream
|
||||
- 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 Bun Module (ts/core_bun/)
|
||||
|
||||
- `request.ts`: Bun implementation using native fetch with unix socket support
|
||||
- Uses Bun's `unix` fetch option for unix socket connections
|
||||
- Supports both `unix` and `socketPath` options (converts socketPath to unix)
|
||||
- Handles URL parsing for `http://unix:/path/to/socket:/http/path` format
|
||||
- Throws errors for Node.js specific options (agent)
|
||||
- `response.ts`: Bun-based CoreResponse implementation
|
||||
- `stream()` returns native web ReadableStream from response.body
|
||||
- `streamNode()` throws error (Bun uses web streams; users should use stream() instead)
|
||||
- `types.ts`: Extends base types with IBunRequestOptions including `unix` option
|
||||
|
||||
### Core Deno Module (ts/core_deno/)
|
||||
|
||||
- `request.ts`: Deno implementation using fetch with HttpClient proxy for unix sockets
|
||||
- Creates and caches Deno.HttpClient instances per socket path
|
||||
- Supports both explicit `client` option and automatic client creation from `socketPath`
|
||||
- HttpClient cache prevents creating multiple clients for same socket
|
||||
- Provides `clearClientCache()` static method for cleanup
|
||||
- Throws errors for Node.js specific options (agent)
|
||||
- `response.ts`: Deno-based CoreResponse implementation
|
||||
- `stream()` returns native web ReadableStream from response.body
|
||||
- `streamNode()` throws error (Deno uses web streams, not Node.js streams)
|
||||
- `types.ts`: Extends base types with IDenoRequestOptions including `client` option
|
||||
|
||||
### Core Module (ts/core/)
|
||||
|
||||
- Dynamically loads appropriate implementation based on environment
|
||||
- Uses @push.rocks/smartenv for environment detection
|
||||
- Exports unified types from core_base
|
||||
|
||||
### Client API (ts/client/)
|
||||
|
||||
- SmartRequest: Fluent API with method chaining
|
||||
- Returns CoreResponse objects with fetch-like methods
|
||||
- Supports pagination, retries, timeouts, and various response types
|
||||
|
||||
### Stream Handling
|
||||
|
||||
- `stream()` method always returns web-style ReadableStream<Uint8Array> across all platforms
|
||||
- In Node.js, converts native streams to web streams
|
||||
- `streamNode()` availability by runtime:
|
||||
- **Node.js**: Returns native Node.js ReadableStream (only runtime that supports this)
|
||||
- **Bun**: Throws error (use web streams via stream() instead)
|
||||
- **Deno**: Throws error (Deno uses web streams only)
|
||||
- **Browser**: Throws error (browsers use web streams only)
|
||||
- Consistent API across platforms with web streams as the common denominator
|
||||
- Only Node.js provides native Node.js streams via streamNode()
|
||||
|
||||
### Binary Request Handling
|
||||
|
||||
- Binary requests handled through ArrayBuffer API
|
||||
- Response body kept as Buffer/ArrayBuffer without string conversion
|
||||
- No automatic transformations applied to binary data
|
||||
|
||||
## Testing
|
||||
|
||||
- Use `pnpm test` to run all tests
|
||||
- Tests use @git.zone/tstest/tapbundle for assertions
|
||||
- Test file naming conventions:
|
||||
- `test.node.ts` - Node.js only tests
|
||||
- `test.bun.ts` - Bun only tests
|
||||
- `test.deno.ts` - Deno only tests
|
||||
- `test.node+bun+deno.ts` - Server-side runtime tests (all three)
|
||||
- `test.browser.ts` or `test.chrome.ts` - Browser tests
|
||||
- Unix socket tests check for Docker socket availability and skip if not present
|
||||
- Browser tests run in headless Chromium via puppeteer
|
||||
934
readme.md
934
readme.md
@@ -1,96 +1,876 @@
|
||||
# @pushrocks/smartrequest
|
||||
dropin replacement for request
|
||||
# @push.rocks/smartrequest
|
||||
|
||||
## Availabililty and Links
|
||||
* [npmjs.org (npm package)](https://www.npmjs.com/package/@pushrocks/smartrequest)
|
||||
* [gitlab.com (source)](https://gitlab.com/pushrocks/smartrequest)
|
||||
* [github.com (source mirror)](https://github.com/pushrocks/smartrequest)
|
||||
* [docs (typedoc)](https://pushrocks.gitlab.io/smartrequest/)
|
||||
A modern, cross-platform HTTP/HTTPS request library for Node.js, Bun, Deno, and browsers with a unified API, supporting form data, file uploads, JSON, binary data, streams, and unix sockets.
|
||||
|
||||
## Status for master
|
||||
## Install
|
||||
|
||||
Status Category | Status Badge
|
||||
-- | --
|
||||
GitLab Pipelines | [](https://lossless.cloud)
|
||||
GitLab Pipline Test Coverage | [](https://lossless.cloud)
|
||||
npm | [](https://lossless.cloud)
|
||||
Snyk | [](https://lossless.cloud)
|
||||
TypeScript Support | [](https://lossless.cloud)
|
||||
node Support | [](https://nodejs.org/dist/latest-v10.x/docs/api/)
|
||||
Code Style | [](https://lossless.cloud)
|
||||
PackagePhobia (total standalone install weight) | [](https://lossless.cloud)
|
||||
PackagePhobia (package size on registry) | [](https://lossless.cloud)
|
||||
BundlePhobia (total size when bundled) | [](https://lossless.cloud)
|
||||
Platform support | [](https://lossless.cloud) [](https://lossless.cloud)
|
||||
```bash
|
||||
# Using npm
|
||||
npm install @push.rocks/smartrequest --save
|
||||
|
||||
# Using pnpm
|
||||
pnpm add @push.rocks/smartrequest
|
||||
|
||||
# Using yarn
|
||||
yarn add @push.rocks/smartrequest
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
- 🚀 **Modern Fetch-like API** - Familiar response methods (`.json()`, `.text()`, `.arrayBuffer()`, `.stream()`)
|
||||
- 🌐 **Cross-Platform** - Works in Node.js, Bun, Deno, and browsers with a unified API
|
||||
- 🔌 **Unix Socket Support** - Connect to local services like Docker (Node.js, Bun, and Deno)
|
||||
- 📦 **Form Data & File Uploads** - Built-in support for multipart/form-data
|
||||
- 🔁 **Pagination Support** - Multiple strategies (offset, cursor, Link headers)
|
||||
- ⚡ **Keep-Alive Connections** - Efficient connection pooling in Node.js
|
||||
- 🛡️ **TypeScript First** - Full type safety and IntelliSense support
|
||||
- 🎯 **Zero Magic Defaults** - Explicit configuration following fetch API principles
|
||||
- 📡 **Streaming Support** - Stream buffers, files, and custom data without loading into memory
|
||||
- 🔧 **Highly Configurable** - Timeouts, retries, headers, rate limiting, and more
|
||||
|
||||
## Architecture
|
||||
|
||||
SmartRequest 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 with unix socket support
|
||||
- **Core Bun** - Bun implementation using native fetch with unix socket support via `unix` option
|
||||
- **Core Deno** - Deno implementation using fetch with unix socket support via HttpClient proxy
|
||||
- **Core Fetch** - Browser implementation using the Fetch API
|
||||
- **Core** - Dynamic runtime detection and implementation selection using @push.rocks/smartenv
|
||||
- **Client** - High-level fluent API for everyday use
|
||||
|
||||
The library automatically detects the runtime environment (Deno, Bun, Node.js, or browser) and loads the appropriate implementation, ensuring optimal performance and native feature support for each platform.
|
||||
|
||||
## Usage
|
||||
|
||||
Use TypeScript for best in class instellisense.
|
||||
`@push.rocks/smartrequest` provides a clean, type-safe API inspired by the native fetch API but with additional features needed for modern applications.
|
||||
|
||||
### Features
|
||||
### Basic Usage
|
||||
|
||||
- supports http
|
||||
- supports https
|
||||
- supports unix socks
|
||||
- supports formData
|
||||
- supports file uploads
|
||||
- supports best practice keepAlive
|
||||
- dedicated functions for working with JSON request/response cycles
|
||||
- written in TypeScript
|
||||
- continuously updated
|
||||
- uses node native http and https modules
|
||||
- used in modules like @pushrocks/smartproxy and @apiglobal/typedrequest
|
||||
- commercial support available at [https://lossless.support](https://lossless.support)
|
||||
```typescript
|
||||
import { SmartRequest } from '@push.rocks/smartrequest';
|
||||
|
||||
> note: smartrequest uses the **native** node http/https modules under the hood (not the bloated one from npm)
|
||||
// Simple GET request
|
||||
async function fetchUserData(userId: number) {
|
||||
const response = await SmartRequest.create()
|
||||
.url(`https://jsonplaceholder.typicode.com/users/${userId}`)
|
||||
.get();
|
||||
|
||||
```javascript
|
||||
import * as smartrequest from 'smartrequest'
|
||||
|
||||
// simple post
|
||||
const options: smartrequest.ISmartRequestOptions = { // typed options
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
"Authorization": "Bearer token"
|
||||
},
|
||||
requestBody: JSON.stringify({
|
||||
key1: 'value1',
|
||||
key2: 3
|
||||
})
|
||||
// Use the fetch-like response API
|
||||
const userData = await response.json();
|
||||
console.log(userData); // The parsed JSON response
|
||||
}
|
||||
|
||||
smartrequest.request('https://example.com', options).then(res => {
|
||||
console.log(res.status)
|
||||
console.log(res.body) // if json, body will be parsed automatically
|
||||
}).catch(err => {
|
||||
console.log(err)
|
||||
})
|
||||
// POST request with JSON body
|
||||
async function createPost(title: string, body: string, userId: number) {
|
||||
const response = await SmartRequest.create()
|
||||
.url('https://jsonplaceholder.typicode.com/posts')
|
||||
.json({ title, body, userId })
|
||||
.post();
|
||||
|
||||
// dedicated JSON methods are available:
|
||||
smartrequest.getJson(...)
|
||||
smartrequest.postJson(...)
|
||||
smartrequest.putJson(...)
|
||||
smartrequest.delJson(...)
|
||||
|
||||
// streaming
|
||||
smartrequest.get('https://example.com/bigfile.mp4', optionsArg, true).then(res => { // third arg = true signals streaming
|
||||
console.log(res.status)
|
||||
res.on('data', data => {
|
||||
// do something with the data chunk here
|
||||
}
|
||||
res.on('end', () => {
|
||||
// do something when things have ended
|
||||
})
|
||||
})
|
||||
const createdPost = await response.json();
|
||||
console.log(createdPost); // The created post
|
||||
}
|
||||
```
|
||||
|
||||
## Contribution
|
||||
### Direct Core API Usage
|
||||
|
||||
We are always happy for code contributions. If you are not the code contributing type that is ok. Still, maintaining Open Source repositories takes considerable time and thought. If you like the quality of what we do and our modules are useful to you we would appreciate a little monthly contribution: You can [contribute one time](https://lossless.link/contribute-onetime) or [contribute monthly](https://lossless.link/contribute). :)
|
||||
For advanced use cases, you can use the Core API directly:
|
||||
|
||||
For further information read the linked docs at the top of this readme.
|
||||
```typescript
|
||||
import { CoreRequest } from '@push.rocks/smartrequest';
|
||||
|
||||
> MIT licensed | **©** [Lossless GmbH](https://lossless.gmbh)
|
||||
| By using this npm module you agree to our [privacy policy](https://lossless.gmbH/privacy)
|
||||
async function directCoreRequest() {
|
||||
const request = new CoreRequest('https://api.example.com/data', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
[](https://maintainedby.lossless.com)
|
||||
const response = await request.fire();
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
### Setting Headers and Query Parameters
|
||||
|
||||
```typescript
|
||||
import { SmartRequest } from '@push.rocks/smartrequest';
|
||||
|
||||
async function searchRepositories(query: string, perPage: number = 10) {
|
||||
const response = await SmartRequest.create()
|
||||
.url('https://api.github.com/search/repositories')
|
||||
.header('Accept', 'application/vnd.github.v3+json')
|
||||
.query({
|
||||
q: query,
|
||||
per_page: perPage.toString(),
|
||||
})
|
||||
.get();
|
||||
|
||||
const data = await response.json();
|
||||
return data.items;
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Timeouts and Retries
|
||||
|
||||
```typescript
|
||||
import { SmartRequest } from '@push.rocks/smartrequest';
|
||||
|
||||
async function fetchWithRetry(url: string) {
|
||||
const response = await SmartRequest.create()
|
||||
.url(url)
|
||||
.timeout(5000) // 5 seconds timeout
|
||||
.retry(3) // Retry up to 3 times on failure
|
||||
.get();
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
```
|
||||
|
||||
### Setting Request Options
|
||||
|
||||
Use the `options()` method to set any request options supported by the underlying implementation:
|
||||
|
||||
```typescript
|
||||
import { SmartRequest } from '@push.rocks/smartrequest';
|
||||
|
||||
// Set various options
|
||||
const response = await SmartRequest.create()
|
||||
.url('https://api.example.com/data')
|
||||
.options({
|
||||
keepAlive: true, // Enable connection reuse (Node.js)
|
||||
timeout: 10000, // 10 second timeout
|
||||
hardDataCuttingTimeout: 15000, // 15 second hard timeout
|
||||
// Platform-specific options are also supported
|
||||
})
|
||||
.get();
|
||||
```
|
||||
|
||||
### Working with Different Response Types
|
||||
|
||||
The API provides a fetch-like interface for handling different response types:
|
||||
|
||||
```typescript
|
||||
import { SmartRequest } from '@push.rocks/smartrequest';
|
||||
|
||||
// JSON response (default)
|
||||
async function fetchJson(url: string) {
|
||||
const response = await SmartRequest.create().url(url).get();
|
||||
|
||||
return await response.json(); // Parses JSON automatically
|
||||
}
|
||||
|
||||
// Text response
|
||||
async function fetchText(url: string) {
|
||||
const response = await SmartRequest.create().url(url).get();
|
||||
|
||||
return await response.text(); // Returns response as string
|
||||
}
|
||||
|
||||
// Binary data
|
||||
async function downloadImage(url: string) {
|
||||
const response = await SmartRequest.create()
|
||||
.url(url)
|
||||
.accept('binary') // Optional: hints to server we want binary
|
||||
.get();
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
return Buffer.from(buffer); // Convert ArrayBuffer to Buffer if needed
|
||||
}
|
||||
|
||||
// Streaming response (Web Streams API)
|
||||
async function streamLargeFile(url: string) {
|
||||
const response = await SmartRequest.create().url(url).get();
|
||||
|
||||
// Get a web-style ReadableStream (works in both Node.js and browsers)
|
||||
const stream = response.stream();
|
||||
|
||||
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/Bun/Deno
|
||||
const nodeStream = response.streamNode();
|
||||
|
||||
nodeStream.on('data', (chunk) => {
|
||||
console.log(`Received ${chunk.length} bytes of data`);
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
nodeStream.on('end', resolve);
|
||||
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/Bun/Deno)
|
||||
- `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 (works everywhere)
|
||||
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 using web ReadableStream (cross-platform!)
|
||||
async function uploadWebStream() {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const data = new TextEncoder().encode('Stream data');
|
||||
controller.enqueue(data);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const response = await SmartRequest.create()
|
||||
.url('https://api.example.com/upload')
|
||||
.stream(stream, 'text/plain')
|
||||
.post();
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Stream a file using Node.js streams (Node.js only)
|
||||
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 (Node.js only)
|
||||
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 (cross-platform) to send
|
||||
- `contentType`: Optional content type (defaults to 'application/octet-stream')
|
||||
- ✅ Works everywhere (Node.js, Bun, Deno, browsers)
|
||||
|
||||
- **`.stream(stream, contentType?)`** - Stream from ReadableStream or Node.js stream
|
||||
- `stream`: Web ReadableStream (cross-platform) or Node.js stream (Node.js only)
|
||||
- `contentType`: Optional content type
|
||||
- ✅ Web ReadableStream works everywhere (Node.js, Bun, Deno, browsers)
|
||||
- ⚠️ Node.js streams only work in Node.js (automatically converted to web streams in Bun/Deno)
|
||||
|
||||
- **`.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, Bun, or Deno
|
||||
- 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, Bun, and Deno)
|
||||
|
||||
SmartRequest supports unix sockets across all server-side runtimes with a unified API:
|
||||
|
||||
```typescript
|
||||
import { SmartRequest } from '@push.rocks/smartrequest';
|
||||
|
||||
// Connect to a service via Unix socket (works on Node.js, Bun, and Deno)
|
||||
async function queryViaUnixSocket() {
|
||||
const response = await SmartRequest.create()
|
||||
.url('http://unix:/var/run/docker.sock:/v1.24/containers/json')
|
||||
.get();
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Alternative: Use socketPath option (works on all server runtimes)
|
||||
async function queryWithSocketPath() {
|
||||
const response = await SmartRequest.create()
|
||||
.url('http://localhost/version')
|
||||
.options({ socketPath: '/var/run/docker.sock' })
|
||||
.get();
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
```
|
||||
|
||||
#### Runtime-Specific Unix Socket APIs
|
||||
|
||||
Each runtime implements unix sockets using its native capabilities:
|
||||
|
||||
**Bun:**
|
||||
```typescript
|
||||
import { CoreRequest } from '@push.rocks/smartrequest/core_bun';
|
||||
|
||||
// Bun uses the native `unix` fetch option
|
||||
const response = await CoreRequest.create('http://localhost/version', {
|
||||
unix: '/var/run/docker.sock'
|
||||
});
|
||||
```
|
||||
|
||||
**Deno:**
|
||||
```typescript
|
||||
import { CoreRequest } from '@push.rocks/smartrequest/core_deno';
|
||||
|
||||
// Deno uses HttpClient with unix socket proxy
|
||||
const client = Deno.createHttpClient({
|
||||
proxy: { url: 'unix:///var/run/docker.sock' }
|
||||
});
|
||||
|
||||
const response = await CoreRequest.create('http://localhost/version', {
|
||||
client
|
||||
});
|
||||
|
||||
// Clean up when done
|
||||
client.close();
|
||||
```
|
||||
|
||||
**Node.js:**
|
||||
```typescript
|
||||
import { CoreRequest } from '@push.rocks/smartrequest/core_node';
|
||||
|
||||
// Node.js uses native socketPath option
|
||||
const response = await CoreRequest.create('http://localhost/version', {
|
||||
socketPath: '/var/run/docker.sock'
|
||||
});
|
||||
```
|
||||
|
||||
### Pagination Support
|
||||
|
||||
The library includes built-in support for various pagination strategies:
|
||||
|
||||
```typescript
|
||||
import { SmartRequest } from '@push.rocks/smartrequest';
|
||||
|
||||
// Offset-based pagination (page & limit)
|
||||
async function fetchAllUsers() {
|
||||
const client = SmartRequest.create()
|
||||
.url('https://api.example.com/users')
|
||||
.withOffsetPagination({
|
||||
pageParam: 'page',
|
||||
limitParam: 'limit',
|
||||
startPage: 1,
|
||||
pageSize: 20,
|
||||
totalPath: 'meta.total',
|
||||
});
|
||||
|
||||
// Get first page with pagination info
|
||||
const firstPage = await client.getPaginated();
|
||||
console.log(`Found ${firstPage.items.length} users on first page`);
|
||||
console.log(`Has more pages: ${firstPage.hasNextPage}`);
|
||||
|
||||
if (firstPage.hasNextPage) {
|
||||
// Get next page
|
||||
const secondPage = await firstPage.getNextPage();
|
||||
console.log(`Found ${secondPage.items.length} more users`);
|
||||
}
|
||||
|
||||
// Or get all pages at once (use with caution for large datasets)
|
||||
const allUsers = await client.getAllPages();
|
||||
console.log(`Retrieved ${allUsers.length} users in total`);
|
||||
}
|
||||
|
||||
// Cursor-based pagination
|
||||
async function fetchAllPosts() {
|
||||
const allPosts = await SmartRequest.create()
|
||||
.url('https://api.example.com/posts')
|
||||
.withCursorPagination({
|
||||
cursorParam: 'cursor',
|
||||
cursorPath: 'meta.nextCursor',
|
||||
hasMorePath: 'meta.hasMore',
|
||||
})
|
||||
.getAllPages();
|
||||
|
||||
console.log(`Retrieved ${allPosts.length} posts in total`);
|
||||
}
|
||||
|
||||
// Link header-based pagination (GitHub API style)
|
||||
async function fetchAllIssues(repo: string) {
|
||||
const paginatedResponse = await SmartRequest.create()
|
||||
.url(`https://api.github.com/repos/${repo}/issues`)
|
||||
.header('Accept', 'application/vnd.github.v3+json')
|
||||
.withLinkPagination()
|
||||
.getPaginated();
|
||||
|
||||
return paginatedResponse.getAllPages();
|
||||
}
|
||||
```
|
||||
|
||||
### Keep-Alive Connections (Node.js)
|
||||
|
||||
```typescript
|
||||
import { SmartRequest } from '@push.rocks/smartrequest';
|
||||
|
||||
// Enable keep-alive for better performance with multiple requests
|
||||
async function performMultipleRequests() {
|
||||
// Note: keepAlive is NOT enabled by default
|
||||
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();
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Advanced usage with custom configuration
|
||||
async function fetchWithCustomRateLimiting() {
|
||||
const response = await SmartRequest.create()
|
||||
.url('https://api.example.com/data')
|
||||
.handle429Backoff({
|
||||
maxRetries: 5, // Try up to 5 times (default: 3)
|
||||
respectRetryAfter: true, // Honor Retry-After header (default: true)
|
||||
maxWaitTime: 30000, // Max 30 seconds wait (default: 60000)
|
||||
fallbackDelay: 2000, // 2s initial delay if no Retry-After (default: 1000)
|
||||
backoffFactor: 2, // Exponential backoff multiplier (default: 2)
|
||||
onRateLimit: (attempt, waitTime) => {
|
||||
console.log(`Rate limited. Attempt ${attempt}, waiting ${waitTime}ms`);
|
||||
},
|
||||
})
|
||||
.get();
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Example: API client with rate limit handling
|
||||
class RateLimitedApiClient {
|
||||
private async request(path: string) {
|
||||
return SmartRequest.create()
|
||||
.url(`https://api.example.com${path}`)
|
||||
.handle429Backoff({
|
||||
maxRetries: 3,
|
||||
onRateLimit: (attempt, waitTime) => {
|
||||
console.log(
|
||||
`API rate limit hit. Waiting ${waitTime}ms before retry ${attempt}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async fetchData(id: string) {
|
||||
const response = await this.request(`/data/${id}`).get();
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The rate limiting feature:
|
||||
|
||||
- Automatically detects 429 responses and retries with backoff
|
||||
- Respects the `Retry-After` header when present (supports both seconds and HTTP date formats)
|
||||
- Uses exponential backoff when no `Retry-After` header is provided
|
||||
- Allows custom callbacks for monitoring rate limit events
|
||||
- Caps maximum wait time to prevent excessive delays
|
||||
|
||||
## Platform-Specific Features
|
||||
|
||||
### Browser-Specific Options
|
||||
|
||||
When running in a browser, you can use browser-specific fetch options:
|
||||
|
||||
```typescript
|
||||
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();
|
||||
```
|
||||
|
||||
### Bun-Specific Options
|
||||
|
||||
When running in Bun, you can use Bun-specific options:
|
||||
|
||||
```typescript
|
||||
const response = await SmartRequest.create()
|
||||
.url('https://api.example.com/data')
|
||||
.options({
|
||||
unix: '/var/run/api.sock', // Unix socket (Bun's native option)
|
||||
keepAlive: true, // Keep-alive support
|
||||
})
|
||||
.get();
|
||||
|
||||
// Bun uses web streams - streamNode() throws an error
|
||||
const streamResponse = await SmartRequest.create()
|
||||
.url('https://api.example.com/data')
|
||||
.get();
|
||||
|
||||
const webStream = streamResponse.stream(); // ✅ Use web streams in Bun
|
||||
// streamNode() is not available - throws error directing you to use stream()
|
||||
```
|
||||
|
||||
### Deno-Specific Options
|
||||
|
||||
When running in Deno, you can use Deno-specific options:
|
||||
|
||||
```typescript
|
||||
// Custom HttpClient for advanced configuration
|
||||
const client = Deno.createHttpClient({
|
||||
proxy: { url: 'unix:///var/run/api.sock' }
|
||||
});
|
||||
|
||||
const response = await SmartRequest.create()
|
||||
.url('https://api.example.com/data')
|
||||
.options({
|
||||
client, // Custom Deno HttpClient
|
||||
})
|
||||
.get();
|
||||
|
||||
// Remember to clean up clients when done
|
||||
client.close();
|
||||
|
||||
// Deno uses web streams - streamNode() throws an error
|
||||
const streamResponse = await SmartRequest.create()
|
||||
.url('https://api.example.com/data')
|
||||
.get();
|
||||
|
||||
const webStream = streamResponse.stream(); // ✅ Use web streams in Deno
|
||||
// streamNode() is not available - throws error directing you to use stream()
|
||||
```
|
||||
|
||||
## Complete Example: Building a REST API Client
|
||||
|
||||
Here's a complete example of building a typed API client:
|
||||
|
||||
```typescript
|
||||
import { SmartRequest, type ICoreResponse } from '@push.rocks/smartrequest';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Post {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
class BlogApiClient {
|
||||
private baseUrl = 'https://jsonplaceholder.typicode.com';
|
||||
|
||||
private async request(path: string) {
|
||||
return SmartRequest.create()
|
||||
.url(`${this.baseUrl}${path}`)
|
||||
.header('Accept', 'application/json');
|
||||
}
|
||||
|
||||
async getUser(id: number): Promise<User> {
|
||||
const response = await this.request(`/users/${id}`).get();
|
||||
return response.json<User>();
|
||||
}
|
||||
|
||||
async createPost(post: Omit<Post, 'id'>): Promise<Post> {
|
||||
const response = await this.request('/posts').json(post).post();
|
||||
return response.json<Post>();
|
||||
}
|
||||
|
||||
async deletePost(id: number): Promise<void> {
|
||||
const response = await this.request(`/posts/${id}`).delete();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete post: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Consume the body
|
||||
await response.text();
|
||||
}
|
||||
|
||||
async getAllPosts(userId?: number): Promise<Post[]> {
|
||||
const client = this.request('/posts');
|
||||
|
||||
if (userId) {
|
||||
client.query({ userId: userId.toString() });
|
||||
}
|
||||
|
||||
const response = await client.get();
|
||||
return response.json<Post[]>();
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const api = new BlogApiClient();
|
||||
const user = await api.getUser(1);
|
||||
const posts = await api.getAllPosts(user.id);
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
import { SmartRequest } from '@push.rocks/smartrequest';
|
||||
|
||||
async function fetchWithErrorHandling(url: string) {
|
||||
try {
|
||||
const response = await SmartRequest.create()
|
||||
.url(url)
|
||||
.timeout(5000)
|
||||
.retry(2)
|
||||
.get();
|
||||
|
||||
// Check if request was successful
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Handle different content types
|
||||
const contentType = response.headers['content-type'];
|
||||
|
||||
if (contentType?.includes('application/json')) {
|
||||
return await response.json();
|
||||
} else if (contentType?.includes('text/')) {
|
||||
return await response.text();
|
||||
} else {
|
||||
return await response.arrayBuffer();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
console.error('Connection refused - is the server running?');
|
||||
} else if (error.code === 'ETIMEDOUT') {
|
||||
console.error('Request timed out');
|
||||
} else if (error.name === 'AbortError') {
|
||||
console.error('Request was aborted');
|
||||
} else {
|
||||
console.error('Request failed:', error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Migrating from Earlier Versions
|
||||
|
||||
### From v3.x to v4.x
|
||||
|
||||
Version 4.0 adds comprehensive cross-platform support:
|
||||
|
||||
1. **Multi-Runtime Support**: Now works natively in Node.js, Bun, Deno, and browsers
|
||||
2. **Unix Sockets Everywhere**: Unix socket support added for Bun and Deno
|
||||
3. **Web Streams**: Full support for web ReadableStream across all platforms
|
||||
4. **Automatic Runtime Detection**: No configuration needed - works everywhere automatically
|
||||
|
||||
### From v2.x to v3.x
|
||||
|
||||
Version 3.0 brought significant architectural improvements:
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
|
||||
119
test/test.chromium.ts
Normal file
119
test/test.chromium.ts
Normal 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();
|
||||
221
test/test.node+bun+deno.ts
Normal file
221
test/test.node+bun+deno.ts
Normal 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();
|
||||
41
test/test.streaming.chrome.ts
Normal file
41
test/test.streaming.chrome.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartRequest } from '../ts/index.js';
|
||||
|
||||
tap.test('browser: should send Uint8Array using buffer() method', async () => {
|
||||
const testData = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" in ASCII
|
||||
|
||||
const smartRequest = SmartRequest.create()
|
||||
.url('https://httpbin.org/post')
|
||||
.buffer(testData, 'application/octet-stream')
|
||||
.method('POST');
|
||||
|
||||
const response = await smartRequest.post();
|
||||
const data = await response.json();
|
||||
|
||||
expect(data).toHaveProperty('data');
|
||||
expect(data.headers['Content-Type']).toEqual('application/octet-stream');
|
||||
});
|
||||
|
||||
tap.test('browser: should send web ReadableStream using stream() method', async () => {
|
||||
// Create a web ReadableStream
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('Test stream data'));
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
const smartRequest = SmartRequest.create()
|
||||
.url('https://httpbin.org/post')
|
||||
.stream(stream, 'text/plain')
|
||||
.method('POST');
|
||||
|
||||
const response = await smartRequest.post();
|
||||
const data = await response.json();
|
||||
|
||||
expect(data).toHaveProperty('data');
|
||||
// httpbin should receive the streamed data
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
61
test/test.streaming.node+bun+deno.ts
Normal file
61
test/test.streaming.node+bun+deno.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartRequest } from '../ts/index.js';
|
||||
|
||||
// Cross-platform tests using web-standard APIs only
|
||||
|
||||
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 web ReadableStream using stream() method', async () => {
|
||||
const testData = 'Stream data test';
|
||||
|
||||
// Use web-standard ReadableStream (works on all platforms)
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(testData));
|
||||
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');
|
||||
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.node.ts
Normal file
27
test/test.streamnode.node.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartRequest } from '../ts/index.js';
|
||||
|
||||
tap.test('should have streamNode() method available', async () => {
|
||||
const response = await SmartRequest.create()
|
||||
.url('https://httpbin.org/get')
|
||||
.get();
|
||||
|
||||
// Verify streamNode() method exists
|
||||
expect(response.streamNode).toBeDefined();
|
||||
expect(typeof response.streamNode).toEqual('function');
|
||||
|
||||
// In Node.js, it should return a stream
|
||||
const nodeStream = response.streamNode();
|
||||
expect(nodeStream).toBeDefined();
|
||||
|
||||
// Verify it's a Node.js readable stream
|
||||
expect(typeof nodeStream.pipe).toEqual('function');
|
||||
expect(typeof nodeStream.on).toEqual('function');
|
||||
|
||||
// Consume the stream to avoid hanging
|
||||
nodeStream.resume();
|
||||
});
|
||||
|
||||
|
||||
|
||||
export default tap.start();
|
||||
60
test/test.timeout.node+bun+deno.ts
Normal file
60
test/test.timeout.node+bun+deno.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartRequest } from '../ts/index.js';
|
||||
|
||||
tap.test('should clear timeout when request completes before timeout', async () => {
|
||||
// Set a long timeout that would keep the process alive if not cleared
|
||||
const response = await SmartRequest.create()
|
||||
.url('https://httpbin.org/delay/1') // 1 second delay
|
||||
.timeout(10000) // 10 second timeout (much longer than needed)
|
||||
.get();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data).toBeDefined();
|
||||
|
||||
// The test should complete quickly, not wait for the 10 second timeout
|
||||
// If the timeout isn't cleared, the process would hang for 10 seconds
|
||||
});
|
||||
|
||||
tap.test('should timeout when request takes longer than timeout', async () => {
|
||||
let errorThrown = false;
|
||||
|
||||
try {
|
||||
// Try to fetch with a very short timeout
|
||||
await SmartRequest.create()
|
||||
.url('https://httpbin.org/delay/3') // 3 second delay
|
||||
.timeout(100) // 100ms timeout (will fail)
|
||||
.get();
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toContain('Request timed out');
|
||||
}
|
||||
|
||||
expect(errorThrown).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should not leak timers with multiple successful requests', async () => {
|
||||
// Make multiple requests with timeouts to ensure no timer leaks
|
||||
const promises = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
promises.push(
|
||||
SmartRequest.create()
|
||||
.url('https://httpbin.org/get')
|
||||
.timeout(5000) // 5 second timeout
|
||||
.get()
|
||||
.then(response => response.json())
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// All requests should complete successfully
|
||||
expect(results).toHaveLength(5);
|
||||
results.forEach(result => {
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
// Process should exit cleanly after this test without hanging
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
38
test/test.ts
38
test/test.ts
@@ -1,38 +0,0 @@
|
||||
import { tap, expect, expectAsync } from '@pushrocks/tapbundle';
|
||||
|
||||
import * as smartrequest from '../ts/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.skip.test('should post a JSON document over http', async () => {
|
||||
await expectAsync(smartrequest.postJson('http://md5.jsontest.com/?text=example_text'))
|
||||
.property('body')
|
||||
.property('md5')
|
||||
.toEqual('fa4c6baa0812e5b5c80ed8885e55a8a6');
|
||||
});
|
||||
|
||||
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();
|
||||
101
test/test.unixsocket.bun.ts
Normal file
101
test/test.unixsocket.bun.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartRequest } from '../ts/client/index.js';
|
||||
import { CoreRequest } from '../ts/core_bun/index.js';
|
||||
|
||||
// Check if Docker socket exists (common unix socket for testing)
|
||||
const dockerSocketPath = '/var/run/docker.sock';
|
||||
let dockerAvailable = false;
|
||||
|
||||
try {
|
||||
const file = Bun.file(dockerSocketPath);
|
||||
dockerAvailable = await file.exists();
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Docker socket not available - skipping unix socket tests. To enable, ensure Docker is running.',
|
||||
);
|
||||
}
|
||||
|
||||
tap.test('bun: should detect unix socket URLs correctly', async () => {
|
||||
expect(CoreRequest.isUnixSocket('unix:/var/run/docker.sock:/version')).toBeTrue();
|
||||
expect(CoreRequest.isUnixSocket('http://unix:/var/run/docker.sock:/version')).toBeTrue();
|
||||
expect(CoreRequest.isUnixSocket('https://unix:/var/run/docker.sock:/version')).toBeTrue();
|
||||
expect(CoreRequest.isUnixSocket('http://example.com')).toBeFalse();
|
||||
expect(CoreRequest.isUnixSocket('https://example.com')).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('bun: should parse unix socket URLs correctly', async () => {
|
||||
const result = CoreRequest.parseUnixSocketUrl('unix:/var/run/docker.sock:/v1.24/version');
|
||||
expect(result.socketPath).toEqual('unix:/var/run/docker.sock');
|
||||
expect(result.path).toEqual('/v1.24/version');
|
||||
});
|
||||
|
||||
if (dockerAvailable) {
|
||||
tap.test('bun: should connect to Docker via unix socket (unix: protocol)', async () => {
|
||||
const response = await SmartRequest.create()
|
||||
.url('http://unix:/var/run/docker.sock:/version')
|
||||
.get();
|
||||
|
||||
expect(response.ok).toBeTrue();
|
||||
expect(response.status).toEqual(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty('Version');
|
||||
console.log(`Docker version: ${body.Version}`);
|
||||
});
|
||||
|
||||
tap.test('bun: should connect to Docker via socketPath option', async () => {
|
||||
const response = await CoreRequest.create('http://localhost/version', {
|
||||
socketPath: '/var/run/docker.sock',
|
||||
});
|
||||
|
||||
expect(response.ok).toBeTrue();
|
||||
expect(response.status).toEqual(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty('Version');
|
||||
});
|
||||
|
||||
tap.test('bun: should connect to Docker via unix option', async () => {
|
||||
const response = await CoreRequest.create('http://localhost/version', {
|
||||
unix: '/var/run/docker.sock',
|
||||
});
|
||||
|
||||
expect(response.ok).toBeTrue();
|
||||
expect(response.status).toEqual(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty('Version');
|
||||
});
|
||||
|
||||
tap.test('bun: should handle unix socket with query parameters', async () => {
|
||||
const response = await SmartRequest.create()
|
||||
.url('http://unix:/var/run/docker.sock:/containers/json')
|
||||
.query({ all: 'true' })
|
||||
.get();
|
||||
|
||||
expect(response.ok).toBeTrue();
|
||||
expect(response.status).toEqual(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(Array.isArray(body)).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('bun: should handle unix socket with POST requests', async () => {
|
||||
// Test POST to Docker API (this specific endpoint may require permissions)
|
||||
const response = await SmartRequest.create()
|
||||
.url('http://unix:/var/run/docker.sock:/containers/json')
|
||||
.query({ all: 'true', limit: '1' })
|
||||
.get();
|
||||
|
||||
expect(response.status).toBeGreaterThanOrEqual(200);
|
||||
expect(response.status).toBeLessThan(500);
|
||||
|
||||
await response.text(); // Consume body
|
||||
});
|
||||
} else {
|
||||
tap.skip.test(
|
||||
'bun: unix socket tests skipped - Docker socket not available',
|
||||
);
|
||||
}
|
||||
|
||||
export default tap.start();
|
||||
154
test/test.unixsocket.deno.ts
Normal file
154
test/test.unixsocket.deno.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartRequest } from '../ts/client/index.js';
|
||||
import { CoreRequest } from '../ts/core_deno/index.js';
|
||||
|
||||
// Check if Docker socket exists (common unix socket for testing)
|
||||
const dockerSocketPath = '/var/run/docker.sock';
|
||||
let dockerAvailable = false;
|
||||
|
||||
try {
|
||||
const fileInfo = await Deno.stat(dockerSocketPath);
|
||||
dockerAvailable = fileInfo.isFile || fileInfo.isSymlink;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Docker socket not available - skipping unix socket tests. To enable, ensure Docker is running.',
|
||||
);
|
||||
}
|
||||
|
||||
tap.test('deno: should detect unix socket URLs correctly', async () => {
|
||||
expect(CoreRequest.isUnixSocket('unix:/var/run/docker.sock:/version')).toBeTrue();
|
||||
expect(CoreRequest.isUnixSocket('http://unix:/var/run/docker.sock:/version')).toBeTrue();
|
||||
expect(CoreRequest.isUnixSocket('https://unix:/var/run/docker.sock:/version')).toBeTrue();
|
||||
expect(CoreRequest.isUnixSocket('http://example.com')).toBeFalse();
|
||||
expect(CoreRequest.isUnixSocket('https://example.com')).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('deno: should parse unix socket URLs correctly', async () => {
|
||||
const result = CoreRequest.parseUnixSocketUrl('unix:/var/run/docker.sock:/v1.24/version');
|
||||
expect(result.socketPath).toEqual('unix:/var/run/docker.sock');
|
||||
expect(result.path).toEqual('/v1.24/version');
|
||||
});
|
||||
|
||||
if (dockerAvailable) {
|
||||
tap.test('deno: should connect to Docker via unix socket (unix: protocol)', async () => {
|
||||
const response = await SmartRequest.create()
|
||||
.url('http://unix:/var/run/docker.sock:/version')
|
||||
.get();
|
||||
|
||||
expect(response.ok).toBeTrue();
|
||||
expect(response.status).toEqual(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty('Version');
|
||||
console.log(`Docker version: ${body.Version}`);
|
||||
});
|
||||
|
||||
tap.test('deno: should connect to Docker via socketPath option', async () => {
|
||||
const response = await CoreRequest.create('http://localhost/version', {
|
||||
socketPath: '/var/run/docker.sock',
|
||||
});
|
||||
|
||||
expect(response.ok).toBeTrue();
|
||||
expect(response.status).toEqual(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty('Version');
|
||||
});
|
||||
|
||||
tap.test('deno: should connect to Docker via HttpClient', async () => {
|
||||
const client = Deno.createHttpClient({
|
||||
proxy: {
|
||||
url: 'unix:///var/run/docker.sock',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await CoreRequest.create('http://localhost/version', {
|
||||
client,
|
||||
});
|
||||
|
||||
expect(response.ok).toBeTrue();
|
||||
expect(response.status).toEqual(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty('Version');
|
||||
|
||||
// Clean up client
|
||||
client.close();
|
||||
});
|
||||
|
||||
tap.test('deno: should handle unix socket with query parameters', async () => {
|
||||
const response = await SmartRequest.create()
|
||||
.url('http://unix:/var/run/docker.sock:/containers/json')
|
||||
.query({ all: 'true' })
|
||||
.get();
|
||||
|
||||
expect(response.ok).toBeTrue();
|
||||
expect(response.status).toEqual(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(Array.isArray(body)).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('deno: should handle unix socket with POST requests', async () => {
|
||||
// Test POST to Docker API (this specific endpoint may require permissions)
|
||||
const response = await SmartRequest.create()
|
||||
.url('http://unix:/var/run/docker.sock:/containers/json')
|
||||
.query({ all: 'true', limit: '1' })
|
||||
.get();
|
||||
|
||||
expect(response.status).toBeGreaterThanOrEqual(200);
|
||||
expect(response.status).toBeLessThan(500);
|
||||
|
||||
await response.text(); // Consume body
|
||||
});
|
||||
|
||||
tap.test('deno: should cache HttpClient for reuse', async () => {
|
||||
// First request creates a client
|
||||
const response1 = await SmartRequest.create()
|
||||
.url('http://unix:/var/run/docker.sock:/version')
|
||||
.get();
|
||||
|
||||
expect(response1.ok).toBeTrue();
|
||||
await response1.text();
|
||||
|
||||
// Second request should reuse the cached client
|
||||
const response2 = await SmartRequest.create()
|
||||
.url('http://unix:/var/run/docker.sock:/version')
|
||||
.get();
|
||||
|
||||
expect(response2.ok).toBeTrue();
|
||||
await response2.text();
|
||||
|
||||
// Clean up cache
|
||||
CoreRequest.clearClientCache();
|
||||
});
|
||||
|
||||
tap.test('deno: should clear HttpClient cache', async () => {
|
||||
const response = await SmartRequest.create()
|
||||
.url('http://unix:/var/run/docker.sock:/version')
|
||||
.get();
|
||||
|
||||
expect(response.ok).toBeTrue();
|
||||
await response.text();
|
||||
|
||||
// Clear cache - should not throw
|
||||
CoreRequest.clearClientCache();
|
||||
|
||||
// Subsequent request should create new client
|
||||
const response2 = await SmartRequest.create()
|
||||
.url('http://unix:/var/run/docker.sock:/version')
|
||||
.get();
|
||||
|
||||
expect(response2.ok).toBeTrue();
|
||||
await response2.text();
|
||||
|
||||
// Clean up
|
||||
CoreRequest.clearClientCache();
|
||||
});
|
||||
} else {
|
||||
tap.skip.test(
|
||||
'deno: unix socket tests skipped - Docker socket not available',
|
||||
);
|
||||
}
|
||||
|
||||
export default tap.start();
|
||||
90
test/test.unixsocket.node.ts
Normal file
90
test/test.unixsocket.node.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/core_node/plugins.js';
|
||||
import { SmartRequest } from '../ts/client/index.js';
|
||||
import { CoreRequest } from '../ts/core_node/index.js';
|
||||
|
||||
// Check if Docker socket exists (common unix socket for testing)
|
||||
const dockerSocketPath = '/var/run/docker.sock';
|
||||
let dockerAvailable = false;
|
||||
|
||||
try {
|
||||
await plugins.fs.promises.access(dockerSocketPath, plugins.fs.constants.R_OK);
|
||||
dockerAvailable = true;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Docker socket not available - skipping unix socket tests. To enable, ensure Docker is running.',
|
||||
);
|
||||
}
|
||||
|
||||
tap.test('node: should detect unix socket URLs correctly', async () => {
|
||||
expect(CoreRequest.isUnixSocket('unix:/var/run/docker.sock:/version')).toBeTrue();
|
||||
expect(CoreRequest.isUnixSocket('http://unix:/var/run/docker.sock:/version')).toBeTrue();
|
||||
expect(CoreRequest.isUnixSocket('https://unix:/var/run/docker.sock:/version')).toBeTrue();
|
||||
expect(CoreRequest.isUnixSocket('http://example.com')).toBeFalse();
|
||||
expect(CoreRequest.isUnixSocket('https://example.com')).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('node: should parse unix socket URLs correctly', async () => {
|
||||
const result = CoreRequest.parseUnixSocketUrl('unix:/var/run/docker.sock:/v1.24/version');
|
||||
expect(result.socketPath).toEqual('unix:/var/run/docker.sock');
|
||||
expect(result.path).toEqual('/v1.24/version');
|
||||
});
|
||||
|
||||
if (dockerAvailable) {
|
||||
tap.test('node: should connect to Docker via unix socket (unix: protocol)', async () => {
|
||||
const response = await SmartRequest.create()
|
||||
.url('http://unix:/var/run/docker.sock:/version')
|
||||
.get();
|
||||
|
||||
expect(response.ok).toBeTrue();
|
||||
expect(response.status).toEqual(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty('Version');
|
||||
console.log(`Docker version: ${body.Version}`);
|
||||
});
|
||||
|
||||
tap.test('node: should connect to Docker via socketPath option', async () => {
|
||||
const response = await CoreRequest.create('http://localhost/version', {
|
||||
socketPath: '/var/run/docker.sock',
|
||||
});
|
||||
|
||||
expect(response.ok).toBeTrue();
|
||||
expect(response.status).toEqual(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty('Version');
|
||||
});
|
||||
|
||||
tap.test('node: should handle unix socket with query parameters', async () => {
|
||||
const response = await SmartRequest.create()
|
||||
.url('http://unix:/var/run/docker.sock:/containers/json')
|
||||
.query({ all: 'true' })
|
||||
.get();
|
||||
|
||||
expect(response.ok).toBeTrue();
|
||||
expect(response.status).toEqual(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(Array.isArray(body)).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('node: should handle unix socket with POST requests', async () => {
|
||||
// Test POST to Docker API (this specific endpoint may require permissions)
|
||||
const response = await SmartRequest.create()
|
||||
.url('http://unix:/var/run/docker.sock:/containers/json')
|
||||
.query({ all: 'true', limit: '1' })
|
||||
.get();
|
||||
|
||||
expect(response.status).toBeGreaterThanOrEqual(200);
|
||||
expect(response.status).toBeLessThan(500);
|
||||
|
||||
await response.text(); // Consume body
|
||||
});
|
||||
} else {
|
||||
tap.skip.test(
|
||||
'node: unix socket tests skipped - Docker socket not available',
|
||||
);
|
||||
}
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @pushrocks/commitinfo
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@pushrocks/smartrequest',
|
||||
version: '2.0.0',
|
||||
description: 'dropin replacement for request'
|
||||
name: '@push.rocks/smartrequest',
|
||||
version: '4.4.0',
|
||||
description: 'A module for modern HTTP/HTTPS requests with support for form data, file uploads, JSON, binary data, streams, and more.'
|
||||
}
|
||||
|
||||
203
ts/client/features/pagination.ts
Normal file
203
ts/client/features/pagination.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { type CoreResponse } from '../../core/index.js';
|
||||
import type { ICoreResponse } from '../../core_base/types.js';
|
||||
import {
|
||||
type TPaginationConfig,
|
||||
PaginationStrategy,
|
||||
type TPaginatedResponse,
|
||||
} from '../types/pagination.js';
|
||||
|
||||
/**
|
||||
* Creates a paginated response from a regular response
|
||||
*/
|
||||
export async function createPaginatedResponse<T>(
|
||||
response: ICoreResponse<any>,
|
||||
paginationConfig: TPaginationConfig,
|
||||
queryParams: Record<string, string>,
|
||||
fetchNextPage: (
|
||||
params: Record<string, string>,
|
||||
) => Promise<TPaginatedResponse<T>>,
|
||||
): Promise<TPaginatedResponse<T>> {
|
||||
// Parse response body first
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
// Default to response.body for items if response is JSON
|
||||
let items: T[] = Array.isArray(body)
|
||||
? body
|
||||
: body?.items || body?.data || body?.results || [];
|
||||
|
||||
let hasNextPage = false;
|
||||
let nextPageParams: Record<string, string> = {};
|
||||
|
||||
// Determine if there's a next page based on pagination strategy
|
||||
switch (paginationConfig.strategy) {
|
||||
case PaginationStrategy.OFFSET: {
|
||||
const config = paginationConfig;
|
||||
const currentPage = parseInt(
|
||||
queryParams[config.pageParam || 'page'] ||
|
||||
String(config.startPage || 1),
|
||||
);
|
||||
const limit = parseInt(
|
||||
queryParams[config.limitParam || 'limit'] ||
|
||||
String(config.pageSize || 20),
|
||||
);
|
||||
const total = getValueByPath(body, config.totalPath || 'total') || 0;
|
||||
|
||||
hasNextPage = currentPage * limit < total;
|
||||
|
||||
if (hasNextPage) {
|
||||
nextPageParams = {
|
||||
...queryParams,
|
||||
[config.pageParam || 'page']: String(currentPage + 1),
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case PaginationStrategy.CURSOR: {
|
||||
const config = paginationConfig;
|
||||
const nextCursor = getValueByPath(
|
||||
body,
|
||||
config.cursorPath || 'nextCursor',
|
||||
);
|
||||
const hasMore = getValueByPath(body, config.hasMorePath || 'hasMore');
|
||||
|
||||
hasNextPage = !!nextCursor || !!hasMore;
|
||||
|
||||
if (hasNextPage && nextCursor) {
|
||||
nextPageParams = {
|
||||
...queryParams,
|
||||
[config.cursorParam || 'cursor']: nextCursor,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case PaginationStrategy.LINK_HEADER: {
|
||||
const linkHeader = response.headers['link'] || '';
|
||||
// Handle both string and string[] types for the link header
|
||||
const headerValue = Array.isArray(linkHeader)
|
||||
? linkHeader[0]
|
||||
: linkHeader;
|
||||
const links = parseLinkHeader(headerValue);
|
||||
|
||||
hasNextPage = !!links.next;
|
||||
|
||||
if (hasNextPage && links.next) {
|
||||
// Extract query parameters from next link URL
|
||||
const url = new URL(links.next);
|
||||
nextPageParams = {};
|
||||
|
||||
url.searchParams.forEach((value, key) => {
|
||||
nextPageParams[key] = value;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case PaginationStrategy.CUSTOM: {
|
||||
const config = paginationConfig;
|
||||
hasNextPage = config.hasNextPage(response);
|
||||
|
||||
if (hasNextPage) {
|
||||
nextPageParams = config.getNextPageParams(response, queryParams);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a function to fetch the next page
|
||||
const getNextPage = async (): Promise<TPaginatedResponse<T>> => {
|
||||
if (!hasNextPage) {
|
||||
throw new Error('No more pages available');
|
||||
}
|
||||
|
||||
return fetchNextPage(nextPageParams);
|
||||
};
|
||||
|
||||
// Create a function to fetch all remaining pages
|
||||
const getAllPages = async (): Promise<T[]> => {
|
||||
const allItems = [...items];
|
||||
let currentPage: TPaginatedResponse<T> = {
|
||||
items,
|
||||
hasNextPage,
|
||||
getNextPage,
|
||||
getAllPages,
|
||||
response,
|
||||
};
|
||||
|
||||
while (currentPage.hasNextPage) {
|
||||
try {
|
||||
currentPage = await currentPage.getNextPage();
|
||||
allItems.push(...currentPage.items);
|
||||
} catch (error) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return allItems;
|
||||
};
|
||||
|
||||
return {
|
||||
items,
|
||||
hasNextPage,
|
||||
getNextPage,
|
||||
getAllPages,
|
||||
response,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Link header for pagination
|
||||
* Link: <https://api.example.com/users?page=2>; rel="next", <https://api.example.com/users?page=5>; rel="last"
|
||||
*/
|
||||
export function parseLinkHeader(header: string): Record<string, string> {
|
||||
const links: Record<string, string> = {};
|
||||
|
||||
if (!header) {
|
||||
return links;
|
||||
}
|
||||
|
||||
// Split parts by comma
|
||||
const parts = header.split(',');
|
||||
|
||||
// Parse each part into a name:value pair
|
||||
for (const part of parts) {
|
||||
const section = part.split(';');
|
||||
if (section.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = section[0].replace(/<(.*)>/, '$1').trim();
|
||||
const name = section[1].replace(/rel="(.*)"/, '$1').trim();
|
||||
|
||||
links[name] = url;
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a nested value from an object using dot notation path
|
||||
* e.g., getValueByPath(obj, "data.pagination.nextCursor")
|
||||
*/
|
||||
export function getValueByPath(obj: any, path?: string): any {
|
||||
if (!path || !obj) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keys = path.split('.');
|
||||
let current = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (
|
||||
current === null ||
|
||||
current === undefined ||
|
||||
typeof current !== 'object'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
55
ts/client/index.ts
Normal file
55
ts/client/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Export the main client
|
||||
export { SmartRequest } from './smartrequest.js';
|
||||
|
||||
// Export response type from core
|
||||
export { CoreResponse } from '../core/index.js';
|
||||
|
||||
// Export types
|
||||
export type {
|
||||
HttpMethod,
|
||||
ResponseType,
|
||||
FormField,
|
||||
RetryConfig,
|
||||
TimeoutConfig,
|
||||
RateLimitConfig,
|
||||
} from './types/common.js';
|
||||
export {
|
||||
PaginationStrategy,
|
||||
type TPaginationConfig as PaginationConfig,
|
||||
type OffsetPaginationConfig,
|
||||
type CursorPaginationConfig,
|
||||
type LinkPaginationConfig,
|
||||
type CustomPaginationConfig,
|
||||
type TPaginatedResponse as PaginatedResponse,
|
||||
} from './types/pagination.js';
|
||||
|
||||
// Convenience factory functions
|
||||
import { SmartRequest } from './smartrequest.js';
|
||||
|
||||
/**
|
||||
* Create a client pre-configured for JSON requests
|
||||
*/
|
||||
export function createJsonClient<T = any>() {
|
||||
return SmartRequest.create<T>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a client pre-configured for form data requests
|
||||
*/
|
||||
export function createFormClient<T = any>() {
|
||||
return SmartRequest.create<T>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a client pre-configured for binary data
|
||||
*/
|
||||
export function createBinaryClient<T = any>() {
|
||||
return SmartRequest.create<T>().accept('binary');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a client pre-configured for streaming
|
||||
*/
|
||||
export function createStreamClient() {
|
||||
return SmartRequest.create().accept('stream');
|
||||
}
|
||||
4
ts/client/plugins.ts
Normal file
4
ts/client/plugins.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// plugins for client module
|
||||
import FormData from 'form-data';
|
||||
|
||||
export { FormData as formData };
|
||||
523
ts/client/smartrequest.ts
Normal file
523
ts/client/smartrequest.ts
Normal file
@@ -0,0 +1,523 @@
|
||||
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);
|
||||
};
|
||||
// Don't delete __nodeStream yet - let CoreRequest implementations handle it
|
||||
// Node.js will use requestDataFunc, Bun/Deno will convert the stream
|
||||
} else if ((this._options as any).__rawStreamFunc) {
|
||||
requestDataFunc = (this._options as any).__rawStreamFunc;
|
||||
// Don't delete __rawStreamFunc yet - let CoreRequest implementations handle it
|
||||
}
|
||||
|
||||
const request = new CoreRequest(this._url, this._options as any, requestDataFunc);
|
||||
|
||||
// Clean up temporary properties after CoreRequest has been created
|
||||
delete (this._options as any).__nodeStream;
|
||||
delete (this._options as any).__rawStreamFunc;
|
||||
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
74
ts/client/types/common.ts
Normal 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;
|
||||
74
ts/client/types/pagination.ts
Normal file
74
ts/client/types/pagination.ts
Normal 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
|
||||
}
|
||||
39
ts/core/index.ts
Normal file
39
ts/core/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
// Export all base types - these are the public API
|
||||
export * from '../core_base/types.js';
|
||||
|
||||
const smartenvInstance = new plugins.smartenv.Smartenv();
|
||||
|
||||
// Dynamically load the appropriate implementation based on runtime
|
||||
let CoreRequest: any;
|
||||
let CoreResponse: any;
|
||||
|
||||
if (smartenvInstance.isDeno) {
|
||||
// In Deno, load the Deno implementation with HttpClient-based unix socket support
|
||||
const impl = await import('../core_deno/index.js');
|
||||
CoreRequest = impl.CoreRequest;
|
||||
CoreResponse = impl.CoreResponse;
|
||||
} else if (smartenvInstance.isBun) {
|
||||
// In Bun, load the Bun implementation with native fetch unix socket support
|
||||
const impl = await import('../core_bun/index.js');
|
||||
CoreRequest = impl.CoreRequest;
|
||||
CoreResponse = impl.CoreResponse;
|
||||
} else if (smartenvInstance.isNode) {
|
||||
// In Node.js, load the Node.js implementation with native http/https unix socket support
|
||||
const modulePath = plugins.smartpath.join(
|
||||
plugins.smartpath.dirname(import.meta.url),
|
||||
'../core_node/index.js',
|
||||
);
|
||||
const impl = await smartenvInstance.getSafeNodeModule(modulePath);
|
||||
CoreRequest = impl.CoreRequest;
|
||||
CoreResponse = impl.CoreResponse;
|
||||
} else {
|
||||
// In browser, load the fetch implementation (no unix socket support)
|
||||
const impl = await import('../core_fetch/index.js');
|
||||
CoreRequest = impl.CoreRequest;
|
||||
CoreResponse = impl.CoreResponse;
|
||||
}
|
||||
|
||||
// Export the loaded implementations
|
||||
export { CoreRequest, CoreResponse };
|
||||
4
ts/core/plugins.ts
Normal file
4
ts/core/plugins.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import * as smartenv from '@push.rocks/smartenv';
|
||||
import * as smartpath from '@push.rocks/smartpath/iso';
|
||||
|
||||
export { smartenv, smartpath };
|
||||
4
ts/core_base/index.ts
Normal file
4
ts/core_base/index.ts
Normal 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
47
ts/core_base/request.ts
Normal 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
50
ts/core_base/response.ts
Normal 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
90
ts/core_base/types.ts
Normal 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_bun/index.ts
Normal file
3
ts/core_bun/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Core Bun exports - Bun's native fetch implementation with unix socket support
|
||||
export * from './response.js';
|
||||
export { CoreRequest } from './request.js';
|
||||
249
ts/core_bun/request.ts
Normal file
249
ts/core_bun/request.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import * as types from './types.js';
|
||||
import { CoreResponse } from './response.js';
|
||||
import { CoreRequest as AbstractCoreRequest } from '../core_base/request.js';
|
||||
|
||||
/**
|
||||
* Bun implementation of Core Request class using native fetch with unix socket support
|
||||
*/
|
||||
export class CoreRequest extends AbstractCoreRequest<
|
||||
types.IBunRequestOptions,
|
||||
CoreResponse
|
||||
> {
|
||||
private timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
private abortController: AbortController | null = null;
|
||||
private requestDataFunc: ((req: any) => void) | null;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
options: types.IBunRequestOptions = {},
|
||||
requestDataFunc: ((req: any) => void) | null = null,
|
||||
) {
|
||||
super(url, options);
|
||||
this.requestDataFunc = requestDataFunc;
|
||||
|
||||
// Check for unsupported Node.js-specific options
|
||||
if (options.agent) {
|
||||
throw new Error(
|
||||
'Node.js specific option (agent) is not supported in Bun implementation',
|
||||
);
|
||||
}
|
||||
|
||||
// Handle Node.js stream conversion if requestDataFunc is provided
|
||||
if (requestDataFunc && (options as any).__nodeStream) {
|
||||
// Convert Node.js stream to web ReadableStream for Bun
|
||||
const nodeStream = (options as any).__nodeStream;
|
||||
|
||||
// Bun can handle Node.js streams via Readable.toWeb if available
|
||||
// Or we can create a web stream that reads from the Node stream
|
||||
if (typeof (nodeStream as any).toWeb === 'function') {
|
||||
this.options.requestBody = (nodeStream as any).toWeb();
|
||||
} else {
|
||||
// Create web ReadableStream from Node.js stream
|
||||
this.options.requestBody = new ReadableStream({
|
||||
async start(controller) {
|
||||
nodeStream.on('data', (chunk: any) => {
|
||||
controller.enqueue(new Uint8Array(chunk));
|
||||
});
|
||||
nodeStream.on('end', () => {
|
||||
controller.close();
|
||||
});
|
||||
nodeStream.on('error', (err: any) => {
|
||||
controller.error(err);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Warn if raw streaming function is provided (not supported in Bun)
|
||||
if (requestDataFunc && (options as any).__rawStreamFunc) {
|
||||
throw new Error(
|
||||
'Raw streaming with .raw() is not supported in Bun. Use .stream() with web ReadableStream instead.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full URL with query parameters
|
||||
*/
|
||||
private buildUrl(): string {
|
||||
// For unix sockets, we need to extract the HTTP path part
|
||||
if (CoreRequest.isUnixSocket(this.url)) {
|
||||
const { path } = CoreRequest.parseUnixSocketUrl(this.url);
|
||||
|
||||
// Build URL for the HTTP request (the hostname doesn't matter for unix sockets)
|
||||
if (
|
||||
!this.options.queryParams ||
|
||||
Object.keys(this.options.queryParams).length === 0
|
||||
) {
|
||||
return `http://localhost${path}`;
|
||||
}
|
||||
|
||||
const url = new URL(`http://localhost${path}`);
|
||||
Object.entries(this.options.queryParams).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, value);
|
||||
});
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// Regular HTTP/HTTPS URL
|
||||
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 with Bun-specific extensions
|
||||
*/
|
||||
private buildFetchOptions(): RequestInit & { unix?: string } {
|
||||
const fetchOptions: RequestInit & { unix?: string } = {
|
||||
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 unix socket
|
||||
if (CoreRequest.isUnixSocket(this.url)) {
|
||||
const { socketPath } = CoreRequest.parseUnixSocketUrl(this.url);
|
||||
fetchOptions.unix = socketPath;
|
||||
} else if (this.options.unix) {
|
||||
// Direct unix option was provided
|
||||
fetchOptions.unix = this.options.unix;
|
||||
} else if (this.options.socketPath) {
|
||||
// Legacy Node.js socketPath option - convert to Bun's unix option
|
||||
fetchOptions.unix = this.options.socketPath;
|
||||
}
|
||||
|
||||
// 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 (Bun supports Node.js Buffer)
|
||||
(typeof Buffer !== 'undefined' && this.options.requestBody instanceof Buffer)
|
||||
) {
|
||||
fetchOptions.body = this.options.requestBody as BodyInit;
|
||||
|
||||
// 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.IBunRequestOptions = {},
|
||||
): 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;
|
||||
95
ts/core_bun/response.ts
Normal file
95
ts/core_bun/response.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import * as types from './types.js';
|
||||
import { CoreResponse as AbstractCoreResponse } from '../core_base/response.js';
|
||||
|
||||
/**
|
||||
* Bun implementation of Core Response class that wraps native fetch Response
|
||||
*/
|
||||
export class CoreResponse<T = any>
|
||||
extends AbstractCoreResponse<T>
|
||||
implements types.IBunResponse<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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get response as a Node.js-style stream
|
||||
* Bun supports Node.js streams, so we can provide this functionality
|
||||
*
|
||||
* Note: In Bun, you may also be able to use the web stream directly with stream() method
|
||||
*/
|
||||
streamNode(): never {
|
||||
// Bun primarily uses web streams and has excellent compatibility
|
||||
// For most use cases, use stream() which returns a standard ReadableStream
|
||||
throw new Error(
|
||||
'streamNode() is not available in Bun environment. Use stream() for web-style ReadableStream, which Bun fully supports.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw Response object
|
||||
*/
|
||||
raw(): Response {
|
||||
return this.responseClone;
|
||||
}
|
||||
}
|
||||
23
ts/core_bun/types.ts
Normal file
23
ts/core_bun/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as baseTypes from '../core_base/types.js';
|
||||
|
||||
// Re-export base types
|
||||
export * from '../core_base/types.js';
|
||||
|
||||
/**
|
||||
* Bun-specific request options
|
||||
*/
|
||||
export interface IBunRequestOptions extends baseTypes.ICoreRequestOptions {
|
||||
/**
|
||||
* Unix domain socket path for Bun's fetch
|
||||
* When provided, the request will be sent over the unix socket instead of TCP
|
||||
*/
|
||||
unix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bun-specific response extensions
|
||||
*/
|
||||
export interface IBunResponse<T = any> extends baseTypes.ICoreResponse<T> {
|
||||
// Access to raw Response object
|
||||
raw(): Response;
|
||||
}
|
||||
23
ts/core_deno/deno.types.ts
Normal file
23
ts/core_deno/deno.types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Minimal Deno type definitions for compilation in Node.js environment
|
||||
* These types are only used during build-time type checking
|
||||
* At runtime, actual Deno APIs will be available in Deno environment
|
||||
*/
|
||||
|
||||
declare global {
|
||||
namespace Deno {
|
||||
interface HttpClient {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
interface CreateHttpClientOptions {
|
||||
proxy?: {
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
function createHttpClient(options: CreateHttpClientOptions): HttpClient;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
3
ts/core_deno/index.ts
Normal file
3
ts/core_deno/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Core Deno exports - Deno's native fetch implementation with unix socket support via HttpClient
|
||||
export * from './response.js';
|
||||
export { CoreRequest } from './request.js';
|
||||
295
ts/core_deno/request.ts
Normal file
295
ts/core_deno/request.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/// <reference path="./deno.types.ts" />
|
||||
import * as types from './types.js';
|
||||
import { CoreResponse } from './response.js';
|
||||
import { CoreRequest as AbstractCoreRequest } from '../core_base/request.js';
|
||||
|
||||
/**
|
||||
* Cache for HttpClient instances keyed by socket path
|
||||
* This prevents creating multiple clients for the same socket
|
||||
*/
|
||||
const httpClientCache = new Map<string, Deno.HttpClient>();
|
||||
|
||||
/**
|
||||
* Deno implementation of Core Request class using native fetch with unix socket support via HttpClient
|
||||
*/
|
||||
export class CoreRequest extends AbstractCoreRequest<
|
||||
types.IDenoRequestOptions,
|
||||
CoreResponse
|
||||
> {
|
||||
private timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
private abortController: AbortController | null = null;
|
||||
private createdClient: Deno.HttpClient | null = null;
|
||||
private requestDataFunc: ((req: any) => void) | null;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
options: types.IDenoRequestOptions = {},
|
||||
requestDataFunc: ((req: any) => void) | null = null,
|
||||
) {
|
||||
super(url, options);
|
||||
this.requestDataFunc = requestDataFunc;
|
||||
|
||||
// Check for unsupported Node.js-specific options
|
||||
if (options.agent) {
|
||||
throw new Error(
|
||||
'Node.js specific option (agent) is not supported in Deno implementation',
|
||||
);
|
||||
}
|
||||
|
||||
// Handle Node.js stream conversion if requestDataFunc is provided
|
||||
if (requestDataFunc && (options as any).__nodeStream) {
|
||||
// Convert Node.js stream to web ReadableStream for Deno
|
||||
const nodeStream = (options as any).__nodeStream;
|
||||
|
||||
// Create web ReadableStream from Node.js stream
|
||||
this.options.requestBody = new ReadableStream({
|
||||
async start(controller) {
|
||||
nodeStream.on('data', (chunk: any) => {
|
||||
controller.enqueue(new Uint8Array(chunk));
|
||||
});
|
||||
nodeStream.on('end', () => {
|
||||
controller.close();
|
||||
});
|
||||
nodeStream.on('error', (err: any) => {
|
||||
controller.error(err);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Throw error if raw streaming function is provided (not supported in Deno)
|
||||
if (requestDataFunc && (options as any).__rawStreamFunc) {
|
||||
throw new Error(
|
||||
'Raw streaming with .raw() is not supported in Deno. Use .stream() with web ReadableStream instead.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create an HttpClient for unix socket communication
|
||||
*/
|
||||
private getHttpClient(): Deno.HttpClient | undefined {
|
||||
// If client was explicitly provided, use it
|
||||
if (this.options.client) {
|
||||
return this.options.client;
|
||||
}
|
||||
|
||||
// Check if we need a unix socket client
|
||||
const socketPath = this.options.socketPath ||
|
||||
(CoreRequest.isUnixSocket(this.url)
|
||||
? CoreRequest.parseUnixSocketUrl(this.url).socketPath
|
||||
: null);
|
||||
|
||||
if (!socketPath) {
|
||||
return undefined; // Use default client
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if (httpClientCache.has(socketPath)) {
|
||||
return httpClientCache.get(socketPath);
|
||||
}
|
||||
|
||||
// Create new HttpClient for this socket
|
||||
const client = Deno.createHttpClient({
|
||||
proxy: {
|
||||
url: `unix://${socketPath}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Cache it
|
||||
httpClientCache.set(socketPath, client);
|
||||
this.createdClient = client;
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full URL with query parameters
|
||||
*/
|
||||
private buildUrl(): string {
|
||||
// For unix sockets, we need to extract the HTTP path part
|
||||
if (CoreRequest.isUnixSocket(this.url)) {
|
||||
const { path } = CoreRequest.parseUnixSocketUrl(this.url);
|
||||
|
||||
// Build URL for the HTTP request (the hostname doesn't matter for unix sockets)
|
||||
if (
|
||||
!this.options.queryParams ||
|
||||
Object.keys(this.options.queryParams).length === 0
|
||||
) {
|
||||
return `http://localhost${path}`;
|
||||
}
|
||||
|
||||
const url = new URL(`http://localhost${path}`);
|
||||
Object.entries(this.options.queryParams).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, value);
|
||||
});
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// Regular HTTP/HTTPS URL
|
||||
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 & { client?: Deno.HttpClient } {
|
||||
const fetchOptions: RequestInit & { client?: Deno.HttpClient } = {
|
||||
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,
|
||||
};
|
||||
|
||||
// Set the HttpClient (for unix sockets or custom configurations)
|
||||
const client = this.getHttpClient();
|
||||
if (client) {
|
||||
fetchOptions.client = client;
|
||||
}
|
||||
|
||||
// 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 (Deno provides Buffer via Node.js compatibility)
|
||||
(typeof Buffer !== 'undefined' && this.options.requestBody instanceof Buffer)
|
||||
) {
|
||||
fetchOptions.body = this.options.requestBody as BodyInit;
|
||||
|
||||
// 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
|
||||
* Note: We don't close the HttpClient here as it's cached for reuse
|
||||
*/
|
||||
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.IDenoRequestOptions = {},
|
||||
): Promise<CoreResponse> {
|
||||
const request = new CoreRequest(url, options);
|
||||
return request.fire();
|
||||
}
|
||||
|
||||
/**
|
||||
* Static method to clear the HttpClient cache
|
||||
* Call this when you want to force new clients to be created
|
||||
*/
|
||||
static clearClientCache(): void {
|
||||
httpClientCache.forEach((client) => {
|
||||
client.close();
|
||||
});
|
||||
httpClientCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience exports for backward compatibility
|
||||
*/
|
||||
export const isUnixSocket = CoreRequest.isUnixSocket;
|
||||
export const parseUnixSocketUrl = CoreRequest.parseUnixSocketUrl;
|
||||
91
ts/core_deno/response.ts
Normal file
91
ts/core_deno/response.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import * as types from './types.js';
|
||||
import { CoreResponse as AbstractCoreResponse } from '../core_base/response.js';
|
||||
|
||||
/**
|
||||
* Deno implementation of Core Response class that wraps native fetch Response
|
||||
*/
|
||||
export class CoreResponse<T = any>
|
||||
extends AbstractCoreResponse<T>
|
||||
implements types.IDenoResponse<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 Deno's standard mode
|
||||
* Throws an error as Deno uses web-standard ReadableStream
|
||||
*/
|
||||
streamNode(): never {
|
||||
throw new Error(
|
||||
'streamNode() is not available in Deno environment. Use stream() for web-style ReadableStream.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw Response object
|
||||
*/
|
||||
raw(): Response {
|
||||
return this.responseClone;
|
||||
}
|
||||
}
|
||||
24
ts/core_deno/types.ts
Normal file
24
ts/core_deno/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/// <reference path="./deno.types.ts" />
|
||||
import * as baseTypes from '../core_base/types.js';
|
||||
|
||||
// Re-export base types
|
||||
export * from '../core_base/types.js';
|
||||
|
||||
/**
|
||||
* Deno-specific request options
|
||||
*/
|
||||
export interface IDenoRequestOptions extends baseTypes.ICoreRequestOptions {
|
||||
/**
|
||||
* Deno HttpClient instance for custom configurations including unix sockets
|
||||
* If not provided and socketPath is specified, a client will be created automatically
|
||||
*/
|
||||
client?: Deno.HttpClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deno-specific response extensions
|
||||
*/
|
||||
export interface IDenoResponse<T = any> extends baseTypes.ICoreResponse<T> {
|
||||
// Access to raw Response object
|
||||
raw(): Response;
|
||||
}
|
||||
3
ts/core_fetch/index.ts
Normal file
3
ts/core_fetch/index.ts
Normal 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
177
ts/core_fetch/request.ts
Normal 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 as BodyInit;
|
||||
|
||||
// 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
90
ts/core_fetch/response.ts
Normal 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
12
ts/core_fetch/types.ts
Normal 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
3
ts/core_node/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Core exports
|
||||
export * from './response.js';
|
||||
export { CoreRequest } from './request.js';
|
||||
@@ -3,17 +3,19 @@ import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as path from 'path';
|
||||
import * as stream from 'stream';
|
||||
|
||||
export { http, https, fs, path };
|
||||
export { http, https, fs, path, stream };
|
||||
|
||||
// pushrocks scope
|
||||
import * as smartpromise from '@pushrocks/smartpromise';
|
||||
import * as smarturl from '@pushrocks/smarturl';
|
||||
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 { HttpAgent, HttpsAgent } from 'agentkeepalive';
|
||||
const agentkeepalive = { HttpAgent, HttpsAgent };
|
||||
import formData from 'form-data';
|
||||
|
||||
export { agentkeepalive, formData };
|
||||
212
ts/core_node/request.ts
Normal file
212
ts/core_node/request.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
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 if (this.options.requestBody instanceof ReadableStream) {
|
||||
// Convert web ReadableStream to Node.js Readable stream
|
||||
const nodeStream = plugins.stream.Readable.fromWeb(this.options.requestBody as any);
|
||||
nodeStream.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();
|
||||
}
|
||||
}
|
||||
175
ts/core_node/response.ts
Normal file
175
ts/core_node/response.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
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();
|
||||
const sliced = buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength,
|
||||
);
|
||||
// Ensure we return ArrayBuffer, not SharedArrayBuffer
|
||||
return sliced instanceof ArrayBuffer ? sliced : new ArrayBuffer(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
21
ts/core_node/types.ts
Normal 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;
|
||||
}
|
||||
15
ts/index.ts
15
ts/index.ts
@@ -1,7 +1,10 @@
|
||||
export { request } from './smartrequest.request.js';
|
||||
export type { IExtendedIncomingMessage } from './smartrequest.request.js';
|
||||
export type { ISmartRequestOptions } from './smartrequest.interfaces.js';
|
||||
// Client API exports
|
||||
export * from './client/index.js';
|
||||
|
||||
export * from './smartrequest.jsonrest.js';
|
||||
export * from './smartrequest.binaryrest.js';
|
||||
export * from './smartrequest.formdata.js';
|
||||
// Core exports for advanced usage
|
||||
export { CoreResponse } from './core/index.js';
|
||||
export type { ICoreRequestOptions, ICoreResponse } from './core_base/types.js';
|
||||
|
||||
// Default export for easier importing
|
||||
import { SmartRequest } from './client/smartrequest.js';
|
||||
export default SmartRequest;
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// this file implements methods to get and post binary data.
|
||||
import * as interfaces from './smartrequest.interfaces.js';
|
||||
import { request } from './smartrequest.request.js';
|
||||
|
||||
import * as plugins from './smartrequest.plugins.js';
|
||||
|
||||
export const getBinary = async (
|
||||
domainArg: string,
|
||||
optionsArg: interfaces.ISmartRequestOptions = {}
|
||||
) => {
|
||||
optionsArg = {
|
||||
...optionsArg,
|
||||
autoJsonParse: false,
|
||||
};
|
||||
const done = plugins.smartpromise.defer();
|
||||
const response = await request(domainArg, optionsArg, true);
|
||||
const data: Array<Buffer> = [];
|
||||
|
||||
response
|
||||
.on('data', function (chunk: Buffer) {
|
||||
data.push(chunk);
|
||||
})
|
||||
.on('end', function () {
|
||||
//at this point data is an array of Buffers
|
||||
//so Buffer.concat() can make us a new Buffer
|
||||
//of all of them together
|
||||
const buffer = Buffer.concat(data);
|
||||
response.body = buffer;
|
||||
done.resolve();
|
||||
});
|
||||
await done.promise;
|
||||
return response;
|
||||
};
|
||||
@@ -1,99 +0,0 @@
|
||||
import * as plugins from './smartrequest.plugins.js';
|
||||
import * as interfaces from './smartrequest.interfaces.js';
|
||||
import { request } from './smartrequest.request.js';
|
||||
|
||||
/**
|
||||
* the interfae for FormFieldData
|
||||
*/
|
||||
export interface IFormField {
|
||||
name: string;
|
||||
type: 'string' | 'filePath' | 'Buffer';
|
||||
payload: string | Buffer;
|
||||
fileName?: string;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
const appendFormField = async (formDataArg: plugins.formData, formDataField: IFormField) => {
|
||||
switch (formDataField.type) {
|
||||
case 'string':
|
||||
formDataArg.append(formDataField.name, formDataField.payload);
|
||||
break;
|
||||
case 'filePath':
|
||||
if (typeof formDataField.payload !== 'string') {
|
||||
throw new Error(
|
||||
`Payload for key ${
|
||||
formDataField.name
|
||||
} must be of type string. Got ${typeof formDataField.payload} instead.`
|
||||
);
|
||||
}
|
||||
const fileData = plugins.fs.readFileSync(
|
||||
plugins.path.join(process.cwd(), formDataField.payload)
|
||||
);
|
||||
formDataArg.append('file', fileData, {
|
||||
filename: formDataField.fileName ? formDataField.fileName : 'upload.pdf',
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
break;
|
||||
case 'Buffer':
|
||||
formDataArg.append(formDataField.name, formDataField.payload, {
|
||||
filename: formDataField.fileName ? formDataField.fileName : 'upload.pdf',
|
||||
contentType: formDataField.contentType ? formDataField.contentType : 'application/pdf',
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export const postFormData = async (
|
||||
urlArg: string,
|
||||
optionsArg: interfaces.ISmartRequestOptions = {},
|
||||
payloadArg: IFormField[]
|
||||
) => {
|
||||
const form = new plugins.formData();
|
||||
for (const formField of payloadArg) {
|
||||
await appendFormField(form, formField);
|
||||
}
|
||||
const requestOptions = {
|
||||
...optionsArg,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...optionsArg.headers,
|
||||
...form.getHeaders(),
|
||||
},
|
||||
requestBody: form,
|
||||
};
|
||||
|
||||
// lets fire the actual request for sending the formdata
|
||||
const response = await request(urlArg, requestOptions);
|
||||
return response;
|
||||
};
|
||||
|
||||
export const postFormDataUrlEncoded = async (
|
||||
urlArg: string,
|
||||
optionsArg: interfaces.ISmartRequestOptions = {},
|
||||
payloadArg: { key: string; content: string }[]
|
||||
) => {
|
||||
let resultString = '';
|
||||
|
||||
for (const keyContentPair of payloadArg) {
|
||||
if (resultString) {
|
||||
resultString += '&';
|
||||
}
|
||||
resultString += `${encodeURIComponent(keyContentPair.key)}=${encodeURIComponent(
|
||||
keyContentPair.content
|
||||
)}`;
|
||||
}
|
||||
|
||||
const requestOptions: interfaces.ISmartRequestOptions = {
|
||||
...optionsArg,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...optionsArg.headers,
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
requestBody: resultString,
|
||||
};
|
||||
|
||||
// lets fire the actual request for sending the formdata
|
||||
const response = await request(urlArg, requestOptions);
|
||||
return response;
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import * as plugins from './smartrequest.plugins.js';
|
||||
import * as https from 'https';
|
||||
|
||||
export interface ISmartRequestOptions extends https.RequestOptions {
|
||||
keepAlive?: boolean;
|
||||
requestBody?: any;
|
||||
autoJsonParse?: boolean;
|
||||
queryParams?: { [key: string]: string };
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// This file implements methods to get and post JSON in a simple manner.
|
||||
|
||||
import * as interfaces from './smartrequest.interfaces.js';
|
||||
import { request } from './smartrequest.request.js';
|
||||
|
||||
/**
|
||||
* gets Json and puts the right headers + handles response aggregation
|
||||
* @param domainArg
|
||||
* @param optionsArg
|
||||
*/
|
||||
export const getJson = async (
|
||||
domainArg: string,
|
||||
optionsArg: interfaces.ISmartRequestOptions = {}
|
||||
) => {
|
||||
optionsArg.method = 'GET';
|
||||
optionsArg.headers = {
|
||||
...optionsArg.headers,
|
||||
};
|
||||
let response = await request(domainArg, optionsArg);
|
||||
return response;
|
||||
};
|
||||
|
||||
export const postJson = async (
|
||||
domainArg: string,
|
||||
optionsArg: interfaces.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',
|
||||
};
|
||||
}
|
||||
let response = await request(domainArg, optionsArg);
|
||||
return response;
|
||||
};
|
||||
|
||||
export const putJson = async (
|
||||
domainArg: string,
|
||||
optionsArg: interfaces.ISmartRequestOptions = {}
|
||||
) => {
|
||||
optionsArg.method = 'PUT';
|
||||
let response = await request(domainArg, optionsArg);
|
||||
return response;
|
||||
};
|
||||
|
||||
export const delJson = async (
|
||||
domainArg: string,
|
||||
optionsArg: interfaces.ISmartRequestOptions = {}
|
||||
) => {
|
||||
optionsArg.method = 'DELETE';
|
||||
let response = await request(domainArg, optionsArg);
|
||||
return response;
|
||||
};
|
||||
@@ -1,190 +0,0 @@
|
||||
import * as plugins from './smartrequest.plugins.js';
|
||||
import * as interfaces from './smartrequest.interfaces.js';
|
||||
|
||||
import { IncomingMessage } from 'http';
|
||||
|
||||
export interface IExtendedIncomingMessage extends IncomingMessage {
|
||||
body: any;
|
||||
}
|
||||
|
||||
const buildUtf8Response = (
|
||||
incomingMessageArg: IncomingMessage,
|
||||
autoJsonParse = true
|
||||
): Promise<IExtendedIncomingMessage> => {
|
||||
const done = plugins.smartpromise.defer<IExtendedIncomingMessage>();
|
||||
// Continuously update stream with data
|
||||
let body = '';
|
||||
incomingMessageArg.on('data', (chunkArg) => {
|
||||
body += chunkArg;
|
||||
});
|
||||
|
||||
incomingMessageArg.on('end', () => {
|
||||
if (autoJsonParse) {
|
||||
try {
|
||||
(incomingMessageArg as IExtendedIncomingMessage).body = JSON.parse(body);
|
||||
} catch (err) {
|
||||
(incomingMessageArg as IExtendedIncomingMessage).body = body;
|
||||
}
|
||||
} else {
|
||||
(incomingMessageArg as IExtendedIncomingMessage).body = body;
|
||||
}
|
||||
done.resolve(incomingMessageArg as IExtendedIncomingMessage);
|
||||
});
|
||||
return done.promise;
|
||||
};
|
||||
|
||||
/**
|
||||
* determine wether a url is a unix sock
|
||||
* @param urlArg
|
||||
*/
|
||||
const testForUnixSock = (urlArg: string): boolean => {
|
||||
const unixRegex = /^(http:\/\/|https:\/\/|)unix:/;
|
||||
return unixRegex.test(urlArg);
|
||||
};
|
||||
|
||||
/**
|
||||
* determine socketPath and path for unixsock
|
||||
*/
|
||||
const parseSocketPathAndRoute = (stringToParseArg: string) => {
|
||||
const parseRegex = /(.*):(.*)/;
|
||||
const result = parseRegex.exec(stringToParseArg);
|
||||
return {
|
||||
socketPath: result[1],
|
||||
path: result[2],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* a custom http agent to make sure we can set custom keepAlive options for speedy subsequent calls
|
||||
*/
|
||||
const httpAgent = new plugins.agentkeepalive({
|
||||
keepAlive: true,
|
||||
freeSocketTimeout: 4000,
|
||||
maxFreeSockets: 1,
|
||||
maxSockets: 100,
|
||||
maxTotalSockets: 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
* a custom http agent to make sure we can set custom keepAlive options for speedy subsequent calls
|
||||
*/
|
||||
const httpAgentKeepAliveFalse = new plugins.agentkeepalive({
|
||||
maxFreeSockets: 0,
|
||||
keepAlive: false,
|
||||
keepAliveMsecs: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* a custom https agent to make sure we can set custom keepAlive options for speedy subsequent calls
|
||||
*/
|
||||
const httpsAgent = new plugins.agentkeepalive.HttpsAgent();
|
||||
|
||||
/**
|
||||
* a custom https agent to make sure we can set custom keepAlive options for speedy subsequent calls
|
||||
*/
|
||||
const httpsAgentKeepAliveFalse = new plugins.https.Agent({
|
||||
maxFreeSockets: 0,
|
||||
keepAlive: false,
|
||||
keepAliveMsecs: 0,
|
||||
});
|
||||
|
||||
export let request = async (
|
||||
urlArg: string,
|
||||
optionsArg: interfaces.ISmartRequestOptions = {},
|
||||
responseStreamArg: boolean = false,
|
||||
requestDataFunc: (req: plugins.http.ClientRequest) => void = null
|
||||
): Promise<IExtendedIncomingMessage> => {
|
||||
const done = plugins.smartpromise.defer<any>();
|
||||
|
||||
// merge options
|
||||
const defaultOptions: interfaces.ISmartRequestOptions = {
|
||||
// agent: agent,
|
||||
autoJsonParse: true,
|
||||
keepAlive: true,
|
||||
};
|
||||
|
||||
optionsArg = {
|
||||
...defaultOptions,
|
||||
...optionsArg,
|
||||
};
|
||||
|
||||
// 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;
|
||||
optionsArg.queryParams = parsedUrl.searchParams;
|
||||
|
||||
// determine if unixsock
|
||||
if (testForUnixSock(urlArg)) {
|
||||
const detailedUnixPath = parseSocketPathAndRoute(optionsArg.path);
|
||||
optionsArg.socketPath = detailedUnixPath.socketPath;
|
||||
optionsArg.path = detailedUnixPath.path;
|
||||
}
|
||||
|
||||
// TODO: support tcp sockets
|
||||
|
||||
// lets determine the request module to use
|
||||
const requestModule = (() => {
|
||||
switch (true) {
|
||||
case parsedUrl.protocol === 'https' && optionsArg.keepAlive:
|
||||
optionsArg.agent = httpsAgent;
|
||||
return plugins.https;
|
||||
case parsedUrl.protocol === 'https' && !optionsArg.keepAlive:
|
||||
optionsArg.agent = httpsAgentKeepAliveFalse;
|
||||
return plugins.https;
|
||||
case parsedUrl.protocol === 'http' && optionsArg.keepAlive:
|
||||
optionsArg.agent = httpAgent;
|
||||
return plugins.http;
|
||||
case parsedUrl.protocol === 'http' && !optionsArg.keepAlive:
|
||||
optionsArg.agent = httpAgentKeepAliveFalse;
|
||||
return plugins.http;
|
||||
}
|
||||
})() as typeof plugins.https;
|
||||
|
||||
if (!requestModule) {
|
||||
console.error(`The request to ${urlArg} is missing a viable protocol. Must be http or https`);
|
||||
return;
|
||||
}
|
||||
|
||||
// lets perform the actual request
|
||||
const requestToFire = requestModule.request(optionsArg, async (response) => {
|
||||
if (responseStreamArg) {
|
||||
done.resolve(response);
|
||||
} else {
|
||||
const builtResponse = await buildUtf8Response(response, optionsArg.autoJsonParse);
|
||||
done.resolve(builtResponse);
|
||||
}
|
||||
});
|
||||
|
||||
// lets write the requestBody
|
||||
if (optionsArg.requestBody) {
|
||||
if (optionsArg.requestBody instanceof plugins.formData) {
|
||||
optionsArg.requestBody.pipe(requestToFire).on('finish', (event: any) => {
|
||||
requestToFire.end();
|
||||
});
|
||||
} else {
|
||||
if (typeof optionsArg.requestBody !== 'string') {
|
||||
optionsArg.requestBody = JSON.stringify(optionsArg.requestBody);
|
||||
}
|
||||
requestToFire.write(optionsArg.requestBody);
|
||||
requestToFire.end();
|
||||
}
|
||||
} else if (requestDataFunc) {
|
||||
requestDataFunc(requestToFire);
|
||||
} else {
|
||||
requestToFire.end();
|
||||
}
|
||||
|
||||
// lets handle an error
|
||||
requestToFire.on('error', (e) => {
|
||||
console.error(e);
|
||||
});
|
||||
|
||||
const result = await done.promise;
|
||||
return result;
|
||||
};
|
||||
@@ -3,8 +3,12 @@
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "nodenext",
|
||||
"esModuleInterop": true
|
||||
}
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {}
|
||||
},
|
||||
"exclude": ["dist_*/**/*.d.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user