Repository navigation
Expand file tree
/
Copy pathnext.config.ts
More file actions
475 lines (470 loc) · 21 KB
/
Copy pathnext.config.ts
File metadata and controls
475 lines (470 loc) · 21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
import type { NextConfig } from "next";
import { RENAMED_BENCH_SLUGS } from "./src/lib/removed-benches";
// Content-Security-Policy. `'unsafe-inline'` for scripts is needed because
// three pages emit JSON-LD via `dangerouslySetInnerHTML` (layout.tsx,
// providers/[slug]/page.tsx, benchmarks/[slug]/page.tsx). The blocks are
// JSON.stringify of editor-controlled data, no user input, so the residual
// XSS risk is bounded. Future hardening: move JSON-LD to <Script> with a
// sha256 hash in script-src.
const RELAY_WS = "wss://stream.openchainbench.com";
// Origin of the standalone Remotion renderer that serves Export Video MP4s
// (cached via sha256 at /v/<hash>.mp4). Allowed in media-src so the
// modal's <video> tag can play the result, and in connect-src so the
// proxy's success URL can be opportunistically pre-fetched.
const VIDEO_RENDERER_ORIGIN = "https://video.openchainbench.com";
// Ahrefs Web Analytics. Loaded from analytics.ahrefs.com and beacons
// back to the same origin — needs both script-src and connect-src
// allowlist entries. Kept as a distinct constant so a future analytics
// swap is a one-line change.
const AHREFS_ORIGIN = "https://analytics.ahrefs.com";
const IS_DEV = process.env.NODE_ENV !== "production";
// React dev mode needs 'unsafe-eval' for fast refresh / call stack reconstruction.
// In prod we keep the lock-tight policy.
const SCRIPT_SRC = IS_DEV
? `script-src 'self' 'unsafe-inline' 'unsafe-eval' ${AHREFS_ORIGIN}`
: `script-src 'self' 'unsafe-inline' ${AHREFS_ORIGIN}`;
const CSP = [
"default-src 'self'",
SCRIPT_SRC,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
`connect-src 'self' ${RELAY_WS} ${VIDEO_RENDERER_ORIGIN} ${AHREFS_ORIGIN}`,
`media-src 'self' ${VIDEO_RENDERER_ORIGIN}`,
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
"upgrade-insecure-requests",
].join("; ");
const SECURITY_HEADERS = [
{ key: "Content-Security-Policy", value: CSP },
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), interest-cohort=()" },
];
const nextConfig: NextConfig = {
// Explicit non-trailing-slash URLs so /benchmarks and /benchmarks/ don't
// diverge into two indexable variants. Default is `false` already; we
// pin it so a future Next minor that flips defaults can't silently
// split bench rankings between the two surface URLs.
trailingSlash: false,
// Inject a build-time timestamp so the sitemap can emit a stable
// <lastmod> per deploy instead of `new Date()` at request time. The
// sitemap runs on force-dynamic (to bypass Next's 2 MB Data Cache
// limit), which means `new Date()` at module init evaluates anew on
// every Google crawl. Result: every URL in the sitemap got a freshly
// updated lastmod each visit, Google flagged the signal as unreliable
// and stopped using it to prioritise recrawls. This baking pins the
// value at build time so it changes only when a new deploy ships.
env: {
NEXT_PUBLIC_BUILD_TIME: new Date().toISOString(),
},
turbopack: {
root: __dirname,
},
// Build-time page budget. The Prom client serializes queries through a
// global concurrency cap (src/lib/prometheus.ts); the FIRST page each
// build worker prerenders pays the whole multi-bench Prom load and can
// exceed the default 60 s budget (observed 2026-06-10: staging build
// failing on /products/<slug> + OG-image routes after the cap landed).
// Later pages reuse the worker cache and render in milliseconds, so
// only that first-page budget needs headroom.
staticPageGenerationTimeout: 240,
// Tree-shake lucide-react down to just the icons we actually import.
// Without this hint Next's App Router can include the full barrel
// (~1k icons, ~25 KB gzipped) on routes that touch lucide indirectly.
experimental: {
optimizePackageImports: ["lucide-react"],
},
// YAML spec files live outside src and are loaded at runtime via
// fs.readFile(process.cwd() + "/benchmarks/..."). Next's default file
// tracer only follows static imports, so those YAML files were not
// making it into the Vercel build artifact and the prebuilt deployment
// kept serving a snapshot from whichever earlier build first cached
// them. outputFileTracingIncludes forces every YAML directory the
// spec loaders read at runtime to be packaged with the deployment so
// the page always reflects the dev HEAD of these specs.
outputFileTracingIncludes: {
"/**": [
"./benchmarks/**/*.yml",
"./benchmarks/**/*.yaml",
"./answers/**/*.yml",
"./answers/**/*.yaml",
"./alternatives/**/*.yml",
"./alternatives/**/*.yaml",
"./src/content/reports/**/*.mdx",
// Prebuild-generated manifest of editorial hub-page git mtimes.
// Read at module scope by src/app/sitemap.ts so <lastmod> reflects
// real edit history instead of Vercel's build-container mtime
// reset (which pins every file to Oct 20 2018). Not committed
// (regenerated every build) so it must be traced in explicitly to
// ship in the serverless bundle.
"./data/page-mtimes.json",
],
},
async headers() {
// No `Vary: Accept` on the HTML side of the three Markdown families
// (rewrites() above): the app router replaces a configured Vary with
// its own (rsc, next-router-state-tree, ...), verified on staging, so
// the rule never reached a response. Isolation of the two
// representations does not need it on Vercel: the header-conditioned
// rewrite changes the origin path to /api/md/<path>, so the CDN keys
// the two documents apart (checked: HTML after Markdown stays HTML and
// the reverse). The Markdown route sets its own Vary: Accept.
return [
{
source: "/:path*",
headers: SECURITY_HEADERS,
},
{
// Any hostname other than the canonical production domain is a
// duplicate of the whole site and must not be indexed. robots.ts
// and the layout's noindex both key off VERCEL_ENV, which says
// "production" for a production build no matter which hostname
// serves it. On 2026-09-29 staging.openchainbench.com was aliased
// to a production deployment and served `Allow: /` with no
// noindex, so the entire catalog was crawlable twice.
//
// This keys off the host instead, so an alias pointed at the wrong
// deployment can no longer expose a second indexable copy. It is a
// response header rather than a meta tag on purpose: reading the
// host in the root layout would opt every page out of static
// rendering.
source: "/:path*",
missing: [{ type: "host", value: "openchainbench.com" }],
headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }],
},
{
// The RPC speed test fires fetch() at user-supplied endpoints
// straight from the browser — the whole product. The site-wide
// connect-src allowlist blocked every probe (surfaced as a fake
// "CORS" failure on endpoints that are actually CORS-open, e.g.
// publicnode). Open connect-src to any https origin on THIS
// route only; every other directive keeps the tight policy.
source: "/speedtest-rpc",
headers: [
...SECURITY_HEADERS.filter((h) => h.key !== "Content-Security-Policy"),
{
key: "Content-Security-Policy",
value: CSP.replace(
/connect-src [^;]+/,
`connect-src 'self' https: ${RELAY_WS}`,
),
},
],
},
{
// Badges are designed to be embedded as <img> in third-party
// READMEs and blogs. Override frame-ancestors so SVG embedding
// via <iframe>/<object> also works. Drop the X-Frame-Options
// equivalent by allowing all ancestors. CSP still applies
// (img-src etc.) — only frame embedding is opened.
source: "/api/badge/:path*",
headers: [
...SECURITY_HEADERS.filter((h) => h.key !== "Content-Security-Policy"),
{
key: "Content-Security-Policy",
value: CSP.replace("frame-ancestors 'none'", "frame-ancestors *"),
},
],
},
{
// Machine endpoints (citable JSON, badges, generated OG images)
// are fetched by Googlebot from links and meta tags and then sit
// in Search Console as "Crawled, currently not indexed" (about 60
// /api/stat and /api/badge URLs on 2026-09-19). noindex settles
// it; LLM crawlers still fetch them (robots.txt stays open).
source: "/api/:path*",
headers: [{ key: "X-Robots-Tag", value: "noindex" }],
},
{
// INERT, kept as a record of intent. Citable JSON is polled by
// LLM crawlers (Perplexity, ChatGPT, Claude Deep Research) and
// this rule was added to put it behind an hour-long edge cache.
// It does not: a config header does not override a Cache-Control
// the route handler sets on its own response, and
// src/app/api/citable/route.ts sets one. Sampled every 45 s on
// 2026-09-30, the edge entry for /api/citable went STALE at age
// 306 and reset to 42, then went STALE again at 313: two clean
// cycles, so the served window is that route's 300 s and not a
// one-off eviction. Change the window in the route, not here.
// The sitemap sets its Cache-Control the same way and for the same
// reason: `src/app/sitemap.xml/route.ts` is a Route Handler that
// returns its own Response, so whatever it sets wins over this
// layer. (It is a Route Handler rather than a `sitemap.ts`
// metadata route precisely because a metadata route left Next in
// charge of the header; the file's own docblock has that history.)
source: "/api/citable",
headers: [
{
key: "Cache-Control",
value: "public, s-maxage=3600, stale-while-revalidate=86400",
},
],
},
{
// Provider marks are static files under `public/`, which Next
// serves as `max-age=0, must-revalidate` unless told otherwise.
// That means a browser holding every logo in its disk cache still
// asks the server about each one before it may paint it.
//
// It was harmless while a page carried a few dozen. /capital now
// references 158 distinct marks, so an ordinary visit opens 158
// conditional requests; measured from a machine next to the cdg1
// edge each 304 costs about 95 ms, and the marks render as empty
// circles until they land. 72 of those files arrived in September
// alone, which is why this turned slow recently rather than
// gradually.
//
// A day of silence, then a month of serving the cached copy while
// a fresh one is fetched behind it. Not `immutable`: these
// filenames are stable and their CONTENTS get replaced (Chainstack
// sent a new mark in #2910), so a year-long immutable cache would
// pin an old logo on every returning reader. A day is short enough
// that a swapped mark appears on its own.
source: "/logos/:path*",
headers: [
{
key: "Cache-Control",
value: "public, max-age=86400, stale-while-revalidate=2592000",
},
],
},
];
},
async rewrites() {
// Markdown negotiation: a client whose Accept header names
// text/markdown and not text/html (agents, curl) gets the Markdown
// view of a bench, the perps hub or a product at the page's own URL,
// served by /api/md/<path>. Browsers send text/html first and never
// match. The middleware stays a literal list of retired URLs.
// beforeFiles: /perps is a concrete page, and a bare array (afterFiles)
// runs after the filesystem match, so the rule would never fire there.
// Case-insensitive on the two media types (JS RegExp here takes no
// flags). The rule is deliberately simple, not a q-value comparison:
// any text/html token above q=0 keeps HTML, whatever q the markdown
// token carries, and a markdown token at q=0 is no preference. A
// client that wants Markdown sends `Accept: text/markdown` alone (or
// text/html;q=0), which is what agents and curl do; browsers never
// name text/markdown and always keep HTML.
const ci = (s: string) => s.replace(/[a-z]/g, (c) => `[${c.toUpperCase()}${c}]`);
const markdownOnly = [
{ type: "header" as const, key: "accept", value: `^(?!.*${ci("text/html")}(?!\\s*;\\s*q=0(?:\\.0+)?(?![.0-9]))).*${ci("text/markdown")}(?!\\s*;\\s*q=0(?:\\.0+)?(?![.0-9])).*$` },
];
const beforeFiles = [
{ source: "/benchmarks/:slug", has: markdownOnly, destination: "/api/md/benchmarks/:slug" },
{ source: "/products/:slug", has: markdownOnly, destination: "/api/md/products/:slug" },
{ source: "/perps", has: markdownOnly, destination: "/api/md/perps" },
{ source: "/rwa", has: markdownOnly, destination: "/api/md/rwa" },
{ source: "/capital", has: markdownOnly, destination: "/api/md/capital" },
];
const afterFiles = [
// PostHog reverse proxy — routes /ingest/* through the Next.js server
// so ad-blockers that block posthog.com directly don't drop events.
// api_host in posthog-provider.tsx is set to "/ingest" to match.
{
source: "/ingest/static/:path*",
destination: "https://us-assets.i.posthog.com/static/:path*",
},
{
source: "/ingest/:path*",
destination: "https://us.i.posthog.com/:path*",
},
];
return { beforeFiles, afterFiles, fallback: [] };
},
async redirects() {
// RPC cluster promotion (2026-07): the per-chain RPC leaderboards
// graduated from variant pages under rpc-capabilities to first-class
// benches (044-053, slug "<chain>-rpc") with their own YAML, FAQ and
// region dimension. 301 the old variant URLs so Google transfers the
// "fastest <chain> rpc" rank signal to the dedicated pages.
const RPC_CLUSTER_CHAINS = [
"ethereum",
"arbitrum",
"base",
"optimism",
"avalanche",
"bnb",
"polygon",
"linea",
"scroll",
"mantle",
"gnosis",
"sonic",
"solana",
"polkadot",
];
const rpcClusterRedirects = RPC_CLUSTER_CHAINS.map((chain) => ({
source: `/benchmarks/rpc-capabilities/${chain}`,
destination: `/benchmarks/${chain}-rpc`,
permanent: true,
}));
// Chains live at /chains/<slug> per the new chain hub route family.
// Many of these slugs also resolve under /products/<slug> because
// the bench loader treats row shape benches (l1-finality,
// l2-block-time, network-fees) as if the chains were providers. We
// 301 the products surface to the chains surface so Google
// consolidates rank signal on the chains canonical and so any old
// bench page link that pointed at /products/<chain> lands on the
// richer chain hub.
const CHAIN_REDIRECT_SLUGS = [
"ethereum",
"solana",
"bnb",
"avalanche",
"sui",
"gram",
"stellar",
"tron",
"cardano",
"litecoin",
"monero",
"polygon",
"arbitrum",
"optimism",
"base",
"zksync",
"linea",
"scroll",
"mantle",
"taiko",
];
const chainRedirects = CHAIN_REDIRECT_SLUGS.map((slug) => ({
source: `/products/${slug}`,
destination: `/chains/${slug}`,
permanent: true,
}));
return [
// www to the apex, permanently.
//
// www.openchainbench.com served the whole site at 200 with no
// redirect, so the catalogue was reachable on two hostnames. The host
// rule added on 2026-09-30 now marks it noindex, which stops the
// duplicate being indexed but throws away anything that links to the
// www form. A 301 passes that authority to the apex instead, which is
// what a hostname with inbound links deserves; the noindex stays as
// the net for every other host.
//
// Written with `has` on the host rather than in middleware so it costs
// nothing at runtime and cannot be missed by a route that skips the
// matcher.
{
source: "/:path*",
has: [{ type: "host", value: "www.openchainbench.com" }],
destination: "https://openchainbench.com/:path*",
permanent: true,
},
{ source: "/live", destination: "/", permanent: true },
{ source: "/networks", destination: "/", permanent: true },
{ source: "/providers", destination: "/products", permanent: true },
{ source: "/providers/:slug", destination: "/products/:slug", permanent: true },
// 2026-09-17: one canonical page per product. The per-entity detail
// routes under /hyperliquid and /perp folded into /products/<slug>
// as views behind the pill bar; the hash opens the matching view.
// The /hyperliquid and /perps hubs (no slug) are untouched.
// The hub's own image routes (/hyperliquid/opengraph-image) must
// not match: a bare :slug would 308 them to a 404. Same for the
// hub's own sub-pages: /hyperliquid/traders is the leaderboard
// audit, not a builder slug, and without the exclusion it 308s to
// /products/traders#hl, which does not exist. Any future sub-page
// of the hub has to be added here too.
// Unlabeled builder addresses have no product page (hex slugs 404
// by design); their old detail URLs land on the bench that lists them.
{
source: "/hyperliquid/:slug(0x[0-9a-fA-F]+)",
destination: "/benchmarks/hyperliquid-frontends",
permanent: true,
},
{
source:
"/hyperliquid/:slug((?!opengraph-image|twitter-image|icon|apple-icon|traders|0x).*)",
destination: "/products/:slug#hl",
permanent: true,
},
{ source: "/perp/:slug", destination: "/products/:slug#perp", permanent: true },
{
source: "/benchmarks/rpc-latency",
destination: "/benchmarks/rpc-capabilities",
permanent: true,
},
// TON → Gram token rebrand (June 2026). Chain slug renamed
// ton → gram across CHAINS, PROVIDER_REGISTRY, per_chain_explainer
// and bench provider entries. Pin permanent 308 redirects from the
// old paths so inbound links + Google SERP entries land on the new
// canonical without losing rank signal.
{
source: "/chains/ton",
destination: "/chains/gram",
permanent: true,
},
{
source: "/products/ton",
destination: "/chains/gram",
permanent: true,
},
// Scoped to the 3 benches that ship a gram (formerly ton)
// per_chain_explainer. The previous catch-all `/benchmarks/:slug/ton`
// fired for every bench, redirecting non-gram benches to a /gram
// path that 404s (e.g. aggregator-head-lag/ton → aggregator-head-lag/gram → 404),
// which Ahrefs and Google flag as a soft-404 redirect chain.
{
source: "/benchmarks/l1-finality/ton",
destination: "/benchmarks/l1-finality/gram",
permanent: true,
},
{
source: "/benchmarks/network-fees/ton",
destination: "/benchmarks/network-fees/gram",
permanent: true,
},
{
source: "/benchmarks/wallet-labels-coverage/ton",
destination: "/benchmarks/wallet-labels-coverage/gram",
permanent: true,
},
// polymarket-resolution-delay was an early bench slug drafted in a
// worktree but never merged. Answer pages and inbound links used the
// old slug; 308 to the live bench (pm-resolution-delay) so rank
// signal consolidates and pages stop 404-ing.
{
source: "/benchmarks/polymarket-resolution-delay",
destination: "/benchmarks/pm-resolution-delay",
permanent: true,
},
{
source: "/benchmarks/polymarket-resolution-delay/:path*",
destination: "/benchmarks/pm-resolution-delay/:path*",
permanent: true,
},
{
source: "/apps/exec",
destination: "/trading-apps",
permanent: true,
},
{
source: "/benchmarks/trading-app-execution",
destination: "/trading-apps",
permanent: true,
},
{
source: "/apps",
destination: "/trading-apps",
permanent: true,
},
...chainRedirects,
...rpcClusterRedirects,
// Renamed / split benches: 308 the legacy URL to its successor so
// backlinks keep their PageRank. Used to be a 301 in middleware;
// a routing-layer redirect costs no function or edge invocation.
...Object.entries(RENAMED_BENCH_SLUGS).map(([from, to]) => ({
source: `/benchmarks/${from}`,
destination: `/benchmarks/${to}`,
permanent: true,
})),
];
},
};
export default nextConfig;