INTEGRITY Документация

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,
		});
	}
}

Следующие шаги

Планирование задач

Узнайте о планировании и keepAlive для длительных операций.

Хранение и синхронизация состояния

Управление состоянием агента для хранения подписок.

Вызываемые методы

Предоставлять методы агента как конечные точки RPC.