About this snippet

A reply that appears as the server sends it, the way chat apps show a model's answer. Press Send and the text streams in with a cursor at its end, including a code block that fills in word by word. Send turns into Stop while the reply is running. The Server menu switches the fake server between a normal stream, a 500 error and a connection that drops partway through, so you can see how each one is handled.

The client reads res.body through a TextDecoderStream with getReader. Chunks do not line up with events, so the code keeps a buffer, splits it on the blank line between server-sent events (lines may end in LF, CRLF or CR), keeps the unfinished tail for the next chunk and reads the data lines of each event. [DONE] ends the stream, and events with no text field are skipped. An AbortController stops the request, and rendering is batched with requestAnimationFrame so a fast stream is not redrawn for every chunk.

fakeFetch builds a real Response around a ReadableStream that cuts the bytes at random points, so the parser meets the same split events a network would give it. Replace it with fetch to your own server route, keep the API key on the server, and change the JSON field the handler reads to match your API. The reply is built from text nodes with textContent and append(), so text from the model is never parsed as HTML.

Size
12.8 KB
Uses
HTML, CSS, JavaScript
Loads
Nothing external
Views
26

Collections

<main class="page">
  <section class="stream" aria-labelledby="stream-title">
    <h1 class="sr-only" id="stream-title">Streaming reply</h1>
    <form class="ask" id="ask">
      <label class="sr-only" for="question">Question</label>
      <input id="question" type="text" value="How do I debounce an input in JavaScript?" autocomplete="off">
      <button type="submit" class="btn btn-primary" id="send">Send</button>
    </form>
    <div class="server">
      <label for="scenario">Server</label>
      <select id="scenario">
        <option value="ok">Streams normally</option>
        <option value="500">Returns a 500 error</option>
        <option value="drop">Drops mid-stream</option>
      </select>
    </div>
    <article class="reply" id="reply" aria-label="Reply" aria-busy="false" tabindex="0">
      <p class="placeholder">Send the question to watch the reply stream in.</p>
    </article>
    <p class="meta" id="meta">Nothing received yet</p>
    <p class="sr-only" id="status" role="status"></p>
  </section>
</main>
*, *::before, *::after {
  box-sizing: border-box;
}

:root {
  --page: #eef2f7;
  --card: #ffffff;
  --text: #0f172a;
  --muted: #5b6678;
  --line: #d5dce6;
  --soft: #eef2f7;
  --accent: #2563eb;
  --error: #b91c1c;
}

body {
  font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  margin: 0;
  background: var(--page);
  color: var(--text);
}

.page {
  display: grid;
  grid-template-columns: minmax(0, 640px);
  justify-content: center;
  align-content: center;
  min-height: 100vh;
  padding: 24px 16px;
}

.stream {
  padding: 20px;
  border-radius: 18px;
  background: var(--card);
  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05), 0 12px 32px -12px rgba(15, 23, 42, 0.2);
}

.ask {
  display: flex;
  gap: 8px;
}

.ask input {
  flex: 1;
  min-width: 0;
  min-height: 44px;
  padding: 8px 14px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--card);
  color: var(--text);
  font: inherit;
}

.btn {
  min-width: 76px;
  min-height: 44px;
  padding: 8px 18px;
  border: 1px solid var(--text);
  border-radius: 12px;
  background: var(--text);
  color: var(--card);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}

.btn:hover {
  opacity: 0.88;
}

.btn.is-stop {
  border-color: var(--line);
  background: transparent;
  color: var(--text);
}

.server {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 12px 0 16px;
  font-size: 0.875rem;
  color: var(--muted);
}

.server select {
  min-height: 34px;
  padding: 4px 8px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--card);
  color: var(--text);
  font: inherit;
}

.ask input:focus-visible,
.btn:focus-visible,
.server select:focus-visible,
.reply:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.reply {
  height: clamp(180px, 45vh, 280px);
  overflow-y: auto;
  padding: 16px;
  border-radius: 12px;
  background: var(--soft);
  line-height: 1.6;
}

.reply p {
  margin: 0 0 12px;
}

.reply p:last-child {
  margin-bottom: 0;
}

.reply .placeholder {
  color: var(--muted);
}

.reply code {
  padding: 1px 5px;
  border-radius: 5px;
  background: var(--card);
  font-family: ui-monospace, Consolas, monospace;
  font-size: 0.875em;
}

.reply pre {
  margin: 0 0 12px;
  padding: 14px 16px;
  overflow-x: auto;
  border-radius: 10px;
  background: #0f172a;
  color: #e2e8f0;
}

.reply pre code {
  padding: 0;
  background: none;
  font-size: 0.8125rem;
  line-height: 1.6;
}

.caret {
  display: inline-block;
  width: 8px;
  height: 1.1em;
  margin-left: 2px;
  border-radius: 2px;
  background: var(--accent);
  vertical-align: text-bottom;
  animation: blink 1s steps(1) infinite;
}

pre .caret {
  background: #93c5fd;
}

.error-note {
  color: var(--error);
  font-weight: 600;
}

.meta {
  min-height: 1.5em;
  margin: 12px 2px 0;
  font-size: 0.8125rem;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@keyframes blink {
  50% {
    opacity: 0;
  }
}

@media (max-width: 420px) {
  .stream {
    padding: 16px 12px;
  }

  .reply {
    padding: 12px;
  }
}

@media (pointer: coarse) {
  .server select {
    min-height: 44px;
  }
}

@media (prefers-color-scheme: dark) {
  :root {
    --page: #0b1120;
    --card: #151e30;
    --text: #e2e8f0;
    --muted: #a3afc2;
    --line: #334155;
    --soft: #0f172a;
    --accent: #60a5fa;
    --error: #fca5a5;
  }

  .reply pre {
    background: #020617;
  }
}

@media (prefers-reduced-motion: reduce) {
  .caret {
    animation: none;
  }
}
const ANSWER = [
  "A debounce waits until the input goes quiet for a set time, then runs the function once. Each keystroke clears the previous timer with `clearTimeout`, so only the last one fires.",
  "",
  "```js",
  "function debounce(fn, delay) {",
  "  let timer;",
  "  return function(...args) {",
  "    clearTimeout(timer);",
  "    timer = setTimeout(() => fn.apply(this, args), delay);",
  "  };",
  "}",
  "",
  "const search = debounce(function(e) {",
  "  runSearch(e.target.value);",
  "}, 300);",
  "",
  "input.addEventListener('input', search);",
  "```",
  "",
  "With a 300 ms delay, typing a whole word quickly runs the search once, 300 ms after the last key."
].join("\n");

const form = document.getElementById("ask");
const question = document.getElementById("question");
const sendBtn = document.getElementById("send");
const scenario = document.getElementById("scenario");
const reply = document.getElementById("reply");
const meta = document.getElementById("meta");
const status = document.getElementById("status");

let controller = null;
let text = "";
let frame = 0;

// A stand-in for fetch that streams server-sent events
function fakeFetch(url, options) {
  const mode = scenario.value;
  const signal = options.signal;
  return new Promise(function(resolve, reject) {
    const firstByte = setTimeout(function() {
      if (mode === "500") {
        resolve(new Response(JSON.stringify({ error: "The model is overloaded" }), { status: 500, headers: { "Content-Type": "application/json" } }));
        return;
      }
      const pieces = ANSWER.match(/\S*\s*/g).filter(Boolean);
      const events = pieces.map(function(piece) {
        return "data: " + JSON.stringify({ text: piece }) + "\n\n";
      }).join("") + "data: [DONE]\n\n";
      const bytes = new TextEncoder().encode(events);
      let offset = 0;
      let timer = null;
      const body = new ReadableStream({
        start: function(stream) {
          signal.addEventListener("abort", function() {
            clearTimeout(timer);
            stream.error(signal.reason);
          });
          function push() {
            if (mode === "drop" && offset > bytes.length * 0.45) {
              stream.error(new TypeError("network error"));
              return;
            }
            const size = 16 + Math.floor(Math.random() * 64);
            stream.enqueue(bytes.slice(offset, offset + size));
            offset += size;
            if (offset >= bytes.length) {
              stream.close();
            } else {
              timer = setTimeout(push, 20 + Math.random() * 40);
            }
          }
          push();
        }
      });
      resolve(new Response(body, { headers: { "Content-Type": "text/event-stream" } }));
    }, 400);
    signal.addEventListener("abort", function() {
      clearTimeout(firstByte);
      reject(signal.reason);
    });
  });
}

// Read the stream and pass on the text of each event
async function streamAnswer(prompt, signal, onText, stats) {
  const res = await fakeFetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt: prompt }),
    signal: signal
  });
  if (!res.ok) {
    const data = await res.json().catch(function() { return {}; });
    throw new Error("The server answered " + res.status + ". " + (data.error || ""));
  }
  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";
  while (true) {
    const { value, done } = await reader.read();
    if (done) return;
    stats.chunks++;
    // Turn CRLF and CR into LF, but keep a trailing CR for later
    buffer = (buffer + value).replace(/\r\n|\r(?!$)/g, "\n");
    const events = buffer.split("\n\n");
    buffer = events.pop();
    for (const event of events) {
      const data = event.split("\n").filter(function(line) {
        return line.startsWith("data:");
      }).map(function(line) {
        return line.replace(/^data: ?/, "");
      }).join("\n");
      if (!data) continue;
      if (data === "[DONE]") return;
      const piece = JSON.parse(data).text;
      if (typeof piece === "string") onText(piece);
    }
  }
}

function el(tag, className) {
  const node = document.createElement(tag);
  if (className) node.className = className;
  return node;
}

// Split the text into paragraphs and code blocks
function toBlocks(source) {
  const blocks = [];
  let current = null;
  source.split("\n").forEach(function(line) {
    if (line.startsWith("```")) {
      current = current && current.type === "code" ? null : { type: "code", lines: [] };
      if (current) blocks.push(current);
      return;
    }
    if (current && current.type === "code") {
      current.lines.push(line);
      return;
    }
    if (!line.trim()) {
      current = null;
      return;
    }
    if (!current) {
      current = { type: "p", lines: [] };
      blocks.push(current);
    }
    current.lines.push(line);
  });
  return blocks;
}

function render(streaming) {
  const stick = reply.scrollHeight - reply.scrollTop - reply.clientHeight < 40;
  reply.textContent = "";
  let last = null;
  toBlocks(text).forEach(function(block) {
    if (block.type === "code") {
      const pre = el("pre");
      last = el("code");
      last.textContent = block.lines.join("\n");
      pre.append(last);
      reply.append(pre);
    } else {
      last = el("p");
      block.lines.join(" ").split("`").forEach(function(part, i) {
        if (!part) return;
        if (i % 2) {
          const code = el("code");
          code.textContent = part;
          last.append(code);
        } else {
          last.append(part);
        }
      });
      reply.append(last);
    }
  });
  if (streaming) {
    if (!last) {
      last = el("p");
      reply.append(last);
    }
    last.append(el("span", "caret"));
  }
  if (stick) reply.scrollTop = reply.scrollHeight;
}

function scheduleRender() {
  if (frame) return;
  frame = requestAnimationFrame(function() {
    frame = 0;
    render(true);
  });
}

function setBusy(busy) {
  sendBtn.textContent = busy ? "Stop" : "Send";
  sendBtn.classList.toggle("is-stop", busy);
  reply.setAttribute("aria-busy", String(busy));
  scenario.disabled = busy;
}

async function ask() {
  const stats = { chunks: 0 };
  const started = performance.now();
  const current = new AbortController();
  controller = current;
  text = "";
  setBusy(true);
  render(true);
  meta.textContent = "Waiting for the first chunk";
  status.textContent = "Reply started";
  try {
    await streamAnswer(question.value, current.signal, function(piece) {
      text += piece;
      meta.textContent = "Received " + stats.chunks + " chunks, " + text.length + " characters";
      scheduleRender();
    }, stats);
    const seconds = ((performance.now() - started) / 1000).toFixed(1);
    meta.textContent = "Done. " + stats.chunks + " chunks, " + text.length + " characters in " + seconds + " s";
    status.textContent = "Reply finished";
  } catch (err) {
    const note = el("p", "error-note");
    if (err.name === "AbortError") {
      meta.textContent = "Stopped after " + stats.chunks + " chunks";
    } else if (err instanceof TypeError && stats.chunks > 0) {
      note.textContent = "The connection dropped partway through, so this reply is incomplete.";
      meta.textContent = "Connection lost after " + stats.chunks + " chunks";
    } else if (err instanceof TypeError) {
      note.textContent = "The server could not be reached. Check the connection and send it again.";
      meta.textContent = "Request failed";
    } else {
      note.textContent = err.message.trim();
      meta.textContent = "Request failed";
    }
    status.textContent = note.textContent || meta.textContent;
    cancelAnimationFrame(frame);
    frame = 0;
    render(false);
    if (note.textContent) reply.append(note);
  } finally {
    cancelAnimationFrame(frame);
    frame = 0;
    if (!reply.querySelector(".error-note")) render(false);
    controller = null;
    setBusy(false);
  }
}

form.addEventListener("submit", function(e) {
  e.preventDefault();
  if (controller) {
    controller.abort();
  } else if (!question.value.trim()) {
    meta.textContent = "Type a question first";
    question.focus();
  } else {
    ask();
  }
});

// Ignore the second click of a double click
sendBtn.addEventListener("click", function(e) {
  if (e.detail > 1) e.preventDefault();
});

setTimeout(function() {
  if (!controller) ask();
}, 600);
Esc
↑↓ moveEnter select

Start from a template

Open an .html file, or HTML, CSS, JS, Markdown, Less or TypeScript files. You can also drop them here. Styles and scripts go into their panes.

Share

Source

Keyboard shortcuts

Save