Проксирование fetch() в серверном JavaScript
fetch() в серверном JavaScript выглядит по-разному в Node.js, Deno, Bun и Cloudflare Workers — потому что спецификация Fetch эту возможность не определяет. Вместо очередного перебора примеров кода: таблица сравнения сред, сценарии «простой прокси за минуту» против «гибкости с undici», архитектурные компромиссы для Workers и чек-лист проверки, действительно ли трафик пошёл через прокси. Прочитайте эту статью, чтобы не копировать первый попавшийся рецепт, а осознанно выбрать решение под вашу задачу.Введение
Вы когда-нибудь тратили час на то, что по логике должно работать из коробки? Проксирование fetch() в серверном JavaScript — именно такой случай. В Deno — один API, в Bun — второй, в Node.js — третий, а в Cloudflare Workers — никакого, идите в обход через Docker. И нет, это не потому, что разработчики рантаймов любят нас запутывать. Просто стандарт Fetch не специфицирует проксирование — в браузерах эта задача почти не возникает. Вот и получилось, что каждый runtime решает её как умеет.
TL;DR
Проксирование fetch() в серверном JavaScript реализовано по-разному, потому что стандарт Fetch этого не специфицирует.
Bun и Deno — самый простой путь: опция proxy у Bun и Deno.createHttpClient() у Deno. Работает из коробки, без зависимостей.
Node.js даёт два варианта. Для всех запросов через один прокси — встроенный fetch() с переменными окружения (HTTP_PROXY, HTTPS_PROXY и флаг --use-env-proxy). Для per-request прокси, моков и тонкой настройки — устанавливайте undici и используйте ProxyAgent. И всегда явно читайте тело ответа, иначе утечка соединений.
Cloudflare Workers не поддерживают прокси нативно. Три обходных пути: Docker-контейнер (прототип), ручной CONNECT через TCP-сокеты (для смелых), вынос логики в отдельный микросервис (продакшен).
Чек-лист проверки: сравните IP через api.ipify.org, посмотрите на заголовки X-Forwarded-For или Via, запомните ошибки ECONNREFUSED, ETIMEDOUT, 407. В Node.js с undici всегда читайте тело ответа (response.body). CORS на сервере не работает — это нормально.
Не будем просто перечислять способы. Таких списков полно, и они не помогают в принятии решений. Вместо этого: таблица, чтобы сориентироваться за полминуты; три реальных сценария — от «просто работающего прокси за минуту» до «архитектурного компромисса в Workers»; и чек-лист, чтобы убедиться, что трафик действительно пошёл через прокси, а не в обход. Прочитав это, вы не скопируете первый попавшийся сниппет — вы будете знать, какой выбрать.
Сравнение сред: таблица для быстрого старта
Прежде чем погружаться в детали, посмотрите таблицу. Она не заменит чтение раздела про вашу среду исполнения, но поможет отсечь заведомо неподходящие варианты. Обратите внимание на колонку «Production-ready»: она отражает не техническую возможность, а степень зрелости решения и объём компромиссов.
| Среда | Нативная поддержка | Сложность настройки | Production-ready | Лучший способ (кратко) |
|---|---|---|---|---|
| Bun | ✅ Да, через опцию proxy |
Низкая | ✅ Да | Прямая опция в fetch() |
| Deno | ✅ Да, через Deno.createHttpClient() |
Низкая | ✅ Да | Создать клиент с proxy и закрыть его |
| Node.js | ⚠️ Условная (env vars) / через пакет | Средняя | ✅ Да (оба варианта) | Env vars для всех запросов, undici для per-request |
| Cloudflare Workers | ❌ Нет (только обходные пути) | Высокая | ⚠️ С оговорками | Docker-контейнер или смена архитектуры |
Теперь разберём каждый сценарий подробно. Начнём с тех сред, где прокси работает «из коробки» и без лишних телодвижений.
Сценарий первый: когда прокси должен работать сразу
Начнём с хороших новостей. Bun и Deno не заставляют танцевать с бубном. В обоих средах исполнения проксирование fetch() — это штатная опция, без переменных окружения, без установки дополнительных пакетов и без контейнеров. Вы просто берёте и делаете.
Bun. Самый лаконичный вариант. В объект параметров fetch() добавляете свойство proxy — и всё. Bun сам позаботится об аутентификации, если передать логин и пароль в URL прокси.
const response = await fetch("https://api.example.com/data", {
proxy: "http://username:password@proxy-server.com:8080"
});
const data = await response.json();
Это всё. Не нужно создавать дополнительные объекты, не нужно их закрывать. Одна строка — и весь трафик через прокси.
Deno. Чуть многословнее, но не сложнее. Здесь вы сначала создаёте HTTP-клиент с настройками прокси, передаёте его в fetch() через опцию client, а после использования клиент нужно закрыть. Deno поддерживает как HTTP-, так и HTTPS-прокси — синтаксис одинаковый.
const client = Deno.createHttpClient({
proxy: { url: "http://username:password@proxy-server.com:8080" }
});
const response = await fetch("https://api.example.com", { client });
const data = await response.json();
client.close(); // не забывайте закрывать
Обратите внимание на последнюю строку. Если вы создаёте клиента один раз на всё приложение и переиспользуете его, закрывать не нужно до завершения работы. Но если создаёте клиента под каждый запрос — закрывайте обязательно, иначе получите утечку ресурсов.
В обоих случаях вы получаете рабочий прокси без внешних зависимостей. Это эталонный сценарий для простых задач: один прокси, все запросы через него, без динамического переключения.
Теперь переходим к месту, где начинаются сложности и варианты выбора — к Node.js.
Node.js: два принципиально разных пути
В Node.js история сложнее, но честнее. У вас есть два варианта, и каждый решает свою задачу. Выбор между ними — не вопрос «правильности», а вопрос того, что вам нужно: простота и отсутствие зависимостей или гибкость и контроль. Начнём с того, что есть «из коробки».
Путь первый: встроенный fetch() и переменные окружения
Начиная с Node.js 22.21.0 и 24.5.0, вы можете использовать переменные окружения для проксирования. Это работает со встроенным fetch() — тем самым, который доступен глобально и не требует установки пакетов. Механизм простой: вы указываете прокси через HTTP_PROXY и HTTPS_PROXY, включаете поддержку флагом или переменной — и все последующие вызовы fetch() автоматически идут через прокси.
export NODE_USE_ENV_PROXY=1
export HTTP_PROXY=http://username:password@proxy-server.com:8080
export HTTPS_PROXY=https://username:password@proxy-server.com:8080
node your-app.js
Альтернативно, можно использовать флаг при запуске: node --use-env-proxy your-app.js. Этот подход идеален, когда весь трафик вашего приложения должен идти через один прокси — например, в корпоративной среде или при развёртывании за строгим firewall. Никакого дополнительного кода, никаких зависимостей. Но и никакого контроля над отдельными запросами: либо всё через прокси, либо ничего.
Путь второй: пакет undici для тонкой настройки
Если вам нужен per-request прокси — когда одни запросы идут через прокси, другие напрямую, или прокси меняется динамически, — тогда вам потребуется установить пакет undici. Это та же библиотека, которая лежит в основе встроенного fetch(), но в виде отдельного пакета она даёт доступ к API, не выставленным наружу.
npm i undici
С установленным пакетом вы создаёте экземпляр ProxyAgent и передаёте его в опцию dispatcher при вызове fetch().
import { ProxyAgent } from 'undici';
const agent = new ProxyAgent('http://username:password@proxy-server.com:8080');
const response = await fetch('https://api.example.com/data', {
dispatcher: agent
});
const data = await response.json();
Если ваш прокси требует аутентификации, вы можете передать учётные данные в URL (как в примере выше) или использовать заголовок proxy-authorization. Второй способ удобнее, когда логин и пароль генерируются динамически или хранятся в защищённом хранилище:
import { ProxyAgent } from 'undici';
const credentials = Buffer.from(`${login}:${password}`).toString('base64');
const agent = new ProxyAgent('http://proxy-server.com:8080');
const response = await fetch('https://api.example.com/data', {
dispatcher: agent,
headers: {
'proxy-authorization': `Basic ${credentials}`
}
});
В обоих вариантах важно помнить: ProxyAgent не освобождает ресурсы автоматически. В долго живущем приложении, где вы создаёте много агентов, стоит явно вызывать agent.close() или переиспользовать один экземпляр на всё приложение.
Как выбрать между двумя путями
Если вы не знаете, какой вариант выбрать, начните со встроенного fetch() и переменных окружения. Этого достаточно для большинства приложений, особенно если они работают в контролируемой среде. Переходите на undici только тогда, когда почувствуете ограничения: нужно динамически менять прокси, логировать запросы на уровне агента, использовать моки для тестирования или настраивать пул соединений. В этом случае установка undici — не «ещё одна зависимость», а осознанный инструмент для решения конкретной задачи.
Cloudflare Workers: архитектурный компромисс
Теперь к самому неприятному. Cloudflare Workers не поддерживают проксирование fetch() нативно. Вообще. Ни через переменные окружения, ни через программные опции. Если вы встретили в интернете статью с примером прокси в Workers, где всё выглядит просто, — скорее всего, автор либо не проверял код, либо описывает обходной путь, выдавая его за штатную возможность.
Но это не значит, что задача нерешаема. Это значит, что решение выходит за рамки простой настройки и становится архитектурным. У вас есть три варианта, и каждый требует жертв.
Вариант первый: Docker-контейнер как прокси-сервер
Этот способ описан в официальной документации Workers, но его сложность часто недооценивают. Идея в том, чтобы запустить внутри Workers не код, а целый Docker-контейнер с Node.js, который уже умеет проксировать. Workers умеют запускать контейнеры через API @cloudflare/containers, и внутри такого контейнера вы поднимаете простой HTTP-сервер, который принимает запросы от Worker'а, проксирует их через fetch() и возвращает ответ.
Вы пишете Dockerfile, собираете образ, настраиваете wrangler.jsonc с биндингами для контейнера, а в коде Worker'а запускаете контейнер и отправляете запросы уже на него. Получается прокси в прокси: Worker -> контейнер с Node.js -> внешний прокси -> целевой сервер. Работает. Но цена — латентность (каждый запрос проходит через два дополнительных hop'а), стоимость (контейнеры в Cloudflare не бесплатны) и сложность отладки. Если ваш прокси перестал отвечать, понять, где именно оборвалась цепочка, будет непросто.
Вариант второй: TCP Socket и CONNECT руками
Более хардкорный путь. Вы не используете fetch() для проксирования, а работаете напрямую с TCP-сокетами. Устанавливаете соединение с прокси-сервером, отправляете ему команду CONNECT с целевым хостом и портом, а затем уже поверх этого туннеля отправляете HTTP-запрос. Этот подход даёт полный контроль и не требует Docker, но его реализация в Workers потребует использования низкоуровневых API connect() из node:net — а это не самая документированная часть экосистемы Workers. Ошибки в ручной реализации туннеля могут привести к трудноуловимым багам с частично прочитанными ответами или зависшими соединениями. Например, при использовании fetch() в Workers с CNAME-настройкой требуются явные DNS-записи в Cloudflare, иначе запросы будут падать с ошибкой 530. Известные ошибки и проблемы, о которых следует помнить при использовании Cloudflare Workers.
Вариант третий: пересмотреть архитектуру
Самый честный вариант. Если вам нужно проксировать запросы из Workers, возможно, Workers — неподходящий инструмент для этой части задачи. Вынесите проксируемые вызовы в отдельный микросервис на Node.js или Go, которая умеет работать с прокси нативно, а Worker оставьте для того, что он делает лучше всего: маршрутизация, трансформация на грани, кэширование на CDN. Worker будет отправлять запросы в ваш микросервис, а та уже — через прокси наружу.
Какой вариант выбрать? Если вам нужен прототип и вы готовы мириться с задержками — попробуйте Docker-контейнер. Если вы опытны в сетевом программировании и не боитесь низкоуровневых сокетов — второй вариант. Если вы строите продакшен-систему — выберите третий и не пытайтесь впихнуть проксирование туда, где его никогда не задумывали.
Чек-лист: как убедиться, что прокси действительно работает
Вы настроили прокси, код запустился, ошибок нет. Но идёт ли трафик через прокси на самом деле? Доверять «а вдруг» в этом деле не стоит — особенно когда речь идёт о production. Вот несколько простых проверок, которые займут пару минут и избавят от неожиданностей.
Проверка IP-адреса. Самый быстрый способ — отправить запрос к сервису, который возвращает публичный IP-адрес клиента, и сравнить его с вашим прямым адресом. Например, https://api.ipify.org или https://httpbin.org/ip. Выполните запрос с прокси и без — адреса должны отличаться. В коде это выглядит так:
const response = await fetch('https://api.ipify.org');
const ip = await response.text();
console.log('Мой IP через прокси:', ip);
Если вы видите IP-адрес прокси-сервера — всё идёт по плану. Если ваш собственный — что-то пошло не так.
Логирование заголовков. Некоторые прокси-серверы добавляют служебные заголовки к запросу, например X-Forwarded-For или Via. Вы можете временно вывести все заголовки ответа, чтобы понять, прошёл ли запрос через прокси. В Node.js с undici это делается через response.headers, в Bun и Deno — аналогично. Для тестового эндпоинта, который вы контролируете, можно добавить свой заголовок на стороне прокси.
Типовые ошибки и их значение. Если прокси настроен неправильно, вы получите не «тишину», а вполне конкретные ошибки. Запомните три самые частые:
ECONNREFUSED— прокси-сервер не отвечает по указанному адресу и порту. Проверьте, запущен ли он и доступен ли из вашей сети.ETIMEDOUT— соединение с прокси устанавливается слишком долго. Возможно, вы указали неверный порт или прокси перегружен.407 Proxy Authentication Required— прокси требует аутентификацию, а вы её не передали или передали неверные учётные данные. Проверьте логин и пароль.
Часто задаваемые вопросы
Можно ли использовать один прокси-агент на все запросы в Node.js?
Да. Создайте один экземпляр ProxyAgent при старте приложения и используйте его во всех вызовах fetch(), передавая в опцию dispatcher. Это эффективнее, чем создавать новый агент под каждый запрос. Не забудьте закрыть агент при завершении работы приложения через agent.close().
Почему запрос идёт напрямую, хотя я настроил прокси?
Частая причина — несовместимость реализации fetch() и FormData. Если вы импортируете fetch из undici, но используете глобальный FormData, multipart-запросы могут пойти в обход. Используйте пару из одного источника: либо оба глобальные, либо оба из undici (через import { fetch, FormData } или undici.install()). Другая возможная причина — ошибка в формате URL прокси или отсутствие обязательного слеша.
Как заставить прокси работать с HTTPS?
В большинстве случаев достаточно указать https:// в URL прокси. Bun, Deno и ProxyAgent из undici поддерживают HTTPS-прокси так же, как HTTP. Исключение — переменные окружения в Node.js: используйте HTTPS_PROXY отдельно от HTTP_PROXY. Workers с Docker-контейнером требуют, чтобы контейнер сам умел работать с HTTPS.
Подходит ли подход с Docker-контейнером для продакшена в Cloudflare Workers?
С оговорками. Он работает, но добавляет латентность, увеличивает стоимость и усложняет отладку. Если ваш сервис чувствителен к задержкам или вы планируете обрабатывать тысячи запросов в секунду — лучше вынести проксируемую логику в отдельный микросервис на Node.js и обращаться к ней из Worker'а как к обычному API.
Почему я получаю ошибку 407, хотя логин и пароль правильные?
Проверьте, какой метод аутентификации требует прокси. ProxyAgent из undici и встроенные механизмы Bun и Deno поддерживают Basic-аутентификацию через URL (http://user:pass@proxy:8080) или через заголовок proxy-authorization. Если прокси требует Digest или NTLM — придётся искать отдельную библиотеку или настраивать прокси-сервер иначе.
Нужно ли закрывать клиент в Deno после каждого запроса?
Нет. Создайте клиент один раз при инициализации приложения и используйте его. Закрывать клиент нужно только при завершении работы приложения или если вы создаёте нового клиента взамен старого. Создание клиента на каждый запрос с последующим закрытием — неоправданная трата ресурсов.
Как проверить, идёт ли трафик через прокси, если я не контролирую сервер на той стороне?
Используйте внешний сервис эхо-заголовков, например https://httpbin.org/headers. Он вернёт все заголовки, которые получил, включая X-Forwarded-For, если прокси их добавляет. Сравните значение с вашим реальным IP. Также можно временно выставить заведомо нерабочий прокси и убедиться, что запросы начинают падать с ожидаемой ошибкой соединения.
Заключение
Мы прошли путь от раздражения «почему везде по-разному» до осознанного выбора инструмента. Если нужно запомнить только одно — вот главные ориентиры.
Для Bun и Deno всё просто: прокси есть из коробки, код занимает несколько строк, дополнительные зависимости не нужны. Это ваш выбор, когда важна скорость внедрения и нет экзотических требований.
Для Node.js решение двоится. Начните со встроенного fetch() и переменных окружения — этого хватает в большинстве случаев. Переходите на пакет undici только тогда, когда понадобится per-request прокси, мокирование для тестов или тонкая настройка соединений. И не забывайте про главное правило Node.js: всегда явно читайте тело ответа или закрывайте его, иначе соединение утечёт.
Для Cloudflare Workers честный ответ — не пытайтесь делать прокси там, где его не задумали. Если очень нужно — используйте Docker-контейнер для прототипа, TCP-сокеты для энтузиастов и вынос в отдельный микросервис для продакшена. Но лучше просто выберите другой runtime для этой части задачи.
И напоследок — чек-лист, который сэкономит вам часы отладки:
- Проверьте IP до и после.
- Посмотрите на заголовки ответа (
X-Forwarded-For,Via). - Запомните три ошибки:
ECONNREFUSED,ETIMEDOUT,407. - В Node.js с
undici— явно прочитайтеbody. - И помните: CORS на сервере не работает, это не баг, а особенность.
Теперь вы не просто скопируете первый попавшийся сниппет из интернета. Вы знаете, какой именно способ подходит вашей задаче, как его проверить и что делать, если что-то пошло не так. И это, пожалуй, главное.
Благодарности
Статья Nicholas C. Zakas, автора блога «Human Who Codes», послужила отправной точкой для этой статьи. Благодарим Николаса за его труд и открытые материалы, которые помогают разработчикам разбираться в сложных темах.