From 8a8107fa04847be158a336da5763bd51180ac1f4 Mon Sep 17 00:00:00 2001 From: Da-Jin Chu Date: Sun, 4 Oct 2026 22:47:03 -0400 Subject: [PATCH 01/10] Add server performance benchmarks Adds tests/bench: an HTTP benchmark of the production Bifrost server against a fixture backend, with wrapped, passthru and native scenarios whose pages are shaped like Alignable's sign-in, business profile and events listing. It reports CPU per request (sampled in the server over IPC), throughput and latency across connection levels, can simulate Rails latency, captures CPU profiles, and checks each response is correct before measuring. Also adds an extractDomElements microbench. The tests/vite server now reads its upstream and public URLs from UPSTREAM_URL and PUBLIC_URL, defaulting to the previous values. Co-Authored-By: Claude Opus 5.5 (1M context) --- DEVELOPING.md | 4 + benchmark.md | 65 +-- package-lock.json | 490 ++++++++++++++++++- package.json | 4 +- tests/bench/.gitignore | 1 + tests/bench/README.md | 115 +++++ tests/bench/backend.ts | 60 +++ tests/bench/bench-agent.mjs | 32 ++ tests/bench/fixtures/businessProfile.ts | 270 ++++++++++ tests/bench/fixtures/events.ts | 25 + tests/bench/fixtures/eventsData.ts | 41 ++ tests/bench/fixtures/html.ts | 36 ++ tests/bench/fixtures/layoutCms.ts | 97 ++++ tests/bench/fixtures/print-stats.ts | 11 + tests/bench/fixtures/rand.ts | 56 +++ tests/bench/fixtures/signIn.ts | 214 ++++++++ tests/bench/fixtures/stats.ts | 47 ++ tests/bench/harness.ts | 102 ++++ tests/bench/micro.ts | 28 ++ tests/bench/package.json | 18 + tests/bench/profile.ts | 69 +++ tests/bench/server.ts | 346 +++++++++++++ tests/bench/tsconfig.json | 15 + tests/vite/bifrost.d.ts | 2 + tests/vite/layouts/BenchVisitorLayout.tsx | 216 ++++++++ tests/vite/pages/bench/+Layout.tsx | 8 + tests/vite/pages/bench/+config.ts | 6 + tests/vite/pages/bench/+onAfterRenderHtml.ts | 7 + tests/vite/pages/bench/native/+Page.tsx | 97 ++++ tests/vite/pages/bench/native/+config.ts | 6 + tests/vite/pages/bench/native/+data.ts | 5 + tests/vite/pages/bench/wrapped/+config.ts | 12 + tests/vite/pages/bench/wrapped/+getLayout.ts | 13 + tests/vite/server/index.ts | 4 +- 34 files changed, 2456 insertions(+), 66 deletions(-) create mode 100644 tests/bench/.gitignore create mode 100644 tests/bench/README.md create mode 100644 tests/bench/backend.ts create mode 100644 tests/bench/bench-agent.mjs create mode 100644 tests/bench/fixtures/businessProfile.ts create mode 100644 tests/bench/fixtures/events.ts create mode 100644 tests/bench/fixtures/eventsData.ts create mode 100644 tests/bench/fixtures/html.ts create mode 100644 tests/bench/fixtures/layoutCms.ts create mode 100644 tests/bench/fixtures/print-stats.ts create mode 100644 tests/bench/fixtures/rand.ts create mode 100644 tests/bench/fixtures/signIn.ts create mode 100644 tests/bench/fixtures/stats.ts create mode 100644 tests/bench/harness.ts create mode 100644 tests/bench/micro.ts create mode 100644 tests/bench/package.json create mode 100644 tests/bench/profile.ts create mode 100644 tests/bench/server.ts create mode 100644 tests/bench/tsconfig.json create mode 100644 tests/vite/layouts/BenchVisitorLayout.tsx create mode 100644 tests/vite/pages/bench/+Layout.tsx create mode 100644 tests/vite/pages/bench/+config.ts create mode 100644 tests/vite/pages/bench/+onAfterRenderHtml.ts create mode 100644 tests/vite/pages/bench/native/+Page.tsx create mode 100644 tests/vite/pages/bench/native/+config.ts create mode 100644 tests/vite/pages/bench/native/+data.ts create mode 100644 tests/vite/pages/bench/wrapped/+config.ts create mode 100644 tests/vite/pages/bench/wrapped/+getLayout.ts diff --git a/DEVELOPING.md b/DEVELOPING.md index f423eb3..382696a 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -21,3 +21,7 @@ overmind start -f Procfile.dev ``` This starts the server on http://localhost:5050 and rebuilds bifrost on changes. Playwright will reuse the dev server for tests. + +## Benchmarks + +`npm run bench` (server under HTTP load) and `npm run bench:micro`. See [tests/bench/README.md](tests/bench/README.md) for the optimize/compare/profile workflow. diff --git a/benchmark.md b/benchmark.md index 91b6b58..3ace736 100644 --- a/benchmark.md +++ b/benchmark.md @@ -1,63 +1,8 @@ -# Run +# Benchmarks -1. `npm run build` in root -2. `npm run build` in tests/vite -3. `overmind start -f Procfile.start` -4. Bench with [bombardier](https://github.com/codesenberg/bombardier) +See [tests/bench/README.md](tests/bench/README.md). -# Results - -## Baseline fake-backend - -fake-backend is about the simplest node server possible. - -``` -Bombarding http://localhost:5050/custom-direct?page=%7B%22title%22:%22b%22%7D for 10s using 125 connection(s) -Statistics Avg Stdev Max - Reqs/sec 13350.83 1440.63 16009.51 - Latency 9.36ms 3.40ms 230.77ms - HTTP codes: - 1xx - 0, 2xx - 133578, 3xx - 0, 4xx - 0, 5xx - 0 - others - 0 - Throughput: 22.13MB/s -``` - -## Passthru proxy - -Passthru proxy adds overhead of - -- Fastify proxying -- Vike routing -- a call to Vike renderPage(), rendering nothing (required to run router) - -``` -Bombarding http://localhost:5050/custom-incorrect?page=%7B%22title%22:%22b%22%7D for 10s using 125 connection(s) -Statistics Avg Stdev Max - Reqs/sec 2060.36 543.72 3319.26 - Latency 60.53ms 72.36ms 2.13s - HTTP codes: - 1xx - 0, 2xx - 20694, 3xx - 0, 4xx - 0, 5xx - 0 - others - 0 - Throughput: 3.55MB/s -``` - -## Wrapped proxy - -Wrapped proxy adds on top of passthru proxy: - -- dom parsing -- vike renderPage -- rendering react - -Previously, jsdom got ~250 req/s. - -``` -Bombarding http://localhost:5050/custom?page=%7B%22title%22:%22b%22%7D for 10s using 125 connection(s) -Statistics Avg Stdev Max - Reqs/sec 1145.73 682.67 2501.49 - Latency 108.91ms 12.41ms 306.31ms - HTTP codes: - 1xx - 0, 2xx - 11510, 3xx - 0, 4xx - 0, 5xx - 0 - others - 0 - Throughput: 4.61MB/s +```sh +npm run bench # server: wrapped, passthru and native pages under HTTP load +npm run bench:micro # in-process microbenchmarks ``` diff --git a/package-lock.json b/package-lock.json index df74952..7a12a54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1530,6 +1530,13 @@ "resolved": "bifrost-fastify", "link": true }, + "node_modules/@assemblyscript/loader": { + "version": "0.19.23", + "resolved": "https://registry.npmjs.org/@assemblyscript/loader/-/loader-0.19.23.tgz", + "integrity": "sha512-ulkCYfFbYj01ie1MDOyxv2F6SpRN1TOj7fQxbP07D6HmeR+gr2JLSmINKjga2emB+b1L2KGrFKBTc+e00p54nw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1836,6 +1843,17 @@ "@brillout/picocolors": "^1.0.26" } }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "dev": true, @@ -2732,6 +2750,16 @@ "node": ">=8" } }, + "node_modules/@minimistjs/subarg": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@minimistjs/subarg/-/subarg-1.0.0.tgz", + "integrity": "sha512-Q/ONBiM2zNeYUy0mVSO44mWWKYM3UHuEK43PKIOzJCbvUnPoMH1K+gk3cf1kgnCVJFlWmddahQQCmrmBGlk9jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.1.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, @@ -3099,6 +3127,16 @@ "optional": true, "peer": true }, + "node_modules/@types/autocannon": { + "version": "7.12.7", + "resolved": "https://registry.npmjs.org/@types/autocannon/-/autocannon-7.12.7.tgz", + "integrity": "sha512-Pd4nPf7wRpacULa6D/EC9x3CwzFQXwA0z5WFuik/fvJjW44V3WzBTM3jtt8nSBoflUNgswPiMCtgrr1bwnAcMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3554,6 +3592,54 @@ "node": ">=8.0.0" } }, + "node_modules/autocannon": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/autocannon/-/autocannon-8.0.0.tgz", + "integrity": "sha512-fMMcWc2JPFcUaqHeR6+PbmEpTxCrPZyBUM95oG4w3ngJ8NfBNas/ZXA+pTHXLqJ0UlFVTcy05GC25WxKx/M20A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@minimistjs/subarg": "^1.0.0", + "chalk": "^4.1.0", + "char-spinner": "^1.0.1", + "cli-table3": "^0.6.0", + "color-support": "^1.1.1", + "cross-argv": "^2.0.0", + "form-data": "^4.0.0", + "has-async-hooks": "^1.0.0", + "hdr-histogram-js": "^3.0.0", + "hdr-histogram-percentiles-obj": "^3.0.0", + "http-parser-js": "^0.5.2", + "hyperid": "^3.0.0", + "lodash.chunk": "^4.2.0", + "lodash.clonedeep": "^4.5.0", + "lodash.flatten": "^4.4.0", + "manage-path": "^2.0.0", + "on-net-listen": "^1.1.1", + "pretty-bytes": "^5.4.1", + "progress": "^2.0.3", + "reinterval": "^1.1.0", + "retimer": "^3.0.0", + "semver": "^7.3.2", + "timestring": "^6.0.0" + }, + "bin": { + "autocannon": "autocannon.js" + } + }, + "node_modules/autocannon/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/avvio": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.1.0.tgz", @@ -3841,6 +3927,69 @@ "cert-info": "bin/cert-info.js" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/char-spinner": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/char-spinner/-/char-spinner-1.0.1.tgz", + "integrity": "sha512-acv43vqJ0+N0rD+Uw3pDHSxP30FHrywu2NO6/wBaHChJIizpDeBUd6NjqhNhy9LGaEAhZAXn46QzmlAvIWd16g==", + "dev": true, + "license": "ISC" + }, "node_modules/chokidar": { "version": "3.5.3", "dev": true, @@ -3867,6 +4016,67 @@ "fsevents": "~2.3.2" } }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-table3/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-table3/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-table3/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-table3/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3885,6 +4095,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -3987,6 +4207,13 @@ "optional": true, "peer": true }, + "node_modules/cross-argv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cross-argv/-/cross-argv-2.0.0.tgz", + "integrity": "sha512-YIaY9TR5Nxeb8SMdtrU8asWVM4jqJDNDYlKV21LxtYcfNJhp1kEsgSa6qXwXgzN0WQWGODps0+TlGp2xQSHwOg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-env": { "version": "7.0.3", "dev": true, @@ -4333,6 +4560,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.17.18", "dev": true, @@ -5159,6 +5402,23 @@ "node": "*" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "dev": true, @@ -5425,6 +5685,13 @@ "version": "0.4.1", "license": "MIT" }, + "node_modules/has-async-hooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-async-hooks/-/has-async-hooks-1.0.0.tgz", + "integrity": "sha512-YF0VPGjkxr7AyyQQNykX8zK4PvtEDsUJAPqwu06UFz1lb6EvI53sPh5H1kWxg8NXI5LsfRCZ8uX9NkYDZBb/mw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -5448,10 +5715,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -5461,6 +5744,28 @@ "node": ">= 0.4" } }, + "node_modules/hdr-histogram-js": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hdr-histogram-js/-/hdr-histogram-js-3.0.1.tgz", + "integrity": "sha512-l3GSdZL1Jr1C0kyb461tUjEdrRPZr8Qry7jByltf5JGrA0xvqOSrxRBfcrJqqV/AMEtqqhHhC6w8HW0gn76tRQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@assemblyscript/loader": "^0.19.21", + "base64-js": "^1.2.0", + "pako": "^1.0.3" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/hdr-histogram-percentiles-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hdr-histogram-percentiles-obj/-/hdr-histogram-percentiles-obj-3.0.0.tgz", + "integrity": "sha512-7kIufnBqdsBGcSZLPJwqHT3yhk1QTsSlFsVD3kx5ixH/AlgBs9yM1q6DPhXZ8f8gtdqgh7N7/5btRLpQsS2gHw==", + "dev": true, + "license": "MIT" + }, "node_modules/help-me": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", @@ -5513,6 +5818,13 @@ "node": ">= 0.8" } }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy": { "version": "1.18.1", "license": "MIT", @@ -5546,6 +5858,54 @@ "node": ">=10.17.0" } }, + "node_modules/hyperid": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/hyperid/-/hyperid-3.3.0.tgz", + "integrity": "sha512-7qhCVT4MJIoEsNcbhglhdmBKb09QtcmJNiIQGq7js/Khf5FtQQ9bzcAuloeqBeee7XD7JqDeve9KNlQya5tSGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "uuid": "^8.3.2", + "uuid-parse": "^1.1.0" + } + }, + "node_modules/hyperid/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/hyperid/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "dev": true, @@ -6163,6 +6523,27 @@ "version": "4.17.21", "license": "MIT" }, + "node_modules/lodash.chunk": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.chunk/-/lodash.chunk-4.2.0.tgz", + "integrity": "sha512-ZzydJKfUHJwHa+hF5X66zLFCBrWn5GeF28OHEr4WVWtNDXlQ/IjWKPBiikqKo2ne0+v6JgCgJ0GzJp8k8bHC7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.sortby": { "version": "4.7.0", "dev": true, @@ -6193,6 +6574,13 @@ "optional": true, "peer": true }, + "node_modules/manage-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/manage-path/-/manage-path-2.0.0.tgz", + "integrity": "sha512-NJhyB+PJYTpxhxZJ3lecIGgh4kwIY2RAh44XvAz9UlqthlQwtPBf62uBVR8XaD8CRuSjQ6TnZH2lNJkbLPZM2A==", + "dev": true, + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6552,6 +6940,16 @@ "node": ">= 0.8" } }, + "node_modules/on-net-listen": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/on-net-listen/-/on-net-listen-1.1.2.tgz", + "integrity": "sha512-y1HRYy8s/RlcBvDUwKXSmkODMdx4KSuIvloCnQYJ2LdBBC1asY4HtfhXwe3UWknLakATZDnbzht2Ijw3M1EqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=9.4.0 || ^8.9.4" + } + }, "node_modules/once": { "version": "1.4.0", "license": "ISC", @@ -6579,6 +6977,13 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/parse5": { "version": "7.1.2", "dev": true, @@ -6830,6 +7235,19 @@ } } }, + "node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/process": { "version": "0.11.10", "license": "MIT", @@ -6857,6 +7275,16 @@ ], "license": "MIT" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "dev": true, @@ -7144,6 +7572,13 @@ "node": ">=8" } }, + "node_modules/reinterval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reinterval/-/reinterval-1.1.0.tgz", + "integrity": "sha512-QIRet3SYrGp0HUHO88jVskiG6seqUGC5iAG7AwI/BV4ypGcuqk9Du6YQBUOUqm9c8pw1eyLoIaONifRua1lsEQ==", + "dev": true, + "license": "MIT" + }, "node_modules/request": { "version": "2.88.2", "license": "Apache-2.0", @@ -7250,6 +7685,13 @@ "node": ">=10" } }, + "node_modules/retimer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/retimer/-/retimer-3.0.0.tgz", + "integrity": "sha512-WKE0j11Pa0ZJI5YIk0nflGI7SQsfl2ljihVy7ogh7DeQSeYAUi0ubZ/yEueGtDfUPk6GH5LRw1hBdLq4IwUBWA==", + "dev": true, + "license": "MIT" + }, "node_modules/reusify": { "version": "1.0.4", "license": "MIT", @@ -7841,6 +8283,10 @@ "resolved": "tests/fake-backend", "link": true }, + "node_modules/tests-bench": { + "resolved": "tests/bench", + "link": true + }, "node_modules/tests-playwright": { "resolved": "tests/e2e", "link": true @@ -7885,6 +8331,26 @@ "xtend": "~4.0.1" } }, + "node_modules/timestring": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/timestring/-/timestring-6.0.0.tgz", + "integrity": "sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-5.1.0.tgz", + "integrity": "sha512-LXKNtFualiKOm6gADe1UXPtf8+Nfn1CtPMEHAT33Fd2YjQatrujkDcK0+4wRC1X6t7fxUDXUs6BsvuIgfkDgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -8342,6 +8808,13 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/uuid-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/uuid-parse/-/uuid-parse-1.1.0.tgz", + "integrity": "sha512-OdmXxA8rDsQ7YpNVbKSJkNzTw2I+S5WsbMDnCtIWSQaosNAcWtFuI/YK1TjzUI6nbkgiqEyh8gWngfcv8Asd9A==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "dev": true, @@ -8923,6 +9396,17 @@ "redbird": "^1.0.2" } }, + "tests/bench": { + "name": "tests-bench", + "version": "1.0.0", + "devDependencies": { + "@types/autocannon": "^7.12.7", + "autocannon": "^8.0.0", + "htmlparser2": "^10.0.0", + "tinybench": "^5.0.0", + "tsx": "^4.7.0" + } + }, "tests/e2e": { "name": "tests-playwright", "version": "1.0.0", diff --git a/package.json b/package.json index 74e9fb7..2baed17 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "scripts": { "test": "npm run -w=tests-playwright test", "build": "npm run -w=bifrost -w=bifrost-fastify build", - "publish": "npm -w=bifrost -w=bifrost-fastify publish --access public" + "publish": "npm -w=bifrost -w=bifrost-fastify publish --access public", + "bench": "npm run -w=tests-bench server --", + "bench:micro": "npm run -w=tests-bench micro --" }, "overrides": { "vike": "$vike" diff --git a/tests/bench/.gitignore b/tests/bench/.gitignore new file mode 100644 index 0000000..fbca225 --- /dev/null +++ b/tests/bench/.gitignore @@ -0,0 +1 @@ +results/ diff --git a/tests/bench/README.md b/tests/bench/README.md new file mode 100644 index 0000000..a966ab8 --- /dev/null +++ b/tests/bench/README.md @@ -0,0 +1,115 @@ +# Bifrost benchmarks + +Reproducible server benchmarks for Bifrost. Fixtures are synthetic but shaped like real Alignable pages, so wins here should carry over to production. + +```sh +npm run bench # HTTP load against the production build (~6 min with defaults) +npm run bench -- -c 10 # one connection level only: the fast loop for optimizing (~4 min) +npm run bench:micro # in-process: extractDomElements +``` + +`npm run bench` rebuilds `bifrost`, `bifrost-fastify` and `tests/vite` first, so it always measures your working tree. Pass `--skip-build` if you've already built. Add `--help` to see all options. + +## Workflow for optimizing + +1. Get a baseline: `npm run bench -- -c 10 -o results/before.json`. Paths are relative to `tests/bench`. +2. Make your change. +3. Compare: `npm run bench -- -c 10 --compare results/before.json`. This adds `Δ` columns. +4. Find hot spots: `npm run bench -- -s wrapped -c 10 --profile`. This prints self time by package and by function, and writes `results/profiles/.cpuprofile`. Re-summarize a saved profile with `npx tsx tests/bench/profile.ts `. + +Before measuring, every scenario checks that its response is correct: layout rendered, backend `` and body attributes merged, passthru bytes identical to the backend's, and so on. A broken optimization fails loudly instead of looking fast. Don't edit fixtures or checks to improve numbers. If you change a fixture on purpose, confirm it still matches the targets below with `npm run -w=tests-bench fixtures`. Pass it saved HTML files of real pages to print their stats alongside the fixtures. + +Don't run benchmarks while other CPU-heavy work is going on, and don't run two benchmarks in parallel. + +## How it measures + +The server under test is the production build of `tests/vite` (Fastify + `viteProxyPlugin`), started fresh for each scenario. `backend.ts` stands in for Rails. It's a bare `node:http` server that serves pre-rendered fixtures in microseconds. `--backend-latency ` delays its page responses to simulate Rails render time. + +Bifrost is one Node process. Its JavaScript runs on a single main thread, so one process can use at most about one core; V8's GC threads and libuv's threadpool use a little extra. The load generator (autocannon) and the backend run in separate processes on other cores. Nothing is pinned to cores, because macOS doesn't support pinning. + +Each scenario warms up for 20s at the highest connection level. Wrapped SSR keeps getting cheaper for about 25s, likely while V8 optimizes the hot paths. It then measures each level in `--connections` (default `1,10,100`). A level is 2s of settling followed by 3 rounds of 5s, and medians are reported. Load is closed-loop: each connection sends its next request as soon as the previous response arrives. + +### Reading the table + +- **`cpu ms/req`** is the number to optimize. It's the Bifrost process's user+system CPU time divided by requests served, sampled over IPC (`bench-agent.mjs`). It counts all of the process's threads. Use the saturated level, `c=10`, where it repeats within ~3% between runs. `cpu spread` is (max − min) / median across rounds; above ~5%, re-run. +- **Throughput and latency come from different levels.** + - `req/s` at a saturated level (`c=10` and up) is the capacity of one process. + - `p50`/`p99` at `c=1` is Bifrost's latency with no queueing. At higher levels latency is mostly time spent waiting behind other requests: at saturation, p50 ≈ connections × cpu ms/req. + - `p50 - backend` subtracts `--backend-latency` for scenarios that call the backend. +- **Low-load CPU numbers aren't meaningful.** With `--backend-latency`, `c=1` and `c=10` run at only ~4–40 req/s. Each request then runs on cold CPU caches, often on slower cores/clocks, and a round has too few samples. `cpu ms/req` comes out 2–5x higher with huge spread. Idle background CPU is negligible (~0.06ms/s), so that isn't the cause. + +### Effect of Rails latency + +Measured with `-s wrapped,wrapped-client-nav,passthru -l 250` against `-l 0`: + +- **CPU per request doesn't change once there's enough load.** At `c=100` with 250ms latency: wrapped 3.59 vs 3.60 cpu ms/req, passthru 1.09 vs 1.15. +- **Capacity per process is the same, but needs more requests in flight.** By Little's law, in-flight requests = rate × latency. Wrapped at ~300 req/s with 250ms Rails latency means ~75 concurrent requests per process, each holding its buffered backend HTML. With 10 connections it only reaches ~34–38 req/s. +- **Bifrost adds a few ms to what users wait.** With no queueing, it adds ~4–10ms to wrapped pages and ~1–4ms to passthru and client navigation. That grows with queueing as load approaches capacity, exactly as it does without Rails latency. + +## Scenarios + +| scenario | request | Bifrost work | +| --- | --- | --- | +| `wrapped` | initial load of `/bench/sign-in` | Vike route, proxy to backend, buffer + parse HTML, `renderPage` with React SSR of the layout, serialize pageContext | +| `wrapped-client-nav` | `/bench/sign-in` with `X-VITE-PROXY: 1` (what the browser sends on client navigation) | Vike route, then stream the backend response | +| `passthru` | `/bench/business/...` | wrapped route, backend returns no layout header, so Bifrost falls back to streaming. This is how Alignable serves most Rails pages | +| `native` | `/bench/events` | Vike page with `+data`, React SSR, serialize pageContext | +| `native-client-nav` | `/bench/events/index.pageContext.json` | Vike `+data` + serialization | +| `backend-direct` | fixture backend, no Bifrost | reference floor | + +### Fixtures vs. the Alignable pages they model + +Production measurements were taken from the dev environment. + +| | real page | fixture | +| --- | --- | --- | +| **Wrapped**: `/biz_users/sign_in` Rails body (`X-VITE-PROXY`) | 8.3KB, 63 elements, depth 15 | `fixtures/signIn.ts`: 7.5KB, 72 elements, depth 15 | +| Wrapped page as served by Bifrost | 80.7KB, 275 elements, depth 20, ~35KB pageContext | 84.6KB, 286 elements, depth 20, ~39KB pageContext | +| **Passthru**: `/acton-ma/kitchen-outfitters` | 79.8KB, 495 elements, depth 21, 59KB of attributes | `fixtures/businessProfile.ts`: 76KB, 492 elements, depth 22, 53KB of attributes | +| **Native**: `/networking-events/near-me` | 328KB, 625 elements, ~166KB pageContext | 341KB, 694 elements, ~160KB pageContext | + +- The wrapped layout (`tests/vite/layouts/BenchVisitorLayout.tsx`) mirrors the CMS-driven visitor layout: desktop and mobile nav, dropdowns, inline SVG logos, footer, and long Tailwind class strings. +- `pages/bench/+onAfterRenderHtml.ts` puts the dehydrated query state into pageContext, as the app does. +- The wrapped route uses `injectScriptsAt: "HTML_BEGIN"`, like Alignable's. +- Fixtures come from a seeded PRNG (`fixtures/rand.ts`), so every run is byte-identical. + +## Baseline and starting points + +Measured on an Apple M1 Pro (2026-10-04), no backend latency: + +| scenario | cpu ms/req (c=10) | req/s (c=10) | p50 ms (c=1) | +| --- | --- | --- | --- | +| wrapped | 3.30 | 331 | 4.2 | +| wrapped-client-nav | 1.04 | 1059 | 1.0 | +| passthru | 1.09 | 1019 | 1.1 | +| native | 5.41 | 201 | ~6 | +| native-client-nav | 2.30 | 457 | ~2 | + +Native costs the most CPU because Bifrost renders the whole page in Node. For wrapped and passthru, Rails builds the page body, and that time isn't counted here. Native's CPU goes mostly to serialization: ~46% of its CPU turns ~200KB of pageContext into JSON (`@brillout/json-serializer` plus vike's replacer). The real page passes a ~166KB relay store, so this cost is representative. + +Optimization opportunities, each measured by experiment (c=10, cpu ms/req): + +1. **vike `catchInfiniteLoop`** (`vike/dist/utils/catchInfiniteLoop.js`) keeps a tracker per `renderPage` call for 5s and loops over all of them on every call. Cost grows with requests per second per process. Making it a no-op saved 24% on wrapped, 54% on wrapped-client-nav, 52% on passthru, 11% on native-client-nav and 3% on native. This regressed in vike#3029, the fix for vike#3028 (false infinite-loop errors above ~20 req/s): keying trackers per request made every call scan every live request. It's still unchanged in vike 0.4.267 and on `main` (2026-10-04). Fix upstream, e.g. delete the request's tracker when `renderPage` returns, or sweep on a timer; or patch it in the meantime. +2. **Routing-only `renderPage` in `preHandler`.** Proxied requests run a full `renderPage` just to read `proxyMode`; wrapped runs a second one to render. In-process it costs ~0.3ms CPU. That's ~60% of passthru and client-nav once (1) is fixed, and ~10% of wrapped. Replace it with routing alone (vike exports a semi-private `route()` from `vike/__internal`), or restructure so each request makes one `renderPage`. Unreleased vike (`pageContext.content`, vike#3551) lets a render hook return a raw body or stream, which would make the single-`renderPage` design possible for passthru too. +3. **pageContext serialization** (app-level, in `dehydrateStores`). vike's serializer costs ~640µs for the wrapped page's ~39KB state and ~1.7ms for native's ~160KB. Passing the state pre-stringified cuts that to 180µs / 744µs (~14% of wrapped, ~18% of native), at the cost of double-escaped HTML. Plain `JSON.stringify` in a separate ``, + ``, + ...inlineScripts.map((s) => ``), + ``, + ].join("\n"); + + const logo = h( + "svg", + { xmlns: "http://www.w3.org/2000/svg", fill: "currentColor", viewBox: "0 0 212 47", role: "img", class: "shrink-0", style: "width:136px;height:32px" }, + h("path", { d: r.svgPath(2790) }), + h("path", { d: r.svgPath(1300) }) + ); + + const navLink = (label: string) => + h("li", { class: "navbar__item" }, h("a", { class: `navbar__link ${r.classes(6)}`, href: `/${label.toLowerCase().replace(/ /g, "-")}` }, h("span", {}, label))); + const navbar = h( + "header", + { class: "navbar navbar--visitor", id: "navbar" }, + nest( + 3, + () => r.classes(5), + [ + h("a", { href: "/", class: "navbar__logo", "aria-label": "Home" }, logo), + h("ul", { class: "navbar__list" }, ["Home", "Features", "About Us", "Testimonials", "Resources", "Events"].map(navLink)), + h( + "div", + { class: "navbar__actions" }, + h("a", { href: "/biz_users/sign_in", class: `btn btn--secondary ${r.classes(14)}` }, "Log in"), + h("a", { href: "/biz_users/sign_up", class: `btn btn--primary ${r.classes(18)}` }, "Join Now") + ), + ] + ) + ); + + const stars = () => h("div", { class: "stars flex flex-row" }, icon(1087, "h-4 w-4 text-yellow-400")); + + const header = h( + "section", + { class: "profile-header" }, + h("img", { class: "profile-header__cover", src: `https://images.example.test/cover/${r.hex(24)}.jpg`, alt: "", loading: "eager", width: 1200, height: 300 }), + nest( + 4, + () => r.classes(4), + [ + h("img", { class: "profile-header__avatar rounded-full", src: `https://images.example.test/logo/${r.hex(24)}.png`, alt: name, width: 120, height: 120 }), + h("h1", { class: "profile-header__name text-heading-1" }, name), + h("div", { class: "profile-header__meta" }, h("span", {}, `${city}, MA`), h("span", {}, r.title(3)), stars(), h("span", {}, `${r.int(10, 90)} recommendations`)), + h( + "div", + { class: "profile-header__actions flex gap-2" }, + ["Recommend", "Message", "Share"].map((label) => + h("a", { href: `#${label.toLowerCase()}`, class: `btn ${r.classes(12)}`, "data-cyc-props": trackProps() }, icon(label === "Share" ? 1364 : 600), h("span", {}, label)) + ) + ), + ] + ) + ); + + const tabs = h( + "nav", + { class: "profile-tabs" }, + h("ul", { class: "flex list-none" }, ["About", "Reviews", "Services", "Photos", "Network"].map((t) => h("li", {}, h("a", { href: `#${t.toLowerCase()}`, class: r.classes(8) }, t)))) + ); + + const about = h( + "section", + { class: "profile-section", id: "about" }, + nest(2, () => r.classes(3), [h("h2", { class: "text-heading-3" }, "About"), h("p", {}, r.sentence(120)), h("p", {}, r.sentence(90))]) + ); + + const services = h( + "section", + { class: "profile-section", id: "services" }, + h("h2", { class: "text-heading-3" }, "Services"), + h("ul", { class: "grid grid-cols-2" }, Array.from({ length: 12 }, () => h("li", { class: r.classes(3) }, h("span", {}, r.title(2))))) + ); + + const photo = () => { + const id = r.hex(24); + const srcset = [320, 640, 960, 1280, 1920].map((w) => `https://images.example.test/c_fill,w_${w},q_auto,f_auto/photos/${id}.jpg ${w}w`).join(", "); + return h("a", { href: `#photo-${id}`, class: r.classes(6) }, h("img", { src: `https://images.example.test/photos/${id}.jpg`, srcset, sizes: "(max-width: 768px) 100vw, 33vw", alt: r.sentence(6), loading: "lazy", width: 320, height: 240 })); + }; + const photos = h("section", { class: "profile-section", id: "photos" }, h("h2", { class: "text-heading-3" }, "Photos"), h("div", { class: "grid grid-cols-3 gap-2" }, Array.from({ length: 9 }, photo))); + + const reviewCard = () => + h( + "div", + { class: `review-card ${r.classes(5)}`, "data-cyc-props": trackProps() }, + nest( + 5, + () => r.classes(4), + [ + h( + "div", + { class: "review-card__author flex" }, + h("a", { href: `/biz/${r.hex(10)}` }, h("img", { src: `https://images.example.test/avatar/${r.hex(24)}.jpg`, alt: "", class: "rounded-full", width: 48, height: 48 })), + h("div", {}, h("a", { href: `/biz/${r.hex(10)}`, class: r.classes(4) }, h("span", {}, r.title(2))), h("span", { class: "text-grey-600" }, r.title(3))) + ), + h("div", { class: "review-card__body" }, h("p", {}, r.sentence(90))), + h( + "div", + { class: "review-card__footer flex justify-between" }, + h("span", {}, r.title(2)), + h("a", { href: "#helpful", class: r.classes(6), "data-cyc-props": trackProps() }, h("span", {}, "Helpful")) + ), + ] + ) + ); + const reviews = h( + "section", + { class: "profile-section", id: "reviews" }, + h("h2", { class: "text-heading-3" }, "Recommendations"), + Array.from({ length: 8 }, reviewCard), + h("a", { href: "#all-reviews", class: r.classes(8) }, "See all recommendations") + ); + + const similarCard = () => + h( + "div", + { class: `similar-card ${r.classes(4)}` }, + nest( + 3, + () => r.classes(3), + [ + h("a", { href: `/${city.toLowerCase()}/${r.hex(8)}` }, h("img", { src: `https://images.example.test/logo/${r.hex(24)}.png`, alt: "", width: 40, height: 40 })), + h("div", {}, h("a", { href: `/${city.toLowerCase()}/${r.hex(8)}`, class: r.classes(3) }, h("span", {}, r.title(2))), h("span", {}, r.title(2))), + ] + ) + ); + const sidebar = h( + "aside", + { class: "profile-sidebar" }, + nest(2, () => r.classes(3), [h("h3", {}, "Similar businesses nearby"), Array.from({ length: 8 }, similarCard)]), + h("div", { class: "profile-sidebar__cta" }, h("h3", {}, "Grow your network"), h("p", {}, r.sentence(18)), h("a", { href: "/biz_users/sign_up", class: `btn ${r.classes(16)}` }, "Join free")) + ); + + const footerColumn = () => + h("div", { class: "footer__column" }, h("h3", {}, r.title(1)), h("ul", {}, Array.from({ length: 3 }, () => h("li", {}, h("a", { href: `/${r.hex(6)}`, class: "no-underline" }, r.title(2)))))); + const footer = h( + "footer", + { class: "footer bg-grey-700 text-white-100" }, + nest( + 2, + () => r.classes(4), + [ + h("div", { class: "footer__brand" }, icon(1308, "h-8"), icon(1308, "h-11")), + h("div", { class: "footer__columns grid" }, Array.from({ length: 4 }, footerColumn)), + h("div", { class: "footer__social flex" }, icon(877, "h-5 w-5"), icon(500, "h-5 w-5")), + h("p", { class: "footer__copyright" }, "© Copyright 2026"), + ] + ) + ); + + const main = h( + "div", + { class: "page page--profile", id: "main-website" }, + nest( + 3, + () => r.classes(3), + [header, tabs, h("div", { class: "profile-layout grid" }, nest(4, () => r.classes(3), [about, services, photos, reviews]), sidebar)] + ) + ); + + const bodyScripts = [ + `runJS(function () { window.benchProfileLoaded = true; });`, + `runJS(function () { window.benchTabs = document.querySelectorAll('.profile-tabs a').length; });`, + `runJS(function () { window.benchReviews = document.querySelectorAll('.review-card').length; });`, + `runJS(function () { window.benchSidebar = true; });`, + `runJS(function () { window.benchFooter = true; });`, + `runJS(function () { window.benchShare = { url: location.href, title: document.title }; });`, + `runJS(function () { window.benchMessage = true; });`, + `runJS(function () { window.benchRecommend = true; });`, + `runJS(function () { window.benchNavbar = true; });`, + ]; + + return ` + + +${head} + + +${navbar} +${main} +${footer} +${bodyScripts.map((s) => ``).join("\n")} + + + + +`; +} diff --git a/tests/bench/fixtures/events.ts b/tests/bench/fixtures/events.ts new file mode 100644 index 0000000..3bb641d --- /dev/null +++ b/tests/bench/fixtures/events.ts @@ -0,0 +1,25 @@ +import type { BenchEvent } from "./eventsData"; +import { createRand } from "./rand"; + +export type { BenchEvent }; + +const r = createRand(4100); +export const eventIconPaths = Array.from({ length: 4 }, () => r.svgPath(r.int(300, 1200))); + +export function eventsJsonLd(events: BenchEvent[]) { + return { + "@context": "https://schema.org", + "@type": "SearchResultsPage", + name: "Business Networking Events", + mainEntity: events.map((e) => ({ + "@type": "Event", + name: e.title, + description: e.description, + startDate: e.startsAt, + endDate: e.endsAt, + location: { "@type": "Place", name: e.venue.name, address: { "@type": "PostalAddress", streetAddress: e.venue.address, addressLocality: e.venue.city, addressRegion: e.venue.state } }, + organizer: { "@type": "Person", name: e.organizer.name }, + image: e.coverImageUrl, + })), + }; +} diff --git a/tests/bench/fixtures/eventsData.ts b/tests/bench/fixtures/eventsData.ts new file mode 100644 index 0000000..ec33e3e --- /dev/null +++ b/tests/bench/fixtures/eventsData.ts @@ -0,0 +1,41 @@ +import { createRand } from "./rand"; + +// Server-only (generation is expensive). Modeled on the relay store (~165KB) behind Alignable's native networking +// events listing, all of which is passed to the client. + +function buildEvents() { + const r = createRand(4000); + const person = () => ({ + __typename: "User", + id: r.token(20), + name: r.title(2), + headline: r.sentence(8), + avatarUrl: `https://images.example.test/avatar/${r.hex(24)}.jpg`, + business: { __typename: "Business", id: r.token(20), name: r.title(3), slug: r.hex(10), city: r.title(1), state: "MA", category: r.title(2) }, + }); + const events = Array.from({ length: 30 }, (_, i) => ({ + __typename: "NetworkingEvent", + id: r.token(24), + slug: `${r.words(3).replace(/ /g, "-")}-${i}`, + title: r.title(r.int(3, 7)), + description: r.sentence(r.int(40, 80)), + startsAt: new Date(Date.UTC(2026, 10, 1 + i, 13, 30)).toISOString(), + endsAt: new Date(Date.UTC(2026, 10, 1 + i, 15, 0)).toISOString(), + timezone: "America/New_York", + format: r.pick(["IN_PERSON", "VIRTUAL", "HYBRID"]), + priceCents: r.pick([0, 0, 0, 1500, 2500]), + coverImageUrl: `https://images.example.test/events/${r.hex(24)}.jpg`, + venue: { __typename: "Venue", id: r.token(20), name: r.title(3), address: `${r.int(1, 999)} ${r.title(2)} St`, city: r.title(1), state: "MA", lat: 42 + r.next(), lng: -71 - r.next() }, + organizer: person(), + attendeeCount: r.int(3, 120), + attendees: { __typename: "AttendeeConnection", edges: Array.from({ length: 8 }, () => ({ __typename: "AttendeeEdge", cursor: r.token(16), node: person() })) }, + tags: Array.from({ length: r.int(2, 5) }, () => r.words(1)), + viewerRsvp: null, + })); + const filters = ["In person", "Virtual", "Hybrid", "Free", "This week", "This month", "Within 10 miles", "Within 50 miles"]; + return { events, filters }; +} + +export const eventsData = buildEvents(); + +export type BenchEvent = (typeof eventsData)["events"][number]; diff --git a/tests/bench/fixtures/html.ts b/tests/bench/fixtures/html.ts new file mode 100644 index 0000000..8f40078 --- /dev/null +++ b/tests/bench/fixtures/html.ts @@ -0,0 +1,36 @@ +export type Attrs = Record; +export type Child = string | false | null | undefined | Child[]; + +const VOID = new Set( + "area base br col embed hr img input link meta source track wbr".split(" ") +); + +export function escapeAttr(value: string) { + return value.replace(/&/g, "&").replace(/"/g, """).replace(/`; + return `${open}>${flatten(children)}`; +} + +function flatten(children: Child[]): string { + let out = ""; + for (const child of children) { + if (Array.isArray(child)) out += flatten(child); + else if (child) out += child; + } + return out; +} + +/** Wraps `inner` in `depth` nested divs, mimicking component wrapper soup. */ +export function nest(depth: number, classes: () => string, inner: Child): string { + let out = flatten([inner]); + for (let i = 0; i < depth; i++) out = h("div", { class: classes() }, out); + return out; +} diff --git a/tests/bench/fixtures/layoutCms.ts b/tests/bench/fixtures/layoutCms.ts new file mode 100644 index 0000000..50ebc13 --- /dev/null +++ b/tests/bench/fixtures/layoutCms.ts @@ -0,0 +1,97 @@ +import { createRand } from "./rand"; + +// Modeled on the CMS-driven visitor layout (nav + footer) Alignable renders around wrapped pages, plus the +// dehydrated query state it passes to the client (~35KB of JSON in production). + +export type CmsLink = { + _uid: string; + component: "nav_menu_link"; + label: string; + link: { cached_url: string; fieldtype: "multilink"; id: string; linktype: "url"; url: string }; + tracking_event_category: string; + tracking_event_label: string; + wysiwyg: Record[]; + children: CmsLink[]; +}; + +export type LayoutCms = ReturnType; + +const STYLE_KEYS = + "max_width background_image padding_top padding_top_unit padding_right padding_right_unit padding_bottom padding_bottom_unit padding_left padding_left_unit margin_top margin_top_unit margin_right margin_right_unit margin_bottom margin_bottom_unit margin_left margin_left_unit border_style border_width border_width_unit border_color border_radius background_type background_color background_gradient background_size width height link_options list_icon text_align font_family font_size".split( + " " + ); + +function buildLayoutCms() { + const r = createRand(3000); + const wysiwyg = (label: string) => ({ + _uid: r.uuid(), + component: "wysiwyg", + ...Object.fromEntries(STYLE_KEYS.map((key) => [key, key === "background_image" ? { fieldtype: "asset", filename: "", meta_data: {}, name: "" } : key === "link_options" || key === "list_icon" ? [] : ""])), + content: { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: label, marks: [{ type: "styled", attrs: { class: "font-sans text-body-semibold" } }] }] }], + }, + }); + const link = (label: string, children: CmsLink[] = []): CmsLink => { + const url = "/" + label.toLowerCase().replace(/ /g, "-"); + return { + _uid: r.uuid(), + component: "nav_menu_link", + label, + link: { cached_url: url, fieldtype: "multilink", id: "", linktype: "url", url }, + tracking_event_category: "Navbar", + tracking_event_label: label, + wysiwyg: [wysiwyg(label)], + children, + }; + }; + const nav = [ + link("Home"), + link("Features", [link("Platform Overview"), link("Network 360")]), + link("About Us"), + link("Testimonials"), + link("Resources", [link("Find Networking Events"), link("Business University"), link("Help Center"), link("Blog"), link("Learning Center")]), + ]; + const footerLinks = Array.from({ length: 12 }, () => link(r.title(r.int(1, 5)))); + const legalLinks = ["Terms of Service", "Privacy Policy", "Code of Conduct", "Cookie Policy"].map((l) => link(l)); + const logoPaths = [r.svgPath(2790), r.svgPath(1300)]; + const iconPaths = Array.from({ length: 6 }, () => r.svgPath(r.int(200, 900))); + + const query = (key: string, data: unknown) => ({ + dehydratedAt: 1791159813894, + state: { + data: { json: data }, + dataUpdateCount: 1, + dataUpdatedAt: 1791159813749, + error: null, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + fetchMeta: null, + isInvalidated: false, + status: "success", + fetchStatus: "idle", + }, + queryKey: [key.split("."), { type: "query" }], + queryHash: JSON.stringify([key.split("."), { type: "query" }]), + }); + + const content = { _uid: r.uuid(), component: "visitor_layout", nav, footerLinks, legalLinks }; + const ssrState = { + relaySsrState: { "client:root": { __id: "client:root", __typename: "__Root" } }, + reactQuerySsrState: { + mutations: [], + queries: [ + query("user.me", null), + query("user.admin", null), + query("user.csrfToken", { csrfToken: r.token(43) }), + query("user.trackingSession", { amplitudeSessionId: null, trackingSessionId: null }), + query("cms.visitorLayout", { alternates: [], content }), + ], + }, + }; + return { content, ssrState, logoPaths, iconPaths }; +} + +export const layoutCms = buildLayoutCms(); diff --git a/tests/bench/fixtures/print-stats.ts b/tests/bench/fixtures/print-stats.ts new file mode 100644 index 0000000..bf1c901 --- /dev/null +++ b/tests/bench/fixtures/print-stats.ts @@ -0,0 +1,11 @@ +import { readFileSync } from "node:fs"; +import { htmlStats } from "./stats"; +import { signInHtml } from "./signIn"; +import { businessProfileHtml } from "./businessProfile"; + +const rows: Record> = { + "signIn (wrapped backend body)": htmlStats(signInHtml()), + "businessProfile (passthru)": htmlStats(businessProfileHtml()), +}; +for (const file of process.argv.slice(2)) rows[file] = htmlStats(readFileSync(file, "utf8")); +console.table(rows); diff --git a/tests/bench/fixtures/rand.ts b/tests/bench/fixtures/rand.ts new file mode 100644 index 0000000..65f93d5 --- /dev/null +++ b/tests/bench/fixtures/rand.ts @@ -0,0 +1,56 @@ +// Deterministic generators so every run benchmarks byte-identical fixtures. + +export type Rand = ReturnType; + +const WORDS = + "local business network owner referral community service quality trusted family kitchen design custom remodel cabinet county main street market growth partner event member review recommend neighbor client project estimate small shop studio supply coffee bakery repair plumbing legal accounting insurance marketing realty fitness salon garden".split( + " " + ); + +const UTILITY_CLASSES = + "flex flex-col flex-row items-center justify-between justify-center gap-2 gap-x-1.5 gap-y-1 rounded-full rounded-[10px] p-2.5 px-4 py-2 mx-auto my-0 mt-4 mb-2 text-body-medium text-body-bold text-black-800 text-grey-600 text-purple-500 bg-white-100 bg-grey-200 bg-purple-100 hover:bg-purple-100 hover:text-purple-600 no-underline transition-all duration-500 shadow-sm border border-solid border-grey-350 w-full h-auto max-w-[1200px] md:px-12 md:flex lg:hidden lg:block sm:w-[300px] truncate leading-tight font-sans font-semibold overflow-hidden shrink-0 grow".split( + " " + ); + +export function createRand(seed: number) { + // mulberry32 + let state = seed >>> 0; + const next = () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + const int = (min: number, max: number) => + min + Math.floor(next() * (max - min + 1)); + const pick = (xs: readonly T[]): T => xs[int(0, xs.length - 1)]; + const words = (n: number) => Array.from({ length: n }, () => pick(WORDS)).join(" "); + const title = (n: number) => + words(n).replace(/\b\w/g, (c) => c.toUpperCase()); + const sentence = (n: number) => { + const s = words(n); + return s[0].toUpperCase() + s.slice(1) + "."; + }; + const classes = (n: number) => + Array.from({ length: n }, () => pick(UTILITY_CLASSES)).join(" "); + const hex = (n: number) => + Array.from({ length: n }, () => int(0, 15).toString(16)).join(""); + const uuid = () => + `${hex(8)}-${hex(4)}-4${hex(3)}-${pick(["8", "9", "a", "b"])}${hex(3)}-${hex(12)}`; + const token = (n: number) => { + const chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + return Array.from({ length: n }, () => pick(chars.split(""))).join(""); + }; + const num = () => (next() * 40 - 10).toFixed(3).replace(/0+$/, ""); + /** SVG path data of roughly `length` characters, like inlined icon/logo paths. */ + const svgPath = (length: number) => { + let d = `M${num()} ${num()}`; + while (d.length < length) { + d += `${pick(["c", "l", "s", "C", "L", "h", "v", "z"])}${num()} ${num()} ${num()} ${num()}`; + } + return d; + }; + return { next, int, pick, words, title, sentence, classes, hex, uuid, token, svgPath }; +} diff --git a/tests/bench/fixtures/signIn.ts b/tests/bench/fixtures/signIn.ts new file mode 100644 index 0000000..3a0ef69 --- /dev/null +++ b/tests/bench/fixtures/signIn.ts @@ -0,0 +1,214 @@ +import { createRand } from "./rand"; +import { escapeAttr, h, nest } from "./html"; + +// Modeled on the Rails response for Alignable's /biz_users/sign_in when requested with X-VITE-PROXY +// (layout omitted, Bifrost renders it). Target: ~8KB, ~65 elements, depth ~15, head ~3.9KB. + +export const SIGN_IN_LAYOUT_HEADERS = { + "x-react-layout": "visitor_layout", + "x-react-current-nav": "", + "x-react-tracking": "sessions%7Cnew%7C", + "x-react-footer": "true", +}; + +const json = (value: unknown) => escapeAttr(JSON.stringify(value)); + +export function signInHtml(variant = 0): string { + const r = createRand(1000 + variant); + const pageDetails = json({ + pageName: "Login", + intent: null, + intent_source: "", + cuid: r.hex(36), + member: false, + }); + const pageResource = json({ + uuid: r.uuid(), + context: "website", + source: "sessions", + action: "new", + params: "{}", + mobile: false, + }); + const cycDetails = json({ + controller: "sessions", + action: "new", + path: "/biz_users/sign_in", + touchpoint_id: null, + newman_tracking_id: null, + }); + + const head = ` + + + + + + + + + + + + + + + + + + + + + + 0.5])) })}" name="app-config"> + + +Login + + + + + + +`; + + const sitekey = r.token(40); + const noscript = ``; + + const form = h( + "form", + { + "data-controller": "form-sleep", + "data-form-sleep-wait": "2.0", + "data-action": "form-sleep#modalClick", + novalidate: "novalidate", + class: "simple_form biz_user", + action: "/biz_users/email_submit", + "accept-charset": "UTF-8", + "data-remote": "true", + method: "post", + }, + h("input", { type: "hidden", name: "authenticity_token", value: r.token(86) }), + nest( + 2, + () => "module module--mobile-border login-form module__body", + [ + h("input", { + class: "string email required login-form__input", + placeholder: "Enter email address", + type: "email", + value: "", + name: "biz_user[email]", + id: "biz_user_email", + }), + h( + "div", + { class: "login-form__recaptcha" }, + ``, + h("div", { "data-sitekey": sitekey, class: "g-recaptcha" }), + noscript + ), + h("input", { + type: "submit", + name: "commit", + value: "Next", + id: "submit", + class: "btn-v2 btn-v2--full btn-v2--primary btn-v2--40", + "data-disable-with": "Please wait...", + "data-target": "form-sleep.submit", + disabled: "disabled", + }), + h( + "div", + { class: "login-form__checkbox-container" }, + h("input", { value: "0", autocomplete: "off", type: "hidden", name: "biz_user[remember_me]" }), + h( + "label", + { class: "checkbox" }, + h("input", { + class: "boolean optional login-form__checkbox", + type: "checkbox", + value: "1", + checked: "checked", + name: "biz_user[remember_me]", + id: "biz_user_remember_me", + }), + " Remember me" + ) + ), + ] + ) + ); + + const login = h( + "div", + { class: "login" }, + h( + "div", + { class: "login__content js-replaceable-login-content", id: "email_submit" }, + h("div", { class: "login__header" }, "Sign in to your account"), + h("div", { class: "login__message-container" }), + form, + h( + "div", + { class: "login__footer" }, + "Not a member yet? ", + h("a", { class: "login-form__link", href: "/biz_users/sign_up?content_code=Website_Login" }, "Sign up") + ) + ) + ); + + const body = ` +
+
+
+
+ + +${login} + +
+
+ + + + +`; + + return ` + +${head} +${body} + +`; +} diff --git a/tests/bench/fixtures/stats.ts b/tests/bench/fixtures/stats.ts new file mode 100644 index 0000000..1f81c15 --- /dev/null +++ b/tests/bench/fixtures/stats.ts @@ -0,0 +1,47 @@ +import { Parser } from "htmlparser2"; + +const VOID = new Set("area base br col embed hr img input link meta source track wbr".split(" ")); + +/** Shape metrics used to keep fixtures comparable to the production pages they model. */ +export function htmlStats(html: string) { + let depth = 0; + let maxDepth = 0; + let elements = 0; + let attrBytes = 0; + let inlineScriptBytes = 0; + let inScript = false; + let headBytes = 0; + let headStart = -1; + const tags: Record = {}; + const parser = new Parser({ + onopentag(name, attrs) { + elements++; + tags[name] = (tags[name] ?? 0) + 1; + for (const [k, v] of Object.entries(attrs)) attrBytes += k.length + v.length; + if (name === "script" && !attrs.src) inScript = true; + if (name === "head") headStart = parser.startIndex; + if (!VOID.has(name)) maxDepth = Math.max(maxDepth, ++depth); + }, + onclosetag(name) { + if (!VOID.has(name)) depth--; + if (name === "script") inScript = false; + if (name === "head") headBytes = parser.endIndex - headStart; + }, + ontext(text) { + if (inScript) inlineScriptBytes += text.length; + }, + }); + parser.write(html); + parser.end(); + return { + bytes: Buffer.byteLength(html), + headBytes, + elements, + maxDepth, + attrBytes, + inlineScriptBytes, + scripts: tags.script ?? 0, + svgs: tags.svg ?? 0, + links: tags.a ?? 0, + }; +} diff --git a/tests/bench/harness.ts b/tests/bench/harness.ts new file mode 100644 index 0000000..7c1e020 --- /dev/null +++ b/tests/bench/harness.ts @@ -0,0 +1,102 @@ +// Building and process management for the benchmarks. +import { execSync, fork, type ChildProcess } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const BENCH_DIR = path.dirname(fileURLToPath(import.meta.url)); +export const REPO = path.resolve(BENCH_DIR, "../.."); +export const RESULTS_DIR = path.join(BENCH_DIR, "results"); +const VITE_APP = path.join(REPO, "tests/vite"); +const BACKEND_PORT = 5657; +const BIFROST_PORT = 5655; +export const BIFROST = `http://127.0.0.1:${BIFROST_PORT}`; +export const BACKEND = `http://127.0.0.1:${BACKEND_PORT}`; + +export function build(verbose: boolean) { + console.log("Building bifrost, bifrost-fastify and tests/vite (--skip-build to skip)..."); + const stdio = verbose ? "inherit" : "pipe"; + execSync("npm run build", { cwd: REPO, stdio }); + execSync("npm run -w=tests-vite build", { cwd: REPO, stdio }); +} + +export function gitInfo() { + const git = (cmd: string) => execSync(`git ${cmd}`, { cwd: REPO }).toString().trim(); + return { commit: git("rev-parse --short HEAD"), dirty: git("status --porcelain") !== "" }; +} + +async function waitForServer(url: string, child: ChildProcess) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`server exited with ${child.exitCode}`); + try { + await fetch(url); + return; + } catch { + await new Promise((r) => setTimeout(r, 100)); + } + } + throw new Error(`timed out waiting for ${url}`); +} + +/** Production build of tests/vite, with bench-agent.mjs preloaded for CPU stats and profiling over IPC. */ +export async function startBifrost(verbose: boolean) { + const child = fork(path.join(VITE_APP, "dist/server/index.js"), [], { + cwd: VITE_APP, + execArgv: ["--import", path.join(BENCH_DIR, "bench-agent.mjs")], + env: { + ...process.env, + NODE_ENV: "production", + PORT: String(BIFROST_PORT), + UPSTREAM_URL: BACKEND, + PUBLIC_URL: BIFROST, + LOG_LEVEL: verbose ? "info" : "error", + }, + stdio: ["ignore", verbose ? "inherit" : "ignore", "inherit", "ipc"], + }); + await waitForServer(BIFROST + "/bench/events", child); + return child; +} + +export async function startBackend(latencyMs: number) { + const child = fork(path.join(BENCH_DIR, "backend.ts"), [], { + execArgv: ["--import", "tsx"], + env: { ...process.env, PORT: String(BACKEND_PORT), BACKEND_LATENCY_MS: String(latencyMs) }, + stdio: ["ignore", "ignore", "inherit", "ipc"], + }); + await waitForServer(BACKEND + "/assets/ready.js", child); + return child; +} + +export function stop(child: ChildProcess) { + return new Promise((resolve) => { + if (child.exitCode !== null) return resolve(); + child.once("exit", () => resolve()); + child.kill("SIGTERM"); + }); +} + +let nextMessageId = 0; +export function request(child: ChildProcess, message: Record): Promise { + const benchId = ++nextMessageId; + return new Promise((resolve) => { + const onMessage = (reply: any) => { + if (reply?.benchId !== benchId) return; + child.off("message", onMessage); + resolve(reply); + }; + child.on("message", onMessage); + child.send({ ...message, benchId }); + }); +} + +export const median = (xs: number[]) => { + const sorted = [...xs].sort((a, b) => a - b); + const mid = sorted.length >> 1; + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +}; + +export const formatDelta = (now: number, before: number) => { + if (!before) return "-"; + const delta = ((now - before) / before) * 100; + return `${delta > 0 ? "+" : ""}${delta.toFixed(1)}%`; +}; diff --git a/tests/bench/micro.ts b/tests/bench/micro.ts new file mode 100644 index 0000000..bfd2421 --- /dev/null +++ b/tests/bench/micro.ts @@ -0,0 +1,28 @@ +// In-process microbenchmarks of Bifrost's pure server-side helpers on the benchmark fixtures. +import { Bench } from "tinybench"; +import { extractDomElements } from "../../bifrost-fastify/lib/extractDomElements"; +import { signInHtml } from "./fixtures/signIn"; +import { businessProfileHtml } from "./fixtures/businessProfile"; + +const signIn = signInHtml(); +const businessProfile = businessProfileHtml(); + +const bench = new Bench({ time: 2000, warmupTime: 500 }); +bench + .add(`extractDomElements sign-in (${(signIn.length / 1024).toFixed(1)}KB)`, () => { + extractDomElements(signIn); + }) + .add(`extractDomElements business profile (${(businessProfile.length / 1024).toFixed(1)}KB)`, () => { + extractDomElements(businessProfile); + }); + +await bench.run(); +console.table( + bench.tasks.map(({ name, result }) => ({ + task: name, + "mean µs": +(result!.latency.mean * 1000).toFixed(1), + "p99 µs": +(result!.latency.p99! * 1000).toFixed(1), + "±%": +result!.latency.rme.toFixed(2), + samples: result!.latency.samples.length, + })) +); diff --git a/tests/bench/package.json b/tests/bench/package.json new file mode 100644 index 0000000..ec7f0d2 --- /dev/null +++ b/tests/bench/package.json @@ -0,0 +1,18 @@ +{ + "name": "tests-bench", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "micro": "tsx micro.ts", + "fixtures": "tsx fixtures/print-stats.ts" + }, + "devDependencies": { + "@types/autocannon": "^7.12.7", + "autocannon": "^8.0.0", + "htmlparser2": "^10.0.0", + "tinybench": "^5.0.0", + "tsx": "^4.7.0" + } +} diff --git a/tests/bench/profile.ts b/tests/bench/profile.ts new file mode 100644 index 0000000..5948606 --- /dev/null +++ b/tests/bench/profile.ts @@ -0,0 +1,69 @@ +// Summarizes a V8 .cpuprofile into self-time tables, so profiles are readable without DevTools. + +type ProfileNode = { + id: number; + callFrame: { functionName: string; url: string; lineNumber: number; columnNumber: number }; + hitCount?: number; +}; +type CpuProfile = { nodes: ProfileNode[]; startTime: number; endTime: number; samples: number[] }; + +function packageOf(url: string): string { + if (!url) return "(native/vm)"; + const nm = url.lastIndexOf("/node_modules/"); + if (nm >= 0) { + const rest = url.slice(nm + "/node_modules/".length).split("/"); + return rest[0].startsWith("@") ? `${rest[0]}/${rest[1]}` : rest[0]; + } + if (url.includes("/bifrost-fastify/")) return "@alignable/bifrost-fastify"; + if (url.includes("/bifrost/dist/")) return "@alignable/bifrost"; + if (url.includes("/tests/vite/")) return "tests-vite (app)"; + if (url.startsWith("node:")) return "node:internal"; + return url.replace(/^file:\/\//, ""); +} + +export function summarizeProfile(profile: CpuProfile, top = 25) { + const totalUs = profile.endTime - profile.startTime; + const sampleUs = totalUs / profile.samples.length; + const byFunction = new Map(); + const byPackage = new Map(); + let idleMs = 0; + for (const node of profile.nodes) { + const selfMs = ((node.hitCount ?? 0) * sampleUs) / 1000; + if (!selfMs) continue; + const { functionName, url, lineNumber } = node.callFrame; + if (functionName === "(idle)") { + idleMs += selfMs; + continue; + } + const pkg = functionName.startsWith("(") ? functionName : packageOf(url); + const file = url.split("/").slice(-1)[0]; + const key = `${functionName || "(anonymous)"} ${file ? `${file}:${lineNumber + 1}` : ""}`.trim(); + const entry = byFunction.get(key) ?? { selfMs: 0, pkg }; + entry.selfMs += selfMs; + byFunction.set(key, entry); + byPackage.set(pkg, (byPackage.get(pkg) ?? 0) + selfMs); + } + const busyMs = totalUs / 1000 - idleMs; + const pct = (ms: number) => `${((ms / busyMs) * 100).toFixed(1)}%`; + return { + busyMs, + packages: [...byPackage.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 15) + .map(([pkg, ms]) => ({ package: pkg, selfMs: +ms.toFixed(1), share: pct(ms) })), + functions: [...byFunction.entries()] + .sort((a, b) => b[1].selfMs - a[1].selfMs) + .slice(0, top) + .map(([fn, { selfMs, pkg }]) => ({ function: fn, package: pkg, selfMs: +selfMs.toFixed(1), share: pct(selfMs) })), + }; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const { readFileSync } = await import("node:fs"); + for (const file of process.argv.slice(2)) { + const summary = summarizeProfile(JSON.parse(readFileSync(file, "utf8"))); + console.log(`\n${file} (busy ${summary.busyMs.toFixed(0)}ms)`); + console.table(summary.packages); + console.table(summary.functions); + } +} diff --git a/tests/bench/server.ts b/tests/bench/server.ts new file mode 100644 index 0000000..79680b4 --- /dev/null +++ b/tests/bench/server.ts @@ -0,0 +1,346 @@ +// HTTP benchmark of the Bifrost server against a fixture backend. See README.md. +import autocannon from "autocannon"; +import type { ChildProcess } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { cpus } from "node:os"; +import path from "node:path"; +import { parseArgs } from "node:util"; +import { + BACKEND, + BIFROST, + RESULTS_DIR, + build, + formatDelta, + gitInfo, + median, + request, + startBackend, + startBifrost, + stop, +} from "./harness"; +import { summarizeProfile } from "./profile"; + +// No accept-encoding: tests-vite registers @fastify/compress, but Alignable compresses at the edge. +const BROWSER_HEADERS = { + accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "accept-language": "en-US,en;q=0.9", + "user-agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + cookie: "_bench_session=0123456789abcdef; remember_biz_user_token=abcdef", +}; + +type Scenario = { + name: string; + description: string; + path: string; + headers?: Record; + /** Benchmark the fixture backend directly, as a floor for the proxied scenarios. */ + direct?: boolean; + /** Requests reach the backend, so --backend-latency applies. */ + callsBackend?: boolean; + validate(res: Response, body: string): Promise | void; +}; + +function check(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +async function backendBody(scenario: Scenario) { + const res = await fetch(BACKEND + scenario.path, { headers: { ...BROWSER_HEADERS, ...scenario.headers } }); + return res.text(); +} + +const SCENARIOS: Scenario[] = [ + { + name: "wrapped", + callsBackend: true, + description: "Initial load of a wrapped page (sign-in): proxy, parse backend HTML, React SSR of layout, ~39KB pageContext", + path: "/bench/sign-in", + validate(res, body) { + check(res.headers.get("x-test-proxymode") === '"wrapped"', "expected wrapped proxy mode"); + check(!res.headers.has("x-react-layout"), "layout headers must be stripped"); + const headEnd = body.indexOf(""); + check(body.indexOf('name="page-details"') < headEnd, "backend content missing from "); + check(/]*class="website_v3 biz-sessions/.test(body), "backend body attributes not copied"); + check(body.includes('
') && body.includes("login__content"), "proxied body missing"); + check(body.includes("Join Millions of Members"), "layout footer missing"); + check(body.includes('"benchSsrState"'), "pageContext not serialized"); + }, + }, + { + name: "wrapped-client-nav", + callsBackend: true, + description: "Client navigation to a wrapped page: route, then stream the backend response through", + path: "/bench/sign-in", + headers: { accept: "text/html", "x-vite-proxy": "1" }, + async validate(res, body) { + check(res.headers.get("x-react-layout") === "visitor_layout", "layout header should reach the client"); + check(body === (await backendBody(this)), "body differs from backend"); + }, + }, + { + name: "passthru", + callsBackend: true, + description: "Page without layout header (business profile, ~78KB): wrapped route falls back to passthru", + path: "/bench/business/kitchen-supply", + async validate(res, body) { + check(res.headers.get("x-test-proxymode") === '"passthru"', "expected passthru proxy mode"); + check(body === (await backendBody(this)), "body differs from backend"); + }, + }, + { + name: "native", + description: "Vike page (events listing): React SSR of ~700 elements, ~160KB pageContext", + path: "/bench/events", + validate(res, body) { + check(res.headers.get("x-test-pageid") === "/pages/bench/native", "expected native page"); + check(body.split('data-testid="event-card"').length - 1 === 18, "expected 18 event cards"); + check(body.includes("Join Millions of Members"), "layout footer missing"); + check(body.includes('"benchSsrState"') && body.includes('"attendees"'), "pageContext not serialized"); + }, + }, + { + name: "native-client-nav", + description: "Client navigation to a Vike page: index.pageContext.json with ~160KB of data", + path: "/bench/events/index.pageContext.json", + headers: { accept: "*/*" }, + validate(res, body) { + check(res.headers.get("content-type")?.includes("json"), "expected JSON"); + check(body.includes('"attendees"'), "data missing"); + }, + }, + { + name: "backend-direct", + callsBackend: true, + description: "Reference: business profile straight from the fixture backend, no Bifrost", + path: "/bench/business/kitchen-supply", + direct: true, + validate(_res, body) { + check(body.length > 70_000, "unexpected backend body"); + }, + }, +]; + +const { values: args } = parseArgs({ + options: { + scenario: { type: "string", short: "s", multiple: true }, + connections: { type: "string", short: "c", default: "1,10,100" }, + "backend-latency": { type: "string", short: "l", default: "0" }, + duration: { type: "string", short: "d", default: "5" }, + rounds: { type: "string", short: "r", default: "3" }, + warmup: { type: "string", short: "w", default: "20" }, + out: { type: "string", short: "o" }, + compare: { type: "string" }, + profile: { type: "boolean", default: false }, + "skip-build": { type: "boolean", default: false }, + verbose: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, +}); + +if (args.help) { + console.log(`Usage: npm run bench -- [options] + + -s, --scenario Run only these scenarios (repeatable): ${SCENARIOS.map((s) => s.name).join(", ")} + -c, --connections Concurrent connection levels to measure (default 1,10,100) + -l, --backend-latency Delay backend page responses to simulate Rails render time (default 0) + -d, --duration Seconds per measured round (default 5) + -r, --rounds Measured rounds per level; medians are reported (default 3) + -w, --warmup Warmup before measuring; wrapped SSR needs ~25s to reach steady state (default 20) + -o, --out Write JSON results (default results/latest.json) + --compare Show deltas against a previous JSON result + --profile CPU-profile one round at the highest connection level and print hot functions + --skip-build Don't rebuild bifrost, bifrost-fastify and tests/vite first + --verbose Show server logs`); + process.exit(0); +} + +const selected = args.scenario?.length + ? args.scenario.flatMap((s) => s.split(",")).map((name) => { + const scenario = SCENARIOS.find((s) => s.name === name); + if (!scenario) throw new Error(`Unknown scenario ${name}`); + return scenario; + }) + : SCENARIOS; +const connectionLevels = args.connections.split(",").map(Number); +const backendLatency = Number(args["backend-latency"]); +const duration = Number(args.duration); +const rounds = Number(args.rounds); +const warmup = Number(args.warmup); +/** Lets the server adjust to a new connection level before measuring. */ +const SETTLE_SECONDS = 2; + +type Stats = { + cpu: NodeJS.CpuUsage; + memory: NodeJS.MemoryUsage; + elu: { idle: number; active: number }; +}; + +/** Closed-loop load: each connection sends its next request as soon as the previous response arrives. */ +async function load(scenario: Scenario, seconds: number, connections: number) { + const latencies: number[] = []; + const result = await new Promise((resolve, reject) => { + const instance = autocannon( + { + url: (scenario.direct ? BACKEND : BIFROST) + scenario.path, + headers: { ...BROWSER_HEADERS, ...scenario.headers }, + duration: seconds, + connections, + }, + (err, result) => (err ? reject(err) : resolve(result)) + ); + // autocannon's own histogram has 1ms resolution; response events carry fractional milliseconds. + instance.on("response", (_client, _status, _bytes, responseTime) => latencies.push(responseTime)); + }); + latencies.sort((a, b) => a - b); + const at = (p: number) => latencies[Math.min(latencies.length - 1, Math.floor(p * latencies.length))] ?? 0; + return { + total: result.requests.total, + rps: result.requests.average, + p50: at(0.5), + p99: at(0.99), + errors: result.errors + result.timeouts + result.non2xx, + }; +} + +const spread = (xs: number[]) => (xs.length > 1 ? (Math.max(...xs) - Math.min(...xs)) / median(xs) : 0); + +async function measureLevel(scenario: Scenario, server: ChildProcess | null, connections: number, profile: boolean) { + await load(scenario, SETTLE_SECONDS, connections); + const rounds_: { rps: number; p50: number; p99: number; cpuMsPerReq: number | null; errors: number }[] = []; + let profileSummary: ReturnType | undefined; + for (let i = 0; i < (profile ? 1 : rounds); i++) { + if (profile && server) await request(server, { type: "profile:start" }); + const before = server && (await request(server, { type: "stats" })); + const result = await load(scenario, duration, connections); + const after = server && (await request(server, { type: "stats" })); + if (profile && server) { + const file = path.join(RESULTS_DIR, "profiles", `${scenario.name}.cpuprofile`); + mkdirSync(path.dirname(file), { recursive: true }); + await request(server, { type: "profile:stop", path: file }); + profileSummary = summarizeProfile(JSON.parse(readFileSync(file, "utf8"))); + console.log(`\n${scenario.name}: CPU profile at ${connections} connections written to ${path.relative(process.cwd(), file)}`); + console.table(profileSummary.packages); + console.table(profileSummary.functions); + } + const cpuMs = before && after ? (after.cpu.user - before.cpu.user + after.cpu.system - before.cpu.system) / 1000 : null; + rounds_.push({ ...result, cpuMsPerReq: cpuMs === null ? null : cpuMs / result.total }); + } + const cpu = rounds_.map((r) => r.cpuMsPerReq).filter((x): x is number => x !== null); + return { + connections, + cpuMsPerReq: cpu.length ? median(cpu) : null, + cpuSpread: cpu.length ? spread(cpu) : null, + rps: median(rounds_.map((r) => r.rps)), + p50Ms: median(rounds_.map((r) => r.p50)), + p99Ms: median(rounds_.map((r) => r.p99)), + errors: rounds_.reduce((sum, r) => sum + r.errors, 0), + profile: profileSummary, + rounds: rounds_, + }; +} + +async function runScenario(scenario: Scenario) { + const server = scenario.direct ? null : await startBifrost(args.verbose); + try { + const res = await fetch((scenario.direct ? BACKEND : BIFROST) + scenario.path, { + headers: { ...BROWSER_HEADERS, ...scenario.headers }, + }); + const body = await res.text(); + check(res.status === 200, `${scenario.name}: expected 200, got ${res.status}`); + try { + await scenario.validate(res, body); + } catch (e) { + throw new Error(`${scenario.name}: response validation failed: ${(e as Error).message}`); + } + + const maxConnections = Math.max(...connectionLevels); + await load(scenario, warmup, maxConnections); + const levels = []; + for (const connections of connectionLevels) { + levels.push(await measureLevel(scenario, server, connections, args.profile && connections === maxConnections)); + } + const memory = server && (await request(server, { type: "stats" })).memory; + return { + description: scenario.description, + responseBytes: Buffer.byteLength(body), + rssMB: memory ? memory.rss / 2 ** 20 : null, + levels, + }; + } finally { + if (server) await stop(server); + } +} + +type ScenarioResult = Awaited>; + +function report(scenarios: Record, baseline?: Record) { + const rows: Record> = {}; + for (const [name, r] of Object.entries(scenarios)) { + const backendMs = SCENARIOS.find((s) => s.name === name)!.callsBackend ? backendLatency : 0; + for (const level of r.levels) { + const row: Record = { + "cpu ms/req": level.cpuMsPerReq === null ? "-" : level.cpuMsPerReq.toFixed(3), + "cpu spread": level.cpuSpread === null ? "-" : `${(level.cpuSpread * 100).toFixed(1)}%`, + "req/s": Math.round(level.rps), + "p50 ms": +level.p50Ms.toFixed(2), + "p99 ms": +level.p99Ms.toFixed(2), + "p50 - backend": +(level.p50Ms - backendMs).toFixed(2), + errors: level.errors, + }; + const base = baseline?.[name]?.levels.find((l) => l.connections === level.connections); + if (base) { + if (level.cpuMsPerReq !== null && base.cpuMsPerReq !== null) row["Δ cpu/req"] = formatDelta(level.cpuMsPerReq, base.cpuMsPerReq); + row["Δ req/s"] = formatDelta(level.rps, base.rps); + row["Δ p50"] = formatDelta(level.p50Ms, base.p50Ms); + } + rows[`${name} c=${level.connections}`] = row; + } + } + console.table(rows); +} + +async function main() { + if (!args["skip-build"]) build(args.verbose); + const baseline = args.compare ? JSON.parse(readFileSync(args.compare, "utf8")).scenarios : undefined; + const backend = await startBackend(backendLatency); + const scenarios: Record = {}; + try { + for (const scenario of selected) { + process.stdout.write(`Running ${scenario.name}... `); + scenarios[scenario.name] = await runScenario(scenario); + console.log("done"); + } + } finally { + await stop(backend); + } + + console.log( + `\nconnections=${connectionLevels.join(",")} backend latency=${backendLatency}ms duration=${duration}s rounds=${args.profile ? 1 : rounds} warmup=${warmup}s` + + (args.profile ? "\nProfiling was on: numbers at the profiled level are inflated by the profiler." : "") + ); + report(scenarios, baseline); + + const out = args.out ?? path.join(RESULTS_DIR, "latest.json"); + mkdirSync(path.dirname(out), { recursive: true }); + writeFileSync( + out, + JSON.stringify( + { + meta: { + date: new Date().toISOString(), + ...gitInfo(), + node: process.version, + cpu: cpus()[0]?.model, + options: { connections: connectionLevels, backendLatency, duration, rounds, warmup, profile: args.profile }, + }, + scenarios, + }, + null, + 2 + ) + ); + console.log(`Results written to ${path.relative(process.cwd(), out)}`); +} + +await main(); diff --git a/tests/bench/tsconfig.json b/tests/bench/tsconfig.json new file mode 100644 index 0000000..a3161fe --- /dev/null +++ b/tests/bench/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "strict": true, + "module": "ESNext", + "target": "ES2022", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "types": ["node"], + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowJs": true + }, + "include": ["*.ts", "fixtures"] +} diff --git a/tests/vite/bifrost.d.ts b/tests/vite/bifrost.d.ts index 5733ec0..c322576 100644 --- a/tests/vite/bifrost.d.ts +++ b/tests/vite/bifrost.d.ts @@ -5,6 +5,7 @@ declare global { } interface PageContext { currentNav?: string; + benchSsrState?: unknown; } interface Config { currentNav?: string; @@ -14,6 +15,7 @@ declare global { biz_layout?: { currentNav: string }; visitor?: { currentNav: string }; ssr_error?: { currentNav: string }; + bench_visitor?: { currentNav: string; tracking: string; footer: boolean }; } } } diff --git a/tests/vite/layouts/BenchVisitorLayout.tsx b/tests/vite/layouts/BenchVisitorLayout.tsx new file mode 100644 index 0000000..6b89a27 --- /dev/null +++ b/tests/vite/layouts/BenchVisitorLayout.tsx @@ -0,0 +1,216 @@ +import React, { useId } from "react"; +import { layoutCms, type CmsLink } from "../../bench/fixtures/layoutCms"; + +// Stand-in for Alignable's CMS-driven visitor layout: same element count, depth and class-string weight. + +const NAV_LINK_CLASS = + "flex flex-row items-center gap-x-1.5 rounded-[10px] p-2.5 text-body-medium text-black-800 no-underline transition-all duration-500 hover:bg-purple-100 hover:text-purple-600 hover:[text-shadow:0_0_black]"; +const NAV_BUTTON_CLASS = + "group flex flex-row items-center gap-x-1.5 rounded-[10px] border-none bg-transparent p-2.5 text-body-medium text-black-800 no-underline transition-all duration-500 data-[state=open]:bg-purple-100 data-[state=open]:text-purple-600 data-[state=open]:text-body-bold"; +const CTA_BASE_CLASS = + "inline-flex items-center justify-center transition-[colors_opacity_shadow] duration-500 gap-2 text-body-bold no-underline border border-solid ring-offset-white box-border focus-visible:outline-none focus-visible:ring-2 data-[loading=false]:disabled:shadow-none data-[loading=false]:disabled:pointer-events-none data-[loading=true]:cursor-wait active:border-purple-600 focus-visible:ring-purple-200 [&_.loading-outer]:fill-purple-200 [&_.loading-inner]:fill-purple-500 [&_.loading-inner]:stroke-purple-500 [&:disabled_.loading-outer]:fill-purple-200 [&:disabled_.loading-inner]:fill-purple-500 [&:disabled_.loading-inner]:stroke-purple-500 data-[loading=false]:disabled:bg-grey-300 data-[loading=false]:disabled:border-grey-350 px-4 py-2 rounded-full w-max-content h-auto font-sans"; +const CTA_SECONDARY_CLASS = `${CTA_BASE_CLASS} border-purple-400 text-purple-400 hover:text-purple-500 bg-white-100 hover:bg-grey-350 data-[loading=false]:disabled:text-black-800/50 shadow-none`; +const CTA_PRIMARY_CLASS = `${CTA_BASE_CLASS} shadow-sm bg-purple-400 border-purple-400 hover:bg-purple-600 text-white-100 hover:text-white-100 data-[loading=true]:text-white-100 data-[loading=false]:disabled:text-grey-550 disabled:border disabled:border-solid disabled:shadow-none [&_.loading-outer]:!fill-tp-light-200 [&_.loading-inner]:!fill-white-100 [&_.loading-inner]:!stroke-white-100`; + +const { content, logoPaths, iconPaths } = layoutCms; + +function Icon({ path, viewBox = "0 0 24 24", className }: { path: string; viewBox?: string; className?: string }) { + return ( + + + + ); +} + +function Logo() { + return ( + + {logoPaths.map((d, i) => ( + + ))} + + ); +} + +function Wysiwyg({ label }: { label: string }) { + return ( +
+
+

+ {label} +

+
+
+ ); +} + +function NavItem({ item }: { item: CmsLink }) { + const id = useId(); + if (item.children.length === 0) { + return ( +
  • + + + +
  • + ); + } + return ( +
  • + +
  • + ); +} + +function DesktopNav() { + return ( + + ); +} + +function MobileNav() { + return ( + + ); +} + +function SocialLinks() { + return ( + <> + + + + + + + + ); +} + +function Footer() { + return ( +
    +
    +
    +
    + + +
    + +
    + +
    +
    +

    Join Millions of Members

    +
    + + + Join + +
    +
    +
    +
    +
    +
    +
    + © Copyright 2026 + +
    +
    +
    +
    + ); +} + +export function BenchVisitorLayout({ children, footer = true }: { children: React.ReactNode; footer?: boolean }) { + return ( + <> +
    +
    +
    + + +
    {children}
    + {footer &&
    } +
    +
    + + ); +} diff --git a/tests/vite/pages/bench/+Layout.tsx b/tests/vite/pages/bench/+Layout.tsx new file mode 100644 index 0000000..3a8c9f3 --- /dev/null +++ b/tests/vite/pages/bench/+Layout.tsx @@ -0,0 +1,8 @@ +import React from "react"; +import { usePageContext } from "vike-react/usePageContext"; +import { BenchVisitorLayout } from "../../layouts/BenchVisitorLayout"; + +export default function Layout({ children }: { children: React.ReactNode }) { + const { proxyLayoutInfo } = usePageContext(); + return {children}; +} diff --git a/tests/vite/pages/bench/+config.ts b/tests/vite/pages/bench/+config.ts new file mode 100644 index 0000000..4354c7a --- /dev/null +++ b/tests/vite/pages/bench/+config.ts @@ -0,0 +1,6 @@ +import { Config } from "vike/types"; + +// Benchmark pages, see tests/bench/README.md +export default { + passToClient: ["benchSsrState"], +} satisfies Config; diff --git a/tests/vite/pages/bench/+onAfterRenderHtml.ts b/tests/vite/pages/bench/+onAfterRenderHtml.ts new file mode 100644 index 0000000..b71481d --- /dev/null +++ b/tests/vite/pages/bench/+onAfterRenderHtml.ts @@ -0,0 +1,7 @@ +import { PageContextServer } from "vike/types"; +import { layoutCms } from "../../../bench/fixtures/layoutCms"; + +// Mirrors the app dehydrating its query/relay stores into pageContext after SSR. +export default function onAfterRenderHtml(pageContext: PageContextServer) { + pageContext.benchSsrState = layoutCms.ssrState; +} diff --git a/tests/vite/pages/bench/native/+Page.tsx b/tests/vite/pages/bench/native/+Page.tsx new file mode 100644 index 0000000..320b99f --- /dev/null +++ b/tests/vite/pages/bench/native/+Page.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import { useData } from "vike-react/useData"; +import { eventIconPaths as iconPaths, eventsJsonLd, type BenchEvent } from "../../../../bench/fixtures/events"; +import type data from "./+data"; + +const CARD_CLASS = "group flex flex-col overflow-hidden rounded-xl border border-solid border-grey-350 bg-white-100 shadow-sm transition-shadow duration-300 hover:shadow-large"; +const CHIP_CLASS = "inline-flex cursor-pointer items-center gap-x-1.5 rounded-full border border-solid border-grey-350 px-3 py-1.5 text-body-medium text-black-800 has-[:checked]:border-purple-400 has-[:checked]:bg-purple-100"; + +const dateFormat = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", timeZone: "America/New_York" }); + +function Icon({ path }: { path: string }) { + return ( + + + + ); +} + +function EventCard({ event }: { event: BenchEvent }) { + const href = `/networking-events/${event.slug}`; + return ( +
    + + + +
    +

    + + {event.title} + +

    +
    + + {dateFormat.format(new Date(event.startsAt))} +
    +
    + + {`${event.venue.name}, ${event.venue.city}, ${event.venue.state}`} +
    +
    +
    + {event.attendees.edges.slice(0, 3).map(({ node }) => ( + {node.name} + ))} +
    + {event.attendeeCount} attending +
    +
    +
    + ); +} + +export default function Page() { + const { events, filters } = useData>>(); + return ( +
    +