Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 980675ea05 | |||
| 15819d8a23 | |||
| bc71d2e5a8 | |||
| 0cf48b3688 | |||
| 1305b92ebe | |||
| 8b52ca1021 | |||
| e14800f077 | |||
| 9f3503704b | |||
| f3ba77050a | |||
| 6211acd60b | |||
| 32332309dc | |||
| 9d29bd92da | |||
| 6d148bb59e | |||
| e0f586693c | |||
| df28cd4778 | |||
| f49cbd2b6a | |||
| 984b53cba2 | |||
| 4c55243646 | |||
| 49cfcaedd1 | |||
| 3996a69f91 | |||
| 629f6dd425 | |||
| d141ceeaf7 | |||
| 7d3c94cae6 | |||
| 5bae452365 | |||
| ffabcf7bdb | |||
| 361d97f440 | |||
| 35867d9148 | |||
| d455a34632 | |||
| 9c5a939499 |
@@ -23,24 +23,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Install pnpm and npmci
|
|
||||||
run: |
|
|
||||||
pnpm install -g pnpm
|
|
||||||
pnpm install -g @ship.zone/npmci
|
|
||||||
|
|
||||||
- name: Run npm prepare
|
|
||||||
run: npmci npm prepare
|
|
||||||
|
|
||||||
- name: Audit production dependencies
|
- name: Audit production dependencies
|
||||||
run: |
|
run: |
|
||||||
npmci command npm config set registry https://registry.npmjs.org
|
npm config set registry https://registry.npmjs.org
|
||||||
npmci command pnpm audit --audit-level=high --prod
|
pnpm audit --audit-level=high --prod
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Audit development dependencies
|
- name: Audit development dependencies
|
||||||
run: |
|
run: |
|
||||||
npmci command npm config set registry https://registry.npmjs.org
|
npm config set registry https://registry.npmjs.org
|
||||||
npmci command pnpm audit --audit-level=high --dev
|
pnpm audit --audit-level=high --dev
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@@ -55,12 +47,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Test stable
|
- name: Test stable
|
||||||
run: |
|
run: |
|
||||||
npmci node install stable
|
pnpm install
|
||||||
npmci npm install
|
pnpm test
|
||||||
npmci npm test
|
|
||||||
|
|
||||||
- name: Test build
|
- name: Test build
|
||||||
run: |
|
run: |
|
||||||
npmci node install stable
|
pnpm install
|
||||||
npmci npm install
|
pnpm build
|
||||||
npmci npm build
|
|
||||||
|
|||||||
@@ -23,22 +23,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Prepare
|
|
||||||
run: |
|
|
||||||
pnpm install -g pnpm
|
|
||||||
pnpm install -g @ship.zone/npmci
|
|
||||||
npmci npm prepare
|
|
||||||
|
|
||||||
- name: Audit production dependencies
|
- name: Audit production dependencies
|
||||||
run: |
|
run: |
|
||||||
npmci command npm config set registry https://registry.npmjs.org
|
npm config set registry https://registry.npmjs.org
|
||||||
npmci command pnpm audit --audit-level=high --prod
|
pnpm audit --audit-level=high --prod
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Audit development dependencies
|
- name: Audit development dependencies
|
||||||
run: |
|
run: |
|
||||||
npmci command npm config set registry https://registry.npmjs.org
|
npm config set registry https://registry.npmjs.org
|
||||||
npmci command pnpm audit --audit-level=high --dev
|
pnpm audit --audit-level=high --dev
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@@ -51,23 +45,15 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Prepare
|
|
||||||
run: |
|
|
||||||
pnpm install -g pnpm
|
|
||||||
pnpm install -g @ship.zone/npmci
|
|
||||||
npmci npm prepare
|
|
||||||
|
|
||||||
- name: Test stable
|
- name: Test stable
|
||||||
run: |
|
run: |
|
||||||
npmci node install stable
|
pnpm install
|
||||||
npmci npm install
|
pnpm test
|
||||||
npmci npm test
|
|
||||||
|
|
||||||
- name: Test build
|
- name: Test build
|
||||||
run: |
|
run: |
|
||||||
npmci node install stable
|
pnpm install
|
||||||
npmci npm install
|
pnpm build
|
||||||
npmci npm build
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: test
|
needs: test
|
||||||
@@ -79,16 +65,27 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Prepare
|
|
||||||
run: |
|
|
||||||
pnpm install -g pnpm
|
|
||||||
pnpm install -g @ship.zone/npmci
|
|
||||||
npmci npm prepare
|
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
run: |
|
run: |
|
||||||
npmci node install stable
|
pnpm install
|
||||||
npmci npm publish
|
# 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:
|
metadata:
|
||||||
needs: test
|
needs: test
|
||||||
@@ -101,24 +98,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Prepare
|
|
||||||
run: |
|
|
||||||
pnpm install -g pnpm
|
|
||||||
pnpm install -g @ship.zone/npmci
|
|
||||||
npmci npm prepare
|
|
||||||
|
|
||||||
- name: Code quality
|
- name: Code quality
|
||||||
run: |
|
run: |
|
||||||
npmci command npm install -g typescript
|
npm install -g typescript
|
||||||
npmci npm install
|
pnpm install
|
||||||
|
|
||||||
- name: Trigger
|
|
||||||
run: npmci trigger
|
|
||||||
|
|
||||||
- name: Build docs and upload artifacts
|
- name: Build docs and upload artifacts
|
||||||
run: |
|
run: |
|
||||||
npmci node install stable
|
pnpm install
|
||||||
npmci npm install
|
|
||||||
pnpm install -g @git.zone/tsdoc
|
pnpm install -g @git.zone/tsdoc
|
||||||
npmci command tsdoc
|
tsdoc
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
119
changelog.md
119
changelog.md
@@ -1,5 +1,124 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-11-17 - 5.0.1 - fix(test)
|
||||||
|
Enable --logfile in test script and bump @git.zone/tstest to ^2.8.2
|
||||||
|
|
||||||
|
- Update npm script: add --logfile flag to the test command to produce test logs
|
||||||
|
- Bump devDependency @git.zone/tstest from ^2.8.1 to ^2.8.2
|
||||||
|
|
||||||
|
## 2025-11-17 - 5.0.0 - BREAKING CHANGE(client/streaming)
|
||||||
|
Unify streaming APIs: remove raw()/streamNode() and standardize on web ReadableStream across runtimes
|
||||||
|
|
||||||
|
- Removed SmartRequest.raw() and RawStreamFunction type. The raw streaming function API is gone — use .stream() with a web ReadableStream for request body streaming.
|
||||||
|
- Removed response.streamNode() from all runtimes. Responses now expose only response.stream() (ReadableStream<Uint8Array>). Node.js consumers must convert using Readable.fromWeb() if a Node.js stream is required.
|
||||||
|
- Node implementation now uses Readable.toWeb() to convert native Node streams into web ReadableStream for a single cross-platform streaming API.
|
||||||
|
- Client request.stream() still accepts Node.js streams but they are converted internally to web streams; temporary internal properties for raw streaming were removed.
|
||||||
|
- Updated tests and documentation (readme) with migration guidance and examples for converting between web and Node.js streams.
|
||||||
|
- Bumped devDependencies (@git.zone/tsbuild, tsrun, tstest) and upgraded form-data to a newer patch release.
|
||||||
|
|
||||||
|
## 2025-11-16 - 4.4.2 - fix(core_base/request)
|
||||||
|
Strip 'unix:' prefix when parsing unix socket URLs so socketPath is a clean filesystem path
|
||||||
|
|
||||||
|
- CoreRequest.parseUnixSocketUrl now removes a leading 'unix:' prefix and returns socketPath as a filesystem path (e.g., /var/run/docker.sock)
|
||||||
|
- Updated tests for Bun, Deno and Node to expect socketPath without the 'unix:' prefix
|
||||||
|
- Adjusted comments/documentation in core_base/request.ts to clarify returned socketPath format
|
||||||
|
|
||||||
|
## 2025-11-16 - 4.4.1 - fix(core_node)
|
||||||
|
Fix unix socket URL parsing and handling in CoreRequest
|
||||||
|
|
||||||
|
- CoreRequest.parseUnixSocketUrl now strips http:// and https:// prefixes so it correctly parses both full URLs (e.g. http://unix:/path/to/socket:/route) and already-stripped unix: paths.
|
||||||
|
- Node.js CoreRequest now passes the original request URL to parseUnixSocketUrl instead of options.path, preventing incorrect socketPath/path extraction.
|
||||||
|
- Fixes connection failures when using unix socket URLs (for example when targeting Docker via http://unix:/var/run/docker.sock:/v1.24/...).
|
||||||
|
|
||||||
|
## 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)
|
## 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
|
Fix CI configuration, prevent socket hangs with auto-drain, and apply various client/core TypeScript fixes and test updates
|
||||||
|
|
||||||
|
|||||||
24
package.json
24
package.json
@@ -1,16 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartrequest",
|
"name": "@push.rocks/smartrequest",
|
||||||
"version": "4.2.2",
|
"version": "5.0.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A module for modern HTTP/HTTPS requests with support for form data, file uploads, JSON, binary data, streams, and more.",
|
"description": "A module for modern HTTP/HTTPS requests with support for form data, file uploads, JSON, binary data, streams, and more.",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist_ts/index.js",
|
".": "./dist_ts/index.js",
|
||||||
"./core_node": "./dist_ts/core_node/index.js",
|
"./core_node": "./dist_ts/core_node/index.js",
|
||||||
"./core_fetch": "./dist_ts/core_fetch/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",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/ --verbose --timeout 120)",
|
"test": "(tstest test/ --verbose --timeout 120 --logfile)",
|
||||||
"build": "(tsbuild --web)",
|
"build": "(tsbuild --web)",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
@@ -30,7 +32,11 @@
|
|||||||
"keepAlive",
|
"keepAlive",
|
||||||
"TypeScript",
|
"TypeScript",
|
||||||
"modern web requests",
|
"modern web requests",
|
||||||
"drop-in replacement"
|
"drop-in replacement",
|
||||||
|
"Bun",
|
||||||
|
"Deno",
|
||||||
|
"Node.js",
|
||||||
|
"unix sockets"
|
||||||
],
|
],
|
||||||
"author": "Task Venture Capital GmbH",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -39,17 +45,17 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://code.foss.global/push.rocks/smartrequest#readme",
|
"homepage": "https://code.foss.global/push.rocks/smartrequest#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/smartenv": "^5.0.13",
|
"@push.rocks/smartenv": "^6.0.0",
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.0.4",
|
"@push.rocks/smartpromise": "^4.0.4",
|
||||||
"@push.rocks/smarturl": "^3.1.0",
|
"@push.rocks/smarturl": "^3.1.0",
|
||||||
"agentkeepalive": "^4.5.0",
|
"agentkeepalive": "^4.5.0",
|
||||||
"form-data": "^4.0.4"
|
"form-data": "^4.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.6.4",
|
"@git.zone/tsbuild": "^3.1.0",
|
||||||
"@git.zone/tsrun": "^1.3.3",
|
"@git.zone/tsrun": "^2.0.0",
|
||||||
"@git.zone/tstest": "^2.3.4",
|
"@git.zone/tstest": "^2.8.2",
|
||||||
"@types/node": "^22.9.0"
|
"@types/node": "^22.9.0"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
5793
pnpm-lock.yaml
generated
5793
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,26 +4,28 @@
|
|||||||
|
|
||||||
- supports http
|
- supports http
|
||||||
- supports https
|
- supports https
|
||||||
- supports unix socks
|
- supports unix sockets on Node.js, Bun, and Deno
|
||||||
- supports formData
|
- supports formData
|
||||||
- supports file uploads
|
- supports file uploads
|
||||||
- supports best practice keepAlive
|
- supports best practice keepAlive
|
||||||
- dedicated functions for working with JSON request/response cycles
|
- dedicated functions for working with JSON request/response cycles
|
||||||
- written in TypeScript
|
- written in TypeScript
|
||||||
- continuously updated
|
- continuously updated
|
||||||
- uses node native http and https modules
|
- supports Node.js, Bun, Deno, and browser environments with automatic runtime detection
|
||||||
- supports both Node.js and browser environments
|
- 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
|
- used in modules like @push.rocks/smartproxy and @api.global/typedrequest
|
||||||
|
|
||||||
## Architecture Overview (as of v3.0.0 major refactoring)
|
## Architecture Overview (as of v4.x with Bun and Deno support)
|
||||||
|
|
||||||
- The project now has a multi-layer architecture with platform abstraction
|
- The project has a multi-layer architecture with runtime abstraction
|
||||||
- Base layer (ts/core_base/) contains abstract classes and unified types
|
- Base layer (ts/core_base/) contains abstract classes and unified types
|
||||||
- Node.js implementation (ts/core_node/) uses native http/https modules
|
- Node.js implementation (ts/core_node/) uses native http/https modules with unix socket support
|
||||||
- Fetch implementation (ts/core_fetch/) uses Fetch API for browser compatibility
|
- Bun implementation (ts/core_bun/) uses Bun's native fetch with unix socket support via `unix` option
|
||||||
- Core module (ts/core/) dynamically selects the appropriate implementation based on environment
|
- Deno implementation (ts/core_deno/) uses Deno's fetch with unix socket support via HttpClient proxy
|
||||||
- Client API (ts/client/) provides a fluent, chainable interface
|
- Browser implementation (ts/core_fetch/) uses standard Fetch API for browser compatibility
|
||||||
- Legacy API has been completely removed in v3.0.0
|
- 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
|
## Key Components
|
||||||
|
|
||||||
@@ -56,6 +58,31 @@
|
|||||||
- `stream()` returns native web ReadableStream from response.body
|
- `stream()` returns native web ReadableStream from response.body
|
||||||
- `streamNode()` throws error explaining it's not available in browser
|
- `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/)
|
### Core Module (ts/core/)
|
||||||
|
|
||||||
- Dynamically loads appropriate implementation based on environment
|
- Dynamically loads appropriate implementation based on environment
|
||||||
@@ -70,10 +97,15 @@
|
|||||||
|
|
||||||
### Stream Handling
|
### Stream Handling
|
||||||
|
|
||||||
- `stream()` method always returns web-style ReadableStream<Uint8Array>
|
- `stream()` method always returns web-style ReadableStream<Uint8Array> across all platforms
|
||||||
- In Node.js, converts native streams to web streams
|
- In Node.js, converts native streams to web streams
|
||||||
- `streamNode()` available only in Node.js environment for native streams
|
- `streamNode()` availability by runtime:
|
||||||
- Consistent API across platforms while preserving platform-specific capabilities
|
- **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 Request Handling
|
||||||
|
|
||||||
@@ -85,5 +117,11 @@
|
|||||||
|
|
||||||
- Use `pnpm test` to run all tests
|
- Use `pnpm test` to run all tests
|
||||||
- Tests use @git.zone/tstest/tapbundle for assertions
|
- Tests use @git.zone/tstest/tapbundle for assertions
|
||||||
- Separate test files for Node.js (test.node.ts) and browser (test.browser.ts)
|
- 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
|
- Browser tests run in headless Chromium via puppeteer
|
||||||
|
|||||||
392
readme.md
392
readme.md
@@ -1,6 +1,6 @@
|
|||||||
# @push.rocks/smartrequest
|
# @push.rocks/smartrequest
|
||||||
|
|
||||||
A modern, cross-platform HTTP/HTTPS request library for Node.js and browsers with a unified API, supporting form data, file uploads, JSON, binary data, streams, and unix sockets.
|
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.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -18,26 +18,30 @@ yarn add @push.rocks/smartrequest
|
|||||||
## Key Features
|
## Key Features
|
||||||
|
|
||||||
- 🚀 **Modern Fetch-like API** - Familiar response methods (`.json()`, `.text()`, `.arrayBuffer()`, `.stream()`)
|
- 🚀 **Modern Fetch-like API** - Familiar response methods (`.json()`, `.text()`, `.arrayBuffer()`, `.stream()`)
|
||||||
- 🌐 **Cross-Platform** - Works in both Node.js and browsers with a unified API
|
- 🌐 **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 only)
|
- 🔌 **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
|
- 📦 **Form Data & File Uploads** - Built-in support for multipart/form-data
|
||||||
- 🔁 **Pagination Support** - Multiple strategies (offset, cursor, Link headers)
|
- 🔁 **Pagination Support** - Multiple strategies (offset, cursor, Link headers)
|
||||||
- ⚡ **Keep-Alive Connections** - Efficient connection pooling in Node.js
|
- ⚡ **Keep-Alive Connections** - Efficient connection pooling in Node.js
|
||||||
- 🛡️ **TypeScript First** - Full type safety and IntelliSense support
|
- 🛡️ **TypeScript First** - Full type safety and IntelliSense support
|
||||||
- 🎯 **Zero Magic Defaults** - Explicit configuration following fetch API principles
|
- 🎯 **Zero Magic Defaults** - Explicit configuration following fetch API principles
|
||||||
- 📡 **Streaming Support** - Handle large files and real-time data
|
- 📡 **Streaming Support** - Stream buffers, files, and custom data without loading into memory
|
||||||
- 🔧 **Highly Configurable** - Timeouts, retries, headers, and more
|
- 🔧 **Highly Configurable** - Timeouts, retries, headers, rate limiting, and more
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
SmartRequest v3.0 features a multi-layer architecture that provides consistent behavior across platforms:
|
SmartRequest features a multi-layer architecture that provides consistent behavior across platforms:
|
||||||
|
|
||||||
- **Core Base** - Abstract classes and unified types shared across implementations
|
- **Core Base** - Abstract classes and unified types shared across implementations
|
||||||
- **Core Node** - Node.js implementation using native http/https modules
|
- **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 Fetch** - Browser implementation using the Fetch API
|
||||||
- **Core** - Dynamic implementation selection based on environment
|
- **Core** - Dynamic runtime detection and implementation selection using @push.rocks/smartenv
|
||||||
- **Client** - High-level fluent API for everyday use
|
- **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
|
## Usage
|
||||||
|
|
||||||
`@push.rocks/smartrequest` provides a clean, type-safe API inspired by the native fetch API but with additional features needed for modern applications.
|
`@push.rocks/smartrequest` provides a clean, type-safe API inspired by the native fetch API but with additional features needed for modern applications.
|
||||||
@@ -178,11 +182,11 @@ async function downloadImage(url: string) {
|
|||||||
return Buffer.from(buffer); // Convert ArrayBuffer to Buffer if needed
|
return Buffer.from(buffer); // Convert ArrayBuffer to Buffer if needed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streaming response (Web Streams API)
|
// Streaming response (Web Streams API - cross-platform)
|
||||||
async function streamLargeFile(url: string) {
|
async function streamLargeFile(url: string) {
|
||||||
const response = await SmartRequest.create().url(url).get();
|
const response = await SmartRequest.create().url(url).get();
|
||||||
|
|
||||||
// Get a web-style ReadableStream (works in both Node.js and browsers)
|
// Get a web-style ReadableStream (works everywhere)
|
||||||
const stream = response.stream();
|
const stream = response.stream();
|
||||||
|
|
||||||
if (stream) {
|
if (stream) {
|
||||||
@@ -200,12 +204,14 @@ async function streamLargeFile(url: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Node.js specific stream (only in Node.js environment)
|
// Convert to Node.js stream if needed (Node.js only)
|
||||||
async function streamWithNodeApi(url: string) {
|
async function streamWithNodeApi(url: string) {
|
||||||
const response = await SmartRequest.create().url(url).get();
|
const response = await SmartRequest.create().url(url).get();
|
||||||
|
|
||||||
// Only available in Node.js, throws error in browser
|
// Convert web stream to Node.js stream
|
||||||
const nodeStream = response.streamNode();
|
import { Readable } from 'stream';
|
||||||
|
const webStream = response.stream();
|
||||||
|
const nodeStream = Readable.fromWeb(webStream);
|
||||||
|
|
||||||
nodeStream.on('data', (chunk) => {
|
nodeStream.on('data', (chunk) => {
|
||||||
console.log(`Received ${chunk.length} bytes of data`);
|
console.log(`Received ${chunk.length} bytes of data`);
|
||||||
@@ -226,8 +232,7 @@ The response object provides these methods:
|
|||||||
- `text(): Promise<string>` - Get response as text
|
- `text(): Promise<string>` - Get response as text
|
||||||
- `arrayBuffer(): Promise<ArrayBuffer>` - Get response as ArrayBuffer
|
- `arrayBuffer(): Promise<ArrayBuffer>` - Get response as ArrayBuffer
|
||||||
- `stream(): ReadableStream<Uint8Array> | null` - Get web-style ReadableStream (cross-platform)
|
- `stream(): ReadableStream<Uint8Array> | null` - Get web-style ReadableStream (cross-platform)
|
||||||
- `streamNode(): NodeJS.ReadableStream` - Get Node.js stream (Node.js only, throws in browser)
|
- `raw(): Response | http.IncomingMessage` - Get the underlying platform response object
|
||||||
- `raw(): Response | http.IncomingMessage` - Get the underlying platform response
|
|
||||||
|
|
||||||
Each body method can only be called once per response, similar to the fetch API.
|
Each body method can only be called once per response, similar to the fetch API.
|
||||||
|
|
||||||
@@ -303,12 +308,107 @@ async function uploadMultipleFiles(
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Unix Socket Support (Node.js only)
|
### 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Uint8Array (works everywhere)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
```typescript
|
||||||
import { SmartRequest } from '@push.rocks/smartrequest';
|
import { SmartRequest } from '@push.rocks/smartrequest';
|
||||||
|
|
||||||
// Connect to a service via Unix socket
|
// Connect to a service via Unix socket (works on Node.js, Bun, and Deno)
|
||||||
async function queryViaUnixSocket() {
|
async function queryViaUnixSocket() {
|
||||||
const response = await SmartRequest.create()
|
const response = await SmartRequest.create()
|
||||||
.url('http://unix:/var/run/docker.sock:/v1.24/containers/json')
|
.url('http://unix:/var/run/docker.sock:/v1.24/containers/json')
|
||||||
@@ -316,6 +416,57 @@ async function queryViaUnixSocket() {
|
|||||||
|
|
||||||
return await response.json();
|
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
|
### Pagination Support
|
||||||
@@ -502,12 +653,61 @@ const response = await SmartRequest.create()
|
|||||||
.get();
|
.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 natively
|
||||||
|
const streamResponse = await SmartRequest.create()
|
||||||
|
.url('https://api.example.com/data')
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const webStream = streamResponse.stream(); // ✅ Use web streams in Bun
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 natively
|
||||||
|
const streamResponse = await SmartRequest.create()
|
||||||
|
.url('https://api.example.com/data')
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const webStream = streamResponse.stream(); // ✅ Use web streams in Deno
|
||||||
|
```
|
||||||
|
|
||||||
## Complete Example: Building a REST API Client
|
## Complete Example: Building a REST API Client
|
||||||
|
|
||||||
Here's a complete example of building a typed API client:
|
Here's a complete example of building a typed API client:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SmartRequest, type CoreResponse } from '@push.rocks/smartrequest';
|
import { SmartRequest, type ICoreResponse } from '@push.rocks/smartrequest';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -547,6 +747,9 @@ class BlogApiClient {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to delete post: ${response.statusText}`);
|
throw new Error(`Failed to delete post: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Consume the body
|
||||||
|
await response.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllPosts(userId?: number): Promise<Post[]> {
|
async getAllPosts(userId?: number): Promise<Post[]> {
|
||||||
@@ -610,9 +813,158 @@ async function fetchWithErrorHandling(url: string) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Migrating from v2.x to v3.x
|
## Migrating from Earlier Versions
|
||||||
|
|
||||||
Version 3.0 brings significant architectural improvements and a more consistent API:
|
### From v4.x to v5.x
|
||||||
|
|
||||||
|
Version 5.0 completes the transition to modern web standards by removing Node.js-specific streaming APIs:
|
||||||
|
|
||||||
|
#### **Breaking Changes**
|
||||||
|
|
||||||
|
1. **`.streamNode()` Method Removed**
|
||||||
|
- The `.streamNode()` method has been removed from all response objects
|
||||||
|
- Use the cross-platform `.stream()` method instead, which returns a web `ReadableStream<Uint8Array>`
|
||||||
|
- For Node.js users who need Node.js streams, convert using `Readable.fromWeb()`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Before (v4.x) - Node.js only
|
||||||
|
const response = await SmartRequest.create().url(url).get();
|
||||||
|
const nodeStream = response.streamNode();
|
||||||
|
|
||||||
|
// ✅ After (v5.x) - Cross-platform
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
const response = await SmartRequest.create().url(url).get();
|
||||||
|
const webStream = response.stream();
|
||||||
|
const nodeStream = Readable.fromWeb(webStream); // Convert to Node.js stream
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Request `.raw()` Method Removed**
|
||||||
|
- The `.raw(streamFunc)` method has been removed from the SmartRequest client
|
||||||
|
- Use `.stream()` with a web `ReadableStream` instead for request body streaming
|
||||||
|
- Node.js users can create web streams from Node.js streams using `Readable.toWeb()`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Before (v4.x) - Node.js only
|
||||||
|
const response = await SmartRequest.create()
|
||||||
|
.url(url)
|
||||||
|
.raw((request) => {
|
||||||
|
request.write('chunk1');
|
||||||
|
request.write('chunk2');
|
||||||
|
request.end();
|
||||||
|
})
|
||||||
|
.post();
|
||||||
|
|
||||||
|
// ✅ After (v5.x) - Cross-platform
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new TextEncoder().encode('chunk1'));
|
||||||
|
controller.enqueue(new TextEncoder().encode('chunk2'));
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await SmartRequest.create()
|
||||||
|
.url(url)
|
||||||
|
.stream(stream)
|
||||||
|
.post();
|
||||||
|
|
||||||
|
// Or convert from Node.js stream (Node.js only)
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
const nodeStream = fs.createReadStream('file.txt');
|
||||||
|
const webStream = Readable.toWeb(nodeStream);
|
||||||
|
|
||||||
|
const response = await SmartRequest.create()
|
||||||
|
.url(url)
|
||||||
|
.stream(webStream)
|
||||||
|
.post();
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Response `.raw()` Method Preserved**
|
||||||
|
- The `response.raw()` method is still available for accessing platform-specific response objects
|
||||||
|
- Returns `http.IncomingMessage` in Node.js or `Response` in other runtimes
|
||||||
|
- Use for advanced scenarios requiring access to raw platform objects
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Still works in v5.x
|
||||||
|
const response = await SmartRequest.create().url(url).get();
|
||||||
|
const rawResponse = response.raw(); // http.IncomingMessage or Response
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Migration Guide**
|
||||||
|
|
||||||
|
**For Response Streaming:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before (v4.x)
|
||||||
|
const response = await SmartRequest.create().url(url).get();
|
||||||
|
const nodeStream = response.streamNode();
|
||||||
|
|
||||||
|
nodeStream.on('data', (chunk) => {
|
||||||
|
console.log(`Received ${chunk.length} bytes`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// After (v5.x) - Option 1: Use web streams directly
|
||||||
|
const response = await SmartRequest.create().url(url).get();
|
||||||
|
const webStream = response.stream();
|
||||||
|
|
||||||
|
if (webStream) {
|
||||||
|
const reader = webStream.getReader();
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
console.log(`Received ${value.length} bytes`);
|
||||||
|
}
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
// After (v5.x) - Option 2: Convert to Node.js stream (Node.js only)
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
const response = await SmartRequest.create().url(url).get();
|
||||||
|
const webStream = response.stream();
|
||||||
|
const nodeStream = Readable.fromWeb(webStream);
|
||||||
|
|
||||||
|
nodeStream.on('data', (chunk) => {
|
||||||
|
console.log(`Received ${chunk.length} bytes`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**For Request Streaming:**
|
||||||
|
|
||||||
|
Node.js streams are still accepted by the `.stream()` method and automatically converted internally. No changes required for most use cases:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Still works in v5.x
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
const fileStream = fs.createReadStream('large-file.bin');
|
||||||
|
const response = await SmartRequest.create()
|
||||||
|
.url('https://api.example.com/upload')
|
||||||
|
.stream(fileStream, 'application/octet-stream')
|
||||||
|
.post();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- ✅ True cross-platform compatibility
|
||||||
|
- ✅ Modern web standards
|
||||||
|
- ✅ Cleaner API surface
|
||||||
|
- ✅ Single streaming approach works everywhere
|
||||||
|
|
||||||
|
### 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.
|
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.
|
2. **Unified Response API**: All responses now use the same fetch-like interface regardless of platform.
|
||||||
|
|||||||
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();
|
||||||
68
test/test.streamnode.node.ts
Normal file
68
test/test.streamnode.node.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { SmartRequest } from '../ts/index.js';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
tap.test('should have stream() method that returns web ReadableStream', async () => {
|
||||||
|
const response = await SmartRequest.create()
|
||||||
|
.url('https://httpbin.org/get')
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Verify stream() method exists
|
||||||
|
expect(response.stream).toBeDefined();
|
||||||
|
expect(typeof response.stream).toEqual('function');
|
||||||
|
|
||||||
|
// Get web stream
|
||||||
|
const webStream = response.stream();
|
||||||
|
expect(webStream).toBeDefined();
|
||||||
|
|
||||||
|
// Verify it's a web ReadableStream
|
||||||
|
expect(typeof webStream.getReader).toEqual('function');
|
||||||
|
expect(typeof webStream.cancel).toEqual('function');
|
||||||
|
|
||||||
|
// Convert to Node.js stream using Readable.fromWeb()
|
||||||
|
// Known TypeScript limitation: @types/node ReadableStream differs from web ReadableStream
|
||||||
|
const nodeStream = Readable.fromWeb(webStream as any);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should convert web stream to Node.js stream correctly', async () => {
|
||||||
|
const response = await SmartRequest.create()
|
||||||
|
.url('https://httpbin.org/get')
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const webStream = response.stream();
|
||||||
|
const nodeStream = Readable.fromWeb(webStream as any);
|
||||||
|
|
||||||
|
// Collect data from stream
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
nodeStream.on('data', (chunk) => {
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodeStream.on('end', () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
nodeStream.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify we received data
|
||||||
|
const data = Buffer.concat(chunks);
|
||||||
|
expect(data.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify it's valid JSON
|
||||||
|
const json = JSON.parse(data.toString('utf-8'));
|
||||||
|
expect(json).toBeDefined();
|
||||||
|
expect(json.url).toEqual('https://httpbin.org/get');
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
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('/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('/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('/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();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartrequest',
|
name: '@push.rocks/smartrequest',
|
||||||
version: '4.2.2',
|
version: '5.0.1',
|
||||||
description: 'A module for modern HTTP/HTTPS requests with support for form data, file uploads, JSON, binary data, streams, and more.'
|
description: 'A module for modern HTTP/HTTPS requests with support for form data, file uploads, JSON, binary data, streams, and more.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,45 @@ export class SmartRequest<T = any> {
|
|||||||
return this;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set request timeout in milliseconds
|
* Set request timeout in milliseconds
|
||||||
*/
|
*/
|
||||||
@@ -389,7 +428,21 @@ export class SmartRequest<T = any> {
|
|||||||
// Main retry loop
|
// Main retry loop
|
||||||
for (let attempt = 0; attempt <= this._retries; attempt++) {
|
for (let attempt = 0; attempt <= this._retries; attempt++) {
|
||||||
try {
|
try {
|
||||||
const request = new CoreRequest(this._url, this._options as any);
|
// Check if we have a Node.js stream 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
const response = (await request.fire()) as ICoreResponse<R>;
|
const response = (await request.fire()) as ICoreResponse<R>;
|
||||||
|
|
||||||
// Check for 429 status if rate limit handling is enabled
|
// Check for 429 status if rate limit handling is enabled
|
||||||
|
|||||||
@@ -5,22 +5,31 @@ export * from '../core_base/types.js';
|
|||||||
|
|
||||||
const smartenvInstance = new plugins.smartenv.Smartenv();
|
const smartenvInstance = new plugins.smartenv.Smartenv();
|
||||||
|
|
||||||
// Dynamically load the appropriate implementation
|
// Dynamically load the appropriate implementation based on runtime
|
||||||
let CoreRequest: any;
|
let CoreRequest: any;
|
||||||
let CoreResponse: any;
|
let CoreResponse: any;
|
||||||
|
|
||||||
if (smartenvInstance.isNode) {
|
if (smartenvInstance.isDeno) {
|
||||||
// In Node.js, load the node implementation
|
// 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(
|
const modulePath = plugins.smartpath.join(
|
||||||
plugins.smartpath.dirname(import.meta.url),
|
plugins.smartpath.dirname(import.meta.url),
|
||||||
'../core_node/index.js',
|
'../core_node/index.js',
|
||||||
);
|
);
|
||||||
console.log(modulePath);
|
|
||||||
const impl = await smartenvInstance.getSafeNodeModule(modulePath);
|
const impl = await smartenvInstance.getSafeNodeModule(modulePath);
|
||||||
CoreRequest = impl.CoreRequest;
|
CoreRequest = impl.CoreRequest;
|
||||||
CoreResponse = impl.CoreResponse;
|
CoreResponse = impl.CoreResponse;
|
||||||
} else {
|
} else {
|
||||||
// In browser, load the fetch implementation
|
// In browser, load the fetch implementation (no unix socket support)
|
||||||
const impl = await import('../core_fetch/index.js');
|
const impl = await import('../core_fetch/index.js');
|
||||||
CoreRequest = impl.CoreRequest;
|
CoreRequest = impl.CoreRequest;
|
||||||
CoreResponse = impl.CoreResponse;
|
CoreResponse = impl.CoreResponse;
|
||||||
|
|||||||
@@ -17,10 +17,28 @@ export abstract class CoreRequest<
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses socket path and route from unix socket URL
|
* Parses socket path and route from unix socket URL
|
||||||
|
* Handles both full URLs (http://unix:/path/to/socket:/route) and pre-stripped paths (unix:/path/to/socket:/route)
|
||||||
|
* Returns clean file system path for socketPath (e.g., /var/run/docker.sock)
|
||||||
*/
|
*/
|
||||||
static parseUnixSocketUrl(url: string): { socketPath: string; path: string } {
|
static parseUnixSocketUrl(url: string): { socketPath: string; path: string } {
|
||||||
|
// Strip http:// or https:// prefix if present
|
||||||
|
// This makes the method work with both full URLs and pre-stripped paths
|
||||||
|
let cleanUrl = url;
|
||||||
|
if (cleanUrl.startsWith('http://')) {
|
||||||
|
cleanUrl = cleanUrl.substring('http://'.length);
|
||||||
|
} else if (cleanUrl.startsWith('https://')) {
|
||||||
|
cleanUrl = cleanUrl.substring('https://'.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip unix: prefix if present to get clean file system path
|
||||||
|
if (cleanUrl.startsWith('unix:')) {
|
||||||
|
cleanUrl = cleanUrl.substring('unix:'.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the socket path and HTTP path
|
||||||
|
// Format: /path/to/socket:/route/path
|
||||||
const parseRegex = /(.*):(.*)/;
|
const parseRegex = /(.*):(.*)/;
|
||||||
const result = parseRegex.exec(url);
|
const result = parseRegex.exec(cleanUrl);
|
||||||
return {
|
return {
|
||||||
socketPath: result[1],
|
socketPath: result[1],
|
||||||
path: result[2],
|
path: result[2],
|
||||||
|
|||||||
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;
|
||||||
81
ts/core_bun/response.ts
Normal file
81
ts/core_bun/response.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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 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;
|
||||||
81
ts/core_deno/response.ts
Normal file
81
ts/core_deno/response.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ export class CoreRequest extends AbstractCoreRequest<
|
|||||||
types.ICoreRequestOptions,
|
types.ICoreRequestOptions,
|
||||||
CoreResponse
|
CoreResponse
|
||||||
> {
|
> {
|
||||||
|
private timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private abortController: AbortController | null = null;
|
||||||
|
|
||||||
constructor(url: string, options: types.ICoreRequestOptions = {}) {
|
constructor(url: string, options: types.ICoreRequestOptions = {}) {
|
||||||
super(url, options);
|
super(url, options);
|
||||||
|
|
||||||
@@ -61,11 +64,19 @@ export class CoreRequest extends AbstractCoreRequest<
|
|||||||
if (
|
if (
|
||||||
typeof this.options.requestBody === 'string' ||
|
typeof this.options.requestBody === 'string' ||
|
||||||
this.options.requestBody instanceof ArrayBuffer ||
|
this.options.requestBody instanceof ArrayBuffer ||
|
||||||
|
this.options.requestBody instanceof Uint8Array ||
|
||||||
this.options.requestBody instanceof FormData ||
|
this.options.requestBody instanceof FormData ||
|
||||||
this.options.requestBody instanceof URLSearchParams ||
|
this.options.requestBody instanceof URLSearchParams ||
|
||||||
this.options.requestBody instanceof ReadableStream
|
this.options.requestBody instanceof ReadableStream ||
|
||||||
|
// Check for Buffer (Node.js polyfills in browser may provide this)
|
||||||
|
(typeof Buffer !== 'undefined' && this.options.requestBody instanceof Buffer)
|
||||||
) {
|
) {
|
||||||
fetchOptions.body = this.options.requestBody;
|
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 {
|
} else {
|
||||||
// Convert objects to JSON
|
// Convert objects to JSON
|
||||||
fetchOptions.body = JSON.stringify(this.options.requestBody);
|
fetchOptions.body = JSON.stringify(this.options.requestBody);
|
||||||
@@ -92,9 +103,13 @@ export class CoreRequest extends AbstractCoreRequest<
|
|||||||
if (this.options.timeout || this.options.hardDataCuttingTimeout) {
|
if (this.options.timeout || this.options.hardDataCuttingTimeout) {
|
||||||
const timeout =
|
const timeout =
|
||||||
this.options.hardDataCuttingTimeout || this.options.timeout;
|
this.options.hardDataCuttingTimeout || this.options.timeout;
|
||||||
const controller = new AbortController();
|
this.abortController = new AbortController();
|
||||||
setTimeout(() => controller.abort(), timeout);
|
this.timeoutId = setTimeout(() => {
|
||||||
fetchOptions.signal = controller.signal;
|
if (this.abortController) {
|
||||||
|
this.abortController.abort();
|
||||||
|
}
|
||||||
|
}, timeout);
|
||||||
|
fetchOptions.signal = this.abortController.signal;
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetchOptions;
|
return fetchOptions;
|
||||||
@@ -117,8 +132,12 @@ export class CoreRequest extends AbstractCoreRequest<
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, options);
|
const response = await fetch(url, options);
|
||||||
|
// Clear timeout on successful response
|
||||||
|
this.clearTimeout();
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Clear timeout on error
|
||||||
|
this.clearTimeout();
|
||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
throw new Error('Request timed out');
|
throw new Error('Request timed out');
|
||||||
}
|
}
|
||||||
@@ -126,6 +145,19 @@ export class CoreRequest extends AbstractCoreRequest<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the timeout and abort controller
|
||||||
|
*/
|
||||||
|
private clearTimeout(): void {
|
||||||
|
if (this.timeoutId) {
|
||||||
|
clearTimeout(this.timeoutId);
|
||||||
|
this.timeoutId = null;
|
||||||
|
}
|
||||||
|
if (this.abortController) {
|
||||||
|
this.abortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Static factory method to create and fire a request
|
* Static factory method to create and fire a request
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -72,15 +72,6 @@ export class CoreResponse<T = any>
|
|||||||
return this.response.body;
|
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
|
* Get the raw Response object
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ export * from '../core_base/types.js';
|
|||||||
* Fetch-specific response extensions
|
* Fetch-specific response extensions
|
||||||
*/
|
*/
|
||||||
export interface IFetchResponse<T = any> extends baseTypes.ICoreResponse<T> {
|
export interface IFetchResponse<T = any> extends baseTypes.ICoreResponse<T> {
|
||||||
// Node.js stream method that throws in browser
|
|
||||||
streamNode(): never;
|
|
||||||
|
|
||||||
// Access to raw Response object
|
// Access to raw Response object
|
||||||
raw(): Response;
|
raw(): Response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import * as fs from 'fs';
|
|||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as https from 'https';
|
import * as https from 'https';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import * as stream from 'stream';
|
||||||
|
|
||||||
export { http, https, fs, path };
|
export { http, https, fs, path, stream };
|
||||||
|
|
||||||
// pushrocks scope
|
// pushrocks scope
|
||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
|
|||||||
@@ -86,9 +86,7 @@ export class CoreRequest extends AbstractCoreRequest<
|
|||||||
|
|
||||||
// Handle unix socket URLs
|
// Handle unix socket URLs
|
||||||
if (CoreRequest.isUnixSocket(this.url)) {
|
if (CoreRequest.isUnixSocket(this.url)) {
|
||||||
const { socketPath, path } = CoreRequest.parseUnixSocketUrl(
|
const { socketPath, path } = CoreRequest.parseUnixSocketUrl(this.url);
|
||||||
this.options.path,
|
|
||||||
);
|
|
||||||
this.options.socketPath = socketPath;
|
this.options.socketPath = socketPath;
|
||||||
this.options.path = path;
|
this.options.path = path;
|
||||||
}
|
}
|
||||||
@@ -119,10 +117,11 @@ export class CoreRequest extends AbstractCoreRequest<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Perform the request
|
// Perform the request
|
||||||
|
let timeoutId: NodeJS.Timeout | null = null;
|
||||||
const request = requestModule.request(this.options, async (response) => {
|
const request = requestModule.request(this.options, async (response) => {
|
||||||
// Handle hard timeout
|
// Handle hard timeout
|
||||||
if (this.options.hardDataCuttingTimeout) {
|
if (this.options.hardDataCuttingTimeout) {
|
||||||
setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
response.destroy();
|
response.destroy();
|
||||||
done.reject(new Error('Request timed out'));
|
done.reject(new Error('Request timed out'));
|
||||||
}, this.options.hardDataCuttingTimeout);
|
}, this.options.hardDataCuttingTimeout);
|
||||||
@@ -132,12 +131,26 @@ export class CoreRequest extends AbstractCoreRequest<
|
|||||||
done.resolve(response);
|
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
|
// Write request body
|
||||||
if (this.options.requestBody) {
|
if (this.options.requestBody) {
|
||||||
if (this.options.requestBody instanceof plugins.formData) {
|
if (this.options.requestBody instanceof plugins.formData) {
|
||||||
this.options.requestBody.pipe(request).on('finish', () => {
|
this.options.requestBody.pipe(request).on('finish', () => {
|
||||||
request.end();
|
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 {
|
} else {
|
||||||
// Write body as-is - caller is responsible for serialization
|
// Write body as-is - caller is responsible for serialization
|
||||||
const bodyData =
|
const bodyData =
|
||||||
@@ -159,11 +172,23 @@ export class CoreRequest extends AbstractCoreRequest<
|
|||||||
request.on('error', (e) => {
|
request.on('error', (e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
request.destroy();
|
request.destroy();
|
||||||
|
// Clear timeout on error
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = null;
|
||||||
|
}
|
||||||
done.reject(e);
|
done.reject(e);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get response and handle response errors
|
// Get response and handle response errors
|
||||||
const response = await done.promise;
|
const response = await done.promise;
|
||||||
|
|
||||||
|
// Clear timeout on successful response
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = null;
|
||||||
|
}
|
||||||
|
|
||||||
response.on('error', (err) => {
|
response.on('error', (err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
response.destroy();
|
response.destroy();
|
||||||
|
|||||||
@@ -115,10 +115,12 @@ export class CoreResponse<T = any>
|
|||||||
*/
|
*/
|
||||||
async arrayBuffer(): Promise<ArrayBuffer> {
|
async arrayBuffer(): Promise<ArrayBuffer> {
|
||||||
const buffer = await this.collectBody();
|
const buffer = await this.collectBody();
|
||||||
return buffer.buffer.slice(
|
const sliced = buffer.buffer.slice(
|
||||||
buffer.byteOffset,
|
buffer.byteOffset,
|
||||||
buffer.byteOffset + buffer.byteLength,
|
buffer.byteOffset + buffer.byteLength,
|
||||||
);
|
);
|
||||||
|
// Ensure we return ArrayBuffer, not SharedArrayBuffer
|
||||||
|
return sliced instanceof ArrayBuffer ? sliced : new ArrayBuffer(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -127,41 +129,15 @@ export class CoreResponse<T = any>
|
|||||||
stream(): ReadableStream<Uint8Array> | null {
|
stream(): ReadableStream<Uint8Array> | null {
|
||||||
this.ensureNotConsumed();
|
this.ensureNotConsumed();
|
||||||
|
|
||||||
// Convert Node.js stream to web stream
|
// Convert Node.js stream to web stream using Readable.toWeb()
|
||||||
// In Node.js 16.5+ we can use Readable.toWeb()
|
// This creates a proper Node.js-compatible web stream that works with Readable.fromWeb()
|
||||||
if (this.incomingMessage.readableEnded || this.incomingMessage.destroyed) {
|
if (this.incomingMessage.readableEnded || this.incomingMessage.destroyed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a web ReadableStream from the Node.js stream
|
// Use Readable.toWeb() to convert Node.js stream to web stream (Node.js 16.5+)
|
||||||
const nodeStream = this.incomingMessage;
|
// The returned type is automatically Node.js ReadableStream which is compatible with Readable.fromWeb()
|
||||||
return new ReadableStream<Uint8Array>({
|
return plugins.stream.Readable.toWeb(this.incomingMessage) as any;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ export interface IExtendedIncomingMessage<T = any>
|
|||||||
* Node.js specific response extensions
|
* Node.js specific response extensions
|
||||||
*/
|
*/
|
||||||
export interface INodeResponse<T = any> extends baseTypes.ICoreResponse<T> {
|
export interface INodeResponse<T = any> extends baseTypes.ICoreResponse<T> {
|
||||||
// Node.js specific methods
|
|
||||||
streamNode(): NodeJS.ReadableStream; // Returns Node.js style stream
|
|
||||||
|
|
||||||
// Legacy compatibility
|
// Legacy compatibility
|
||||||
raw(): plugins.http.IncomingMessage;
|
raw(): plugins.http.IncomingMessage;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user