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 };
}