Mercurial
diff benchmark/bun-http-framework-benchmark/bench.ts @ 183:a8976a008a9d
[BenchMark] Added bun bench mark to test seoboe vs other popular benchmarks.
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 23 Jan 2026 21:19:08 -0800 |
| parents | |
| children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/benchmark/bun-http-framework-benchmark/bench.ts Fri Jan 23 21:19:08 2026 -0800 @@ -0,0 +1,398 @@ +import { + readdirSync, + mkdirSync, + existsSync, + lstatSync, + readFileSync, + writeFileSync +} from 'fs' +import killPort from 'kill-port' +import { $, pathToFileURL } from 'bun' + +const whitelists = <string[]>[] + +// ? Not working +const blacklists = [ + // Not booting up in test + 'node/adonis/index', + // Not setting content-type header for some reason + 'node/nest/index', + // 'Not booting up in test' + 'node/hapi', + // Body: Result not match + 'bun/xirelta', + // Crash + 'bun/bagel', + // Crash + 'bun/bunrest', + // Doesn't work properly + 'bun/colston', + // Crash on 0.6.2 + 'bun/zarf', + // Crash due to invalid npm version requirement of uWebSockets + 'deno/byte', + // Crash + 'bun/fastify', + // failed to parse body in benchmark + 'bun/byte', + // doesn't run + 'bun/vixeny', + 'node/elysia', + 'c/BUILD/index', + 'deno/acorn', + 'deno/deno', + 'deno/deno-web-standard', + 'deno/hono', + 'deno/oak' +] as const + +const time = 10 + +const commands = [ + `/home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d ${time}s http://127.0.0.1:3000/`, + `/home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d ${time}s http://127.0.0.1:3000/id/1?name=bun`, + `/home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d ${time}s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json` +] as const + +const runtimeCommand = { + node: 'node', + deno: 'deno run --allow-net --allow-env', + bun: 'bun', + c: '' // C binaries are run directly after bazel build +} as const + +// Path to the bazel-bin directory relative to project root +const getBazelBinaryPath = (framework: string) => + `../../bazel-bin/benchmark/bun-http-framework-benchmark/src/c/${framework}` + +const catchNumber = /Reqs\/sec\s+(\d+[.|,]\d+)/m +const format = (value: string | number) => + Intl.NumberFormat('en-US').format(+value) +const sleep = (s = 1) => new Promise((resolve) => setTimeout(resolve, s * 1000)) + +const secToMin = (seconds: number) => + Math.floor(seconds / 60) + + ':' + + (seconds % 60 < 10 ? '0' : '') + + (seconds % 60) + +// Fetch with retry +const retryFetch = ( + url: string, + options?: RequestInit, + time = 0, + resolveEnd?: Function, + rejectEnd?: Function +) => { + return new Promise<Response>((resolve, reject) => { + fetch(url, options) + .then((a) => { + if (resolveEnd) resolveEnd(a) + + resolve(a) + }) + .catch((e) => { + if (time > 7) { + if (rejectEnd) rejectEnd(e) + + return reject(e) + } + setTimeout( + () => retryFetch(url, options, time + 1, resolve, reject), + 200 + ) + }) + }) +} + +const test = async () => { + try { + const index = await retryFetch('http://127.0.0.1:3000/') + + if ((await index.text()) !== 'Hi') + throw new Error('Index: Result not match') + + if (!index.headers.get('Content-Type')?.includes('text/plain')) + throw new Error('Index: Content-Type not match') + + const query = await retryFetch('http://127.0.0.1:3000/id/1?name=bun') + if ((await query.text()) !== '1 bun') + throw new Error('Query: Result not match') + + if (!query.headers.get('Content-Type')?.includes('text/plain')) + throw new Error('Query: Content-Type not match') + + if (!query.headers.get('X-Powered-By')?.includes('benchmark')) + throw new Error('Query: X-Powered-By not match') + + const body = await retryFetch('http://127.0.0.1:3000/json', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + hello: 'world' + }) + }) + + if ((await body.text()) !== JSON.stringify({ hello: 'world' })) + throw new Error('Body: Result not match') + + if (!body.headers.get('Content-Type')?.includes('application/json')) + throw new Error('Body: Content-Type not match') + } catch (error) { + throw error + } +} + +const spawn = (target: string, title = true) => { + let [runtime, framework, index] = target.split('/') as [ + keyof typeof runtimeCommand, + string, + string + ] + if (index) framework += '/index' + + const name = framework.replace('/index', '') + + if (title) { + console.log('\n', name) + console.log(' >', runtime, framework, '\n') + } + + let cmd: string[] + + if (runtime === 'c') { + // For C, run the pre-built bazel binary directly + const binaryPath = getBazelBinaryPath(framework) + cmd = [binaryPath] + } else { + const file = existsSync(`./src/${runtime}/${framework}.ts`) + ? `src/${runtime}/${framework}.ts` + : `src/${runtime}/${framework}.js` + cmd = [...runtimeCommand[runtime].split(" "), file] + } + + const server = Bun.spawn({ + cmd, + env: { + ...Bun.env, + NODE_ENV: 'production' + } + }) + + return async () => { + await server.kill() + await sleep(0.3) + + try { + await fetch('http://127.0.0.1:3000') + await sleep(0.6) + await fetch('http://127.0.0.1:3000') + + await killPort(3000) + } catch { + // Empty + } + } +} + +try { + if (lstatSync('results').isDirectory()) rimraf.sync('results') +} catch {} +await Bun.$`rm -rf ./results` +mkdirSync('results') +writeFileSync('results/results.md', '') +const resultFile = Bun.file('results/results.md') +const result = resultFile.writer() + +const main = async () => { + try { + await fetch('http://127.0.0.1:3000') + await killPort(3000) + } catch { + // Empty + } + + const runtimes = <string[]>[] + + let frameworks = readdirSync('src') + .flatMap((runtime) => { + if (!lstatSync(`src/${runtime}`).isDirectory()) return + + if (!existsSync(`results/${runtime}`)) + mkdirSync(`results/${runtime}`) + + return readdirSync(`src/${runtime}`) + .filter( + (a) => + a.endsWith('.ts') || + a.endsWith('.js') || + a.endsWith('.c') || + !a.includes('.') + ) + .map((a) => + a.includes('.') + ? `${runtime}/` + a.replace(/\.(j|t|c)s?$/, '') + : `${runtime}/${a}/index` + ) + .filter( + (a) => + !blacklists.includes(a as (typeof blacklists)[number]) + ) + }) + .filter((x) => x) + .sort() + + // Overwrite test here + frameworks = whitelists?.length ? whitelists : frameworks + + console.log(`${frameworks.length} frameworks`) + for (const framework of frameworks) console.log(`- ${framework}`) + + console.log('\nTest:') + for (const target of frameworks) { + const kill = spawn(target!, false) + + let [runtime, framework] = target!.split('/') + await sleep(0.1) + + if (runtimes.includes(runtime)) { + const folder = `results/${runtime}` + + if (!lstatSync(folder).isDirectory()) rimraf(folder) + } + + try { + const kill = await test() + + console.log(`✅ ${framework} (${runtime})`) + } catch (error) { + console.log(`❌ ${framework} (${runtime})`) + console.log(' ', (error as Error)?.message || error) + + frameworks.splice(frameworks.indexOf(target!), 1) + } finally { + await kill() + } + } + + const estimateTime = frameworks.length * (commands.length * time + 1) + + console.log() + console.log(`${frameworks.length} frameworks`) + for (const framework of frameworks) console.log(`- ${framework}`) + + console.log(`\nEstimate time: ${secToMin(estimateTime)} min`) + + // process.exit() + + result.write( + ` +| Framework | Runtime | Average | Ping | Query | Body | +| ---------------- | ------- | ------- | ---------- | ---------- | ---------- | +` + ) + + for (const target of frameworks) { + const kill = spawn(target!) + + let [runtime, framework, index] = target!.split('/') as [ + keyof typeof runtimeCommand, + string, + string + ] + + const name = framework.replace('/index', '') + + const frameworkResultFile = Bun.file(`results/${runtime}/${name}.txt`) + const frameworkResult = frameworkResultFile.writer() + + result.write(`| ${name} | ${runtime} `) + + // Wait .3 second for server to bootup + await sleep(0.4) + + let content = '' + const total = [] + + for (const command of commands) { + frameworkResult.write(`${command}\n`) + + console.log(command) + + const res = await Bun.spawn({ + cmd: command.split(' '), + env: Bun.env + }) + + const stdout = await new Response(res.stdout).text() + console.log(stdout) + + const results = catchNumber.exec(stdout) + if (!results?.[1]) continue + + content += `| ${format(results[1])} ` + total.push(toNumber(results[1])) + + frameworkResult.write(results + '\n') + } + + content = + `| ${format( + total.reduce((a, b) => +a + +b, 0) / commands.length + )} ` + + content + + '|\n' + + result.write(content) + await result.flush() + + await kill() + } +} + +const toNumber = (a: string) => +a.replaceAll(',', '') + +const arrange = () => { + const table = readFileSync('results/results.md', { + encoding: 'utf-8' + }) + + const orders = [] + + const [title, divider, ...rows] = table.split('\n') + for (const row of rows) { + const data = row + .replace(/\ /g, '') + .split('|') + .filter((a) => a) + + if (data.length !== commands.length + 3) continue + + const [name, runtime, total] = data + orders.push({ + name, + runtime, + total: toNumber(total), + row + }) + } + + const content = [ + title, + divider, + ...orders.sort((a, b) => b.total - a.total).map((a) => a.row) + ].join('\n') + + console.log(content) + writeFileSync('results/results.md', content) + + process.exit(0) +} + +process.on('beforeExit', async () => { + await killPort(3000) +}) + +main().then(arrange)