Frontend Labs
⋯

public/js/stations/network.js

// Station 1: from URL to bytes (§25–§27, §80). Everything on this page is
// measured from the real requests your browser just made.
import { h, station, panel, button, sleep, ms } from "../lib/dom.js";
import { missions, missionList } from "../lib/missions.js";

// ------------------------------------------------ navigation phases ----
function navigationPhases() {
  const n = performance.getEntriesByType("navigation")[0];
  if (!n) return h("p", {}, "No navigation entry available.");
  const phases = [
    ["Redirects", n.redirectEnd - n.redirectStart],
    ["Service worker / cache", n.domainLookupStart - n.fetchStart],
    ["DNS lookup", n.domainLookupEnd - n.domainLookupStart],
    ["TCP connect", (n.secureConnectionStart || n.connectEnd) - n.connectStart],
    ["TLS handshake", n.secureConnectionStart ? n.connectEnd - n.secureConnectionStart : 0],
    ["Request → first byte", n.responseStart - n.requestStart],
    ["Download HTML", n.responseEnd - n.responseStart],
    ["Parse → DOMContentLoaded", n.domContentLoadedEventEnd - n.responseEnd],
  ];
  const max = Math.max(...phases.map(([, v]) => v), 1);
  return h("div", { class: "phases" },
    phases.map(([label, v], i) =>
      h("div", { class: "phase" },
        h("span", {}, label),
        h("div", {}, h("div", { class: "fill", style: { width: `${Math.max(0, (v / max) * 100)}%`, animationDelay: `${i * 90}ms` } })),
        h("span", { class: "mono" }, ms(Math.max(0, v))))),
    h("p", { class: "note" },
      `Protocol: ${n.nextHopProtocol || "?"} · transfer ${n.transferSize} B (0 means it came from cache) · `,
      n.secureConnectionStart ? "a fresh TLS connection" : location.protocol === "https:" ? "TLS connection reused" : "no TLS (plain HTTP)",
      ". On localhost DNS and TCP are near zero; on a real site they are often 50–300ms each, which is why connection reuse and preconnect matter."));
}

// ----------------------------------------------------- TLS 1.3 ----
const TLS13 = [
  ["c2s", "ClientHello", "versions, cipher suites, + key_share (an ECDHE public key, guessed up front)"],
  ["s2c", "ServerHello", "chosen suite + server key_share → both sides now derive the handshake keys"],
  ["s2c", "🔒 EncryptedExtensions, Certificate, CertificateVerify, Finished", "already encrypted: the cert chain is hidden from on-path observers"],
  ["c2s", "🔒 Finished + first HTTP request", "the client validated the cert chain and sends data right away: 1 RTT total"],
  ["s2c", "🔒 HTTP response", "application data, encrypted with traffic keys"],
];
const TLS12 = [
  ["c2s", "ClientHello", "versions, cipher suites, no key share yet"],
  ["s2c", "ServerHello, Certificate, ServerKeyExchange, ServerHelloDone", "certificate travels in the clear"],
  ["c2s", "ClientKeyExchange, ChangeCipherSpec, Finished", "second round trip"],
  ["s2c", "ChangeCipherSpec, Finished", "now both sides have keys"],
  ["c2s", "🔒 HTTP request", "data only after 2 RTTs"],
  ["s2c", "🔒 HTTP response", ""],
];

function tlsPanel() {
  const msgs = h("div", { class: "msgs", "aria-live": "polite" });
  let running = false;
  const play = async (script, rtts) => {
    if (running) return;
    running = true;
    msgs.replaceChildren();
    for (const [dir, title, detail] of script) {
      msgs.append(h("div", { class: `msg-line ${dir}` }, title, h("small", {}, detail)));
      await sleep(900);
    }
    msgs.append(h("div", { class: "rtt" }, `${rtts} before the first byte of HTTP data can be sent`));
    running = false;
    if (script === TLS13) missions.complete("network", "tls");
  };
  return panel("The TLS handshake, step by step",
    h("div", { class: "row" },
      button("▶ TLS 1.3 (1-RTT)", () => play(TLS13, "1 round trip"), { class: "primary" }),
      button("▶ TLS 1.2 (2-RTT)", () => play(TLS12, "2 round trips"))),
    h("div", { class: "seq" }, h("div", { class: "actor" }, "💻 Browser"), h("div", { class: "actor" }, "🖥️ Server"), msgs),
    h("p", { class: "note" }, "With QUIC (HTTP/3) the transport and TLS handshakes are merged, so a new connection costs 1 RTT in total, and resumed connections can send 0-RTT data (replayable, so only for idempotent requests)."));
}

// ------------------------------------------------- whoami ----
function whoamiPanel() {
  const out = h("div", {}, h("p", { class: "note" }, "Ask the server what it saw: the negotiated protocol, cipher, and every header your browser sent."));
  const go = async () => {
    const info = await (await fetch("/api/whoami")).json();
    const interesting = Object.entries(info.headers).filter(([k]) => /^(:|sec-|priority|accept-encoding|user-agent|cookie|referer|origin)/.test(k));
    out.replaceChildren(
      h("dl", { class: "kv" },
        h("dt", {}, "HTTP version"), h("dd", {}, info.httpVersion),
        h("dt", {}, "ALPN"), h("dd", {}, info.alpn ?? "— (no TLS)"),
        h("dt", {}, "TLS version"), h("dd", {}, info.tls ?? "—"),
        h("dt", {}, "Cipher"), h("dd", {}, info.cipher ?? "—"),
        h("dt", {}, "Client port"), h("dd", {}, String(info.remotePort))),
      h("table", {}, h("thead", {}, h("tr", {}, h("th", {}, "Request header"), h("th", {}, "Value"))),
        h("tbody", {}, interesting.map(([k, v]) => h("tr", {}, h("td", { class: "mono" }, k), h("td", { class: "mono" }, String(v)))))),
      h("p", { class: "note" }, "Headers starting with \":\" are HTTP/2 pseudo-headers. sec-fetch-* are Fetch Metadata, which servers use to reject cross-site requests (§65). \"priority\" is the HTTP extensible-priorities header (RFC 9218)."));
    missions.complete("network", "whoami");
  };
  return panel("What did we negotiate?", button("Ask the server", go, { class: "primary" }), out);
}

// ------------------------------------------------- waterfall ----
function waterfallPanel() {
  const out = h("div", {});
  const count = h("input", { type: "number", value: "18", min: "1", max: "40", "aria-label": "Number of requests", style: { width: "70px" } });
  const delay = h("input", { type: "number", value: "600", min: "50", max: "3000", step: "50", "aria-label": "Server delay (ms)", style: { width: "80px" } });

  const fire = async () => {
    fireBtn.disabled = true;
    const run = Math.random().toString(36).slice(2, 8);
    const n = Number(count.value);
    out.replaceChildren(h("p", {}, `Firing ${n} requests…`));
    const t0 = performance.now();
    await Promise.all(Array.from({ length: n }, (_, i) => fetch(`/api/delay?ms=${delay.value}&id=${i}&run=${run}`, { cache: "no-store" }).then((r) => r.json())));
    const wall = performance.now() - t0;
    await sleep(50); // let the resource timing entries land
    const entries = performance.getEntriesByType("resource").filter((e) => e.name.includes(`run=${run}`))
      .sort((a, b) => a.startTime - b.startTime);
    const start = Math.min(...entries.map((e) => e.startTime));
    const end = Math.max(...entries.map((e) => e.responseEnd));
    const pct = (t) => ((t - start) / (end - start)) * 100;
    const proto = entries[0]?.nextHopProtocol || "?";
    const conns = assignConnections(entries, proto);
    out.replaceChildren(
      h("div", { class: "readouts" },
        h("div", { class: "readout" }, h("small", {}, "Protocol"), h("b", {}, proto)),
        h("div", { class: "readout" }, h("small", {}, "Wall time"), h("b", {}, ms(wall))),
        h("div", { class: "readout" }, h("small", {}, "Connections used (approx)"), h("b", {}, String(conns.lanes))),
        h("div", { class: "readout" }, h("small", {}, "Server time each"), h("b", {}, ms(entries[0]?.serverTiming?.[0]?.duration)))),
      h("div", { class: "legend" },
        h("span", {}, h("i", { class: "seg-wait" }), "queued / stalled"),
        h("span", {}, h("i", { class: "seg-ttfb" }), "waiting for server (TTFB)"),
        h("span", {}, h("i", { class: "seg-dl" }), "download")),
      h("div", { class: "waterfall", role: "img", "aria-label": `Waterfall of ${entries.length} requests over ${proto}` },
        entries.map((e, i) => {
          const segs = [
            ["seg-wait", e.requestStart - e.startTime],
            ["seg-ttfb", e.responseStart - e.requestStart],
            ["seg-dl", e.responseEnd - e.responseStart],
          ];
          const total = e.responseEnd - e.startTime;
          return h("div", { class: "wf-row" },
            h("span", {}, `#${i} c${conns.of.get(e)}`),
            h("div", { class: "wf-track" },
              h("div", { class: "wf-bar", style: { left: `${pct(e.startTime)}%`, width: `${Math.max(0.5, pct(e.responseEnd) - pct(e.startTime))}%`, animationDelay: `${i * 40}ms` } },
                segs.map(([cls, d]) => h("span", { class: cls, style: { width: `${(Math.max(0, d) / total) * 100}%` } })))),
            h("span", {}, ms(total)));
        })),
      h("p", { class: "note" }, proto === "h2" || proto === "h3"
        ? "HTTP/2: every request shares one connection as interleaved streams, so they all start at once. The grey \"queued\" segments are gone."
        : "HTTP/1.1: a connection carries one request at a time and browsers open at most 6 per origin, so the requests run in waves. Open the https:// URL (npm run cert first) to compare with HTTP/2."));
    fireBtn.disabled = false;
    missions.complete("network", "waterfall");
  };
  const fireBtn = button("🚀 Fire the burst", fire, { class: "primary" });
  return panel("Experiment: many requests at once",
    h("p", {}, "Each request hits /api/delay, which waits on the server and then answers. Watch how the protocol schedules them."),
    h("div", { class: "row" }, h("label", {}, "requests ", count), h("label", {}, "server delay ms ", delay), fireBtn),
    out);
}

// Group requests into connections the simple way: a request reuses the
// first lane that was free when it started sending. HTTP/2 is always one lane.
function assignConnections(entries, proto) {
  const of = new Map();
  if (proto === "h2" || proto === "h3") {
    entries.forEach((e) => of.set(e, 1));
    return { lanes: 1, of };
  }
  const lanes = [];
  for (const e of entries) {
    let i = lanes.findIndex((free) => free <= e.requestStart + 1);
    if (i === -1) i = lanes.push(0) - 1;
    lanes[i] = e.responseEnd;
    of.set(e, i + 1);
  }
  return { lanes: lanes.length, of };
}

function ssePanel() {
  const log = h("ol", { class: "log", "aria-label": "Server-sent events" });
  let es;
  const start = () => {
    es?.close();
    log.replaceChildren();
    es = new EventSource("/api/ticks");
    es.onmessage = (e) => {
      const { tick, at } = JSON.parse(e.data);
      log.prepend(h("li", {}, `id=${e.lastEventId} tick ${tick} · server→browser ${Date.now() - at}ms`));
      if (tick === 10) es.close();
    };
    es.onerror = () => es.close();
  };
  return {
    el: panel("Streaming: Server-Sent Events", h("p", { class: "note" }, "One long-lived response; the server writes an event every 500ms. Over HTTP/2 it is just another stream on the shared connection, so it doesn't use up one of the 6 HTTP/1.1 connection slots."), button("Open the stream", start), log),
    close: () => es?.close(),
  };
}

export function mount() {
  const sse = ssePanel();
  const el = station(
    "URL to bytes: TLS & HTTP/2",
    "Before any JavaScript runs, the browser has to find the server, open a connection, secure it and ask for the page. These numbers come from your browser's own Resource Timing and Navigation Timing entries.",
    missionList("network"),
    panel("How this page arrived", navigationPhases()),
    waterfallPanel(),
    h("div", { class: "grid-2" }, tlsPanel(), whoamiPanel()),
    sse.el,
  );
  return { el, unmount: sse.close };
}

Generated from SOURCE.md · 8 Oct 2026 16:36