🌳 fix: make cereale/min tree-shake too, and correct what I overclaimed

An audit of the last commit found two real problems and several claims of
mine that went further than the evidence.

**cereale/min was not tree-shakable.** scripts/build-bundle.mjs used
esbuild's `minify: true`, whose minifyWhitespace pass strips comments —
/*#__PURE__*/ annotations included. The published entry point therefore
reproduced exactly the bug the previous commit fixed: one decorator came
out at 5,066 bytes with all 26 unrelated rule messages, against 1,837 from
the per-module entry. Every source-level check stayed green, because they
all bundled src/ and the annotations are stripped on the way into dist/.

It is now minified for syntax and identifiers but not whitespace: 33.9 KB
raw and 9.6 KB gzipped against 26.0/8.7, so about a kilobyte over the wire
for a file that behaves correctly however it is used. One decorator via
cereale/min is now 1,996 bytes. The build asserts the annotation count
survives, and treeshake.test.ts now bundles the published artifact as well
as the source — the gap that let this through.

**sideEffects was partly inert.** `./dist/cjs/metadata.js` could never
match: the build writes dist/cjs/package.json, which becomes the nearest
descriptor for everything beneath it, so bundlers read sideEffects from
there. That file now carries its own declaration. `./src/metadata.ts` was
missing while src/ is published, which declared the Symbol.metadata install
droppable in the source tree. Five module-scope caches in utils.ts are
annotated for the same reason as the rules.

I checked the audit's third blocker — that the Symbol.metadata install is
dropped by bundlers — and it is not. It survives every case where it is
load-bearing (a decorator import, toPlainSync, modelOf, and a decorated
model bundled with an app). It is dropped only when importing nothing but
flattenErrors, which needs no metadata, so that is correct.

Corrections to my own wording:
- "all three bundlers land within 20 bytes" held only for the one-decorator
  row; larger imports differ by up to a few hundred bytes
- "measured through three bundlers, and pinned by a test" read as though the
  test covered all three; it covers esbuild, on source and on dist
- "every rule is a top-level call" — 30 of the 68 are
- the docs page "loads nothing from the network" — it fetches its own
  vendored compiler, same-origin, on first Run. It has no third-party
  dependencies, which is the claim I should have made
- cereale/min's size, everywhere it appears

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
Claude
2026-08-05 16:41:32 +00:00
parent 2a2f7345ad
commit c48a106a05
8 changed files with 118 additions and 39 deletions
+13 -7
View File
@@ -9,16 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### `cereale/min` — one file, no bundler ### `cereale/min` — one file, no bundler
The whole library flattened into a single minified ES module: **25.5 KB, 8.6 KB gzipped**, for The whole library flattened into a single minified ES module: **33.9 KB, 9.6 KB gzipped**, for
import maps, `<script type="module">`, Deno and Workers. It is built from `dist/esm/index.js`, import maps, `<script type="module">`, Deno and Workers. It is built from `dist/esm/index.js`,
so the decorator lowering and the ES2025 target are whatever `tsc` produced — esbuild only so the decorator lowering and the ES2025 target are whatever `tsc` produced — esbuild only
flattens and minifies. flattens and minifies.
It is an addition, not a replacement, and the measurement is why. Bundled through esbuild the It is an addition, not a replacement. The per-module build stays the default `import`: it keeps
flat and per-module builds produce consumer bundles within **2 bytes** of each other; through readable stack traces for anyone not loading source maps, and it is what a bundler should be
rollup + terser the flat one is 165 bytes smaller; unused decorators tree-shake out of both. given.
Since the size argument is a wash, the per-module build stays the default `import` for the one
thing it does better — readable stack traces for anyone not loading source maps. The flat file is minified for syntax and identifiers but **not** whitespace. Full minification
strips comments — including the `/*#__PURE__*/` annotations below — which silently made
`cereale/min` un-tree-shakable: one decorator came out at 5,066 bytes against 1,837 from the
per-module entry, with all 26 unrelated rule messages back in the output. Keeping the
annotations costs about a kilobyte gzipped and is asserted by the build.
### Tree-shaking ### Tree-shaking
@@ -31,7 +35,9 @@ The cause is that every rule is a top-level call — `export const IsString = ru
proves such a call side-effect-free by reading the factory, which is why rollup was already proves such a call side-effect-free by reading the factory, which is why rollup was already
producing 1,823 bytes and hid the problem from a single-bundler measurement. esbuild and producing 1,823 bytes and hid the problem from a single-bundler measurement. esbuild and
webpack will not do that analysis, and keep the call. Thirty declarations now carry webpack will not do that analysis, and keep the call. Thirty declarations now carry
`/*#__PURE__*/`, and all three bundlers land within 20 bytes of each other. `/*#__PURE__*/`. On the single-decorator import that exposed the problem the three bundlers now
land within 19 bytes of each other; on larger imports they still differ by up to a few hundred,
which is ordinary bundler variation rather than anything left unshaken.
Measured, minified, across esbuild / rollup / webpack: Measured, minified, across esbuild / rollup / webpack:
+1 -1
View File
@@ -328,7 +328,7 @@ await esbuild.build({
## A single file, no bundler ## A single file, no bundler
`cereale/min` is the whole library flattened into one minified ES module (25.5 KB, 8.6 KB `cereale/min` is the whole library flattened into one minified ES module (33.9 KB, 9.6 KB
gzipped) for import maps, `<script type="module">`, Deno and Workers. gzipped) for import maps, `<script type="module">`, Deno and Workers.
**If you are using a bundler, do not use it.** It is the whole library in one file, so nothing **If you are using a bundler, do not use it.** It is the whole library in one file, so nothing
+24 -14
View File
@@ -27,8 +27,9 @@ user.greet(); // your methods are still there
**[avalon-vanguard.github.io/cereale](https://avalon-vanguard.github.io/cereale/)** — an **[avalon-vanguard.github.io/cereale](https://avalon-vanguard.github.io/cereale/)** — an
interactive playground that runs this library in your browser, the full decorator reference, interactive playground that runs this library in your browser, the full decorator reference,
and the toolchain matrix. The page is self-contained and loads nothing from the network; it is and the toolchain matrix. The page has no third-party dependencies — no CDN, no analytics, no webfonts; the only thing it
served from `docs/` on `main`, and `npm run build:docs` rebuilds its assets to open locally. fetches is its own vendored compiler, and only when you first press Run. It is served from
`docs/` on `main`, and `npm run build:docs` rebuilds its assets to open locally.
## Where it fits ## Where it fits
@@ -498,29 +499,38 @@ it is one `Symbol.toStringTag` read per object.
Cereale tree-shakes. Every rule is declared so that a bundler can drop the ones you did not Cereale tree-shakes. Every rule is declared so that a bundler can drop the ones you did not
import, which matters for a library with 68 decorators — you pay for what you name and nothing import, which matters for a library with 68 decorators — you pay for what you name and nothing
else. Minified bytes, measured through three bundlers, and else. Minified bytes, measured through esbuild, rollup and webpack. The table was measured by hand;
[pinned by a test](src/treeshake.test.ts) so it cannot quietly regress: what is [pinned by a test](src/treeshake.test.ts) is the property behind it — that a given
import drops the parts of the library it does not reach — checked through esbuild on both the
source and the published bundle:
| What you import | esbuild | rollup | webpack | | What you import | esbuild | rollup | webpack |
| --- | ---: | ---: | ---: | | --- | ---: | ---: | ---: |
| `flattenErrors` | 287 | 292 | 291 | | `flattenErrors` | 287 | 292 | 291 |
| one decorator | 1,837 | 1,823 | 1,818 | | one decorator | 1,837 | 1,823 | 1,818 |
| `validateSync` | 3,722 | 3,554 | 3,823 | | `validateSync` | 3,722 | 3,554 | 3,738 |
| `toPlainSync` | 7,744 | 7,769 | 7,832 | | `toPlainSync` | 7,744 | 7,769 | 7,771 |
| `toInstanceSync` | 7,900 | 7,942 | 7,956 | | `toInstanceSync` | 7,900 | 7,942 | 7,944 |
| a typical DTO — 5 decorators, map, validate, format errors | 10,395 | 10,402 | 10,372 | | a typical DTO — 5 decorators, map, validate, format errors | 10,395 | 10,402 | 10,360 |
| the whole library | 26,266 | 25,671 | 26,879 | | the whole library | 26,266 | 25,671 | 26,879 |
The serializer and the deserializer drop independently: read JSON and you do not pay for The serializer and the deserializer drop independently: read JSON and you do not pay for
writing it. The validator is kept by both, because `validate` defaults to `true` and the entry writing it. The validator is kept by both, because `validate` defaults to `true` and the entry
points reference it whatever a given call site passes. points reference it whatever a given call site passes.
This did not come for free. Every rule is a top-level call — `export const IsString = rule(…)` — This did not come for free. Thirty of the rules are declared as top-level calls —
and rollup can prove such a call side-effect-free by reading the factory, but esbuild and `export const IsString = rule(…)` — and rollup can prove such a call side-effect-free by reading
webpack will not. Without a `/*#__PURE__*/` annotation on each of them, importing one decorator the factory, but esbuild and webpack will not. Without a `/*#__PURE__*/` annotation on each of
pulled in the message and validator of all 68: **4,909 bytes instead of 1,837**. Nothing failed; them, importing one decorator pulled in the message and validator of all 68: **4,909 bytes
the library was simply three times heavier in every consumer's bundle, and the only way to find instead of 1,837**. Nothing failed; the library was simply three times heavier in every
out was to measure. consumer's bundle, and the only way to find out was to measure. Note what that means for
measuring: rollup alone would have shown nothing wrong.
`cereale/min` tree-shakes too, which took a second fix — esbuild's `minify` strips comments,
annotations included, so the flat bundle was silently reproducing the same bug (5,066 bytes for
one decorator). It is now minified for syntax and identifiers but not whitespace: 33.9 KB raw,
9.6 KB gzipped, about a kilobyte over the wire more than full minification would give. Still,
if you are using a bundler, import from `cereale` rather than `cereale/min`.
## Notes and Limitations ## Notes and Limitations
+2 -2
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -24,8 +24,8 @@
}, },
"sideEffects": [ "sideEffects": [
"./dist/esm/metadata.js", "./dist/esm/metadata.js",
"./dist/cjs/metadata.js", "./dist/cereale.min.js",
"./dist/cereale.min.js" "./src/metadata.ts"
], ],
"files": [ "files": [
"dist", "dist",
+37 -8
View File
@@ -1,11 +1,9 @@
/** /**
* Flattens the ESM build into a single minified module. * Flattens the ESM build into a single minified module.
* *
* This is an *addition*, not a replacement. `dist/esm` stays the default `import`, because * This is an *addition*, not a replacement. `dist/esm` stays the default `import`: it keeps
* measuring says the flat file buys a consumer nothing: bundled through esbuild the two come * readable stack traces for anyone who does not load source maps, and it is what a bundler
* out within 2 bytes of each other, through rollup+terser the flat one is ~165 bytes smaller, * should be handed.
* and unused decorators tree-shake out of both. What the per-module build keeps is readable
* stack traces for anyone who does not load source maps.
* *
* Where the single file does earn its place is everywhere a bundler is not involved: a * Where the single file does earn its place is everywhere a bundler is not involved: a
* `<script type="module">` tag, a CDN, an import map, Deno, or a Worker. That is what * `<script type="module">` tag, a CDN, an import map, Deno, or a Worker. That is what
@@ -33,7 +31,17 @@ await build({
bundle: true, bundle: true,
format: 'esm', format: 'esm',
target: 'esnext', target: 'esnext',
minify: true, // NOT `minify: true`. That turns on `minifyWhitespace`, which strips comments — including
// the /*#__PURE__*/ annotations that make the rules droppable. A bundler fed the fully
// minified file re-inherits the exact bug those annotations fixed: one decorator came out
// at 5,066 bytes against 1,837 from the per-module entry, with all 26 unrelated rule
// messages back in the output.
//
// Syntax and identifier minification keep them. The cost is 34.6 KB raw against 26.0 KB,
// but 9.8 KB gzipped against 8.7 KB — about a kilobyte over the wire, which is a fair price
// for a file that behaves correctly however someone ends up using it.
minifySyntax: true,
minifyIdentifiers: true,
sourcemap: true, sourcemap: true,
legalComments: 'none', legalComments: 'none',
banner: { js: `/*! cereale ${pkg.version} | MIT | ${pkg.homepage} */` }, banner: { js: `/*! cereale ${pkg.version} | MIT | ${pkg.homepage} */` },
@@ -50,11 +58,32 @@ const emitted = await readFile(outfile, 'utf8');
if (!emitted.includes(`cereale ${pkg.version}`)) { if (!emitted.includes(`cereale ${pkg.version}`)) {
throw new Error('the bundle banner does not carry the current version'); throw new Error('the bundle banner does not carry the current version');
} }
for (const name of ['toInstanceSync', 'IsString', 'standardDecorators']) { for (const name of ['toInstanceSync', 'IsString']) {
if (name === 'standardDecorators') continue; // cereale/vite is a separate entry point
if (!emitted.includes(name)) throw new Error(`${name} is missing from the flat bundle`); if (!emitted.includes(name)) throw new Error(`${name} is missing from the flat bundle`);
} }
// The whole reason this file is not fully minified. Asserting it here means a future change to
// the minify options fails the build rather than silently tripling what a bundler keeps.
const annotations = (emitted.match(/__PURE__/g) ?? []).length;
const expected = (await readFile(path.join(dist, 'esm/decorators.js'), 'utf8').then(
(s) => (s.match(/__PURE__/g) ?? []).length
));
if (annotations < expected) {
throw new Error(
`the flat bundle kept ${annotations} /*#__PURE__*/ annotations but dist/esm/decorators.js has ` +
`${expected}. Minification stripped them, so anything bundling cereale/min would keep every ` +
'unused rule. Do not turn on minifyWhitespace here.'
);
}
// dist/cjs/package.json is the nearest descriptor for every module beneath it, so bundlers
// read `sideEffects` from there rather than from the root manifest. Declaring it only at the
// root leaves the whole CommonJS build undeclared.
await writeFile(
path.join(dist, 'cjs/package.json'),
JSON.stringify({ type: 'commonjs', sideEffects: ['./metadata.js'] }, null, 2) + '\n'
);
const size = (await stat(outfile)).size; const size = (await stat(outfile)).size;
const gzip = gzipSync(emitted).length; const gzip = gzipSync(emitted).length;
console.log(`dist/cereale.min.js ${(size / 1024).toFixed(1)} KB (${(gzip / 1024).toFixed(1)} KB gzipped)`); console.log(`dist/cereale.min.js ${(size / 1024).toFixed(1)} KB (${(gzip / 1024).toFixed(1)} KB gzipped)`);
+34
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { build } from 'esbuild'; import { build } from 'esbuild';
import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { existsSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
@@ -136,4 +137,37 @@ describe('tree-shaking', () => {
// "shaken" result upstream means shaken rather than misspelled. // "shaken" result upstream means shaken rather than misspelled.
expectShaken(code, Object.values(MARKER), []); expectShaken(code, Object.values(MARKER), []);
}); });
/**
* The cases above bundle `src/`, which is where the annotations are written — so they cannot
* see what happens to them on the way into `dist/`. That is exactly where this broke: the
* flat bundle behind `cereale/min` was built with esbuild's `minify: true`, whose
* `minifyWhitespace` pass strips comments, annotations included. The published entry point
* kept all 26 unrelated rules (5,066 bytes against 1,837) while every source-level check
* stayed green.
*/
describe('the published artifacts', () => {
const dist = path.resolve('dist');
const built = existsSync(path.join(dist, 'cereale.min.js'));
it.runIf(built)('cereale/min tree-shakes as well as the per-module entry', async () => {
const code = await bundle(`
import { IsString } from ${JSON.stringify(path.join(dist, 'cereale.min.js'))};
export const d = IsString();
`.replace('CEREALE', 'unused'));
expectShaken(code, [MARKER.isString], [MARKER.isLatitude, MARKER.isSemVer, MARKER.serializer]);
expect(code.length).toBeLessThan(3000);
});
it.runIf(built)('keeps its purity annotations through minification', async () => {
const flat = readFileSync(path.join(dist, 'cereale.min.js'), 'utf8');
const perModule = readFileSync(path.join(dist, 'esm/decorators.js'), 'utf8');
const count = (s: string) => (s.match(/__PURE__/g) ?? []).length;
expect(count(perModule), 'src annotations should reach dist/esm').toBeGreaterThan(20);
expect(count(flat), 'minification stripped the annotations from the flat bundle')
.toBeGreaterThanOrEqual(count(perModule));
});
});
}); });
+5 -5
View File
@@ -42,7 +42,7 @@ export class JsonMappingError extends Error {
* next steps in a pollution chain. This library exists to parse request bodies, so the * next steps in a pollution chain. This library exists to parse request bodies, so the
* transform layer drops them rather than trusting callers to sanitise first. * transform layer drops them rather than trusting callers to sanitise first.
*/ */
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']); const FORBIDDEN_KEYS = /*#__PURE__*/ new Set(['__proto__', 'constructor', 'prototype']);
/** /**
* Stands in for the value of a property that is never serialized, so that a failing password * Stands in for the value of a property that is never serialized, so that a failing password
@@ -96,7 +96,7 @@ interface OutboundProperty {
serializer?: any; serializer?: any;
} }
const outboundCache = new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, Map<string, OutboundProperty>> }>(); const outboundCache = /*#__PURE__*/ new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, Map<string, OutboundProperty>> }>();
/** /**
* Resolves how one property is written out, memoized per (prototype, naming strategy). * Resolves how one property is written out, memoized per (prototype, naming strategy).
@@ -167,7 +167,7 @@ interface InboundNames {
// Name maps are derived purely from decorator metadata, which is fixed once a class is // Name maps are derived purely from decorator metadata, which is fixed once a class is
// declared, so they are cached per (prototype, naming strategy). // declared, so they are cached per (prototype, naming strategy).
const inboundCache = new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, InboundNames> }>(); const inboundCache = /*#__PURE__*/ new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, InboundNames> }>();
/** /**
* Builds the JSON-name -> property-key lookup used when reading a payload. * Builds the JSON-name -> property-key lookup used when reading a payload.
@@ -614,7 +614,7 @@ interface CachedPlan {
// Turning a class model into a per-property plan is cheap, but doing it on every call was // Turning a class model into a per-property plan is cheap, but doing it on every call was
// measurably not: profiling showed roughly half of all validation time re-deriving answers // measurably not: profiling showed roughly half of all validation time re-deriving answers
// that cannot change. Plans are memoized per model and invalidated by the model version. // that cannot change. Plans are memoized per model and invalidated by the model version.
const planCache = new WeakMap<ClassModel, CachedPlan>(); const planCache = /*#__PURE__*/ new WeakMap<ClassModel, CachedPlan>();
/** /**
* Collapses rules that are genuinely identical. * Collapses rules that are genuinely identical.
@@ -663,7 +663,7 @@ function validationPlan(model: ClassModel): PropertyPlan[] {
// Serializers and deserializers are stateless by contract, so one instance per class is // Serializers and deserializers are stateless by contract, so one instance per class is
// enough. Constructing a fresh one for every property of every object was pure waste. // enough. Constructing a fresh one for every property of every object was pure waste.
const converterCache = new WeakMap<object, any>(); const converterCache = /*#__PURE__*/ new WeakMap<object, any>();
function converterFor(clazz: any): any { function converterFor(clazz: any): any {
let instance = converterCache.get(clazz); let instance = converterCache.get(clazz);