@push.rocks/smartrequest
A modern, cross-platform HTTP/HTTPS request library for Node.js, Bun, Deno, and browsers with a unified API, supporting form data, file uploads, JSON, binary data, streams, and unix sockets.
Install
# Using npm
npm install @push.rocks/smartrequest --save
# Using pnpm
pnpm add @push.rocks/smartrequest
# Using yarn
yarn add @push.rocks/smartrequest
Key Features
- 🚀 Modern Fetch-like API - Familiar response methods (
.json(),.text(),.arrayBuffer(),.stream()) - 🌐 Cross-Platform - Works in Node.js, Bun, Deno, and browsers with a unified API
- 🔌 Unix Socket Support - Connect to local services like Docker (Node.js, Bun, and Deno)
- 📦 Form Data & File Uploads - Built-in support for multipart/form-data
- 🔁 Pagination Support - Multiple strategies (offset, cursor, Link headers)
- ⚡ Keep-Alive Connections - Efficient connection pooling in Node.js
- 🛡️ TypeScript First - Full type safety and IntelliSense support
- 🎯 Zero Magic Defaults - Explicit configuration following fetch API principles
- 📡 Streaming Support - Stream buffers, files, and custom data without loading into memory
- 🔧 Highly Configurable - Timeouts, retries, headers, rate limiting, and more
- 🔒 Public-Only Requests - Fetch URLs from untrusted input without reaching your own network, and check plain socket targets (IMAP, SMTP) the same way
- 🧭 Fetch-Compatible Client -
SmartFetchreturns nativeResponseobjects, with retries and backoff, URL failover, timeouts, interceptors and deduplication (@push.rocks/smartrequest/fetch) - 💾 HTTP Cache - Opt-in persistent caching that honours
Cache-ControlandETag(@push.rocks/smartrequest/cache, IndexedDB)
Architecture
SmartRequest features a multi-layer architecture that provides consistent behavior across platforms:
- Core Base - Abstract classes and unified types shared across implementations
- Core Node - Node.js implementation using native http/https modules with unix socket support
- Core Bun - Bun implementation using native fetch with unix socket support via
unixoption - Core Deno - Deno implementation using fetch with unix socket support via HttpClient proxy
- Core Fetch - Browser implementation using the Fetch API
- Core - Dynamic runtime detection and implementation selection using @push.rocks/smartenv
- Client - High-level fluent API for everyday use
- Fetch - Fetch-compatible
SmartFetchclient onglobalThis.fetch(subpath/fetch) - Cache - IndexedDB-backed
HttpCacheforSmartFetch(subpath/cache)
The library automatically detects the runtime environment (Deno, Bun, Node.js, or browser) and loads the appropriate implementation, ensuring optimal performance and native feature support for each platform.
Usage
@push.rocks/smartrequest provides a clean, type-safe API inspired by the native fetch API but with additional features needed for modern applications.
Basic Usage
import { SmartRequest } from '@push.rocks/smartrequest';
// Simple GET request
async function fetchUserData(userId: number) {
const response = await SmartRequest.create()
.url(`https://jsonplaceholder.typicode.com/users/${userId}`)
.get();
// Use the fetch-like response API
const userData = await response.json();
console.log(userData); // The parsed JSON response
}
// POST request with JSON body
async function createPost(title: string, body: string, userId: number) {
const response = await SmartRequest.create()
.url('https://jsonplaceholder.typicode.com/posts')
.json({ title, body, userId })
.post();
const createdPost = await response.json();
console.log(createdPost); // The created post
}
Direct Core API Usage
For advanced use cases, you can use the Core API directly:
import { CoreRequest } from '@push.rocks/smartrequest';
async function directCoreRequest() {
const request = new CoreRequest('https://api.example.com/data', {
method: 'GET',
headers: {
Accept: 'application/json',
},
});
const response = await request.fire();
const data = await response.json();
return data;
}
Setting Headers and Query Parameters
import { SmartRequest } from '@push.rocks/smartrequest';
async function searchRepositories(query: string, perPage: number = 10) {
const response = await SmartRequest.create()
.url('https://api.github.com/search/repositories')
.header('Accept', 'application/vnd.github.v3+json')
.query({
q: query,
per_page: perPage.toString(),
})
.get();
const data = await response.json();
return data.items;
}
Handling Timeouts and Retries
import { SmartRequest } from '@push.rocks/smartrequest';
async function fetchWithRetry(url: string) {
const response = await SmartRequest.create()
.url(url)
.timeout(5000) // 5 seconds timeout
.retry(3) // Retry up to 3 times on failure
.get();
return await response.json();
}
Setting Request Options
Use the options() method to set any request options supported by the underlying implementation:
import { SmartRequest } from '@push.rocks/smartrequest';
// Set various options
const response = await SmartRequest.create()
.url('https://api.example.com/data')
.options({
keepAlive: true, // Enable connection reuse (Node.js)
timeout: 10000, // 10 second timeout
hardDataCuttingTimeout: 15000, // 15 second hard timeout
// Platform-specific options are also supported
})
.get();
Working with Different Response Types
The API provides a fetch-like interface for handling different response types:
import { SmartRequest } from '@push.rocks/smartrequest';
// JSON response (default)
async function fetchJson(url: string) {
const response = await SmartRequest.create().url(url).get();
return await response.json(); // Parses JSON automatically
}
// Text response
async function fetchText(url: string) {
const response = await SmartRequest.create().url(url).get();
return await response.text(); // Returns response as string
}
// Binary data
async function downloadImage(url: string) {
const response = await SmartRequest.create()
.url(url)
.accept('binary') // Optional: hints to server we want binary
.get();
const buffer = await response.arrayBuffer();
return Buffer.from(buffer); // Convert ArrayBuffer to Buffer if needed
}
// Streaming response (Web Streams API - cross-platform)
async function streamLargeFile(url: string) {
const response = await SmartRequest.create().url(url).get();
// Get a web-style ReadableStream (works everywhere)
const stream = response.stream();
if (stream) {
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(`Received ${value.length} bytes of data`);
}
} finally {
reader.releaseLock();
}
}
}
// Convert to Node.js stream if needed (Node.js only)
async function streamWithNodeApi(url: string) {
const response = await SmartRequest.create().url(url).get();
// Convert web stream to Node.js stream
import { Readable } from 'stream';
const webStream = response.stream();
const nodeStream = Readable.fromWeb(webStream);
nodeStream.on('data', (chunk) => {
console.log(`Received ${chunk.length} bytes of data`);
});
return new Promise((resolve, reject) => {
nodeStream.on('end', resolve);
nodeStream.on('error', reject);
});
}
Response Object Methods
The response object provides these methods:
json<T>(): Promise<T>- Parse response as JSONtext(): Promise<string>- Get response as textarrayBuffer(): Promise<ArrayBuffer>- Get response as ArrayBufferstream(): ReadableStream<Uint8Array> | null- Get web-style ReadableStream (cross-platform)raw(): Response | http.IncomingMessage- Get the underlying platform response object
Each body method can only be called once per response, similar to the fetch API.
Important: Always Consume Response Bodies
You should always consume response bodies, even if you don't need the data. Unconsumed response bodies can cause:
- Memory leaks as data accumulates in buffers
- Socket hanging with keep-alive connections
- Connection pool exhaustion
// ❌ BAD - Response body is not consumed
const response = await SmartRequest.create()
.url('https://api.example.com/status')
.get();
if (response.ok) {
console.log('Success!');
}
// Socket may hang here!
// ✅ GOOD - Response body is consumed
const response = await SmartRequest.create()
.url('https://api.example.com/status')
.get();
if (response.ok) {
console.log('Success!');
}
await response.text(); // Consume the body even if not needed
In Node.js, SmartRequest automatically drains unconsumed responses to prevent socket hanging, but it's still best practice to explicitly consume response bodies. When auto-drain occurs, you'll see a console log: Auto-draining unconsumed response body for [URL] (status: [STATUS]).
You can disable auto-drain if needed:
// Disable auto-drain (not recommended unless you have specific requirements)
const response = await SmartRequest.create()
.url('https://api.example.com/data')
.autoDrain(false) // Disable auto-drain
.get();
// Now you MUST consume the body or the socket will hang
await response.text();
Advanced Features
Form Data with File Uploads
import { SmartRequest } from '@push.rocks/smartrequest';
import * as fs from 'fs';
async function uploadMultipleFiles(
files: Array<{ name: string; path: string }>,
) {
const formFields = files.map((file) => ({
name: 'files',
value: fs.readFileSync(file.path),
filename: file.name,
contentType: 'application/octet-stream',
}));
const response = await SmartRequest.create()
.url('https://api.example.com/upload')
.formData(formFields)
.post();
return await response.json();
}
Streaming Request Bodies
SmartRequest provides multiple ways to stream data in requests, making it easy to upload large files or send real-time data without loading everything into memory:
import { SmartRequest } from '@push.rocks/smartrequest';
import * as fs from 'fs';
import { Readable } from 'stream';
// Stream a Buffer directly (works everywhere)
async function uploadBuffer() {
const buffer = Buffer.from('Hello, World!');
const response = await SmartRequest.create()
.url('https://api.example.com/upload')
.buffer(buffer, 'text/plain')
.post();
return await response.json();
}
// Stream using web ReadableStream (cross-platform!)
async function uploadWebStream() {
const stream = new ReadableStream({
start(controller) {
const data = new TextEncoder().encode('Stream data');
controller.enqueue(data);
controller.close();
},
});
const response = await SmartRequest.create()
.url('https://api.example.com/upload')
.stream(stream, 'text/plain')
.post();
return await response.json();
}
// Stream a file using Node.js streams (Node.js only)
async function uploadLargeFile(filePath: string) {
const fileStream = fs.createReadStream(filePath);
const response = await SmartRequest.create()
.url('https://api.example.com/upload')
.stream(fileStream, 'application/octet-stream')
.post();
return await response.json();
}
// Stream data from any readable source (Node.js only)
async function streamData(dataSource: Readable) {
const response = await SmartRequest.create()
.url('https://api.example.com/stream')
.stream(dataSource)
.post();
return await response.json();
}
// Send Uint8Array (works everywhere)
async function uploadBinaryData() {
const data = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
const response = await SmartRequest.create()
.url('https://api.example.com/binary')
.buffer(data, 'application/octet-stream')
.post();
return await response.json();
}
Streaming Methods
-
.buffer(data, contentType?)- Stream a Buffer or Uint8Array directlydata: Buffer (Node.js) or Uint8Array (cross-platform) to sendcontentType: Optional content type (defaults to 'application/octet-stream')- ✅ Works everywhere (Node.js, Bun, Deno, browsers)
-
.stream(stream, contentType?)- Stream from ReadableStream or Node.js streamstream: Web ReadableStream (cross-platform) or Node.js stream (Node.js only)contentType: Optional content type- ✅ Web ReadableStream works everywhere (Node.js, Bun, Deno, browsers)
- ⚠️ Node.js streams only work in Node.js (automatically converted to web streams in Bun/Deno)
These methods are particularly useful for:
- Uploading large files without loading them into memory
- Streaming real-time data to servers
- Proxying data between services
- Implementing chunked transfer encoding
Unix Socket Support (Node.js, Bun, and Deno)
SmartRequest supports unix sockets across all server-side runtimes with a unified API:
import { SmartRequest } from '@push.rocks/smartrequest';
// Connect to a service via Unix socket (works on Node.js, Bun, and Deno)
async function queryViaUnixSocket() {
const response = await SmartRequest.create()
.url('http://unix:/var/run/docker.sock:/v1.24/containers/json')
.get();
return await response.json();
}
// Alternative: Use socketPath option (works on all server runtimes)
async function queryWithSocketPath() {
const response = await SmartRequest.create()
.url('http://localhost/version')
.options({ socketPath: '/var/run/docker.sock' })
.get();
return await response.json();
}
Runtime-Specific Unix Socket APIs
Each runtime implements unix sockets using its native capabilities:
Bun:
import { CoreRequest } from '@push.rocks/smartrequest/core_bun';
// Bun uses the native `unix` fetch option
const response = await CoreRequest.create('http://localhost/version', {
unix: '/var/run/docker.sock'
});
Deno:
Unix sockets need Deno 2.3.2 or later, the first release with the typed { transport: 'unix', path } proxy of Deno.createHttpClient() (denoland/deno#29154). Deno 2.9.6 and later reject the undocumented proxy: { url: 'unix://…' } form with invalid proxy url.
import { CoreRequest } from '@push.rocks/smartrequest/core_deno';
// Deno uses HttpClient with its typed unix socket proxy
const client = Deno.createHttpClient({
proxy: { transport: 'unix', path: '/var/run/docker.sock' }
});
const response = await CoreRequest.create('http://localhost/version', {
client
});
// Clean up when done
client.close();
Node.js:
import { CoreRequest } from '@push.rocks/smartrequest/core_node';
// Node.js uses native socketPath option
const response = await CoreRequest.create('http://localhost/version', {
socketPath: '/var/run/docker.sock'
});
Pagination Support
The library includes built-in support for various pagination strategies:
import { SmartRequest } from '@push.rocks/smartrequest';
// Offset-based pagination (page & limit)
async function fetchAllUsers() {
const client = SmartRequest.create()
.url('https://api.example.com/users')
.withOffsetPagination({
pageParam: 'page',
limitParam: 'limit',
startPage: 1,
pageSize: 20,
totalPath: 'meta.total',
});
// Get first page with pagination info
const firstPage = await client.getPaginated();
console.log(`Found ${firstPage.items.length} users on first page`);
console.log(`Has more pages: ${firstPage.hasNextPage}`);
if (firstPage.hasNextPage) {
// Get next page
const secondPage = await firstPage.getNextPage();
console.log(`Found ${secondPage.items.length} more users`);
}
// Or get all pages at once (use with caution for large datasets)
const allUsers = await client.getAllPages();
console.log(`Retrieved ${allUsers.length} users in total`);
}
// Cursor-based pagination
async function fetchAllPosts() {
const allPosts = await SmartRequest.create()
.url('https://api.example.com/posts')
.withCursorPagination({
cursorParam: 'cursor',
cursorPath: 'meta.nextCursor',
hasMorePath: 'meta.hasMore',
})
.getAllPages();
console.log(`Retrieved ${allPosts.length} posts in total`);
}
// Link header-based pagination (GitHub API style)
async function fetchAllIssues(repo: string) {
const paginatedResponse = await SmartRequest.create()
.url(`https://api.github.com/repos/${repo}/issues`)
.header('Accept', 'application/vnd.github.v3+json')
.withLinkPagination()
.getPaginated();
return paginatedResponse.getAllPages();
}
Keep-Alive Connections (Node.js)
import { SmartRequest } from '@push.rocks/smartrequest';
// Enable keep-alive for better performance with multiple requests
async function performMultipleRequests() {
// Note: keepAlive is NOT enabled by default
const response1 = await SmartRequest.create()
.url('https://api.example.com/endpoint1')
.options({ keepAlive: true })
.get();
const response2 = await SmartRequest.create()
.url('https://api.example.com/endpoint2')
.options({ keepAlive: true })
.get();
// Connections are pooled and reused when keepAlive is enabled
return [await response1.json(), await response2.json()];
}
Rate Limiting (429 Too Many Requests) Handling
The library includes built-in support for handling HTTP 429 (Too Many Requests) responses with intelligent backoff:
import { SmartRequest } from '@push.rocks/smartrequest';
// Simple usage - handle 429 with defaults
async function fetchWithRateLimitHandling() {
const response = await SmartRequest.create()
.url('https://api.example.com/data')
.handle429Backoff() // Automatically retry on 429
.get();
return await response.json();
}
// Advanced usage with custom configuration
async function fetchWithCustomRateLimiting() {
const response = await SmartRequest.create()
.url('https://api.example.com/data')
.handle429Backoff({
maxRetries: 5, // Try up to 5 times (default: 3)
respectRetryAfter: true, // Honor Retry-After header (default: true)
maxWaitTime: 30000, // Max 30 seconds wait (default: 60000)
fallbackDelay: 2000, // 2s initial delay if no Retry-After (default: 1000)
backoffFactor: 2, // Exponential backoff multiplier (default: 2)
onRateLimit: (attempt, waitTime) => {
console.log(`Rate limited. Attempt ${attempt}, waiting ${waitTime}ms`);
},
})
.get();
return await response.json();
}
// Example: API client with rate limit handling
class RateLimitedApiClient {
private async request(path: string) {
return SmartRequest.create()
.url(`https://api.example.com${path}`)
.handle429Backoff({
maxRetries: 3,
onRateLimit: (attempt, waitTime) => {
console.log(
`API rate limit hit. Waiting ${waitTime}ms before retry ${attempt}`,
);
},
});
}
async fetchData(id: string) {
const response = await this.request(`/data/${id}`).get();
return response.json();
}
}
The rate limiting feature:
- Automatically detects 429 responses and retries with backoff
- Respects the
Retry-Afterheader when present (supports both seconds and HTTP date formats) - Uses exponential backoff when no
Retry-Afterheader is provided - Allows custom callbacks for monitoring rate limit events
- Caps maximum wait time to prevent excessive delays
Public-Only Requests (Untrusted URLs)
When a URL comes from someone else (a form field, a webhook setting, a link in a document), the request must not reach your own network: no localhost, no private or link-local address, no cloud metadata endpoint at 169.254.169.254. publicOnly() makes a request that only reaches public internet hosts, and bounds what comes back:
import { SmartRequest, PublicOnlyError } from '@push.rocks/smartrequest';
async function readImprint(untrustedUrl: string) {
try {
const response = await SmartRequest.create()
.url(untrustedUrl)
.header('User-Agent', 'ExampleBot/1.0 (+https://example.com/bot)')
.publicOnly({
maxRedirects: 5,
maxResponseSize: 2 * 1024 * 1024, // bytes, also counted after decompression
allowedContentTypes: ['text/html', 'application/xhtml+xml', 'text/plain'],
connectTimeout: 5_000,
responseTimeout: 10_000,
totalTimeout: 15_000,
})
.get();
if (!response.ok) {
return { status: response.status }; // e.g. 403 or 404, returned as it is
}
// The body was read completely, within the limits, and decoded
const html = await response.text();
return { url: response.url, html }; // response.url is the final URL after redirects
} catch (error) {
if (error instanceof PublicOnlyError) {
// error.code says why (see the table below), error.detail carries the specifics
console.log(error.code, error.message);
}
throw error;
}
}
For the first request and for every redirect:
- URL:
http:andhttps:only (orallowedProtocols: ['https:']), no user name or password in the URL, and only the allowed ports (allowedPorts, default 80 and 443;'any'allows every port from 1 to 65535, for a server whose port its operator chooses — the address checks apply either way). The URL is parsed as a browser parses it: an IDN becomes punycode, and2130706433,0x7f.1,0177.0.0.1and127.0.0.1.are all 127.0.0.1. - Host names: special-use names are refused before any lookup:
localhost,.local,.internal,.arpa(includinghome.arpa),.test,.invalid,.example,.onion,.alt, and single-label names such asintranet. A service that runs in its operator's own network can admit some of them by name:allowSpecialUseDomains: ['internal', 'arpa']letsmail.corp.internalandmail.home.arpabe looked up, andallowSingleLabelNames: truea name such asintranet. Admitting a name never admits an address: every address it resolves to still has to passallowAddress, so a private network needs both. Any other special-use name stays refused. - Addresses: the host name is resolved once, and every address must be public; one non-public answer refuses the request. The connection then goes to exactly the checked addresses, so a second DNS answer cannot change the target (DNS rebinding), and nothing of the request is sent before the connection's peer is confirmed to be one of them. TLS still sends the host name (SNI) and verifies the certificate for it, and the
Hostheader is the host of the URL. - Redirects: followed up to
maxRedirects(default 5), and every target is checked like the first URL. Loops are detected.Authorization,CookieandProxy-Authorizationare dropped when a redirect leaves the origin. As in the Fetch standard, 301 and 302 turn a POST, and 303 any method but HEAD, into a GET without a body; 307 and 308 send the body again, which a stream body cannot, so that redirect is refused. - Response: the body is read completely before the response is returned. Its size is counted as the bytes arrive and again after each decoding step (
gzip,deflate,br), so neither a wrongContent-Lengthnor a compression bomb gets pastmaxResponseSize(default 5 MiB): decoding stops at the limit.allowedContentTypes(type/subtypeortype/*, default any) applies to successful (2xx) responses; an error response is returned as it is, so its status stays visible. - Certificates:
https:is verified against the runtime's root certificates.ca(one PEM certificate or a list) replaces them for a server whose certificate a private certificate authority issued, as a self-hosted installation's own servers often have; the host name is still verified. To trust the usual roots as well, pass them too:[...tls.rootCertificates, privateCa]withnode:tlsin Node.js and Bun; Deno answerstls.rootCertificatesonly with the--allow-syspermission. - Time:
connectTimeoutcovers TCP and the TLS handshake (default 10 s),responseTimeoutthe wait for the response headers (default 15 s), andtotalTimeouteverything, including redirects and the body (default 30 s)..timeout(ms)and anAbortSignalpassed with.options({ signal })apply as well.
Refused addresses (IANA special-purpose registries):
| Family | Ranges |
|---|---|
| IPv4 | 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.31.196.0/24, 192.52.193.0/24, 192.88.99.0/24, 192.168.0.0/16, 192.175.48.0/24, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4, 255.255.255.255 |
| IPv6 | ::, ::1, 64:ff9b:1::/48, 100::/64, 2001::/23, 2001:db8::/32, 2620:4f:8000::/48, 3fff::/20, 5f00::/16, fc00::/7, fe80::/10, fec0::/10, ff00::/8, and everything outside 2000::/3 |
| IPv6 carrying IPv4 | IPv4-mapped ::ffff:0:0/96, NAT64 64:ff9b::/96, 6to4 2002::/16 and Teredo 2001::/32 are judged by the IPv4 address they carry; the deprecated IPv4-compatible ::/96 is never public |
Errors (error.code, with the specifics in error.detail):
| Code | Meaning |
|---|---|
option_not_allowed, header_not_allowed, method_not_allowed |
The request itself is refused: an option that would choose the connection or bypass the checks (detail.option), a Host header, or a method other than GET, HEAD, POST, PUT, PATCH, DELETE and OPTIONS |
runtime_not_supported |
A browser, which cannot run public-only requests (see below) |
invalid_url, scheme_not_allowed, credentials_in_url, port_not_allowed |
The URL itself is refused |
host_not_allowed |
A special-use, single-label or invalid host name |
address_not_public |
The host is, or resolves to, a non-public address (detail.address says which and why) |
dns_failed |
The host name could not be resolved |
connect_failed, connect_timeout, tls_failed |
No connection, or no TLS connection |
response_timeout, total_timeout, response_failed, aborted |
The response did not arrive completely |
too_many_redirects, redirect_loop, invalid_redirect, redirect_body_not_replayable |
A redirect that is not followed |
content_type_not_allowed, content_encoding_not_supported, content_decoding_failed, response_too_large |
The response is refused (detail.status has its status) |
Every error carries url (the URL being requested when it happened, empty when the URL does not parse) and redirects (the URLs that redirected before). No part of an error contains a user name or password from a URL: not url, redirects, detail (an invalid Location is shown without them) or the message. A successful response is a PublicOnlyResponse, which also has redirects and remoteAddress (the address the final response came from). Its text() decodes the body by the charset of the Content-Type, as a browser reads responseText: a byte order mark decides over the charset, and a body without a charset, or with one no TextDecoder knows, is read as UTF-8. json() always reads UTF-8, as JSON requires. arrayBuffer() holds exactly the bytes of the body. PublicOnlyError extends Error. An invalid configuration (a negative timeout, a port outside 1 to 65535) is a programming error and throws a plain Error when the request is sent.
Testing Against Local Servers
The default policy refuses local test servers as well. Give the request a stub resolver and admit the test address explicitly, only for the names your test uses:
const response = await SmartRequest.create()
.url(`http://shop.example.com:${port}/impressum`)
.publicOnly({
resolve: async (hostname) => (hostname === 'shop.example.com' ? ['127.0.0.1'] : []),
allowedPorts: [port],
allowAddress: (address, target) =>
address.isPublic ||
(address.address === '127.0.0.1' && target.hostname === 'shop.example.com'),
})
.get();
A Public-Only fetch With a Streamed Body
publicOnly() reads the body completely. A client library that takes a fetch function and reads a stream (a model provider's SDK, for example) gets createPublicOnlyFetch(config) instead: a fetch with the same policy, whose Response arrives with the headers and whose body streams.
import { createPublicOnlyFetch, PublicOnlyError } from '@push.rocks/smartrequest';
// An endpoint a customer entered: HTTPS on port 443 only, no redirects, a long stream allowed
const customerFetch = createPublicOnlyFetch({
allowedProtocols: ['https:'],
allowedPorts: [443],
maxRedirects: 0,
maxResponseSize: 20 * 1024 * 1024,
totalTimeout: 10 * 60_000,
});
const response = await customerFetch('https://models.example.com/v1/chat/completions', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
body: JSON.stringify(request),
signal: AbortSignal.timeout(120_000),
});
for await (const chunk of response.body!) {
// decoded bytes, as they arrive
}
- Same checks as
publicOnly(): the URL, the host name, every resolved address (one resolution per request, the connection pinned to the checked addresses) and every redirect target.redirect: 'error'refuses any redirect (asmaxRedirects: 0does),redirect: 'manual'returns the redirect response without following it. - The response is a standard
Response(aPublicOnlyFetchResponse) withurl(the final URL),redirected,redirectsandremoteAddress. The body is decoded (gzip,deflate,br); it isnullfor HEAD and for 204, 205 and 304. - Limits while the body streams:
maxResponseSizecounts the body as it arrives and after each decoding step, andtotalTimeoutcovers the whole request including the body. A limit reached while the body is read errors the stream with aPublicOnlyError. The connection is released when the body was read, cancelled (response.body.cancel()) or failed; a body nobody reads holds its connection untiltotalTimeout. - Errors: an abort through
init.signalrejects, or errors the body, with the signal's reason, asfetchdoes. Arguments the Fetch standard refuses reject with aTypeError, as infetch: a body with GET or HEAD, an unknownredirectmode, and what theRequestandHeadersconstructors and the body extraction refuse. Every other refusal and every other failure is aPublicOnlyErrorwith the codes above, including a response the runtime'sResponsecannot represent (response_failed, for example a status text with a control character; its connection is closed). An invalid configuration throws a plainErrorwhencreatePublicOnlyFetchis called. - Arguments: the arguments of
fetch(a URL string, aURLor aRequest, and aRequestInit). A body other than aReadableStream(or aRequest's body) is read first, so a 307 or 308 redirect can send it again. The options that would choose another connection (dispatcher,client,proxy,unix,agent) andintegrityare refused with aPublicOnlyError(option_not_allowed), aHostheader withheader_not_allowed; the browser options (mode,credentials,cache,referrer,referrerPolicy,keepalive,priority) are ignored, as thefetchof Node.js ignores them.
Public-Only Sockets (IMAP, SMTP and Other Protocols)
A protocol that is not HTTP opens its own socket. resolvePublicOnlySocket(target, config) checks such a target with the same policy and resolves it once; the answer is what to connect with.
import * as tls from 'node:tls';
import { resolvePublicOnlySocket, PublicOnlyError } from '@push.rocks/smartrequest';
// A mail server a customer entered: IMAP over TLS or STARTTLS only
const target = await resolvePublicOnlySocket(
{ host: 'imap.example.com', port: 993 },
{ allowedPorts: [993, 143], resolveTimeout: 5_000, signal },
);
const socket = tls.connect({
host: target.host, // the checked name, in ASCII form
port: target.port,
lookup: target.lookup, // answers only with the checked addresses
servername: target.servername, // SNI, and the name the certificate is verified for
});
- Checks: the port must be one of
allowedPorts(there is no default; every protocol has its own ports). An IP literal (IPv6 with or without brackets) must be a public address. A host name is taken in its ASCII form (Bücher.examplebecomesxn--bcher-kva.example) and must not be special-use or single-label (unlessallowSpecialUseDomainsorallowSingleLabelNamesadmits it, as for requests), and every address it resolves to must be public: one private address refuses the host. - Pinned:
lookupanswers only fortarget.hostand only with the checkedaddresses, so a second DNS answer cannot change where the socket goes. Passhostandlookuptogether, tonet.connect,tls.connector a library that hands these options on to them. An IP literal needs no lookup: connect totarget.host. - TLS:
servernameis the host name without a trailing dot; it is sent as SNI and the certificate is verified for it. Node.js verifies the certificate againstservernamewhenever it is given, also whenhostis an IP address; without it, againsthost. An IP literal target has noservername. - Errors: every refusal and failure is a
PublicOnlyErrorwhoseurlishost:port:port_not_allowed,host_not_allowed,address_not_public,dns_failed(also when the resolver gives no answer withinresolveTimeout, default 10 seconds) andaborted(throughsignal). An invalid configuration rejects with a plainError.resolveandallowAddresswork as for requests, so local test servers are admitted the same way (see above). - Scope: the check covers where the socket connects. The protocol on top (its own redirects or referrals, for example) is the caller's; check any further host the same way.
Classifying Addresses
The classification is available on its own and runs in every runtime, browsers included:
import { classifyAddress, isPublicAddress } from '@push.rocks/smartrequest';
isPublicAddress('8.8.8.8'); // true
classifyAddress('::ffff:169.254.169.254');
// {
// address: '::ffff:a9fe:a9fe', family: 6, category: 'link-local', isPublic: false,
// range: '169.254.0.0/16',
// embedded: { mechanism: 'ipv4-mapped', prefix: '::ffff:0:0/96', ipv4: '169.254.169.254' },
// }
Runtimes and Limits
- Node.js runs public-only requests natively; Bun and Deno run them through their Node.js compatibility layer (
node:http,node:https,node:dns,node:zlib). The tests run in all three. - Browsers refuse them with a
PublicOnlyError(runtime_not_supported): a browser can neither resolve host names nor choose the address it connects to. - A public-only request always connects directly and never through an HTTP proxy. Every request gets its own connection; the options that would choose the target or share connections (
agent,socketPath,hostname,port,path,keepAlive: true, aHostheader) are refused with aPublicOnlyError(option_not_allowed,header_not_allowed). - The policy judges addresses. A host with a public address that only your network can reach (behind your firewall) looks like any other public host; if that matters, run the requests from where such hosts are not reachable.
Fetch-Compatible Client and HTTP Cache
Two subpath entry points add a client that speaks the native fetch contract (Request in, Response out), for code that wants standard responses, retries with backoff, URL failover and a persistent HTTP cache:
| Entry point | Exports | Dependencies |
|---|---|---|
@push.rocks/smartrequest/fetch |
SmartFetch, FetchTimeoutError, isFetchTimeoutError, types |
none: dispatches with globalThis.fetch (Node.js, Deno, Bun, browsers) |
@push.rocks/smartrequest/cache |
HttpCache |
@push.rocks/webstore (IndexedDB) |
The main entry point (SmartRequest) does not load either of them, and /fetch never loads /cache: a browser bundle pays for IndexedDB code only when it imports HttpCache.
Use SmartRequest for unix sockets, streaming uploads, multipart forms, pagination and public-only requests; use SmartFetch where a standard Response and the features below are wanted.
SmartFetch
import { SmartFetch, isFetchTimeoutError } from '@push.rocks/smartrequest/fetch';
const client = new SmartFetch({
defaults: { headers: { authorization: 'Bearer …' }, timeout: 10_000 },
});
// fetch-compatible: a native Response
const response = await client.fetch('https://api.example.com/items', { method: 'GET' });
// JSON helpers throw `HTTP <status>: <statusText>` for an unsuccessful response
const item = await client.getJson<IItem>('https://api.example.com/items/1');
const created = await client.postJson<IItem>('https://api.example.com/items', { name: 'new' });
await client.putJson('https://api.example.com/items/1', { name: 'renamed' });
await client.deleteJson('https://api.example.com/items/1');
// Retries with backoff (by default on status 408, 429, 500, 502, 503 and 504; network errors and timeouts too)
await client.getJson(url, {
retry: { maxAttempts: 4, backoff: 'exponential', initialDelay: 250, maxDelay: 5_000 },
});
// Failover: the next URL after a network error, 408 or 5xx; a 4xx is final
await client.postJson(primaryUrl, payload, { fallbackUrls: [backupUrl] });
// Timeouts reject with a FetchTimeoutError
try {
await client.getJson(url, { timeout: 2_000 });
} catch (error) {
if (isFetchTimeoutError(error)) {
// error.url, error.timeoutMs
}
}
Options of a call are the standard RequestInit plus:
| Option | Meaning |
|---|---|
retry |
true or { maxAttempts, backoff: 'exponential' | 'linear' | 'constant', initialDelay, maxDelay, retryOn, onRetry } |
fallbackUrls |
URLs tried in order after the request URL fails; works with or without retry |
timeout |
Milliseconds for the response headers of each attempt (default 60000). The JSON helpers also bound the body; without retry and fallbackUrls it is one deadline for the whole call |
interceptors |
{ request, response, error } arrays for this call, run after the instance interceptors (addRequestInterceptor, addResponseInterceptor, addErrorInterceptor, remove…, clearInterceptors) |
deduplicate |
Concurrent GET or HEAD calls with the same URL and headers share one request; each caller gets its own response, see below |
cacheStrategy, cacheKey, cacheMaxAge |
HTTP cache, see below |
logging |
Log cache decisions to the console |
The standard cache option ('no-store', 'force-cache', …) goes to the runtime's fetch unchanged.
Deduplicated calls share one request but not their fate:
- The first call starts the request with its options (
timeout,retry, interceptors); later calls withdeduplicate: trueand the same method, URL and headers attach to it. - A joining call inherits every option of the first call, not only
timeout,retryand interceptors but alsocacheStrategy,redirectandcredentials; of its own options only itssignalapplies. A'network-only'joiner can receive a response withfromCache: true. Disablededuplicatewhen concurrent calls to one URL differ in these options. - Every caller keeps its own
signal. A caller that aborts is rejected at once with its signal's reason and detached; the request continues for the callers still attached, the first one included. - The shared request is aborted only when every attached caller has aborted. A call that arrives afterwards starts a new request.
- Each caller receives an independent
Responseit can read on its own.
SmartFetch reads a request body once and sends the same bytes with every attempt, fallback URL and revalidation. For a streaming upload, use SmartRequest and its .stream() method.
fetchWithMetadata() and the …WithMetadata JSON helpers return { response, metadata: { fromCache, revalidated } } (frozen), so a caller learns whether the cache answered without trusting response headers.
new SmartFetch({ fetch }) dispatches through another fetch, for example a public-only one:
import { createPublicOnlyFetch } from '@push.rocks/smartrequest';
import { SmartFetch } from '@push.rocks/smartrequest/fetch';
const client = new SmartFetch({ fetch: createPublicOnlyFetch() });
HttpCache
import { SmartFetch } from '@push.rocks/smartrequest/fetch';
import { HttpCache } from '@push.rocks/smartrequest/cache';
const httpCache = new HttpCache(); // { dbName: 'smartrequest-cache', storeName: 'cache', maxEntries: 256 }
const client = new SmartFetch({ httpCache });
const result = await client.getJsonWithMetadata(url, { cacheStrategy: 'cache-first' });
result.metadata.fromCache; // true when the cache answered
// A POST is cached only under a key that identifies its body
await client.postJson(rpcUrl, payload, { cacheStrategy: 'cache-first', cacheKey: `rpc:${hash}` });
await httpCache.delete(`rpc:${hash}`);
await httpCache.clear();
await httpCache.close(); // whoever creates the cache closes it
Caching is opt-in per call: without cacheStrategy (or with 'network-only') SmartFetch never reads or writes the cache.
cacheStrategy |
Behaviour |
|---|---|
'network-only' (default) |
Never touches the cache |
'network-first' |
Network; a network error (not an abort or timeout) answers from the cache |
'cache-first' |
A fresh entry answers; an entry marked no-cache or must-revalidate is revalidated with ETag / Last-Modified; otherwise the network answers and refreshes the entry |
'stale-while-revalidate' |
An entry answers at once and is refreshed in the background when stale |
'cache-only' |
An entry answers, or the call fails |
- Only successful responses are stored, and never one marked
no-store. Freshness followsCache-Control: max-age,immutableandExpires;cacheMaxAge(milliseconds) caps it. - GET requests use their URL as key. Every other method needs
cacheKey(a string or a function of theRequest); without one the call is refused with aTypeError. - The key is the URL (or the explicit
cacheKey) and nothing else: request headers such asAuthorization,Vary,Cache-Control: privateandSet-Cookieare not taken into account. Share anHttpCache(or itsdbName) only between callers that may see each other's responses, or give per-user requests an explicitcacheKey. - Caches created with the same
dbNameshare their entries. Every write prunes expired entries and then the oldest down tomaxEntries. - Browsers persist the cache in IndexedDB. Node.js, Deno and Bun have no IndexedDB, so
@push.rocks/webstoreinstalls an in-memory one (fake-indexeddb) asglobalThis.indexedDB: there the cache lives as long as the process. The cache is tested in Node.js and Chromium. fallbackUrlscannot be combined with a cache strategy.
Platform-Specific Features
Browser-Specific Options
When running in a browser, you can use browser-specific fetch options:
const response = await SmartRequest.create()
.url('https://api.example.com/data')
.options({
credentials: 'include', // Include cookies
mode: 'cors', // CORS mode
cache: 'no-cache', // Cache mode
referrerPolicy: 'no-referrer',
})
.get();
Node.js-Specific Options
When running in Node.js, you can use Node-specific options:
import { Agent } from 'https';
const response = await SmartRequest.create()
.url('https://api.example.com/data')
.options({
agent: new Agent({ keepAlive: true }), // Custom agent
socketPath: '/var/run/api.sock', // Unix socket
})
.get();
Bun-Specific Options
When running in Bun, you can use Bun-specific options:
const response = await SmartRequest.create()
.url('https://api.example.com/data')
.options({
unix: '/var/run/api.sock', // Unix socket (Bun's native option)
keepAlive: true, // Keep-alive support
})
.get();
// Bun uses web streams natively
const streamResponse = await SmartRequest.create()
.url('https://api.example.com/data')
.get();
const webStream = streamResponse.stream(); // ✅ Use web streams in Bun
Deno-Specific Options
When running in Deno, you can use Deno-specific options:
// Custom HttpClient for advanced configuration
const client = Deno.createHttpClient({
proxy: { transport: 'unix', path: '/var/run/api.sock' }
});
const response = await SmartRequest.create()
.url('https://api.example.com/data')
.options({
client, // Custom Deno HttpClient
})
.get();
// Remember to clean up clients when done
client.close();
// Deno uses web streams natively
const streamResponse = await SmartRequest.create()
.url('https://api.example.com/data')
.get();
const webStream = streamResponse.stream(); // ✅ Use web streams in Deno
Complete Example: Building a REST API Client
Here's a complete example of building a typed API client:
import { SmartRequest, type ICoreResponse } from '@push.rocks/smartrequest';
interface User {
id: number;
name: string;
email: string;
}
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
class BlogApiClient {
private baseUrl = 'https://jsonplaceholder.typicode.com';
private async request(path: string) {
return SmartRequest.create()
.url(`${this.baseUrl}${path}`)
.header('Accept', 'application/json');
}
async getUser(id: number): Promise<User> {
const response = await this.request(`/users/${id}`).get();
return response.json<User>();
}
async createPost(post: Omit<Post, 'id'>): Promise<Post> {
const response = await this.request('/posts').json(post).post();
return response.json<Post>();
}
async deletePost(id: number): Promise<void> {
const response = await this.request(`/posts/${id}`).delete();
if (!response.ok) {
throw new Error(`Failed to delete post: ${response.statusText}`);
}
// Consume the body
await response.text();
}
async getAllPosts(userId?: number): Promise<Post[]> {
const client = this.request('/posts');
if (userId) {
client.query({ userId: userId.toString() });
}
const response = await client.get();
return response.json<Post[]>();
}
}
// Usage
const api = new BlogApiClient();
const user = await api.getUser(1);
const posts = await api.getAllPosts(user.id);
Error Handling
import { SmartRequest } from '@push.rocks/smartrequest';
async function fetchWithErrorHandling(url: string) {
try {
const response = await SmartRequest.create()
.url(url)
.timeout(5000)
.retry(2)
.get();
// Check if request was successful
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Handle different content types
const contentType = response.headers['content-type'];
if (contentType?.includes('application/json')) {
return await response.json();
} else if (contentType?.includes('text/')) {
return await response.text();
} else {
return await response.arrayBuffer();
}
} catch (error) {
if (error.code === 'ECONNREFUSED') {
console.error('Connection refused - is the server running?');
} else if (error.code === 'ETIMEDOUT') {
console.error('Request timed out');
} else if (error.name === 'AbortError') {
console.error('Request was aborted');
} else {
console.error('Request failed:', error.message);
}
throw error;
}
}
Migrating from Earlier Versions
Migration from @push.rocks/webrequest
@push.rocks/webrequest is merged into this package as SmartFetch (@push.rocks/smartrequest/fetch) and HttpCache (@push.rocks/smartrequest/cache). Replace the dependency:
pnpm remove @push.rocks/webrequest
pnpm add @push.rocks/smartrequest
// before
import { WebrequestClient, isWebrequestTimeoutError } from '@push.rocks/webrequest';
const client = new WebrequestClient();
await client.postJsonWithMetadata(url, payload, { cacheStrategy: 'cache-first', cacheKey });
await client.deleteCache(cacheKey);
// after
import { SmartFetch, isFetchTimeoutError } from '@push.rocks/smartrequest/fetch';
import { HttpCache } from '@push.rocks/smartrequest/cache';
const httpCache = new HttpCache();
const client = new SmartFetch({ httpCache });
await client.postJsonWithMetadata(url, payload, { cacheStrategy: 'cache-first', cacheKey });
await httpCache.delete(cacheKey);
@push.rocks/webrequest |
@push.rocks/smartrequest |
|---|---|
WebrequestClient |
SmartFetch (/fetch) |
new WebrequestClient(defaults) |
new SmartFetch({ defaults, httpCache, fetch }) |
client.request(input, init) |
client.fetch(input, init) |
client.requestWithMetadata(…) |
client.fetchWithMetadata(…) |
client.getJson / postJson / putJson / deleteJson and their …WithMetadata forms |
same names on SmartFetch |
client.add…Interceptor / remove…Interceptor / clearInterceptors |
same names on SmartFetch |
client.deleteCache(key) / client.clearCache() |
httpCache.delete(key) / httpCache.clear() (/cache) |
client.close() |
httpCache.close(): the cache is the only resource; whoever creates it closes it |
webrequest(input, init) and its static helpers (webrequest.getJson, .postJson, .requestWithMetadata, …) |
dropped: create a SmartFetch and call its methods. A module-level client shared by every importer cannot be configured or closed safely |
webrequest.addRequestInterceptor / addResponseInterceptor / addErrorInterceptor / clearInterceptors (global) |
dropped with the global client; register interceptors on your SmartFetch |
webrequest.createClient(options) |
new SmartFetch({ defaults: options }) |
webrequest.getDefaultClient() / webrequest.clearCache() |
dropped with the global client |
isWebrequestTimeoutError(error) |
isFetchTimeoutError(error), or error instanceof FetchTimeoutError |
IWebrequestOptions |
IFetchRequestOptions |
ICacheOptions |
IFetchCacheOptions |
IRetryOptions |
IFetchRetryOptions (retryOn functions now receive (response | undefined, error | undefined)) |
IInterceptors |
IFetchInterceptors (now also takes per-call error interceptors) |
TCacheStrategy, TBackoffStrategy, TRequestInterceptor, TResponseInterceptor, TErrorInterceptor |
same names (/fetch) |
IWebrequestResponseResult / IWebrequestJsonResult<T> / IWebrequestResponseMetadata |
IFetchResult / IFetchJsonResult<T> / IFetchResponseMetadata |
TStandardCacheMode |
dropped: the standard cache option is the runtime's RequestCache and is passed to fetch unchanged |
TWebrequestResult, IWebrequestSuccess, IWebrequestError |
dropped: no API returned them |
CacheManager, CacheStore, defaultCacheMaxEntries, ICacheStoreOperationOptions, ICacheStoreWriteOptions, ICacheEntry, ICacheMetadata |
internal; HttpCache is the public cache (HttpCache.defaultMaxEntries is 256) |
RetryManager, InterceptorManager, RequestDeduplicator |
internal; configure them through the call options |
extractCacheMetadata, isFresh, requiresRevalidation, createConditionalHeaders, headersToObject, objectToHeaders |
internal; HttpCache applies the HTTP caching rules itself |
Options that changed:
| webrequest option | smartrequest |
|---|---|
no cache option (webrequest stored every successful response, POST included, under network-first) |
no caching: pass cacheStrategy explicitly |
cache: 'force-cache' / 'only-if-cached' / 'default', 'no-cache' (mapped to an IndexedDB strategy) |
cacheStrategy: 'cache-first' / 'cache-only' / 'network-first'; cache itself now goes to the runtime's fetch |
cache: 'no-store' / 'reload' |
leave out cacheStrategy |
cacheMaxEntries (per call) |
new HttpCache({ maxEntries }) |
revalidate |
dropped: webrequest never read it |
fallbackUrls (only honoured together with retry) |
honoured on its own; refused together with a cacheStrategy |
Behaviour fixed on the way:
- A request body is read once and replayed: retries of a POST, fallback URLs and conditional revalidation all send the original body (webrequest retried with the already consumed body, which fails, and revalidated a POST without its body).
- A POST, PUT or DELETE is cached only under an explicit
cacheKey; webrequest keyed it byMETHOD:url, so different bodies shared one entry. cache-firststores only successful responses; webrequest also stored error responses.deduplicateshares only GET and HEAD requests with identical headers, and a caller that aborts is released at once without cancelling the request for the others.- The cache database is
smartrequest-cache. Entries of webrequest'swebrequest-v4database are not read; browsers keep that database (at most 256 entries) until site data is cleared.
From v4.x to v5.x
Version 5.0 completes the transition to modern web standards by removing Node.js-specific streaming APIs:
Breaking Changes
-
.streamNode()Method Removed- The
.streamNode()method has been removed from all response objects - Use the cross-platform
.stream()method instead, which returns a webReadableStream<Uint8Array> - For Node.js users who need Node.js streams, convert using
Readable.fromWeb()
// ❌ Before (v4.x) - Node.js only const response = await SmartRequest.create().url(url).get(); const nodeStream = response.streamNode(); // ✅ After (v5.x) - Cross-platform import { Readable } from 'stream'; const response = await SmartRequest.create().url(url).get(); const webStream = response.stream(); const nodeStream = Readable.fromWeb(webStream); // Convert to Node.js stream - The
-
Request
.raw()Method Removed- The
.raw(streamFunc)method has been removed from the SmartRequest client - Use
.stream()with a webReadableStreaminstead for request body streaming - Node.js users can create web streams from Node.js streams using
Readable.toWeb()
// ❌ Before (v4.x) - Node.js only const response = await SmartRequest.create() .url(url) .raw((request) => { request.write('chunk1'); request.write('chunk2'); request.end(); }) .post(); // ✅ After (v5.x) - Cross-platform const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('chunk1')); controller.enqueue(new TextEncoder().encode('chunk2')); controller.close(); } }); const response = await SmartRequest.create() .url(url) .stream(stream) .post(); // Or convert from Node.js stream (Node.js only) import { Readable } from 'stream'; import * as fs from 'fs'; const nodeStream = fs.createReadStream('file.txt'); const webStream = Readable.toWeb(nodeStream); const response = await SmartRequest.create() .url(url) .stream(webStream) .post(); - The
-
Response
.raw()Method Preserved- The
response.raw()method is still available for accessing platform-specific response objects - Returns
http.IncomingMessagein Node.js orResponsein other runtimes - Use for advanced scenarios requiring access to raw platform objects
// ✅ Still works in v5.x const response = await SmartRequest.create().url(url).get(); const rawResponse = response.raw(); // http.IncomingMessage or Response - The
Migration Guide
For Response Streaming:
// Before (v4.x)
const response = await SmartRequest.create().url(url).get();
const nodeStream = response.streamNode();
nodeStream.on('data', (chunk) => {
console.log(`Received ${chunk.length} bytes`);
});
// After (v5.x) - Option 1: Use web streams directly
const response = await SmartRequest.create().url(url).get();
const webStream = response.stream();
if (webStream) {
const reader = webStream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(`Received ${value.length} bytes`);
}
reader.releaseLock();
}
// After (v5.x) - Option 2: Convert to Node.js stream (Node.js only)
import { Readable } from 'stream';
const response = await SmartRequest.create().url(url).get();
const webStream = response.stream();
const nodeStream = Readable.fromWeb(webStream);
nodeStream.on('data', (chunk) => {
console.log(`Received ${chunk.length} bytes`);
});
For Request Streaming:
Node.js streams are still accepted by the .stream() method and automatically converted internally. No changes required for most use cases:
// ✅ Still works in v5.x
import * as fs from 'fs';
const fileStream = fs.createReadStream('large-file.bin');
const response = await SmartRequest.create()
.url('https://api.example.com/upload')
.stream(fileStream, 'application/octet-stream')
.post();
Benefits:
- ✅ True cross-platform compatibility
- ✅ Modern web standards
- ✅ Cleaner API surface
- ✅ Single streaming approach works everywhere
From v3.x to v4.x
Version 4.0 adds comprehensive cross-platform support:
- Multi-Runtime Support: Now works natively in Node.js, Bun, Deno, and browsers
- Unix Sockets Everywhere: Unix socket support added for Bun and Deno
- Web Streams: Full support for web ReadableStream across all platforms
- Automatic Runtime Detection: No configuration needed - works everywhere automatically
From v2.x to v3.x
Version 3.0 brought significant architectural improvements:
- Legacy API Removed: The function-based API (getJson, postJson, etc.) has been removed. Use SmartRequest instead.
- Unified Response API: All responses now use the same fetch-like interface regardless of platform.
- Stream Changes: The
stream()method now returns a web-style ReadableStream on all platforms. UsestreamNode()for Node.js streams. - Cross-Platform by Default: The library now works in browsers out of the box with automatic platform detection.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
License and Legal Information
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the license file within this repository.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
Company Information
Task Venture Capital GmbH
Registered at District court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.