import http from 'node:http'; import { Transform } from 'node:stream'; /** * ThrottleTransform * Delays stream chunks to simulate bandwidth caps (e.g. 1.6 Mbps = 200,000 B/s). */ class ThrottleTransform extends Transform { constructor(bytesPerSec) { super(); this.bytesPerSec = bytesPerSec; } _transform(chunk, encoding, callback) { if (!this.bytesPerSec || this.bytesPerSec <= 0) { this.push(chunk); return callback(); } const delayMs = (chunk.length / this.bytesPerSec) * 1000; setTimeout(() => { this.push(chunk); callback(); }, Math.min(delayMs, 2000)); } } /** * Categorize Content-Type into standard metric buckets. */ export function categorizeContentType(contentType, urlPath) { const ct = (contentType || '').toLowerCase(); const path = (urlPath || '').toLowerCase(); if (ct.includes('text/html') || path.endsWith('.html')) return 'html'; if (ct.includes('javascript') || path.endsWith('.js') || path.endsWith('.mjs')) return 'js'; if (ct.includes('text/css') || path.endsWith('.css')) return 'css'; if (ct.includes('font') || path.endsWith('.woff2') || path.endsWith('.woff') || path.endsWith('.ttf')) return 'fonts'; if (ct.includes('image/') || path.endsWith('.png') || path.endsWith('.jpg') || path.endsWith('.svg') || path.endsWith('.webp') || path.endsWith('.ico')) return 'images'; if (ct.includes('video/') || ct.includes('audio/') || path.endsWith('.mp4') || path.endsWith('.m4a') || path.endsWith('.mp3')) return 'media'; if (ct.includes('application/json') || path.startsWith('/api/')) return 'api'; return 'other'; } /** * Creates an HTTP throttling & stalling proxy for WebKit (and browser network tracing). * Supports both forward proxying and reverse proxying to targetPort. */ export function createThrottleProxy({ targetPort, profile = 'lte' }) { let activeTracking = false; let requestCount = 0; let requests = []; let totalWireBytes = 0; let bytesByType = { html: 0, js: 0, css: 0, fonts: 0, images: 0, media: 0, api: 0, other: 0, }; let reqIndex = 0; let isOfflineMode = false; // Profile configuration: // LTE: 150ms roundtrip latency, 1.6 Mbps (200 KB/s) download, 750 kbps upload // Lossy: 250ms latency, 1.0 Mbps (125 KB/s) download, stall every 7th request by 1500ms // Unthrottled: 0 latency, 0 throttle (for Chromium where CDP throttles network/CPU) const isLossy = profile === 'lossy'; const isUnthrottled = profile === 'unthrottled'; const latencyMs = isUnthrottled ? 0 : (isLossy ? 250 : 150); const downloadBytesPerSec = isUnthrottled ? 0 : (isLossy ? (1.0 * 1000 * 1000) / 8 : (1.6 * 1000 * 1000) / 8); const server = http.createServer((clientReq, clientRes) => { if (isOfflineMode) { clientReq.destroy(); return; } reqIndex++; const currentReqIndex = reqIndex; let stallMs = 0; if (isLossy && currentReqIndex % 7 === 0) { stallMs = 1500; // Simulated lossy stall } const totalDelay = isUnthrottled ? 0 : (latencyMs + stallMs); const rawUrl = clientReq.url; const targetUrl = rawUrl.startsWith('http://') || rawUrl.startsWith('https://') ? rawUrl : `http://127.0.0.1:${targetPort}${rawUrl}`; const parsedTarget = new URL(targetUrl); const pathname = parsedTarget.pathname; const options = { protocol: parsedTarget.protocol, hostname: parsedTarget.hostname, port: parsedTarget.port, path: parsedTarget.pathname + parsedTarget.search, method: clientReq.method, headers: { ...clientReq.headers, host: parsedTarget.host }, }; const forward = () => { const upReq = http.request(options, (upRes) => { const ct = upRes.headers['content-type'] || ''; const cat = categorizeContentType(ct, pathname); let recorded; if (activeTracking) { requestCount++; recorded={url:clientReq.url,status:upRes.statusCode,bytes:0}; requests.push(recorded); } clientRes.writeHead(upRes.statusCode, upRes.headers); if (isUnthrottled) { upRes.on('data', (chunk) => { if (activeTracking) { totalWireBytes += chunk.length; if(recorded) recorded.bytes += chunk.length; bytesByType[cat] = (bytesByType[cat] || 0) + chunk.length; } clientRes.write(chunk); }); upRes.on('end', () => clientRes.end()); } else { const throttler = new ThrottleTransform(downloadBytesPerSec); throttler.on('data', (chunk) => { if (activeTracking) { totalWireBytes += chunk.length; if(recorded) recorded.bytes += chunk.length; bytesByType[cat] = (bytesByType[cat] || 0) + chunk.length; } }); upRes.pipe(throttler).pipe(clientRes); } }); upReq.on('error', (err) => { try { clientRes.statusCode = 502; clientRes.end(err.message); } catch {} }); clientReq.pipe(upReq); }; if (totalDelay > 0) { setTimeout(forward, totalDelay); } else { forward(); } }); return { server, listen: () => new Promise((resolve) => { server.listen(0, '127.0.0.1', () => { const port = server.address().port; resolve(port); }); }), close: () => new Promise((resolve) => { server.close(resolve); }), startTracking: () => { activeTracking = true; }, stopTracking: () => { activeTracking = false; }, resetStats: () => { requestCount = 0; requests = []; totalWireBytes = 0; bytesByType = { html: 0, js: 0, css: 0, fonts: 0, images: 0, media: 0, api: 0, other: 0, }; }, setOffline: (val) => { isOfflineMode = !!val; }, getStats: () => ({ requestCount, requests, totalWireBytes, bytesByType: { ...bytesByType }, }), }; }