208 lines
6.0 KiB
JavaScript
208 lines
6.0 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 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 },
|
|
}),
|
|
};
|
|
}
|