← Создание агентов на Cloudflare / agents / communication-channels / webhooks
Push-уведомления
Отправляйте push-уведомления браузера из вашего агента, даже если пользователь закрыл вкладку. За счёт сочетания постоянного состояния агента (для хранения push-подписок), планирования (для доставки по расписанию) и Web Push API ↗, вы можете достучаться до пользователей, которые полностью офлайн.
Как это работает
Browser Agent (Durable Object)
─────── ──────────────────────
1. Register service worker
2. Subscribe to push (VAPID key)
3. Send subscription to agent ──────► Store in this.state
4. Create reminder ─────────────────► this.schedule(delay, "sendReminder", payload)
... user closes tab ...
5. Alarm fires → sendReminder()
web-push sends encrypted payload
│
6. Service worker receives push ◄─────────────┘
7. showNotification()Агент устойчиво хранит push-подписки в своём состоянии и использует this.schedule() чтобы отправлять уведомления в нужный момент. Когда срабатывает будильник, агент вызывает конечную точку push-сервиса, используя web-push ↗ библиотеку. Service worker браузера получает событие push и показывает нативное уведомление.
Предварительные требования
Сгенерируйте ключи VAPID
Для Web Push требуется пара ключей VAPID (Voluntary Application Server Identification). Сгенерируйте её:
npx web-push generate-vapid-keysСохраните ключи в .env файл для локальной разработки:
VAPID_PUBLIC_KEY=BGxK...
VAPID_PRIVATE_KEY=abc1...
VAPID_SUBJECT=mailto:[email protected]В продакшене используйте wrangler secret put:
wrangler secret put VAPID_PUBLIC_KEY
wrangler secret put VAPID_PRIVATE_KEY
wrangler secret put VAPID_SUBJECTСоздайте агента
У агента три задачи: хранить push-подписки, планировать напоминания и отправлять уведомления при срабатывании будильников.
import { Agent, callable, routeAgentRequest } from "agents";
import webpush from "web-push";
export class ReminderAgent extends Agent {
initialState = {
subscriptions: [],
reminders: [],
};
@callable()
getVapidPublicKey() {
return this.env.VAPID_PUBLIC_KEY;
}
@callable()
async subscribe(subscription) {
const exists = this.state.subscriptions.some(
(s) => s.endpoint === subscription.endpoint,
);
if (!exists) {
this.setState({
...this.state,
subscriptions: [...this.state.subscriptions, subscription],
});
}
return { ok: true };
}
@callable()
async unsubscribe(endpoint) {
this.setState({
...this.state,
subscriptions: this.state.subscriptions.filter(
(s) => s.endpoint !== endpoint,
),
});
return { ok: true };
}
@callable()
async createReminder(message, delaySeconds) {
const id = crypto.randomUUID();
const scheduledAt = Date.now() + delaySeconds * 1000;
const reminder = { id, message, scheduledAt, sent: false };
this.setState({
...this.state,
reminders: [...this.state.reminders, reminder],
});
await this.schedule(delaySeconds, "sendReminder", { id, message });
return reminder;
}
async sendReminder(payload) {
webpush.setVapidDetails(
this.env.VAPID_SUBJECT,
this.env.VAPID_PUBLIC_KEY,
this.env.VAPID_PRIVATE_KEY,
);
const deadEndpoints = [];
await Promise.all(
this.state.subscriptions.map(async (sub) => {
try {
await webpush.sendNotification(
sub,
JSON.stringify({
title: "Reminder",
body: payload.message,
tag: `reminder-${payload.id}`,
}),
);
} catch (err) {
const statusCode =
err instanceof webpush.WebPushError ? err.statusCode : 0;
if (statusCode === 404 || statusCode === 410) {
deadEndpoints.push(sub.endpoint);
}
}
}),
);
if (deadEndpoints.length > 0) {
this.setState({
...this.state,
subscriptions: this.state.subscriptions.filter(
(s) => !deadEndpoints.includes(s.endpoint),
),
});
}
this.setState({
...this.state,
reminders: this.state.reminders.map((r) =>
r.id === payload.id ? { ...r, sent: true } : r,
),
});
this.broadcast(
JSON.stringify({
type: "reminder_sent",
id: payload.id,
timestamp: Date.now(),
}),
);
}
}
export default {
async fetch(request, env) {
return (
(await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 })
);
},
};import { Agent, callable, routeAgentRequest } from "agents";
import webpush from "web-push";
type Subscription = {
endpoint: string;
expirationTime: number | null;
keys: {
p256dh: string;
auth: string;
};
};
type Reminder = {
id: string;
message: string;
scheduledAt: number;
sent: boolean;
};
type ReminderAgentState = {
subscriptions: Subscription[];
reminders: Reminder[];
};
export class ReminderAgent extends Agent<Env, ReminderAgentState> {
initialState: ReminderAgentState = {
subscriptions: [],
reminders: [],
};
@callable()
getVapidPublicKey(): string {
return this.env.VAPID_PUBLIC_KEY;
}
@callable()
async subscribe(subscription: Subscription): Promise<{ ok: boolean }> {
const exists = this.state.subscriptions.some(
(s) => s.endpoint === subscription.endpoint,
);
if (!exists) {
this.setState({
...this.state,
subscriptions: [...this.state.subscriptions, subscription],
});
}
return { ok: true };
}
@callable()
async unsubscribe(endpoint: string): Promise<{ ok: boolean }> {
this.setState({
...this.state,
subscriptions: this.state.subscriptions.filter(
(s) => s.endpoint !== endpoint,
),
});
return { ok: true };
}
@callable()
async createReminder(
message: string,
delaySeconds: number,
): Promise<Reminder> {
const id = crypto.randomUUID();
const scheduledAt = Date.now() + delaySeconds * 1000;
const reminder: Reminder = { id, message, scheduledAt, sent: false };
this.setState({
...this.state,
reminders: [...this.state.reminders, reminder],
});
await this.schedule(delaySeconds, "sendReminder", { id, message });
return reminder;
}
async sendReminder(payload: { id: string; message: string }) {
webpush.setVapidDetails(
this.env.VAPID_SUBJECT,
this.env.VAPID_PUBLIC_KEY,
this.env.VAPID_PRIVATE_KEY,
);
const deadEndpoints: string[] = [];
await Promise.all(
this.state.subscriptions.map(async (sub) => {
try {
await webpush.sendNotification(
sub,
JSON.stringify({
title: "Reminder",
body: payload.message,
tag: `reminder-${payload.id}`,
}),
);
} catch (err: unknown) {
const statusCode =
err instanceof webpush.WebPushError ? err.statusCode : 0;
if (statusCode === 404 || statusCode === 410) {
deadEndpoints.push(sub.endpoint);
}
}
}),
);
if (deadEndpoints.length > 0) {
this.setState({
...this.state,
subscriptions: this.state.subscriptions.filter(
(s) => !deadEndpoints.includes(s.endpoint),
),
});
}
this.setState({
...this.state,
reminders: this.state.reminders.map((r) =>
r.id === payload.id ? { ...r, sent: true } : r,
),
});
this.broadcast(
JSON.stringify({
type: "reminder_sent",
id: payload.id,
timestamp: Date.now(),
}),
);
}
}
export default {
async fetch(request: Request, env: Env) {
return (
(await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 })
);
},
} satisfies ExportedHandler<Env>; sendReminder колбэк отвечает за три вещи: доставку push-уведомления через web-push библиотеку, удаляет неактуальные подписки (push-сервис возвращает 404 или 410, если подписка больше не действительна) и рассылает сообщения всем подключённым клиентам, чтобы интерфейс обновлялся в реальном времени.
Настройте service worker
Service worker выполняется в браузере и получает push-события, даже если нет открытых вкладок. Поместите этот файл по пути public/sw.js чтобы он обслуживался с корня вашего домена:
self.addEventListener("push", (event) => {
if (!event.data) return;
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title || "Notification", {
body: data.body || "",
icon: data.icon || "/favicon.ico",
tag: data.tag,
data: data.data,
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(
self.clients.matchAll({ type: "window" }).then((windowClients) => {
for (const client of windowClients) {
if (
client.url.includes(self.location.origin) &&
"focus" in client
) {
return client.focus();
}
}
return self.clients.openWindow("/");
}),
);
}); push обработчик событий разбирает полезную нагрузку JSON и отображает нативное уведомление. notificationclick обработчик переключает фокус на существующую вкладку или открывает новую, когда пользователь нажимает на уведомление.
Создайте клиент
Клиенту нужно: зарегистрировать service worker, запросить разрешение на показ уведомлений, подписаться на push с помощью публичного ключа VAPID и отправить подписку агенту.
Зарегистрируйте service worker
useEffect(() => {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
return;
}
navigator.serviceWorker.register("/sw.js");
}, []);useEffect(() => {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
return;
}
navigator.serviceWorker.register("/sw.js");
}, []);Подпишитесь на push
Получите публичный ключ VAPID от агента, а затем оформите подписку через Push API:
function base64urlToUint8Array(base64url) {
const padded = base64url + "=".repeat((4 - (base64url.length % 4)) % 4);
const binary = atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
async function subscribeToPush(agent) {
const permission = await Notification.requestPermission();
if (permission !== "granted") return;
const vapidPublicKey = await agent.call("getVapidPublicKey");
const reg = await navigator.serviceWorker.ready;
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: base64urlToUint8Array(vapidPublicKey).buffer,
});
const subJson = subscription.toJSON();
await agent.call("subscribe", [
{
endpoint: subJson.endpoint,
expirationTime: subJson.expirationTime ?? null,
keys: subJson.keys,
},
]);
}function base64urlToUint8Array(base64url: string): Uint8Array {
const padded = base64url + "=".repeat((4 - (base64url.length % 4)) % 4);
const binary = atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
async function subscribeToPush(
agent: ReturnType<typeof useAgent>,
) {
const permission = await Notification.requestPermission();
if (permission !== "granted") return;
const vapidPublicKey = await agent.call("getVapidPublicKey");
const reg = await navigator.serviceWorker.ready;
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: base64urlToUint8Array(vapidPublicKey).buffer,
});
const subJson = subscription.toJSON();
await agent.call("subscribe", [
{
endpoint: subJson.endpoint,
expirationTime: subJson.expirationTime ?? null,
keys: subJson.keys,
},
]);
}Создавайте напоминания
После сохранения подписки создание напоминания сводится к одному вызову RPC. Планирование и доставку берёт на себя агент:
await agent.call("createReminder", ["Check the oven", 300]);await agent.call("createReminder", ["Check the oven", 300]);Агент планирует будильник на 300 секунд (5 минут). Когда он срабатывает, приходит push-уведомление, даже если пользователь закрыл вкладку несколько минут назад.
Конфигурация
wrangler.jsonc
{
"name": "push-notifications",
"compatibility_date": "2026-01-28",
"compatibility_flags": ["nodejs_compat"],
"main": "src/server.ts",
"durable_objects": {
"bindings": [
{ "name": "ReminderAgent", "class_name": "ReminderAgent" },
],
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["ReminderAgent"] }],
"assets": {
"not_found_handling": "single-page-application",
},
} nodejs_compat требуется флаг совместимости для web-push библиотека.
Зависимости
npm install agents web-pushОсобенности работы в продакшене
Истечение подписки
Push-подписки могут истекать или быть отозваны пользователем. Всегда обрабатывайте ответы 404 и 410 от push-сервиса, удаляя неактуальную подписку из состояния, как показано в sendReminder приведённый выше пример.
Агенты для каждого пользователя или общие агенты
В большинстве приложений используйте одного агента на пользователя (в качестве имени агента укажите ID пользователя). Это изолирует подписки и напоминания каждого пользователя. Для массовых уведомлений (одно и то же сообщение многим пользователям) все подписки может хранить общий агент, но нужно учитывать рост размера состояния по мере увеличения списка подписок.
Сочетание push-уведомлений с широковещательной рассылкой через WebSocket
Используйте this.broadcast() для клиентов, которые сейчас подключены (мгновенно, без обращения к push-сервису), и Web Push для клиентов, которые офлайн. sendReminder приведённый выше пример делает и то, и другое: подключённые клиенты получают сообщение WebSocket в реальном времени, а офлайн-клиенты получают push-уведомление.
Несколько устройств
Один пользователь может подписаться с нескольких браузеров или устройств. Агент хранит каждую подписку отдельно, и sendReminder перебирает все из них. Каждое устройство получает собственное push-уведомление.
Повтор при сбое
Если push-сервис возвращает ошибку 5xx (временный сбой), можно повторить попытку с помощью this.schedule() с небольшой задержкой:
try {
await webpush.sendNotification(sub, payload);
} catch (err) {
const statusCode = err instanceof webpush.WebPushError ? err.statusCode : 0;
if (statusCode >= 500) {
await this.schedule(60, "retrySendNotification", {
endpoint: sub.endpoint,
payload,
});
}
}try {
await webpush.sendNotification(sub, payload);
} catch (err: unknown) {
const statusCode =
err instanceof webpush.WebPushError ? err.statusCode : 0;
if (statusCode >= 500) {
await this.schedule(60, "retrySendNotification", {
endpoint: sub.endpoint,
payload,
});
}
}