← JavaScript/Server-Sent Events и длинные опросы#142 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

Server-Sent Events и длинные опросы

Представь: ты строишь страницу отслеживания заказа. Покупатель должен видеть статус в реальном времени — «принят», «готовится», «в доставке», «доставлен» — без перезагрузки страницы. Как получать обновления от сервера? Есть несколько подходов с разными компромиссами.

Что решает этот механизм

WebSocket — двусторонний канал, но требует особой настройки сервера. Для задач, где данные идут только от сервера к клиенту (уведомления, статус заказа, live-feed), SSE проще в реализации и работает поверх обычного HTTP.

На основе предыдущих уроков

  • async/await, Promise — long polling и обработка SSE потоков строятся на async/await
  • fetch — SSE и long polling используют HTTP-соединения
  • генераторы — async generator идеально моделирует поток событий
  • Обычный polling — короткие опросы

    Клиент периодически отправляет запросы:

    // Простой polling каждые 3 секунды — неэффективно
    setInterval(async () => {
      const response = await fetch('/api/notifications')
      const data = await response.json()
      if (data.length > 0) {
        showNotifications(data)
      }
    }, 3000)
    // Проблема: большинство запросов вернут пустой ответ,
    // зря нагружая сервер и тратя трафик

    Длинный опрос (Long Polling)

    Клиент делает запрос и ждёт, пока сервер не ответит. Сервер держит соединение открытым до появления новых данных. Как только данные есть — сервер отвечает, клиент сразу делает новый запрос:

    async function longPoll(url) {
      while (true) {
        try {
          // Запрос может висеть долго (сервер отвечает только при новых данных)
          const response = await fetch(url + '?lastEventId=' + lastId)
          const data = await response.json()
    
          processData(data)  // обрабатываем данные
    
          // Сразу делаем новый запрос — не ждём
        } catch (error) {
          // Ошибка сети — подождать и повторить
          await sleep(5000)
        }
      }
    }

    Преимущества: работает везде где есть HTTP, нет проблем с прокси.

    Недостатки: высокая нагрузка на сервер при масштабировании.

    Server-Sent Events (SSE)

    SSE — это стандарт, при котором сервер отправляет поток событий через одно HTTP-соединение. Клиент использует EventSource:

    const source = new EventSource('/api/events')
    
    // Обработчик сообщений по умолчанию (тип "message")
    source.onmessage = (event) => {
      const data = JSON.parse(event.data)
      console.log('Новое событие:', data)
    }
    
    // Кастомные типы событий
    source.addEventListener('order:update', (event) => {
      const order = JSON.parse(event.data)
      updateOrderStatus(order)
    })
    
    source.addEventListener('notification', (event) => {
      showNotification(JSON.parse(event.data))
    })
    
    // Обработка ошибок — браузер автоматически переподключается
    source.onerror = (error) => {
      console.error('SSE ошибка, переподключение...')
    }
    
    // Закрыть соединение вручную
    source.close()

    Формат SSE-ответа на сервере

    Сервер отправляет текст в специальном формате — каждое событие заканчивается двойным переводом строки:

    Content-Type: text/event-stream
    Cache-Control: no-cache
    
    data: {"status": "pending"}\n\n
    
    data: {"status": "processing"}\n\n
    
    event: order:update
    data: {"id": 42, "status": "delivered"}\n\n
    
    id: 100
    retry: 5000
    data: {"message": "heartbeat"}\n\n
    // На сервере (Node.js/Express)
    app.get('/api/order-status/:id', (req, res) => {
      res.setHeader('Content-Type', 'text/event-stream')
      res.setHeader('Cache-Control', 'no-cache')
      res.setHeader('Connection', 'keep-alive')
    
      function sendEvent(type, data) {
        res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`)
      }
    
      sendEvent('order:update', { id: req.params.id, status: 'processing' })
    
      const timer = setInterval(() => {
        sendEvent('order:update', { id: req.params.id, status: 'ready' })
        clearInterval(timer)
        res.end()
      }, 3000)
    
      req.on('close', () => clearInterval(timer))
    })

    Симуляция SSE через async generator

    В JavaScript можно смоделировать поток SSE через асинхронный генератор:

    async function* sseStream(url) {
      // В реальном коде: fetch + ReadableStream
      // Для демонстрации — генерируем события
      const statuses = ['pending', 'processing', 'shipped', 'delivered']
      for (const status of statuses) {
        await sleep(1000)
        yield { type: 'order:update', data: { status } }
      }
    }
    
    // Обработка потока
    for await (const event of sseStream('/api/events')) {
      console.log(event.type, event.data)
    }

    Сравнение подходов

    | | Short Polling | Long Polling | SSE | WebSocket |

    |---|---|---|---|---|

    | Направление | Клиент → Сервер | Клиент → Сервер | Сервер → Клиент | Двустороннее |

    | Задержка | Высокая | Низкая | Низкая | Минимальная |

    | Нагрузка на сервер | Высокая | Средняя | Низкая | Низкая |

    | Сложность | Минимальная | Средняя | Низкая | Высокая |

    | Автопереподключение | Вручную | Вручную | Встроено | Вручную |

    Типичные ошибки

    1. Не закрывать EventSource при уходе пользователя

    // ПЛОХО — соединение висит даже после перехода на другую страницу
    function initOrderTracking(orderId) {
      const source = new EventSource(`/api/orders/${orderId}/stream`)
      source.onmessage = (e) => updateUI(e.data)
      // Утечка: source никогда не закрывается
    }
    
    // ХОРОШО — возвращаем функцию отписки
    function initOrderTracking(orderId) {
      const source = new EventSource(`/api/orders/${orderId}/stream`)
      source.onmessage = (e) => updateUI(e.data)
      return () => source.close()  // вызвать при unmount компонента
    }

    2. Бесконечный long poll без защиты от зависания

    // ПЛОХО — если сервер вечно не отвечает, запрос висит бесконечно
    async function longPoll(url) {
      while (true) {
        const res = await fetch(url)  // может висеть часами
        processData(await res.json())
      }
    }
    
    // ХОРОШО — с таймаутом и AbortController
    async function longPoll(url, timeoutMs = 30000) {
      while (true) {
        const controller = new AbortController()
        const timer = setTimeout(() => controller.abort(), timeoutMs)
        try {
          const res = await fetch(url, { signal: controller.signal })
          processData(await res.json())
        } catch (err) {
          if (err.name !== 'AbortError') await sleep(3000)
        } finally {
          clearTimeout(timer)
        }
      }
    }

    3. Игнорировать поле id в SSE — теряем позицию при переподключении

    // Сервер должен отправлять id для каждого события
    // data: {...}\n\n             — ПЛОХО: при разрыве начнём с начала
    
    // id: 42\ndata: {...}\n\n     — ХОРОШО: браузер отправит Last-Event-ID при переподключении
    // EventSource автоматически добавит заголовок: Last-Event-ID: 42

    В реальных проектах

  • Ozon, Wildberries: статус заказа на странице «Мои заказы» обновляется через SSE без перезагрузки
  • GitHub Actions: лог выполнения pipeline стримится в браузер через SSE
  • Системы мониторинга (Grafana, Datadog): live-метрики используют long polling или SSE
  • Онлайн-курсы: уведомления о проверке задания приходят через SSE или WebSocket
  • Примеры

    Симуляция SSE потока через async generator: live-статус заказа от pending до delivered

    // Симуляция Server-Sent Events через async generator
    // В реальном браузере использовался бы EventSource + ReadableStream
    
    function sleep(ms) {
      return new Promise(resolve => setTimeout(resolve, ms))
    }
    
    // Async generator — имитирует поток событий от сервера
    async function* simulateOrderStream(orderId) {
      const timeline = [
        { event: 'status', data: { orderId, status: 'pending',    message: 'Заказ принят' } },
        { event: 'status', data: { orderId, status: 'confirmed',  message: 'Оплата подтверждена' } },
        { event: 'status', data: { orderId, status: 'processing', message: 'Собирается на складе' } },
        { event: 'status', data: { orderId, status: 'shipped',    message: 'Передан в доставку' } },
        { event: 'status', data: { orderId, status: 'delivered',  message: 'Доставлен!' } },
      ]
    
      for (const item of timeline) {
        await sleep(50)  // эмулируем задержку сервера (50ms вместо реальных секунд)
        yield item
      }
    }
    
    // Клиентский код — обработка потока (как EventSource в браузере)
    async function trackOrder(orderId) {
      console.log(`Отслеживаем заказ #${orderId}\n`)
    
      const handlers = new Map()
    
      // Регистрируем обработчики событий (как source.addEventListener)
      handlers.set('status', ({ status, message }) => {
        console.log(`[${status.toUpperCase()}] ${message}`)
        return status === 'delivered'  // true = закрыть поток
      })
    
      // Читаем поток (как цикл обработки SSE)
      for await (const { event, data } of simulateOrderStream(orderId)) {
        const handler = handlers.get(event)
        const shouldClose = handler?.(data)
    
        if (shouldClose) {
          console.log('\nПоток закрыт — заказ доставлен!')
          break
        }
      }
    }
    
    // Запускаем отслеживание
    trackOrder(78432)
    
    // ===
    
    // Паттерн Long Polling (синхронная симуляция)
    console.log('\n=== Long Polling паттерн ===')
    
    function createStatusServer() {
      let callCount = 0
      const responses = [null, null, { status: 'shipped' }, { status: 'delivered' }]
      return {
        // Симулирует долгий ответ сервера (возвращает null пока нет данных)
        poll() {
          return responses[callCount++] || null
        },
        hasMore() { return callCount < responses.length }
      }
    }
    
    async function simulateLongPoll(server, intervalMs = 10) {
      const results = []
      let attempts = 0
    
      while (true) {
        attempts++
        await sleep(intervalMs)  // задержка между запросами
    
        const data = server.poll()
    
        if (data !== null) {
          results.push(data)
          console.log(`Попытка ${attempts}: получен ответ → ${data.status}`)
    
          if (data.status === 'delivered') break
        } else {
          console.log(`Попытка ${attempts}: сервер ещё не ответил...`)
        }
    
        if (attempts >= 10) break  // защита от бесконечного цикла
      }
    
      return results
    }
    
    const mockServer = createStatusServer()
    simulateLongPoll(mockServer, 10).then(results => {
      console.log('\nВсе полученные статусы:', results.map(r => r.status).join(' → '))
    })
    
    // Формат SSE сообщений
    console.log('\n=== Парсинг SSE сообщений ===')
    
    function parseSSEMessage(raw) {
      const result = { event: 'message', data: '', id: null, retry: null }
      const lines = raw.trim().split('\n')
    
      for (const line of lines) {
        if (line.startsWith('event:')) result.event = line.slice(6).trim()
        else if (line.startsWith('data:'))  result.data = line.slice(5).trim()
        else if (line.startsWith('id:'))    result.id = line.slice(3).trim()
        else if (line.startsWith('retry:')) result.retry = parseInt(line.slice(6).trim())
      }
    
      return result
    }
    
    const rawMessages = [
      'data: {"status":"pending"}',
      'event: order:update\ndata: {"id":42,"status":"shipped"}',
      'id: 100\nevent: notification\ndata: {"text":"Ваш заказ в пути"}\nretry: 5000',
    ]
    
    rawMessages.forEach(raw => {
      const parsed = parseSSEMessage(raw)
      console.log('Событие:', parsed.event, '| data:', parsed.data.slice(0, 40))
    })

    Server-Sent Events и длинные опросы

    Представь: ты строишь страницу отслеживания заказа. Покупатель должен видеть статус в реальном времени — «принят», «готовится», «в доставке», «доставлен» — без перезагрузки страницы. Как получать обновления от сервера? Есть несколько подходов с разными компромиссами.

    Что решает этот механизм

    WebSocket — двусторонний канал, но требует особой настройки сервера. Для задач, где данные идут только от сервера к клиенту (уведомления, статус заказа, live-feed), SSE проще в реализации и работает поверх обычного HTTP.

    На основе предыдущих уроков

  • async/await, Promise — long polling и обработка SSE потоков строятся на async/await
  • fetch — SSE и long polling используют HTTP-соединения
  • генераторы — async generator идеально моделирует поток событий
  • Обычный polling — короткие опросы

    Клиент периодически отправляет запросы:

    // Простой polling каждые 3 секунды — неэффективно
    setInterval(async () => {
      const response = await fetch('/api/notifications')
      const data = await response.json()
      if (data.length > 0) {
        showNotifications(data)
      }
    }, 3000)
    // Проблема: большинство запросов вернут пустой ответ,
    // зря нагружая сервер и тратя трафик

    Длинный опрос (Long Polling)

    Клиент делает запрос и ждёт, пока сервер не ответит. Сервер держит соединение открытым до появления новых данных. Как только данные есть — сервер отвечает, клиент сразу делает новый запрос:

    async function longPoll(url) {
      while (true) {
        try {
          // Запрос может висеть долго (сервер отвечает только при новых данных)
          const response = await fetch(url + '?lastEventId=' + lastId)
          const data = await response.json()
    
          processData(data)  // обрабатываем данные
    
          // Сразу делаем новый запрос — не ждём
        } catch (error) {
          // Ошибка сети — подождать и повторить
          await sleep(5000)
        }
      }
    }

    Преимущества: работает везде где есть HTTP, нет проблем с прокси.

    Недостатки: высокая нагрузка на сервер при масштабировании.

    Server-Sent Events (SSE)

    SSE — это стандарт, при котором сервер отправляет поток событий через одно HTTP-соединение. Клиент использует EventSource:

    const source = new EventSource('/api/events')
    
    // Обработчик сообщений по умолчанию (тип "message")
    source.onmessage = (event) => {
      const data = JSON.parse(event.data)
      console.log('Новое событие:', data)
    }
    
    // Кастомные типы событий
    source.addEventListener('order:update', (event) => {
      const order = JSON.parse(event.data)
      updateOrderStatus(order)
    })
    
    source.addEventListener('notification', (event) => {
      showNotification(JSON.parse(event.data))
    })
    
    // Обработка ошибок — браузер автоматически переподключается
    source.onerror = (error) => {
      console.error('SSE ошибка, переподключение...')
    }
    
    // Закрыть соединение вручную
    source.close()

    Формат SSE-ответа на сервере

    Сервер отправляет текст в специальном формате — каждое событие заканчивается двойным переводом строки:

    Content-Type: text/event-stream
    Cache-Control: no-cache
    
    data: {"status": "pending"}\n\n
    
    data: {"status": "processing"}\n\n
    
    event: order:update
    data: {"id": 42, "status": "delivered"}\n\n
    
    id: 100
    retry: 5000
    data: {"message": "heartbeat"}\n\n
    // На сервере (Node.js/Express)
    app.get('/api/order-status/:id', (req, res) => {
      res.setHeader('Content-Type', 'text/event-stream')
      res.setHeader('Cache-Control', 'no-cache')
      res.setHeader('Connection', 'keep-alive')
    
      function sendEvent(type, data) {
        res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`)
      }
    
      sendEvent('order:update', { id: req.params.id, status: 'processing' })
    
      const timer = setInterval(() => {
        sendEvent('order:update', { id: req.params.id, status: 'ready' })
        clearInterval(timer)
        res.end()
      }, 3000)
    
      req.on('close', () => clearInterval(timer))
    })

    Симуляция SSE через async generator

    В JavaScript можно смоделировать поток SSE через асинхронный генератор:

    async function* sseStream(url) {
      // В реальном коде: fetch + ReadableStream
      // Для демонстрации — генерируем события
      const statuses = ['pending', 'processing', 'shipped', 'delivered']
      for (const status of statuses) {
        await sleep(1000)
        yield { type: 'order:update', data: { status } }
      }
    }
    
    // Обработка потока
    for await (const event of sseStream('/api/events')) {
      console.log(event.type, event.data)
    }

    Сравнение подходов

    | | Short Polling | Long Polling | SSE | WebSocket |

    |---|---|---|---|---|

    | Направление | Клиент → Сервер | Клиент → Сервер | Сервер → Клиент | Двустороннее |

    | Задержка | Высокая | Низкая | Низкая | Минимальная |

    | Нагрузка на сервер | Высокая | Средняя | Низкая | Низкая |

    | Сложность | Минимальная | Средняя | Низкая | Высокая |

    | Автопереподключение | Вручную | Вручную | Встроено | Вручную |

    Типичные ошибки

    1. Не закрывать EventSource при уходе пользователя

    // ПЛОХО — соединение висит даже после перехода на другую страницу
    function initOrderTracking(orderId) {
      const source = new EventSource(`/api/orders/${orderId}/stream`)
      source.onmessage = (e) => updateUI(e.data)
      // Утечка: source никогда не закрывается
    }
    
    // ХОРОШО — возвращаем функцию отписки
    function initOrderTracking(orderId) {
      const source = new EventSource(`/api/orders/${orderId}/stream`)
      source.onmessage = (e) => updateUI(e.data)
      return () => source.close()  // вызвать при unmount компонента
    }

    2. Бесконечный long poll без защиты от зависания

    // ПЛОХО — если сервер вечно не отвечает, запрос висит бесконечно
    async function longPoll(url) {
      while (true) {
        const res = await fetch(url)  // может висеть часами
        processData(await res.json())
      }
    }
    
    // ХОРОШО — с таймаутом и AbortController
    async function longPoll(url, timeoutMs = 30000) {
      while (true) {
        const controller = new AbortController()
        const timer = setTimeout(() => controller.abort(), timeoutMs)
        try {
          const res = await fetch(url, { signal: controller.signal })
          processData(await res.json())
        } catch (err) {
          if (err.name !== 'AbortError') await sleep(3000)
        } finally {
          clearTimeout(timer)
        }
      }
    }

    3. Игнорировать поле id в SSE — теряем позицию при переподключении

    // Сервер должен отправлять id для каждого события
    // data: {...}\n\n             — ПЛОХО: при разрыве начнём с начала
    
    // id: 42\ndata: {...}\n\n     — ХОРОШО: браузер отправит Last-Event-ID при переподключении
    // EventSource автоматически добавит заголовок: Last-Event-ID: 42

    В реальных проектах

  • Ozon, Wildberries: статус заказа на странице «Мои заказы» обновляется через SSE без перезагрузки
  • GitHub Actions: лог выполнения pipeline стримится в браузер через SSE
  • Системы мониторинга (Grafana, Datadog): live-метрики используют long polling или SSE
  • Онлайн-курсы: уведомления о проверке задания приходят через SSE или WebSocket
  • Примеры

    Симуляция SSE потока через async generator: live-статус заказа от pending до delivered

    // Симуляция Server-Sent Events через async generator
    // В реальном браузере использовался бы EventSource + ReadableStream
    
    function sleep(ms) {
      return new Promise(resolve => setTimeout(resolve, ms))
    }
    
    // Async generator — имитирует поток событий от сервера
    async function* simulateOrderStream(orderId) {
      const timeline = [
        { event: 'status', data: { orderId, status: 'pending',    message: 'Заказ принят' } },
        { event: 'status', data: { orderId, status: 'confirmed',  message: 'Оплата подтверждена' } },
        { event: 'status', data: { orderId, status: 'processing', message: 'Собирается на складе' } },
        { event: 'status', data: { orderId, status: 'shipped',    message: 'Передан в доставку' } },
        { event: 'status', data: { orderId, status: 'delivered',  message: 'Доставлен!' } },
      ]
    
      for (const item of timeline) {
        await sleep(50)  // эмулируем задержку сервера (50ms вместо реальных секунд)
        yield item
      }
    }
    
    // Клиентский код — обработка потока (как EventSource в браузере)
    async function trackOrder(orderId) {
      console.log(`Отслеживаем заказ #${orderId}\n`)
    
      const handlers = new Map()
    
      // Регистрируем обработчики событий (как source.addEventListener)
      handlers.set('status', ({ status, message }) => {
        console.log(`[${status.toUpperCase()}] ${message}`)
        return status === 'delivered'  // true = закрыть поток
      })
    
      // Читаем поток (как цикл обработки SSE)
      for await (const { event, data } of simulateOrderStream(orderId)) {
        const handler = handlers.get(event)
        const shouldClose = handler?.(data)
    
        if (shouldClose) {
          console.log('\nПоток закрыт — заказ доставлен!')
          break
        }
      }
    }
    
    // Запускаем отслеживание
    trackOrder(78432)
    
    // ===
    
    // Паттерн Long Polling (синхронная симуляция)
    console.log('\n=== Long Polling паттерн ===')
    
    function createStatusServer() {
      let callCount = 0
      const responses = [null, null, { status: 'shipped' }, { status: 'delivered' }]
      return {
        // Симулирует долгий ответ сервера (возвращает null пока нет данных)
        poll() {
          return responses[callCount++] || null
        },
        hasMore() { return callCount < responses.length }
      }
    }
    
    async function simulateLongPoll(server, intervalMs = 10) {
      const results = []
      let attempts = 0
    
      while (true) {
        attempts++
        await sleep(intervalMs)  // задержка между запросами
    
        const data = server.poll()
    
        if (data !== null) {
          results.push(data)
          console.log(`Попытка ${attempts}: получен ответ → ${data.status}`)
    
          if (data.status === 'delivered') break
        } else {
          console.log(`Попытка ${attempts}: сервер ещё не ответил...`)
        }
    
        if (attempts >= 10) break  // защита от бесконечного цикла
      }
    
      return results
    }
    
    const mockServer = createStatusServer()
    simulateLongPoll(mockServer, 10).then(results => {
      console.log('\nВсе полученные статусы:', results.map(r => r.status).join(' → '))
    })
    
    // Формат SSE сообщений
    console.log('\n=== Парсинг SSE сообщений ===')
    
    function parseSSEMessage(raw) {
      const result = { event: 'message', data: '', id: null, retry: null }
      const lines = raw.trim().split('\n')
    
      for (const line of lines) {
        if (line.startsWith('event:')) result.event = line.slice(6).trim()
        else if (line.startsWith('data:'))  result.data = line.slice(5).trim()
        else if (line.startsWith('id:'))    result.id = line.slice(3).trim()
        else if (line.startsWith('retry:')) result.retry = parseInt(line.slice(6).trim())
      }
    
      return result
    }
    
    const rawMessages = [
      'data: {"status":"pending"}',
      'event: order:update\ndata: {"id":42,"status":"shipped"}',
      'id: 100\nevent: notification\ndata: {"text":"Ваш заказ в пути"}\nretry: 5000',
    ]
    
    rawMessages.forEach(raw => {
      const parsed = parseSSEMessage(raw)
      console.log('Событие:', parsed.event, '| data:', parsed.data.slice(0, 40))
    })

    Задание

    Напиши async функцию simulateLongPoll(getStatus, intervalMs), которая симулирует длинный опрос. getStatus — async функция возвращающая текущий статус (строку). Функция должна опрашивать getStatus каждые intervalMs миллисекунд, собирать все статусы в массив и остановиться когда статус === "done". Вернуть массив всех полученных статусов включая "done".

    Подсказка

    await sleep(intervalMs), const status = await getStatus(), results.push(status), if (status === "done") break. Цикл while(true) с этими четырьмя шагами внутри.

    Загружаем среду выполнения...
    Загружаем AI-помощника...