Skip to content

Commit c6ca283

Browse files
committed
feat(ci): bundle-size tracking + ES-compat checks (OSS-123, OSS-121)
Adds two CI signals for keeping the published packages small and broadly compatible: - Bundle size: size-limit file-mode config across packages plus a CopilotChat import-size regression signal (gzip) so growth in the headline consumer entrypoint is visible on every PR. A bundle-size workflow comments results on the PR (Phase 1: no hard-fail). - ES compatibility: a compat-check (es-check) script across 9 packages with a root .browserslistrc, validating built .mjs/.cjs against the es2022 build target. The measure script is importable (measureBundle) and unit-tested. Dev docs live under dev-docs/ (bundle-size.md, browser-compat.md). All action refs are pinned to full commit SHAs for supply-chain safety.
1 parent e39f1ab commit c6ca283

27 files changed

Lines changed: 615 additions & 20 deletions

.browserslistrc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# kaios 2.5 excluded deliberately — Gecko 48 era; excluded for CSS autoprefixer consumers.
2+
# JS compat targets (es2022 ESM/CJS, es2018 UMD; es2020 for react-core UMD) are hardcoded in tsdown configs and compat-check scripts.
3+
defaults
4+
not dead
5+
not op_mini all
6+
not kaios 2.5
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
name: static / bundle-size
2+
3+
on:
4+
pull_request:
5+
paths-ignore:
6+
- "docs/**"
7+
- "README.md"
8+
- "examples/**"
9+
- "showcase/**"
10+
11+
concurrency:
12+
group: ${{ github.workflow }}-${{ github.ref }}
13+
cancel-in-progress: true
14+
15+
permissions:
16+
contents: read
17+
18+
env:
19+
NODE_OPTIONS: "--max-old-space-size=4096"
20+
NX_VERBOSE_LOGGING: true
21+
22+
jobs:
23+
# Posts a per-PR comment with per-file gzip diffs for the 9 in-scope packages.
24+
# Runs preactjs/compressed-size-action, which builds the PR head and the base
25+
# branch, scans the `pattern` glob in each, and diffs the gzip sizes; the
26+
# comment updates in place on subsequent pushes. No hard-fail (Phase 1) — see
27+
# dev-docs/bundle-size.md for the rollout plan.
28+
#
29+
# Fork limitation: pull_request runs from a fork get a read-only GITHUB_TOKEN,
30+
# so the action cannot post the comment and prints the report to the logs
31+
# instead. The measurement still runs. Accepted for Phase 1 (informational, no
32+
# hard-fail); see dev-docs/bundle-size.md for the relay pattern if the comment
33+
# ever becomes required.
34+
bundle-size:
35+
runs-on: ubuntu-latest
36+
timeout-minutes: 30
37+
# pull-requests: write is scoped to this job only — it's the one that posts
38+
# the size-diff comment. The import-size job below stays read-only.
39+
permissions:
40+
contents: read
41+
pull-requests: write
42+
steps:
43+
- name: Checkout
44+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
45+
with:
46+
persist-credentials: false
47+
fetch-depth: 0
48+
49+
- name: Setup pnpm
50+
# Omit `version:` so pnpm/action-setup inherits from the repo's
51+
# `packageManager` field in package.json (via corepack).
52+
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
53+
54+
- name: Use Node.js 20
55+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
56+
with:
57+
node-version: 20.x
58+
# setup-node built-in cache is fork-safe (fork PRs can't write to base repo cache)
59+
cache: "pnpm"
60+
cache-dependency-path: "**/pnpm-lock.yaml"
61+
62+
- name: Measure compressed bundle sizes
63+
uses: preactjs/compressed-size-action@66325aad6443cb7cf89c4bfcd414aea2367cda94 # 2.9.1
64+
with:
65+
repo-token: ${{ secrets.GITHUB_TOKEN }}
66+
# Use the root `build` script (present on both this branch and the base
67+
# branch) so compressed-size-action can build both sides for comparison.
68+
# The `pattern` below restricts measurement to the 9 in-scope packages.
69+
build-script: build
70+
pattern: "packages/{core,shared,react-core,react-ui,react-textarea,runtime-client-gql,web-inspector,voice,a2ui-renderer}/dist/**/*.{mjs,js,cjs}"
71+
72+
# Measures what an app importing { CopilotChat } from
73+
# @copilotkit/react-core/v2 bundles, by driving esbuild over a synthetic entry
74+
# and summing the gzipped output. Writes the total to the GitHub job summary
75+
# for per-PR visibility. It is a relative regression signal across PRs, not a
76+
# production Vite/Next figure — see dev-docs/bundle-size.md.
77+
copilotchat-import-size:
78+
runs-on: ubuntu-latest
79+
timeout-minutes: 30
80+
permissions:
81+
contents: read
82+
steps:
83+
- name: Checkout
84+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
85+
with:
86+
persist-credentials: false
87+
88+
- name: Setup pnpm
89+
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
90+
91+
- name: Use Node.js 20
92+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
93+
with:
94+
node-version: 20.x
95+
cache: "pnpm"
96+
cache-dependency-path: "**/pnpm-lock.yaml"
97+
98+
- name: Install dependencies
99+
run: pnpm install --frozen-lockfile
100+
101+
- name: Build react-core
102+
run: npx nx run @copilotkit/react-core:build
103+
104+
- name: Measure CopilotChat bundle regression signal
105+
run: pnpm --filter @copilotkit/react-core size:headline
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: static / compat
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths-ignore:
7+
- "docs/**"
8+
- "README.md"
9+
- "examples/**"
10+
- "showcase/**"
11+
pull_request:
12+
paths-ignore:
13+
- "docs/**"
14+
- "README.md"
15+
- "examples/**"
16+
- "showcase/**"
17+
18+
concurrency:
19+
group: ${{ github.workflow }}-${{ github.ref }}
20+
cancel-in-progress: true
21+
22+
permissions:
23+
contents: read
24+
25+
jobs:
26+
compat-check:
27+
runs-on: ubuntu-latest
28+
timeout-minutes: 30
29+
steps:
30+
- name: Checkout
31+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
32+
with:
33+
persist-credentials: false
34+
35+
- name: Setup pnpm
36+
# Omit `version:` so pnpm/action-setup inherits from the repo's
37+
# `packageManager` field in package.json (via corepack).
38+
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
39+
40+
- name: Use Node.js 20
41+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
42+
with:
43+
node-version: 20.x
44+
# setup-node built-in cache is fork-safe (fork PRs can't write to base repo cache)
45+
cache: "pnpm"
46+
cache-dependency-path: "**/pnpm-lock.yaml"
47+
48+
- name: Install dependencies
49+
run: pnpm install --frozen-lockfile
50+
51+
- name: Build packages
52+
run: >
53+
npx nx run-many -t build
54+
--projects=@copilotkit/core,@copilotkit/shared,@copilotkit/react-core,@copilotkit/react-ui,@copilotkit/react-textarea,@copilotkit/runtime-client-gql,@copilotkit/web-inspector,@copilotkit/voice,@copilotkit/a2ui-renderer
55+
56+
- name: Run compat-check
57+
run: >
58+
npx nx run-many -t compat-check
59+
--projects=@copilotkit/core,@copilotkit/shared,@copilotkit/react-core,@copilotkit/react-ui,@copilotkit/react-textarea,@copilotkit/runtime-client-gql,@copilotkit/web-inspector,@copilotkit/voice,@copilotkit/a2ui-renderer

.oxfmtrc.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"packages/web-inspector/src/styles/generated.css",
1313
"showcase/aimock/shared/**",
1414
"showcase/aimock/d4/**",
15-
"showcase/aimock/d6/**"
15+
"showcase/aimock/d6/**",
16+
"**/package.json"
1617
]
1718
}

dev-docs/browser-compat.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Browser Compatibility
2+
3+
## Browserslist Matrix
4+
5+
The `.browserslistrc` at the repo root declares the intended browser support policy. It is useful for tools that read browserslist directly (e.g. PostCSS autoprefixer, documentation generators). To see the current resolved matrix, run:
6+
7+
```
8+
npx browserslist
9+
```
10+
11+
The resolved set changes over time as `defaults` is a dynamic query maintained by the browserslist project — there is no fixed version table in this doc.
12+
13+
One entry is explicitly excluded: `not kaios 2.5`. KaiOS 2.5 ships a Gecko 48-era engine and is documented here as out-of-scope so that consumers of this policy (autoprefixer and similar tools) know not to target it.
14+
15+
Note: `.browserslistrc` does **not** drive `compat-check`. The `es-check` targets (ES2022 for ESM/CJS; ES2018 for UMD, except `@copilotkit/react-core` UMD which uses ES2020 — see "What `compat-check` Does" below) are hardcoded in the `compat-check` scripts and are independent of the browserslist query. See the "Decoupled" section below for why these two concerns are kept separate.
16+
17+
## What `compat-check` Does
18+
19+
`compat-check` runs `es-check` against the built `dist/` output after a build:
20+
21+
- **ESM and CJS files** are checked against **ES2022** (matches the `tsdown` `target: "es2022"` setting for all packages).
22+
- **UMD files** are checked against **ES2018** for all packages except `@copilotkit/react-core`, which uses **ES2020** because its source contains dynamic `import()` expressions that cannot be downcompiled to ES2018 by rollup when the imported modules are external.
23+
24+
If any file uses syntax above the target level, `es-check` fails loudly with the offending file and the problematic feature. The check runs in CI so failures surface before a release.
25+
26+
## Why It Is Decoupled from the `tsdown` Target
27+
28+
`tsdown`'s `target` option tells the compiler what it _should_ emit — but there are two ways the actual output syntax can exceed that target without tsdown itself introducing the violation:
29+
30+
1. **Transitive dependencies.** Bundled code from a dep can carry syntax that was never downcompiled because tsdown only transforms its own output, not pre-compiled dep artifacts.
31+
2. **tsdown version bumps.** A new tsdown version may change how it handles certain patterns, inadvertently emitting newer syntax.
32+
33+
Neither of those failures would be caught by reading the tsdown config. The `compat-check` catches them by inspecting the real built artifacts, so drift is detected before a customer encounters a parse error in a supported browser.
34+
35+
## Handling a Failure
36+
37+
When `compat-check` fails, the output from `es-check` will name the offending file and the syntax it objected to. The decision tree is:
38+
39+
1. **Identify the offending file.** Look at the `es-check` error output — it will point to a specific file in `dist/`.
40+
2. **Trace it to a dep or to first-party code.** Check whether the syntax comes from a bundled dependency or from code we wrote. Source maps or a quick `grep` of the dist file for a known identifier usually clarifies this.
41+
3. **Decide:**
42+
- If the violation came from **a dep update** that unintentionally introduced newer syntax: pin or override the dep, or open an issue upstream asking them to ship a downcompiled artifact.
43+
- If we **intentionally dropped support** for an older browser tier: raise the `es-check` target in the `compat-check` script and update this document to match. Note: updating `.browserslistrc` has no effect on `compat-check` — the es-check targets are hardcoded in the scripts and are independent of the browserslist query.
44+
45+
Do not suppress the failure or widen the allowed syntax band without updating the `compat-check` targets and this document to match.

dev-docs/bundle-size.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Bundle Size Tracking
2+
3+
## How it works — two tiers
4+
5+
### Tier 1: CI (compressed-size-action)
6+
7+
`static_bundle_size.yml` runs on every PR via `preactjs/compressed-size-action@v2.9.1`. It scans a glob (`packages/{...}/dist/**/*.{mjs,js,cjs}`), computes the gzip size of each matched file (the action's default compression; the workflow sets no `compression` input), and posts a PR comment showing per-file diffs. It has **no hard-fail** (Phase 1).
8+
9+
> **Fork PRs:** `pull_request` runs triggered from a fork receive a read-only `GITHUB_TOKEN`, so `compressed-size-action` cannot post or update the PR comment — it prints the size report to the job logs instead. The measurement still runs; only the comment is unavailable. This is an accepted Phase 1 limitation (the report is informational and there is no hard-fail). If the PR comment ever becomes a required signal, switch to a `pull_request_target` + `workflow_run` relay pattern so the comment is posted from a trusted context without exposing write tokens to fork code.
10+
11+
Key facts:
12+
13+
- Reports by **file path**, not by named entry — it does not read `.size-limit.json` at all.
14+
- The action runs `build-script: build` (the root `build` script — `nx run-many -t build` over all `packages/**`) on both the PR branch and the base branch, then measures only the files matched by the `pattern` glob. The root `build` script is used (rather than a bundle-size-specific one) because the action must build the base branch too, and `build` exists on every branch. No separate build step is needed before the workflow triggers — the action handles both builds.
15+
- PR comments show paths like `packages/react-core/dist/index.mjs (+1.2 kB gzip)`.
16+
17+
### The CopilotChat regression signal (job summary, not the PR comment)
18+
19+
The `copilotchat-import-size` job in `static_bundle_size.yml` measures what an app
20+
importing `{ CopilotChat }` from `@copilotkit/react-core/v2` bundles, via
21+
`packages/react-core/scripts/measure-copilotchat.mjs` (run locally with
22+
`pnpm --filter @copilotkit/react-core size:headline`). It drives `esbuild`
23+
directly — bundling `{ CopilotChat }` minified, with `react`/`react-dom` external
24+
and CSS/fonts stubbed to `empty` (we measure JS) — and writes the total gzipped
25+
JS to the GitHub **job summary**.
26+
27+
**This is a _relative_ regression signal, not a production figure.** Its absolute
28+
value (currently ~3 MB gzip) is an esbuild number; a real consumer bundler
29+
(Vite/Next/webpack) splits eager-vs-lazy differently and reports different
30+
absolutes — the Notion "Header Embed Bundle Readout" measured ~386 kB _main
31+
initial JS_ under Vite, with the shiki/mermaid language packs as separate
32+
generated chunks. The script's worth is **consistency**: the same measurement
33+
every PR, so a change that grows CopilotChat's JS shows up, and the number
34+
collapses once OSS-122 moves the language packs to a CDN. A faithful _production_
35+
headline (real Next 15 fixture + `@next/bundle-analyzer`) is OSS-122 Phase 0.
36+
37+
Why a custom script and not `size-limit`: CopilotChat pulls `katex`'s CSS, whose
38+
`url()` font refs crash `@size-limit/esbuild` (which exposes no loader hook).
39+
Driving esbuild directly lets us stub the CSS/font assets.
40+
41+
### Tier 2: Local dev (size-limit)
42+
43+
The four **bundled** packages (`core`, `react-core`, `react-ui`, `react-textarea`) each have a `.size-limit.json` at their root listing one or more named entries pointing at `dist/` paths. Run locally via:
44+
45+
```
46+
pnpm --filter <pkg> size
47+
```
48+
49+
The five unbundled packages (`shared`, `runtime-client-gql`, `web-inspector`, `voice`, `a2ui-renderer`) have no `.size-limit.json` and no `size` script — their sizes are tracked by the CI glob only.
50+
51+
> **Node version requirement:** `size-limit@12.1.0` requires Node 20, 22, or 24+ (`^20 || ^22 || >=24`). Running `pnpm --filter <pkg> size` on Node 18 will produce an `EBADENGINE` error.
52+
53+
## Where configuration lives
54+
55+
`.size-limit.json` files live at the root of each bundled package (`core`, `react-core`, `react-ui`, `react-textarea`) and are used exclusively by the local `size` script. They are not read by CI.
56+
57+
## Adding a new measurement
58+
59+
Only bundled packages support local size tracking. For unbundled packages, CI covers all chunk files via the glob; no local config is needed.
60+
61+
To add a measurement to a bundled package:
62+
63+
1. Add an entry to the package's `.size-limit.json`:
64+
```json
65+
{ "name": "my-package: MyExport", "path": "dist/index.mjs", "gzip": true }
66+
```
67+
2. Build the package first: `pnpm --filter <pkg> build`
68+
3. Run locally: `pnpm --filter <pkg> size`
69+
4. Commit the updated `.size-limit.json`.
70+
71+
Note: named entries appear in **local** size-limit output only. CI PR comments report by file path from the glob, not by these names.
72+
73+
> **Bundled vs. unbundled packages:** `@size-limit/file` reports accurate sizes for bundled packages (those that build a single-file bundle). For unbundled packages (those that emit re-export barrels with separate chunk files), `@size-limit/file` only counts the barrel file — the CI `compressed-size-action` glob covers all chunks correctly regardless.
74+
75+
## CI behavior (Phase 1 — current)
76+
77+
`static_bundle_size.yml` posts a comment with per-file gzip diffs on every PR. It has **no hard-fail**. Sizes today reflect pre-OSS-122 bloat; adding budget limits now would either lock in that bloat permanently or fail immediately on every PR. Neither is useful.
78+
79+
## Phase 2 — after OSS-122 (separate ticket, blocked)
80+
81+
Once OSS-122 has reduced the baseline:
82+
83+
1. Add `"limit"` fields to each `.size-limit.json` entry.
84+
2. Add a size-limit step to the CI workflow (currently the workflow has no size-limit step — Phase 2 adds one, it does not flip an existing step).
85+
3. PRs that regress past a limit will fail CI.
86+
87+
Do not add `"limit"` fields before OSS-122 lands.

lefthook.yml

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,16 @@ pre-commit:
4949
run: |
5050
set -- {staged_files}
5151
if [ "$#" -gt 0 ]; then
52-
pnpm exec oxlint --fix "$@" &&
53-
pnpm exec oxfmt --write "$@" ;
54-
ruff format "$@" 2>/dev/null || true
52+
pnpm exec oxlint --fix "$@"
53+
# Defense-in-depth: filter package.json from explicit oxfmt args even though
54+
# .oxfmtrc.json ignorePatterns covers it. Explicit-path invocations bypass the
55+
# pattern in some tool versions; the shell filter makes the exclusion unconditional.
56+
fmt_files=""; for f in "$@"; do case "$f" in */package.json|package.json) ;; *) fmt_files="$fmt_files $f" ;; esac; done
57+
[ -n "$fmt_files" ] && pnpm exec oxfmt --write $fmt_files
58+
# ruff v0.9+ formats JSON with trailing commas (JSONC), which breaks
59+
# pnpm's JSON parser in the sync-lockfile hook. Scope it to .py only.
60+
py_files=""; for f in "$@"; do case "$f" in *.py) py_files="$py_files $f";; esac; done
61+
[ -n "$py_files" ] && ruff format $py_files 2>/dev/null || true
5562
fi
5663
stage_fixed: true
5764
test-and-check-packages:

nx.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@
8787
"dependsOn": ["build"],
8888
"inputs": ["{projectRoot}/package.json", "{projectRoot}/dist/**"],
8989
"cache": true
90+
},
91+
"compat-check": {
92+
"dependsOn": ["build"],
93+
"inputs": ["{projectRoot}/package.json", "{projectRoot}/dist/**"],
94+
"cache": true
9095
}
9196
},
9297
"parallel": 14,

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,16 @@
4242
"@arethetypeswrong/cli": "^0.18.2",
4343
"@commitlint/cli": "^20.3.0",
4444
"@commitlint/config-conventional": "^20.3.0",
45+
"@size-limit/file": "12.1.0",
4546
"@storybook/addon-docs": "^10.2.10",
4647
"@storybook/addon-webpack5-compiler-swc": "^4.0.2",
4748
"@storybook/react-webpack5": "^10.2.10",
4849
"@types/jscodeshift": "^17.3.0",
4950
"@types/node": "^18.11.17",
5051
"@vitest/coverage-v8": "^3.2.4",
52+
"browserslist": "^4.24.0",
5153
"danger": "^12.3.3",
54+
"es-check": "9.6.4",
5255
"glob": "^10.3.12",
5356
"install": "^0.13.0",
5457
"jscodeshift": "^17.3.0",
@@ -61,6 +64,7 @@
6164
"remark": "^15.0.1",
6265
"remark-mdx": "^3.1.1",
6366
"remark-parse": "^11.0.0",
67+
"size-limit": "12.1.0",
6468
"storybook": "^10.2.10",
6569
"ts-node": "^10.9.2",
6670
"tsdown": "^0.20.3",

packages/a2ui-renderer/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
},
4848
"scripts": {
4949
"build": "tsdown",
50+
"compat-check": "es-check es2022 --module 'dist/**/!(*.umd).{mjs,cjs,js}' && es-check es2018 'dist/**/*.umd.js'",
5051
"check-types": "tsc --noEmit -p tsconfig.json",
5152
"test": "vitest run",
5253
"test:watch": "vitest",

0 commit comments

Comments
 (0)