API отвечает в терминале, а браузер показывает ошибку CORS. Проверьте адрес страницы и адрес запроса: разница в порте уже создаёт границу доступа. Воспроизведём сбой локально, разрешим конкретный origin и проверим предварительный запрос.

Соберём два учебных сервера без сторонних пакетов: страницу на http://127.0.0.1:5173 и API на http://127.0.0.1:3000. После упражнения вы сможете объяснить, на каком шаге браузер остановил запрос, и выбрать настройку API либо прокси для разработки.

Базовые методы и JSON разобраны в статье о работе с API. Здесь сосредоточимся на адресах и заголовках. Для диагностики рекомендуем сначала воспроизвести отказ с прямым запросом к API: прокси способен скрыть ошибку конфигурации, которая снова проявится при публикации.

CORS: сначала разрешение, затем данныеСхема для GET с заголовком X-Lesson из учебного примера.Страница127.0.0.1:5173API127.0.0.1:30001. OPTIONS: разрешён ли GET + X-Lesson?2. Ответ с разрешённым origin и заголовком3. GET /api/products + X-Lesson4. JSON + Access-Control-Allow-Origin
Это последовательность запроса с X-Lesson. Обычный GET без этого заголовка в данном примере не требует preflight; CORS-заголовок в ответе всё равно нужен.

Сравните 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 и запишите, какие источники разрешены в разработке и какой маршрут будет использовать опубликованный интерфейс.


«Доктайп» — журнал о фронтенде. Читайте, слушайте и учитесь с нами.

ТелеграмПодкастБесплатные учебники