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;32m and [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, cargo or docker build produce 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.

Style state must outlive each batch Stack of three consecutive SSE batches, with a red style opened in the first and closed in the third, showing where a stateless converter goes wrong. Style state must outlive each batch Batch 1 ESC[31m error[E0308]: opens red Batch 2 expected u32, found &str still red, no code in batch Batch 3 ESC[0m Compiling api closes red
The error block spans three events. Converting each event from a clean state renders only the first line red.

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.

The SGR codes build tools actually emit Matrix of common SGR codes, their meaning, and whether a minimal parser needs to handle them. The SGR codes build tools actually emit Code Meaning Handle it? 0 reset all yes 1 / 22 bold on / off yes 30–37, 90–97 foreground colour yes 40–47 background colour usually 38;5;n 256-colour map to nearest ESC[2K, ESC[1A clear line, cursor up strip on server
A dozen codes cover nearly every compiler and test runner. 256-colour and truecolour codes are rare in CI output and can fall back to the nearest basic colour.

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.

Before and after the conversion Two panels comparing the rendered log before and after applying the ANSI parser, carriage-return collapsing and safe rendering. Before and after the conversion Raw output [31m error[0m visible 200 spinner lines markup executed jumps while reading Converted red error, spans batches one updating line text nodes only follows only at bottom
Every item on the left is a bug report waiting to happen; every item on the right is a single, testable function.

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.