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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions client/src/components/api-explorer/ScalarReference.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
81 changes: 81 additions & 0 deletions client/src/pages/ApiExplorer.bundle.test.js
Original file line number Diff line number Diff line change
@@ -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-<hash>.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);
});
});
15 changes: 14 additions & 1 deletion docs/DEPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Before removing a Tier 3 candidate, run a transitive-dep check (`npm ls <pkg>`).
| `@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 | |
Expand Down Expand Up @@ -96,6 +96,19 @@ Before removing a Tier 3 candidate, run a transitive-dep check (`npm ls <pkg>`).
- **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()`.
Expand Down