Decorator withLongPolling
The updated withLongPolling decorator is a high-performance tool for building a real-time update infrastructure using the standard HTTP protocol. In the v2 architecture, the decorator is completely free of hidden background while loops and recursive timeouts that were breaking the state manager scheduler.
It now operates as a pure command ticker: it executes exactly one long network transaction and then declaratively requests the business service to advance the reactive pollingTick signal. Changing the signal invalidates the engine.resource resource cache, forcing the engine core to legitimately and natively open the next HTTP connection.
Example
(wip)
ts
import { AbstractService, withLongPolling } from '@pravosleva/reactive-engine'
import { CountdownService } from './service.CountdownService'
export interface NotificationItem {
id: string
text: string
timestamp: string
type: 'like' | 'message'
}
interface ServerResponse {
ok: boolean
notifications: NotificationItem[]
}
// Из кортежа убран сигнал isServerOnline.
// Теперь запуск триггерится строго канонично: по шагу времени или фокусу вкладки!
type TPollingDeps = [isLoopActive: boolean, pollingTick: number, isTabActive: boolean];
export class LiveNotificationsLogic extends AbstractService {
public countdown = this.engine.inject(CountdownService)
// Источники правды для UI и графа
public userIdSignal = this.createSignal<string>('user-123', 'live:signal:user-id')
public isServerOnline = this.createSignal<boolean>(true, 'live:signal:server-status')
public pollingTick = this.createSignal<number>(0, 'live:signal:tick')
public isLoopActive = this.createSignal<boolean>(false, 'live:signal:loop-active')
public isTabActive = this.createSignal<boolean>(true, 'live:signal:tab-active')
public receivedNotifications = this.createSignal<NotificationItem[]>([], 'live:signal:feed-list')
public outgoingBuffer = this.createSignal<NotificationItem[]>([], 'live:signal:outgoing-buffer')
private feedSessionId = this.createSignal<string>(Math.random().toString(), 'live:signal:session-id')
private resourceSessionMap = new Map<any, string>()
// Метрики
public requestCount = this.createSignal<number>(0, 'live:signal:request-count')
public currentStatus = this.createSignal<string>('🛑 Поллинг остановлен. Нажмите Init для запуска.', 'live:signal:status')
// Очищенный реактивный кортеж зависимостей
private apiDeps = this.engine.computed<TPollingDeps>(() => [
this.isLoopActive.value,
this.pollingTick.value,
this.isTabActive.value
], 'live:computed:apiDeps')
/**
* ДЕКЛАРАТИВНЫЙ РЕСУРС ЛОНГ-ПОЛЛИНГА
* Полностью переведен на v2 Options API декоратора withLongPolling с методом onError.
*/
public notificationsResource = this.engine.resource<ServerResponse | null, TPollingDeps>(
withLongPolling(
async (deps, abortSignal) => {
const [isLoopActive, pollingTick, isTabActive] = deps
if (!this.isServerOnline.value) {
this.currentStatus.value = `🚨 Ошибка 502: Нет связи. Буферизируем...`
throw new Error('502 Bad Gateway')
}
this.requestCount.value += 1
this.currentStatus.value = `📡 [Такт: ${pollingTick}] Удерживаем длинное соединение...`
const notificationsPayload = [...this.outgoingBuffer.value]
this.outgoingBuffer.value = []
const mockPayload: ServerResponse = { ok: true, notifications: notificationsPayload }
const queryParams = new URLSearchParams({
userId: String(this.userIdSignal.value),
tick: String(pollingTick),
_addData: JSON.stringify(mockPayload)
}).toString()
try {
const response = await fetch(`/fake-feed-vite-proxy/?${queryParams}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: this.userIdSignal.value, notifications: notificationsPayload }),
signal: abortSignal
})
if (!response.ok) {
this.outgoingBuffer.value = [...notificationsPayload, ...this.outgoingBuffer.value]
throw new Error(`Ошибка HTTP: ${response.status}`)
}
const jsonResult = await response.json()
this.resourceSessionMap.set(jsonResult, this.feedSessionId.value)
return jsonResult
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') throw error
this.resourceSessionMap.set(mockPayload, this.feedSessionId.value)
return mockPayload
}
},
{
onNextTick: () => this.nextTick(),
/**
* Реализует мост между ошибкой декоратора и инфраструктурным UI-таймером.
*
* @param {number} ms - Время ожидания в мс до следующей попытки (Backoff).
* @param {() => void} onRetryScheduled - Сигнальный триггер для фиксации шага экспоненты.
*/
onError: (ms: number, onRetryScheduled: () => void) => this.countdown.start(ms, onRetryScheduled),
delay: 3000,
errorInitialDelay: 2000,
errorMaxDelay: 8000
}
),
this.apiDeps,
{
name: 'live:resource:notifications',
resetDataOnSourceChange: true,
validateBeforeFetch: () => this.isLoopActive.value && this.isTabActive.value,
responseValidate: (res: any) => {
const payload = res as ServerResponse | null
if (!payload || payload.ok !== true) {
this.currentStatus.value = '🚨 Ошибка валидации структуры ответа'
return 'Некорректная структура JSON ответа'
}
const activeSession = this.feedSessionId.value
// Сначала жестко проверяем наличие элементов в прилетевшей пачке бэкенда.
// Если пачка не пустая — мы ВСЕГДА выводим статус успешной доставки, убирая дедлок "Канал чист"!
if (payload.notifications && payload.notifications.length > 0) {
const incomingItems = payload.notifications
const currentList = this.receivedNotifications.value
// Фильтруем дубликаты
const filteredItems = incomingItems.filter(item => !currentList.some(exist => exist.id === item.id))
if (filteredItems.length > 0) {
this.receivedNotifications.value = [...filteredItems, ...currentList]
this.currentStatus.value = `⚡ Успешно доставлен пакет из ${filteredItems.length} событий!`
} else {
// Если все элементы из пачки уже были в ленте ранее
this.currentStatus.value = '⏳ Канал обновлен. Новые события уже обработаны.'
}
} else {
// Строго если бэкенд прислал абсолютно пустой массив (нормальный таймаут удержания)
this.currentStatus.value = '⏳ Канал чист. Новых событий на бэкенде нет.'
}
return true
}
}
)
public setTabVisibility(isActive: boolean) {
this.isTabActive.value = isActive
if (isActive) {
if (this.isLoopActive.value) {
this.currentStatus.value = '🔄 Вкладка снова активна. Возобновляем лонг-поллинг...'
this.nextTick()
}
} else {
this.countdown.stop()
this.currentStatus.value = '⏸️ Вкладка неактивна. Лонг-поллинг временно заморожен.'
}
}
/**
* ИМПЕРАТИВНЫЙ СТАРТЕР (Init)
* 🎓 Мы убрали вызов .refetch(). Переключения реактивного сигнала
* теперь абсолютно достаточно, чтобы движок сам легитимно открыл соединение.
*/
public startLongPolling = () => {
if (this.isLoopActive.value) return
this.isLoopActive.value = true
}
public nextTick() {
if (this.isLoopActive.value && this.isTabActive.value) {
this.pollingTick.value += 1
}
}
public async triggerEventOnServer(text: string, type: 'like' | 'message') {
const newItem: NotificationItem = {
id: Math.random().toString(36).substring(2, 11),
text,
timestamp: new Date().toLocaleTimeString(),
type
}
this.outgoingBuffer.value = [...this.outgoingBuffer.value, newItem]
this.currentStatus.value = `⏳ Событие зафиксировано и ждет отправки.`
// Если поллинг спит (Destroy) — мы НЕ должны пинать refetch ресурса!
// События будут копиться в очереди, пока пользователь сам не нажмет Init.
if (this.isLoopActive.value && this.isServerOnline.value && this.isTabActive.value) {
this.notificationsResource.refetch()
}
}
/**
* Изменение isServerOnline больше не дергает транзакции планировщика напрямую.
* Код работает плавно, без дедлоков рантайма.
*/
public toggleServerStatus() {
this.isServerOnline.value = !this.isServerOnline.value
if (this.isServerOnline.value) {
this.countdown.stop()
this.currentStatus.value = '🔄 Сеть найдена. Возобновляем лонг-поллинг...'
// Пинаем перезапуск только если сам поллинг сейчас активен
if (this.isLoopActive.value) {
this.notificationsResource.refetch()
}
} else {
this.currentStatus.value = '🚨 Связь потеряна. Накапливаем буфер.'
if (this.isLoopActive.value) {
this.nextTick()
}
}
}
public clearFeed() {
this.feedSessionId.value = Math.random().toString()
this.receivedNotifications.value = []
this.currentStatus.value = '🗑️ Лента успешно очищена.'
}
public destroy() {
this.countdown.stop()
this.isLoopActive.value = false
this.pollingTick.value = 0
this.resourceSessionMap.clear()
this.outgoingBuffer.value = []
this.receivedNotifications.value = []
this.requestCount.value = 0
this.isServerOnline.value = true
this.currentStatus.value = '🛑 Поллинг остановлен. Нажмите Init для запуска.'
}
}tsx
import { useEffect } from 'react'
import baseClasses from '~/ui.common.module.scss'
import btnClasses from '~/ui.button.module.scss'
import { ReactiveEngine } from '@pravosleva/reactive-engine'
import { LiveNotificationsLogic } from './service.LiveNotificationsLogic'
import clsx from 'clsx'
const engine = new ReactiveEngine()
export const LiveNotificationsExample = () => {
const logic = engine.inject(LiveNotificationsLogic)
const userId = engine.use(logic.userIdSignal)
const isServerOnline = engine.use(logic.isServerOnline)
const isLoopActive = engine.use(logic.isLoopActive)
const isTabActive = engine.use(logic.isTabActive) // Подписываемся на флаг вкладки
const requestCount = engine.use(logic.requestCount)
const currentStatus = engine.use(logic.currentStatus)
const secondsToRetry = engine.use(logic.countdown.secondsLeft)
const receivedNotifications = engine.use(logic.receivedNotifications)
const outgoingBuffer = engine.use(logic.outgoingBuffer)
// ИСПРАВЛЕНИЕ: Комплексный эффект инициализации и отслеживания активности вкладки
useEffect(() => {
logic.startLongPolling()
const handleVisibilityChange = () => {
// Передаем статус видимости вкладки (true/false) в бизнес-сервис
logic.setTabVisibility(document.visibilityState === 'visible')
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
logic.destroy()
}
}, [logic])
return (
<div
className={clsx(baseClasses.unit, baseClasses.stack2)}
style={{ padding: '24px', fontFamily: 'system-ui', width: '600px' }}
>
<div className={baseClasses.absoluteUnitLabel}>
Long Polling Live Stream Demo {!isTabActive && '⏸️'}
</div>
{/* Описание */}
<div style={{ fontSize: 'small' }}>
⚙️ С помощью кнопок <strong>Init</strong> и <strong>Destroy</strong> вы можете полностью
запускать и гасить фоновые сетевые потоки. Переключение между ними абсолютно взаимоисключающее.
</div>
{/* УПРАВЛЕНИЕ ЖИЗНЕННЫМ ЦИКЛОМ (Строго ваша стилистика кнопок) */}
<div style={{ display: 'flex', gap: '16px', width: '100%' }}>
<button
disabled={isLoopActive}
onClick={() => logic.startLongPolling()}
className={clsx(btnClasses.btn, btnClasses.neonBtn, btnClasses['neonBtn--primary'], {
[btnClasses['neonBtn--outlined']]: isLoopActive,
[btnClasses['neonBtn--contained']]: !isLoopActive,
})}
style={{ flex: 1, opacity: isLoopActive ? 0.3 : 1 }}
>
Init
</button>
<button
disabled={!isLoopActive}
onClick={() => logic.destroy()}
className={clsx(btnClasses.btn, btnClasses.neonBtn, btnClasses['neonBtn--danger'], btnClasses['neonBtn--outlined'])}
style={{ flex: 1, opacity: !isLoopActive ? 0.3 : 1 }}
>
Destroy
</button>
</div>
{/* Индикаторы сети */}
<div style={{ display: 'flex', justifyContent: 'space-between', background: '#15151a', color: '#fff', padding: '16px', borderRadius: '8px', fontSize: '13px' }}>
<div>
Сессия: <strong style={{ color: '#00b4d8' }}>{userId}</strong>
{!isTabActive && <span style={{ color: '#ffb74d', marginLeft: '8px' }}>(Вкладка в фоне)</span>}
</div>
<div>Совершено HTTP-запросов: <strong style={{ color: '#e6af2e' }}>{requestCount}</strong></div>
</div>
{/* Переключатель сервера */}
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '16px', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', fontSize: '14px' }}>
<span style={{
display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%',
background: isServerOnline ? '#4caf50' : '#ef5350',
boxShadow: isServerOnline ? '0 0 8px #4caf50' : '0 0 8px #ef5350'
}} />
<span>Бэкенд: <strong>{isServerOnline ? 'ONLINE' : 'OFFLINE (502)'}</strong></span>
</div>
<button
disabled={!isLoopActive}
onClick={() => logic.toggleServerStatus()}
className={clsx(
btnClasses.btn,
btnClasses.neonBtn,
{
[btnClasses['neonBtn--primary']]: !isServerOnline,
[btnClasses['neonBtn--danger']]: isServerOnline,
},
btnClasses['neonBtn--outlined']
)}
>
{isServerOnline ? 'Выключить сервер' : 'Включить сервер'}
</button>
</div>
{/* Статус-бар */}
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', fontSize: '12px', minHeight: '38px', background: '#111', borderRadius: '8px', padding: '6px 12px', textAlign: 'center' }}>
{!isServerOnline && isLoopActive ? (
<span style={{ color: '#ef5350' }}>
🚨 Сеть разорвана. Накапливаем буфер. Переподключение через: <strong style={{ fontSize: '14px', color: '#fff', background: '#3a1a1a', padding: '2px 6px', borderRadius: '4px', marginLeft: '4px' }}>{secondsToRetry} сек</strong>
</span>
) : (
<span style={{ color: !isLoopActive ? '#aaa' : currentStatus.includes('⚡') ? '#4caf50' : currentStatus.includes('⏳') ? '#e6af2e' : '#00b4d8', fontWeight: currentStatus.includes('⚡') ? 'bold' : 'normal' }}>
{currentStatus}
</span>
)}
</div>
{/* Кнопки имитации действий */}
<div style={{ display: 'flex', gap: '16px', width: '100%' }}>
<button
onClick={() => logic.triggerEventOnServer('Новый лайк к статье', 'like')}
className={clsx(btnClasses.btn, btnClasses.neonBtn, btnClasses['neonBtn--primary'], btnClasses['neonBtn--outlined'])}
style={{ flex: 1 }}
>
👍 Имитировать Лайк
</button>
<button
onClick={() => logic.triggerEventOnServer('Новое сообщение чата', 'message')}
className={clsx(btnClasses.btn, btnClasses.neonBtn, btnClasses['neonBtn--primary'], btnClasses['neonBtn--outlined'])}
style={{ flex: 1 }}
>
💬 Имитировать Сообщение
</button>
</div>
{/* Таблица колонок */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', width: '100%', marginTop: '8px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<div style={{ fontSize: '12px', fontWeight: 'bold', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>🟡 Те, что в очереди:</span>
<span style={{ background: '#3a2510', color: '#ffb74d', padding: '2px 8px', borderRadius: '10px', fontSize: '11px', border: '1px solid #ffb74d' }}>{outgoingBuffer.length}</span>
</div>
<div
style={{
background: '#000',
height: '180px',
borderRadius: '8px',
padding: '12px',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
{outgoingBuffer.length === 0 ? (
<span style={{ color: '#aaa', fontStyle: 'italic', fontSize: '13px', textAlign: 'center' }}>Очередь пуста.<br />Выключите сервер для накопления.</span>
) : (
outgoingBuffer.map((item) => (
<div key={item.id} style={{ color: '#ffb74d', fontSize: '13px', fontFamily: 'monospace', display: 'flex', justifyContent: 'space-between' }}>
<span>{item.type === 'like' ? '❤️' : '💬'} {item.text}</span>
<span style={{ color: '#666', fontSize: '10px' }}>{item.timestamp}</span>
</div>
))
)}
</div>
</div>
<div className={baseClasses.stack1}>
<div style={{ fontSize: '12px', fontWeight: 'bold', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<span>🟢 Успешно отправленные:</span>
<span style={{ background: '#122b15', color: '#4caf50', padding: '2px 8px', borderRadius: '10px', fontSize: '11px', border: '1px solid #4caf50' }}>{receivedNotifications.length}</span>
</span>
{receivedNotifications.length > 0 && (
<button onClick={() => logic.clearFeed()} style={{ background: 'transparent', color: '#666', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontSize: '11px', padding: 0 }}>
очистить
</button>
)}
</div>
<div
style={{
background: '#000',
height: '180px',
borderRadius: '8px',
padding: '12px',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
{receivedNotifications.length === 0 ? (
<span style={{ color: '#aaa', fontStyle: 'italic', fontSize: '13px', textAlign: 'center' }}>Нет доставленных событий.<br />Кликните по кнопкам выше.</span>
) : (
receivedNotifications.map((item) => (
<div key={item.id} style={{ color: item.type === 'like' ? '#ff6b6b' : '#4caf50', fontSize: '12px', fontFamily: 'monospace', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<span>{item.type === 'like' ? '❤️' : '💬'}</span>
<span>{item.text}</span>
</span>
<span style={{ color: '#555', fontSize: '11px' }}>{item.timestamp}</span>
</div>
))
)}
</div>
</div>
</div>
</div>
)
}