// The network tap: stream audit's integration for any web player that fetches its playlists and // segments with the page's `fetch` or `XMLHttpRequest` (wai/extensions/stream-audit.md §13). // // It wraps both, and hands the session (./audit.js) every successful response (200 or 206) whose // URL the presentation names: the playlist or manifest the page gives it, every URI a playlist // it saw lists (variants, segments, `EXT-X-MAP`), and, under a DASH manifest, every response of a // media type. A key a playlist names is never handed over (§4.1), and neither is anything else // the page fetches. The response is read from a copy (`Response.clone()`), so the player's own // read is untouched. const dec = new TextDecoder(); const enc = new TextEncoder(); const MEDIA_TYPES = /^(video\/|audio\/|application\/mp4|application\/vnd\.apple\.mpegurl|application\/x-mpegurl)/i; const isPlaylist = (bytes) => bytes.length >= 7 && dec.decode(bytes.subarray(0, 7)) === "#EXTM3U"; const isMpd = (bytes) => /]/.test(dec.decode(bytes.subarray(0, 2048))); /** * Tap `win`'s `fetch` and `XMLHttpRequest` for `session`, starting from the presentation at * `manifest`. Gives `{ restore }`, which puts both back. */ export function tap(session, { manifest, win = globalThis } = {}) { const known = new Set([new URL(manifest, win.location?.href).href]); let dash = false; const relevant = (url, type) => known.has(url) || session.segments.has(url) || (dash && MEDIA_TYPES.test(type ?? "")); const handle = async (url, bytes, status, header) => { if ((status !== 200 && status !== 206) || session.isKey(url)) return; let media = true; if (isPlaylist(bytes)) { media = false; const p = session.playlist(dec.decode(bytes), url); for (const s of p.segments) known.add(s.uri); if (p.map) known.add(p.map); } else if (isMpd(bytes)) { media = false; dash = true; } await session.object({ url, bytes, media, status, cmsdStatic: header("cmsd-static"), cmsdDynamic: header("cmsd-dynamic") }); }; const fetch0 = win.fetch; win.fetch = async function (input, init) { const res = await fetch0.call(this ?? win, input, init); try { if (relevant(res.url, res.headers.get("content-type"))) { const copy = res.clone(); copy.arrayBuffer().then((b) => handle(res.url, new Uint8Array(b), res.status, (h) => res.headers.get(h))).catch(() => {}); } } catch { // An opaque or already-read response: not the presentation's, or nothing to hash. } return res; }; // A listener added as the request is made runs before any the player adds later, so it reads the // response before the player can hand its buffer on (a player that moves fragments to a worker // empties the buffer it moves). const XHR0 = win.XMLHttpRequest; if (XHR0) { win.XMLHttpRequest = class extends XHR0 { constructor(...a) { super(...a); this.addEventListener("readystatechange", () => { if (this.readyState !== 4) return; const url = this.responseURL; if (!url || !relevant(url, this.getResponseHeader("content-type"))) return; let bytes = null; if (this.responseType === "arraybuffer" && this.response) bytes = new Uint8Array(this.response).slice(); else if (this.responseType === "" || this.responseType === "text") bytes = enc.encode(this.responseText); if (bytes) handle(url, bytes, this.status, (h) => this.getResponseHeader(h)).catch(() => {}); }); } }; } return { restore() { win.fetch = fetch0; if (XHR0) win.XMLHttpRequest = XHR0; }, }; }