Webhooks и повторная доставка события
Webhook сообщает другой системе, что произошло событие. Он полезен, когда публикация статьи должна привести к обновлению cache или поискового индекса. Уведомление не является гарантией, что действие выполнено один раз и в нужном порядке. Сеть может разорваться после обработки запроса, отправитель — повторить доставку, а получатель — перезапуститься. Разберём эти состояния на «Редакционном доме».
После урока подготовленный получатель будет принимать события публикации, снятия публикации и удаления Article, проверять секрет и сохранять небольшой durable inbox. Автоматического worker здесь нет. Мы не обещаем обновление реального сайта или индекса и не запускали обработчик. Результат — понятный контракт приёма, на который можно опереться при дальнейшем расширении.
Отправитель выбирает события
В Settings Strapi создайте локальный webhook с адресом http://127.0.0.1:4178/webhooks/strapi. Выберите события entry.publish, entry.unpublish и entry.delete. В первом уроке config/server.js уже определил defaultHeaders Authorization с отдельным WEBHOOK_TOKEN. Тот же token указывается в окружении сайта. Он не совпадает с CMS_TOKEN, потому что направления доступа различаются.
Документированные события и настройка заголовков описаны в Strapi Webhooks. Встроенные callbacks не следует считать подписанными автоматически только потому, что документация показывает рекомендации HMAC. Наш пример использует общий Bearer secret для локального отправителя; полноценная подпись потребовала бы согласованного изменения отправляющего механизма.
События сохранения draft не нужны обычному опубликованному каталогу. Поэтому мы не подписываем получатель на каждое entry.update ради демонстрации активности. Уведомление должно соответствовать действию, которое действительно нужно потребителю. Если бы мы индексировали внутренний редакционный поиск, набор событий был бы другим и его полномочия тоже отличались бы.
URL получателя не должен принимать произвольные данные как истину. Даже в loopback-сценарии полезно проверять Authorization, размер body, корректность JSON и тип ресурса. Внешнее размещение добавит HTTPS, управление секретами и сетевые ограничения. Наличие callback в панели ещё не доказывает, что отправитель может связаться с вашим портом.
Повтор не создаёт второй файл
Полный site/webhook.mjs включён сервером предыдущих уроков. Он читает исходные bytes, вычисляет SHA-256 и пытается записать inbox-файл с flag wx. Создание уже существующего имени вызывает EEXIST, который считается обычным повтором. Успешное сохранение и повтор получают 202.
import { createHash, timingSafeEqual } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
export async function acceptWebhook(request, response) {
const expected = Buffer.from(`Bearer ${process.env.WEBHOOK_TOKEN || ''}`);
const supplied = Buffer.from(request.headers.authorization || '');
if (!process.env.WEBHOOK_TOKEN || supplied.length !== expected.length ||
!timingSafeEqual(expected, supplied)) {
response.writeHead(401).end('Unauthorized');
return;
}
const chunks = [];
let length = 0;
for await (const chunk of request) {
length += chunk.length;
if (length > 65536) {
response.writeHead(413).end('Too large');
return;
}
chunks.push(chunk);
}
const raw = Buffer.concat(chunks);
let event;
try { event = JSON.parse(raw.toString('utf8')); }
catch { response.writeHead(400).end('Invalid JSON'); return; }
if (!['entry.publish', 'entry.unpublish', 'entry.delete'].includes(event.event)) {
response.writeHead(204).end();
return;
}
if (event.model !== 'article' || !event.entry?.documentId) {
response.writeHead(400).end('Unexpected resource');
return;
}
const key = createHash('sha256').update(raw).digest('hex');
const directory = resolve('.state', 'inbox');
await mkdir(directory, { recursive: true });
try {
await writeFile(resolve(directory, `${key}.json`), raw, { flag: 'wx' });
} catch (error) {
if (error.code !== 'EEXIST') throw error;
}
response.writeHead(202).end('Accepted');
}
Directory .state/inbox находится вне публичных routes. Сайт не предоставляет endpoint для скачивания его содержимого. Тем не менее body webhook может содержать редакционные значения; локальная папка требует подходящих прав и не должна попадать в общедоступный архив. В наборе курса inbox отсутствует, поскольку обработчик не запускался.
Почему не использовать Set в памяти? После перезапуска Set пуст, и тот же callback снова будет считаться новым. Файл сохраняет знание о принятом raw body между запусками. Для одного локального процесса это понятный durable приём. Он не создаёт распределённой транзакции и не решает все свойства промышленной очереди.
SHA-256 здесь не подтверждает отправителя. Это только ключ одинакового содержимого. Подлинность контролируется отдельно через secret. Если два payload имеют одинаковый смысл, но отличаются временем или порядком полей, их bytes и hash могут отличаться. Наш контракт подавляет точный повтор, а не любые семантически похожие уведомления.
Приём и действие — два этапа
Ответ 202 означает, что получатель сохранил сообщение или обнаружил его точный дубль. Он не означает, что обновлён cache. Worker отсутствует намеренно: сначала полезно понять разницу между подтверждением доставки и результатом полезного действия. Если записывать 200 до сохранения, авария может потерять событие, хотя отправитель уверен в успешном приёме.
Для будущего worker потребуется дополнительное состояние pending/processing/done и правило повторного действия. Например, «перечитать опубликованный документ и заменить его запись индекса» обычно удобнее, чем «добавить ещё одну запись индекса». Второе действие создаёт дубли при повторе. Идемпотентность определяется тем, как действие относится к одному документу, а не одним словом в имени функции.
Не используйте callback body как окончательную версию страницы без проверки. Уведомления могут прийти с задержкой: событие публикации после события снятия публикации. Будущий worker должен запросить текущее published-состояние CMS по documentId/locale и привести индекс к нему. Тогда позднее уведомление становится поводом для сверки, а не командой вернуть старый текст.
Удаление требует той же логики. Если текущая published-запись отсутствует, индекс должен убрать документ. Однако network error при сверке нельзя принять за отсутствие Article. Отсутствие ответа источника и успешный ответ без документа различаются, как мы уже видели в сайте-потребителе. При временной ошибке задача остаётся для повторной обработки.
Ограничения учебного inbox
Файл записывается локально и сохраняет raw payload. В промышленном сценарии понадобятся контроль дискового места, срок хранения, резервирование, несколько worker и атомарное управление обработкой. SHA-ключ может храниться отдельно от полезных данных; персональные сведения следует минимизировать. В нашей вымышленной библиотеке нет пользователей Content API и реальных контактов, но это не делает любой будущий payload безопасным для бесконечного хранения.
Также не обещайте автоматический retry отправителя с определённым интервалом, не проверив выбранный механизм. Контракт нашего получателя должен выдерживать повтор независимо от того, кто его инициировал. Для ручного опыта читатель может сохранить одно учебное body и доставить его дважды с тем же Authorization. Мы такую доставку не выполняли, поэтому количество файлов и HTTP-ответы остаются ожидаемыми.
Нужно определить и ответ при ошибке диска. Если файл не удалось записать по причине, отличной от EEXIST, получатель не должен сообщать успешный durable приём. Исключение попадает в серверный обработчик и даёт 503. Это сохраняет возможность повторной доставки после устранения проблемы. В дальнейшем безопасный журнал должен помочь отличить заполненный диск от неверного body, но не печатать секрет Authorization целиком. Сообщение о сбое и содержимое callback имеют разные области доступа.
Предполагаемый результат: два одинаковых payload дают один inbox-файл, неправильный secret приводит к 401, слишком большой body — к 413, событие другого вида — к 204 без записи. Это четыре разных решения обработчика. Их смысл виден в коде, а подтверждение потребует будущего локального запуска. Теперь можно перейти к переносу исходных данных: импорт также нуждается в явной области действия, иначе повторная команда рискует создать множество одинаковых статей.