jkunz 43ffc6244e
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 0s
Default (tags) / metadata (push) Skipped
v5.6.0
2026-10-01 19:53:22 +00:00
…
2026-10-01 19:53:22 +00:00
2026-10-01 19:53:22 +00:00
…
2026-10-01 19:53:22 +00:00
…

@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 - SmartFetch returns native Response objects, with retries and backoff, URL failover, timeouts, interceptors and deduplication (@push.rocks/smartrequest/fetch)
  • 💾 HTTP Cache - Opt-in persistent caching that honours Cache-Control and ETag (@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 unix option
  • Core Deno - Deno implementation using fetch with unix socket support via HttpClient proxy
  • Core Fetch - Browser implementation using the Fetch API
  • Core - Dynamic runtime detection and implementation selection using @push.rocks/smartenv
  • Client - High-level fluent API for everyday use
  • Fetch - Fetch-compatible SmartFetch client on globalThis.fetch (subpath /fetch)
  • Cache - IndexedDB-backed HttpCache for SmartFetch (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 JSON
  • text(): Promise<string> - Get response as text
  • arrayBuffer(): Promise<ArrayBuffer> - Get response as ArrayBuffer
  • stream(): ReadableStream<Uint8Array> | null - Get web-style ReadableStream (cross-platform)
  • 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 directly

    • data: Buffer (Node.js) or Uint8Array (cross-platform) to send
    • contentType: Optional content type (defaults to 'application/octet-stream')
    • ✅ Works everywhere (Node.js, Bun, Deno, browsers)
  • .stream(stream, contentType?) - Stream from ReadableStream or Node.js stream

    • stream: Web ReadableStream (cross-platform) or Node.js stream (Node.js only)
    • contentType: Optional content type
    • ✅ Web ReadableStream works everywhere (Node.js, Bun, Deno, browsers)
    • ⚠️ Node.js streams only work in Node.js (automatically converted to web streams in Bun/Deno)

These methods are particularly useful for:

  • Uploading large files without loading them into memory
  • Streaming real-time data to servers
  • Proxying data between services
  • Implementing chunked transfer encoding

Unix Socket Support (Node.js, Bun, and Deno)

SmartRequest supports unix sockets across all server-side runtimes with a unified API:

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-After header when present (supports both seconds and HTTP date formats)
  • Uses exponential backoff when no Retry-After header is provided
  • Allows custom callbacks for monitoring rate limit events
  • Caps maximum wait time to prevent excessive delays

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: and https: only (or allowedProtocols: ['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, and 2130706433, 0x7f.1, 0177.0.0.1 and 127.0.0.1. are all 127.0.0.1.
  • Host names: special-use names are refused before any lookup: localhost, .local, .internal, .arpa (including home.arpa), .test, .invalid, .example, .onion, .alt, and single-label names such as intranet. A service that runs in its operator's own network can admit some of them by name: allowSpecialUseDomains: ['internal', 'arpa'] lets mail.corp.internal and mail.home.arpa be looked up, and allowSingleLabelNames: true a name such as intranet. Admitting a name never admits an address: every address it resolves to still has to pass allowAddress, 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 Host header 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, Cookie and Proxy-Authorization are 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 wrong Content-Length nor a compression bomb gets past maxResponseSize (default 5 MiB): decoding stops at the limit. allowedContentTypes (type/subtype or type/*, 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] with node:tls in Node.js and Bun; Deno answers tls.rootCertificates only with the --allow-sys permission.
  • Time: connectTimeout covers TCP and the TLS handshake (default 10 s), responseTimeout the wait for the response headers (default 15 s), and totalTimeout everything, including redirects and the body (default 30 s). .timeout(ms) and an AbortSignal passed 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 (as maxRedirects: 0 does), redirect: 'manual' returns the redirect response without following it.
  • The response is a standard Response (a PublicOnlyFetchResponse) with url (the final URL), redirected, redirects and remoteAddress. The body is decoded (gzip, deflate, br); it is null for HEAD and for 204, 205 and 304.
  • Limits while the body streams: maxResponseSize counts the body as it arrives and after each decoding step, and totalTimeout covers the whole request including the body. A limit reached while the body is read errors the stream with a PublicOnlyError. The connection is released when the body was read, cancelled (response.body.cancel()) or failed; a body nobody reads holds its connection until totalTimeout.
  • Errors: an abort through init.signal rejects, or errors the body, with the signal's reason, as fetch does. Arguments the Fetch standard refuses reject with a TypeError, as in fetch: a body with GET or HEAD, an unknown redirect mode, and what the Request and Headers constructors and the body extraction refuse. Every other refusal and every other failure is a PublicOnlyError with the codes above, including a response the runtime's Response cannot represent (response_failed, for example a status text with a control character; its connection is closed). An invalid configuration throws a plain Error when createPublicOnlyFetch is called.
  • Arguments: the arguments of fetch (a URL string, a URL or a Request, and a RequestInit). A body other than a ReadableStream (or a Request'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) and integrity are refused with a PublicOnlyError (option_not_allowed), a Host header with header_not_allowed; the browser options (mode, credentials, cache, referrer, referrerPolicy, keepalive, priority) are ignored, as the fetch of 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.example becomes xn--bcher-kva.example) and must not be special-use or single-label (unless allowSpecialUseDomains or allowSingleLabelNames admits it, as for requests), and every address it resolves to must be public: one private address refuses the host.
  • Pinned: lookup answers only for target.host and only with the checked addresses, so a second DNS answer cannot change where the socket goes. Pass host and lookup together, to net.connect, tls.connect or a library that hands these options on to them. An IP literal needs no lookup: connect to target.host.
  • TLS: servername is the host name without a trailing dot; it is sent as SNI and the certificate is verified for it. Node.js verifies the certificate against servername whenever it is given, also when host is an IP address; without it, against host. An IP literal target has no servername.
  • Errors: every refusal and failure is a PublicOnlyError whose url is host:port: port_not_allowed, host_not_allowed, address_not_public, dns_failed (also when the resolver gives no answer within resolveTimeout, default 10 seconds) and aborted (through signal). An invalid configuration rejects with a plain Error. resolve and allowAddress work 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, a Host header) are refused with a PublicOnlyError (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 with deduplicate: true and the same method, URL and headers attach to it.
  • A joining call inherits every option of the first call, not only timeout, retry and interceptors but also cacheStrategy, redirect and credentials; of its own options only its signal applies. A 'network-only' joiner can receive a response with fromCache: true. Disable deduplicate when 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 Response it 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 follows Cache-Control: max-age, immutable and Expires; cacheMaxAge (milliseconds) caps it.
  • GET requests use their URL as key. Every other method needs cacheKey (a string or a function of the Request); without one the call is refused with a TypeError.
  • The key is the URL (or the explicit cacheKey) and nothing else: request headers such as Authorization, Vary, Cache-Control: private and Set-Cookie are not taken into account. Share an HttpCache (or its dbName) only between callers that may see each other's responses, or give per-user requests an explicit cacheKey.
  • Caches created with the same dbName share their entries. Every write prunes expired entries and then the oldest down to maxEntries.
  • Browsers persist the cache in IndexedDB. Node.js, Deno and Bun have no IndexedDB, so @push.rocks/webstore installs an in-memory one (fake-indexeddb) as globalThis.indexedDB: there the cache lives as long as the process. The cache is tested in Node.js and Chromium.
  • fallbackUrls cannot 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 by METHOD:url, so different bodies shared one entry.
  • cache-first stores only successful responses; webrequest also stored error responses.
  • deduplicate shares 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's webrequest-v4 database 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

  1. .streamNode() Method Removed

    • The .streamNode() method has been removed from all response objects
    • Use the cross-platform .stream() method instead, which returns a web ReadableStream<Uint8Array>
    • For Node.js users who need Node.js streams, convert using Readable.fromWeb()
    // ❌ Before (v4.x) - Node.js only
    const response = await SmartRequest.create().url(url).get();
    const nodeStream = response.streamNode();
    
    // ✅ After (v5.x) - Cross-platform
    import { Readable } from 'stream';
    
    const response = await SmartRequest.create().url(url).get();
    const webStream = response.stream();
    const nodeStream = Readable.fromWeb(webStream); // Convert to Node.js stream
    
  2. Request .raw() Method Removed

    • The .raw(streamFunc) method has been removed from the SmartRequest client
    • Use .stream() with a web ReadableStream instead for request body streaming
    • Node.js users can create web streams from Node.js streams using Readable.toWeb()
    // ❌ Before (v4.x) - Node.js only
    const response = await SmartRequest.create()
      .url(url)
      .raw((request) => {
        request.write('chunk1');
        request.write('chunk2');
        request.end();
      })
      .post();
    
    // ✅ After (v5.x) - Cross-platform
    const stream = new ReadableStream({
      start(controller) {
        controller.enqueue(new TextEncoder().encode('chunk1'));
        controller.enqueue(new TextEncoder().encode('chunk2'));
        controller.close();
      }
    });
    
    const response = await SmartRequest.create()
      .url(url)
      .stream(stream)
      .post();
    
    // Or convert from Node.js stream (Node.js only)
    import { Readable } from 'stream';
    import * as fs from 'fs';
    
    const nodeStream = fs.createReadStream('file.txt');
    const webStream = Readable.toWeb(nodeStream);
    
    const response = await SmartRequest.create()
      .url(url)
      .stream(webStream)
      .post();
    
  3. Response .raw() Method Preserved

    • The response.raw() method is still available for accessing platform-specific response objects
    • Returns http.IncomingMessage in Node.js or Response in other runtimes
    • Use for advanced scenarios requiring access to raw platform objects
    // ✅ Still works in v5.x
    const response = await SmartRequest.create().url(url).get();
    const rawResponse = response.raw(); // http.IncomingMessage or Response
    

Migration Guide

For Response Streaming:

// 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:

  1. Multi-Runtime Support: Now works natively in Node.js, Bun, Deno, and browsers
  2. Unix Sockets Everywhere: Unix socket support added for Bun and Deno
  3. Web Streams: Full support for web ReadableStream across all platforms
  4. Automatic Runtime Detection: No configuration needed - works everywhere automatically

From v2.x to v3.x

Version 3.0 brought significant architectural improvements:

  1. Legacy API Removed: The function-based API (getJson, postJson, etc.) has been removed. Use SmartRequest instead.
  2. Unified Response API: All responses now use the same fetch-like interface regardless of platform.
  3. Stream Changes: The stream() method now returns a web-style ReadableStream on all platforms. Use streamNode() for Node.js streams.
  4. Cross-Platform by Default: The library now works in browsers out of the box with automatic platform detection.

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.

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.

S
Description
A module for modern HTTP/HTTPS requests with support for form data, file uploads, JSON, binary data, streams, and more.
Readme
3.9 MiB
Languages
TypeScript 100%