├── .gitignore ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bunfig.toml ├── examples ├── abort.ts ├── cache.ts ├── detailed.ts └── error.ts ├── package.json ├── src ├── index.ts ├── models │ ├── constants.ts │ ├── headers.ts │ ├── proxy.ts │ └── utils.ts ├── services │ ├── cache.ts │ ├── command.ts │ ├── http.ts │ └── response.ts └── types.ts ├── tests ├── body.test.ts ├── cache.test.ts ├── concurrent.test.ts ├── err.test.ts ├── image.test.ts └── redirects.test.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Others 133 | bun.lock 134 | .vscode 135 | dev-tests -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/.git 2 | **/.svn 3 | **/.hg 4 | **/node_modules -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "htmlWhitespaceSensitivity": "css", 5 | "insertPragma": false, 6 | "bracketSameLine": false, 7 | "jsxSingleQuote": true, 8 | "printWidth": 80, 9 | "proseWrap": "always", 10 | "quoteProps": "as-needed", 11 | "requirePragma": false, 12 | "semi": true, 13 | "singleQuote": true, 14 | "tabWidth": 2, 15 | "trailingComma": "all", 16 | "useTabs": false 17 | } 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | > **Note:** Dates are formatted as `yyyy-mm-dd`. 4 | 5 | ## [0.0.33] - 2025-05-29 6 | 7 | > ⚠️ **WARNING:** Before upgrading, **clear your Redis cache** — any previously cached requests may not be parsed correctly. 8 | 9 | - **Redis support** 10 | Now compatible with Bun’s native Redis client. To continue using the npm-based implementation, enable `GlobalInit.cache.useRedisPackage` or configure your own cache server. 11 | 12 | - **Performance improvements** 13 | Although less noticeable for small responses, difference is major for larger ones. 14 | 15 | - **New**: `RequestInit.sortHeaders` 16 | Control header ordering prior to dispatch. 17 | 18 | - **Fix**: Typo in `transformRequest` (previously `transfomRequest`) 19 | 20 | - **New**: `ResponseInit.redirects` 21 | Collects redirect entries as full response objects by default. To receive URLs only, set the `redirectsAsUrls` client option. 22 | 23 | - **Improved** response handler for more reliable parsing. 24 | 25 | - **Fix**: Corrected `ResponseInit.redirected` flag logic so it now reflects actual redirects. 26 | 27 | - **New**: `GlobalInit.maxConcurrentRequests` 28 | Limit simultaneous requests to prevent overload. 29 | 30 | - **Fix**: Corrected typos in JSDoc comments. 31 | 32 | ## [0.0.32] - 2025-03-29 33 | 34 | - Request headers are now ordered, accept array pairs, and respect key casing if it’s not a `Headers` instance. 35 | - Local DNS caching is now disabled by default because most cURL builds already offer DNS caching. 36 | - Fixed the `RequestInit.dns.resolve` logic. 37 | - Response headers are now instances of `Headers` instead of `URLSearchParams`. 38 | - TLS versions must now be provided in their numeric formats in `RequestInit.tls.versions`; use the exported `TLS` variable for simplicity. 39 | - Added TLS 1.0 and TLS 1.1 support; however, the default versions remain the same (TLS 1.2, TLS 1.3). 40 | - Fixed the **HEAD** request method. 41 | - Renamed `RequestInit.tls.ciphers.TLS12` to `RequestInit.tls.ciphers.DEFAULT`. 42 | - **Google**'s servers are no longer used by default for DNS lookups. 43 | 44 | ## [0.0.31] - 2025-03-12 45 | 46 | - Optimizations to response manipulation and command builder ⚡. 47 | - Introduced the `RequestInit.tls.insecure` property ⭐. 48 | - Added JSDoc comments for the **BunCurl2** instance 💭. 49 | 50 | ## [0.0.27] & [0.0.28] - 2025-03-08 51 | 52 | - Fixed TypeScript issues (sorry about that). 53 | - Added DNS caching support locally (max 255 entries), configurable via the `RequestInit.dns` property. 54 | - Added TCP FastOpen and TCP NoDelay support, configurable via the `GlobalInit.tcp` property. 55 | - Improved cache key generation logic and added a `generate` function in the `RequestInit.cache` property for manually generating the cache key. 56 | - Fixed the issue where the `RequestInit.follow` property was not set to `true` by default. 57 | - HTTP version no longer defaults to 3.0 if the cURL build supports it. 58 | 59 | ## [0.0.25] & [0.0.26] - 2025-03-06 60 | 61 | - Reduced build size by **~50%**. 62 | - Minor optimizations: full requests now take **~5%** less time and consume less memory. 63 | - Renamed `parseResponse` to `parseJSON`. 64 | - You can now disable response compression per request by providing `compress: false` in the request options. 65 | - Added the `dns` request property. 66 | 67 | ## [0.0.24] - 2025-03-05 68 | 69 | - A mini update that should fix issues regarding HTTP versions and TLS ciphers in some cURL builds. 70 | 71 | ## [0.0.23] - 2025-03-03 72 | 73 | - Improved error handling [[EXAMPLE]](./examples/error.ts). 74 | - If the `follow` property is **true**, the request will follow redirects up to 10 times. 75 | - The `ResponseWrapper.url` now reflects the final destination URL even after redirects. 76 | 77 | ## [0.0.21] & [0.0.22] - 2025-02-28 78 | 79 | - Added a `local` cache mode! However, Redis remains the default. [[EXAMPLE]](./examples/cache.ts). 80 | - The `body` property now supports all the types that **fetch** has implemented (EXPERIMENTAL). 81 | - Added additional TypeScript fixes. 82 | 83 | ## [0.0.20] - 2025-02-26 84 | 85 | - **⚠️ IMPORTANT:** Fixed an incorrect argument name passed when the `follow` property was provided (`--follow` **->** `--location`). 86 | - Fixed JSDoc comments being removed. 87 | 88 | ## [0.0.19] - 2025-02-25 89 | 90 | - Added cache validation support via a function, which can be either asynchronous or synchronous. 91 | - Added global `parseResponse` support in the `BunCurl2` options. 92 | - `transformResponse` now accepts promise responses. 93 | 94 | ## [0.0.18] - 2025-02-25 95 | 96 | - Added transformResponse support. 97 | - Types are now exported. 98 | - Made it possible to provide `transformRequest: false` in options to disable the global transformation provided by `BunCurl2` for that specific request. 99 | 100 | ## [0.0.17] - 2025-02-25 101 | 102 | - Added backwards compatibility in `package.json`. 103 | 104 | ## [0.0.16] - 2025-02-25 105 | 106 | - Added AbortController signal support. [[EXAMPLE]](./examples/abort.ts) 107 | - Fixed Redis logic and TTL issues. 108 | - Fixed response type propagation in `BunCurl2` methods. 109 | - Added the `BunCurl2.disconnectCache` method. 110 | - Fixed the issue where `keepAlive` and `keepAliveProbes` were not being used. 111 | 112 | --- -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2004 Sam Hocevar 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BunCurl2 🚀 2 | 3 | BunCurl2 is a **super-fast, fetch-like HTTP client** for [Bun](https://bun.sh)! Built and maintained as a solo project, it leverages Bun’s powerful child processes combined with cURL to deliver blazing performance, advanced TLS options, and flexible caching solutions. Redis caching is enabled by default, but you can also opt for local, memory-based caching using JavaScript's built-in Map object. 4 | 5 | --- 6 | 7 | ## ✨ Features 8 | 9 | - **Fetch-like API:** 10 | Intuitive and familiar HTTP methods (`GET`, `POST`, etc.) with extended capabilities. 11 | - **Lightning Fast:** 12 | Powered by Bun’s child processes and optimized cURL integration. 13 | - **HTTP/2 & HTTP/3 Support:** 14 | Take advantage of modern protocols (requires appropriate cURL build). 15 | - **Custom TLS & Ciphers:** 16 | Enhance security by fine-tuning TLS settings. 17 | - **Flexible Caching:** 18 | - **Default (recommended):** Redis, ideal for persistent or long-lived caching. 19 | - **Optional:** Local, memory-based caching using JavaScript's Map object, suitable for short-term caching within the same process. 20 | - **Type-Safe Requests & Responses:** 21 | Enjoy clear and maintainable TypeScript typings. 22 | - **Custom Transformations:** 23 | Modify and tailor requests and responses according to your needs. 24 | 25 | --- 26 | 27 | ## 📜 Changelog 28 | 29 | > **What's New?** 30 | > Stay informed about updates and improvements by checking **[Changelog](./CHANGELOG.md)**. 31 | 32 | --- 33 | 34 | ## ⚙️ Installation 35 | 36 | ```bash 37 | bun add bun-curl2 38 | ``` 39 | 40 | --- 41 | 42 | ## 📋 Requirements 43 | 44 | | Tool | Minimum Version | Recommended Version | 45 | | ---- | --------------- | ------------------- | 46 | | Bun | ^1.2.0 | Latest | 47 | | cURL | ^7.0.0 | Latest | 48 | 49 | > **Note:** For optimal performance and compatibility, always use the latest versions. 50 | > I personally use [stunnel/static-curl](https://github.com/stunnel/static-curl) with quictls for cURL builds. 51 | 52 | --- 53 | 54 | ## 📡 Usage 55 | 56 | ### Recommended: Creating a Client Instance 57 | 58 | This approach provides the best experience with advanced configurations and caching. 59 | 60 | ```ts 61 | import BunCurl2, { RequestInit, ResponseInit } from 'bun-curl2'; 62 | 63 | // Create a new client with customized options and caching. 64 | const client = new BunCurl2({ 65 | defaultAgent: 'MyCustomAgent/1.0', 66 | compress: true, 67 | cache: { 68 | mode: 'redis', // Recommended caching mode 69 | options: { url: 'redis://localhost:6379' }, 70 | defaultExpiration: 60, // Cache expiration in seconds 71 | }, 72 | tcp: { 73 | fastOpen: true, 74 | noDelay: true, 75 | }, 76 | transformRequest: opts => opts, 77 | }); 78 | 79 | // (Optional) Initialize cache if caching is enabled. 80 | await client.connect(); 81 | 82 | // Make a GET request with type-safe response handling: 83 | const req: ResponseInit> = await client.get( 84 | 'https://api.example.com/data', 85 | { cache: true } 86 | ); 87 | 88 | /* 89 | Response Details: 90 | - status: HTTP status code 91 | - response: Parsed response (here: Record) 92 | - headers: Headers instance 93 | - Helper methods: json(), text(), arrayBuffer(), blob() 94 | */ 95 | console.log('Status:', req.status); 96 | console.log('Response:', req.response); 97 | ``` 98 | 99 | ### Alternative: Direct `fetch`-like Usage 100 | 101 | For simpler use cases, you can directly use a familiar fetch-like syntax: 102 | 103 | ```ts 104 | import { fetch } from 'bun-curl2'; 105 | 106 | const req: ResponseInit = await fetch( 107 | 'https://www.example.com' 108 | ); 109 | 110 | console.log('Status:', req.status); 111 | console.log('Response:', req.response); 112 | ``` 113 | 114 | --- 115 | 116 | ## 🤝 Contributing 117 | 118 | Your feedback, issues, or pull requests are welcomed! 119 | 120 | --- 121 | 122 | ## 🏳️ License 123 | 124 | This project is licensed under the **[WTFPL](./LICENSE)**. 125 | -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [test] 2 | root = "tests" -------------------------------------------------------------------------------- /examples/abort.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Example: Aborting an HTTP Request with bun-curl2 3 | * 4 | * This example demonstrates how to abort an HTTP request using the built-in 5 | * AbortSignal with timeout and the Http function from bun-curl2. 6 | * 7 | * The HTTP call is initiated with an abort signal. If the request takes too long, 8 | * it will be aborted after 100 milliseconds. 9 | */ 10 | 11 | import { Http } from '../src/index'; 12 | 13 | // Start the HTTP request with the abort signal. 14 | const requestPromise = Http('https://reqres.in/api/users?delay=1', { 15 | signal: AbortSignal.timeout(100), 16 | tls: { 17 | insecure: true, 18 | }, 19 | }); 20 | 21 | try { 22 | // Await the request promise. 23 | const res = await requestPromise; 24 | console.log('Request completed successfully (this was not expected).', res); 25 | } catch (error: any) { 26 | // Handle the error by checking if it is an AbortError. 27 | if (error.name === 'AbortError') { 28 | console.log('AbortError caught as expected.'); 29 | } else { 30 | console.error('An unexpected error occurred:', error); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/cache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Example: Using Cache with bun-curl2 3 | * 4 | * This example demonstrates how to configure and use caching with BunCurl2. 5 | * It sets up local in-memory caching (via a Map) with a 3-second expiration. 6 | * The example then shows how cached responses are handled across multiple requests. 7 | * 8 | */ 9 | 10 | import BunCurl2 from '../src'; 11 | 12 | // Initialize BunCurl2 with caching enabled. 13 | // Here, caching mode is set to 'local' (in-memory via Map) with a default expiration of 3 seconds. 14 | const testWithCache = new BunCurl2({ 15 | cache: { 16 | defaultExpiration: 3, // Cache entries expire in 3 seconds. 17 | mode: 'local', // Local caching using in-memory storage. 18 | }, 19 | }); 20 | 21 | // Connect to initialize cache-related resources. 22 | // await is not needed since the mode we are using here is "local" but for "redis" it is required to await. 23 | testWithCache.connect(); 24 | 25 | // ─── First Request ───────────────────────────────────────────── 26 | // This request should not be cached (since it's the first one). 27 | const firstRequest = await testWithCache.get('https://www.example.com', { 28 | cache: true, 29 | }); 30 | 31 | // Wait 2 seconds before the next request. 32 | await Bun.sleep(2000); 33 | 34 | // ─── Second Request ───────────────────────────────────────────── 35 | // This request should return a cached response (cached flag should be true). 36 | const secondRequest = await testWithCache.get('https://www.example.com', { 37 | cache: true, 38 | }); 39 | 40 | // Wait an additional 1 second (total 3 seconds since first request). 41 | await Bun.sleep(1000); 42 | 43 | // ─── Third Request ───────────────────────────────────────────── 44 | // At this point, the cache entry should have expired (3-second expiration), 45 | // so this request should not return a cached response. 46 | const thirdRequest = await testWithCache.get('https://www.example.com', { 47 | cache: true, 48 | }); 49 | 50 | // Always disconnect when finished to allow the process to exit. 51 | // For local cache, a timer may keep the process alive; for Redis caching, the socket remains open. 52 | await testWithCache.disconnect(); 53 | 54 | // Log the caching results. 55 | console.log('Cache Tests', { 56 | firstRequest: firstRequest.cached, // Expected: false 57 | secondRequest: secondRequest.cached, // Expected: true 58 | thirdRequest: thirdRequest.cached, // Expected: false 59 | }); 60 | -------------------------------------------------------------------------------- /examples/detailed.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Detailed Example of bun-curl2 Usage with Top-Level Await 3 | * 4 | * This example demonstrates how to initialize and use BunCurl2 with various options. 5 | * It covers examples for GET, POST, PUT, DELETE, PATCH, and HEAD requests. 6 | * Global settings include a default user agent, compression, and TCP options. 7 | * A global request transformer is provided to add a custom header and modify the HTTP method based on the URL. 8 | * 9 | * Note: The TLS configuration option is not supported in BunCurl2. 10 | */ 11 | 12 | import BunCurl2 from '../src'; 13 | 14 | // Create a BunCurl2 instance with global configuration options. 15 | const bunCurl = new BunCurl2({ 16 | // Set a default user agent if none is provided. 17 | defaultAgent: 'bun-curl2/1.0', 18 | // Enable response compression. 19 | compress: true, 20 | // TCP options to optimize connection performance. 21 | tcp: { fastOpen: true, noDelay: true }, 22 | // Global request transformer: 23 | // Adds a custom header and, if the URL contains '/submit', forces the method to POST. 24 | transformRequest(args) { 25 | if (args.headers) { 26 | if (args.headers instanceof Headers) { 27 | args.headers.set('x-global-header', 'global_value'); 28 | } else { 29 | args.headers['x-global-header'] = 'global_value'; 30 | } 31 | } else { 32 | args.headers = { 'x-global-header': 'global_value' }; 33 | } 34 | if (args.url.includes('/submit')) { 35 | args.method = 'POST'; 36 | } 37 | return args; 38 | }, 39 | // Optional caching configuration (enabled but not the focus here). 40 | cache: { mode: 'local', defaultExpiration: 5 }, 41 | }); 42 | 43 | // Initialize resources (for example, cache initialization). 44 | await bunCurl.connect(); 45 | 46 | // ─── Example 1: GET Request ───────────────────────────────────────────── 47 | const getResponse = await bunCurl.get('https://httpbin.org/anything'); 48 | console.log('GET Response:', getResponse.response); 49 | console.log('GET Cached:', getResponse.cached); 50 | 51 | // ─── Example 2: POST Request with Disabled Global Transform ────────────── 52 | const postResponse = await bunCurl.fetch('https://httpbin.org/post', { 53 | body: { data: 'sample data' }, 54 | transformRequest: false, 55 | }); 56 | console.log('POST Response:', postResponse.response); 57 | 58 | // ─── Example 3: PUT Request ───────────────────────────────────────────── 59 | const putResponse = await bunCurl.put('https://httpbin.org/put', { 60 | body: { update: 'new value' }, 61 | }); 62 | console.log('PUT Response:', putResponse.response); 63 | 64 | // ─── Example 4: DELETE Request ────────────────────────────────────────── 65 | const deleteResponse = await bunCurl.delete('https://httpbin.org/delete'); 66 | console.log('DELETE Response:', deleteResponse.response); 67 | 68 | // ─── Example 5: PATCH Request ─────────────────────────────────────────── 69 | const patchResponse = await bunCurl.patch('https://httpbin.org/patch', { 70 | body: { patch: 'value' }, 71 | }); 72 | console.log('PATCH Response:', patchResponse.response); 73 | 74 | // ─── Example 6: HEAD Request ──────────────────────────────────────────── 75 | const headResponse = await bunCurl.head('https://httpbin.org/anything'); 76 | console.log('HEAD Response Headers:', headResponse.headers); 77 | 78 | // Clean up resources. 79 | await bunCurl.disconnect(); 80 | console.log('Disconnected from bun-curl2'); 81 | -------------------------------------------------------------------------------- /examples/error.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Example: Handling Errors with bun-curl2's fetch 3 | * 4 | * This example demonstrates how to handle errors when using the fetch 5 | * function from bun-curl2. In this scenario, a request is made to an invalid 6 | * hostname which results in an error. The error object includes the request 7 | * options (such as the URL) and an error message, which are both logged for debugging. 8 | */ 9 | 10 | import { fetch } from '../src'; 11 | 12 | try { 13 | // Attempt to fetch from an invalid hostname. 14 | await fetch('https://www.doesntexist123.com'); 15 | } catch (error: any) { 16 | // Log the request options provided within the error object. 17 | console.error('Request Options:', error.options); 18 | 19 | // Log the error message for further insight. 20 | console.error('Error Message:', error.message); 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bun-curl2", 3 | "version": "0.0.33", 4 | "author": { 5 | "name": "Nicholas", 6 | "url": "https://github.com/Aslarex", 7 | "email": "err.nicholas@gmail.com" 8 | }, 9 | "repository": "https://github.com/Aslarex/bun-curl2", 10 | "keywords": [ 11 | "bun", 12 | "curl", 13 | "fetch alternative", 14 | "fast fetch", 15 | "bun curl", 16 | "quick http", 17 | "http2 fetch", 18 | "http3 fetch" 19 | ], 20 | "description": "blazing fast, fetch-like HTTP client built with Bun and cURL in TypeScript.", 21 | "license": "WTFPL", 22 | "scripts": { 23 | "dev": "DEBUG=* bun test --watch --timeout 15000", 24 | "build": "rm -rf dist && tsc --project tsconfig.json && esbuild --minify $(find ./dist -type f -name '*.js') --outdir=dist --tree-shaking=true --allow-overwrite --format=esm && find ./dist -type f -empty -delete", 25 | "prettier": "prettier src/ tests/ examples/ --write" 26 | }, 27 | "exports": { 28 | ".": { 29 | "types": "./dist/index.d.ts", 30 | "import": "./dist/index.js" 31 | } 32 | }, 33 | "type": "module", 34 | "module": "./dist/index.js", 35 | "files": [ 36 | "dist" 37 | ], 38 | "types": "./dist/index.d.ts", 39 | "optionalDependencies": { 40 | "redis": "^5.1.1", 41 | "prettier": "^3.5.3" 42 | }, 43 | "devDependencies": { 44 | "@types/bun": "^1.2.15", 45 | "@types/node": "^22.15.24", 46 | "bun-types": "^1.2.15", 47 | "esbuild": "^0.25.5", 48 | "typescript": "^5.8.3" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | if (!globalThis.Bun) { 2 | throw new Error('Bun (https://bun.sh) is required to run this package'); 3 | } 4 | if (!('BUN_CONFIG_DNS_TIME_TO_LIVE_SECONDS' in process.env)) { 5 | process.env.BUN_CONFIG_DNS_TIME_TO_LIVE_SECONDS = '0'; 6 | } 7 | import HTTPRequest from './services/http'; 8 | import type { 9 | RequestInit, 10 | RequestInitWithURL, 11 | ResponseInit, 12 | CacheType, 13 | GlobalInit, 14 | BaseResponseInit, 15 | RedisServer, 16 | BaseRequestInit, 17 | BaseCache, 18 | CacheKeys, 19 | CacheInstance, 20 | } from './types'; 21 | import Headers from './models/headers'; 22 | import { LocalCache } from './services/cache'; 23 | import { ResponseWrapper } from './services/response'; 24 | import { DNS_CACHE_MAP, TLS } from './models/constants'; 25 | import { compareVersions } from './models/utils'; 26 | import { RedisOptions } from 'bun'; 27 | 28 | export type { 29 | RequestInit, 30 | RequestInitWithURL, 31 | BaseResponseInit, 32 | CacheType, 33 | GlobalInit, 34 | ResponseInit, 35 | RedisServer, 36 | BaseRequestInit, 37 | BaseCache, 38 | CacheKeys, 39 | }; 40 | 41 | export { 42 | HTTPRequest as Http, 43 | HTTPRequest as HTTP, 44 | HTTPRequest as fetch, 45 | HTTPRequest, 46 | Headers, 47 | ResponseWrapper, 48 | TLS, 49 | }; 50 | 51 | /** 52 | * BunCurl2 provides a high-level HTTP client with caching support. 53 | * 54 | * @example 55 | * const bunCurl = new BunCurl2({ cache: { mode: 'local' } }); 56 | * await bunCurl.initializeCache(); 57 | * const response = await bunCurl.get('https://example.com'); 58 | */ 59 | export class BunCurl2 { 60 | /** 61 | * The cache instance that can be either a Redis server or a local cache. 62 | * 63 | * @private 64 | */ 65 | private cache?: CacheInstance; 66 | 67 | /** 68 | * Creates an instance of BunCurl2. 69 | * 70 | * @param args - Global initialization options merged with cache settings. 71 | */ 72 | constructor( 73 | private args: GlobalInit & { redirectsAsUrls?: U; cache?: CacheType } = {}, 74 | ) {} 75 | 76 | /** 77 | * @description 78 | * Prepare the cache server 79 | */ 80 | async connect() { 81 | const { cache } = this.args; 82 | if (!cache) return false; 83 | cache.mode = cache.mode ?? 'redis'; 84 | this.cache = { 85 | defaultExpiration: cache.defaultExpiration, 86 | server: null!, 87 | }; 88 | try { 89 | switch (cache.mode) { 90 | case 'local': 91 | case 'client': 92 | this.cache.server = new LocalCache(); 93 | break; 94 | case 'redis': 95 | if (cache.server) { 96 | this.cache.server = cache.server; 97 | } else { 98 | const isBunClientSupported = 99 | compareVersions(Bun.version, '1.2.9') >= 0; 100 | if (cache.useRedisPackage || !isBunClientSupported) { 101 | if (!isBunClientSupported) 102 | console.warn( 103 | '⚠️ [BunCurl2] - Detected Bun version does not supported redis client API implemented by them, fallbacking to redis package', 104 | ); 105 | const { createClient } = await import('redis'); 106 | this.cache.server = createClient( 107 | cache.useRedisPackage 108 | ? cache.options 109 | : { url: cache.options?.url }, 110 | ); 111 | } else { 112 | const { RedisClient } = await import('bun'); 113 | this.cache.server = new RedisClient( 114 | cache.options?.url, 115 | Object.assign(cache.options ?? {}, { 116 | url: undefined, 117 | }) as RedisOptions, 118 | ); 119 | } 120 | } 121 | const server = this.cache.server; 122 | if ('isOpen' in server && !server.isOpen) { 123 | await server.connect(); 124 | } else if ('connected' in server && !server.connected) { 125 | await server.connect(); 126 | } 127 | break; 128 | default: 129 | console.error( 130 | `[BunCurl2] - Received invalid cache mode (${cache.mode})`, 131 | ); 132 | return false; 133 | } 134 | return true; 135 | } catch (e) { 136 | const cacheInitializationError = new Error( 137 | '[BunCurl2] - Client initialization has failed', 138 | ); 139 | Object.defineProperties(cacheInitializationError, { 140 | code: { 141 | value: 'ERR_CLIENT_INITIALIZATION', 142 | }, 143 | cause: { 144 | value: e, 145 | }, 146 | }); 147 | throw cacheInitializationError; 148 | } 149 | } 150 | 151 | /** 152 | * @alias connect 153 | */ 154 | init = this.connect; 155 | 156 | /** 157 | * @description 158 | * Destroys the `BunCurl2` client. 159 | */ 160 | async destroy() { 161 | DNS_CACHE_MAP.end(); 162 | if (!this.cache?.server) return; 163 | const server = this.cache.server; 164 | if (server instanceof LocalCache) { 165 | server.end(); 166 | } else { 167 | 'disconnect' in server ? await server.disconnect() : server.close(); 168 | } 169 | } 170 | 171 | /** 172 | * @alias destroy 173 | */ 174 | disconnect = this.destroy; 175 | 176 | /** 177 | * Internal method to perform an HTTP request. 178 | * 179 | * @private 180 | * @template T - The expected type of the response body. 181 | * @param url - The URL to request. 182 | * @param method - The HTTP method to use. 183 | * @param options - Additional request options. 184 | */ 185 | private async request( 186 | url: string, 187 | method: RequestInit['method'], 188 | options: RequestInit = {}, 189 | ) { 190 | return HTTPRequest( 191 | url, 192 | { ...options, method }, 193 | { ...this.args, cache: this.cache }, 194 | ); 195 | } 196 | 197 | /** 198 | * Performs an HTTP fetch request. 199 | * 200 | * @template T - The expected type of the response body. 201 | * @param url - The URL to fetch. 202 | * @param options - Optional request options. 203 | */ 204 | async fetch(url: string, options?: RequestInit) { 205 | return this.request(url, options?.method || 'GET', options); 206 | } 207 | 208 | /** 209 | * Performs an HTTP GET request. 210 | * 211 | * @template T - The expected type of the response body. 212 | * @param url - The URL to request. 213 | * @param options - Request options excluding method and body. 214 | */ 215 | async get( 216 | url: string, 217 | options?: Omit, 'method' | 'body'>, 218 | ) { 219 | return this.request(url, 'GET', options); 220 | } 221 | 222 | /** 223 | * Performs an HTTP POST request. 224 | * 225 | * @template T - The expected type of the response body. 226 | * @param url - The URL to request. 227 | * @param options - Request options excluding method. 228 | */ 229 | async post( 230 | url: string, 231 | options?: Omit, 'method'>, 232 | ) { 233 | return this.request(url, 'POST', options); 234 | } 235 | 236 | /** 237 | * Performs an HTTP DELETE request. 238 | * 239 | * @template T - The expected type of the response body. 240 | * @param url - The URL to request. 241 | * @param options - Request options excluding method. 242 | */ 243 | async delete( 244 | url: string, 245 | options?: Omit, 'method'>, 246 | ) { 247 | return this.request(url, 'DELETE', options); 248 | } 249 | 250 | /** 251 | * Performs an HTTP PUT request. 252 | * 253 | * @template T - The expected type of the response body. 254 | * @param url - The URL to request. 255 | * @param options - Request options excluding method. 256 | */ 257 | async put(url: string, options?: Omit, 'method'>) { 258 | return this.request(url, 'PUT', options); 259 | } 260 | 261 | /** 262 | * Performs an HTTP PATCH request. 263 | * 264 | * @template T - The expected type of the response body. 265 | * @param url - The URL to request. 266 | * @param options - Request options excluding method. 267 | */ 268 | async patch( 269 | url: string, 270 | options?: Omit, 'method'>, 271 | ) { 272 | return this.request(url, 'PATCH', options); 273 | } 274 | 275 | /** 276 | * Performs an HTTP HEAD request. 277 | * 278 | * @template T - The expected type of the response body. 279 | * @param url - The URL to request. 280 | * @param options - Request options excluding method and body. 281 | */ 282 | async head( 283 | url: string, 284 | options?: Omit, 'method' | 'body'>, 285 | ) { 286 | return this.request(url, 'HEAD', options); 287 | } 288 | } 289 | 290 | export default BunCurl2; 291 | -------------------------------------------------------------------------------- /src/models/constants.ts: -------------------------------------------------------------------------------- 1 | import { $ } from 'bun'; 2 | import { LocalCache } from '../services/cache'; 3 | 4 | // Define constants for CURL options. 5 | const CURL = { 6 | BASE: 'curl', 7 | SILENT: '-s', 8 | SHOW_ERROR: '-S', 9 | WRITE_OUT: '-w', 10 | INFO: '-i', 11 | TIMEOUT: '-m', 12 | CONNECT_TIMEOUT: '--connect-timeout', 13 | HTTP_VERSION: { 14 | 3.0: '--http3', 15 | 2.0: '--http2', 16 | 1.1: '--http1.1', 17 | }, 18 | INSECURE: '--insecure', 19 | TLSv1_0: '--tlsv1.0', 20 | TLSv1_1: '--tlsv1.1', 21 | TLSv1_2: '--tlsv1.2', 22 | TLSv1_3: '--tlsv1.3', 23 | CIPHERS: '--ciphers', 24 | TLS_MAX: '--tls-max', 25 | TLS13_CIPHERS: '--tls13-ciphers', 26 | COMPRESSED: '--compressed', 27 | PROXY: '--proxy', 28 | FOLLOW: '--location', 29 | MAX_REDIRS: '--max-redirs', 30 | NO_KEEPALIVE: '--no-keepalive', 31 | KEEPALIVE_TIME: '--keepalive-time', 32 | KEEPALIVE_CNT: '--keepalive-cnt', 33 | DATA_RAW: '--data-raw', 34 | DNS_SERVERS: '--dns-servers', 35 | DNS_RESOLVE: '--resolve', 36 | TCP_FASTOPEN: '--tcp-fastopen', 37 | TCP_NODELAY: '--tcp-nodelay', 38 | USER_AGENT: '-A', 39 | HEADER: '-H', 40 | METHOD: '-X', 41 | HEAD: '-I', 42 | }; 43 | 44 | const TLS = { 45 | /** 46 | * TLS 1.0 47 | */ 48 | Version10: 0x0301, 49 | /** 50 | * TLS 1.1 51 | */ 52 | Version11: 0x0302, 53 | /** 54 | * TLS 1.2 55 | */ 56 | Version12: 0x0303, 57 | /** 58 | * TLS 1.3 59 | */ 60 | Version13: 0x0304, 61 | } as const; 62 | 63 | const HTTP = { 64 | /** HTTP 1.1 */ 65 | Version11: 1.1, 66 | /** HTTP 2.0 */ 67 | Version20: 2.0, 68 | /** HTTP 3.0 */ 69 | Version30: 3.0, 70 | } as const; 71 | 72 | const PROTOCOL_PORTS = { 73 | http: 80, 74 | https: 443, 75 | ftp: 21, 76 | ftps: 990, 77 | sftp: 22, 78 | scp: 22, 79 | smtp: 25, 80 | smtps: 465, 81 | imap: 143, 82 | imaps: 993, 83 | pop3: 110, 84 | pop3s: 995, 85 | ldap: 389, 86 | ldaps: 636, 87 | mqtt: 1883, 88 | mqtts: 8883, 89 | telnet: 23, 90 | tftp: 69, 91 | rtsp: 554, 92 | smb: 445, 93 | dict: 2628, 94 | } as Record; 95 | 96 | const CURL_OUTPUT = (await $`curl --version`.quiet().text()).toLowerCase(); 97 | 98 | const curlVersionMatch = CURL_OUTPUT.match(/curl\s+(\d+.\d+.\d+)/); 99 | 100 | const CURL_VERSION = curlVersionMatch ? curlVersionMatch[1] : '0.0.0'; 101 | 102 | const DNS_CACHE_MAP = new LocalCache({ 103 | maxItems: 255, 104 | noInterval: true, 105 | }); 106 | 107 | export { 108 | CURL, 109 | PROTOCOL_PORTS, 110 | CURL_VERSION, 111 | CURL_OUTPUT, 112 | TLS, 113 | HTTP, 114 | DNS_CACHE_MAP, 115 | }; 116 | -------------------------------------------------------------------------------- /src/models/headers.ts: -------------------------------------------------------------------------------- 1 | import { RequestInit } from '../types'; 2 | 3 | /** 4 | * The accepted initializer types. 5 | */ 6 | export type HeadersInit = 7 | | Headers 8 | | Record 9 | | Iterable 10 | | Iterable>; 11 | 12 | export default class CustomHeaders extends Headers { 13 | constructor(init?: HeadersInit) { 14 | super(); 15 | if (init) { 16 | if (init instanceof Headers && typeof (init as any).raw === 'function') { 17 | const rawInit = (init as any).raw(); 18 | for (const [name, values] of Object.entries(rawInit)) { 19 | for (const value of values as string[]) { 20 | this.append(name, value); 21 | } 22 | } 23 | } else if (init instanceof Headers) { 24 | for (const [name, value] of init.entries()) { 25 | this.append(name, value); 26 | } 27 | } else if (typeof init === 'object') { 28 | const iterator = (init as any)[Symbol.iterator]; 29 | if (typeof iterator === 'function') { 30 | for (const pair of init as Iterable) { 31 | const [name, value] = Array.from(pair); 32 | this.append(name as string, String(value)); 33 | } 34 | } else { 35 | for (const [name, value] of Object.entries(init)) { 36 | if (Array.isArray(value)) { 37 | for (const v of value) { 38 | this.append(name, String(v)); 39 | } 40 | } else { 41 | this.append(name, String(value)); 42 | } 43 | } 44 | } 45 | } else { 46 | throw new TypeError( 47 | "[BunCurl2] - Failed to construct 'Headers': The provided value is not a valid headers object", 48 | ); 49 | } 50 | } 51 | } 52 | 53 | /** 54 | * Returns all headers as an object mapping each header name to an array of values. 55 | */ 56 | raw(): Record { 57 | const result: Record = {}; 58 | for (const [key, values] of this.entries()) { 59 | result[key] = key in result ? [...result[key], values] : [values]; 60 | } 61 | return result; 62 | } 63 | } 64 | 65 | const prioritizedOrder = new Map( 66 | [ 67 | 'accept', 68 | 'accept-charset', 69 | 'accept-encoding', 70 | 'accept-language', 71 | 'access-control-request-headers', 72 | 'access-control-request-method', 73 | 'authorization', 74 | 'cache-control', 75 | 'connection', 76 | 'content-length', 77 | 'content-type', 78 | 'cookie', 79 | 'dnt', 80 | 'expect', 81 | 'host', 82 | 'if-match', 83 | 'if-modified-since', 84 | 'if-none-match', 85 | 'if-range', 86 | 'if-unmodified-since', 87 | 'keep-alive', 88 | 'origin', 89 | 'pragma', 90 | 'proxy-authorization', 91 | 'range', 92 | 'referer', 93 | 'sec-fetch-dest', 94 | 'sec-fetch-mode', 95 | 'sec-websocket-extensions', 96 | 'sec-websocket-key', 97 | 'sec-websocket-protocol', 98 | 'sec-websocket-version', 99 | 'te', 100 | 'upgrade-insecure-requests', 101 | 'user-agent', 102 | ].map((header, index) => [header, index]), 103 | ); 104 | 105 | export function sortHeaders( 106 | inputHeaders: Exclude, 107 | ): [string, string][] { 108 | let headerEntries: [string, string, string][]; 109 | 110 | if (inputHeaders instanceof Headers) { 111 | headerEntries = Array.from(inputHeaders, ([key, value]) => [ 112 | key, 113 | key.toLowerCase(), 114 | value, 115 | ]); 116 | } else if (Array.isArray(inputHeaders)) { 117 | headerEntries = inputHeaders.map( 118 | ([key, value]) => 119 | [key, key.toLowerCase(), String(value)] as [string, string, string], 120 | ); 121 | } else { 122 | headerEntries = Object.entries(inputHeaders).map( 123 | ([key, value]) => 124 | [key, key.toLowerCase(), String(value)] as [string, string, string], 125 | ); 126 | } 127 | 128 | headerEntries.sort((a, b) => { 129 | const indexA = prioritizedOrder.get(a[1]); 130 | const indexB = prioritizedOrder.get(b[1]); 131 | 132 | if (indexA !== undefined && indexB !== undefined) { 133 | return indexA - indexB; 134 | } 135 | if (indexA !== undefined) { 136 | return -1; 137 | } 138 | if (indexB !== undefined) { 139 | return 1; 140 | } 141 | return a[1].localeCompare(b[1]); 142 | }); 143 | 144 | return headerEntries.map(([original, , value]) => [original, value]); 145 | } 146 | -------------------------------------------------------------------------------- /src/models/proxy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Validates that the given port string represents a valid port number. 3 | * 4 | * @param port - The port string. 5 | * @returns True if valid, false otherwise. 6 | */ 7 | function isValidPort(port: string): boolean { 8 | const num = Number(port); 9 | return Number.isInteger(num) && num > 0 && num <= 65535; 10 | } 11 | 12 | /** 13 | * Basic validation for an IP address or hostname. 14 | * 15 | * @param host - The host string. 16 | * @returns True if valid, false otherwise. 17 | */ 18 | function isValidHost(host: string): boolean { 19 | if (!host) return false; 20 | try { 21 | // Prepend a dummy protocol so that the URL parser can validate the host. 22 | const url = new URL(`http://${host}`); 23 | return Boolean(url.hostname); 24 | } catch { 25 | return false; 26 | } 27 | } 28 | 29 | /** 30 | * Formats a proxy connection string with improved validation. 31 | * 32 | * Supports: 33 | * - Non‑authenticated proxies: "ip:port" 34 | * - Authenticated proxies: "ip:port:username:password" 35 | * - Already formatted credentials: "username:password@ip:port" 36 | * 37 | * If the input already starts with a protocol (e.g. "http://"), that protocol is used 38 | * unless a protocol override is provided. 39 | * 40 | * @param input - The proxy string. It may optionally start with a protocol. 41 | * @param protocolOverride - Optional protocol to force (if not provided, the input’s protocol or "http" is used). 42 | * @returns The formatted proxy URL. 43 | * 44 | * @throws Error if the input format or any part of it is invalid. 45 | */ 46 | export default function formatProxyString( 47 | input: string, 48 | protocolOverride?: string, 49 | ): string { 50 | // Default protocol if none is found and no override is provided. 51 | let protocol = protocolOverride || 'http'; 52 | 53 | // Check if the input already starts with a protocol (e.g. "http://") 54 | const protocolMatch = input.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//); 55 | if (protocolMatch) { 56 | protocol = protocolOverride || protocolMatch[1]; 57 | input = input.slice(protocolMatch[0].length); 58 | } 59 | 60 | // Check if the input contains an "@" symbol (i.e. already formatted credentials: "username:password@ip:port") 61 | if (input.includes('@')) { 62 | const [credentials, hostPort] = input.split('@'); 63 | const [username, password] = credentials.split(':'); 64 | if (!username || !password) { 65 | throw new Error( 66 | `[BunCurl2] - Invalid credentials format. Expected "username:password", received: ${input}`, 67 | ); 68 | } 69 | const [host, port] = hostPort.split(':'); 70 | if (!isValidHost(host)) { 71 | throw new Error(`[BunCurl2] - Invalid host: ${host}`); 72 | } 73 | if (!isValidPort(port)) { 74 | throw new Error(`[BunCurl2] - Invalid port: ${port}`); 75 | } 76 | return `${protocol}://${credentials}@${host}:${port}`; 77 | } 78 | 79 | // Split the remaining string by colon. 80 | const parts = input.split(':'); 81 | 82 | // Non‑authenticated proxy: "ip:port" 83 | if (parts.length === 2) { 84 | const [host, port] = parts; 85 | if (!isValidHost(host)) { 86 | throw new Error(`[BunCurl2] - Invalid host: ${host}`); 87 | } 88 | if (!isValidPort(port)) { 89 | throw new Error(`[BunCurl2] - Invalid port: ${port}`); 90 | } 91 | return `${protocol}://${host}:${port}`; 92 | } 93 | // Authenticated proxy: "ip:port:username:password" 94 | else if (parts.length === 4) { 95 | const [host, port, username, password] = parts; 96 | if (!isValidHost(host)) { 97 | throw new Error(`[BunCurl2] - Invalid host: ${host}`); 98 | } 99 | if (!isValidPort(port)) { 100 | throw new Error(`[BunCurl2] - Invalid port: ${port}`); 101 | } 102 | if (!username || !password) { 103 | throw new Error( 104 | `[BunCurl2] - Invalid credentials format. Expected "username:password", received: ${input}`, 105 | ); 106 | } 107 | return `${protocol}://${username}:${password}@${host}:${port}`; 108 | } else { 109 | throw new Error( 110 | `[BunCurl2] - Invalid input format: ${input}. Expected either "ip:port", "ip:port:username:password", or "username:password@ip:port", with an optional protocol prefix.`, 111 | ); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/models/utils.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | import { PROTOCOL_PORTS } from './constants'; 3 | 4 | /** 5 | * Helper: Determine if a string or object is a valid JSON structure (object or array). 6 | * 7 | * @param i - The string or object to test. 8 | * @returns {boolean} True if the input represents a JSON object or array, false otherwise. 9 | */ 10 | export function hasJsonStructure(i: string | object): boolean { 11 | let res: any; 12 | if (typeof i === 'string') { 13 | const str = i.trim(); 14 | const firstChar = str[0]; 15 | const lastChar = str[str.length - 1]; 16 | // If the string doesn't start and end with matching JSON brackets, return false. 17 | if ( 18 | (firstChar !== '{' || lastChar !== '}') && 19 | (firstChar !== '[' || lastChar !== ']') 20 | ) { 21 | return false; 22 | } 23 | try { 24 | res = JSON.parse(str); 25 | } catch { 26 | return false; 27 | } 28 | } else { 29 | res = i; 30 | } 31 | // Check that the parsed value is either an object or an array. 32 | return ( 33 | res !== null && 34 | typeof res === 'object' && 35 | (res.constructor === Object || Array.isArray(res)) 36 | ); 37 | } 38 | 39 | /** 40 | * Helper: Determine Content-Type based on body content. 41 | * 42 | * @param body - The body string. 43 | * @returns {string} The determined Content-Type. 44 | */ 45 | export function determineContentType(body: string): string { 46 | if (hasJsonStructure(body)) { 47 | return 'application/json'; 48 | } 49 | 50 | // Fast pre-check: if no '=' exists, it's unlikely to be URL-encoded. 51 | if (body.indexOf('=') === -1) return 'text/plain'; 52 | 53 | // Manually check that each ampersand-separated part contains an '='. 54 | const pairs = body.split('&'); 55 | for (let i = 0, len = pairs.length; i < len; i++) { 56 | // If any pair does not include '=', it's not properly URL-encoded. 57 | if (pairs[i].indexOf('=') === -1) return 'text/plain'; 58 | } 59 | return 'application/x-www-form-urlencoded'; 60 | } 61 | 62 | export function md5(str: string) { 63 | return crypto.createHash('md5').update(str).digest('hex'); 64 | } 65 | 66 | export function getDefaultPort(protocol: string): number | undefined { 67 | return PROTOCOL_PORTS[ 68 | protocol.toLowerCase().replaceAll(':', '').replaceAll('/', '') 69 | ]; 70 | } 71 | 72 | export function isValidIPv4(ip: string): boolean { 73 | const parts = ip.split('.'); 74 | if (parts.length !== 4) return false; 75 | 76 | for (const part of parts) { 77 | const len = part.length; 78 | if (len === 0 || len > 3) return false; 79 | 80 | if (len > 1 && part[0] === '0') return false; 81 | 82 | let num = 0; 83 | for (let i = 0; i < len; i++) { 84 | const code = part.charCodeAt(i); 85 | if (code < 48 || code > 57) return false; 86 | num = num * 10 + (code - 48); 87 | } 88 | if (num > 255) return false; 89 | } 90 | return true; 91 | } 92 | 93 | export function containsAlphabet(str: string): boolean { 94 | for (let i = 0; i < str.length; i++) { 95 | const code = str.charCodeAt(i); 96 | if ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) { 97 | return true; 98 | } 99 | } 100 | return false; 101 | } 102 | 103 | export function compareVersions(v1: string, v2: string): number { 104 | let i = 0, 105 | j = 0; 106 | const len1 = v1.length, 107 | len2 = v2.length; 108 | 109 | while (i < len1 || j < len2) { 110 | let num1 = 0, 111 | num2 = 0; 112 | 113 | // Parse next number in v1 114 | while (i < len1 && v1.charAt(i) !== '.') { 115 | // Assuming version strings are valid digits 116 | num1 = num1 * 10 + (v1.charCodeAt(i) - 48); 117 | i++; 118 | } 119 | 120 | // Parse next number in v2 121 | while (j < len2 && v2.charAt(j) !== '.') { 122 | num2 = num2 * 10 + (v2.charCodeAt(j) - 48); 123 | j++; 124 | } 125 | 126 | if (num1 > num2) return 1; 127 | if (num1 < num2) return -1; 128 | 129 | // Skip the '.' character 130 | i++; 131 | j++; 132 | } 133 | 134 | return 0; 135 | } 136 | -------------------------------------------------------------------------------- /src/services/cache.ts: -------------------------------------------------------------------------------- 1 | type CacheValue = { 2 | value: T; 3 | expiresAt: number; 4 | }; 5 | 6 | type Options = { 7 | maxItems?: number; 8 | noInterval?: boolean; 9 | }; 10 | 11 | export class LocalCache { 12 | private data: Map>; 13 | private cleanupInterval: ReturnType | undefined; 14 | 15 | constructor(private options?: Options) { 16 | this.data = new Map(); 17 | if (!options?.noInterval) { 18 | this.cleanupInterval = setInterval(() => this.cleanup(), 60 * 1000); 19 | } 20 | } 21 | 22 | /** 23 | * Stores a value with the given TTL (in seconds). 24 | */ 25 | set(key: string, value: T, ttl: number): void { 26 | if (this.options?.maxItems && this.data.size >= this.options.maxItems) { 27 | const key = [...this.data.keys()].pop(); 28 | key && this.data.delete(key); 29 | } 30 | const expiresAt = Date.now() + ttl * 1000; 31 | this.data.set(key, { value, expiresAt }); 32 | } 33 | 34 | /** 35 | * Returns the value if it exists and is not expired, otherwise undefined. 36 | */ 37 | get(key: string): T | null { 38 | const entry = this.data.get(key); 39 | if (!entry) return null; 40 | if (Date.now() > entry.expiresAt) { 41 | this.data.delete(key); 42 | return null; 43 | } 44 | return entry.value; 45 | } 46 | 47 | /** 48 | * Checks whether the key exists and is not expired. 49 | */ 50 | has(key: string): boolean { 51 | const entry = this.data.get(key); 52 | if (!entry) return false; 53 | if (Date.now() > entry.expiresAt) { 54 | this.data.delete(key); 55 | return false; 56 | } 57 | return true; 58 | } 59 | 60 | /** 61 | * Deletes the key from the cache. 62 | */ 63 | delete(key: string): boolean { 64 | return this.data.delete(key); 65 | } 66 | 67 | /** 68 | * Clears the entire cache. 69 | */ 70 | clear(): void { 71 | this.data.clear(); 72 | } 73 | 74 | /** 75 | * Destroys interval & clears the cache entries 76 | */ 77 | end(): void { 78 | this.cleanupInterval && clearInterval(this.cleanupInterval); 79 | this.data.clear(); 80 | } 81 | 82 | /** 83 | * Iterates over all entries and removes expired ones. 84 | */ 85 | private cleanup(): void { 86 | const now = Date.now(); 87 | for (const [key, entry] of this.data.entries()) { 88 | if (now > entry.expiresAt) { 89 | this.data.delete(key); 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/services/command.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalInit, RequestInit } from '../types'; 2 | import { Buffer } from 'buffer'; 3 | import { dns } from 'bun'; 4 | import { 5 | CURL, 6 | CURL_VERSION, 7 | CURL_OUTPUT, 8 | TLS, 9 | DNS_CACHE_MAP, 10 | HTTP, 11 | } from '../models/constants'; 12 | import formatProxyString from '../models/proxy'; 13 | import { 14 | compareVersions, 15 | containsAlphabet, 16 | determineContentType, 17 | getDefaultPort, 18 | hasJsonStructure, 19 | isValidIPv4, 20 | } from '../models/utils'; 21 | import { sortHeaders } from '../models/headers'; 22 | 23 | const SUPPORTS = { 24 | HTTP2: CURL_OUTPUT.includes('http2'), 25 | HTTP3: CURL_OUTPUT.includes('http3'), 26 | DNS_SERVERS: CURL_OUTPUT.includes('c-ares'), 27 | DNS_RESOLVE: compareVersions(CURL_VERSION, '7.21.3') >= 0, 28 | TCP_FASTOPEN: compareVersions(CURL_VERSION, '7.49.0') >= 0, 29 | TCP_NODELAY: compareVersions(CURL_VERSION, '7.11.2') >= 0, 30 | }; 31 | 32 | async function buildMultipartBody(formData: FormData) { 33 | const boundary = 34 | '----WebKitFormBoundary' + Math.random().toString(36).slice(2); 35 | const parts: Uint8Array[] = []; 36 | 37 | for (const [key, value] of Array.from(formData.entries())) { 38 | let header = `--${boundary}\r\nContent-Disposition: form-data; name="${key}"`; 39 | let chunk: Uint8Array; 40 | 41 | if (value instanceof Blob) { 42 | const file = value as unknown as File; 43 | header += `; filename="${file.name || 'file'}"\r\nContent-Type: ${file.type || 'application/octet-stream'}\r\n\r\n`; 44 | chunk = new Uint8Array(await file.arrayBuffer()); 45 | } else { 46 | header += '\r\n\r\n'; 47 | chunk = new TextEncoder().encode(String(value)); 48 | } 49 | 50 | parts.push( 51 | new TextEncoder().encode(header), 52 | chunk, 53 | new TextEncoder().encode('\r\n'), 54 | ); 55 | } 56 | 57 | parts.push(new TextEncoder().encode(`--${boundary}--\r\n`)); 58 | return { body: Buffer.concat(parts.map((u) => Buffer.from(u))), boundary }; 59 | } 60 | 61 | async function prepareRequestBody(body: unknown) { 62 | if (body instanceof URLSearchParams) { 63 | return { body: body.toString(), type: 'application/x-www-form-urlencoded' }; 64 | } 65 | 66 | if (body instanceof FormData) { 67 | const { body: bd, boundary } = await buildMultipartBody(body); 68 | return { body: bd, type: `multipart/form-data; boundary=${boundary}` }; 69 | } 70 | 71 | if ( 72 | body instanceof Blob || 73 | body instanceof ArrayBuffer || 74 | ArrayBuffer.isView(body) 75 | ) { 76 | const arr = 77 | body instanceof Blob ? await body.arrayBuffer() : (body as ArrayBuffer); 78 | return { body: Buffer.from(arr) }; 79 | } 80 | 81 | if (body instanceof ReadableStream) { 82 | const reader = body.getReader(); 83 | const chunks: Uint8Array[] = []; 84 | while (true) { 85 | const { value, done } = await reader.read(); 86 | if (done) break; 87 | if (value) chunks.push(value); 88 | } 89 | return { body: Buffer.concat(chunks.map((c) => Buffer.from(c))) }; 90 | } 91 | 92 | if (typeof body === 'object' && body !== null && hasJsonStructure(body)) { 93 | return { body: JSON.stringify(body), type: 'application/json' }; 94 | } 95 | 96 | const str = String(body); 97 | return { body: str, type: determineContentType(str) }; 98 | } 99 | 100 | const prepareHeaders = ( 101 | headers: RequestInit['headers'], 102 | sort: boolean, 103 | ): [string, string][] => { 104 | if (!headers) return []; 105 | if (sort) return sortHeaders(headers); 106 | return headers instanceof Headers 107 | ? Array.from(headers) 108 | : Array.isArray(headers) 109 | ? headers.map(([k, v]) => [k, String(v)]) 110 | : Object.entries(headers).map(([k, v]) => [k, String(v)]); 111 | }; 112 | 113 | function buildTLSOptions( 114 | options: RequestInit, 115 | cmd: string[], 116 | ) { 117 | const tlsVers = options.tls?.versions ?? [TLS.Version12, TLS.Version13]; 118 | const [low, high] = [Math.min(...tlsVers), Math.max(...tlsVers)]; 119 | const tlsMap: Record = { 120 | 769: { flag: CURL.TLSv1_0, str: '1.0' }, 121 | 770: { flag: CURL.TLSv1_1, str: '1.1' }, 122 | 771: { flag: CURL.TLSv1_2, str: '1.2' }, 123 | 772: { flag: CURL.TLSv1_3, str: '1.3' }, 124 | }; 125 | if (options.tls?.insecure) cmd.push(CURL.INSECURE); 126 | if (tlsMap[low]) cmd.push(tlsMap[low].flag); 127 | if (tlsMap[high]) cmd.push(CURL.TLS_MAX, tlsMap[high].str); 128 | if (options.tls?.ciphers) { 129 | const { DEFAULT, TLS13 } = options.tls.ciphers; 130 | if (DEFAULT) 131 | cmd.push( 132 | CURL.CIPHERS, 133 | Array.isArray(DEFAULT) ? DEFAULT.join(':') : DEFAULT, 134 | ); 135 | if (TLS13 && tlsVers.includes(772)) 136 | cmd.push( 137 | CURL.TLS13_CIPHERS, 138 | Array.isArray(TLS13) ? TLS13.join(':') : TLS13, 139 | ); 140 | } 141 | } 142 | 143 | async function buildDNSOptions( 144 | url: URL, 145 | options: RequestInit, 146 | cmd: string[], 147 | ) { 148 | if (options.dns?.servers && SUPPORTS.DNS_SERVERS) 149 | cmd.push(CURL.DNS_SERVERS, options.dns.servers.join(',')); 150 | 151 | if (SUPPORTS.DNS_RESOLVE && containsAlphabet(url.host)) { 152 | let resolveIP = options.dns?.resolve; 153 | const cached = DNS_CACHE_MAP.get(url.host); 154 | if (!resolveIP) { 155 | if (options.dns?.cache && cached) resolveIP = cached; 156 | else { 157 | try { 158 | const [rec] = await dns.lookup(url.host, { family: 4 }); 159 | resolveIP = rec?.address; 160 | } catch {} 161 | } 162 | } 163 | if (resolveIP && isValidIPv4(resolveIP)) { 164 | if (options.dns?.cache && !cached) 165 | DNS_CACHE_MAP.set( 166 | url.host, 167 | resolveIP, 168 | typeof options.dns.cache === 'number' ? options.dns.cache : 30, 169 | ); 170 | cmd.push( 171 | CURL.DNS_RESOLVE, 172 | `${url.host}:${getDefaultPort(url.protocol)}:${resolveIP}`, 173 | ); 174 | } 175 | } 176 | } 177 | 178 | export default async function BuildCommand( 179 | url: URL, 180 | options: RequestInit, 181 | init: GlobalInit, 182 | ): Promise { 183 | const urlStr = url.toString(); 184 | options = options.transformRequest 185 | ? options.transformRequest({ url: urlStr, ...options }) 186 | : init.transformRequest && options.transformRequest !== false 187 | ? init.transformRequest({ url: urlStr, ...options }) 188 | : options; 189 | 190 | const maxTime = options.maxTime ?? 10; 191 | const connTimeout = options.connectionTimeout ?? 5; 192 | const method = options.method!.toUpperCase(); 193 | const version = 194 | options.http?.version ?? (SUPPORTS.HTTP2 ? HTTP.Version20 : HTTP.Version11); 195 | 196 | const cmd = [ 197 | CURL.BASE, 198 | CURL.INFO, 199 | CURL.SILENT, 200 | CURL.SHOW_ERROR, 201 | CURL.TIMEOUT, 202 | String(maxTime), 203 | CURL.CONNECT_TIMEOUT, 204 | String(connTimeout), 205 | CURL.HTTP_VERSION[version], 206 | ]; 207 | 208 | buildTLSOptions(options, cmd); 209 | await buildDNSOptions(url, options, cmd); 210 | 211 | if (options.compress && method !== 'HEAD') cmd.push(CURL.COMPRESSED); 212 | if (init.tcp?.fastOpen && SUPPORTS.TCP_FASTOPEN) cmd.push(CURL.TCP_FASTOPEN); 213 | if (init.tcp?.noDelay && SUPPORTS.TCP_NODELAY) cmd.push(CURL.TCP_NODELAY); 214 | if (options.proxy) cmd.push(CURL.PROXY, formatProxyString(options.proxy)); 215 | 216 | if (options.follow ?? true) { 217 | cmd.push( 218 | CURL.FOLLOW, 219 | CURL.MAX_REDIRS, 220 | String(typeof options.follow === 'number' ? options.follow : 10), 221 | ); 222 | } 223 | 224 | if (version === 1.1) { 225 | if (options.http?.keepAlive === false || options.http?.keepAlive === 0) 226 | cmd.push(CURL.NO_KEEPALIVE); 227 | else if (typeof options.http?.keepAlive === 'number') 228 | cmd.push(CURL.KEEPALIVE_TIME, String(options.http.keepAlive)); 229 | if (typeof options.http?.keepAliveProbes === 'number') 230 | cmd.push(CURL.KEEPALIVE_CNT, String(options.http.keepAliveProbes)); 231 | } 232 | 233 | let prepared; 234 | if (options.body) { 235 | prepared = await prepareRequestBody(options.body); 236 | const data = 237 | typeof prepared.body === 'string' 238 | ? prepared.body 239 | : prepared.body.toString('utf-8'); 240 | cmd.push(CURL.DATA_RAW, data); 241 | } 242 | 243 | const ordered = prepareHeaders(options.headers, options.sortHeaders!); 244 | let hasCt = false; 245 | let ua = init.defaultAgent ?? `Bun/${Bun.version}`; 246 | for (const [k, v] of ordered) { 247 | if (k.toLowerCase() === 'content-type') hasCt = true; 248 | if (k.toLowerCase() === 'user-agent') ua = v; 249 | else cmd.push(CURL.HEADER, `${k}: ${v}`); 250 | } 251 | cmd.push(CURL.USER_AGENT, ua); 252 | if (prepared?.type && !hasCt) 253 | cmd.push(CURL.HEADER, `content-type: ${prepared.type}`); 254 | 255 | if (method === 'HEAD') cmd.push(CURL.HEAD); 256 | else cmd.push(CURL.METHOD, method); 257 | 258 | cmd.push(urlStr.replace(/\[|\]/g, (c) => (c === '[' ? '%5B' : '%5D'))); 259 | 260 | return cmd; 261 | } 262 | -------------------------------------------------------------------------------- /src/services/http.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CacheKeys, 3 | GlobalInit, 4 | RequestInit, 5 | ResponseInit, 6 | CacheInstance, 7 | } from '../types'; 8 | import BuildCommand from './command'; 9 | import { processAndBuild } from './response'; 10 | import { hasJsonStructure, md5 } from '../models/utils'; 11 | import { LocalCache } from './cache'; 12 | import { RedisClient } from 'bun'; 13 | 14 | let concurrentRequests = 0; 15 | 16 | const concurrentError = ( 17 | max: number, 18 | options: RequestInit, 19 | url: string, 20 | ) => 21 | Object.assign( 22 | new Error(`[BunCurl2] - Maximum concurrent requests (${max}) reached`), 23 | { code: 'ERR_CONCURRENT_REQUESTS_REACHED', options: { ...options, url } }, 24 | ); 25 | 26 | export default async function Http( 27 | url: string, 28 | options: RequestInit = {}, 29 | init: GlobalInit & { redirectsAsUrls?: U; cache?: CacheInstance } = {}, 30 | ): Promise> { 31 | prepareOptions(options, init); 32 | 33 | const maxConcurrent = init.maxConcurrentRequests ?? 250; 34 | if (concurrentRequests >= maxConcurrent) 35 | throw concurrentError(maxConcurrent, options, url); 36 | 37 | let cacheKey: string | undefined; 38 | const cacheServer = init.cache?.server; 39 | 40 | if (options.cache && cacheServer) { 41 | cacheKey = await getCacheKey(url, options); 42 | const cached = await cacheServer.get(cacheKey); 43 | if (cached != null) { 44 | try { 45 | const ts = performance.now(); 46 | const resp = processAndBuild( 47 | url, 48 | cached, 49 | ts, 50 | options.parseJSON!, 51 | true, 52 | options, 53 | init, 54 | ); 55 | return options.transformResponse 56 | ? options.transformResponse(resp) 57 | : resp; 58 | } catch { 59 | console.warn( 60 | `[BunCurl2] - Corrupted cache entry, skipping [${cacheKey}]`, 61 | ); 62 | } 63 | } 64 | } 65 | 66 | concurrentRequests++; 67 | let proc: Bun.Subprocess | undefined; 68 | try { 69 | const tsStart = performance.now(); 70 | const cmd = await BuildCommand(new URL(url), options, init); 71 | proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe' }); 72 | 73 | const stdoutPromise = (async () => { 74 | if (!proc!.stdout) throw new Error('[BunCurl2] - Missing stdout'); 75 | const outStream = 76 | typeof proc!.stdout === 'number' 77 | ? Bun.file(proc!.stdout, { type: 'stream' }).stream() 78 | : proc!.stdout; 79 | const buf = await new Response( 80 | outStream as ReadableStream, 81 | ).arrayBuffer(); 82 | return Buffer.from(buf).toString('binary'); 83 | })(); 84 | 85 | const abortPromise = new Promise((_, reject) => { 86 | if (options.signal) { 87 | if (options.signal.aborted) { 88 | proc!.kill(); 89 | reject(new DOMException('The operation was aborted.', 'AbortError')); 90 | } else { 91 | options.signal.addEventListener( 92 | 'abort', 93 | () => { 94 | proc!.kill(); 95 | reject( 96 | new DOMException('The operation was aborted.', 'AbortError'), 97 | ); 98 | }, 99 | { once: true }, 100 | ); 101 | } 102 | } 103 | }); 104 | 105 | let stdout: string; 106 | try { 107 | stdout = await Promise.race([stdoutPromise, abortPromise]); 108 | } catch (err) { 109 | await proc!.exited; 110 | throw err; 111 | } 112 | 113 | await proc.exited; 114 | 115 | if (proc.exitCode !== 0) { 116 | const stderrData = await (async () => { 117 | if (!proc!.stderr) throw new Error('[BunCurl2] - Missing stderr'); 118 | const errStream = 119 | typeof proc!.stderr === 'number' 120 | ? Bun.file(proc!.stderr, { type: 'stream' }).stream() 121 | : proc!.stderr; 122 | const buf = await new Response( 123 | errStream as ReadableStream, 124 | ).arrayBuffer(); 125 | return Buffer.from(buf).toString('utf-8'); 126 | })(); 127 | 128 | const msg = stderrData.trim().replace(/curl:\s*\(\d+\)\s*/, ''); 129 | throw Object.assign(new Error(`[BunCurl2] - ${msg}`), { 130 | code: 'ERR_CURL_FAILED', 131 | exitCode: proc.exitCode, 132 | options: { ...options, url }, 133 | }); 134 | } 135 | 136 | const resp = processAndBuild( 137 | url, 138 | stdout, 139 | tsStart, 140 | options.parseJSON!, 141 | false, 142 | options, 143 | init, 144 | ); 145 | 146 | if (cacheKey && cacheServer && typeof options.cache === 'object') { 147 | if ( 148 | typeof options.cache.validate === 'function' && 149 | !(await options.cache.validate(resp)) 150 | ) { 151 | return options.transformResponse 152 | ? options.transformResponse(resp) 153 | : resp; 154 | } 155 | 156 | const expire = 157 | typeof options.cache.expire === 'number' 158 | ? options.cache.expire 159 | : init.cache!.defaultExpiration!; 160 | 161 | if (cacheServer instanceof LocalCache) { 162 | cacheServer.set(cacheKey, stdout, expire); 163 | } else if (cacheServer instanceof RedisClient) { 164 | await cacheServer.send('SET', [ 165 | cacheKey, 166 | stdout, 167 | 'PX', 168 | String(expire * 1000 - 5), 169 | 'NX', 170 | ]); 171 | } else { 172 | await cacheServer.set(cacheKey, stdout, { 173 | expiration: { type: 'EX', value: expire }, 174 | condition: 'NX', 175 | }); 176 | } 177 | } 178 | 179 | return options.transformResponse ? options.transformResponse(resp) : resp; 180 | } finally { 181 | concurrentRequests--; 182 | } 183 | } 184 | 185 | async function getCacheKey( 186 | url: string, 187 | options: RequestInit, 188 | ): Promise { 189 | if (typeof options.cache === 'object' && options.cache.generate) 190 | return options.cache.generate({ url, ...options }); 191 | return generateCacheKey(url, options); 192 | } 193 | 194 | function prepareOptions( 195 | options: RequestInit, 196 | init: GlobalInit & { cache?: CacheInstance }, 197 | ) { 198 | options.parseJSON = options.parseJSON ?? init.parseJSON ?? true; 199 | options.method = options.method ?? 'GET'; 200 | options.compress = options.compress ?? init.compress ?? true; 201 | options.follow = options.follow ?? true; 202 | options.sortHeaders = options.sortHeaders ?? true; 203 | init.tcp = init.tcp ?? {}; 204 | init.tcp.fastOpen = init.tcp.fastOpen ?? true; 205 | init.tcp.noDelay = init.tcp.noDelay ?? true; 206 | if (init.cache) 207 | init.cache.defaultExpiration = init.cache.defaultExpiration ?? 5; 208 | } 209 | 210 | function generateCacheKey( 211 | url: string, 212 | options: RequestInit, 213 | ): string { 214 | const fields: CacheKeys[] = ['url', 'headers', 'body', 'proxy', 'method']; 215 | const keys = 216 | !options.cache || 217 | typeof options.cache === 'boolean' || 218 | !('keys' in options.cache) || 219 | !options.cache.keys 220 | ? fields 221 | : options.cache.keys; 222 | const serialized = keys.map((key) => serializeField(key, options, url)); 223 | return md5(`BunCurl2|${serialized.join('|')}`); 224 | } 225 | 226 | function serializeField( 227 | key: CacheKeys, 228 | options: RequestInit, 229 | url: string, 230 | ): string { 231 | let val: unknown = key === 'url' ? url : (options as any)[key]; 232 | if (val instanceof Headers) { 233 | val = Array.from(val.entries()).map(([h, v]) => h + v); 234 | } 235 | return hasJsonStructure(val as any) 236 | ? JSON.stringify(val as any) 237 | : String(val); 238 | } 239 | -------------------------------------------------------------------------------- /src/services/response.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | GlobalInit, 3 | BaseResponseInit, 4 | RequestInit, 5 | ResponseInit, 6 | } from '../types'; 7 | import Headers from '../models/headers'; 8 | import { hasJsonStructure } from '../models/utils'; 9 | 10 | export class ResponseWrapper 11 | implements ResponseInit 12 | { 13 | constructor( 14 | public url: string, 15 | public response: T, 16 | public headers: Headers, 17 | public status: number, 18 | public ok: boolean, 19 | public redirected: boolean, 20 | public type: string, 21 | public cached: boolean, 22 | public elapsedTime: number, 23 | public options: RequestInit, 24 | public redirects: ResponseInit['redirects'] = [], 25 | ) {} 26 | 27 | json(): T { 28 | return typeof this.response === 'string' 29 | ? JSON.parse(this.response) 30 | : this.response; 31 | } 32 | 33 | text(): string { 34 | return typeof this.response === 'string' 35 | ? this.response 36 | : JSON.stringify(this.response); 37 | } 38 | 39 | arrayBuffer(): ArrayBuffer { 40 | return Buffer.from(this.text(), 'binary').buffer; 41 | } 42 | 43 | blob(): Blob { 44 | return new Blob([Buffer.from(this.text(), 'binary')]); 45 | } 46 | } 47 | 48 | export function processResponses( 49 | url: string, 50 | raw: string, 51 | startTime: number, 52 | parseJSON: boolean, 53 | cached: boolean, 54 | ): BaseResponseInit[] { 55 | const entries: BaseResponseInit[] = []; 56 | const rawLen = raw.length; 57 | const starts: number[] = []; 58 | 59 | let pos = 0; 60 | while (true) { 61 | const idx = raw.indexOf('HTTP/', pos); 62 | if (idx === -1) break; 63 | if ( 64 | idx === 0 || 65 | raw[idx - 1] === '\n' || 66 | (idx > 1 && raw[idx - 1] === '\r' && raw[idx - 2] === '\n') 67 | ) { 68 | starts.push(idx); 69 | } 70 | pos = idx + 5; 71 | } 72 | if (starts.length === 0) return entries; 73 | starts.push(rawLen); 74 | 75 | for (let i = 0; i < starts.length - 1; i++) { 76 | const part = raw.substring(starts[i], starts[i + 1]).replace(/^\r?\n/, ''); 77 | const rnrn = part.indexOf('\r\n\r\n'); 78 | const nn = part.indexOf('\n\n'); 79 | let hdrEnd = rnrn > -1 ? rnrn : nn > -1 ? nn : part.length; 80 | let sepLen = rnrn > -1 ? 4 : nn > -1 ? 2 : 0; 81 | 82 | const headerBlock = part.substring(0, hdrEnd); 83 | const body = sepLen ? part.substring(hdrEnd + sepLen).trim() : ''; 84 | 85 | const lineEnd = headerBlock.indexOf('\r\n'); 86 | const statusLine = 87 | lineEnd > -1 ? headerBlock.substring(0, lineEnd) : headerBlock; 88 | const status = parseInt(statusLine.split(' ')[1], 10) || 500; 89 | 90 | const hdrs: string[][] = []; 91 | let cursor = lineEnd > -1 ? lineEnd + 2 : statusLine.length; 92 | while (cursor < headerBlock.length) { 93 | const nextEnd = headerBlock.indexOf('\r\n', cursor); 94 | const endPos = nextEnd > -1 ? nextEnd : headerBlock.length; 95 | const colon = headerBlock.indexOf(': ', cursor); 96 | if (colon > cursor && colon < endPos) { 97 | hdrs.push([ 98 | headerBlock.substring(cursor, colon), 99 | headerBlock.substring(colon + 2, endPos), 100 | ]); 101 | } 102 | cursor = endPos + 2; 103 | } 104 | 105 | entries.push({ 106 | url, 107 | body, 108 | headers: hdrs, 109 | status, 110 | requestStartTime: startTime, 111 | parseJSON, 112 | cached, 113 | }); 114 | } 115 | 116 | return entries; 117 | } 118 | 119 | export function buildResponse( 120 | entry: BaseResponseInit, 121 | opts: RequestInit, 122 | cfg: GlobalInit, 123 | ): ResponseInit { 124 | if (cfg.maxBodySize) { 125 | const limit = cfg.maxBodySize * 1024 * 1024; 126 | if (entry.body.length > limit) { 127 | const err = new Error( 128 | `[BunCurl2] - Maximum body size exceeded (${( 129 | entry.body.length / limit 130 | ).toFixed(2)} MiB)`, 131 | ); 132 | Object.defineProperty(err, 'code', { value: 'ERR_BODY_SIZE_EXCEEDED' }); 133 | throw err; 134 | } 135 | } 136 | 137 | let ct = ''; 138 | for (const [k, v] of entry.headers) { 139 | if (k.charCodeAt(0) === 99 && k.toLowerCase() === 'content-type') { 140 | ct = v; 141 | break; 142 | } 143 | } 144 | const lower = ct.toLowerCase(); 145 | const isText = 146 | lower.startsWith('text/') || 147 | lower.includes('json') || 148 | lower.includes('xml') || 149 | lower.includes('javascript'); 150 | 151 | let data: T; 152 | if (isText) { 153 | const txt = Buffer.from(entry.body, 'binary').toString('utf-8'); 154 | if (entry.parseJSON) { 155 | try { 156 | const parsed = JSON.parse(txt); 157 | data = hasJsonStructure(parsed) ? (parsed as T) : (txt as unknown as T); 158 | } catch { 159 | data = txt as unknown as T; 160 | } 161 | } else { 162 | data = txt as unknown as T; 163 | } 164 | } else { 165 | data = entry.body as unknown as T; 166 | } 167 | 168 | const status = entry.status; 169 | const ok = status >= 200 && status < 300; 170 | const redir = status >= 300 && status < 400; 171 | const type = status >= 400 ? 'error' : 'default'; 172 | 173 | return new ResponseWrapper( 174 | entry.url, 175 | data, 176 | new Headers(entry.headers), 177 | status, 178 | ok, 179 | redir, 180 | type, 181 | entry.cached, 182 | performance.now() - entry.requestStartTime, 183 | opts, 184 | ); 185 | } 186 | 187 | export function processAndBuild( 188 | url: string, 189 | raw: string, 190 | startTime: number, 191 | parseJSON: boolean, 192 | cached: boolean, 193 | opts: RequestInit, 194 | cfg: GlobalInit, 195 | ): ResponseInit { 196 | const entries = processResponses(url, raw, startTime, parseJSON, cached); 197 | 198 | if (entries.length > 1) { 199 | const firstHdrs = new Headers(entries[0].headers); 200 | if (!firstHdrs.has('location')) { 201 | entries.shift(); 202 | } 203 | } 204 | 205 | if (cfg.redirectsAsUrls === true) { 206 | let cur = url; 207 | const urls: string[] = []; 208 | 209 | for (const entry of entries) { 210 | if (entry.status >= 300 && entry.status < 400) { 211 | const loc = new Headers(entry.headers).get('location'); 212 | if (loc) { 213 | cur = new URL(loc, cur).toString(); 214 | urls.push(cur); 215 | } 216 | } 217 | } 218 | 219 | const lastEntry = entries[entries.length - 1]; 220 | const final = buildResponse(lastEntry, opts, cfg) as ResponseInit< 221 | T, 222 | U 223 | >; 224 | final.url = cur; 225 | final.redirected = urls.length > 0; 226 | final.redirects = urls as ResponseInit['redirects']; 227 | return final; 228 | } 229 | 230 | const wrappers = entries.map((e) => buildResponse(e, opts, cfg)); 231 | wrappers[0].url = url; 232 | let cur = url; 233 | const lastIdx = wrappers.length - 1; 234 | 235 | for (let i = 0; i < lastIdx; i++) { 236 | const w = wrappers[i]; 237 | if (w.status >= 300 && w.status < 400) { 238 | const loc = w.headers.get('location'); 239 | if (loc) { 240 | cur = new URL(loc, cur).toString(); 241 | wrappers[i + 1].url = cur; 242 | } 243 | } 244 | } 245 | 246 | const final = wrappers[lastIdx] as ResponseInit; 247 | final.redirected = lastIdx > 0; 248 | final.redirects = wrappers.slice(0, lastIdx) as ResponseInit< 249 | T, 250 | U 251 | >['redirects']; 252 | return final; 253 | } 254 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { RedisClientOptions } from 'redis'; 2 | import CustomHeaders from './models/headers'; 3 | import { TLS } from './models/constants'; 4 | import { LocalCache } from './services/cache'; 5 | import { RedisClient, RedisOptions } from 'bun'; 6 | /** 7 | * Represents a connection to a Redis server and provides basic operations. 8 | */ 9 | interface RedisServer { 10 | /** 11 | * Establishes a connection to the Redis server. 12 | * 13 | * @returns A promise that resolves to the connected RedisServer instance. 14 | */ 15 | connect: () => Promise; 16 | 17 | /** 18 | * Gracefully close a client's connection to Redis. Wait for commands in process, but reject any new commands. 19 | */ 20 | disconnect: () => Promise; 21 | 22 | /** 23 | * Retrieves the value associated with the specified key from the Redis server. 24 | * 25 | * @param key - The key whose value is to be retrieved. 26 | * @returns A promise that resolves to the value as a string, or null if the key does not exist. 27 | */ 28 | get: (key: string) => Promise; 29 | 30 | /** 31 | * Sets a key-value pair in the Redis server with an optional expiration time. 32 | * 33 | * @param key - The key to be set. 34 | * @param value - The value to be stored. 35 | * @param options - Optional settings; supports EX (expiration in seconds), NX (Only set the key if it does not already exist). 36 | * @returns A promise that resolves to the stored value as a string, or null. 37 | */ 38 | set: ( 39 | key: string, 40 | value: string, 41 | options: { 42 | expiration?: { 43 | type: 'EX' | 'PX' | 'EXAT' | 'PXAT'; 44 | value: number; 45 | }; 46 | condition?: 'NX' | 'XX'; 47 | }, 48 | ) => Promise; 49 | 50 | /** 51 | * Determines if the connection is open 52 | */ 53 | isOpen: boolean; 54 | } 55 | 56 | /** 57 | * Base configuration for caching mechanisms. 58 | */ 59 | interface BaseCache { 60 | /** 61 | * The default expiration time for cached entries in seconds. 62 | * @default 5 63 | */ 64 | defaultExpiration?: number; 65 | } 66 | 67 | type RedisCache = 68 | | (BaseCache & { 69 | mode?: 'redis'; 70 | server: RedisServer | RedisClient; 71 | options?: never; 72 | useRedisPackage?: never; 73 | }) 74 | | (BaseCache & { 75 | mode?: 'redis'; 76 | server?: never; 77 | useRedisPackage: true; 78 | options?: RedisClientOptions; 79 | }) 80 | | (BaseCache & { 81 | mode?: 'redis'; 82 | server?: never; 83 | useRedisPackage: false; 84 | options?: RedisOptions & { url?: string }; 85 | }) 86 | | (BaseCache & { 87 | mode?: 'redis'; 88 | server?: never; 89 | useRedisPackage?: never; 90 | options?: RedisOptions & { url?: string }; 91 | }); 92 | 93 | type CacheType = RedisCache | (BaseCache & { mode?: 'local' | 'client' }); 94 | 95 | type CacheInstance = { 96 | server: RedisServer | RedisClient | LocalCache; 97 | defaultExpiration?: number; 98 | }; 99 | 100 | /** 101 | * Initialization options for configuring requests. 102 | */ 103 | type GlobalInit = { 104 | /** 105 | * Transforms the outgoing request options before the request is sent. 106 | * 107 | * @param args - The initial RequestInit object. 108 | * @returns A transformed RequestInit object. 109 | */ 110 | transformRequest?: ( 111 | args: RequestInitWithURL, 112 | ) => RequestInit; 113 | 114 | /** 115 | * Enables response compression if set to true. 116 | * @default true 117 | */ 118 | compress?: boolean; 119 | 120 | /** 121 | * Specifies the default user agent to use if one is not provided in the request headers. 122 | */ 123 | defaultAgent?: string; 124 | 125 | /** 126 | * Maximum allowed size of the response body in megabytes. 127 | */ 128 | maxBodySize?: number; 129 | 130 | /** 131 | * Flag indicating if we should try parsing response to **JSON** automatically. 132 | * 133 | * @default true 134 | */ 135 | parseJSON?: boolean; 136 | 137 | /** 138 | * TCP Configuration Options 139 | */ 140 | tcp?: { 141 | /** 142 | * @description 143 | * sets a --tcp-fastopen flag 144 | * @default true 145 | * @requires cURL >= 7.49.0 146 | */ 147 | fastOpen?: boolean; 148 | /** 149 | * @description 150 | * sets a --tcp-nodelay flag 151 | * @default true 152 | * @requires cURL >= 7.11.2 153 | */ 154 | noDelay?: boolean; 155 | }; 156 | 157 | /** 158 | * @description 159 | * Maximum amount of allowed concurrent requests 160 | * 161 | * Cached requests are skipped 162 | * 163 | * @default 250 164 | */ 165 | maxConcurrentRequests?: number; 166 | 167 | /** 168 | * @description 169 | * If `ResponseInit.redirects` should only contain **URL**-strings instead of **Response** objects 170 | * @default false 171 | */ 172 | redirectsAsUrls?: boolean; 173 | }; 174 | 175 | /** 176 | * Represents the connection settings for HTTP/TLS and proxy configurations. 177 | */ 178 | interface Connection { 179 | /** 180 | * Proxy server URL to route the request through. 181 | */ 182 | proxy?: string; 183 | 184 | /** 185 | * TLS-specific configurations. 186 | */ 187 | tls?: { 188 | /** 189 | * Supported cipher suites. 190 | */ 191 | ciphers?: { 192 | DEFAULT?: string[] | string; 193 | TLS13?: string[] | string; 194 | }; 195 | /** 196 | * Supported TLS versions. 197 | * @default 771,772 198 | */ 199 | versions?: (typeof TLS)[keyof typeof TLS][]; 200 | 201 | /** 202 | * Disable certificate checks for HTTPS targets 203 | * @default false 204 | */ 205 | insecure?: boolean; 206 | }; 207 | 208 | /** 209 | * HTTP-specific connection settings. 210 | */ 211 | http?: { 212 | /** 213 | * HTTP protocol version. 214 | */ 215 | version: 3.0 | 2.0 | 1.1; 216 | /** 217 | * The keep-alive setting for the connection (HTTP/1.1). 218 | * 219 | * When set to a number, it represents the time in seconds to keep the connection alive. 220 | * 221 | * When set to a boolean, it indicates whether to enable (true) or disable (false) the keep-alive feature. 222 | */ 223 | keepAlive?: number | boolean; 224 | /** 225 | * Number of probes to check if the connection is still alive. 226 | */ 227 | keepAliveProbes?: number; 228 | }; 229 | 230 | /** 231 | * Connection timeout duration in seconds. 232 | */ 233 | connectionTimeout?: number; 234 | 235 | /** 236 | * Maximum time in seconds allowed for the entire request/response cycle. 237 | */ 238 | maxTime?: number; 239 | } 240 | 241 | export type CacheKeys = 'url' | 'body' | 'headers' | 'proxy' | 'method'; 242 | 243 | export type RequestInitWithURL< 244 | T = any, 245 | U extends boolean = false, 246 | > = RequestInit & { url: string }; 247 | 248 | /** 249 | * Extra options to enhance request and response handling. 250 | * 251 | * @template T - The type of data expected in the request or response. 252 | */ 253 | interface ExtraOptions { 254 | /** 255 | * Function to transform the request options before the request is made. 256 | * 257 | * @param args - The initial RequestInit object. 258 | * @returns A transformed RequestInit object. 259 | * @override GlobalInit.transformRequest 260 | */ 261 | transformRequest?: 262 | | ((args: RequestInitWithURL) => RequestInit) 263 | | false; 264 | 265 | /** 266 | * Function to transform the response before it is returned to the caller. 267 | * 268 | * @param args - The original Response object. 269 | * @returns A transformed Response object. 270 | */ 271 | transformResponse?: ( 272 | args: ResponseInit, 273 | ) => ResponseInit | Promise>; 274 | 275 | /** 276 | * Flag indicating if we should try parsing response to **JSON** automatically. 277 | * 278 | * @default true 279 | */ 280 | parseJSON?: boolean; 281 | 282 | /** 283 | * Configures caching for the request. 284 | */ 285 | cache?: 286 | | boolean 287 | | { 288 | /** 289 | * The expiration time for the cache entry in seconds. 290 | */ 291 | expire?: number; 292 | /** 293 | * An array of keys (from RequestInit) to be considered for caching. (default: **all**) 294 | */ 295 | keys?: CacheKeys[]; 296 | /** 297 | * Function to validate if request is eligible for caching. 298 | */ 299 | validate?: (response: ResponseInit) => boolean | Promise; 300 | /** 301 | * Function for manually generating the cache identifier (key) 302 | * @override `cache.keys` 303 | */ 304 | generate?: ( 305 | request: RequestInitWithURL, 306 | ) => string | Promise; 307 | }; 308 | /** 309 | * Enables response compression if set to true. 310 | * @default true 311 | * @override GlobalInit.compress 312 | */ 313 | compress?: boolean; 314 | 315 | /** 316 | * DNS Configuration options 317 | */ 318 | dns?: { 319 | /** 320 | * @description 321 | * An array of DNS server IP addresses to use for domain resolution. 322 | * 323 | * Each server should be provided as a string (e.g., "8.8.8.8"). 324 | * 325 | * @requires cURL build with **c-ares** 326 | */ 327 | servers?: string[]; 328 | /** 329 | * @description 330 | * TTL in seconds for DNS should be cached for current hostname. 331 | * 332 | * Provide `false` if you want to disable DNS caching for following hostname. 333 | * 334 | * If the value is `true`, cache will last for 30 seconds. 335 | * @default false 336 | */ 337 | cache?: number | boolean; 338 | /** 339 | * @description 340 | * Directly connect to the target with following IP to skip DNS lookup 341 | * @format `ip` 342 | */ 343 | resolve?: string; 344 | }; 345 | 346 | /** 347 | * @description 348 | * Re-serialize request headers in the Canonical HTTP/1.1 Header Order (RFC 2616 §14). 349 | * 350 | * @default true 351 | */ 352 | sortHeaders?: boolean; 353 | } 354 | 355 | type BodyInit = 356 | | string 357 | | Record 358 | | Blob 359 | | BufferSource 360 | | FormData 361 | | URLSearchParams 362 | | ReadableStream; 363 | 364 | /** 365 | * Basic request initialization options. 366 | */ 367 | interface BaseRequestInit { 368 | /** 369 | * The request body 370 | */ 371 | body?: BodyInit; 372 | 373 | /** 374 | * The request headers. 375 | */ 376 | headers?: 377 | | Record 378 | | Headers 379 | | [string, string | number][]; 380 | 381 | /** 382 | * The HTTP method to be used for the request (e.g., GET, POST). 383 | */ 384 | method?: string; 385 | 386 | /** 387 | * Flag or count indicating if and how redirects should be followed. 388 | * @default true 389 | */ 390 | follow?: boolean | number; 391 | 392 | /** 393 | * An AbortSignal to set request's signal. 394 | */ 395 | signal?: AbortSignal; 396 | } 397 | 398 | /** 399 | * Comprehensive request initialization type combining base options, 400 | * extra options for transformation and caching, as well as connection settings. 401 | * 402 | * @template T - The type associated with the request body. 403 | */ 404 | interface RequestInit 405 | extends BaseRequestInit, 406 | ExtraOptions, 407 | Connection {} 408 | 409 | /** 410 | * Represents an HTTP response with additional metadata. 411 | * 412 | * @template T - The type of the response data. 413 | */ 414 | interface ResponseInit { 415 | /** 416 | * The response payload. 417 | */ 418 | response: T; 419 | 420 | /** 421 | * The request options that generated this response. 422 | */ 423 | options: RequestInit; 424 | 425 | /** 426 | * Retrieves the response body as plain text. 427 | * 428 | * @returns The response body as a string. 429 | */ 430 | text(): string; 431 | 432 | /** 433 | * Retrieves the response as an ArrayBuffer. 434 | * 435 | * @returns The response body as an ArrayBuffer. 436 | */ 437 | arrayBuffer(): ArrayBuffer; 438 | 439 | /** 440 | * Retrieves the response as a Blob. 441 | * 442 | * @returns The response body as a Blob. 443 | */ 444 | blob(): Blob; 445 | 446 | /** 447 | * Parses and retrieves the response body as JSON. 448 | * 449 | * @returns The parsed JSON object. 450 | */ 451 | json(): any; 452 | 453 | /** 454 | * The response headers. 455 | */ 456 | headers: CustomHeaders; 457 | 458 | /** 459 | * The HTTP status code of the response. 460 | */ 461 | status: number; 462 | 463 | /** 464 | * Indicates whether the response status code is in the successful range. 465 | */ 466 | ok: boolean; 467 | 468 | /** 469 | * Indicates whether the response was redirected. 470 | */ 471 | redirected: boolean; 472 | 473 | /** 474 | * Redirect chain 475 | */ 476 | redirects: U extends true ? string[] : ResponseInit[]; 477 | 478 | /** 479 | * The type of the response. 480 | */ 481 | type: string; 482 | 483 | /** 484 | * The URL from which the response was obtained. 485 | */ 486 | url: string; 487 | 488 | /** 489 | * Indicates whether the response was served from a cache. 490 | */ 491 | cached: boolean; 492 | 493 | /** 494 | * The total time elapsed during the request in milliseconds. 495 | */ 496 | elapsedTime: number; 497 | } 498 | 499 | /** 500 | * Represents the raw response details from an HTTP request. 501 | */ 502 | type BaseResponseInit = { 503 | /** 504 | * The URL from which the response was fetched. 505 | */ 506 | url: string; 507 | 508 | /** 509 | * The response body as a string. 510 | */ 511 | body: string; 512 | 513 | /** 514 | * The response headers represented as an array of key-value pairs. 515 | */ 516 | headers: string[][] | [string, string][]; 517 | 518 | /** 519 | * The HTTP status code. 520 | */ 521 | status: number; 522 | 523 | /** 524 | * The timestamp marking the start of the request. 525 | */ 526 | requestStartTime: number; 527 | 528 | /** 529 | * Indicates whether the response was served from a cache. 530 | */ 531 | cached: boolean; 532 | 533 | /** 534 | * Flag indicating if we should try parsing response to **JSON** automatically. 535 | */ 536 | parseJSON: boolean; 537 | }; 538 | 539 | export type { 540 | RequestInit, 541 | ResponseInit, 542 | CacheType, 543 | CacheInstance, 544 | GlobalInit, 545 | BaseResponseInit, 546 | RedisServer, 547 | BaseRequestInit, 548 | BaseCache, 549 | RedisCache, 550 | }; 551 | -------------------------------------------------------------------------------- /tests/body.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'bun:test'; 2 | import { fetch } from '../src'; 3 | 4 | test('body', async () => { 5 | const formData = new FormData(); 6 | // Create a Blob to simulate a file. 7 | const fileContent = 'Hello, world!'; 8 | const blob = new Blob([fileContent], { type: 'text/plain' }); 9 | formData.append('test_file', blob, 'hello.txt'); 10 | 11 | const test_formData = await fetch<{ files: Record<'test_file', string> }>( 12 | 'https://httpbin.org/anything', 13 | { 14 | body: formData, 15 | }, 16 | ); 17 | 18 | const stream = new ReadableStream({ 19 | start(controller) { 20 | controller.enqueue(new TextEncoder().encode('Hello, ')); 21 | controller.enqueue(new TextEncoder().encode('world!')); 22 | controller.close(); 23 | }, 24 | }); 25 | 26 | const test_stream = await fetch<{ data: string }>( 27 | 'https://httpbin.org/anything', 28 | { 29 | method: 'POST', 30 | body: stream, 31 | headers: { 32 | 'content-type': 'text/plain;charset=utf-8', 33 | }, 34 | }, 35 | ); 36 | 37 | const params = new URLSearchParams({ foo: 'bar', baz: 'qux' }); 38 | 39 | const test_params = await fetch<{ form: Record }>( 40 | 'https://httpbin.org/anything', 41 | { 42 | method: 'POST', 43 | body: params, 44 | }, 45 | ); 46 | 47 | expect(JSON.stringify(test_params.response.form)).toBe( 48 | JSON.stringify({ baz: 'qux', foo: 'bar' }), 49 | ); // httpbin returns form in incorrect order for some reason 50 | 51 | expect(test_stream.response.data).toBe('Hello, world!'); 52 | 53 | expect(test_formData.response.files.test_file).toBe(fileContent); 54 | }); 55 | -------------------------------------------------------------------------------- /tests/cache.test.ts: -------------------------------------------------------------------------------- 1 | import BunCurl2 from '../src'; 2 | import { expect, test } from 'bun:test'; 3 | 4 | const Client = new BunCurl2({ 5 | cache: { 6 | mode: 'redis', 7 | useRedisPackage: false, 8 | }, 9 | }); 10 | 11 | test('cache', async () => { 12 | await Client.connect(); 13 | 14 | const ShouldNotCache = await Client.get('https://www.example.com', { 15 | cache: { 16 | validate: () => false, 17 | }, 18 | parseJSON: false, 19 | }); 20 | 21 | const ShouldNotCacheEither = await Client.get('https://www.example.com', { 22 | cache: { 23 | validate: async () => false, 24 | }, 25 | parseJSON: false, 26 | }); 27 | 28 | const CacheRequest = await Client.get('https://www.example.com', { 29 | cache: { 30 | validate: async () => true, 31 | expire: 1, 32 | }, 33 | parseJSON: false, 34 | }); 35 | 36 | const ShouldReturnFromCache = await Client.get('https://www.example.com', { 37 | cache: true, 38 | parseJSON: false, 39 | }); 40 | 41 | await Bun.sleep(1000); 42 | 43 | const ShouldBeExpired = await Client.get('https://www.example.com', { 44 | cache: true, 45 | parseJSON: false, 46 | }); 47 | 48 | // required if we want the process to exit after finish 49 | await Client.destroy(); 50 | 51 | expect(ShouldNotCache.cached).toBe(false); 52 | 53 | expect(ShouldNotCacheEither.cached).toBe(false); 54 | 55 | expect(CacheRequest.cached).toBe(false); 56 | 57 | expect(ShouldReturnFromCache.cached).toBe(true); 58 | 59 | expect(ShouldBeExpired.cached).toBe(false); 60 | }); 61 | -------------------------------------------------------------------------------- /tests/concurrent.test.ts: -------------------------------------------------------------------------------- 1 | import BunCurl2, { RequestInit } from '../src'; 2 | import { expect, test } from 'bun:test'; 3 | 4 | const TEST_URL = 'https://httpbin.org/delay/1'; 5 | 6 | test('concurrent requests (5)', () => { 7 | const client = new BunCurl2({ maxConcurrentRequests: 5 }); 8 | 9 | const inFlight: Promise[] = []; 10 | for (let i = 0; i < 5; i++) { 11 | inFlight.push(client.get(TEST_URL)); 12 | } 13 | 14 | expect(client.get(TEST_URL)).rejects.toMatchObject({ 15 | code: 'ERR_CONCURRENT_REQUESTS_REACHED', 16 | }); 17 | 18 | Promise.allSettled(inFlight); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/err.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'bun:test'; 2 | import { fetch, ResponseWrapper } from '../src'; 3 | 4 | test('error', async () => { 5 | // Test: invalid host should reject with an error that has exitCode 6. 6 | expect(fetch('https://invalid_host.name.com')).rejects.toMatchObject({ 7 | exitCode: 6, 8 | code: 'ERR_CURL_FAILED', 9 | }); 10 | 11 | // Test: valid URL returns a ResponseWrapper instance. 12 | const successResponse = await fetch('https://www.example.com'); 13 | 14 | expect(successResponse).toBeInstanceOf(ResponseWrapper); 15 | 16 | // Test: invalid proxy string should result in an error that has exitCode 5. 17 | expect( 18 | fetch('https://www.example.com', { 19 | proxy: 'test:123', 20 | connectionTimeout: 0.1, 21 | }), 22 | ).rejects.toThrowError(); 23 | }); 24 | -------------------------------------------------------------------------------- /tests/image.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'bun:test'; 2 | import BunCurl2 from '../src'; 3 | 4 | test('image response', async () => { 5 | const client = new BunCurl2(); 6 | 7 | const imageRequest = await client.get( 8 | 'https://cdn.discordapp.com/embed/avatars/1.png?size=128', 9 | ); 10 | 11 | const blob = imageRequest.blob(); 12 | 13 | expect(blob).toBeInstanceOf(Blob); 14 | 15 | expect(blob.size).toBeGreaterThan(1000); 16 | 17 | expect(imageRequest.json).toThrowError(); 18 | }); 19 | -------------------------------------------------------------------------------- /tests/redirects.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'bun:test'; 2 | import BunCurl2, { ResponseInit } from '../src'; 3 | 4 | type Equals = 5 | (() => X extends A ? 1 : 2) extends () => X extends B ? 1 : 2 6 | ? true 7 | : false; 8 | 9 | type Expect = T; 10 | 11 | const clientWithRedirectUrls = new BunCurl2({ 12 | redirectsAsUrls: true, 13 | }); 14 | 15 | const clientWithRedirectObjects = new BunCurl2({ 16 | redirectsAsUrls: false, 17 | }); 18 | 19 | test('redirects test', async () => { 20 | const desiredChainForUrls = [ 21 | 'https://httpbin.org/relative-redirect/2', 22 | 'https://httpbin.org/relative-redirect/1', 23 | 'https://httpbin.org/get', 24 | ]; 25 | 26 | const urls = await clientWithRedirectUrls.get( 27 | 'https://httpbin.org/redirect/3', 28 | ); 29 | 30 | // @ts-check 31 | type isStrArray = Expect>; // Must have a type of: true 32 | 33 | expect(urls.redirects).toBeArrayOfSize(3); 34 | expect(urls.redirects).toMatchObject(desiredChainForUrls); 35 | 36 | // For full-response objects, url must preserve the initial request URL instead of the redirect URL 37 | // As we can still see the redirect URL in "location" header :) 38 | 39 | const desiredChainForObjects = [ 40 | 'https://httpbin.org/redirect/3', 41 | 'https://httpbin.org/relative-redirect/2', 42 | 'https://httpbin.org/relative-redirect/1', 43 | ]; 44 | 45 | const objects = await clientWithRedirectObjects.get( 46 | 'https://httpbin.org/redirect/3', 47 | ); 48 | 49 | // @ts-check 50 | type isResponseInitArray = Expect< 51 | Equals<(typeof objects)['redirects'], ResponseInit[]> 52 | >; // Must have a type of: true 53 | 54 | expect(objects.redirects).toBeArrayOfSize(3); 55 | expect( 56 | objects.redirects.map((e) => ((e satisfies ResponseInit) ? e.url : 0)), 57 | ).toMatchObject(desiredChainForObjects); 58 | }); 59 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "types": ["bun-types"], 8 | "declaration": true, 9 | "outDir": "./dist", 10 | "removeComments": false, 11 | "esModuleInterop": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "strict": true, 14 | "skipLibCheck": true, 15 | "allowSyntheticDefaultImports": true 16 | }, 17 | "include": ["src/**/*"] 18 | } 19 | --------------------------------------------------------------------------------