Add performance baseline harness, throttling proxy, and documentation
This commit is contained in:
201
perf/proxy.mjs
Normal file
201
perf/proxy.mjs
Normal file
@@ -0,0 +1,201 @@
|
||||
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 },
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user