runlocally

runlocally engineering notes

HAR Viewer

How HAR Viewer is built

By Geppetto · · Open HAR Viewer →

HAR Viewer opens a DevTools .har capture and renders the request list, a per-request timing waterfall, and full header/body/cookie detail entirely in the browser. This post covers the HAR format itself, how the parser and waterfall are built without a third-party HAR or charting library, and the heuristics used to flag values that look like tokens or session cookies.

Tech used

The HAR format: what a DevTools capture actually records

A .har file is the HAR 1.2 format — plain JSON, produced when a browser’s DevTools Network panel is told to save the recorded traffic. At the top there’s log.version and log.entries, an array with one object per network request. Each entry carries startedDateTime and a total time; a request object (method, url, httpVersion, headers, queryString, cookies, and an optional postData with a mimeType and body text); a response object (status, statusText, headers, cookies, and a content object with size, mimeType, and optionally base64-encoded text); and a timings object that breaks the request’s total time into named phases — blocked, dns, connect, send, wait, receive, and an optional ssl. That timings breakdown is the field the waterfall renders directly, and the header/query/cookie name-value pairs are exactly what the secret scanner reads.

Because the format is plain JSON with a documented shape, there is nothing to link against to read it — src/utils/harParse.ts is standalone, dependency-free code.

Parsing: JSON.parse plus a shape check, no HAR library

parseHarText does the minimum needed to trust the rest of the app with the data: parse the JSON, then check that log.entries is an array — “the one thing every real HAR capture has,” per the module’s own comment — and return a typed failure (empty, invalidJson, notHar, or, from the file-reading wrapper, unreadable) instead of throwing. Each entry then goes through normalizeEntry, which coerces every field to a safe default rather than trusting the input: a missing request.method becomes 'GET', a missing url becomes a placeholder like (entry 3), and numeric fields are clamped non-negative with Math.max(0, num(v)). startedDateTime is left undefined rather than defaulted when it’s missing or not a string, which matters later for the waterfall’s span calculation. None of this is defensive overkill for its own sake — HAR captures from real tools do occasionally omit or mangle fields the spec calls required, and normalizing once here means no other module in the codebase has to guard against undefined.

The request list: reusing CSV Viewer’s windowed rendering

The request table’s scrolling is the same windowing approach introduced in the CSV Viewer notes: computeWindow in src/utils/virtualWindow.ts turns a scroll position and viewport height into the slice of rows to actually mount, plus two spacer heights that keep the scrollbar sized as if every row existed. Here it kicks in above VIRTUALIZE_THRESHOLD (200) entries with a fixed ROW_HEIGHT of 34px; below that, every row renders directly. The table itself has a fixed COLUMN_COUNT of 9 (flag, method, URL, status, type, size, time, waterfall, expand action), which is what the spacer <tr>’s colSpan needs to span correctly.

The timing waterfall: stacked divs, no charting library

Each row’s waterfall bar is computeWaterfall (src/utils/waterfall.ts) turning timings into six WaterfallSegments — one per phase, in the fixed order blocked, dns, connect, send, wait, receive — rendered by WaterfallBar as plain <span>s with a CSS width: {pct}%. ssl is deliberately excluded from the stack: per the HAR spec it’s already counted inside connect, so adding its width again would double the bar. A phase value of -1 or an absent field means “not measured,” and both are treated as zero contribution rather than a negative width.

The width math is the part worth calling out: each segment’s percentage is phase_ms / captureSpanMs * 100, where captureSpanMs (computeCaptureSpanMs in harParse.ts) is the wall-clock span of the entire capture — earliest entry start to latest entry end — not the individual request’s own duration. A request that took up a large share of the whole session draws a long bar and a quick one draws a short bar, comparable at a glance down the whole table. When no entry has a parseable startedDateTime — the field is spec-required but the code accounts for real captures omitting it — the span falls back to the slowest single entry’s time. totalPct is clamped to 100 defensively, and the division guards against a zero-length span. A second function, describeWaterfall, turns the same six phases into a text string (“Blocked 1 ms, DNS – , Connect 12 ms, …”) used as the bar’s aria-label and title, so the bar’s meaning doesn’t depend on reading segment colors.

Sensitive-value detection: hand-authored heuristics, not a secret-scanning library

src/utils/secretDetect.ts opens with the reason this scan exists at all: in 2023, an Okta customer uploaded a .har file containing valid session cookies to a support ticket, and an attacker who later reached that support system’s data used the cookie inside it to hijack sessions. The scan runs automatically on every loaded file, and it’s scoped narrowly — only header values, query-string parameter values, and cookie values are checked; request and response bodies are explicitly out of scope for this version (the module doc points at issue #86 for a possible future extension), and a unit test enforces this structurally rather than just by convention: scanEntrySecrets’s input type has no postData/content fields at all, so a token sitting in a body literally cannot reach the scanner.

The patterns themselves are hand-authored regexes for shapes that show up in real captures, not a general secret-scanning library:

  • JWT^eyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}$, anchored on the whole value. eyJ isn’t arbitrary: it’s the base64url encoding of {", which every JWT header JSON starts with.
  • AWS access keyAKIA followed by 16 uppercase alphanumeric characters.
  • GitHub tokenghp_ or gho_ followed by 20+ alphanumeric characters.
  • sk--prefixed API key (OpenAI/Stripe-style) — sk- followed by 16+ alphanumeric characters. The long trailing run is required specifically so ordinary words like “risk-assessment-report” or “desk-top” don’t match; a unit test checks this directly.
  • Bearer token — an Authorization header matching ^bearer\s+\S+, flagged separately from the shape checks above (a Bearer token is also tested against the JWT/AWS/GitHub/sk- patterns on its token portion alone, since it very often is one of those shapes).
  • Secret-named query parameter — a parameter named api_key, access_token, or token (case-insensitively), flagged regardless of its value’s shape.
  • Cookie header presence — any Cookie or Set-Cookie header is flagged regardless of its value. This is the heuristic that answers the Okta scenario most directly: a legitimate session cookie has no distinctive shape to pattern-match, so the signal is that the header exists at all, not what’s inside it.

Matches are surfaced with a <mark> in the detail view and a warning icon in the row — never hidden or replaced. That’s a deliberate scope boundary, not an oversight: the README and the module doc both note that a “sanitize and re-export a cleaned HAR” tool is a different job with its own tradeoffs, and point at Cloudflare’s existing open-source client-side har-sanitizer for that use case rather than building a second one here.

Shell: Astro, Preact, Service Worker

The static Astro + Preact island and Service-Worker PWA shell are the same across these tools, introduced in the HEIC to JPG notes. HarViewer is described in its own file header as “the tool’s only non-frozen widget” — the one hydrated island everything above lives inside. There’s no Web Worker and no WASM: parsing is native JSON.parse, which the README describes as running directly on the main thread without needing to be offloaded. The only runtime dependencies are astro (^4.16.18), preact (^10.24.3), and @astrojs/preact (^3.5.3).

Implementation & operational notes

  • Extension is authoritative for file validation, MIME is only a fallback. fileValidation.ts accepts a file primarily by its .har extension; browsers rarely report a specific MIME type for the format (commonly empty, application/json, or text/plain), so a non-.har name is only accepted if the reported MIME matches one of those generic values — covering a HAR that was renamed or exported without its extension.
  • Body rendering degrades gracefully, never throws. prepareBody decodes base64 when the entry’s encoding says so, and pretty-prints with 2-space indent when the mimeType contains json — but a body that claims JSON and isn’t just falls back to raw text, and a base64 decode failure falls back to a binary placeholder, rather than surfacing a parse error to the user.
  • Opening a file never touches the URL. A Playwright test (covenants.spec.ts, its own comment citing “a HAR file can carry live session tokens”) asserts the page URL is byte-identical before and after loading a capture, and contains no data:, base64, or blob: fragment — this constraint matters more for this tool than most of the others in the catalog, given what a HAR can contain.
  • Full offline coverage is tested, not just implemented. A separate covenant test loads the page online once (so the Service Worker installs and caches the app shell and island bundle), then goes fully offline and repeats a full file-open flow to confirm nothing about opening and inspecting a capture requires a network request.
  • Numeric handling stays defensive throughout the timing math, not just at the parse boundary: computeWaterfall’s totalPct is clamped to 100 even if an entry’s timings sum past the capture span it’s being measured against, and the span denominator is floored at 1 so a zero-length span can’t produce a division by zero.

Try it / source