server.mjs
// Browser Lab server — zero dependencies (§79).
//
// npm run cert one-time: a self-signed cert for localhost (or use mkcert)
// npm start http://localhost:8088 HTTP/1.1, plain text
// https://localhost:8443 HTTP/2 over TLS 1.3 (when certs/ exists)
//
// Open both and run the Network station's "fire 18 requests" experiment in
// each: HTTP/1.1 shows a 6-connection staircase, HTTP/2 one multiplexed burst.
import http from "node:http";
import http2 from "node:http2";
import { readFile } from "node:fs/promises";
import { existsSync, readFileSync } from "node:fs";
import { extname, join, normalize, sep } from "node:path";
import { fileURLToPath } from "node:url";
const HTTP_PORT = Number(process.env.HTTP_PORT ?? 8088);
const HTTPS_PORT = Number(process.env.HTTPS_PORT ?? 8443);
const HOST = process.env.HOST ?? "localhost";
const ROOT = fileURLToPath(new URL(".", import.meta.url));
const PUBLIC_DIR = join(ROOT, "public") + sep;
const CERT = join(ROOT, "certs", "cert.pem");
const KEY = join(ROOT, "certs", "key.pem");
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".json": "application/json",
};
// Cross-origin isolation (COOP + COEP) unlocks SharedArrayBuffer and
// performance.measureUserAgentSpecificMemory() for the Threads and Debugging
// stations (§39, §83). The CSP is strict: no inline script, no eval — the
// Debugging station shows what a violation looks like.
const HEADERS = {
"Content-Security-Policy":
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; " +
"worker-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
"Cross-Origin-Resource-Policy": "same-origin",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function send(res, status, body, headers = {}) {
res.writeHead(status, { ...HEADERS, ...headers });
res.end(body);
}
const json = (res, status, data, headers = {}) =>
send(res, status, JSON.stringify(data), { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers });
// What the browser actually negotiated and sent, from the server's side.
function connectionInfo(req) {
const socket = req.stream?.session?.socket ?? req.socket;
return {
httpVersion: req.httpVersion,
alpn: socket.alpnProtocol || null,
tls: socket.getProtocol?.() ?? null,
cipher: socket.getCipher?.()?.name ?? null,
remotePort: socket.remotePort,
headers: req.headers,
};
}
const routes = {
// A deliberately slow endpoint for the waterfall experiment.
"/api/delay": async (req, res, url) => {
const ms = Math.min(Number(url.searchParams.get("ms")) || 300, 5000);
const start = performance.now();
await sleep(ms);
// Server-Timing shows up in DevTools and in PerformanceResourceTiming.serverTiming.
json(res, 200, { ms, id: url.searchParams.get("id") }, {
"Server-Timing": `wait;dur=${(performance.now() - start).toFixed(1)};desc="simulated work"`,
});
},
"/api/whoami": (req, res) => json(res, 200, connectionInfo(req)),
// A server-sent event stream: one tick every 500ms, ten ticks.
"/api/ticks": async (req, res) => {
res.writeHead(200, { ...HEADERS, "Content-Type": "text/event-stream", "Cache-Control": "no-store" });
for (let i = 1; i <= 10 && !res.destroyed; i++) {
res.write(`id: ${i}\ndata: ${JSON.stringify({ tick: i, at: Date.now() })}\n\n`);
await sleep(500);
}
res.end();
},
};
async function handler(req, res) {
const url = new URL(req.url, "http://x");
try {
if (routes[url.pathname]) return await routes[url.pathname](req, res, url);
const rel = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.slice(1));
const file = normalize(join(PUBLIC_DIR, rel));
if (!file.startsWith(PUBLIC_DIR)) return json(res, 404, { error: "Not found" });
const body = await readFile(file);
send(res, 200, body, { "Content-Type": MIME[extname(file)] ?? "application/octet-stream", "Cache-Control": "no-cache" });
} catch (err) {
if (err.code === "ENOENT" || err.code === "EISDIR") return json(res, 404, { error: "Not found" });
console.error(err);
if (!res.headersSent) json(res, 500, { error: "Something went wrong" });
}
}
http.createServer(handler).listen(HTTP_PORT, HOST, () =>
console.log(`Browser Lab HTTP/1.1 http://${HOST}:${HTTP_PORT}`),
);
if (existsSync(CERT) && existsSync(KEY)) {
// allowHTTP1: browsers that don't offer "h2" in ALPN still get a page.
http2
.createSecureServer({ cert: readFileSync(CERT), key: readFileSync(KEY), allowHTTP1: true }, handler)
.listen(HTTPS_PORT, HOST, () => console.log(`Browser Lab HTTP/2 https://${HOST}:${HTTPS_PORT}`));
} else {
console.log("No certs/ found: run `npm run cert` to also serve HTTP/2 over TLS on :" + HTTPS_PORT);
}