diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46e2765eab..d1b3911bf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -320,6 +320,13 @@ jobs: if: needs.impact.outputs.build == 'true' run: npm run build --prefix client + # The Scalar bundle budget can only be measured against a real build, and it + # skips itself when client/dist is absent — so it runs here, not in the unit + # test job. See client/src/pages/ApiExplorer.bundle.test.js. + - name: Check API Explorer bundle budget + if: needs.impact.outputs.build == 'true' + run: npm run test --prefix client -- ApiExplorer.bundle + - name: Cancel sibling CI jobs after failure if: failure() && github.event_name == 'pull_request' env: diff --git a/client/src/components/api-explorer/ScalarReference.jsx b/client/src/components/api-explorer/ScalarReference.jsx index ff82156444..957e11a4f4 100644 --- a/client/src/components/api-explorer/ScalarReference.jsx +++ b/client/src/components/api-explorer/ScalarReference.jsx @@ -1,3 +1,17 @@ +// @scalar/api-reference-react is the heaviest dependency in the client, by a wide +// margin: 261 of the client's 599 installed packages (44%) are reachable ONLY through +// it, and its dist assets weigh 3.24 MB against ~13.0 MB of built JS in total (25%). That +// includes an entire second UI framework (Vue 3 + radix-vue + @headlessui/vue) and the +// Vercel AI SDK, pulled in as a hard dependency via @scalar/agent-chat. +// +// `agent: { disabled: true }` below is a RUNTIME config value, not a build-time flag — +// Rollup cannot tree-shake on it, so the agent chat interface is still emitted as its +// own chunk. Turning it off changes the UI, not the bundle. +// +// The footprint is bounded by client/src/pages/ApiExplorer.bundle.test.js (a 4.0 MB +// budget over the Scalar-attributable dist chunks, run in CI right after the client +// build). Read that test and the `@scalar/api-reference-react` entry in docs/DEPS.md +// before bumping this dependency. import { useMemo } from 'react'; import { ApiReferenceReact } from '@scalar/api-reference-react'; import '@scalar/api-reference-react/style.css'; diff --git a/client/src/pages/ApiExplorer.bundle.test.js b/client/src/pages/ApiExplorer.bundle.test.js new file mode 100644 index 0000000000..d9241b48d7 --- /dev/null +++ b/client/src/pages/ApiExplorer.bundle.test.js @@ -0,0 +1,81 @@ +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// Bundle-footprint budget for @scalar/api-reference-react (Dev Tools → API Explorer). +// +// Scalar is lazy-loaded, so it never lands in the initial payload — but it still +// drags an entire second UI framework (Vue 3 + radix-vue + @headlessui/vue) and the +// Vercel AI SDK (via @scalar/agent-chat) into the build. `agent: { disabled: true }` +// in ScalarReference.jsx is a RUNTIME toggle: Rollup cannot tree-shake on it, so the +// agent chat interface is emitted as its own chunk regardless. +// +// Measured 2026-09-02 against a fresh `npm run build --prefix client`: +// OperationBlock.vue-*.js 2,313,825 B +// ScalarReference-*.js 622,286 B +// AgentScalarChatInterface.vue-*.js 202,239 B +// ScalarReference-*.css 255,913 B +// --------------------------------------------- +// total 3,394,263 B (3.24 MB of 13.02 MB of assets) +// +// The budget leaves ~23% headroom for routine minor bumps. A failure is a signal to +// re-measure and decide deliberately — see the `@scalar/api-reference-react` entry in +// docs/DEPS.md — not an invitation to raise the number reflexively. +const SCALAR_BUDGET_BYTES = 4 * 1024 * 1024; + +// Vite names these chunks after the module that pulls them in: our own +// ScalarReference entry, plus Scalar's Vue single-file components +// (`OperationBlock.vue-.js`). The `vue-` half is matched at a name boundary +// rather than only after a dot, so a Vue-runtime or `radix-vue` vendor chunk is +// counted too if Rollup ever splits one out. Nothing else in the client is authored +// in Vue, so anything Vue-named is Scalar-attributable by construction. +const isScalarAsset = (name) => /\.(js|css)$/.test(name) + && (name.includes('Scalar') || /(^|[.-])vue-/.test(name)); + +// Anchors the matcher: this chunk is named after +// client/src/components/api-explorer/ScalarReference.jsx, the module ApiExplorer +// lazy-imports. If Vite's naming changes, the matcher would silently find nothing +// and report a 0-byte pass — this assertion fails loudly instead. +const ENTRY_CHUNK = /^ScalarReference-[^/]*\.js$/; + +// `import.meta.url` is not usable here: the jsdom environment rewrites it to an http +// URL. Resolve from cwd instead — client/ under `npm test --prefix client`, but the +// repo root when a runner is pointed at the client project from above, so try both +// rather than silently skipping on a build that is present. +const ASSETS_DIR = ['dist/assets', 'client/dist/assets'] + .map((rel) => resolve(process.cwd(), rel)) + .find(existsSync) ?? resolve(process.cwd(), 'dist/assets'); + +const scalarAssets = () => readdirSync(ASSETS_DIR) + .filter(isScalarAsset) + .map((name) => ({ name, bytes: statSync(join(ASSETS_DIR, name)).size })) + .sort((a, b) => b.bytes - a.bytes); + +// Only the CI build job (and a local `npm run build`) produces dist/. The plain unit +// test run has nothing to measure, so skip rather than fail. +const hasBuild = existsSync(ASSETS_DIR); + +describe.skipIf(!hasBuild)('API Explorer bundle footprint', () => { + it('emits the Scalar entry chunk, so the size assertion is not vacuous', () => { + const names = scalarAssets().map((asset) => asset.name); + expect( + names.some((name) => ENTRY_CHUNK.test(name)), + `no ScalarReference-*.js chunk in ${ASSETS_DIR}. Either the lazy import in ` + + 'ApiExplorer.jsx was renamed/removed, or Vite changed its chunk naming — in ' + + 'both cases isScalarAsset in this test no longer measures anything. Found: ' + + `${names.join(', ') || '(no matching assets)'}` + ).toBe(true); + }); + + it('keeps @scalar/api-reference-react under its bundle budget', () => { + const assets = scalarAssets(); + const total = assets.reduce((sum, asset) => sum + asset.bytes, 0); + const breakdown = assets.map((a) => `${a.name} ${a.bytes}B`).join(', '); + expect( + total, + `Scalar-attributable assets total ${total}B, over the ${SCALAR_BUDGET_BYTES}B ` + + 'budget. Re-measure and decide deliberately before raising it — see the ' + + `@scalar/api-reference-react entry in docs/DEPS.md. Breakdown: ${breakdown}` + ).toBeLessThanOrEqual(SCALAR_BUDGET_BYTES); + }); +}); diff --git a/docs/DEPS.md b/docs/DEPS.md index 2d60f7f960..4cbd93a85a 100644 --- a/docs/DEPS.md +++ b/docs/DEPS.md @@ -48,7 +48,7 @@ Before removing a Tier 3 candidate, run a transitive-dep check (`npm ls `). | `@dnd-kit/sortable` | 1 | KEEP | drag/drop | | | `@react-three/drei` | 1 | KEEP | CyberCity 3D | Three.js helpers | | `@react-three/fiber` | 1 | KEEP | CyberCity 3D | React renderer for Three | -| `@scalar/api-reference-react` | 1 | KEEP | Dev Tools → API Explorer | Interactive OpenAPI reference UI rendering | +| `@scalar/api-reference-react` | 2 | KEEP | Dev Tools → API Explorer | Interactive OpenAPI reference UI. Heaviest client dependency by far — 261/599 packages, 3.24 MB of dist assets. Kept for lack of a maintained lighter alternative; bounded by a budget test. See detailed finding | | `@xterm/xterm` | 1 | KEEP | browser terminal | | | `@xterm/addon-fit` | 1 | KEEP | xterm sizing | | | `@xterm/addon-web-links` | 1 | KEEP | xterm links | | @@ -96,6 +96,19 @@ Before removing a Tier 3 candidate, run a transitive-dep check (`npm ls `). - **Grep caveat for the next audit**: `server/services/legacyExport.js` contains a byte sequence that makes `file(1)` classify it as `data`, so plain `grep -r` **silently skips it** — a repo-wide dependency sweep must use `grep -ra`. That is exactly how the fourth import site was missed when this migration was first scoped; it surfaced only as an `ERR_MODULE_NOT_FOUND` in the suite. - **Re-audit trigger**: revisit if `@cantoo/pdf-lib` itself goes >12 months without a publish, or on any CVE against it. The fallback is the same shape as the swap in: another maintained fork, or upstream `pdf-lib` if it ever resumes releases. +### `@scalar/api-reference-react` — KEEP (Tier 2) + +- **Usage**: 1 import, in `client/src/components/api-explorer/ScalarReference.jsx`, which `client/src/pages/ApiExplorer.jsx` reaches through a `lazy()` dynamic import on one route (Dev Tools → API Explorer, the REST Reference tab). No other call site. +- **Measured footprint** (2026-09-02, fresh `npm run build --prefix client`): + - **Packages**: 261 of the client's 599 installed packages (44%) are reachable ONLY via Scalar — computed by walking each top-level dependency's transitive closure in `client/package-lock.json` and subtracting every closure that does not include Scalar. Scalar's own subtree is 282. The exclusive set includes an entire second UI framework (`vue`, `radix-vue`, `@headlessui/vue`, `@floating-ui/vue`, `vue-sonner`, `@unhead/vue`) and the Vercel AI SDK (`ai`, `@ai-sdk/gateway`, `@ai-sdk/provider`, `@ai-sdk/provider-utils`, `@ai-sdk/vue`). + - **Bundle**: 3.24 MB of dist assets against ~13.0 MB of built JS — `OperationBlock.vue-*.js` (2.21 MB, the single largest chunk in the app, roughly twice the whole three.js vendor bundle), `ScalarReference-*.js` (608 KB), `AgentScalarChatInterface.vue-*.js` (197 KB), `ScalarReference-*.css` (250 KB). +- **The AI SDK is a hard dependency, not an optional peer**: `@scalar/api-reference` depends on `@scalar/agent-chat`, whose own dependencies include `ai` and `@ai-sdk/vue`. `agent: { disabled: true }` in `ScalarReference.jsx` is a **runtime** config value, so Rollup cannot tree-shake on it — the agent chat interface is emitted as its own chunk regardless. Turning the feature off changes the UI, not the build. +- **What bounds the user-facing cost**: the `lazy()` import. None of this is in the initial payload; it downloads only when a developer opens the REST Reference tab. +- **Replacement complexity**: Complex, and every surveyed alternative is worse. `rapidoc` has had no publish since 2024-10 (trading a heavy maintained dependency for an unmaintained one), and `@stoplight/elements` is comparably large. Owning an OpenAPI renderer is a project of its own. +- **Decision**: KEEP, bounded by a test. What was missing here was measurement, not removal. +- **Regression cover**: `client/src/pages/ApiExplorer.bundle.test.js` sums the Scalar-attributable `client/dist/assets` files and asserts they stay under a **4.0 MB** budget (~23% headroom over the measured 3.24 MB). It skips itself when `client/dist` is absent, so the plain unit-test job stays green; CI runs it as its own step right after `npm run build --prefix client`, against a fresh build rather than a stale `dist/`. A companion assertion pins non-vacuity — it fails if no `ScalarReference-*.js` chunk is found, so a Vite chunk-naming change cannot turn the budget into a 0-byte pass. This is the only test in the client suite that looks at build output; every other one runs against source, so a version bump that doubles the chunk is otherwise completely unobserved. +- **Re-audit trigger**: revisit if the budget test fails (re-measure and decide deliberately — do not reflexively raise the number), if Scalar drops the `agent` config toggle, or if a maintained framework-free OpenAPI renderer appears. + ### `kokoro-js` — KEEP (Tier 2) - **Usage**: 1 dynamic import in `server/services/voice/tts-kokoro.js` (~80 LOC module). 3 call sites: `KokoroTTS.from_pretrained()`, `tts.generate(text, {voice, speed})`, `audio.toWav()`.