How to politely close an SSE event stream using htmx?
Asked Answered
R

3

9

I'm trying to set up a short-lived event stream handler using htmx. Imagine, e.g., streaming a chatgpt response or similar. The stream will last for 10-30 seconds and then I expect it to be exhausted.

Is there a way to have htmx listen for an SSE event type and close the SSE source in response?

I have a clunky workaround where I have an htmx listener for the stream termination event type, and that listener then makes a request to an endpoint that just returns an empty div and swaps that div in for the original listener to prevent it from endlessly trying to reconnect to the SSE source.

But I feel like I must be missing something because this doesn't seem very elegant.

Here's what I have now, below. It works (mostly), but is there something better? Thanks!

<div
  id="chat-sse-listener"
  hx-ext="sse"
  sse-connect="{% url 'stream_test' %}"
>

  {# This listens for the next token in the stream and appends it to the chat. #}
  <div
    sse-swap="message"
    hx-target="#chat-message"
    hx-swap="beforeend"
  ></div>

  {# This listens for my custom EndOfStream SSE event and awkwardly replaces the entirely SSE listener with an empty div. #}
  <div
    hx-trigger="sse:EndOfStream"
    hx-target="#chat-sse-listener"
    hx-swap="outerHTML"
    hx-get="{% url 'empty_div_response' %}"
  ></div>

</div>

<div id="chat-message"></div>

Update:

After playing around with it for a bit, I have a slightly less awkward (but still not great) solution. Instead of having a second listener and a second event type, I end the stream by having the server pass down a div with htmx's out-of-band swap API. That div is then used to replace (and thus remove) the sse listener.

Code:

    async def __anext__(self):
        await asyncio.sleep(0.25)

        if self.story_tokens_queue.empty():
            if self.sent_close_token:
                raise StopAsyncIteration
            else:
                self.sent_close_token = True
                # this div will be swapped, out-of-band, for the existing #chat-see-listener, effectively removing it
                # and closing the SSE source on the client side
                return 'data: <div id="chat-sse-listener" hx-swap-oob="true"></div>\n\n'
        else:
            token = self.story_tokens_queue.get().replace("\n", "<br/>")
            return f"data: {token}\n\n"

Fewer moving parts, but not as crisp as explicitly requesting the client close the source. I'm convinced at this point thought that that would require additional javascript.

Final Follow-up Here's what I'm going with in production for my side project. It works for now!

My backend code puts this in as the very last token sent by the SSE stream:

            # second, we close the SSE listener on the client side
            elif not self.sent_close_token:
                self.sent_close_token = True
                # this div will be swapped, out-of-band, for the existing #chat-see-listener, effectively removing
                # it and closing the SSE source on the client side
                return 'data: <div id="chat-sse-listener" hx-swap-oob="true"></div>\n\n'

My HTML looks like this:

<div
  id="chat-sse-listener"
  hx-ext="sse"
  sse-connect="{% url 'chat_request_answer' id=chat_thread.id %}"
>

  {# This listens for the next token in the stream and appends it to the chat. #}
  <div
    sse-swap="message"
    hx-target="#sage-answer-{{ new_answer_id }}"
    hx-swap="beforeend"
  ></div>

</div>

I'm using htmx 1.9.6.

It seems to work for me with that setup.

Runck answered 30/10, 2023 at 8:28 Comment(4)
Did you make any progress with this? My approach is currently similar to yours. When I want to remove the SSE listener, I replace the <div id='sse-dev' sse-swap=...> with <div hx-swap-oob='true' id='sse-div'>. But I get the following error messages in the JS console: htmx.min.js:1 TypeError: Cannot read properties of null (reading 'htmx-internal-data') at Object.K [as getInternalData] (htmx.min.js:1:4561)...Burnedout
I can fix this by modifying the HTMX sse extension (line 43: case "htmx:beforeCleanupElement": if (evt.target == null) return;) but this seems hacky.Burnedout
@JulesKuehn. I've added a final follow-up at the end of the question above showing what works for me. It's not perfect but it is working!Runck
Did you try asking "Please, close"? Sorry I couldn't resist, I'll be going nowGlazunov
F
3

htmx 2.0 will have updated version of SSE extension, which has sse-close attribute for closing the event source on a specific event. This feature is included in the extension since 2.1.0

https://www.npmjs.com/package/htmx-ext-sse

Fryd answered 21/5 at 17:39 Comment(0)
W
0

I think I might have found a nice way:

<div id="sse">
    <div 
      id="sse-container" 
      hx-ext="sse" 
      sse-connect="/stream_accuracy">
        <div 
          sse-swap="message" 
          hx-target="#benchmark" 
          hx-swap="beforeend"></div>
        <div 
          sse-swap="streamCompleted" 
          hx-target="#sse"></div>
    </div>
</div>
<div id="benchmark"></div>

So the streamCompleted message is targeted to essentially remove all sse stuff. This will make the client close the connection. Inspired by: https://github.com/bigskysoftware/htmx/issues/1234

Willable answered 30/3 at 22:10 Comment(0)
P
0

Neither of the 2 above(below?) solutions worked for me so I did something a bit strange but it works.

<div id="sse-container-<%= item.id %>">
  <div hx-ext="sse" sse-connect="/responses/<%= item.id %>/stream">
    <div sse-swap="partial-response">
      <%= image_tag 'loading.svg' %>
    </div>
    <div sse-swap="final-response" hx-target="#sse-container-<%= item.id %>"></div>
  </div>
</div>
Paragrapher answered 25/8 at 16:43 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.