Installation
No dependencies — built entirely on native Web APIs (fetch, crypto.subtle, ReadableStream). Requires Node.js 18+ or any modern browser.
Quick Start
Import and make requests immediately using the default singleton client.
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.
import { Phtps } from '@sovan_kandar/phtps';
Phtps.get('/api/users');Custom instance (recommended)
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
// Inherits parent config but is a completely independent instance
const authedClient = api.create({
headers: { Authorization: `Bearer ${token}` },
});Instance methods
| Method | Description |
|---|---|
| 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
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()
const response = await api.request<User>({
url: '/users/1',
method: 'GET',
params: { include: 'profile' },
});Streaming
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
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
await api.post('/upload', formData, {
onUploadProgress: ({ loaded, total, progress, rate }) => {
console.log(`${Math.round(progress * 100)}% — ${rate} bytes/s`);
},
});Download progress
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.
| Option | Type | Description |
|---|---|---|
| url | string | Request URL (relative or absolute) |
| baseURL | string | Prepended to relative URLs |
| method | string | HTTP method. Default: 'GET' |
| headers | HeadersInit | Merged with global headers |
| body | any | Auto-serialised to JSON if object |
| params | Record<string, …> | Query string params (null/undefined skipped) |
| signal | AbortSignal | Cancellation signal |
| timeout | number | ms before request aborts. Default: 10000 |
| retries | number | Retry attempts (requires RetryPlugin). Default: 0 |
| retryDelay | number | fn | ms or function. Default: exponential from 1000ms |
| retryCondition | (err) => bool | Custom function to decide if error is retryable |
| useCache | boolean | Enable caching for this request. Default: false |
| cacheTTL | number | Cache lifetime in ms. Default: 60000 |
| cacheAdapter | CacheAdapter | Override cache storage for this request |
| deduplicate | boolean | Deduplicate in-flight GET requests. Default: true |
| queue | boolean | Route through queue. Default: true if QueuePlugin installed |
| encryptionKey | string | Runtime key — never put in env vars |
| encryptPayload | boolean | Encrypt request body. Default: false |
| decryptResponse | boolean | Decrypt response body. Default: false |
| csrf | CsrfConfig | boolean | Enable CSRF protection. Default: false |
| stream | boolean | Return ReadableStream instead of data |
| streamType | 'raw'|'text'|'json'|'sse' | Stream parsing mode |
| onUploadProgress | (event) => void | Upload progress callback |
| onDownloadProgress | (event) => void | Download progress callback |
| paginate | PaginateConfig | boolean | Pagination config (requires PaginationPlugin) |
| payment | PaymentRequestConfig | Per-request payment overrides |
Interceptors
Interceptors run on every request and response — before plugins, before the pipeline.
// 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.
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;
}
});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.
import { RetryPlugin } from 'phtps/plugins';
api.use(RetryPlugin());Per-request config
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.
import { AuthPlugin } from 'phtps/plugins';
api.use(AuthPlugin());Per-request config
await api.get('/api/me', {
// Called on 401 — return 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.
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.
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
await api.get('/api/users', {
useCache: true,
cacheTTL: 300000, // Cache for 5 minutes
});
// Clear manually
api.clearCache();Custom adapter interface
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.
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.
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.
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.
// 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.
import { PaginationPlugin } from 'phtps/plugins';
api.use([PaginationPlugin(), CachePlugin()]);Page-based (default)
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 pagesCursor-based
const { data } = await api.get('/api/feed', {
paginate: {
strategy: 'cursor',
cursorField: 'nextCursor',
limit: 200,
},
});Offset-based
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.
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.
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
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.
// 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
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.
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 / Method | Type | Description |
|---|---|---|
| interceptors.request | IInterceptorManager | Add request interceptors |
| interceptors.response | IInterceptorManager | Add response interceptors |
| useMiddleware(fn) | (Middleware) => void | Add to the middleware pipeline |
| setConfig(config) | (Partial<Config>) => void | Merge config into the client |
| request(config) | Promise<HttpResponse> | Make a request from inside the plugin |
| cacheManager | ICacheManager? | Access cache (if CachePlugin installed) |
| queueManager | IQueueManager? | Access queue (if QueuePlugin installed) |
| tokenRotationManager | TokenRotationManager | Access token rotation |
Error Handling
All errors thrown by Phtps are HttpError objects.
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
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.
import type {
HttpClientConfig,
HttpResponse,
HttpStreamResponse,
HttpError,
HttpProgressEvent,
CacheAdapter,
PhtpsPlugin,
IHttpClient,
PaginateConfig,
TokenRotationConfig,
CsrfConfig,
} from '@sovan_kandar/phtps';Typed responses
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 UserSSR / 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:
// 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.
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.
