#!/usr/bin/env bun /** * HAR + CPU inspection for plugin endpoints using real test payloads. * * Builds the Cloudflare plugin worker (wrangler dry-run), boots ONE Miniflare * isolate, replays /ok + /updates + /stats + /channel_self bodies shaped like * tests/test-utils getBaseData(), captures: * - HAR 1.2 entries (timings / sizes / status) * - host-side process.cpuUsage() around dispatchFetch * * Usage: * bun scripts/bench_plugin_har_inspect.ts * bun scripts/bench_plugin_har_inspect.ts --save scripts/bench/plugin_har_inspect_results.json * bun scripts/bench_plugin_har_inspect.ts --har scripts/bench/plugin_endpoints.har */ import { spawnSync } from 'node:child_process' import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { Miniflare } from 'miniflare' const ROOT = resolve(import.meta.dirname, '..') interface Scenario { name: string method: string path: string body?: unknown headers?: Record warmIters: number } interface RequestSample { name: string status: number wallMs: number cpuMs: number responseBytes: number startedDateTime: string time: number timings: { blocked: number dns: number connect: number send: number wait: number receive: number ssl: number } } function parseArgs(argv: string[]) { let savePath = 'scripts/bench/plugin_har_inspect_results.json' let harPath = 'scripts/bench/plugin_endpoints.har' let warmIters = 40 let muteLogs = true for (let i = 0; i < argv.length; i++) { if (argv[i] === '--save') savePath = argv[++i]! else if (argv[i] === '--har') harPath = argv[++i]! else if (argv[i] === '--warm') warmIters = Number.parseInt(argv[++i]!, 10) else if (argv[i] === '--mute-logs') muteLogs = true else if (argv[i] === '--keep-logs') muteLogs = false } return { savePath, harPath, warmIters, muteLogs } } function withMutedConsole(enabled: boolean, fn: () => Promise): Promise { if (!enabled) return fn() const noop = () => {} const original = { log: console.log, info: console.info, warn: console.warn, error: console.error, debug: console.debug, } console.log = noop console.info = noop console.warn = noop console.error = noop console.debug = noop return fn().finally(() => { console.log = original.log console.info = original.info console.warn = original.warn console.error = original.error console.debug = original.debug }) } function buildPlugin(outDir: string) { mkdirSync(outDir, { recursive: true }) const result = spawnSync('bunx', [ 'wrangler', 'deploy', '--config', 'cloudflare_workers/plugin/wrangler.jsonc', '--env=local', '--dry-run', '--minify', `--outdir=${outDir}`, ], { cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, }) if (result.status !== 0) throw new Error(`wrangler dry-run failed:\n${result.stdout}\n${result.stderr}`) const uploadMatch = result.stdout.match(/Total Upload:\s*([\d.]+)\s*KiB\s*\/\s*gzip:\s*([\d.]+)\s*KiB/) return { uploadKiB: uploadMatch ? Number.parseFloat(uploadMatch[1]) : null, gzipKiB: uploadMatch ? Number.parseFloat(uploadMatch[2]) : null, scriptPath: join(outDir, 'index.js'), } } function createMiniflare(scriptPath: string) { return new Miniflare({ compatibilityDate: '2026-04-21', compatibilityFlags: ['nodejs_compat', 'nodejs_compat_populate_process_env'], modules: [ { type: 'ESModule', path: 'index.js', contents: readFileSync(scriptPath, 'utf8'), }, ], bindings: { ENV_NAME: 'capgo_plugin-local', CAPGO_PREVENT_BACKGROUND_FUNCTIONS: 'true', }, }) } /** Mirror tests/test-utils makeBaseData / getBaseData shape. */ function basePluginBody(appId = 'com.demo.app', deviceId = '00009a6b-eefe-490a-9c60-8e965132ae51') { return { channel: 'production', platform: 'android', device_id: deviceId, app_id: appId, custom_id: '', version_build: '1.0.0', version_code: '1', version_os: '13', version_name: '1.0.0', plugin_version: '7.0.0', is_emulator: false, is_prod: true, } } function deviceIdFor(index: number) { // Unique per request so channel_self IP/device rate limits do not skew the bench. const hex = index.toString(16).padStart(12, '0') return `00009a6b-eefe-490a-9c60-${hex}` } function scenarios(warmIters: number): Scenario[] { const updateBody = basePluginBody() const statsBody = { ...basePluginBody(), action: 'get', } const channelSelfBody = { ...basePluginBody(), channel: 'production', } const invalidUpdate = { app_id: 'not a domain', device_id: 'bad', version_name: '1.0.0', version_build: '1.0.0', is_emulator: false, is_prod: true, platform: 'ios', plugin_version: 'nope', } return [ { name: 'ok_get', method: 'GET', path: '/ok', warmIters }, { name: 'updates_valid_shape', method: 'POST', path: '/updates', body: updateBody, headers: { 'content-type': 'application/json' }, warmIters }, { name: 'updates_invalid', method: 'POST', path: '/updates', body: invalidUpdate, headers: { 'content-type': 'application/json' }, warmIters }, { name: 'stats_valid_shape', method: 'POST', path: '/stats', body: statsBody, headers: { 'content-type': 'application/json' }, warmIters }, { name: 'channel_self_valid_shape', method: 'POST', path: '/channel_self', body: channelSelfBody, headers: { 'content-type': 'application/json' }, warmIters }, ] } async function sampleRequest(mf: Miniflare, scenario: Scenario, index: number): Promise { let body = scenario.body if (body && typeof body === 'object' && scenario.name === 'channel_self_valid_shape') body = { ...body as Record, device_id: deviceIdFor(index) } const bodyText = body === undefined ? undefined : JSON.stringify(body) const headers = new Headers(scenario.headers) if (bodyText) headers.set('content-type', headers.get('content-type') ?? 'application/json') // Mimic Cloudflare production: every edge request carries cf-ray. headers.set('cf-ray', `har-inspect-${scenario.name}-${index}`) const request = new Request(`http://local${scenario.path}`, { method: scenario.method, headers, body: bodyText, }) const startedDateTime = new Date().toISOString() const cpu0 = process.cpuUsage() const t0 = performance.now() const response = await mf.dispatchFetch(request) const buf = await response.arrayBuffer() const wallMs = performance.now() - t0 const cpu = process.cpuUsage(cpu0) const cpuMs = (cpu.user + cpu.system) / 1000 return { name: scenario.name, status: response.status, wallMs, cpuMs, responseBytes: buf.byteLength, startedDateTime, time: wallMs, timings: { blocked: -1, dns: -1, connect: -1, send: 0, wait: wallMs, receive: 0, ssl: -1, }, } } function median(values: number[]) { const sorted = [...values].sort((a, b) => a - b) return sorted[Math.floor(sorted.length / 2)]! } function p95(values: number[]) { const sorted = [...values].sort((a, b) => a - b) return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))]! } function toHar(samples: RequestSample[], scenariosList: Scenario[]) { const byName = new Map(scenariosList.map(s => [s.name, s])) return { log: { version: '1.2', creator: { name: 'capgo-bench_plugin_har_inspect', version: '1.0.0' }, entries: samples.map((sample) => { const scenario = byName.get(sample.name)! const postData = scenario.body === undefined ? undefined : { mimeType: 'application/json', text: JSON.stringify(scenario.body), } return { startedDateTime: sample.startedDateTime, time: sample.time, request: { method: scenario.method, url: `http://local${scenario.path}`, httpVersion: 'HTTP/1.1', headers: [ { name: 'content-type', value: 'application/json' }, { name: 'cf-ray', value: 'har-inspect' }, ], queryString: [], cookies: [], headersSize: -1, bodySize: postData ? postData.text.length : 0, postData, }, response: { status: sample.status, statusText: String(sample.status), httpVersion: 'HTTP/1.1', headers: [], cookies: [], content: { size: sample.responseBytes, mimeType: 'application/json', }, redirectURL: '', headersSize: -1, bodySize: sample.responseBytes, }, cache: {}, timings: sample.timings, comment: `cpuMs=${sample.cpuMs.toFixed(3)} wallMs=${sample.wallMs.toFixed(3)}`, } }), }, } } async function main() { const args = parseArgs(process.argv.slice(2)) const outDir = mkdtempSync(join(tmpdir(), 'capgo-plugin-har-')) console.log(`Building plugin worker into ${outDir}`) const built = buildPlugin(outDir) console.log(`upload ${built.uploadKiB} KiB / gzip ${built.gzipKiB} KiB`) console.log(`log capture: ${args.muteLogs ? 'muted during samples (use --keep-logs to include console CPU)' : 'kept'}`) const list = scenarios(args.warmIters) const mf = createMiniflare(built.scriptPath) const allSamples: RequestSample[] = [] const summary: Record = {} try { // Cold /ok first (module eval) const cold = await withMutedConsole(args.muteLogs, () => sampleRequest(mf, { name: 'ok_get', method: 'GET', path: '/ok', warmIters: 1 }, 0)) allSamples.push(cold) summary.cold_ok = { status: cold.status, wallMs: cold.wallMs, cpuMs: cold.cpuMs, responseBytes: cold.responseBytes, } for (const scenario of list) { const samples: RequestSample[] = [] for (let i = 0; i < scenario.warmIters; i++) samples.push(await withMutedConsole(args.muteLogs, () => sampleRequest(mf, scenario, i + 1))) allSamples.push(...samples) const cpu = samples.map(s => s.cpuMs) const wall = samples.map(s => s.wallMs) summary[scenario.name] = { status: [...new Set(samples.map(s => s.status))], iters: samples.length, medianCpuMs: median(cpu), p95CpuMs: p95(cpu), medianWallMs: median(wall), p95WallMs: p95(wall), medianResponseBytes: median(samples.map(s => s.responseBytes)), } console.log( `${scenario.name.padEnd(28)} status=${(summary[scenario.name] as any).status.join(',')} ` + `cpu(med/p95)=${median(cpu).toFixed(3)}/${p95(cpu).toFixed(3)} ms ` + `wall(med/p95)=${median(wall).toFixed(3)}/${p95(wall).toFixed(3)} ms`, ) } } finally { void mf.dispose().catch(() => {}) } const ranked = Object.entries(summary) .filter(([k]) => k !== 'cold_ok') .map(([name, v]) => ({ name, ...(v as any) })) .sort((a, b) => b.medianCpuMs - a.medianCpuMs) console.log('\n=== Ranked by median CPU ms (warm) ===') for (const row of ranked) console.log(`${row.name.padEnd(28)} ${row.medianCpuMs.toFixed(3)} ms (p95 ${row.p95CpuMs.toFixed(3)}) status=${row.status}`) const report = { generatedAt: new Date().toISOString(), note: 'Without DB bindings, valid-shape /updates|/stats|/channel_self exercise middleware+parse+validation+early handler path (often fail after validation). Invalid paths stay pure CPU. Use --har for waterfall import.', bundle: { uploadKiB: built.uploadKiB, gzipKiB: built.gzipKiB }, summary, rankedByMedianCpuMs: ranked, } const saveAbs = resolve(ROOT, args.savePath) mkdirSync(resolve(saveAbs, '..'), { recursive: true }) writeFileSync(saveAbs, `${JSON.stringify(report, null, 2)}\n`) console.log(`\nSaved ${saveAbs}`) const harAbs = resolve(ROOT, args.harPath) writeFileSync(harAbs, `${JSON.stringify(toHar(allSamples, list), null, 2)}\n`) console.log(`Saved HAR ${harAbs}`) process.exit(0) } main().catch((error) => { console.error(error) process.exit(1) })