Перейти к содержанию

Локальный API и первая загрузка

До этого момента данные приходили из JavaScript-файла внутри клиента. Теперь перенесём исходные записи в отдельный учебный HTTP-сервер. Компонент получит каталог через GET, а позднее тот же сервер примет создание и удаление карточек. Так мы сохраним общий пример и увидим, где заканчивается работа React и начинается договорённость по API.

Сервер предназначен для локального знакомства с CRUD. Он хранит изменения только в памяти процесса и загружает исходные три записи при каждом старте. Это не backend действующего ProfessorWeb и не заготовка с готовой авторизацией. Сам публичный сайт остаётся отдельной статической библиотекой.

Адрес клиента и адрес API

Клиент Vite использует 127.0.0.1:5174, учебный API — 127.0.0.1:4301. В компоненте запрос отправляется по относительному адресу /api/notes. Во время разработки Vite проксирует такой путь к серверу, поэтому браузер обращается к тому же origin, с которого открыто приложение.

Полный файл vite.config.js теперь содержит фиксированный порт и прокси:

import {defineConfig} from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
  plugins: [react()],
  server: {host: '127.0.0.1', port: 5174, strictPort: true,
    proxy: {'/api': {target: 'http://127.0.0.1:4301'}}}
});

StrictPort не позволяет незаметно перейти на соседний порт, если выбранный занят. Это важно для согласованного allowedOrigin на сервере. Адрес localhost может указывать на тот же компьютер, но в сравнении origin он не равен 127.0.0.1. Открывайте именно предусмотренный адрес, чтобы не исследовать случайное несовпадение конфигураций вместо загрузки данных.

Прокси относится к серверу разработки. После сборки он не превращается в работающий production API. Для настоящего размещения потребуется отдельно определить маршрутизацию /api и обслуживание статического клиента. Мы не добавляем такую конфигурацию в учебный проект и не объявляем его готовым к публикации.

Контракт карточек

В server/seed.json лежат прежние html-start, css-layout и js-state. Ответ GET /api/notes представляет массив объектов. GET /api/notes/html-start вернёт один объект. Отличие структуры важно: массив подходит для списка, объект — для подробной карточки. Один универсальный map на любой ответ здесь не работает.

Запрос Успешный ответ Изменение данных
GET /api/notes 200, массив Нет
GET /api/notes/html-start 200, одна запись Нет
POST /api/notes 201, созданная запись Добавление в память
DELETE /api/notes/html-start 204, без тела Удаление из памяти

Создание и удаление уже реализованы в fixture, но интерфейс пока их не вызывает. Это позволяет не менять серверный контракт посреди серии. Идентификатор новой карточки назначает сервер; клиент передаёт title, body, topic и minutes. Поле id не входит в допустимые входные поля POST.

Полный server/server.mjs, использующий встроенный модуль Node HTTP:

import {createServer} from 'node:http';
import {readFileSync} from 'node:fs';
import {randomUUID} from 'node:crypto';
const host = '127.0.0.1';
const port = 4301;
const allowedOrigin = 'http://127.0.0.1:5174';
const topics = new Set(['html', 'css', 'javascript']);
let notes = JSON.parse(readFileSync(new URL('./seed.json', import.meta.url), 'utf8'));
const limit = 16 * 1024;
class BadRequest extends Error {constructor(status) {super('Request rejected'); this.status = status;}}
function send(res, status, body) {
  if (res.destroyed || res.writableEnded) return;
  res.writeHead(status, {'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'});
  res.end(body === undefined ? undefined : JSON.stringify(body));
}
async function readJson(req) {
  if ((req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() !== 'application/json') throw new BadRequest(415);
  const chunks = [];
  let bytes = 0;
  for await (const chunk of req) {
    bytes += chunk.length;
    if (bytes > limit) {chunks.length = 0; continue;}
    chunks.push(chunk);
  }
  if (bytes > limit) throw new BadRequest(413);
  try {return JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(Buffer.concat(chunks)));}
  catch {throw new BadRequest(400);}
}
function normalize(value) {
  if (!value || typeof value !== 'object' || Array.isArray(value) ||
      Object.keys(value).some(key => !['title', 'body', 'topic', 'minutes'].includes(key)) ||
      typeof value.title !== 'string' || typeof value.body !== 'string' ||
      !topics.has(value.topic) || !Number.isInteger(value.minutes) || value.minutes < 5 || value.minutes > 180) throw new BadRequest(400);
  const title = value.title.trim();
  const body = value.body.trim();
  if (title.length < 3 || title.length > 120 || body.length < 20 || body.length > 2000) throw new BadRequest(400);
  return {title, body, topic: value.topic, minutes: value.minutes};
}
const server = createServer(async (req, res) => {
  try {
    if (req.headers.origin && req.headers.origin !== allowedOrigin) throw new BadRequest(403);
    const url = new URL(req.url, `http://${host}:${port}`);
    const match = /^\/api\/notes\/([a-z0-9-]{1,64})$/.exec(url.pathname);
    if (req.method === 'GET' && url.pathname === '/api/notes') return send(res, 200, notes);
    if (req.method === 'GET' && match) {
      const note = notes.find(item => item.id === match[1]);
      return note ? send(res, 200, note) : send(res, 404, {code: 'not_found'});
    }
    if (req.method === 'POST' && url.pathname === '/api/notes') {
      const fields = normalize(await readJson(req));
      const note = {id: `note-${randomUUID()}`, ...fields};
      notes = [...notes, note];
      return send(res, 201, note);
    }
    if (req.method === 'DELETE' && match) {
      const exists = notes.some(item => item.id === match[1]);
      if (!exists) return send(res, 404, {code: 'not_found'});
      notes = notes.filter(item => item.id !== match[1]);
      return send(res, 204);
    }
    send(res, 404, {code: 'not_found'});
  } catch (error) {
    send(res, error instanceof BadRequest ? error.status : 500, {code: 'request_failed'});
  }
});
server.requestTimeout = 15000;
server.headersTimeout = 10000;
server.listen(port, host, () => console.log(`Учебный API: http://${host}:${port}/api/notes`));

В пакете появляется отдельный скрипт api. После самостоятельной установки выбранных зависимостей читатель может держать npm run api и npm run dev в двух терминалах. Здесь это инструкция к исходнику, а не сообщение о выполненном запуске. Файлы всех этапов доступны в архиве курса.

Читаем ответ, а не предполагаем успех

Общая функция requestJson в src/lib/api.js проверяет response.ok, затем тип содержимого и JSON. Получение HTTP-ответа само по себе не означает успех: например, 404 является полученным ответом. Ошибка сети и ответ с ошибочным статусом — разные ситуации. Такое поведение определяется Fetch Standard.

export class HttpError extends Error {
  constructor(status) {super(`Сервер ответил ${status}`); this.status = status;}
}
export async function requestJson(url, options = {}) {
  const response = await fetch(url, {credentials: 'omit', cache: 'no-store', ...options,
    headers: {Accept: 'application/json', ...options.headers}});
  if (!response.ok) throw new HttpError(response.status);
  if (response.status === 204) return {status: 204, data: null};
  if ((response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase() !== 'application/json') {
    throw new Error('Ожидался JSON');
  }
  return {status: response.status, data: await response.json()};
}

После разбора JSON вызываем parseNotes из src/lib/models.js. Она проверяет массив, поля записей, диапазон минут, словарь тем и уникальность идентификаторов. JSON означает формат передачи, но не правильность модели. Сервер, случайно вернувший HTML страницы входа, не должен превращаться в пустой каталог или набор странных карточек.

Ограничение списка в тысячу записей — учебное ограничение клиентской модели. Это не постраничная загрузка и не защита от произвольно большого ответа: response.json читает тело целиком. Для большого каталога потребуется отдельная пагинация и более строгая политика размера ответа. В рамках текущих трёх исходных записей ограничение служит явным условием допустимых данных.

Путь /api/notes должен сохраниться при проксировании целиком. Наш сервер ожидает этот префикс, поэтому rewrite с удалением /api здесь ошибочен: он отправил бы /notes и получил 404. Такой случай полезно отличать от недоступного процесса. В первом сервер ответил, но не узнал путь; во втором нет подходящего ответа. Настройка адреса назначения и преобразование пути являются разными решениями.

Кроме того, seed не редактируется после каждого POST. Файл служит исходным состоянием, а текущие записи находятся в переменной notes. Если открыть JSON на диске и не увидеть новую карточку, это не означает, что создание проигнорировано. Для текущего состояния нужен GET; для сохранения на диске понадобится другой механизм, которого fixture не обещает.

Первый запрос компонента

Заменим Home полностью. Временно сосредоточимся на чтении каталога; локальный фильтр и скрытие вернутся после выделения собственного хука:

import {useEffect, useState} from 'react';
import LessonList from '../components/LessonList.jsx';
import {requestJson} from '../lib/api.js';
import {parseNotes} from '../lib/models.js';
export default function Home() {
  const [state, setState] = useState({status: 'loading', data: null, error: null});
  useEffect(() => {
    let active = true;
    const controller = new AbortController();
    setState({status: 'loading', data: null, error: null});
    async function load() {
      try {
        const response = await requestJson('/api/notes', {signal: controller.signal});
        const data = parseNotes(response.data);
        if (active) setState({status: 'success', data, error: null});
      } catch (error) {
        if (active && !controller.signal.aborted) setState({status: 'error', data: null, error});
      }
    }
    load();
    return () => {active = false; controller.abort();};
  }, []);
  return <section id="catalog"><h1>Учебный каталог</h1>
    {state.status === 'loading' && <p role="status">Загружаем карточки…</p>}
    {state.status === 'error' && <p role="alert" className="error">Не удалось получить каталог.</p>}
    {state.status === 'success' && <LessonList title="Все карточки" notes={state.data} />}
  </section>;
}

У эффекта есть свой AbortController и свой флаг active. При очистке запрос отменяется, а результат завершившейся работы больше не записывается в состояние этого запуска. В StrictMode дополнительная настройка и очистка допустимы: мы не считаем число GET-запросов признаком пользовательских действий.

Такой подход предотвращает использование устаревшего результата на клиенте. Он не доказывает, что сервер совсем не получил запрос. Для чтения это обычно не создаёт изменения данных; для POST разница станет принципиальной. Поэтому не будем переносить будущую отправку формы в этот эффект.

Ограничения учебного сервера

Процесс слушает только loopback. Проверка Origin ограничивает предусмотренный сценарий браузера, но не заменяет аутентификацию, CSRF-модель или проверку полномочий. Здесь нет пользователей, сессий, базы данных, транзакций и сохранения между перезапусками. Встроенный HTTP API описан в документации Node.

Тело POST ограничено шестнадцатью килобайтами входных байтов и декодируется как UTF-8 после накопления ограниченного массива чанков. При превышении лимита чанки очищаются и остаток потока дочитывается без накопления. Если полный запрос доставлен, fixture отвечает 413. При оборванном соединении обещать наблюдаемый ответ нельзя: браузер может увидеть сетевую ошибку.

После самостоятельного запуска ожидается тот же набор карточек, но его источник находится в серверной памяти. Если API недоступен, интерфейс должен показать ошибку, а не прежние данные из импорта. Следующий урок подробно разберёт состояния загрузки и кнопку повторного чтения, чтобы пользователь мог отличить пустой каталог от неудачного запроса.

Оглавление серии