From ca90861880543a9019506f52f85b67227821c345 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Wed, 2 Sep 2026 06:21:45 +0000 Subject: [PATCH 1/2] deps: bound the @scalar/api-reference-react bundle footprint with a budget test (#5677) Scalar powers one developer-facing route (Dev Tools -> API Explorer) but is the client's heaviest dependency by a wide margin: 261 of 599 installed packages are reachable only through it, and it contributes 3.24 MB of dist assets against ~13.0 MB of built JS -- including a second UI framework (Vue 3) and the Vercel AI SDK, reached as a hard dependency via @scalar/agent-chat. `agent: { disabled: true }` is a runtime toggle, so Rollup still emits the agent chat chunk. Nothing measured any of this, so a routine version bump could grow it without a signal. Keeps the dependency -- the surveyed alternatives are unmaintained (rapidoc, last published 2024-10) or comparably large -- and adds the measurement that was missing: a 4.0 MB budget over the Scalar-attributable dist assets, checked in CI right after the client build so it sees a fresh build rather than a stale dist/. The test skips itself when client/dist is absent, and a companion assertion fails if no ScalarReference-*.js chunk is found, so a Vite chunk-naming change cannot turn the budget into a 0-byte pass. Reclassifies the DEPS.md row from Tier 1 to Tier 2 with the measured numbers and a re-audit trigger. --- .github/workflows/ci.yml | 7 ++ .../api-explorer/ScalarReference.jsx | 14 ++++ client/src/pages/ApiExplorer.bundle.test.js | 73 +++++++++++++++++++ docs/DEPS.md | 15 +++- 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 client/src/pages/ApiExplorer.bundle.test.js 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..65aaf88a08 --- /dev/null +++ b/client/src/pages/ApiExplorer.bundle.test.js @@ -0,0 +1,73 @@ +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 (`*.vue-`). +// Nothing else in the client is authored in Vue, so `.vue-` is Scalar-exclusive. +const SCALAR_ASSET = /(Scalar|\.vue-)[^/]*\.(js|css)$/; + +// 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$/; + +// Vitest's project root is client/, so cwd is client/ under `npm test --prefix client`. +// `import.meta.url` is not usable here: the jsdom environment rewrites it to an http URL. +const ASSETS_DIR = resolve(process.cwd(), 'dist/assets'); + +const scalarAssets = () => readdirSync(ASSETS_DIR) + .filter((name) => SCALAR_ASSET.test(name)) + .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 SCALAR_ASSET 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()`. From 8d8edbad9edaadf23daadad2a1d9e5b875f03c23 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Wed, 2 Sep 2026 06:24:09 +0000 Subject: [PATCH 2/2] test: make the Scalar bundle budget robust to cwd and Vue vendor chunk splits (#5677) Local review flagged two ways the budget could go quiet instead of failing: resolving dist/assets from cwd alone skipped the whole suite when a runner is pointed at the client project from the repo root, and matching Vue chunks only after a dot would miss a vendor chunk if Rollup ever splits the Vue runtime or radix-vue out on its own. Try both dist paths, and match vue- at a name boundary. --- client/src/pages/ApiExplorer.bundle.test.js | 24 ++++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/client/src/pages/ApiExplorer.bundle.test.js b/client/src/pages/ApiExplorer.bundle.test.js index 65aaf88a08..d9241b48d7 100644 --- a/client/src/pages/ApiExplorer.bundle.test.js +++ b/client/src/pages/ApiExplorer.bundle.test.js @@ -24,9 +24,13 @@ import { describe, expect, it } from 'vitest'; 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 (`*.vue-`). -// Nothing else in the client is authored in Vue, so `.vue-` is Scalar-exclusive. -const SCALAR_ASSET = /(Scalar|\.vue-)[^/]*\.(js|css)$/; +// 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 @@ -34,12 +38,16 @@ const SCALAR_ASSET = /(Scalar|\.vue-)[^/]*\.(js|css)$/; // and report a 0-byte pass — this assertion fails loudly instead. const ENTRY_CHUNK = /^ScalarReference-[^/]*\.js$/; -// Vitest's project root is client/, so cwd is client/ under `npm test --prefix client`. -// `import.meta.url` is not usable here: the jsdom environment rewrites it to an http URL. -const ASSETS_DIR = resolve(process.cwd(), 'dist/assets'); +// `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((name) => SCALAR_ASSET.test(name)) + .filter(isScalarAsset) .map((name) => ({ name, bytes: statSync(join(ASSETS_DIR, name)).size })) .sort((a, b) => b.bytes - a.bytes); @@ -54,7 +62,7 @@ describe.skipIf(!hasBuild)('API Explorer bundle footprint', () => { 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 SCALAR_ASSET in this test no longer measures anything. Found: ' + + 'both cases isScalarAsset in this test no longer measures anything. Found: ' + `${names.join(', ') || '(no matching assets)'}` ).toBe(true); });