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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/generate-stats.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ on:
- '!packages/starter-*/corejs-stats.json'
- '!packages/starter-*/browser-baseline-stats.json'
- '!packages/starter-*/e18e-stats.json'
- '!packages/starter-*/node-engines-stats.json'
- 'packages/app-*/**'
- '!packages/app-*/ci-stats.json'
- '!packages/app-*/stats/**'
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/measure-framework.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ jobs:
if: contains(toJson(matrix.framework.measurements), 'browserBaseline')
run: pnpm --filter @framework-tracker/stats-generator run:browser-baseline ${{ matrix.framework.package }}

- name: Scan installed dependencies for Node engine constraints
run: pnpm --filter @framework-tracker/stats-generator run:node-engines ${{ matrix.framework.package }}

- name: Upload build stats
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
Expand All @@ -139,6 +142,14 @@ jobs:
retention-days: 1
if-no-files-found: error

- name: Upload node engines stats
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: node-engines-stats-${{ matrix.framework.name }}
path: packages/${{ matrix.framework.package }}/node-engines-stats.json
retention-days: 1
if-no-files-found: warn

measure-ssr-request-throughput:
if: inputs.ssr-request-throughput-matrix != '[]'
runs-on: depot-ubuntu-24.04
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/validate-stats.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ on:
- '!packages/starter-*/corejs-stats.json'
- '!packages/starter-*/browser-baseline-stats.json'
- '!packages/starter-*/e18e-stats.json'
- '!packages/starter-*/node-engines-stats.json'

jobs:
validate:
Expand Down Expand Up @@ -81,6 +82,8 @@ jobs:
pnpm --filter @framework-tracker/stats-generator run:install $PKG 1
echo "Running build benchmark for $PKG..."
pnpm --filter @framework-tracker/stats-generator run:build $PKG
echo "Running Node engines scan for $PKG..."
pnpm --filter @framework-tracker/stats-generator run:node-engines $PKG
done

echo ""
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ packages/*/build-stats.json
packages/*/corejs-stats.json
packages/*/e18e-stats.json
packages/*/framework-dependency-stats.json
packages/*/node-engines-stats.json

# Misc
*.tmp
Expand Down
23 changes: 23 additions & 0 deletions packages/docs/src/components/MinimumNodeVersionTable.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
import { minimumNodeVersionTableData } from '../lib/collections'
import { getFrameworkSlug } from '../lib/utils'
import StatsTable from './StatsTable.astro'

const columns = [
{
key: 'name',
header: 'Framework',
nameCell: true,
href: (row: Record<string, unknown>) =>
`/framework/${getFrameworkSlug(row.package as string)}`,
},
{ key: 'minNodeVersion', header: 'Min Node' },
{ key: 'imposedBy', header: 'Set by' },
]
---

<StatsTable
label="Minimum Node version by framework"
columns={columns}
data={minimumNodeVersionTableData}
/>
2 changes: 2 additions & 0 deletions packages/docs/src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ const devtimeSchema = z.object({
baselineFeatureCount: z.number(),
})
.optional(),
minimumNodeVersion: z.string().optional(),
minimumNodeVersionImposedBy: z.array(z.string()).optional(),
timingMeasuredAt: z.string(),
runner: z.string(),
browserVersion: z.string().optional(),
Expand Down
7 changes: 7 additions & 0 deletions packages/docs/src/content/docs/all-frameworks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import DependencyCountText from '../../components/DependencyCountText.astro'
import DependencyStatsTable from '../../components/DependencyStatsTable.astro'
import FrameworkDependencyCharts from '../../components/FrameworkDependencyCharts.astro'
import FrameworkDependencyStatsTable from '../../components/FrameworkDependencyStatsTable.astro'
import MinimumNodeVersionTable from '../../components/MinimumNodeVersionTable.astro'
import NodeModulesSizeCharts from '../../components/NodeModulesSizeCharts.astro'
import ServerSideRenderedCharts from '../../components/ServerSideRenderedCharts.astro'
import ServerSideRenderedStatsTable from '../../components/ServerSideRenderedStatsTable.astro'
Expand Down Expand Up @@ -68,6 +69,12 @@ Methodology: [Browser Baseline](/methodology/#browser-baseline).

<BrowserBaselineTable />

## Minimum Node Version

Methodology: [Minimum Node Version](/methodology/#minimum-node-version).

<MinimumNodeVersionTable />

## Client Side Rendered Tests

Methodology: [Client Side Rendered Tests](/methodology/#client-side-rendered-tests).
Expand Down
7 changes: 7 additions & 0 deletions packages/docs/src/content/docs/dev-time.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import DependencyCountText from '../../components/DependencyCountText.astro'
import DependencyStatsTable from '../../components/DependencyStatsTable.astro'
import FrameworkDependencyCharts from '../../components/FrameworkDependencyCharts.astro'
import FrameworkDependencyStatsTable from '../../components/FrameworkDependencyStatsTable.astro'
import MinimumNodeVersionTable from '../../components/MinimumNodeVersionTable.astro'
import NodeModulesSizeCharts from '../../components/NodeModulesSizeCharts.astro'

## Setup
Expand Down Expand Up @@ -61,4 +62,10 @@ Methodology: [Browser Baseline](/methodology/#browser-baseline).

<BrowserBaselineTable />

## Minimum Node Version

Methodology: [Minimum Node Version](/methodology/#minimum-node-version).

<MinimumNodeVersionTable />

</div>
6 changes: 6 additions & 0 deletions packages/docs/src/content/docs/methodology.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ matches the framework version tracked by the starter project.
- Features is the number of unique web platform feature IDs detected in the
browser-facing build output.

### Minimum Node Version

The oldest Node.js release that satisfies every `engines.node` range declared by the packages installed in the starter's `node_modules`, dev and prod dependencies included, together with the starter's own `package.json`. The ranges are deduplicated and intersected, so the floor is the lowest version that every package accepts at once. The "Set by" column lists the packages that impose that floor, meaning removing any one of them would lower it. When several packages share the floor and no single one is responsible, the column shows a dash.

A dash in the Min Node column means no installed package declares a resolvable `engines.node` range.

### Duplicate Dependencies

- Duplicate dependency details come from e18e dependency analysis messages
Expand Down
10 changes: 10 additions & 0 deletions packages/docs/src/lib/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,16 @@ export const depsStats = starterStats.map((f) => ({
graph: 'View',
}))

export const minimumNodeVersionTableData = starterStats.map((f) => ({
name: f.name,
package: f.package,
isFocused: f.isFocused,
minNodeVersion: f.minimumNodeVersion ? `v${f.minimumNodeVersion}` : '—',
imposedBy: f.minimumNodeVersionImposedBy?.length
? f.minimumNodeVersionImposedBy.join(', ')
: '—',
}))

const frameworkPackageNames: Record<string, string> = {
'starter-astro': 'astro',
'starter-mastro': '@mastrojs/mastro',
Expand Down
2 changes: 2 additions & 0 deletions packages/stats-generator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"run:corejs": "node src/run-corejs-scan.ts",
"run:browser-baseline": "node src/run-browser-baseline-scan.ts",
"run:framework-dependencies": "node src/run-framework-dependency-scan.ts",
"run:node-engines": "node src/run-node-engines-scan.ts",
"save:ci-stats": "node src/save-ci-stats.ts",
"generate:preview-comment": "node src/generate-preview-comment.ts",
"validate": "node src/validate-stats.ts",
Expand All @@ -33,6 +34,7 @@
"react-dom": "19.2.8",
"tinybench": "^6.1.2",
"tinyglobby": "^0.2.17",
"verkit": "^0.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
Expand Down
130 changes: 130 additions & 0 deletions packages/stats-generator/src/node-engines.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { resolveMinimumNodeVersion } from './node-engines.ts'
import type { NodeEngineEntry } from './node-engines.ts'

function floorOf(range: string): string | null {
return resolveMinimumNodeVersion([{ name: 'pkg', range }]).minimumNodeVersion
}

test('two ranges whose intersection starts above either floor report both as imposing', () => {
const stats = resolveMinimumNodeVersion([
{ name: 'a', range: '^18 || >=20' },
{ name: 'b', range: '>=19' },
])
assert.equal(stats.minimumNodeVersion, '20.0.0')
assert.deepEqual(stats.imposedBy, ['a', 'b'])
assert.deepEqual(stats.unsatisfiableRanges, [])
})

test('spelling variants of one range both impose the floor', () => {
const stats = resolveMinimumNodeVersion([
{ name: 'a', range: '>=20' },
{ name: 'b', range: '>=20.0.0' },
{ name: 'c', range: '>= v20.x' },
])
assert.equal(stats.minimumNodeVersion, '20.0.0')
assert.deepEqual(stats.imposedBy, ['a', 'b', 'c'])
})

test('>= 0.4 floors at 0.4.0', () => {
assert.equal(floorOf('>= 0.4'), '0.4.0')
})

test('>=0.8.x floors at 0.8.0', () => {
assert.equal(floorOf('>=0.8.x'), '0.8.0')
})

test('>=v12.22.7 floors at 12.22.7', () => {
assert.equal(floorOf('>=v12.22.7'), '12.22.7')
})

test('>=16 || 14 >=14.17 floors at the two-comparator branch, 14.17.0', () => {
assert.equal(floorOf('>=16 || 14 >=14.17'), '14.17.0')
})

test('6.* || 8.* || >= 10.* floors at 6.0.0', () => {
assert.equal(floorOf('6.* || 8.* || >= 10.*'), '6.0.0')
})

test('4.x || >=6.0.0 floors at 4.0.0', () => {
assert.equal(floorOf('4.x || >=6.0.0'), '4.0.0')
})

test('20 || >=22 floors at 20.0.0', () => {
assert.equal(floorOf('20 || >=22'), '20.0.0')
})

test('18.20.8 || ^20.3.0 || >=22.0.0 floors at 18.20.8', () => {
assert.equal(floorOf('18.20.8 || ^20.3.0 || >=22.0.0'), '18.20.8')
})

test('^18.18.0 || ^20.9.0 || >=21.1.0 floors at 18.18.0', () => {
assert.equal(floorOf('^18.18.0 || ^20.9.0 || >=21.1.0'), '18.18.0')
})

test('a mixed tree intersects to 20.9.0 imposed by the two ranges that exclude 18.18.0 and 20.0.0', () => {
const entries: NodeEngineEntry[] = [
{ name: 'alpha', range: '>= 0.4' },
{ name: 'bravo', range: '>=16 || 14 >=14.17' },
{ name: 'charlie', range: '6.* || 8.* || >= 10.*' },
{ name: 'delta', range: '4.x || >=6.0.0' },
{ name: 'echo', range: '20 || >=22' },
{ name: 'foxtrot', range: '^18.18.0 || ^20.9.0 || >=21.1.0' },
{ name: 'golf' },
]
const stats = resolveMinimumNodeVersion(entries)
assert.equal(stats.minimumNodeVersion, '20.9.0')
assert.deepEqual(stats.imposedBy, ['echo', 'foxtrot'])
assert.deepEqual(stats.unsatisfiableRanges, [])
assert.equal(stats.packagesScanned, 7)
assert.equal(stats.packagesDeclaringNodeEngine, 6)
})

test('universal, garbage, blank, and missing ranges are skipped but counted', () => {
const stats = resolveMinimumNodeVersion([
{ name: 'star', range: '*' },
{ name: 'zero', range: '>=0.0.0' },
{ name: 'garbage', range: 'not-a-range' },
{ name: 'blank', range: ' ' },
{ name: 'missing' },
{ name: 'real', range: '>=18' },
])
assert.equal(stats.minimumNodeVersion, '18.0.0')
assert.deepEqual(stats.imposedBy, ['real'])
assert.equal(stats.packagesScanned, 6)
assert.equal(stats.packagesDeclaringNodeEngine, 4)
})

test('empty input resolves to nothing', () => {
assert.deepEqual(resolveMinimumNodeVersion([]), {
minimumNodeVersion: null,
imposedBy: [],
packagesScanned: 0,
packagesDeclaringNodeEngine: 0,
unsatisfiableRanges: [],
})
})

test('disjoint ranges yield null and list every range a candidate failed', () => {
const stats = resolveMinimumNodeVersion([
{ name: 'a', range: '^18.0.0' },
{ name: 'b', range: '>=20' },
])
assert.equal(stats.minimumNodeVersion, null)
assert.deepEqual(stats.imposedBy, [])
assert.deepEqual(stats.unsatisfiableRanges, [
'>=18.0.0 <19.0.0-0',
'>=20.0.0',
])
})

test('the starter package itself can be the sole imposer', () => {
const stats = resolveMinimumNodeVersion([
{ name: 'starter-mastro', range: '>=24.12' },
{ name: 'a', range: '>=18' },
{ name: 'b', range: '^20 || >=22' },
])
assert.equal(stats.minimumNodeVersion, '24.12.0')
assert.deepEqual(stats.imposedBy, ['starter-mastro'])
})
Loading
Loading