Newer
Older
smart-home-server / webclient / src / api / http.js
import { getAccessToken } from "./auth";

const DEFAULT_TIMEOUT_MS = Number(import.meta.env.VITE_API_TIMEOUT_MS || 10000);

// Logger for HTTP requests — console-only, no server sync
const LOG_PREFIX = "[vue:http]";
const LOG_LEVELS = {
  DEBUG: 0,
  INFO: 1,
  WARN: 2,
  ERROR: 3,
};
const MIN_LEVEL = import.meta.env.VITE_LOG_LEVEL
  ? LOG_LEVELS[import.meta.env.VITE_LOG_LEVEL.toUpperCase()] ?? LOG_LEVELS.INFO
  : LOG_LEVELS.INFO;

function shouldLog(level) {
  return LOG_LEVELS[level] >= MIN_LEVEL;
}

function formatDuration(ms) {
  return ms < 10 ? `${ms.toFixed(1)}ms` : `${Math.round(ms)}ms`;
}

function truncate(str, maxLen = 500) {
  if (!str) return "";
  const s = String(str);
  return s.length > maxLen ? s.slice(0, maxLen) + "…" : s;
}

function buildQuery(params) {
  const query = new URLSearchParams();

  for (const [key, value] of Object.entries(params || {})) {
    if (value === undefined || value === null) {
      continue;
    }

    query.append(key, String(value));
  }

  const serialized = query.toString();
  return serialized ? `?${serialized}` : "";
}

function joinUrl(baseUrl, path) {
  const base = String(baseUrl || "").replace(/\/+$/, "");
  const nextPath = String(path || "").replace(/^\/+/, "");

  if (!base) {
    return `/${nextPath}`;
  }

  return `${base}/${nextPath}`;
}

function wrapProxyPath(path, query) {
  const proxyPath = import.meta.env.VITE_API_PROXY_PATH || "";

  if (!proxyPath) {
    return `${path}${buildQuery(query)}`;
  }

  return `${proxyPath}${buildQuery({ path, ...(query || {}) })}`;
}

export async function requestHttp(method, path, body, options = {}) {
  const timeoutMs = Number(options.timeoutMs || DEFAULT_TIMEOUT_MS);
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
  const baseUrl = import.meta.env.VITE_API_BASE_URL || "";
  const url = joinUrl(baseUrl, wrapProxyPath(path, options.query));

  const headers = {
    Accept: "application/json",
    ...(options.headers || {}),
  };

  const token = getAccessToken();
  if (token) {
    headers["Authorization"] = `Bearer ${token}`;
  }

  const init = {
    method,
    headers,
    signal: controller.signal,
    credentials: "include",
  };

  if (options.signal) {
    options.signal.addEventListener("abort", () => controller.abort(), { once: true });
  }

  if (body !== undefined && body !== null) {
    headers["Content-Type"] = "application/json";
    init.body = JSON.stringify(body);
  }

  // Log request start
  if (shouldLog("DEBUG")) {
    console.debug(LOG_PREFIX, method, url, body ? truncate(JSON.stringify(body)) : "");
  }

  const startTime = performance.now();

  try {
    const response = await fetch(url, init);
    const text = await response.text();
    let data = text;

    if (text) {
      try {
        data = JSON.parse(text);
      } catch (_) {
        data = text;
      }
    }

    const duration = performance.now() - startTime;

    // Log response
    if (shouldLog("INFO")) {
      const level = response.status >= 500 ? "ERROR" : response.status >= 400 ? "WARN" : "INFO";
      if (shouldLog(level)) {
        console[level.toLowerCase()](
          LOG_PREFIX,
          `${method} ${url} — ${response.status} in ${formatDuration(duration)}`,
          truncate(text || "", 200)
        );
      }
    }

    return {
      response,
      data,
      meta: {
        url,
        method,
        statusCode: response.status,
        headers: response.headers,
      },
    };
  } catch (err) {
    const duration = performance.now() - startTime;

    // Log error
    if (shouldLog("ERROR")) {
      console.error(
        LOG_PREFIX,
        `${method} ${url} — FAILED in ${formatDuration(duration)}`,
        err?.message || err
      );
    }

    throw err;
  } finally {
    clearTimeout(timeout);
  }
}