Phtps
/Docs
Documentation

Phtps Docs

A modern, framework-agnostic HTTP client built on the native Fetch API. Zero dependencies. Plugin-first architecture.

npm install @pings/phtps

Installation

No dependencies — built entirely on native Web APIs (fetch, crypto.subtle, ReadableStream). Requires Node.js 18+ or any modern browser.

npm install @pings/phtps
pnpm add @pings/phtps
yarn add @pings/phtps

Quick Start

Import and make requests immediately using the default singleton client.

ts
import { Phtps } from '@sovan_kandar/phtps';

// GET
const { data } = await Phtps.get('/api/users');

// POST
const { data } = await Phtps.post('/api/users', { name: 'Alice' });

// With full config
const { data } = await Phtps.get('/api/users', {
  baseURL: 'https://api.example.com',
  timeout: 5000,
  params: { page: 1, limit: 20 },
});

Core Concepts

Phtps is plugin-first. The core client handles only HTTP — no retry logic, no caching, no auth in the base. Every feature is a plugin you opt into.

  • Users who only need fetch + timeout pay zero cost for retry or pagination code.
  • Features are composable and independently testable.
  • You can write your own plugin with the same interface.
  • Plugin install order matters — see the Plugin Install Order section.

Creating a Client

Singleton (browser apps)

The default singleton is locked — you cannot mutate its global headers. Use it for quick requests in browser-only apps.

ts
import { Phtps } from '@sovan_kandar/phtps';

Phtps.get('/api/users');

Custom instance (recommended)

ts
import { createHttpClient } from '@sovan_kandar/phtps';

const api = createHttpClient({
  baseURL: 'https://api.example.com',
  timeout: 10000,
  headers: { 'X-App-Version': '2.0' },
});

export default api;

create() — per-request child instance

ts
// Inherits parent config but is a completely independent instance
const authedClient = api.create({
  headers: { Authorization: `Bearer ${token}` },
});

Instance methods

MethodDescription
use(plugin)Install one or more plugins
setConfig(config)Merge new config into the instance
setGlobalHeader(key, value)Set a header on every request
removeGlobalHeader(key)Remove a global header
clearCache()Clear the cache (requires CachePlugin)
destroy()Tear down timers, interceptors, queue
lock()Prevent further mutations (for singletons)
create(config?)Create a child instance inheriting this config

Making Requests

Shorthand methods

ts
api.get<User[]>('/users')
api.post<User>('/users', { name: 'Alice' })
api.put<User>('/users/1', { name: 'Alice' })
api.patch<User>('/users/1', { name: 'Alice' })
api.delete('/users/1')

Generic request()

ts
const response = await api.request<User>({
  url: '/users/1',
  method: 'GET',
  params: { include: 'profile' },
});

Streaming

ts
const stream = await api.stream('/api/events', {
  streamType: 'sse', // 'raw' | 'text' | 'json' | 'sse'
});

const reader = stream.data.getReader();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  console.log(value); // parsed SSE event object
}

stream.cancel();

Cancellation

ts
const controller = new AbortController();

api.get('/api/users', { signal: controller.signal })
  .catch(err => {
    if (err.isCancel) console.log('Request was cancelled');
  });

controller.abort();

Upload progress

ts
await api.post('/upload', formData, {
  onUploadProgress: ({ loaded, total, progress, rate }) => {
    console.log(`${Math.round(progress * 100)}% — ${rate} bytes/s`);
  },
});

Download progress

ts
await api.get('/large-file', {
  onDownloadProgress: ({ loaded, total, progress }) => {
    console.log(`Downloaded ${loaded} of ${total} bytes`);
  },
});

Config Reference

Every request method accepts these options. All are optional.

OptionTypeDescription
urlstringRequest URL (relative or absolute)
baseURLstringPrepended to relative URLs
methodstringHTTP method. Default: 'GET'
headersHeadersInitMerged with global headers
bodyanyAuto-serialised to JSON if object
paramsRecord<string, …>Query string params (null/undefined skipped)
signalAbortSignalCancellation signal
timeoutnumberms before request aborts. Default: 10000
retriesnumberRetry attempts (requires RetryPlugin). Default: 0
retryDelaynumber | fnms or function. Default: exponential from 1000ms
retryCondition(err) => boolCustom function to decide if error is retryable
useCachebooleanEnable caching for this request. Default: false
cacheTTLnumberCache lifetime in ms. Default: 60000
cacheAdapterCacheAdapterOverride cache storage for this request
deduplicatebooleanDeduplicate in-flight GET requests. Default: true
queuebooleanRoute through queue. Default: true if QueuePlugin installed
encryptionKeystringRuntime key — never put in env vars
encryptPayloadbooleanEncrypt request body. Default: false
decryptResponsebooleanDecrypt response body. Default: false
csrfCsrfConfig | booleanEnable CSRF protection. Default: false
streambooleanReturn ReadableStream instead of data
streamType'raw'|'text'|'json'|'sse'Stream parsing mode
onUploadProgress(event) => voidUpload progress callback
onDownloadProgress(event) => voidDownload progress callback
paginatePaginateConfig | booleanPagination config (requires PaginationPlugin)
paymentPaymentRequestConfigPer-request payment overrides

Interceptors

Interceptors run on every request and response — before plugins, before the pipeline.

ts
// Request interceptor — modify config before request fires
api.interceptors.request.use(
  (config) => {
    config.headers.set('X-Request-ID', crypto.randomUUID());
    return config;
  },
  (error) => Promise.reject(error)
);

// Response interceptor — transform response or handle errors globally
api.interceptors.response.use(
  (response) => {
    console.log(`${response.status} — ${response.config.url}`);
    return response;
  },
  (error) => {
    if (error.response?.status === 403) {
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

// Remove an interceptor
const id = api.interceptors.request.use(fn);
api.interceptors.request.eject(id);

Middleware

Middleware wraps the entire request pipeline — including queue, dedup, and cache. Use it for cross-cutting concerns that need to run around the actual network call.

ts
api.useMiddleware(async (config, next) => {
  const start = Date.now();
  try {
    const response = await next(config);
    console.log(`${config.url} — ${Date.now() - start}ms`);
    return response;
  } catch (error) {
    console.error(`${config.url} failed in ${Date.now() - start}ms`);
    throw error;
  }
});
Plugins

Install plugins at once with api.use([RetryPlugin(), AuthPlugin(), CachePlugin()])

RetryPluginPlugin

Automatic retry with exponential backoff and jitter. Never retries 401s (those belong to AuthPlugin) or cancelled requests.

ts
import { RetryPlugin } from 'phtps/plugins';

api.use(RetryPlugin());

Per-request config

ts
await api.get('/api/data', {
  retries: 3,          // Try up to 3 more times after first failure
  retryDelay: 1000,    // Base delay in ms. Doubles each attempt (1s, 2s, 4s ± jitter)

  // Custom delay function — 1-indexed attempt number
  retryDelay: (attempt, error) => attempt * 2000,

  // Custom condition — default: retry on 5xx and network errors
  retryCondition: (error) => error.response?.status === 429,
});
  • Retries on: network error, 5xx status codes
  • Never retries: 4xx errors (except via custom retryCondition), cancellations, 401
  • Delay: exponential backoff starting at retryDelay ms ± 20% jitter

AuthPluginPlugin

Handles token refresh on 401 responses. Queues all concurrent requests while refresh is in flight — onTokenRefresh is called exactly once no matter how many requests 401 simultaneously.

ts
import { AuthPlugin } from 'phtps/plugins';

api.use(AuthPlugin());

Per-request config

ts
await api.get('/api/me', {
  // Called on 401return the new token string
  onTokenRefresh: async () => {
    const { token } = await refreshTokenRequest();
    return token;
  },
  // Called if onTokenRefresh throws
  onRefreshFailure: (error) => {
    localStorage.removeItem('token');
    window.location.href = '/login';
  },
});

Proactive token rotation

Refresh the token before it expires, not after a 401.

ts
api.setConfig({
  tokenRotation: {
    enabled: true,
    getToken: () => localStorage.getItem('token') ?? undefined,
    getExpiration: (token) => {
      const { exp } = JSON.parse(atob(token.split('.')[1]));
      return exp * 1000; // convert to ms epoch
    },
    onRefresh: async () => {
      const { token } = await refreshTokenRequest();
      localStorage.setItem('token', token);
      return token;
    },
    refreshWindow: 60000,         // Refresh 60s before expiry
    autoRefreshBackground: true,  // Start a background timer
    headerName: 'Authorization',
    headerPrefix: 'Bearer ',
  },
});

CachePluginPlugin

Caches GET responses in memory (or a custom adapter). Cache is keyed by METHOD:fullURL.

ts
import { CachePlugin } from 'phtps/plugins';
import { LocalStorageCacheAdapter } from '@sovan_kandar/phtps';

// In-memory (default)
api.use(CachePlugin());

// Persistent — survives page reload
api.use(CachePlugin({
  adapter: new LocalStorageCacheAdapter(),
}));

Per-request config

ts
await api.get('/api/users', {
  useCache: true,
  cacheTTL: 300000,   // Cache for 5 minutes
});

// Clear manually
api.clearCache();

Custom adapter interface

ts
interface CacheAdapter {
  get(key: string): any | Promise<any>;
  set(key: string, value: any, ttl: number): void | Promise<void>;
  delete(key: string): void | Promise<void>;
  clear(): void | Promise<void>;
}

// Example: Redis adapter
api.use(CachePlugin({ adapter: new RedisAdapter(redisClient) }));

DedupePluginPlugin

Deduplicates in-flight GET requests. If two identical requests fire at the same moment, only one network call is made — both callers get the same response.

ts
import { DedupePlugin } from 'phtps/plugins';

api.use(DedupePlugin());

// Opt out per request
await api.get('/api/user', { deduplicate: false });

Deduplication is built into the core client for GET requests by default. In most apps you do not need to install DedupePlugin explicitly — deduplicate: true (the default) already works.

QueuePluginPlugin

Limits how many requests run concurrently. Useful for rate-limited APIs or preventing request storms.

ts
import { QueuePlugin } from 'phtps/plugins';

api.use(QueuePlugin({ concurrency: 3 }));

// Control the queue at runtime
const queue = api.queueManager;

queue.pause();             // Stop new tasks from starting
queue.resume();            // Resume the queue
queue.setConcurrency(5);   // Change the limit live
queue.clear();             // Reject all queued tasks immediately

// Bypass the queue for a single request
await api.get('/api/urgent', { queue: false });

EncryptionPluginPlugin

Encrypts request bodies and decrypts responses automatically using AES-GCM with PBKDF2-SHA256 key derivation (100,000 iterations, random salt per call). Built on the native Web Crypto API.

ts
import { EncryptionPlugin } from 'phtps/plugins';

api.use(EncryptionPlugin());

// Per-request
await api.post('/api/sensitive', { ssn: '123-45-6789' }, {
  encryptionKey: runtimeKey,  // Never hardcode — inject at runtime
  encryptPayload: true,
  decryptResponse: true,
});

Always pass encryptionKey at runtime (from sessionStorage, a user-entered passphrase, or a server-issued key). Never put it in .env files that get bundled.

  • Request: body is encrypted → { data: '<base64>' }, header X-Phtps-Encrypted: true added
  • Response: if server sets X-Phtps-Encrypted: true, the { data } field is decrypted automatically
  • Key is stripped from all error objects — never leaks in logs

CsrfPluginPlugin

CSRF protection is built into the core client. Configure per-request or globally.

ts
// Per-request
await api.post('/api/action', data, {
  csrf: {
    enabled: true,
    cookieName: 'csrf-token',
    headerName: 'X-CSRF-Token',
    methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
    strict: true,
    token: () => getCsrfToken(),
    onTokenMissing: () => fetchNewCsrfToken(),
  },
});

// Global
const api = createHttpClient({
  csrf: {
    enabled: true,
    cookieName: 'XSRF-TOKEN',    // Same default as Axios
    headerName: 'X-XSRF-TOKEN',
  },
});

PaginationPluginPlugin

Fetches all pages of a paginated API automatically. Supports page-based, cursor-based, and offset-based pagination.

ts
import { PaginationPlugin } from 'phtps/plugins';

api.use([PaginationPlugin(), CachePlugin()]);

Page-based (default)

ts
const { data } = await api.get('/api/posts', {
  paginate: {
    strategy: 'page',
    limit: 100,       // Stop after 100 total items
    pageSize: 20,     // Items per page
  },
});
// data is the merged array of all items across all pages

Cursor-based

ts
const { data } = await api.get('/api/feed', {
  paginate: {
    strategy: 'cursor',
    cursorField: 'nextCursor',
    limit: 200,
  },
});

Offset-based

ts
const { data } = await api.get('/api/logs', {
  paginate: {
    strategy: 'offset',
    pageSize: 50,
    limit: 500,
  },
});

Prefetch mode

Loads upcoming pages into cache silently while the user reads the current page.

ts
const { data: firstPage } = await api.get('/api/posts', {
  useCache: true,
  paginate: {
    mode: 'prefetch',
    strategy: 'page',
    limit: 3,
    prefetchStrategy: 'idle',  // 'immediate' | 'adaptive' | 'idle'
    maxPrefetchQueue: 2,
  },
});

PaymentPluginPlugin

Adds payment-grade security: HMAC-SHA256 request signing, idempotency keys, timestamp headers, rate limiting, and sensitive data masking on errors.

ts
import { PaymentPlugin } from 'phtps/plugins';

const paymentClient = createHttpClient({
  baseURL: 'https://payments.example.com',
});

paymentClient.use(PaymentPlugin({
  environment: 'production',
  secretKey: getRuntimeKey(),   // Inject at runtime
  signRequests: true,           // HMAC-SHA256 signing
  idempotency: true,            // Auto-generate Idempotency-Key
  timestamp: true,              // Replay-attack prevention
  maskSensitiveData: true,      // Strip CVV, cardNumber from errors
  rateLimit: {
    maxRequests: 10,
    windowMs: 60000,
  },
}));

Per-request overrides

ts
await paymentClient.post('/charge', payload, {
  payment: {
    idempotencyKey: 'order-abc-123',  // Your own key
    skipRateLimit: true,
  },
});

// Custom signer (AWS SigV4, etc.)
paymentClient.use(PaymentPlugin({
  customSigner: async (payload, timestamp) => {
    return await myAwsSigner(payload, timestamp);
  },
}));

Streaming

First-class streaming support via the native ReadableStream API.

ts
// SSE — used by OpenAI, Anthropic, Gemini
const stream = await api.stream('/api/chat', {
  method: 'POST',
  body: { messages },
  streamType: 'sse',
});

for await (const event of streamToAsyncIterator(stream.data)) {
  console.log(event.data);
}
stream.cancel();

// NDJSON — newline-delimited JSON
const stream = await api.stream('/api/events', { streamType: 'json' });

// Raw bytes
const stream = await api.stream('/api/file', { streamType: 'raw' });

// Text chunks
const stream = await api.stream('/api/log', { streamType: 'text' });

Async iterator helper

ts
async function* streamToAsyncIterator<T>(stream: ReadableStream<T>) {
  const reader = stream.getReader();
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) return;
      yield value;
    }
  } finally {
    reader.releaseLock();
  }
}

Writing Your Own Plugin

Any object with a name string and an install(client) function is a valid plugin.

ts
import { PhtpsPlugin, IHttpClient } from '@sovan_kandar/phtps';

const LoggerPlugin = (): PhtpsPlugin => ({
  name: 'logger',
  install: (client: IHttpClient) => {
    client.interceptors.request.use((config) => {
      console.log(`→ ${config.method} ${config.url}`);
      return config;
    });

    client.interceptors.response.use(
      (response) => {
        console.log(`← ${response.status} ${response.config.url}`);
        return response;
      },
      (error) => {
        console.error(`✗ ${error.response?.status} ${error.config?.url}`);
        return Promise.reject(error);
      }
    );
  },
});

api.use(LoggerPlugin());

Available on IHttpClient inside install()

Property / MethodTypeDescription
interceptors.requestIInterceptorManagerAdd request interceptors
interceptors.responseIInterceptorManagerAdd response interceptors
useMiddleware(fn)(Middleware) => voidAdd to the middleware pipeline
setConfig(config)(Partial<Config>) => voidMerge config into the client
request(config)Promise<HttpResponse>Make a request from inside the plugin
cacheManagerICacheManager?Access cache (if CachePlugin installed)
queueManagerIQueueManager?Access queue (if QueuePlugin installed)
tokenRotationManagerTokenRotationManagerAccess token rotation

Error Handling

All errors thrown by Phtps are HttpError objects.

ts
import { HttpError } from '@sovan_kandar/phtps';

try {
  await api.get('/api/users');
} catch (error: unknown) {
  const err = error as HttpError;

  if (err.isCancel) {
    // Request was cancelled via AbortSignal
  }

  if (err.isTimeout) {
    // Request exceeded the timeout
  }

  if (err.response) {
    // Server responded with a non-2xx status
    console.log(err.response.status);   // e.g. 404
    console.log(err.response.data);     // Response body
    console.log(err.response.headers);  // Response headers
  }

  if (!err.response) {
    console.log('Network error:', err.message);
  }
}

HttpError shape

ts
interface HttpError extends Error {
  response?: HttpResponse;      // Set for server errors (4xx, 5xx)
  config?: HttpClientConfig;    // The config that triggered the error
  isCancel?: boolean;           // true when cancelled via AbortSignal
  isTimeout?: boolean;          // true when timeout was exceeded
}

TypeScript

All types are exported from the main package.

ts
import type {
  HttpClientConfig,
  HttpResponse,
  HttpStreamResponse,
  HttpError,
  HttpProgressEvent,
  CacheAdapter,
  PhtpsPlugin,
  IHttpClient,
  PaginateConfig,
  TokenRotationConfig,
  CsrfConfig,
} from '@sovan_kandar/phtps';

Typed responses

ts
interface User {
  id: number;
  name: string;
  email: string;
}

const { data } = await api.get<User[]>('/api/users');
// data is User[]

const { data } = await api.post<User>('/api/users', { name: 'Alice' });
// data is User

SSR / Next.js / Node.js

Never use the Phtps singleton in server-side code. The singleton is locked and intentionally unusable for mutation in SSR to prevent state bleed between requests.

Always create a per-request client in server contexts:

ts
// app/api/users/route.ts (Next.js App Router)
import { createHttpClient } from '@sovan_kandar/phtps';
import { AuthPlugin } from 'phtps/plugins';

export async function GET(request: Request) {
  const token = request.headers.get('Authorization');

  // Fresh client per request — no shared state
  const api = createHttpClient({
    baseURL: process.env.INTERNAL_API_URL,
    headers: { Authorization: token ?? '' },
  });

  const { data } = await api.get<User[]>('/users');
  return Response.json(data);
}

In Next.js middleware or edge runtimes, the Web Crypto API is available natively. Phtps works without any polyfills.

Plugin Install Order

The order you call use() determines execution order.

ts
api.use([
  QueuePlugin({ concurrency: 5 }),  // 1. Outermost — controls concurrency
  RetryPlugin(),                     // 2. Wraps the actual request to retry
  AuthPlugin(),                      // 3. Intercepts 401 after retry gives up
  CachePlugin(),                     // 4. Caches successful responses
  DedupePlugin(),                    // 5. Deduplicates before cache check
  EncryptionPlugin(),                // 6. Encrypts before send, decrypts after
  PaginationPlugin(),                // 7. Drives pagination loops
  CsrfPlugin(),                      // 8. Adds CSRF token to headers
  PaymentPlugin({ secretKey }),      // 9. Payment-specific headers last
]);
  • QueuePlugin at the top means queuing happens before retries — a retry does not create a new queue slot.
  • RetryPlugin wraps AuthPlugin so retries on 5xx work independently of auth refresh.
  • CachePlugin after retry means only successful responses are cached.
  • EncryptionPlugin runs close to the wire so encrypted data is not passed to pagination logic.

Ready to start building?

All features ship in one package. Zero dependencies.