.js
JavaScript
(text/javascript)
/**
 * Minimal HTTP client using only Node.js built-ins.
 *
 * Exports:
 *   - HttpClient: class with methods get/post/put/patch/delete/head/options/custom
 *   - HttpResponse: response wrapper (left unchanged per request)
 *
 * HttpResponse API:
 *   - statusCode: number
 *   - statusText: string
 *   - ok: boolean (2xx)
 *   - headers: object
 *   - text(): Promise<string>
 *   - isJson(): Promise<boolean>
 *   - json(): Promise<any>
 *
 * HttpClient usage (example):
 *   const { HttpClient } = require('./client');
 *   const client = new HttpClient();
 *   const res = await client.get('http://localhost:3000/auth', {
 *     headers: { 'content-type':'application/json' },
 *     // body: ..., // .post/.put/.patch only
 *   });
 *   if (!res.ok) {
 *     const txt = await res.text();
 *     return txt;
 *   }
 *   if (!await res.isJson()) {
 *     const txt = await res.text();
 *     return txt;
 *   }
 *   const obj = await res.json();
 *   // ... whatever with obj ...
 */

const { request } = require("http");
const { URL } = require("url");

class HttpResponse {
  /**
   * Wrap an IncomingMessage.
   * @param {import('http').IncomingMessage} incoming
   */
  constructor(incoming) {
    this._incoming = incoming;
    this._buf = null;
    this.statusCode = incoming.statusCode || 0;
    this.statusText = incoming.statusMessage || "";
    this.ok = this.statusCode >= 200 && this.statusCode < 300;
    this.headers = incoming.headers;
    this.header = incoming.header;
    this._json = null;
  }

  /**
   * Buffer the full response body (private).
   * @returns {Promise<Buffer>}
   */
  async #readAll() {
    return new Promise((resolve, reject) => {
      const bufs = [];
      this._incoming.on("data", (b) => bufs.push(b));
      this._incoming.on("end", () => resolve(Buffer.concat(bufs)));
      this._incoming.on("error", reject);
    });
  }

  /**
   * Return the response body as a string. Buffers on first call and reuses the value.
   * @returns {Promise<string>}
   */
  async text() {
    if (!this._buf) this._buf = await this.#readAll();
    return this._buf.toString();
  }

  /**
   * Feature-detect whether the body is valid JSON.
   * Uses text() as the single source-of-truth for the buffered body.
   * @returns {Promise<boolean>}
   */
  async isJson() {
    try {
      this._json = JSON.parse(await this.text());
      return true;
    } catch {
      return false;
    }
  }

  /**
   * Parse and return JSON body. Throws if body is not JSON.
   * @returns {Promise<any>}
   */
  async json() {
    if ((await this.isJson()) === false) throw new Error("not json");
    if (this._json == null) this._json = JSON.parse(await this.text());
    return this._json;
  }
}

class HttpClient {
  /**
   * Internal request factory that performs a request and returns HttpResponse.
   * Private by design: use convenience methods (get/post/...) instead.
   *
   * @param {string} method HTTP method
   * @param {string} url Request URL
   * @param {{ headers?: Record<string,string>, body?: string|Buffer|any }} [config]
   * @returns {Promise<HttpResponse>}
   */
  async #requestFactory(method, url, config = {}) {
    const u = new URL(url);
    const req = request({
      method,
      protocol: u.protocol,
      hostname: u.hostname,
      port: u.port,
      path: u.pathname + u.search,
      headers: config.headers || {},
    });

    // write body if provided
    if (config.body) {
      if (typeof config.body === "string" || Buffer.isBuffer(config.body)) {
        req.write(config.body);
      } else {
        req.write(JSON.stringify(config.body));
      }
    }

    req.end();

    const incoming = await new Promise((resolve, reject) => {
      req.on("error", reject);
      req.on("response", resolve);
    });

    return new HttpResponse(incoming);
  }

  // Convenience methods for common HTTP verbs:

  /**
   * GET request
   * @param {string} url
   * @param {{ headers?: Record<string,string>, body?: string|Buffer|any }} [config]
   */
  get(url, config) {
    return this.#requestFactory("GET", url, config);
  }

  /**
   * POST request
   */
  post(url, config) {
    return this.#requestFactory("POST", url, config);
  }

  /**
   * PUT request
   */
  put(url, config) {
    return this.#requestFactory("PUT", url, config);
  }

  /**
   * PATCH request
   */
  patch(url, config) {
    return this.#requestFactory("PATCH", url, config);
  }

  /**
   * DELETE request
   */
  delete(url, config) {
    return this.#requestFactory("DELETE", url, config);
  }

  /**
   * HEAD request
   */
  head(url, config) {
    return this.#requestFactory("HEAD", url, config);
  }

  /**
   * OPTIONS request
   */
  options(url, config) {
    return this.#requestFactory("OPTIONS", url, config);
  }

  /**
   * Custom method
   * @param {string} method
   */
  custom(method, url, config) {
    return this.#requestFactory(method, url, config);
  }
}

module.exports = { HttpClient, HttpResponse };