Браузер не принимает ответ API: CORS и прокси в локальном проекте
- 28 августа 2026
API отвечает в терминале, а браузер показывает ошибку CORS. Проверьте адрес страницы и адрес запроса: разница в порте уже создаёт границу доступа. Воспроизведём сбой локально, разрешим конкретный origin и проверим предварительный запрос.
Соберём два учебных сервера без сторонних пакетов: страницу на http://127.0.0.1:5173 и API на http://127.0.0.1:3000. После упражнения вы сможете объяснить, на каком шаге браузер остановил запрос, и выбрать настройку API либо прокси для разработки.
Базовые методы и JSON разобраны в статье о работе с API. Здесь сосредоточимся на адресах и заголовках. Для диагностики рекомендуем сначала воспроизвести отказ с прямым запросом к API: прокси способен скрыть ошибку конфигурации, которая снова проявится при публикации.
Сравните origin страницы и запроса
Origin, или источник, определяется схемой, именем хоста и портом. У наших адресов одинаковы схема http и хост 127.0.0.1, но различаются порты. Поэтому для браузера это два разных источника.
Адрес http://localhost:5173 тоже отличается от http://127.0.0.1:5173. Даже если оба ведут на ваш компьютер, строки origin разные. Во всём упражнении открывайте страницу именно через 127.0.0.1; разрешение на сервере будет рассчитано на этот адрес.
CORS позволяет серверу указать в ответе, с какого источника браузер может предоставить данные JavaScript. Для некоторых запросов браузер сначала выполняет предварительную проверку, preflight. Её механизм и набор заголовков описаны в официальной справке MDN по CORS.
Запрос из терминала не проверяет доступ страницы браузера к ответу. Поэтому успешный curl полезен для проверки доступности API, но не доказывает, что CORS настроен.
Создайте страницу для двух видов запроса
Понадобятся Node.js, браузер и понимание fetch(). Создайте отдельную папку cors-lab. Сохраните в ней index.html:
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Проверка CORS</title>
</head>
<body>
<main>
<h1>Учебный API</h1>
<button id="ordinary" type="button">Отправить GET</button>
<button id="custom" type="button">GET с учебным заголовком</button>
<p id="result" role="status"></p>
</main>
<script>
const output = document.querySelector('#result');
async function check(custom) {
output.textContent = 'Ждём ответ…';
try {
const response = await fetch('http://127.0.0.1:3000/api/products', {
headers: custom ? { 'X-Lesson': 'cors-lab' } : {},
});
if (!response.ok) throw new Error('HTTP ' + response.status);
output.textContent = JSON.stringify(await response.json());
} catch (error) {
output.textContent = 'Ответ недоступен. Откройте консоль и Network.';
console.error(error);
}
}
document.querySelector('#ordinary').addEventListener('click', () => check(false));
document.querySelector('#custom').addEventListener('click', () => check(true));
</script>
</body>
</html>Один GET отправляется без дополнительных заголовков. Другой несёт X-Lesson, который намеренно вызывает предварительный запрос. Это название принадлежит учебному примеру; у него нет специального значения для Node.js.
Страницу тоже нужно отдавать по HTTP. Сохраните рядом client.mjs:
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const html = await readFile(new URL('./index.html', import.meta.url));
createServer((req, res) => {
const found = req.url === '/';
res.writeHead(found ? 200 : 404, {
'Content-Type': 'text/html; charset=utf-8',
});
res.end(found ? html : 'Not found');
}).listen(5173, '127.0.0.1');Этот сервер отдаёт одну страницу. Сервер принимает только корневой путь. Каталога пока нет. Его создадим на другом порте. Встроенный модуль описан в документации Node.js HTTP.
Запустите API с выключенным разрешением
В api.mjs поместите следующий код. Строка allowCors = false сначала оставляет разрешение выключенным; позже поменяем только её. В API есть список товаров, ответ на OPTIONS и проверка разрешённого источника.
import { createServer } from 'node:http';
const clientOrigin = 'http://127.0.0.1:5173';
const allowCors = false;
createServer((req, res) => {
const origin = req.headers.origin;
res.setHeader('Vary', 'Origin');
if (allowCors && origin === clientOrigin) {
res.setHeader('Access-Control-Allow-Origin', clientOrigin);
}
if (allowCors && origin && origin !== clientOrigin) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'ORIGIN_NOT_ALLOWED' }));
return;
}
if (req.method === 'OPTIONS' && allowCors) {
const requestedMethod = req.headers['access-control-request-method'];
const requestedHeaders = String(req.headers['access-control-request-headers'] || '')
.toLowerCase().split(',').map((name) => name.trim()).filter(Boolean);
const permitted = origin === clientOrigin
&& requestedMethod === 'GET'
&& requestedHeaders.every((name) => name === 'x-lesson');
if (!permitted) {
res.writeHead(403);
res.end();
return;
}
res.writeHead(204, {
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'X-Lesson',
});
res.end();
return;
}
if (req.method !== 'GET') {
res.writeHead(405, { Allow: 'GET, OPTIONS' });
res.end();
return;
}
const path = new URL(req.url, 'http://127.0.0.1:3000').pathname;
res.writeHead(path === '/api/products' ? 200 : 404, {
'Content-Type': 'application/json; charset=utf-8',
});
res.end(JSON.stringify(path === '/api/products'
? [{ id: 1, name: 'Учебный блокнот' }]
: { error: 'NOT_FOUND' }));
}).listen(3000, '127.0.0.1');В одном терминале выполните node api.mjs, в другом — node client.mjs. Откройте http://127.0.0.1:5173, затем инструменты разработчика. Во вкладке Network включите сохранение журнала и нажмите «Отправить GET».
В сети должен появиться ответ API, но прочитать его на странице не получится: у ответа нет разрешающего заголовка. Теперь нажмите вторую кнопку. Браузер сначала отправит OPTIONS. В начальном варианте сервер ответит 405, поэтому основной GET с X-Lesson не последует.
Это разные отказы. В одном случае реальный запрос уже дошёл до сервера, в другом остановился на предварительной проверке.
Разрешите точный адрес и проверьте OPTIONS
Остановите только API сочетанием Ctrl+C. Измените const allowCors = false на const allowCors = true и запустите node api.mjs снова. Обновите страницу и повторите оба действия.
Теперь обычный GET должен показать учебный товар, а в Network вы увидите статус 200. У второго запроса в Network будет успешный OPTIONS со статусом 204, затем GET со статусом 200. Заголовок Access-Control-Allow-Origin появится в ответах для разрешённого источника, включая HTTP-ошибки: иначе клиент не сможет прочитать полезное сообщение об отказе API.
Для отдельной проверки предварительного запроса выполните:
curl -i -X OPTIONS http://127.0.0.1:3000/api/products -H 'Origin: http://127.0.0.1:5173' -H 'Access-Control-Request-Method: GET' -H 'Access-Control-Request-Headers: x-lesson'Ожидаемый ответ содержит 204 и три заголовка: разрешённый источник, метод GET и заголовок X-Lesson. Команда показывает серверную часть протокола. Финальную проверку доступа выполняйте в браузере.
| Наблюдение | Что проверить |
|---|---|
| Обычный GET есть, страница не видит данные | Заголовок Allow-Origin в ответе |
| Виден OPTIONS, основной GET отсутствует | Статус и разрешения preflight |
| Ответ работает через curl | Ещё проверить запрос из страницы |
| Страница открыта через localhost | Сравнить origin с разрешённой строкой |
| GET после проверки вернул 404 | Проверить маршрут и сохранить CORS-заголовок ошибки |
Мы разрешаем один локальный источник. Возвращайте адрес в ответе только после проверки. Если подставлять в ответ любой полученный Origin, читать данные сможет страница с любого источника. Для нескольких известных адресов проверяйте вхождение в заранее заданный набор, затем возвращайте ровно один допустимый origin.
Используйте прокси в проекте на Vite
Если у вас уже есть проект с Vite, сервер разработки может направлять запросы /api к локальному API. Тогда браузер обращается к тому же источнику, с которого открыта страница. Поддерживаемые правила описаны в документации server.proxy.
Остановите наш client.mjs, чтобы освободить порт. В существующий vite.config.js добавьте настройки, сохранив остальные поля проекта:
import { defineConfig } from 'vite';
export default defineConfig({
server: {
host: '127.0.0.1',
port: 5173,
strictPort: true,
proxy: {
'/api': {
target: 'http://127.0.0.1:3000',
},
},
},
});На клиенте замените абсолютный адрес запроса на /api/products. Запускайте Vite командой разработки из package.json своего проекта. Поле strictPort помогает сохранить адрес упражнения: если порт занят, сервер остановится вместо перехода на соседний порт.
В такой схеме браузер видит запрос на 5173, а Vite пересылает его на 3000. Настройка относится к серверу разработки. При публикации статических файлов этот прокси не возникает автоматически: нужен отдельный маршрут на рабочем сервере либо согласованная настройка CORS у API.
Проверьте решение на своём адресе
Параметр mode: 'no-cors' не подходит для чтения JSON-каталога. Он даёт непрозрачный ответ: JavaScript не получает доступ к его телу и заголовкам. Это ограничение описано в руководстве Fetch.
CORS также не заменяет аутентификацию и проверку прав на сервере. Наш API содержит публичные вымышленные товары и разрешает запросы без Origin, например из терминала. Для закрытых данных нужна собственная модель доступа независимо от браузерного разрешения.
Для самостоятельной проверки откройте клиент через localhost:5173. Зафиксируйте отказ и вернитесь к разрешённому адресу. После этого добавьте ещё один допустимый заголовок и проверьте preflight. Сравните Network до и после правки, включая ответ на неизвестный маршрут.
Продолжите работу с серверным контрактом
Если вы можете написать функции и обработчики ошибок на JavaScript, а основы TypeScript уже знакомы, сверяйте подготовку с входными требованиями курса «Node.js. Профессиональная разработка REST API». Перед выбором программы сохраните рабочую пару клиент–API и запишите, какие источники разрешены в разработке и какой маршрут будет использовать опубликованный интерфейс.
«Доктайп» — журнал о фронтенде. Читайте, слушайте и учитесь с нами.