diff --git a/.github/workflows/generate-stats.yml b/.github/workflows/generate-stats.yml
index e77f71bb3..465f0a3cd 100644
--- a/.github/workflows/generate-stats.yml
+++ b/.github/workflows/generate-stats.yml
@@ -71,6 +71,7 @@ jobs:
deps-matrix: ${{ steps.set-matrix.outputs.deps }}
client-side-rendered-matrix: ${{ steps.set-matrix.outputs.client-side-rendered }}
server-side-rendered-matrix: ${{ steps.set-matrix.outputs.server-side-rendered }}
+ initial-load-matrix: ${{ steps.set-matrix.outputs.initial-load }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -102,6 +103,7 @@ jobs:
echo "deps=$(echo "$FRAMEWORKS" | jq -c '[.[] | select(.starter) | select(.starter.measurements | contains(["dependencies"])) | {name, displayName, package: .starter.package}]')" >> $GITHUB_OUTPUT
echo "client-side-rendered=$(echo "$FRAMEWORKS" | jq -c '[.[] | select(.app) | select(.app.measurements | contains(["clientSideRendered"])) | {name, displayName, package: .app.package, buildScript: .app.buildScript, buildOutputDir: .app.buildOutputDir, measurements: .app.measurements}]')" >> $GITHUB_OUTPUT
echo "server-side-rendered=$(echo "$FRAMEWORKS" | jq -c '[.[] | select(.app) | select(.app.measurements | contains(["serverSideRendered"])) | {name, displayName, package: .app.package, buildScript: .app.buildScript, buildOutputDir: .app.buildOutputDir, measurements: .app.measurements}]')" >> $GITHUB_OUTPUT
+ echo "initial-load=$(echo "$FRAMEWORKS" | jq -c '[.[] | select(.app) | select(.app.measurements | contains(["initialLoad"])) | {name, displayName, package: .app.package, buildScript: .app.buildScript, buildOutputDir: .app.buildOutputDir, measurements: .app.measurements}]')" >> $GITHUB_OUTPUT
measure:
name: Run
@@ -117,6 +119,7 @@ jobs:
deps-matrix: ${{ needs.setup.outputs.deps-matrix }}
client-side-rendered-matrix: ${{ needs.setup.outputs.client-side-rendered-matrix }}
server-side-rendered-matrix: ${{ needs.setup.outputs.server-side-rendered-matrix }}
+ initial-load-matrix: ${{ needs.setup.outputs.initial-load-matrix }}
validate:
name: Validate results
diff --git a/.github/workflows/measure-framework.yml b/.github/workflows/measure-framework.yml
index 3827ca43b..ba19b36ca 100644
--- a/.github/workflows/measure-framework.yml
+++ b/.github/workflows/measure-framework.yml
@@ -48,6 +48,11 @@ on:
type: string
required: false
default: '[]'
+ initial-load-matrix:
+ description: 'JSON array of frameworks to measure initial page resource downloads'
+ type: string
+ required: false
+ default: '[]'
jobs:
measure-install:
@@ -470,3 +475,49 @@ jobs:
path: packages/${{ matrix.framework.package }}/ci-stats.json
retention-days: 1
if-no-files-found: error
+
+ measure-initial-load:
+ name: ${{ matrix.framework.displayName }} · Initial load
+ if: inputs.initial-load-matrix != '[]'
+ runs-on: depot-ubuntu-24.04
+ strategy:
+ fail-fast: false
+ matrix:
+ framework: ${{ fromJson(inputs.initial-load-matrix) }}
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6.0.5
+
+ - name: Setup Node.js
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version-file: '.nvmrc'
+ cache: 'pnpm'
+
+ - name: Install workspace dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Install package dependencies
+ working-directory: ./packages/${{ matrix.framework.package }}
+ run: pnpm install --frozen-lockfile
+
+ - name: Build app
+ working-directory: ./packages/${{ matrix.framework.package }}
+ run: pnpm build
+
+ - name: Verify runner Chrome
+ run: google-chrome --version
+
+ - name: Run initial load benchmark
+ run: pnpm --filter @framework-tracker/stats-generator run:initial-load ${{ matrix.framework.package }}
+
+ - name: Upload initial load stats
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: initial-load-stats-${{ matrix.framework.name }}
+ path: packages/${{ matrix.framework.package }}/ci-stats.json
+ retention-days: 1
+ if-no-files-found: error
diff --git a/.github/workflows/preview-stats.yml b/.github/workflows/preview-stats.yml
index 23022b437..cf7a527eb 100644
--- a/.github/workflows/preview-stats.yml
+++ b/.github/workflows/preview-stats.yml
@@ -16,6 +16,7 @@ jobs:
ssr-request-throughput-matrix: ${{ steps.detect.outputs.ssr-request-throughput }}
ssr-load-matrix: ${{ steps.detect.outputs.ssr-load }}
ssr-router-link-load-matrix: ${{ steps.detect.outputs.ssr-router-link-load }}
+ initial-load-matrix: ${{ steps.detect.outputs.initial-load }}
deps-matrix: ${{ steps.detect.outputs.deps }}
framework-names: ${{ steps.detect.outputs.framework-names }}
has-changes: ${{ steps.detect.outputs.has-changes }}
@@ -66,6 +67,7 @@ jobs:
echo "ssr-request-throughput=[]" >> $GITHUB_OUTPUT
echo "ssr-load=[]" >> $GITHUB_OUTPUT
echo "ssr-router-link-load=[]" >> $GITHUB_OUTPUT
+ echo "initial-load=[]" >> $GITHUB_OUTPUT
echo "deps=[]" >> $GITHUB_OUTPUT
exit 0
fi
@@ -92,6 +94,7 @@ jobs:
echo "ssr-request-throughput=$(echo "$FRAMEWORKS" | jq -c --argjson names "$NAMES_JSON" '[.[] | select(.name as $n | $names | contains([$n])) | select(.app) | select(.app.measurements | contains(["ssrRequestThroughput"])) | {name, displayName, package: .app.package, buildScript: .app.buildScript, buildOutputDir: .app.buildOutputDir, measurements: .app.measurements}]')" >> $GITHUB_OUTPUT
echo "ssr-load=$(echo "$FRAMEWORKS" | jq -c --argjson names "$NAMES_JSON" '[.[] | select(.name as $n | $names | contains([$n])) | select(.app) | select(.app.measurements | contains(["ssrLoad"])) | {name, displayName, package: .app.package, buildScript: .app.buildScript, buildOutputDir: .app.buildOutputDir, measurements: .app.measurements}]')" >> $GITHUB_OUTPUT
echo "ssr-router-link-load=$(echo "$FRAMEWORKS" | jq -c --argjson names "$NAMES_JSON" '[.[] | select(.name as $n | $names | contains([$n])) | select(.app) | select(.app.measurements | contains(["ssrRouterLinkLoad"])) | {name, displayName, package: .app.package, buildScript: .app.buildScript, buildOutputDir: .app.buildOutputDir, measurements: .app.measurements}]')" >> $GITHUB_OUTPUT
+ echo "initial-load=$(echo "$FRAMEWORKS" | jq -c --argjson names "$NAMES_JSON" '[.[] | select(.name as $n | $names | contains([$n])) | select(.app) | select(.app.measurements | contains(["initialLoad"])) | {name, displayName, package: .app.package, buildScript: .app.buildScript, buildOutputDir: .app.buildOutputDir, measurements: .app.measurements}]')" >> $GITHUB_OUTPUT
echo "deps=$(echo "$FRAMEWORKS" | jq -c --argjson names "$NAMES_JSON" '[.[] | select(.name as $n | $names | contains([$n])) | select(.starter) | select(.starter.measurements | contains(["dependencies"])) | {name, displayName, package: .starter.package}]')" >> $GITHUB_OUTPUT
measure:
@@ -106,6 +109,7 @@ jobs:
ssr-request-throughput-matrix: ${{ needs.setup.outputs.ssr-request-throughput-matrix }}
ssr-load-matrix: ${{ needs.setup.outputs.ssr-load-matrix }}
ssr-router-link-load-matrix: ${{ needs.setup.outputs.ssr-router-link-load-matrix }}
+ initial-load-matrix: ${{ needs.setup.outputs.initial-load-matrix }}
deps-matrix: ${{ needs.setup.outputs.deps-matrix }}
preview-comment:
diff --git a/packages/app-astro/src/pages/initial-load.astro b/packages/app-astro/src/pages/initial-load.astro
new file mode 100644
index 000000000..b0d843190
--- /dev/null
+++ b/packages/app-astro/src/pages/initial-load.astro
@@ -0,0 +1,55 @@
+---
+import '../../../testdata/src/initial-load.css'
+
+export const prerender = false
+---
+
+
+
Ecosystem Performance
+
+
Ecosystem Performance
+ Small steps. Faster experiences.
+
+ A healthier JavaScript ecosystem starts with the essentials. Explore
+ three ways to make your projects lighter, faster, and easier to
+ maintain.
+
+
+
+
diff --git a/packages/app-next-js/app/initial-load/page.tsx b/packages/app-next-js/app/initial-load/page.tsx
new file mode 100644
index 000000000..fb3c0cbf9
--- /dev/null
+++ b/packages/app-next-js/app/initial-load/page.tsx
@@ -0,0 +1,72 @@
+import { Suspense } from 'react'
+import { connection } from 'next/server'
+import '../../../testdata/src/initial-load.css'
+
+export default function InitialLoad() {
+ return (
+
+
+
+ )
+}
+
+async function RequestContent() {
+ await connection()
+
+ return (
+ <>
+
+
+
Ecosystem Performance
+
Small steps. Faster experiences.
+
+ A healthier JavaScript ecosystem starts with the essentials. Explore
+ three ways to make your projects lighter, faster, and easier to
+ maintain.
+
+
+ -
+
+ 01
+
+
Cleanup
+
+ Remove unnecessary dependencies and simplify your code to build
+ a leaner foundation.
+
+
+ Explore Cleanup↗
+
+
+ -
+
+ 02
+
+
Speedup
+
+ Find performance bottlenecks and make the tools you use every
+ day run faster.
+
+
+ Explore Speedup↗
+
+
+ -
+
+ 03
+
+
Levelup
+
+ Improve your tooling and adopt better practices for a healthier
+ JavaScript ecosystem.
+
+
+ Explore Levelup↗
+
+
+
+
+
+ >
+ )
+}
diff --git a/packages/app-nuxt/app/pages/initial-load.vue b/packages/app-nuxt/app/pages/initial-load.vue
new file mode 100644
index 000000000..7831f980b
--- /dev/null
+++ b/packages/app-nuxt/app/pages/initial-load.vue
@@ -0,0 +1,52 @@
+
+
+
+
+
+
Ecosystem Performance
+
Small steps. Faster experiences.
+
+ A healthier JavaScript ecosystem starts with the essentials. Explore
+ three ways to make your projects lighter, faster, and easier to
+ maintain.
+
+
+
+
+
diff --git a/packages/app-pracht/src/routes.ts b/packages/app-pracht/src/routes.ts
index dea31ca6b..116218d92 100644
--- a/packages/app-pracht/src/routes.ts
+++ b/packages/app-pracht/src/routes.ts
@@ -2,6 +2,10 @@ import { defineApp, route } from '@pracht/core'
export const app = defineApp({
routes: [
+ route('/initial-load', './routes/initial-load.tsx', {
+ id: 'initial-load',
+ render: 'ssr',
+ }),
route('/', './routes/home.tsx', { id: 'home', render: 'ssr' }),
route('/server-side-rendered', './routes/server-side-rendered.tsx', {
id: 'server-side-rendered',
diff --git a/packages/app-pracht/src/routes/initial-load.tsx b/packages/app-pracht/src/routes/initial-load.tsx
new file mode 100644
index 000000000..bd817aa45
--- /dev/null
+++ b/packages/app-pracht/src/routes/initial-load.tsx
@@ -0,0 +1,60 @@
+import '../../../testdata/src/initial-load.css'
+
+export function Component() {
+ return (
+ <>
+
+
+
Ecosystem Performance
+
Small steps. Faster experiences.
+
+ A healthier JavaScript ecosystem starts with the essentials. Explore
+ three ways to make your projects lighter, faster, and easier to
+ maintain.
+
+
+ -
+
+ 01
+
+
Cleanup
+
+ Remove unnecessary dependencies and simplify your code to build
+ a leaner foundation.
+
+
+ Explore Cleanup↗
+
+
+ -
+
+ 02
+
+
Speedup
+
+ Find performance bottlenecks and make the tools you use every
+ day run faster.
+
+
+ Explore Speedup↗
+
+
+ -
+
+ 03
+
+
Levelup
+
+ Improve your tooling and adopt better practices for a healthier
+ JavaScript ecosystem.
+
+
+ Explore Levelup↗
+
+
+
+
+
+ >
+ )
+}
diff --git a/packages/app-react-router/app/routes.ts b/packages/app-react-router/app/routes.ts
index e1d1e5973..a52d2346a 100644
--- a/packages/app-react-router/app/routes.ts
+++ b/packages/app-react-router/app/routes.ts
@@ -2,6 +2,7 @@ import { type RouteConfig, index, route } from '@react-router/dev/routes'
export default [
index('routes/home.tsx'),
+ route('/initial-load', 'routes/initial-load.tsx'),
route('/client-side-rendered', 'routes/client-side-rendered.tsx'),
route('/client-side-rendered/:id', 'routes/client-side-rendered.detail.tsx'),
route('/server-side-rendered', 'routes/server-side-rendered.tsx'),
diff --git a/packages/app-react-router/app/routes/initial-load.tsx b/packages/app-react-router/app/routes/initial-load.tsx
new file mode 100644
index 000000000..6a6c3bfbb
--- /dev/null
+++ b/packages/app-react-router/app/routes/initial-load.tsx
@@ -0,0 +1,60 @@
+import '../../../testdata/src/initial-load.css'
+
+export default function InitialLoad() {
+ return (
+ <>
+
+
+
Ecosystem Performance
+
Small steps. Faster experiences.
+
+ A healthier JavaScript ecosystem starts with the essentials. Explore
+ three ways to make your projects lighter, faster, and easier to
+ maintain.
+
+
+ -
+
+ 01
+
+
Cleanup
+
+ Remove unnecessary dependencies and simplify your code to build
+ a leaner foundation.
+
+
+ Explore Cleanup↗
+
+
+ -
+
+ 02
+
+
Speedup
+
+ Find performance bottlenecks and make the tools you use every
+ day run faster.
+
+
+ Explore Speedup↗
+
+
+ -
+
+ 03
+
+
Levelup
+
+ Improve your tooling and adopt better practices for a healthier
+ JavaScript ecosystem.
+
+
+ Explore Levelup↗
+
+
+
+
+
+ >
+ )
+}
diff --git a/packages/app-solid-start/src/routes/initial-load.tsx b/packages/app-solid-start/src/routes/initial-load.tsx
new file mode 100644
index 000000000..6a6c3bfbb
--- /dev/null
+++ b/packages/app-solid-start/src/routes/initial-load.tsx
@@ -0,0 +1,60 @@
+import '../../../testdata/src/initial-load.css'
+
+export default function InitialLoad() {
+ return (
+ <>
+
+
+
Ecosystem Performance
+
Small steps. Faster experiences.
+
+ A healthier JavaScript ecosystem starts with the essentials. Explore
+ three ways to make your projects lighter, faster, and easier to
+ maintain.
+
+
+ -
+
+ 01
+
+
Cleanup
+
+ Remove unnecessary dependencies and simplify your code to build
+ a leaner foundation.
+
+
+ Explore Cleanup↗
+
+
+ -
+
+ 02
+
+
Speedup
+
+ Find performance bottlenecks and make the tools you use every
+ day run faster.
+
+
+ Explore Speedup↗
+
+
+ -
+
+ 03
+
+
Levelup
+
+ Improve your tooling and adopt better practices for a healthier
+ JavaScript ecosystem.
+
+
+ Explore Levelup↗
+
+
+
+
+
+ >
+ )
+}
diff --git a/packages/app-sveltekit/src/routes/initial-load/+page.svelte b/packages/app-sveltekit/src/routes/initial-load/+page.svelte
new file mode 100644
index 000000000..31f5fb023
--- /dev/null
+++ b/packages/app-sveltekit/src/routes/initial-load/+page.svelte
@@ -0,0 +1,5 @@
+
+
+Ecosystem Performance
Small steps. Faster experiences.
A healthier JavaScript ecosystem starts with the essentials. Explore three ways to make your projects lighter, faster, and easier to maintain.
diff --git a/packages/app-tanstack-start-react/src/routeTree.gen.ts b/packages/app-tanstack-start-react/src/routeTree.gen.ts
index 0fe40cb99..3c6f49f77 100644
--- a/packages/app-tanstack-start-react/src/routeTree.gen.ts
+++ b/packages/app-tanstack-start-react/src/routeTree.gen.ts
@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ClientSideRenderedRouteImport } from './routes/client-side-rendered'
+import { Route as InitialLoadRouteImport } from './routes/initial-load'
import { Route as ServerSideRenderedRouteImport } from './routes/server-side-rendered'
import { Route as ServerSideRenderedPlainLinksRouteImport } from './routes/server-side-rendered-plain-links'
import { Route as SsrThroughputRouteImport } from './routes/ssr-throughput'
@@ -27,6 +28,11 @@ const ClientSideRenderedRoute = ClientSideRenderedRouteImport.update({
path: '/client-side-rendered',
getParentRoute: () => rootRouteImport,
} as any)
+const InitialLoadRoute = InitialLoadRouteImport.update({
+ id: '/initial-load',
+ path: '/initial-load',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ServerSideRenderedRoute = ServerSideRenderedRouteImport.update({
id: '/server-side-rendered',
path: '/server-side-rendered',
@@ -57,6 +63,7 @@ const ServerSideRenderedIdRoute = ServerSideRenderedIdRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/client-side-rendered': typeof ClientSideRenderedRoute
+ '/initial-load': typeof InitialLoadRoute
'/server-side-rendered': typeof ServerSideRenderedRoute
'/server-side-rendered-plain-links': typeof ServerSideRenderedPlainLinksRoute
'/ssr-throughput': typeof SsrThroughputRoute
@@ -66,6 +73,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/client-side-rendered': typeof ClientSideRenderedRoute
+ '/initial-load': typeof InitialLoadRoute
'/server-side-rendered': typeof ServerSideRenderedRoute
'/server-side-rendered-plain-links': typeof ServerSideRenderedPlainLinksRoute
'/ssr-throughput': typeof SsrThroughputRoute
@@ -76,6 +84,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/client-side-rendered': typeof ClientSideRenderedRoute
+ '/initial-load': typeof InitialLoadRoute
'/server-side-rendered': typeof ServerSideRenderedRoute
'/server-side-rendered-plain-links': typeof ServerSideRenderedPlainLinksRoute
'/ssr-throughput': typeof SsrThroughputRoute
@@ -87,6 +96,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/client-side-rendered'
+ | '/initial-load'
| '/server-side-rendered'
| '/server-side-rendered-plain-links'
| '/ssr-throughput'
@@ -96,6 +106,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/client-side-rendered'
+ | '/initial-load'
| '/server-side-rendered'
| '/server-side-rendered-plain-links'
| '/ssr-throughput'
@@ -105,6 +116,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/client-side-rendered'
+ | '/initial-load'
| '/server-side-rendered'
| '/server-side-rendered-plain-links'
| '/ssr-throughput'
@@ -115,6 +127,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ClientSideRenderedRoute: typeof ClientSideRenderedRoute
+ InitialLoadRoute: typeof InitialLoadRoute
ServerSideRenderedRoute: typeof ServerSideRenderedRoute
ServerSideRenderedPlainLinksRoute: typeof ServerSideRenderedPlainLinksRoute
SsrThroughputRoute: typeof SsrThroughputRoute
@@ -138,6 +151,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ClientSideRenderedRouteImport
parentRoute: typeof rootRouteImport
}
+ '/initial-load': {
+ id: '/initial-load'
+ path: '/initial-load'
+ fullPath: '/initial-load'
+ preLoaderRoute: typeof InitialLoadRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/server-side-rendered': {
id: '/server-side-rendered'
path: '/server-side-rendered'
@@ -179,6 +199,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ClientSideRenderedRoute: ClientSideRenderedRoute,
+ InitialLoadRoute: InitialLoadRoute,
ServerSideRenderedRoute: ServerSideRenderedRoute,
ServerSideRenderedPlainLinksRoute: ServerSideRenderedPlainLinksRoute,
SsrThroughputRoute: SsrThroughputRoute,
diff --git a/packages/app-tanstack-start-react/src/routes/initial-load.tsx b/packages/app-tanstack-start-react/src/routes/initial-load.tsx
new file mode 100644
index 000000000..c7128a2c6
--- /dev/null
+++ b/packages/app-tanstack-start-react/src/routes/initial-load.tsx
@@ -0,0 +1,66 @@
+import '../../../testdata/src/initial-load.css'
+
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/initial-load')({
+ component: InitialLoad,
+})
+
+function InitialLoad() {
+ return (
+ <>
+
+
+
Ecosystem Performance
+
Small steps. Faster experiences.
+
+ A healthier JavaScript ecosystem starts with the essentials. Explore
+ three ways to make your projects lighter, faster, and easier to
+ maintain.
+
+
+ -
+
+ 01
+
+
Cleanup
+
+ Remove unnecessary dependencies and simplify your code to build
+ a leaner foundation.
+
+
+ Explore Cleanup↗
+
+
+ -
+
+ 02
+
+
Speedup
+
+ Find performance bottlenecks and make the tools you use every
+ day run faster.
+
+
+ Explore Speedup↗
+
+
+ -
+
+ 03
+
+
Levelup
+
+ Improve your tooling and adopt better practices for a healthier
+ JavaScript ecosystem.
+
+
+ Explore Levelup↗
+
+
+
+
+
+ >
+ )
+}
diff --git a/packages/results/src/frameworks.ts b/packages/results/src/frameworks.ts
index a4f238a96..a565b6b9e 100644
--- a/packages/results/src/frameworks.ts
+++ b/packages/results/src/frameworks.ts
@@ -56,6 +56,7 @@ export const frameworks = [
'ssrRequestThroughput',
'clientSideRendered',
'serverSideRendered',
+ 'initialLoad',
'ssrLoad',
],
},
@@ -86,6 +87,7 @@ export const frameworks = [
'ssrRequestThroughput',
'clientSideRendered',
'serverSideRendered',
+ 'initialLoad',
'ssrLoad',
'ssrRouterLinkLoad',
],
@@ -117,6 +119,7 @@ export const frameworks = [
'ssrRequestThroughput',
'clientSideRendered',
'serverSideRendered',
+ 'initialLoad',
'ssrLoad',
'ssrRouterLinkLoad',
],
@@ -148,6 +151,7 @@ export const frameworks = [
'ssrRequestThroughput',
'clientSideRendered',
'serverSideRendered',
+ 'initialLoad',
'ssrLoad',
'ssrRouterLinkLoad',
],
@@ -179,6 +183,7 @@ export const frameworks = [
'ssrRequestThroughput',
'clientSideRendered',
'serverSideRendered',
+ 'initialLoad',
'ssrLoad',
'ssrRouterLinkLoad',
],
@@ -210,6 +215,7 @@ export const frameworks = [
'ssrRequestThroughput',
'clientSideRendered',
'serverSideRendered',
+ 'initialLoad',
'ssrLoad',
],
},
@@ -240,6 +246,7 @@ export const frameworks = [
'ssrRequestThroughput',
'clientSideRendered',
'serverSideRendered',
+ 'initialLoad',
'ssrLoad',
],
},
@@ -270,6 +277,7 @@ export const frameworks = [
'ssrRequestThroughput',
'clientSideRendered',
'serverSideRendered',
+ 'initialLoad',
'ssrLoad',
'ssrRouterLinkLoad',
],
diff --git a/packages/results/src/measurements.ts b/packages/results/src/measurements.ts
index 4304d1b9b..e81389d36 100644
--- a/packages/results/src/measurements.ts
+++ b/packages/results/src/measurements.ts
@@ -11,6 +11,7 @@ export const measurements = {
ssrRouterLinkLoad: { runs: 3 },
clientSideRendered: { runs: 5 },
serverSideRendered: { runs: 5 },
+ initialLoad: {},
} satisfies Record
export type MeasurementName = keyof typeof measurements
diff --git a/packages/results/src/schemas.ts b/packages/results/src/schemas.ts
index 435a343da..8c548f3a2 100644
--- a/packages/results/src/schemas.ts
+++ b/packages/results/src/schemas.ts
@@ -23,6 +23,7 @@ export const MetricUnitSchema = z.enum([
'errors',
'requests/s',
'bytes/s',
+ 'bytes',
])
export const MetricBaseSchema = z.object({
@@ -455,6 +456,43 @@ export const ServerSideRenderedStatsSchema = z.object({
browserVersion: z.string().min(1),
})
+/** @see https://developer.chrome.com/docs/lighthouse/performance/resource-summary */
+export const InitialLoadResourceTypeSchema = z.enum([
+ 'total',
+ 'document',
+ 'script',
+ 'stylesheet',
+ 'image',
+ 'media',
+ 'font',
+ 'other',
+ 'third-party',
+])
+
+export const InitialLoadResourceSchema = z.object({
+ requestCount: z.number().int().nonnegative(),
+ /** Transferred bytes, including response headers, as reported by Lighthouse. */
+ transferSize: z.number().int().nonnegative(),
+})
+
+export const InitialLoadStatsSchema = TestBaseSchema.extend({
+ name: z.literal('initialLoad'),
+ source: z.literal('lighthouse-resource-summary'),
+ path: z.literal('/initial-load'),
+ resources: z.record(InitialLoadResourceTypeSchema, InitialLoadResourceSchema),
+})
+
+export const InitialLoadResultSchema = z.object({
+ initialLoadTests: InitialLoadStatsSchema,
+ frameworkVersion: z.string().optional(),
+ timingMeasuredAt: z.string().optional(),
+ runner: z.string().optional(),
+ browserVersion: z.string().min(1),
+})
+
+export type InitialLoadStats = z.infer
+export type InitialLoadResult = z.infer
+
export const DependencyCountsSchema = z.object({
prodDependencies: z.number().int().nonnegative(),
devDependencies: z.number().int().nonnegative(),
@@ -497,6 +535,7 @@ export const FrameworkResultSchema = z.object({
browserVersion: z.string().optional(),
frameworkVersion: z.string().optional(),
releasedAt: z.string().optional(),
+ initialLoadTests: InitialLoadStatsSchema.optional(),
install: InstallBenchmarkSchema.optional(),
coldBuildTime: BuildStatsSchema.shape.coldBuildTime.optional(),
warmBuildTime: BuildStatsSchema.shape.warmBuildTime.optional(),
diff --git a/packages/stats-generator/package.json b/packages/stats-generator/package.json
index 3bec65cd6..61761084c 100644
--- a/packages/stats-generator/package.json
+++ b/packages/stats-generator/package.json
@@ -13,6 +13,7 @@
"serve:ssr-load": "node src/run-ssr-load-server.ts",
"run:client-side-rendered": "node src/run-client-side-rendered-benchmark.ts",
"run:server-side-rendered": "node src/run-server-side-rendered-benchmark.ts",
+ "run:initial-load": "node src/run-initial-load-benchmark.ts",
"run:install": "node src/run-install-benchmark.ts",
"run:build": "node src/run-build-benchmark.ts",
"run:dev-server": "node src/run-dev-server-benchmark.ts",
diff --git a/packages/stats-generator/src/benchmark-server.test.ts b/packages/stats-generator/src/benchmark-server.test.ts
new file mode 100644
index 000000000..7a9858da8
--- /dev/null
+++ b/packages/stats-generator/src/benchmark-server.test.ts
@@ -0,0 +1,23 @@
+import assert from 'node:assert/strict'
+import { createServer } from 'node:http'
+import { test } from 'node:test'
+import { waitForBenchmarkServer } from './benchmark-server.ts'
+
+test('readiness deadline interrupts a server that never responds', async () => {
+ const server = createServer(() => {})
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+ const address = server.address()
+ assert.ok(address && typeof address !== 'string')
+ try {
+ await assert.rejects(
+ waitForBenchmarkServer(
+ `http://127.0.0.1:${address.port}`,
+ AbortSignal.timeout(100),
+ ),
+ { name: 'TimeoutError' },
+ )
+ } finally {
+ server.closeAllConnections()
+ await new Promise((resolve) => server.close(() => resolve()))
+ }
+})
diff --git a/packages/stats-generator/src/benchmark-server.ts b/packages/stats-generator/src/benchmark-server.ts
new file mode 100644
index 000000000..d29df3fee
--- /dev/null
+++ b/packages/stats-generator/src/benchmark-server.ts
@@ -0,0 +1,83 @@
+import { spawn } from 'node:child_process'
+import { join } from 'node:path'
+import { setTimeout as sleep } from 'node:timers/promises'
+import { fileURLToPath } from 'node:url'
+import { packagesDir } from './constants.ts'
+import type { ServingFrameworkConfig } from './serving-frameworks.ts'
+
+export async function waitForBenchmarkServer(
+ url: string,
+ signal: AbortSignal,
+): Promise {
+ while (true) {
+ signal.throwIfAborted()
+ try {
+ const response = await fetch(url, { signal })
+ await response.body?.cancel()
+ if (response.status === 200) return
+ } catch {
+ signal.throwIfAborted()
+ }
+ await sleep(500, undefined, { signal })
+ }
+}
+
+export async function startBenchmarkServer(
+ config: Pick,
+ options: {
+ host: string
+ port: number
+ readinessPath: string
+ timeoutMs?: number
+ },
+): Promise<() => void> {
+ const { host, port, readinessPath, timeoutMs = 30_000 } = options
+ const scriptPath = fileURLToPath(
+ new URL(`./serve/${config.serveScript}`, import.meta.url),
+ )
+ const proc = spawn(
+ process.execPath,
+ [scriptPath, join(packagesDir, config.package)],
+ {
+ env: {
+ ...process.env,
+ HOST: host,
+ NODE_ENV: 'production',
+ PORT: String(port),
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ },
+ )
+ proc.stdout.on('data', (chunk: Buffer) =>
+ process.stdout.write(`[${config.package}] ${chunk}`),
+ )
+ proc.stderr.on('data', (chunk: Buffer) =>
+ process.stderr.write(`[${config.package}] ${chunk}`),
+ )
+
+ const lifecycle = new AbortController()
+ proc.once('error', (error) => lifecycle.abort(error))
+ proc.once('exit', (code, signal) => {
+ lifecycle.abort(
+ new Error(`Server process exited with ${signal ?? `code ${code}`}`),
+ )
+ })
+ const stop = () => {
+ lifecycle.abort(new Error('Server stopped'))
+ if (proc.exitCode === null && proc.signalCode === null) proc.kill('SIGTERM')
+ }
+ const url = `http://${host}:${port}${readinessPath}`
+ try {
+ await waitForBenchmarkServer(
+ url,
+ AbortSignal.any([lifecycle.signal, AbortSignal.timeout(timeoutMs)]),
+ )
+ } catch (error) {
+ stop()
+ throw new Error(
+ `Server at ${url} failed to become ready within ${timeoutMs}ms`,
+ { cause: error },
+ )
+ }
+ return stop
+}
diff --git a/packages/stats-generator/src/find-chromium.ts b/packages/stats-generator/src/find-chromium.ts
new file mode 100644
index 000000000..5916dc02b
--- /dev/null
+++ b/packages/stats-generator/src/find-chromium.ts
@@ -0,0 +1,29 @@
+import { existsSync, readdirSync } from 'node:fs'
+
+export function findChromium(): string {
+ if (process.env.CHROME_PATH) return process.env.CHROME_PATH
+
+ const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH
+ if (browsersPath && existsSync(browsersPath)) {
+ const chromiumDir = readdirSync(browsersPath).find((e) =>
+ e.startsWith('chromium-'),
+ )
+ if (chromiumDir) {
+ const chromePath = `${browsersPath}/${chromiumDir}/chrome-linux/chrome`
+ if (existsSync(chromePath)) return chromePath
+ }
+ }
+
+ const candidates = [
+ '/usr/bin/google-chrome',
+ '/usr/bin/chromium-browser',
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+ ]
+ for (const candidate of candidates) {
+ if (existsSync(candidate)) return candidate
+ }
+
+ throw new Error(
+ 'Could not find Chromium/Chrome. Set the CHROME_PATH env var.',
+ )
+}
diff --git a/packages/stats-generator/src/initial-load-persistence.test.ts b/packages/stats-generator/src/initial-load-persistence.test.ts
new file mode 100644
index 000000000..b1add7d63
--- /dev/null
+++ b/packages/stats-generator/src/initial-load-persistence.test.ts
@@ -0,0 +1,65 @@
+import assert from 'node:assert/strict'
+import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { test } from 'node:test'
+import { InitialLoadResourceTypeSchema } from '@framework-tracker/results'
+import {
+ projectResult,
+ readProjectResult,
+} from '@framework-tracker/results/storage'
+import { frameworks } from './frameworks.ts'
+import { processCIStats } from './save-ci-stats.ts'
+
+test('CI requires, validates, and preserves initial load artifacts', async () => {
+ const directory = mkdtempSync(join(tmpdir(), 'initial-load-ci-'))
+ const framework = frameworks.find((framework) => framework.name === 'astro')!
+ const configured = {
+ ...framework,
+ starter: undefined,
+ app: { ...framework.app!, measurements: ['initialLoad' as const] },
+ }
+ const options = {
+ validateOnly: true,
+ requireAllArtifacts: true,
+ frameworks: [configured],
+ }
+ const path = join(directory, 'initial-load-stats-astro', 'ci-stats.json')
+ const resources = Object.fromEntries(
+ InitialLoadResourceTypeSchema.options.map((key) => [
+ key,
+ { requestCount: 0, transferSize: 0 },
+ ]),
+ )
+ const artifact = {
+ frameworkVersion: readProjectResult('app-astro')?.frameworkVersion,
+ browserVersion: 'Chrome/156',
+ initialLoadTests: {
+ name: 'initialLoad',
+ source: 'lighthouse-resource-summary',
+ path: '/initial-load',
+ resources,
+ },
+ }
+ try {
+ await assert.rejects(
+ processCIStats(directory, options),
+ /Missing or invalid measurement artifact/,
+ )
+ mkdirSync(join(directory, 'initial-load-stats-astro'))
+ writeFileSync(path, JSON.stringify(artifact))
+ const results = await processCIStats(directory, options)
+ assert.deepEqual(
+ projectResult(results.get('astro')!, 'app')?.initialLoadTests,
+ artifact.initialLoadTests,
+ )
+ assert.equal(
+ projectResult(results.get('astro')!, 'app')?.frameworkVersion,
+ artifact.frameworkVersion,
+ )
+ writeFileSync(path, JSON.stringify({ ...artifact, initialLoadTests: {} }))
+ await assert.rejects(processCIStats(directory, options))
+ } finally {
+ rmSync(directory, { recursive: true, force: true })
+ }
+})
diff --git a/packages/stats-generator/src/initial-load.test.ts b/packages/stats-generator/src/initial-load.test.ts
new file mode 100644
index 000000000..45c100349
--- /dev/null
+++ b/packages/stats-generator/src/initial-load.test.ts
@@ -0,0 +1,33 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import { InitialLoadResourceTypeSchema } from '@framework-tracker/results'
+import { parseResourceSummary } from './initialLoad/run-benchmark.ts'
+
+const items = InitialLoadResourceTypeSchema.options.map((resourceType) => ({
+ resourceType,
+ requestCount: 1,
+ transferSize: 100,
+}))
+
+test('initial load retains Lighthouse totals without adding third-party again', () => {
+ const result = parseResourceSummary({ type: 'table', items })
+ assert.deepEqual(result.resources.total, {
+ requestCount: 1,
+ transferSize: 100,
+ })
+ assert.equal('iterations' in result, false)
+})
+
+test('initial load rejects missing, duplicate, or invalid resource rows', () => {
+ for (const details of [
+ undefined,
+ { type: 'table', items: items.slice(1) },
+ { type: 'table', items: [...items, items[0]] },
+ {
+ type: 'table',
+ items: [{ ...items[0], transferSize: -1 }, ...items.slice(1)],
+ },
+ ]) {
+ assert.throws(() => parseResourceSummary(details))
+ }
+})
diff --git a/packages/stats-generator/src/initialLoad/index.ts b/packages/stats-generator/src/initialLoad/index.ts
new file mode 100644
index 000000000..d125f0078
--- /dev/null
+++ b/packages/stats-generator/src/initialLoad/index.ts
@@ -0,0 +1,36 @@
+import { servingFrameworks } from '../serving-frameworks.ts'
+import { startBenchmarkServer } from '../benchmark-server.ts'
+import { getHost } from '../serve/common.ts'
+import { runBenchmark } from './run-benchmark.ts'
+import type { InitialLoadResult } from '@framework-tracker/results'
+
+const INITIAL_LOAD_HOST = getHost()
+const INITIAL_LOAD_PORT = 3003
+
+export async function runInitialLoadBenchmark(
+ packageName: string,
+): Promise {
+ const config = servingFrameworks.find((f) => f.package === packageName)
+
+ if (!config) {
+ throw new Error(
+ `Unknown initial load package: ${packageName}. Available: ${servingFrameworks.map((f) => f.package).join(', ')}`,
+ )
+ }
+
+ console.info(`Starting server for ${config.displayName}...`)
+ const killServer = await startBenchmarkServer(config, {
+ host: INITIAL_LOAD_HOST,
+ port: INITIAL_LOAD_PORT,
+ readinessPath: '/initial-load',
+ })
+
+ try {
+ console.info(`Running initial load benchmark for ${config.displayName}...`)
+ return await runBenchmark(
+ `http://${INITIAL_LOAD_HOST}:${INITIAL_LOAD_PORT}`,
+ )
+ } finally {
+ killServer()
+ }
+}
diff --git a/packages/stats-generator/src/initialLoad/run-benchmark.ts b/packages/stats-generator/src/initialLoad/run-benchmark.ts
new file mode 100644
index 000000000..58e524f1e
--- /dev/null
+++ b/packages/stats-generator/src/initialLoad/run-benchmark.ts
@@ -0,0 +1,78 @@
+import puppeteer from 'puppeteer-core'
+import { startFlow } from 'lighthouse'
+import { z } from 'zod'
+import {
+ InitialLoadResourceTypeSchema,
+ InitialLoadResourceSchema,
+ InitialLoadStatsSchema,
+} from '@framework-tracker/results'
+import { findChromium } from '../find-chromium.ts'
+
+const ResourceRowsSchema = z.array(
+ InitialLoadResourceSchema.extend({
+ resourceType: InitialLoadResourceTypeSchema,
+ }),
+)
+
+export function parseResourceSummary(details: unknown) {
+ const table = z
+ .object({ type: z.literal('table'), items: ResourceRowsSchema })
+ .parse(details)
+ const resources = Object.fromEntries(
+ table.items.map(({ resourceType, requestCount, transferSize }) => [
+ resourceType,
+ { requestCount, transferSize },
+ ]),
+ )
+ if (
+ new Set(table.items.map((row) => row.resourceType)).size !==
+ table.items.length
+ ) {
+ throw new Error('Duplicate Lighthouse resource-summary category')
+ }
+ // Do not include third-party when calculating totals.
+ // https://developer.chrome.com/docs/lighthouse/performance/resource-summary
+ return InitialLoadStatsSchema.parse({
+ name: 'initialLoad',
+ source: 'lighthouse-resource-summary',
+ path: '/initial-load',
+ resources,
+ })
+}
+
+export async function runBenchmark(url: string) {
+ const browser = await puppeteer.launch({
+ executablePath: findChromium(),
+ headless: true,
+ defaultViewport: { width: 800, height: 600, deviceScaleFactor: 1 },
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
+ })
+ try {
+ const page = await browser.newPage()
+ const flow = await startFlow(page, {
+ name: 'Initial load',
+ flags: {
+ throttlingMethod: 'provided',
+ formFactor: 'desktop',
+ screenEmulation: { disabled: true },
+ onlyAudits: ['resource-summary'],
+ },
+ })
+ await flow.navigate(`${url}/initial-load`)
+ const result = await flow.createFlowResult()
+ const lhr = result.steps[0]?.lhr
+ if (!lhr || lhr.runtimeError) {
+ throw new Error(
+ `Initial load failed: ${lhr?.runtimeError?.message ?? 'Missing Lighthouse result'}`,
+ )
+ }
+ return {
+ browserVersion: await browser.version(),
+ initialLoadTests: parseResourceSummary(
+ lhr.audits['resource-summary']?.details,
+ ),
+ }
+ } finally {
+ await browser.close()
+ }
+}
diff --git a/packages/stats-generator/src/run-initial-load-benchmark.ts b/packages/stats-generator/src/run-initial-load-benchmark.ts
new file mode 100644
index 000000000..a43b77892
--- /dev/null
+++ b/packages/stats-generator/src/run-initial-load-benchmark.ts
@@ -0,0 +1,63 @@
+import { readProjectResult } from '@framework-tracker/results/storage'
+import { InitialLoadResultSchema } from '@framework-tracker/results'
+import { join } from 'node:path'
+import { runInitialLoadBenchmark } from './initialLoad/index.ts'
+import { packagesDir } from './constants.ts'
+import {
+ getFrameworkByPackage,
+ getFrameworkVersion,
+ normalizeCIStats,
+ parseArgs,
+ readJsonFile,
+ writeJsonFile,
+} from './utils.ts'
+import type { CIStats } from './types.ts'
+
+async function main() {
+ const { packageName } = parseArgs(
+ 'Usage: run-initial-load-benchmark \nExample: run-initial-load-benchmark app-astro',
+ )
+ const { framework } = await getFrameworkByPackage(packageName)
+ const frameworkVersion = await getFrameworkVersion(
+ packageName,
+ framework.frameworkPackage,
+ )
+
+ const result = await runInitialLoadBenchmark(packageName)
+ const timestamp = new Date().toISOString()
+ const runner = process.env.RUNNER_LABEL || 'local'
+
+ const existingStats = normalizeCIStats(
+ readJsonFile(join(packagesDir, packageName, 'ci-stats.json')) ??
+ (readProjectResult(packageName) as CIStats | null) ??
+ {},
+ )
+
+ const ciStats: CIStats = {
+ ...existingStats,
+ timingMeasuredAt: timestamp,
+ runner,
+ browserVersion: result.browserVersion,
+ frameworkVersion: frameworkVersion ?? existingStats?.frameworkVersion,
+ initialLoadTests: result.initialLoadTests,
+ }
+
+ const outputPath = join(packagesDir, packageName, 'ci-stats.json')
+ InitialLoadResultSchema.parse(ciStats)
+ writeJsonFile(outputPath, ciStats)
+
+ console.info(
+ `\n✓ Saved ${packageName} v${frameworkVersion ?? 'unknown'} (${packageName})`,
+ )
+ console.info(
+ ` Requests: ${result.initialLoadTests.resources.total.requestCount}`,
+ )
+ console.info(
+ ` Transfer: ${result.initialLoadTests.resources.total.transferSize} bytes`,
+ )
+}
+
+main().catch((error) => {
+ console.error(error)
+ process.exitCode = 1
+})
diff --git a/packages/stats-generator/src/save-ci-stats.ts b/packages/stats-generator/src/save-ci-stats.ts
index d3cd42abe..b62f9ad60 100644
--- a/packages/stats-generator/src/save-ci-stats.ts
+++ b/packages/stats-generator/src/save-ci-stats.ts
@@ -13,6 +13,7 @@ import {
FrameworkDependencyScanSchema,
FirstPartyDependencyScanSchema,
ServerSideRenderedStatsSchema,
+ InitialLoadResultSchema,
ClientSideRenderedStatsSchema,
BuildStatsSchema,
DevServerStatsSchema,
@@ -607,6 +608,33 @@ export async function processCIStats(
)
}
+ const initialLoadArtifactPath = join(
+ artifactsDir,
+ `initial-load-stats-${name}`,
+ 'ci-stats.json',
+ )
+ const initialLoadArtifact = readArtifact(
+ initialLoadArtifactPath,
+ framework.app,
+ 'initialLoad',
+ )
+ if (initialLoadArtifact) {
+ const initialLoad = InitialLoadResultSchema.parse(initialLoadArtifact)
+ stats = {
+ ...stats,
+ browserVersion: initialLoad.browserVersion,
+ frameworkVersion:
+ initialLoad.frameworkVersion ?? stats.frameworkVersion,
+ initialLoadTests: initialLoad.initialLoadTests,
+ }
+ frameworkVersion = initialLoad.frameworkVersion ?? frameworkVersion
+ console.info(' ✓ Found initial load stats artifact')
+ } else {
+ console.warn(
+ `No initial load stats artifact found at ${initialLoadArtifactPath}`,
+ )
+ }
+
// check for releasedAt, if not fetch from repository
const releasedAt =
existingStats?.frameworkVersion === frameworkVersion &&
diff --git a/packages/stats-generator/src/serverSideRendered/index.ts b/packages/stats-generator/src/serverSideRendered/index.ts
index f7a53b92f..dca786e91 100644
--- a/packages/stats-generator/src/serverSideRendered/index.ts
+++ b/packages/stats-generator/src/serverSideRendered/index.ts
@@ -1,7 +1,5 @@
-import { spawn } from 'node:child_process'
-import { join } from 'node:path'
-import { fileURLToPath } from 'node:url'
-import { packagesDir } from '../constants.ts'
+import { servingFrameworks } from '../serving-frameworks.ts'
+import { startBenchmarkServer } from '../benchmark-server.ts'
import { getHost } from '../serve/common.ts'
import { runBenchmark } from './run-benchmark.ts'
import type { ServerSideRenderedBenchmarkResult } from './types.ts'
@@ -9,156 +7,24 @@ import type { ServerSideRenderedBenchmarkResult } from './types.ts'
const SERVER_SIDE_RENDERED_HOST = getHost()
const SERVER_SIDE_RENDERED_PORT = 3002
-interface ServerSideRenderedFrameworkConfig {
- name: string
- displayName: string
- package: string
- serveScript: string
- fullDocumentNavigation: boolean
-}
-
-const SERVER_SIDE_RENDERED_FRAMEWORKS: ServerSideRenderedFrameworkConfig[] = [
- {
- name: 'astro-server-side-rendered',
- displayName: 'Astro Server Side Rendered',
- package: 'app-astro',
- serveScript: 'astro.ts',
- fullDocumentNavigation: true,
- },
- {
- name: 'next-server-side-rendered',
- displayName: 'Next.js Server Side Rendered',
- package: 'app-next-js',
- serveScript: 'next.ts',
- fullDocumentNavigation: false,
- },
- {
- name: 'nuxt-server-side-rendered',
- displayName: 'Nuxt Server Side Rendered',
- package: 'app-nuxt',
- serveScript: 'nitro.ts',
- fullDocumentNavigation: false,
- },
- {
- name: 'pracht-server-side-rendered',
- displayName: 'Pracht Server Side Rendered',
- package: 'app-pracht',
- serveScript: 'pracht.ts',
- fullDocumentNavigation: false,
- },
- {
- name: 'react-router-server-side-rendered',
- displayName: 'React Router Server Side Rendered',
- package: 'app-react-router',
- serveScript: 'react-router.ts',
- fullDocumentNavigation: false,
- },
- {
- name: 'solid-start-server-side-rendered',
- displayName: 'SolidStart Server Side Rendered',
- package: 'app-solid-start',
- serveScript: 'nitro.ts',
- fullDocumentNavigation: false,
- },
- {
- name: 'sveltekit-server-side-rendered',
- displayName: 'SvelteKit Server Side Rendered',
- package: 'app-sveltekit',
- serveScript: 'sveltekit.ts',
- fullDocumentNavigation: false,
- },
- {
- name: 'tanstack-start-server-side-rendered',
- displayName: 'TanStack Start Server Side Rendered',
- package: 'app-tanstack-start-react',
- serveScript: 'tanstack-start.ts',
- fullDocumentNavigation: false,
- },
-]
-
-async function waitForServer(url: string, timeoutMs = 30_000): Promise {
- const deadline = Date.now() + timeoutMs
- while (Date.now() < deadline) {
- try {
- const res = await fetch(url)
- if (res.status === 200) return
- } catch {
- // not ready yet
- }
- await new Promise((r) => setTimeout(r, 500))
- }
- throw new Error(`Server at ${url} did not become ready within ${timeoutMs}ms`)
-}
-
-async function spawnServer(
- config: ServerSideRenderedFrameworkConfig,
-): Promise<() => void> {
- const appDir = join(packagesDir, config.package)
- const scriptPath = fileURLToPath(
- new URL(`../serve/${config.serveScript}`, import.meta.url),
- )
-
- const proc = spawn('node', [scriptPath, appDir], {
- env: {
- ...process.env,
- HOST: SERVER_SIDE_RENDERED_HOST,
- NODE_ENV: 'production',
- PORT: String(SERVER_SIDE_RENDERED_PORT),
- },
- stdio: ['ignore', 'pipe', 'pipe'],
- })
-
- proc.stdout?.on('data', (chunk: Buffer) =>
- process.stdout.write(`[${config.package}] ${chunk}`),
- )
- proc.stderr?.on('data', (chunk: Buffer) =>
- process.stderr.write(`[${config.package}] ${chunk}`),
- )
-
- let exited = false
- proc.on('exit', (code) => {
- exited = true
- if (code != null && code !== 0) {
- console.error(`[${config.package}] server exited with code ${code}`)
- }
- })
-
- const exitPromise = new Promise((_, reject) => {
- proc.on('exit', (code) => {
- if (code != null && code !== 0) {
- reject(new Error(`Server process exited with code ${code}`))
- }
- })
- })
-
- await Promise.race([
- waitForServer(
- `http://${SERVER_SIDE_RENDERED_HOST}:${SERVER_SIDE_RENDERED_PORT}/server-side-rendered`,
- ),
- exitPromise,
- ])
-
- return () => {
- if (!exited) proc.kill('SIGTERM')
- }
-}
-
export async function runServerSideRenderedBenchmark(
packageName: string,
runs = 5,
): Promise {
- const config = SERVER_SIDE_RENDERED_FRAMEWORKS.find(
- (f) => f.package === packageName,
- )
+ const config = servingFrameworks.find((f) => f.package === packageName)
if (!config) {
throw new Error(
- `Unknown server side rendered package: ${packageName}. Available: ${SERVER_SIDE_RENDERED_FRAMEWORKS.map((f) => f.package).join(', ')}`,
+ `Unknown server side rendered package: ${packageName}. Available: ${servingFrameworks.map((f) => f.package).join(', ')}`,
)
}
console.info(`Starting server for ${config.displayName}...`)
- const killServer = await spawnServer(config)
+ const killServer = await startBenchmarkServer(config, {
+ host: SERVER_SIDE_RENDERED_HOST,
+ port: SERVER_SIDE_RENDERED_PORT,
+ readinessPath: '/server-side-rendered',
+ })
try {
console.info(
@@ -166,7 +32,7 @@ export async function runServerSideRenderedBenchmark(
)
return await runBenchmark(
`http://${SERVER_SIDE_RENDERED_HOST}:${SERVER_SIDE_RENDERED_PORT}`,
- config.name,
+ `${config.package === 'app-next-js' ? 'next' : config.package === 'app-tanstack-start-react' ? 'tanstack-start' : config.package.slice(4)}-server-side-rendered`,
config.displayName,
runs,
config.fullDocumentNavigation,
diff --git a/packages/stats-generator/src/serverSideRendered/run-benchmark.ts b/packages/stats-generator/src/serverSideRendered/run-benchmark.ts
index 821f2c3e6..88c902c3d 100644
--- a/packages/stats-generator/src/serverSideRendered/run-benchmark.ts
+++ b/packages/stats-generator/src/serverSideRendered/run-benchmark.ts
@@ -1,7 +1,7 @@
-import { existsSync, readdirSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import puppeteer from 'puppeteer-core'
import { startFlow } from 'lighthouse'
+import { findChromium } from '../find-chromium.ts'
import { warmUpServer } from '../server-warmup.ts'
import {
getInteractionTimingFromLighthouse,
@@ -16,34 +16,6 @@ import { assembleServerRendering } from '../server-rendering.ts'
const SERVER_SIDE_RENDERED_PATH = '/server-side-rendered'
-function findChromium(): string {
- if (process.env.CHROME_PATH) return process.env.CHROME_PATH
-
- const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH
- if (browsersPath && existsSync(browsersPath)) {
- const chromiumDir = readdirSync(browsersPath).find((e) =>
- e.startsWith('chromium-'),
- )
- if (chromiumDir) {
- const chromePath = `${browsersPath}/${chromiumDir}/chrome-linux/chrome`
- if (existsSync(chromePath)) return chromePath
- }
- }
-
- const candidates = [
- '/usr/bin/google-chrome',
- '/usr/bin/chromium-browser',
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
- ]
- for (const candidate of candidates) {
- if (existsSync(candidate)) return candidate
- }
-
- throw new Error(
- 'Could not find Chromium/Chrome. Set the CHROME_PATH env var.',
- )
-}
-
function getBrowserVersion(chromiumPath: string): string | undefined {
try {
return execFileSync(chromiumPath, ['--version'], {
diff --git a/packages/stats-generator/src/serving-frameworks.ts b/packages/stats-generator/src/serving-frameworks.ts
new file mode 100644
index 000000000..639e1115f
--- /dev/null
+++ b/packages/stats-generator/src/serving-frameworks.ts
@@ -0,0 +1,57 @@
+export interface ServingFrameworkConfig {
+ displayName: string
+ package: string
+ serveScript: string
+ fullDocumentNavigation: boolean
+}
+
+export const servingFrameworks: ServingFrameworkConfig[] = [
+ {
+ displayName: 'Astro Server Side Rendered',
+ package: 'app-astro',
+ serveScript: 'astro.ts',
+ fullDocumentNavigation: true,
+ },
+ {
+ displayName: 'Next.js Server Side Rendered',
+ package: 'app-next-js',
+ serveScript: 'next.ts',
+ fullDocumentNavigation: false,
+ },
+ {
+ displayName: 'Nuxt Server Side Rendered',
+ package: 'app-nuxt',
+ serveScript: 'nitro.ts',
+ fullDocumentNavigation: false,
+ },
+ {
+ displayName: 'Pracht Server Side Rendered',
+ package: 'app-pracht',
+ serveScript: 'pracht.ts',
+ fullDocumentNavigation: false,
+ },
+ {
+ displayName: 'React Router Server Side Rendered',
+ package: 'app-react-router',
+ serveScript: 'react-router.ts',
+ fullDocumentNavigation: false,
+ },
+ {
+ displayName: 'SolidStart Server Side Rendered',
+ package: 'app-solid-start',
+ serveScript: 'nitro.ts',
+ fullDocumentNavigation: false,
+ },
+ {
+ displayName: 'SvelteKit Server Side Rendered',
+ package: 'app-sveltekit',
+ serveScript: 'sveltekit.ts',
+ fullDocumentNavigation: false,
+ },
+ {
+ displayName: 'TanStack Start Server Side Rendered',
+ package: 'app-tanstack-start-react',
+ serveScript: 'tanstack-start.ts',
+ fullDocumentNavigation: false,
+ },
+]
diff --git a/packages/stats-generator/src/types.ts b/packages/stats-generator/src/types.ts
index 76fbd4c39..402ec456c 100644
--- a/packages/stats-generator/src/types.ts
+++ b/packages/stats-generator/src/types.ts
@@ -14,6 +14,7 @@ import type {
DevServerStatsSchema,
InstallBenchmarkSchema,
InstallResultSchema,
+ InitialLoadStatsSchema,
} from '@framework-tracker/results'
import type { SSRLoadTests } from './ssrLoad/types.ts'
export type {
@@ -45,6 +46,7 @@ interface RenderedTestStandardDeviation extends InteractionTiming {
}
export interface CIStats {
+ initialLoadTests?: z.infer
framework?: string
packagePath?: string
timingMeasuredAt?: string
diff --git a/packages/testdata/src/initial-load.css b/packages/testdata/src/initial-load.css
new file mode 100644
index 000000000..3b49a5f48
--- /dev/null
+++ b/packages/testdata/src/initial-load.css
@@ -0,0 +1,110 @@
+.performance-page {
+ box-sizing: border-box;
+ min-height: 100vh;
+ padding: 80px 24px;
+ background: #f5f7f4;
+ color: #203329;
+ font-family: system-ui, sans-serif;
+}
+.performance-page * {
+ box-sizing: border-box;
+}
+.performance-content {
+ max-width: 1080px;
+ margin: 0 auto;
+}
+.performance-eyebrow {
+ margin: 0 0 16px;
+ color: #476650;
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+.performance-page h1 {
+ margin: 0;
+ font-size: clamp(36px, 5vw, 56px);
+ line-height: 1.1;
+ letter-spacing: -0.04em;
+}
+.performance-intro {
+ max-width: 580px;
+ margin: 20px 0 40px;
+ color: #56665b;
+ font-size: 18px;
+ line-height: 1.7;
+}
+.performance-cards {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 20px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+.performance-card {
+ display: flex;
+ flex-direction: column;
+ padding: 28px;
+ background: #fff;
+ border: 1px solid #dce5db;
+ border-radius: 20px;
+ box-shadow: 0 6px 20px #20332906;
+}
+.performance-number {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ margin-bottom: 32px;
+ border-radius: 12px;
+ background: #edf4e9;
+ color: #46653b;
+ font-size: 13px;
+ font-weight: 700;
+}
+.performance-card h2 {
+ margin: 0 0 12px;
+ font-size: 25px;
+ letter-spacing: -0.025em;
+}
+.performance-card p {
+ flex: 1;
+ margin: 0 0 28px;
+ color: #56665b;
+ font-size: 16px;
+ line-height: 1.7;
+}
+.performance-card a {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 13px 16px;
+ border: 1px solid #dce5db;
+ border-radius: 10px;
+ color: #284b30;
+ font-size: 14px;
+ font-weight: 650;
+ text-decoration: none;
+}
+.performance-card a:hover {
+ background: #edf4e9;
+ border-color: #8da583;
+}
+.performance-card a:focus-visible {
+ outline: 3px solid #476650;
+ outline-offset: 4px;
+}
+@media (max-width: 760px) {
+ .performance-page {
+ padding: 48px 20px;
+ }
+ .performance-cards {
+ grid-template-columns: 1fr;
+ }
+ .performance-number {
+ margin-bottom: 24px;
+ }
+}