HTTP-каркас чата поддержки
Договор сообщений уже определён, но браузеру ещё некуда обратиться. Создадим небольшой HTTP-сервер «Линии»: он отдаст страницу, создаст учебную сессию и вернёт список комнат. Эти операции выполняются обычными запросами, потому что они имеют начало, один ответ и понятный код результата. Длительное соединение для переписки добавим отдельно, сохранив тот же сервер и адрес приложения.
Снимок stages/02 самодостаточен: в нём находятся package.json, server.js, identity.js, contract.js и каталог public. Предполагается Node.js 24 LTS и Express 5.2.1. Установка пакетов и запуск в рамках подготовки не выполнялись; статус примера not_run. Если вы воспроизводите урок, распакуйте архив серии, выберите только второй снимок и выполняйте действия в собственном локальном окружении.
Один адрес для страницы и API
Страница и её запросы используют адрес http://127.0.0.1:4310. Это устраняет лишние вопросы о разных источниках, CORS и передаче cookie между доменами. Привязка к 127.0.0.1 также показывает назначение проекта: локальная демонстрация, которую мы не открываем через сетевой интерфейс для реальных посетителей. Написание localhost вместо числового адреса уже меняет строку Origin, поэтому в серии используйте адрес буквально.
Express описывает маршруты, а HTTP-сервер Node.js обслуживает соединения. Создаём сервер через createServer(app) сразу, хотя пока достаточно было бы app.listen(). Позднее тот же объект получит обработчик upgrade, и HTTP-страница с каналом чата будут делить порт. Это архитектурное решение не требует переписывать маршруты, когда добавляется WebSocket.
Полный файл server.js второго состояния выглядит следующим образом. Относительный путь к public вычисляется от файла модуля, поэтому запуск из другого рабочего каталога не должен случайно открыть соседнюю папку.
import express from 'express';
import { createServer } from 'node:http';
import { fileURLToPath } from 'node:url';
import { actors, roomIds } from './contract.js';
import { createDemoSession, readSession, origin } from './identity.js';
const app = express();
let stopping = false;
app.use(express.json({ limit: '2kb' }));
app.use(express.static(fileURLToPath(new URL('./public', import.meta.url))));
app.get('/health', (request, response) => {
response.status(stopping ? 503 : 200).json({ ready: !stopping });
});
app.post('/demo/session', (request, response) => {
if (request.get('Origin') !== origin) return response.sendStatus(403);
const id = createDemoSession(request.body?.actorId);
if (!id) return response.status(400).json({ error: 'invalid_demo_actor' });
response.set('Set-Cookie',
`line_demo=${id}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`);
response.json({ actor: actors[request.body.actorId] });
});
app.get('/api/rooms', (request, response) => {
const session = readSession(request);
if (!session) return response.sendStatus(401);
response.json({ rooms: roomIds });
});
app.use((error, request, response, next) => {
if (response.headersSent) return next(error);
response.status(error.status || 500).json({ error: 'request_failed' });
});
const server = createServer(app);
server.listen(4310, '127.0.0.1');
Маршрут /health возвращает ожидаемый JSON {"ready":true}. Пока он означает лишь готовность текущего процесса обслуживать учебные маршруты. Он не проверяет долговечность сообщений, сторонний провайдер входа или состояние очереди, потому что таких зависимостей в проекте ещё нет. Не следует превращать произвольный ответ с кодом 200 в обещание исправности всей будущей системы.
express.json({ limit: '2kb' }) разбирает небольшое тело запроса создания сессии. Это ограничение относится к HTTP, а не к будущим кадрам WebSocket. Лимит помогает остановить бессмысленно большой JSON до выполнения обработчика. Ошибка разбора передаётся завершающему middleware; четыре аргумента в его объявлении отличают обработчик ошибок Express от обычного промежуточного обработчика запроса.
Сессия хранится на сервере
Cookie содержит случайный идентификатор, а роль и разрешённые комнаты остаются в серверной карте. Объект участника берётся из contract.js. Код не доверяет роли из произвольного поля JSON: обработчик получает только actorId, проверяет его по конечному набору учебных персонажей и создаёт новый объект сессии. Эта проверка предотвращает неизвестное значение, но не проверяет личность человека, выбирающего персонажа.
Ниже полный файл identity.js. Карта ограничена сотней живых сессий, а старые записи удаляются при создании следующей. Срок жизни составляет один час и относится к памяти текущего процесса.
import { randomUUID } from 'node:crypto';
import { actors } from './contract.js';
const sessions = new Map();
const lifetime = 60 * 60 * 1000;
export const origin = 'http://127.0.0.1:4310';
export function createDemoSession(actorId) {
const actor = actors[actorId];
if (!actor) return null;
const now = Date.now();
for (const [key, value] of sessions) {
if (value.expiresAt <= now) sessions.delete(key);
}
if (sessions.size >= 100) return null;
const id = randomUUID();
sessions.set(id, { actor, expiresAt: now + lifetime });
return id;
}
export function readSession(request) {
const cookies = String(request.headers.cookie || '').split(';');
const cookie = cookies.map(value => value.trim())
.find(value => value.startsWith('line_demo='));
const id = cookie ? cookie.slice('line_demo='.length) : '';
const session = sessions.get(id);
if (!session || session.expiresAt <= Date.now()) return null;
return session;
}
export function canEnter(actor, roomId) {
return actor.rooms.includes(roomId);
}
После успешного POST /demo/session ожидается код 200, cookie line_demo и описание выбранного участника в JSON. Ответ можно использовать для оформления страницы, но серверные проверки всегда обращаются к своей карте. Подмена имени в видимом интерфейсе не изменит сохранённую роль. При неизвестном участнике сервер возвращает 400; при несовпадающем Origin — 403. Это разные причины отказа и разные места проверки.
Значение HttpOnly запрещает обычному скрипту страницы читать cookie через document.cookie. При этом браузер продолжает самостоятельно отправлять cookie серверу. Path=/ делает её доступной для всех маршрутов приложения, а Max-Age=3600 задаёт срок хранения браузером. Сервер отдельно хранит expiresAt: удаление или истечение браузерной cookie и срок серверной записи не должны считаться одной и той же проверкой.
Что увидит читатель
На странице есть выбор персонажа и кнопка создания сессии. После нажатия ожидается надпись, что учебная сессия создана, а клиент чата появится в четвёртом уроке. Кнопка отправки пока выключена. Это честное промежуточное состояние: доступность HTTP-страницы не означает, что канал переписки уже работает. Внешний вид специально остаётся простым, чтобы связь запроса, cookie и ответа была заметнее оформления.
Запрос /api/rooms без сессии должен получить 401. После выбора персонажа второй снимок возвращает обе учебные комнаты; права на конкретное обращение вводятся отдельным уроком. Такой промежуточный маршрут нельзя использовать для настоящих обращений. Уже сейчас полезно отделить отсутствие сессии от отсутствия права: первый вопрос относится к контексту запроса, второй — к предметному ресурсу внутри приложения.
В одном профиле браузера вкладки делят cookie. Если сначала выбрать Алису, а затем оператора в другой вкладке, следующие HTTP-запросы обеих вкладок используют новую cookie. Поэтому для сравнения двух участников понадобятся разные профили или отдельный приватный контекст. Это свойство браузерного хранения, а не ошибка комнат. Позднее существующий сокет сохранит контекст, с которым был открыт, до закрытия или истечения сессии.
Перезапуск процесса удаляет карту сессий. Браузер может продолжить хранить старый идентификатор, однако сервер уже не найдёт его и ответит 401. Ожидаемый способ продолжить локальный урок — снова выбрать учебного участника. Не пытайтесь восстанавливать роль из содержимого старой cookie: именно отсутствие доверия к клиентскому значению сохраняет границу между указателем сессии и серверными сведениями.
Ещё один полезный случай — открыть страницу по другому имени хоста при том же порте. HTTP-сервер может быть доступен, однако Origin браузера уже не совпадёт с разрешённой строкой. Отказ создания сессии тогда ожидаем, а не свидетельствует о неправильной cookie. У браузера также есть область cookie по хосту, поэтому localhost и числовой адрес не следует менять между действиями серии. В действующем приложении разрешённые источники задаются проверенной конфигурацией, особенно за прокси, но никогда не берутся автоматически из произвольного входного заголовка Host. Иначе клиент мог бы сам определить условие, по которому его запрос считается разрешённым. Фиксированный локальный адрес делает этот вопрос явным.
Разбор маршрутов и JSON основан на Express 5 API, а разделение приложения и сервера — на HTTP API Node.js 24. После этого состояния есть воспроизводимая HTTP-основа с ограниченным вводом и явной учебной идентичностью. Теперь можно добавить переключение протокола, не меняя смысл сессии и не передавая её через URL.