Проксирование запросов через middleware

При разработке современных веб-приложений часто возникает необходимость перенаправлять запросы от клиента к внешним API, внутренним сервисам или микросервисной инфраструктуре. Во время локальной разработки такая задача особенно актуальна из-за ограничений браузера, связанных с политикой одного источника (Same-Origin Policy), различий между доменами, портами и протоколами.

Проксирование запросов через middleware позволяет:

  • перенаправлять HTTP-запросы на другой сервер;
  • скрывать реальные адреса API;
  • обходить ограничения CORS во время разработки;
  • модифицировать запросы и ответы на лету;
  • реализовывать маршрутизацию между несколькими бэкенд-сервисами;
  • выполнять аутентификацию и логирование.

В экосистеме Esbuild проксирование обычно организуется через встроенный сервер разработки либо через собственные middleware-обработчики, подключаемые к HTTP-серверу.


Middleware в контексте Esbuild

Сам по себе Esbuild является сборщиком (bundler) и транспилятором. Он не предоставляет сложную систему middleware наподобие Express.js. Однако благодаря API плагинов и встроенному серверу разработки можно внедрять промежуточные обработчики, которые перехватывают запросы до их обработки конечным сервером.

Типичная схема выглядит следующим образом:

Браузер
    ↓
Middleware
    ↓
Прокси
    ↓
Внешний API

Каждый запрос проходит через промежуточный слой, где может быть:

  • изменён URL;
  • добавлен заголовок;
  • выполнена проверка авторизации;
  • произведена запись в журнал;
  • выполнено перенаправление на другой сервис.

Причины использования прокси при разработке

Рассмотрим ситуацию:

Frontend работает по адресу:

http://localhost:3000

Backend API расположен на другом порту:

http://localhost:8080

Запрос:

fetch("http://localhost:8080/api/users");

может столкнуться с ошибкой CORS:

Access to fetch at 'http://localhost:8080'
from origin 'http://localhost:3000'
has been blocked by CORS policy

Вместо обращения напрямую к API используется прокси:

fetch("/api/users");

Middleware принимает запрос:

GET /api/users

и автоматически перенаправляет его на:

http://localhost:8080/api/users

Для браузера запрос выглядит локальным, поэтому проблема CORS исчезает.


Архитектура проксирования

Прокси-сервер работает как посредник между клиентом и целевым сервисом.

Схема взаимодействия:

Client
   |
   v
Proxy Middleware
   |
   v
Target API

Последовательность действий:

  1. Клиент отправляет запрос.
  2. Middleware анализирует URL.
  3. Выбирается целевой сервер.
  4. Запрос пересылается.
  5. Ответ возвращается клиенту.

Например:

GET /api/products

Преобразуется в:

GET https://api.company.com/products

Создание собственного прокси-сервера

Часто Esbuild используется совместно со встроенным HTTP-сервером Node.js.

Простейший пример:

import http from "http";

const server = http.createServer((req, res) => {
  console.log(req.url);

  res.end("Hello");
});

server.listen(3000);

Добавим промежуточный слой:

function middleware(req, res, next) {
  console.log("Request:", req.method, req.url);

  next();
}

Использование:

const server = http.createServer((req, res) => {
  middleware(req, res, () => {
    res.end("OK");
  });
});

Теперь каждый запрос проходит через middleware.


Проксирование через Fetch API

Современный Node.js содержит встроенный Fetch API.

Проксирование можно реализовать следующим образом:

import http from "http";

const server = http.createServer(async (req, res) => {
  if (req.url.startsWith("/api")) {
    const response = await fetch(
      `https://jsonplaceholder.typicode.com${req.url}`
    );

    const data = await response.text();

    res.writeHead(response.status, {
      "Content-Type":
        response.headers.get("content-type") || "application/json"
    });

    res.end(data);
    return;
  }

  res.end("Frontend");
});

server.listen(3000);

Запрос:

GET /api/posts

будет автоматически отправлен на:

https://jsonplaceholder.typicode.com/api/posts

Изменение маршрутов при проксировании

Часто необходимо удалять префиксы маршрутов.

Исходный URL:

/api/users

Целевой API ожидает:

/users

Решение:

const targetPath = req.url.replace(/^\/api/, "");

Использование:

const response = await fetch(
  `https://backend.local${targetPath}`
);

Результат:

/api/users

превращается в:

/users

Добавление заголовков

Middleware позволяет автоматически дополнять запросы служебными данными.

Например:

const response = await fetch(url, {
  headers: {
    Authorization: "Bearer TOKEN",
    "X-Application": "frontend"
  }
});

После этого все запросы содержат дополнительные заголовки.

Полезные сценарии:

  • JWT-аутентификация;
  • сервисные токены;
  • идентификаторы клиента;
  • трассировка запросов.

Передача тела запроса

Прокси должен поддерживать не только GET-запросы.

Чтение тела запроса:

async function readBody(req) {
  const chunks = [];

  for await (const chunk of req) {
    chunks.push(chunk);
  }

  return Buffer.concat(chunks);
}

Использование:

const body = await readBody(req);

const response = await fetch(targetUrl, {
  method: req.method,
  body
});

Теперь поддерживаются:

POST
PUT
PATCH
DELETE

Копирование клиентских заголовков

Для корректной работы некоторых API необходимо передавать исходные заголовки.

Пример:

const response = await fetch(targetUrl, {
  method: req.method,
  headers: req.headers
});

Особенно важно сохранять:

Authorization
Cookie
Accept
Content-Type

Исключение служебных заголовков

Некоторые заголовки нельзя передавать напрямую.

Например:

const headers = { ...req.headers };

delete headers.host;
delete headers.connection;
delete headers["content-length"];

Далее:

await fetch(targetUrl, {
  method: req.method,
  headers
});

Это предотвращает ошибки маршрутизации и конфликты между серверами.


Передача ответа клиенту

После получения ответа от удалённого сервера необходимо вернуть его браузеру.

Пример:

const data = await response.arrayBuffer();

res.writeHead(response.status);

res.end(Buffer.from(data));

Более полный вариант:

res.writeHead(response.status, {
  "Content-Type":
    response.headers.get("content-type")
});

Логирование запросов

Middleware часто используется для мониторинга.

Пример:

function logger(req, res, next) {
  const start = Date.now();

  res.on("finish", () => {
    console.log(
      req.method,
      req.url,
      Date.now() - start,
      "ms"
    );
  });

  next();
}

Вывод:

GET /api/users 25 ms
POST /api/login 78 ms

Обработка ошибок прокси

Удалённый сервер может быть недоступен.

Необходимо перехватывать ошибки:

try {
  const response = await fetch(targetUrl);

  const data = await response.text();

  res.end(data);
} catch (error) {
  res.statusCode = 502;

  res.end("Bad Gateway");
}

Код:

502 Bad Gateway

традиционно используется для ошибок промежуточного прокси-сервера.


Middleware для нескольких API

Крупные проекты часто работают сразу с несколькими сервисами.

Пример маршрутизации:

const routes = {
  "/api": "http://localhost:8080",
  "/auth": "http://localhost:9000",
  "/files": "http://localhost:7000"
};

Поиск нужного сервера:

function resolveTarget(path) {
  for (const prefix in routes) {
    if (path.startsWith(prefix)) {
      return routes[prefix];
    }
  }

  return null;
}

Использование:

const target = resolveTarget(req.url);

Переписывание URL

Иногда структура маршрутов различается.

Исходный запрос:

/api/products

Требуемый адрес:

/v1/catalog/products

Middleware может выполнять преобразование:

const path = req.url.replace(
  "/api",
  "/v1/catalog"
);

Результат:

/api/products

/v1/catalog/products

Балансировка между несколькими серверами

Middleware способен распределять нагрузку между несколькими узлами.

Список серверов:

const servers = [
  "http://localhost:5001",
  "http://localhost:5002",
  "http://localhost:5003"
];

Алгоритм Round Robin:

let index = 0;

function nextServer() {
  const server = servers[index];

  index = (index + 1) % servers.length;

  return server;
}

Каждый новый запрос будет направляться на следующий сервер.


Кэширование ответов

Для снижения нагрузки можно сохранять результаты запросов.

Пример:

const cache = new Map();

Проверка:

if (cache.has(req.url)) {
  res.end(cache.get(req.url));
  return;
}

Сохранение:

cache.set(req.url, data);

Такой подход ускоряет работу при частых запросах к одним и тем же данным.


Добавление CORS-заголовков

Хотя прокси обычно устраняет проблемы CORS, иногда требуется дополнительно управлять заголовками.

Пример:

res.setHeader(
  "Access-Control-Allow-Origin",
  "*"
);

res.setHeader(
  "Access-Control-Allow-Methods",
  "GET,POST,PUT,DELETE"
);

Для предварительных запросов:

if (req.method === "OPTIONS") {
  res.statusCode = 204;
  res.end();
  return;
}

Использование middleware совместно с Esbuild

Во время разработки часто запускается процесс сборки и HTTP-сервер одновременно.

Пример запуска Esbuild:

import * as esbuild from "esbuild";

await esbuild.context({
  entryPoints: ["src/index.js"],
  bundle: true,
  outfile: "dist/app.js"
});

Параллельно работает прокси-сервер:

server.listen(3000);

Структура проекта:

project
├── src
├── dist
├── build.js
└── server.js

Где:

  • build.js отвечает за сборку;
  • server.js содержит middleware и проксирование.

Интеграция через плагины Esbuild

Плагины позволяют вмешиваться в процесс обработки ресурсов.

Простейший пример:

const plugin = {
  name: "proxy-plugin",
  setup(build) {
    build.onResolve(
      { filter: /^api:/ },
      args => {
        return {
          path: args.path
        };
      }
    );
  }
};

Регистрация:

await esbuild.build({
  entryPoints: ["src/index.js"],
  bundle: true,
  plugins: [plugin]
});

Хотя подобные плагины чаще используются для обработки импортов, они могут участвовать в построении инфраструктуры взаимодействия с удалёнными сервисами.


Безопасность при использовании прокси

При разработке проксирующего middleware необходимо учитывать ряд рисков.

Открытый прокси

Опасный вариант:

fetch(req.query.url);

Пользователь способен направлять запросы на любые ресурсы.

Лучше использовать белый список:

const allowedHosts = [
  "api.company.com"
];

Проверка:

if (!allowedHosts.includes(hostname)) {
  res.statusCode = 403;
  return;
}

Защита внутренних сервисов

Нельзя публиковать адреса:

localhost
127.0.0.1
10.x.x.x
192.168.x.x

если прокси доступен извне.

Контроль размеров запросов

Для предотвращения перегрузки:

const MAX_SIZE = 5 * 1024 * 1024;

Следует ограничивать объём загружаемых данных.


Практический пример полноценного middleware-прокси

import http from "http";

const TARGET =
  "https://jsonplaceholder.typicode.com";

const server = http.createServer(
  async (req, res) => {
    try {
      if (!req.url.startsWith("/api")) {
        res.end("Frontend");
        return;
      }

      const path =
        req.url.replace(/^\/api/, "");

      const response = await fetch(
        TARGET + path,
        {
          method: req.method,
          headers: req.headers
        }
      );

      const data =
        await response.arrayBuffer();

      res.writeHead(
        response.status,
        {
          "Content-Type":
            response.headers.get(
              "content-type"
            )
        }
      );

      res.end(Buffer.from(data));
    } catch (error) {
      res.statusCode = 502;

      res.end("Proxy error");
    }
  }
);

server.listen(3000);

Такой middleware реализует основные возможности прокси-сервера:

  • принимает запросы клиента;
  • фильтрует маршруты;
  • переписывает URL;
  • пересылает запрос на удалённый сервер;
  • возвращает ответ клиенту;
  • обрабатывает ошибки соединения;
  • может быть интегрирован в процесс разработки приложений, собираемых при помощи Esbuild.