🌳 perf: make the library actually tree-shakable
Importing one decorator pulled in the message and validator of all 68 — 4,909 bytes instead of 1,837 through esbuild, 4,823 instead of 1,818 through webpack. Nothing failed and nothing warned. The library was simply about three times heavier than it needed to be in every consumer's bundle. Every rule is a top-level call: `export const IsString = rule(...)`. rollup proves such a call side-effect-free by reading the factory, which is why it was already emitting 1,823 bytes — and why a single-bundler measurement would have shown no problem at all. esbuild and webpack do not do that analysis and keep the call. Thirty declarations now carry /*#__PURE__*/, which tsc preserves into the ESM emit, and all three bundlers now land within 20 bytes of each other. Measured, minified, esbuild / rollup / webpack: flattenErrors 287 / 292 / 291 one decorator 1837 / 1823 / 1818 validateSync 3722 / 3554 / 3823 toPlainSync 7744 / 7769 / 7832 toInstanceSync 7900 / 7942 / 7956 a typical DTO 10395 / 10402 / 10372 everything 26266 / 25671 / 26879 The serializer and deserializer drop independently — read JSON and you do not pay for writing it. Both mapping entry points keep the validator, because `validate` defaults to true and that is a real reference rather than a missed optimisation. The annotations are a promise to the bundler, so I checked the three factories they cover: rule, pattern and affix each return a closure and touch nothing outside themselves. A false promise here would mean silent deletion in someone else's production build. src/treeshake.test.ts pins the property. It asserts content rather than only bytes — it names the rules that must not appear — and one case asserts everything IS present when everything is used, so a "shaken" result cannot come from a bundle that failed to build. That mattered: two earlier passes at this measurement reported a clean sweep of shaken symbols because rollup had failed to resolve its entry and grep was reading missing files as absence. Strip the annotations and the test fails with `"must be a latitude" should have been shaken out`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
@@ -20,6 +20,40 @@ rollup + terser the flat one is 165 bytes smaller; unused decorators tree-shake
|
|||||||
Since the size argument is a wash, the per-module build stays the default `import` for the one
|
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.
|
thing it does better — readable stack traces for anyone not loading source maps.
|
||||||
|
|
||||||
|
### Tree-shaking
|
||||||
|
|
||||||
|
Importing one decorator pulled in the message and validator of all 68. **4,909 bytes instead of
|
||||||
|
1,837** through esbuild, 4,823 instead of 1,818 through webpack. Nothing failed and nothing
|
||||||
|
warned; the library was simply about three times heavier than it needed to be in every
|
||||||
|
consumer's bundle.
|
||||||
|
|
||||||
|
The cause is that every rule is a top-level call — `export const IsString = rule(…)`. rollup
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
|
||||||
|
Measured, minified, across esbuild / rollup / webpack:
|
||||||
|
|
||||||
|
| What you import | esbuild | rollup | webpack |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| `flattenErrors` | 287 | 292 | 291 |
|
||||||
|
| one decorator | 1,837 | 1,823 | 1,818 |
|
||||||
|
| `validateSync` | 3,722 | 3,554 | 3,823 |
|
||||||
|
| `toPlainSync` | 7,744 | 7,769 | 7,832 |
|
||||||
|
| `toInstanceSync` | 7,900 | 7,942 | 7,956 |
|
||||||
|
| a typical DTO | 10,395 | 10,402 | 10,372 |
|
||||||
|
| everything | 26,266 | 25,671 | 26,879 |
|
||||||
|
|
||||||
|
The serializer and deserializer drop independently. The validator is kept by both mapping
|
||||||
|
entry points because `validate` defaults to `true`, which is a real reference rather than a
|
||||||
|
missed optimisation.
|
||||||
|
|
||||||
|
`src/treeshake.test.ts` pins it. The assertions are mostly about content rather than bytes — it
|
||||||
|
names the rules that must not appear — and one case asserts that everything IS present when
|
||||||
|
everything is used, so a "shaken" result cannot come from a bundle that failed to build. Strip
|
||||||
|
the annotations and it fails with `"must be a latitude" should have been shaken out`.
|
||||||
|
|
||||||
### Frameworks
|
### Frameworks
|
||||||
|
|
||||||
[FRAMEWORKS.md](FRAMEWORKS.md) — a setup recipe for each, every one run before it was written,
|
[FRAMEWORKS.md](FRAMEWORKS.md) — a setup recipe for each, every one run before it was written,
|
||||||
|
|||||||
+6
-4
@@ -329,10 +329,12 @@ 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 (25.5 KB, 8.6 KB
|
||||||
gzipped) for import maps, `<script type="module">`, Deno and Workers. Bundler users should keep
|
gzipped) for import maps, `<script type="module">`, Deno and Workers.
|
||||||
the default entry point: measured through esbuild and through rollup + terser, the two produce
|
|
||||||
consumer bundles within a couple of hundred bytes of each other and tree-shake identically, and
|
**If you are using a bundler, do not use it.** It is the whole library in one file, so nothing
|
||||||
the per-module build keeps readable stack traces.
|
can be dropped from it. The default entry point tree-shakes — one decorator costs about 1.8 KB
|
||||||
|
against 26 KB for everything — and produces a smaller result in any real application. See
|
||||||
|
[Bundle size](README.md#bundle-size).
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
|
|||||||
@@ -494,6 +494,34 @@ costs a few percent on `toPlain`, which is the price of never emitting `{}` wher
|
|||||||
used to be; primitives are handled inline and the check is skipped for arrays and dates, so
|
used to be; primitives are handled inline and the check is skipped for arrays and dates, so
|
||||||
it is one `Symbol.toStringTag` read per object.
|
it is one `Symbol.toStringTag` read per object.
|
||||||
|
|
||||||
|
## Bundle size
|
||||||
|
|
||||||
|
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
|
||||||
|
else. Minified bytes, measured through three bundlers, and
|
||||||
|
[pinned by a test](src/treeshake.test.ts) so it cannot quietly regress:
|
||||||
|
|
||||||
|
| What you import | esbuild | rollup | webpack |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| `flattenErrors` | 287 | 292 | 291 |
|
||||||
|
| one decorator | 1,837 | 1,823 | 1,818 |
|
||||||
|
| `validateSync` | 3,722 | 3,554 | 3,823 |
|
||||||
|
| `toPlainSync` | 7,744 | 7,769 | 7,832 |
|
||||||
|
| `toInstanceSync` | 7,900 | 7,942 | 7,956 |
|
||||||
|
| a typical DTO — 5 decorators, map, validate, format errors | 10,395 | 10,402 | 10,372 |
|
||||||
|
| the whole library | 26,266 | 25,671 | 26,879 |
|
||||||
|
|
||||||
|
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
|
||||||
|
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(…)` —
|
||||||
|
and rollup can prove such a call side-effect-free by reading the factory, but esbuild and
|
||||||
|
webpack will not. Without a `/*#__PURE__*/` annotation on each of them, importing one decorator
|
||||||
|
pulled in the message and validator of all 68: **4,909 bytes instead of 1,837**. Nothing failed;
|
||||||
|
the library was simply three times heavier in every consumer's bundle, and the only way to find
|
||||||
|
out was to measure.
|
||||||
|
|
||||||
## Notes and Limitations
|
## Notes and Limitations
|
||||||
|
|
||||||
- **Rules are checked, types are not inferred.** You write both the field type and the rule;
|
- **Rules are checked, types are not inferred.** You write both the field type and the rule;
|
||||||
|
|||||||
+30
-30
@@ -262,17 +262,17 @@ export function ValidateNested(options?: ValidationOptions): FieldDecorator<obje
|
|||||||
// Type rules
|
// Type rules
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const IsString: Rule<string> = rule('isString', v => typeof v === 'string', p => `${p} must be a string`);
|
export const IsString: Rule<string> = /*#__PURE__*/ rule('isString', v => typeof v === 'string', p => `${p} must be a string`);
|
||||||
export const IsNumber: Rule<number> = rule('isNumber', v => typeof v === 'number' && !isNaN(v), p => `${p} must be a number`);
|
export const IsNumber: Rule<number> = /*#__PURE__*/ rule('isNumber', v => typeof v === 'number' && !isNaN(v), p => `${p} must be a number`);
|
||||||
export const IsInt: Rule<number> = rule('isInt', v => Number.isInteger(v), p => `${p} must be an integer`);
|
export const IsInt: Rule<number> = /*#__PURE__*/ rule('isInt', v => Number.isInteger(v), p => `${p} must be an integer`);
|
||||||
export const IsBoolean: Rule<boolean> = rule('isBoolean', v => typeof v === 'boolean', p => `${p} must be a boolean`);
|
export const IsBoolean: Rule<boolean> = /*#__PURE__*/ rule('isBoolean', v => typeof v === 'boolean', p => `${p} must be a boolean`);
|
||||||
export const IsBigInt: Rule<bigint> = rule('isBigInt', v => typeof v === 'bigint', p => `${p} must be a bigint`);
|
export const IsBigInt: Rule<bigint> = /*#__PURE__*/ rule('isBigInt', v => typeof v === 'bigint', p => `${p} must be a bigint`);
|
||||||
export const IsDate: Rule<Date> = rule('isDate', v => v instanceof Date && !isNaN(v.getTime()), p => `${p} must be a valid Date object`);
|
export const IsDate: Rule<Date> = /*#__PURE__*/ rule('isDate', v => v instanceof Date && !isNaN(v.getTime()), p => `${p} must be a valid Date object`);
|
||||||
export const IsObject: Rule<object> = rule('isObject', v => typeof v === 'object' && v !== null && !Array.isArray(v), p => `${p} must be an object`);
|
export const IsObject: Rule<object> = /*#__PURE__*/ rule('isObject', v => typeof v === 'object' && v !== null && !Array.isArray(v), p => `${p} must be an object`);
|
||||||
|
|
||||||
export const IsDefined: Rule<unknown> = rule('isDefined', v => v !== null && v !== undefined, p => `${p} should not be null or undefined`);
|
export const IsDefined: Rule<unknown> = /*#__PURE__*/ rule('isDefined', v => v !== null && v !== undefined, p => `${p} should not be null or undefined`);
|
||||||
export const IsNotEmpty: Rule<unknown> = rule('isNotEmpty', v => v !== null && v !== undefined && v !== '', p => `${p} should not be empty`);
|
export const IsNotEmpty: Rule<unknown> = /*#__PURE__*/ rule('isNotEmpty', v => v !== null && v !== undefined && v !== '', p => `${p} should not be empty`);
|
||||||
export const IsEmpty: Rule<unknown> = rule('isEmpty', v => {
|
export const IsEmpty: Rule<unknown> = /*#__PURE__*/ rule('isEmpty', v => {
|
||||||
if (v === null || v === undefined || v === '') return true;
|
if (v === null || v === undefined || v === '') return true;
|
||||||
if (Array.isArray(v)) return v.length === 0;
|
if (Array.isArray(v)) return v.length === 0;
|
||||||
if (typeof v === 'object') return Object.keys(v).length === 0;
|
if (typeof v === 'object') return Object.keys(v).length === 0;
|
||||||
@@ -301,8 +301,8 @@ export function Max(max: number, options?: ValidationOptions): unknown {
|
|||||||
}), options);
|
}), options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Positive: Rule<number> = rule('positive', v => typeof v === 'number' && v > 0, p => `${p} must be positive`);
|
export const Positive: Rule<number> = /*#__PURE__*/ rule('positive', v => typeof v === 'number' && v > 0, p => `${p} must be positive`);
|
||||||
export const Negative: Rule<number> = rule('negative', v => typeof v === 'number' && v < 0, p => `${p} must be negative`);
|
export const Negative: Rule<number> = /*#__PURE__*/ rule('negative', v => typeof v === 'number' && v < 0, p => `${p} must be negative`);
|
||||||
|
|
||||||
export function IsDivisibleBy(divisor: number, options: EachValidationOptions): Each<number>;
|
export function IsDivisibleBy(divisor: number, options: EachValidationOptions): Each<number>;
|
||||||
export function IsDivisibleBy(divisor: number, options?: ValidationOptions): One<number>;
|
export function IsDivisibleBy(divisor: number, options?: ValidationOptions): One<number>;
|
||||||
@@ -315,13 +315,13 @@ export function IsDivisibleBy(divisor: number, options?: ValidationOptions): unk
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** An integer in 0..65535. Accepts a number or a numeric string. */
|
/** An integer in 0..65535. Accepts a number or a numeric string. */
|
||||||
export const IsPort: Rule<number | string> = rule('isPort', v => {
|
export const IsPort: Rule<number | string> = /*#__PURE__*/ rule('isPort', v => {
|
||||||
const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v;
|
const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v;
|
||||||
return typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 65535;
|
return typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 65535;
|
||||||
}, p => `${p} must be a valid port number`);
|
}, p => `${p} must be a valid port number`);
|
||||||
|
|
||||||
export const IsLatitude: Rule<number> = rule('isLatitude', v => typeof v === 'number' && Number.isFinite(v) && v >= -90 && v <= 90, p => `${p} must be a latitude between -90 and 90`);
|
export const IsLatitude: Rule<number> = /*#__PURE__*/ rule('isLatitude', v => typeof v === 'number' && Number.isFinite(v) && v >= -90 && v <= 90, p => `${p} must be a latitude between -90 and 90`);
|
||||||
export const IsLongitude: Rule<number> = rule('isLongitude', v => typeof v === 'number' && Number.isFinite(v) && v >= -180 && v <= 180, p => `${p} must be a longitude between -180 and 180`);
|
export const IsLongitude: Rule<number> = /*#__PURE__*/ rule('isLongitude', v => typeof v === 'number' && Number.isFinite(v) && v >= -180 && v <= 180, p => `${p} must be a longitude between -180 and 180`);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Strings
|
// Strings
|
||||||
@@ -358,22 +358,22 @@ export function Length(min: number, max?: number, options?: ValidationOptions):
|
|||||||
}), options);
|
}), options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Email: Rule<string> = pattern('isEmail', /^[^\s@]+@[^\s@]+\.[^\s@]+$/, p => `${p} must be a valid email`);
|
export const Email: Rule<string> = /*#__PURE__*/ pattern('isEmail', /^[^\s@]+@[^\s@]+\.[^\s@]+$/, p => `${p} must be a valid email`);
|
||||||
export const IsAlpha: Rule<string> = pattern('isAlpha', /^[A-Za-z]+$/, p => `${p} must contain only letters`);
|
export const IsAlpha: Rule<string> = /*#__PURE__*/ pattern('isAlpha', /^[A-Za-z]+$/, p => `${p} must contain only letters`);
|
||||||
export const IsAlphanumeric: Rule<string> = pattern('isAlphanumeric', /^[A-Za-z0-9]+$/, p => `${p} must contain only letters and numbers`);
|
export const IsAlphanumeric: Rule<string> = /*#__PURE__*/ pattern('isAlphanumeric', /^[A-Za-z0-9]+$/, p => `${p} must contain only letters and numbers`);
|
||||||
export const IsSemVer: Rule<string> = pattern('isSemVer', /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/, p => `${p} must be a valid semantic version`);
|
export const IsSemVer: Rule<string> = /*#__PURE__*/ pattern('isSemVer', /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/, p => `${p} must be a valid semantic version`);
|
||||||
export const IsHexColor: Rule<string> = pattern('isHexColor', /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i, p => `${p} must be a hex color`);
|
export const IsHexColor: Rule<string> = /*#__PURE__*/ pattern('isHexColor', /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i, p => `${p} must be a hex color`);
|
||||||
|
|
||||||
export const IsLowercase: Rule<string> = rule('isLowercase', v => typeof v === 'string' && v === v.toLowerCase(), p => `${p} must be lowercase`);
|
export const IsLowercase: Rule<string> = /*#__PURE__*/ rule('isLowercase', v => typeof v === 'string' && v === v.toLowerCase(), p => `${p} must be lowercase`);
|
||||||
export const IsUppercase: Rule<string> = rule('isUppercase', v => typeof v === 'string' && v === v.toUpperCase(), p => `${p} must be uppercase`);
|
export const IsUppercase: Rule<string> = /*#__PURE__*/ rule('isUppercase', v => typeof v === 'string' && v === v.toUpperCase(), p => `${p} must be uppercase`);
|
||||||
export const IsNumberString: Rule<string> = rule('isNumberString', v => typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)), p => `${p} must be a number string`);
|
export const IsNumberString: Rule<string> = /*#__PURE__*/ rule('isNumberString', v => typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)), p => `${p} must be a number string`);
|
||||||
export const IsDateString: Rule<string> = rule('isDateString', v => typeof v === 'string' && !isNaN(Date.parse(v)), p => `${p} must be a valid ISO 8601 date string`);
|
export const IsDateString: Rule<string> = /*#__PURE__*/ rule('isDateString', v => typeof v === 'string' && !isNaN(Date.parse(v)), p => `${p} must be a valid ISO 8601 date string`);
|
||||||
export const IsJSON: Rule<string> = rule('isJson', v => {
|
export const IsJSON: Rule<string> = /*#__PURE__*/ rule('isJson', v => {
|
||||||
if (typeof v !== 'string') return false;
|
if (typeof v !== 'string') return false;
|
||||||
try { JSON.parse(v); return true; } catch { return false; }
|
try { JSON.parse(v); return true; } catch { return false; }
|
||||||
}, p => `${p} must be a JSON string`);
|
}, p => `${p} must be a JSON string`);
|
||||||
|
|
||||||
export const IsUrl: Rule<string> = rule('isUrl', v => {
|
export const IsUrl: Rule<string> = /*#__PURE__*/ rule('isUrl', v => {
|
||||||
try { new URL(v as string); return true; } catch { return false; }
|
try { new URL(v as string); return true; } catch { return false; }
|
||||||
}, p => `${p} must be a valid URL`);
|
}, p => `${p} must be a valid URL`);
|
||||||
|
|
||||||
@@ -449,10 +449,10 @@ function affix(name: string, test: (value: string, seed: string) => boolean, des
|
|||||||
return decorator;
|
return decorator;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Contains = affix('contains', (v, s) => v.includes(s), (p, s) => `${p} must contain ${JSON.stringify(s)}`);
|
export const Contains = /*#__PURE__*/ affix('contains', (v, s) => v.includes(s), (p, s) => `${p} must contain ${JSON.stringify(s)}`);
|
||||||
export const NotContains = affix('notContains', (v, s) => !v.includes(s), (p, s) => `${p} must not contain ${JSON.stringify(s)}`);
|
export const NotContains = /*#__PURE__*/ affix('notContains', (v, s) => !v.includes(s), (p, s) => `${p} must not contain ${JSON.stringify(s)}`);
|
||||||
export const StartsWith = affix('startsWith', (v, s) => v.startsWith(s), (p, s) => `${p} must start with ${JSON.stringify(s)}`);
|
export const StartsWith = /*#__PURE__*/ affix('startsWith', (v, s) => v.startsWith(s), (p, s) => `${p} must start with ${JSON.stringify(s)}`);
|
||||||
export const EndsWith = affix('endsWith', (v, s) => v.endsWith(s), (p, s) => `${p} must end with ${JSON.stringify(s)}`);
|
export const EndsWith = /*#__PURE__*/ affix('endsWith', (v, s) => v.endsWith(s), (p, s) => `${p} must end with ${JSON.stringify(s)}`);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Equality and membership
|
// Equality and membership
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { build } from 'esbuild';
|
||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tree-shakability is a property of the source that nothing else notices when it breaks.
|
||||||
|
*
|
||||||
|
* Every rule is declared as a top-level call — `export const IsString = rule(...)` — and rollup
|
||||||
|
* can prove such a call pure by reading the factory, but esbuild and webpack will not. Without
|
||||||
|
* the `/*#__PURE__*\/` annotations on those declarations, importing one decorator dragged in the
|
||||||
|
* message and validator of all 68: 4909 bytes rather than 1837 through esbuild, 4823 rather
|
||||||
|
* than 1818 through webpack. Nothing failed. The library simply got three times heavier in
|
||||||
|
* every consumer's bundle, and the only way to notice was to go and measure.
|
||||||
|
*
|
||||||
|
* So these assertions are mostly about *content* rather than bytes: a byte ceiling tells you
|
||||||
|
* something drifted, but naming the thing that should not be there says what.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Markers that identify a chunk of the library in minified output. */
|
||||||
|
const MARKER = {
|
||||||
|
isString: 'must be a string',
|
||||||
|
minLength: 'must be longer than or equal to',
|
||||||
|
isLatitude: 'must be a latitude',
|
||||||
|
isSemVer: 'must be a valid semantic version',
|
||||||
|
arraySize: 'must contain at least',
|
||||||
|
serializer: 'Circular reference',
|
||||||
|
representable: 'cannot be serialized to JSON',
|
||||||
|
deserializer: 'Unknown property',
|
||||||
|
validator: '[redacted]',
|
||||||
|
naming: 'SCREAMING_SNAKE_CASE',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const ENTRY = path.resolve('src/index.js').replace(/\.js$/, '.js');
|
||||||
|
|
||||||
|
async function bundle(source: string): Promise<string> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'cereale-shake-'));
|
||||||
|
try {
|
||||||
|
const entry = path.join(dir, 'entry.ts');
|
||||||
|
await writeFile(entry, source.replace('CEREALE', JSON.stringify(ENTRY)));
|
||||||
|
const result = await build({
|
||||||
|
entryPoints: [entry],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
minify: true,
|
||||||
|
target: 'es2022',
|
||||||
|
write: false,
|
||||||
|
tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } },
|
||||||
|
});
|
||||||
|
return result.outputFiles[0]!.text;
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts what a bundle kept and what it dropped.
|
||||||
|
*
|
||||||
|
* `keeps` is not decoration. A bundle that failed to build, or that resolved the library as an
|
||||||
|
* external and inlined none of it, contains none of the markers — so an "everything was shaken"
|
||||||
|
* result and a broken harness look identical without it.
|
||||||
|
*/
|
||||||
|
function expectShaken(code: string, keeps: string[], drops: string[]) {
|
||||||
|
expect(code.length, 'the bundle is empty — the harness is broken, not the tree-shaking').toBeGreaterThan(200);
|
||||||
|
for (const marker of keeps) {
|
||||||
|
expect(code, `expected the bundle to contain ${JSON.stringify(marker)}`).toContain(marker);
|
||||||
|
}
|
||||||
|
for (const marker of drops) {
|
||||||
|
expect(code, `${JSON.stringify(marker)} should have been shaken out`).not.toContain(marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('tree-shaking', () => {
|
||||||
|
it('drops the 67 rules you did not import', async () => {
|
||||||
|
const code = await bundle(`
|
||||||
|
import { IsString } from CEREALE;
|
||||||
|
export const d = IsString();
|
||||||
|
`);
|
||||||
|
|
||||||
|
expectShaken(code, [MARKER.isString], [
|
||||||
|
MARKER.isLatitude, MARKER.isSemVer, MARKER.arraySize, MARKER.minLength,
|
||||||
|
MARKER.serializer, MARKER.deserializer, MARKER.naming,
|
||||||
|
]);
|
||||||
|
// Generous ceiling: the measured figure is ~1.8 KB, and this is here to catch a regression
|
||||||
|
// of the kind above (which trebled it), not to police every byte.
|
||||||
|
expect(code.length).toBeLessThan(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the deserializer and drops the serializer when only reading', async () => {
|
||||||
|
const code = await bundle(`
|
||||||
|
import { toInstanceSync } from CEREALE;
|
||||||
|
export const f = (C, p) => toInstanceSync(C, p, { validate: false });
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Validation is kept on purpose: `validate` defaults to true, so the entry point
|
||||||
|
// references it whatever the call site passes.
|
||||||
|
expectShaken(code, [MARKER.deserializer, MARKER.validator], [MARKER.serializer, MARKER.representable]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the serializer and drops the deserializer when only writing', async () => {
|
||||||
|
const code = await bundle(`
|
||||||
|
import { toPlainSync } from CEREALE;
|
||||||
|
export const f = (o) => toPlainSync(o, { validate: false });
|
||||||
|
`);
|
||||||
|
|
||||||
|
expectShaken(code, [MARKER.serializer, MARKER.representable, MARKER.validator], [MARKER.deserializer]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops both engines when only validating', async () => {
|
||||||
|
const code = await bundle(`
|
||||||
|
import { validateSync } from CEREALE;
|
||||||
|
export const f = (o) => validateSync(o);
|
||||||
|
`);
|
||||||
|
|
||||||
|
expectShaken(code, [MARKER.validator], [MARKER.serializer, MARKER.deserializer, MARKER.isString]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('costs almost nothing to import only an error helper', async () => {
|
||||||
|
const code = await bundle(`
|
||||||
|
import { flattenErrors } from CEREALE;
|
||||||
|
export const f = (e) => flattenErrors(e);
|
||||||
|
`);
|
||||||
|
|
||||||
|
expectShaken(code, [], [MARKER.serializer, MARKER.deserializer, MARKER.validator, MARKER.isString]);
|
||||||
|
expect(code.length).toBeLessThan(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still contains everything when everything is used', async () => {
|
||||||
|
const code = await bundle(`
|
||||||
|
import * as cereale from CEREALE;
|
||||||
|
export default cereale;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// The counterweight to every assertion above: proves the markers are findable at all, so a
|
||||||
|
// "shaken" result upstream means shaken rather than misspelled.
|
||||||
|
expectShaken(code, Object.values(MARKER), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user