WebSocket and SSE event streams#

Litestar Vite includes browser helpers for JSON event streams. They own connection lifecycle, reconnect backoff, heartbeat filtering, duplicate suppression, and sequence-gap warnings. Your application still owns authorization and rendering.

Choose the narrowest helper for the server route:

  • createEventStream() for an application-defined WebSocket or SSE route.

  • createChannelsStream() for WebSocket routes generated by Litestar’s ChannelsPlugin.

  • createQueueEventStream() for routes generated by Litestar Queues, or a custom route that intentionally uses the same queue event envelope.

Generic streams#

createEventStream() is neutral: it parses JSON when possible but does not assume queue event names, heartbeat shapes, identifiers, or sequence fields.

import { createEventStream } from "litestar-vite-plugin/helpers"

const stream = createEventStream({
  url: "/events",
  onEvent: (event) => console.log(event),
  onGap: (gap) => console.warn("Missing events", gap),
})

stream.connect()
// Later:
stream.dispose()

Use buildUrl instead of url when a signed endpoint or token can change. It runs immediately before every connection attempt:

const stream = createEventStream({
  buildUrl: () => `/events?ticket=${encodeURIComponent(currentTicket)}`,
  onEvent: handleEvent,
})

Regular Litestar Channels#

When ChannelsPlugin generates /<channel> WebSocket routes, let createChannelsStream() encode the channel and build the route:

import { createChannelsStream } from "litestar-vite-plugin/helpers"

const stream = createChannelsStream({
  basePath: "/channels",
  channel: "notifications",
  onEvent: showNotification,
})
stream.connect()

channel may be a function and transformUrl may add a current signed token before each reconnect.

Litestar Queues#

createQueueEventStream() understands QueuePlugin’s generated task, queue, worker, global, and custom routes. It also owns the queue event names and the standard heartbeat, deduplication, and sequence selectors:

import { createQueueEventStream } from "litestar-vite-plugin/helpers"

const stream = createQueueEventStream({
  scope: "task",
  taskId: () => selectedTaskId,
  transport: "sse",
  onEvent: updateTask,
  onGap: warnAboutMissingEvents,
})
stream.connect()

A custom endpoint can opt into the same queue envelope explicitly by supplying url or buildUrl instead of a generated-route target.

Declarative stream element#

Call defineStreamElement() once, then bind the connection to ordinary DOM lifecycle:

import { defineStreamElement } from "litestar-vite-plugin/helpers"

defineStreamElement()
<litestar-stream url="/events" transport="sse" swap="json">
  <template ls-for="item in $data.items">
    <p>${item}</p>
  </template>
</litestar-stream>

The element uses light DOM and dispatches bubbling litestar:stream-event, litestar:stream-health, litestar:stream-gap, litestar:stream-open, and litestar:stream-close events. Adding preset="queues" delegates to createQueueEventStream(); generic behavior remains the default.

The element replaces htmx-ext-ws and htmx-ext-sse for JSON streams. Do not layer both extensions on the same endpoint: two reconnect policies would compete for one connection.

React, Vue, and Svelte#

Framework adapters are optional subpaths. All return the same state fields: healthy, lastEvent, lastGap, and a bounded events buffer.

import { useQueueEventStream } from "litestar-vite-plugin/react"

const stream = useQueueEventStream({
  key: taskId,
  scope: "task",
  taskId,
  onEvent: handleTaskEvent,
})
import { useEventStream } from "litestar-vite-plugin/vue"

const stream = useEventStream({
  key: () => projectId.value,
  url: "/events",
  onEvent: handleEvent,
})
import { createEventStreamStore } from "litestar-vite-plugin/svelte"

const stream = createEventStreamStore({
  url: "/events",
  onEvent: handleEvent,
})

React and Vue reconnect only when their explicit key or transport changes, not when an inline callback gets a new identity. A Svelte store connects for its first subscriber and disposes after its last subscriber leaves.

Authentication#

Authentication stays on the Litestar route:

  • Same-origin session cookies and JWT cookies are sent automatically by native WebSocket and EventSource clients. Validate them with Litestar auth middleware and guards.

  • For a rotating signed URL, use buildUrl or transformUrl. Keep the credential in a property or closure instead of serializing it into markup.

  • Native browser WebSocket and EventSource APIs cannot attach an arbitrary Authorization header. Prefer an HttpOnly cookie. If bearer authentication must remain, exchange it over authenticated HTTP for a short-lived, audience-restricted stream ticket instead of putting a general access token in the query string.

  • Cross-origin SSE credentials require a credentialed EventSource client plus matching CORS policy. The default Litestar Vite design keeps streams same-origin through the Litestar port.

For WebSockets, reserve a close code such as 4001 for expired authentication and a different code such as 4003 for a real authorization denial. shouldReconnect(code) can trigger application-owned refresh logic and suppress retries for terminal denials.

Transport choice#

SSE is unidirectional and has native reconnect behavior, but named server events must be enumerated because EventSource.onmessage receives only unnamed events. WebSocket is bidirectional and needs no event-name list, but browser clients cannot add authorization headers during the handshake.

Replay and gaps#

The helper warns about a sequence gap; it never guesses at missing data or silently refetches it. Silent recovery across reconnects requires storage on both sides of a QueuePlugin stream:

  • configure Litestar Queues with replay_limit > 0 (the default is 0);

  • configure the Channels backend with history= (for example, MemoryChannelsBackend(history=200)).

Without both settings, an outage can produce a gap that the browser correctly reports but cannot recover. See the stream example for ordinary Channels plus queue-shaped WebSocket and SSE routes.