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=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=module -e "import * as m from './dist/cereale.min.js'; if (typeof m.toInstanceSync !== 'function') throw new Error('flat bundle broken');"
|
||||
- name: Run Demo
|
||||
run: npm run demo
|
||||
- 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/),
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
served from `docs/` on `main`, and `npm run build:docs` rebuilds its assets to open locally.
|
||||
and the toolchain matrix. The page has no third-party dependencies — no CDN, no analytics, no webfonts; the only thing it
|
||||
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
|
||||
|
||||
@@ -108,6 +109,20 @@ inside a native binary with no standalone transform API.
|
||||
| swc | ✅ | `jsc.transform.decoratorVersion: "2022-03"` |
|
||||
| **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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
|
||||
- **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; }
|
||||
.tick { color: var(--ok); font-weight: 700; }
|
||||
.cross { color: var(--bad); font-weight: 700; }
|
||||
.warn-mark { color: var(--warn); font-weight: 700; }
|
||||
|
||||
.compare { display: grid; gap: 1rem; }
|
||||
@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.
|
||||
</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-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';
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// Generated by scripts/build-docs.mjs — do not edit.
|
||||
window.CEREALE_META = {
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"node": ">=20.0.0"
|
||||
};
|
||||
|
||||
+11
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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.",
|
||||
"type": "module",
|
||||
"main": "./dist/cjs/index.js",
|
||||
@@ -16,21 +16,29 @@
|
||||
"types": "./dist/esm/vite.d.ts",
|
||||
"import": "./dist/esm/vite.js",
|
||||
"require": "./dist/cjs/vite.js"
|
||||
},
|
||||
"./min": {
|
||||
"types": "./dist/esm/index.d.ts",
|
||||
"default": "./dist/cereale.min.js"
|
||||
}
|
||||
},
|
||||
"sideEffects": [
|
||||
"./dist/esm/index.js",
|
||||
"./dist/esm/metadata.js",
|
||||
"./dist/cjs/metadata.js"
|
||||
"./dist/cereale.min.js",
|
||||
"./src/index.ts",
|
||||
"./src/metadata.ts"
|
||||
],
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"FRAMEWORKS.md",
|
||||
"CHANGELOG.md",
|
||||
"!src/**/*.test.ts",
|
||||
"!src/example.ts"
|
||||
],
|
||||
"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",
|
||||
"demo": "node --no-warnings=ExperimentalWarning --loader ts-node/esm src/example.ts",
|
||||
"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
|
||||
// ============================================================================
|
||||
|
||||
export const IsString: Rule<string> = 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 IsInt: Rule<number> = 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 IsBigInt: Rule<bigint> = 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 IsObject: Rule<object> = rule('isObject', v => typeof v === 'object' && v !== null && !Array.isArray(v), p => `${p} must be an object`);
|
||||
export const IsString: Rule<string> = /*#__PURE__*/ rule('isString', v => typeof v === 'string', p => `${p} must be a string`);
|
||||
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> = /*#__PURE__*/ rule('isInt', v => Number.isInteger(v), p => `${p} must be an integer`);
|
||||
export const IsBoolean: Rule<boolean> = /*#__PURE__*/ rule('isBoolean', v => typeof v === 'boolean', p => `${p} must be a boolean`);
|
||||
export const IsBigInt: Rule<bigint> = /*#__PURE__*/ rule('isBigInt', v => typeof v === 'bigint', p => `${p} must be a bigint`);
|
||||
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> = /*#__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 IsNotEmpty: Rule<unknown> = rule('isNotEmpty', v => v !== null && v !== undefined && v !== '', p => `${p} should not be empty`);
|
||||
export const IsEmpty: Rule<unknown> = rule('isEmpty', v => {
|
||||
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> = /*#__PURE__*/ rule('isNotEmpty', v => v !== null && v !== undefined && v !== '', p => `${p} should not be empty`);
|
||||
export const IsEmpty: Rule<unknown> = /*#__PURE__*/ rule('isEmpty', v => {
|
||||
if (v === null || v === undefined || v === '') return true;
|
||||
if (Array.isArray(v)) return 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);
|
||||
}
|
||||
|
||||
export const Positive: Rule<number> = 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 Positive: Rule<number> = /*#__PURE__*/ rule('positive', v => typeof v === 'number' && v > 0, p => `${p} must be positive`);
|
||||
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?: 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. */
|
||||
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;
|
||||
return typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 65535;
|
||||
}, 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 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 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> = /*#__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
|
||||
@@ -358,22 +358,22 @@ export function Length(min: number, max?: number, options?: ValidationOptions):
|
||||
}), options);
|
||||
}
|
||||
|
||||
export const Email: Rule<string> = 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 IsAlphanumeric: Rule<string> = 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 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 Email: Rule<string> = /*#__PURE__*/ pattern('isEmail', /^[^\s@]+@[^\s@]+\.[^\s@]+$/, p => `${p} must be a valid email`);
|
||||
export const IsAlpha: Rule<string> = /*#__PURE__*/ pattern('isAlpha', /^[A-Za-z]+$/, p => `${p} must contain only letters`);
|
||||
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> = /*#__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> = /*#__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 IsUppercase: Rule<string> = 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 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 IsJSON: Rule<string> = rule('isJson', v => {
|
||||
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> = /*#__PURE__*/ rule('isUppercase', v => typeof v === 'string' && v === v.toUpperCase(), p => `${p} must be uppercase`);
|
||||
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> = /*#__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> = /*#__PURE__*/ rule('isJson', v => {
|
||||
if (typeof v !== 'string') return false;
|
||||
try { JSON.parse(v); return true; } catch { return false; }
|
||||
}, 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; }
|
||||
}, p => `${p} must be a valid URL`);
|
||||
|
||||
@@ -449,10 +449,10 @@ function affix(name: string, test: (value: string, seed: string) => boolean, des
|
||||
return decorator;
|
||||
}
|
||||
|
||||
export const Contains = 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 StartsWith = 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 Contains = /*#__PURE__*/ affix('contains', (v, s) => v.includes(s), (p, s) => `${p} must 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 = /*#__PURE__*/ affix('startsWith', (v, s) => v.startsWith(s), (p, s) => `${p} must start 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
|
||||
|
||||
+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');
|
||||
|
||||
// Also installed globally, because a consumer's own compiler emit may read `Symbol.metadata`
|
||||
// directly. package.json marks this module as having side effects so it survives bundling.
|
||||
// Also installed globally: tsc's decorator emit reads `Symbol.metadata` directly rather than
|
||||
// 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;
|
||||
|
||||
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
|
||||
* 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
|
||||
@@ -96,7 +96,7 @@ interface OutboundProperty {
|
||||
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).
|
||||
@@ -167,7 +167,7 @@ interface InboundNames {
|
||||
|
||||
// Name maps are derived purely from decorator metadata, which is fixed once a class is
|
||||
// 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.
|
||||
@@ -614,7 +614,7 @@ interface CachedPlan {
|
||||
// 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
|
||||
// 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.
|
||||
@@ -663,7 +663,7 @@ function validationPlan(model: ClassModel): PropertyPlan[] {
|
||||
|
||||
// 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.
|
||||
const converterCache = new WeakMap<object, any>();
|
||||
const converterCache = /*#__PURE__*/ new WeakMap<object, any>();
|
||||
|
||||
function converterFor(clazz: any): any {
|
||||
let instance = converterCache.get(clazz);
|
||||
|
||||
Reference in New Issue
Block a user