Files
ytplayer/perf/proxy.mjs

202 lines
5.7 KiB
JavaScript

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 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);
if (activeTracking) {
requestCount++;
}
clientRes.writeHead(upRes.statusCode, upRes.headers);
if (isUnthrottled) {
upRes.on('data', (chunk) => {
if (activeTracking) {
totalWireBytes += 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;
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;
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,
totalWireBytes,
bytesByType: { ...bytesByType },
}),
};
}