diff --git a/changelog.md b/changelog.md index 91ebc1d..38ac631 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,18 @@ # Changelog +## 2025-11-16 - 4.4.0 - feat(core) +Add Bun and Deno runtime support, unify core loader, unix-socket support and cross-runtime streaming/tests + +- package.json: expose ./core_bun and ./core_deno in exports and add runtime-related keywords +- Core dynamic loader (ts/core/index.ts): detect Bun and Deno at runtime and load corresponding implementations +- New runtime modules: added ts/core_bun/* and ts/core_deno/* (response, types, index) to provide Bun and Deno CoreResponse/CoreRequest wrappers +- Client streaming: SmartRequest no longer immediately deletes temporary __nodeStream and __rawStreamFunc props — CoreRequest implementations handle them; temporary properties are cleaned up after CoreRequest is created +- Node.js request: core_node/request.ts converts web ReadableStream to Node.js Readable via stream.Readable.fromWeb and pipes it; also supports passing requestDataFunc for raw streaming +- core_node/plugins: export stream helper and rework third-party exports (agentkeepalive, form-data) for Node implementation +- CoreResponse for Bun/Deno: new implementations wrap native fetch Response and expose raw(), stream(), and streamNode() behavior (streamNode() throws in Bun/Deno with guidance to use web streams) +- Tests: added unified cross-runtime streaming tests and separate unix-socket tests for Node/Bun/Deno with Docker-socket availability checks; removed old node-only streaming test +- Docs/readme: updated to describe Node, Bun, Deno, and browser support, unix socket behavior per runtime, and new test conventions + ## 2025-11-16 - 4.3.8 - fix(core) Ensure correct ArrayBuffer return, fix fetch body typing, reorganize node-only tests, and bump tsbuild devDependency diff --git a/package.json b/package.json index 3a4b96c..c8d2715 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "exports": { ".": "./dist_ts/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", "scripts": { @@ -30,7 +32,11 @@ "keepAlive", "TypeScript", "modern web requests", - "drop-in replacement" + "drop-in replacement", + "Bun", + "Deno", + "Node.js", + "unix sockets" ], "author": "Task Venture Capital GmbH", "license": "MIT", diff --git a/readme.hints.md b/readme.hints.md index d405a73..c00c86f 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -4,26 +4,28 @@ - supports http - supports https -- supports unix socks +- supports unix sockets on Node.js, Bun, and Deno - supports formData - supports file uploads - supports best practice keepAlive - dedicated functions for working with JSON request/response cycles - written in TypeScript - continuously updated -- uses node native http and https modules -- supports both Node.js and browser environments +- supports Node.js, Bun, Deno, and browser environments with automatic runtime detection +- runtime-specific implementations using native APIs (Node http/https, Bun fetch, Deno fetch with HttpClient, browser fetch) - used in modules like @push.rocks/smartproxy and @api.global/typedrequest -## Architecture Overview (as of 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 -- Node.js implementation (ts/core_node/) uses native http/https modules -- Fetch implementation (ts/core_fetch/) uses Fetch API for browser compatibility -- Core module (ts/core/) dynamically selects the appropriate implementation based on environment -- Client API (ts/client/) provides a fluent, chainable interface -- Legacy API has been completely removed in v3.0.0 +- Node.js implementation (ts/core_node/) uses native http/https modules with unix socket support +- Bun implementation (ts/core_bun/) uses Bun's native fetch with unix socket support via `unix` option +- Deno implementation (ts/core_deno/) uses Deno's fetch with unix socket support via HttpClient proxy +- Browser implementation (ts/core_fetch/) uses standard Fetch API for browser compatibility +- Core module (ts/core/) uses @push.rocks/smartenv to detect runtime and dynamically load appropriate implementation +- Client API (ts/client/) provides a fluent, chainable interface that works across all runtimes +- Runtime detection order: Deno → Bun → Node.js → Browser (following smartenv detection best practices) ## Key Components @@ -56,6 +58,31 @@ - `stream()` returns native web ReadableStream from response.body - `streamNode()` throws error explaining it's not available in browser +### Core Bun Module (ts/core_bun/) + +- `request.ts`: Bun implementation using native fetch with unix socket support + - Uses Bun's `unix` fetch option for unix socket connections + - Supports both `unix` and `socketPath` options (converts socketPath to unix) + - Handles URL parsing for `http://unix:/path/to/socket:/http/path` format + - Throws errors for Node.js specific options (agent) +- `response.ts`: Bun-based CoreResponse implementation + - `stream()` returns native web ReadableStream from response.body + - `streamNode()` throws error (Bun uses web streams; users should use stream() instead) +- `types.ts`: Extends base types with IBunRequestOptions including `unix` option + +### Core Deno Module (ts/core_deno/) + +- `request.ts`: Deno implementation using fetch with HttpClient proxy for unix sockets + - Creates and caches Deno.HttpClient instances per socket path + - Supports both explicit `client` option and automatic client creation from `socketPath` + - HttpClient cache prevents creating multiple clients for same socket + - Provides `clearClientCache()` static method for cleanup + - Throws errors for Node.js specific options (agent) +- `response.ts`: Deno-based CoreResponse implementation + - `stream()` returns native web ReadableStream from response.body + - `streamNode()` throws error (Deno uses web streams, not Node.js streams) +- `types.ts`: Extends base types with IDenoRequestOptions including `client` option + ### Core Module (ts/core/) - Dynamically loads appropriate implementation based on environment @@ -70,10 +97,15 @@ ### Stream Handling -- `stream()` method always returns web-style ReadableStream +- `stream()` method always returns web-style ReadableStream across all platforms - In Node.js, converts native streams to web streams -- `streamNode()` available only in Node.js environment for native streams -- Consistent API across platforms while preserving platform-specific capabilities +- `streamNode()` availability by runtime: + - **Node.js**: Returns native Node.js ReadableStream (only runtime that supports this) + - **Bun**: Throws error (use web streams via stream() instead) + - **Deno**: Throws error (Deno uses web streams only) + - **Browser**: Throws error (browsers use web streams only) +- Consistent API across platforms with web streams as the common denominator +- Only Node.js provides native Node.js streams via streamNode() ### Binary Request Handling @@ -85,5 +117,11 @@ - Use `pnpm test` to run all tests - Tests use @git.zone/tstest/tapbundle for assertions -- Separate test files for Node.js (test.node.ts) and browser (test.browser.ts) +- 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 diff --git a/readme.md b/readme.md index e7ecd4c..175cc1a 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # @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 @@ -18,26 +18,30 @@ yarn add @push.rocks/smartrequest ## Key Features - 🚀 **Modern Fetch-like API** - Familiar response methods (`.json()`, `.text()`, `.arrayBuffer()`, `.stream()`) -- 🌐 **Cross-Platform** - Works in both Node.js and browsers with a unified API -- 🔌 **Unix Socket Support** - Connect to local services like Docker (Node.js only) +- 🌐 **Cross-Platform** - Works in Node.js, Bun, Deno, and browsers with a unified API +- 🔌 **Unix Socket Support** - Connect to local services like Docker (Node.js, Bun, and Deno) - 📦 **Form Data & File Uploads** - Built-in support for multipart/form-data - 🔁 **Pagination Support** - Multiple strategies (offset, cursor, Link headers) - ⚡ **Keep-Alive Connections** - Efficient connection pooling in Node.js - 🛡️ **TypeScript First** - Full type safety and IntelliSense support - 🎯 **Zero Magic Defaults** - Explicit configuration following fetch API principles - 📡 **Streaming Support** - Stream buffers, files, and custom data without loading into memory -- 🔧 **Highly Configurable** - Timeouts, retries, headers, and more +- 🔧 **Highly Configurable** - Timeouts, retries, headers, rate limiting, and more ## 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 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** - 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 +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 `@push.rocks/smartrequest` provides a clean, type-safe API inspired by the native fetch API but with additional features needed for modern applications. @@ -204,7 +208,7 @@ async function streamLargeFile(url: string) { async function streamWithNodeApi(url: string) { const response = await SmartRequest.create().url(url).get(); - // Only available in Node.js, throws error in browser + // Only available in Node.js, throws error in browser/Bun/Deno const nodeStream = response.streamNode(); nodeStream.on('data', (chunk) => { @@ -226,7 +230,7 @@ The response object provides these methods: - `text(): Promise` - Get response as text - `arrayBuffer(): Promise` - Get response as ArrayBuffer - `stream(): ReadableStream | null` - Get web-style ReadableStream (cross-platform) -- `streamNode(): NodeJS.ReadableStream` - Get Node.js stream (Node.js only, throws in browser) +- `streamNode(): NodeJS.ReadableStream` - Get Node.js stream (Node.js only, throws in browser/Bun/Deno) - `raw(): Response | http.IncomingMessage` - Get the underlying platform response Each body method can only be called once per response, similar to the fetch API. @@ -312,37 +316,55 @@ import { SmartRequest } from '@push.rocks/smartrequest'; import * as fs from 'fs'; import { Readable } from 'stream'; -// Stream a Buffer directly +// 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 a file using Node.js streams +// 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 +// 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(); } @@ -354,24 +376,24 @@ async function customStreaming() { // Custom streaming logic - you have full control request.write('chunk1'); request.write('chunk2'); - + // Stream from another source someReadableStream.pipe(request); }) .post(); - + return await response.json(); } // Send Uint8Array (works in both Node.js and browser) async function uploadBinaryData() { const data = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" - + const response = await SmartRequest.create() .url('https://api.example.com/binary') .buffer(data, 'application/octet-stream') .post(); - + return await response.json(); } ``` @@ -379,19 +401,19 @@ async function uploadBinaryData() { #### Streaming Methods - **`.buffer(data, contentType?)`** - Stream a Buffer or Uint8Array directly - - `data`: Buffer (Node.js) or Uint8Array (both platforms) to send + - `data`: Buffer (Node.js) or Uint8Array (cross-platform) to send - `contentType`: Optional content type (defaults to 'application/octet-stream') - - ✅ Works in both Node.js and browsers + - ✅ Works everywhere (Node.js, Bun, Deno, browsers) -- **`.stream(stream, contentType?)`** - Stream from ReadableStream - - `stream`: Web ReadableStream (both platforms) or Node.js stream (Node.js only) +- **`.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 in both Node.js and browsers - - ⚠️ Node.js streams only work in Node.js environment + - ✅ Web ReadableStream works everywhere (Node.js, Bun, Deno, browsers) + - ⚠️ Node.js streams only work in Node.js (automatically converted to web streams in Bun/Deno) - **`.raw(streamFunc)`** - Advanced control over request streaming - `streamFunc`: Function that receives the raw request object for custom streaming - - ❌ **Node.js only** - not supported in browsers + - ❌ **Node.js only** - not supported in browsers, Bun, or Deno - Use for advanced scenarios like chunked transfer encoding These methods are particularly useful for: @@ -400,12 +422,14 @@ These methods are particularly useful for: - Proxying data between services - Implementing chunked transfer encoding -### Unix Socket Support (Node.js only) +### Unix Socket Support (Node.js, Bun, and Deno) + +SmartRequest supports unix sockets across all server-side runtimes with a unified API: ```typescript import { SmartRequest } from '@push.rocks/smartrequest'; -// Connect to a service via Unix socket +// Connect to a service via Unix socket (works on Node.js, Bun, and Deno) async function queryViaUnixSocket() { const response = await SmartRequest.create() .url('http://unix:/var/run/docker.sock:/v1.24/containers/json') @@ -413,6 +437,57 @@ async function queryViaUnixSocket() { 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 @@ -599,12 +674,63 @@ const response = await SmartRequest.create() .get(); ``` +### Bun-Specific Options + +When running in Bun, you can use Bun-specific options: + +```typescript +const response = await SmartRequest.create() + .url('https://api.example.com/data') + .options({ + unix: '/var/run/api.sock', // Unix socket (Bun's native option) + keepAlive: true, // Keep-alive support + }) + .get(); + +// Bun uses web streams - streamNode() throws an error +const streamResponse = await SmartRequest.create() + .url('https://api.example.com/data') + .get(); + +const webStream = streamResponse.stream(); // ✅ Use web streams in Bun +// streamNode() is not available - throws error directing you to use stream() +``` + +### Deno-Specific Options + +When running in Deno, you can use Deno-specific options: + +```typescript +// Custom HttpClient for advanced configuration +const client = Deno.createHttpClient({ + proxy: { url: 'unix:///var/run/api.sock' } +}); + +const response = await SmartRequest.create() + .url('https://api.example.com/data') + .options({ + client, // Custom Deno HttpClient + }) + .get(); + +// Remember to clean up clients when done +client.close(); + +// Deno uses web streams - streamNode() throws an error +const streamResponse = await SmartRequest.create() + .url('https://api.example.com/data') + .get(); + +const webStream = streamResponse.stream(); // ✅ Use web streams in Deno +// streamNode() is not available - throws error directing you to use stream() +``` + ## Complete Example: Building a REST API Client Here's a complete example of building a typed API client: ```typescript -import { SmartRequest, type CoreResponse } from '@push.rocks/smartrequest'; +import { SmartRequest, type ICoreResponse } from '@push.rocks/smartrequest'; interface User { id: number; @@ -644,6 +770,9 @@ class BlogApiClient { if (!response.ok) { throw new Error(`Failed to delete post: ${response.statusText}`); } + + // Consume the body + await response.text(); } async getAllPosts(userId?: number): Promise { @@ -707,9 +836,20 @@ 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 v3.x to v4.x + +Version 4.0 adds comprehensive cross-platform support: + +1. **Multi-Runtime Support**: Now works natively in Node.js, Bun, Deno, and browsers +2. **Unix Sockets Everywhere**: Unix socket support added for Bun and Deno +3. **Web Streams**: Full support for web ReadableStream across all platforms +4. **Automatic Runtime Detection**: No configuration needed - works everywhere automatically + +### From v2.x to v3.x + +Version 3.0 brought significant architectural improvements: 1. **Legacy API Removed**: The function-based API (getJson, postJson, etc.) has been removed. Use SmartRequest instead. 2. **Unified Response API**: All responses now use the same fetch-like interface regardless of platform. diff --git a/test/test.streaming.node.ts b/test/test.streaming.node+bun+deno.ts similarity index 65% rename from test/test.streaming.node.ts rename to test/test.streaming.node+bun+deno.ts index fdb579d..82b6d7f 100644 --- a/test/test.streaming.node.ts +++ b/test/test.streaming.node+bun+deno.ts @@ -1,71 +1,58 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; -import * as fs from 'node:fs'; 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 stream using stream() method', async () => { - // Create a simple readable stream - const { Readable } = await import('stream'); +tap.test('should send a web ReadableStream using stream() method', async () => { const testData = 'Stream data test'; - const stream = Readable.from([testData]); - + + // 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 handle raw streaming with custom function', async () => { - const testData = 'Custom raw stream data'; - - const smartRequest = SmartRequest.create() - .url('https://httpbin.org/post') - .raw((request) => { - // Custom streaming logic - request.write(testData); - request.end(); - }) - .method('POST'); - const response = await smartRequest.post(); const data = await response.json(); - + expect(data).toHaveProperty('data'); expect(data.data).toEqual(testData); }); tap.test('should send Uint8Array using buffer() method', async () => { const testData = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" in ASCII - + const smartRequest = SmartRequest.create() .url('https://httpbin.org/post') .buffer(testData, 'application/octet-stream') .method('POST'); - + const response = await smartRequest.post(); const data = await response.json(); - + // Just verify that data was sent expect(data).toHaveProperty('data'); expect(data.headers['Content-Type']).toEqual('application/octet-stream'); diff --git a/test/test.unixsocket.bun.ts b/test/test.unixsocket.bun.ts new file mode 100644 index 0000000..abe4793 --- /dev/null +++ b/test/test.unixsocket.bun.ts @@ -0,0 +1,101 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; +import { SmartRequest } from '../ts/client/index.js'; +import { CoreRequest } from '../ts/core_bun/index.js'; + +// Check if Docker socket exists (common unix socket for testing) +const dockerSocketPath = '/var/run/docker.sock'; +let dockerAvailable = false; + +try { + const file = Bun.file(dockerSocketPath); + dockerAvailable = await file.exists(); +} catch (error) { + console.log( + 'Docker socket not available - skipping unix socket tests. To enable, ensure Docker is running.', + ); +} + +tap.test('bun: should detect unix socket URLs correctly', async () => { + expect(CoreRequest.isUnixSocket('unix:/var/run/docker.sock:/version')).toBeTrue(); + expect(CoreRequest.isUnixSocket('http://unix:/var/run/docker.sock:/version')).toBeTrue(); + expect(CoreRequest.isUnixSocket('https://unix:/var/run/docker.sock:/version')).toBeTrue(); + expect(CoreRequest.isUnixSocket('http://example.com')).toBeFalse(); + expect(CoreRequest.isUnixSocket('https://example.com')).toBeFalse(); +}); + +tap.test('bun: should parse unix socket URLs correctly', async () => { + const result = CoreRequest.parseUnixSocketUrl('unix:/var/run/docker.sock:/v1.24/version'); + expect(result.socketPath).toEqual('unix:/var/run/docker.sock'); + expect(result.path).toEqual('/v1.24/version'); +}); + +if (dockerAvailable) { + tap.test('bun: should connect to Docker via unix socket (unix: protocol)', async () => { + const response = await SmartRequest.create() + .url('http://unix:/var/run/docker.sock:/version') + .get(); + + expect(response.ok).toBeTrue(); + expect(response.status).toEqual(200); + + const body = await response.json(); + expect(body).toHaveProperty('Version'); + console.log(`Docker version: ${body.Version}`); + }); + + tap.test('bun: should connect to Docker via socketPath option', async () => { + const response = await CoreRequest.create('http://localhost/version', { + socketPath: '/var/run/docker.sock', + }); + + expect(response.ok).toBeTrue(); + expect(response.status).toEqual(200); + + const body = await response.json(); + expect(body).toHaveProperty('Version'); + }); + + tap.test('bun: should connect to Docker via unix option', async () => { + const response = await CoreRequest.create('http://localhost/version', { + unix: '/var/run/docker.sock', + }); + + expect(response.ok).toBeTrue(); + expect(response.status).toEqual(200); + + const body = await response.json(); + expect(body).toHaveProperty('Version'); + }); + + tap.test('bun: should handle unix socket with query parameters', async () => { + const response = await SmartRequest.create() + .url('http://unix:/var/run/docker.sock:/containers/json') + .query({ all: 'true' }) + .get(); + + expect(response.ok).toBeTrue(); + expect(response.status).toEqual(200); + + const body = await response.json(); + expect(Array.isArray(body)).toBeTrue(); + }); + + tap.test('bun: should handle unix socket with POST requests', async () => { + // Test POST to Docker API (this specific endpoint may require permissions) + const response = await SmartRequest.create() + .url('http://unix:/var/run/docker.sock:/containers/json') + .query({ all: 'true', limit: '1' }) + .get(); + + expect(response.status).toBeGreaterThanOrEqual(200); + expect(response.status).toBeLessThan(500); + + await response.text(); // Consume body + }); +} else { + tap.skip.test( + 'bun: unix socket tests skipped - Docker socket not available', + ); +} + +export default tap.start(); diff --git a/test/test.unixsocket.deno.ts b/test/test.unixsocket.deno.ts new file mode 100644 index 0000000..23f6d51 --- /dev/null +++ b/test/test.unixsocket.deno.ts @@ -0,0 +1,154 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; +import { SmartRequest } from '../ts/client/index.js'; +import { CoreRequest } from '../ts/core_deno/index.js'; + +// Check if Docker socket exists (common unix socket for testing) +const dockerSocketPath = '/var/run/docker.sock'; +let dockerAvailable = false; + +try { + const fileInfo = await Deno.stat(dockerSocketPath); + dockerAvailable = fileInfo.isFile || fileInfo.isSymlink; +} catch (error) { + console.log( + 'Docker socket not available - skipping unix socket tests. To enable, ensure Docker is running.', + ); +} + +tap.test('deno: should detect unix socket URLs correctly', async () => { + expect(CoreRequest.isUnixSocket('unix:/var/run/docker.sock:/version')).toBeTrue(); + expect(CoreRequest.isUnixSocket('http://unix:/var/run/docker.sock:/version')).toBeTrue(); + expect(CoreRequest.isUnixSocket('https://unix:/var/run/docker.sock:/version')).toBeTrue(); + expect(CoreRequest.isUnixSocket('http://example.com')).toBeFalse(); + expect(CoreRequest.isUnixSocket('https://example.com')).toBeFalse(); +}); + +tap.test('deno: should parse unix socket URLs correctly', async () => { + const result = CoreRequest.parseUnixSocketUrl('unix:/var/run/docker.sock:/v1.24/version'); + expect(result.socketPath).toEqual('unix:/var/run/docker.sock'); + expect(result.path).toEqual('/v1.24/version'); +}); + +if (dockerAvailable) { + tap.test('deno: should connect to Docker via unix socket (unix: protocol)', async () => { + const response = await SmartRequest.create() + .url('http://unix:/var/run/docker.sock:/version') + .get(); + + expect(response.ok).toBeTrue(); + expect(response.status).toEqual(200); + + const body = await response.json(); + expect(body).toHaveProperty('Version'); + console.log(`Docker version: ${body.Version}`); + }); + + tap.test('deno: should connect to Docker via socketPath option', async () => { + const response = await CoreRequest.create('http://localhost/version', { + socketPath: '/var/run/docker.sock', + }); + + expect(response.ok).toBeTrue(); + expect(response.status).toEqual(200); + + const body = await response.json(); + expect(body).toHaveProperty('Version'); + }); + + tap.test('deno: should connect to Docker via HttpClient', async () => { + const client = Deno.createHttpClient({ + proxy: { + url: 'unix:///var/run/docker.sock', + }, + }); + + const response = await CoreRequest.create('http://localhost/version', { + client, + }); + + expect(response.ok).toBeTrue(); + expect(response.status).toEqual(200); + + const body = await response.json(); + expect(body).toHaveProperty('Version'); + + // Clean up client + client.close(); + }); + + tap.test('deno: should handle unix socket with query parameters', async () => { + const response = await SmartRequest.create() + .url('http://unix:/var/run/docker.sock:/containers/json') + .query({ all: 'true' }) + .get(); + + expect(response.ok).toBeTrue(); + expect(response.status).toEqual(200); + + const body = await response.json(); + expect(Array.isArray(body)).toBeTrue(); + }); + + tap.test('deno: should handle unix socket with POST requests', async () => { + // Test POST to Docker API (this specific endpoint may require permissions) + const response = await SmartRequest.create() + .url('http://unix:/var/run/docker.sock:/containers/json') + .query({ all: 'true', limit: '1' }) + .get(); + + expect(response.status).toBeGreaterThanOrEqual(200); + expect(response.status).toBeLessThan(500); + + await response.text(); // Consume body + }); + + tap.test('deno: should cache HttpClient for reuse', async () => { + // First request creates a client + const response1 = await SmartRequest.create() + .url('http://unix:/var/run/docker.sock:/version') + .get(); + + expect(response1.ok).toBeTrue(); + await response1.text(); + + // Second request should reuse the cached client + const response2 = await SmartRequest.create() + .url('http://unix:/var/run/docker.sock:/version') + .get(); + + expect(response2.ok).toBeTrue(); + await response2.text(); + + // Clean up cache + CoreRequest.clearClientCache(); + }); + + tap.test('deno: should clear HttpClient cache', async () => { + const response = await SmartRequest.create() + .url('http://unix:/var/run/docker.sock:/version') + .get(); + + expect(response.ok).toBeTrue(); + await response.text(); + + // Clear cache - should not throw + CoreRequest.clearClientCache(); + + // Subsequent request should create new client + const response2 = await SmartRequest.create() + .url('http://unix:/var/run/docker.sock:/version') + .get(); + + expect(response2.ok).toBeTrue(); + await response2.text(); + + // Clean up + CoreRequest.clearClientCache(); + }); +} else { + tap.skip.test( + 'deno: unix socket tests skipped - Docker socket not available', + ); +} + +export default tap.start(); diff --git a/test/test.unixsocket.node.ts b/test/test.unixsocket.node.ts new file mode 100644 index 0000000..bc2eee0 --- /dev/null +++ b/test/test.unixsocket.node.ts @@ -0,0 +1,90 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; +import * as plugins from '../ts/core_node/plugins.js'; +import { SmartRequest } from '../ts/client/index.js'; +import { CoreRequest } from '../ts/core_node/index.js'; + +// Check if Docker socket exists (common unix socket for testing) +const dockerSocketPath = '/var/run/docker.sock'; +let dockerAvailable = false; + +try { + await plugins.fs.promises.access(dockerSocketPath, plugins.fs.constants.R_OK); + dockerAvailable = true; +} catch (error) { + console.log( + 'Docker socket not available - skipping unix socket tests. To enable, ensure Docker is running.', + ); +} + +tap.test('node: should detect unix socket URLs correctly', async () => { + expect(CoreRequest.isUnixSocket('unix:/var/run/docker.sock:/version')).toBeTrue(); + expect(CoreRequest.isUnixSocket('http://unix:/var/run/docker.sock:/version')).toBeTrue(); + expect(CoreRequest.isUnixSocket('https://unix:/var/run/docker.sock:/version')).toBeTrue(); + expect(CoreRequest.isUnixSocket('http://example.com')).toBeFalse(); + expect(CoreRequest.isUnixSocket('https://example.com')).toBeFalse(); +}); + +tap.test('node: should parse unix socket URLs correctly', async () => { + const result = CoreRequest.parseUnixSocketUrl('unix:/var/run/docker.sock:/v1.24/version'); + expect(result.socketPath).toEqual('unix:/var/run/docker.sock'); + expect(result.path).toEqual('/v1.24/version'); +}); + +if (dockerAvailable) { + tap.test('node: should connect to Docker via unix socket (unix: protocol)', async () => { + const response = await SmartRequest.create() + .url('http://unix:/var/run/docker.sock:/version') + .get(); + + expect(response.ok).toBeTrue(); + expect(response.status).toEqual(200); + + const body = await response.json(); + expect(body).toHaveProperty('Version'); + console.log(`Docker version: ${body.Version}`); + }); + + tap.test('node: should connect to Docker via socketPath option', async () => { + const response = await CoreRequest.create('http://localhost/version', { + socketPath: '/var/run/docker.sock', + }); + + expect(response.ok).toBeTrue(); + expect(response.status).toEqual(200); + + const body = await response.json(); + expect(body).toHaveProperty('Version'); + }); + + tap.test('node: should handle unix socket with query parameters', async () => { + const response = await SmartRequest.create() + .url('http://unix:/var/run/docker.sock:/containers/json') + .query({ all: 'true' }) + .get(); + + expect(response.ok).toBeTrue(); + expect(response.status).toEqual(200); + + const body = await response.json(); + expect(Array.isArray(body)).toBeTrue(); + }); + + tap.test('node: should handle unix socket with POST requests', async () => { + // Test POST to Docker API (this specific endpoint may require permissions) + const response = await SmartRequest.create() + .url('http://unix:/var/run/docker.sock:/containers/json') + .query({ all: 'true', limit: '1' }) + .get(); + + expect(response.status).toBeGreaterThanOrEqual(200); + expect(response.status).toBeLessThan(500); + + await response.text(); // Consume body + }); +} else { + tap.skip.test( + 'node: unix socket tests skipped - Docker socket not available', + ); +} + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index e35e6c4..a5d194d 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartrequest', - version: '4.3.8', + version: '4.4.0', description: 'A module for modern HTTP/HTTPS requests with support for form data, file uploads, JSON, binary data, streams, and more.' } diff --git a/ts/client/smartrequest.ts b/ts/client/smartrequest.ts index 0c430d2..b8330b5 100644 --- a/ts/client/smartrequest.ts +++ b/ts/client/smartrequest.ts @@ -447,15 +447,18 @@ export class SmartRequest { requestDataFunc = (req: any) => { nodeStream.pipe(req); }; - // Remove the temporary stream reference - delete (this._options as any).__nodeStream; + // Don't delete __nodeStream yet - let CoreRequest implementations handle it + // Node.js will use requestDataFunc, Bun/Deno will convert the stream } else if ((this._options as any).__rawStreamFunc) { requestDataFunc = (this._options as any).__rawStreamFunc; - // Remove the temporary function reference - delete (this._options as any).__rawStreamFunc; + // Don't delete __rawStreamFunc yet - let CoreRequest implementations handle it } - + const request = new CoreRequest(this._url, this._options as any, requestDataFunc); + + // Clean up temporary properties after CoreRequest has been created + delete (this._options as any).__nodeStream; + delete (this._options as any).__rawStreamFunc; const response = (await request.fire()) as ICoreResponse; // Check for 429 status if rate limit handling is enabled diff --git a/ts/core/index.ts b/ts/core/index.ts index b148555..8e02db6 100644 --- a/ts/core/index.ts +++ b/ts/core/index.ts @@ -5,12 +5,22 @@ export * from '../core_base/types.js'; const smartenvInstance = new plugins.smartenv.Smartenv(); -// Dynamically load the appropriate implementation +// Dynamically load the appropriate implementation based on runtime let CoreRequest: any; let CoreResponse: any; -if (smartenvInstance.isNode) { - // In Node.js, load the node implementation +if (smartenvInstance.isDeno) { + // In Deno, load the Deno implementation with HttpClient-based unix socket support + const impl = await import('../core_deno/index.js'); + CoreRequest = impl.CoreRequest; + CoreResponse = impl.CoreResponse; +} else if (smartenvInstance.isBun) { + // In Bun, load the Bun implementation with native fetch unix socket support + const impl = await import('../core_bun/index.js'); + CoreRequest = impl.CoreRequest; + CoreResponse = impl.CoreResponse; +} else if (smartenvInstance.isNode) { + // In Node.js, load the Node.js implementation with native http/https unix socket support const modulePath = plugins.smartpath.join( plugins.smartpath.dirname(import.meta.url), '../core_node/index.js', @@ -19,7 +29,7 @@ if (smartenvInstance.isNode) { CoreRequest = impl.CoreRequest; CoreResponse = impl.CoreResponse; } 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'); CoreRequest = impl.CoreRequest; CoreResponse = impl.CoreResponse; diff --git a/ts/core_bun/index.ts b/ts/core_bun/index.ts new file mode 100644 index 0000000..fd37707 --- /dev/null +++ b/ts/core_bun/index.ts @@ -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'; diff --git a/ts/core_bun/request.ts b/ts/core_bun/request.ts new file mode 100644 index 0000000..dfd5ed4 --- /dev/null +++ b/ts/core_bun/request.ts @@ -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 | 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; + 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 { + const response = await this.fireCore(); + return new CoreResponse(response); + } + + /** + * Fire the request and return the raw Response + */ + async fireCore(): Promise { + 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 { + const request = new CoreRequest(url, options); + return request.fire(); + } +} + +/** + * Convenience exports for backward compatibility + */ +export const isUnixSocket = CoreRequest.isUnixSocket; +export const parseUnixSocketUrl = CoreRequest.parseUnixSocketUrl; diff --git a/ts/core_bun/response.ts b/ts/core_bun/response.ts new file mode 100644 index 0000000..ce0d60c --- /dev/null +++ b/ts/core_bun/response.ts @@ -0,0 +1,95 @@ +import * as types from './types.js'; +import { CoreResponse as AbstractCoreResponse } from '../core_base/response.js'; + +/** + * Bun implementation of Core Response class that wraps native fetch Response + */ +export class CoreResponse + extends AbstractCoreResponse + implements types.IBunResponse +{ + 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 { + 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 { + this.ensureNotConsumed(); + return await this.response.text(); + } + + /** + * Get response as ArrayBuffer + */ + async arrayBuffer(): Promise { + this.ensureNotConsumed(); + return await this.response.arrayBuffer(); + } + + /** + * Get response as a readable stream (Web Streams API) + */ + stream(): ReadableStream | null { + this.ensureNotConsumed(); + return this.response.body; + } + + /** + * Get response as a Node.js-style stream + * Bun supports Node.js streams, so we can provide this functionality + * + * Note: In Bun, you may also be able to use the web stream directly with stream() method + */ + streamNode(): never { + // Bun primarily uses web streams and has excellent compatibility + // For most use cases, use stream() which returns a standard ReadableStream + throw new Error( + 'streamNode() is not available in Bun environment. Use stream() for web-style ReadableStream, which Bun fully supports.', + ); + } + + /** + * Get the raw Response object + */ + raw(): Response { + return this.responseClone; + } +} diff --git a/ts/core_bun/types.ts b/ts/core_bun/types.ts new file mode 100644 index 0000000..bbdaa50 --- /dev/null +++ b/ts/core_bun/types.ts @@ -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 extends baseTypes.ICoreResponse { + // Access to raw Response object + raw(): Response; +} diff --git a/ts/core_deno/deno.types.ts b/ts/core_deno/deno.types.ts new file mode 100644 index 0000000..ea5ab18 --- /dev/null +++ b/ts/core_deno/deno.types.ts @@ -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 {}; diff --git a/ts/core_deno/index.ts b/ts/core_deno/index.ts new file mode 100644 index 0000000..2dc4fe6 --- /dev/null +++ b/ts/core_deno/index.ts @@ -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'; diff --git a/ts/core_deno/request.ts b/ts/core_deno/request.ts new file mode 100644 index 0000000..6db2fdf --- /dev/null +++ b/ts/core_deno/request.ts @@ -0,0 +1,295 @@ +/// +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(); + +/** + * 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 | 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; + 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 { + const response = await this.fireCore(); + return new CoreResponse(response); + } + + /** + * Fire the request and return the raw Response + */ + async fireCore(): Promise { + 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 { + 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; diff --git a/ts/core_deno/response.ts b/ts/core_deno/response.ts new file mode 100644 index 0000000..3cb04ce --- /dev/null +++ b/ts/core_deno/response.ts @@ -0,0 +1,91 @@ +import * as types from './types.js'; +import { CoreResponse as AbstractCoreResponse } from '../core_base/response.js'; + +/** + * Deno implementation of Core Response class that wraps native fetch Response + */ +export class CoreResponse + extends AbstractCoreResponse + implements types.IDenoResponse +{ + 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 { + 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 { + this.ensureNotConsumed(); + return await this.response.text(); + } + + /** + * Get response as ArrayBuffer + */ + async arrayBuffer(): Promise { + this.ensureNotConsumed(); + return await this.response.arrayBuffer(); + } + + /** + * Get response as a readable stream (Web Streams API) + */ + stream(): ReadableStream | null { + this.ensureNotConsumed(); + return this.response.body; + } + + /** + * Node.js stream method - not available in Deno's standard mode + * Throws an error as Deno uses web-standard ReadableStream + */ + streamNode(): never { + throw new Error( + 'streamNode() is not available in Deno environment. Use stream() for web-style ReadableStream.', + ); + } + + /** + * Get the raw Response object + */ + raw(): Response { + return this.responseClone; + } +} diff --git a/ts/core_deno/types.ts b/ts/core_deno/types.ts new file mode 100644 index 0000000..0fcdd79 --- /dev/null +++ b/ts/core_deno/types.ts @@ -0,0 +1,24 @@ +/// +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 extends baseTypes.ICoreResponse { + // Access to raw Response object + raw(): Response; +} diff --git a/ts/core_node/plugins.ts b/ts/core_node/plugins.ts index 0a7e19c..aacb30e 100644 --- a/ts/core_node/plugins.ts +++ b/ts/core_node/plugins.ts @@ -3,8 +3,9 @@ import * as fs from 'fs'; import * as http from 'http'; import * as https from 'https'; import * as path from 'path'; +import * as stream from 'stream'; -export { http, https, fs, path }; +export { http, https, fs, path, stream }; // pushrocks scope import * as smartpromise from '@push.rocks/smartpromise'; diff --git a/ts/core_node/request.ts b/ts/core_node/request.ts index 2d1f45a..d8c1cc4 100644 --- a/ts/core_node/request.ts +++ b/ts/core_node/request.ts @@ -147,6 +147,12 @@ export class CoreRequest extends AbstractCoreRequest< this.options.requestBody.pipe(request).on('finish', () => { request.end(); }); + } else if (this.options.requestBody instanceof ReadableStream) { + // Convert web ReadableStream to Node.js Readable stream + const nodeStream = plugins.stream.Readable.fromWeb(this.options.requestBody as any); + nodeStream.pipe(request).on('finish', () => { + request.end(); + }); } else { // Write body as-is - caller is responsible for serialization const bodyData =