Skip to content
Closed
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
8 changes: 8 additions & 0 deletions app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ export default defineAppConfig({
list: 'rounded-full',
},
},
prose: {
code: {
// `inline-block` makes a chip unbreakable, so a long token like
// `@vercube/telemetry` pushes itself onto a line of its own on a narrow
// screen. Inline, and allowed to wrap.
base: 'px-1.5 py-0.5 text-sm font-mono font-medium rounded-md inline [overflow-wrap:anywhere] border border-muted text-highlighted bg-muted',
},
},
},
seo: {
siteName: 'Vercube Docs',
Expand Down
221 changes: 161 additions & 60 deletions app/components/Home/Benchmarks.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
<span class="text-foreground">Measurably </span><span class="text-[#bc4eff]">faster</span>
</h2>
<p class="mt-5 text-foreground/60 font-(family-name:--font-geist-mono) text-balance">
Real numbers from the open benchmark suite. Same endpoints, comparable config, January 2026.
A container and decorators, at the speed of a bare router. Same routes, same machine, one run of the open benchmark
suite on Node, September 2026.
</p>
</div>

Expand All @@ -30,41 +31,59 @@
</div>

<!-- chart -->
<div class="space-y-5">
<div v-for="(row, i) in activeMetric.rows" :key="row.name" class="bench-row" :style="{ '--d': `${i * 80}ms` }">
<div class="mb-2 flex items-baseline justify-between gap-3">
<span
class="flex items-center gap-2 font-(family-name:--font-geist-mono) text-sm"
:class="row.name === 'Vercube' ? 'text-foreground' : 'text-foreground/55'"
>
{{ row.name }}
<span
v-if="row.name === 'Vercube'"
class="rounded-full border border-[#bc4eff]/30 bg-[#bc4eff]/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-[#bc4eff]"
>
Fastest
</span>
</span>
<span
class="font-(family-name:--font-geist-mono) text-sm tabular-nums"
:class="row.name === 'Vercube' ? 'text-[#bc4eff]' : 'text-foreground/45'"
>
{{ format(row.value, activeMetric.unit) }} {{ activeMetric.unit }}
<div class="space-y-12">
<div v-for="group in activeMetric.groups" :key="group.title">
<div class="mb-5 flex items-baseline justify-between gap-3 border-b border-white/[0.07] pb-2">
<h3 class="font-(family-name:--font-geist-mono) text-xs uppercase tracking-wider text-foreground/50">
{{ group.title }}
</h3>
<span class="font-(family-name:--font-geist-mono) text-[10px] text-foreground/30">
{{ group.caption }}
</span>
</div>
<div class="track">
<div
class="bar"
:class="row.name === 'Vercube' ? 'bar--primary' : 'bar--muted'"
:style="{ width: `${barWidth(row.value)}%` }"
/>

<div class="space-y-5">
<div v-for="(row, i) in group.rows" :key="row.name" class="bench-row" :style="{ '--d': `${i * 80}ms` }">
<div class="mb-2 flex items-baseline justify-between gap-3">
<span
class="flex items-center gap-2 font-(family-name:--font-geist-mono) text-sm"
:class="row.name === 'Vercube' ? 'text-foreground' : 'text-foreground/55'"
>
{{ row.name }}
<span
v-if="row.name === leaderOf(group)"
class="rounded-full border border-white/15 bg-white/5 px-2 py-0.5 text-[10px] uppercase tracking-wide text-foreground/60"
:class="row.name === 'Vercube' ? 'border-[#bc4eff]/30 bg-[#bc4eff]/10 text-[#bc4eff]' : ''"
>
Fastest
</span>
</span>
<span
class="font-(family-name:--font-geist-mono) text-sm tabular-nums"
:class="row.name === 'Vercube' ? 'text-[#bc4eff]' : 'text-foreground/45'"
>
{{ format(row.value, activeMetric.unit) }} {{ activeMetric.unit }}
</span>
</div>
<div class="track">
<div
class="bar"
:class="row.name === 'Vercube' ? 'bar--primary' : 'bar--muted'"
:style="{ width: `${barWidth(row.value)}%` }"
/>
</div>
</div>
</div>
</div>
</div>

<p class="mt-10 text-center text-xs text-foreground/35 font-(family-name:--font-geist-mono)">
<p class="mt-8 text-center text-xs text-foreground/45 font-(family-name:--font-geist-mono)">
{{ activeMetric.note }}
</p>

<p class="mt-3 text-center text-xs text-foreground/35 font-(family-name:--font-geist-mono)">
<span v-if="!activeMetric.higherIsBetter">Lower is better. </span>
Run it yourself:
Node 24, 500 connections, 10s per route. Run it yourself:
<NuxtLink
to="https://github.com/vercube/benchmarks"
target="_blank"
Expand All @@ -84,61 +103,143 @@ interface BenchRow {
value: number;
}

interface BenchGroup {
title: string;
caption: string;
rows: BenchRow[];
}

interface Metric {
key: string;
label: string;
unit: string;
higherIsBetter: boolean;
rows: BenchRow[];
note: string;
groups: BenchGroup[];
}

// Source: github.com/vercube/benchmarks, latest published run (January 2026).
const ROUTERS = 'Against the fast routers';
const ROUTERS_CAPTION = 'no container, no decorators';
const FRAMEWORKS = 'Against frameworks with a container';
const FRAMEWORKS_CAPTION = 'the ones you actually choose between';

// Source: github.com/vercube/benchmarks, results/results.md, September 2026.
// One run of the whole suite on one machine, Node 24. Every framework answers
// the same four routes on top of the same 112-route table. Video throughput is
// left out on purpose: it moves +/-13% between runs of identical code, and the
// suite's Average column includes it.
const metrics: Metric[] = [
{
key: 'throughput',
label: 'Requests/s',
key: 'ping',
label: 'Plain text',
unit: 'req/s',
higherIsBetter: true,
rows: [
{ name: 'Vercube', value: 95_588 },
{ name: 'NestJS', value: 82_705 },
{ name: 'Rikta', value: 81_156 },
{ name: 'Routing Controllers', value: 78_195 },
{ name: 'Ts.ED', value: 32_156 },
note: 'GET /, a two-byte text response.',
groups: [
{
title: ROUTERS,
caption: ROUTERS_CAPTION,
rows: [
{ name: 'Vercube', value: 99_030 },
{ name: 'Fastify', value: 97_072 },
{ name: 'Hono', value: 91_927 },
{ name: 'Elysia', value: 90_005 },
{ name: 'h3', value: 87_526 },
],
},
{
title: FRAMEWORKS,
caption: FRAMEWORKS_CAPTION,
rows: [
{ name: 'Vercube', value: 99_030 },
{ name: 'Rikta', value: 98_682 },
{ name: 'Ts.ED', value: 46_909 },
{ name: 'NestJS', value: 39_833 },
{ name: 'routing-controllers', value: 35_155 },
],
},
],
},
{
key: 'coldstart',
label: 'Cold start',
unit: 'ms',
higherIsBetter: false,
rows: [
{ name: 'Vercube', value: 280 },
{ name: 'Rikta', value: 326 },
{ name: 'Routing Controllers', value: 329 },
{ name: 'NestJS', value: 377 },
{ name: 'Ts.ED', value: 946 },
key: 'query',
label: 'Path + query',
unit: 'req/s',
higherIsBetter: true,
note: 'GET /id/:id?name=, one path parameter and one query parameter.',
groups: [
{
title: ROUTERS,
caption: ROUTERS_CAPTION,
rows: [
{ name: 'Vercube', value: 96_682 },
{ name: 'Fastify', value: 95_679 },
{ name: 'h3', value: 82_481 },
{ name: 'Hono', value: 80_426 },
{ name: 'Elysia', value: 78_683 },
],
},
{
title: FRAMEWORKS,
caption: FRAMEWORKS_CAPTION,
rows: [
{ name: 'Rikta', value: 98_508 },
{ name: 'Vercube', value: 96_682 },
{ name: 'NestJS', value: 36_823 },
{ name: 'routing-controllers', value: 36_294 },
{ name: 'Ts.ED', value: 36_099 },
],
},
],
},
{
key: 'build',
label: 'Build time',
unit: 's',
higherIsBetter: false,
rows: [
{ name: 'Vercube', value: 0.28 },
{ name: 'Routing Controllers', value: 0.42 },
{ name: 'Ts.ED', value: 0.46 },
{ name: 'Rikta', value: 0.97 },
{ name: 'NestJS', value: 1.3 },
key: 'body',
label: 'JSON body',
unit: 'req/s',
higherIsBetter: true,
note: 'POST /json, parsed and echoed back.',
groups: [
{
title: ROUTERS,
caption: ROUTERS_CAPTION,
rows: [
{ name: 'Vercube', value: 86_259 },
{ name: 'Elysia', value: 77_725 },
{ name: 'h3', value: 77_637 },
{ name: 'Hono', value: 73_333 },
{ name: 'Fastify', value: 66_571 },
],
},
{
title: FRAMEWORKS,
caption: FRAMEWORKS_CAPTION,
rows: [
{ name: 'Vercube', value: 86_259 },
{ name: 'Rikta', value: 77_353 },
{ name: 'Ts.ED', value: 35_691 },
{ name: 'routing-controllers', value: 33_644 },
{ name: 'NestJS', value: 31_224 },
],
},
],
},
];

const active = ref(0);
const activeMetric = computed(() => metrics[active.value]!);

const maxValue = computed(() => Math.max(...activeMetric.value.rows.map((row) => row.value)));
// One scale for both groups of a metric, so the row Vercube shares between them
// is the same width twice and the two groups can be read against each other.
const maxValue = computed(() => Math.max(...activeMetric.value.groups.flatMap((group) => group.rows.map((row) => row.value))));

// The badge marks whichever framework actually leads its group. Vercube leads
// one group on one tab, so hard-coding it here would be a lie everywhere else.
function leaderOf(group: BenchGroup): string | undefined {
const best = activeMetric.value.higherIsBetter
? Math.max(...group.rows.map((row) => row.value))
: Math.min(...group.rows.map((row) => row.value));

return group.rows.find((row) => row.value === best)?.name;
}

function barWidth(value: number): number {
// Bars represent the real value; min 6% so the smallest is still visible.
Expand Down
38 changes: 21 additions & 17 deletions app/components/Home/Hero.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,20 @@
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-64 bg-gradient-to-b from-transparent to-[#010101]" />

<div class="container relative z-10 mx-auto flex flex-col items-center px-6">
<div class="flex min-h-[78vh] flex-col items-center justify-center pt-24 text-center">
<p class="hero-line font-(family-name:--font-geist-mono) text-sm text-foreground/45" :style="{ '--i': 0 }">
<div class="hero-part flex min-h-[60vh] flex-col items-center justify-center pt-28 text-center" :style="{ '--i': 0 }">
<p class="font-(family-name:--font-geist-mono) text-sm text-foreground/45">
<span class="text-[#bc4eff]">//</span> {{ page.hero.slug }}
</p>

<h1
class="hero-line mt-5 text-5xl leading-[1.08] font-(family-name:--font-geist-pixel-circle) sm:text-6xl lg:text-7xl"
:style="{ '--i': 1 }"
>
<h1 class="mt-5 text-5xl leading-[1.08] font-(family-name:--font-geist-pixel-circle) sm:text-6xl lg:text-7xl">
{{ page.hero.title }}
</h1>

<p
class="hero-line mx-auto mt-6 max-w-xl text-balance text-foreground/55 font-(family-name:--font-geist-mono)"
:style="{ '--i': 2 }"
>
<p class="mx-auto mt-6 max-w-xl text-balance text-foreground/55 font-(family-name:--font-geist-mono)">
{{ page.hero.description }}
</p>

<div class="hero-line mt-9 flex flex-col items-center gap-5" :style="{ '--i': 3 }">
<div class="mt-9 flex flex-col items-center gap-5">
<button type="button" class="install group" :aria-label="`Copy: ${installCmd}`" @click="copyInstall">
<span class="prompt">$</span>
<code>{{ installCmd }}</code>
Expand Down Expand Up @@ -68,7 +62,9 @@
</div>
</div>

<div class="hero-line w-full max-w-6xl pb-28 text-left" :style="{ '--i': 4 }">
<!-- The file starts inside the first screen: the fold crops it, which is
what invites the scroll. -->
<div class="hero-part mt-14 w-full max-w-6xl pb-24 text-left" :style="{ '--i': 1 }">
<HomeCode />
</div>
</div>
Expand Down Expand Up @@ -100,7 +96,9 @@ async function copyInstall() {

<style scoped>
.scrim {
background: radial-gradient(ellipse 70% 52% at 50% 40%, rgb(1 1 1 / 0.8), rgb(1 1 1 / 0.4) 55%, transparent 82%);
/* Denser in the middle than it was: the display face is built from dots and
so is the field behind it, so the headline needs the contrast. */
background: radial-gradient(ellipse 72% 54% at 50% 38%, rgb(1 1 1 / 0.88), rgb(1 1 1 / 0.45) 56%, transparent 82%);
}

.install {
Expand Down Expand Up @@ -129,6 +127,11 @@ async function copyInstall() {
transform: scale(0.98);
}

.install:focus-visible {
outline: 2px solid #bc4eff;
outline-offset: 3px;
}

.install .prompt {
color: #bc4eff;
}
Expand All @@ -143,16 +146,17 @@ async function copyInstall() {
}

@media (prefers-reduced-motion: no-preference) {
.hero-line {
animation: hero-rise 0.7s cubic-bezier(0.23, 1, 0.32, 1) both;
animation-delay: calc(var(--i) * 90ms + 120ms);
/* One move, in two beats: the title card, then the file. */
.hero-part {
animation: hero-rise 0.75s cubic-bezier(0.23, 1, 0.32, 1) both;
animation-delay: calc(var(--i) * 160ms + 120ms);
}
}

@keyframes hero-rise {
from {
opacity: 0;
transform: translateY(16px);
transform: translateY(18px);
}
to {
opacity: 1;
Expand Down
Loading
Loading