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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ jobs:
- name: Test (unit + property + worker + type-level, coverage-gated)
run: pnpm test -- --coverage

- name: Bundle-size budget (tree-shaken min+gzip)
run: pnpm size
Comment on lines +43 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant size check across matrix nodes

The bundle-size step runs on both the Node 22 and Node 24 matrix variants. esbuild output is deterministic and zlib.gzipSync at the default compression level should produce byte-identical results regardless of Node version, so the second run adds CI time without additional signal. If future zlib differences ever produced a different gzip length between versions, it could cause confusing pass/fail splits on the same commit. Consider either running the size check outside the matrix (a dedicated step or a separate job), or restricting it to one matrix leg with a if: matrix.node == 22 condition.

Prompt To Fix With AI
This is a comment left during a code review.
Path: .github/workflows/ci.yml
Line: 43-44

Comment:
**Redundant size check across matrix nodes**

The bundle-size step runs on both the Node 22 and Node 24 matrix variants. esbuild output is deterministic and `zlib.gzipSync` at the default compression level should produce byte-identical results regardless of Node version, so the second run adds CI time without additional signal. If future zlib differences ever produced a different gzip length between versions, it could cause confusing pass/fail splits on the same commit. Consider either running the size check outside the matrix (a dedicated step or a separate job), or restricting it to one matrix leg with a `if: matrix.node == 22` condition.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code


# ---------------------------------------------------------------------------
# Runtime lanes (P3): prove the SHIPPED dist actually runs on each claimed runtime.
# Each lane builds first (the smoke imports packages/ecsia/dist), then runs the SAME
Expand Down
17 changes: 17 additions & 0 deletions bundle-budget.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"_comment": "Bundle-size budgets: max min+gzip BYTES per tree-shaken entry (scripts/size-check.mjs). The honest \"lean install\" number. Ratchet DOWN with `node scripts/size-check.mjs --update` when a build shrinks; CI fails if a build grows past budget*1.03.",
"budgets": {
"kernel": {
"gzip": 39987,
"min": 126142
},
"core-min": {
"gzip": 30154,
"min": 97265
},
"full-umbrella": {
"gzip": 54587,
"min": 171999
}
}
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"smoke:runtime": "node scripts/runtime-smoke.mjs",
"smoke:browser:bundle": "node scripts/browser-smoke/build.mjs",
"smoke:browser": "playwright test --config scripts/browser-smoke/playwright.config.ts",
"size": "node scripts/size-check.mjs",
"size:update": "node scripts/size-check.mjs --update",
"docs:api": "typedoc",
"docs:check": "node scripts/check-doc-snippets.mjs",
"docs:dev": "vitepress dev website",
Expand Down
119 changes: 119 additions & 0 deletions scripts/size-check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Bundle-size budget. Bundles representative tree-shaken entry points against the BUILT dist,
// minifies (esbuild) + gzips (zlib), and asserts each stays under a committed budget. This is the
// honest "lean install" number — what a bundler ships for a real app, NOT the unminified source.
// Run after `pnpm build`. `--update` rewrites the budget to the measured sizes (the ratchet).
//
// Why per-import, not one number: ecsia is batteries-included but `sideEffects: false`, so a typed
// data + scheduler app pulls only the kernel; relations / serialization / topics drop unless used.
// The budgets pin that tree-shaking stays honest.

import { build } from 'esbuild'
import { gzipSync } from 'node:zlib'
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'

const HERE = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(HERE, '..')
const BUDGET_PATH = resolve(ROOT, 'bundle-budget.json')

// Each entry is the smallest realistic import for a use-case. Measured tree-shaken: the umbrella is
// pure static re-exports, so unused subsystems drop.
const ENTRIES = [
{
name: 'kernel',
note: 'typed data + systems + scheduler (the typical app)',
code: `import { createWorld, defineComponent, defineSystem, createScheduler, read, write } from 'ecsia'
console.log(createWorld, defineComponent, defineSystem, createScheduler, read, write)`,
},
{
name: 'core-min',
note: 'just a world + a component (@ecsia/core alone)',
code: `import { createWorld, defineComponent } from '@ecsia/core'
console.log(createWorld, defineComponent)`,
},
{
name: 'full-umbrella',
note: 'everything re-exported from the umbrella (the upper bound)',
code: `import * as ecsia from 'ecsia'
console.log(ecsia)`,
},
]

const alias = {
ecsia: resolve(ROOT, 'packages/ecsia/dist/index.js'),
'@ecsia/core': resolve(ROOT, 'packages/core/dist/index.js'),
'@ecsia/schema': resolve(ROOT, 'packages/schema/dist/index.js'),
'@ecsia/scheduler': resolve(ROOT, 'packages/scheduler/dist/index.js'),
'@ecsia/relations': resolve(ROOT, 'packages/relations/dist/index.js'),
'@ecsia/serialization': resolve(ROOT, 'packages/serialization/dist/index.js'),
}

async function measure(entry) {
const out = await build({
stdin: { contents: entry.code, resolveDir: ROOT, loader: 'js' },
bundle: true,
minify: true,
format: 'esm',
platform: 'neutral',
write: false,
legalComments: 'none',
alias,
// node:worker_threads is only reached on the threaded path via dynamic import — never in a
// browser bundle; mark it external so it doesn't inflate the measured size.
external: ['node:worker_threads', 'node:url'],
})
const code = out.outputFiles[0].contents
return { min: code.length, gzip: gzipSync(code).length }
}

async function main() {
for (const a of Object.values(alias)) {
if (!existsSync(a)) {
console.error(`size-check: missing build artifact ${a} — run \`pnpm build\` first.`)
process.exit(1)
}
}
const update = process.argv.includes('--update')
const budget = existsSync(BUDGET_PATH)
? JSON.parse(readFileSync(BUDGET_PATH, 'utf8'))
: { _comment: '', budgets: {} }

const results = {}
let failed = false
const TOLERANCE = 1.03 // 3% headroom so a trivial change doesn't trip the gate; ratchet with --update
for (const entry of ENTRIES) {
const { min, gzip } = await measure(entry)
results[entry.name] = { gzip, min }
const limit = budget.budgets?.[entry.name]?.gzip
const status = limit === undefined ? 'NEW' : gzip <= Math.ceil(limit * TOLERANCE) ? 'ok' : 'OVER'
if (status === 'OVER') failed = true
console.log(
` ${entry.name.padEnd(16)} ${String(gzip).padStart(6)} B gz (${String(min).padStart(6)} B min) ` +
`${limit === undefined ? '(no budget)' : `budget ${limit} B`} ${status}` +
` — ${entry.note}`,
)
}

if (update) {
const next = {
_comment:
'Bundle-size budgets: max min+gzip BYTES per tree-shaken entry (scripts/size-check.mjs). The honest "lean install" number. Ratchet DOWN with `node scripts/size-check.mjs --update` when a build shrinks; CI fails if a build grows past budget*1.03.',
budgets: Object.fromEntries(Object.entries(results).map(([k, v]) => [k, { gzip: v.gzip, min: v.min }])),
}
writeFileSync(BUDGET_PATH, JSON.stringify(next, null, 2) + '\n')
console.log(`\nsize-check: budget written to ${BUDGET_PATH}`)
return
}
Comment on lines +98 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 When --update is called and one or more entries are already "OVER" budget, the script unconditionally writes the new (inflated) values and exits 0 — failed is set but never consulted in this branch. A developer who runs pnpm size:update to capture intentional shrinkage after a refactor could accidentally absorb an unrelated regression in the same pass, and CI would pass on the next push with no record that the budget grew. At minimum, the update path should print a warning (or exit non-zero) when it is ratcheting values up rather than down.

Suggested change
if (update) {
const next = {
_comment:
'Bundle-size budgets: max min+gzip BYTES per tree-shaken entry (scripts/size-check.mjs). The honest "lean install" number. Ratchet DOWN with `node scripts/size-check.mjs --update` when a build shrinks; CI fails if a build grows past budget*1.03.',
budgets: Object.fromEntries(Object.entries(results).map(([k, v]) => [k, { gzip: v.gzip, min: v.min }])),
}
writeFileSync(BUDGET_PATH, JSON.stringify(next, null, 2) + '\n')
console.log(`\nsize-check: budget written to ${BUDGET_PATH}`)
return
}
if (update) {
if (failed) {
console.warn('\nsize-check: WARNING — updating budget with one or more entries that grew past the old limit (OVER above). Commit intentionally.')
}
const next = {
_comment:
'Bundle-size budgets: max min+gzip BYTES per tree-shaken entry (scripts/size-check.mjs). The honest "lean install" number. Ratchet DOWN with `node scripts/size-check.mjs --update` when a build shrinks; CI fails if a build grows past budget*1.03.',
budgets: Object.fromEntries(Object.entries(results).map(([k, v]) => [k, { gzip: v.gzip, min: v.min }])),
}
writeFileSync(BUDGET_PATH, JSON.stringify(next, null, 2) + '\n')
console.log(`\nsize-check: budget written to ${BUDGET_PATH}`)
return
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/size-check.mjs
Line: 98-107

Comment:
When `--update` is called and one or more entries are already "OVER" budget, the script unconditionally writes the new (inflated) values and exits 0 — `failed` is set but never consulted in this branch. A developer who runs `pnpm size:update` to capture intentional shrinkage after a refactor could accidentally absorb an unrelated regression in the same pass, and CI would pass on the next push with no record that the budget grew. At minimum, the update path should print a warning (or exit non-zero) when it is ratcheting values **up** rather than down.

```suggestion
  if (update) {
    if (failed) {
      console.warn('\nsize-check: WARNING — updating budget with one or more entries that grew past the old limit (OVER above). Commit intentionally.')
    }
    const next = {
      _comment:
        'Bundle-size budgets: max min+gzip BYTES per tree-shaken entry (scripts/size-check.mjs). The honest "lean install" number. Ratchet DOWN with `node scripts/size-check.mjs --update` when a build shrinks; CI fails if a build grows past budget*1.03.',
      budgets: Object.fromEntries(Object.entries(results).map(([k, v]) => [k, { gzip: v.gzip, min: v.min }])),
    }
    writeFileSync(BUDGET_PATH, JSON.stringify(next, null, 2) + '\n')
    console.log(`\nsize-check: budget written to ${BUDGET_PATH}`)
    return
  }
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code


if (failed) {
console.error('\nsize-check: FAILED — a bundle grew past its budget. Investigate, or `--update` to ratchet if intended.')
process.exit(1)
}
console.log('\nsize-check: OK — all bundles within budget.')
}

main().catch((err) => {
console.error(err)
process.exit(1)
})
25 changes: 17 additions & 8 deletions website/guide/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,23 @@ pnpm bench:macro:pool # the worker-pool speedup sweep on its own, with the pri

## Regression guard

CI does **not** run `bench:report`. Wall-clock time on shared CI runners is noise — a slow neighbor on
the runner would flap the suite — so we deliberately **do not assert milliseconds in CI**. What CI
*does* guard is correctness and behavior, with counter-based assertions: the worker-pool smoke test (a
quick check that it compiles and runs, not a measurement) asserts every threaded configuration is
byte-identical to single-thread (the `byte-identical` column above), and the bench builders are
cross-checked so neither `eachChunk` nor `bindColumns` can silently diverge from `.each`. Performance
regressions are caught by re-running `bench:report` on a fixed machine and comparing `RESULTS.json`,
not by a CI timer.
CI does **not** assert milliseconds — wall-clock time on a shared runner is noise. It guards
performance two ways instead. First, correctness: the worker-pool smoke test asserts every threaded
configuration is byte-identical to single-thread (the `byte-identical` column above), and the bench
builders are cross-checked so neither `eachChunk` nor `bindColumns` can silently diverge from `.each`.
Second, a dedicated bench job asserts each iteration path's ns/entity **ratio against a same-run bitECS
control** stays under a committed ceiling — the ratio cancels runner drift, so it catches a real
regression (say, `bindColumns` deopting) without flapping on a slow neighbor. The absolute published
numbers still come from `bench:report` on a fixed machine.

## Bundle size

The kernel — typed data, systems, queries, and the scheduler — is about **40 KB min+gzip**; just a
world and components (`@ecsia/core` alone) is ~30 KB, and importing *everything* from the umbrella is
~55 KB. ecsia is batteries-included, so it is larger than a minimal core like bitECS (~5 KB); the
packages are `sideEffects: false`, so a bundler ships only the subsystems you import — relations,
serialization, and topics drop unless used. A CI budget (`pnpm size`) holds these numbers in place so
a change can't quietly inflate the install.

## See also

Expand Down
Loading