# `StarView.Stream`
[🔗](https://github.com/Neophen/star_view/blob/v0.7.0/lib/star_view/stream.ex#L1)

Opens an SSE stream and owns its receive loop, liveness probes, and teardown.

    case StarView.Stream.open(conn, key: current_user.id) do
      {:ok, conn} ->
        StarView.Stream.run(conn,
          connect: &subscribe/1,
          info: &handle_update/2,
          disconnect: &unsubscribe/1
        )

      {:error, conn} -> conn
    end

Callbacks receive and return the current conn; `:info` may return
`{:halt, conn}`. `:disconnect` runs once after a normal end, after the exact
stream claim is released. Raises propagate after claim release. A typed
SSE write failure with reason `:closed`, `:econnreset`, or `:epipe` ends normally.

Probes run on a fixed deadline (`:idle_check`, default 30,000ms), even when
application messages keep arriving. Bandit's HTTP/2 flow-control messages
remain in the mailbox for the adapter. Generation-tagged replacement messages
stop only their own stream, so a stale message cannot stop a newer stream on
a reused HTTP/1.1 keep-alive process.

## Coalesced refresh

Pass `:render` to re-render after scoped invalidations. `:info` (or any
callback) calls `invalidate/1`; the loop then calls `:render` once for every
burst of invalidations already queued. `:render_interval` (ms, default `0`)
is the minimum time between render starts. With `:render_on_connect`
(default `true` when `:render` is given) one snapshot render follows
`:connect`, closing the gap between page load, subscription and reconnect.

    StarView.Stream.run(conn,
      connect: &subscribe/1,
      info: fn {:order_changed, _id}, conn -> StarView.Stream.invalidate(conn) end,
      render: &render_orders/1,
      render_interval: 50
    )

`:render` may return `{:halt, conn}`. Reload and authorize business state in
the callback from trusted conn assigns; invalidation messages should carry
identifiers, not data. Pending timers and render messages are cancelled when
`run/2` returns or raises. A request process can own one active `run/2`;
nesting another run inside a callback raises `ArgumentError`.

Lifecycle concepts adapted from dstar (MIT), Copyright (c) 2025 Rico Trevisan.

# `invalidate`

```elixir
@spec invalidate(Plug.Conn.t()) :: Plug.Conn.t()
```

Requests one coalesced `:render` call from the running stream loop.

Further calls before that render are no-ops, and the render waits for
`:render_interval` since the previous render started. Raises
`ArgumentError` unless the conn belongs to the currently running `run/2`
with a `:render` callback; a conn kept after `run/2` returns (including in
the `:disconnect` callback) cannot schedule a render.

# `open`

```elixir
@spec open(Plug.Conn.t(), keyword()) :: {:ok, Plug.Conn.t()} | {:error, Plug.Conn.t()}
```

Starts SSE, optionally claiming `{key, tabId}` atomically.

Returns `{:ok, conn}` with chunked SSE, or `{:error, conn}` with a halted
normal HTTP response: 400/413 for invalid/oversized signals, 503 for a refused
keyed claim. Do not subscribe on the error path. Accepts `:max_bytes` for raw
signals. The optional registry is required only when a usable tab id and
`:key` are both present.

# `run`

```elixir
@spec run(Plug.Conn.t(), keyword()) :: Plug.Conn.t()
```

Runs until a client disconnects, `:info` halts, or a newer stream takes over.

Options: `:connect` (conn -> conn), `:info` (message, conn -> conn or
`{:halt, conn}`), `:disconnect` (conn -> any), `:idle_check` (positive ms).
Optional `:replaced` receives the legacy `{:EXIT, pid, :replaced}` message
after exact claim release and before disconnect.

Refresh options: `:render` (conn -> conn or `{:halt, conn}`),
`:render_interval` (non-negative ms, default `0`) and `:render_on_connect`
(boolean, default `true` when `:render` is given). See "Coalesced refresh".

