A chat front end in the browser
The DOM and events, model text kept out of innerHTML, a streamed reply parsed from server-sent events, and a Stop button.
Create a free account Try part of it here
Why it matters
A chat interface looks simple: a box, a button, a growing list of messages. Underneath, it streams text that arrives a few words at a time, lets people stop it halfway, stays usable while it waits, and shows text written by a model that anyone can steer with a clever prompt. Get the last part wrong and a reply can run code in your users' browsers. This lesson builds that front end piece by piece on a practice page: the same calls work in any browser, and the checks look at the page your code builds.
What you'll be able to do
- Find, create and change elements on a page, and respond to clicks and form submits
- Show model output with textContent, and explain why innerHTML turns it into an attack
- Stream a reply into the page as server-sent events, handling events split across chunks
- Add a Stop button with AbortController, and keep the page usable while a reply streams
What it covers
- The page is a tree
- Events: clicks and submits
- Model output is untrusted input
- Find the bug: the reply that runs
- Streaming a reply
- A server-sent events parser
- Stop, and staying usable while it streams
- When the request fails
Show the answer
The image fails to load, so its onerror code runs inside your page
innerHTML parses the text as HTML, so the reply becomes a real <img> element, and its onerror attribute runs as code with your page's access. textContent would have shown it as plain text.
The page is a tree
When a browser reads HTML it builds the DOM, a tree of objects: document at the root, elements such as <form> and <div> as branches, and text as leaves. JavaScript changes the page by changing that tree, and the browser redraws to match.
document.body.innerHTML = '<h2>Trail shop</h2><ul id="cart"></ul>'; const cart = document.getElementById("cart"); for (const name of ["Ridge tent", "Trail mug"]) { const item = document.createElement("li"); item.textContent = name; item.classList.add("line"); cart.append(item); } console.log(cart.children.length, document.querySelector("#cart .line").textContent);
Four calls do most of the work. document.getElementById and querySelector find elements (the second takes a CSS selector, as in the stylesheet lesson). createElement makes a new one and append puts it in the tree. textContent, classList, hidden and disabled change what's shown. Run the cell and the page your code built appears under the output.
document.body.innerHTML = '<p id="greet">Hi <b>Ana</b>, welcome back</p>'; console.log(document.getElementById("greet").textContent);
Show the answer
Hi Ana, welcome back
textContent joins all the text inside an element, tags left out. Reading it is how you get what the reader sees.
Events: clicks and submits
Pages react through event listeners: element.addEventListener("click", handler) calls handler each time the element is clicked, with an event object describing what happened. For a chat box, listen for the form's submit event rather than the button's click: it fires for the button and for Enter in the text box.
document.body.innerHTML = '<form id="ask"><input id="prompt"><button>Send</button></form><div id="log"></div>'; const form = document.getElementById("ask"); const input = document.getElementById("prompt"); form.addEventListener("submit", (event) => { event.preventDefault(); const bubble = document.createElement("div"); bubble.className = "msg user"; bubble.textContent = input.value; document.getElementById("log").append(bubble); input.value = ""; }); input.value = "Is the tent waterproof?"; document.querySelector("#ask button").click(); console.log(document.querySelectorAll("#log .msg").length, JSON.stringify(input.value));
event.preventDefault() matters: a form's default is to send itself to the server and load a new page, which would wipe the chat. Here the code clicks the button itself; on a real page, the person does.
The question for each line: could this text have come from a model or a user?
Write a sentence or two, then compare it with a good answer in the lesson.
Model output is untrusted input
Treat a model's reply the way you treat a form field from a stranger. People can steer a model into writing anything, directly or by planting instructions in a document it reads; that's prompt injection. If your page then parses the reply as HTML, their markup runs with your page's access: it can read what's on screen, send requests as the signed-in user, or swap your page for a fake login.
The rule is short: put untrusted text in textContent, never innerHTML. When you want formatting, such as Markdown with bold text and lists, convert it with a renderer that escapes HTML and then clean the result with a sanitizer library built for it, like DOMPurify. Writing your own filter with a regular expression is how most cross-site scripting bugs are born.
The same goes beyond chat: product names, file names and user profiles all came from outside your code. The practice page warns you under the output when your code has built something that would run.
Find the bug: the reply that runs
showReply(text) adds an assistant bubble to #log. It works for ordinary replies, but a reply containing markup turns into live elements, and the check finds an onerror handler on the page. Make it show any reply as plain text.
document.body.innerHTML = '<div id="log"></div>'; function showReply(text) { const bubble = document.createElement("div"); bubble.className = "msg assistant"; bubble.innerHTML = text; document.getElementById("log").append(bubble); return bubble; } showReply("The Ridge tent sleeps two."); showReply('Here it is: <img src=x onerror="alert(document.cookie)">');
Streaming a reply
Asked with stream: true, the practice chat service sends its reply as server-sent events: lines like data: {"delta":"The "} each followed by a blank line, and a final data: [DONE]. res.body is a stream of bytes; a reader hands them over in chunks as they arrive, and a TextDecoder turns bytes into text.
const res = await fetch("https://chat.trailhead.test/v1/chat", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ messages: [{ role: "user", content: "Tell me about the tent" }], stream: true }), }); const reader = res.body.getReader(); const decoder = new TextDecoder(); let chunks = 0; let raw = ""; for (;;) { const { value, done } = await reader.read(); if (done) break; chunks += 1; raw += decoder.decode(value, { stream: true }); } console.log(chunks, "chunks"); console.log(raw.split("\n\n").slice(0, 2));
A chunk is whatever the network delivered, not one event. On a real connection one chunk can hold three events, or half of one, so parsers keep a buffer: add each chunk, cut off every complete event (they end in a blank line), and keep the unfinished tail for next time.
const chunks = ['data: {"delta":"Hel', 'lo"}\n\ndata: [DONE]\n\n']; let buffer = ""; const deltas = []; for (const chunk of chunks) { buffer += chunk; const events = buffer.split("\n\n"); buffer = events.pop(); for (const e of events) if (e !== "data: [DONE]") deltas.push(JSON.parse(e.slice(6)).delta); } console.log(deltas, JSON.stringify(buffer));
Show the answer
[ 'Hello' ] ""
After the first chunk there's no blank line yet, so split gives one piece, which pop keeps as the buffer. The second chunk completes it, and the event parses whole. The empty string left at the end is the start of the next event.
A server-sent events parser
Write takeEvents(buffer) for the chat stream. It returns { deltas, rest, done }: the delta text of every complete event in order, the unfinished tail to keep, and whether data: [DONE] arrived. Events end with a blank line ("\n\n") and start with data: .
function takeEvents(buffer) {
// split off complete events, keep the tail, collect the deltas
return { deltas: [], rest: buffer, done: false };
}
console.log(takeEvents('data: {"delta":"Hi "}\n\ndata: {"delta":"there"}\n\ndata: {"del'));Write a sentence or two, then compare it with a good answer in the lesson.
Stop, and staying usable while it streams
A reply can run for many seconds, so give people a way out. Create an AbortController for each request, pass its signal to fetch, and call abort() from the Stop button; the reader then rejects with an AbortError, which your code catches and treats as a normal ending, not a failure.
While a reply streams, disable Send so a second question can't start a second stream into the same bubble, and re-enable it in a finally block so an error can't leave the page stuck. Two small touches help everyone: give the message list role="log" and aria-live="polite" so screen readers announce new messages, and move focus back to the text box when a reply ends.
Keep the conversation in an array of { role, content } messages and send all of it with each request, because the model remembers nothing between calls. The page shows that array; it isn't the source of it, so never rebuild the history by reading text back out of the bubbles.
Show the answer
A rejection whose error is named AbortError
Aborting rejects whatever is waiting on that request with an AbortError. Catch it, check err.name === "AbortError", and end the reply quietly.
Show the answer
A second question would start a second stream writing into the page at once
Two streams at once would interleave their words, and the conversation history would no longer match what's on screen. One reply at a time keeps both straight.
When the request fails
Three things go wrong in practice, and each deserves a message on the page rather than a silent line in the console. The service answers with an error status: a 429 or 503 means it's busy, so say so, offer a Retry button that sends the same history again, and wait a little before retrying on your own. The network drops: fetch rejects with a TypeError, so say the connection was lost and leave the question in the text box, so nothing has to be typed again. Or the stream stops halfway: keep the words that arrived, add a short note that the reply is incomplete, and keep the history matching what the person saw.
Put these messages in an element with role="alert", so screen readers announce them, and style them so they can't be mistaken for the assistant. An error that looks like the model talking will be believed, and quoted back to you as the product's answer.
Show the answer
Say it's busy, offer Retry, and keep the question
A 503 is temporary, so offer a retry, and keep everything the person typed. The message must look like the page talking, not the model.
Write a sentence or two, then compare it with a good answer in the lesson.
Practice what you just read
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Which line shows a model's reply safely?