Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"!src/content/docs",
"!public/docs",
"!src/data/benchmarks.json",
"!src/data/benchmarks-post.json",
"!src/data/plugins.json",
"!src/data/plugins-by-downloads.json",
"!src/data/versions.json",
Expand Down
83 changes: 58 additions & 25 deletions scripts/download-benchmarks.mjs
Original file line number Diff line number Diff line change
@@ -1,31 +1,46 @@
// @ts-nocheck
/**
* Fetch the latest benchmark results from fastify/benchmarks and emit
* `src/data/benchmarks.json` for the benchmarks page.
* `src/data/benchmarks.json` (GET) and `src/data/benchmarks-post.json` (POST)
* for the benchmarks page.
*
* Mirrors the official fastify/website scripts/download-benchmarks.js, adapted
* for this Astro project:
* - Native `fetch` (Node 22+) instead of `undici`.
* - Output path matches the existing `src/data/*.json` pattern (gitignored,
* regenerated by `pnpm predev` / `pnpm prebuild`).
* regenerated by `npm run build:data`).
* - Output schema: `{ date, reference, frameworks: [{ name, tag, test,
* repository, requests, version, latency, throughput }] }` — sorted by
* `requests` desc, zero-requests entries dropped. `latency` is in ms,
* `throughput` in MB/s — both required by the chart surfaces downstream.
*
* Falls back to the most recent historical commit when the live
* `benchmark-results.json` contains `N/A` data (matches upstream behaviour).
* Falls back to the most recent historical commit when the live results
* contain `N/A` data (matches upstream behaviour). POST results that are not
* published yet (404) are not fatal: an empty `frameworks` list is written and
* the page hides POST. Any other failure fails the build, as it does for GET.
*/
import { writeFile } from "node:fs/promises";
import path from "node:path";
import pino from "pino";
import frameworks from "./frameworks.json" with { type: "json" };

const ROOT = path.resolve(import.meta.dirname, "..");
const OUTPUT = path.join(ROOT, "src/data/benchmarks.json");

const URL_BENCHMARK =
"https://raw.githubusercontent.com/fastify/benchmarks/main/benchmark-results.json";
const SCENARIOS = [
{
sourceFile: "benchmark-results.json",
output: path.join(ROOT, "src/data/benchmarks.json"),
required: true,
},
{
sourceFile: "benchmark-results-post.json",
output: path.join(ROOT, "src/data/benchmarks-post.json"),
required: false,
},
];

const RAW_BASE_URL =
"https://raw.githubusercontent.com/fastify/benchmarks/main";
const GITHUB_BASE_URL = "https://api.github.com/repos/fastify/benchmarks";
const FRAMEWORK_TAGS = frameworks.map(({ tag }) => tag);
const TIMEOUT_MS = 10_000;
Expand All @@ -40,47 +55,60 @@ const log = pino({
});

export async function downloadBenchmarks() {
const data = await downloadBenchmarksFromUrl(URL_BENCHMARK);
await writeFile(OUTPUT, `${JSON.stringify(data, null, 2)}\n`);
await Promise.all(SCENARIOS.map(downloadScenario));
}

async function downloadScenario({ sourceFile, output, required }) {
let data;
try {
data = await downloadBenchmarksFromFile(sourceFile);
} catch (err) {
if (required || err.status !== 404) throw err;
log.warn(`Skipping ${sourceFile}: ${err.message}`);
data = { date: "Unknown", reference: 0, frameworks: [] };
}
await writeFile(output, `${JSON.stringify(data, null, 2)}\n`);
log.info(
`Wrote ${data.frameworks.length} frameworks (reference ${data.reference}) to ${path.relative(ROOT, OUTPUT)}`,
`Wrote ${data.frameworks.length} frameworks (reference ${data.reference}) to ${path.relative(ROOT, output)}`,
);
}

async function downloadBenchmarksFromUrl(githubUrl) {
const data = await getJSON(githubUrl);
async function downloadBenchmarksFromFile(sourceFile) {
const data = await getJSON(`${RAW_BASE_URL}/${sourceFile}`);
if (isValidBenchmark(data)) {
const date = await getBenchmarkDate();
const date = await getBenchmarkDate(sourceFile);
return buildBenchmarksJSON(data, date);
}

log.warn("Fetched file contains `N/A` data. Searching for previous revision");
log.warn(
`${sourceFile} contains \`N/A\` data. Searching for previous revision`,
);

const commits = await getCommits();
const commits = await getCommits(sourceFile);
for (let i = 0; i < commits.length; i++) {
log.debug(`Checking commit ${commits[i]}`);
const treeUrl = await getTree(commits[i]);
const blobUrl = await getUrlFromTree(treeUrl);
const blobUrl = await getUrlFromTree(treeUrl, sourceFile);
const data = await getBlob(blobUrl);
if (isValidBenchmark(data)) {
const date = await getBenchmarkDate(i);
const date = await getBenchmarkDate(sourceFile, i);
return buildBenchmarksJSON(data, date);
}
}

throw new Error("Unable to find a valid benchmark result");
throw new Error(`Unable to find a valid benchmark result in ${sourceFile}`);
}

async function getBenchmarkDate(offset = 0) {
async function getBenchmarkDate(sourceFile, offset = 0) {
const commits = await getJSON(
`${GITHUB_BASE_URL}/commits?path=benchmark-results.json&per_page=10`,
`${GITHUB_BASE_URL}/commits?path=${sourceFile}&per_page=10`,
);
return commits[offset]?.commit?.committer?.date ?? "Unknown";
}

async function getCommits() {
async function getCommits(sourceFile) {
const commits = await getJSON(
`${GITHUB_BASE_URL}/commits?path=benchmark-results.json&per_page=10`,
`${GITHUB_BASE_URL}/commits?path=${sourceFile}&per_page=10`,
);
return commits.map((commit) => commit.sha);
}
Expand All @@ -90,9 +118,9 @@ async function getTree(commitSha) {
return commit.tree.url;
}

async function getUrlFromTree(treeUrl) {
async function getUrlFromTree(treeUrl, sourceFile) {
const tree = await getJSON(treeUrl);
return tree.tree.find((item) => item.path === "benchmark-results.json").url;
return tree.tree.find((item) => item.path === sourceFile).url;
}

async function getBlob(blobUrl) {
Expand All @@ -114,6 +142,7 @@ function buildBenchmarksJSON(data, date = "Unknown") {
date,
reference,
frameworks: frameworks
.filter((framework) => data.some(({ name }) => name === framework.tag))
.map((framework) => {
const item = data.find(({ name }) => name === framework.tag);
const requests = parseMetric(item.requests);
Expand Down Expand Up @@ -155,6 +184,10 @@ async function getJSON(url) {
headers,
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`);
if (!res.ok) {
const err = new Error(`GET ${url} -> ${res.status}`);
err.status = res.status;
throw err;
}
return res.json();
}
13 changes: 10 additions & 3 deletions src/components/BenchBars.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
import { BENCHMARKS, formatMetric } from "~/data/site";
import { type Bench, formatMetric } from "~/data/site";

type Metric = "reqs" | "latency" | "throughput";

Expand All @@ -8,11 +8,18 @@ type Props = {
unit: string;
betterIs: "higher" | "lower";
decimals?: number;
benchmarks: Bench[];
};

const { valueKey, unit, betterIs, decimals = 0 } = Astro.props;
const {
valueKey,
unit,
betterIs,
decimals = 0,
benchmarks,
} = Astro.props;

const rows = [...BENCHMARKS].sort((a, b) => b[valueKey] - a[valueKey]);
const rows = [...benchmarks].sort((a, b) => b[valueKey] - a[valueKey]);
const max = Math.max(...rows.map((b) => b[valueKey]));
---

Expand Down
59 changes: 59 additions & 0 deletions src/components/benchmarks/BenchCards.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
import BenchBars from "~/components/BenchBars.astro";
import BenchStat from "~/components/benchmarks/BenchStat.astro";
import type { Bench, BenchmarkStats } from "~/data/site";

type Props = {
stats: BenchmarkStats;
benchmarks: Bench[];
};

const { stats, benchmarks } = Astro.props;
const {
self: fastify,
multiplier,
latencyMultiplier,
throughputMultiplier,
} = stats;

const lastUpdated = (
Number.isNaN(Date.parse(stats.date)) ? new Date() : new Date(stats.date)
Comment thread
ilteoood marked this conversation as resolved.
).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
---

<div class="space-y-8">
<div class="card p-6 sm:p-10">
<div class="mb-8 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-0">
<h2 class="font-display text-2xl font-semibold">Requests per second</h2>
<span class="font-mono text-xs text-muted"
>Node.js · single instance · updated {lastUpdated}</span
>
</div>
<BenchStat value={fastify.reqs} unit="req/s" multiplier={multiplier} comparison="faster than Express" />
<BenchBars valueKey="reqs" unit="req/s" betterIs="higher" decimals={0} benchmarks={benchmarks} />
</div>
<div class="card p-6 sm:p-10">
<div class="mb-8 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-0">
<h2 class="font-display text-2xl font-semibold">Latency</h2>
<span class="font-mono text-xs text-muted"
>Node.js · single instance · updated {lastUpdated}</span
>
</div>
<BenchStat value={fastify.latency} unit="ms" multiplier={latencyMultiplier} comparison="lower than Express" decimals={2} />
<BenchBars valueKey="latency" unit="ms" betterIs="lower" decimals={2} benchmarks={benchmarks} />
</div>
<div class="card p-6 sm:p-10">
<div class="mb-8 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-0">
<h2 class="font-display text-2xl font-semibold">Throughput</h2>
<span class="font-mono text-xs text-muted"
>Node.js · single instance · updated {lastUpdated}</span
>
</div>
<BenchStat value={fastify.throughput} unit="MB/s" multiplier={throughputMultiplier} comparison="faster than Express" decimals={2} />
<BenchBars valueKey="throughput" unit="MB/s" betterIs="higher" decimals={2} benchmarks={benchmarks} />
</div>
</div>
3 changes: 2 additions & 1 deletion src/components/index/Benchmarks.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import BenchBars from "~/components/BenchBars.astro";
import ButtonLink from "~/components/ButtonLink.astro";
import Icon from "~/components/Icon.astro";
import { BENCHMARKS } from "~/data/site";
import { withBase } from "~/lib/href";
---

Expand Down Expand Up @@ -31,7 +32,7 @@ import { withBase } from "~/lib/href";
</ButtonLink>
</div>
<div class="card p-6 sm:p-8">
<BenchBars valueKey="reqs" unit="req/s" betterIs="higher" decimals={0} />
<BenchBars valueKey="reqs" unit="req/s" betterIs="higher" decimals={0} benchmarks={BENCHMARKS} />
</div>
</div>
</section>
54 changes: 40 additions & 14 deletions src/data/site.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { ProfileCardPerson } from "~/components/ProfileCard.astro";
import benchmarksData from "~/data/benchmarks.json" with { type: "json" };
import postBenchmarksData from "~/data/benchmarks-post.json" with {
type: "json",
};

export const SITE = {
name: "Fastify",
Expand Down Expand Up @@ -33,17 +36,32 @@ export type Bench = {
self?: boolean;
};

// Requests/sec from the official fastify/benchmarks suite, refreshed at build
// time by `scripts/download-benchmarks.mjs` (see `prebuild` / `predev`).
export const BENCHMARKS: Bench[] = benchmarksData.frameworks.map(
({ name, requests, version, latency, throughput }) => ({
type BenchmarksFile = {
date: string;
frameworks: {
name: string;
requests: number;
version: string;
latency: number;
throughput: number;
}[];
};

const toBenchmarks = ({ frameworks }: BenchmarksFile): Bench[] =>
frameworks.map(({ name, requests, version, latency, throughput }) => ({
name,
reqs: requests,
latency,
throughput,
version,
self: name === "Fastify",
}),
}));

// Requests/sec from the official fastify/benchmarks suite, refreshed at build
// time by `scripts/download-benchmarks.mjs` (see `npm run build:data`).
export const BENCHMARKS: Bench[] = toBenchmarks(benchmarksData);
export const POST_BENCHMARKS: Bench[] = toBenchmarks(
postBenchmarksData as BenchmarksFile,
);

export const formatMetric = (n: number, decimals = 0) =>
Expand All @@ -55,14 +73,14 @@ export const formatMetric = (n: number, decimals = 0) =>
// Derived metrics shared by every benchmark surface (gauge, bars, summary
// tiles). Computed once at module load so callers stay in sync if the data
// ever changes.
export const BENCHMARK_STATS = {
date: benchmarksData.date,
self: BENCHMARKS.find((b) => b.self) ?? BENCHMARKS[0],
max: Math.max(...BENCHMARKS.map((b) => b.reqs)),
maxLatency: Math.max(...BENCHMARKS.map((b) => b.latency)),
maxThroughput: Math.max(...BENCHMARKS.map((b) => b.throughput)),
peers: BENCHMARKS.filter((b) => !b.self),
express: BENCHMARKS.find((b) => b.name === "Express") ?? null,
const benchmarkStats = (benchmarks: Bench[], date: string) => ({
date,
self: benchmarks.find((b) => b.self) ?? benchmarks[0],
max: Math.max(...benchmarks.map((b) => b.reqs)),
maxLatency: Math.max(...benchmarks.map((b) => b.latency)),
maxThroughput: Math.max(...benchmarks.map((b) => b.throughput)),
peers: benchmarks.filter((b) => !b.self),
express: benchmarks.find((b) => b.name === "Express") ?? null,
get multiplier(): string | null {
return this.express
? (this.self.reqs / this.express.reqs).toFixed(1)
Expand All @@ -78,7 +96,15 @@ export const BENCHMARK_STATS = {
? (this.self.throughput / this.express.throughput).toFixed(1)
: null;
},
};
});

export type BenchmarkStats = ReturnType<typeof benchmarkStats>;

export const BENCHMARK_STATS = benchmarkStats(BENCHMARKS, benchmarksData.date);
// `null` until fastify/benchmarks has published POST results.
export const POST_BENCHMARK_STATS = POST_BENCHMARKS.length
? benchmarkStats(POST_BENCHMARKS, postBenchmarksData.date)
: null;

export type Feature = {
title: string;
Expand Down
Loading
Loading