Streaming CI Build Output with ANSI Colors Permalink to this section
Part of Log Tailing & CI Output Streaming, under Real-Time Application Patterns.
Compilers, test runners and package managers colour their output with ANSI escape sequences: red for errors, yellow for warnings, bold for headings. Stream that output to a browser unchanged and users see \x1b[31merror\x1b[0m scattered through the text. Strip the codes and the build log loses the one feature that makes failures easy to spot. This guide keeps the colours: it converts ANSI sequences into safe, styled HTML, preserves colour state across Server-Sent Events batches, handles the carriage-return redraws that progress spinners produce, and follows the tail without fighting the user’s scroll.
Symptom & Developer Intent Permalink to this section
- Build logs show raw escape sequences such as
[1;32mand[0m. - Colours are correct within a batch but reset at batch boundaries, so half an error message is red and half is plain.
- Progress spinners from
npm,cargoordocker buildproduce hundreds of nearly identical lines. - A crafted commit message or test name injects HTML into the log view.
- The view jumps to the bottom whenever new output arrives, even while the user is reading an earlier error.
The intent is a log view that looks like the terminal the developer is used to — colours, bold, a single updating progress line — and is safe to render and pleasant to read while it streams.
Root Cause Analysis Permalink to this section
An ANSI styled segment is stateful: ESC[31m turns red on, and everything after it is red until ESC[0m or another colour code. The state spans lines and therefore spans SSE batches. A converter that starts from “no style” on each batch loses the state at every boundary.
Carriage returns are a second kind of state. A spinner writes ⠋ Resolving\r⠙ Resolving\r⠹ Resolving\n: each \r returns the cursor to column zero and the next text overwrites the line. A terminal shows one line; a naive line splitter shows three, or one line with all three concatenated.
Injection is the third issue. Converting ANSI to HTML by building strings and assigning innerHTML renders whatever angle brackets appear in test names, file paths or commit messages. The text must be escaped before styling, or — better — never parsed as HTML at all.
Step-by-Step Resolution Permalink to this section
Step 1 — Collapse carriage-return redraws on the server Permalink to this section
Handle \r where the full line is available, before batching:
// Keep only the final state of each terminal line.
function collapseCR(line) {
if (!line.includes('\r')) return line;
const parts = line.split('\r').filter((p) => p.length); // "\r\n" leaves an empty tail
return parts.length ? parts[parts.length - 1] : '';
}
// In the tailer, before pushing into the batch:
batch.push(collapseCR(rawLine));
A spinner that redraws two hundred times in one line becomes one line. Doing this on the server also cuts bandwidth substantially for chatty tools.
Step 2 — Parse ANSI into styled segments, not HTML Permalink to this section
A small state machine handles the SGR (“select graphic rendition”) codes that build tools use in practice: reset, bold, dim, underline, and the 8/16-colour foregrounds and backgrounds.
// ansi.js — stateful SGR parser producing text segments with style objects.
const SGR_RE = /\x1b\[([0-9;]*)m/g;
const OTHER_ESC_RE = /\x1b\[[0-9;?]*[A-Za-ln-z]/g; // cursor moves etc.: drop them
export function createAnsiParser() {
let style = {}; // persists across calls = across batches
return function parse(line) {
const clean = line.replace(OTHER_ESC_RE, '');
const segments = [];
let last = 0;
for (const m of clean.matchAll(SGR_RE)) {
if (m.index > last) segments.push({ text: clean.slice(last, m.index), style: { ...style } });
style = applySgr(style, m[1]);
last = m.index + m[0].length;
}
if (last < clean.length) segments.push({ text: clean.slice(last), style: { ...style } });
return segments;
};
}
function applySgr(style, params) {
const codes = params === '' ? [0] : params.split(';').map(Number);
let s = { ...style };
for (const c of codes) {
if (c === 0) s = {};
else if (c === 1) s.bold = true;
else if (c === 2) s.dim = true;
else if (c === 4) s.underline = true;
else if (c === 22) { delete s.bold; delete s.dim; }
else if (c >= 30 && c <= 37) s.fg = c - 30;
else if (c >= 90 && c <= 97) s.fg = c - 90 + 8;
else if (c === 39) delete s.fg;
else if (c >= 40 && c <= 47) s.bg = c - 40;
else if (c === 49) delete s.bg;
}
return s;
}
One parser instance lives for the whole stream, so a colour opened in one batch applies to lines in the next.
Step 3 — Render with text nodes and classes, never innerHTML Permalink to this section
function renderLine(segments) {
const row = document.createElement('div');
row.className = 'log-line';
for (const seg of segments) {
const span = document.createElement('span');
span.textContent = seg.text; // text, never markup: no injection
const cls = [];
if (seg.style.fg != null) cls.push(`fg-${seg.style.fg}`);
if (seg.style.bg != null) cls.push(`bg-${seg.style.bg}`);
if (seg.style.bold) cls.push('b');
if (seg.style.dim) cls.push('dim');
if (seg.style.underline) cls.push('u');
if (cls.length) span.className = cls.join(' ');
row.appendChild(span);
}
return row;
}
/* Terminal palette mapped to theme tokens so colours meet contrast in light and dark. */
.log-line .fg-1, .log-line .fg-9 { color: var(--log-red); }
.log-line .fg-2, .log-line .fg-10 { color: var(--log-green); }
.log-line .fg-3, .log-line .fg-11 { color: var(--log-yellow); }
.log-line .fg-4, .log-line .fg-12 { color: var(--log-blue); }
.log-line .b { font-weight: 700; }
.log-line .dim { opacity: 0.7; }
Mapping codes to classes rather than inline colours lets the palette follow the site theme; raw terminal yellow on a white background fails contrast checks badly.
Step 4 — Follow the tail only when the user is at the bottom Permalink to this section
const view = document.querySelector('.log-view');
let follow = true;
view.addEventListener('scroll', () => {
const atBottom = view.scrollHeight - view.scrollTop - view.clientHeight < 24;
follow = atBottom; // scrolling up pauses following
jumpButton.hidden = atBottom;
});
function appendBatch(rows) {
const frag = document.createDocumentFragment();
rows.forEach((r) => frag.appendChild(r));
view.appendChild(frag);
if (follow) view.scrollTop = view.scrollHeight;
}
A “jump to latest” button reappears whenever following is paused. For very long logs, replace appendChild with a virtualised list, as described in the log tailing topic.
Step 5 — Wire it to the stream Permalink to this section
const parse = createAnsiParser();
const es = new EventSource(`/api/builds/${buildId}/log`, { withCredentials: true });
let pending = [];
es.addEventListener('lines', (e) => { pending.push(...JSON.parse(e.data).lines); });
(function frame() {
if (pending.length) { appendBatch(pending.splice(0, 2000).map((l) => renderLine(parse(l)))); }
requestAnimationFrame(frame);
})();
es.addEventListener('end', () => es.close());
Converting at most 2,000 lines per frame spreads a compiler burst across a few frames instead of freezing the page for one long one.
Validation & Monitoring Permalink to this section
# A fixture with colours spanning lines, a spinner and an injection attempt.
printf '\033[31merror: first line\nstill red\033[0m back to normal\n' > fixture.log
printf '⠋ Resolving\r⠙ Resolving\r✔ Resolved\n' >> fixture.log
printf 'test <img src=x onerror=alert(1)> passed\n' >> fixture.log
Stream the fixture through the endpoint and check three things in the browser: “still red” is red, the spinner is one line reading “✔ Resolved”, and the <img> appears as literal text with no network request for x. Run axe or Lighthouse against the log view in both light and dark themes to confirm every colour class meets contrast.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Should ANSI conversion happen on the server or the client?
Collapse carriage returns and strip cursor-movement codes on the server, where whole lines are available. Convert colours on the client, so styling follows the site theme and the stream stays compact plain text.
Can I use an existing ANSI-to-HTML library?
Yes, if it can keep state between calls and it escapes text before adding markup. Check both explicitly, since several popular converters reset state per call or assume trusted input.
Why do some tools print no colours when run in CI?
Many tools disable colour when stdout is not a terminal. Set the conventional environment variables, such as FORCE_COLOR or CLICOLOR_FORCE, or the tool's own flag, in the CI job.
How do I make the log searchable?
Search the plain text of each line, not the rendered segments, and highlight matches on the visible rows. For large logs, run the search on the server over the whole log and stream matching line numbers back.