A complete HTTP client implementation using Node.js built-in http and https modules, featuring typed responses, automatic JSON handling, redirect following, and configurable timeouts.
import * as http from 'node:http';
import * as https from 'node:https';
import { URL } from 'node:url';
interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
headers?: Record<string, string>;
body?: unknown;
timeout?: number;
maxRedirects?: number;
}
interface HttpResponse<T = unknown> {
status: number;
statusText: string;
headers: http.IncomingHttpHeaders;
data: T;
url: string;
}
class HttpError extends Error {
constructor(
message: string,
public status: number,
public response?: HttpResponse
) {
super(message);
this.name = 'HttpError';
}
}
class TimeoutError extends Error {
constructor(message: string) {
super(message);
this.name = 'TimeoutError';
}
}
const STATUS_TEXTS: Record<number, string> = {
200: 'OK',
201: 'Created',
204: 'No Content',
301: 'Moved Permanently',
302: 'Found',
304: 'Not Modified',
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
500: 'Internal Server Error',
};
async function request<T = unknown>(
url: string,
options: RequestOptions = {}
): Promise<HttpResponse<T>> {
const {
method = 'GET',
headers = {},
body,
timeout = 30000,
maxRedirects = 5,
} = options;
let redirectCount = 0;
let currentUrl = url;
const executeRequest = (targetUrl: string): Promise<HttpResponse<T>> => {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(targetUrl);
const isHttps = parsedUrl.protocol === 'https:';
const transport = isHttps ? https : http;
const requestHeaders: Record<string, string> = {
'User-Agent': 'NodeHttpClient/1.0',
Accept: 'application/json, text/plain, */*',
...headers,
};
let requestBody: string | undefined;
if (body !== undefined) {
if (typeof body === 'string') {
requestBody = body;
} else {
requestBody = JSON.stringify(body);
requestHeaders['Content-Type'] ??= 'application/json';
}
requestHeaders['Content-Length'] = Buffer.byteLength(requestBody).toString();
}
const reqOptions: http.RequestOptions = {
method,
hostname: parsedUrl.hostname,
port: parsedUrl.port || (isHttps ? 443 : 80),
path: parsedUrl.pathname + parsedUrl.search,
headers: requestHeaders,
timeout,
};
const req = transport.request(reqOptions, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
res.on('end', () => {
const rawData = Buffer.concat(chunks).toString('utf-8');
const statusCode = res.statusCode ?? 0;
// Handle redirects
if ([301, 302, 303, 307, 308].includes(statusCode) && res.headers.location) {
if (redirectCount >= maxRedirects) {
reject(new HttpError(`Max redirects (${maxRedirects}) exceeded`, statusCode));
return;
}
redirectCount++;
const redirectUrl = new URL(res.headers.location, targetUrl).toString();
resolve(executeRequest(redirectUrl));
return;
}
// Parse response data
let data: T;
const contentType = res.headers['content-type'] ?? '';
if (contentType.includes('application/json') && rawData) {
try {
data = JSON.parse(rawData) as T;
} catch {
data = rawData as T;
}
} else {
data = rawData as T;
}
const response: HttpResponse<T> = {
status: statusCode,
statusText: STATUS_TEXTS[statusCode] ?? 'Unknown',
headers: res.headers,
data,
url: targetUrl,
};
if (statusCode >= 400) {
reject(new HttpError(`Request failed with status ${statusCode}`, statusCode, response as HttpResponse));
return;
}
resolve(response);
});
res.on('error', reject);
});
req.on('timeout', () => {
req.destroy();
reject(new TimeoutError(`Request timed out after ${timeout}ms`));
});
req.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'ECONNREFUSED') {
reject(new Error(`Connection refused: ${targetUrl}`));
} else if (error.code === 'ENOTFOUND') {
reject(new Error(`Host not found: ${parsedUrl.hostname}`));
} else {
reject(error);
}
});
if (requestBody) {
req.write(requestBody);
}
req.end();
});
};
return executeRequest(currentUrl);
}
// Convenience methods
const httpClient = {
get: <T = unknown>(url: string, options?: Omit<RequestOptions, 'method' | 'body'>) =>
request<T>(url, { ...options, method: 'GET' }),
post: <T = unknown>(url: string, body?: unknown, options?: Omit<RequestOptions, 'method' | 'body'>) =>
request<T>(url, { ...options, method: 'POST', body }),
put: <T = unknown>(url: string, body?: unknown, options?: Omit<RequestOptions, 'method' | 'body'>) =>
request<T>(url, { ...options, method: 'PUT', body }),
patch: <T = unknown>(url: string, body?: unknown, options?: Omit<RequestOptions, 'method' | 'body'>) =>
request<T>(url, { ...options, method: 'PATCH', body }),
delete: <T = unknown>(url: string, options?: Omit<RequestOptions, 'method'>) =>
request<T>(url, { ...options, method: 'DELETE' }),
};
// Example usage and type definitions
interface User {
id: number;
name: string;
email: string;
}
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
async function main() {
try {
// GET request with typed response
const usersResponse = await httpClient.get<User[]>(
'https://jsonplaceholder.typicode.com/users'
);
console.log('Users:', usersResponse.data.slice(0, 2));
console.log('Status:', usersResponse.status);
// POST request with JSON body
const newPost = await httpClient.post<Post>(
'https://jsonplaceholder.typicode.com/posts',
{
title: 'Hello World',
body: 'This is a test post',
userId: 1,
}
);
console.log('Created post:', newPost.data);
// Request with custom headers and timeout
const customRequest = await httpClient.get<User>(
'https://jsonplaceholder.typicode.com/users/1',
{
headers: {
'X-Custom-Header': 'custom-value',
},
timeout: 5000,
}
);
console.log('Single user:', customRequest.data);
// Test redirect following (httpbin redirects)
const redirectResponse = await request<string>(
'http://httpbin.org/redirect/2',
{ maxRedirects: 3 }
);
console.log('After redirects, final URL:', redirectResponse.url);
} catch (error) {
if (error instanceof HttpError) {
console.error(`HTTP Error ${error.status}: ${error.message}`);
} else if (error instanceof TimeoutError) {
console.error(`Timeout: ${error.message}`);
} else {
console.error('Unexpected error:', error);
}
}
}
main();
export { httpClient, request, HttpResponse, RequestOptions, HttpError, TimeoutError };This HTTP client implementation demonstrates how to work with Node.js's low-level http and https modules while providing a modern, type-safe API. The core function request returns a Promise that wraps the event-based nature of Node's built-in modules, making it compatible with async/await patterns.
The client automatically selects between http and https transports based on the URL protocol. Request bodies are automatically serialized to JSON when they're objects, and the Content-Type header is set accordingly using the nullish coalescing assignment operator (??=) to avoid overwriting explicit headers. Response bodies are accumulated in chunks and parsed as JSON when the Content-Type header indicates JSON content.
Redirect handling is implemented recursively with a configurable maximum redirect count to prevent infinite redirect loops. The client follows 301, 302, 303, 307, and 308 status codes, resolving relative redirect URLs against the current URL. Each redirect increments a counter, and an error is thrown when the limit is exceeded.
Timeout handling uses Node's built-in request timeout option, which triggers a 'timeout' event. When this occurs, the request is explicitly destroyed and a TimeoutError is thrown. The error handling also distinguishes between common network errors like ECONNREFUSED and ENOTFOUND, providing clearer error messages.
The convenience methods (get, post, put, patch, delete) provide a cleaner API for common use cases and use TypeScript's Omit utility type to prevent invalid option combinations. Generic type parameters flow through the entire chain, allowing TypeScript to infer response types from the call site. This pattern is ideal when you need HTTP functionality without adding dependencies, in environments where fetch isn't available, or when you need fine-grained control over the request lifecycle.