Skip to content
25 changes: 25 additions & 0 deletions .socket.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Socket.dev Configuration
# https://docs.socket.dev/docs/socket-yml
version: 2

# Exclude build and coverage outputs from Socket analysis
projectIgnorePaths:
- "dist/**"
- "coverage/**"
- "test/**"

# Only trigger pull request alerts when package dependencies change
triggerPaths:
- "package.json"
- "package-lock.json"

# Configure issue rules
issueRules:
# react-native-fetch-client is an HTTP client library whose core purpose is network access via WHATWG fetch
networkAccess: false

# Socket GitHub App settings
githubApp:
enabled: true
dependencyOverviewEnabled: true

15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ A lightweight, Fetch-native HTTP client for TypeScript and JavaScript across Rea
[![npm version](https://img.shields.io/npm/v/react-native-fetch-client.svg)](https://www.npmjs.com/package/react-native-fetch-client)
[![CI](https://github.com/arunkumarArjun/react-native-fetch-client/actions/workflows/ci.yml/badge.svg)](https://github.com/arunkumarArjun/react-native-fetch-client/actions/workflows/ci.yml)
[![Socket Security](https://badge.socket.dev/npm/package/react-native-fetch-client)](https://socket.dev/npm/package/react-native-fetch-client)
[![Bundle Size](https://img.shields.io/badge/bundle%20size-12.8%20KB%20min%2Bgzip-brightgreen.svg)](https://bundlephobia.com/package/react-native-fetch-client)
[![Bundle Size](https://img.shields.io/badge/bundle%20size-12.67%20KB%20min%2Bgzip-brightgreen.svg)](https://bundlephobia.com/package/react-native-fetch-client)
[![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](package.json)
[![Tests](https://img.shields.io/badge/tests-301%20passed-brightgreen.svg)](test)
[![Tests](https://img.shields.io/badge/tests-304%20passed-brightgreen.svg)](test)
[![Documentation](https://img.shields.io/badge/docs-interactive%20site-blue.svg)](https://react-native-fetch-client.vercel.app/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/)
Expand All @@ -19,7 +19,7 @@ A lightweight, Fetch-native HTTP client for TypeScript and JavaScript across Rea
| Feature | `react-native-fetch-client` | Axios |
| :--- | :--- | :--- |
| **Runtime Dependencies** | **0** (`dependencies: {}`) | 4 dependencies (`proxy-agent`, `redirects`, etc.) |
| **Minified Core Bundle** | **38.5 KB** (~12.8 KB gzip) | ~75 KB |
| **Minified Core Bundle** | **38.08 KB** (~12.67 KB gzip) | ~75 KB |
| **Mobile Network Engine**| Native WHATWG `fetch` (Hermes C++ JNI) | Legacy `XMLHttpRequest` (XHR bridge) |
| **401 Token Refresh** | **Built-in Atomic Mutex** (zero race conditions) | Manual race-prone interceptor boilerplate |
| **Caching & Deduplication** | **Built-in SWR Cache (0ms)** & In-Flight Dedup | Requires 3rd-party plugins |
Expand Down Expand Up @@ -646,9 +646,14 @@ try {

For release history, migration notes, and version highlights, see [CHANGELOG.md](CHANGELOG.md).

## Security
## Security & Supply Chain

For vulnerability disclosure and security guidance, see [SECURITY.md](SECURITY.md).
`react-native-fetch-client` is designed from the ground up for strict supply-chain security:
- **Zero Runtime Dependencies**: `"dependencies": {}` completely eliminates third-party transitive vulnerabilities.
- **Socket.dev Clearance**: Static security scanners flag the `networkAccess` capability as expected behavior for this HTTP client (dispatched exclusively via `globalThis.fetch` on caller invocation). Zero background telemetry or data collection.
- **Automatic Sanitization**: Auth headers and credentials are automatically redacted in error logs and stripped from offline queues on disk.

For complete vulnerability reporting and security policies, see [SECURITY.md](SECURITY.md).

## Contributing

Expand Down
15 changes: 15 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ This keeps the report private, encrypted, and accessible only to the project mai

---

## Supply Chain & Scanner Policy (Socket.dev)

### Network Access Capability (`networkAccess`)

Static security scanners such as [Socket.dev](https://socket.dev/npm/package/react-native-fetch-client) flag the `networkAccess` capability for `react-native-fetch-client` because the library resolves and executes `globalThis.fetch`.

- **Intended Capability**: As an HTTP client library, network communication via the WHATWG `fetch` API is the core purpose of `react-native-fetch-client`.
- **Zero Background Telemetry**: The library contains **zero telemetry, tracking pings, metrics collection, or covert network requests**. Outbound requests are only dispatched when the consuming application explicitly calls client methods (`.get()`, `.post()`, `.request()`, etc.).
- **Zero Supply Chain Dependencies**: The library maintains strictly zero production dependencies (`"dependencies": {}`), eliminating third-party transitive supply chain risks.
- **Zero Install Scripts**: The package contains no `postinstall`, `preinstall`, or arbitrary lifecycle scripts.

If your CI/CD pipeline enforces Socket.dev checks, configure `.socket.yml` to acknowledge `networkAccess: false` for this repository.

---

## Network Security & VAPT Guidance

### Sensitive Data Protection & Proxy Defenses
Expand Down
25 changes: 25 additions & 0 deletions socket.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Socket.dev Configuration
# https://docs.socket.dev/docs/socket-yml
version: 2

# Exclude build and coverage outputs from Socket analysis
projectIgnorePaths:
- "dist/**"
- "coverage/**"
- "test/**"

# Only trigger pull request alerts when package dependencies change
triggerPaths:
- "package.json"
- "package-lock.json"

# Configure issue rules
issueRules:
# react-native-fetch-client is an HTTP client library whose core purpose is network access via WHATWG fetch
networkAccess: false

# Socket GitHub App settings
githubApp:
enabled: true
dependencyOverviewEnabled: true

88 changes: 45 additions & 43 deletions src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,29 @@ import type {
HttpMethod,
HttpResponse,
RequestCacheConfig,
ResolvedRequestConfig,
ResponseCache,
} from './types.js';
import { isAbsoluteUrl } from './url.js';
import { sortQueryString } from './url.js';
import { hashAuthHeader } from './hash.js';

const DUMMY_HYDRATE_CONFIG: ResolvedRequestConfig = {
url: '',
method: 'GET',
headers: {},
timeout: 0,
responseType: 'json',
retry: {
attempts: 0,
backoff: 'exponential',
delay: 1000,
maxDelay: 30000,
jitter: true,
retryOn: [429, 500, 502, 503, 504],
safeMethods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'],
respectRetryAfter: true,
},
};

/** Internal cache entry storing the cloned response and expiration timestamp. */
export interface CacheEntry<T = unknown> {
Expand Down Expand Up @@ -53,19 +73,6 @@ export function cloneData<T>(data: T): T {
}
}

/**
* Deterministically hashes an authorization header to an opaque hex digest.
* Prevents raw authentication tokens and credentials from leaking into cache keys,
* debug logs, or unencrypted persistent storage.
*/
function hashAuthHeader(str: string): string {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) ^ str.charCodeAt(i);
}
return (hash >>> 0).toString(16);
}

/**
* Generates a cache key for a request.
* Normalizes URL and includes authorization header hash if authenticated caching is allowed,
Expand All @@ -79,11 +86,8 @@ export function generateCacheKey(
responseType?: string,
): string {
const normMethod = method.toUpperCase();
const isRelative = !isAbsoluteUrl(url);
const urlObj = new URL(url, 'http://localhost');
urlObj.searchParams.sort();
const pathWithSearch = urlObj.pathname + (urlObj.search ? urlObj.search : '');
const sortedUrl = isRelative ? pathWithSearch : `${urlObj.origin}${pathWithSearch}`;

const sortedUrl = sortQueryString(url);

const resType = responseType || 'json';
const typePart = resType !== 'json' ? `|type:${resType}` : '';
Expand Down Expand Up @@ -174,6 +178,7 @@ export class CacheManager implements ResponseCache {
public readonly storage?: CacheStorageAdapter;
public readonly storageKey: string;
public readonly defaultStrategy: 'cache-first' | 'stale-while-revalidate';
public readonly clone: boolean;
private hydrationPromise: Promise<void> | null = null;

constructor(config?: CacheConfig) {
Expand All @@ -184,6 +189,7 @@ export class CacheManager implements ResponseCache {
}
this.storageKey = config?.storageKey ?? 'rnfc_http_cache';
this.defaultStrategy = config?.strategy ?? config?.defaultStrategy ?? 'cache-first';
this.clone = config?.clone !== false;

// Auto-hydrate if persistent storage is provided
if (this.storage) {
Expand Down Expand Up @@ -223,23 +229,7 @@ export class CacheManager implements ResponseCache {
status: rawEntry.status,
statusText: rawEntry.statusText,
headers: rawEntry.headers,
config: {
url: '',
method: 'GET',
headers: {},
timeout: 0,
responseType: 'json',
retry: {
attempts: 0,
backoff: 'exponential',
delay: 1000,
maxDelay: 30000,
jitter: true,
retryOn: [429, 500, 502, 503, 504],
safeMethods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'],
respectRetryAfter: true,
},
},
config: DUMMY_HYDRATE_CONFIG,
cached: true,
fromDedup: false,
request: (typeof Request !== 'undefined'
Expand Down Expand Up @@ -308,7 +298,7 @@ export class CacheManager implements ResponseCache {
*/
public get<T = unknown>(
key: string,
options?: { allowStale?: boolean },
options?: { allowStale?: boolean | undefined; clone?: boolean | undefined },
): HttpResponse<T> | undefined {
const entry = this.store.get(key) as CacheEntry<T> | undefined;
if (!entry) {
Expand All @@ -326,10 +316,12 @@ export class CacheManager implements ResponseCache {
this.store.delete(key);
this.store.set(key, entry);

// Return cloned response with cached: true and stale indicator
const shouldClone = options?.clone !== undefined ? options.clone : this.clone;

// Return response with cached: true and stale indicator
const res: HttpResponse<T> = {
...entry.response,
data: cloneData(entry.response.data),
data: shouldClone ? cloneData(entry.response.data) : entry.response.data,
cached: true,
};
if (isExpired) {
Expand All @@ -340,6 +332,7 @@ export class CacheManager implements ResponseCache {

/**
* Checks if an entry exists and is expired (stale).
* @internal Not part of the ResponseCache public interface.
*/
public isStale(key: string): boolean {
const entry = this.store.get(key);
Expand Down Expand Up @@ -387,26 +380,35 @@ export class CacheManager implements ResponseCache {

if (this.storage) void this.persist();

const shouldClone = this.clone;

return {
...entry.response,
data: cloneData(entry.response.data),
data: shouldClone ? cloneData(entry.response.data) : entry.response.data,
cached: true,
stale: false,
};
}

public set<T = unknown>(key: string, response: HttpResponse<T>, ttlMs?: number): void {
public set<T = unknown>(
key: string,
response: HttpResponse<T>,
ttlMs?: number,
options?: { clone?: boolean | undefined },
): void {
const ttl = ttlMs !== undefined && ttlMs >= 0 ? ttlMs : this.defaultTtl;
const expiresAt = Date.now() + ttl;

const etag = response.headers['etag'];
const lastModified = response.headers['last-modified'];

// Deep clone data to store immutable snapshot
const shouldClone = options?.clone !== undefined ? options.clone : this.clone;

// Deep clone data to store immutable snapshot if cloning is enabled
const entry: CacheEntry<T> = {
response: {
...response,
data: cloneData(response.data),
data: shouldClone ? cloneData(response.data) : response.data,
cached: true,
},
expiresAt,
Expand Down
Loading
Loading