Merge pull request #11 from avalon-vanguard/develop
🌳 Release 0.4.0 — tree-shaking, and `cereale/min`
This commit is contained in:
@@ -39,6 +39,7 @@ jobs:
|
|||||||
node --input-type=commonjs -e "const m = require('./dist/cjs/index.js'); if (typeof m.toInstance !== 'function') throw new Error('CJS entry point broken');"
|
node --input-type=commonjs -e "const m = require('./dist/cjs/index.js'); if (typeof m.toInstance !== 'function') throw new Error('CJS entry point broken');"
|
||||||
node --input-type=module -e "import { standardDecorators } from './dist/esm/vite.js'; if (standardDecorators().enforce !== 'pre') throw new Error('ESM cereale/vite broken');"
|
node --input-type=module -e "import { standardDecorators } from './dist/esm/vite.js'; if (standardDecorators().enforce !== 'pre') throw new Error('ESM cereale/vite broken');"
|
||||||
node --input-type=commonjs -e "const { standardDecorators } = require('./dist/cjs/vite.js'); if (standardDecorators().enforce !== 'pre') throw new Error('CJS cereale/vite broken');"
|
node --input-type=commonjs -e "const { standardDecorators } = require('./dist/cjs/vite.js'); if (standardDecorators().enforce !== 'pre') throw new Error('CJS cereale/vite broken');"
|
||||||
|
node --input-type=module -e "import * as m from './dist/cereale.min.js'; if (typeof m.toInstanceSync !== 'function') throw new Error('flat bundle broken');"
|
||||||
- name: Run Demo
|
- name: Run Demo
|
||||||
run: npm run demo
|
run: npm run demo
|
||||||
- name: Published types stand alone
|
- name: Published types stand alone
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: Junie Review
|
||||||
|
|
||||||
|
# Automated PR review by Junie, JetBrains' coding agent. Advisory only: it posts a
|
||||||
|
# summary and inline comments, and is deliberately not a required check — CI is what
|
||||||
|
# gates a merge, and a review that can block one on a judgement call is a review that
|
||||||
|
# gets rubber-stamped.
|
||||||
|
#
|
||||||
|
# Requires a JUNIE_API_KEY repository secret (Settings → Secrets and variables →
|
||||||
|
# Actions). Without it the action fails at the first step rather than skipping, so
|
||||||
|
# add the secret before merging this file.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [ opened, synchronize, reopened ]
|
||||||
|
branches: [ main, develop ]
|
||||||
|
|
||||||
|
# A PR that gets three pushes in a minute should end up with one review of the final
|
||||||
|
# state, not three reviews of intermediate ones. Combined with use_single_comment
|
||||||
|
# below, each PR keeps exactly one review comment, rewritten as the diff changes.
|
||||||
|
concurrency:
|
||||||
|
group: junie-review-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
review:
|
||||||
|
name: Junie
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
# Secrets are not exposed to `pull_request` runs originating from a fork, so a
|
||||||
|
# fork PR would fail on an empty API key rather than review anything. Skip those
|
||||||
|
# explicitly — a skipped job reads as "not applicable", a failed one as "broken".
|
||||||
|
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read # read the diff; Junie does not push from this workflow
|
||||||
|
pull-requests: write # post the review summary and inline comments
|
||||||
|
issues: write # the PR conversation is an issue timeline to the API
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Review the pull request
|
||||||
|
uses: JetBrains/junie-github-action@v1
|
||||||
|
with:
|
||||||
|
junie_api_key: ${{ secrets.JUNIE_API_KEY }}
|
||||||
|
# Built-in structured review prompt, as opposed to a free-form instruction.
|
||||||
|
prompt: "code-review"
|
||||||
|
# Update one comment across re-runs instead of appending a new one per push.
|
||||||
|
use_single_comment: "true"
|
||||||
+113
@@ -5,6 +5,119 @@ All notable changes to this project are documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-08-05
|
||||||
|
|
||||||
|
### `cereale/min` — one file, no bundler
|
||||||
|
|
||||||
|
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`,
|
||||||
|
so the decorator lowering and the ES2025 target are whatever `tsc` produced — esbuild only
|
||||||
|
flattens and minifies.
|
||||||
|
|
||||||
|
It is an addition, not a replacement. The per-module build stays the default `import`: it keeps
|
||||||
|
readable stack traces for anyone not loading source maps, and it is what a bundler should be
|
||||||
|
given.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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__*/`. 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:
|
||||||
|
|
||||||
|
| What you import | esbuild | rollup | webpack |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| `flattenErrors` | 394 | 367 | 394 |
|
||||||
|
| one decorator | 1,837 | 1,823 | 1,818 |
|
||||||
|
| `validateSync` | 3,722 | 3,554 | 3,738 |
|
||||||
|
| `toPlainSync` | 7,744 | 7,769 | 7,771 |
|
||||||
|
| `toInstanceSync` | 7,900 | 7,942 | 7,944 |
|
||||||
|
| a typical DTO | 10,395 | 10,402 | 10,360 |
|
||||||
|
| 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`.
|
||||||
|
|
||||||
|
The same pass shook out something that was supposed to stay. Cereale installs `Symbol.metadata`
|
||||||
|
when the runtime lacks it, and `sideEffects` named the module holding that install — but not the
|
||||||
|
barrel that re-exports it. A side-effect-free barrel is droppable as a whole, so all three
|
||||||
|
bundlers pruned the `export * from './metadata.js'` edge before metadata.js's own marking was
|
||||||
|
ever consulted: `import { configure } from 'cereale'` came out at 145 bytes through esbuild, 143
|
||||||
|
through webpack and 144 through rollup, with `Symbol.metadata` in none of them. That matters
|
||||||
|
because `tsc`'s decorator emit reads the well-known symbol directly — `typeof Symbol ===
|
||||||
|
"function" && Symbol.metadata ? Object.create(null) : void 0` — so without the install a
|
||||||
|
decorated class gets `metadata: undefined`, which is to say no rules at all.
|
||||||
|
|
||||||
|
`index.js` and `index.ts` are now listed too. It costs about 100 bytes, and only on imports that
|
||||||
|
reach nothing else; every row of the table above except the first was byte-identical before and
|
||||||
|
after, across all three bundlers. Two more cases in `treeshake.test.ts` pin it, one on the source
|
||||||
|
and one on `dist/esm`, because those are separate paths in the manifest and a typo in either is
|
||||||
|
invisible from the other side.
|
||||||
|
|
||||||
|
### Frameworks
|
||||||
|
|
||||||
|
[FRAMEWORKS.md](FRAMEWORKS.md) — a setup recipe for each, every one run before it was written,
|
||||||
|
with the versions and date it was verified against.
|
||||||
|
|
||||||
|
The finding worth stating first: **Angular works**. The CLI scaffolds
|
||||||
|
`"experimentalDecorators": true`, but Angular does not need it — `ngtsc` erases `@Component`
|
||||||
|
and `@Injectable` into static properties rather than relying on TypeScript's decorator emit.
|
||||||
|
Flip the flag and both systems work in one program. Verified with `ngc` on Angular 21.2 with
|
||||||
|
`strictTemplates`: templates still type-check, and a wrong cereale rule is still a compile
|
||||||
|
error inside the Angular build.
|
||||||
|
|
||||||
|
**Next.js cannot work inline**, and the reason is structural rather than a missing option. It
|
||||||
|
derives *both* the SWC parser's decorator support and the transform mode from the single
|
||||||
|
`experimentalDecorators` flag, so the flag on gives legacy emit that cereale refuses, and the
|
||||||
|
flag off makes `@` a syntax error. There is no third setting.
|
||||||
|
|
||||||
|
**NestJS cannot work inline** either: its dependency injection genuinely needs the
|
||||||
|
`design:type` metadata only `emitDecoratorMetadata` produces.
|
||||||
|
|
||||||
|
Both have the same answer, and it is better than it sounds: put the cereale classes in a
|
||||||
|
package compiled by `tsc` and import the built output. The decorators run at class-definition
|
||||||
|
time inside that package, so the app only ever sees plain JavaScript and its own decorator
|
||||||
|
setting stops mattering. Verified inside a program with **both** legacy flags on, running
|
||||||
|
alongside `@Injectable()` — mapping and validation work normally, and the compile-time
|
||||||
|
guarantee still holds where the rules are written.
|
||||||
|
|
||||||
|
Also verified: **Bun** 1.3 needs no configuration at all, and a real Vite 8 build with the
|
||||||
|
`cereale/vite` plugin produces working output where the same build without it silently leaves
|
||||||
|
decorator syntax in the bundle.
|
||||||
|
|
||||||
|
### Packaging
|
||||||
|
|
||||||
|
`FRAMEWORKS.md` ships with the package. `sideEffects` now lists the flat bundle, which inlines
|
||||||
|
the `Symbol.metadata` install.
|
||||||
|
|
||||||
|
### Why 0.4.0 and not 0.3.1
|
||||||
|
|
||||||
|
`cereale/min` is a new public entry point, which is a minor bump under 0.x. It also keeps the
|
||||||
|
existing `v0.3.0` tag meaningful instead of force-moving it onto a commit it was never cut
|
||||||
|
from.
|
||||||
|
|
||||||
## [0.3.0] - 2026-08-05
|
## [0.3.0] - 2026-08-05
|
||||||
|
|
||||||
Every change here comes from the same question: where does cereale currently fail *quietly*?
|
Every change here comes from the same question: where does cereale currently fail *quietly*?
|
||||||
|
|||||||
+343
@@ -0,0 +1,343 @@
|
|||||||
|
# Using cereale with your framework
|
||||||
|
|
||||||
|
Every recipe here was run before it was written. Where something does not work, this says so
|
||||||
|
rather than offering a workaround that has not been tried.
|
||||||
|
|
||||||
|
## The only requirement
|
||||||
|
|
||||||
|
cereale reads the metadata that a **TC39 standard** decorator transform emits. That means your
|
||||||
|
compiler must have `experimentalDecorators` **off** — which is the default in TypeScript 5.0
|
||||||
|
and later, but not what several frameworks scaffold.
|
||||||
|
|
||||||
|
There is no partial credit and no per-file override: `experimentalDecorators` is a property of
|
||||||
|
a TypeScript *program*, so every decorator in one compilation uses the same system. If the flag
|
||||||
|
is on, `tsc` refuses cereale's decorators outright:
|
||||||
|
|
||||||
|
```
|
||||||
|
error TS1240: Unable to resolve signature of property decorator when called as an expression.
|
||||||
|
Argument of type 'User' is not assignable to parameter of type 'undefined'.
|
||||||
|
```
|
||||||
|
|
||||||
|
and if you skip type-checking (esbuild, swc and Babel all strip types without checking them),
|
||||||
|
the emit reaches cereale as a legacy call and it says so by name rather than failing obscurely.
|
||||||
|
|
||||||
|
## Two ways to adopt it
|
||||||
|
|
||||||
|
**Inline** — write cereale decorators directly in your app. Needs `experimentalDecorators: false`.
|
||||||
|
This is what you want, and most toolchains allow it.
|
||||||
|
|
||||||
|
**A precompiled models package** — when the program is committed to legacy decorators for
|
||||||
|
something else (NestJS's DI, TypeORM entities, Next's SWC pipeline), put your cereale classes in
|
||||||
|
a separate package compiled by `tsc`, and import the built output. The decorators run at class
|
||||||
|
definition time inside that package; your app only ever sees plain JavaScript, so its own
|
||||||
|
decorator setting is irrelevant. Verified working inside a program with **both**
|
||||||
|
`experimentalDecorators: true` and `emitDecoratorMetadata: true`.
|
||||||
|
|
||||||
|
You keep the compile-time guarantee where it matters — in the package where the rules are
|
||||||
|
written — and lose nothing at runtime.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Verified on the versions listed, on 2026-08-05. ✅ inline, ⚠️ via a precompiled package.
|
||||||
|
Rows marked — were not tested; they are listed so their absence is not mistaken for a verdict.
|
||||||
|
|
||||||
|
| Toolchain | | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tsc` 5.2+ | ✅ | `experimentalDecorators: false`, `target: ES2022`+ |
|
||||||
|
| **Angular** 21 | ✅ | flip the scaffolded `experimentalDecorators` to `false` — Angular does not need it |
|
||||||
|
| **Vite** 8 | ✅ | add `cereale/vite`; covers React, Vue, Svelte, Solid, Qwik, Astro, Nuxt, SvelteKit |
|
||||||
|
| **Vitest** 4 | ✅ | same plugin |
|
||||||
|
| **Bun** 1.3 | ✅ | works with no configuration |
|
||||||
|
| esbuild 0.25 | ✅ | top-level `target: es2022`+ **and** `experimentalDecorators: false` |
|
||||||
|
| swc 1.15 | ✅ | `jsc.transform.decoratorVersion: "2022-03"` |
|
||||||
|
| **Next.js** 16 | ⚠️ | no inline support — see below |
|
||||||
|
| **NestJS** 11.1 | ⚠️ | its DI needs `emitDecoratorMetadata` — see below |
|
||||||
|
| Deno | — | untested |
|
||||||
|
| webpack + `ts-loader` | — | untested; follows whatever `tsc` is configured to do |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Angular
|
||||||
|
|
||||||
|
The surprise is that Angular works. The CLI scaffolds `"experimentalDecorators": true`, but
|
||||||
|
Angular's compiler does not need it — `ngtsc` erases `@Component` and `@Injectable` into static
|
||||||
|
properties itself, rather than relying on TypeScript's decorator emit. Turn the flag off and
|
||||||
|
both work in the same program.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// tsconfig.json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"experimentalDecorators": false, // was true; Angular does not need it
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ESNext", "DOM", "ESNext.Decorators"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// user.dto.ts
|
||||||
|
import { IsString, MinLength, IsInt, Min, JsonProperty } from 'cereale';
|
||||||
|
|
||||||
|
export class UserDto {
|
||||||
|
@JsonProperty('display_name')
|
||||||
|
@IsString() @MinLength(3)
|
||||||
|
displayName!: string;
|
||||||
|
|
||||||
|
@IsInt() @Min(18)
|
||||||
|
age!: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// user.service.ts
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { map } from 'rxjs';
|
||||||
|
import { toInstanceSync } from 'cereale';
|
||||||
|
import { UserDto } from './user.dto';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class UserService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
load(id: string) {
|
||||||
|
return this.http.get(`/api/users/${id}`).pipe(map(body => toInstanceSync(UserDto, body)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified with `ngc` on Angular 21.2 and `strictTemplates: true`: the component's template is
|
||||||
|
still type-checked, and a wrong cereale rule is still a compile error inside the Angular build —
|
||||||
|
`@IsString()` on `age!: number` fails with TS1240 exactly as it does anywhere else.
|
||||||
|
|
||||||
|
## Any Vite app — React, Vue, Svelte, Solid, Astro, Nuxt, SvelteKit
|
||||||
|
|
||||||
|
Vite 8 transforms with oxc, which does not implement the standard decorator transform **and does
|
||||||
|
not report that**. `vite build` succeeds and leaves the decorator syntax in the bundle, which
|
||||||
|
throws the moment anything imports it. cereale ships the plugin that fixes it.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// vite.config.ts
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react'; // or vue(), svelte(), solid()…
|
||||||
|
import { standardDecorators } from 'cereale/vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [standardDecorators(), react()],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// tsconfig.json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"experimentalDecorators": false,
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ESNext", "DOM", "ESNext.Decorators"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// UserForm.tsx
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toInstanceSync, validateSync, flattenErrors } from 'cereale';
|
||||||
|
import { UserDto } from './user.dto'; // a .ts file, not .tsx — see below
|
||||||
|
|
||||||
|
export function UserForm() {
|
||||||
|
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
||||||
|
|
||||||
|
function onSubmit(form: FormData) {
|
||||||
|
const draft = toInstanceSync(UserDto, Object.fromEntries(form), { validate: false });
|
||||||
|
setErrors(flattenErrors(validateSync(draft)));
|
||||||
|
}
|
||||||
|
// …
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The plugin transforms `.ts`, `.mts` and `.cts` outside `node_modules`. `.tsx` is excluded by
|
||||||
|
default, because lowering decorators there means also deciding what happens to the JSX — keep
|
||||||
|
decorated classes in `.ts` files, or pass an `include` of your own:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
standardDecorators({ include: (id) => /\.[cm]?tsx?$/.test(id) && !id.includes('/node_modules/') })
|
||||||
|
```
|
||||||
|
|
||||||
|
Options: `include`, `target` (default `es2022`), and `transformer` (`'auto'` prefers esbuild and
|
||||||
|
falls back to the TypeScript compiler; cereale depends on neither).
|
||||||
|
|
||||||
|
## Vitest
|
||||||
|
|
||||||
|
Same plugin, same reason — Vitest 4 uses the same oxc pipeline, and without it prints `0 test`
|
||||||
|
next to a bare `SyntaxError`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// vitest.config.ts
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { standardDecorators } from 'cereale/vite';
|
||||||
|
|
||||||
|
export default defineConfig({ plugins: [standardDecorators()] });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Node, with tsc
|
||||||
|
|
||||||
|
Nothing to configure beyond the flag.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"lib": ["ESNext", "ESNext.Decorators"],
|
||||||
|
"experimentalDecorators": false,
|
||||||
|
"strictPropertyInitialization": false // optional; `field!: T` otherwise needs the `!`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import express from 'express';
|
||||||
|
import { fromJsonSync, JsonValidationError, flattenErrors } from 'cereale';
|
||||||
|
import { UserDto } from './user.dto.js';
|
||||||
|
|
||||||
|
app.post('/users', (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = fromJsonSync(UserDto, JSON.stringify(req.body));
|
||||||
|
return res.status(201).json(user);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof JsonValidationError) {
|
||||||
|
return res.status(400).json({ errors: flattenErrors(error.errors) });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bun
|
||||||
|
|
||||||
|
Works with no configuration — Bun's transpiler emits standard decorators when
|
||||||
|
`experimentalDecorators` is off, which is its default.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun add cereale
|
||||||
|
bun run app.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next.js
|
||||||
|
|
||||||
|
**Not supported inline.** Next derives *both* the SWC parser's decorator support and its
|
||||||
|
transform mode from the single `experimentalDecorators` flag:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const enableDecorators = Boolean(jsConfig?.compilerOptions?.experimentalDecorators);
|
||||||
|
// parser: decorators: enableDecorators
|
||||||
|
// transform: legacyDecorator: enableDecorators
|
||||||
|
```
|
||||||
|
|
||||||
|
So the flag on gives legacy emit that cereale refuses, and the flag off makes `@` a syntax
|
||||||
|
error. There is no third setting, and no exposed `decoratorVersion` option.
|
||||||
|
|
||||||
|
Use a precompiled models package instead:
|
||||||
|
|
||||||
|
```
|
||||||
|
repo/
|
||||||
|
models/ ← compiled by tsc, experimentalDecorators: false
|
||||||
|
package.json { "name": "@acme/models", "exports": { ".": "./dist/index.js" } }
|
||||||
|
tsconfig.json
|
||||||
|
src/user.dto.ts ← your cereale classes live here
|
||||||
|
web/ ← the Next app, untouched
|
||||||
|
package.json { "dependencies": { "@acme/models": "workspace:*" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// models/tsconfig.json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ESNext", "ESNext.Decorators"],
|
||||||
|
"experimentalDecorators": false,
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Build `models` before `web`. Next only ever sees the compiled JavaScript — no decorator syntax
|
||||||
|
reaches its parser — and mapping and validation work normally. Verified against Next 16.3's own
|
||||||
|
SWC configuration.
|
||||||
|
|
||||||
|
## NestJS
|
||||||
|
|
||||||
|
**Not supported inline.** Nest scaffolds `experimentalDecorators: true` *and*
|
||||||
|
`emitDecoratorMetadata: true`, and its dependency injection genuinely needs the `design:type`
|
||||||
|
metadata that only legacy decorators emit. Turning the flag off breaks Nest.
|
||||||
|
|
||||||
|
The precompiled models package above works here too, and this is the case that proves the
|
||||||
|
pattern: a program compiled with **both** legacy flags on can import cereale DTOs from a
|
||||||
|
precompiled package and validate with them normally, alongside its own `@Injectable()` and
|
||||||
|
`@Inject()` decorators.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
|
import { UserDto } from '@acme/models'; // precompiled
|
||||||
|
import { toInstanceSync, validateSync, formatErrors } from 'cereale';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
create(body: unknown) {
|
||||||
|
const draft = toInstanceSync(UserDto, body, { validate: false });
|
||||||
|
const errors = validateSync(draft);
|
||||||
|
if (errors.length) throw new BadRequestException(formatErrors(errors));
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If you would rather not split the package, stay on class-validator for now — Nest's own
|
||||||
|
`ValidationPipe` is built around it, and cereale does not try to replace that integration.
|
||||||
|
|
||||||
|
## Other bundlers
|
||||||
|
|
||||||
|
**esbuild** needs its own top-level `target`, not one inside `tsconfigRaw`. A `target` in
|
||||||
|
`tsconfigRaw` sets the `useDefineForClassFields` default and nothing else, so the decorators
|
||||||
|
are left in the output:
|
||||||
|
|
||||||
|
```js
|
||||||
|
await esbuild.build({
|
||||||
|
target: 'es2022', // ← required, and top-level
|
||||||
|
tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**swc** spells the choice as a proposal date:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// .swcrc
|
||||||
|
{
|
||||||
|
"jsc": {
|
||||||
|
"parser": { "syntax": "typescript", "decorators": true },
|
||||||
|
"transform": { "decoratorVersion": "2022-03" },
|
||||||
|
"target": "es2022"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## A single file, no bundler
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
**If you are using a bundler, do not use it.** It is the whole library in one file, so nothing
|
||||||
|
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
|
||||||
|
<script type="importmap">
|
||||||
|
{ "imports": { "cereale": "https://unpkg.com/cereale/dist/cereale.min.js" } }
|
||||||
|
</script>
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -108,6 +109,20 @@ inside a native binary with no standalone transform API.
|
|||||||
| swc | ✅ | `jsc.transform.decoratorVersion: "2022-03"` |
|
| swc | ✅ | `jsc.transform.decoratorVersion: "2022-03"` |
|
||||||
| **oxc** | ❌ | Used by **Vite 8** and **Vitest 4** — see below |
|
| **oxc** | ❌ | Used by **Vite 8** and **Vitest 4** — see below |
|
||||||
|
|
||||||
|
### Frameworks
|
||||||
|
|
||||||
|
**[FRAMEWORKS.md](FRAMEWORKS.md)** has a setup recipe for each, every one of them run before it
|
||||||
|
was written. The short version:
|
||||||
|
|
||||||
|
| | | |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Angular** 21 | ✅ | Flip the scaffolded `experimentalDecorators` to `false`. Angular does not need it — `ngtsc` erases its own decorators itself |
|
||||||
|
| **React, Vue, Svelte, Solid, Astro, Nuxt** | ✅ | Any Vite 8 app: add the `cereale/vite` plugin below |
|
||||||
|
| **Bun** | ✅ | No configuration |
|
||||||
|
| **Node** + `tsc` | ✅ | Just the flag |
|
||||||
|
| **Next.js** 16 | ⚠️ | Not inline: it derives both the SWC parser *and* the transform from one flag, so decorators either compile legacy or fail to parse. Keep your models in a package compiled by `tsc` |
|
||||||
|
| **NestJS** 11 | ⚠️ | Not inline: its DI needs `emitDecoratorMetadata`. Same precompiled-package route — verified working alongside `@Injectable()` in a program with both legacy flags on |
|
||||||
|
|
||||||
**If you are on Vite 8 or Vitest 4**, oxc leaves decorator syntax in the output without
|
**If you are on Vite 8 or Vitest 4**, oxc leaves decorator syntax in the output without
|
||||||
reporting anything: `vitest` prints `0 test` next to a bare `SyntaxError`, and `vite build`
|
reporting anything: `vitest` prints `0 test` next to a bare `SyntaxError`, and `vite build`
|
||||||
reports success while emitting a bundle that throws the moment it is imported. Cereale ships
|
reports success while emitting a bundle that throws the moment it is imported. Cereale ships
|
||||||
@@ -480,6 +495,50 @@ 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 esbuild, rollup and webpack. The table was measured by hand;
|
||||||
|
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 |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| `flattenErrors` | 394 | 367 | 394 |
|
||||||
|
| one decorator | 1,837 | 1,823 | 1,818 |
|
||||||
|
| `validateSync` | 3,722 | 3,554 | 3,738 |
|
||||||
|
| `toPlainSync` | 7,744 | 7,769 | 7,771 |
|
||||||
|
| `toInstanceSync` | 7,900 | 7,942 | 7,944 |
|
||||||
|
| 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 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.
|
||||||
|
|
||||||
|
The floor is about a hundred bytes: cereale installs `Symbol.metadata` if the runtime lacks it,
|
||||||
|
and that install has to survive tree-shaking or a `tsc`-compiled consumer decorates its classes
|
||||||
|
with no metadata at all. It is why `sideEffects` names `index.js` as well as `metadata.js` —
|
||||||
|
marking only the latter leaves the barrel itself droppable, so the edge to it is pruned before
|
||||||
|
its own marking is ever read. That cost lands only on the first row; every import that touches a
|
||||||
|
model was already carrying it.
|
||||||
|
|
||||||
|
This did not come for free. Thirty of the rules are declared as top-level calls —
|
||||||
|
`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. 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
|
||||||
|
|
||||||
- **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;
|
||||||
|
|||||||
+2
-2
File diff suppressed because one or more lines are too long
@@ -271,6 +271,7 @@ header.nav {
|
|||||||
.panel p { color: var(--text-muted); font-size: .9375rem; margin: 0; }
|
.panel p { color: var(--text-muted); font-size: .9375rem; margin: 0; }
|
||||||
.tick { color: var(--ok); font-weight: 700; }
|
.tick { color: var(--ok); font-weight: 700; }
|
||||||
.cross { color: var(--bad); font-weight: 700; }
|
.cross { color: var(--bad); font-weight: 700; }
|
||||||
|
.warn-mark { color: var(--warn); font-weight: 700; }
|
||||||
|
|
||||||
.compare { display: grid; gap: 1rem; }
|
.compare { display: grid; gap: 1rem; }
|
||||||
@media (min-width: 860px) { .compare { grid-template-columns: 1fr 1fr; } }
|
@media (min-width: 860px) { .compare { grid-template-columns: 1fr 1fr; } }
|
||||||
@@ -693,6 +694,26 @@ Break the cycle with @JsonIgnore() on the back-reference, or supply a
|
|||||||
that fixes it.
|
that fixes it.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="table-scroll" style="margin-top:1.5rem">
|
||||||
|
<table>
|
||||||
|
<caption class="sr-only">Framework support</caption>
|
||||||
|
<thead><tr><th scope="col">Framework</th><th scope="col">Works</th><th scope="col">What it takes</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Angular 21</td><td><span class="tick">✓</span></td><td class="note">Flip the scaffolded <code>experimentalDecorators</code> to <code>false</code> — Angular does not need it</td></tr>
|
||||||
|
<tr><td>React, Vue, Svelte…</td><td><span class="tick">✓</span></td><td class="note">Any Vite 8 app — add the plugin below</td></tr>
|
||||||
|
<tr><td>Bun</td><td><span class="tick">✓</span></td><td class="note">Nothing</td></tr>
|
||||||
|
<tr><td>Node + <code>tsc</code></td><td><span class="tick">✓</span></td><td class="note">Just the flag</td></tr>
|
||||||
|
<tr><td>Next.js 16</td><td><span class="warn-mark">~</span></td><td class="note">Not inline — keep models in a package compiled by <code>tsc</code></td></tr>
|
||||||
|
<tr><td>NestJS 11</td><td><span class="warn-mark">~</span></td><td class="note">Not inline — its DI needs <code>emitDecoratorMetadata</code>; same precompiled route</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="pg-note" style="max-width:var(--measure)">
|
||||||
|
Each of these was set up and run before it was written down.
|
||||||
|
<a href="https://github.com/avalon-vanguard/cereale/blob/main/FRAMEWORKS.md">FRAMEWORKS.md</a>
|
||||||
|
has the full recipe for every one, including the two that need the precompiled route.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="code" style="margin-top:1.25rem;max-width:640px">
|
<div class="code" style="margin-top:1.25rem;max-width:640px">
|
||||||
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">vite.config.ts</span></div>
|
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">vite.config.ts</span></div>
|
||||||
<pre><code data-lang="ts">import { defineConfig } from 'vite';
|
<pre><code data-lang="ts">import { defineConfig } from 'vite';
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// Generated by scripts/build-docs.mjs — do not edit.
|
// Generated by scripts/build-docs.mjs — do not edit.
|
||||||
window.CEREALE_META = {
|
window.CEREALE_META = {
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
};
|
};
|
||||||
|
|||||||
+11
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cereale",
|
"name": "cereale",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"description": "Strongly typed JSON mapping and validation for TypeScript classes \u2014 validated domain objects, not validated data. A zero-dependency replacement for class-validator + class-transformer, on TC39 standard decorators.",
|
"description": "Strongly typed JSON mapping and validation for TypeScript classes \u2014 validated domain objects, not validated data. A zero-dependency replacement for class-validator + class-transformer, on TC39 standard decorators.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
@@ -16,21 +16,29 @@
|
|||||||
"types": "./dist/esm/vite.d.ts",
|
"types": "./dist/esm/vite.d.ts",
|
||||||
"import": "./dist/esm/vite.js",
|
"import": "./dist/esm/vite.js",
|
||||||
"require": "./dist/cjs/vite.js"
|
"require": "./dist/cjs/vite.js"
|
||||||
|
},
|
||||||
|
"./min": {
|
||||||
|
"types": "./dist/esm/index.d.ts",
|
||||||
|
"default": "./dist/cereale.min.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
"./dist/esm/index.js",
|
||||||
"./dist/esm/metadata.js",
|
"./dist/esm/metadata.js",
|
||||||
"./dist/cjs/metadata.js"
|
"./dist/cereale.min.js",
|
||||||
|
"./src/index.ts",
|
||||||
|
"./src/metadata.ts"
|
||||||
],
|
],
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"src",
|
"src",
|
||||||
|
"FRAMEWORKS.md",
|
||||||
"CHANGELOG.md",
|
"CHANGELOG.md",
|
||||||
"!src/**/*.test.ts",
|
"!src/**/*.test.ts",
|
||||||
"!src/example.ts"
|
"!src/example.ts"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json",
|
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && node scripts/build-bundle.mjs",
|
||||||
"build:docs": "node scripts/build-docs.mjs",
|
"build:docs": "node scripts/build-docs.mjs",
|
||||||
"demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts",
|
"demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Flattens the ESM build into a single minified module.
|
||||||
|
*
|
||||||
|
* This is an *addition*, not a replacement. `dist/esm` stays the default `import`: it keeps
|
||||||
|
* readable stack traces for anyone who does not load source maps, and it is what a bundler
|
||||||
|
* should be handed.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* `cereale/min` is for.
|
||||||
|
*
|
||||||
|
* It is built from `dist/esm/index.js` rather than from `src/`, so the decorator lowering and
|
||||||
|
* the ES2025 target are whatever `tsc` produced — esbuild is only flattening and minifying.
|
||||||
|
* (esbuild has no `es2025` target name; `esnext` means "downlevel nothing", which is what we
|
||||||
|
* want when the input is already at the target.)
|
||||||
|
*/
|
||||||
|
import { build } from 'esbuild';
|
||||||
|
import { readFile, writeFile, stat } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { gzipSync } from 'node:zlib';
|
||||||
|
|
||||||
|
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||||
|
const dist = path.join(root, 'dist');
|
||||||
|
const outfile = path.join(dist, 'cereale.min.js');
|
||||||
|
|
||||||
|
const pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entryPoints: [path.join(dist, 'esm/index.js')],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
target: 'esnext',
|
||||||
|
// NOT `minify: true`: `minifyWhitespace` strips comments, /*#__PURE__*/ included, and a
|
||||||
|
// bundler fed the result keeps every unused rule — 5,066 bytes for one decorator against
|
||||||
|
// 1,837. Asserted below. Costs about a kilobyte gzipped.
|
||||||
|
minifySyntax: true,
|
||||||
|
minifyIdentifiers: true,
|
||||||
|
sourcemap: true,
|
||||||
|
legalComments: 'none',
|
||||||
|
banner: { js: `/*! cereale ${pkg.version} | MIT | ${pkg.homepage} */` },
|
||||||
|
// The input is compiled JavaScript, so the project's tsconfig has no bearing here — and
|
||||||
|
// reading it only earns a warning, because esbuild does not know the ES2025 target name
|
||||||
|
// that tsc is perfectly happy with.
|
||||||
|
tsconfigRaw: {},
|
||||||
|
outfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// The floor on `expected` is not decoration: comparing the two counts alone passes vacuously if
|
||||||
|
// tsc ever stops emitting the annotations, since 0 >= 0. It has to be wrong in both directions.
|
||||||
|
const count = (s) => (s.match(/__PURE__/g) ?? []).length;
|
||||||
|
const emitted = await readFile(outfile, 'utf8');
|
||||||
|
const annotations = count(emitted);
|
||||||
|
const expected = count(await readFile(path.join(dist, 'esm/decorators.js'), 'utf8'));
|
||||||
|
if (expected < 30) {
|
||||||
|
throw new Error(
|
||||||
|
`dist/esm/decorators.js carries only ${expected} /*#__PURE__*/ annotations; src/decorators.ts ` +
|
||||||
|
'writes 30. The compiler is dropping them, so every consumer keeps all 68 rules.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 gzip = gzipSync(emitted).length;
|
||||||
|
console.log(`dist/cereale.min.js ${(size / 1024).toFixed(1)} KB (${(gzip / 1024).toFixed(1)} KB gzipped)`);
|
||||||
+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
|
||||||
|
|||||||
+7
-2
@@ -15,8 +15,13 @@ import type { ClassConstructor } from './interfaces.js';
|
|||||||
*/
|
*/
|
||||||
const METADATA_KEY: symbol = (Symbol as { metadata?: symbol }).metadata ?? Symbol.for('Symbol.metadata');
|
const METADATA_KEY: symbol = (Symbol as { metadata?: symbol }).metadata ?? Symbol.for('Symbol.metadata');
|
||||||
|
|
||||||
// Also installed globally, because a consumer's own compiler emit may read `Symbol.metadata`
|
// Also installed globally: tsc's decorator emit reads `Symbol.metadata` directly rather than
|
||||||
// directly. package.json marks this module as having side effects so it survives bundling.
|
// falling back the way we do — `typeof Symbol === "function" && Symbol.metadata ? … : void 0` —
|
||||||
|
// so without this a decorated class gets `metadata: undefined` and no rules at all.
|
||||||
|
//
|
||||||
|
// `sideEffects` in package.json keeps the statement through bundling, and must name `index.*`
|
||||||
|
// as well as this module: marking only this one leaves the barrel droppable, so the edge to it
|
||||||
|
// is pruned before this marking is ever read. Pinned by treeshake.test.ts.
|
||||||
((Symbol as { metadata?: symbol }).metadata as symbol | undefined) ??= METADATA_KEY;
|
((Symbol as { metadata?: symbol }).metadata as symbol | undefined) ??= METADATA_KEY;
|
||||||
|
|
||||||
export interface ValidationArguments {
|
export interface ValidationArguments {
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { build } from 'esbuild';
|
||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
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 = JSON.stringify(path.resolve('src/index.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);
|
||||||
|
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 ${ENTRY};
|
||||||
|
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 ${ENTRY};
|
||||||
|
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 ${ENTRY};
|
||||||
|
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 ${ENTRY};
|
||||||
|
export const f = (o) => validateSync(o);
|
||||||
|
`);
|
||||||
|
|
||||||
|
expectShaken(code, [MARKER.validator], [MARKER.serializer, MARKER.deserializer, MARKER.isString]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `Symbol.metadata` install at the top of metadata.ts is a bare statement, not an export,
|
||||||
|
* so it survives only because `sideEffects` names that module — and naming it is one hop short
|
||||||
|
* of enough. The barrel that re-exports it is side-effect-free too, so a bundler prunes the
|
||||||
|
* `export * from './metadata.js'` edge before metadata.js's own marking is ever consulted.
|
||||||
|
*
|
||||||
|
* Nothing notices, in the usual way. `import { configure } from 'cereale'` came out at 145
|
||||||
|
* bytes through esbuild, 143 through webpack and 144 through rollup with `Symbol.metadata`
|
||||||
|
* absent from all three — and a tsc-compiled consumer on a runtime without the well-known
|
||||||
|
* symbol is then decorated with `metadata: undefined`, which is to say with no rules at all.
|
||||||
|
*/
|
||||||
|
it('installs Symbol.metadata even when nothing model-shaped is imported', async () => {
|
||||||
|
const code = await bundle(`
|
||||||
|
import { configure } from ${ENTRY};
|
||||||
|
export const f = (o) => configure(o);
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(code, 'the Symbol.metadata install was pruned along with the barrel').toContain('Symbol.metadata');
|
||||||
|
expectShaken(code, [], [MARKER.isString, MARKER.serializer, MARKER.deserializer, MARKER.validator]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('costs almost nothing to import only an error helper', async () => {
|
||||||
|
const code = await bundle(`
|
||||||
|
import { flattenErrors } from ${ENTRY};
|
||||||
|
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 ${ENTRY};
|
||||||
|
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), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
`);
|
||||||
|
|
||||||
|
expectShaken(code, [MARKER.isString], [MARKER.isLatitude, MARKER.isSemVer, MARKER.serializer]);
|
||||||
|
expect(code.length).toBeLessThan(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The source-level case above proves the `sideEffects` mechanism works; this one proves the
|
||||||
|
* two entries are spelled the way the published tree is laid out. `./src/index.ts` and
|
||||||
|
* `./dist/esm/index.js` are separate paths in the manifest, and a typo in either is invisible
|
||||||
|
* from the other side.
|
||||||
|
*/
|
||||||
|
it.runIf(built)('installs Symbol.metadata from the published barrel too', async () => {
|
||||||
|
const code = await bundle(`
|
||||||
|
import { configure } from ${JSON.stringify(path.join(dist, 'esm/index.js'))};
|
||||||
|
export const f = (o) => configure(o);
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(code, 'the Symbol.metadata install was pruned from dist/esm').toContain('Symbol.metadata');
|
||||||
|
expectShaken(code, [], [MARKER.isString, MARKER.serializer, MARKER.deserializer]);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
+5
-5
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user