fetch, CORS и загрузка данных

Что такое CORS?

CORS (Cross-Origin Resource Sharing) — механизм браузера, позволяющий или запрещающий запросы к ресурсам с другого источника (origin = протокол + домен + порт).

Same-Origin Policy (по умолчанию) запрещает скриптам делать запросы к другому origin. CORS расширяет это правило через HTTP-заголовки.

Как работает:

  1. Браузер добавляет заголовок Origin: https://mysite.com к запросу
  2. Сервер отвечает: Access-Control-Allow-Origin: https://mysite.com (или *)
  3. Если заголовка нет — браузер блокирует ответ

Preflight-запрос и “непростые” запросы

Браузер делает preflight (OPTIONS) не для всех кросс-доменных запросов, а только для “непростых”.

Простой запрос — не требует preflight, если одновременно выполнены все условия:

  • Метод: GET, HEAD или POST
  • Content-Type только: application/x-www-form-urlencoded, multipart/form-data или text/plain
  • Нет кастомных заголовков (Authorization, X-Custom-Header и т.д.)

Непростой запрос — всё остальное:

  • Метод PUT, DELETE, PATCH
  • Content-Type: application/json
  • Заголовок Authorization
  • Любые кастомные заголовки

В таких случаях браузер сначала шлёт OPTIONS-запрос, чтобы спросить у сервера разрешение:

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type, Authorization

Как работает параметр mode в fetch?

mode определяет, как браузер обрабатывает cross-origin запросы.

ЗначениеПоведение
corsПо умолчанию. Разрешает cross-origin, сервер должен вернуть CORS-заголовки
same-originЗапрещает любые cross-origin запросы — бросает ошибку
no-corsРазрешает отправить запрос, но ответ opaque — тело и заголовки недоступны JS
navigateДля навигации браузера, не для кода

no-cors полезен для аналитики (пиксель трекинга), но не для получения данных.


Картинка не грузится (ошибка 403), но открывается напрямую — как исправить?

Что происходит и почему

Когда вставляешь чужую картинку через <img src="..."> на своей странице, браузер при загрузке картинки автоматически добавляет HTTP-заголовок:

Referer: https://твоя-страница.com/path

Многие серверы используют это для hotlink protection (защита от встраивания картинок с чужих сайтов): они проверяют заголовок Referer и, если он не совпадает с их доменом, возвращают 403 Forbidden.

Когда напрямую открываешь URL картинки в новой вкладке, браузер не добавляет Referer (или добавляет неинформативный) — поэтому картинка открывается.

Решения

  1. Скачать картинку и хостить у себя — самый надёжный вариант

  2. Убрать Referer для картинки:

<img src="..." referrerpolicy="no-referrer">
  1. Убрать Referer для всей страницы через мета-тег в <head>:
<meta name="referrer" content="no-referrer">
  1. Проксировать через свой сервер — бэкенд сам запрашивает картинку (без Referer от твоей страницы) и возвращает её клиенту. Надёжно, но дополнительная нагрузка.

⚠️ Варианты 2 и 3 работают не всегда: некоторые серверы запрещают даже запросы без Referer.


Скачать большой файл (5 ГБ) с сервера — как?

Вариант 1: Простая ссылка <a download>

<a href="/api/download/bigfile" download="file.zip">Скачать</a>

Браузер открывает GET-запрос, сервер отдаёт поток. Браузер пишет прямо на диск, не держа файл в памяти целиком. Плюсы: максимально просто. Минусы: нет контроля из JS (нельзя показать прогресс или обработать ошибку самостоятельно).

Вариант 2: fetch + ReadableStream (с прогресс-баром)

const response = await fetch('/api/bigfile');
const reader = response.body.getReader();
const contentLength = +response.headers.get('Content-Length');
let received = 0;
const chunks = [];
 
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  chunks.push(value);
  received += value.length;
  console.log(`${(received / contentLength * 100).toFixed(1)}%`);
}
 
const blob = new Blob(chunks);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'file.zip';
a.click();

Минусы: все чанки накапливаются в памяти (RAM) до момента сохранения — плохо для 5 ГБ!

Вариант 3: File System Access API (лучший для 5 ГБ)

const fileHandle = await window.showSaveFilePicker({ suggestedName: 'file.zip' });
const writable = await fileHandle.createWritable();
 
const response = await fetch('/api/bigfile');
await response.body.pipeTo(writable);

Данные стримятся прямо на диск, не накапливаясь в памяти. pipeTo соединяет ReadableStream (от fetch) с WritableStream (файл на диске). Минусы: API работает только в Chrome, Edge; не работает в Firefox/Safari без флагов.

Вариант 4: Range-запросы (возобновляемая загрузка)

// На сервере должен быть заголовок: Accept-Ranges: bytes
const start = 1048576; // продолжить с 1 МБ (уже скачанного)
const response = await fetch(url, {
  headers: { 'Range': `bytes=${start}-` }
});

Сервер отвечает 206 Partial Content. Можно скачивать параллельно несколькими запросами или возобновить после обрыва. Требования: сервер должен поддерживать Accept-Ranges: bytes и Content-Range.

См. также